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:
bryanpedini committed 2026-10-06 10:52:25 +02:00
1 parent a02eef13a7
commit fe0cd4bf6d
33 files changed
+6370

No files matched your search

+216
View File
@@ -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));
}