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:
1 parent
a02eef13a7
commit
fe0cd4bf6d
33 files changed
+6370
No files matched your search
@@ -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) };
|
||||
}
|
||||
Reference in new issue
Block a user