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.
170 lines
6.5 KiB
TypeScript
170 lines
6.5 KiB
TypeScript
// 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 };
|
|
});
|
|
}
|