From 0230a84b82c97d33bb12a67f87a87111d0ca9c5a Mon Sep 17 00:00:00 2001 From: Todd Schiller Date: Mon, 8 Jun 2026 22:37:06 -0400 Subject: [PATCH 1/3] Add: window.__abs_dumpTrace bridge for CDP debug-trace retrieval Exposes window.__abs_dumpTrace() in the top frame's MAIN world whenever the debug-trace recorder is on (build-flag default or popup toggle) so CDP-driven harnesses can scrape the per-tab mutation record via Runtime.evaluate to investigate false positives without a human flipping the popup toggle. Centralizes the recipe in a new debug-trace docs page; install.md and the four CDP integration docs link out instead of duplicating. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/astro.config.mjs | 1 + docs/src/content/docs/browser-use.md | 10 + docs/src/content/docs/browserbase-python.md | 10 + docs/src/content/docs/debug-trace.md | 129 ++++++++++ docs/src/content/docs/hermes-agent.md | 10 + docs/src/content/docs/install.md | 15 +- docs/src/content/docs/openclaw.md | 10 + extension/build.ts | 8 + extension/src/background.ts | 33 +++ extension/src/content.ts | 8 + extension/src/dump-trace-bridge.ts | 18 ++ .../dump-trace-bridge-registration.test.ts | 171 +++++++++++++ .../dump-trace-bridge-source.test.ts | 228 ++++++++++++++++++ .../dump-trace-content-bridge.test.ts | 217 +++++++++++++++++ extension/src/lib/detection-messages.ts | 23 ++ .../src/lib/dump-trace-bridge-registration.ts | 105 ++++++++ extension/src/lib/dump-trace-bridge-source.ts | 111 +++++++++ .../src/lib/dump-trace-content-bridge.ts | 97 ++++++++ 18 files changed, 1197 insertions(+), 7 deletions(-) create mode 100644 docs/src/content/docs/debug-trace.md create mode 100644 extension/src/dump-trace-bridge.ts create mode 100644 extension/src/lib/__tests__/dump-trace-bridge-registration.test.ts create mode 100644 extension/src/lib/__tests__/dump-trace-bridge-source.test.ts create mode 100644 extension/src/lib/__tests__/dump-trace-content-bridge.test.ts create mode 100644 extension/src/lib/dump-trace-bridge-registration.ts create mode 100644 extension/src/lib/dump-trace-bridge-source.ts create mode 100644 extension/src/lib/dump-trace-content-bridge.ts diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 89890a7..10f549b 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -26,6 +26,7 @@ export default defineConfig({ sidebar: [ { label: "Install", slug: "install" }, { label: "Rules reference", slug: "rules" }, + { label: "Debug trace", slug: "debug-trace" }, { label: "Use with browser-use", slug: "browser-use" }, { label: "Use with Browserbase (Python)", slug: "browserbase-python" }, { label: "Use with OpenClaw", slug: "openclaw" }, diff --git a/docs/src/content/docs/browser-use.md b/docs/src/content/docs/browser-use.md index 69044e3..ec91a08 100644 --- a/docs/src/content/docs/browser-use.md +++ b/docs/src/content/docs/browser-use.md @@ -200,6 +200,16 @@ If none of those markers appear, the extension is not attached: upload step — there's no way to add the extension to an already-running session. +## Investigating a false positive + +When a shield rule hides, masks, or rewrites something it shouldn't have, turn +on the debug-trace recorder and pull the per-tab mutation record via +`window.__abs_dumpTrace()` through the browser-use session's `evaluate` (or any +raw-CDP `Runtime.evaluate`). Each entry tells you which rule fired, what it +matched, and the `outerHTML` before and after the mutation — enough to reproduce +the diagnosis offline. See [Debug trace](/agent-browser-shield/debug-trace/) for +the recipes and event schema. + ## Why not browser-use's built-in `Browser()`? `Browser()` launches a fresh Chromium under browser-use's control and does not diff --git a/docs/src/content/docs/browserbase-python.md b/docs/src/content/docs/browserbase-python.md index 5a1b4f4..7af198d 100644 --- a/docs/src/content/docs/browserbase-python.md +++ b/docs/src/content/docs/browserbase-python.md @@ -165,6 +165,16 @@ Any Python framework that accepts a CDP URL works the same way — pass `session.connect_url` to browser-use, LangChain's browser tools, or your own agent runner. +## Investigating a false positive + +When a shield rule hides, masks, or rewrites something it shouldn't have, turn +on the debug-trace recorder and pull the per-tab mutation record via +`window.__abs_dumpTrace()` over CDP. Each entry tells you which rule fired, what +it matched, and the `outerHTML` before and after the mutation — enough to +reproduce the diagnosis offline. See +[Debug trace](/agent-browser-shield/debug-trace/) for the Playwright + raw-CDP +recipes and event schema. + ## Brief the agent on what the shield changes The extension rewrites the DOM — masked text becomes `[PII masked]`, dark diff --git a/docs/src/content/docs/debug-trace.md b/docs/src/content/docs/debug-trace.md new file mode 100644 index 0000000..2da52ff --- /dev/null +++ b/docs/src/content/docs/debug-trace.md @@ -0,0 +1,129 @@ +--- +title: Debug trace +description: Investigate false positives — record every rule-driven mutation the shield makes and scrape the trace from the popup or over CDP via window.__abs_dumpTrace. +--- + +Turn on the debug-trace recorder when you need to investigate a **false +positive** — a rule hid, masked, or rewrote content that should have been left +alone, and you want to know which rule fired, what it matched, and what the page +looked like before and after. The recorder captures every rule-driven mutation +(selector, mutation kind, before/after `outerHTML`, segment id) and persists it +to IndexedDB at the extension origin so you can pull the record after the fact +and reproduce the diagnosis offline. + +Typical scenarios: + +- **An agent failed a task and you suspect the shield got in the way.** Compare + the trace to what the agent's accessibility-tree dump showed — + rule-application entries tell you exactly which DOM nodes were rewritten + between the page load and the agent's read. +- **A user reported "the page looks broken."** The trace's `outerHTML` + before/after pair shows the exact mutation; you can replay it on a clean page + locally to confirm the false positive and craft a regression test. +- **You're tuning a new rule and want a footprint report.** Run the page through + the recorder, dump the trace, and review every selector match before deciding + whether the rule's scope is right. + +The recorder is **off by default**. Turn it on for the runs you want to inspect; +leave it off everywhere else (the recorder has both a runtime cost and exposes a +`window.__abs_*` global, see [Caveats](#caveats)). + +## Turning the recorder on + +Two paths, depending on how the extension was installed: + +- **Build-time default** — pass `debugTrace: true` in your overrides file when + running `bun run build --defaults `. See the + [`debugTrace`](/agent-browser-shield/install/#build-time-defaults) bullet on + Install. Right for CDP-driven harnesses (Browserbase, Hermes, browser-use, + OpenClaw) that ship the extension with the recorder always on so no human has + to flip a toggle per session. +- **Popup toggle** — open the extension popup and flip **Debug trace**. Right + for Chrome Web Store installs where the user enables tracing on demand. The + toggle persists in `chrome.storage` for the profile. Pages already open when + the toggle flips need a reload before the `window.__abs_dumpTrace()` bridge + appears (the dynamic content-script registration only takes effect on + subsequent navigations). + +Both paths flip the same underlying `debugTraceStorage` value — the bridge +behaves identically either way. + +## Retrieving the trace + +### From the popup + +Open the extension popup on the tab you want to inspect and click **Export**. +The popup downloads a JSONL file with one stored event per line, ordered +chronologically across all frames for that tab. + +### From CDP + +When the recorder is on, the extension exposes `window.__abs_dumpTrace()` in the +top frame's MAIN world. The async function returns the same stored-event shape +as the JSONL export, scoped to the calling tab. Any CDP client can call it via +`Runtime.evaluate`. + +#### Playwright + +```python +trace = page.evaluate("async () => await window.__abs_dumpTrace()") +``` + +#### Raw CDP + +```python +result = client.send("Runtime.evaluate", { + "expression": "(async () => await window.__abs_dumpTrace())()", + "awaitPromise": True, + "returnByValue": True, +}) +entries = result["result"]["value"] +``` + +#### Feature-detection + +```python +present = page.evaluate("typeof window.__abs_dumpTrace === 'function'") +``` + +The function is absent on tabs where the recorder is off and on builds where the +bridge was never registered (no `debugTrace: true` default and no popup toggle +flip). + +## Event shape + +Each entry returned by the dump (and each JSONL line in the export) matches the +schema in +[`extension/data/debug-trace.schema.json`](https://github.com/pixiebrix/agent-browser-shield/blob/main/extension/data/debug-trace.schema.json). +The three top-level entry types are: + +- **`segment`** — bookkeeping marker emitted at initial load, route changes, + modal opens, and large mutation bursts. Subsequent rule-application entries + carry the active segment id so consumers can group events by user-visible + phase. +- **`rule-application`** — a single rule-driven mutation. Carries the rule id, + mutation kind (`hide` / `mask` / `strip` / `sanitize` / `flag` / `embed`), the + matched selector, and `outerHTML` before/after the mutation. CSS-only matches + (the rule installed a stylesheet but didn't write to the element) set + `cssOnly: true` and have identical before/after HTML. +- **`navigation`** — emitted by the background service worker on every top-level + loading transition. Lets a single trace span multiple page loads in the same + tab. + +The store caps each tab at 2000 events; older events are dropped first. + +## Caveats + +- **Top frame only.** `window.__abs_dumpTrace()` is exposed only on the top + frame. The response includes events from every frame on the tab (each carries + its `frameId`), so a CDP caller asking from the top frame still sees the full + picture. +- **The page can see it too.** When the bridge is installed, any page-world + script in the tab can call `window.__abs_dumpTrace()`. Don't enable the + recorder on a CWS profile you use for browsing untrusted sites. +- **Reload required for already-open tabs.** Toggling the popup switch on + doesn't retroactively expose the bridge on tabs you already had open. The next + navigation in that tab picks it up. +- **Recorder cost.** When on, the recorder serializes `outerHTML` around every + rule mutation. Cheap on most pages, but visibly slower on chatty SPAs that + re-render constantly. Leave the recorder off in production. diff --git a/docs/src/content/docs/hermes-agent.md b/docs/src/content/docs/hermes-agent.md index ba4718f..81a7ca0 100644 --- a/docs/src/content/docs/hermes-agent.md +++ b/docs/src/content/docs/hermes-agent.md @@ -118,6 +118,16 @@ shield is loaded in the Chrome instance running against default profile. `/browser status` from the Hermes CLI confirms the CDP endpoint. +## Investigating a false positive + +When a shield rule hides, masks, or rewrites something it shouldn't have, turn +on the debug-trace recorder and pull the per-tab mutation record via +`window.__abs_dumpTrace()` over the same CDP connection Hermes is using. Each +entry tells you which rule fired, what it matched, and the `outerHTML` before +and after the mutation — enough to reproduce the diagnosis offline. See +[Debug trace](/agent-browser-shield/debug-trace/) for the recipes and event +schema. + ## Why not Hermes' default browser providers? Hermes' first-party browser providers — Browserbase via its own session manager, diff --git a/docs/src/content/docs/install.md b/docs/src/content/docs/install.md index 9846258..cb28a71 100644 --- a/docs/src/content/docs/install.md +++ b/docs/src/content/docs/install.md @@ -106,13 +106,14 @@ set of reserved keys is also accepted for non-rule build-time toggles: - `debugTrace` (boolean, default **off**) — start with the dev-mode debug-trace recorder enabled. The recorder captures every rule-driven mutation (selector, - before/after `outerHTML`, segment id) and persists it to IndexedDB at the - extension origin so the popup's **Export** button can dump a JSONL trace. - Enable in builds you ship to automation harnesses (CDP-driven browsers, - Browserbase sessions) where you want post-hoc visibility into what the - extension touched without a human flipping the popup toggle. The export shape - is documented in - [`extension/data/debug-trace.schema.json`](https://github.com/pixiebrix/agent-browser-shield/blob/main/extension/data/debug-trace.schema.json). + before/after `outerHTML`, segment id) to IndexedDB and exposes + `window.__abs_dumpTrace()` for CDP-driven harnesses to scrape — for + investigating false positives (a rule hid, masked, or rewrote something it + shouldn't have) after the fact. Enable in builds you ship to automation + harnesses (Browserbase, Hermes, browser-use, OpenClaw) so the trace is on + every session without a human flipping the popup toggle. See + [Debug trace](/agent-browser-shield/debug-trace/) for the retrieval recipes + and event schema. The file may be partial; rules not listed keep the committed default. Unknown keys (neither a registered rule id nor a reserved key) and non-boolean values diff --git a/docs/src/content/docs/openclaw.md b/docs/src/content/docs/openclaw.md index 8654691..98541fa 100644 --- a/docs/src/content/docs/openclaw.md +++ b/docs/src/content/docs/openclaw.md @@ -208,6 +208,16 @@ If none of those markers appear, the extension is not attached: - *Browserbase:* confirm the session was created with the `extensionId` from step 2 — there's no way to add the extension to an already-running session. +## Investigating a false positive + +When a shield rule hides, masks, or rewrites something it shouldn't have, turn +on the debug-trace recorder and pull the per-tab mutation record via +`window.__abs_dumpTrace()` over the same CDP connection OpenClaw is using. Each +entry tells you which rule fired, what it matched, and the `outerHTML` before +and after the mutation — enough to reproduce the diagnosis offline. See +[Debug trace](/agent-browser-shield/debug-trace/) for the recipes and event +schema. + ## Why not OpenClaw's managed profile? OpenClaw's `openclaw` profile launches a dedicated Chromium under its own diff --git a/extension/build.ts b/extension/build.ts index e10d564..59beae0 100644 --- a/extension/build.ts +++ b/extension/build.ts @@ -146,6 +146,14 @@ async function build(): Promise { // on. See `lib/shadow-root-probe-source.ts` and // `lib/shadow-root-probe-registration.ts`. join(SRC, "shadow-root-probe.ts"), + // Standalone main-world bundle registered by the background worker + // whenever the debug-trace toggle is on. Exposes + // `window.__abs_dumpTrace()` for CDP-driven harnesses to scrape + // the IDB-backed trace mid-flow via `Runtime.evaluate` without + // needing the popup's Export button. See + // `lib/dump-trace-bridge-source.ts` and + // `lib/dump-trace-bridge-registration.ts`. + join(SRC, "dump-trace-bridge.ts"), ], outdir: DIST, target: "browser", diff --git a/extension/src/background.ts b/extension/src/background.ts index 0c92b89..ee34b11 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -7,12 +7,14 @@ import { debugTraceStorage } from "./lib/debug-trace"; import { appendEvent as appendDebugTraceEvent, clearTab as clearDebugTraceTab, + getEventsForTab as getDebugTraceForTab, } from "./lib/debug-trace-store"; import type { DebugTraceEntry, DebugTraceEventMessage, DetectionKind, DetectionPayload, + GetTabDebugTraceResponse, GetTabDetectionsRequest, GetTabDetectionsResponse, GetTabRuleCountsRequest, @@ -20,6 +22,7 @@ import type { RuleCountEntry, RuleDetectionMessage, } from "./lib/detection-messages"; +import { startDumpTraceBridgeRegistration } from "./lib/dump-trace-bridge-registration"; import { subscribeEnforcementEnabled } from "./lib/enforcement"; import { startClassifyPortListener } from "./lib/llm-background"; import { log } from "./lib/log"; @@ -434,6 +437,28 @@ chrome.runtime.onMessage.addListener( return undefined; } + if (message.type === "get-tab-debug-trace") { + const tabId = sender.tab?.id; + if (typeof tabId !== "number") { + return undefined; + } + // Async response — the page-world bridge is awaiting via + // postMessage round-trip, so a missing reply hangs the caller + // (up to its 10s timeout). `return true` keeps the message + // channel open until `sendResponse` fires. + void getDebugTraceForTab(tabId) + .then((entries) => { + const response: GetTabDebugTraceResponse = { entries }; + sendResponse(response); + }) + .catch((error: unknown) => { + log.error("get-tab-debug-trace IDB read failed", { error }); + const response: GetTabDebugTraceResponse = { entries: [] }; + sendResponse(response); + }); + return true; + } + if (message.type === "debug-trace-event") { const tabId = sender.tab?.id; const frameId = sender.frameId; @@ -522,3 +547,11 @@ startCheckoutCheckboxDefenseRegistration(); // `rules/closed-shadow-root-annotate.ts` rely on. See // `lib/shadow-root-probe-registration.ts`. startShadowRootProbeRegistration(); + +// Register/unregister the page-world `__abs_dumpTrace` bridge whenever +// the debug-trace toggle flips. Exposes `window.__abs_dumpTrace()` for +// CDP-driven harnesses to scrape the IDB-backed trace mid-flow without +// the popup's Export button. See `lib/dump-trace-bridge-registration.ts` +// for the lifecycle and `lib/dump-trace-bridge-source.ts` for the +// page-world implementation. +startDumpTraceBridgeRegistration(); diff --git a/extension/src/content.ts b/extension/src/content.ts index 4f3b0fb..3ca7955 100644 --- a/extension/src/content.ts +++ b/extension/src/content.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 PixieBrix, Inc. // Licensed under PolyForm Shield 1.0.0 — see LICENSE. +import { startDumpTraceContentBridge } from "./lib/dump-trace-content-bridge"; import { isTopFrame } from "./lib/frame"; import { log } from "./lib/log"; import { startOptionsBadge } from "./lib/options-badge"; @@ -39,4 +40,11 @@ startSegmentTracker(); if (isTopFrame()) { startOptionsBadge(); + // Isolated-world half of the `window.__abs_dumpTrace` bridge. The + // page-world half is registered separately by background when the + // debug-trace toggle is on; this listener is idle until a page + // actually calls the function (which only exists when the page-world + // half is registered). The returned unsubscribe handle is only used + // by tests — production runs for the document lifetime. + void startDumpTraceContentBridge(); } diff --git a/extension/src/dump-trace-bridge.ts b/extension/src/dump-trace-bridge.ts new file mode 100644 index 0000000..1ce3faf --- /dev/null +++ b/extension/src/dump-trace-bridge.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Build entrypoint for the page-world dump-trace bridge. Registered +// dynamically by the background worker via +// `chrome.scripting.registerContentScripts` with `world: "MAIN"` and +// `runAt: "document_start"` whenever the debug-trace toggle is on. +// Exposes `window.__abs_dumpTrace()` for CDP clients to scrape the +// extension's IndexedDB-backed trace mid-flow. +// +// Kept tiny on purpose — anything imported here ships into every +// page-world heap when the bridge is registered. Pull only from +// `lib/dump-trace-bridge-source.ts`, which is also kept +// dependency-free. + +import { installDumpTraceBridge } from "./lib/dump-trace-bridge-source"; + +installDumpTraceBridge.call(globalThis as unknown as Window); diff --git a/extension/src/lib/__tests__/dump-trace-bridge-registration.test.ts b/extension/src/lib/__tests__/dump-trace-bridge-registration.test.ts new file mode 100644 index 0000000..7d9f729 --- /dev/null +++ b/extension/src/lib/__tests__/dump-trace-bridge-registration.test.ts @@ -0,0 +1,171 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Exercises the dynamic main-world content-script registration that +// `lib/dump-trace-bridge-registration.ts` wires up. The module syncs +// chrome.scripting state against the `debugTraceStorage` toggle, so the +// test covers the four corners plus the no-op short-circuits when the +// desired state already matches the current one. +// +// `chrome.scripting`'s in-memory store comes from the shared +// `installScriptingRegistry()` helper — same store semantics as the +// other main-world registration tests, see +// `__test-mocks__/chrome-scripting-registry.ts` for the contract. + +import type { + RegisteredScript, + ScriptingRegistryHandle, +} from "../../__test-mocks__/chrome-scripting-registry"; +import { + flushScriptingPromises, + installScriptingRegistry, +} from "../../__test-mocks__/chrome-scripting-registry"; + +// See storage.test.ts for the rationale on these mocks — the storage +// module pulls in the full rule catalog transitively. +jest.mock("nanoid", () => ({ nanoid: () => "test-ref" })); +jest.mock("abort-utils", () => ({ + ReusableAbortController: class { + abort(): void { + // noop + } + get signal(): AbortSignal { + return new AbortController().signal; + } + }, + onAbort: (): (() => void) => () => { + // noop + }, +})); + +interface RegistrationModule { + startDumpTraceBridgeRegistration: () => void; +} + +interface DebugTraceModule { + debugTraceStorage: { + get: () => Promise; + set: (value: boolean) => Promise; + }; +} + +let registry: ScriptingRegistryHandle; + +async function loadModule( + toggleOn: boolean, + initiallyRegistered = false, +): Promise<{ module: RegistrationModule; debugTrace: DebugTraceModule }> { + let module!: RegistrationModule; + let debugTrace!: DebugTraceModule; + + await jest.isolateModulesAsync(async () => { + if (initiallyRegistered) { + registry.seed([ + { + id: "dump-trace-bridge-main-world", + matches: [""], + js: ["dump-trace-bridge.js"], + runAt: "document_start", + world: "MAIN", + allFrames: false, + }, + ]); + } + + debugTrace = await import("../debug-trace"); + await debugTrace.debugTraceStorage.set(toggleOn); + + module = await import("../dump-trace-bridge-registration"); + }); + + return { module, debugTrace }; +} + +beforeEach(() => { + registry = installScriptingRegistry(); +}); + +describe("startDumpTraceBridgeRegistration", () => { + it("registers the main-world bridge on startup when the toggle is on", async () => { + const { module } = await loadModule(true); + + module.startDumpTraceBridgeRegistration(); + await flushScriptingPromises(); + + expect(registry.registerMock).toHaveBeenCalledTimes(1); + const [scripts] = registry.registerMock.mock.calls[0] as [ + RegisteredScript[], + ]; + expect(scripts[0]).toMatchObject({ + id: "dump-trace-bridge-main-world", + matches: [""], + js: ["dump-trace-bridge.js"], + runAt: "document_start", + world: "MAIN", + allFrames: false, + }); + }); + + it("does not register when the toggle is off", async () => { + const { module } = await loadModule(false); + + module.startDumpTraceBridgeRegistration(); + await flushScriptingPromises(); + + expect(registry.registerMock).not.toHaveBeenCalled(); + expect(registry.unregisterMock).not.toHaveBeenCalled(); + }); + + it("unregisters an already-registered script when the toggle is off", async () => { + const { module } = await loadModule(false, /* initiallyRegistered */ true); + + module.startDumpTraceBridgeRegistration(); + await flushScriptingPromises(); + + expect(registry.unregisterMock).toHaveBeenCalledWith({ + ids: ["dump-trace-bridge-main-world"], + }); + }); + + it("does not re-register when the desired state already matches", async () => { + const { module } = await loadModule(true, /* initiallyRegistered */ true); + + module.startDumpTraceBridgeRegistration(); + await flushScriptingPromises(); + + expect(registry.registerMock).not.toHaveBeenCalled(); + expect(registry.unregisterMock).not.toHaveBeenCalled(); + }); + + it("registers when the toggle flips from off to on after startup", async () => { + const { module, debugTrace } = await loadModule(false); + + module.startDumpTraceBridgeRegistration(); + await flushScriptingPromises(); + expect(registry.registerMock).not.toHaveBeenCalled(); + + await debugTrace.debugTraceStorage.set(true); + await flushScriptingPromises(); + + expect(registry.registerMock).toHaveBeenCalledTimes(1); + }); + + it("unregisters when the toggle flips from on to off after startup", async () => { + const { module, debugTrace } = await loadModule( + true, + /* initiallyRegistered */ true, + ); + + module.startDumpTraceBridgeRegistration(); + await flushScriptingPromises(); + // No register call needed — the seed already matches. + expect(registry.registerMock).not.toHaveBeenCalled(); + + await debugTrace.debugTraceStorage.set(false); + await flushScriptingPromises(); + + expect(registry.unregisterMock).toHaveBeenCalledWith({ + ids: ["dump-trace-bridge-main-world"], + }); + }); +}); diff --git a/extension/src/lib/__tests__/dump-trace-bridge-source.test.ts b/extension/src/lib/__tests__/dump-trace-bridge-source.test.ts new file mode 100644 index 0000000..32ded3b --- /dev/null +++ b/extension/src/lib/__tests__/dump-trace-bridge-source.test.ts @@ -0,0 +1,228 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"url": "https://example.com/"} + */ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Tests for the page-world `installDumpTraceBridge` source. Mirrors the +// production install path: `dump-trace-bridge.ts` calls +// `installDumpTraceBridge.call(globalThis)` in MAIN world; jsdom's +// single-world model means installing into the test world exercises the +// same code path. +// +// The isolated-world half is not in play here — these tests stub the +// response side directly by posting a `direction: "response"` message +// from the same window, simulating what `dump-trace-content-bridge.ts` +// would do after a background round-trip. + +import { installDumpTraceBridge } from "../dump-trace-bridge-source"; + +interface BridgeWindow extends Window { + __abs_dumpTrace?: () => Promise; + __abs_dump_trace_installed?: boolean; +} + +function getBridgeWindow(): BridgeWindow { + return globalThis as unknown as BridgeWindow; +} + +// jsdom's `window.postMessage` sets `event.source` to null and +// `event.origin` to "" — both of which the bridge's response listener +// rejects. Dispatch a MessageEvent directly with the fields the bridge +// expects so the simulated response reaches the page-side resolver. +function dispatchResponse(data: unknown): void { + const event = new MessageEvent("message", { + data, + source: globalThis, + origin: globalThis.location.origin, + }); + globalThis.dispatchEvent(event); +} + +// The bridge is install-once per window (production: each MAIN-world +// document gets one install). The flag persists across tests, so the +// `bridgeWindow.addEventListener("message", ...)` registered inside +// the installer is registered exactly once for the whole file — +// matching what a real page sees. Tests share the same +// `window.__abs_dumpTrace` reference; the request-id counter keeps +// counting up across cases, which is fine because each test asserts +// only against ids it observed during its own calls. +beforeAll(() => { + installDumpTraceBridge.call(getBridgeWindow()); +}); + +describe("installDumpTraceBridge", () => { + it("exposes window.__abs_dumpTrace as an async function", () => { + const dumpTrace = getBridgeWindow().__abs_dumpTrace; + expect(typeof dumpTrace).toBe("function"); + }); + + it("posts a request and resolves when a matching response arrives", async () => { + const dumpTrace = getBridgeWindow().__abs_dumpTrace; + + const requestIds: string[] = []; + const interceptor = (event: MessageEvent): void => { + const data = event.data as { + source?: unknown; + direction?: unknown; + id?: unknown; + }; + if (data.source === "abs-dump-trace" && data.direction === "request") { + requestIds.push(data.id as string); + dispatchResponse({ + source: "abs-dump-trace", + direction: "response", + id: data.id, + entries: [ + { + tabId: 1, + frameId: 0, + addedAt: 9, + entry: { type: "navigation", url: null, timestamp: 1 }, + }, + ], + }); + } + }; + window.addEventListener("message", interceptor); + + const entries = await (dumpTrace as () => Promise)(); + + expect(requestIds).toHaveLength(1); + expect(entries).toEqual([ + { + tabId: 1, + frameId: 0, + addedAt: 9, + entry: { type: "navigation", url: null, timestamp: 1 }, + }, + ]); + window.removeEventListener("message", interceptor); + }); + + it("rejects with an Error when the response carries an error field", async () => { + const dumpTrace = getBridgeWindow().__abs_dumpTrace; + + const interceptor = (event: MessageEvent): void => { + const data = event.data as { + source?: unknown; + direction?: unknown; + id?: unknown; + }; + if (data.source === "abs-dump-trace" && data.direction === "request") { + dispatchResponse({ + source: "abs-dump-trace", + direction: "response", + id: data.id, + error: "boom", + }); + } + }; + window.addEventListener("message", interceptor); + + await expect((dumpTrace as () => Promise)()).rejects.toThrow( + "boom", + ); + window.removeEventListener("message", interceptor); + }); + + it("rejects when no response arrives before the timeout", async () => { + jest.useFakeTimers(); + try { + const dumpTrace = getBridgeWindow().__abs_dumpTrace; + + const promise = (dumpTrace as () => Promise)(); + // Suppress unhandled-rejection warnings while we advance timers — + // the rejection isn't observed until the `await expect()` below. + promise.catch(() => { + // noop + }); + jest.advanceTimersByTime(11_000); + await expect(promise).rejects.toThrow(/timed out/); + } finally { + jest.useRealTimers(); + } + }); + + it("is a no-op when called a second time on the same window", () => { + const first = getBridgeWindow().__abs_dumpTrace; + installDumpTraceBridge.call(getBridgeWindow()); + const second = getBridgeWindow().__abs_dumpTrace; + + expect(second).toBe(first); + }); + + it("assigns a unique id to each request", async () => { + const dumpTrace = getBridgeWindow().__abs_dumpTrace; + + const seen: string[] = []; + const interceptor = (event: MessageEvent): void => { + const data = event.data as { + source?: unknown; + direction?: unknown; + id?: unknown; + }; + if (data.source === "abs-dump-trace" && data.direction === "request") { + const id = data.id as string; + seen.push(id); + dispatchResponse({ + source: "abs-dump-trace", + direction: "response", + id, + entries: [], + }); + } + }; + window.addEventListener("message", interceptor); + + await Promise.all([ + (dumpTrace as () => Promise)(), + (dumpTrace as () => Promise)(), + (dumpTrace as () => Promise)(), + ]); + + // Three calls → three distinct ids. The interceptor may also catch + // a stray request queued by an earlier test (jsdom's postMessage + // uses setTimeout, and the fake-timer test above can leak one + // queued dispatch into this case); filter the snapshot to ids that + // appear at least once, then confirm cardinality and uniqueness. + expect(seen.length).toBeGreaterThanOrEqual(3); + expect(new Set(seen).size).toBe(seen.length); + window.removeEventListener("message", interceptor); + }); +}); + +describe("dump-trace bridge protocol parity", () => { + it("uses the same source/direction literals as the isolated-world bridge", () => { + // The isolated-world half hard-codes "abs-dump-trace" and + // "request"/"response" as constants in `dump-trace-content-bridge.ts`. + // The MAIN-world source mirrors them inline (no module imports + // cross the world boundary). This test asserts the literals agree + // so a future rename doesn't silently break the bridge. + const dumpTrace = getBridgeWindow().__abs_dumpTrace; + + let observed: { source?: unknown; direction?: unknown } | undefined; + const interceptor = (event: MessageEvent): void => { + const data = event.data as { source?: unknown; direction?: unknown }; + if (data.source && data.direction === "request") { + observed = data; + } + }; + window.addEventListener("message", interceptor); + + void (dumpTrace as () => Promise)().catch(() => { + // we don't resolve the request — the promise dangles until GC, + // which is fine for a literal-equality check. + }); + + return new Promise((resolve) => { + setTimeout(() => { + expect(observed?.source).toBe("abs-dump-trace"); + expect(observed?.direction).toBe("request"); + window.removeEventListener("message", interceptor); + resolve(); + }, 0); + }); + }); +}); diff --git a/extension/src/lib/__tests__/dump-trace-content-bridge.test.ts b/extension/src/lib/__tests__/dump-trace-content-bridge.test.ts new file mode 100644 index 0000000..ed88c9c --- /dev/null +++ b/extension/src/lib/__tests__/dump-trace-content-bridge.test.ts @@ -0,0 +1,217 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"url": "https://example.com/"} + */ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Tests for the isolated-world content-script half of the +// `window.__abs_dumpTrace` bridge. The listener accepts +// `{ source: "abs-dump-trace", direction: "request", id }` messages +// from the page world, forwards a `get-tab-debug-trace` runtime +// message to the background, and posts the response (or error) back +// keyed by id. +// +// jsdom's single-world model means a message posted with +// `window.postMessage` reaches the bridge's listener the same way a +// page-world script would in production. + +import { startDumpTraceContentBridge } from "../dump-trace-content-bridge"; + +interface ResponseBody { + source?: unknown; + direction?: unknown; + id?: unknown; + entries?: unknown; + error?: unknown; +} + +function captureResponses(): { responses: ResponseBody[]; stop: () => void } { + const responses: ResponseBody[] = []; + const handler = (event: MessageEvent): void => { + const data = event.data as ResponseBody | null; + if (!data || typeof data !== "object") { + return; + } + if (data.source !== "abs-dump-trace" || data.direction !== "response") { + return; + } + responses.push(data); + }; + window.addEventListener("message", handler); + return { + responses, + stop: () => { + window.removeEventListener("message", handler); + }, + }; +} + +// Wait for one tick of the macrotask queue so jsdom's queued postMessage +// dispatches and any pending microtasks settle. A single `setTimeout(0)` +// flush is enough for the request → sendMessage → response chain in +// these tests because each hop only adds microtasks beyond the +// already-queued message event. +function flush(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +// jsdom's `window.postMessage` sets `event.source` to null and +// `event.origin` to "" — both of which the bridge's listener rejects. +// Dispatch a MessageEvent directly with the fields the bridge expects +// so the test exercises the same code path a real same-origin page +// would take. Returns the dispatched event for assertion convenience. +function dispatchBridgeMessage(data: unknown): MessageEvent { + const event = new MessageEvent("message", { + data, + source: globalThis, + origin: globalThis.location.origin, + }); + globalThis.dispatchEvent(event); + return event; +} + +let sendMessage: jest.Mock; +let stopBridge: () => void; +let stopCapture: () => void; + +beforeEach(() => { + sendMessage = chrome.runtime.sendMessage as unknown as jest.Mock; + sendMessage.mockReset(); + stopBridge = () => { + // noop until a test starts the bridge + }; + stopCapture = () => { + // noop until a test captures responses + }; +}); + +afterEach(() => { + stopBridge(); + stopCapture(); +}); + +describe("startDumpTraceContentBridge", () => { + it("forwards a valid request to background and echoes entries back to the page", async () => { + const fakeEntries = [ + { + tabId: 7, + frameId: 0, + addedAt: 123, + entry: { type: "segment", segmentId: 1, kind: "initial-load" }, + }, + ]; + sendMessage.mockResolvedValueOnce({ entries: fakeEntries }); + stopBridge = startDumpTraceContentBridge(); + const capture = captureResponses(); + stopCapture = capture.stop; + const { responses } = capture; + + dispatchBridgeMessage({ + source: "abs-dump-trace", + direction: "request", + id: "req-1", + }); + await flush(); + await flush(); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith({ type: "get-tab-debug-trace" }); + expect(responses).toHaveLength(1); + expect(responses[0]).toMatchObject({ + source: "abs-dump-trace", + direction: "response", + id: "req-1", + entries: fakeEntries, + }); + }); + + it("posts an error response when the background rejects", async () => { + sendMessage.mockRejectedValueOnce(new Error("nope")); + stopBridge = startDumpTraceContentBridge(); + const capture = captureResponses(); + stopCapture = capture.stop; + const { responses } = capture; + + dispatchBridgeMessage({ + source: "abs-dump-trace", + direction: "request", + id: "req-err", + }); + await flush(); + await flush(); + + expect(responses).toHaveLength(1); + expect(responses[0]?.error).toContain("nope"); + expect(responses[0]?.entries).toBeUndefined(); + }); + + it("ignores messages with the wrong source literal", async () => { + stopBridge = startDumpTraceContentBridge(); + + dispatchBridgeMessage({ + source: "other-extension", + direction: "request", + id: "x", + }); + await flush(); + + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it("ignores messages with a non-string id", async () => { + stopBridge = startDumpTraceContentBridge(); + + dispatchBridgeMessage({ + source: "abs-dump-trace", + direction: "request", + id: 42, + }); + await flush(); + + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it("ignores response-direction messages so it doesn't echo its own replies", async () => { + stopBridge = startDumpTraceContentBridge(); + + dispatchBridgeMessage({ + source: "abs-dump-trace", + direction: "response", + id: "req-1", + entries: [], + }); + await flush(); + + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it("ignores messages whose id exceeds the length cap", async () => { + stopBridge = startDumpTraceContentBridge(); + + dispatchBridgeMessage({ + source: "abs-dump-trace", + direction: "request", + id: "x".repeat(200), + }); + await flush(); + + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it("ignores cross-origin messages even with the right source literal", async () => { + stopBridge = startDumpTraceContentBridge(); + + const event = new MessageEvent("message", { + data: { source: "abs-dump-trace", direction: "request", id: "x" }, + source: globalThis, + origin: "https://evil.example", + }); + globalThis.dispatchEvent(event); + await flush(); + + expect(sendMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/lib/detection-messages.ts b/extension/src/lib/detection-messages.ts index a32f256..185938e 100644 --- a/extension/src/lib/detection-messages.ts +++ b/extension/src/lib/detection-messages.ts @@ -192,3 +192,26 @@ export interface DebugTraceEventMessage { type: "debug-trace-event"; entry: DebugTraceEntry; } + +// Stored shape returned by `getEventsForTab`. Mirrors the `StoredEvent` +// interface in `debug-trace-store.ts`; kept structurally parallel here +// (instead of importing) so this messages module stays free of the IDB +// dependency the rule modules don't need to pull in. +export interface DebugTraceStoredEntry { + tabId: number; + frameId: number; + addedAt: number; + entry: DebugTraceEntry; +} + +// Page-world bridge → background round-trip for `window.__abs_dumpTrace`. +// The tabId comes from `sender.tab?.id` in the background — there is no +// cross-tab caller, so the request body is empty other than the type +// discriminator. +export interface GetTabDebugTraceRequest { + type: "get-tab-debug-trace"; +} + +export interface GetTabDebugTraceResponse { + entries: DebugTraceStoredEntry[]; +} diff --git a/extension/src/lib/dump-trace-bridge-registration.ts b/extension/src/lib/dump-trace-bridge-registration.ts new file mode 100644 index 0000000..42cd162 --- /dev/null +++ b/extension/src/lib/dump-trace-bridge-registration.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Background-side life-cycle for the page-world dump-trace bridge. +// When the user's debug-trace toggle is on (`debugTraceStorage`), +// `dump-trace-bridge.js` is registered via +// `chrome.scripting.registerContentScripts` with `world: "MAIN"` and +// `runAt: "document_start"` so subsequent navigations get +// `window.__abs_dumpTrace()` exposed before the page's first script. +// When the toggle is off, the registration is removed so future +// navigations get a clean Window prototype. +// +// The toggle is the same one that gates emission in +// `lib/debug-trace.ts` — its default flows from the build-time +// `EXTENSION_DEBUG_TRACE_DEFAULT` env var, so a CDP-driven build that +// ships with `debugTrace: true` registers the bridge at service-worker +// startup, and a CWS install registers the bridge when the user flips +// the popup toggle. Either path, same code. +// +// There is no on-demand `executeScript` fallback for the already-open +// tab (unlike the probe registrations): the trace recorder also only +// starts collecting on next navigation after the toggle flips, so a +// reload is already implied. The doc paragraph in install.md mentions +// the reload constraint. + +import { debugTraceStorage } from "./debug-trace"; +import { log } from "./log"; + +const SCRIPT_ID = "dump-trace-bridge-main-world"; +const SCRIPT_FILE = "dump-trace-bridge.js"; + +async function shouldBeRegistered(): Promise { + return debugTraceStorage.get(); +} + +async function isRegistered(): Promise { + try { + const registered = await chrome.scripting.getRegisteredContentScripts({ + ids: [SCRIPT_ID], + }); + return registered.length > 0; + } catch (error) { + // getRegisteredContentScripts throws if no script with the id + // exists in some Chrome versions; treat that as "not registered" + // rather than a failure mode that prevents registration. + log.warn("dump-trace-bridge registration: getRegistered threw", { error }); + return false; + } +} + +async function register(): Promise { + try { + await chrome.scripting.registerContentScripts([ + { + id: SCRIPT_ID, + matches: [""], + js: [SCRIPT_FILE], + runAt: "document_start", + world: "MAIN", + // Top frame only — the isolated-world content-bridge only + // starts inside `isTopFrame()` and `getEventsForTab` returns + // every frame's entries for the tab, so a CDP caller asking + // from the top gets the full picture without sub-frame + // bridges. + allFrames: false, + persistAcrossSessions: true, + }, + ]); + log.info("dump-trace-bridge registered at document_start (main world)"); + } catch (error) { + log.error("dump-trace-bridge registration failed", { error }); + } +} + +async function unregister(): Promise { + try { + await chrome.scripting.unregisterContentScripts({ ids: [SCRIPT_ID] }); + log.info("dump-trace-bridge unregistered"); + } catch (error) { + // Unregister fails if the script wasn't registered to begin with; + // that's a benign state, not a problem. + log.debug("dump-trace-bridge unregister no-op", { error }); + } +} + +async function sync(): Promise { + const [target, current] = await Promise.all([ + shouldBeRegistered(), + isRegistered(), + ]); + if (target === current) { + return; + } + await (target ? register() : unregister()); +} + +// Wire up the registration life-cycle. Called once from background.ts. +export function startDumpTraceBridgeRegistration(): void { + // Initial reconciliation when the service worker spins up — covers + // both first install and SW restarts on Chrome's idle timer. + void sync(); + debugTraceStorage.subscribe(() => { + void sync(); + }); +} diff --git a/extension/src/lib/dump-trace-bridge-source.ts b/extension/src/lib/dump-trace-bridge-source.ts new file mode 100644 index 0000000..bd46464 --- /dev/null +++ b/extension/src/lib/dump-trace-bridge-source.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Page-world (main-world) bridge that exposes `window.__abs_dumpTrace()` — +// an async function CDP-driven harnesses call via +// `Runtime.evaluate("(async () => await window.__abs_dumpTrace())()", +// { awaitPromise: true })` to scrape the current tab's debug-trace +// IndexedDB store mid-flow, without needing the popup's Export button. +// +// IDB lives at the extension origin so the page can't read it directly; +// instead the page posts a `{ source: "abs-dump-trace", direction: "request" }` +// message to its own window, the isolated-world content-script bridge +// (`lib/dump-trace-content-bridge.ts`) forwards a `get-tab-debug-trace` +// runtime message to the background, the background reads IDB via +// `getEventsForTab(sender.tab.id)`, and the response chain echoes the +// stored entries back through the same hops. A per-request id correlates +// the round-trip across the two postMessage boundaries. +// +// Registered only when the debug-trace toggle is on +// (`lib/dump-trace-bridge-registration.ts`) — `window.__abs_dumpTrace` +// is undefined on every page in builds where the recorder is off, so +// pages don't fingerprint the extension by probing the global. +// +// The function must not reference any module-scope identifiers — only +// the function body's source crosses into the page world via the +// bundled `dump-trace-bridge.js` entry point. The protocol literals +// (`source`, `direction`, request id format) are mirrored as constants +// in the isolated-world bridge; a unit test asserts the two agree. + +export function installDumpTraceBridge(this: Window): void { + const FLAG = "__abs_dump_trace_installed"; + const bridgeWindow = this as Window & Record; + if (bridgeWindow[FLAG]) { + return; + } + bridgeWindow[FLAG] = true; + + const SOURCE = "abs-dump-trace"; + const REQUEST_TIMEOUT_MS = 10_000; + + interface PendingRequest { + resolve: (entries: unknown) => void; + reject: (error: unknown) => void; + timer: ReturnType; + } + const pending = new Map(); + let counter = 0; + function nextId(): string { + counter += 1; + return `abs-${Date.now().toString(36)}-${counter.toString(36)}`; + } + + bridgeWindow.addEventListener("message", (event: MessageEvent) => { + if (event.source !== bridgeWindow) { + return; + } + if (event.origin !== bridgeWindow.location.origin) { + return; + } + const data = event.data as { + source?: unknown; + direction?: unknown; + id?: unknown; + entries?: unknown; + error?: unknown; + } | null; + if (!data || typeof data !== "object") { + return; + } + if (data.source !== SOURCE || data.direction !== "response") { + return; + } + if (typeof data.id !== "string") { + return; + } + const handler = pending.get(data.id); + if (!handler) { + return; + } + pending.delete(data.id); + clearTimeout(handler.timer); + if (typeof data.error === "string") { + handler.reject(new Error(data.error)); + return; + } + handler.resolve(data.entries); + }); + + function dumpTrace(): Promise { + return new Promise((resolve, reject) => { + const id = nextId(); + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error("abs:__abs_dumpTrace timed out")); + }, REQUEST_TIMEOUT_MS); + pending.set(id, { resolve, reject, timer }); + try { + bridgeWindow.postMessage( + { source: SOURCE, direction: "request", id }, + bridgeWindow.location.origin, + ); + } catch (error) { + pending.delete(id); + clearTimeout(timer); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + } + + bridgeWindow.__abs_dumpTrace = dumpTrace; +} diff --git a/extension/src/lib/dump-trace-content-bridge.ts b/extension/src/lib/dump-trace-content-bridge.ts new file mode 100644 index 0000000..6c33555 --- /dev/null +++ b/extension/src/lib/dump-trace-content-bridge.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Isolated-world counterpart to `lib/dump-trace-bridge-source.ts`. +// Listens for `{ source: "abs-dump-trace", direction: "request" }` +// postMessages from the page world, forwards a `get-tab-debug-trace` +// runtime message to the background (which reads IDB via +// `getEventsForTab(sender.tab.id)`), and echoes the response back to +// the page world by id. +// +// Always installed — the MAIN-world bridge is what gates exposure of +// `window.__abs_dumpTrace`, so this listener is idle on tabs where +// the user hasn't enabled the recorder (the page can't issue a request +// because the function doesn't exist). +// +// Started from `content.ts` inside the existing `isTopFrame()` block. + +import type { + GetTabDebugTraceRequest, + GetTabDebugTraceResponse, +} from "./detection-messages"; +import { log } from "./log"; + +// Protocol constants — kept as module-scope literals here. The MAIN-world +// source mirrors them inline (no module imports cross the world +// boundary); a unit test asserts the two agree. +const SOURCE = "abs-dump-trace"; +const MAX_REQUEST_ID_LENGTH = 128; + +interface BridgeRequest { + source: typeof SOURCE; + direction: "request"; + id: string; +} + +function isBridgeRequest(value: unknown): value is BridgeRequest { + if (!value || typeof value !== "object") { + return false; + } + const data = value as { source?: unknown; direction?: unknown; id?: unknown }; + return ( + data.source === SOURCE && + data.direction === "request" && + typeof data.id === "string" && + data.id.length > 0 && + data.id.length <= MAX_REQUEST_ID_LENGTH + ); +} + +function postResponse( + id: string, + body: { entries?: unknown; error?: string }, +): void { + window.postMessage( + { source: SOURCE, direction: "response", id, ...body }, + globalThis.location.origin, + ); +} + +async function forwardRequest(id: string): Promise { + const request: GetTabDebugTraceRequest = { type: "get-tab-debug-trace" }; + let response: GetTabDebugTraceResponse; + try { + response = await chrome.runtime.sendMessage< + GetTabDebugTraceRequest, + GetTabDebugTraceResponse + >(request); + } catch (error) { + log.warn("dump-trace bridge: background request failed", { error }); + postResponse(id, { error: String(error) }); + return; + } + postResponse(id, { entries: response.entries }); +} + +function bridgeListener(event: MessageEvent): void { + if (event.source !== globalThis) { + return; + } + if (event.origin !== globalThis.location.origin) { + return; + } + if (!isBridgeRequest(event.data)) { + return; + } + void forwardRequest(event.data.id); +} + +// Returns an unsubscribe handle. Production callers ignore it (the +// bridge lives for the document lifetime), but tests use it in +// `afterEach` so stale listeners don't accumulate across cases. +export function startDumpTraceContentBridge(): () => void { + window.addEventListener("message", bridgeListener); + return () => { + window.removeEventListener("message", bridgeListener); + }; +} From 9f4e581f8439552a8b0ce1e0a13780034f0dcae2 Mon Sep 17 00:00:00 2001 From: Todd Schiller Date: Mon, 8 Jun 2026 22:42:06 -0400 Subject: [PATCH 2/3] Fix: typecheck + knip on dump-trace bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The biome auto-fix rewrote `window` → `globalThis` for the same-window comparison, but TypeScript's `typeof globalThis` doesn't structurally satisfy `MessageEventSource`. Hold a `Window`-typed alias once and use it for the comparison and postMessage origin so both biome and tsc are happy. Add the dump-trace bundle entrypoint to knip's allow-list alongside the other probe entrypoints. Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/knip.json | 1 + .../__tests__/dump-trace-bridge-source.test.ts | 11 ++++++++--- .../dump-trace-content-bridge.test.ts | 15 ++++++++++----- extension/src/lib/dump-trace-content-bridge.ts | 18 ++++++++++++------ 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/extension/knip.json b/extension/knip.json index a349444..e9210f2 100644 --- a/extension/knip.json +++ b/extension/knip.json @@ -9,6 +9,7 @@ "src/webdriver-probe.ts", "src/checkout-checkbox-defense.ts", "src/shadow-root-probe.ts", + "src/dump-trace-bridge.ts", "eslint-rules/index.js" ], "project": ["src/**/*.{ts,tsx}", "scripts/**/*.ts", "eslint-rules/**/*.js"], diff --git a/extension/src/lib/__tests__/dump-trace-bridge-source.test.ts b/extension/src/lib/__tests__/dump-trace-bridge-source.test.ts index 32ded3b..7c7e6f5 100644 --- a/extension/src/lib/__tests__/dump-trace-bridge-source.test.ts +++ b/extension/src/lib/__tests__/dump-trace-bridge-source.test.ts @@ -31,13 +31,18 @@ function getBridgeWindow(): BridgeWindow { // `event.origin` to "" — both of which the bridge's response listener // rejects. Dispatch a MessageEvent directly with the fields the bridge // expects so the simulated response reaches the page-side resolver. +// `MessageEvent.source` is typed as `MessageEventSource` (Window | +// MessagePort | ServiceWorker), but TypeScript's `typeof globalThis` +// doesn't structurally satisfy `Window` — same constraint the bridge +// works around. Cast to a Window-typed alias once. +const eventSource: Window = globalThis as unknown as Window; function dispatchResponse(data: unknown): void { const event = new MessageEvent("message", { data, - source: globalThis, - origin: globalThis.location.origin, + source: eventSource, + origin: eventSource.location.origin, }); - globalThis.dispatchEvent(event); + eventSource.dispatchEvent(event); } // The bridge is install-once per window (production: each MAIN-world diff --git a/extension/src/lib/__tests__/dump-trace-content-bridge.test.ts b/extension/src/lib/__tests__/dump-trace-content-bridge.test.ts index ed88c9c..7c69b56 100644 --- a/extension/src/lib/__tests__/dump-trace-content-bridge.test.ts +++ b/extension/src/lib/__tests__/dump-trace-content-bridge.test.ts @@ -63,13 +63,18 @@ function flush(): Promise { // Dispatch a MessageEvent directly with the fields the bridge expects // so the test exercises the same code path a real same-origin page // would take. Returns the dispatched event for assertion convenience. +// `MessageEvent.source` is typed as `MessageEventSource` (Window | +// MessagePort | ServiceWorker), but TypeScript's `typeof globalThis` +// doesn't structurally satisfy `Window` — same constraint the bridge +// works around. Cast to a Window-typed alias once. +const eventSource: Window = globalThis as unknown as Window; function dispatchBridgeMessage(data: unknown): MessageEvent { const event = new MessageEvent("message", { data, - source: globalThis, - origin: globalThis.location.origin, + source: eventSource, + origin: eventSource.location.origin, }); - globalThis.dispatchEvent(event); + eventSource.dispatchEvent(event); return event; } @@ -206,10 +211,10 @@ describe("startDumpTraceContentBridge", () => { const event = new MessageEvent("message", { data: { source: "abs-dump-trace", direction: "request", id: "x" }, - source: globalThis, + source: eventSource, origin: "https://evil.example", }); - globalThis.dispatchEvent(event); + eventSource.dispatchEvent(event); await flush(); expect(sendMessage).not.toHaveBeenCalled(); diff --git a/extension/src/lib/dump-trace-content-bridge.ts b/extension/src/lib/dump-trace-content-bridge.ts index 6c33555..67c503f 100644 --- a/extension/src/lib/dump-trace-content-bridge.ts +++ b/extension/src/lib/dump-trace-content-bridge.ts @@ -27,6 +27,12 @@ import { log } from "./log"; const SOURCE = "abs-dump-trace"; const MAX_REQUEST_ID_LENGTH = 128; +// `globalThis` is the lint-preferred reference to the script's own +// global, but TypeScript types it as `typeof globalThis` which doesn't +// overlap with `MessageEventSource` for the same-window check below. +// Hold a Window-typed alias so the comparison type-checks. +const selfWindow: Window = globalThis as unknown as Window; + interface BridgeRequest { source: typeof SOURCE; direction: "request"; @@ -51,9 +57,9 @@ function postResponse( id: string, body: { entries?: unknown; error?: string }, ): void { - window.postMessage( + selfWindow.postMessage( { source: SOURCE, direction: "response", id, ...body }, - globalThis.location.origin, + selfWindow.location.origin, ); } @@ -74,10 +80,10 @@ async function forwardRequest(id: string): Promise { } function bridgeListener(event: MessageEvent): void { - if (event.source !== globalThis) { + if (event.source !== selfWindow) { return; } - if (event.origin !== globalThis.location.origin) { + if (event.origin !== selfWindow.location.origin) { return; } if (!isBridgeRequest(event.data)) { @@ -90,8 +96,8 @@ function bridgeListener(event: MessageEvent): void { // bridge lives for the document lifetime), but tests use it in // `afterEach` so stale listeners don't accumulate across cases. export function startDumpTraceContentBridge(): () => void { - window.addEventListener("message", bridgeListener); + selfWindow.addEventListener("message", bridgeListener); return () => { - window.removeEventListener("message", bridgeListener); + selfWindow.removeEventListener("message", bridgeListener); }; } From 7ec3e85dd9a5d811a34df2b09e999db99527c98e Mon Sep 17 00:00:00 2001 From: Todd Schiller Date: Mon, 8 Jun 2026 22:44:54 -0400 Subject: [PATCH 3/3] Fix: handle undefined background reply in dump-trace bridge When sender.tab?.id is missing the background's get-tab-debug-trace handler returns undefined without calling sendResponse, so chrome.runtime.sendMessage resolves with undefined and the previous response.entries dereference crashed with TypeError. Widen the type to GetTabDebugTraceResponse | undefined and fall back to an empty entries array. Add a regression test covering the undefined-reply path. Caught by Unblocked review on PR #223. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dump-trace-content-bridge.test.ts | 30 +++++++++++++++++++ .../src/lib/dump-trace-content-bridge.ts | 11 +++++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/extension/src/lib/__tests__/dump-trace-content-bridge.test.ts b/extension/src/lib/__tests__/dump-trace-content-bridge.test.ts index 7c69b56..e043298 100644 --- a/extension/src/lib/__tests__/dump-trace-content-bridge.test.ts +++ b/extension/src/lib/__tests__/dump-trace-content-bridge.test.ts @@ -133,6 +133,36 @@ describe("startDumpTraceContentBridge", () => { }); }); + it("treats an undefined background reply as an empty trace", async () => { + // chrome.runtime.sendMessage resolves with `undefined` when the + // background listener returns without calling sendResponse — that + // happens in the get-tab-debug-trace branch when sender.tab?.id is + // missing. Earlier code dereferenced response.entries and crashed + // with TypeError; now it should post an empty-entries response. + sendMessage.mockResolvedValueOnce(undefined); + stopBridge = startDumpTraceContentBridge(); + const capture = captureResponses(); + stopCapture = capture.stop; + const { responses } = capture; + + dispatchBridgeMessage({ + source: "abs-dump-trace", + direction: "request", + id: "req-undef", + }); + await flush(); + await flush(); + + expect(responses).toHaveLength(1); + expect(responses[0]).toMatchObject({ + source: "abs-dump-trace", + direction: "response", + id: "req-undef", + entries: [], + }); + expect(responses[0]?.error).toBeUndefined(); + }); + it("posts an error response when the background rejects", async () => { sendMessage.mockRejectedValueOnce(new Error("nope")); stopBridge = startDumpTraceContentBridge(); diff --git a/extension/src/lib/dump-trace-content-bridge.ts b/extension/src/lib/dump-trace-content-bridge.ts index 67c503f..164c4b4 100644 --- a/extension/src/lib/dump-trace-content-bridge.ts +++ b/extension/src/lib/dump-trace-content-bridge.ts @@ -65,18 +65,23 @@ function postResponse( async function forwardRequest(id: string): Promise { const request: GetTabDebugTraceRequest = { type: "get-tab-debug-trace" }; - let response: GetTabDebugTraceResponse; + let response: GetTabDebugTraceResponse | undefined; try { response = await chrome.runtime.sendMessage< GetTabDebugTraceRequest, - GetTabDebugTraceResponse + GetTabDebugTraceResponse | undefined >(request); } catch (error) { log.warn("dump-trace bridge: background request failed", { error }); postResponse(id, { error: String(error) }); return; } - postResponse(id, { entries: response.entries }); + // chrome.runtime.sendMessage resolves with `undefined` when the + // listener returns without calling sendResponse — that happens in + // the background's `get-tab-debug-trace` branch if sender.tab?.id is + // missing (rare, but possible for off-tab callers). Treat that as an + // empty trace rather than crashing the page-world promise. + postResponse(id, { entries: response?.entries ?? [] }); } function bridgeListener(event: MessageEvent): void {