diff --git a/scripts/ci-prepare-test-scope.cjs b/scripts/ci-prepare-test-scope.cjs index 66309db1a..90d5aef50 100644 --- a/scripts/ci-prepare-test-scope.cjs +++ b/scripts/ci-prepare-test-scope.cjs @@ -16,13 +16,37 @@ const path = require('path'); const { ExitError, runMain } = require('./lib/cli-exit.cjs'); -const scope = process.env.TEST_SCOPE || ''; -const targeted = process.env.TARGETED_TESTS || ''; -const windows = process.env.WINDOWS_TESTS || ''; +// Suite sentinels understood by run-tests.cjs (scripts/run-tests.cjs SUITES). +// A sentinel resolves to its live file set at run time, so — unlike an explicit +// filename — it can never reference a since-deleted test file. +const SUITE_SENTINELS = ['all', 'unit', 'integration', 'install', 'security', 'slow']; -const FALLBACK = 'tests/command-contract.test.cjs tests/commands.test.cjs tests/core.test.cjs tests/package-manifest.test.cjs'; +// Fast smoke set used when scope detection yields no targeted tests. Each entry +// MUST resolve via run-tests.cjs: an existing repo-relative test file or a +// SUITE_SENTINELS token. A stale filename here (a test deleted by a refactor) +// is exactly what broke CI in #1329 — `tests/core.test.cjs`, deleted in #1291, +// was still listed and crashed every scoped lane that hit this fallback. The +// parity guard in tests/bug-641-files-from-suite-token.test.cjs fails the +// moment an entry stops resolving; resolveSelection() filters at write time so +// a stale entry degrades instead of crashing the lane. +const FALLBACK = [ + 'tests/command-contract.test.cjs', + 'tests/commands.test.cjs', + 'tests/package-manifest.test.cjs', +]; -function main() { +// Last-resort selector when every FALLBACK entry has been deleted: the whole +// unit suite, resolved live by run-tests.cjs (the #408/#641 sentinel path). +const FALLBACK_SENTINEL = 'unit'; + +// An entry is runnable if it is a suite sentinel or an existing file under root. +function isResolvable(entry, root) { + return SUITE_SENTINELS.includes(entry) || fs.existsSync(path.join(root, entry)); +} + +// Resolve the scoped test selection for the lane. Pure (no I/O beyond the +// existence probe under `root`) so it can be unit-tested directly. +function resolveSelection({ scope, targeted, windows, root }) { let selected; if (scope === 'windows') { selected = windows; @@ -32,20 +56,38 @@ function main() { throw new ExitError(1, `::error::Unknown test scope: ${scope}`); } - // Trim and fall back to default set if empty. - if (!selected.trim()) { - selected = FALLBACK; + // Detected list passes through verbatim: affected-tests-lib.cjs already filters + // deleted files, and the list may legitimately carry a suite sentinel. + const detected = (selected || '').split(/\s+/).filter(Boolean); + if (detected.length > 0) { + return detected; } - // Split on whitespace, filter blanks, join with newlines. - const lines = selected.split(/\s+/).filter(Boolean); - const content = lines.join('\n') + '\n'; + // Empty detection → smoke fallback, existence-filtered so a stale entry can + // never crash the scoped lane (#1329). If nothing survives, use the unit + // sentinel, which run-tests.cjs always resolves. + const survivors = FALLBACK.filter((f) => isResolvable(f, root)); + return survivors.length > 0 ? survivors : [FALLBACK_SENTINEL]; +} - const outPath = path.join(process.cwd(), '.ci-selected-tests.txt'); - fs.writeFileSync(outPath, content, 'utf-8'); +function main() { + const root = process.cwd(); + const lines = resolveSelection({ + scope: process.env.TEST_SCOPE || '', + targeted: process.env.TARGETED_TESTS || '', + windows: process.env.WINDOWS_TESTS || '', + root, + }); + + const content = lines.join('\n') + '\n'; + fs.writeFileSync(path.join(root, '.ci-selected-tests.txt'), content, 'utf-8'); process.stdout.write('Scoped tests:\n'); process.stdout.write(content); } -runMain(main); +if (require.main === module) { + runMain(main); +} + +module.exports = { FALLBACK, FALLBACK_SENTINEL, SUITE_SENTINELS, resolveSelection, main }; diff --git a/tests/bug-641-files-from-suite-token.test.cjs b/tests/bug-641-files-from-suite-token.test.cjs index 25462e129..a427fc527 100644 --- a/tests/bug-641-files-from-suite-token.test.cjs +++ b/tests/bug-641-files-from-suite-token.test.cjs @@ -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'); + }); +});