Files
dpsapi/e2e/harness/workspace.ts
T
bryanpedini fe0cd4bf6d 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.
2026-10-06 10:52:25 +02:00

121 lines
4.9 KiB
TypeScript

// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
/**
* Per-test temporary workspaces and TCP port allocation.
*/
import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import { ALPINE_IMAGE, LABEL_RUN, RESOURCE_PREFIX, runId, workspacesDir } from './env';
import { HarnessError } from './errors';
import { runProcess, toolEnv } from './proc';
export class Workspace {
/** Default SQLite path for the test (`<dir>/dpsapi.db`); nothing is created there by the harness. */
readonly dbPath: string;
constructor(readonly dir: string) {
this.dbPath = path.join(dir, 'dpsapi.db');
}
/** Creates a fresh workspace directory under the run's workspaces dir. */
static create(key: string): Workspace {
fs.mkdirSync(workspacesDir(), { recursive: true });
const dir = fs.mkdtempSync(path.join(workspacesDir(), `${key}-`));
return new Workspace(dir);
}
/** Absolute path inside the workspace (not created). */
path(...parts: string[]): string {
return path.join(this.dir, ...parts);
}
/** Creates (recursively) and returns a directory inside the workspace. */
mkdir(...parts: string[]): string {
const p = this.path(...parts);
fs.mkdirSync(p, { recursive: true });
return p;
}
writeFile(name: string, data: string | Buffer, mode?: number): string {
const p = this.path(name);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, data, mode === undefined ? undefined : { mode });
return p;
}
/** Removes the workspace, falling back to a throwaway container for root-owned files. */
async remove(): Promise<void> {
await forceRemoveDir(this.dir);
}
}
/**
* Removes a directory tree. Files written by containers running as root cannot be removed by
* the host user; for those, the contents are deleted from a throwaway, labelled alpine container.
*/
export async function forceRemoveDir(dir: string): Promise<void> {
try {
fs.rmSync(dir, { recursive: true, force: true });
return;
} catch (e) {
const code = (e as NodeJS.ErrnoException).code;
if (code !== 'EACCES' && code !== 'EPERM' && code !== 'ENOTEMPTY') throw e;
}
const name = `${RESOURCE_PREFIX}${runId()}-rm-${Math.random().toString(36).slice(2, 10)}`;
const r = await runProcess(
'docker',
['run', '--rm', '--name', name, '--label', `${LABEL_RUN}=${runId()}`, '--network', 'none', '-v', `${dir}:/victim`, ALPINE_IMAGE, 'sh', '-c', 'find /victim -mindepth 1 -delete'],
{ env: toolEnv(), timeoutMs: 60_000, label: 'docker cleanup container' },
);
if (r.exitCode !== 0) throw new HarnessError('infrastructure', `cannot clean ${dir} via container: ${r.stderr}`);
fs.rmSync(dir, { recursive: true, force: true });
}
/** Lowest port used by the allocator; windows of PORT_WINDOW ports per parallel slot, below the ephemeral range. */
const PORT_BASE = 20_000;
const PORT_WINDOW = 500;
const PORT_SLOTS = 24; // 20000..31999, below Linux's default ephemeral range (32768+)
let portCursor = 0;
/** True when `port` can currently be bound on every local address (checked dual-stack). */
export function isPortFree(port: number): Promise<boolean> {
const tryListen = (host: string, ipv6Only: boolean) =>
new Promise<'free' | 'busy' | 'unsupported'>((resolve) => {
const srv = net.createServer();
srv.once('error', (e: NodeJS.ErrnoException) => {
resolve(e.code === 'EADDRINUSE' || e.code === 'EACCES' ? 'busy' : 'unsupported');
});
srv.listen({ host, port, ipv6Only, exclusive: true }, () => srv.close(() => resolve('free')));
});
return tryListen('::', false).then(async (r) => {
if (r === 'unsupported') return (await tryListen('0.0.0.0', false)) === 'free';
return r === 'free';
});
}
/**
* Allocates a currently free TCP port. Concurrent workers draw from disjoint windows keyed
* by their parallel index, so two running tests never get the same port from the allocator;
* the bind check additionally skips ports used by anything else on the host.
*/
export async function allocatePort(parallelIndex: number): Promise<number> {
const slot = parallelIndex % PORT_SLOTS;
for (let attempt = 0; attempt < PORT_WINDOW; attempt++) {
const port = PORT_BASE + slot * PORT_WINDOW + (portCursor++ % PORT_WINDOW);
if (await isPortFree(port)) return port;
}
throw new HarnessError('infrastructure', `no free port in window of parallel slot ${slot}`);
}
/** Holds a port busy (listening socket) until close() — for "address already in use" tests. */
export async function occupyPort(port: number, host = '127.0.0.1'): Promise<{ close: () => Promise<void> }> {
const srv = net.createServer((s) => s.destroy());
await new Promise<void>((resolve, reject) => {
srv.once('error', reject);
srv.listen({ host, port, exclusive: true }, () => resolve());
});
return { close: () => new Promise<void>((r) => srv.close(() => r())) };
}