Files
msd-core/tests/run-tests-temp-root.test.cjs
Tom Boucher ccb39aec15 test(#4528): migrate final seam-dispatch batch and retire the timeout-literal allowlist (#4684)
Batch 17 of 17 — the terminal batch — in the ad hoc timeout literal
migration (epic #4445). Replaces every bare numeric timeout/timeoutMs
object-literal property in tests/cjs-command-router-adapter.test.cjs,
tests/dispatcher.test.cjs, tests/run-tests-temp-root.test.cjs, and
tests/shell-command-projection-dispatch.test.cjs with a named constant,
per eslint-rules/no-adhoc-timeout-literal.cjs. No src/bin file touched,
no numeric value changed anywhere.

Eslint ground truth (9 sites) matches the issue's own stated count
exactly for the first time in this epic — no drift to disclose.

Reuses PROBE_TIMEOUT_MS (1 site) and QUICK_SPAWN_TIMEOUT_MS (1 site).
Adds four file-local constants for shapes with no existing match:
RUN_TESTS_ISOLATED_PROBE_TIMEOUT_MS and RUN_TESTS_HARNESS_SPAWN_TIMEOUT_MS
(run-tests-temp-root.test.cjs, distinguishing a `node -e` isolated
function call from a real end-to-end spawn of the test runner itself,
despite each coinciding numerically with an unrelated existing
constant), and EXEC_TOOL_OPTION_PASSTHROUGH_TIMEOUT_MS and
DISPATCH_FORCED_TIMEOUT_MS (shell-command-projection-dispatch.test.cjs
— a mocked-spawnSync pass-through fixture and a deliberately-forced
real timeout, neither a real subprocess bound in the usual sense).

Terminal-batch cleanup: deletes
eslint-rules/no-adhoc-timeout-literal.allowlist.json entirely, drops
its require and the allowlist option from eslint.config.mjs's
local/no-adhoc-timeout-literal registration (now a bare 'error',
mirroring local/no-unbounded-spawn's own already-terminal
configuration in the same file), and updates TESTING-STANDARDS.md's
enforcement note to match — a stale pointer to the deleted file caught
by review, fixed inline. A full-repo eslint run with no cache confirms
zero violations anywhere in the tree under the now allowlist-free rule.

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 03:44:51 -04:00

323 lines
17 KiB
JavaScript

'use strict';
/**
* #4020 — the runner's run-scoped temp root.
*
* Fixture trees leak under os.tmpdir() on the success path; on a tmpfs /tmp a full
* `npm test` exhausts the filesystem and the failure surfaces as misleading EDQUOT
* (-122) copyfile errors in whichever suite runs next. The fix bounds the run: a
* dedicated `gsd-test-run-*` root repointed via TMPDIR (so every child's
* mkdtempSync(os.tmpdir()) lands inside it), a sweep between chunks that spares the
* two reserved sandboxes, a leak-count fail-fast, and removal on exit.
*
* Unit rows exercise the runner's exported helpers in-process (the harness
* convention); the spawn row drives the real CLI.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runNode } = require('./helpers/process-seam.cjs');
const { createTempDir, cleanup } = require('./helpers.cjs');
const RUNNER = path.join(__dirname, '..', 'scripts', 'run-tests.cjs');
/**
* A `node -e` snippet that requires `scripts/run-tests.cjs` and calls one of
* its exported functions in an isolated child process, purely so the call
* doesn't poison the PARENT process's own os.tmpdir() resolution for every
* other subtest. No subprocess fan-out beneath the single node child.
* Coincides numerically with GENERATOR_SCRIPT_TIMEOUT_MS, BUILD_TIMEOUT_MS,
* and others in tests/helpers/timeouts.cjs, none of which describe this
* shape (scripts/run-tests.cjs is the test runner itself, not a generator/
* lint script, a hooks bundle, or a git plumbing call) -- kept file-local
* and separately named rather than forced onto an unrelated shared norm.
*/
const RUN_TESTS_ISOLATED_PROBE_TIMEOUT_MS = 30000;
/**
* A REAL end-to-end spawn of scripts/run-tests.cjs itself, running one
* trivial test file through its full lifecycle (temp-root setup, discovery,
* execution, sweep, teardown) -- heavier than
* RUN_TESTS_ISOLATED_PROBE_TIMEOUT_MS above, which only calls one exported
* function in isolation. Coincides numerically with INSTALL_TIMEOUT_MS (a
* full bin/install.js run) but describes a completely unrelated operation
* -- disclosed, not merged.
*/
const RUN_TESTS_HARNESS_SPAWN_TIMEOUT_MS = 120000;
describe('#4020 — run-tests temp root', () => {
// setupRunTempRoot mutates the PROCESS env (TMPDIR/TEMP/TMP), so these rows
// drive it in an isolated child — an in-process call would poison every other
// subtest's os.tmpdir() resolution (observed: ENOENT cascades in the bench).
const setupProbe = `
const runner = require(${JSON.stringify(path.join(__dirname, '..', 'scripts', 'run-tests.cjs'))});
const root = runner.setupRunTempRoot();
console.log(JSON.stringify({
root,
base: require('path').basename(root),
parent: require('path').dirname(root),
TMPDIR: process.env.TMPDIR, TEMP: process.env.TEMP, TMP: process.env.TMP,
}));
`;
test('setupRunTempRoot creates a dedicated run temp root and repoints the env', (t) => {
const outer = createTempDir('gsd-4020-outer-');
t.after(() => cleanup(outer));
const r = runNode(['-e', setupProbe], {
timeoutMs: RUN_TESTS_ISOLATED_PROBE_TIMEOUT_MS,
env: { ...process.env, TMPDIR: outer, TEMP: outer, TMP: outer },
});
assert.equal(r.exitCode, 0, `probe failed: ${r.stderr.slice(-300)}`);
const out = JSON.parse(r.stdout.trim().split(/\n/).pop());
assert.ok(out.base.startsWith('gsd-test-run-'),
'the root is a gsd-test-run-* directory, so a sweep is scoped to it');
assert.equal(out.parent, outer,
'the root lives INSIDE the operator-provided TMPDIR, preserving the workaround');
assert.equal(out.TMPDIR, out.root, 'TMPDIR is repointed so children allocate inside the root');
assert.equal(out.TEMP, out.root, 'TEMP repointed (Windows children read TEMP, not TMPDIR)');
assert.equal(out.TMP, out.root, 'TMP repointed (Windows fallback)');
cleanup(out.root);
});
test('setupRunTempRoot is idempotent for nested run-tests spawns', (t) => {
// Mirrors the GSD_HOME sandbox contract: a nested run-tests spawn (the harness
// regression test) inherits the root via env and must REUSE it, never mkdtemp a
// fresh root per invocation.
const probe = setupProbe.replace('const root = runner.setupRunTempRoot();',
'const first = runner.setupRunTempRoot(); const root = runner.setupRunTempRoot(); console.error(JSON.stringify({ reuse: root === first }));');
const outer = createTempDir('gsd-4020-idem-');
t.after(() => cleanup(outer));
const r = runNode(['-e', probe], {
timeoutMs: RUN_TESTS_ISOLATED_PROBE_TIMEOUT_MS,
env: { ...process.env, TMPDIR: outer, TEMP: outer, TMP: outer },
});
assert.equal(r.exitCode, 0, `probe failed: ${r.stderr.slice(-300)}`);
assert.ok(JSON.parse(r.stderr.trim().split('\n').pop()).reuse === true,
'a second invocation with the root active reuses it');
});
test('sweepRunTempRoot removes leaked fixtures and spares the reserved sandboxes', (t) => {
const runner = require('../scripts/run-tests.cjs');
assert.equal(typeof runner.sweepRunTempRoot, 'function',
'the runner must export sweepRunTempRoot for in-process verification');
const root = createTempDir('gsd-test-run-sweep-');
t.after(() => cleanup(root));
for (const name of ['gsd-2930-overlay-a', 'gsd-slurm-b', 'spec-section-c', 'unprefixed-d']) {
fs.mkdirSync(path.join(root, name));
}
fs.mkdirSync(path.join(root, 'gsd-test-home-keep'));
fs.writeFileSync(path.join(root, 'gsd-run-tests-events-keep'), '');
const removed = runner.sweepRunTempRoot(root);
assert.equal(removed, 4, 'exactly the four leaked fixture entries are removed');
for (const name of ['gsd-2930-overlay-a', 'gsd-slurm-b', 'spec-section-c', 'unprefixed-d']) {
assert.ok(!fs.existsSync(path.join(root, name)), `${name} removed`);
}
assert.ok(fs.existsSync(path.join(root, 'gsd-test-home-keep')),
'the GSD_HOME sandbox survives the sweep (nested-spawn reuse contract)');
assert.ok(fs.existsSync(path.join(root, 'gsd-run-tests-events-keep')),
'the events dir survives the sweep (timeout diagnostics)');
// #4020 CI fix: ancestors of the runner's own selected files must survive —
// the harness stages synthetic test files under the temp root and later
// chunks still need them (tests/run-tests-harness.test.cjs #3597).
for (const name of ['gsd-leak-x', 'gsd-leak-y']) fs.mkdirSync(path.join(root, name));
const protectedSwept = runner.sweepRunTempRoot(root, new Set([path.join(root, 'gsd-leak-x')]));
assert.equal(protectedSwept, 1, 'only the unprotected entry is removed');
assert.ok(fs.existsSync(path.join(root, 'gsd-leak-x')), 'the protected entry survives');
assert.ok(!fs.existsSync(path.join(root, 'gsd-leak-y')), 'the unprotected entry is removed');
});
test('the leak guard fails fast naming the leaked roots', (t) => {
const runner = require('../scripts/run-tests.cjs');
assert.equal(typeof runner.assertTempRootBounded, 'function',
'the runner must export assertTempRootBounded for in-process verification');
const root = createTempDir('gsd-test-run-guard-');
t.after(() => cleanup(root));
// Boundary: at the limit it passes (limit-1 and limit), at limit+1 it throws.
for (let i = 0; i < 3; i++) fs.mkdirSync(path.join(root, `gsd-leak-${i}`));
assert.doesNotThrow(() => runner.assertTempRootBounded(root, 3), 'residue at the limit passes');
assert.throws(
() => runner.assertTempRootBounded(root, 2),
(err) => /temp root leak/i.test(err.message) && err.message.includes('gsd-leak-0'),
'one over the limit throws a message naming the leaked roots (not EDQUOT)');
});
test('the runner removes its temp root on exit', (t) => {
const sandbox = createTempDir('gsd-4020-spawn-');
t.after(() => cleanup(sandbox));
// A single trivial real test file so the runner has work to do and exits 0.
const target = path.join(__dirname, 'helpers-4020-probe.test.cjs');
fs.writeFileSync(target, "require('node:test').test('noop #4020', () => {});\n");
// A single file in the repo tree, not a temp dir — cleanup() refuses
// out-of-temp-root paths by design, so unlink it directly.
t.after(() => fs.unlinkSync(target));
const r = runNode(
[RUNNER, '--files', path.basename(target)],
{ timeoutMs: RUN_TESTS_HARNESS_SPAWN_TIMEOUT_MS, env: { ...process.env, TMPDIR: sandbox, TEMP: sandbox, TMP: sandbox } },
);
assert.equal(r.exitCode, 0, `runner should pass: ${r.stderr.slice(-400)}`);
const m = /tmp-root=(\S+)/.exec(r.stderr);
assert.ok(m, 'runner stderr must announce its temp root');
assert.ok(m[1].startsWith(sandbox), 'the announced root lives inside the sandboxed TMPDIR');
assert.ok(!fs.existsSync(m[1]), 'the temp root is removed after the run');
});
test('a nested runner reuses an inherited root and never removes it', (t) => {
// #4020 review: the harness regression test spawns run-tests INSIDE a live
// run — the nested process must reuse the outer root and leave it standing
// on ITS exit, or the outer suite mass-ENOENTs every later fixture.
const inherited = createTempDir('gsd-test-run-inherited');
t.after(() => cleanup(inherited));
const target = path.join(__dirname, 'helpers-4020-probe.test.cjs');
fs.writeFileSync(target, "require('node:test').test('noop #4020 nested', () => {});\n");
// A single file in the repo tree, not a temp dir — cleanup() refuses
// out-of-temp-root paths by design, so unlink it directly.
t.after(() => fs.unlinkSync(target));
const r = runNode(
[RUNNER, '--files', path.basename(target)],
{ timeoutMs: RUN_TESTS_HARNESS_SPAWN_TIMEOUT_MS, env: { ...process.env, TMPDIR: inherited, TEMP: inherited, TMP: inherited } },
);
assert.equal(r.exitCode, 0, `nested runner should pass: ${r.stderr.slice(-400)}`);
const m = /tmp-root=(\S+)/.exec(r.stderr);
assert.ok(m, 'nested runner announces its root');
assert.equal(m[1], inherited, 'the nested runner REUSES the inherited root');
// The sweep may legitimately clean the root's CONTENTS between chunks; the
// property under test is that the nested runner never rmSyncs the root ITSELF.
assert.ok(fs.existsSync(inherited), 'the inherited root survives the nested runner\'s exit');
// OWNER-ONLY SWEEP: the nested runner runs while the OUTER chunk's sibling
// test files may be live — it must not sweep THEIR fixtures out from under
// them (macOS CI: template.test.cjs lost its plan file to exactly that).
const sibling = path.join(inherited, 'gsd-sibling-live-fixture');
fs.mkdirSync(sibling);
const r2 = runNode(
[RUNNER, '--files', path.basename(target)],
{ timeoutMs: RUN_TESTS_HARNESS_SPAWN_TIMEOUT_MS, env: { ...process.env, TMPDIR: inherited, TEMP: inherited, TMP: inherited } },
);
assert.equal(r2.exitCode, 0, `second nested runner should pass: ${r2.stderr.slice(-300)}`);
assert.ok(fs.existsSync(sibling),
'a nested runner never sweeps the shared root — sibling fixtures survive');
});
describe('#4220 — computeSweepProtectSet ancestor-walk termination', () => {
// The original inline block stopped the ancestor walk on
// `cur !== runTempRoot && cur.length > 1`, a POSIX-only sentinel:
// path.posix.dirname('/') === '/' (length 1) correctly stops, but
// path.win32.dirname('C:\\') === 'C:\\' (length 3) never satisfies a
// length check — dirname returns the SAME string forever, so the loop
// never terminates on Windows whenever a selected file lives outside
// runTempRoot (the common case: most selected test files are in the repo
// checkout, not the temp root). This bounds every assertion below with a
// hard iteration cap so a genuinely-hanging implementation fails FAST in
// this test run rather than wedging the suite.
const HARD_ITERATION_CAP = 10_000;
// A dirnameImpl wrapper that throws once it has been called more times
// than a real ancestor walk could ever need, turning an infinite loop
// into a fast, loud test failure instead of a hang.
function boundedDirname(realDirnameImpl) {
let calls = 0;
return (p) => {
calls++;
if (calls > HARD_ITERATION_CAP) {
throw new Error(
`computeSweepProtectSet: dirname called >${HARD_ITERATION_CAP} times — ` +
`the ancestor walk did not terminate (regression of #4220)`,
);
}
return realDirnameImpl(p);
};
}
test('terminates and protects ancestors on win32 paths outside runTempRoot', () => {
const runner = require('../scripts/run-tests.cjs');
assert.equal(typeof runner.computeSweepProtectSet, 'function',
'the runner must export computeSweepProtectSet for in-process verification');
const win32 = require('node:path').win32;
// A file living entirely outside runTempRoot (the common case — most
// selected test files are in the repo checkout), walked with the
// Windows dirname implementation whose drive-root is NOT length 1.
const selected = ['C:\\repo\\tests\\a\\b.test.cjs'];
const runTempRoot = 'C:\\Users\\ci\\AppData\\Local\\Temp\\gsd-test-run-xyz';
const protectSet = runner.computeSweepProtectSet(
selected, runTempRoot, boundedDirname(win32.dirname),
);
assert.ok(protectSet.has('C:\\repo\\tests\\a\\b.test.cjs'), 'the file itself is protected');
assert.ok(protectSet.has('C:\\repo\\tests\\a'), 'immediate parent is protected');
assert.ok(protectSet.has('C:\\repo\\tests'), 'grandparent is protected');
assert.ok(protectSet.has('C:\\repo'), 'walk reaches up to the drive-relative root');
// The walk must have stopped — it must NOT contain the drive root
// itself looping forever, and the protect set must be finite/small.
assert.ok(protectSet.size < 20, 'the walk terminates with a small, bounded protect set');
});
test('terminates and protects ancestors on win32 paths INSIDE runTempRoot (exact-file case)', () => {
const runner = require('../scripts/run-tests.cjs');
const win32 = require('node:path').win32;
const runTempRoot = 'C:\\Users\\ci\\AppData\\Local\\Temp\\gsd-test-run-xyz';
const selected = [`${runTempRoot}\\fixture\\nested\\c.test.cjs`];
const protectSet = runner.computeSweepProtectSet(
selected, runTempRoot, boundedDirname(win32.dirname),
);
assert.ok(protectSet.has(selected[0]), 'the file itself is protected');
assert.ok(protectSet.has(`${runTempRoot}\\fixture\\nested`), 'immediate parent is protected');
assert.ok(protectSet.has(`${runTempRoot}\\fixture`), 'grandparent is protected');
assert.ok(!protectSet.has(win32.dirname(runTempRoot)),
'the walk stops at runTempRoot and never protects its parent');
});
test('terminates and protects ancestors on posix paths (parity with win32)', () => {
const runner = require('../scripts/run-tests.cjs');
const posix = require('node:path').posix;
const selected = ['/repo/tests/a/b.test.cjs'];
const runTempRoot = '/synthetic-root/gsd-test-run-xyz';
const protectSet = runner.computeSweepProtectSet(
selected, runTempRoot, boundedDirname(posix.dirname),
);
assert.ok(protectSet.has('/repo/tests/a/b.test.cjs'), 'the file itself is protected');
assert.ok(protectSet.has('/repo/tests/a'), 'immediate parent is protected');
assert.ok(protectSet.has('/repo/tests'), 'grandparent is protected');
assert.ok(protectSet.has('/repo'), 'walk reaches up toward the root');
assert.ok(!protectSet.has('/'),
'the walk stops before adding the filesystem root itself, mirroring win32 behavior');
});
test('exact-file case on posix: selected file lies directly under runTempRoot', () => {
const runner = require('../scripts/run-tests.cjs');
const posix = require('node:path').posix;
const runTempRoot = '/synthetic-root/gsd-test-run-xyz';
const selected = [runTempRoot];
const protectSet = runner.computeSweepProtectSet(
selected, runTempRoot, boundedDirname(posix.dirname),
);
assert.ok(protectSet.has(runTempRoot), 'the exact-file case protects runTempRoot itself');
});
test('defaults dirnameImpl to the real platform path.dirname when not injected', () => {
const runner = require('../scripts/run-tests.cjs');
const path = require('node:path');
const os = require('node:os');
const runTempRoot = path.join(os.tmpdir(), 'gsd-test-run-default');
const selected = [path.join(runTempRoot, 'fixture', 'd.test.cjs')];
const protectSet = runner.computeSweepProtectSet(selected, runTempRoot);
assert.ok(protectSet.has(selected[0]), 'default dirnameImpl still protects the selected file');
assert.ok(protectSet.has(path.join(runTempRoot, 'fixture')), 'default dirnameImpl walks ancestors');
});
});
});