2026-09-15 15:56:23 +00:00
|
|
|
import {inspect} from "node:util"
|
2026-09-16 06:48:40 +00:00
|
|
|
import type {BrowserContext, ConsoleMessage} from "@playwright/test"
|
2026-09-15 15:56:23 +00:00
|
|
|
|
|
|
|
|
// A CSP refusal reaches the console and nothing else, so a policy that has rotted past the script
|
|
|
|
|
// it names is invisible to every spec: app.html's requestIdleCallback shim was refused on every
|
|
|
|
|
// platform for a week with the suite green (#535).
|
|
|
|
|
const isRefusal = (text: string) => text.includes("Content Security Policy")
|
|
|
|
|
|
|
|
|
|
// Every test recreates the zooid container, chromium aborts what the page had in flight when the
|
|
|
|
|
// interfaces churn, and sveltekit reports a route chunk lost that way as an uncaught TypeError. It
|
|
|
|
|
// reaches nearly every spec — 256 of the 258 faults a full survey run raised — so it stays in the
|
|
|
|
|
// log and out of the fault set until #529 stops the churn.
|
|
|
|
|
const isChunkLoss = (text: string) => text.includes("Failed to fetch dynamically imported module")
|
|
|
|
|
|
2026-09-16 06:48:40 +00:00
|
|
|
// Chrome reports a failed request as "Failed to load resource: the server responded with a status
|
|
|
|
|
// of 404 (Not Found)" and carries the url nowhere but the message's location, so a line built from
|
|
|
|
|
// the text alone cannot say which resource went missing.
|
|
|
|
|
const locate = (message: ConsoleMessage) => {
|
|
|
|
|
const text = message.text()
|
|
|
|
|
const {url} = message.location()
|
|
|
|
|
|
|
|
|
|
return url && !text.includes(url) ? `${text} ${url}` : text
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 15:56:23 +00:00
|
|
|
// Playwright builds this from the page's exception details, and a page that throws something other
|
|
|
|
|
// than an Error leaves it with neither message nor stack — which is how a fault used to reach the
|
|
|
|
|
// console log as a bare "uncaught:".
|
|
|
|
|
const describe = (error: Error) => error.stack || error.message || inspect(error)
|
|
|
|
|
|
|
|
|
|
export type FaultWatch = {
|
|
|
|
|
// Everything either side said, for the account attached to a failing test.
|
|
|
|
|
log: string[]
|
|
|
|
|
// The subset of it that means the app broke rather than the box being noisy.
|
|
|
|
|
found: string[]
|
|
|
|
|
observe(context: BrowserContext, who: string): void
|
|
|
|
|
// Throws when the app itself broke while the test ran: an uncaught exception, or code of ours the
|
|
|
|
|
// browser refused to run. A failed request or a noisy warning is neither — a dev server is full
|
|
|
|
|
// of both — so those stay in the log.
|
|
|
|
|
assertNone(): void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const watchFaults = (): FaultWatch => {
|
|
|
|
|
const log: string[] = []
|
|
|
|
|
const found: string[] = []
|
|
|
|
|
|
|
|
|
|
const record = (line: string, fault: boolean) => {
|
|
|
|
|
log.push(line)
|
|
|
|
|
|
2026-09-21 16:48:35 +00:00
|
|
|
if (fault) {
|
|
|
|
|
found.push(line)
|
|
|
|
|
}
|
2026-09-15 15:56:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
log,
|
|
|
|
|
found,
|
|
|
|
|
observe(context, who) {
|
|
|
|
|
context.on("console", message => {
|
|
|
|
|
const type = message.type()
|
2026-09-16 06:48:40 +00:00
|
|
|
const text = locate(message)
|
2026-09-15 15:56:23 +00:00
|
|
|
|
|
|
|
|
if (["error", "warning"].includes(type)) {
|
|
|
|
|
record(`[${who}] ${type}: ${text}`, type === "error" && isRefusal(text))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
context.on("weberror", error => {
|
|
|
|
|
const text = describe(error.error())
|
|
|
|
|
|
|
|
|
|
record(`[${who}] uncaught: ${text}`, !isChunkLoss(text))
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
assertNone() {
|
|
|
|
|
if (found.length > 0) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
[
|
|
|
|
|
"The app broke while the test ran, so nothing it asserted means anything:",
|
|
|
|
|
...found.map(fault => ` ${fault}`),
|
|
|
|
|
].join("\n"),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|