mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
1 parent
34d393adc8
commit
e8902f472a
9 files changed
+648
-3
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user