inspector/scripts/verify-test-timeouts.test.mjs
cliffhall 58e53d5a92 fix: state every test-gate timeout instead of inheriting a library default
Three of the six Vitest projects ran on Vitest's own testTimeout: 5000 and
five on its hookTimeout/teardownTimeout: 10000, and all 788 Testing Library
waits in the web unit project ran on asyncUtilTimeout: 1000. None of those
numbers was chosen here, and all of them were sized for an idle machine rather
than for three or four concurrent worktree sessions each running a full
local:gate on eight cores. #2292, #2278, #2250, #1942 and #1742 were each that
class, diagnosed one site at a time.

This is the complement of #1596, not a retreat from it: every fix that issue
produced stands, and no race is hidden here. What changes is that a correct,
deterministic test is no longer cut off by a budget nobody picked.

- vitest.shared.mts exports frozen TIMEOUTS / INTEGRATION_TIMEOUTS; all six
  projects spread one of them, clients/launcher importing the shared module for
  the first time to do so.
- Both web projects configure Testing Library's asyncUtilTimeout. The storybook
  one needs its own setup file, deliberately outside .storybook/ and free of
  setProjectAnnotations so the automatic preview-annotation provisioning
  (#1898) is untouched.
- 28 per-suite restatements of the integration project's own budget are gone,
  so raising the shared value now moves every site under it. The handful that
  genuinely differ — cross-process lock contention, a full OAuth round trip —
  keep their budget as a named constant that says why.
- AppRenderer's waitFor no longer asks for exactly its enclosing budget, which
  it could never win (#2292's shape); cli's e2e child timer drops below the
  project budget so the bound carrying the diagnostic is the one that fires.
- The transition auto-settle's slack term is named RAF_SLACK_MS and raised;
  the term derived from HEADER_ANIM_MS is untouched. The two answer to
  different things and must not be scaled as one number.
- The 19 Playwright budgets scattered across three web smokes become four
  named constants in scripts/lib/browser-timeouts.mjs.

scripts/verify-test-timeouts.mjs resolves each project through Vitest itself
and asserts the budgets, the asyncUtilTimeout sites, and that retry stays
unset. It was observed red against the pre-change config: unit/tui/launcher at
testTimeout 5000, five projects at hookTimeout 10000, both asyncUtilTimeout
sites unmet.

Closes #2323

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 08:32:34 -04:00

150 lines
5.2 KiB
JavaScript

/**
* Unit tests for `verify-test-timeouts.mjs`'s pure decision logic.
*
* The guard's own resolution half needs a real Vitest and four real configs, so
* it is exercised by running it (it is in `validate`). What is tested here is
* everything that decides PASS or FAIL once a project is resolved — including
* the two failure modes a green repo can never produce on its own: an unknown
* project, and a `retry` someone added.
*
* ⚠️ Keep the filename exactly `verify-test-timeouts.test.mjs`. `node --test`
* silently SKIPS a file its glob misses and still exits 0, so a typo here reads
* as a passing suite.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
ASYNC_UTIL_SITES,
CONFIG_ROOTS,
EXPECTED_INTEGRATION_TIMEOUTS,
EXPECTED_PROJECTS,
EXPECTED_TIMEOUTS,
checkAsyncUtilSource,
checkProject,
identifyProject,
} from "./verify-test-timeouts.mjs";
const ok = { ...EXPECTED_TIMEOUTS };
test("a project on the stated budgets passes", () => {
assert.deepEqual(checkProject("unit", ok), []);
assert.deepEqual(checkProject("cli", ok), []);
assert.deepEqual(
checkProject("integration", { ...EXPECTED_INTEGRATION_TIMEOUTS }),
[],
);
});
test("each budget is checked independently", () => {
for (const key of Object.keys(EXPECTED_TIMEOUTS)) {
const failures = checkProject("unit", { ...ok, [key]: 1234 });
assert.equal(failures.length, 1, `${key} was not checked`);
assert.match(failures[0], new RegExp(`${key} to 1234, expected`));
}
});
test("a budget left on a library default is a failure, not an omission", () => {
// The exact shape this guard exists for: Vitest's own 5000/10000, which is
// what five of the six projects resolved to before #2323.
const failures = checkProject("tui", {
testTimeout: 5000,
hookTimeout: 10000,
teardownTimeout: 10000,
});
assert.equal(failures.length, 3);
});
test("an absent budget fails rather than being treated as fine", () => {
// `undefined !== 15000`. Worth pinning: a config that stopped being loaded
// resolves every key to undefined, and that must not read as a pass.
assert.equal(checkProject("unit", {}).length, 3);
});
test("a project with no row fails loudly", () => {
const failures = checkProject("brand-new", ok);
assert.equal(failures.length, 1);
assert.match(failures[0], /no row in EXPECTED_PROJECTS/);
});
test("retry is allowed to be unset or 0, and nothing else", () => {
assert.deepEqual(checkProject("unit", { ...ok }), []);
assert.deepEqual(checkProject("unit", { ...ok, retry: 0 }), []);
for (const retry of [1, 2]) {
const failures = checkProject("unit", { ...ok, retry });
assert.equal(failures.length, 1);
assert.match(failures[0], /retry/);
}
});
test("every project in the table is one the configs are asked for", () => {
const declared = CONFIG_ROOTS.flatMap((c) => c.projects).sort();
assert.deepEqual(declared, Object.keys(EXPECTED_PROJECTS).sort());
});
test("a nameless single project takes its name from the config entry", () => {
// The node clients set no `name`, so their one project resolves as "".
assert.equal(identifyProject({ name: "" }, ["cli"]), "cli");
});
test("a browser project is matched past its instance suffix", () => {
assert.equal(
identifyProject({ name: "storybook (chromium)" }, [
"unit",
"integration",
"storybook",
]),
"storybook",
);
});
test("an unrecognized project name is not silently matched", () => {
assert.equal(
identifyProject({ name: "e2e" }, ["unit", "storybook"]),
undefined,
);
// Nameless only resolves when the entry expects exactly one project —
// otherwise the guard cannot tell which row it belongs to.
assert.equal(identifyProject({ name: "" }, ["unit", "storybook"]), undefined);
});
test("a setup file that configures asyncUtilTimeout passes", () => {
const source = `import { cleanup, configure } from "@testing-library/react";
configure({ asyncUtilTimeout: 5000 });`;
assert.deepEqual(checkAsyncUtilSource(source, "@testing-library/react"), []);
});
test("configure() without the timeout is caught", () => {
const source = `import { configure } from "storybook/test";
configure({ testIdAttribute: "data-testid" });`;
assert.deepEqual(checkAsyncUtilSource(source, "storybook/test"), [
"calls configure() without an asyncUtilTimeout",
]);
});
test("the import is checked, not just the call", () => {
// Storybook instruments its own Testing Library copy, so configuring
// `@testing-library/*` from a story setup configures a copy no play function
// ever calls — green by inspection, dead in practice.
const source = `import { configure } from "@testing-library/dom";
configure({ asyncUtilTimeout: 5000 });`;
assert.deepEqual(checkAsyncUtilSource(source, "storybook/test"), [
'does not import from "storybook/test"',
]);
});
test("a setup file with no configure() at all is caught", () => {
const failures = checkAsyncUtilSource(
"export const nothing = 1;\n",
"storybook/test",
);
assert.equal(failures.length, 2);
});
test("both web projects have an asyncUtilTimeout site", () => {
assert.deepEqual(ASYNC_UTIL_SITES.map((s) => s.project).sort(), [
"storybook",
"unit",
]);
});