Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
9 changes: 6 additions & 3 deletions packages/bcode-browser/skills/browser-execute/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,12 @@ For unknown param shapes, call with `{}` and inspect the thrown `CdpError` — `
Common moves:

```js
// Navigate.
// Navigate. Register the load waiter BEFORE navigate so a fast load isn't missed.
await session.Page.enable()
const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 15_000 })
await session.Page.navigate({ url: "https://example.com" })
await session.waitFor("Page.loadEventFired")
await loaded
// Page.navigate resolves even on network errors — its result carries `errorText` when the load failed.

// Evaluate JS in the page.
const r = await session.Runtime.evaluate({
Expand Down Expand Up @@ -153,8 +155,9 @@ 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 })
await session.Page.navigate({ url })
await session.waitFor("Page.loadEventFired")
await loaded
const r = await session.Runtime.evaluate({ expression: "document.title", returnByValue: true })
titles.push(r.result.value)
}
Expand Down
42 changes: 37 additions & 5 deletions packages/bcode-browser/src/cdp/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,21 +188,53 @@ 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> {
return new Promise((resolve, reject) => {
/**
* Wait for the next event matching `method` (and optional predicate).
* Register the waiter before the call that triggers the event:
* const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 15_000 })
* await session.Page.navigate({ url })
* await loaded
*/
waitFor<T = unknown>(
method: string,
opts: { predicate?: (params: T) => boolean; timeoutMs?: number } = {},
...rest: never[]
): Promise<T> {
// Both legacy positional shapes fail loudly rather than silently reverting
// to the 30s default: `(method, predicate)` lands on the first guard,
// `(method, predicate?, timeoutMs)` on the second. Snippets are written at
// runtime, so a stale call shape can only be caught here.
if (typeof opts === 'function') {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
throw new TypeError('waitFor(method, { predicate, timeoutMs }) — pass the predicate in the options object');
}
if (rest.length > 0) {
throw new TypeError('waitFor(method, { predicate, timeoutMs }) — pass the timeout in the options object');
}
const p = new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
unsub();
reject(new Error(`Timeout waiting for ${method}`));
}, timeoutMs);
}, opts.timeoutMs ?? 30_000);
const unsub = this.onEvent((m, params) => {
if (m !== method) return;
if (predicate && !predicate(params as T)) return;
try {
if (opts.predicate && !opts.predicate(params as T)) return;
} catch (e) {
clearTimeout(timer);
unsub();
reject(e);
return;
}
clearTimeout(timer);
unsub();
resolve(params as T);
});
});
// Pre-observe so an abandoned waiter (snippet returned or threw before
// awaiting it) times out without an unhandled rejection. Awaiting
// callers still see the rejection.
p.catch(() => {});
return p;
}

// Transport implementation. Called by the generated domain bindings.
Expand Down
6 changes: 4 additions & 2 deletions packages/bcode-browser/test/browser-execute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,<title>bcode-be</title>" })
await session.waitFor("Page.loadEventFired", undefined, 5000)
await loaded
const r = await session.Runtime.evaluate({ expression: "document.title", returnByValue: true })
return r.result.value
}`,
Expand Down Expand Up @@ -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,<title>shot</title><body>hi" });
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 };`,
Expand Down
79 changes: 79 additions & 0 deletions packages/bcode-browser/test/cdp-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// waitFor semantics against a bare WebSocket server (no Chrome needed).
// Test structure adapted from PR #111 by @MagMueller.
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() {},
},
})
const session = new Session()
const emit = (method: string, params: unknown) => {
server.publish(channel, JSON.stringify({ method, params }))
}

beforeAll(async () => {
await session.connect({ wsUrl: `ws://127.0.0.1:${server.port}/` })
})

afterAll(() => {
session.close()
server.stop(true)
})

test("waitFor resolves on a matching event, respecting the predicate", async () => {
const waiting = session.waitFor<{ ready: boolean }>("Test.event", {
predicate: (params) => params.ready,
timeoutMs: 1_000,
})
emit("Test.event", { ready: false })
emit("Test.event", { ready: true })
expect(await waiting).toEqual({ ready: true })
})

test("waitFor honors timeoutMs", async () => {
await expect(session.waitFor("Test.timeout", { timeoutMs: 20 })).rejects.toThrow("Timeout waiting for Test.timeout")
})

test("waitFor rejects and unsubscribes when a predicate throws", async () => {
let calls = 0
const waiting = session.waitFor("Test.bad", {
predicate: () => {
calls++
throw new Error("predicate failed")
},
timeoutMs: 1_000,
})
emit("Test.bad", {})
await expect(waiting).rejects.toThrow("predicate failed")
emit("Test.bad", {})
await Bun.sleep(10)
expect(calls).toBe(1)
})

test("waitFor throws synchronously on the removed positional-predicate form", () => {
// @ts-expect-error old signature: waitFor(method, predicate, timeoutMs)
expect(() => session.waitFor("Test.positional", () => true, 1_000)).toThrow(TypeError)
})

test("waitFor throws on a positional timeout rather than silently using the 30s default", async () => {
// The whole point of this change is removing a silent 30s stall, so the one
// remaining positional shape — an omitted predicate with a third-argument
// timeout — must not quietly fall back to the default.
// @ts-expect-error old signature: waitFor(method, undefined, timeoutMs)
expect(() => session.waitFor("Test.positionalTimeout", undefined, 50)).toThrow(TypeError)

// And the supported form still honours the timeout it was given.
const started = Date.now()
await expect(session.waitFor("Test.never", { timeoutMs: 50 })).rejects.toThrow(/Timeout waiting for/)
expect(Date.now() - started).toBeLessThan(1_000)
})
3 changes: 2 additions & 1 deletion packages/bcode-browser/test/cdp-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,<title>bcode-smoke</title>" })
await session.waitFor("Page.loadEventFired", undefined, 5000)
await loaded

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