test(#969): fix stale-build flake (incremental + re-emit-on-missing) and make runGsdTools retry-once before surfacing subprocess kills (#996)

Closes #969

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Tom Boucher
2026-06-10 12:04:59 -04:00
committed by GitHub
parent 1fd5c86a1e
commit 88e30d5342
6 changed files with 474 additions and 44 deletions

View File

@@ -261,6 +261,7 @@
"bug-948-state-noop-write-guard.test.cjs",
"bug-950-quick-summary-status-complete.test.cjs",
"bug-967-verify-key-links-strict-paths.test.cjs",
"bug-969-test-infra-flake-hardening.test.cjs",
"bug-974-graphify-budget-missing-value.test.cjs",
"bug-977-fnm-multishell-path.test.cjs",
"bug-978-milestone-complete-force.test.cjs",

View File

@@ -20,7 +20,7 @@
// See docs/TESTING-SUITES.md for full grouping policy.
'use strict';
const { readdirSync, existsSync } = require('fs');
const { readdirSync } = require('fs');
const { join } = require('path');
const { execFileSync } = require('child_process');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
@@ -31,21 +31,76 @@ const SUITES = ['all', 'unit', 'integration', 'install', 'security', 'slow'];
// 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 if missing. It is a no-op once built
// (dev, pretest, a prior run in the same job), which keeps the harness test's
// spawned invocations side-effect-free. Paths resolve from __dirname (not cwd),
// so it works regardless of GSD_TEST_DIR / temp-dir cwd. NOTE: the sentinel is
// the pilot module; revisit (or switch to an unconditional quiet build) as more
// modules migrate into src/.
// 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() {
const { existsSync, readdirSync, statSync, unlinkSync } = require('fs');
const root = join(__dirname, '..');
const sentinel = join(root, 'gsd-core', 'bin', 'lib', 'semver-compare.cjs');
if (existsSync(sentinel)) return;
const srcDir = join(root, 'src');
const outDir = join(root, 'gsd-core', 'bin', 'lib');
const tsBuildInfoPath = join(root, 'gsd-core', 'bin', 'tsconfig.build.tsbuildinfo');
const tscBin = require.resolve('typescript/bin/tsc');
execFileSync(process.execPath, [tscBin, '-p', join(root, 'tsconfig.build.json')], {
cwd: root,
stdio: 'inherit',
});
const tscArgs = [tscBin, '-p', join(root, 'tsconfig.build.json')];
// 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);
}
// 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'];
@@ -320,4 +375,4 @@ if (require.main === module) {
runMain(main);
}
module.exports = { suiteOf };
module.exports = { suiteOf, ensureBuiltArtifacts };