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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-09-27 07:50:07 +02:00
1 parent 34d393adc8
commit e8902f472a
9 files changed
+648 -3

No files matched your search

+70 -2
View File
@@ -209,6 +209,10 @@ jobs:
name: Unit Tests and Typechecks name: Unit Tests and Typechecks
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 45 timeout-minutes: 45
permissions:
contents: read
# The scope step lists the PR's changed files through the API.
pull-requests: read
steps: steps:
- name: Checkout code - name: Checkout code
@@ -247,11 +251,75 @@ jobs:
- name: Check i18n drift - name: Check i18n drift
run: pnpm run i18n:check 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 - name: Run Tier A unit coverage suite
if: steps.scope.outputs.run == 'true'
run: pnpm run coverage:ci run: pnpm run coverage:ci
env: env:
CI: true CI: true
NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false 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 - name: Run Tier B/C validation commands
run: node tools/coverage/check-coverage-policy.mjs --run-non-tier-a run: node tools/coverage/check-coverage-policy.mjs --run-non-tier-a
@@ -260,7 +328,7 @@ jobs:
NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false
- name: Upload unit coverage artifact - name: Upload unit coverage artifact
if: always() if: always() && steps.scope.outputs.run == 'true'
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: unit-coverage name: unit-coverage
@@ -269,7 +337,7 @@ jobs:
retention-days: 14 retention-days: 14
- name: Upload unit coverage to Codecov - name: Upload unit coverage to Codecov
if: always() if: always() && steps.scope.outputs.run == 'true'
uses: codecov/codecov-action@v7 uses: codecov/codecov-action@v7
with: with:
files: ./coverage/merged/lcov.info,./coverage/merged/cobertura-coverage.xml files: ./coverage/merged/lcov.info,./coverage/merged/cobertura-coverage.xml
+4
View File
@@ -21,6 +21,10 @@ const collectCoverageFrom = [
export default { export default {
...nxPreset, ...nxPreset,
...angularEsmPreset, ...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', displayName: 'web',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'], setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../coverage/apps/web', coverageDirectory: '../../coverage/apps/web',
+22
View File
@@ -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 not fail Jest (the web configs already ran with `diagnostics: false`), while
`isolatedModules`-incompatible syntax such as a type re-export without `isolatedModules`-incompatible syntax such as a type re-export without
`export type` still fails at load time. `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 `coverage:merge` requires every configured Tier A report before replacing the
merged output. Strict health validation also requires the merged Istanbul map merged output. Strict health validation also requires the merged Istanbul map
itself to contain usable instrumentation for every runtime-owning Tier A file, itself to contain usable instrumentation for every runtime-owning Tier A file,
+8
View File
@@ -12,8 +12,16 @@ const collectCoverageFrom = [
'!src/**/index.ts', '!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 = { module.exports = {
...nxPreset, ...nxPreset,
...cacheDirectory,
coverageReporters, coverageReporters,
collectCoverageFrom, collectCoverageFrom,
}; };
+4
View File
@@ -11,6 +11,10 @@ const coverageReporters = ['json', 'json-summary', 'lcovonly', 'text-summary'];
export default { export default {
...nxPreset, ...nxPreset,
...angularEsmPreset, ...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: '.', rootDir: '.',
roots: ['<rootDir>/apps/web', '<rootDir>/libs'], roots: ['<rootDir>/apps/web', '<rootDir>/libs'],
// Jest's 5s default is thin for Angular component specs: TestBed compiles // Jest's 5s default is thin for Angular component specs: TestBed compiles
+1 -1
View File
@@ -44,7 +44,7 @@
"styles:inputs:test": "node --test tools/nx/check-stylesheet-inputs.test.mjs", "styles:inputs:test": "node --test tools/nx/check-stylesheet-inputs.test.mjs",
"styles:inputs:check": "node tools/nx/check-stylesheet-inputs.mjs", "styles:inputs:check": "node tools/nx/check-stylesheet-inputs.mjs",
"styles:inputs:validate": "pnpm run styles:inputs:test && pnpm run styles:inputs:check", "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:unit:ci": "node tools/coverage/run-tier-a-coverage.mjs",
"coverage:merge": "node tools/coverage/merge-coverage.mjs", "coverage:merge": "node tools/coverage/merge-coverage.mjs",
"coverage:health": "node tools/coverage/coverage-health.mjs", "coverage:health": "node tools/coverage/coverage-health.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<workspace-relative file, Set<referencing file>>. */
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;
}
+231
View File
@@ -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 <base>...HEAD |
* node tools/coverage/unit-coverage-scope.mjs [--base <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`);
}
}
+237
View File
@@ -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/);
});