Skip to content
Draft
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: 9 additions & 0 deletions .changeset/typed-networks-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@browserbasehq/stagehand-protocol": patch
"@browserbasehq/stagehand-python": patch
"@browserbasehq/stagehand-extension": patch
"@browserbasehq/stagehand-go": patch
"@browserbasehq/stagehand": patch
---

expose page-scoped network capture events with response bodies
1 change: 1 addition & 0 deletions packages/docs/tests/sdk-reference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ describe("SDK reference surface", () => {
"Awaited",
"Buffer",
"Error",
"EventName",
"EvaluateResult",
"Input",
"Map",
Expand Down
29 changes: 16 additions & 13 deletions packages/docs/v4/reference/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -336,8 +336,9 @@ const title = await page.evaluate(() => document.title);

## on()

Subscribe to console messages from this page and its page-owned sessions. Stagehand
delivers each message using the underlying `"Runtime.consoleAPICalled"` event envelope.
Subscribe to console or network events from this page and its page-owned sessions. The
listener type is narrowed from the event name: `"console"` delivers `PageConsoleEvent`,
while `"network"` delivers `PageNetworkEvent`.

```typescript
const subscription = await page.on("console", (event) => {
Expand All @@ -348,13 +349,13 @@ await page.evaluate(() => console.log("ready"));
await subscription.unsubscribe();
```

<ParamField path="event" type="PageEventName">
The console event name. Currently, the only supported value is `"console"`.
<ParamField path="event" type="EventName">
The event name: `"console"` or `"network"`.
</ParamField>

<ParamField path="listener" type="PageEventListener">
A callback that receives the event method, raw parameters, session ID, target ID,
and page ID. Async callbacks may overlap and are not awaited by later page calls.
<ParamField path="listener" type="PageEventListener&lt;EventName&gt;">
A callback that receives the typed event payload. Async callbacks may overlap and
are not awaited by later page calls.
</ParamField>

<ResponseField name="result" type="Promise<CDPSubscription>">
Expand Down Expand Up @@ -1016,8 +1017,9 @@ title = await page.evaluate("document.title", result_type=str)

## on()

Subscribe to console messages from this page and its page-owned sessions. Stagehand
delivers each message using the underlying `"Runtime.consoleAPICalled"` event envelope.
Subscribe to console or network events from this page and its page-owned sessions.
Both values deliver a `PageCDPEvent` envelope with the corresponding CDP method and
parameters.

```python
async def handle_console(event: PageCDPEvent) -> None:
Expand All @@ -1029,7 +1031,7 @@ await subscription.unsubscribe()
```

<ParamField path="event" type="PageEventName">
The console event name. Currently, the only supported value is `"console"`.
The event name: `"console"` or `"network"`.
</ParamField>

<ParamField path="listener" type="PageEventListener">
Expand Down Expand Up @@ -1722,8 +1724,9 @@ fmt.Println(title)

## On()

Subscribe to console messages from this page and its page-owned sessions. Stagehand
delivers each message using the underlying `"Runtime.consoleAPICalled"` event envelope.
Subscribe to console or network events from this page and its page-owned sessions.
Both values deliver a `PageCDPEvent` envelope with the corresponding CDP method and
parameters.

```go
subscription, err := page.On(ctx, stagehand.PageEventNameConsole, func(event stagehand.PageCDPEvent) {
Expand All @@ -1742,7 +1745,7 @@ if err := subscription.Close(ctx); err != nil {
```

<ParamField path="event" type="PageEventName">
The console event name. Currently, the only supported value is `PageEventNameConsole`.
The event name: `PageEventNameConsole` or `PageEventNameNetwork`.
</ParamField>

<ParamField path="listener" type="func(PageCDPEvent)">
Expand Down
7 changes: 5 additions & 2 deletions packages/extension/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,10 @@ export type UnderstudyRuntimePage = {
close(): Promise<void> | void;
captureSnapshot(options?: SnapshotOptions): Promise<HybridSnapshot>;
deepLocator(selector: string): UnderstudyRuntimeLocator;
subscribeCDPEvent(listener: (event: PageCDPEvent) => void): () => void;
subscribeCDPEvent(
eventName: PageOnParams["event"],
listener: (event: PageCDPEvent) => void,
): () => void;
};

export type UnderstudyRuntimeScreenshotOptions = Omit<PageScreenshotOptions, "mask"> & {
Expand Down Expand Up @@ -743,7 +746,7 @@ export class StagehandRuntime {
if (this.pageEventSubscriptions.has(params.subscriptionId)) {
throw new DuplicatePageEventSubscriptionError();
}
const dispose = this.resolvePage(params.pageId).subscribeCDPEvent((event) => {
const dispose = this.resolvePage(params.pageId).subscribeCDPEvent(params.event, (event) => {
this.adapters.emitPageCDPEvent({ subscriptionId: params.subscriptionId, event });
});
this.pageEventSubscriptions.set(params.subscriptionId, { pageId: params.pageId, dispose });
Expand Down
102 changes: 94 additions & 8 deletions packages/extension/tests/page-cdp-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,17 @@ import { Page } from "../understudy/page.js";

class FakeCDPSession implements CDPSessionLike {
readonly handlers = new Map<string, Set<(params: unknown) => void>>();
readonly sendCalls: Array<{ method: string; params?: object }> = [];
readonly responseBodies = new Map<string, { body: string; base64Encoded: boolean }>();

constructor(readonly id: string) {}

async send<Result = unknown>(): Promise<Result> {
async send<Result = unknown>(method: string, params?: object): Promise<Result> {
this.sendCalls.push({ method, params });
if (method === "Network.getResponseBody") {
const requestId = (params as { requestId?: string } | undefined)?.requestId ?? "";
return (this.responseBodies.get(requestId) ?? {}) as Result;
}
return {} as Result;
}

Expand Down Expand Up @@ -45,13 +52,13 @@ function createPage(
}

describe("Page CDP event subscriptions", () => {
it("covers the main session plus current and future OOPIF sessions", () => {
it("covers the main session plus current and future OOPIF sessions", async () => {
const main = new FakeCDPSession("main");
const child = new FakeCDPSession("child");
const page = createPage(main);
const events: unknown[] = [];

const unsubscribe = page.subscribeCDPEvent((event) => {
const unsubscribe = await page.subscribeCDPEvent("console", (event) => {
events.push(event);
});
main.emit("Runtime.consoleAPICalled", { type: "log", args: [] });
Expand Down Expand Up @@ -81,29 +88,29 @@ describe("Page CDP event subscriptions", () => {
expect(main.listenerCount("Runtime.consoleAPICalled")).toBe(0);
});

it("removes every raw listener when the page is disposed", () => {
it("removes every raw listener when the page is disposed", async () => {
const main = new FakeCDPSession("main");
const child = new FakeCDPSession("child");
const page = createPage(main);

page.adoptOopifSession(child, "frame-child");
page.subscribeCDPEvent(() => {});
await page.subscribeCDPEvent("console", () => {});
page.dispose();

expect(main.listenerCount("Runtime.consoleAPICalled")).toBe(0);
expect(child.listenerCount("Runtime.consoleAPICalled")).toBe(0);
});

it("isolates listener failures so other subscriptions still receive the event", () => {
it("isolates listener failures so other subscriptions still receive the event", async () => {
const main = new FakeCDPSession("main");
const logError = vi.fn();
const page = createPage(main, { error: logError } as unknown as StagehandLogger);
const events: PageCDPEvent[] = [];

page.subscribeCDPEvent(() => {
await page.subscribeCDPEvent("console", () => {
throw new Error("listener failed");
});
page.subscribeCDPEvent((event) => events.push(event));
await page.subscribeCDPEvent("console", (event) => events.push(event));

expect(() => main.emit("Runtime.consoleAPICalled", { type: "log", args: [] })).not.toThrow();
expect(events).toHaveLength(1);
Expand All @@ -117,4 +124,83 @@ describe("Page CDP event subscriptions", () => {
}),
);
});

it("emits typed network captures with response bodies across page sessions", async () => {
const main = new FakeCDPSession("main");
const child = new FakeCDPSession("child");
main.responseBodies.set("request-1", { body: '{"ok":true}', base64Encoded: false });
const page = createPage(main);
page.adoptOopifSession(child, "frame-child");
const events: PageCDPEvent[] = [];

const unsubscribe = await page.subscribeCDPEvent("network", (event) => events.push(event));
main.emit("Network.requestWillBeSent", {
requestId: "request-1",
request: {
url: "https://example.test/api",
method: "POST",
headers: { "Content-Type": "application/json", attempts: 2 },
postData: '{"ready":true}',
},
type: "Fetch",
});
main.emit("Network.responseReceived", {
requestId: "request-1",
response: {
url: "https://example.test/api",
status: 200,
statusText: "OK",
headers: { "Content-Type": "application/json" },
mimeType: "application/json",
},
});
main.emit("Network.loadingFinished", { requestId: "request-1" });
child.emit("Network.requestWillBeSent", {
requestId: "request-1",
request: { url: "https://child.example.test/", method: "GET", headers: {} },
type: "Document",
});
child.emit("Network.loadingFailed", {
requestId: "request-1",
errorText: "net::ERR_FAILED",
});

await vi.waitFor(() => expect(events).toHaveLength(4));
expect(events[0]).toMatchObject({
method: "Network.requestWillBeSent",
sessionId: "main",
targetId: "target-main",
params: {
requestKey: "main:request-1",
requestId: "request-1",
httpMethod: "POST",
headers: { "Content-Type": "application/json", attempts: "2" },
body: '{"ready":true}',
},
});
expect(events[1]).toMatchObject({
method: "Network.requestWillBeSent",
sessionId: "child",
targetId: "target-child",
params: { requestKey: "child:request-1" },
});
expect(events[2]).toMatchObject({
method: "Network.loadingFailed",
sessionId: "child",
params: { requestKey: "child:request-1", errorText: "net::ERR_FAILED" },
});
expect(events[3]).toMatchObject({
method: "Network.loadingFinished",
sessionId: "main",
params: {
requestKey: "main:request-1",
status: 200,
body: '{"ok":true}',
base64Encoded: false,
},
});

unsubscribe();
expect(main.sendCalls.some((call) => call.method === "Network.disable")).toBe(false);
});
});
26 changes: 19 additions & 7 deletions packages/extension/tests/stagehand-clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import type {
PageEvaluateParams,
PageKeyPressParams,
PageNavigationOptions,
PageOnParams,
PageReloadParams,
PageSnapshotOptions,
PageSetExtraHTTPHeadersParams,
Expand Down Expand Up @@ -457,14 +458,25 @@ class FakeUnderstudyRuntimePage implements UnderstudyRuntimePage {
return locator;
}

subscribeCDPEvent(listener: (event: PageCDPEvent) => void): () => void {
const method = "Runtime.consoleAPICalled";
const listeners = this.cdpEventListeners.get(method) ?? new Set();
listeners.add(listener);
this.cdpEventListeners.set(method, listeners);
subscribeCDPEvent(
eventName: PageOnParams["event"],
listener: (event: PageCDPEvent) => void,
): () => void {
const methods: PageCDPEvent["method"][] =
eventName === "console"
? ["Runtime.consoleAPICalled"]
: ["Network.requestWillBeSent", "Network.loadingFinished", "Network.loadingFailed"];
for (const method of methods) {
const listeners = this.cdpEventListeners.get(method) ?? new Set();
listeners.add(listener);
this.cdpEventListeners.set(method, listeners);
}
return () => {
listeners.delete(listener);
if (listeners.size === 0) this.cdpEventListeners.delete(method);
for (const method of methods) {
const listeners = this.cdpEventListeners.get(method);
listeners?.delete(listener);
if (listeners?.size === 0) this.cdpEventListeners.delete(method);
}
};
}

Expand Down
9 changes: 9 additions & 0 deletions packages/extension/types/private/network.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Protocol } from "devtools-protocol";
import type { PageNetworkEvent } from "../../../protocol/types.js";

/** Metadata tracked for each network request currently in-flight. */
export type NetworkRequestInfo = {
Expand All @@ -20,6 +21,14 @@ export interface NetworkObserver {
onRequestFailed(info: NetworkRequestInfo): void;
}

export type NetworkCaptureEvent = PageNetworkEvent extends infer Event
? Event extends PageNetworkEvent
? Omit<Event, "pageId" | "targetId">
: never
: never;

export type NetworkCaptureObserver = (event: NetworkCaptureEvent) => void;

/** Options for the idle waiter helper. */
export type WaitForIdleOptions = {
startTime?: number;
Expand Down
Loading