test(e2e): add playwright e2e harness
Process and API-only Playwright harness: binary and image setup, server/CLI runners, fake Docker Engine socket, read-only database inspection, secret redaction in reports, seeded fuzzing, leak detection and harness self-checks.
This commit is contained in:
1 parent
a02eef13a7
commit
fe0cd4bf6d
33 files changed
+6370
No files matched your search
@@ -0,0 +1,216 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
/**
|
||||
* Process spawning with mandatory timeouts, exact argv (never a shell unless asked),
|
||||
* and leak tracking.
|
||||
*/
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { HarnessError } from './errors';
|
||||
import { PROC_MARKER_ENV, runId } from './env';
|
||||
|
||||
export interface ProcResult {
|
||||
/** Executable that was run. */
|
||||
command: string;
|
||||
/** Arguments exactly as passed (not including the command). */
|
||||
argv: string[];
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
stdoutBuffer: Buffer;
|
||||
stderrBuffer: Buffer;
|
||||
/** Exit status, or null when terminated by a signal. */
|
||||
exitCode: number | null;
|
||||
/** Terminating signal, or null on a normal exit. */
|
||||
signal: NodeJS.Signals | null;
|
||||
/** True when the harness killed the process because its deadline passed. */
|
||||
timedOut: boolean;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface RunProcessOptions {
|
||||
/** Complete environment of the child (nothing is inherited implicitly). */
|
||||
env: NodeJS.ProcessEnv;
|
||||
cwd?: string;
|
||||
/** Hard deadline; the whole process group is SIGKILLed when it passes. Default 30 s. */
|
||||
timeoutMs?: number;
|
||||
/** Data written to stdin (stdin is /dev/null otherwise). */
|
||||
input?: string | Buffer;
|
||||
/** Return (instead of throwing HarnessError) when the deadline passes. */
|
||||
allowTimeout?: boolean;
|
||||
/** Return (instead of throwing HarnessError) when the process dies from a signal. */
|
||||
allowSignal?: boolean;
|
||||
/** Human label used in error messages. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_PROCESS_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** Processes spawned by this worker that have not been observed to exit yet. */
|
||||
const live = new Map<number, { label: string; child: ChildProcess }>();
|
||||
|
||||
/** Snapshot of processes spawned by this worker that are still running. */
|
||||
export function liveProcesses(): Array<{ pid: number; label: string }> {
|
||||
return [...live.entries()].map(([pid, v]) => ({ pid, label: v.label }));
|
||||
}
|
||||
|
||||
/** Registers a long-running child (e.g. a server) for leak tracking; returns an unregister fn. */
|
||||
export function trackProcess(child: ChildProcess, label: string): void {
|
||||
if (child.pid === undefined) return;
|
||||
const pid = child.pid;
|
||||
live.set(pid, { label, child });
|
||||
child.once('exit', () => live.delete(pid));
|
||||
child.once('error', () => live.delete(pid));
|
||||
}
|
||||
|
||||
/** Kills the whole process group of a detached child (best effort). */
|
||||
export function killGroup(pid: number, signal: NodeJS.Signals = 'SIGKILL'): void {
|
||||
try {
|
||||
process.kill(-pid, signal);
|
||||
} catch {
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Environment for harness-spawned helper tools (docker, curl, sh): inherits the worker env plus the leak marker. */
|
||||
export function toolEnv(extra: Record<string, string | undefined> = {}): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, [PROC_MARKER_ENV]: runId() };
|
||||
for (const [k, v] of Object.entries(extra)) {
|
||||
if (v === undefined) delete env[k];
|
||||
else env[k] = v;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a command to completion. argv is passed verbatim (no shell).
|
||||
* Throws HarnessError('spawn') if it cannot start, ('timeout') on deadline,
|
||||
* ('signal') on signal termination, unless explicitly allowed.
|
||||
*/
|
||||
export function runProcess(command: string, argv: string[], opts: RunProcessOptions): Promise<ProcResult> {
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS;
|
||||
const label = opts.label ?? command;
|
||||
const started = performance.now();
|
||||
return new Promise<ProcResult>((resolve, reject) => {
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
child = spawn(command, argv, {
|
||||
env: opts.env,
|
||||
cwd: opts.cwd,
|
||||
stdio: [opts.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
||||
detached: true, // own process group: the deadline kills descendants too
|
||||
shell: false,
|
||||
});
|
||||
} catch (e) {
|
||||
reject(new HarnessError('spawn', `cannot spawn ${label}: ${(e as Error).message}`));
|
||||
return;
|
||||
}
|
||||
trackProcess(child, label);
|
||||
const out: Buffer[] = [];
|
||||
const err: Buffer[] = [];
|
||||
let timedOut = false;
|
||||
let spawnError: Error | undefined;
|
||||
child.stdout!.on('data', (d: Buffer) => out.push(d));
|
||||
child.stderr!.on('data', (d: Buffer) => err.push(d));
|
||||
if (opts.input !== undefined) {
|
||||
child.stdin!.on('error', () => {
|
||||
/* EPIPE when the child does not read stdin: irrelevant */
|
||||
});
|
||||
child.stdin!.end(opts.input);
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
if (child.pid !== undefined) killGroup(child.pid, 'SIGKILL');
|
||||
}, timeoutMs);
|
||||
child.on('error', (e) => {
|
||||
spawnError = e;
|
||||
});
|
||||
child.on('close', (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
// Also reap any descendants that kept running after the main process exited
|
||||
if (child.pid !== undefined) killGroup(child.pid, 'SIGKILL');
|
||||
const stdoutBuffer = Buffer.concat(out);
|
||||
const stderrBuffer = Buffer.concat(err);
|
||||
const result: ProcResult = {
|
||||
command,
|
||||
argv: [...argv],
|
||||
stdout: stdoutBuffer.toString('utf8'),
|
||||
stderr: stderrBuffer.toString('utf8'),
|
||||
stdoutBuffer,
|
||||
stderrBuffer,
|
||||
exitCode: code,
|
||||
signal,
|
||||
timedOut,
|
||||
durationMs: performance.now() - started,
|
||||
};
|
||||
if (spawnError) {
|
||||
// Node reports spawn failures (ENOENT, E2BIG, EACCES) as a negative errno exit code
|
||||
reject(new HarnessError('spawn', `cannot spawn ${label}: ${spawnError.message}`, result));
|
||||
return;
|
||||
}
|
||||
if (timedOut && !opts.allowTimeout) {
|
||||
reject(
|
||||
new HarnessError(
|
||||
'timeout',
|
||||
`${label} did not finish within ${timeoutMs} ms and was killed\n--- stdout (tail)\n${tail(result.stdout)}\n--- stderr (tail)\n${tail(result.stderr)}`,
|
||||
result,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!timedOut && signal !== null && !opts.allowSignal) {
|
||||
reject(
|
||||
new HarnessError(
|
||||
'signal',
|
||||
`${label} was terminated by unexpected signal ${signal}\n--- stderr (tail)\n${tail(result.stderr)}`,
|
||||
result,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Last n characters of a string, for error messages. */
|
||||
export function tail(s: string, n = 2000): string {
|
||||
return s.length <= n ? s : '…' + s.slice(-n);
|
||||
}
|
||||
|
||||
/** Runs async tasks with bounded concurrency, preserving result order. */
|
||||
export async function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
|
||||
const results = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
||||
while (true) {
|
||||
const i = next++;
|
||||
if (i >= items.length) return;
|
||||
results[i] = await fn(items[i], i);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Polls `probe` until it returns a non-undefined value or the deadline passes (HarnessError 'timeout'). */
|
||||
export async function pollUntil<T>(
|
||||
what: string,
|
||||
probe: () => Promise<T | undefined> | T | undefined,
|
||||
{ timeoutMs = 10_000, intervalMs = 25 }: { timeoutMs?: number; intervalMs?: number } = {},
|
||||
): Promise<T> {
|
||||
const deadline = performance.now() + timeoutMs;
|
||||
while (true) {
|
||||
const v = await probe();
|
||||
if (v !== undefined) return v;
|
||||
if (performance.now() > deadline) throw new HarnessError('timeout', `timed out after ${timeoutMs} ms waiting for ${what}`);
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
Reference in new issue
Block a user