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

+525
View File
@@ -0,0 +1,525 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
/**
* HTTP clients: Playwright APIRequestContext, real curl, and a raw TCP socket.
*/
import fs from 'node:fs';
import net from 'node:net';
import type { APIRequestContext } from '@playwright/test';
import { HarnessError } from './errors';
import { runProcess, toolEnv } from './proc';
import type { Recorder } from './recorder';
import type { Workspace } from './workspace';
// ---------------------------------------------------------------------------------------------
// Shared types
// ---------------------------------------------------------------------------------------------
export interface HeaderEntry {
name: string;
value: string;
}
export interface HttpResult {
client: 'playwright' | 'curl';
method: string;
url: string;
status: number;
statusText: string;
/** Lower-cased header names; repeated headers joined with ', '. */
headers: Record<string, string>;
/** Every header line in order, names as received. */
headersArray: HeaderEntry[];
body: string;
bodyBuffer: Buffer;
/** JSON.parse(body) (throws when the body is not JSON). */
json<T = unknown>(): T;
durationMs: number;
}
export function bearer(token: string): Record<string, string> {
return { Authorization: `Bearer ${token}` };
}
function headersRecord(arr: HeaderEntry[]): Record<string, string> {
const out: Record<string, string> = {};
for (const { name, value } of arr) {
const k = name.toLowerCase();
out[k] = k in out ? `${out[k]}, ${value}` : value;
}
return out;
}
function withJson<T extends { body: string }>(r: T): T & { json<J = unknown>(): J } {
return Object.assign(r, {
json<J = unknown>(): J {
return JSON.parse(r.body) as J;
},
});
}
// ---------------------------------------------------------------------------------------------
// Raw socket
// ---------------------------------------------------------------------------------------------
export interface RawHttpResponse {
httpVersion: string;
status: number;
statusText: string;
headers: Array<[string, string]>;
/** First value of a header (case-insensitive). */
header(name: string): string | undefined;
/** Every value of a header (case-insensitive). */
headerAll(name: string): string[];
body: Buffer;
bodyText: string;
}
/**
* - response: at least one complete response was parsed;
* - closed: the server closed the connection without a complete response;
* - reset: the connection was reset (ECONNRESET/EPIPE) without a complete response;
* - refused: nothing listening (ECONNREFUSED);
* - timeout: only returned when allowTimeout is set (otherwise HarnessError).
*/
export type RawOutcome = 'response' | 'closed' | 'reset' | 'refused' | 'timeout';
export interface RawResult {
outcome: RawOutcome;
responses: RawHttpResponse[];
/** responses[0] */
response?: RawHttpResponse;
/** Every byte received. */
raw: Buffer;
/** True when the server closed the connection (FIN) before the harness did. */
closedByServer: boolean;
error?: string;
durationMs: number;
}
export interface RawHttpOptions {
host: string;
port: number;
/** Bytes to send. An array is sent as separate writes `chunkDelayMs` apart. */
data: string | Buffer | Array<string | Buffer>;
chunkDelayMs?: number;
/** Default 10 s. */
timeoutMs?: number;
/** Resolve once this many complete responses were parsed (default 1). */
expectResponses?: number;
/** Keep reading until the server closes (default false). */
waitForClose?: boolean;
/** Half-close (FIN) after writing (default false). */
endAfterWrite?: boolean;
/** Return outcome 'timeout' instead of throwing HarnessError. */
allowTimeout?: boolean;
/** Treat responses as bodiless (HEAD). Default: data starts with "HEAD ". */
head?: boolean;
}
/** Parses as many complete HTTP/1.x responses as `buf` contains. `eof` completes a read-until-close body. */
export function parseHttpResponses(buf: Buffer, eof: boolean, head = false): { responses: RawHttpResponse[]; rest: Buffer } {
const responses: RawHttpResponse[] = [];
let off = 0;
while (off < buf.length) {
const hdrEnd = buf.indexOf('\r\n\r\n', off);
if (hdrEnd < 0) break;
const headText = buf.subarray(off, hdrEnd).toString('latin1');
const [statusLine, ...headerLines] = headText.split('\r\n');
const m = /^(HTTP\/\d(?:\.\d)?) (\d{3})(?: (.*))?$/.exec(statusLine);
if (!m) throw new HarnessError('protocol', `unparseable status line ${JSON.stringify(statusLine)}`);
const headers: Array<[string, string]> = headerLines.map((l) => {
const i = l.indexOf(':');
if (i < 0) throw new HarnessError('protocol', `unparseable header line ${JSON.stringify(l)}`);
return [l.slice(0, i), l.slice(i + 1).trim()];
});
const get = (n: string) => headers.filter(([k]) => k.toLowerCase() === n.toLowerCase()).map(([, v]) => v);
const status = Number(m[2]);
let bodyStart = hdrEnd + 4;
let body: Buffer;
let next: number;
const noBody = (head && responses.length === 0) || (status >= 100 && status < 200) || status === 204 || status === 304;
if (noBody) {
body = Buffer.alloc(0);
next = bodyStart;
} else if (get('transfer-encoding').some((v) => v.toLowerCase().includes('chunked'))) {
const chunks: Buffer[] = [];
let p = bodyStart;
let done = false;
while (true) {
const le = buf.indexOf('\r\n', p);
if (le < 0) break;
const size = parseInt(buf.subarray(p, le).toString('latin1').split(';')[0], 16);
if (Number.isNaN(size)) throw new HarnessError('protocol', 'bad chunk size');
if (size === 0) {
const end = buf.indexOf('\r\n\r\n', le);
if (end < 0 && buf.length < le + 4) break;
p = end < 0 ? le + 4 : end + 4;
done = true;
break;
}
if (buf.length < le + 2 + size + 2) break;
chunks.push(buf.subarray(le + 2, le + 2 + size));
p = le + 2 + size + 2;
}
if (!done) break;
body = Buffer.concat(chunks);
next = p;
} else if (get('content-length').length > 0) {
const len = Number(get('content-length')[0]);
if (buf.length < bodyStart + len) break;
body = buf.subarray(bodyStart, bodyStart + len);
next = bodyStart + len;
} else {
if (!eof) break;
body = buf.subarray(bodyStart);
next = buf.length;
}
responses.push({
httpVersion: m[1],
status,
statusText: m[3] ?? '',
headers,
header: (n) => get(n)[0],
headerAll: (n) => get(n),
body: Buffer.from(body),
bodyText: body.toString('utf8'),
});
off = next;
bodyStart = next;
}
return { responses, rest: buf.subarray(off) };
}
/** Sends raw bytes over TCP and parses the HTTP response(s). */
export function rawHttp(opts: RawHttpOptions): Promise<RawResult> {
const timeoutMs = opts.timeoutMs ?? 10_000;
const expectResponses = opts.expectResponses ?? 1;
const chunks = (Array.isArray(opts.data) ? opts.data : [opts.data]).map((c) => (typeof c === 'string' ? Buffer.from(c, 'latin1') : c));
const head = opts.head ?? chunks[0]?.subarray(0, 5).toString('latin1') === 'HEAD ';
const started = performance.now();
return new Promise<RawResult>((resolve, reject) => {
const received: Buffer[] = [];
let closedByServer = false;
let settled = false;
const sock = net.connect({ host: opts.host, port: opts.port });
const finish = (outcome: RawOutcome | 'auto', error?: string) => {
if (settled) return;
settled = true;
clearTimeout(timer);
sock.destroy();
const raw = Buffer.concat(received);
let responses: RawHttpResponse[] = [];
try {
responses = parseHttpResponses(raw, closedByServer || outcome !== 'auto', head).responses;
} catch (e) {
reject(e);
return;
}
let o: RawOutcome = outcome === 'auto' ? 'response' : outcome;
if (responses.length > 0 && (o === 'closed' || o === 'reset')) o = 'response';
if (o === 'response' && responses.length === 0) o = 'closed';
const durationMs = performance.now() - started;
if (o === 'timeout' && !opts.allowTimeout) {
reject(
new HarnessError('timeout', `raw HTTP to ${opts.host}:${opts.port}: no complete response within ${timeoutMs} ms (received ${raw.length} bytes: ${JSON.stringify(raw.subarray(0, 300).toString('latin1'))})`),
);
return;
}
resolve({ outcome: o, responses, response: responses[0], raw, closedByServer, error, durationMs });
};
const timer = setTimeout(() => finish('timeout'), timeoutMs);
sock.on('connect', async () => {
try {
for (let i = 0; i < chunks.length; i++) {
if (i > 0 && opts.chunkDelayMs) await new Promise((r) => setTimeout(r, opts.chunkDelayMs));
if (sock.destroyed) return;
sock.write(chunks[i]);
}
if (opts.endAfterWrite) sock.end();
} catch {
/* surfaced via 'error' */
}
});
sock.on('data', (d: Buffer) => {
received.push(d);
if (opts.waitForClose) return;
try {
const { responses } = parseHttpResponses(Buffer.concat(received), false, head);
if (responses.length >= expectResponses) finish('auto');
} catch (e) {
settled = true;
clearTimeout(timer);
sock.destroy();
reject(e);
}
});
sock.on('end', () => {
closedByServer = true;
finish('closed');
});
sock.on('error', (e: NodeJS.ErrnoException) => {
if (e.code === 'ECONNREFUSED') finish('refused', e.code);
else if (e.code === 'ECONNRESET' || e.code === 'EPIPE') {
closedByServer = true;
finish('reset', e.code);
} else {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(new HarnessError('transport', `raw socket error to ${opts.host}:${opts.port}: ${e.message}`));
}
});
});
}
// ---------------------------------------------------------------------------------------------
// curl
// ---------------------------------------------------------------------------------------------
export interface CurlOptions {
url: string;
/** -X <method> (use head: true for HEAD so curl does not wait for a body). */
method?: string;
/** Raw header lines, repeatable, passed as `-H <line>` verbatim ('Name: value', 'Name;' = empty value, 'Name:' = remove). */
headers?: string[];
/** Bearer token shortcut: adds `-H 'Authorization: Bearer <token>'`. */
token?: string;
/** Request body via --data-binary @file (curl then defaults to POST unless method is set). */
data?: string | Buffer;
/** --path-as-is: send /./ and /../ segments unmodified. */
pathAsIs?: boolean;
/** -I (HEAD request). */
head?: boolean;
/** Default '1.1'. */
httpVersion?: '1.0' | '1.1';
/** curl --max-time in ms (default 15 s); exceeding it is a HarnessError unless allowTimeout. */
timeoutMs?: number;
allowTimeout?: boolean;
/** Extra raw arguments appended before the URL. */
extraArgs?: string[];
}
export interface CurlResult {
client: 'curl';
/** curl argv (body/header file paths are harness temp files). */
argv: string[];
/** curl's exit code (0 ok, 7 refused, 52 empty reply, 56 recv failure, ...). */
exitCode: number;
/** HTTP status (0 when no response). */
status: number;
httpVersion: string;
headers: Record<string, string>;
headersArray: HeaderEntry[];
body: string;
bodyBuffer: Buffer;
json<T = unknown>(): T;
stderr: string;
/** curl's own -w %{json} metadata (time_total etc.). */
writeOut: Record<string, unknown>;
durationMs: number;
}
let curlSeq = 0;
export async function runCurl(ws: Workspace, opts: CurlOptions): Promise<CurlResult> {
const n = ++curlSeq;
const bodyFile = ws.path(`.curl-${n}.body`);
const headFile = ws.path(`.curl-${n}.headers`);
const dataFile = ws.path(`.curl-${n}.data`);
const maxTimeMs = opts.timeoutMs ?? 15_000;
const argv = ['-sS', '--max-time', (maxTimeMs / 1000).toFixed(3), '-o', bodyFile, '-D', headFile, '-w', '%{json}'];
argv.push(opts.httpVersion === '1.0' ? '--http1.0' : '--http1.1');
if (opts.head) argv.push('-I');
if (opts.method) argv.push('-X', opts.method);
if (opts.token !== undefined) argv.push('-H', `Authorization: Bearer ${opts.token}`);
for (const h of opts.headers ?? []) argv.push('-H', h);
if (opts.data !== undefined) {
fs.writeFileSync(dataFile, opts.data);
argv.push('--data-binary', `@${dataFile}`);
}
if (opts.pathAsIs) argv.push('--path-as-is');
argv.push(...(opts.extraArgs ?? []), opts.url);
try {
const r = await runProcess('curl', argv, { env: toolEnv(), timeoutMs: maxTimeMs + 10_000, label: 'curl' });
let writeOut: Record<string, unknown> = {};
try {
writeOut = JSON.parse(r.stdout) as Record<string, unknown>;
} catch {
throw new HarnessError('protocol', `curl -w %{json} output unparseable: ${JSON.stringify(r.stdout.slice(0, 300))}`);
}
if (r.exitCode === 28 && !opts.allowTimeout) {
throw new HarnessError('timeout', `curl ${opts.url}: no response within ${maxTimeMs} ms (${r.stderr.trim()})`);
}
const bodyBuffer = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile) : Buffer.alloc(0);
const headText = fs.existsSync(headFile) ? fs.readFileSync(headFile, 'latin1') : '';
// -D may contain several blocks (1xx, redirects); keep the last
const blocks = headText.split(/\r\n\r\n/).filter((b) => b.trim() !== '');
const last = blocks[blocks.length - 1] ?? '';
const [statusLine, ...lines] = last.split('\r\n');
const sm = /^(HTTP\/[\d.]+) (\d{3})/.exec(statusLine ?? '');
const headersArray = lines.filter((l) => l.includes(':')).map((l) => ({ name: l.slice(0, l.indexOf(':')), value: l.slice(l.indexOf(':') + 1).trim() }));
// with -I curl writes the header block to the output file: a HEAD response has no body
const realBody = opts.head ? Buffer.alloc(0) : bodyBuffer;
const body = realBody.toString('utf8');
return withJson({
client: 'curl' as const,
argv,
exitCode: r.exitCode ?? -1,
status: Number(writeOut.http_code ?? (sm ? sm[2] : 0)),
httpVersion: sm?.[1] ?? '',
headers: headersRecord(headersArray),
headersArray,
body,
bodyBuffer: realBody,
stderr: r.stderr,
writeOut,
durationMs: r.durationMs,
});
} finally {
for (const f of [bodyFile, headFile, dataFile]) fs.rmSync(f, { force: true });
}
}
// ---------------------------------------------------------------------------------------------
// Client facade (bound to a test by the `http` fixture)
// ---------------------------------------------------------------------------------------------
export interface HttpRequestOptions {
method?: string;
headers?: Record<string, string>;
/** Bearer token shortcut (adds Authorization: Bearer <token>). */
token?: string;
data?: string | Buffer;
/** Default 30 s. Exceeding it is a HarnessError. */
timeoutMs?: number;
}
export class HttpClient {
constructor(
private readonly request: APIRequestContext,
private readonly ws: Workspace,
private readonly recorder: Recorder,
) {}
/**
* Playwright APIRequestContext request (no redirects followed, never throws on status).
* Transport failures (refused, reset, timeout) throw HarnessError('transport'/'timeout').
*/
async fetch(url: string, opts: HttpRequestOptions = {}): Promise<HttpResult> {
const method = opts.method ?? 'GET';
const headers = { ...(opts.token !== undefined ? bearer(opts.token) : {}), ...(opts.headers ?? {}) };
const started = performance.now();
const base = {
client: 'playwright' as const,
method,
url,
requestHeaders: Object.entries(headers) as Array<[string, string]>,
requestBodyBytes: opts.data === undefined ? 0 : Buffer.byteLength(opts.data),
};
let res;
try {
res = await this.request.fetch(url, { method, headers, data: opts.data, timeout: opts.timeoutMs ?? 30_000, maxRedirects: 0, failOnStatusCode: false });
} catch (e) {
const msg = (e as Error).message;
this.recorder.addHttp({ ...base, durationMs: performance.now() - started, outcome: msg });
if (/Timeout \d+ms exceeded|timed out/i.test(msg)) throw new HarnessError('timeout', `${method} ${url}: ${msg}`);
throw new HarnessError('transport', `${method} ${url}: ${msg}`);
}
const bodyBuffer = await res.body();
const durationMs = performance.now() - started;
const headersArray = res.headersArray();
const result = withJson({
client: 'playwright' as const,
method,
url,
status: res.status(),
statusText: res.statusText(),
headers: headersRecord(headersArray),
headersArray,
body: bodyBuffer.toString('utf8'),
bodyBuffer,
durationMs,
});
await res.dispose();
this.recorder.addHttp({
...base,
status: result.status,
responseHeaders: headersArray.map((h) => [h.name, h.value]),
bodyPreview: result.body,
durationMs,
});
return result;
}
get(url: string, opts: Omit<HttpRequestOptions, 'method'> = {}): Promise<HttpResult> {
return this.fetch(url, { ...opts, method: 'GET' });
}
/** Real curl process; see CurlOptions. */
async curl(opts: CurlOptions): Promise<CurlResult> {
const started = performance.now();
try {
const r = await runCurl(this.ws, opts);
this.recorder.addHttp({
client: 'curl',
method: opts.head ? 'HEAD' : (opts.method ?? (opts.data !== undefined ? 'POST' : 'GET')),
url: opts.url,
requestHeaders: [...(opts.token !== undefined ? [['Authorization', `Bearer ${opts.token}`] as [string, string]] : []), ...(opts.headers ?? []).map((h) => [h, ''] as [string, string])],
requestBodyBytes: opts.data === undefined ? 0 : Buffer.byteLength(opts.data),
status: r.status,
responseHeaders: r.headersArray.map((h) => [h.name, h.value]),
bodyPreview: r.body,
durationMs: r.durationMs,
curlExitCode: r.exitCode,
outcome: r.exitCode === 0 ? undefined : r.stderr.trim(),
});
return r;
} catch (e) {
this.recorder.addHttp({ client: 'curl', method: opts.method ?? 'GET', url: opts.url, requestHeaders: [], requestBodyBytes: 0, durationMs: performance.now() - started, outcome: (e as Error).message });
throw e;
}
}
/** Raw TCP request; see RawHttpOptions. */
async raw(opts: RawHttpOptions): Promise<RawResult> {
const first = (Array.isArray(opts.data) ? opts.data.map((c) => c.toString()).join('') : opts.data.toString()).split('\r\n')[0] ?? '';
const started = performance.now();
try {
const r = await rawHttp(opts);
this.recorder.addHttp({
client: 'raw',
method: first.split(' ')[0] ?? '',
url: `tcp://${opts.host}:${opts.port} ${first}`,
requestHeaders: [],
requestBodyBytes: Array.isArray(opts.data) ? opts.data.reduce((n, c) => n + Buffer.byteLength(c), 0) : Buffer.byteLength(opts.data),
status: r.response?.status,
responseHeaders: r.response?.headers,
bodyPreview: r.response?.bodyText,
durationMs: r.durationMs,
outcome: r.outcome,
});
return r;
} catch (e) {
this.recorder.addHttp({ client: 'raw', method: '', url: `tcp://${opts.host}:${opts.port} ${first}`, requestHeaders: [], requestBodyBytes: 0, durationMs: performance.now() - started, outcome: (e as Error).message });
throw e;
}
}
}
/** Builds a raw HTTP/1.1 request. Header lines are written verbatim (duplicates and odd spacing allowed). */
export function buildRawRequest(opts: { method?: string; target: string; headers?: Array<string | [string, string]>; body?: string; host?: string; version?: string; close?: boolean }): string {
const lines = [`${opts.method ?? 'GET'} ${opts.target} ${opts.version ?? 'HTTP/1.1'}`];
lines.push(`Host: ${opts.host ?? 'localhost'}`);
for (const h of opts.headers ?? []) lines.push(typeof h === 'string' ? h : `${h[0]}: ${h[1]}`);
if (opts.body !== undefined) lines.push(`Content-Length: ${Buffer.byteLength(opts.body)}`);
if (opts.close ?? true) lines.push('Connection: close');
return lines.join('\r\n') + '\r\n\r\n' + (opts.body ?? '');
}
/** host:port of a base URL like http://127.0.0.1:1234 or http://[::1]:1234. */
export function hostPortOf(baseURL: string): { host: string; port: number } {
const u = new URL(baseURL);
return { host: u.hostname.replace(/^\[|\]$/g, ''), port: Number(u.port) };
}