inspector/scripts/verify-test-timeouts.mjs
cliffhall 69e6097b68 refactor: assert retry and asyncUtilTimeout at runtime, delete the source scanner
Round 8 of the review asked for an AST. That was the right diagnosis of the
wrong problem: the question was never which parser to use, it was why a
test-timeout guard was parsing JavaScript at all.

It got there one reasonable step at a time. The guard enforces two rules no
Vitest config can report — a per-test `retry`, and Testing Library's
`asyncUtilTimeout` — so it read them out of source text, and the review found a
new valid spelling it missed in five consecutive rounds: a trailing comment,
nested parens in `it.each([makeCase()])`, a member-access prefix, the shorthand
`{ retry }`, an aliased import, a non-literal title. Each fix was correct and
each made it more parser-shaped, until it carried eight helpers doing quote
tracking and bracket balancing.

Both rules are observable at runtime, exactly:

- `vitest.setup.shared.mts` is loaded by all six projects and reads
  `ctx.task.retry` — the value Vitest resolved for the test. That covers
  strictly more than the scan did: a per-test option, a `describe` option
  inherited by children, a project-level setting, and a `--retry` flag typed at
  a command line, which the scan could never see since it only read npm scripts.
- `clients/web/src/test/asyncUtilTimeout.test.ts` asserts
  `getConfig().asyncUtilTimeout`, the value this project's own waitFors read.

Neither has a spelling to anticipate, so neither can be outrun by a new one.
Both were observed firing before being trusted.

The guard keeps only what it can answer by asking Vitest: resolved budgets,
project-level retry, config discovery, and that each project still loads the
setup file — the one thing about the runtime assertion a config CAN report.
868 lines to 361, every scanner helper gone, and 33 of 48 unit tests deleted
because they pinned edge cases of a thing that no longer exists.

storybookSetup.ts goes with it. At the library's own value its configure() call
was a no-op, and the assertion is what pins the number.

AGENTS.md gains the general rule: a rule about what the code DOES is asserted at
runtime, not read out of the source — reach for a verify:* guard when the
question is about configuration, which a tool can be asked to resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-12 15:58:19 -04:00

361 lines
14 KiB
JavaScript

/**
* Guards the wall-clock budgets every Vitest project in this repo runs under
* (#2323), and the decision that no project retries.
*
* The class it encodes against: a budget nobody chose. Three of the six
* projects ran on Vitest's own `testTimeout: 5000` and five on its
* `hookTimeout` / `teardownTimeout: 10000` — values sized for an idle machine,
* not for the one this team works on (three or four concurrent agent sessions
* in separate worktrees, each free to run the full `npm run local:gate`, on
* eight logical cores). A correct, deterministic test cut off mid-flight by
* such a budget fails a gate its diff did not break, which trains people to
* re-run rather than read — the same argument AGENTS.md's "Lint has no warning
* tier" makes from the other direction. #2292, #1942 and #1742 were each that
* class, found and fixed one site at a time. ⚠️ Not every timing failure in
* this repo's history belongs here: #2278 was a missing condition wait around a
* geometry read and #2250 a genuine race in a test's own timing, both fixed by
* making the test wait for the right thing. Citing those as evidence for a
* larger ceiling would argue against #1596, which this guard upholds.
*
* **Everything here is read from a resolved Vitest project, never from source
* text.** That is a deliberate and hard-won boundary. An earlier revision of
* #2334 also enforced the two rules that no config can report — a per-test
* `retry`, and Testing Library's `asyncUtilTimeout` — by scanning source, and
* the review found a new valid JavaScript spelling it missed in five
* consecutive rounds. Each fix was correct and each made the scanner more
* parser-shaped, until it carried eight helpers doing quote tracking and
* bracket balancing: a bad parser inside a linter. Both rules are now asserted
* where they are unambiguous —
*
* • `retry`, at runtime, by `vitest.setup.shared.mts`, which every project
* loads and which reads the value Vitest actually resolved for the test;
* • `asyncUtilTimeout`, at runtime, by
* `clients/web/src/test/asyncUtilTimeout.test.ts`, which reads the value
* the project's own `waitFor`s use.
*
* Both are strictly stronger than the scan was, because neither has to
* anticipate a spelling. **If a future rule here cannot be answered by asking
* the tool, assert it at runtime — do not read the source for it.**
*
* Three properties of what remains:
*
* 1. **Resolved, not declared.** It asks Vitest to resolve each project and
* reads the number a test actually gets. Asserting that a key is absent from
* some config block would pass just as happily on a config that had stopped
* being loaded at all.
* 2. **Unknown projects fail loudly.** A seventh project with no row here, or a
* config file this guard does not discover, is exactly the drift it exists to
* prevent — so both are errors rather than silent skips.
* 3. **Every project loads the no-retry setup.** The runtime assertion only
* binds a project that actually loads it, so that wiring is checked here
* rather than assumed.
*
* ⚠️ Observed to FAIL against the unfixed config before it was trusted: on
* `origin/v2/main` it reports `unit`, `tui` and `launcher` at
* `testTimeout: 5000` and five of the six projects at `hookTimeout: 10000`.
*/
import { existsSync, readdirSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
/**
* The budgets, restated here rather than imported from `vitest.shared.mts`.
*
* That duplication is the point: importing the same object the configs spread
* in would make this guard agree with any value at all, including a default
* someone reinstated by deleting the spread. A guard has to state the
* expectation independently or it is only asserting that a file parses.
*/
export const EXPECTED_TIMEOUTS = Object.freeze({
testTimeout: 15_000,
hookTimeout: 30_000,
teardownTimeout: 30_000,
});
/** Web's `integration` project pays for real servers, sockets and OAuth flows. */
export const EXPECTED_INTEGRATION_TIMEOUTS = Object.freeze({
...EXPECTED_TIMEOUTS,
testTimeout: 30_000,
});
/**
* Every Vitest project in the repo, keyed by the resolved project name. Vitest
* appends the browser instance to a browser project's name, so `storybook`
* resolves as `storybook (chromium)`; match on the leading segment.
*/
export const EXPECTED_PROJECTS = Object.freeze({
unit: EXPECTED_TIMEOUTS,
integration: EXPECTED_INTEGRATION_TIMEOUTS,
storybook: EXPECTED_TIMEOUTS,
cli: EXPECTED_TIMEOUTS,
tui: EXPECTED_TIMEOUTS,
launcher: EXPECTED_TIMEOUTS,
});
/**
* The four Vitest configs to resolve, and the project names each must yield.
*
* The node clients name no project, so their single project resolves with an
* empty name — `projects` supplies the name this guard checks it under. Web's
* three name themselves, so its entry lists them and the resolver matches by
* name instead of by position.
*/
export const CONFIG_ROOTS = Object.freeze([
{ root: "clients/web", projects: ["unit", "integration", "storybook"] },
{ root: "clients/cli", projects: ["cli"] },
{ root: "clients/tui", projects: ["tui"] },
{ root: "clients/launcher", projects: ["launcher"] },
]);
/**
* Directory every Vitest config in this repo lives one level under. Discovery
* (below) walks it rather than trusting a hand-written list.
*/
export const CLIENTS_DIR = "clients";
/**
* Every filename Vitest will load a config from, most specific first.
*
* The full set, not the two spellings this repo happens to use: discovery is
* only deny-by-default if it sees every config Vitest would (Copilot). A
* `clients/foo/vitest.config.mts` that this list did not name would be
* invisible, and an invisible config yields no project to reject — the same
* hole as a hand-written `CONFIG_ROOTS`, one level down. Order matters at
* resolution time, where the first match wins, and mirrors Vitest's own
* preference for a `vitest.config.*` over a `vite.config.*`.
*/
export const VITEST_CONFIG_FILENAMES = Object.freeze([
...["ts", "mts", "cts", "js", "mjs", "cjs"].map((e) => `vitest.config.${e}`),
...["ts", "mts", "cts", "js", "mjs", "cjs"].map((e) => `vite.config.${e}`),
]);
/**
* Compare one resolved project against its row.
*
* @param {string} name project name as this guard knows it
* @param {{testTimeout?: unknown, hookTimeout?: unknown, teardownTimeout?: unknown, retry?: unknown}} config
* @param {Record<string, Readonly<Record<string, number>>>} [expected]
* @returns {string[]} one message per violation; empty when the project is fine
*/
export function checkProject(name, config, expected = EXPECTED_PROJECTS) {
const row = expected[name];
if (!row) {
return [
`project "${name}" has no row in EXPECTED_PROJECTS — a project whose budgets ` +
`nobody stated is exactly the drift this guard exists to prevent. Add it.`,
];
}
const failures = [];
for (const [key, want] of Object.entries(row)) {
const got = config[key];
if (got !== want) {
failures.push(
`project "${name}" resolves ${key} to ${String(got)}, expected ${want}`,
);
}
}
// Vitest leaves `retry` undefined when nothing sets it; 0 is the same
// decision written out.
const retry = config.retry;
if (retry !== undefined && retry !== 0) {
failures.push(
`project "${name}" sets retry to ${String(retry)} — a retry turns a ` +
`load-induced red into a silent green on the only pre-push gate here (#1596)`,
);
}
return failures;
}
/**
* Does this project load the setup file that asserts no test retries?
*
* The runtime assertion in `vitest.setup.shared.mts` binds only a project that
* actually loads it, so a dropped `setupFiles` entry would silently un-enforce
* the rule for that project while everything still looked configured. This is
* the one thing about that assertion a resolved config *can* answer, so it is
* answered here rather than assumed.
*
* @param {string} name project name as this guard knows it
* @param {unknown} setupFiles the project's resolved `setupFiles`
* @returns {string[]}
*/
export function checkNoRetrySetupLoaded(name, setupFiles) {
const loaded = Array.isArray(setupFiles) ? setupFiles : [];
const found = loaded.some(
(f) =>
typeof f === "string" &&
f.split("\\").join("/").endsWith("vitest.setup.shared.mts"),
);
return found
? []
: [
`project "${name}" does not load vitest.setup.shared.mts, so nothing ` +
`stops a test in it from declaring a retry (#2323)`,
];
}
/**
* Every Vitest config under `clients/`, as repo-relative directories.
*
* Discovery rather than the hand-written `CONFIG_ROOTS` list, so that a new
* client with its own config cannot go unwatched — which would have made the
* "a project with no row is an error" promise vacuous, since an undiscovered
* config yields no project to reject (Copilot). `CONFIG_ROOTS` still exists to
* say which project names each config must produce; this is what proves the
* list is complete.
*
* @param {string} [root] absolute repo root
* @returns {string[]}
*/
export function discoverConfigRoots(root = repoRoot) {
const clients = resolve(root, CLIENTS_DIR);
if (!existsSync(clients)) return [];
return readdirSync(clients, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => join(clients, e.name))
.filter((dir) =>
VITEST_CONFIG_FILENAMES.some((f) => existsSync(join(dir, f))),
)
.map((dir) => relative(root, dir).split("\\").join("/"))
.sort();
}
/**
* Compare what is on disk against what this guard is configured to check.
*
* @param {string[]} discovered
* @param {readonly {root: string}[]} [configured]
* @returns {string[]}
*/
export function checkConfigRootCoverage(discovered, configured = CONFIG_ROOTS) {
const known = new Set(configured.map((c) => c.root));
const failures = [];
for (const root of discovered) {
if (!known.has(root)) {
failures.push(
`${root} has a Vitest config that this guard does not check — add it to ` +
`CONFIG_ROOTS and EXPECTED_PROJECTS rather than leaving its budgets unstated`,
);
}
}
for (const root of known) {
if (!discovered.includes(root)) {
failures.push(
`CONFIG_ROOTS names ${root}, which has no Vitest config on disk — a stale ` +
`row silently stops checking anything`,
);
}
}
return failures;
}
/**
* Resolve every project of one config, via Vitest's own resolver.
*
* @param {string} root absolute directory holding one of `VITEST_CONFIG_FILENAMES`
* @returns {Promise<{name: string, config: Record<string, unknown>}[]>}
*/
async function resolveProjects(root) {
const { createVitest } = await import("vitest/node");
const config = VITEST_CONFIG_FILENAMES.map((f) => join(root, f)).find((f) =>
existsSync(f),
);
if (!config) {
throw new Error(`no vitest/vite config under ${root}`);
}
const vitest = await createVitest("test", {
watch: false,
run: true,
root,
config,
});
try {
return vitest.projects.map((p) => ({
name: p.name,
config: /** @type {Record<string, unknown>} */ (p.config),
}));
} finally {
await vitest.close();
}
}
/**
* Match a resolved project to the name this guard knows it by.
*
* A node client's single project resolves nameless, so its config entry
* supplies the name. Web's three name themselves, but a browser project's name
* carries its instance (`storybook (chromium)`), so compare the leading
* segment rather than the whole string.
*
* @param {{name: string}} project
* @param {string[]} expectedNames
* @returns {string | undefined}
*/
export function identifyProject(project, expectedNames) {
if (expectedNames.length === 1 && !project.name) return expectedNames[0];
const base = project.name.replace(/\s*\(.*\)$/, "");
return expectedNames.find((n) => n === base);
}
async function main() {
// Seeded from discovery, not empty: the unknown-project check below can only
// reject a project this guard actually resolves, so a config it never opens
// is invisible to it. Without this line the deny-by-default claim rested on
// `test:scripts` happening to run the same comparison — a different command,
// which a standalone `npm run verify:test-timeouts` does not invoke (Copilot).
const failures = checkConfigRootCoverage(discoverConfigRoots());
let checked = 0;
for (const { root, projects: expectedNames } of CONFIG_ROOTS) {
const resolved = await resolveProjects(resolve(repoRoot, root));
const seen = new Set();
for (const project of resolved) {
const name = identifyProject(project, expectedNames);
if (!name) {
failures.push(
`${root}: resolved an unexpected project "${project.name}" — add it to ` +
`CONFIG_ROOTS and EXPECTED_PROJECTS rather than leaving its budgets unstated`,
);
continue;
}
seen.add(name);
checked += 1;
failures.push(
...checkProject(name, project.config).map((f) => `${root}: ${f}`),
...checkNoRetrySetupLoaded(name, project.config.setupFiles).map(
(f) => `${root}: ${f}`,
),
);
}
for (const name of expectedNames) {
if (!seen.has(name)) {
failures.push(`${root}: project "${name}" did not resolve at all`);
}
}
}
if (failures.length > 0) {
console.error("verify:test-timeouts FAILED\n");
for (const f of failures) console.error(` - ${f}`);
console.error(
"\nEvery test-gate budget must be a value someone chose, sized for a machine\n" +
"running three or four concurrent worktree gates (#2323). The shared values live\n" +
"in `vitest.shared.mts` (TIMEOUTS / INTEGRATION_TIMEOUTS) and every project\n" +
"spreads one of them. Raising a budget is a decision to state there, not a\n" +
"per-suite argument to add.\n\n" +
"`retry` itself is asserted at RUNTIME by vitest.setup.shared.mts, which every\n" +
"project loads — this only checks that each project still loads it.",
);
process.exit(1);
}
console.log(
`verify:test-timeouts OK — ${checked} Vitest projects on stated budgets, ` +
`no project-level retry, and every one loading the runtime no-retry setup.`,
);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) main();