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

+169
View File
@@ -0,0 +1,169 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
/**
* Real Docker Engine helpers: uniquely named, labelled test containers with guaranteed cleanup.
*
* Every container is named `dpsapi-e2e-<runId>-<testKey>-<suffix>` and labelled
* `dpsapi.e2e=<runId>` + `dpsapi.e2e.test=<testKey>`. Assertions must only ever look at these
* names/labels — the host runs unrelated containers.
*/
import { ALPINE_IMAGE, LABEL_RUN, LABEL_TEST, RESOURCE_PREFIX, runId } from './env';
import { HarnessError } from './errors';
import { mapLimit, runProcess, toolEnv, type ProcResult } from './proc';
export interface DockerCliOptions {
/** Default 60 s. */
timeoutMs?: number;
/** Return non-zero results instead of throwing HarnessError('infrastructure'). */
allowFailure?: boolean;
input?: string | Buffer;
}
/** Runs the docker CLI. Non-zero exit is a HarnessError unless allowFailure. */
export async function dockerCli(args: string[], opts: DockerCliOptions = {}): Promise<ProcResult> {
const r = await runProcess('docker', args, { env: toolEnv(), timeoutMs: opts.timeoutMs ?? 60_000, input: opts.input, label: `docker ${args[0]}` });
if (r.exitCode !== 0 && !opts.allowFailure) {
throw new HarnessError('infrastructure', `docker ${args.join(' ')} failed (${r.exitCode}): ${r.stderr.trim()}`, r);
}
return r;
}
export interface TestContainer {
id: string;
name: string;
image: string;
labels: Record<string, string>;
command: string[];
}
export interface RunContainerOptions {
/** Name suffix (default c<n>); final name dpsapi-e2e-<runId>-<testKey>-<suffix>. */
suffix?: string;
/** Default alpine:3.22. */
image?: string;
/** Default ['sleep', '3600']. */
command?: string[];
/** Extra labels (the run/test labels are always added). */
labels?: Record<string, string>;
env?: Record<string, string>;
/** --init so SIGTERM stops sleep-style commands promptly (default true). */
init?: boolean;
/** Default 'none'. */
network?: string;
extraArgs?: string[];
}
export interface OwnContainer {
id: string;
name: string;
state: string;
labels: Record<string, string>;
}
let seq = 0;
export class RealDocker {
readonly runId = runId();
/** Prefix of every container name created through this instance. */
readonly namePrefix: string;
private readonly created = new Set<string>();
constructor(readonly testKey: string) {
this.namePrefix = `${RESOURCE_PREFIX}${this.runId}-${testKey}-`;
}
/** Full container name for a suffix. */
name(suffix: string): string {
return this.namePrefix + suffix;
}
/** Run + test labels (+ extra). */
labels(extra: Record<string, string> = {}): Record<string, string> {
return { [LABEL_RUN]: this.runId, [LABEL_TEST]: this.testKey, ...extra };
}
/** `docker run -d` a labelled container and return its identity. */
async run(opts: RunContainerOptions = {}): Promise<TestContainer> {
const suffix = opts.suffix ?? `c${++seq}`;
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(suffix)) throw new HarnessError('infrastructure', `invalid container suffix ${suffix}`);
const name = this.name(suffix);
const image = opts.image ?? ALPINE_IMAGE;
const command = opts.command ?? ['sleep', '3600'];
const labels = this.labels(opts.labels);
const args = ['run', '-d', '--name', name, '--network', opts.network ?? 'none'];
if (opts.init ?? true) args.push('--init');
for (const [k, v] of Object.entries(labels)) args.push('--label', `${k}=${v}`);
for (const [k, v] of Object.entries(opts.env ?? {})) args.push('-e', `${k}=${v}`);
args.push(...(opts.extraArgs ?? []), image, ...command);
this.created.add(name);
const r = await dockerCli(args);
return { id: r.stdout.trim(), name, image, labels, command };
}
/** Starts n containers with bounded concurrency; optsFor(i) customizes each. */
runMany(n: number, optsFor: (i: number) => RunContainerOptions = () => ({}), concurrency = 6): Promise<TestContainer[]> {
return mapLimit(
Array.from({ length: n }, (_, i) => i),
concurrency,
(i) => this.run(optsFor(i)),
);
}
/** docker stop -t <timeoutSec> (default 2). */
async stop(c: TestContainer | string, timeoutSec = 2): Promise<void> {
await dockerCli(['stop', '-t', String(timeoutSec), typeof c === 'string' ? c : c.name]);
}
async start(c: TestContainer | string): Promise<void> {
await dockerCli(['start', typeof c === 'string' ? c : c.name]);
}
/** docker rm (-f by default). */
async remove(c: TestContainer | string, force = true): Promise<void> {
await dockerCli(['rm', ...(force ? ['-f'] : []), typeof c === 'string' ? c : c.name]);
}
async inspect(c: TestContainer | string): Promise<Record<string, unknown>> {
const r = await dockerCli(['inspect', typeof c === 'string' ? c : c.name]);
return (JSON.parse(r.stdout) as Array<Record<string, unknown>>)[0];
}
/** Containers (any state by default) carrying this test's label. */
async listOwn({ all = true }: { all?: boolean } = {}): Promise<OwnContainer[]> {
return listByLabel(`${LABEL_TEST}=${this.testKey}`, all);
}
/** Full IDs of this test's running containers (what `docker ps` would show). */
async runningIds(): Promise<string[]> {
return (await this.listOwn({ all: false })).map((c) => c.id);
}
/** Force-removes every container carrying this test's label (and any name created here). Returns removed names. */
async cleanup(): Promise<string[]> {
const own = await this.listOwn();
const names = new Set([...own.map((c) => c.name)]);
if (names.size === 0) return [];
await dockerCli(['rm', '-f', '-v', ...names], { allowFailure: true, timeoutMs: 120_000 });
const left = await this.listOwn();
if (left.length) throw new HarnessError('leak', `containers survived cleanup: ${left.map((c) => c.name).join(', ')}`);
return [...names];
}
}
/** docker ps [-a] --filter label=<label> (label 'k=v' or 'k'). */
export async function listByLabel(label: string, all = true): Promise<OwnContainer[]> {
const r = await dockerCli(['ps', ...(all ? ['-a'] : []), '--no-trunc', '--filter', `label=${label}`, '--format', '{{json .}}']);
return r.stdout
.split('\n')
.filter((l) => l.trim())
.map((l) => {
const j = JSON.parse(l) as { ID: string; Names: string; State: string; Labels: string };
const labels: Record<string, string> = {};
for (const kv of j.Labels ? j.Labels.split(',') : []) {
const i = kv.indexOf('=');
if (i > 0) labels[kv.slice(0, i)] = kv.slice(i + 1);
}
return { id: j.ID, name: j.Names, state: j.State, labels };
});
}