Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions packages/bcode-browser/skills/browser-execute/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
await Promise.all([
session.waitFor("Page.loadEventFired", { timeoutMs: 15_000 }),

@cubic-dev-ai cubic-dev-ai Bot Jul 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The new Promise.all pattern removes the if (navigation.loaderId) await loaded guard for same-document navigations. Per the CDP protocol spec, Page.navigate omits loaderId for same-document navigations (hash changes, history.pushState/replaceState), and Page.loadEventFired does NOT fire in that case. The old code correctly only waited for the load event when loaderId was present. The new code always waits, so every same-document navigation will sit for 15 seconds and then reject with a timeout — even though the navigation succeeded. This is especially impactful in the scrapeTitles example which iterates arbitrary URLs, any of which could be a hash/SPA navigation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/bcode-browser/skills/browser-execute/SKILL.md, line 119:

<comment>The new `Promise.all` pattern removes the `if (navigation.loaderId) await loaded` guard for same-document navigations. Per the CDP protocol spec, `Page.navigate` omits `loaderId` for same-document navigations (hash changes, `history.pushState`/`replaceState`), and `Page.loadEventFired` does NOT fire in that case. The old code correctly only waited for the load event when `loaderId` was present. The new code always waits, so every same-document navigation will sit for 15 seconds and then reject with a timeout — even though the navigation succeeded. This is especially impactful in the `scrapeTitles` example which iterates arbitrary URLs, any of which could be a hash/SPA navigation.</comment>

<file context>
@@ -115,13 +115,10 @@ Common moves:
-// 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" }),
+])
</file context>
Fix with cubic

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5f8f76b by moving the protocol-specific branches behind session.navigate(url). The skill now uses one call; the helper waits only when loaderId is present and isDownload is false, and it surfaces errorText immediately. Deterministic tests cover early load events, same-document navigation, downloads, and navigation errors.

@cubic-dev-ai cubic-dev-ai Bot Jul 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new Promise.all pattern drops the if (navigation.errorText) throw new Error(...) check from the navigation pattern. When a page fails to load (e.g., DNS failure, network error), Page.navigate returns { errorText: 'net::ERR_FAILED' } — the error text is the only way to detect navigation failure. The new code either lets it silently succeed (if the error page happens to fire a load event) or surfaces a generic timeout error instead of the actionable errorText. The PR description claims to "surface Page.navigate.errorText" but this change actively removes that surfacing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/bcode-browser/skills/browser-execute/SKILL.md, line 119:

<comment>The new `Promise.all` pattern drops the `if (navigation.errorText) throw new Error(...)` check from the navigation pattern. When a page fails to load (e.g., DNS failure, network error), `Page.navigate` returns `{ errorText: 'net::ERR_FAILED' }` — the error text is the only way to detect navigation failure. The new code either lets it silently succeed (if the error page happens to fire a load event) or surfaces a generic timeout error instead of the actionable `errorText`. The PR description claims to "surface Page.navigate.errorText" but this change actively removes that surfacing.</comment>

<file context>
@@ -115,13 +115,10 @@ Common moves:
-// 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" }),
+])
</file context>
Fix with cubic

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5f8f76b by moving the protocol-specific branches behind session.navigate(url). The skill now uses one call; the helper waits only when loaderId is present and isDownload is false, and it surfaces errorText immediately. Deterministic tests cover early load events, same-document navigation, downloads, and navigation errors.

session.Page.navigate({ url: "https://example.com" }),
])

// Evaluate JS in the page.
const r = await session.Runtime.evaluate({
Expand Down Expand Up @@ -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")
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)
}
Expand Down
46 changes: 42 additions & 4 deletions packages/bcode-browser/src/cdp/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ export type ConnectOptions = {
timeoutMs?: number;
};

export type WaitForOptions<T> = {
/** 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'. */
Expand Down Expand Up @@ -189,15 +196,47 @@ export class Session implements Transport {
}

/** Wait for the next event matching `method` (and optional predicate). */
waitFor<T = unknown>(method: string, predicate?: (params: T) => boolean, timeoutMs = 30_000): Promise<T> {
waitFor<T = unknown>(method: string, options?: WaitForOptions<T>): Promise<T>;
waitFor<T = unknown>(method: string, predicate?: (params: T) => boolean, timeoutMs?: number): Promise<T>;
waitFor<T = unknown>(
method: string,
predicateOrOptions?: ((params: T) => boolean) | WaitForOptions<T>,
positionalTimeoutMs?: number,
): Promise<T> {
if (

@cubic-dev-ai cubic-dev-ai Bot Jul 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Invalid runtime method values still create a waiter that can never match a CDP event and only fail at timeout. Validate the required method string alongside the other arguments so invalid JavaScript callers fail immediately.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/bcode-browser/src/cdp/session.ts, line 206:

<comment>Invalid runtime `method` values still create a waiter that can never match a CDP event and only fail at timeout. Validate the required method string alongside the other arguments so invalid JavaScript callers fail immediately.</comment>

<file context>
@@ -189,15 +196,45 @@ export class Session implements Transport {
+    predicateOrOptions?: ((params: T) => boolean) | WaitForOptions<T>,
+    positionalTimeoutMs?: number,
+  ): Promise<T> {
+    if (
+      predicateOrOptions !== undefined &&
+      typeof predicateOrOptions !== 'function' &&
</file context>
Suggested change
if (
if (typeof method !== 'string') {
throw new TypeError('waitFor method must be a string');
}
if (
Fix with cubic

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I’m intentionally not adding this check: method is already a required TypeScript string, while this PR fixes the overloaded second argument that is valid in the documented JavaScript API. Expanding runtime validation to an unrelated required parameter would add scope without changing the event or navigation race.

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;
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');
}

return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
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 (predicate && !predicate(params as T)) return;
if (sessionId !== undefined && eventSessionId !== sessionId) return;
try {
if (predicate && !predicate(params as T)) return;
} catch (error) {
clearTimeout(timer);
unsub();
reject(error);
return;
}
clearTimeout(timer);
unsub();
resolve(params as T);
Expand Down Expand Up @@ -423,4 +462,3 @@ async function tryReadDevToolsActivePort(
return undefined;
}
}

12 changes: 8 additions & 4 deletions packages/bcode-browser/test/browser-execute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,10 @@ test.skipIf(!enabled)("workspace import inside a snippet", async () => {
await session.use(page.targetId)
}
await session.Page.enable()
await session.Page.navigate({ url: "data:text/html,<title>bcode-be</title>" })
await session.waitFor("Page.loadEventFired", undefined, 5000)
await Promise.all([
session.waitFor("Page.loadEventFired", { timeoutMs: 5000 }),
session.Page.navigate({ url: "data:text/html,<title>bcode-be</title>" }),
])
const r = await session.Runtime.evaluate({ expression: "document.title", returnByValue: true })
return r.result.value
}`,
Expand Down Expand Up @@ -125,8 +127,10 @@ test.skipIf(!enabled)("Page.captureScreenshot is collected into result.screensho
{
description: "Capture two screenshots",
code: `await session.Page.enable();
await session.Page.navigate({ url: "data:text/html,<title>shot</title><body>hi" });
await session.waitFor("Page.loadEventFired", undefined, 5000);
await Promise.all([
session.waitFor("Page.loadEventFired", { timeoutMs: 5000 }),
session.Page.navigate({ url: "data:text/html,<title>shot</title><body>hi" }),
]);
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 };`,
Expand Down
107 changes: 107 additions & 0 deletions packages/bcode-browser/test/cdp-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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, sessionId?: string) => {
server.publish(channel, JSON.stringify({ method, params, sessionId }))
}

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] = 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 () => {
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("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")
})
6 changes: 4 additions & 2 deletions packages/bcode-browser/test/cdp-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ test.skipIf(!enabled)("Session connects, navigates, reads title", async () => {
}

await session.domains.Page.enable()
await session.domains.Page.navigate({ url: "data:text/html,<title>bcode-smoke</title>" })
await session.waitFor("Page.loadEventFired", undefined, 5000)
await Promise.all([
session.waitFor("Page.loadEventFired", { timeoutMs: 5000 }),
session.domains.Page.navigate({ url: "data:text/html,<title>bcode-smoke</title>" }),
])

const r = (await session.domains.Runtime.evaluate({
expression: "document.title",
Expand Down
Loading