Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 100 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -314,8 +314,9 @@ jobs:

# QNBS-v3: no retry — a first-attempt failure remains visible instead of being masked as a pass.
# --no-experimental-webstorage disables Node 24's native Web Storage API for deterministic setup.
# QNBS-v3: both outputFile paths explicit in this one invocation — never relying on vitest.config.ts's own reporter-tuple default, which could silently drift independently of this exact command.
- name: Unit tests (Vitest, no retry)
run: pnpm exec vitest run --coverage --reporter=json --outputFile=test-results.json
run: pnpm exec vitest run --coverage --reporter=json --reporter=junit --outputFile.json=test-results.json --outputFile.junit=reports/junit.xml
env:
NODE_OPTIONS: "--no-experimental-webstorage --max-old-space-size=4096"
CI: "true"
Expand All @@ -334,6 +335,15 @@ jobs:
if-no-files-found: ignore
retention-days: 7

- name: Upload Vitest JUnit results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vitest-junit-node${{ matrix.node-version }}
path: reports/junit.xml
if-no-files-found: ignore
retention-days: 7

- name: Upload coverage report
# QNBS-v3: matrix value stays literal 'lts/*' — one upload per workflow suffices (Codecov optional).
if: always() && matrix.node-version == '22'
Expand All @@ -354,6 +364,19 @@ jobs:
flags: unit
fail_ci_if_error: false

# QNBS-v3: !cancelled() (not the default success()) — a real test failure must still upload results, only a cancelled run skips it.
- name: Publish unit test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
report_type: test_results
files: reports/junit.xml
disable_search: true
flags: unit-node${{ matrix.node-version }}
name: unit-node${{ matrix.node-version }}
fail_ci_if_error: false

# ----------------------------------------------------------
# 1b. RUST-TAURI: Tauri Rust Gate (fmt/check/clippy/test), path-scoped via `changes`
# ----------------------------------------------------------
Expand Down Expand Up @@ -496,10 +519,15 @@ jobs:
- name: Bundle chunk size budget
run: pnpm run bundle:budget

- name: Bundle analysis (rollup visualizer)
# QNBS-v3: token scoped to this exact step only, never job/workflow-wide — Codecov Bundle Analysis needs it inside this one Vite build, nothing else does.
- name: Bundle analysis (rollup visualizer + Codecov)
run: pnpm run analyze
env:
NODE_ENV: production
CODECOV_BUNDLE_ANALYSIS: 'true'
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
# QNBS-v3: PR head SHA (not GitHub's synthetic merge commit) so Bundle Analysis attributes to the same commit reviewers see; falls back to github.sha on main pushes.
CODECOV_BUNDLE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}

- name: Upload bundle analysis HTML
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand Down Expand Up @@ -703,6 +731,18 @@ jobs:
if-no-files-found: warn
retention-days: 7

- name: Publish E2E test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
report_type: test_results
files: tests/e2e/results/junit.xml
disable_search: true
flags: e2e
name: playwright-e2e
fail_ci_if_error: false

# ----------------------------------------------------------
# 6a. E2E DEEP COVERAGE: Feature-flag matrix + error paths (non-blocking)
# QNBS-v3: Separate from the required `e2e` gate. Runs tests/e2e/deep/ with
Expand Down Expand Up @@ -757,6 +797,28 @@ jobs:
if-no-files-found: warn
retention-days: 7

# QNBS-v3: same playwright.config.ts reporter as the required e2e job, produced independently on this runner — previously never uploaded.
- name: Upload deep E2E JUnit results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-deep-junit
path: tests/e2e/results/junit.xml
if-no-files-found: warn
retention-days: 7

- name: Publish deep E2E test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
report_type: test_results
files: tests/e2e/results/junit.xml
disable_search: true
flags: e2e-deep
name: e2e-deep
fail_ci_if_error: false

# ----------------------------------------------------------
# 6. LIGHTHOUSE: Performance budgets (after build)
# ----------------------------------------------------------
Expand Down Expand Up @@ -862,6 +924,8 @@ jobs:
env:
CI: true
DEBUG: pw:browser*
# QNBS-v3: --junit's underlying jest-junit defaults to <cwd>/junit.xml, outside every path this job already uploads — pin it into test-results/ instead of guessing at the default.
JEST_JUNIT_OUTPUT_FILE: test-results/storybook-junit.xml
run: |
npx http-server storybook-static -p 6006 -s &
npx wait-on http://localhost:6006 --timeout 90000
Expand All @@ -883,6 +947,18 @@ jobs:
**/screenshots/
retention-days: 7

- name: Publish Storybook test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
report_type: test_results
files: test-results/storybook-junit.xml
disable_search: true
flags: storybook
name: storybook
fail_ci_if_error: false

# ----------------------------------------------------------
# 8. VRT: Visual regression (Playwright screenshots, chromium only)
# ----------------------------------------------------------
Expand Down Expand Up @@ -942,3 +1018,25 @@ jobs:
tests/e2e/visual-regression.spec.ts-snapshots/
tests/e2e/test-results/
retention-days: 7

# QNBS-v3: same playwright.config.ts reporter as the required e2e job, produced independently on this runner — previously never uploaded.
- name: Upload VRT JUnit results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vrt-junit
path: tests/e2e/results/junit.xml
if-no-files-found: warn
retention-days: 7

- name: Publish VRT test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
report_type: test_results
files: tests/e2e/results/junit.xml
disable_search: true
flags: vrt
name: vrt
fail_ci_if_error: false
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<img src="https://img.shields.io/badge/Storage-IndexedDB_v8-F59E0B" alt="IndexedDB v8">
<img src="https://img.shields.io/badge/PWA-v3.0-5BB974?logo=pwa" alt="PWA v3.0">
<img src="https://img.shields.io/badge/i18n-19_locales-2940_keys-0EA5E9" alt="i18n 19 locales — 2940 keys">
<img src="https://img.shields.io/badge/Tests-7428%2B_%2F_597_files-22C55E" alt="7428+ tests / 597 files">
<img src="https://img.shields.io/badge/Tests-7433%2B_%2F_597_files-22C55E" alt="7433+ tests / 597 files">
<img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage">
<img src="https://img.shields.io/badge/License-MIT-22C55E" alt="License MIT">
<img src="https://img.shields.io/github/actions/workflow/status/qnbs/WorldScript-Studio/.github/workflows/ci.yml?branch=main&logo=github" alt="CI Status">
Expand Down Expand Up @@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2940 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
| **Testing** | Vitest 4.x (7428+ tests / 597 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Testing** | Vitest 4.x (7433+ tests / 597 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
Expand Down Expand Up @@ -549,7 +549,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
│ ├── unit/ # Vitest unit tests (7428+ tests, 597 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ ├── unit/ # Vitest unit tests (7433+ tests, 597 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
Expand Down Expand Up @@ -711,7 +711,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |

**Current test metrics (2026-09-04, source-synchronized; CI remains authoritative for pass/fail):**
- **7428+ unit tests** across **597 test files** — CI is authoritative for pass/fail
- **7433+ unit tests** across **597 test files** — CI is authoritative for pass/fail
- Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2940 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)

Expand Down
7 changes: 7 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
ignore:
- "tests/**"

# Informational only: no historical WorldScript Studio bundle baseline exists yet, and the
# existing `pnpm run bundle:budget` absolute ceilings remain the blocking gate. Revisit once
# several main/PR uploads establish a real baseline.
bundle_analysis:
warning_threshold: "5%"
status: "informational"
60 changes: 58 additions & 2 deletions docs/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,9 @@ registry gzip-decoding failure mode, while OSV failures remain blocking.
| `pr-size` | `workflow-policy` | PR-size governance (`scripts/check-pr-size.mjs`) — tiered file/line/commit limits, advisory below the absolute ceiling and blocking only above it. `pull_request` only. Runs the **base ref's** own copy of the checker (never the PR's working-tree copy) when it exists there, so a PR touching it can't raise its own limits; falls back to the PR's own copy one time only, for the introducing PR whose base ref has no checker yet. |
| `security` | `workflow-policy` | `pnpm audit --audit-level=high`; **OSV scanner** (`google/osv-scanner-action`) for npm + Rust lockfiles; `gitleaks` secrets scan; on PRs: `dependency-review-action` |
| `scheduled-osv` | — | Separate daily and manually triggerable (`workflow_dispatch`) `.github/workflows/security-scheduled.yml` scan of the same three lockfiles; `contents: read` only; fails closed and writes lockfile/package/advisory details to the step summary |
| `quality` | `security` | Matrix **Node 22** and **24** → Biome lint, **`pnpm run i18n:check`**, **`pnpm run docs:check`**, **`pnpm run csp:verify`**, **`pnpm run parity:check`**, `pnpm run typecheck`, Vitest + coverage (+ non-blocking coverage-ratchet suggestion), Codecov (optional token), coverage artifact |
| `quality` | `security` | Matrix **Node 22** and **24** → Biome lint, **`pnpm run i18n:check`**, **`pnpm run docs:check`**, **`pnpm run csp:verify`**, **`pnpm run parity:check`**, `pnpm run typecheck`, Vitest + coverage (+ non-blocking coverage-ratchet suggestion), Codecov coverage (Node 22 only, optional token), Codecov Test Analytics per Node version (`unit-node22`/`unit-node24` flags), coverage + JUnit artifacts |
| `rust-tauri` | `security` | Rust `cargo fmt --check`, `cargo check --locked`, `cargo clippy --locked --all-targets -- -D warnings`, and `cargo test --locked`; compile/lint signal for Tauri changes without building installers on every PR |
| `build` | `quality` | Production `pnpm run build`, **`bundle:budget`**, **`analyze`** (upload `bundle-analysis.html`), **`pnpm run smoke:prod`** (headless-Chromium prod-build + CSP-runtime gate — see below), `dist` artifact; on `main` (non-PR): Pages artifact + **SLSA build provenance attestation**. No `if:` on the job itself — `smoke:prod` runs on every PR, not just `main` pushes. |
| `build` | `quality` | Production `pnpm run build`, **`bundle:budget`**, **`analyze`** (upload `bundle-analysis.html`; Codecov Bundle Analysis runs inside this same build — see below), **`pnpm run smoke:prod`** (headless-Chromium prod-build + CSP-runtime gate — see below), `dist` artifact; on `main` (non-PR): Pages artifact + **SLSA build provenance attestation**. No `if:` on the job itself — `smoke:prod` runs on every PR, not just `main` pushes. |
| `e2e` | `quality` | Playwright **Chromium** + **Mobile Chrome** (Pixel 5) — `CI=true`, 2× retries, 50 min timeout; browser cache via `actions/cache@v5`. Firefox optional locally. `PLAYWRIGHT_SKIP_VRT=true` (VRT is its own job). |
| `lighthouse` | `build` | LHCI (mobile): **accessibility error gate** `minScore: 0.95`; **CLS error** ≤ 0.1; performance/SEO warn. Desktop run: `continue-on-error: true` until baselines stabilise. Timeout 25 min. |
| `storybook` | `quality` | Cloud-first — Storybook build + test-runner only run in CI (not locally); Playwright browser cache `v5`; `--maxWorkers=2 --junit` (non-blocking, `continue-on-error: true` — see [exit criteria](#non-blocking-gates--exit-criteria-f-13)); artifacts uploaded always. Debug: manual `storybook-debug.yml` workflow. |
Expand All @@ -155,6 +155,62 @@ registry gzip-decoding failure mode, while OSV failures remain blocking.
> Settings → Branches → `main` → Required status checks → remove the 4 individual entries, add
> `✅ CI Success`.

### Codecov integration

Three independent, additive analysis planes — none replaces an existing repository-native gate
(`bundle:budget`, coverage thresholds, the coverage ratchet, first-attempt-failure policy, or any
artifact upload already listed above).

**Coverage.** Unchanged: `quality` (Node 22 only) uploads `coverage/lcov.info` under flag `unit`.

**Test Analytics.** Every suite that already produces a Playwright/Vitest JUnit file uploads it via
`codecov/codecov-action` with `report_type: test_results`, `disable_search: true`, and an explicit
`files:` path — never the deprecated `codecov/test-results-action`. Each upload runs on
`if: ${{ !cancelled() }}`, not the default `success()`, so a real test failure still uploads its
result instead of silently disappearing.

| Suite | Flag | JUnit source |
|-------|------|--------------|
| Unit (Node 22) | `unit-node22` | `reports/junit.xml` (Vitest, `--reporter=junit`) |
| Unit (Node 24) | `unit-node24` | `reports/junit.xml` (Vitest, `--reporter=junit`) |
| Required E2E | `e2e` | `tests/e2e/results/junit.xml` (Playwright) |
| Deep E2E (advisory) | `e2e-deep` | `tests/e2e/results/junit.xml`, same runner as above |
| Storybook (advisory) | `storybook` | `test-results/storybook-junit.xml` (`JEST_JUNIT_OUTPUT_FILE` pins the path — `--junit`'s own default, jest-junit's `<cwd>/junit.xml`, was never captured by the existing artifact upload) |
| VRT | `vrt` | `tests/e2e/results/junit.xml`, same runner as E2E |

Vitest runs once per Node version with two reporters in the same invocation
(`--reporter=json --reporter=junit`) — never a second test run merely to get JUnit; the `junit`
reporter inherits `vitest.config.ts`'s own `reports/junit.xml` path, so only the `json` output needs
an explicit `--outputFile.json=`. The no-`--retry` policy is unchanged.

**Bundle Analysis.** `@codecov/vite-plugin` runs as the last plugin in `vite.config.ts`, gated by an
explicit `CODECOV_BUNDLE_ANALYSIS=true` env var set only on the `build` job's existing `analyze`
step — never on the plain `pnpm run build` step, and never merely because `CODECOV_TOKEN` happens to
be present. This reuses the existing ANALYZE build (no third Vite build); the token is scoped to
that one step, not the job or workflow. Configured bundle name is the stable `worldscript-studio-web`
(no version/SHA/PR number, so Codecov tracks one bundle over time) — the plugin itself appends the
output format, so the name actually visible on Codecov's dashboard is `worldscript-studio-web-esm`
(this repo's build only ever emits `es` output). `uploadOverrides.sha` uses the PR's head SHA (not
GitHub's synthetic merge commit) via `CODECOV_BUNDLE_SHA`. `telemetry: false` disables the plugin's
own telemetry about itself — it does not disable the bundle upload. `codecov.yml`'s
`bundle_analysis.status: "informational"` keeps this non-blocking until a real size baseline exists;
`pnpm run bundle:budget`'s absolute ceilings remain the actual blocking gate.

`@codecov/vite-plugin@2.0.1` declares `peerDependencies: { vite: "4.x || 5.x || 6.x" }` — this repo
runs Vite 8 with Rolldown. Verified empirically (not merely assumed) against the real production
build, including PWA `injectManifest`, manual chunking, and every existing plugin: the build
succeeds cleanly with no plugin-order or chunk-graph regressions. This is an intentionally-accepted
gap against the plugin's own declared contract, not a false positive — tracked in
[#606](https://github.com/qnbs/WorldScript-Studio/issues/606) to revalidate once `@codecov/vite-plugin`
officially declares Vite 8 support.

Coverage and Test Analytics (`codecov/codecov-action`) use `fail_ci_if_error: false` — a Codecov
outage must never turn an otherwise-correct build into a false CI failure. Bundle Analysis has no
such input: `@codecov/vite-plugin` calls the underlying `Output.write()` without its optional
`emitError` argument, so provider-detection, auth, and upload failures are caught internally and
never fail the build — verified against the plugin's own source, not assumed from the coverage
upload's unrelated flag.

### Release-truth checks

`pnpm run docs:check` treats the latest available stable tag as the release frontier. A dated
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@
"devDependencies": {
"@axe-core/playwright": "^4.13.0",
"@biomejs/biome": "^2.5.8",
"@codecov/vite-plugin": "2.0.1",
Comment thread
qnbs marked this conversation as resolved.
"@lhci/cli": "^0.15.1",
"@playwright/test": "^1.62.1",
"@storybook/addon-a11y": "^10.5.7",
Expand Down
Loading
Loading