From 50d806fae5e98d434ecc88a6dbbe102c52000f4f Mon Sep 17 00:00:00 2001 From: MagMueller Date: Wed, 22 Jul 2026 20:17:26 -0700 Subject: [PATCH 1/7] fix(browser): harden waitFor event handling --- .../skills/browser-execute/SKILL.md | 12 ++- packages/bcode-browser/src/cdp/session.ts | 42 ++++++++- .../test/browser-execute.test.ts | 6 +- .../bcode-browser/test/cdp-session.test.ts | 93 +++++++++++++++++++ packages/bcode-browser/test/cdp-smoke.test.ts | 3 +- 5 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 packages/bcode-browser/test/cdp-session.test.ts diff --git a/packages/bcode-browser/skills/browser-execute/SKILL.md b/packages/bcode-browser/skills/browser-execute/SKILL.md index 1e6ae048b0..3a5c3ddaee 100644 --- a/packages/bcode-browser/skills/browser-execute/SKILL.md +++ b/packages/bcode-browser/skills/browser-execute/SKILL.md @@ -115,8 +115,10 @@ Common moves: ```js // Navigate. await session.Page.enable() -await session.Page.navigate({ url: "https://example.com" }) -await session.waitFor("Page.loadEventFired") +const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 15_000 }) +const navigation = await session.Page.navigate({ url: "https://example.com" }) +if (navigation.errorText) throw new Error(`Navigation failed: ${navigation.errorText}`) +await loaded // Evaluate JS in the page. const r = await session.Runtime.evaluate({ @@ -153,8 +155,10 @@ export async function scrapeTitles(session: any, urls: string[]) { const titles: string[] = [] await session.Page.enable() for (const url of urls) { - await session.Page.navigate({ url }) - await session.waitFor("Page.loadEventFired") + const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 15_000 }) + const navigation = await session.Page.navigate({ url }) + if (navigation.errorText) throw new Error(`Navigation failed: ${navigation.errorText}`) + await loaded const r = await session.Runtime.evaluate({ expression: "document.title", returnByValue: true }) titles.push(r.result.value) } diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index 6267868276..3ec21166c0 100644 --- a/packages/bcode-browser/src/cdp/session.ts +++ b/packages/bcode-browser/src/cdp/session.ts @@ -25,6 +25,13 @@ export type ConnectOptions = { timeoutMs?: number; }; +export type WaitForOptions = { + /** Only resolve when the event payload matches this predicate. */ + predicate?: (params: T) => boolean; + /** Maximum wait in ms. Default 30000. */ + timeoutMs?: number; +}; + /** A Chromium-based browser detected as running on this machine. */ export type DetectedBrowser = { /** Short label, e.g. 'Google Chrome', 'Brave', 'Comet'. */ @@ -189,7 +196,30 @@ export class Session implements Transport { } /** Wait for the next event matching `method` (and optional predicate). */ - waitFor(method: string, predicate?: (params: T) => boolean, timeoutMs = 30_000): Promise { + waitFor(method: string, options?: WaitForOptions): Promise; + waitFor(method: string, predicate?: (params: T) => boolean, timeoutMs?: number): Promise; + waitFor( + method: string, + predicateOrOptions?: ((params: T) => boolean) | WaitForOptions, + positionalTimeoutMs?: number, + ): Promise { + if ( + predicateOrOptions !== undefined && + typeof predicateOrOptions !== 'function' && + (typeof predicateOrOptions !== 'object' || predicateOrOptions === null || Array.isArray(predicateOrOptions)) + ) { + throw new TypeError('waitFor expects a predicate function or an options object'); + } + const options = typeof predicateOrOptions === 'object' ? predicateOrOptions : undefined; + if (options?.predicate !== undefined && typeof options.predicate !== 'function') { + throw new TypeError('waitFor options.predicate must be a function'); + } + const predicate = typeof predicateOrOptions === 'function' ? predicateOrOptions : options?.predicate; + const timeoutMs = options?.timeoutMs ?? positionalTimeoutMs ?? 30_000; + if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { + throw new TypeError('waitFor timeoutMs must be a non-negative finite number'); + } + return new Promise((resolve, reject) => { const timer = setTimeout(() => { unsub(); @@ -197,7 +227,14 @@ export class Session implements Transport { }, timeoutMs); const unsub = this.onEvent((m, params) => { if (m !== method) return; - if (predicate && !predicate(params as T)) return; + try { + if (predicate && !predicate(params as T)) return; + } catch (error) { + clearTimeout(timer); + unsub(); + reject(error); + return; + } clearTimeout(timer); unsub(); resolve(params as T); @@ -423,4 +460,3 @@ async function tryReadDevToolsActivePort( return undefined; } } - diff --git a/packages/bcode-browser/test/browser-execute.test.ts b/packages/bcode-browser/test/browser-execute.test.ts index fa11b6c6eb..5b1ca9eb49 100644 --- a/packages/bcode-browser/test/browser-execute.test.ts +++ b/packages/bcode-browser/test/browser-execute.test.ts @@ -87,8 +87,9 @@ test.skipIf(!enabled)("workspace import inside a snippet", async () => { await session.use(page.targetId) } await session.Page.enable() + const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 5000 }) await session.Page.navigate({ url: "data:text/html,bcode-be" }) - await session.waitFor("Page.loadEventFired", undefined, 5000) + await loaded const r = await session.Runtime.evaluate({ expression: "document.title", returnByValue: true }) return r.result.value }`, @@ -125,8 +126,9 @@ test.skipIf(!enabled)("Page.captureScreenshot is collected into result.screensho { description: "Capture two screenshots", code: `await session.Page.enable(); + const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 5000 }); await session.Page.navigate({ url: "data:text/html,shothi" }); - await session.waitFor("Page.loadEventFired", undefined, 5000); + await loaded; const a = await session.Page.captureScreenshot({ format: "png" }); const b = await session.Page.captureScreenshot({ format: "jpeg", quality: 50 }); return { aLen: a.data.length, bLen: b.data.length };`, diff --git a/packages/bcode-browser/test/cdp-session.test.ts b/packages/bcode-browser/test/cdp-session.test.ts new file mode 100644 index 0000000000..9dacc44288 --- /dev/null +++ b/packages/bcode-browser/test/cdp-session.test.ts @@ -0,0 +1,93 @@ +import { afterAll, beforeAll, expect, test } from "bun:test" +import { Session } from "../src/cdp/session" + +const channel = "cdp-events" +const server = Bun.serve({ + port: 0, + fetch(req, srv) { + return srv.upgrade(req) ? undefined : new Response("nope", { status: 400 }) + }, + websocket: { + open(ws) { + ws.subscribe(channel) + }, + message(ws, raw) { + const message: unknown = JSON.parse(String(raw)) + if (typeof message !== "object" || message === null) return + const method = Reflect.get(message, "method") + const id = Reflect.get(message, "id") + if (method !== "Page.navigate" || typeof id !== "number") return + ws.send(JSON.stringify({ method: "Page.loadEventFired", params: { timestamp: 1 } })) + ws.send(JSON.stringify({ id, result: { frameId: "frame" } })) + }, + close() {}, + }, +}) +const session = new Session() + +beforeAll(async () => { + await session.connect({ wsUrl: `ws://127.0.0.1:${server.port}/` }) +}) + +afterAll(() => { + session.close() + server.stop(true) +}) + +const emit = (method: string, params: unknown) => { + server.publish(channel, JSON.stringify({ method, params })) +} + +test("waitFor accepts predicate and timeout options", async () => { + const waiting = session.waitFor<{ ready: boolean }>("Test.options", { + predicate: (params) => params.ready, + timeoutMs: 1_000, + }) + emit("Test.options", { ready: false }) + emit("Test.options", { ready: true }) + expect(await waiting).toEqual({ ready: true }) +}) + +test("waitFor options timeout is honored", async () => { + const started = performance.now() + await expect(session.waitFor("Test.timeout", { timeoutMs: 20 })).rejects.toThrow("Timeout waiting for Test.timeout") + expect(performance.now() - started).toBeLessThan(500) +}) + +test("waitFor rejects and unsubscribes when a predicate throws", async () => { + let calls = 0 + const waiting = session.waitFor("Test.predicate-error", { + predicate: () => { + calls++ + throw new Error("predicate failed") + }, + timeoutMs: 1_000, + }) + emit("Test.predicate-error", {}) + await expect(waiting).rejects.toThrow("predicate failed") + emit("Test.predicate-error", {}) + await Bun.sleep(10) + expect(calls).toBe(1) +}) + +test("waitFor retains the positional signature", async () => { + const waiting = session.waitFor<{ ready: boolean }>("Test.positional", (params) => params.ready, 1_000) + emit("Test.positional", { ready: true }) + expect(await waiting).toEqual({ ready: true }) +}) + +test("a waiter registered before navigation catches an event emitted before the navigation response", async () => { + const loaded = session.waitFor<{ timestamp: number }>("Page.loadEventFired", { timeoutMs: 1_000 }) + await session.domains.Page.navigate({ url: "https://example.com" }) + expect(await loaded).toEqual({ timestamp: 1 }) +}) + +test("waitFor rejects invalid runtime arguments immediately", () => { + expect(() => + // @ts-expect-error Runtime callers can still pass invalid JavaScript. + session.waitFor("Test.invalid-predicate", { predicate: "not a function" }), + ).toThrow("waitFor options.predicate must be a function") + expect(() => + session.waitFor("Test.invalid-timeout", { timeoutMs: Number.NaN }), + ).toThrow("waitFor timeoutMs must be a non-negative finite number") +}) diff --git a/packages/bcode-browser/test/cdp-smoke.test.ts b/packages/bcode-browser/test/cdp-smoke.test.ts index f1fe257798..426f767eb3 100644 --- a/packages/bcode-browser/test/cdp-smoke.test.ts +++ b/packages/bcode-browser/test/cdp-smoke.test.ts @@ -32,8 +32,9 @@ test.skipIf(!enabled)("Session connects, navigates, reads title", async () => { } await session.domains.Page.enable() + const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 5000 }) await session.domains.Page.navigate({ url: "data:text/html,bcode-smoke" }) - await session.waitFor("Page.loadEventFired", undefined, 5000) + await loaded const r = (await session.domains.Runtime.evaluate({ expression: "document.title", From 9d674e029a77e3cf75968ed115275f04f9a9f1ff Mon Sep 17 00:00:00 2001 From: MagMueller Date: Wed, 22 Jul 2026 20:43:03 -0700 Subject: [PATCH 2/7] fix(browser): handle navigation waiter edge cases --- .../skills/browser-execute/SKILL.md | 8 +++-- packages/bcode-browser/src/cdp/session.ts | 4 ++- .../bcode-browser/test/cdp-session.test.ts | 30 +++++++++++++++++-- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/bcode-browser/skills/browser-execute/SKILL.md b/packages/bcode-browser/skills/browser-execute/SKILL.md index 3a5c3ddaee..5ed2f59def 100644 --- a/packages/bcode-browser/skills/browser-execute/SKILL.md +++ b/packages/bcode-browser/skills/browser-execute/SKILL.md @@ -116,9 +116,12 @@ Common moves: // Navigate. await session.Page.enable() const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 15_000 }) +// Keep the rejection observed if navigation itself fails before `await loaded`. +void loaded.catch(() => {}) const navigation = await session.Page.navigate({ url: "https://example.com" }) if (navigation.errorText) throw new Error(`Navigation failed: ${navigation.errorText}`) -await loaded +// Same-document navigations have no loaderId and do not fire a new load event. +if (navigation.loaderId) await loaded // Evaluate JS in the page. const r = await session.Runtime.evaluate({ @@ -156,9 +159,10 @@ export async function scrapeTitles(session: any, urls: string[]) { await session.Page.enable() for (const url of urls) { const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 15_000 }) + void loaded.catch(() => {}) const navigation = await session.Page.navigate({ url }) if (navigation.errorText) throw new Error(`Navigation failed: ${navigation.errorText}`) - await loaded + if (navigation.loaderId) await loaded const r = await session.Runtime.evaluate({ expression: "document.title", returnByValue: true }) titles.push(r.result.value) } diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index 3ec21166c0..f0c03ec4cb 100644 --- a/packages/bcode-browser/src/cdp/session.ts +++ b/packages/bcode-browser/src/cdp/session.ts @@ -216,6 +216,7 @@ export class Session implements Transport { } const predicate = typeof predicateOrOptions === 'function' ? predicateOrOptions : options?.predicate; const timeoutMs = options?.timeoutMs ?? positionalTimeoutMs ?? 30_000; + const sessionId = isBrowserLevel(method) ? undefined : this.activeSessionId; if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { throw new TypeError('waitFor timeoutMs must be a non-negative finite number'); } @@ -225,8 +226,9 @@ export class Session implements Transport { unsub(); reject(new Error(`Timeout waiting for ${method}`)); }, timeoutMs); - const unsub = this.onEvent((m, params) => { + const unsub = this.onEvent((m, params, eventSessionId) => { if (m !== method) return; + if (sessionId !== undefined && eventSessionId !== sessionId) return; try { if (predicate && !predicate(params as T)) return; } catch (error) { diff --git a/packages/bcode-browser/test/cdp-session.test.ts b/packages/bcode-browser/test/cdp-session.test.ts index 9dacc44288..5582e02942 100644 --- a/packages/bcode-browser/test/cdp-session.test.ts +++ b/packages/bcode-browser/test/cdp-session.test.ts @@ -17,6 +17,12 @@ const server = Bun.serve({ const method = Reflect.get(message, "method") const id = Reflect.get(message, "id") if (method !== "Page.navigate" || typeof id !== "number") return + const params = Reflect.get(message, "params") + const url = typeof params === "object" && params !== null ? Reflect.get(params, "url") : undefined + if (url === "https://navigation-fails.example") { + ws.send(JSON.stringify({ id, result: { frameId: "frame", errorText: "net::ERR_FAILED" } })) + return + } ws.send(JSON.stringify({ method: "Page.loadEventFired", params: { timestamp: 1 } })) ws.send(JSON.stringify({ id, result: { frameId: "frame" } })) }, @@ -34,8 +40,8 @@ afterAll(() => { server.stop(true) }) -const emit = (method: string, params: unknown) => { - server.publish(channel, JSON.stringify({ method, params })) +const emit = (method: string, params: unknown, sessionId?: string) => { + server.publish(channel, JSON.stringify({ method, params, sessionId })) } test("waitFor accepts predicate and timeout options", async () => { @@ -82,6 +88,26 @@ test("a waiter registered before navigation catches an event emitted before the expect(await loaded).toEqual({ timestamp: 1 }) }) +test("waitFor ignores matching events from another attached session", async () => { + session.setActiveSession("session-active") + try { + const waiting = session.waitFor<{ source: string }>("Page.loadEventFired", { timeoutMs: 1_000 }) + emit("Page.loadEventFired", { source: "background" }, "session-background") + emit("Page.loadEventFired", { source: "active" }, "session-active") + expect(await waiting).toEqual({ source: "active" }) + } finally { + session.setActiveSession(undefined) + } +}) + +test("navigation failure does not leave an unhandled waiter rejection", async () => { + const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 20 }) + void loaded.catch(() => {}) + const navigation = await session.domains.Page.navigate({ url: "https://navigation-fails.example" }) + expect(navigation.errorText).toBe("net::ERR_FAILED") + await Bun.sleep(30) +}) + test("waitFor rejects invalid runtime arguments immediately", () => { expect(() => // @ts-expect-error Runtime callers can still pass invalid JavaScript. From 576a80aad82158f01ab3fa3281de2ae8f3e3877c Mon Sep 17 00:00:00 2001 From: MagMueller Date: Wed, 22 Jul 2026 20:48:41 -0700 Subject: [PATCH 3/7] fix(browser): simplify navigation wait --- .../skills/browser-execute/SKILL.md | 20 +++++++---------- .../test/browser-execute.test.ts | 14 +++++++----- .../bcode-browser/test/cdp-session.test.ts | 22 +++++-------------- packages/bcode-browser/test/cdp-smoke.test.ts | 7 +++--- 4 files changed, 25 insertions(+), 38 deletions(-) diff --git a/packages/bcode-browser/skills/browser-execute/SKILL.md b/packages/bcode-browser/skills/browser-execute/SKILL.md index 5ed2f59def..1c9a1c43cd 100644 --- a/packages/bcode-browser/skills/browser-execute/SKILL.md +++ b/packages/bcode-browser/skills/browser-execute/SKILL.md @@ -115,13 +115,10 @@ Common moves: ```js // Navigate. await session.Page.enable() -const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 15_000 }) -// Keep the rejection observed if navigation itself fails before `await loaded`. -void loaded.catch(() => {}) -const navigation = await session.Page.navigate({ url: "https://example.com" }) -if (navigation.errorText) throw new Error(`Navigation failed: ${navigation.errorText}`) -// Same-document navigations have no loaderId and do not fire a new load event. -if (navigation.loaderId) await loaded +await Promise.all([ + session.waitFor("Page.loadEventFired", { timeoutMs: 15_000 }), + session.Page.navigate({ url: "https://example.com" }), +]) // Evaluate JS in the page. const r = await session.Runtime.evaluate({ @@ -158,11 +155,10 @@ export async function scrapeTitles(session: any, urls: string[]) { const titles: string[] = [] await session.Page.enable() for (const url of urls) { - const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 15_000 }) - void loaded.catch(() => {}) - const navigation = await session.Page.navigate({ url }) - if (navigation.errorText) throw new Error(`Navigation failed: ${navigation.errorText}`) - if (navigation.loaderId) await loaded + await Promise.all([ + session.waitFor("Page.loadEventFired", { timeoutMs: 15_000 }), + session.Page.navigate({ url }), + ]) const r = await session.Runtime.evaluate({ expression: "document.title", returnByValue: true }) titles.push(r.result.value) } diff --git a/packages/bcode-browser/test/browser-execute.test.ts b/packages/bcode-browser/test/browser-execute.test.ts index 5b1ca9eb49..f86abe8b87 100644 --- a/packages/bcode-browser/test/browser-execute.test.ts +++ b/packages/bcode-browser/test/browser-execute.test.ts @@ -87,9 +87,10 @@ test.skipIf(!enabled)("workspace import inside a snippet", async () => { await session.use(page.targetId) } await session.Page.enable() - const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 5000 }) - await session.Page.navigate({ url: "data:text/html,bcode-be" }) - await loaded + await Promise.all([ + session.waitFor("Page.loadEventFired", { timeoutMs: 5000 }), + session.Page.navigate({ url: "data:text/html,bcode-be" }), + ]) const r = await session.Runtime.evaluate({ expression: "document.title", returnByValue: true }) return r.result.value }`, @@ -126,9 +127,10 @@ test.skipIf(!enabled)("Page.captureScreenshot is collected into result.screensho { description: "Capture two screenshots", code: `await session.Page.enable(); - const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 5000 }); - await session.Page.navigate({ url: "data:text/html,shothi" }); - await loaded; + await Promise.all([ + session.waitFor("Page.loadEventFired", { timeoutMs: 5000 }), + session.Page.navigate({ url: "data:text/html,shothi" }), + ]); const a = await session.Page.captureScreenshot({ format: "png" }); const b = await session.Page.captureScreenshot({ format: "jpeg", quality: 50 }); return { aLen: a.data.length, bLen: b.data.length };`, diff --git a/packages/bcode-browser/test/cdp-session.test.ts b/packages/bcode-browser/test/cdp-session.test.ts index 5582e02942..e5a39c010b 100644 --- a/packages/bcode-browser/test/cdp-session.test.ts +++ b/packages/bcode-browser/test/cdp-session.test.ts @@ -17,12 +17,6 @@ const server = Bun.serve({ const method = Reflect.get(message, "method") const id = Reflect.get(message, "id") if (method !== "Page.navigate" || typeof id !== "number") return - const params = Reflect.get(message, "params") - const url = typeof params === "object" && params !== null ? Reflect.get(params, "url") : undefined - if (url === "https://navigation-fails.example") { - ws.send(JSON.stringify({ id, result: { frameId: "frame", errorText: "net::ERR_FAILED" } })) - return - } ws.send(JSON.stringify({ method: "Page.loadEventFired", params: { timestamp: 1 } })) ws.send(JSON.stringify({ id, result: { frameId: "frame" } })) }, @@ -83,9 +77,11 @@ test("waitFor retains the positional signature", async () => { }) test("a waiter registered before navigation catches an event emitted before the navigation response", async () => { - const loaded = session.waitFor<{ timestamp: number }>("Page.loadEventFired", { timeoutMs: 1_000 }) - await session.domains.Page.navigate({ url: "https://example.com" }) - expect(await loaded).toEqual({ timestamp: 1 }) + const [loaded] = await Promise.all([ + session.waitFor<{ timestamp: number }>("Page.loadEventFired", { timeoutMs: 1_000 }), + session.domains.Page.navigate({ url: "https://example.com" }), + ]) + expect(loaded).toEqual({ timestamp: 1 }) }) test("waitFor ignores matching events from another attached session", async () => { @@ -100,14 +96,6 @@ test("waitFor ignores matching events from another attached session", async () = } }) -test("navigation failure does not leave an unhandled waiter rejection", async () => { - const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 20 }) - void loaded.catch(() => {}) - const navigation = await session.domains.Page.navigate({ url: "https://navigation-fails.example" }) - expect(navigation.errorText).toBe("net::ERR_FAILED") - await Bun.sleep(30) -}) - test("waitFor rejects invalid runtime arguments immediately", () => { expect(() => // @ts-expect-error Runtime callers can still pass invalid JavaScript. diff --git a/packages/bcode-browser/test/cdp-smoke.test.ts b/packages/bcode-browser/test/cdp-smoke.test.ts index 426f767eb3..bc4d018d13 100644 --- a/packages/bcode-browser/test/cdp-smoke.test.ts +++ b/packages/bcode-browser/test/cdp-smoke.test.ts @@ -32,9 +32,10 @@ test.skipIf(!enabled)("Session connects, navigates, reads title", async () => { } await session.domains.Page.enable() - const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 5000 }) - await session.domains.Page.navigate({ url: "data:text/html,bcode-smoke" }) - await loaded + await Promise.all([ + session.waitFor("Page.loadEventFired", { timeoutMs: 5000 }), + session.domains.Page.navigate({ url: "data:text/html,bcode-smoke" }), + ]) const r = (await session.domains.Runtime.evaluate({ expression: "document.title", From 5f8f76b68c764ccbb3280032dd6bd3e35f48b7cd Mon Sep 17 00:00:00 2001 From: MagMueller Date: Wed, 22 Jul 2026 21:01:55 -0700 Subject: [PATCH 4/7] fix(browser): encapsulate navigation waits --- .../skills/browser-execute/SKILL.md | 12 +----- packages/bcode-browser/src/cdp/session.ts | 21 ++++++++- .../test/browser-execute.test.ts | 12 +----- .../bcode-browser/test/cdp-session.test.ts | 43 ++++++++++++++++--- packages/bcode-browser/test/cdp-smoke.test.ts | 6 +-- 5 files changed, 61 insertions(+), 33 deletions(-) diff --git a/packages/bcode-browser/skills/browser-execute/SKILL.md b/packages/bcode-browser/skills/browser-execute/SKILL.md index 1c9a1c43cd..8af2a3aa86 100644 --- a/packages/bcode-browser/skills/browser-execute/SKILL.md +++ b/packages/bcode-browser/skills/browser-execute/SKILL.md @@ -114,11 +114,7 @@ Common moves: ```js // Navigate. -await session.Page.enable() -await Promise.all([ - session.waitFor("Page.loadEventFired", { timeoutMs: 15_000 }), - session.Page.navigate({ url: "https://example.com" }), -]) +await session.navigate("https://example.com", { timeoutMs: 15_000 }) // Evaluate JS in the page. const r = await session.Runtime.evaluate({ @@ -153,12 +149,8 @@ Imports work at any depth; pick whatever layout makes the project easiest to nav // ./.bcode/agent-workspace/scrape_titles.ts (you write this with the `write` tool) export async function scrapeTitles(session: any, urls: string[]) { const titles: string[] = [] - await session.Page.enable() for (const url of urls) { - await Promise.all([ - session.waitFor("Page.loadEventFired", { timeoutMs: 15_000 }), - session.Page.navigate({ url }), - ]) + await session.navigate(url, { timeoutMs: 15_000 }) const r = await session.Runtime.evaluate({ expression: "document.title", returnByValue: true }) titles.push(r.result.value) } diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index f0c03ec4cb..bb70011d1c 100644 --- a/packages/bcode-browser/src/cdp/session.ts +++ b/packages/bcode-browser/src/cdp/session.ts @@ -6,7 +6,7 @@ * Target.sendMessageToTarget envelopes). */ -import { bindDomains, type Domains, type Transport } from './generated.ts'; +import { bindDomains, type Domains, type Page, type Transport } from './generated.ts'; type Pending = { resolve: (v: unknown) => void; @@ -32,6 +32,11 @@ export type WaitForOptions = { timeoutMs?: number; }; +export type NavigateOptions = { + /** Maximum wait for a cross-document load event in ms. Default 30000. */ + timeoutMs?: number; +}; + /** A Chromium-based browser detected as running on this machine. */ export type DetectedBrowser = { /** Short label, e.g. 'Google Chrome', 'Brave', 'Comet'. */ @@ -244,6 +249,20 @@ export class Session implements Transport { }); } + /** + * Navigate the active page and wait for cross-document loads. + * Same-document and download navigations do not emit Page.loadEventFired. + */ + async navigate(url: string, options: NavigateOptions = {}): Promise { + await this.domains.Page.enable(); + const loaded = this.waitFor('Page.loadEventFired', { timeoutMs: options.timeoutMs }); + void loaded.catch(() => {}); + const navigation = await this.domains.Page.navigate({ url }); + if (navigation.errorText) throw new Error(`Navigation failed: ${navigation.errorText}`); + if (navigation.loaderId && !navigation.isDownload) await loaded; + return navigation; + } + // Transport implementation. Called by the generated domain bindings. _call(method: string, params: unknown = {}): Promise { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { diff --git a/packages/bcode-browser/test/browser-execute.test.ts b/packages/bcode-browser/test/browser-execute.test.ts index f86abe8b87..fb237139f8 100644 --- a/packages/bcode-browser/test/browser-execute.test.ts +++ b/packages/bcode-browser/test/browser-execute.test.ts @@ -86,11 +86,7 @@ test.skipIf(!enabled)("workspace import inside a snippet", async () => { } else { await session.use(page.targetId) } - await session.Page.enable() - await Promise.all([ - session.waitFor("Page.loadEventFired", { timeoutMs: 5000 }), - session.Page.navigate({ url: "data:text/html,bcode-be" }), - ]) + await session.navigate("data:text/html,bcode-be", { timeoutMs: 5000 }) const r = await session.Runtime.evaluate({ expression: "document.title", returnByValue: true }) return r.result.value }`, @@ -126,11 +122,7 @@ test.skipIf(!enabled)("Page.captureScreenshot is collected into result.screensho return yield* impl.execute( { description: "Capture two screenshots", - code: `await session.Page.enable(); - await Promise.all([ - session.waitFor("Page.loadEventFired", { timeoutMs: 5000 }), - session.Page.navigate({ url: "data:text/html,shothi" }), - ]); + code: `await session.navigate("data:text/html,shothi", { timeoutMs: 5000 }); const a = await session.Page.captureScreenshot({ format: "png" }); const b = await session.Page.captureScreenshot({ format: "jpeg", quality: 50 }); return { aLen: a.data.length, bLen: b.data.length };`, diff --git a/packages/bcode-browser/test/cdp-session.test.ts b/packages/bcode-browser/test/cdp-session.test.ts index e5a39c010b..352a1772e7 100644 --- a/packages/bcode-browser/test/cdp-session.test.ts +++ b/packages/bcode-browser/test/cdp-session.test.ts @@ -16,9 +16,28 @@ const server = Bun.serve({ if (typeof message !== "object" || message === null) return const method = Reflect.get(message, "method") const id = Reflect.get(message, "id") - if (method !== "Page.navigate" || typeof id !== "number") return + if (typeof id !== "number") return + if (method === "Page.enable") { + ws.send(JSON.stringify({ id, result: {} })) + return + } + if (method !== "Page.navigate") return + const params = Reflect.get(message, "params") + const url = typeof params === "object" && params !== null ? Reflect.get(params, "url") : undefined + if (url === "https://example.com/#same-document") { + ws.send(JSON.stringify({ id, result: { frameId: "frame" } })) + return + } + if (url === "https://navigation-fails.example") { + ws.send(JSON.stringify({ id, result: { frameId: "frame", errorText: "net::ERR_FAILED" } })) + return + } + if (url === "https://download.example") { + ws.send(JSON.stringify({ id, result: { frameId: "frame", loaderId: "loader", isDownload: true } })) + return + } ws.send(JSON.stringify({ method: "Page.loadEventFired", params: { timestamp: 1 } })) - ws.send(JSON.stringify({ id, result: { frameId: "frame" } })) + ws.send(JSON.stringify({ id, result: { frameId: "frame", loaderId: "loader" } })) }, close() {}, }, @@ -77,11 +96,21 @@ test("waitFor retains the positional signature", async () => { }) test("a waiter registered before navigation catches an event emitted before the navigation response", async () => { - const [loaded] = await Promise.all([ - session.waitFor<{ timestamp: number }>("Page.loadEventFired", { timeoutMs: 1_000 }), - session.domains.Page.navigate({ url: "https://example.com" }), - ]) - expect(loaded).toEqual({ timestamp: 1 }) + const navigation = await session.navigate("https://example.com", { timeoutMs: 1_000 }) + expect(navigation.loaderId).toBe("loader") +}) + +test("navigate does not wait for same-document loads or downloads", async () => { + const sameDocument = await session.navigate("https://example.com/#same-document", { timeoutMs: 20 }) + const download = await session.navigate("https://download.example", { timeoutMs: 20 }) + expect(sameDocument.loaderId).toBeUndefined() + expect(download.isDownload).toBe(true) +}) + +test("navigate surfaces Page.navigate errorText", async () => { + await expect( + session.navigate("https://navigation-fails.example", { timeoutMs: 20 }), + ).rejects.toThrow("Navigation failed: net::ERR_FAILED") }) test("waitFor ignores matching events from another attached session", async () => { diff --git a/packages/bcode-browser/test/cdp-smoke.test.ts b/packages/bcode-browser/test/cdp-smoke.test.ts index bc4d018d13..75661cbd22 100644 --- a/packages/bcode-browser/test/cdp-smoke.test.ts +++ b/packages/bcode-browser/test/cdp-smoke.test.ts @@ -31,11 +31,7 @@ test.skipIf(!enabled)("Session connects, navigates, reads title", async () => { await session.use(page.targetId) } - await session.domains.Page.enable() - await Promise.all([ - session.waitFor("Page.loadEventFired", { timeoutMs: 5000 }), - session.domains.Page.navigate({ url: "data:text/html,bcode-smoke" }), - ]) + await session.navigate("data:text/html,bcode-smoke", { timeoutMs: 5000 }) const r = (await session.domains.Runtime.evaluate({ expression: "document.title", From a229720b21fd54e495e85ca6dfc2287b3b748831 Mon Sep 17 00:00:00 2001 From: MagMueller Date: Thu, 23 Jul 2026 12:48:08 -0700 Subject: [PATCH 5/7] fix(browser): cancel unused navigation waiters --- packages/bcode-browser/src/cdp/session.ts | 55 +++++++++++++++---- .../bcode-browser/test/cdp-session.test.ts | 3 + 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index bb70011d1c..2c817307c0 100644 --- a/packages/bcode-browser/src/cdp/session.ts +++ b/packages/bcode-browser/src/cdp/session.ts @@ -37,6 +37,11 @@ export type NavigateOptions = { timeoutMs?: number; }; +type EventWaiter = { + promise: Promise; + cancel: () => void; +}; + /** A Chromium-based browser detected as running on this machine. */ export type DetectedBrowser = { /** Short label, e.g. 'Google Chrome', 'Brave', 'Comet'. */ @@ -226,9 +231,26 @@ export class Session implements Transport { throw new TypeError('waitFor timeoutMs must be a non-negative finite number'); } - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { + return this.createEventWaiter(method, predicate, timeoutMs, sessionId).promise; + } + + private createEventWaiter( + method: string, + predicate: ((params: T) => boolean) | undefined, + timeoutMs: number, + sessionId: string | undefined, + ): EventWaiter { + let cancel = () => {}; + const promise = new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + if (settled) return; + settled = true; + clearTimeout(timer); unsub(); + }; + const timer = setTimeout(() => { + cleanup(); reject(new Error(`Timeout waiting for ${method}`)); }, timeoutMs); const unsub = this.onEvent((m, params, eventSessionId) => { @@ -237,16 +259,16 @@ export class Session implements Transport { try { if (predicate && !predicate(params as T)) return; } catch (error) { - clearTimeout(timer); - unsub(); + cleanup(); reject(error); return; } - clearTimeout(timer); - unsub(); + cleanup(); resolve(params as T); }); + cancel = cleanup; }); + return { promise, cancel }; } /** @@ -255,12 +277,21 @@ export class Session implements Transport { */ async navigate(url: string, options: NavigateOptions = {}): Promise { await this.domains.Page.enable(); - const loaded = this.waitFor('Page.loadEventFired', { timeoutMs: options.timeoutMs }); - void loaded.catch(() => {}); - const navigation = await this.domains.Page.navigate({ url }); - if (navigation.errorText) throw new Error(`Navigation failed: ${navigation.errorText}`); - if (navigation.loaderId && !navigation.isDownload) await loaded; - return navigation; + const waiter = this.createEventWaiter( + 'Page.loadEventFired', + undefined, + options.timeoutMs ?? 30_000, + this.activeSessionId, + ); + void waiter.promise.catch(() => {}); + try { + const navigation = await this.domains.Page.navigate({ url }); + if (navigation.errorText) throw new Error(`Navigation failed: ${navigation.errorText}`); + if (navigation.loaderId && !navigation.isDownload) await waiter.promise; + return navigation; + } finally { + waiter.cancel(); + } } // Transport implementation. Called by the generated domain bindings. diff --git a/packages/bcode-browser/test/cdp-session.test.ts b/packages/bcode-browser/test/cdp-session.test.ts index 352a1772e7..08241975cc 100644 --- a/packages/bcode-browser/test/cdp-session.test.ts +++ b/packages/bcode-browser/test/cdp-session.test.ts @@ -102,15 +102,18 @@ test("a waiter registered before navigation catches an event emitted before the test("navigate does not wait for same-document loads or downloads", async () => { const sameDocument = await session.navigate("https://example.com/#same-document", { timeoutMs: 20 }) + expect((session as any).eventListeners).toHaveLength(0) const download = await session.navigate("https://download.example", { timeoutMs: 20 }) expect(sameDocument.loaderId).toBeUndefined() expect(download.isDownload).toBe(true) + expect((session as any).eventListeners).toHaveLength(0) }) test("navigate surfaces Page.navigate errorText", async () => { await expect( session.navigate("https://navigation-fails.example", { timeoutMs: 20 }), ).rejects.toThrow("Navigation failed: net::ERR_FAILED") + expect((session as any).eventListeners).toHaveLength(0) }) test("waitFor ignores matching events from another attached session", async () => { From b88f652e4b2cc738aaf1dd176af8071113b0d8c0 Mon Sep 17 00:00:00 2001 From: MagMueller Date: Thu, 23 Jul 2026 12:57:50 -0700 Subject: [PATCH 6/7] fix(browser): validate navigation timeout --- packages/bcode-browser/src/cdp/session.ts | 6 +++++- packages/bcode-browser/test/cdp-session.test.ts | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index 2c817307c0..fe23cd5a7b 100644 --- a/packages/bcode-browser/src/cdp/session.ts +++ b/packages/bcode-browser/src/cdp/session.ts @@ -276,11 +276,15 @@ export class Session implements Transport { * Same-document and download navigations do not emit Page.loadEventFired. */ async navigate(url: string, options: NavigateOptions = {}): Promise { + const timeoutMs = options.timeoutMs ?? 30_000; + if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { + throw new TypeError('navigate timeoutMs must be a non-negative finite number'); + } await this.domains.Page.enable(); const waiter = this.createEventWaiter( 'Page.loadEventFired', undefined, - options.timeoutMs ?? 30_000, + timeoutMs, this.activeSessionId, ); void waiter.promise.catch(() => {}); diff --git a/packages/bcode-browser/test/cdp-session.test.ts b/packages/bcode-browser/test/cdp-session.test.ts index 08241975cc..2dca9a43b7 100644 --- a/packages/bcode-browser/test/cdp-session.test.ts +++ b/packages/bcode-browser/test/cdp-session.test.ts @@ -128,7 +128,7 @@ test("waitFor ignores matching events from another attached session", async () = } }) -test("waitFor rejects invalid runtime arguments immediately", () => { +test("waitFor and navigate reject invalid runtime arguments immediately", async () => { expect(() => // @ts-expect-error Runtime callers can still pass invalid JavaScript. session.waitFor("Test.invalid-predicate", { predicate: "not a function" }), @@ -136,4 +136,7 @@ test("waitFor rejects invalid runtime arguments immediately", () => { expect(() => session.waitFor("Test.invalid-timeout", { timeoutMs: Number.NaN }), ).toThrow("waitFor timeoutMs must be a non-negative finite number") + await expect( + session.navigate("https://example.com", { timeoutMs: Number.POSITIVE_INFINITY }), + ).rejects.toThrow("navigate timeoutMs must be a non-negative finite number") }) From ba38df98e923113afdbbe021761347974e178a27 Mon Sep 17 00:00:00 2001 From: MagMueller Date: Thu, 23 Jul 2026 13:03:37 -0700 Subject: [PATCH 7/7] docs(browser): use default navigation timeout --- packages/bcode-browser/skills/browser-execute/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/bcode-browser/skills/browser-execute/SKILL.md b/packages/bcode-browser/skills/browser-execute/SKILL.md index 8af2a3aa86..fccbe91255 100644 --- a/packages/bcode-browser/skills/browser-execute/SKILL.md +++ b/packages/bcode-browser/skills/browser-execute/SKILL.md @@ -114,7 +114,7 @@ Common moves: ```js // Navigate. -await session.navigate("https://example.com", { timeoutMs: 15_000 }) +await session.navigate("https://example.com") // Evaluate JS in the page. const r = await session.Runtime.evaluate({ @@ -150,7 +150,7 @@ Imports work at any depth; pick whatever layout makes the project easiest to nav export async function scrapeTitles(session: any, urls: string[]) { const titles: string[] = [] for (const url of urls) { - await session.navigate(url, { timeoutMs: 15_000 }) + await session.navigate(url) const r = await session.Runtime.evaluate({ expression: "document.title", returnByValue: true }) titles.push(r.result.value) }