From 064d38d2faf800025b92761d155f0cfd48353a1a Mon Sep 17 00:00:00 2001 From: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Date: Mon, 28 Sep 2026 11:36:54 -0400 Subject: [PATCH 1/3] Collect browser page errors through one watcher Browser tests had 34 private pageerror collectors in 22 files. They did not share the fixture's reviewed WebKit ResizeObserver rule, so new-message failed on a warning that the fixture accepts. tests/browser/page-errors.mjs is now the only collector. A Biome GritQL plugin rejects pageerror listeners anywhere else. navigation-repairs failed on "Fetch API cannot load .../query due to access control checks." That is not an unhandled rejection. Playwright's WebKit backend turns WebKit console errors into page errors, and WebKit logs this line when a reload cancels a fetch still in progress. An unhandled rejection would be a separate "Unhandled Promise Rejection" page error, and the CI trace has none. The watcher accepts this log only when Playwright also saw that exact request URL cancelled, once per cancellation. Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- biome.json | 4 + src/bundled/workflows/workflows.journey.mjs | 39 +++---- tests/browser/agent-control.spec.mjs | 6 +- tests/browser/agent-editor-grid.spec.mjs | 6 +- tests/browser/agent-models.spec.mjs | 6 +- tests/browser/agents.spec.mjs | 6 +- tests/browser/avatar-edit.spec.mjs | 6 +- tests/browser/conversation.spec.mjs | 6 +- tests/browser/diffs.spec.mjs | 6 +- tests/browser/emoji.spec.mjs | 6 +- tests/browser/fixture.mjs | 29 +++-- tests/browser/mentions.spec.mjs | 11 +- tests/browser/messages.spec.mjs | 6 +- tests/browser/new-message.spec.mjs | 10 +- tests/browser/page-errors.d.mts | 10 ++ tests/browser/page-errors.grit | 17 +++ tests/browser/page-errors.mjs | 56 ++++++++++ tests/browser/presence.spec.mjs | 4 +- tests/browser/profiles.spec.mjs | 6 +- tests/browser/reactions.spec.mjs | 6 +- tests/browser/terminal-renderer.spec.mjs | 21 ++-- tests/browser/typeahead.spec.mjs | 6 +- tests/browser/unread.spec.mjs | 2 +- tests/browser/user-status.spec.mjs | 6 +- tests/demos/identity-names/capture.mjs | 10 +- tests/demos/identity-names/surfaces.mjs | 8 +- tests/fixtures/design-system/viewer.spec.ts | 13 ++- tests/integration/page-errors.test.mjs | 116 ++++++++++++++++++++ 28 files changed, 314 insertions(+), 114 deletions(-) create mode 100644 tests/browser/page-errors.d.mts create mode 100644 tests/browser/page-errors.grit create mode 100644 tests/browser/page-errors.mjs create mode 100644 tests/integration/page-errors.test.mjs diff --git a/biome.json b/biome.json index cde7b68c9..92bfa841d 100644 --- a/biome.json +++ b/biome.json @@ -80,6 +80,10 @@ } }, "overrides": [ + { + "includes": ["**", "!tests/browser/page-errors.mjs"], + "plugins": ["./tests/browser/page-errors.grit"] + }, { "includes": ["src/shared/design-system/icons/**"], "linter": { diff --git a/src/bundled/workflows/workflows.journey.mjs b/src/bundled/workflows/workflows.journey.mjs index 4c922d464..03b985931 100644 --- a/src/bundled/workflows/workflows.journey.mjs +++ b/src/bundled/workflows/workflows.journey.mjs @@ -3,6 +3,7 @@ import { createServer } from "../../../tests/browser/vite-server.mjs"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; import { parse as parseYaml } from "yaml"; +import { watchPageErrors } from "../../../tests/browser/page-errors.mjs"; let server; let url; @@ -98,8 +99,7 @@ function editorControls(page) { test("workflow editor preserves YAML, resolves exact saves, retains conflicts and purges access", async ({ page, }) => { - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); await page.goto(url); const editor = editorControls(page); const { button, yaml, tab } = editor; @@ -230,7 +230,7 @@ test("workflow editor preserves YAML, resolves exact saves, retains conflicts an page.evaluate(() => window.workflowFixture.definitions.disposed()), ) .toBe(true); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); }); // Real layout and native menu focus cannot be established by jsdom. @@ -301,10 +301,10 @@ test("keyboard switches feed enabled-save confirmation and disabled readback", a page, browserName, }) => { - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); + const consoleErrors = []; page.on("console", (message) => { - if (message.type() === "error") errors.push(message.text()); + if (message.type() === "error") consoleErrors.push(message.text()); }); // Wide layout: the inspector sits beside the flow, so its controls and the // footer share one keyboard layer. @@ -442,7 +442,7 @@ test("keyboard switches feed enabled-save confirmation and disabled readback", a await expect(dialog).toContainText("It will run for every new message"); expect(await saves()).toBe(3); await page.keyboard.press("Escape"); - expect(errors).toEqual([]); + expect([...errors.unexplained(), ...consoleErrors]).toEqual([]); }); test("history stays lazy and paged; acknowledging an unknown run never repeats it", async ({ @@ -509,8 +509,7 @@ test("history stays lazy and paged; acknowledging an unknown run never repeats i test("real session page under StrictMode fences community changes, warns before discarding a dirty draft and purges access", async ({ page, }) => { - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); await page.goto(url.replace("/fixture.html", "/session-fixture.html")); const editor = editorControls(page); const { button } = editor; @@ -583,14 +582,13 @@ test("real session page under StrictMode fences community changes, warns before expect(await page.locator("body").innerText()).not.toContain( "Revoked private text", ); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); }); test("landing batches 129 channels into two workflow reads", async ({ page, }) => { - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); await page.goto(url.replace("/fixture.html", "/session-fixture.html?many")); await expect( page.getByRole("button", { name: "Open Fixture A helper", exact: true }), @@ -600,7 +598,7 @@ test("landing batches 129 channels into two workflow reads", async ({ page.evaluate(() => window.workflowSessionFixture.definitionQueries()), ) .toBe(2); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); }); test("landing scan survives channel presentation churn without restarting", async ({ @@ -1234,8 +1232,7 @@ test("invalid timeout text stays in the draft and blocks saves in both editor mo test("schedule presets round-trip into YAML and warn before enabling a frequent one", async ({ page, }) => { - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); // Wide layout keeps the trigger inspector beside the footer tabs. await page.setViewportSize({ width: 1440, height: 900 }); await page.goto(url); @@ -1351,14 +1348,13 @@ test("schedule presets round-trip into YAML and warn before enabling a frequent parseYaml(await page.evaluate(() => window.workflowFixture.input().yaml)) .trigger, ).toEqual({ on: "schedule", interval: "1h" }); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); }); test("a webhook save shows its one-time secret once and asks before leaving it behind", async ({ page, }) => { - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); await page.addInitScript(() => { window.__copied = []; Object.defineProperty(navigator, "clipboard", { @@ -1467,7 +1463,7 @@ test("a webhook save shows its one-time secret once and asks before leaving it b ), ).toBeUndefined(); await expect(button("Save changes")).toBeEnabled(); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); }); // Browser boundary: the routed page must deliver a real session's late receipt @@ -1690,8 +1686,7 @@ test("generic Outbox offers message retry but no workflow replay", async ({ test("a created workflow saves, reads back exactly and reopens unchanged", async ({ page, }) => { - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); await page.setViewportSize({ width: 1440, height: 900 }); await page.goto(url); const editor = editorControls(page); @@ -1759,7 +1754,7 @@ test("a created workflow saves, reads back exactly and reopens unchanged", async await tab("YAML").click(); await expect(yaml).toHaveValue(submitted); expect(await saves()).toBe(1); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); }); // Browser-only boundary: nested modal hit testing, focus guards and return diff --git a/tests/browser/agent-control.spec.mjs b/tests/browser/agent-control.spec.mjs index f75cfa59a..f9789873b 100644 --- a/tests/browser/agent-control.spec.mjs +++ b/tests/browser/agent-control.spec.mjs @@ -2,6 +2,7 @@ import { test, expect } from "@playwright/test"; import { npubEncode } from "nostr-tools/nip19"; import { createServer } from "./vite-server.mjs"; import config from "../fixtures/agent-control.vite.mjs"; +import { watchPageErrors } from "./page-errors.mjs"; async function closeEditor(page) { const dialog = page.getByRole("dialog", { name: "Edit agent", exact: true }); @@ -48,8 +49,7 @@ test("local controls preserve drafts, confirm operations and distinguish disable logLevel: "error", server: { host: "127.0.0.1", port: 0, strictPort: false }, }); - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); await server.listen(); try { await page.goto( @@ -234,7 +234,7 @@ test("local controls preserve drafts, confirm operations and distinguish disable await expect( libraryCard.getByRole("button", { name: /Actions|Start|Edit/ }), ).toHaveCount(0); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); } finally { await server.close(); } diff --git a/tests/browser/agent-editor-grid.spec.mjs b/tests/browser/agent-editor-grid.spec.mjs index c2df983f0..cf0a886f2 100644 --- a/tests/browser/agent-editor-grid.spec.mjs +++ b/tests/browser/agent-editor-grid.spec.mjs @@ -1,6 +1,7 @@ import { test, expect } from "@playwright/test"; import { createServer } from "./vite-server.mjs"; import config from "../fixtures/agent-control.vite.mjs"; +import { watchPageErrors } from "./page-errors.mjs"; test("existing grid opens the focused editor, selects a model and saves/reopens", async ({ page, @@ -12,8 +13,7 @@ test("existing grid opens the focused editor, selects a model and saves/reopens" server: { host: "127.0.0.1", port: 0, strictPort: false }, }); await server.listen(); - const errors = []; - page.on("pageerror", (e) => errors.push(String(e))); + const errors = watchPageErrors(page); try { await page.goto( `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/agent-control.html`, @@ -312,7 +312,7 @@ test("existing grid opens the focused editor, selects a model and saves/reopens" .getByRole("textbox", { name: "Name", exact: true }) .press("Escape"); await expect(dialog).toHaveCount(0); // clean Escape still closes - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); } finally { await server.close(); } diff --git a/tests/browser/agent-models.spec.mjs b/tests/browser/agent-models.spec.mjs index 928ff546e..0ba4f95ce 100644 --- a/tests/browser/agent-models.spec.mjs +++ b/tests/browser/agent-models.spec.mjs @@ -1,6 +1,7 @@ import { test, expect } from "@playwright/test"; import { createServer } from "./vite-server.mjs"; import config from "../fixtures/agent-control.vite.mjs"; +import { watchPageErrors } from "./page-errors.mjs"; test("on-demand model search preserves custom drafts and fences cancellation/context changes", async ({ page, @@ -12,8 +13,7 @@ test("on-demand model search preserves custom drafts and fences cancellation/con server: { host: "127.0.0.1", port: 0, strictPort: false }, }); await server.listen(); - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); try { // Install before page timers exist; pause at a fixed later instant below. await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") }); @@ -239,7 +239,7 @@ test("on-demand model search preserves custom drafts and fences cancellation/con window.agentModelsFixture.calls.filter((x) => x === "cancel").length, ), ).toBeGreaterThan(before); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); } finally { await server.close(); } diff --git a/tests/browser/agents.spec.mjs b/tests/browser/agents.spec.mjs index d97ba7674..1fc708449 100644 --- a/tests/browser/agents.spec.mjs +++ b/tests/browser/agents.spec.mjs @@ -3,6 +3,7 @@ import { npubEncode } from "nostr-tools/nip19"; import { createServer } from "./vite-server.mjs"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; +import { watchPageErrors } from "./page-errors.mjs"; test("Old Buzz library reads the existing library with exact linked keys and session-safe retries", async ({ page, @@ -15,8 +16,7 @@ test("Old Buzz library reads the existing library with exact linked keys and ses logLevel: "error", server: { host: "127.0.0.1", port: 0, strictPort: false }, }); - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); try { await server.listen(); await page.goto( @@ -240,7 +240,7 @@ test("Old Buzz library reads the existing library with exact linked keys and ses .click(); await expect(page.getByRole("status")).toContainText("Library cleared"); await expect(page.getByRole("article")).toHaveCount(0); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); } finally { await server.close(); } diff --git a/tests/browser/avatar-edit.spec.mjs b/tests/browser/avatar-edit.spec.mjs index 2d302dd78..dbf5d810a 100644 --- a/tests/browser/avatar-edit.spec.mjs +++ b/tests/browser/avatar-edit.spec.mjs @@ -1,5 +1,6 @@ import { test, expect } from "./source-fixture.mjs"; import { readFile } from "node:fs/promises"; +import { watchPageErrors } from "./page-errors.mjs"; // Browser boundary: nested overlay hit-testing/focus, canvas image preparation, // lazy shadow-DOM emoji picker, and narrow viewport geometry in both engines. @@ -9,8 +10,7 @@ test("shared human and agent avatar upload, scoped save, publication retry and n const artwork = await readFile( new URL("../fixtures/design-system/assets/avatar.png", import.meta.url), ); - const errors = []; - page.on("pageerror", (e) => errors.push(e.message)); + const errors = watchPageErrors(page); await page.route("**/api/relay/**", (route) => route.abort()); await page.goto("/tests/fixtures/agent-control.html?avatars"); await page @@ -156,5 +156,5 @@ test("shared human and agent avatar upload, scoped save, publication retry and n "profile", ]); expect(result.actions[0].payload.edit.picture).toBe(result.agent.picture); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); }); diff --git a/tests/browser/conversation.spec.mjs b/tests/browser/conversation.spec.mjs index bdfc388b9..4200c49de 100644 --- a/tests/browser/conversation.spec.mjs +++ b/tests/browser/conversation.spec.mjs @@ -6,6 +6,7 @@ import { run } from "./run-command.mjs"; import { mkdtemp, cp, readFile, writeFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { watchPageErrors } from "./page-errors.mjs"; const root = fileURLToPath(new URL("../../", import.meta.url)); test("independent packed author consumer and native-installed contribution survive removal, replacement and retarget", async ({ @@ -138,8 +139,7 @@ test("independent packed author consumer and native-installed contribution survi server: { host: "127.0.0.1", port: 0, strictPort: false }, }); await server.listen(); - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); await page.route("**/proof-media/**", (route) => route.fulfill({ contentType: "image/svg+xml", @@ -480,7 +480,7 @@ test("independent packed author consumer and native-installed contribution survi .getByRole("button", { name: "Channels" }) .click(); await expect(draft).toHaveJSProperty("value", "B draft"); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); await writeFile( test.info().outputPath("boundary-proof.json"), JSON.stringify( diff --git a/tests/browser/diffs.spec.mjs b/tests/browser/diffs.spec.mjs index 1c9cbd015..229c741ad 100644 --- a/tests/browser/diffs.spec.mjs +++ b/tests/browser/diffs.spec.mjs @@ -2,6 +2,7 @@ import { test, expect } from "@playwright/test"; import { createServer } from "./vite-server.mjs"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; +import { watchPageErrors } from "./page-errors.mjs"; // Real table geometry/scroll containment, modal portal/focus return and theme // rendering need a browser. Patch matrices and plugin failures stay in Vitest. @@ -25,8 +26,7 @@ test("diff preview expands in both layouts and keeps focus and scroll containmen }); try { await server.listen(); - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); await page.goto( `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/diffs.html`, ); @@ -166,7 +166,7 @@ test("diff preview expands in both layouts and keeps focus and scroll containmen } finally { await page.keyboard.up("ArrowRight"); } - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); } finally { await server.close(); } diff --git a/tests/browser/emoji.spec.mjs b/tests/browser/emoji.spec.mjs index 729d182fc..3366107a9 100644 --- a/tests/browser/emoji.spec.mjs +++ b/tests/browser/emoji.spec.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { watchPageErrors } from "./page-errors.mjs"; test("community picker uses keyboard, proxy thumbnails, event-local history and scoped send/reply tags", async ({ browserName, @@ -24,8 +25,7 @@ test("community picker uses keyboard, proxy thumbnails, event-local history and logLevel: "error", server: { host: "127.0.0.1", port: 0, strictPort: false }, }); - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); await page.route("**/emoji-media/**", async (route) => { if (route.request().url().includes("broken.png")) return route.fulfill({ status: 404, body: "missing" }); @@ -1029,7 +1029,7 @@ test("community picker uses keyboard, proxy thumbnails, event-local history and () => window.emojiFixture.report.publications.at(-1).event.content, ), ).toBe("😀 🙏 👏 hello"); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); } finally { try { await server?.close(); diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index e87a10a11..976fd62cd 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -17,6 +17,7 @@ import { relayBrokerPlugin } from "../../dev/relay-broker.mjs"; import { policyRelay } from "./policy-relay.mjs"; import { buildApp } from "./build.mjs"; import { fixtureBody } from "./fixture-body.mjs"; +import { watchPageErrors } from "./page-errors.mjs"; const root = fileURLToPath(new URL("../../", import.meta.url)); export const channels = ["alpha", "beta"]; @@ -1359,6 +1360,8 @@ export const test = base.extend({ const foregroundRequests = []; let iconsReleased = false; let server; + // Each watched page's errors; additional pages join through app.watchPageErrors. + const watchedPages = []; try { server = await preview({ ...compiledApp.config, @@ -1440,7 +1443,10 @@ export const test = base.extend({ report.unexpected.push(`Blocked WebSocket: ${socket.url()}`); socket.close(); }); - page.on("pageerror", (error) => report.errors.push(error.message)); + watchedPages.push(watchPageErrors(page)); + report.errors = watchedPages[0].errors; + const unexplainedPageErrors = () => + watchedPages.flatMap((watched) => watched.unexplained()); page.on("console", (message) => { if (message.type() === "error") { consoleLocations.set( @@ -1494,6 +1500,11 @@ export const test = base.extend({ sign: (template) => finalizeEvent(template, userKey), origin, report, + watchPageErrors(other) { + const watched = watchPageErrors(other); + watchedPages.push(watched); + return watched; + }, iconCongestion: iconCongestion ? { iconRequests, @@ -1771,21 +1782,15 @@ export const test = base.extend({ ), ), ).toEqual([]); - // Existing WebKit observer warning is recorded, never silently swallowed. - expect( - report.errors.filter( - (message) => - !( - browserName === "webkit" && - message === - "ResizeObserver loop completed with undelivered notifications." - ), - ), - ).toEqual([]); + // All page errors stay in the evidence; only known engine reports pass. + expect(unexplainedPageErrors()).toEqual([]); } finally { if (iconCongestion) for (const response of heldIcons) if (!response.writableEnded) send(response, {}); + report.additionalPageErrors = watchedPages + .slice(1) + .flatMap((watched) => watched.errors); await writeFile( testInfo.outputPath("evidence.json"), JSON.stringify(report, null, 2), diff --git a/tests/browser/mentions.spec.mjs b/tests/browser/mentions.spec.mjs index 49235afae..58f11c155 100644 --- a/tests/browser/mentions.spec.mjs +++ b/tests/browser/mentions.spec.mjs @@ -3,6 +3,7 @@ import { test, expect } from "@playwright/test"; import { createServer } from "./vite-server.mjs"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; +import { watchPageErrors } from "./page-errors.mjs"; test("settings-enabled mentions fixture renders the composer and preference", async ({ page, @@ -17,8 +18,7 @@ test("settings-enabled mentions fixture renders the composer and preference", as logLevel: "error", server: { host: "127.0.0.1", port: 0 }, }); - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); try { await server.listen(); await page.goto( @@ -26,7 +26,7 @@ test("settings-enabled mentions fixture renders the composer and preference", as ); await expect(page.getByRole("textbox")).toBeVisible(); await expect(page.getByText("Remember mentioned agents")).toBeVisible(); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); } finally { await server.close(); } @@ -53,8 +53,7 @@ test("actual composer selects namesakes by exact key, publishes channel/reply ta watch: { ignored: ["**/src-tauri/**", "**/target/**"] }, }, }); - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); try { await server.listen(); await page.goto( @@ -490,7 +489,7 @@ test("actual composer selects namesakes by exact key, publishes channel/reply ta expect(afterRemoval.tags.filter(([tag]) => tag === "p")).toEqual([ ["p", keys.second], ]); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); } finally { await server.close(); } diff --git a/tests/browser/messages.spec.mjs b/tests/browser/messages.spec.mjs index cca0e866c..d375d506a 100644 --- a/tests/browser/messages.spec.mjs +++ b/tests/browser/messages.spec.mjs @@ -2,6 +2,7 @@ import { test, expect } from "@playwright/test"; import { createServer } from "./vite-server.mjs"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; +import { watchPageErrors } from "./page-errors.mjs"; const fixtureImage = ``; @@ -262,8 +263,7 @@ test("shared thread UI auto-loads, follows live replies, retries and isolates re page, }, testInfo) => { const server = await createMessagesServer(); - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); try { await server.listen(); const address = server.httpServer.address(); @@ -578,7 +578,7 @@ test("shared thread UI auto-loads, follows live replies, retries and isolates re await expect(literal).toBeVisible(); await expect(literal).toHaveCSS("white-space", "pre-wrap"); } - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); } finally { await server.close(); } diff --git a/tests/browser/new-message.spec.mjs b/tests/browser/new-message.spec.mjs index 5241118ab..9690e1312 100644 --- a/tests/browser/new-message.spec.mjs +++ b/tests/browser/new-message.spec.mjs @@ -6,6 +6,7 @@ import { relayBrokerPlugin } from "../../dev/relay-broker.mjs"; import { brokerSocket } from "../broker-socket.mjs"; import { fixtureAliases, fixtureRelayUrl } from "../relay-config.ts"; import { buildApp } from "./build.mjs"; +import { watchPageErrors } from "./page-errors.mjs"; // Actual app, composer, session and broker; only the upstream relay is modeled. // Ephemeral identities and a network fence prevent any live message or profile write. @@ -51,8 +52,7 @@ const test = base.extend({ ...people, ]; const commands = [], - reads = [], - errors = []; + reads = []; let failOpen = false, hold = false, release = () => {}; @@ -187,7 +187,7 @@ const test = base.extend({ : route.abort(), ); await context.routeWebSocket("**/*", (socket) => socket.close()); - page.on("pageerror", (error) => errors.push(error.message)); + const errors = watchPageErrors(page); await page.addInitScript( ({ viewer }) => { const key = `buzz-client.v1:${viewer}`; @@ -641,7 +641,7 @@ test("empty compose, keyboard selection, pagination, removal effects, retry, the page.locator("[data-message-id]", { hasText: "Another message" }), ).toBeVisible(); await expect(sidebarDm).toHaveAttribute("aria-current", "page"); - expect(app.errors).toEqual([]); + expect(app.errors.unexplained()).toEqual([]); }); test("profile Message opens a fresh DM and restores a hidden one", async ({ @@ -696,5 +696,5 @@ test("profile Message opens a fresh DM and restores a hidden one", async ({ await page.reload(); await expect(sidebarDm).toBeVisible(); expect(app.commands).toHaveLength(2); - expect(app.errors).toEqual([]); + expect(app.errors.unexplained()).toEqual([]); }); diff --git a/tests/browser/page-errors.d.mts b/tests/browser/page-errors.d.mts new file mode 100644 index 000000000..5f06395dc --- /dev/null +++ b/tests/browser/page-errors.d.mts @@ -0,0 +1,10 @@ +import type { Page } from "@playwright/test"; + +export interface PageErrors { + /** Every page error the page reported, as evidence. */ + readonly errors: string[]; + /** Page errors that are not a reviewed engine report. */ + unexplained(): string[]; +} + +export function watchPageErrors(page: Page): PageErrors; diff --git a/tests/browser/page-errors.grit b/tests/browser/page-errors.grit new file mode 100644 index 000000000..b20ef8c6d --- /dev/null +++ b/tests/browser/page-errors.grit @@ -0,0 +1,17 @@ +language js + +// Page errors have one collector, watchPageErrors in page-errors.mjs, so every +// test applies the same reviewed engine rules. See AGENTS.md "Deterministic tests". +`$page.$method($event, $handler)` where { + $method <: or { + `on`, + `once`, + `addListener`, + `prependListener` + }, + $event <: or { + `"pageerror"`, + `'pageerror'` + }, + register_diagnostic(span=$event, message="Collect page errors with watchPageErrors from tests/browser/page-errors.mjs.", severity="error") +} diff --git a/tests/browser/page-errors.mjs b/tests/browser/page-errors.mjs new file mode 100644 index 000000000..813020f76 --- /dev/null +++ b/tests/browser/page-errors.mjs @@ -0,0 +1,56 @@ +// The only owner of Playwright `pageerror` collection for browser tests. A lint +// plugin (page-errors.grit) rejects other collectors. +// +// Every page error is kept as evidence in `errors`. `unexplained()` returns the +// errors that are not a known engine report; tests assert that it is empty. +// Add an engine report here only with evidence that the application did not +// throw or leave a rejection unhandled. + +const resizeObserverLoop = + "ResizeObserver loop completed with undelivered notifications."; +// WebKit's cancellation text for a request: Linux (CI), then macOS. +const cancellations = new Set(["Load request cancelled", "cancelled"]); +const cancelledLoad = + /^Fetch API cannot load (\S+) due to access control checks\.$/; + +// Playwright converts every WebKit console message with source "javascript" and +// level "error" into a page error, and splits its text at the first colon +// ("Fetch API cannot load http" becomes the name). The stack's first line keeps +// the full text. Reports without a stack keep their name, for example +// "Unhandled Promise Rejection". +const errorText = (error) => + (error.stack ?? "").split("\n")[0] || + (error.name ? `${error.name}: ${error.message}` : error.message); + +export function watchPageErrors(page) { + const webkit = page.context().browser()?.browserType().name() === "webkit"; + const errors = []; + const cancelled = []; + page.on("pageerror", (error) => errors.push(errorText(error))); + if (webkit) + page.on("requestfailed", (request) => { + if (cancellations.has(request.failure()?.errorText)) + cancelled.push(request.url()); + }); + return { + errors, + unexplained() { + if (!webkit) return [...errors]; + const unmatched = [...cancelled]; + return errors.filter((message) => { + // A layout warning that WebKit reports as a page error. + if (message === resizeObserverLoop) return false; + // When a reload or navigation cancels a fetch, WebKit logs the fetch as + // an access-control failure even though the caller handles the + // rejection. An unhandled rejection is a separate page error named + // "Unhandled Promise Rejection". Accept the log only for a request that + // Playwright saw cancelled, once per cancellation. + const url = cancelledLoad.exec(message)?.[1]; + const match = url ? unmatched.indexOf(url) : -1; + if (match < 0) return true; + unmatched.splice(match, 1); + return false; + }); + }, + }; +} diff --git a/tests/browser/presence.spec.mjs b/tests/browser/presence.spec.mjs index f9f6d79e9..5bc81cfbc 100644 --- a/tests/browser/presence.spec.mjs +++ b/tests/browser/presence.spec.mjs @@ -289,8 +289,8 @@ test("real same-origin windows queue one publisher and transfer its Web Lock on .toBe(1); const owner = (await presenceLocks(page)).held[0]; const second = await context.newPage(); + const pageErrors = app.watchPageErrors(second); const errors = []; - second.on("pageerror", (error) => errors.push(error.message)); second.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); @@ -336,7 +336,7 @@ test("real same-origin windows queue one publisher and transfer its Web Lock on expect(app.report.presencePublications.at(-1).event.content).toBe( "offline", ); - expect(errors).toEqual([]); + expect([...pageErrors.unexplained(), ...errors]).toEqual([]); app.report.measurements.push({ publisherLockHandoff: { owner: owner.clientId, successor }, }); diff --git a/tests/browser/profiles.spec.mjs b/tests/browser/profiles.spec.mjs index 2fd7603e1..ca9ba9dfb 100644 --- a/tests/browser/profiles.spec.mjs +++ b/tests/browser/profiles.spec.mjs @@ -1,10 +1,10 @@ import { test, expect } from "./source-fixture.mjs"; +import { watchPageErrors } from "./page-errors.mjs"; test("profile plumbing: exact avatar/mention targets, thread enrichment, lifecycle and recovery", async ({ page, }, testInfo) => { - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); await page.goto("/tests/fixtures/profiles.html"); const panel = page.getByRole("complementary", { name: "Profile", @@ -260,7 +260,7 @@ test("profile plumbing: exact avatar/mention targets, thread enrichment, lifecyc key: window.profilesFixture.keys.pinky, })); expect(reads.reads.some((batch) => batch.includes(reads.key))).toBe(true); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); }); test("contextual panel callbacks retire with opening, channel, contribution and session", async ({ diff --git a/tests/browser/reactions.spec.mjs b/tests/browser/reactions.spec.mjs index f254812f2..0a626730b 100644 --- a/tests/browser/reactions.spec.mjs +++ b/tests/browser/reactions.spec.mjs @@ -3,6 +3,7 @@ import { test as sourceTest } from "./source-fixture.mjs"; import { createServer } from "./vite-server.mjs"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; +import { watchPageErrors } from "./page-errors.mjs"; test("native reaction emoji fit inside compact pills", async ({ page }) => { const server = await createServer({ @@ -47,8 +48,7 @@ test("reaction plus opens a visible emoji-only picker, restores focus and publis server: { host: "127.0.0.1", port: 0 }, }); try { - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); await page.route("**/emoji-media/**", (route) => route.fulfill({ contentType: "image/svg+xml", @@ -282,7 +282,7 @@ test("reaction plus opens a visible emoji-only picker, restores focus and publis await sole.press("Enter"); await expect(sole).toHaveCount(0); await expect(stableAction).toBeFocused(); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); } finally { await server.close(); } diff --git a/tests/browser/terminal-renderer.spec.mjs b/tests/browser/terminal-renderer.spec.mjs index 4f85d9dfc..5e158bfd5 100644 --- a/tests/browser/terminal-renderer.spec.mjs +++ b/tests/browser/terminal-renderer.spec.mjs @@ -2,6 +2,7 @@ import { test, expect } from "@playwright/test"; import { createServer } from "./vite-server.mjs"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; +import { watchPageErrors } from "./page-errors.mjs"; test("real xterm retains output across detach, handles input and resize, and does not reserve absent shortcuts", async ({ page, @@ -14,8 +15,7 @@ test("real xterm retains output across detach, handles input and resize, and doe plugins: [react()], server: { host: "127.0.0.1", port: 0 }, }); - const errors = []; - page.on("pageerror", (e) => errors.push(String(e))); + const errors = watchPageErrors(page); try { await server.listen(); await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") }); @@ -224,7 +224,7 @@ test("real xterm retains output across detach, handles input and resize, and doe await button("Toggle mount").click(); await expect(splash).toHaveCount(0); await page.clock.resume(); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); } finally { await server.close(); } @@ -241,8 +241,7 @@ test("terminal shared controls keep focus, recovery and layout in both modes", a plugins: [react()], server: { host: "127.0.0.1", port: 0 }, }); - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); try { await server.listen(); await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") }); @@ -426,7 +425,7 @@ test("terminal shared controls keep focus, recovery and layout in both modes", a await page.screenshot({ path: test.info().outputPath("terminal-short-200.png"), }); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); } finally { await server.close(); } @@ -442,8 +441,7 @@ test("real xterm replies survive scope switches while stale input and retired wr plugins: [react()], server: { host: "127.0.0.1", port: 0 }, }); - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); try { await server.listen(); const url = `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/terminal-session.html`; @@ -538,7 +536,7 @@ test("real xterm replies survive scope switches while stale input and retired wr if (operation === "end") await page.evaluate(() => window.terminalSession.dispose()); } - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); } finally { await server.close(); } @@ -563,8 +561,7 @@ test("focused terminal follows rebind, restore and reset without swallowing the plugins: [react()], server: { host: "127.0.0.1", port: 0 }, }); - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); try { await server.listen(); await page.goto( @@ -656,7 +653,7 @@ test("focused terminal follows rebind, restore and reset without swallowing the expect(await page.evaluate(() => window.terminalPanel.toggles())).toBe(2); expect(await page.evaluate(() => window.terminalPanel.written())).toBe(""); await page.evaluate(() => window.terminalPanel.dispose()); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); } finally { await server.close(); } diff --git a/tests/browser/typeahead.spec.mjs b/tests/browser/typeahead.spec.mjs index b1ebb8b1e..75ae17fc4 100644 --- a/tests/browser/typeahead.spec.mjs +++ b/tests/browser/typeahead.spec.mjs @@ -1,4 +1,5 @@ import { test, expect } from "./source-fixture.mjs"; +import { watchPageErrors } from "./page-errors.mjs"; const open = async (page) => { await page.goto("/tests/fixtures/mentions.html?test-controls"); @@ -180,8 +181,7 @@ for (const mode of ["light", "dark"]) { test("typeahead replaces only the query and publishes selected namesake identity, including replies", async ({ page, }) => { - const errors = []; - page.on("pageerror", (e) => errors.push(String(e))); + const errors = watchPageErrors(page); const input = await open(page); const keys = await page.evaluate(() => ({ first: window.mentionFixture.first, @@ -229,7 +229,7 @@ test("typeahead replaces only the query and publishes selected namesake identity const sent = await page.evaluate(() => window.mentionFixture.publications[1]); expect(sent.tags).toContainEqual(["e", "a".repeat(64), "", "reply"]); expect(sent.tags.filter(([tag]) => tag === "p")).toEqual([["p", keys.first]]); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); }); test("emoji keyboard, Escape, selected text, blur, IME and plugin disable preserve ordinary editing", async ({ page, diff --git a/tests/browser/unread.spec.mjs b/tests/browser/unread.spec.mjs index 74e58b895..781a67a25 100644 --- a/tests/browser/unread.spec.mjs +++ b/tests/browser/unread.spec.mjs @@ -305,7 +305,7 @@ test("a surviving window publishes a closed window's durable read intent", async await open(page, app); await composer(page).focus(); const survivor = await context.newPage(); - survivor.on("pageerror", (error) => app.report.errors.push(error.message)); + app.watchPageErrors(survivor); survivor.on("console", (message) => { if (message.type() === "error") app.report.consoleErrors.push(message.text()); diff --git a/tests/browser/user-status.spec.mjs b/tests/browser/user-status.spec.mjs index ba4198e5d..3f4487286 100644 --- a/tests/browser/user-status.spec.mjs +++ b/tests/browser/user-status.spec.mjs @@ -2,6 +2,7 @@ import { test, expect } from "@playwright/test"; import { createServer } from "./vite-server.mjs"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; +import { watchPageErrors } from "./page-errors.mjs"; test("statuses edit, synchronize, clear, reject stale traffic and retain failed drafts", async ({ page, @@ -15,8 +16,7 @@ test("statuses edit, synchronize, clear, reject stale traffic and retain failed server: { host: "127.0.0.1", port: 0, open: false }, preview: { open: false }, }); - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); try { await page.route("https://emoji.test/**", (route) => route.fulfill({ @@ -261,7 +261,7 @@ test("statuses edit, synchronize, clear, reject stale traffic and retain failed /linear-gradient/, ); } - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); } finally { await server.close(); } diff --git a/tests/demos/identity-names/capture.mjs b/tests/demos/identity-names/capture.mjs index eb6f48b48..cd9811265 100644 --- a/tests/demos/identity-names/capture.mjs +++ b/tests/demos/identity-names/capture.mjs @@ -1,4 +1,5 @@ import { chromium, expect } from "@playwright/test"; +import { watchPageErrors } from "../../browser/page-errors.mjs"; import { mkdir, writeFile } from "node:fs/promises"; import { execFileSync } from "node:child_process"; const out = process.env.DEMO_OUTPUT ?? "test-results/identity-names-demo"; @@ -7,8 +8,7 @@ const b = await chromium.launch(); const page = await b.newPage(); const data = await (await fetch("http://127.0.0.1:1435/demo/data")).json(); await page.setViewportSize({ width: 1440, height: 1000 }); -const errors = []; -page.on("pageerror", (e) => errors.push(e.message)); +const pageErrors = watchPageErrors(page); await page.goto("http://127.0.0.1:1435"); await page .getByRole("navigation", { name: "Pages", exact: true }) @@ -113,12 +113,12 @@ await writeFile( browser: b.version(), data, shots, - errors, + errors: pageErrors.errors, }, null, 2, ), ); -expect(errors).toEqual([]); -console.log(JSON.stringify({ shots, errors }, null, 2)); +expect(pageErrors.unexplained()).toEqual([]); +console.log(JSON.stringify({ shots, errors: pageErrors.errors }, null, 2)); await b.close(); diff --git a/tests/demos/identity-names/surfaces.mjs b/tests/demos/identity-names/surfaces.mjs index 149cf3d97..674434d1a 100644 --- a/tests/demos/identity-names/surfaces.mjs +++ b/tests/demos/identity-names/surfaces.mjs @@ -1,12 +1,12 @@ import { chromium, expect } from "@playwright/test"; +import { watchPageErrors } from "../../browser/page-errors.mjs"; import { execFileSync } from "node:child_process"; import { mkdir, writeFile } from "node:fs/promises"; const out = process.env.DEMO_OUTPUT ?? "test-results/identity-names-demo"; await mkdir(out, { recursive: true }); const b = await chromium.launch(); const p = await b.newPage({ viewport: { width: 1440, height: 1000 } }); -const errors = []; -p.on("pageerror", (e) => errors.push(e.message)); +const pageErrors = watchPageErrors(p); await p.addInitScript(() => { window.demoNotifications = []; window.Notification = class { @@ -375,7 +375,7 @@ await shot( "surface-library-custom", "Custom library identity cards with key qualifiers; template names are not identity names", ); -expect(errors).toEqual([]); +expect(pageErrors.unexplained()).toEqual([]); await writeFile( `${out}/SURFACE_CAPTURES.json`, JSON.stringify( @@ -386,7 +386,7 @@ await writeFile( dirty: execFileSync("git", ["status", "--short"], { encoding: "utf8" }), browser: b.version(), shots, - errors, + errors: pageErrors.errors, }, null, 2, diff --git a/tests/fixtures/design-system/viewer.spec.ts b/tests/fixtures/design-system/viewer.spec.ts index bbb3f1dff..6958eef79 100644 --- a/tests/fixtures/design-system/viewer.spec.ts +++ b/tests/fixtures/design-system/viewer.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Locator } from "@playwright/test"; +import { watchPageErrors } from "../../browser/page-errors.mjs"; import { COMPONENTS } from "../../../src/shared/design-system/ui/registry"; import { PHOSPHOR_ICONS } from "../../../src/shared/design-system/icons/inventory"; @@ -258,7 +259,7 @@ test("built viewer loads every specimen and foundation without app connections", }) => { const failures: string[] = []; const sockets: string[] = []; - page.on("pageerror", (e) => failures.push(e.message)); + const pageErrors = watchPageErrors(page); page.on("response", (r) => { if (r.status() >= 400) failures.push(`${r.status()} ${r.url()}`); }); @@ -333,7 +334,7 @@ test("built viewer loads every specimen and foundation without app connections", await expect( nav.getByRole("link", { name: /Conversation|Agent work/ }), ).toHaveCount(0); - expect(failures).toEqual([]); + expect([...pageErrors.unexplained(), ...failures]).toEqual([]); expect(sockets).toEqual([]); }); @@ -773,7 +774,7 @@ test("a stale or renamed link explains itself instead of rendering blank", async page, }) => { const failures: string[] = []; - page.on("pageerror", (e) => failures.push(e.message)); + const pageErrors = watchPageErrors(page); for (const hash of [ "#/design/components/renamed-away", "#/design/colours", @@ -791,7 +792,7 @@ test("a stale or renamed link explains itself instead of rendering blank", async page.getByRole("heading", { name: "Buzz Design System", exact: true }), ).toBeVisible(); } - expect(failures).toEqual([]); + expect([...pageErrors.unexplained(), ...failures]).toEqual([]); }); test("switch labels activate the control and busy switches preserve focus", async ({ @@ -1497,7 +1498,7 @@ test("built Messages gallery renders isolated product states and follows viewer }) => { const failures: string[] = []; const sockets: string[] = []; - page.on("pageerror", (error) => failures.push(error.message)); + const pageErrors = watchPageErrors(page); page.on("response", (response) => { if (response.status() >= 400) failures.push(`${response.status()} ${response.url()}`); @@ -1589,7 +1590,7 @@ test("built Messages gallery renders isolated product states and follows viewer ).toBeVisible(); await page.reload(); await expect(gallery.locator(".message-gallery-example")).toHaveCount(33); - expect(failures).toEqual([]); + expect([...pageErrors.unexplained(), ...failures]).toEqual([]); expect(sockets).toEqual([]); }); diff --git a/tests/integration/page-errors.test.mjs b/tests/integration/page-errors.test.mjs new file mode 100644 index 000000000..7926f5470 --- /dev/null +++ b/tests/integration/page-errors.test.mjs @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import test from "node:test"; +import { watchPageErrors } from "../browser/page-errors.mjs"; + +// A Playwright page reduced to the events and browser identity the watcher reads. +const fakePage = (engine) => { + const page = new EventEmitter(); + page.context = () => ({ + browser: () => ({ browserType: () => ({ name: () => engine }) }), + }); + return page; +}; +const pageError = ({ name, message, stack }) => + Object.assign(new Error(message), { name, stack }); +const cancel = (page, url, errorText = "Load request cancelled") => + page.emit("requestfailed", { + url: () => url, + failure: () => ({ errorText }), + }); + +const query = "http://127.0.0.1:43347/api/relay/primary/query"; +// Replayed from the Linux WebKit trace of navigation-repairs.spec.mjs:89 on main +// (run for 85d6bf82): Playwright split the console text at its first colon. +const reloadCancelLog = (url = query) => + pageError({ + name: "Fetch API cannot load http", + message: `${url.slice("http:/".length)} due to access control checks.`, + stack: `Fetch API cannot load ${url} due to access control checks.\n at query (http://127.0.0.1:43347/assets/index.js:141:28966)`, + }); +const resizeObserverLoop = () => + pageError({ + name: "", + message: "ResizeObserver loop completed with undelivered notifications.", + stack: "", + }); + +test("WebKit's log for a fetch the reload cancelled is kept as evidence but explained", () => { + const page = fakePage("webkit"); + const watched = watchPageErrors(page); + cancel(page, query); + page.emit("pageerror", reloadCancelLog()); + assert.deepEqual(watched.errors, [ + `Fetch API cannot load ${query} due to access control checks.`, + ]); + assert.deepEqual(watched.unexplained(), []); +}); + +test("macOS WebKit's cancellation text is also a cancellation", () => { + const page = fakePage("webkit"); + const watched = watchPageErrors(page); + cancel(page, query, "cancelled"); + page.emit("pageerror", reloadCancelLog()); + assert.deepEqual(watched.unexplained(), []); +}); + +test("the access-control log fails without a matching cancelled request", () => { + for (const setup of [ + () => {}, + (page) => cancel(page, "http://127.0.0.1:43347/api/relay/primary/session"), + (page) => cancel(page, query, "Could not connect to the server."), + ]) { + const page = fakePage("webkit"); + const watched = watchPageErrors(page); + setup(page); + page.emit("pageerror", reloadCancelLog()); + assert.equal(watched.unexplained().length, 1); + } +}); + +test("one cancellation explains one log", () => { + const page = fakePage("webkit"); + const watched = watchPageErrors(page); + cancel(page, query); + page.emit("pageerror", reloadCancelLog()); + page.emit("pageerror", reloadCancelLog()); + assert.equal(watched.unexplained().length, 1); +}); + +test("an unhandled rejection is never an engine report", () => { + const page = fakePage("webkit"); + const watched = watchPageErrors(page); + cancel(page, query); + page.emit( + "pageerror", + pageError({ + name: "Unhandled Promise Rejection", + message: "TypeError: Load failed", + stack: "", + }), + ); + assert.deepEqual(watched.unexplained(), [ + "Unhandled Promise Rejection: TypeError: Load failed", + ]); +}); + +test("engine reports are WebKit-only", () => { + const page = fakePage("chromium"); + const watched = watchPageErrors(page); + cancel(page, query); + page.emit("pageerror", reloadCancelLog()); + page.emit("pageerror", resizeObserverLoop()); + assert.equal(watched.unexplained().length, 2); + const webkit = fakePage("webkit"); + const watchedWebkit = watchPageErrors(webkit); + webkit.emit("pageerror", resizeObserverLoop()); + assert.deepEqual(watchedWebkit.unexplained(), []); + assert.equal(watchedWebkit.errors.length, 1); +}); + +test("an application error keeps its name", () => { + const page = fakePage("webkit"); + const watched = watchPageErrors(page); + page.emit("pageerror", new TypeError("a: b")); + assert.deepEqual(watched.unexplained(), ["TypeError: a: b"]); +}); From 01b738ce4c244376b75a95d0c44c05be33ed7283 Mon Sep 17 00:00:00 2001 From: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Date: Mon, 28 Sep 2026 11:53:45 -0400 Subject: [PATCH 2/3] Copy Biome lint plugins into the hook test repository Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- tests/integration/hooks.test.mjs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/tests/integration/hooks.test.mjs b/tests/integration/hooks.test.mjs index 41cee7426..813c34bcc 100644 --- a/tests/integration/hooks.test.mjs +++ b/tests/integration/hooks.test.mjs @@ -51,28 +51,29 @@ function fixture(t) { write("partial.ts", "export const first = 1;\nexport const second = 2;\n"); git("add", "."); git("commit", "-qm", "fixture base"); - for (const file of [ + // The Biome config loads lint plugins by path; the fixture needs them too. + const biomePlugins = JSON.parse( + readFileSync(path.join(root, "biome.json"), "utf8"), + ).overrides.flatMap((override) => override.plugins ?? []); + const configFiles = [ "biome.json", "package.json", "lefthook.yml", "scripts", ".githooks", - ]) + ...biomePlugins.map((plugin) => path.normalize(plugin)), + ]; + for (const file of configFiles) { + mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); cpSync(path.join(root, file), path.join(dir, file), { recursive: true }); + } symlinkSync(path.join(root, "bin"), path.join(dir, "bin"), "dir"); symlinkSync( path.join(root, "node_modules"), path.join(dir, "node_modules"), "dir", ); - git( - "add", - "biome.json", - "package.json", - "lefthook.yml", - "scripts", - ".githooks", - ); + git("add", ...configFiles); git("commit", "-qm", "hook configuration"); const sibling = path.join(dir, "sibling"); git("worktree", "add", "--detach", sibling); From aa70e710afb0020800c4356c96ad074f9aa6c07d Mon Sep 17 00:00:00 2001 From: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Date: Mon, 28 Sep 2026 14:01:11 -0400 Subject: [PATCH 3/3] Route new page-error collectors from main through the shared watcher custom-emoji-authoring and mocked-native-ipc landed on main with private pageerror listeners, which the lint rule now rejects. Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- tests/browser/custom-emoji-authoring.spec.mjs | 6 +++--- tests/browser/mocked-native-ipc.spec.mjs | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/browser/custom-emoji-authoring.spec.mjs b/tests/browser/custom-emoji-authoring.spec.mjs index 410bc33ed..93b5d362c 100644 --- a/tests/browser/custom-emoji-authoring.spec.mjs +++ b/tests/browser/custom-emoji-authoring.spec.mjs @@ -5,6 +5,7 @@ import { createHash } from "node:crypto"; import { fileURLToPath } from "node:url"; import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools"; import { verifyEvent } from "nostr-tools/pure"; +import { watchPageErrors } from "./page-errors.mjs"; import { relayBrokerPlugin } from "../../dev/relay-broker.mjs"; import { fixtureAliases, fixtureRelayUrl } from "../relay-config.ts"; @@ -224,8 +225,7 @@ test("adds custom emoji through the production broker, then uses, replaces, retr ], }); await server.listen(); - const errors = []; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); const origin = `http://127.0.0.1:${server.httpServer.address().port}`; await page.goto(`${origin}/tests/fixtures/custom-emoji.html`); const primary = "https://primary.example"; @@ -458,7 +458,7 @@ test("adds custom emoji through the production broker, then uses, replaces, retr ), contentType: "application/json", }); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); } finally { await server?.close(); } diff --git a/tests/browser/mocked-native-ipc.spec.mjs b/tests/browser/mocked-native-ipc.spec.mjs index b843159a5..eae6f7ce5 100644 --- a/tests/browser/mocked-native-ipc.spec.mjs +++ b/tests/browser/mocked-native-ipc.spec.mjs @@ -2,6 +2,7 @@ import { test, expect } from "@playwright/test"; import { createServer } from "./vite-server.mjs"; import react from "@vitejs/plugin-react"; import { fileURLToPath } from "node:url"; +import { watchPageErrors } from "./page-errors.mjs"; import { finalizeEvent, generateSecretKey, @@ -26,11 +27,10 @@ test("mocked native IPC admission and uncertain delivery survive page reload wit const viewer = getPublicKey(key); const community = "https://packaged-fixture.test"; const calls = [], - messages = [], - errors = []; + messages = []; let admitted = false, profile; - page.on("pageerror", (error) => errors.push(String(error))); + const errors = watchPageErrors(page); await page.addInitScript(() => { window.isTauri = true; Object.defineProperty(navigator, "platform", { value: "MacIntel" }); @@ -163,7 +163,7 @@ test("mocked native IPC admission and uncertain delivery survive page reload wit expect(calls.filter((path) => path === "/api/invites/claim")).toHaveLength( 1, ); - expect(errors).toEqual([]); + expect(errors.unexplained()).toEqual([]); await page.screenshot({ path: test.info().outputPath("mocked-native-ipc-recovery.png"), });