mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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
148 lines
5.1 KiB
TypeScript
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();
|
|
}
|