openclaw/scripts/check-test-timeout-race-ratchet.mts
Peter Steinberger be052821d6
chore: block new wall-clock test timeout races (#162357)
Tests must not race real timers (docs/help/testing/writing-tests.md, Cost
budget and Flake triage), yet withTestTimeout and raceWithTimeoutResult call
sites grew from 123 (2026-09-01) to 478 (2026-09-26). #161691 audited them,
fixed the worst files, and added the timer-free replacements
awaitGateBeforeSettlement and withinTest. This stops new uses.

check:test-timeout-race-ratchet keeps per-file counts in
config/test-timeout-race-baseline.txt (172 files, 403 sites) and only lets
them shrink. It parses every repository code file that mentions either helper
and counts each identifier reference except import specifiers: plain and
generic calls, namespace calls, aliases, re-exports, and local copies such as
the private raceWithTimeoutResult in fetch-guard.ssrf.test.ts. Comments and
strings do not count; test/helpers/promise.ts owns the helpers and is
excluded. Failures point authors at awaitGateBeforeSettlement, withinTest, or
fake timers through the owner's clock seam; removed sites require --prune.

The per-file count lifecycle (merge-base comparison, verified renames, base
drift allowance, prune, shrink demand) moves from the assertion-safety ratchet
into scripts/lib/shrink-ratchet.mts so both ratchets share one owner. The
assertion-safety output and exit codes are unchanged.

Wiring: scripts/check.mts preflight, check:changed routing for any code file
or the baseline, and one added line each in the existing PR baseline-ratchet
step and the main-push ratchet step (ci.yml +164 bytes, no new steps).

Proof (Linux Testbox, pre-rebase tree; baseline refreshed after rebase): new ratchet passes in under 1 s; injecting a call into
a baselined file and a new test file fails with the guidance; assertion-safety
reports its unchanged totals; tsgo:scripts, tsgo:test:root, and
check:changed --base origin/main pass; the affected test/scripts files pass.
New test file: 4 s with --maxWorkers=1.

Release note context: maintainer tooling only; no user-visible change.

CI: run 36812237216 green except checks-windows-node-test-3, which fails the same
package-update-swap.windows.test.ts assertion on main (scheduled run 36807764485);
fix owned by #162361.

Related: #161691
2026-09-30 21:39:29 -07:00

148 lines
5.1 KiB
TypeScript

import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import * as ts from "typescript/unstable/ast";
import {
createNativeTypeScriptParser,
type NativeTypeScriptParser,
} from "./lib/native-typescript.mts";
import { loadRatchetSources, runPerFileCountRatchet } from "./lib/shrink-ratchet.mts";
const BASELINE_PATH = "config/test-timeout-race-baseline.txt";
const BASELINE_HEADER = [
"# Per-file identifier references to withTestTimeout/raceWithTimeoutResult and imported aliases.",
"# Import specifiers and the helper owner test/helpers/promise.ts are excluded.",
"# Ratchet: counts may only shrink. New wall-clock test deadlines are not allowed.",
"# Format: repo-relative path, tab, positive count. Zero-count files are omitted.",
"",
].join("\n");
const TIMEOUT_NAMES = new Set(["withTestTimeout", "raceWithTimeoutResult"]);
const CODE_EXTENSION_RE = /\.(?:[cm]?[jt]s|[jt]sx)$/u;
const GIT_MAX_BUFFER = 256 * 1024 * 1024;
function isGovernedSourcePath(filePath: string) {
return (
filePath !== "test/helpers/promise.ts" &&
CODE_EXTENSION_RE.test(filePath) &&
!/\.d\.[cm]?ts$/u.test(filePath)
);
}
export function countTestTimeoutRaceReferences(
filePath: string,
sourceFile: ts.SourceFile,
parser: NativeTypeScriptParser,
) {
const diagnostic = parser.getSyntacticDiagnostics(sourceFile.fileName)[0];
if (diagnostic) {
const line = sourceFile.getLineAndCharacterOfPosition(diagnostic.pos).line + 1;
throw new Error(`${filePath}:${line}: ${diagnostic.text}`);
}
const names = new Set(TIMEOUT_NAMES);
const collectAliases = (node: ts.Node): void => {
if (ts.isImportSpecifier(node) && TIMEOUT_NAMES.has((node.propertyName ?? node.name).text)) {
names.add(node.name.text);
}
node.forEachChild(collectAliases);
};
collectAliases(sourceFile);
let count = 0;
const visit = (node: ts.Node): void => {
if (ts.isImportSpecifier(node)) {
return;
}
// Count every reference so aliases, re-exports, and local copies cannot hide a deadline.
if (ts.isIdentifier(node) && names.has(node.text)) {
count += 1;
}
node.forEachChild(visit);
};
visit(sourceFile);
return count;
}
function collectTestTimeoutRaceCounts(root: string, options: { staged: boolean }) {
const result = spawnSync(
"git",
[
"grep",
"-l",
"-z",
"-F",
...(options.staged ? ["--cached"] : ["--untracked"]),
"-e",
"withTestTimeout",
"-e",
"raceWithTimeoutResult",
"--",
".",
],
{ cwd: root, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER },
);
if (result.error) {
throw result.error;
}
if (result.status !== 0 && result.status !== 1) {
throw new Error(result.stderr.trim() || `git grep failed (${result.status})`);
}
const filePaths = result.stdout.split("\0").filter(isGovernedSourcePath).toSorted();
const sources = options.staged
? loadRatchetSources(root, filePaths)
: filePaths.map((filePath): [string, string] => [
filePath,
fs.readFileSync(path.join(root, filePath), "utf8"),
]);
using parser = createNativeTypeScriptParser({ cwd: root });
const counts = new Map<string, number>();
for (const [filePath, source] of sources) {
const count = countTestTimeoutRaceReferences(
filePath,
parser.parseSourceFile(filePath, source),
parser,
);
if (count > 0) {
counts.set(filePath, count);
}
}
return counts;
}
export function main(root = process.cwd(), argv: string[] = process.argv.slice(2)) {
using parser = createNativeTypeScriptParser({ cwd: root });
return runPerFileCountRatchet(root, argv, {
baselinePath: BASELINE_PATH,
baselineHeader: BASELINE_HEADER,
renameSourceRoots: ["."],
collectCurrent: (options) => collectTestTimeoutRaceCounts(root, options),
countAtRef(ref, filePath) {
const source = execFileSync("git", ["show", `${ref}:${filePath}`], {
cwd: root,
encoding: "utf8",
maxBuffer: GIT_MAX_BUFFER,
stdio: ["ignore", "pipe", "ignore"],
});
return countTestTimeoutRaceReferences(
filePath,
parser.parseSourceFile(filePath, source),
parser,
);
},
messages: {
increaseTitle: "Wall-clock test timeout races exceed the grandfathered per-file baseline:",
expansionTitle: "The test timeout race baseline may only shrink:",
guidance:
"New wall-clock test deadlines are not allowed (docs/help/testing/writing-tests.md: Cost budget, Flake triage).\n" +
"Instead of withTestTimeout/raceWithTimeoutResult or a local copy, wait for the owned completion signal:\n" +
"awaitGateBeforeSettlement(gate, operation, message) or withinTest(work, signal) from test/helpers/promise.ts,\n" +
"or vi.useFakeTimers() driven through the owner's injected clock seam.",
countNoun: "sites",
successTitle: "test timeout race ratchet OK",
},
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exitCode = main();
}