From e8902f472a03f7905db83d662920639f1d94db94 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:50:07 +0200 Subject: [PATCH] perf(ci): skip unit coverage on PRs that cannot reach it and persist the Jest cache (#1711) Pull requests whose changes cannot reach any Tier A test (allowlist checked against declared Tier A inputs and an AST scan of cross-project reads) skip the unit coverage suite; master pushes always run it. Jest's transform cache is persisted with actions/cache: PRs restore only, master pushes start empty and save. Paired CI runs: Tier A 9m04s cold -> 6m09s warm. Nx Cloud is intentionally not used. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 72 +++++- apps/web/jest.config.ts | 4 + docs/architecture/validation-map.md | 22 ++ jest.preset.js | 8 + jest.web-esm.workspace.ts | 4 + package.json | 2 +- tools/coverage/tier-a-external-references.mjs | 71 ++++++ tools/coverage/unit-coverage-scope.mjs | 231 +++++++++++++++++ tools/coverage/unit-coverage-scope.test.mjs | 237 ++++++++++++++++++ 9 files changed, 648 insertions(+), 3 deletions(-) create mode 100644 tools/coverage/tier-a-external-references.mjs create mode 100644 tools/coverage/unit-coverage-scope.mjs create mode 100644 tools/coverage/unit-coverage-scope.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72be0299b..33550a61b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -209,6 +209,10 @@ jobs: name: Unit Tests and Typechecks runs-on: ubuntu-latest timeout-minutes: 45 + permissions: + contents: read + # The scope step lists the PR's changed files through the API. + pull-requests: read steps: - name: Checkout code @@ -247,11 +251,75 @@ jobs: - name: Check i18n drift run: pnpm run i18n:check + # A pull request whose changes cannot reach any Tier A test (docs, + # notes, other workflows, website, E2E and mock-server apps, release + # and packaging tooling, a scripts-only package.json edit) skips the + # suite. The allowlist lives in a unit-tested script; anything it + # does not know runs everything, and master always runs everything. + - name: Decide unit coverage scope + id: scope + env: + EVENT_NAME: ${{ github.event_name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + CHANGED_FILES: ${{ github.event.pull_request.changed_files }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" != "pull_request" ]; then + echo "Not a pull request; running the full suite." + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # The list-files endpoint stops at 3,000 files; a truncated + # list could hide a file that needs the suite. + if [ "${CHANGED_FILES:-0}" -ge 3000 ]; then + echo "PR changes ${CHANGED_FILES} files, beyond the API listing limit; running the full suite." + echo "run=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + git fetch --no-tags --depth=1 origin "$BASE_SHA" + gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \ + --paginate --jq '.[] | .filename, (.previous_filename // empty)' | + node tools/coverage/unit-coverage-scope.mjs --base FETCH_HEAD --github-output + + # Jest's transform cache (TypeScript/Angular transpilation plus + # coverage instrumentation, keyed by file content) is persisted + # between runs. Only master pushes and maintainer dispatches save + # it; pull requests restore it and never write, so a PR cannot plant + # an entry that a later master run would read. + - name: Restore Jest transform cache + if: steps.scope.outputs.run == 'true' && github.event_name != 'push' + uses: actions/cache/restore@v6 + with: + path: ${{ runner.temp }}/jest-cache + key: jest-transform-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'tsconfig.base.json', 'jest.preset.js', 'jest.web-esm.workspace.ts', 'apps/web/jest.config.ts') }}-${{ github.run_id }} + restore-keys: | + jest-transform-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'tsconfig.base.json', 'jest.preset.js', 'jest.web-esm.workspace.ts', 'apps/web/jest.config.ts') }}- + - name: Run Tier A unit coverage suite + if: steps.scope.outputs.run == 'true' run: pnpm run coverage:ci env: CI: true NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false + JEST_CACHE_DIRECTORY: ${{ runner.temp }}/jest-cache + + - name: Validate coverage tooling (suite skipped) + if: steps.scope.outputs.run != 'true' + run: pnpm run coverage:tools:test && pnpm run coverage:policy:check + + # Master pushes start from an empty cache, so the saved cache holds + # exactly the current tree and does not grow run over run. + - name: Save Jest transform cache + if: >- + steps.scope.outputs.run == 'true' && + (github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && github.ref == 'refs/heads/master')) + uses: actions/cache/save@v6 + with: + path: ${{ runner.temp }}/jest-cache + key: jest-transform-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'tsconfig.base.json', 'jest.preset.js', 'jest.web-esm.workspace.ts', 'apps/web/jest.config.ts') }}-${{ github.run_id }} - name: Run Tier B/C validation commands run: node tools/coverage/check-coverage-policy.mjs --run-non-tier-a @@ -260,7 +328,7 @@ jobs: NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false - name: Upload unit coverage artifact - if: always() + if: always() && steps.scope.outputs.run == 'true' uses: actions/upload-artifact@v7 with: name: unit-coverage @@ -269,7 +337,7 @@ jobs: retention-days: 14 - name: Upload unit coverage to Codecov - if: always() + if: always() && steps.scope.outputs.run == 'true' uses: codecov/codecov-action@v7 with: files: ./coverage/merged/lcov.info,./coverage/merged/cobertura-coverage.xml diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index 6fb90a9a1..3186de1c7 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -21,6 +21,10 @@ const collectCoverageFrom = [ export default { ...nxPreset, ...angularEsmPreset, + // See jest.preset.js: CI persists the transform cache from this directory. + ...(process.env.JEST_CACHE_DIRECTORY + ? { cacheDirectory: process.env.JEST_CACHE_DIRECTORY } + : {}), displayName: 'web', setupFilesAfterEnv: ['/src/test-setup.ts'], coverageDirectory: '../../coverage/apps/web', diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index 92e500a9c..897561211 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -82,6 +82,28 @@ type-checking each through a language service; spec type errors therefore do not fail Jest (the web configs already ran with `diagnostics: false`), while `isolatedModules`-incompatible syntax such as a type re-export without `export type` still fails at load time. + +In CI, a pull request skips the Tier A suite (and the merged-coverage upload) +when every changed file is outside Tier A test inputs: +`tools/coverage/unit-coverage-scope.mjs` holds the allowlist (Markdown, `docs/`, +notes and plans, agent guidance, workflows other than `ci.yml` and +`build-and-make.yaml`, the website, E2E and mock-server apps, +release/packaging/skills/performance tooling, and a `package.json` edit +confined to non-`coverage:*` scripts). Anything else, including files no Nx +project owns such as `jest.preset.js` or `tsconfig.base.json`, and every +`{workspaceRoot}` input a Tier A test target declares, runs the full suite; +master pushes always run it. `nx affected` is deliberately not used for this +decision because a change to an unowned file affects no project. A node test +parses every Tier A source for string literals that point at repository files +outside the owning project (`tier-a-external-references.mjs`) and fails if the +allowlist would skip any of them, so a new cross-project read cannot be +silently exempted. +Jest's transform cache is kept in `JEST_CACHE_DIRECTORY` and persisted with +`actions/cache`: pull requests restore it, while only master pushes (starting +from an empty cache, so it holds exactly the current tree) and maintainer +dispatches save it. A shared Nx task cache is not used: Nx indexes its local +cache in a machine-specific database, so a restored cache folder is never hit, +and sharing it safely needs Nx Cloud or another supported remote cache. `coverage:merge` requires every configured Tier A report before replacing the merged output. Strict health validation also requires the merged Istanbul map itself to contain usable instrumentation for every runtime-owning Tier A file, diff --git a/jest.preset.js b/jest.preset.js index 0f7d1350d..02c56a0ff 100644 --- a/jest.preset.js +++ b/jest.preset.js @@ -12,8 +12,16 @@ const collectCoverageFrom = [ '!src/**/index.ts', ]; +// CI points every Jest run at one directory (JEST_CACHE_DIRECTORY) so the +// transform cache can be persisted between workflow runs; unset, Jest keeps +// its default per-user temp directory. +const cacheDirectory = process.env.JEST_CACHE_DIRECTORY + ? { cacheDirectory: process.env.JEST_CACHE_DIRECTORY } + : {}; + module.exports = { ...nxPreset, + ...cacheDirectory, coverageReporters, collectCoverageFrom, }; diff --git a/jest.web-esm.workspace.ts b/jest.web-esm.workspace.ts index c0b3f992e..1d81db5dc 100644 --- a/jest.web-esm.workspace.ts +++ b/jest.web-esm.workspace.ts @@ -11,6 +11,10 @@ const coverageReporters = ['json', 'json-summary', 'lcovonly', 'text-summary']; export default { ...nxPreset, ...angularEsmPreset, + // See jest.preset.js: CI persists the transform cache from this directory. + ...(process.env.JEST_CACHE_DIRECTORY + ? { cacheDirectory: process.env.JEST_CACHE_DIRECTORY } + : {}), rootDir: '.', roots: ['/apps/web', '/libs'], // Jest's 5s default is thin for Angular component specs: TestBed compiles diff --git a/package.json b/package.json index c4071a31a..4a5da016e 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "styles:inputs:test": "node --test tools/nx/check-stylesheet-inputs.test.mjs", "styles:inputs:check": "node tools/nx/check-stylesheet-inputs.mjs", "styles:inputs:validate": "pnpm run styles:inputs:test && pnpm run styles:inputs:check", - "coverage:tools:test": "node --test tools/coverage/coverage-integrity.test.mjs tools/coverage/e2e-shard-reports.test.mjs tools/coverage/coverage-run-pool.test.mjs", + "coverage:tools:test": "node --test tools/coverage/coverage-integrity.test.mjs tools/coverage/e2e-shard-reports.test.mjs tools/coverage/coverage-run-pool.test.mjs tools/coverage/unit-coverage-scope.test.mjs", "coverage:unit:ci": "node tools/coverage/run-tier-a-coverage.mjs", "coverage:merge": "node tools/coverage/merge-coverage.mjs", "coverage:health": "node tools/coverage/coverage-health.mjs", diff --git a/tools/coverage/tier-a-external-references.mjs b/tools/coverage/tier-a-external-references.mjs new file mode 100644 index 000000000..eedff4749 --- /dev/null +++ b/tools/coverage/tier-a-external-references.mjs @@ -0,0 +1,71 @@ +/** + * Lists repository files outside a Tier A project that its code refers to by + * path: relative module specifiers and path strings such as + * `'../../../../../.github/workflows/build-and-make.yaml'` or + * `'tools/embedded-mpv/stage-runtime.mjs'`. The unit-coverage scope rule must + * never treat these as skippable; unit-coverage-scope.test.mjs asserts that. + * + * String literals are read from the TypeScript AST, so paths mentioned in + * comments do not count. Imports of other apps/libs code are project-graph + * edges and are left to Nx; node_modules is ignored. + */ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +const SOURCE_FILE = /\.(ts|mts|cts|js|mjs|cjs)$/; +const WORKSPACE_PATH = /^(?:\.github|tools|docs|patches|scripts|snap|resources|build)\/|^[\w.-]+\.(?:json|ya?ml|md|js|cjs|mjs)$/; + +function listSources(directory, out = []) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.name === 'node_modules') continue; + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) listSources(fullPath, out); + else if (SOURCE_FILE.test(entry.name)) out.push(fullPath); + } + return out; +} + +function stringLiterals(fileName, text) { + const source = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, false); + const literals = []; + const visit = (node) => { + if (ts.isStringLiteralLike(node)) literals.push(node.text); + ts.forEachChild(node, visit); + }; + visit(source); + return literals; +} + +function isOtherProjectCode(relative) { + return /^(apps|libs)\//.test(relative) && !/\.(json|ya?ml|md)$/.test(relative); +} + +/** Returns Map>. */ +export function tierAExternalReferences({ workspaceRoot, tierAProjects }) { + const references = new Map(); + const record = (target, from) => { + if (!references.has(target)) references.set(target, new Set()); + references.get(target).add(path.relative(workspaceRoot, from)); + }; + for (const project of tierAProjects) { + const projectRoot = path.resolve(workspaceRoot, project.root); + for (const file of listSources(path.resolve(workspaceRoot, project.sourceRoot))) { + for (const literal of stringLiterals(file, readFileSync(file, 'utf8'))) { + let absolute = null; + if (literal.startsWith('../')) { + absolute = path.resolve(path.dirname(file), literal); + } else if (WORKSPACE_PATH.test(literal)) { + absolute = path.resolve(workspaceRoot, literal); + } + if (!absolute || !existsSync(absolute)) continue; + const relative = path.relative(workspaceRoot, absolute); + if (relative.startsWith('..') || relative.startsWith('node_modules')) continue; + if (absolute === projectRoot || absolute.startsWith(projectRoot + path.sep)) continue; + if (isOtherProjectCode(relative)) continue; + record(relative.split(path.sep).join('/'), file); + } + } + } + return references; +} diff --git a/tools/coverage/unit-coverage-scope.mjs b/tools/coverage/unit-coverage-scope.mjs new file mode 100644 index 000000000..9f1833d14 --- /dev/null +++ b/tools/coverage/unit-coverage-scope.mjs @@ -0,0 +1,231 @@ +/** + * Decides whether a pull request needs the Tier A unit coverage suite. + * + * The suite runs every Tier A project and merges one report, so it cannot run + * a subset (the merged ratchet and Codecov baseline need every project). What + * it can do is not run at all when a change cannot reach any Tier A test. + * + * `nx affected` is not used for this decision: Tier A tests also depend on + * files no project owns (`jest.preset.js`, `jest.web-esm.workspace.ts`, + * `tsconfig.base.json`, `tools/testing/`, `patches/`), and a change to an + * unowned file affects no project, which would skip tests it does break. + * The rule here is the reverse: skip only when every changed file matches an + * allowlist of paths no Tier A test reads. Anything unknown runs the suite. + * + * Usage: + * git diff --name-only ...HEAD | + * node tools/coverage/unit-coverage-scope.mjs [--base ] [--github-output] + */ +import { execFileSync } from 'node:child_process'; +import { appendFileSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Paths that no Tier A test imports or reads. Tier A specs only read files + * inside their own project (checked 2026-09-26), so this list is about + * whole areas, not individual files. + */ +export const SKIPPABLE_PATTERNS = [ + // Documentation, notes, plans and agent guidance. + /\.md$/, + /^docs\//, + /^\.changes\//, + /^\.plans\//, + /^\.codex\//, + /^\.claude\//, + // Workflows other than ci.yml (which runs everything) and build-and-make + // (an electron-backend spec asserts its embedded-MPV steps). + /^\.github\/(?!workflows\/(ci\.yml|build-and-make\.yaml)$)/, + // Apps outside Tier A with their own validation. + /^apps\/website\//, + /^apps\/web-e2e\//, + /^apps\/electron-backend-e2e\//, + /^apps\/xtream-mock-server\//, + /^apps\/stalker-mock-server\//, + // Tooling with its own Tier B tests or checks that run in every job. + // tools/embedded-mpv is deliberately absent: electron-backend imports its + // runtime contracts and its specs read the staging scripts. + /^tools\/(release|packaging|skills|performance|i18n|eslint|dependencies|nx)\//, + // Website tests and the packaged-app smoke launcher; run-web-esm-lib-tests.mjs + // in the same directory is a Tier A input and stays out of this list. + /^tools\/testing\/(website-|launch-packaged-electron\.mjs$)/, + /^LICENSE$/, +]; + +/** + * `package.json` feeds Tier A through dependencies, the `@package` version + * import and Jest/Nx configuration, but not through `scripts`, with one + * exception: the `coverage:*` scripts are the Tier A invocation itself, and a + * skipped suite would never exercise an edit to them. + */ +export function isScriptsOnlyChange(basePackageJson, headPackageJson) { + let base; + let head; + try { + base = JSON.parse(basePackageJson); + head = JSON.parse(headPackageJson); + } catch { + return false; + } + const withoutScripts = ({ scripts: _scripts, ...rest }) => rest; + if ( + JSON.stringify(withoutScripts(base)) !== + JSON.stringify(withoutScripts(head)) + ) { + return false; + } + const baseScripts = base.scripts ?? {}; + const headScripts = head.scripts ?? {}; + const changedScripts = new Set( + [...Object.keys(baseScripts), ...Object.keys(headScripts)].filter( + (name) => baseScripts[name] !== headScripts[name] + ) + ); + return ![...changedScripts].some((name) => name.startsWith('coverage:')); +} + +/** + * Files outside a project that a Tier A test target declares as inputs + * (`{workspaceRoot}/...` in its project.json). Nx already knows the test + * reads them, so they always need the suite, whatever the allowlist says. + */ +export function declaredWorkspaceInputs(tierAProjects, readProjectJson) { + const inputs = []; + for (const project of tierAProjects) { + const testTarget = readProjectJson(project)?.targets?.test; + for (const input of testTarget?.inputs ?? []) { + if (typeof input !== 'string') continue; + const match = /^\{workspaceRoot\}\/(.+)$/.exec(input); + if (match) inputs.push(match[1]); + } + } + return inputs; +} + +export function isSkippable(file) { + return SKIPPABLE_PATTERNS.some((pattern) => pattern.test(file)); +} + +/** + * Converts an Nx input glob to a regular expression. The glob is tokenised + * rather than rewritten with chained replaces, so the `.*` produced for `**` + * cannot be rewritten again by the single-`*` rule. + */ +export function globToRegExp(glob) { + let pattern = ''; + for (let index = 0; index < glob.length; index += 1) { + const char = glob[index]; + if (char === '*' && glob[index + 1] === '*') { + if (glob[index + 2] === '/') { + pattern += '(?:.*/)?'; + index += 2; + } else { + pattern += '.*'; + index += 1; + } + } else if (char === '*') { + pattern += '[^/]*'; + } else if (char === '?') { + pattern += '[^/]'; + } else { + pattern += char.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + } + } + return new RegExp(`^${pattern}$`); +} + +export function decideUnitCoverageScope( + files, + { packageJsonScriptsOnly = false, declaredInputs = [] } = {} +) { + const declared = declaredInputs.map(globToRegExp); + const changed = files.map((file) => file.trim()).filter(Boolean); + if (changed.length === 0) { + return { + run: true, + reason: 'No changed files were listed; running the suite to be safe.', + blocking: [], + }; + } + const blocking = changed.filter( + (file) => + declared.some((pattern) => pattern.test(file)) || + (!isSkippable(file) && + !(file === 'package.json' && packageJsonScriptsOnly)) + ); + if (blocking.length > 0) { + return { + run: true, + reason: `${blocking.length} of ${changed.length} changed files can affect Tier A tests.`, + blocking, + }; + } + return { + run: false, + reason: `All ${changed.length} changed files are outside Tier A test inputs (docs, notes, other workflows, non-Tier-A apps and tooling).`, + blocking: [], + }; +} + +const isMain = + process.argv[1] && + path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); + +function gitShow(ref, file) { + try { + return execFileSync('git', ['show', `${ref}:${file}`], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + return null; + } +} + +if (isMain) { + const argv = process.argv.slice(2); + const baseIndex = argv.indexOf('--base'); + const base = baseIndex === -1 ? null : argv[baseIndex + 1]; + const files = readFileSync(0, 'utf8').split('\n'); + let packageJsonScriptsOnly = false; + if (base && files.some((file) => file.trim() === 'package.json')) { + const basePackageJson = gitShow(base, 'package.json'); + const headPackageJson = readFileSync('package.json', 'utf8'); + packageJsonScriptsOnly = + basePackageJson !== null && + isScriptsOnlyChange(basePackageJson, headPackageJson); + console.log( + `package.json changed ${packageJsonScriptsOnly ? 'only in scripts' : 'outside scripts'} relative to ${base}.` + ); + } + const policy = JSON.parse( + readFileSync('tools/coverage/coverage-policy.json', 'utf8') + ); + const declaredInputs = declaredWorkspaceInputs( + policy.unitCoverage.tierA, + (project) => + JSON.parse( + readFileSync(path.join(project.root, 'project.json'), 'utf8') + ) + ); + const decision = decideUnitCoverageScope(files, { + packageJsonScriptsOnly, + declaredInputs, + }); + console.log(`Tier A unit coverage: ${decision.run ? 'run' : 'skip'}. ${decision.reason}`); + for (const file of decision.blocking.slice(0, 20)) { + console.log(` needs tests: ${file}`); + } + if (decision.blocking.length > 20) { + console.log(` … and ${decision.blocking.length - 20} more`); + } + if (process.argv.includes('--github-output')) { + const outputFile = process.env.GITHUB_OUTPUT; + if (!outputFile) { + console.error('--github-output needs GITHUB_OUTPUT to be set.'); + process.exit(1); + } + appendFileSync(outputFile, `run=${decision.run}\n`); + } +} diff --git a/tools/coverage/unit-coverage-scope.test.mjs b/tools/coverage/unit-coverage-scope.test.mjs new file mode 100644 index 000000000..66daabd6a --- /dev/null +++ b/tools/coverage/unit-coverage-scope.test.mjs @@ -0,0 +1,237 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { after, before, test } from 'node:test'; + +import { tierAExternalReferences } from './tier-a-external-references.mjs'; +import { + declaredWorkspaceInputs, + decideUnitCoverageScope, + globToRegExp, + isScriptsOnlyChange, + isSkippable, +} from './unit-coverage-scope.mjs'; + +const workspaceRoot = fileURLToPath(new URL('../..', import.meta.url)); +const policy = JSON.parse( + await readFile(path.join(workspaceRoot, 'tools/coverage/coverage-policy.json'), 'utf8') +); +const readProjectJson = (project) => + JSON.parse( + readFileSync(path.join(workspaceRoot, project.root, 'project.json'), 'utf8') + ); + +const scriptPath = fileURLToPath( + new URL('./unit-coverage-scope.mjs', import.meta.url) +); + +let workDir; +before(async () => { + workDir = await mkdtemp(path.join(os.tmpdir(), 'unit-coverage-scope-')); +}); +after(async () => { + await rm(workDir, { recursive: true, force: true }); +}); + +test('skips a change made only of docs, notes, website and e2e files', () => { + const decision = decideUnitCoverageScope([ + 'docs/architecture/validation-map.md', + 'README.md', + '.changes/web-thing.md', + '.plans/2026-09-26-x.md', + 'apps/website/src/pages/index.astro', + 'apps/web-e2e/src/settings.e2e.ts', + 'apps/electron-backend-e2e/src/journeys/launch.journey.ts', + 'apps/xtream-mock-server/src/main.ts', + '.github/workflows/e2e-tests.yaml', + 'tools/release/build-release-notes.mjs', + 'tools/testing/website-guides.test.mjs', + '', + ]); + assert.equal(decision.run, false); + assert.deepEqual(decision.blocking, []); + assert.match(decision.reason, /All 11 changed files/); +}); + +test('runs when any file can reach a Tier A test', () => { + for (const file of [ + 'apps/web/src/app/app.component.ts', + 'apps/electron-backend/src/main.ts', + 'libs/services/src/lib/x.ts', + 'libs/ui/playback/README.md.ts', + '.github/workflows/ci.yml', + 'tools/coverage/run-tier-a-coverage.mjs', + 'tools/testing/run-web-esm-lib-tests.mjs', + 'jest.preset.js', + 'jest.web-esm.workspace.ts', + 'tsconfig.base.json', + 'pnpm-lock.yaml', + 'patches/vite.patch', + 'electron-builder.json', + '.github/workflows/build-and-make.yaml', + 'tools/embedded-mpv/stage-runtime.mjs', + 'nx.json', + 'package.json', + 'some-new-root-file.json', + ]) { + const decision = decideUnitCoverageScope(['docs/a.md', file]); + assert.equal(decision.run, true, file); + assert.deepEqual(decision.blocking, [file]); + } +}); + +test('every file outside a project that Tier A code refers to keeps the suite running', () => { + const references = tierAExternalReferences({ + workspaceRoot, + tierAProjects: policy.unitCoverage.tierA, + }); + const declaredInputs = declaredWorkspaceInputs( + policy.unitCoverage.tierA, + readProjectJson + ); + // The scan must see the known cross-project reads, or it proves nothing. + assert.ok(references.has('.github/workflows/build-and-make.yaml')); + assert.ok(references.has('tools/embedded-mpv/runtime-probe-contract.cjs')); + const skippable = [...references.keys()].filter( + (file) => !decideUnitCoverageScope([file], { declaredInputs }).run + ); + assert.deepEqual( + skippable.map((file) => `${file} <- ${[...references.get(file)].join(', ')}`), + [], + 'Tier A code reads these files, so the scope allowlist must not skip them' + ); +}); + +test('Tier A test targets declare their workspace inputs and they always block', () => { + const declaredInputs = declaredWorkspaceInputs( + policy.unitCoverage.tierA, + readProjectJson + ); + assert.ok(declaredInputs.includes('tools/embedded-mpv/runtime-probe-contract.cjs')); + assert.equal( + decideUnitCoverageScope(['tools/embedded-mpv/runtime-probe-contract.cjs'], { + declaredInputs, + }).run, + true + ); + assert.deepEqual( + decideUnitCoverageScope(['tools/release/a.mjs', 'tools/foo/sub/b.ts'], { + declaredInputs: ['tools/foo/**/*.ts'], + }).blocking, + ['tools/foo/sub/b.ts'] + ); +}); + +test('declared-input globs keep ** recursive and * within one segment', () => { + const deep = globToRegExp('tools/foo/**/*.ts'); + for (const file of ['tools/foo/a.ts', 'tools/foo/a/b.ts', 'tools/foo/a/b/c/d.ts']) { + assert.ok(deep.test(file), file); + } + assert.equal(deep.test('tools/foo/a/b.js'), false); + assert.equal(deep.test('tools/foobar/a.ts'), false); + const single = globToRegExp('tools/foo/*.cjs'); + assert.ok(single.test('tools/foo/a.cjs')); + assert.equal(single.test('tools/foo/a/b.cjs'), false); + assert.ok(globToRegExp('tools/foo/**').test('tools/foo/a/b/c')); + assert.ok(globToRegExp('a.b+c').test('a.b+c')); + assert.equal(globToRegExp('a.b').test('axb'), false); + assert.equal( + decideUnitCoverageScope(['tools/release/a/b/c.ts'], { + declaredInputs: ['tools/release/**/*.ts'], + }).run, + true + ); +}); + +test('an empty file list runs the suite rather than skipping it', () => { + assert.equal(decideUnitCoverageScope([]).run, true); + assert.equal(decideUnitCoverageScope(['', ' ']).run, true); +}); + +test('package.json is skippable only when the caller proved a scripts-only change', () => { + assert.equal( + decideUnitCoverageScope(['package.json'], { packageJsonScriptsOnly: true }).run, + false + ); + assert.equal(decideUnitCoverageScope(['package.json']).run, true); + assert.equal(isSkippable('package.json'), false); +}); + +test('detects a scripts-only package.json change', () => { + const base = JSON.stringify({ + name: 'x', + version: '1.0.0', + scripts: { a: 'node a' }, + devDependencies: { jest: '1' }, + }); + const scripts = JSON.stringify({ + name: 'x', + version: '1.0.0', + scripts: { a: 'node a', b: 'node b' }, + devDependencies: { jest: '1' }, + }); + const dependency = JSON.stringify({ + name: 'x', + version: '1.0.0', + scripts: { a: 'node a' }, + devDependencies: { jest: '2' }, + }); + const version = JSON.stringify({ + name: 'x', + version: '1.1.0', + scripts: { a: 'node a' }, + devDependencies: { jest: '1' }, + }); + assert.equal(isScriptsOnlyChange(base, scripts), true); + assert.equal(isScriptsOnlyChange(base, dependency), false); + assert.equal(isScriptsOnlyChange(base, version), false); + assert.equal(isScriptsOnlyChange(base, '{not json'), false); + const coverageScript = JSON.stringify({ + name: 'x', + version: '1.0.0', + scripts: { a: 'node a', 'coverage:ci': 'true' }, + devDependencies: { jest: '1' }, + }); + assert.equal(isScriptsOnlyChange(base, coverageScript), false); +}); + +test('CLI prints the decision and writes run=false to GITHUB_OUTPUT', async () => { + const outputFile = path.join(workDir, 'github-output'); + const result = spawnSync(process.execPath, [scriptPath, '--github-output'], { + input: 'docs/a.md\n.changes/b.md\n', + encoding: 'utf8', + env: { ...process.env, GITHUB_OUTPUT: outputFile }, + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Tier A unit coverage: skip\./); + assert.equal(await readFile(outputFile, 'utf8'), 'run=false\n'); +}); + +test('CLI lists the files that need tests and writes run=true', async () => { + const outputFile = path.join(workDir, 'github-output-run'); + const result = spawnSync(process.execPath, [scriptPath, '--github-output'], { + input: 'docs/a.md\nlibs/services/src/lib/x.ts\n', + encoding: 'utf8', + env: { ...process.env, GITHUB_OUTPUT: outputFile }, + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Tier A unit coverage: run\. 1 of 2 changed files/); + assert.match(result.stdout, /needs tests: libs\/services\/src\/lib\/x\.ts/); + assert.equal(await readFile(outputFile, 'utf8'), 'run=true\n'); +}); + +test('CLI refuses --github-output without GITHUB_OUTPUT', () => { + const env = { ...process.env }; + delete env.GITHUB_OUTPUT; + const result = spawnSync(process.execPath, [scriptPath, '--github-output'], { + input: 'docs/a.md\n', + encoding: 'utf8', + env, + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /needs GITHUB_OUTPUT/); +});