fix(#1329): existence-filter scoped-CI fallback so a deleted test can't crash the lane
ci-prepare-test-scope.cjs's empty-detection FALLBACK hardcoded tests/core.test.cjs, 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 full/sharded lanes glob the suite and were immune, so only the scoped lanes went red (e.g. run 27599149212 on #1308). Existence-filter the FALLBACK at write time and fall back to the 'unit' suite sentinel (the #408/#641 path, resolved live by run-tests) when nothing survives, so a stale reference degrades instead of crashing the lane. Detected lists still pass through verbatim (they may carry a suite sentinel and are already filtered by affected-tests-lib). Refactor to an exported, testable resolveSelection(). Add a generative parity guard (DEFECT.GENERATIVE-FIX) asserting every FALLBACK entry resolves on disk or is a known suite sentinel — it fails the instant a refactor deletes a listed file, which #1291 did and CI did not catch — plus resolveSelection unit tests and an end-to-end subprocess test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -147,3 +147,103 @@ describe('bug #641 — --files-from with bare suite token', () => {
|
||||
assert.ok(r.stderr.includes('a.test.cjs'), `unit test must run.\nstderr: ${r.stderr}`);
|
||||
});
|
||||
});
|
||||
|
||||
// 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('gsd-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: '', windows: '', 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.
|
||||
const lines = resolveSelection({ scope: 'windows', targeted: '', windows: '', root: tmpDir });
|
||||
assert.deepStrictEqual(lines, [FALLBACK_SENTINEL]);
|
||||
});
|
||||
|
||||
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',
|
||||
windows: '',
|
||||
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 = spawnSync(
|
||||
process.execPath,
|
||||
[path.join(REPO_ROOT, 'scripts', 'ci-prepare-test-scope.cjs')],
|
||||
{ cwd: tmpDir, env: { ...process.env, TEST_SCOPE: 'targeted', TARGETED_TESTS: '', WINDOWS_TESTS: '' }, encoding: 'utf8' },
|
||||
);
|
||||
assert.strictEqual(prep.status, 0, `prepare step failed: ${prep.stderr}`);
|
||||
|
||||
const selected = fs.readFileSync(path.join(tmpDir, '.ci-selected-tests.txt'), 'utf8');
|
||||
for (const line of selected.split('\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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user