Files
msd-core/scripts/run-tests.cjs
Tom Boucher 4698b3e349 fix(#1051): force-exit + per-chunk timeout for the windows full-test lane; close leaked test handles (#1054)
The `full test (windows-latest, 22)` job intermittently got CANCELLED at its
20m wall-clock cap with no failed test step — a false-negative gate (recurrence
of #869). Root cause: a unit test leaves an open event-loop handle, so the
chunk's `node --test` child hangs ~150s on Windows after its last test prints;
two such stalls push the already-~13m job past 20m.

Fix (defense in depth):
- run-tests.cjs: pass --test-force-exit (Node >=22; engines requires >=22.0.0)
  so the runner exits once all tests finish regardless of lingering handles —
  the durable backstop. Account for the flag in the argv-length ceiling.
- run-tests.cjs: add a per-chunk execFileSync timeout (default 600000ms, env
  RUN_TESTS_CHUNK_TIMEOUT_MS) that fails loudly with a diagnostic naming the
  chunk's files, so a hung chunk can never silently eat the job budget.
- perf-316 test: terminate both Worker threads on all paths (afterEach +
  finally) so they cannot outlive the test.
- locking-bugs test: kill spawned children in a finally that wraps the whole
  spawn -> waitFor -> barrier-release -> Promise.all sequence, so a barrier
  timeout no longer leaks live child processes.
- Refresh the stale synckit comment (synckit/SDK bridge was removed).

Regression tests in run-tests-harness: a hung chunk hits the per-chunk timeout
and fails with a clear message; force-exit lets a chunk with a leaked handle
exit cleanly.

Closes #1051

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 14:11:50 -04:00

428 lines
17 KiB
JavaScript

#!/usr/bin/env node
// Cross-platform test runner — resolves test file globs via Node
// instead of relying on shell expansion (which fails on Windows PowerShell/cmd).
// Propagates NODE_V8_COVERAGE so c8 collects coverage from the child process.
//
// Suite filtering (issue #3597):
// node scripts/run-tests.cjs # default — runs ALL tests (backcompat)
// node scripts/run-tests.cjs --suite all # explicit "everything"
// node scripts/run-tests.cjs --suite unit # only files with no other suite marker
// node scripts/run-tests.cjs --suite security # *.security.test.cjs
// node scripts/run-tests.cjs --suite integration # *.integration.test.cjs
// node scripts/run-tests.cjs --suite install # *.install.test.cjs
// node scripts/run-tests.cjs --suite slow # *.slow.test.cjs
// node scripts/run-tests.cjs --files "a.test.cjs b.test.cjs"
// node scripts/run-tests.cjs --files-from /tmp/selected-tests.txt
//
// Suite grouping convention: filename suffix marker before `.test.cjs`.
// A file named `foo.security.test.cjs` belongs to the `security` suite.
// A file named `foo.test.cjs` (no marker) belongs to the `unit` suite.
// See docs/TESTING-SUITES.md for full grouping policy.
'use strict';
const { readdirSync } = require('fs');
const { join } = require('path');
const { execFileSync } = require('child_process');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const SUITES = ['all', 'unit', 'integration', 'install', 'security', 'slow'];
// ADR-457 build-at-publish: gsd-core/bin/lib/*.cjs is generated from
// src/*.cts and gitignored, so on a clean checkout (fresh CI, before any build)
// the artifact is absent — yet test files require it. This is the universal
// chokepoint every test path funnels through (test:unit, --files-from, direct
// invocation), so build the artifact here.
//
// Strategy (incremental + re-emit-on-missing, closes both #969 failure modes):
// 1. Run tsc incrementally (fast ~380ms no-op when sources unchanged).
// 2. Verify every src/*.cts (non-.d.cts) maps to a non-empty gsd-core/bin/lib/*.cjs.
// 3. If any expected .cjs is missing or zero-bytes (persistent-mirror scenario:
// tsc no-ops because tsbuildinfo looks current even though the file was deleted),
// delete the tsbuildinfo and run tsc ONCE MORE (clean re-emit), then re-verify.
//
// Common case: fast incremental no-op. Stale/deleted-output case: detected by
// the cheap existsSync loop and force-rebuilt. Paths resolve from __dirname so
// it works regardless of GSD_TEST_DIR / temp-dir cwd.
function ensureBuiltArtifacts(overrides = {}) {
const { existsSync, readdirSync, statSync, unlinkSync } = require('fs');
const root = overrides.root || join(__dirname, '..');
const srcDir = overrides.srcDir || join(root, 'src');
const outDir = overrides.outDir || join(root, 'gsd-core', 'bin', 'lib');
const tsBuildInfoPath = overrides.tsBuildInfoPath || join(root, 'tsconfig.build.tsbuildinfo');
const tsconfigPath = overrides.tsconfigPath || join(root, 'tsconfig.build.json');
const tscBin = require.resolve('typescript/bin/tsc');
const tscArgs = [tscBin, '-p', tsconfigPath];
// Build the 1:1 map of expected output paths from src/*.cts sources.
// Excludes *.d.cts (declaration-only files that produce no output).
// Handles subdirectories (e.g. src/installer-migrations/*.cts → gsd-core/bin/lib/installer-migrations/*.cjs).
function gatherExpectedOutputs() {
const expected = [];
function scan(dir, relBase) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
scan(join(dir, entry.name), relBase ? `${relBase}/${entry.name}` : entry.name);
} else if (entry.name.endsWith('.cts') && !entry.name.endsWith('.d.cts')) {
const stem = entry.name.slice(0, -'.cts'.length);
const rel = relBase ? `${relBase}/${stem}.cjs` : `${stem}.cjs`;
expected.push(join(outDir, rel));
}
}
}
scan(srcDir, '');
return expected;
}
function checkMissingOutputs(expectedPaths) {
return expectedPaths.filter(p => !existsSync(p) || statSync(p).size === 0);
}
// #996 placed the tsbuildinfo inside gsd-core/bin/ (a copied/shipped tree), which
// raced install-test copies. It now lives at the repo root. Best-effort purge any
// stale bin-local copy so persistent workspaces/mirrors self-heal (no-op on a temp
// override root or a clean checkout).
const legacyTsBuildInfo = join(root, 'gsd-core', 'bin', 'tsconfig.build.tsbuildinfo');
try { if (existsSync(legacyTsBuildInfo)) unlinkSync(legacyTsBuildInfo); } catch { /* best-effort */ }
// Step 1: incremental build (fast no-op when sources unchanged).
execFileSync(process.execPath, tscArgs, { cwd: root, stdio: 'inherit' });
// Step 2: verify expected outputs.
const expected = gatherExpectedOutputs();
const missing = checkMissingOutputs(expected);
// Step 3: if any output is missing/zero-bytes, force a clean re-emit.
// This handles the persistent-mirror case where tsc's incremental no-op left
// a deleted .cjs unregenerated (tsbuildinfo recorded it as up-to-date).
if (missing.length > 0) {
if (existsSync(tsBuildInfoPath)) {
unlinkSync(tsBuildInfoPath);
}
execFileSync(process.execPath, tscArgs, { cwd: root, stdio: 'inherit' });
// Re-verify after clean re-emit; surface any remaining gaps loudly.
const stillMissing = checkMissingOutputs(expected);
if (stillMissing.length > 0) {
const names = stillMissing.map(p => require('path').basename(p)).join(', ');
throw new Error(
`ensureBuiltArtifacts: tsc clean re-emit still missing outputs: ${names}. ` +
`Check src/ for compilation errors.`
);
}
}
}
const MARKED_SUITES = ['integration', 'install', 'security', 'slow'];
function parseArgs(argv) {
let suite = null;
let seen = false;
let files = null;
let filesFrom = null;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--suite') {
if (seen) {
return { error: 'duplicate --suite flag' };
}
seen = true;
const v = argv[i + 1];
if (!v || v.startsWith('--')) {
return { error: '--suite requires a value' };
}
suite = v;
i++;
} else if (a.startsWith('--suite=')) {
if (seen) {
return { error: 'duplicate --suite flag' };
}
seen = true;
suite = a.slice('--suite='.length);
if (!suite) {
return { error: '--suite requires a value' };
}
} else if (a === '--files') {
if (files !== null) {
return { error: 'duplicate --files flag' };
}
const v = argv[i + 1];
if (!v || v.startsWith('--')) {
return { error: '--files requires a value' };
}
files = v;
i++;
} else if (a.startsWith('--files=')) {
if (files !== null) {
return { error: 'duplicate --files flag' };
}
files = a.slice('--files='.length);
if (!files) {
return { error: '--files requires a value' };
}
} else if (a === '--files-from') {
if (filesFrom !== null) {
return { error: 'duplicate --files-from flag' };
}
const v = argv[i + 1];
if (!v || v.startsWith('--')) {
return { error: '--files-from requires a value' };
}
filesFrom = v;
i++;
} else if (a.startsWith('--files-from=')) {
if (filesFrom !== null) {
return { error: 'duplicate --files-from flag' };
}
filesFrom = a.slice('--files-from='.length);
if (!filesFrom) {
return { error: '--files-from requires a value' };
}
} else {
return { error: `unknown argument: ${a}` };
}
}
if (files !== null && filesFrom !== null) {
return { error: '--files and --files-from cannot be combined' };
}
return { suite, files, filesFrom };
}
// Return the marked suite name embedded in a filename, or null if it's unmarked.
// foo.security.test.cjs -> "security"
// foo.test.cjs -> null (unit)
function suiteOf(filename) {
if (!filename.endsWith('.test.cjs')) return null;
const base = filename.slice(0, -'.test.cjs'.length);
const lastDot = base.lastIndexOf('.');
if (lastDot === -1) return null;
const marker = base.slice(lastDot + 1);
return MARKED_SUITES.includes(marker) ? marker : null;
}
function selectFiles(allFiles, suite) {
if (suite === null || suite === 'all') {
return allFiles;
}
if (suite === 'unit') {
return allFiles.filter(f => suiteOf(f) === null);
}
return allFiles.filter(f => suiteOf(f) === suite);
}
function splitFileList(value) {
if (!value) return [];
return value
.split(/[,\s]+/)
.map(v => v.trim())
.filter(Boolean)
.map(v => v.replace(/^tests[\\/]/, ''));
}
function selectExplicitFiles(allFiles, filesValue, filesFrom) {
const fs = require('fs');
const requested = filesFrom
? splitFileList(fs.readFileSync(filesFrom, 'utf8'))
: splitFileList(filesValue);
const available = new Set(allFiles);
const selected = [];
const missing = [];
for (const file of requested) {
// If the token is a bare suite name (e.g. "unit" written by ci-test-scope
// as the #408 fallback sentinel), delegate to the existing suite resolver
// rather than treating it as a filename. This prevents the
// "requested test file(s) not found: unit" crash (#641).
if (SUITES.includes(file)) {
for (const f of selectFiles(allFiles, file)) {
selected.push(f);
}
} else if (available.has(file)) {
selected.push(file);
} else {
missing.push(file);
}
}
if (missing.length > 0) {
return {
error: `requested test file(s) not found: ${missing.join(', ')}`,
};
}
return { files: [...new Set(selected)] };
}
function main() {
const args = process.argv.slice(2);
const parsed = parseArgs(args);
if (parsed.error) {
console.error(`run-tests: ${parsed.error}`);
console.error(`Valid suites: ${SUITES.join(', ')}`);
throw new ExitError(2);
}
const suite = parsed.suite;
if (suite !== null && !SUITES.includes(suite)) {
console.error(`run-tests: unknown suite "${suite}"`);
console.error(`Valid suites: ${SUITES.join(', ')}`);
throw new ExitError(2);
}
const testDir = process.env.GSD_TEST_DIR
? process.env.GSD_TEST_DIR
: join(__dirname, '..', 'tests');
const allFiles = readdirSync(testDir)
.filter(f => f.endsWith('.test.cjs'))
.sort();
if (allFiles.length === 0) {
console.error(`No test files found in ${testDir}`);
throw new ExitError(1);
}
let selectedNames;
if (parsed.files !== null || parsed.filesFrom !== null) {
const explicit = selectExplicitFiles(allFiles, parsed.files, parsed.filesFrom);
if (explicit.error) {
console.error(`run-tests: ${explicit.error}`);
throw new ExitError(2);
}
selectedNames = explicit.files;
} else {
selectedNames = selectFiles(allFiles, suite);
}
const selected = selectedNames.map(f => join(testDir, f));
if (selected.length === 0) {
// Empty suite: report and exit 0 so empty lanes (e.g. `security` before
// adversarial tests land) don't gate CI. CI consumers wanting strictness
// can grep stderr for "no tests in suite".
console.error(`run-tests: no tests in suite "${suite || 'all'}"`);
return 0;
}
// Build the gitignored bin/lib artifact if absent, before any test requires it.
ensureBuiltArtifacts();
// Hermeticity: in-process tests resolve `.planning` via planningDir(cwd), which
// honours GSD_PROJECT/GSD_WORKSTREAM. A developer shell inside a GSD workstream
// exports GSD_WORKSTREAM, which would redirect fixture STATE.md reads away from
// each <tmp>/.planning and silently diverge from the clean CI/Docker env. Strip
// them so the local runner matches CI; tests that need them set them explicitly.
delete process.env.GSD_PROJECT;
delete process.env.GSD_WORKSTREAM;
// Log selected files to stderr for CI / harness-test visibility.
// node:test default reporter doesn't echo filenames, so this gives
// operators a single stable line they can grep.
console.error(
`run-tests: suite="${suite || 'all'}" files=${selected.length}: ${selected
.map(f => f.split(/[\\/]/).pop())
.join(' ')}`,
);
// Default concurrency: 4 on Linux/macOS, 2 on Windows.
//
// Windows has significantly higher per-subprocess overhead than Linux/macOS:
// - Windows Defender scans each spawned process on first execution, adding
// latency proportional to the number of concurrent spawns.
// - NTFS has higher file-system latency under concurrent access compared to
// ext4/APFS, which amplifies contention when multiple test chunks run in
// parallel and all read/write the same fixture directories.
// Reducing to 2 halves the peak concurrent subprocess count on Windows and
// keeps per-chunk wall-clock time well within the 20m CI job cap.
//
// Operator override via TEST_CONCURRENCY env var for local debugging.
const defaultConcurrency = process.platform === 'win32' ? 2 : 4;
const concurrency = process.env.TEST_CONCURRENCY
? `--test-concurrency=${process.env.TEST_CONCURRENCY}`
: `--test-concurrency=${defaultConcurrency}`;
// Windows `CreateProcess` caps the full command line at 32,767 chars
// (lpCommandLine). With 500+ test paths the spawn fails instantly with no
// test output. Linux/macOS allow ~2 MB (ARG_MAX) so unchunked spawns are
// fine there. Split into chunks sized for the tightest target so behavior
// is identical across platforms. (#3597)
// Operator override (also used by tests to force chunking with short paths).
const MAX_CMDLINE_CHARS = process.env.RUN_TESTS_MAX_CMDLINE_CHARS
? Number(process.env.RUN_TESTS_MAX_CMDLINE_CHARS)
: 28000; // headroom below the 32,767 Windows ceiling
// node:test does not exit until the event loop drains. A unit test that leaks
// an open handle (un-terminated Worker, un-killed child_process, ref'd timer)
// makes a chunk's `node --test` child hang ~150s on Windows AFTER its last test
// prints; two such stalls push the windows full lane past its 20m cap and the
// job is CANCELLED with no failed step — a false-negative gate (#1051, recurrence
// of #869). --test-force-exit (Node >=22; engines requires >=22.0.0) exits the
// runner once all tests finish regardless of lingering handles. The leaking
// tests are also fixed at the source; this is the defensive backstop.
// RUN_TESTS_NO_FORCE_EXIT=1 disables it (used by the harness regression test to
// observe the pre-fix hang).
const nodeMajor = Number(process.versions.node.split('.')[0]);
const forceExit = nodeMajor >= 22 && !process.env.RUN_TESTS_NO_FORCE_EXIT;
const FIXED_OVERHEAD = process.execPath.length + '--test'.length + concurrency.length + (forceExit ? '--test-force-exit'.length + 1 : 0) + 8;
const chunks = [];
let current = [];
let currentLen = FIXED_OVERHEAD;
for (const file of selected) {
const add = file.length + 1; // +1 for the inter-arg separator
if (current.length > 0 && currentLen + add > MAX_CMDLINE_CHARS) {
chunks.push(current);
current = [];
currentLen = FIXED_OVERHEAD;
}
current.push(file);
currentLen += add;
}
if (current.length > 0) chunks.push(current);
// A chunk that still hangs (a leak the backstop somehow misses, or a wedged
// subprocess) must fail loudly rather than silently burn the job's wall-clock
// budget until the CI runner cancels the whole job. Default 10 min per chunk:
// well above a healthy chunk (~4-5 min on the windows lane) but below the 20m
// job cap. Operator/test override via RUN_TESTS_CHUNK_TIMEOUT_MS.
const chunkTimeoutMs = process.env.RUN_TESTS_CHUNK_TIMEOUT_MS
? Number(process.env.RUN_TESTS_CHUNK_TIMEOUT_MS)
: 600000;
let firstFailureExit = 0;
for (let i = 0; i < chunks.length; i++) {
if (chunks.length > 1) {
console.error(`run-tests: chunk ${i + 1}/${chunks.length} — ${chunks[i].length} files`);
}
try {
execFileSync(
process.execPath,
['--test', ...(forceExit ? ['--test-force-exit'] : []), concurrency, ...chunks[i]],
{
stdio: 'inherit',
env: { ...process.env },
timeout: chunkTimeoutMs,
},
);
} catch (err) {
// When the per-chunk timeout fires, execFileSync kills the child and
// surfaces it as err.code === 'ETIMEDOUT' (POSIX) and/or err.killed === true
// (platform-dependent). Check both so detection holds on Windows and POSIX.
const timedOut = err.killed === true || err.code === 'ETIMEDOUT';
if (timedOut) {
console.error(
`run-tests: chunk ${i + 1}/${chunks.length} exceeded the per-chunk timeout ` +
`of ${chunkTimeoutMs}ms and was killed — a test in this chunk is likely leaking ` +
`an open handle (un-terminated Worker, un-killed child process, or ref'd timer) ` +
`so node --test never exits. Files: ${chunks[i]
.map(f => f.split(/[\\/]/).pop())
.join(' ')}`,
);
}
const code = err.status || 1;
// Run every chunk so the operator sees all failures in one pass; report
// the first non-zero exit at the end.
if (firstFailureExit === 0) firstFailureExit = code;
}
}
if (firstFailureExit !== 0) return firstFailureExit;
}
if (require.main === module) {
runMain(main);
}
module.exports = { suiteOf, ensureBuiltArtifacts };