diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 534ef1be..9ef6e2ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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" @@ -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' @@ -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` # ---------------------------------------------------------- @@ -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 @@ -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 @@ -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) # ---------------------------------------------------------- @@ -862,6 +924,8 @@ jobs: env: CI: true DEBUG: pw:browser* + # QNBS-v3: --junit's underlying jest-junit defaults to /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 @@ -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) # ---------------------------------------------------------- @@ -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 diff --git a/README.md b/README.md index ce4d6103..3cf9908b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2940 keys - 7428+ tests / 597 files + 7433+ tests / 597 files Codecov Coverage License MIT CI Status @@ -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` | @@ -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 @@ -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) diff --git a/codecov.yml b/codecov.yml index f8981dcb..0a3d247e 100644 --- a/codecov.yml +++ b/codecov.yml @@ -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" diff --git a/docs/CI.md b/docs/CI.md index f4ce054c..b0538583 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -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. | @@ -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 `/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 diff --git a/package.json b/package.json index de0fcb0e..798ba526 100644 --- a/package.json +++ b/package.json @@ -173,6 +173,7 @@ "devDependencies": { "@axe-core/playwright": "^4.13.0", "@biomejs/biome": "^2.5.8", + "@codecov/vite-plugin": "2.0.1", "@lhci/cli": "^0.15.1", "@playwright/test": "^1.62.1", "@storybook/addon-a11y": "^10.5.7", diff --git a/playwright.config.ts b/playwright.config.ts index 3f15a9c7..9c7c8d29 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -38,7 +38,15 @@ export default defineConfig({ workers: process.env['CI'] ? 1 : 4, reporter: [ ['html', { outputFolder: 'tests/e2e/html-report' }], - ['junit', { outputFile: 'tests/e2e/results/junit.xml' }], + // QNBS-v3: junit.xml feeds Codecov Test Analytics; project-name-prefixed titles keep per-project failures distinguishable there + [ + 'junit', + { + outputFile: 'tests/e2e/results/junit.xml', + includeProjectInTestName: true, + stripANSIControlSequences: true, + }, + ], ], /** Omit `{platform}` so one baseline PNG works on Linux (CI) and Windows/macOS dev machines. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b028334..3b85bd59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,6 +212,9 @@ importers: '@biomejs/biome': specifier: ^2.5.8 version: 2.5.8 + '@codecov/vite-plugin': + specifier: 2.0.1 + version: 2.0.1(vite@8.2.1(@types/node@25.9.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) '@lhci/cli': specifier: ^0.15.1 version: 0.15.1(supports-color@10.2.2) @@ -394,6 +397,24 @@ importers: packages: + '@actions/core@3.0.1': + resolution: {integrity: sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==} + + '@actions/exec@3.0.0': + resolution: {integrity: sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==} + + '@actions/github@9.1.1': + resolution: {integrity: sha512-tL5JbYOBZHc0ngEnCsaDcryUizIUIlQyIMwy1Wkx93H5HzbBJ7TbiPx2PnFjBwZW0Vh05JmfFZhecE6gglYegA==} + + '@actions/http-client@3.0.2': + resolution: {integrity: sha512-JP38FYYpyqvUsz+Igqlc/JG6YO9PaKuvqjM3iGvaLqFnJ7TFmcLyy2IDrY0bI0qCQug8E9K+elv5ZNfw62ZJzA==} + + '@actions/http-client@4.0.1': + resolution: {integrity: sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==} + + '@actions/io@3.0.2': + resolution: {integrity: sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==} + '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} @@ -1242,6 +1263,16 @@ packages: cpu: [x64] os: [win32] + '@codecov/bundler-plugin-core@2.0.1': + resolution: {integrity: sha512-TkdKn/rEwZQ723M7DDUmHe5r0IJa23rUT4TAx5jXmg12wGZGAHGWWU7LKeQsYCsKdLMxK7bLaGk9M++4wSRD5w==} + engines: {node: '>=20.0.0'} + + '@codecov/vite-plugin@2.0.1': + resolution: {integrity: sha512-w7nGA9SSc0WECzn05yRrw3uN8293I620Kd9x97I0kyyxUjtWxCMmlgmAbS364gJZYvljd0A6iQyAigVV2dOX9A==} + engines: {node: '>=20.0.0'} + peerDependencies: + vite: 4.x || 5.x || 6.x + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -2036,6 +2067,54 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 + '@octokit/auth-token@6.0.0': + resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} + engines: {node: '>= 20'} + + '@octokit/core@7.0.7': + resolution: {integrity: sha512-DcB0M3KFgr9ECI328lhBMVsyFT2DnmNucSBTqEN3exyNKUzkkpUSCHmTRcunF41Eou2TIQKW4seewri8ON9bSA==} + engines: {node: '>= 20'} + + '@octokit/endpoint@11.0.4': + resolution: {integrity: sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA==} + engines: {node: '>= 20'} + + '@octokit/graphql@9.0.4': + resolution: {integrity: sha512-5s15CCiY8XXQ+FG+b1YQcl6Z2FA++nwAz/tg2VUrTmnMncP+2nnGUEYANImdnxsA2Fnq+Mbl7hDjUTw7cFAwcg==} + engines: {node: '>= 20'} + + '@octokit/openapi-types@27.0.0': + resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} + + '@octokit/openapi-types@28.0.0': + resolution: {integrity: sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==} + + '@octokit/plugin-paginate-rest@14.0.0': + resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-rest-endpoint-methods@17.0.0': + resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/request-error@7.1.1': + resolution: {integrity: sha512-+eaY7G2VVpSf2pc5Gn1+mph837V/d/TYTJAgWL9Tb0ogGYcpN3IlAVFgjL+Vv93F/sevrxkvsYCedtpLdcFLzA==} + engines: {node: '>= 20'} + + '@octokit/request@10.0.15': + resolution: {integrity: sha512-3CBg9aJ0hO9Pjyij8LbK/xYtEaPws9SW7xKz67daPNxQB1q5Y9OMA7DDOG0A6Hwf9ygGu3tvzusg0LXQ8/wAjA==} + engines: {node: '>= 20'} + + '@octokit/types@16.0.0': + resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + + '@octokit/types@17.0.0': + resolution: {integrity: sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==} + '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} @@ -3797,6 +3876,9 @@ packages: resolution: {integrity: sha512-3ilxa3n4276wGQp/ImRAuz4ALdsj/2Wd3FqoZBZlajDYnByCZ0JMb4+26Rde0wGXIbM0G2HWSfr/Fi8b21KX8g==} engines: {node: '>=10.0.0'} + before-after-hook@4.0.0: + resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + bezier-js@6.1.4: resolution: {integrity: sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==} @@ -4049,6 +4131,10 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@3.0.0: + resolution: {integrity: sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw==} + engines: {node: '>=22'} + convert-source-map@1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} @@ -5540,6 +5626,9 @@ packages: json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json-with-bigint@3.5.12: + resolution: {integrity: sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -7303,6 +7392,9 @@ packages: resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} engines: {node: '>=8'} + universal-user-agent@7.0.3: + resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -7311,6 +7403,10 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unplugin@1.16.1: + resolution: {integrity: sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==} + engines: {node: '>=14.0.0'} + unplugin@2.3.11: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} @@ -7812,6 +7908,37 @@ packages: snapshots: + '@actions/core@3.0.1': + dependencies: + '@actions/exec': 3.0.0 + '@actions/http-client': 4.0.1 + + '@actions/exec@3.0.0': + dependencies: + '@actions/io': 3.0.2 + + '@actions/github@9.1.1': + dependencies: + '@actions/http-client': 3.0.2 + '@octokit/core': 7.0.7 + '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.7) + '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.7) + '@octokit/request': 10.0.15 + '@octokit/request-error': 7.1.1 + undici: 7.29.0 + + '@actions/http-client@3.0.2': + dependencies: + tunnel: 0.0.6 + undici: 7.29.0 + + '@actions/http-client@4.0.1': + dependencies: + tunnel: 0.0.6 + undici: 7.29.0 + + '@actions/io@3.0.2': {} + '@adobe/css-tools@4.4.4': {} '@ai-sdk/gateway@4.0.51(zod@4.4.3(patch_hash=338616f6b312752cd87f1779a712517e6ffff9fc470a3f3e69d1cdc5a8ca0d93))': @@ -8802,6 +8929,21 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260804.1': optional: true + '@codecov/bundler-plugin-core@2.0.1': + dependencies: + '@actions/core': 3.0.1 + '@actions/github': 9.1.1 + chalk: 4.1.2 + semver: 7.8.5 + unplugin: 1.16.1 + zod: 3.25.76 + + '@codecov/vite-plugin@2.0.1(vite@8.2.1(@types/node@25.9.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))': + dependencies: + '@codecov/bundler-plugin-core': 2.0.1 + unplugin: 1.16.1 + vite: 8.2.1(@types/node@25.9.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0) + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -9613,6 +9755,64 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@octokit/auth-token@6.0.0': {} + + '@octokit/core@7.0.7': + dependencies: + '@octokit/auth-token': 6.0.0 + '@octokit/graphql': 9.0.4 + '@octokit/request': 10.0.15 + '@octokit/request-error': 7.1.1 + '@octokit/types': 17.0.0 + before-after-hook: 4.0.0 + universal-user-agent: 7.0.3 + + '@octokit/endpoint@11.0.4': + dependencies: + '@octokit/types': 17.0.0 + universal-user-agent: 7.0.3 + + '@octokit/graphql@9.0.4': + dependencies: + '@octokit/request': 10.0.15 + '@octokit/types': 17.0.0 + universal-user-agent: 7.0.3 + + '@octokit/openapi-types@27.0.0': {} + + '@octokit/openapi-types@28.0.0': {} + + '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.7)': + dependencies: + '@octokit/core': 7.0.7 + '@octokit/types': 16.0.0 + + '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.7)': + dependencies: + '@octokit/core': 7.0.7 + '@octokit/types': 16.0.0 + + '@octokit/request-error@7.1.1': + dependencies: + '@octokit/types': 17.0.0 + + '@octokit/request@10.0.15': + dependencies: + '@octokit/endpoint': 11.0.4 + '@octokit/request-error': 7.1.1 + '@octokit/types': 17.0.0 + content-type: 3.0.0 + json-with-bigint: 3.5.12 + universal-user-agent: 7.0.3 + + '@octokit/types@16.0.0': + dependencies: + '@octokit/openapi-types': 27.0.0 + + '@octokit/types@17.0.0': + dependencies: + '@octokit/openapi-types': 28.0.0 + '@opentelemetry/api@1.9.1': optional: true @@ -11115,6 +11315,8 @@ snapshots: basic-ftp@6.0.1: {} + before-after-hook@4.0.0: {} + bezier-js@6.1.4: {} bidi-js@1.0.3: @@ -11389,6 +11591,8 @@ snapshots: content-type@1.0.5: {} + content-type@3.0.0: {} + convert-source-map@1.9.0: {} convert-source-map@2.0.0: {} @@ -13252,6 +13456,8 @@ snapshots: json-stringify-safe@5.0.1: optional: true + json-with-bigint@3.5.12: {} + json5@2.2.3: {} jsonc-parser@3.3.1: {} @@ -15210,10 +15416,17 @@ snapshots: dependencies: crypto-random-string: 2.0.0 + universal-user-agent@7.0.3: {} + universalify@2.0.1: {} unpipe@1.0.0: {} + unplugin@1.16.1: + dependencies: + acorn: 8.18.0 + webpack-virtual-modules: 0.6.2 + unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 diff --git a/tests/unit/workflowPolicy.test.ts b/tests/unit/workflowPolicy.test.ts index 6e32dd72..8febefbb 100644 --- a/tests/unit/workflowPolicy.test.ts +++ b/tests/unit/workflowPolicy.test.ts @@ -276,4 +276,116 @@ describe('Tauri release workflow policy', () => { expect(ciSuccess).toContain('✅ CI Success'); expect(workflowSource).toContain('name: ✅ CI Success'); }); + + // QNBS-v3: codecov/test-results-action is deprecated upstream in favor of the main codecov-action; guard against it creeping back in. + it('never uses the deprecated codecov/test-results-action', () => { + expect(workflowSource).not.toContain('test-results-action'); + }); + + // QNBS-v3: one upload per test suite (unit x1 source occurrence covers both matrix legs, e2e, e2e-deep, storybook, vrt) — a sixth occurrence would mean an untracked/duplicate upload. + it('publishes test analytics for every suite with report_type: test_results, disable_search, and !cancelled()', () => { + const reportTypeCount = (workflowSource.match(/report_type: test_results/g) ?? []).length; + expect(reportTypeCount).toBe(5); + const disableSearchCount = (workflowSource.match(/disable_search: true/g) ?? []).length; + expect(disableSearchCount).toBe(reportTypeCount); + // QNBS-v3: a real test failure must still upload — default success() would hide it. + const cancelledGuardCount = ( + workflowSource.match(/if: \$\{\{ !cancelled\(\) \}\}\n\s+uses: codecov\/codecov-action/g) ?? + [] + ).length; + expect(cancelledGuardCount).toBe(reportTypeCount); + }); + + // QNBS-v3: the aggregate count above cannot tell five correct uploads from five miswired duplicates of one suite — assert each suite's own exact upload. + it.each([ + { + job: 'quality', + step: 'Publish unit test results to Codecov', + files: 'reports/junit.xml', + flags: `unit-node\${{ matrix.node-version }}`, + name: `unit-node\${{ matrix.node-version }}`, + }, + { + job: 'e2e', + step: 'Publish E2E test results to Codecov', + files: 'tests/e2e/results/junit.xml', + flags: 'e2e', + name: 'playwright-e2e', + }, + { + job: 'e2e-deep', + step: 'Publish deep E2E test results to Codecov', + files: 'tests/e2e/results/junit.xml', + flags: 'e2e-deep', + name: 'e2e-deep', + }, + { + job: 'storybook', + step: 'Publish Storybook test results to Codecov', + files: 'test-results/storybook-junit.xml', + flags: 'storybook', + name: 'storybook', + }, + { + job: 'vrt', + step: 'Publish VRT test results to Codecov', + files: 'tests/e2e/results/junit.xml', + flags: 'vrt', + name: 'vrt', + }, + ])( + '$job uploads its own test analytics with the correct files/flags/name', + ({ job, step, files, flags, name }) => { + const jobBlock = extractJobBlock(workflowSource, job); + const uploadStep = extractStepBlock(jobBlock, step); + expect(uploadStep).toContain( + 'uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f', + ); + expect(uploadStep).toContain(`if: \${{ !cancelled() }}`); + expect(uploadStep).toContain('report_type: test_results'); + expect(uploadStep).toContain('disable_search: true'); + expect(uploadStep).toContain(`files: ${files}`); + expect(uploadStep).toContain(`flags: ${flags}`); + expect(uploadStep).toContain(`name: ${name}`); + }, + ); + + // QNBS-v3: a workflow/job-wide token would reach every step, including ones that never need it. + it('never assigns CODECOV_TOKEN at workflow or job level, only inside individual steps', () => { + expect(workflowSource).not.toMatch(/^env:\n(?:.*\n)*?\s*CODECOV_TOKEN/m); + for (const jobName of extractJobNames(workflowSource)) { + const jobBlock = extractJobBlock(workflowSource, jobName); + const jobLevelEnv = jobBlock.match(/^ {4}env:\n([\s\S]*?)(?=\n {4}\S|\n {2}\S|$)/m)?.[1]; + if (jobLevelEnv) expect(jobLevelEnv).not.toContain('CODECOV_TOKEN'); + } + }); + + // QNBS-v3: a second test run just to get JUnit would double Vitest's cost for no new coverage. + it('runs Vitest once with both JSON and JUnit reporters at explicit paths, still without retry', () => { + const qualityBlock = extractJobBlock(workflowSource, 'quality'); + const vitestStep = extractStepBlock(qualityBlock, 'Unit tests (Vitest, no retry)'); + expect(vitestStep).toContain('--reporter=json'); + expect(vitestStep).toContain('--reporter=junit'); + // QNBS-v3: explicit paths for both reporters — never relying on vitest.config.ts's own reporter-tuple default drifting independently of this exact command. + expect(vitestStep).toContain('--outputFile.json=test-results.json'); + expect(vitestStep).toContain('--outputFile.junit=reports/junit.xml'); + expect(vitestStep).not.toContain('--retry'); + expect((qualityBlock.match(/pnpm exec vitest run/g) ?? []).length).toBe(1); + }); + + // QNBS-v3: an always-on flag would upload from local `pnpm run build` too, not just the intended CI analysis pass. + it('scopes Codecov Bundle Analysis to the analysis-build step only, never the plain build step', () => { + const buildBlock = extractJobBlock(workflowSource, 'build'); + const plainBuildStep = extractStepBlock(buildBlock, 'Build application'); + const analysisStep = extractStepBlock( + buildBlock, + 'Bundle analysis (rollup visualizer + Codecov)', + ); + expect(plainBuildStep).not.toContain('CODECOV_BUNDLE_ANALYSIS'); + expect(plainBuildStep).not.toContain('CODECOV_TOKEN'); + expect(analysisStep).toContain("CODECOV_BUNDLE_ANALYSIS: 'true'"); + expect(analysisStep).toContain(`CODECOV_TOKEN: \${{ secrets.CODECOV_TOKEN }}`); + // QNBS-v3: exactly one analyze/build invocation in this job — a second `vite build` would defeat the point of reusing the existing ANALYZE pass. + expect((buildBlock.match(/run: pnpm run (build|analyze)\n/g) ?? []).length).toBe(2); + }); }); diff --git a/turbo.json b/turbo.json index 44122b85..f33e6e61 100644 --- a/turbo.json +++ b/turbo.json @@ -24,7 +24,8 @@ "tasks": { "build": { "dependsOn": ["^build"], - "outputs": ["dist/**", "storybook-static/**"] + "outputs": ["dist/**", "storybook-static/**"], + "env": ["CODECOV_BUNDLE_ANALYSIS", "CODECOV_BUNDLE_SHA", "CODECOV_TOKEN"] }, "dev": { "cache": false, diff --git a/vite.config.ts b/vite.config.ts index 4bad0e14..1a50dabc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { codecovVitePlugin } from '@codecov/vite-plugin'; import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; import { visualizer } from 'rollup-plugin-visualizer'; @@ -7,6 +8,10 @@ import { VitePWA } from 'vite-plugin-pwa'; import { GITHUB_PAGES_BASE, isTauriBuild, resolveViteBase } from './config/resolveViteBase'; const isAnalyze = process.env['ANALYZE'] === 'true'; +// QNBS-v3: explicit CI-only flag — bundle upload never fires merely because CODECOV_TOKEN happens to be set locally. +const enableCodecovBundleAnalysis = process.env['CODECOV_BUNDLE_ANALYSIS'] === 'true'; +const codecovToken = process.env['CODECOV_TOKEN'] || undefined; +const codecovBundleSha = process.env['CODECOV_BUNDLE_SHA'] || undefined; const deployBase = resolveViteBase(); const isTauri = isTauriBuild(); @@ -86,6 +91,16 @@ export default defineConfig({ }), ] : []), + // QNBS-v3: Codecov docs require this plugin to run last; only enabled for the CI analysis build, never local `pnpm run build`/`analyze`. + codecovVitePlugin({ + enableBundleAnalysis: enableCodecovBundleAnalysis, + bundleName: 'worldscript-studio-web', + gitService: 'github', + telemetry: false, + // QNBS-v3: exactOptionalPropertyTypes forbids passing `undefined` for an optional string key — omit it entirely instead of assigning undefined (fork PRs run tokenless). + ...(codecovToken ? { uploadToken: codecovToken } : {}), + ...(codecovBundleSha ? { uploadOverrides: { sha: codecovBundleSha } } : {}), + }), ], resolve: {