/** * Collects console errors, page errors, and failed network requests * during a Playwright test, with regex-based allowlist filtering. * * Usage: * const collector = installErrorCollector(page, [...GLOBAL_ALLOWLIST]); * // ... run test ... * collector.assertClean(); // throws if any non-allowlisted error * * The collector is wired into the `frigateApp` fixture so every test * gets it for free. Tests that intentionally trigger an error pass * additional regexes via the `expectedErrors` fixture parameter. */ import type { Page, Request, Response, ConsoleMessage } from "@playwright/test"; export type CollectedError = { kind: "console" | "pageerror" | "request"; message: string; url?: string; stack?: string; }; export type ErrorCollector = { errors: CollectedError[]; assertClean(): void; }; function isAllowlisted(message: string, allowlist: RegExp[]): boolean { return allowlist.some((pattern) => pattern.test(message)); } function firstStackFrame(stack: string | undefined): string | undefined { if (!stack) return undefined; const lines = stack .split("\n") .map((l) => l.trim()) .filter(Boolean); // Skip the error message line (line 0); return the first "at ..." frame return lines.find((l) => l.startsWith("at ")); } function isSameOrigin(url: string, baseURL: string | undefined): boolean { if (!baseURL) return true; try { return new URL(url).origin === new URL(baseURL).origin; } catch { return false; } } export function installErrorCollector( page: Page, allowlist: RegExp[], ): ErrorCollector { const errors: CollectedError[] = []; const baseURL = ( page.context() as unknown as { _options?: { baseURL?: string } } )._options?.baseURL; const onConsole = (msg: ConsoleMessage) => { if (msg.type() !== "error") return; const text = msg.text(); if (isAllowlisted(text, allowlist)) return; errors.push({ kind: "console", message: text, url: msg.location().url, }); }; const onPageError = (err: Error) => { const text = err.message; if (isAllowlisted(text, allowlist)) return; errors.push({ kind: "pageerror", message: text, stack: firstStackFrame(err.stack), }); }; const onResponse = (response: Response) => { const status = response.status(); if (status < 500) return; const url = response.url(); if (!isSameOrigin(url, baseURL)) return; const text = `${status} ${response.statusText()} ${url}`; if (isAllowlisted(text, allowlist)) return; errors.push({ kind: "request", message: text, url }); }; const onRequestFailed = (request: Request) => { const url = request.url(); if (!isSameOrigin(url, baseURL)) return; const failure = request.failure(); const text = `failed: ${failure?.errorText ?? "unknown"} ${url}`; if (isAllowlisted(text, allowlist)) return; errors.push({ kind: "request", message: text, url }); }; page.on("console", onConsole); page.on("pageerror", onPageError); page.on("response", onResponse); page.on("requestfailed", onRequestFailed); return { errors, assertClean() { if (errors.length === 0) return; const formatted = errors .map((e, i) => { const stack = e.stack ? `\n ${e.stack}` : ""; const url = e.url && e.url !== e.message ? ` (${e.url})` : ""; return ` ${i + 1}. [${e.kind}] ${e.message}${url}${stack}`; }) .join("\n"); throw new Error( `Page emitted ${errors.length} unexpected error${errors.length === 1 ? "" : "s"}:\n${formatted}`, ); }, }; }