Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
1390 lines
72 KiB
JavaScript
1390 lines
72 KiB
JavaScript
// docs-guard-exempt: 'docs/...' strings are synthetic changed-file inputs fed to scopeFor()/classify(); the docs/ literal is never read as file content.
|
||
'use strict';
|
||
|
||
const { describe, test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
const os = require('node:os');
|
||
const { cleanup } = require('./helpers.cjs');
|
||
const { runNode } = require('./helpers/process-seam.cjs');
|
||
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
|
||
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||
|
||
const ROOT = path.join(__dirname, '..');
|
||
const SCRIPT = path.join(ROOT, 'scripts', 'ci-test-scope.cjs');
|
||
const WORKFLOWS_DIR = path.join(ROOT, '.github', 'workflows');
|
||
|
||
function scopeForAt(files, cwd) {
|
||
const r = runNode([SCRIPT, '--files', files.join(' ')], { cwd, timeoutMs: PROBE_TIMEOUT_MS });
|
||
assert.strictEqual(r.exitCode, 0, `stderr: ${r.stderr}\nstdout: ${r.stdout}`);
|
||
return JSON.parse(r.stdout);
|
||
}
|
||
|
||
function scopeFor(files) {
|
||
return scopeForAt(files, ROOT);
|
||
}
|
||
|
||
describe('ci-test-scope.cjs', () => {
|
||
test('docs-only changes: code_changed is false, product_changed false (skip matrix entirely)', () => {
|
||
const result = scopeFor(['docs/usage.md']);
|
||
assert.strictEqual(result.code_changed, false,
|
||
`expected code_changed=false for docs-only change, got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result.product_changed, false,
|
||
`expected product_changed=false for docs-only change, got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result.full_matrix, false);
|
||
// docs-parity is NOT in targeted_tests when docs-only (it runs via docs-required.yml instead)
|
||
assert.ok(
|
||
!result.targeted_tests.some(t => t.includes('docs-parity-live-registry')),
|
||
`docs-parity-live-registry must NOT be in targeted_tests for docs-only, got: ${JSON.stringify(result.targeted_tests)}`,
|
||
);
|
||
});
|
||
|
||
test('root markdown only: code_changed is false, product_changed false', () => {
|
||
const result = scopeFor(['README.md']);
|
||
assert.strictEqual(result.code_changed, false,
|
||
`expected code_changed=false for root markdown, got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result.product_changed, false,
|
||
`expected product_changed=false for root markdown, got: ${JSON.stringify(result)}`);
|
||
});
|
||
|
||
test('pipeline workflow (test.yml) — product_changed true, full_matrix true, workflow contract tests', () => {
|
||
const result = scopeFor(['.github/workflows/test.yml']);
|
||
assert.strictEqual(result.code_changed, true);
|
||
assert.strictEqual(result.product_changed, true,
|
||
`expected product_changed=true for test.yml, got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result.full_matrix, true);
|
||
assert.ok(result.targeted_tests.includes('tests/workflow-shell-pinning.test.cjs'));
|
||
assert.ok(result.targeted_tests.includes('tests/release-tarball-smoke-workflow.test.cjs'));
|
||
// #4641: the `test` job's windows lane is deleted; full_matrix (already
|
||
// asserted true above) is the only Windows signal left, and classify()
|
||
// emits no windows_tests key at all.
|
||
assert.strictEqual(Object.hasOwn(result, 'windows_tests'), false,
|
||
`expected no windows_tests property, got keys: ${JSON.stringify(Object.keys(result))}`);
|
||
});
|
||
|
||
test('pipeline workflow (install-smoke.yml) — product_changed true, full_matrix true', () => {
|
||
const result = scopeFor(['.github/workflows/install-smoke.yml']);
|
||
assert.strictEqual(result.code_changed, true);
|
||
assert.strictEqual(result.product_changed, true,
|
||
`expected product_changed=true for install-smoke.yml, got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result.full_matrix, true);
|
||
});
|
||
|
||
test('inert CI only (stale.yml) — code_changed true, product_changed false, full_matrix false', () => {
|
||
const result = scopeFor(['.github/workflows/stale.yml']);
|
||
assert.strictEqual(result.code_changed, true,
|
||
`expected code_changed=true for inert CI, got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result.product_changed, false,
|
||
`expected product_changed=false for inert CI, got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result.full_matrix, false,
|
||
`expected full_matrix=false for inert CI, got: ${JSON.stringify(result)}`);
|
||
assert.ok(result.targeted_tests.includes('tests/workflow-shell-pinning.test.cjs'),
|
||
`expected workflow-shell-pinning in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`);
|
||
assert.ok(result.targeted_tests.includes('tests/policy-lint-shallow-checkout.test.cjs'),
|
||
`expected policy-lint-shallow-checkout in targeted_tests for inert CI, got: ${JSON.stringify(result.targeted_tests)}`);
|
||
});
|
||
|
||
test('TS runtime sources (src/semver-compare.cts) — code_changed true, product_changed true, full_matrix false, semver tests targeted', () => {
|
||
// #4592: must be a REAL, on-disk src/ file with no narrow platform signal.
|
||
// A nonexistent path (the former fixture, 'src/semver.cts', names no real
|
||
// file in this repo) now hits reachesConformanceTierOrSeam's readFileSync
|
||
// fail-safe (ENOENT → full_matrix=true by design), which would silently
|
||
// test the fail-safe path instead of this test's actual subject: the "TS
|
||
// runtime sources" RULES entry has no fullMatrix field of its own.
|
||
const result = scopeFor(['src/semver-compare.cts']);
|
||
assert.strictEqual(result.code_changed, true,
|
||
`expected code_changed=true for src/ change, got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result.product_changed, true,
|
||
`expected product_changed=true for src/ change, got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result.full_matrix, false,
|
||
`expected full_matrix=false for src/-only change (TS runtime sources rule has no fullMatrix), got: ${JSON.stringify(result)}`);
|
||
assert.ok(result.targeted_tests.includes('tests/semver-compare.test.cjs'),
|
||
`expected semver-compare in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`);
|
||
});
|
||
|
||
test('product code (msd-core/bin/lib/foo.cjs) — product_changed true', () => {
|
||
const result = scopeFor(['msd-core/bin/lib/foo.cjs']);
|
||
assert.strictEqual(result.code_changed, true);
|
||
assert.strictEqual(result.product_changed, true,
|
||
`expected product_changed=true for msd-core/ change, got: ${JSON.stringify(result)}`);
|
||
});
|
||
|
||
test('unknown/new workflow defaults to pipeline (fail-safe) — product_changed true', () => {
|
||
const result = scopeFor(['.github/workflows/brand-new-thing.yml']);
|
||
assert.strictEqual(result.code_changed, true);
|
||
assert.strictEqual(result.product_changed, true,
|
||
`expected product_changed=true for unknown workflow (fail-safe), got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result.full_matrix, true,
|
||
`expected full_matrix=true for unknown workflow (fail-safe), got: ${JSON.stringify(result)}`);
|
||
});
|
||
|
||
test('mixed docs + code — escalates to product_changed true', () => {
|
||
// Use bin/msd (installer rule, fullMatrix:true) to get a code file that reliably triggers full matrix.
|
||
const result = scopeFor(['docs/x.md', 'bin/msd']);
|
||
assert.strictEqual(result.code_changed, true);
|
||
assert.strictEqual(result.product_changed, true,
|
||
`expected product_changed=true for docs+code, got: ${JSON.stringify(result)}`);
|
||
});
|
||
|
||
test('inert CI (docs-required.yml) — includes shallow-checkout policy test, product_changed false', () => {
|
||
const result = scopeFor(['.github/workflows/docs-required.yml']);
|
||
assert.strictEqual(result.code_changed, true,
|
||
`expected code_changed=true for docs-required.yml, got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result.product_changed, false,
|
||
`expected product_changed=false for docs-required.yml, got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result.full_matrix, false,
|
||
`expected full_matrix=false for docs-required.yml, got: ${JSON.stringify(result)}`);
|
||
assert.ok(result.targeted_tests.includes('tests/policy-lint-shallow-checkout.test.cjs'),
|
||
`expected policy-lint-shallow-checkout in targeted_tests for docs-required.yml, got: ${JSON.stringify(result.targeted_tests)}`);
|
||
});
|
||
|
||
test('mixed docs + inert CI — code_changed true, product_changed false (inert lane)', () => {
|
||
const result = scopeFor(['docs/x.md', '.github/workflows/stale.yml']);
|
||
assert.strictEqual(result.code_changed, true);
|
||
assert.strictEqual(result.product_changed, false,
|
||
`expected product_changed=false for docs+inert, got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result.full_matrix, false);
|
||
});
|
||
|
||
test('mixed docs + src — product_changed true', () => {
|
||
const result = scopeFor(['docs/x.md', 'src/semver.cts']);
|
||
assert.strictEqual(result.code_changed, true);
|
||
assert.strictEqual(result.product_changed, true,
|
||
`expected product_changed=true for docs+src, got: ${JSON.stringify(result)}`);
|
||
});
|
||
|
||
test('command changes request command tests without full parity matrix', () => {
|
||
const result = scopeFor(['commands/msd/plan-phase.md']);
|
||
assert.strictEqual(result.code_changed, true);
|
||
assert.strictEqual(result.full_matrix, false);
|
||
assert.ok(result.targeted_tests.includes('tests/command-contract.test.cjs'));
|
||
assert.ok(result.targeted_tests.includes('tests/commands.test.cjs'));
|
||
});
|
||
|
||
test('changed test files are selected directly', () => {
|
||
const result = scopeFor(['tests/run-tests-harness.test.cjs']);
|
||
assert.strictEqual(result.code_changed, true);
|
||
assert.ok(result.targeted_tests.includes('tests/run-tests-harness.test.cjs'));
|
||
assert.strictEqual(result.full_matrix, true,
|
||
'expected full_matrix=true for a changed test file (rescinded #494 carve-out, see #4421)');
|
||
});
|
||
|
||
test('installer-sensitive changes request full matrix and install tests', () => {
|
||
const result = scopeFor(['bin/msd']);
|
||
assert.strictEqual(result.code_changed, true);
|
||
assert.strictEqual(result.product_changed, true,
|
||
`expected product_changed=true for bin/msd, got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result.full_matrix, true);
|
||
assert.ok(result.targeted_tests.includes('tests/install.test.cjs'));
|
||
// release-tarball-smoke.install.test.cjs is intentionally EXCLUDED from the
|
||
// scoped/targeted lane (SCOPED_LANE_EXCLUDE in ci-test-scope.cjs): it is a
|
||
// 3–6 min npm-pack + global-install integration test that runs via its own
|
||
// install-smoke.yml workflow, not here — running it in the scoped lane too is
|
||
// redundant and overran the per-chunk Windows timeout (epic #1969).
|
||
assert.ok(!result.targeted_tests.includes('tests/release-tarball-smoke.install.test.cjs'),
|
||
`release-tarball-smoke.install.test.cjs must not be in the scoped targeted lane; got: ${JSON.stringify(result.targeted_tests)}`);
|
||
});
|
||
|
||
test('missing required CLI values fail with usage', () => {
|
||
const r = runNode([SCRIPT, '--files'], { cwd: ROOT, timeoutMs: PROBE_TIMEOUT_MS });
|
||
assert.notStrictEqual(r.exitCode, 0);
|
||
// allow-test-rule: pending-migration-to-typed-ir [#3090]
|
||
// Regex-matches the CLI's human-readable stderr formatter (usage banner +
|
||
// arg-parser Error#message) — CONTRIBUTING's own BAD example verbatim.
|
||
// scripts/ci-test-scope.cjs has no --json / frozen-reason-enum error mode
|
||
// yet; adding one is a production change out of scope here. Tracked under #3090.
|
||
assert.match(r.stderr, /--files requires a value/);
|
||
assert.match(r.stderr, /Usage:/);
|
||
});
|
||
|
||
// bug-408: unconditional DEFAULT_SMOKE_TESTS injection removed; unit fallback added
|
||
test('bug-408: code change with matched rules produces exactly the rule-selected tests (no smoke list appended)', () => {
|
||
// commands/ matches the "command definitions" rule only — no smoke list should be added
|
||
const result = scopeFor(['commands/msd/plan-phase.md']);
|
||
assert.strictEqual(result.code_changed, true);
|
||
const expectedTests = [
|
||
'tests/command-contract.test.cjs',
|
||
'tests/command-routing-hub.test.cjs',
|
||
'tests/commands.test.cjs',
|
||
'tests/phase-command-router.test.cjs',
|
||
'tests/roadmap-command-router.test.cjs',
|
||
];
|
||
// Every expected test must be present
|
||
for (const t of expectedTests) {
|
||
assert.ok(result.targeted_tests.includes(t), `expected ${t} in targeted_tests`);
|
||
}
|
||
// No DEFAULT_SMOKE_TESTS files should be injected beyond what the rule selects.
|
||
// The former smoke list contained package-manifest.test.cjs and core.test.cjs —
|
||
// neither is in the "command definitions" rule, so they must not appear.
|
||
assert.ok(!result.targeted_tests.includes('tests/core.test.cjs'),
|
||
'tests/core.test.cjs must NOT be unconditionally injected for command changes');
|
||
assert.ok(!result.targeted_tests.includes('tests/package-manifest.test.cjs'),
|
||
'tests/package-manifest.test.cjs must NOT be unconditionally injected for command changes');
|
||
});
|
||
|
||
test('bug-408: code change with no rule match falls back to unit suite token', () => {
|
||
// A plain source file that matches no RULES entry but is under msd-core/ (code path)
|
||
const result = scopeFor(['msd-core/src/some-util.js']);
|
||
assert.strictEqual(result.code_changed, true);
|
||
assert.deepStrictEqual(result.targeted_tests, ['unit'],
|
||
'targeted_tests must be [\'unit\'] when code changed but no rule matched');
|
||
});
|
||
|
||
test('three-dot diff: docs-only PR on a stale base ignores product commits next gained after the merge-base', () => {
|
||
// Reproduces #837: a docs-only PR branched from a slightly older `next`.
|
||
// After the branch point, `next` advances with a PRODUCT commit. A two-dot
|
||
// `git diff base head` would surface that product file (flipping product_changed/
|
||
// full_matrix true); a three-dot `git diff base...head` (vs the merge-base, which is
|
||
// GitHub's PR semantics) must see ONLY the docs change.
|
||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-scope-837-'));
|
||
try {
|
||
const git = (...a) => gitOrThrow(a, { cwd: tmp }).trim();
|
||
git('init', '-q');
|
||
git('config', 'user.email', 'test@example.com');
|
||
git('config', 'user.name', 'Test');
|
||
git('config', 'commit.gpgsign', 'false');
|
||
|
||
// merge-base: a docs file + a product file (package.json)
|
||
fs.mkdirSync(path.join(tmp, 'tests'), { recursive: true }); // existingTests() reads tests/
|
||
fs.mkdirSync(path.join(tmp, 'docs'), { recursive: true });
|
||
fs.writeFileSync(path.join(tmp, 'docs', 'a.md'), 'base\n');
|
||
fs.writeFileSync(path.join(tmp, 'package.json'), '{"name":"x","version":"1.0.0"}\n');
|
||
git('add', '-A');
|
||
git('commit', '-qm', 'merge-base');
|
||
const baseBranch = git('rev-parse', '--abbrev-ref', 'HEAD');
|
||
|
||
// PR branch (head): docs-only change
|
||
git('checkout', '-q', '-b', 'feature');
|
||
fs.writeFileSync(path.join(tmp, 'docs', 'a.md'), 'base\nnew docs line\n');
|
||
git('add', '-A');
|
||
git('commit', '-qm', 'docs: add line');
|
||
const head = git('rev-parse', 'HEAD');
|
||
|
||
// base advances (next gains a PRODUCT commit after the merge-base)
|
||
git('checkout', '-q', baseBranch);
|
||
fs.writeFileSync(path.join(tmp, 'package.json'), '{"name":"x","version":"2.0.0"}\n');
|
||
git('add', '-A');
|
||
git('commit', '-qm', 'chore: bump version on next');
|
||
const base = git('rev-parse', 'HEAD');
|
||
|
||
const r = runNode([SCRIPT, '--base', base, '--head', head], { cwd: tmp, timeoutMs: PROBE_TIMEOUT_MS });
|
||
assert.strictEqual(r.exitCode, 0, `script failed: stderr=${r.stderr}\nstdout=${r.stdout}`);
|
||
const result = JSON.parse(r.stdout);
|
||
|
||
assert.deepStrictEqual(
|
||
result.changed_files,
|
||
['docs/a.md'],
|
||
`expected three-dot diff to see only the docs file, got: ${JSON.stringify(result.changed_files)}`,
|
||
);
|
||
assert.strictEqual(
|
||
result.product_changed,
|
||
false,
|
||
`docs-only PR must not set product_changed even on a stale base, got: ${JSON.stringify(result)}`,
|
||
);
|
||
assert.strictEqual(
|
||
result.full_matrix,
|
||
false,
|
||
`docs-only PR must not set full_matrix even on a stale base, got: ${JSON.stringify(result)}`,
|
||
);
|
||
} finally {
|
||
cleanup(tmp);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('ci-test-scope superset invariant (#494, rescinded by #4421)', () => {
|
||
// Facet A: #494 originally narrowed this so a changed test file joined only
|
||
// the scoped windows lane instead of triggering the full parity matrix.
|
||
// Rescinded per #4421 (2026-09-06 RCA: PR #4384 shipped a macOS-only
|
||
// regression invisible pre-merge because of exactly this carve-out) — a
|
||
// changed test file always joins the scoped windows lane, and (until
|
||
// #4592) ALWAYS set full_matrix=true too. #4592 replaces that blanket rule
|
||
// with a reachability check: full_matrix now fires only when the changed
|
||
// test file is tagged in Phase 2's CONFORMANCE_TIER_FILES (or is the
|
||
// classification mechanism itself). A1/A2 below are real, committed
|
||
// conformance-tier files, so they still assert full_matrix=true — for the
|
||
// new, documented reason, not the removed blanket rule.
|
||
test('A1: a changed test file joins the windows scoped lane AND triggers full_matrix', () => {
|
||
const result = scopeFor(['tests/perf-317-context-monitor-fs.test.cjs']);
|
||
assert.strictEqual(result.full_matrix, true,
|
||
`expected full_matrix=true for a tests/**-only change (rescinded #494 carve-out, see #4421), got: ${JSON.stringify(result)}`);
|
||
assert.ok(result.targeted_tests.includes('tests/perf-317-context-monitor-fs.test.cjs'),
|
||
`expected the changed test in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`);
|
||
// #4641: the `test` job's windows lane is deleted; full_matrix (already
|
||
// asserted true above) is the only Windows signal left.
|
||
assert.strictEqual(Object.hasOwn(result, 'windows_tests'), false,
|
||
`expected no windows_tests property, got keys: ${JSON.stringify(Object.keys(result))}`);
|
||
});
|
||
|
||
test('A2: a changed test file with no windows hint still triggers full_matrix, with no side lane (#4641)', () => {
|
||
// commands.test.cjs matches none of the WINDOWS_HINTS substrings — under
|
||
// the old #494/#4421 behavior it still joined the windows lane. Post-#4641
|
||
// there is no windows lane to join; full_matrix is the sole signal.
|
||
const result = scopeFor(['tests/commands.test.cjs']);
|
||
assert.strictEqual(result.full_matrix, true,
|
||
`expected full_matrix=true (rescinded #494 carve-out, see #4421), got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(Object.hasOwn(result, 'windows_tests'), false,
|
||
`expected no windows_tests property, got keys: ${JSON.stringify(Object.keys(result))}`);
|
||
});
|
||
|
||
test('A3: a deleted/nonexistent test path falls back to the unit token; full_matrix now depends on conformance-tier reachability (#4592)', () => {
|
||
// Pre-#4592, the removed blanket rule forced full_matrix=true for ANY
|
||
// changed tests/*.test.cjs path regardless of on-disk existence or
|
||
// content. #4592 replaces that with direct CONFORMANCE_TIER_FILES
|
||
// membership — a path absent from the committed list is treated as
|
||
// genuinely Linux-safe, not as uncertain (design doc's explicit policy),
|
||
// so a synthetic/nonexistent path with no tier entry now yields
|
||
// full_matrix=false.
|
||
const result = scopeFor(['tests/some-new.test.cjs']);
|
||
assert.strictEqual(result.full_matrix, false,
|
||
`expected full_matrix=false for a tests/*.test.cjs path absent from CONFORMANCE_TIER_FILES (#4592), got: ${JSON.stringify(result)}`);
|
||
// The nonexistent file is filtered by existingTests(); with nothing left,
|
||
// the #408 fallback applies so the targeted lane still runs something.
|
||
assert.deepStrictEqual(result.targeted_tests, ['unit']);
|
||
});
|
||
|
||
// Facet B: commands/**, agents/** → code_changed AND docs-parity selected
|
||
// docs/ is NO LONGER in this facet — docs-only PRs skip the matrix entirely.
|
||
test('B1: docs/adr change: code_changed is false (docs skip matrix)', () => {
|
||
const result = scopeFor(['docs/adr/22-plan-drift-guard.md']);
|
||
assert.strictEqual(result.code_changed, false,
|
||
`expected code_changed=false for docs/** change (matrix skip), got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result.product_changed, false,
|
||
`expected product_changed=false for docs/** change, got: ${JSON.stringify(result)}`);
|
||
// docs-parity is NOT in targeted_tests (handled by docs-required.yml)
|
||
assert.ok(
|
||
!result.targeted_tests.some(t => t.includes('docs-parity-live-registry')),
|
||
`docs-parity-live-registry must NOT be in targeted_tests for docs-only, got: ${JSON.stringify(result.targeted_tests)}`,
|
||
);
|
||
});
|
||
|
||
test('B2: docs locale dir change: code_changed is false (docs skip matrix)', () => {
|
||
const result = scopeFor(['docs/ja-JP/USAGE.md']);
|
||
assert.strictEqual(result.code_changed, false,
|
||
`expected code_changed=false for docs/ja-JP/** change, got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result.product_changed, false,
|
||
`expected product_changed=false for docs/ja-JP/** change, got: ${JSON.stringify(result)}`);
|
||
});
|
||
|
||
test('B3: commands/** change selects docs-parity-live-registry', () => {
|
||
const result = scopeFor(['commands/msd/plan-phase.md']);
|
||
assert.ok(
|
||
result.targeted_tests.some(t => t.includes('docs-parity-live-registry')),
|
||
`expected docs-parity-live-registry in targeted_tests for commands/** change, got: ${JSON.stringify(result.targeted_tests)}`,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('INERT_WORKFLOWS allowlist integrity guard', () => {
|
||
// Load the INERT_WORKFLOWS set from the script by spawning it and using --files
|
||
// on a sentinel path, then separately verify the set contents via the filesystem.
|
||
|
||
// Known pipeline workflows that MUST NOT appear in INERT_WORKFLOWS.
|
||
// Must stay in sync with PROTECTED_WORKFLOWS in scripts/ci-test-scope.cjs.
|
||
const KNOWN_PIPELINE = [
|
||
'test.yml',
|
||
'install-smoke.yml',
|
||
'mutation.yml',
|
||
'security-scan.yml',
|
||
'release.yml',
|
||
];
|
||
|
||
// Canonical inert workflow list — reused by both tests below.
|
||
const knownInert = [
|
||
'stale.yml', 'branch-cleanup.yml', 'branch-naming.yml', 'auto-label-issues.yml',
|
||
'auto-branch.yml', 'auto-backmerge.yml', 'close-draft-prs.yml',
|
||
'dismiss-unauthorized-pr-approvals.yml', 'pr-target-validator.yml',
|
||
'pr-template-format.yml', 'require-issue-link.yml', 'changeset-required.yml',
|
||
'docs-required.yml', 'discord-changelog.yml',
|
||
];
|
||
|
||
test('all entries in INERT_WORKFLOWS exist under .github/workflows/', () => {
|
||
// We derive the inert set implicitly: any .github/workflows/*.yml that produces
|
||
// full_matrix=false when passed alone is inert. We check the known inert names
|
||
// against the filesystem instead.
|
||
// The canonical list is in the script — we verify each named file exists.
|
||
for (const name of knownInert) {
|
||
const fullPath = path.join(WORKFLOWS_DIR, name);
|
||
assert.ok(
|
||
fs.existsSync(fullPath),
|
||
`INERT_WORKFLOWS entry '${name}' does not exist at ${fullPath}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('known pipeline workflows are NOT treated as inert (product_changed true, full_matrix true)', () => {
|
||
for (const name of KNOWN_PIPELINE) {
|
||
const result = scopeFor([`.github/workflows/${name}`]);
|
||
assert.strictEqual(result.product_changed, true,
|
||
`${name} must be pipeline (product_changed=true), got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result.full_matrix, true,
|
||
`${name} must be pipeline (full_matrix=true), got: ${JSON.stringify(result)}`);
|
||
}
|
||
});
|
||
|
||
// Explicit per-workflow guard: each of the five protected workflows must route to
|
||
// the full matrix. This documents intent and proves that PROTECTED_WORKFLOWS
|
||
// enforcement is covered end-to-end via the spawn helper.
|
||
test('all five PROTECTED_WORKFLOWS individually route to full matrix (tamper-evidence)', () => {
|
||
const protected_ = [
|
||
'test.yml',
|
||
'install-smoke.yml',
|
||
'mutation.yml',
|
||
'security-scan.yml',
|
||
'release.yml',
|
||
];
|
||
for (const name of protected_) {
|
||
const result = scopeFor([`.github/workflows/${name}`]);
|
||
assert.strictEqual(result.product_changed, true,
|
||
`PROTECTED_WORKFLOW ${name}: expected product_changed=true, got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result.full_matrix, true,
|
||
`PROTECTED_WORKFLOW ${name}: expected full_matrix=true, got: ${JSON.stringify(result)}`);
|
||
}
|
||
});
|
||
|
||
test('every inert workflow produces code_changed=true, product_changed=false, and full_matrix=false', () => {
|
||
for (const name of knownInert) {
|
||
const result = scopeFor([`.github/workflows/${name}`]);
|
||
assert.strictEqual(result.code_changed, true,
|
||
`${name}: expected code_changed=true`);
|
||
assert.strictEqual(result.product_changed, false,
|
||
`${name}: expected product_changed=false`);
|
||
assert.strictEqual(result.full_matrix, false,
|
||
`${name}: expected full_matrix=false`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('test.yml changes job contract (#837)', () => {
|
||
// ci-test-scope.cjs uses a three-dot `git diff base...head`, which requires the
|
||
// merge-base commit to be locally present. The `changes` job in test.yml guarantees
|
||
// this via `fetch-depth: 0` on its checkout step. This test pins that contract so
|
||
// any future reduction of fetch-depth fails CI loudly (#837).
|
||
test('changes job checkout step sets fetch-depth: 0 (required for three-dot diff merge-base)', () => {
|
||
const workflowPath = path.join(WORKFLOWS_DIR, 'test.yml');
|
||
const text = fs.readFileSync(workflowPath, 'utf8');
|
||
const lines = text.split(/\r?\n/);
|
||
|
||
// Locate the `changes:` job (two-space-indented top-level job key).
|
||
const jobStart = lines.findIndex(l => /^ {2}changes:\s*$/.test(l));
|
||
assert.ok(jobStart !== -1, 'Could not find ` changes:` job in test.yml');
|
||
|
||
// Find the next top-level job key at the same two-space indentation to bound the region.
|
||
let jobEnd = lines.length;
|
||
for (let i = jobStart + 1; i < lines.length; i++) {
|
||
if (/^ {2}[A-Za-z0-9_-]+:\s*$/.test(lines[i])) {
|
||
jobEnd = i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const changesJobText = lines.slice(jobStart, jobEnd).join('\n');
|
||
|
||
assert.ok(
|
||
/fetch-depth:\s*0/.test(changesJobText),
|
||
'changes job checkout must set `fetch-depth: 0` so the three-dot `git diff base...head` ' +
|
||
'in ci-test-scope.cjs can resolve the merge-base locally (#837). ' +
|
||
'Reducing fetch-depth breaks the three-dot diff and causes incorrect scope detection.',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('test.yml gating internals (#2472 / #4241)', () => {
|
||
const yaml = require('js-yaml');
|
||
|
||
// #2472: every job of a run must merge ONE base commit. Each job runs the
|
||
// rebase-check step independently, minutes apart across the matrix, so
|
||
// merging the moving branch ref lets jobs see different trees when the base
|
||
// advances mid-run. The sharded lane makes jobs agree on a PARTITION, and
|
||
// disagreement there places a file in two shards or none — silently, because
|
||
// each job stays internally consistent and CI stays green.
|
||
describe('#2472 base-commit pin', () => {
|
||
const { resolveBaseRefs } = require('../scripts/ci-rebase-check.cjs');
|
||
const SHA = 'a1b2c3d4e5f60718293a4b5c6d7e8f9012345678';
|
||
|
||
test('every rebase-check step pins CI_REBASE_BASE_SHA', () => {
|
||
const doc = yaml.load(fs.readFileSync(path.join(WORKFLOWS_DIR, 'test.yml'), 'utf8'));
|
||
const steps = Object.values(doc.jobs)
|
||
.flatMap(j => j.steps || [])
|
||
.filter(s => typeof s.run === 'string' && s.run.includes('ci-rebase-check.cjs'));
|
||
assert.ok(steps.length > 0, 'expected at least one rebase-check step');
|
||
for (const s of steps) {
|
||
assert.ok(
|
||
s.env && typeof s.env.CI_REBASE_BASE_SHA === 'string' && s.env.CI_REBASE_BASE_SHA.includes('base.sha'),
|
||
'each rebase-check step must pin CI_REBASE_BASE_SHA to the PR base sha; '
|
||
+ 'an unpinned job can merge a different tree than its siblings',
|
||
);
|
||
}
|
||
});
|
||
|
||
test('a full 40-hex sha pins both fetch and merge to that commit', () => {
|
||
const r = resolveBaseRefs({ GITHUB_BASE_REF: 'next', CI_REBASE_BASE_SHA: SHA }, 'main');
|
||
assert.strictEqual(r.fetchRef, SHA);
|
||
assert.strictEqual(r.mergeRef, SHA, 'fetch and merge must target the same pinned commit');
|
||
assert.strictEqual(r.pinned, true);
|
||
});
|
||
|
||
test('no pin falls back to the branch ref (push / workflow_dispatch)', () => {
|
||
const r = resolveBaseRefs({ GITHUB_BASE_REF: 'next' }, 'main');
|
||
assert.strictEqual(r.fetchRef, 'next');
|
||
assert.strictEqual(r.mergeRef, 'origin/next');
|
||
assert.strictEqual(r.pinned, false);
|
||
});
|
||
|
||
// A non-sha value must never reach `git fetch` as a refspec.
|
||
for (const [label, value] of [
|
||
['short sha', 'abc123'],
|
||
['uppercase sha', 'A'.repeat(40)],
|
||
['argument injection', 'next --upload-pack=evil'],
|
||
['ref expression', 'next^{commit}'],
|
||
['empty', ''],
|
||
]) {
|
||
test(`rejects ${label} and falls back to the branch ref`, () => {
|
||
const r = resolveBaseRefs({ GITHUB_BASE_REF: 'next', CI_REBASE_BASE_SHA: value }, 'main');
|
||
assert.strictEqual(r.pinned, false, `"${value}" must not be accepted as a pin`);
|
||
assert.strictEqual(r.fetchRef, 'next');
|
||
assert.strictEqual(r.mergeRef, 'origin/next');
|
||
});
|
||
}
|
||
});
|
||
|
||
test('required-tests fan-in keeps the protected name', () => {
|
||
// Hyrum's Law: branch protection requires a status check literally named
|
||
// "Required tests". Renaming it would silently break the gate.
|
||
const text = fs.readFileSync(path.join(WORKFLOWS_DIR, 'test.yml'), 'utf8');
|
||
const doc = yaml.load(text);
|
||
const fanIn = doc.jobs['required-tests'];
|
||
assert.ok(fanIn, 'required-tests job must exist');
|
||
assert.strictEqual(fanIn.name, 'Required tests', 'the branch-protection check name must stay "Required tests"');
|
||
});
|
||
|
||
test('workflow triggers on merge_group (#4241)', () => {
|
||
// GitHub's merge queue fires `merge_group`, not `pull_request` or `push`,
|
||
// for the temporary merge-group commit it creates. Without this trigger
|
||
// the whole workflow — and therefore `required-tests` — never schedules
|
||
// for a queued PR, silently stalling the merge queue on a check that
|
||
// never runs. `on.merge_group` has no required config, so its presence
|
||
// as a key (even with a `null`/empty value) is what matters here.
|
||
const text = fs.readFileSync(path.join(WORKFLOWS_DIR, 'test.yml'), 'utf8');
|
||
const doc = yaml.load(text);
|
||
assert.ok(
|
||
Object.prototype.hasOwnProperty.call(doc.on, 'merge_group'),
|
||
'test.yml must trigger `on: merge_group` so required-tests schedules for merge-queue commits',
|
||
);
|
||
});
|
||
|
||
test('every AUDIT_BASELINE_REF pin covers merge_group, not just pull_request/push (#4241)', () => {
|
||
// scripts/npm-audit-baseline.cjs's resolveBaselineRef() documents its
|
||
// origin/next live-tip fallback as UNREACHABLE from CI because
|
||
// AUDIT_BASELINE_REF is "always set by test.yml". A merge_group event
|
||
// carries neither pull_request nor push context, so a ternary that only
|
||
// branches on those two silently falls through to '' for a merge-queue
|
||
// run -- reopening the exact origin/next-can-advance-mid-run race #4196
|
||
// fixed, but only for merge_group. Every job that sets AUDIT_BASELINE_REF
|
||
// must also branch on merge_group, using merge_group.base_sha (the base
|
||
// tip the temporary merge-group commit was built against).
|
||
const text = fs.readFileSync(path.join(WORKFLOWS_DIR, 'test.yml'), 'utf8');
|
||
const doc = yaml.load(text);
|
||
|
||
const jobsUnderTest = Object.entries(doc.jobs).filter(
|
||
([, job]) => job.env && typeof job.env.AUDIT_BASELINE_REF === 'string',
|
||
);
|
||
assert.ok(jobsUnderTest.length >= 3, `expected at least 3 jobs to pin AUDIT_BASELINE_REF, got ${jobsUnderTest.length}`);
|
||
|
||
for (const [name, job] of jobsUnderTest) {
|
||
const expr = job.env.AUDIT_BASELINE_REF;
|
||
assert.ok(
|
||
expr.includes("github.event_name == 'merge_group'") && expr.includes('github.event.merge_group.base_sha'),
|
||
`job ${name}: AUDIT_BASELINE_REF must branch on merge_group using ` +
|
||
`github.event.merge_group.base_sha, got: ${expr}`,
|
||
);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('emitted-provenance selection (#1691 drift guard, retargeted by #2724)', () => {
|
||
// Regression: a src/*.cts-only edit recompiles bin/lib/*.cjs (changing installed
|
||
// hashes), but the scoped CI lane was not re-running the drift guard — causing
|
||
// emitted state to silently drift (#1691 milestone/roadmap cts change). Both the
|
||
// 'TS runtime sources' and 'installer and package layout' rules must select
|
||
// tests/emitted-provenance.test.cjs. Originally asserted against
|
||
// tests/golden-install-parity.test.cjs, deleted by #2724 (ADR-2719 Phase 4); the
|
||
// differential attribution check is the sole replacement, so this now asserts
|
||
// its selection directly instead of "travels with the golden".
|
||
|
||
test('src/*.cts change selects emitted-provenance (TS runtime sources rule)', () => {
|
||
const result = scopeFor(['src/milestone.cts']);
|
||
assert.strictEqual(result.code_changed, true,
|
||
`expected code_changed=true for src/ change, got: ${JSON.stringify(result)}`);
|
||
assert.ok(
|
||
result.targeted_tests.includes('tests/emitted-provenance.test.cjs'),
|
||
`expected emitted-provenance in targeted_tests for src/*.cts change, got: ${JSON.stringify(result.targeted_tests)}`,
|
||
);
|
||
});
|
||
|
||
test('bin/install.js change selects emitted-provenance (installer and package layout rule)', () => {
|
||
const result = scopeFor(['bin/install.js']);
|
||
assert.strictEqual(result.code_changed, true,
|
||
`expected code_changed=true for bin/ change, got: ${JSON.stringify(result)}`);
|
||
assert.ok(
|
||
result.targeted_tests.includes('tests/emitted-provenance.test.cjs'),
|
||
`expected emitted-provenance in targeted_tests for bin/install.js change, got: ${JSON.stringify(result.targeted_tests)}`,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('shipped install content (emitted-attribution drift guard, #2267, retargeted by #2724)', () => {
|
||
// #2266 regression: hooks/msd-statusline.js changed installed output but no
|
||
// RULES entry selected the drift guard, so stale emitted state merged to `next`
|
||
// undetected. The installer emits hooks/*, commands/*, agents/*, skills/*,
|
||
// msd-core/workflows/*, msd-core/templates/*, msd-core/references/*,
|
||
// msd-core/bin/shared/*.json, and a handful of shipped scripts/* files — every
|
||
// one of those paths must additionally select the emitted gates AND the
|
||
// install-tree snapshot (union semantics: this rule ADDS to whatever
|
||
// content-specific rule already matched the path). Originally asserted the now-
|
||
// deleted tests/golden-install-parity.test.cjs (#2724, ADR-2719 Phase 4).
|
||
// One path per prefix the rule handles — every installed-source dir the
|
||
// installer emits. msd-core/contexts/ is the regression for the review miss
|
||
// (a real shipped dir that the initial enumeration omitted).
|
||
const SHIPPED_PATHS = [
|
||
'hooks/msd-statusline.js', // exact #2266 regression
|
||
'msd-core/workflows/plan-phase.md',
|
||
'msd-core/templates/config.json',
|
||
'msd-core/references/ui-brand.md',
|
||
'msd-core/contexts/dev.md', // regression: initially omitted from the rule
|
||
'agents/msd-planner.md',
|
||
'commands/msd/mempalace-capture.md',
|
||
'skills/msd-explore/SKILL.md',
|
||
'msd-core/bin/shared/model-catalog.json',
|
||
'scripts/changeset/lint.cjs',
|
||
'scripts/lib/cli-exit.cjs',
|
||
'scripts/fix-slash-commands.cjs', // exact-match allowlist entry
|
||
'scripts/gen-capability-registry.cjs', // exact-match allowlist entry
|
||
'scripts/gen-loop-host-contract.cjs', // exact-match allowlist entry
|
||
];
|
||
|
||
for (const file of SHIPPED_PATHS) {
|
||
test(`${file} selects emitted-provenance + golden-install-tree`, () => {
|
||
const result = scopeFor([file]);
|
||
assert.strictEqual(result.code_changed, true,
|
||
`expected code_changed=true for ${file}, got: ${JSON.stringify(result)}`);
|
||
assert.ok(
|
||
result.targeted_tests.includes('tests/emitted-provenance.test.cjs'),
|
||
`expected emitted-provenance in targeted_tests for ${file}, got: ${JSON.stringify(result.targeted_tests)}`,
|
||
);
|
||
assert.ok(
|
||
result.targeted_tests.includes('tests/golden-install-tree.test.cjs'),
|
||
`expected golden-install-tree in targeted_tests for ${file}, got: ${JSON.stringify(result.targeted_tests)}`,
|
||
);
|
||
});
|
||
}
|
||
|
||
test('docs/ change does NOT select emitted-provenance (negative case)', () => {
|
||
const result = scopeFor(['docs/usage.md']);
|
||
assert.strictEqual(result.code_changed, false,
|
||
`expected code_changed=false for docs-only change, got: ${JSON.stringify(result)}`);
|
||
assert.ok(
|
||
!result.targeted_tests.includes('tests/emitted-provenance.test.cjs'),
|
||
`docs-only change must NOT select emitted-provenance, got: ${JSON.stringify(result.targeted_tests)}`,
|
||
);
|
||
});
|
||
|
||
test('non-shipped scripts/ file does NOT select golden-install-tree (allowlist is exact)', () => {
|
||
// The rule allowlists only 3 named scripts + scripts/changeset|lib/. A
|
||
// sibling script that is code but NOT installed verbatim must NOT pull in
|
||
// the install-tree snapshot — proving this is not a blanket 'scripts/'
|
||
// prefix that would re-run the guard on every script edit. Assert on
|
||
// golden-install-TREE (the rule's dedicated test), not the emitted gates: many
|
||
// scripts/ paths legitimately select those via the installer rule's
|
||
// path.includes('install') substring.
|
||
const result = scopeFor(['scripts/build-hooks.js']);
|
||
assert.ok(
|
||
!result.targeted_tests.includes('tests/golden-install-tree.test.cjs'),
|
||
`non-shipped script must NOT select golden-install-tree, got: ${JSON.stringify(result.targeted_tests)}`,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('the two emitted gates always travel together (#2758, simplified by #2724)', () => {
|
||
// #2758: a shipped-content-only PR — the archetypal emitted ripple — must select
|
||
// BOTH tests/emitted-provenance.test.cjs and tests/emitted-attribution.test.cjs.
|
||
// Originally phrased as "gates travel with the golden" during the #2723 dual-run
|
||
// window; #2724 (ADR-2719 Phase 4) deleted tests/golden-install-parity.test.cjs,
|
||
// so there is no longer a third file for the gates to travel alongside — this
|
||
// now asserts the pairing directly, on the same rule table.
|
||
const { RULES } = require('../scripts/ci-test-scope.cjs');
|
||
const GATES = ['tests/emitted-provenance.test.cjs', 'tests/emitted-attribution.test.cjs'];
|
||
|
||
test('every RULES entry selecting one emitted gate selects both', () => {
|
||
const partial = RULES.filter(r => GATES.some(g => r.tests.includes(g)));
|
||
// Guards the guard: if this count ever drops to 0, the assertion below is
|
||
// vacuously true and would silently stop meaning anything.
|
||
assert.ok(
|
||
partial.length >= 3,
|
||
`expected at least 3 RULES entries selecting an emitted gate, found ${partial.length}: ` +
|
||
`${partial.map(r => r.name).join(', ')}`,
|
||
);
|
||
const offenders = partial.filter(r => !GATES.every(g => r.tests.includes(g)));
|
||
assert.deepStrictEqual(
|
||
offenders.map(r => r.name),
|
||
[],
|
||
`rule(s) select one emitted gate without the other: ${offenders.map(r => r.name).join(', ')}`,
|
||
);
|
||
});
|
||
|
||
test('a pure shipped-content path selects both emitted gates', () => {
|
||
// #2758 AC: "a pure shipped-content path (e.g. msd-core/workflows/plan-phase.md)
|
||
// selects the differential." The archetypal emitted ripple.
|
||
const result = scopeFor(['msd-core/workflows/plan-phase.md']);
|
||
assert.strictEqual(result.code_changed, true);
|
||
for (const g of GATES) {
|
||
assert.ok(
|
||
result.targeted_tests.includes(g),
|
||
`expected ${g} in targeted_tests for a shipped-content-only change, got: ${JSON.stringify(result.targeted_tests)}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('a src/*.cts-only change selects both emitted gates (TS runtime sources rule)', () => {
|
||
const result = scopeFor(['src/milestone.cts']);
|
||
for (const g of GATES) {
|
||
assert.ok(
|
||
result.targeted_tests.includes(g),
|
||
`expected ${g} in targeted_tests for src/ change, got: ${JSON.stringify(result.targeted_tests)}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('bin/install.js selects both emitted gates (installer and package layout rule)', () => {
|
||
const result = scopeFor(['bin/install.js']);
|
||
for (const g of GATES) {
|
||
assert.ok(
|
||
result.targeted_tests.includes(g),
|
||
`expected ${g} in targeted_tests for bin/install.js, got: ${JSON.stringify(result.targeted_tests)}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test('docs-only change still does NOT select the emitted gates (negative case)', () => {
|
||
const result = scopeFor(['docs/usage.md']);
|
||
for (const g of GATES) {
|
||
assert.ok(!result.targeted_tests.includes(g), `docs-only must NOT select ${g}, got: ${JSON.stringify(result.targeted_tests)}`);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('RULES totality guard: no rule names a test file absent from disk (#2758)', () => {
|
||
// #2758: Phase 4 (#2724) deletes tests/golden-install-parity.test.cjs. Without an
|
||
// independent guard, a rule still naming it would produce no signal at all —
|
||
// existingTests() (scripts/ci-test-scope.cjs) silently filters missing files out
|
||
// of targeted_tests, so the gate simply stops being selected while CI stays
|
||
// green. This exists independently of the fix above: it catches ANY rule naming
|
||
// ANY absent file, not only the two gate filenames this issue is about.
|
||
const { RULES, missingRuleTestFiles } = require('../scripts/ci-test-scope.cjs');
|
||
|
||
test('no RULES entry today references a test file absent from disk', () => {
|
||
assert.deepStrictEqual(
|
||
missingRuleTestFiles(RULES), [],
|
||
'RULES reference test file(s) that do not exist — see missingRuleTestFiles() in scripts/ci-test-scope.cjs',
|
||
);
|
||
});
|
||
|
||
test('the guard mechanism itself catches a phantom entry (hostile input)', () => {
|
||
// Runs the REAL checker function used by the module-load assertion in
|
||
// scripts/ci-test-scope.cjs — not a hand-copied reimplementation of it — against
|
||
// a synthetic rule table, proving the mechanism would have caught exactly the
|
||
// Phase-4 shape: a rule naming a file that no longer exists on disk.
|
||
const phantomFile = 'tests/does-not-exist-2758.test.cjs';
|
||
assert.ok(
|
||
!fs.existsSync(path.join(ROOT, phantomFile)),
|
||
'precondition: the phantom file must genuinely not exist for this test to discriminate',
|
||
);
|
||
const synthetic = [
|
||
{ name: 'real', tests: ['tests/commands.test.cjs'] },
|
||
{ name: 'phantom', tests: [phantomFile, 'tests/commands.test.cjs'] },
|
||
];
|
||
assert.deepStrictEqual(missingRuleTestFiles(synthetic), [phantomFile]);
|
||
});
|
||
|
||
test('an all-real synthetic table reports nothing missing (negative case)', () => {
|
||
const synthetic = [{ name: 'real', tests: ['tests/commands.test.cjs', 'tests/ci-test-scope.test.cjs'] }];
|
||
assert.deepStrictEqual(missingRuleTestFiles(synthetic), []);
|
||
});
|
||
});
|
||
|
||
describe('code_changed=false implies clean output invariant', () => {
|
||
// Fix 1: when code_changed is false, full_matrix, targeted_tests, windows_tests
|
||
// must ALL be empty/false — even if a docs path coincidentally
|
||
// matches a content rule via coarse substring (e.g. path.includes('install') or
|
||
// path.includes('config')).
|
||
|
||
test('docs-only: code_changed=false → product_changed=false, full_matrix=false, empty targeted_tests', () => {
|
||
const result = scopeFor(['docs/usage.md']);
|
||
assert.strictEqual(result.code_changed, false);
|
||
assert.strictEqual(result.product_changed, false);
|
||
assert.strictEqual(result.full_matrix, false);
|
||
assert.deepStrictEqual(result.targeted_tests, []);
|
||
});
|
||
|
||
// docs/installer-migrations.md contains 'install' → would match the installer rule
|
||
// via path.includes('install'). Normalization must suppress the contradictory output.
|
||
test('docs/installer-migrations.md: code_changed=false AND product_changed=false AND full_matrix=false AND empty targeted_tests', () => {
|
||
const result = scopeFor(['docs/installer-migrations.md']);
|
||
assert.strictEqual(result.code_changed, false,
|
||
`expected code_changed=false for docs/installer-migrations.md, got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result.product_changed, false,
|
||
`expected product_changed=false for docs/installer-migrations.md, got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result.full_matrix, false,
|
||
`expected full_matrix=false for docs/installer-migrations.md, got: ${JSON.stringify(result)}`);
|
||
assert.deepStrictEqual(result.targeted_tests, [],
|
||
`expected empty targeted_tests for docs/installer-migrations.md, got: ${JSON.stringify(result.targeted_tests)}`);
|
||
});
|
||
|
||
// docs/how-to/configure-model-profiles.md contains 'config' → matches configuration rule.
|
||
test('docs path matching config rule: code_changed=false → empty output (coarse-substring docs suppressed)', () => {
|
||
const result = scopeFor(['docs/how-to/configure-model-profiles.md']);
|
||
assert.strictEqual(result.code_changed, false,
|
||
`expected code_changed=false, got: ${JSON.stringify(result)}`);
|
||
assert.strictEqual(result.product_changed, false);
|
||
assert.strictEqual(result.full_matrix, false);
|
||
assert.deepStrictEqual(result.targeted_tests, []);
|
||
});
|
||
|
||
// code_changed=true must produce >= 1 targeted_test or 'unit' fallback.
|
||
test('code_changed=true implies non-empty targeted_tests', () => {
|
||
for (const files of [
|
||
['src/semver.cts'],
|
||
['bin/msd'],
|
||
['.github/workflows/test.yml'],
|
||
['.github/workflows/stale.yml'],
|
||
]) {
|
||
const result = scopeFor(files);
|
||
assert.strictEqual(result.code_changed, true,
|
||
`expected code_changed=true for ${files}, got: ${JSON.stringify(result)}`);
|
||
assert.ok(result.targeted_tests.length >= 1,
|
||
`expected >= 1 targeted_test for ${files}, got: ${JSON.stringify(result.targeted_tests)}`);
|
||
}
|
||
});
|
||
});
|
||
|
||
|
||
describe('#4592 reachability-based full_matrix classifier', () => {
|
||
// Row 1: a real, committed conformance-tier test file forces full_matrix,
|
||
// and the reason names the new mechanism (not a generic path-prefix rule).
|
||
test('full_matrix true for a conformance-tier test file', () => {
|
||
const { CONFORMANCE_TIER_FILES } = require('../scripts/lib/platform-conformance-tier.generated.cjs');
|
||
assert.ok(CONFORMANCE_TIER_FILES.length > 0, 'precondition: committed tier list must be non-empty');
|
||
const file = CONFORMANCE_TIER_FILES[0];
|
||
const result = scopeFor([file]);
|
||
assert.strictEqual(result.full_matrix, true,
|
||
`expected full_matrix=true for conformance-tier file ${file}, got: ${JSON.stringify(result)}`);
|
||
assert.ok(result.reasons.includes(`${file}: conformance-tier reachability`),
|
||
`expected reasons to name the conformance-tier mechanism, got: ${JSON.stringify(result.reasons)}`);
|
||
});
|
||
|
||
// Row 2: the whole point of the change — a changed test file that is NOT in
|
||
// CONFORMANCE_TIER_FILES and matches no other RULES entry must NOT force
|
||
// full_matrix.
|
||
test('full_matrix false for a non-conformance-tier test file alone', () => {
|
||
const { CONFORMANCE_TIER_FILES } = require('../scripts/lib/platform-conformance-tier.generated.cjs');
|
||
const file = 'tests/some-brand-new-non-tier.test.cjs';
|
||
assert.ok(!CONFORMANCE_TIER_FILES.includes(file), 'precondition: fixture path must not be tier-tagged');
|
||
const result = scopeFor([file]);
|
||
assert.strictEqual(result.full_matrix, false,
|
||
`expected full_matrix=false for a non-conformance-tier test file, got: ${JSON.stringify(result)}`);
|
||
});
|
||
|
||
// Row 3: named #4421 regression — the exact file from PR #4384 must still
|
||
// force full_matrix, now for the documented reachability reason instead of
|
||
// the removed blanket rule.
|
||
test('#4421 regression: state-todos-render.test.cjs still forces full_matrix, for the documented reason', () => {
|
||
const file = 'tests/state-todos-render.test.cjs';
|
||
const { CONFORMANCE_TIER_FILES } = require('../scripts/lib/platform-conformance-tier.generated.cjs');
|
||
assert.ok(CONFORMANCE_TIER_FILES.includes(file),
|
||
'precondition: state-todos-render.test.cjs must be present in the committed conformance tier');
|
||
const result = scopeFor([file]);
|
||
assert.strictEqual(result.full_matrix, true,
|
||
`expected full_matrix=true for the #4421 regression file, got: ${JSON.stringify(result)}`);
|
||
assert.ok(result.reasons.includes(`${file}: conformance-tier reachability`),
|
||
`expected reasons to name the conformance-tier mechanism (not the removed blanket rule), got: ${JSON.stringify(result.reasons)}`);
|
||
});
|
||
|
||
// Row 4: the seam file itself always forces full_matrix — its own content
|
||
// carries genuine narrow platform signals (process-platform, raw-child-
|
||
// process, etc.), so this is the organic content-check path, not a special
|
||
// case in the code.
|
||
test('full_matrix true when the seam file itself changes', () => {
|
||
const result = scopeFor(['src/shell-command-projection.cts']);
|
||
assert.strictEqual(result.full_matrix, true,
|
||
`expected full_matrix=true for the seam file, got: ${JSON.stringify(result)}`);
|
||
assert.ok(result.reasons.includes('src/shell-command-projection.cts: platform seam reachability'),
|
||
`expected reasons to name platform seam reachability, got: ${JSON.stringify(result.reasons)}`);
|
||
});
|
||
|
||
// Rows 5-7: minimal constructed src/ fixtures (not a real, drifting file) —
|
||
// same temp-tree pattern as the '#837 three-dot diff' test above, but
|
||
// invoked via --files (no git commit needed).
|
||
test('full_matrix true for a non-seam src/ file with a genuine platform signal', () => {
|
||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-scope-4592-narrow-'));
|
||
try {
|
||
fs.mkdirSync(path.join(tmp, 'tests'), { recursive: true });
|
||
fs.mkdirSync(path.join(tmp, 'src'), { recursive: true });
|
||
const file = 'src/narrow-signal-fixture.cts';
|
||
fs.writeFileSync(path.join(tmp, file), "if (process.platform === 'win32') { doWindowsThing(); }\n");
|
||
|
||
const result = scopeForAt([file], tmp);
|
||
assert.strictEqual(result.full_matrix, true,
|
||
`expected full_matrix=true for a src/ file with a narrow platform signal, got: ${JSON.stringify(result)}`);
|
||
assert.ok(result.reasons.includes(`${file}: platform seam reachability`),
|
||
`expected reasons to name platform seam reachability, got: ${JSON.stringify(result.reasons)}`);
|
||
} finally {
|
||
cleanup(tmp);
|
||
}
|
||
});
|
||
|
||
test('full_matrix false for a src/ file with only noisy signals', () => {
|
||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-scope-4592-noisy-'));
|
||
try {
|
||
fs.mkdirSync(path.join(tmp, 'tests'), { recursive: true });
|
||
fs.mkdirSync(path.join(tmp, 'src'), { recursive: true });
|
||
const file = 'src/noisy-only-fixture.cts';
|
||
// Matches ONLY hardcoded-path-vs-path-call (path.join + a leading-slash
|
||
// literal) and symlink-keyword ("symlink") — no narrow signal at all.
|
||
fs.writeFileSync(
|
||
path.join(tmp, file),
|
||
"const p = path.join(root, 'x');\nassert.equal(rendered, '/etc/passwd');\n// see symlink handling elsewhere\n",
|
||
);
|
||
|
||
const result = scopeForAt([file], tmp);
|
||
assert.strictEqual(result.full_matrix, false,
|
||
`expected full_matrix=false for a src/ file with only noisy signals, got: ${JSON.stringify(result)}`);
|
||
} finally {
|
||
cleanup(tmp);
|
||
}
|
||
});
|
||
|
||
test('full_matrix false for a src/ file with no platform signal', () => {
|
||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-scope-4592-clean-'));
|
||
try {
|
||
fs.mkdirSync(path.join(tmp, 'tests'), { recursive: true });
|
||
fs.mkdirSync(path.join(tmp, 'src'), { recursive: true });
|
||
const file = 'src/no-signal-fixture.cts';
|
||
fs.writeFileSync(path.join(tmp, file), 'function add(a, b) { return a + b; }\n');
|
||
|
||
const result = scopeForAt([file], tmp);
|
||
assert.strictEqual(result.full_matrix, false,
|
||
`expected full_matrix=false for a src/ file with zero signals, got: ${JSON.stringify(result)}`);
|
||
} finally {
|
||
cleanup(tmp);
|
||
}
|
||
});
|
||
|
||
// Rows 8-10: the classifier's own definition files always fail-safe to
|
||
// full_matrix=true — a change to the classification mechanism itself
|
||
// cannot be presumed safe by the very mechanism being changed.
|
||
for (const file of [
|
||
'scripts/lib/platform-conformance-tier.generated.cjs',
|
||
'scripts/gen-platform-conformance-tier.cjs',
|
||
'scripts/lib/suite-detection.cjs',
|
||
]) {
|
||
test(`full_matrix true when ${file} itself changes`, () => {
|
||
const result = scopeFor([file]);
|
||
assert.strictEqual(result.full_matrix, true,
|
||
`expected full_matrix=true for classifier definition file ${file}, got: ${JSON.stringify(result)}`);
|
||
assert.ok(result.reasons.includes(`${file}: reachability classifier definition changed`),
|
||
`expected reasons to name the classifier-definition fail-safe, got: ${JSON.stringify(result.reasons)}`);
|
||
});
|
||
}
|
||
|
||
// Row 11: fail-safe when the generated tier module fails to load. Exercised
|
||
// in-process against the real, exported classify()/reachesConformanceTierOrSeam
|
||
// with an injected loader that throws — no real file is corrupted, and this
|
||
// proves the failure propagates all the way through classify() without an
|
||
// uncaught exception escaping it.
|
||
describe('full_matrix true when the generated tier module fails to load', () => {
|
||
const { classify, reachesConformanceTierOrSeam } = require('../scripts/ci-test-scope.cjs');
|
||
const throwingDeps = { loadConformanceTier: () => { throw new Error('simulated load failure'); } };
|
||
|
||
test('reachesConformanceTierOrSeam fails safe to true, does not throw', () => {
|
||
let result;
|
||
assert.doesNotThrow(() => {
|
||
result = reachesConformanceTierOrSeam('tests/some.test.cjs', throwingDeps);
|
||
});
|
||
assert.strictEqual(result, true);
|
||
});
|
||
|
||
test('classify() propagates the fail-safe without an uncaught exception', () => {
|
||
let result;
|
||
assert.doesNotThrow(() => {
|
||
result = classify(['tests/some.test.cjs'], throwingDeps);
|
||
});
|
||
assert.strictEqual(result.full_matrix, true,
|
||
`expected full_matrix=true when the tier module fails to load, got: ${JSON.stringify(result)}`);
|
||
});
|
||
});
|
||
|
||
// Row 13: pairing a conformance-tier test file with an inert-workflow-only
|
||
// file must not be overridden by the inert-CI normalization, since
|
||
// productOrPipelineChanged is already true for any tests/ path.
|
||
test('full_matrix stays true when a conformance-tier test file is paired with an inert workflow file', () => {
|
||
const { CONFORMANCE_TIER_FILES } = require('../scripts/lib/platform-conformance-tier.generated.cjs');
|
||
const tierFile = CONFORMANCE_TIER_FILES[0];
|
||
const result = scopeFor([tierFile, '.github/workflows/stale.yml']);
|
||
assert.strictEqual(result.full_matrix, true,
|
||
`expected full_matrix=true even paired with an inert workflow file, got: ${JSON.stringify(result)}`);
|
||
});
|
||
});
|
||
|
||
describe('#4641 windows lane removal: test-conformance becomes the sole Windows selector', () => {
|
||
const { classify } = require('../scripts/ci-test-scope.cjs');
|
||
|
||
// Case C: a changed test file that is not tagged in Phase 2's
|
||
// CONFORMANCE_TIER_FILES no longer needs to force a Windows run of its own —
|
||
// once the `test` job's `scope: windows` lane is deleted, the only Windows
|
||
// signal left is full_matrix (routed to test-conformance).
|
||
test('a non-tier test file no longer forces a Windows run (#4641)', () => {
|
||
const { CONFORMANCE_TIER_FILES } = require('../scripts/lib/platform-conformance-tier.generated.cjs');
|
||
const file = 'tests/some-brand-new-non-tier-4641.test.cjs';
|
||
assert.ok(!CONFORMANCE_TIER_FILES.includes(file), 'precondition: fixture path must not be tier-tagged');
|
||
const result = classify([file]);
|
||
assert.strictEqual(result.full_matrix, false,
|
||
`expected full_matrix=false for a non-conformance-tier test file, got: ${JSON.stringify(result)}`);
|
||
});
|
||
|
||
// Case D: post-#4641, classify() must not emit a `windows_tests` key at all —
|
||
// "absent" and "empty array" are different claims, and only the former
|
||
// matches a workflow with no windows_tests output to consume.
|
||
test('windows_tests is gone, not merely empty (#4641)', () => {
|
||
const result = classify(['tests/some-brand-new-non-tier-4641.test.cjs']);
|
||
assert.strictEqual(
|
||
Object.hasOwn(result, 'windows_tests'), false,
|
||
`expected classify() to return no windows_tests property at all, got keys: ${JSON.stringify(Object.keys(result))}`,
|
||
);
|
||
});
|
||
|
||
// Case E: the one non-redundant residue of the deleted windows lane — a
|
||
// RULE whose tests[] includes a filename matching isWindowsHint — must be
|
||
// preserved by escalating to full_matrix instead of a side lane.
|
||
// 'portability lint rules (ADR-1703)' pulls in
|
||
// tests/no-path-literal-in-assert.rule.test.cjs and
|
||
// tests/normalize-path-in-content.rule.test.cjs, both matching the 'path'
|
||
// hint in WINDOWS_HINTS, and today carries no fullMatrix of its own.
|
||
test('RULE-pulled windows-hint tests force full_matrix, not a side lane (#4641)', () => {
|
||
const file = 'eslint-rules/no-path-literal-in-assert.cjs';
|
||
const result = classify([file]);
|
||
assert.strictEqual(result.full_matrix, true,
|
||
`expected full_matrix=true because the matched rule pulls in a windows-hint test, got: ${JSON.stringify(result)}`);
|
||
assert.ok(
|
||
result.reasons.some(r => r.startsWith(`${file}: portability lint rules (ADR-1703)`)),
|
||
`expected reasons to name the windows-hint rule, got: ${JSON.stringify(result.reasons)}`,
|
||
);
|
||
});
|
||
|
||
// Case F: MUST-PASS pin, already covered by the "full_matrix true for a
|
||
// conformance-tier test file" test in the '#4592 reachability-based
|
||
// full_matrix classifier' describe block above — a changed test file that
|
||
// IS in CONFORMANCE_TIER_FILES still yields full_matrix=true. Restated here
|
||
// so the #4641 removal's regression surface is pinned in one place too.
|
||
test('a conformance-tier test file still yields full_matrix=true (#4641 pin)', () => {
|
||
const { CONFORMANCE_TIER_FILES } = require('../scripts/lib/platform-conformance-tier.generated.cjs');
|
||
assert.ok(CONFORMANCE_TIER_FILES.length > 0, 'precondition: committed tier list must be non-empty');
|
||
const file = CONFORMANCE_TIER_FILES[0];
|
||
const result = classify([file]);
|
||
assert.strictEqual(result.full_matrix, true,
|
||
`expected full_matrix=true for conformance-tier file ${file}, got: ${JSON.stringify(result)}`);
|
||
});
|
||
});
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-641-files-from-suite-token.test.cjs — consolidation epic #1969 (B6 #1975)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-641-files-from-suite-token (consolidation epic #1969 B6 #1975)", () => {
|
||
// Regression test for issue #641:
|
||
// `--files-from` with a bare suite token (e.g. "unit") crashes with
|
||
// "requested test file(s) not found: unit" instead of expanding the token
|
||
// to the matching suite's files.
|
||
//
|
||
// The bug: selectExplicitFiles() checked `available.has('unit')` against the
|
||
// set of *.test.cjs filenames. 'unit' is not a filename, so it landed in
|
||
// `missing` and caused exit 2. The fix teaches selectExplicitFiles() to
|
||
// delegate bare SUITES members to selectFiles() before the path-existence
|
||
// check.
|
||
'use strict';
|
||
|
||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||
const { runNode } = require('./helpers/process-seam.cjs');
|
||
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||
// Exported so the suite-token resolution contract below can be asserted
|
||
// in-process rather than through a timed subprocess spawn (see evidence
|
||
// comment above describe('bug #641 ...')).
|
||
const {
|
||
walkTestFiles,
|
||
selectExplicitFiles,
|
||
selectFiles,
|
||
parseArgs,
|
||
} = require('../scripts/run-tests.cjs');
|
||
|
||
const PASS_BODY = `'use strict';
|
||
const { test } = require('node:test');
|
||
test('noop', () => {});
|
||
`;
|
||
|
||
function seed(dir, names) {
|
||
for (const name of names) {
|
||
fs.writeFileSync(path.join(dir, name), PASS_BODY, 'utf8');
|
||
}
|
||
}
|
||
|
||
// Mirrors scripts/run-tests.cjs main()'s selection path (parseArgs ->
|
||
// walkTestFiles -> selectExplicitFiles/selectFiles) exactly, called
|
||
// in-process instead of through a spawned child that then spawns a nested
|
||
// `node --test`. See the evidence comment above describe('bug #641 ...').
|
||
function selectInProcess(testDir, args) {
|
||
const parsed = parseArgs(args);
|
||
if (parsed.error) return parsed;
|
||
const allFiles = walkTestFiles(testDir, '').sort();
|
||
const usingExplicitFiles = parsed.files !== null || parsed.filesFrom !== null;
|
||
if (usingExplicitFiles) {
|
||
return selectExplicitFiles(allFiles, parsed.files, parsed.filesFrom);
|
||
}
|
||
return { files: selectFiles(allFiles, parsed.suite) };
|
||
}
|
||
|
||
// Redesign evidence (2026-08-08): these probes originally spawned
|
||
// scripts/run-tests.cjs as a real child — which itself spawns a NESTED
|
||
// `node --test` — under a fixed PROBE_TIMEOUT_MS=15000 wall-clock budget,
|
||
// concurrently with the ~31k-test full suite running in the same container.
|
||
// On the remote runner this produced intermittent failures shaped
|
||
// `null !== 0` (r.status === null: the child was KILLED at the timeout),
|
||
// never a failed assertion about suite-token resolution. Reproduced on
|
||
// `next` alone (sha e705652ba): 5 failures on linux-node22, 0 failures on
|
||
// linux-node24. The victim subset varied by run and by lane, and the
|
||
// failure count went DOWN (7 -> 4 unique failures) as an unrelated diff got
|
||
// heavier — a resource collision, not a flake. The subject under test is
|
||
// suite-TOKEN RESOLUTION (parseArgs / selectExplicitFiles / selectFiles),
|
||
// not test execution; running the seeded trivial fixture files was
|
||
// incidental and was the entire timeout surface. These probes now call the
|
||
// exported selection functions in-process — removing the wall-clock budget
|
||
// around a nested spawn, not raising its number. Real end-to-end coverage of
|
||
// run-tests.cjs spawning and running to completion (exit 0 from a real
|
||
// harness run) already exists in tests/run-tests-harness.test.cjs (e.g. 'no
|
||
// flag runs ALL test files', '--suite unit excludes marked suites',
|
||
// 'non-zero from node:test propagates through harness'), so no execution
|
||
// coverage is lost by converting the "(tests run successfully)" probe below.
|
||
describe('bug #641 — --files-from with bare suite token', () => {
|
||
let tmpDir;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTempDir('msd-641-suite-token-');
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('--files-from with bare "unit" token expands to unit suite, does not exit 2', () => {
|
||
// Seed a mix: one unit file, one security file.
|
||
seed(tmpDir, ['a.test.cjs', 'b.security.test.cjs']);
|
||
const listPath = path.join(tmpDir, 'ci-selected-tests.txt');
|
||
fs.writeFileSync(listPath, 'unit\n', 'utf8');
|
||
|
||
const r = selectInProcess(tmpDir, ['--files-from', listPath]);
|
||
|
||
// Must NOT be the "not found" error shape (the old exit-2 crash).
|
||
assert.strictEqual(r.error, undefined, `Expected a resolved file list, got error: ${r.error}`);
|
||
// The unit suite file (a.test.cjs) must appear in the resolution.
|
||
assert.ok(
|
||
r.files.includes('a.test.cjs'),
|
||
`Expected a.test.cjs (unit suite) to be selected. Resolved: ${r.files}`,
|
||
);
|
||
// The security suite file must NOT be included (unit token = unit only).
|
||
assert.ok(
|
||
!r.files.includes('b.security.test.cjs'),
|
||
`Expected b.security.test.cjs (security suite) to be excluded. Resolved: ${r.files}`,
|
||
);
|
||
});
|
||
|
||
test('--files-from with bare "unit" token exits 0 (tests run successfully)', () => {
|
||
// "Exits 0" is determined entirely by selection succeeding with a
|
||
// non-empty list — the seeded fixture is a trivial no-op, so executing
|
||
// it contributes nothing this in-process call doesn't already prove.
|
||
// End-to-end execution coverage of run-tests.cjs lives in
|
||
// tests/run-tests-harness.test.cjs (see the evidence comment above).
|
||
seed(tmpDir, ['a.test.cjs']);
|
||
const listPath = path.join(tmpDir, 'ci-selected-tests.txt');
|
||
fs.writeFileSync(listPath, 'unit\n', 'utf8');
|
||
|
||
const r = selectInProcess(tmpDir, ['--files-from', listPath]);
|
||
|
||
assert.strictEqual(r.error, undefined, `Expected a resolved file list, got error: ${r.error}`);
|
||
assert.deepStrictEqual(r.files, ['a.test.cjs']);
|
||
});
|
||
|
||
test('--files with bare "unit" token also resolves correctly', () => {
|
||
seed(tmpDir, ['a.test.cjs', 'b.security.test.cjs']);
|
||
const r = selectInProcess(tmpDir, ['--files', 'unit']);
|
||
|
||
assert.strictEqual(r.error, undefined, `Expected exit 0, got error: ${r.error}`);
|
||
assert.ok(r.files.includes('a.test.cjs'), `a.test.cjs must be selected. Resolved: ${r.files}`);
|
||
assert.ok(!r.files.includes('b.security.test.cjs'), `security file must not be selected. Resolved: ${r.files}`);
|
||
});
|
||
|
||
test('mixed: suite token "unit" alongside an explicit file resolves both', () => {
|
||
seed(tmpDir, ['a.test.cjs', 'b.test.cjs', 'c.security.test.cjs']);
|
||
const listPath = path.join(tmpDir, 'ci-selected-tests.txt');
|
||
// 'unit' expands to [a.test.cjs, b.test.cjs]; b.test.cjs is explicit too.
|
||
fs.writeFileSync(listPath, 'unit\nb.test.cjs\n', 'utf8');
|
||
|
||
const r = selectInProcess(tmpDir, ['--files-from', listPath]);
|
||
|
||
assert.strictEqual(r.error, undefined, `Expected a resolved file list, got error: ${r.error}`);
|
||
// Both unit files present exactly once (the union dedupes them); security not.
|
||
assert.ok(r.files.includes('a.test.cjs'), `a.test.cjs must be selected. Resolved: ${r.files}`);
|
||
assert.ok(r.files.includes('b.test.cjs'), `b.test.cjs must be selected. Resolved: ${r.files}`);
|
||
assert.ok(!r.files.includes('c.security.test.cjs'), `c.security.test.cjs must be excluded. Resolved: ${r.files}`);
|
||
assert.strictEqual(r.files.length, 2, `expected no duplicate b.test.cjs. Resolved: ${r.files}`);
|
||
});
|
||
|
||
test('#408 fallback: ci-test-scope "unit" sentinel does not crash run-tests', () => {
|
||
// This test simulates the end-to-end #408 fallback path:
|
||
// ci-test-scope produces "unit" (the fallback sentinel for "code changed
|
||
// but no rule matched any test"), ci-prepare-test-scope writes it verbatim,
|
||
// and run-tests must resolve it rather than crash.
|
||
seed(tmpDir, ['a.test.cjs', 'b.security.test.cjs']);
|
||
// Simulate what ci-prepare-test-scope writes: "unit\n"
|
||
const listPath = path.join(tmpDir, '.ci-selected-tests.txt');
|
||
fs.writeFileSync(listPath, 'unit\n', 'utf8');
|
||
|
||
const r = selectInProcess(tmpDir, ['--files-from', listPath]);
|
||
|
||
assert.strictEqual(r.error, undefined, `#408 fallback: expected a resolved file list, got error: ${r.error}`);
|
||
assert.ok(r.files.includes('a.test.cjs'), `unit test must resolve. Resolved: ${r.files}`);
|
||
});
|
||
});
|
||
|
||
// Regression test for issue #1329:
|
||
// ci-prepare-test-scope's empty-detection FALLBACK hardcoded an explicit file
|
||
// list that included tests/core.test.cjs — a file deleted in #1291. Every
|
||
// scoped lane (scope=targeted|windows) that hit the fallback wrote the stale
|
||
// path into .ci-selected-tests.txt and crashed run-tests with
|
||
// "requested test file(s) not found: core.test.cjs". The fix: existence-filter
|
||
// the fallback at write time, fall back to the 'unit' suite sentinel when
|
||
// nothing survives, and guard the FALLBACK constant against disk reality.
|
||
describe('bug #1329 — ci-prepare-test-scope fallback never emits a deleted file', () => {
|
||
const { FALLBACK, FALLBACK_SENTINEL, SUITE_SENTINELS, resolveSelection } =
|
||
require('../scripts/ci-prepare-test-scope.cjs');
|
||
const REPO_ROOT = path.join(__dirname, '..');
|
||
|
||
// Generative parity guard (DEFECT.GENERATIVE-FIX): the FALLBACK constant and
|
||
// the test files on disk are two surfaces that must stay in sync. This fails
|
||
// the instant a refactor deletes a file still named in FALLBACK — which is
|
||
// precisely what #1291 did and CI did not catch.
|
||
test('every FALLBACK entry resolves on disk or is a known suite sentinel', () => {
|
||
for (const entry of FALLBACK) {
|
||
const isSentinel = SUITE_SENTINELS.includes(entry);
|
||
const exists = fs.existsSync(path.join(REPO_ROOT, entry));
|
||
assert.ok(
|
||
isSentinel || exists,
|
||
`FALLBACK entry "${entry}" is neither an existing test file nor a suite sentinel — stale reference will crash scoped CI lanes (see #1329).`,
|
||
);
|
||
}
|
||
});
|
||
|
||
let tmpDir;
|
||
beforeEach(() => {
|
||
tmpDir = createTempDir('msd-1329-fallback-');
|
||
fs.mkdirSync(path.join(tmpDir, 'tests'), { recursive: true });
|
||
});
|
||
afterEach(() => {
|
||
cleanup(tmpDir);
|
||
});
|
||
|
||
test('empty detection drops a non-existent fallback entry instead of emitting it', () => {
|
||
// Create all but the last FALLBACK file under a controlled root, simulating
|
||
// a since-deleted test (the #1329 mechanism), independent of which files
|
||
// FALLBACK happens to name today.
|
||
const present = FALLBACK.slice(0, -1);
|
||
const absent = FALLBACK[FALLBACK.length - 1];
|
||
for (const f of present) {
|
||
fs.writeFileSync(path.join(tmpDir, f), PASS_BODY, 'utf8');
|
||
}
|
||
|
||
const lines = resolveSelection({ scope: 'targeted', targeted: '', root: tmpDir });
|
||
|
||
assert.ok(!lines.includes(absent), `absent file "${absent}" must be filtered out, got: ${lines.join(', ')}`);
|
||
for (const f of present) {
|
||
assert.ok(lines.includes(f), `present file "${f}" must survive, got: ${lines.join(', ')}`);
|
||
}
|
||
});
|
||
|
||
test('empty detection with no surviving fallback files falls back to the unit sentinel', () => {
|
||
// tmpDir/tests exists but contains none of the FALLBACK files.
|
||
// #4641: this used to run under scope: 'windows'; that scope was retired
|
||
// with the windows CI lane. Re-pointed at 'targeted' (still empty-detected)
|
||
// to keep the fallback-sentinel contract covered.
|
||
const lines = resolveSelection({ scope: 'targeted', targeted: '', root: tmpDir });
|
||
assert.deepStrictEqual(lines, [FALLBACK_SENTINEL]);
|
||
});
|
||
|
||
test('resolveSelection rejects the retired windows scope (#4641)', () => {
|
||
// #4641: the `test` job's `scope: windows` lane was deleted (see
|
||
// docs/adr/4641-windows-selector-consolidation.md). Do not restore this
|
||
// scope — resolveSelection must now fail loudly for it rather than
|
||
// silently falling back.
|
||
assert.throws(
|
||
() => resolveSelection({ scope: 'windows', targeted: '', root: tmpDir }),
|
||
/Unknown test scope: windows/,
|
||
);
|
||
});
|
||
|
||
test('detected list passes through verbatim — files and suite sentinels preserved, not existence-filtered', () => {
|
||
// The detected list is already filtered by affected-tests-lib and may carry
|
||
// a suite sentinel; ci-prepare-test-scope must not touch it.
|
||
const lines = resolveSelection({
|
||
scope: 'targeted',
|
||
targeted: 'tests/does-not-exist.test.cjs unit',
|
||
root: tmpDir,
|
||
});
|
||
assert.deepStrictEqual(lines, ['tests/does-not-exist.test.cjs', 'unit']);
|
||
});
|
||
|
||
test('end-to-end: the real script writes a fallback list whose every entry resolves', () => {
|
||
// Run the real script (subprocess) with empty detection inside an isolated
|
||
// root that holds the FALLBACK files, then verify every line it wrote into
|
||
// .ci-selected-tests.txt resolves — the exact scoped-lane path that crashed
|
||
// in #1329. Hermetic: the temp root is removed by afterEach's cleanup().
|
||
for (const f of FALLBACK) {
|
||
fs.writeFileSync(path.join(tmpDir, f), PASS_BODY, 'utf8');
|
||
}
|
||
const prep = runNode(
|
||
[path.join(REPO_ROOT, 'scripts', 'ci-prepare-test-scope.cjs')],
|
||
{
|
||
cwd: tmpDir,
|
||
env: { ...process.env, TEST_SCOPE: 'targeted', TARGETED_TESTS: '' },
|
||
timeoutMs: PROBE_TIMEOUT_MS,
|
||
},
|
||
);
|
||
assert.strictEqual(prep.exitCode, 0, `prepare step failed: ${prep.stderr}`);
|
||
|
||
const selected = fs.readFileSync(path.join(tmpDir, '.ci-selected-tests.txt'), 'utf8');
|
||
for (const line of selected.split(/\r?\n/).filter(Boolean)) {
|
||
const isSentinel = SUITE_SENTINELS.includes(line);
|
||
assert.ok(
|
||
isSentinel || fs.existsSync(path.join(tmpDir, line)),
|
||
`selected entry "${line}" does not resolve — would crash run-tests (#1329)`,
|
||
);
|
||
}
|
||
assert.doesNotMatch(selected, /core\.test\.cjs/, 'deleted core.test.cjs must never be selected');
|
||
});
|
||
});
|
||
});
|
||
}
|