Skip to content

Commit 1ea92df

Browse files
committed
test(spec): the gate-spawning beforeAll in check-liveness.test.ts runs under the case budget
The beforeAll of the tombstone describe spawns the whole liveness gate but, unlike the cases, ran under vitest's default 10 s hookTimeout: a 5 to 9 s run had 1 to 5 s of margin and a loaded shard spent it. It now passes GATE_BUDGET_MS (60_000, the cases' testTimeout) as its timeout. A new block at the end of the file parses the file's own source and fails when any lifecycle hook whose callback reaches spawnSync (directly or through runGate) does not pass GATE_BUDGET_MS, and when GATE_BUDGET_MS falls below the testTimeout declared in vitest.config.ts. Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
1 parent 39a912e commit 1ea92df

1 file changed

Lines changed: 105 additions & 1 deletion

File tree

‎packages/spec/scripts/liveness/check-liveness.test.ts‎

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import { cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, sta
2525
import { tmpdir } from 'node:os';
2626
import path from 'node:path';
2727
import { fileURLToPath } from 'node:url';
28+
// The parser the hook-budget block at the bottom of this file reads THIS file with.
29+
import ts from 'typescript';
2830
// The registry itself, so the denominator block at the bottom of this file can
2931
// hold the gate's output answerable to it rather than to a copied list (#18133).
3032
import {
@@ -44,6 +46,15 @@ const SPEC = path.resolve(HERE, '../..');
4446
const GATE = path.join(HERE, 'check-liveness.mts');
4547
const LEDGERS = path.join(SPEC, 'liveness');
4648

49+
// The budget the gate-spawning CASES run under: `testTimeout` in vitest.config.ts
50+
// (60_000 in both projects). A HOOK runs under `hookTimeout` instead, vitest's
51+
// 10_000 ms default, which that config does not set — so a hook that spawns the
52+
// whole gate names the case budget itself (#21421). A literal, not an import: the
53+
// config cannot be imported from a test (its top level runs the filter preflights
54+
// against process.argv) and exports no constant. The last block of this file holds
55+
// this value at or above the config's, so the two cannot drift apart unseen.
56+
const GATE_BUDGET_MS = 60_000;
57+
4758
// A repo-rooted path shaped exactly like a real pointer (so `evidence.mts`
4859
// extracts it) that this repo has never contained.
4960
const ROTTED = 'packages/plugins/driver-sql/src/sql-driver.ts';
@@ -1398,6 +1409,8 @@ describe('check:liveness — a tombstoned key may not be graded `live` (#19062)'
13981409
let tmp: string;
13991410
let carrier: Carrier;
14001411

1412+
// The one hook in this file that spawns the gate: it runs under the case budget
1413+
// (GATE_BUDGET_MS, last argument), not vitest's 10 s `hookTimeout` default (#21421).
14011414
beforeAll(() => {
14021415
tmp = mkdtempSync(path.join(tmpdir(), 'os-liveness-tombstone-'));
14031416

@@ -1428,7 +1441,7 @@ describe('check:liveness — a tombstoned key may not be graded `live` (#19062)'
14281441
enumerated: enumerated.length,
14291442
eligible: eligible.length,
14301443
};
1431-
});
1444+
}, GATE_BUDGET_MS);
14321445
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
14331446

14341447
/**
@@ -1671,3 +1684,94 @@ describe('check:liveness — a `live` row may not opt into `authorWarn` (#21127)
16711684
expect(output.split('\n').filter((l) => l.startsWith('✗')), output).toHaveLength(1);
16721685
});
16731686
});
1687+
1688+
// ---------------------------------------------------------------------------
1689+
// A hook that spawns the gate runs under the case budget (#21421)
1690+
//
1691+
// The cases above spawn the whole gate under `testTimeout`; a hook spawning the
1692+
// same gate defaults to `hookTimeout` (10 s), so a 5 to 9 s run had 1 to 5 s of
1693+
// margin and a loaded CI shard spent it — the `beforeAll` of the #19062 block
1694+
// timed out and its cases were skipped behind it. This reads THIS file's own
1695+
// source for every lifecycle hook whose callback reaches `spawnSync`, directly
1696+
// or through another function here, and holds each to `GATE_BUDGET_MS` as its
1697+
// timeout argument. Parsed rather than matched as text, so a hook is judged by
1698+
// its call, never by what a comment or this block's own prose happens to spell.
1699+
// ---------------------------------------------------------------------------
1700+
describe('check:liveness — a hook that spawns the gate carries the case budget (#21421)', () => {
1701+
const HOOKS = new Set(['beforeAll', 'beforeEach', 'afterAll', 'afterEach']);
1702+
const self = fileURLToPath(import.meta.url);
1703+
const sf = ts.createSourceFile(self, readFileSync(self, 'utf8'), ts.ScriptTarget.Latest, true);
1704+
1705+
const calleeOf = (n: ts.Node): string => (ts.isCallExpression(n) && ts.isIdentifier(n.expression) ? n.expression.text : '');
1706+
const callsAny = (root: ts.Node, names: ReadonlySet<string>): boolean => {
1707+
let hit = false;
1708+
const walk = (n: ts.Node): void => {
1709+
if (hit) return;
1710+
if (names.has(calleeOf(n))) hit = true;
1711+
else ts.forEachChild(n, walk);
1712+
};
1713+
walk(root);
1714+
return hit;
1715+
};
1716+
1717+
// Every function in this file that reaches `spawnSync`, to a fixpoint.
1718+
const bodies = new Map<string, ts.Node>();
1719+
const collect = (n: ts.Node): void => {
1720+
if (ts.isFunctionDeclaration(n) && n.name && n.body) bodies.set(n.name.text, n.body);
1721+
if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.initializer
1722+
&& (ts.isArrowFunction(n.initializer) || ts.isFunctionExpression(n.initializer))) {
1723+
bodies.set(n.name.text, n.initializer.body);
1724+
}
1725+
ts.forEachChild(n, collect);
1726+
};
1727+
collect(sf);
1728+
const spawners = new Set(['spawnSync']);
1729+
for (let grew = true; grew;) {
1730+
grew = false;
1731+
for (const [name, body] of bodies) {
1732+
if (!spawners.has(name) && callsAny(body, spawners)) {
1733+
spawners.add(name);
1734+
grew = true;
1735+
}
1736+
}
1737+
}
1738+
1739+
const hooks: { hook: string; line: number; spawns: boolean; timeout: string | null }[] = [];
1740+
const visit = (n: ts.Node): void => {
1741+
if (HOOKS.has(calleeOf(n))) {
1742+
const [fn, timeout] = (n as ts.CallExpression).arguments;
1743+
hooks.push({
1744+
hook: calleeOf(n),
1745+
line: sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1,
1746+
spawns: fn !== undefined && callsAny(fn, spawners),
1747+
timeout: timeout === undefined ? null : timeout.getText(sf),
1748+
});
1749+
}
1750+
ts.forEachChild(n, visit);
1751+
};
1752+
visit(sf);
1753+
1754+
// The scan must be capable of finding something: a refactor that renames
1755+
// `runGate` or the hooks would otherwise leave the next case green over nothing.
1756+
it('sees the gate runner and this file\'s hooks', () => {
1757+
expect(spawners.has('runGate'), 'the scan no longer recognises runGate as reaching spawnSync').toBe(true);
1758+
expect(hooks.length, 'the scan found no lifecycle hook in this file').toBeGreaterThan(0);
1759+
});
1760+
1761+
it('every hook whose callback reaches the gate passes GATE_BUDGET_MS as its timeout', () => {
1762+
const unbudgeted = hooks
1763+
.filter((h) => h.spawns && h.timeout !== 'GATE_BUDGET_MS')
1764+
.map((h) => `${h.hook} at line ${h.line}: timeout argument ${h.timeout ?? 'absent (vitest hookTimeout default, 10 s)'}`);
1765+
expect(
1766+
unbudgeted,
1767+
'a hook that spawns the gate must run under the case budget — pass GATE_BUDGET_MS as its second argument',
1768+
).toEqual([]);
1769+
});
1770+
1771+
it('GATE_BUDGET_MS is not below the testTimeout vitest.config.ts gives the cases', () => {
1772+
const config = readFileSync(path.join(SPEC, 'vitest.config.ts'), 'utf8');
1773+
const budgets = [...config.matchAll(/\btestTimeout:\s*([\d_]+)/g)].map((m) => Number((m[1] ?? '').replaceAll('_', '')));
1774+
expect(budgets.length, 'no testTimeout found in vitest.config.ts').toBeGreaterThan(0);
1775+
expect(GATE_BUDGET_MS, `vitest.config.ts testTimeout values: ${budgets.join(', ')}`).toBeGreaterThanOrEqual(Math.max(...budgets));
1776+
});
1777+
});

0 commit comments

Comments
 (0)