-
Notifications
You must be signed in to change notification settings - Fork 35
fix(browser): harden waitFor event handling #111
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
50d806f
fix(browser): harden waitFor event handling
MagMueller 9d674e0
fix(browser): handle navigation waiter edge cases
MagMueller 576a80a
fix(browser): simplify navigation wait
MagMueller 5f8f76b
fix(browser): encapsulate navigation waits
MagMueller a229720
fix(browser): cancel unused navigation waiters
MagMueller b88f652
fix(browser): validate navigation timeout
MagMueller ba38df9
docs(browser): use default navigation timeout
MagMueller File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| 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 (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", loaderId: "loader" } })) | ||
| }, | ||
| 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 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 }) | ||
| 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 () => { | ||
| 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 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" }), | ||
| ).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") | ||
| await expect( | ||
| session.navigate("https://example.com", { timeoutMs: Number.POSITIVE_INFINITY }), | ||
| ).rejects.toThrow("navigate timeoutMs must be a non-negative finite number") | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3: Invalid runtime
methodvalues 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
There was a problem hiding this comment.
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:
methodis already a required TypeScriptstring, 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.