* test: strengthen integration oracles and verification * test: bound lifecycle gates and strengthen workflow checks * test: verify complete paged and extracted results * test: preserve bytecode and cancellation evidence * test: strengthen retained evidence and fixture cleanup * test: require cross-projection bridge identities * test: retain fixture owners through cleanup failures * test: verify graph examples and project selectors * test: tighten provider and lifecycle regressions * test: consolidate hex coverage and bound cutter cleanup * test: bound subprocess output waits and strengthen graph checks * test: check mcp recovery and preserve cleanup owners * test: verify structured mcp argument failures * test: preserve artifact cleanup and gdb limit evidence * test: preserve evidence identities and bound provider gates * test: retain browser and comparison cleanup owners * test: bound startup and strengthen fixture oracles * test: bound lifecycle gates and verify recovered evidence * test: verify public outcomes and lifecycle ownership * test: assert precise evidence and release lifecycle gates * test: verify selected facts and bound cleanup waits * test: check retained evidence and decoded records * test: verify final reports and retain cleanup ownership * test: bound reviewed startup gates and retain cleanup * test: verify PE errors and preserve MCP cleanup * test: preserve MCP fixture cleanup ownership * test: release provider gates before late startup
211 lines
6.6 KiB
TypeScript
211 lines
6.6 KiB
TypeScript
import {
|
|
spawn as nodeSpawn,
|
|
type ChildProcess,
|
|
type ChildProcessWithoutNullStreams,
|
|
type SpawnOptionsWithoutStdio,
|
|
} from "node:child_process";
|
|
|
|
import { test as base } from "vitest";
|
|
|
|
const SHUTDOWN_GRACE_MS = 1_000;
|
|
|
|
/** Captured output and termination state from one child process. */
|
|
export interface ProcessResult {
|
|
readonly exitCode: number | null;
|
|
readonly signal: NodeJS.Signals | null;
|
|
readonly stdout: string;
|
|
readonly stderr: string;
|
|
}
|
|
|
|
/** Options for a bounded, output-capturing child process invocation. */
|
|
export interface ProcessRunOptions extends SpawnOptionsWithoutStdio {
|
|
readonly timeoutMs?: number;
|
|
readonly input?: string | Uint8Array;
|
|
}
|
|
|
|
/** Registry for child processes owned by one test. */
|
|
export interface TestProcesses {
|
|
spawn(
|
|
command: string,
|
|
arguments_: readonly string[],
|
|
options?: SpawnOptionsWithoutStdio,
|
|
): ChildProcessWithoutNullStreams;
|
|
run(
|
|
command: string,
|
|
arguments_: readonly string[],
|
|
options?: ProcessRunOptions,
|
|
): Promise<ProcessResult>;
|
|
shutdown(): Promise<void>;
|
|
}
|
|
|
|
/** Wait for a child process to exit within a timeout, reporting completion. */
|
|
export const waitForExit = (
|
|
child: ChildProcess,
|
|
timeoutMs: number,
|
|
): Promise<boolean> =>
|
|
new Promise((resolve) => {
|
|
if (child.exitCode !== null || child.signalCode !== null) {
|
|
resolve(true);
|
|
return;
|
|
}
|
|
const timer = setTimeout(() => {
|
|
child.removeListener("exit", onExit);
|
|
resolve(false);
|
|
}, timeoutMs);
|
|
timer.unref();
|
|
const onExit = (): void => {
|
|
clearTimeout(timer);
|
|
resolve(true);
|
|
};
|
|
child.once("exit", onExit);
|
|
});
|
|
|
|
const stopChild = async (
|
|
child: ChildProcessWithoutNullStreams,
|
|
): Promise<boolean> => {
|
|
if (child.exitCode !== null || child.signalCode !== null) return true;
|
|
child.kill("SIGTERM");
|
|
if (await waitForExit(child, SHUTDOWN_GRACE_MS)) return true;
|
|
child.kill("SIGKILL");
|
|
return waitForExit(child, SHUTDOWN_GRACE_MS);
|
|
};
|
|
|
|
/** Create a child-process registry for a fixture composition root. */
|
|
export const createTestProcessRegistry = (
|
|
spawnChild: (
|
|
command: string,
|
|
arguments_: readonly string[],
|
|
options: SpawnOptionsWithoutStdio,
|
|
) => ChildProcessWithoutNullStreams = nodeSpawn,
|
|
): TestProcesses => {
|
|
const children = new Set<ChildProcessWithoutNullStreams>();
|
|
|
|
const spawn = (
|
|
command: string,
|
|
arguments_: readonly string[],
|
|
options: SpawnOptionsWithoutStdio = {},
|
|
): ChildProcessWithoutNullStreams => {
|
|
const child = spawnChild(command, arguments_, {
|
|
...options,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
children.add(child);
|
|
child.once("exit", () => children.delete(child));
|
|
child.once("close", () => children.delete(child));
|
|
return child;
|
|
};
|
|
|
|
return {
|
|
spawn,
|
|
run: async (command, arguments_, options = {}) => {
|
|
const { input, timeoutMs, ...spawnOptions } = options;
|
|
const child = spawn(command, arguments_, spawnOptions);
|
|
const stdout: Buffer[] = [];
|
|
const stderr: Buffer[] = [];
|
|
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
|
|
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
|
|
if (input === undefined) child.stdin.end();
|
|
else child.stdin.end(input);
|
|
|
|
let timedOut = false;
|
|
let killTimeout: NodeJS.Timeout | undefined;
|
|
let closeTimeout: NodeJS.Timeout | undefined;
|
|
let rejectUnclosed: ((error: Error) => void) | undefined;
|
|
const captured = (): ProcessResult => ({
|
|
exitCode: child.exitCode,
|
|
signal: child.signalCode,
|
|
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
});
|
|
const timeoutFailure = (): Error =>
|
|
new Error(`Test process exceeded its ${timeoutMs}ms deadline`, {
|
|
cause: captured(),
|
|
});
|
|
const timeout =
|
|
timeoutMs === undefined
|
|
? undefined
|
|
: setTimeout(() => {
|
|
timedOut = true;
|
|
if (child.exitCode === null && child.signalCode === null) {
|
|
child.kill("SIGTERM");
|
|
}
|
|
killTimeout = setTimeout(() => {
|
|
if (child.exitCode === null && child.signalCode === null) {
|
|
child.kill("SIGKILL");
|
|
}
|
|
closeTimeout = setTimeout(() => {
|
|
// An inherited output pipe may stay open after the leader exits.
|
|
rejectUnclosed?.(timeoutFailure());
|
|
child.stdout.destroy();
|
|
child.stderr.destroy();
|
|
}, SHUTDOWN_GRACE_MS);
|
|
closeTimeout.unref();
|
|
}, SHUTDOWN_GRACE_MS);
|
|
killTimeout.unref();
|
|
}, timeoutMs);
|
|
timeout?.unref();
|
|
|
|
const result = await new Promise<ProcessResult>((resolve, reject) => {
|
|
rejectUnclosed = reject;
|
|
child.once("error", reject);
|
|
child.once("close", (exitCode, signal) =>
|
|
resolve({
|
|
exitCode,
|
|
signal,
|
|
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
}),
|
|
);
|
|
}).finally(() => {
|
|
if (timeout !== undefined) clearTimeout(timeout);
|
|
if (killTimeout !== undefined) clearTimeout(killTimeout);
|
|
if (closeTimeout !== undefined) clearTimeout(closeTimeout);
|
|
});
|
|
if (timedOut) {
|
|
throw new Error(`Test process exceeded its ${timeoutMs}ms deadline`, {
|
|
cause: result,
|
|
});
|
|
}
|
|
return result;
|
|
},
|
|
shutdown: async () => {
|
|
const results = await Promise.allSettled(
|
|
[...children].reverse().map(async (child) => {
|
|
if (!(await stopChild(child))) {
|
|
throw new Error(
|
|
`test child process ${String(child.pid)} did not exit after SIGKILL`,
|
|
);
|
|
}
|
|
}),
|
|
);
|
|
const failures = results.flatMap((result) =>
|
|
result.status === "rejected" ? [result.reason] : [],
|
|
);
|
|
if (children.size > 0) {
|
|
failures.push(
|
|
new Error(`${children.size} test child process(es) leaked`),
|
|
);
|
|
}
|
|
if (failures.length > 0) {
|
|
throw new AggregateError(
|
|
failures,
|
|
"Test child process shutdown failed",
|
|
);
|
|
}
|
|
},
|
|
};
|
|
};
|
|
|
|
/** Vitest API extended with a bounded child-process registry. */
|
|
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();
|
|
try {
|
|
await use(processes);
|
|
} finally {
|
|
await processes.shutdown();
|
|
}
|
|
},
|
|
});
|