import { AsyncLocalStorage } from "node:async_hooks"; import type { BrowserContext, Request, TestStepInfo } from "@playwright/test"; import { config } from "./config"; export interface Exchange { by: "node" | "browser"; method: string; url: string; status?: number; ms: number; request?: { headers: Record; body?: string }; response?: { headers: Record; body?: string }; error?: string; at?: number; } export interface StepRecord { notes: Record; exchanges: Exchange[]; bytes: number; dropped: number; pending: Set>; } const BODY_CAP = 64 * 1024; const STEP_CAP = 512 * 1024; const isLocal = (url: string) => { const host = new URL(url).hostname; return host === "localhost" || host === "127.0.0.1" || host.endsWith(".tngl.boltless.dev"); }; // any non local config drops bodies and masks secrets in the url const keepsBodies = Object.values(config) .filter((value): value is string => typeof value === "string" && /^https?:\/\//.test(value)) .every(isLocal); const serviceHosts = new Set( [ config.pdsUrl, config.knotUrl, config.hydrantUrl, config.bobbinUrl, config.spindleUrl, config.deliberiUrl ].map((url) => new URL(url).host) ); const masked = (url: string) => url.replace(/([?&][^=&]*(?:token|password|secret|code)[^=&]*=)[^&]*/gi, "$1…"); const clip = (text: string | undefined) => text === undefined || text.length <= BODY_CAP ? text : `${text.slice(0, BODY_CAP)}\n… truncated, ${text.length} chars in all`; const clipped = (part: Exchange["request"]) => part && { ...part, body: clip(part.body) }; const steps = new AsyncLocalStorage(); // browser events have no step async context, so they go to the running top step let topStep: StepRecord | undefined; export const fresh = (): StepRecord => ({ notes: {}, exchanges: [], bytes: 0, dropped: 0, pending: new Set() }); const current = () => steps.getStore(); export const within = (record: StepRecord, run: () => T) => steps.run(record, run); // recording must never fail the step it watches export const keep = (exchange: Exchange, record = current()) => { try { if (!record) return; const sent = { ...exchange, at: Date.now() - exchange.ms }; const kept: Exchange = keepsBodies ? { ...sent, request: clipped(exchange.request), response: clipped(exchange.response) } : { ...sent, url: masked(exchange.url), request: undefined, response: undefined }; const size = JSON.stringify(kept).length; if (record.bytes + size > STEP_CAP) { record.dropped++; return; } record.bytes += size; record.exchanges.push(kept); } catch {} }; export const note = (key: string, value: unknown, record = current()) => { if (record) record.notes[key] = value; }; export const attach = async (info: TestStepInfo, record: StepRecord) => { try { await Promise.race([ Promise.allSettled(record.pending), new Promise((settled) => setTimeout(settled, 250)) ]); if (record.dropped) record.notes["requests not kept"] = record.dropped; if (!Object.keys(record.notes).length && !record.exchanges.length) return; const body = JSON.stringify({ notes: record.notes, exchanges: record.exchanges }); await info.attach("e2e-step", { body, contentType: "application/json" }); } catch {} }; export const recorded = async ( info: TestStepInfo, run: () => Promise, pageUrl: () => string | undefined ) => { const record = fresh(); topStep = record; try { return await within(record, run); } finally { const url = pageUrl(); if (url && url !== "about:blank") note("page", url, record); await attach(info, record); } }; // asset requests would drown the service calls, so only those are kept export const watchBrowser = (context: BrowserContext) => { const finished = (request: Request) => { const record = topStep; if (!record || !serviceHosts.has(new URL(request.url()).host)) return; const ms = request.timing().responseEnd; const filled = (async () => { const response = await request.response(); keep( { by: "browser", method: request.method(), url: request.url(), status: response?.status(), ms: ms >= 0 ? Math.round(ms) : 0, request: { headers: await request.allHeaders(), body: request.postData() ?? undefined }, response: response ? { headers: await response.allHeaders(), body: await response.text().catch(() => undefined) } : undefined, error: request.failure()?.errorText }, record ); })().catch(() => {}); record.pending.add(filled); filled.finally(() => record.pending.delete(filled)); }; context.on("requestfinished", finished); context.on("requestfailed", finished); };