diff options
| author | amankumarsingh77 <amankumarsingh7702@gmail.com> | 2026-07-28 19:44:17 +0530 |
|---|---|---|
| committer | Aman Kumar <145339995+amankumarsingh77@users.noreply.github.com> | 2026-08-02 19:12:46 +0530 |
| commit | 226e814d47a5e1acd4d308619921ca1012723b88 (patch) | |
| tree | 769c3a303298c5065592e81c1908adf900133073 | |
| parent | ce30547af39f692bef777591bdb329a7fa00e86e (diff) | |
fix(cdp): route background-tab events to the tab they belong to
The event loop dropped every event whose sessionId was not the active tab's, so
the per-tab console and network buffers could only ever hold data captured while
their tab was in the foreground. The filter now drops only sessions this instance
never attached, and the dialog, frameNavigated and loadEventFired arms resolve
their tab from the event's sessionId instead of assuming the active tab — without
that, a background tab's dialog would have been recorded against the foreground
one. Tabs are also registered before the domain-enable round trips, so events
arriving during attach are no longer dropped as unknown.
Report: docs/superpowers/reports/2026-07-26-bug-audit.md#f4
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| -rw-r--r-- | src/cdp/session.ts | 8 | ||||
| -rw-r--r-- | test/cdp/session-events-test.ts | 100 |
2 files changed, 104 insertions, 4 deletions
diff --git a/src/cdp/session.ts b/src/cdp/session.ts index 39cd87e..2e0e71f 100644 --- a/src/cdp/session.ts +++ b/src/cdp/session.ts @@ -90,11 +90,11 @@ export const createCdpSession = ( // Filter: skip events from sessions we're not currently tracking. // Target.targetDestroyed is browser-level (no sessionId) — always process. if (ev.method !== "Target.targetDestroyed") { - if (ev.sessionId && ev.sessionId !== sessionId) continue; + if (ev.sessionId && !sessionIdToTargetId.has(ev.sessionId)) continue; } switch (ev.method) { case "Page.javascriptDialogOpening": { - const tab = targetId ? tabs.get(targetId) : undefined; + const tab = resolveTab(ev.sessionId); if (!tab) break; const decoded = decodeEvent(ev.method, ev.params); tab.dialog = decoded.success @@ -114,7 +114,7 @@ export const createCdpSession = ( // was about to read. (Fix for spec §7 predictability bug #2.) case "Page.frameNavigated": case "Page.loadEventFired": { - const tab = targetId ? tabs.get(targetId) : undefined; + const tab = resolveTab(ev.sessionId); if (tab) tab.pageInfoDirty = true; break; } @@ -333,7 +333,6 @@ export const createCdpSession = ( const attachedSessionId = attached.data; sessionId = attachedSessionId; targetId = pickTargetId; - await enableDomains(attachedSessionId); tabs.set(pickTargetId, { sessionId: attachedSessionId, targetId: pickTargetId, @@ -345,6 +344,7 @@ export const createCdpSession = ( refSig: new Map(), }); sessionIdToTargetId.set(attachedSessionId, pickTargetId); + await enableDomains(attachedSessionId); return ok({ targetId: pickTargetId, sessionId: attachedSessionId }); }, async switchTo(tid) { diff --git a/test/cdp/session-events-test.ts b/test/cdp/session-events-test.ts new file mode 100644 index 0000000..73f849e --- /dev/null +++ b/test/cdp/session-events-test.ts @@ -0,0 +1,100 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { setImmediate as tick } from "node:timers/promises"; +import { createCdpSession } from "../../src/cdp/session"; +import { createOwnershipRegistry } from "../../src/cdp/ownership"; +import { createStubTransport } from "../domains/fake-client"; +import { ok } from "../../src/util/result"; + +const flush = async (): Promise<void> => { + await tick(); + await tick(); +}; + +const twoTabSession = async () => { + const stub = createStubTransport({ + "Target.setDiscoverTargets": ok({}), + "Target.getTargets": ok({ + targetInfos: [ + { targetId: "t1", type: "page", title: "One", url: "https://one.test/" }, + { targetId: "t2", type: "page", title: "Two", url: "https://two.test/" }, + ], + }), + "Target.attachToTarget": [ok({ sessionId: "s1" }), ok({ sessionId: "s2" }), ok({ sessionId: "s3" })], + "Browser.getWindowForTarget": ok({ windowId: 7 }), + "Target.activateTarget": ok({}), + }); + const ownership = createOwnershipRegistry({ ownedTargetIds: ["t1", "t2"] }); + const session = createCdpSession(stub.transport, ownership); + const attached = await session.attachFirstPage(); + assert.equal(attached.success, true); + await session.switchTo("t2"); + return { stub, session }; +}; + +describe("CdpSession event routing across tabs", () => { + test("a console event from a background tab reaches that tab's buffer", async () => { + const { stub, session } = await twoTabSession(); + stub.emit({ + method: "Runtime.consoleAPICalled", + sessionId: "s1", + params: { type: "error", args: [{ type: "string", value: "boom" }] }, + }); + await flush(); + assert.equal(session.drainConsoleBuffer({}).records.length, 0); + await session.switchTo("t1"); + const drained = session.drainConsoleBuffer({}); + assert.equal(drained.records.length, 1); + assert.equal(drained.records[0]?.text, "boom"); + }); + + test("a network event from a background tab reaches that tab's buffer", async () => { + const { stub, session } = await twoTabSession(); + stub.emit({ + method: "Network.requestWillBeSent", + sessionId: "s1", + params: { requestId: "r1", request: { url: "https://one.test/api", method: "GET" }, type: "XHR" }, + }); + await flush(); + await session.switchTo("t1"); + const drained = session.drainNetworkBuffer({}); + assert.equal(drained.records.length, 1); + assert.equal(drained.records[0]?.url, "https://one.test/api"); + }); + + test("a background tab's dialog is recorded against that tab, not the active one", async () => { + const { stub, session } = await twoTabSession(); + stub.emit({ + method: "Page.javascriptDialogOpening", + sessionId: "s1", + params: { type: "confirm", message: "really?" }, + }); + await flush(); + assert.equal(session.takeDialog(), null); + await session.switchTo("t1"); + assert.equal(session.takeDialog()?.message, "really?"); + }); + + test("a background tab's navigation marks that tab's page info dirty, not the active one", async () => { + const { stub, session } = await twoTabSession(); + session.drainPageInfoInvalidations(); + stub.emit({ method: "Page.loadEventFired", sessionId: "s1", params: {} }); + await flush(); + assert.equal(session.drainPageInfoInvalidations(), false); + await session.switchTo("t1"); + assert.equal(session.drainPageInfoInvalidations(), true); + }); + + test("an event from a session this instance never attached is ignored", async () => { + const { stub, session } = await twoTabSession(); + stub.emit({ + method: "Runtime.consoleAPICalled", + sessionId: "unknown-session", + params: { type: "error", args: [{ type: "string", value: "stranger" }] }, + }); + await flush(); + assert.equal(session.drainConsoleBuffer({}).records.length, 0); + await session.switchTo("t1"); + assert.equal(session.drainConsoleBuffer({}).records.length, 0); + }); +}); |
