diff --git a/docs/development/testing.md b/docs/development/testing.md index fac0ef580d..f2afb40285 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -307,7 +307,10 @@ assertion. `vitest.config.ts` applies the deadlines in and 120 seconds for built projects, with hooks sharing their project's deadline. Coverage doubles every deadline, and so does hosted CI, where CPU-bound cases share every core with other workers and their subprocesses. -Do not pass a per-test timeout to fit a slow host. Tag a bounded workload that needs longer with +Do not pass a per-test timeout to fit a slow host. A child started through +the `processTest` registry, including `cli.run`, gets the test's remaining +deadline less a reporting reserve, so a hung child fails with its captured +output. Pass `timeoutMs` only when the child's deadline is itself under test. Tag a bounded workload that needs longer with `{ tags: ["long-running"] }`, or a whole file with `// @module-tag long-running`. Assert elapsed time explicitly when speed is the behavior under test. Derive an explicit deadline only when a test must outlive diff --git a/tests/boundary/process/processFixtureLifecycle.test.ts b/tests/boundary/process/processFixtureLifecycle.test.ts index d4a897d13b..79f96c7488 100644 --- a/tests/boundary/process/processFixtureLifecycle.test.ts +++ b/tests/boundary/process/processFixtureLifecycle.test.ts @@ -6,7 +6,11 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { expect, it, vi } from "vitest"; import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; -import { createTestProcessRegistry } from "../../support/process/processFixture.js"; +import { + createTestProcessRegistry, + processTest, + remainingProcessDeadline, +} from "../../support/process/processFixture.js"; const within = async ( operation: Promise, @@ -277,3 +281,34 @@ it("terminates a stalled real child at its deadline while retaining captured out await processes.shutdown(); } }); + +it("derives a child deadline from the test deadline, keeping a reporting reserve", () => { + expect(remainingProcessDeadline(120_000, 0)).toBe(108_000); + expect(remainingProcessDeadline(30_000, 20_000)).toBe(5_000); + // The reserve never drops below five seconds, nor the deadline below one. + expect(remainingProcessDeadline(8_000, 0)).toBe(3_000); + expect(remainingProcessDeadline(8_000, 7_500)).toBe(1_000); + expect(remainingProcessDeadline(0, 0)).toBeUndefined(); +}); + +processTest( + "stops a hung child before its test deadline and reports its output", + // The test deadline is the behavior under test: the child gets what remains. + { timeout: 8_000 }, + async ({ processes }) => { + const started = performance.now(); + const failure: unknown = await processes + .run(process.execPath, [ + "-e", + "process.stdout.write('started'); setInterval(() => {}, 1_000);", + ]) + .catch((error: unknown) => error); + expect(failure).toMatchObject({ + message: expect.stringMatching( + /^Test process exceeded its \d+ms deadline$/u, + ), + cause: { stdout: "started" }, + }); + expect(performance.now() - started).toBeLessThan(6_000); + }, +); diff --git a/tests/support/process/processFixture.ts b/tests/support/process/processFixture.ts index 496ab20ec4..628111fd0a 100644 --- a/tests/support/process/processFixture.ts +++ b/tests/support/process/processFixture.ts @@ -8,6 +8,26 @@ import { import { test as base } from "vitest"; const SHUTDOWN_GRACE_MS = 1_000; +/** Minimum time kept back from a test deadline to stop a child and report it. */ +const MIN_REPORT_RESERVE_MS = 5_000; + +/** + * Default child deadline: the test's remaining deadline, less a reserve for + * stopping the child and reporting its captured output. A hung child then fails + * with its output instead of an opaque test timeout, without a fixed value that + * assumes a host's speed. Undefined when the test itself has no deadline. + */ +export const remainingProcessDeadline = ( + testDeadlineMs: number, + elapsedMs: number, +): number | undefined => { + if (!(testDeadlineMs > 0)) return undefined; + const reserve = Math.max(MIN_REPORT_RESERVE_MS, testDeadlineMs / 10); + return Math.max( + SHUTDOWN_GRACE_MS, + Math.floor(testDeadlineMs - elapsedMs - reserve), + ); +}; /** Captured output and termination state from one child process. */ export interface ProcessResult { @@ -70,13 +90,17 @@ const stopChild = async ( return waitForExit(child, SHUTDOWN_GRACE_MS); }; -/** Create a child-process registry for a fixture composition root. */ +/** + * Create a child-process registry for a fixture composition root. A run without + * its own `timeoutMs` uses `defaultTimeoutMs`, when supplied. + */ export const createTestProcessRegistry = ( spawnChild: ( command: string, arguments_: readonly string[], options: SpawnOptionsWithoutStdio, ) => ChildProcessWithoutNullStreams = nodeSpawn, + defaultTimeoutMs?: () => number | undefined, ): TestProcesses => { const children = new Set(); @@ -98,7 +122,11 @@ export const createTestProcessRegistry = ( return { spawn, run: async (command, arguments_, options = {}) => { - const { input, timeoutMs, ...spawnOptions } = options; + const { + input, + timeoutMs = defaultTimeoutMs?.(), + ...spawnOptions + } = options; const child = spawn(command, arguments_, spawnOptions); const stdout: Buffer[] = []; const stderr: Buffer[] = []; @@ -196,11 +224,16 @@ export const createTestProcessRegistry = ( }; }; -/** Vitest API extended with a bounded child-process registry. */ +/** + * Vitest API extended with a child-process registry whose runs are bounded by + * the remaining test deadline unless they choose their own. + */ export const processTest = base.extend<{ processes: TestProcesses }>({ - // oxlint-disable-next-line no-empty-pattern -- Vitest parses fixture dependencies from object destructuring. - processes: async ({}, use) => { - const processes = createTestProcessRegistry(); + processes: async ({ task }, use) => { + const started = performance.now(); + const processes = createTestProcessRegistry(nodeSpawn, () => + remainingProcessDeadline(task.timeout, performance.now() - started), + ); try { await use(processes); } finally {