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
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
+4
View File
@@ -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: ['<rootDir>/src/test-setup.ts'],
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
`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,
+8
View File
@@ -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,
};
+4
View File
@@ -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: ['<rootDir>/apps/web', '<rootDir>/libs'],
// 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: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",
@@ -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/);
});