/** * Captures the API traffic a test produced, for the evidence report. * * A screenshot shows what the user saw; this shows what crossed the wire -- the * request (method, path, payload) and the response (status, body) for every * `/api/**` call. Together they make the report a complete record of the * scenario. * * Security: the Authorization header carries the Basic-auth credential (a * base64 of email:password), so it is redacted before anything is stored. The * report is meant to be shared; a credential must never ride along in it. */ import type { Response } from '@playwright/test'; export interface ApiCall { method: string; path: string; status: number; statusText: string; durationMs: number; requestHeaders: Record; requestBody?: string; responseBody?: string; truncated?: boolean; } // Headers that must never appear in a shareable report. const SENSITIVE = new Set(['authorization', 'cookie', 'set-cookie', 'proxy-authorization']); // Response bodies are capped so a large flight list cannot bloat the report. const MAX_BODY = 12000; function redactHeaders(headers: Record): Record { const out: Record = {}; for (const [key, value] of Object.entries(headers)) { out[key] = SENSITIVE.has(key.toLowerCase()) ? '[redacted]' : value; } return out; } /** Pretty-prints JSON and caps length, reporting whether it was truncated. */ function presentBody(raw: string, contentType: string): { body: string; truncated: boolean } { let text = raw; if (/json/i.test(contentType)) { try { text = JSON.stringify(JSON.parse(raw), null, 2); } catch { // Leave non-parseable bodies as-is. } } if (text.length > MAX_BODY) { return { body: text.slice(0, MAX_BODY) + '\n… (truncated)', truncated: true }; } return { body: text, truncated: false }; } /** * Turns one response into an ApiCall record, or null if it is not an API call. * The body is read here, while the response is still live, and returned via the * promise the caller awaits at test teardown. */ export function captureApiCall(response: Response): Promise { const request = response.request(); let url: URL; try { url = new URL(request.url()); } catch { return Promise.resolve(null); } if (!url.pathname.startsWith('/api/')) { return Promise.resolve(null); } return (async () => { try { const contentType = response.headers()['content-type'] ?? ''; let responseBody: string | undefined; let truncated = false; if (/json|text|xml|form/i.test(contentType)) { const raw = await response.text().catch(() => ''); if (raw) { const shown = presentBody(raw, contentType); responseBody = shown.body; truncated = shown.truncated; } } const timing = request.timing(); const durationMs = timing.responseEnd > 0 ? Math.round(timing.responseEnd) : 0; return { method: request.method(), path: url.pathname + url.search, status: response.status(), statusText: response.statusText(), durationMs, requestHeaders: redactHeaders(request.headers()), requestBody: request.postData() ?? undefined, responseBody, truncated: truncated || undefined, }; } catch { // A response that cannot be read (e.g. the page navigated away) is // skipped rather than failing the test it was only observing. return null; } })(); }