Skip to content

Commit 089681b

Browse files
committed
fix(browser): make waitFor options-only and register load waiters before navigate
1 parent 89f8ffc commit 089681b

5 files changed

Lines changed: 104 additions & 11 deletions

File tree

packages/bcode-browser/skills/browser-execute/SKILL.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,12 @@ For unknown param shapes, call with `{}` and inspect the thrown `CdpError` — `
113113
Common moves:
114114
115115
```js
116-
// Navigate.
116+
// Navigate. Register the load waiter BEFORE navigate so a fast load isn't missed.
117117
await session.Page.enable()
118+
const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 15_000 })
118119
await session.Page.navigate({ url: "https://example.com" })
119-
await session.waitFor("Page.loadEventFired")
120+
await loaded
121+
// Page.navigate resolves even on network errors — its result carries `errorText` when the load failed.
120122
121123
// Evaluate JS in the page.
122124
const r = await session.Runtime.evaluate({
@@ -153,8 +155,9 @@ export async function scrapeTitles(session: any, urls: string[]) {
153155
const titles: string[] = []
154156
await session.Page.enable()
155157
for (const url of urls) {
158+
const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 15_000 })
156159
await session.Page.navigate({ url })
157-
await session.waitFor("Page.loadEventFired")
160+
await loaded
158161
const r = await session.Runtime.evaluate({ expression: "document.title", returnByValue: true })
159162
titles.push(r.result.value)
160163
}

packages/bcode-browser/src/cdp/session.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -188,21 +188,42 @@ export class Session implements Transport {
188188
};
189189
}
190190

191-
/** Wait for the next event matching `method` (and optional predicate). */
192-
waitFor<T = unknown>(method: string, predicate?: (params: T) => boolean, timeoutMs = 30_000): Promise<T> {
193-
return new Promise((resolve, reject) => {
191+
/**
192+
* Wait for the next event matching `method` (and optional predicate).
193+
* Register the waiter before the call that triggers the event:
194+
* const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 15_000 })
195+
* await session.Page.navigate({ url })
196+
* await loaded
197+
*/
198+
waitFor<T = unknown>(method: string, opts: { predicate?: (params: T) => boolean; timeoutMs?: number } = {}): Promise<T> {
199+
if (typeof opts === 'function') {
200+
throw new TypeError('waitFor(method, { predicate, timeoutMs }) — pass the predicate in the options object');
201+
}
202+
const p = new Promise<T>((resolve, reject) => {
194203
const timer = setTimeout(() => {
195204
unsub();
196205
reject(new Error(`Timeout waiting for ${method}`));
197-
}, timeoutMs);
206+
}, opts.timeoutMs ?? 30_000);
198207
const unsub = this.onEvent((m, params) => {
199208
if (m !== method) return;
200-
if (predicate && !predicate(params as T)) return;
209+
try {
210+
if (opts.predicate && !opts.predicate(params as T)) return;
211+
} catch (e) {
212+
clearTimeout(timer);
213+
unsub();
214+
reject(e);
215+
return;
216+
}
201217
clearTimeout(timer);
202218
unsub();
203219
resolve(params as T);
204220
});
205221
});
222+
// Pre-observe so an abandoned waiter (snippet returned or threw before
223+
// awaiting it) times out without an unhandled rejection. Awaiting
224+
// callers still see the rejection.
225+
p.catch(() => {});
226+
return p;
206227
}
207228

208229
// Transport implementation. Called by the generated domain bindings.

packages/bcode-browser/test/browser-execute.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,9 @@ test.skipIf(!enabled)("workspace import inside a snippet", async () => {
8787
await session.use(page.targetId)
8888
}
8989
await session.Page.enable()
90+
const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 5000 })
9091
await session.Page.navigate({ url: "data:text/html,<title>bcode-be</title>" })
91-
await session.waitFor("Page.loadEventFired", undefined, 5000)
92+
await loaded
9293
const r = await session.Runtime.evaluate({ expression: "document.title", returnByValue: true })
9394
return r.result.value
9495
}`,
@@ -125,8 +126,9 @@ test.skipIf(!enabled)("Page.captureScreenshot is collected into result.screensho
125126
{
126127
description: "Capture two screenshots",
127128
code: `await session.Page.enable();
129+
const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 5000 });
128130
await session.Page.navigate({ url: "data:text/html,<title>shot</title><body>hi" });
129-
await session.waitFor("Page.loadEventFired", undefined, 5000);
131+
await loaded;
130132
const a = await session.Page.captureScreenshot({ format: "png" });
131133
const b = await session.Page.captureScreenshot({ format: "jpeg", quality: 50 });
132134
return { aLen: a.data.length, bLen: b.data.length };`,
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// waitFor semantics against a bare WebSocket server (no Chrome needed).
2+
// Test structure adapted from PR #111 by @MagMueller.
3+
import { afterAll, beforeAll, expect, test } from "bun:test"
4+
import { Session } from "../src/cdp/session"
5+
6+
const channel = "cdp-events"
7+
const server = Bun.serve({
8+
port: 0,
9+
fetch(req, srv) {
10+
return srv.upgrade(req) ? undefined : new Response("nope", { status: 400 })
11+
},
12+
websocket: {
13+
open(ws) {
14+
ws.subscribe(channel)
15+
},
16+
message() {},
17+
},
18+
})
19+
const session = new Session()
20+
const emit = (method: string, params: unknown) => {
21+
server.publish(channel, JSON.stringify({ method, params }))
22+
}
23+
24+
beforeAll(async () => {
25+
await session.connect({ wsUrl: `ws://127.0.0.1:${server.port}/` })
26+
})
27+
28+
afterAll(() => {
29+
session.close()
30+
server.stop(true)
31+
})
32+
33+
test("waitFor resolves on a matching event, respecting the predicate", async () => {
34+
const waiting = session.waitFor<{ ready: boolean }>("Test.event", {
35+
predicate: (params) => params.ready,
36+
timeoutMs: 1_000,
37+
})
38+
emit("Test.event", { ready: false })
39+
emit("Test.event", { ready: true })
40+
expect(await waiting).toEqual({ ready: true })
41+
})
42+
43+
test("waitFor honors timeoutMs", async () => {
44+
await expect(session.waitFor("Test.timeout", { timeoutMs: 20 })).rejects.toThrow("Timeout waiting for Test.timeout")
45+
})
46+
47+
test("waitFor rejects and unsubscribes when a predicate throws", async () => {
48+
let calls = 0
49+
const waiting = session.waitFor("Test.bad", {
50+
predicate: () => {
51+
calls++
52+
throw new Error("predicate failed")
53+
},
54+
timeoutMs: 1_000,
55+
})
56+
emit("Test.bad", {})
57+
await expect(waiting).rejects.toThrow("predicate failed")
58+
emit("Test.bad", {})
59+
await Bun.sleep(10)
60+
expect(calls).toBe(1)
61+
})
62+
63+
test("waitFor throws synchronously on the removed positional-predicate form", () => {
64+
// @ts-expect-error old signature: waitFor(method, predicate, timeoutMs)
65+
expect(() => session.waitFor("Test.positional", () => true, 1_000)).toThrow(TypeError)
66+
})

packages/bcode-browser/test/cdp-smoke.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ test.skipIf(!enabled)("Session connects, navigates, reads title", async () => {
3232
}
3333

3434
await session.domains.Page.enable()
35+
const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 5000 })
3536
await session.domains.Page.navigate({ url: "data:text/html,<title>bcode-smoke</title>" })
36-
await session.waitFor("Page.loadEventFired", undefined, 5000)
37+
await loaded
3738

3839
const r = (await session.domains.Runtime.evaluate({
3940
expression: "document.title",

0 commit comments

Comments
 (0)