import { expect, type ConsoleMessage, type Page, type Request, type Response, type TestInfo, } from '@playwright/test'; export type BrowserSignal = { kind: 'console' | 'pageerror' | 'requestfailed' | 'response'; summary: string; url?: string; status?: number; }; export type SignalGuardOptions = { inspectResponse?: (response: Response) => boolean; allow?: (signal: BrowserSignal) => boolean; }; export type BrowserSignalGuard = { signals: BrowserSignal[]; assertClean: () => Promise; attach: (testInfo: TestInfo) => Promise; }; /** * Capture browser failures for the entire workflow. Install immediately after * the page is created, before navigation or app actions. */ export function installBrowserSignalGuard( page: Page, options: SignalGuardOptions = {}, ): BrowserSignalGuard { const signals: BrowserSignal[] = []; const record = (signal: BrowserSignal) => { if (!options.allow?.(signal)) signals.push(signal); }; page.on('console', (message: ConsoleMessage) => { if (message.type() === 'error') { record({ kind: 'console', summary: message.text(), url: message.location().url, }); } }); page.on('pageerror', (error: Error) => { record({ kind: 'pageerror', summary: error.stack ?? error.message }); }); page.on('requestfailed', (request: Request) => { record({ kind: 'requestfailed', summary: request.failure()?.errorText ?? 'Request failed', url: request.url(), }); }); page.on('response', (response: Response) => { const shouldInspect = options.inspectResponse ? options.inspectResponse(response) : sameOrigin(response.url(), page.url()); if (shouldInspect && response.status() >= 400) { record({ kind: 'response', summary: `${response.request().method()} ${response.status()}`, url: response.url(), status: response.status(), }); } }); return { signals, async assertClean() { await expect( signals, `Unexpected browser signals:\n${JSON.stringify(signals, null, 2)}`, ).toEqual([]); }, async attach(testInfo: TestInfo) { await testInfo.attach('browser-signals.json', { body: Buffer.from(JSON.stringify(signals, null, 2)), contentType: 'application/json', }); }, }; } function sameOrigin(candidate: string, currentPage: string): boolean { try { const pageOrigin = new URL(currentPage).origin; return pageOrigin !== 'null' && new URL(candidate).origin === pageOrigin; } catch { return false; } }