Skip to content

Commit 04978bd

Browse files
[api] store frame ID map (#907)
# why - to enable multi-tab handling on the Stagehand API # what changed - added a mapping of CDP Frame ID -> Page objects - frame IDs are now included in the payload for API request so that the API knows which page to take the action on # test plan - `act` evals - `regression` evals - `extract` evals - `observe` evals
1 parent a611115 commit 04978bd

File tree

7 files changed

+115
-32
lines changed

7 files changed

+115
-32
lines changed

.changeset/swift-roses-design.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@browserbasehq/stagehand": patch
3+
---
4+
5+
store mapping of CDP frame ID -> page

evals/deterministic/tests/BrowserContext/multiPage.test.ts

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { test, expect } from "@playwright/test";
22
import { Stagehand } from "@browserbasehq/stagehand";
33
import StagehandConfig from "@/evals/deterministic/stagehand.config";
4-
import { Page } from "@browserbasehq/stagehand";
54

65
import http from "http";
76
import express from "express";
@@ -128,26 +127,24 @@ test.describe("StagehandContext - Multi-page Support", () => {
128127
* Test popup handling
129128
*/
130129
test("should handle popups with enhanced capabilities", async () => {
131-
const mainPage = stagehand.page;
132-
let popupPage: Page | null = null;
130+
await stagehand.page.goto(`http://localhost:${serverPort}/page1`);
131+
await stagehand.page.click("#popupBtn");
133132

134-
mainPage.on("popup", (page: Page) => {
135-
popupPage = page;
136-
});
133+
await expect.poll(() => stagehand.context.pages().length).toBe(2);
137134

138-
await mainPage.goto(`http://localhost:${serverPort}/page1`);
139-
await mainPage.click("#popupBtn");
135+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
136+
const [_original, popupPage] = stagehand.context.pages();
140137

141-
// Verify popup has enhanced capabilities
142-
expect(popupPage).not.toBeNull();
143-
expect(typeof popupPage.act).toBe("function");
144-
expect(typeof popupPage.extract).toBe("function");
145-
expect(typeof popupPage.observe).toBe("function");
138+
await popupPage.waitForLoadState();
146139

147-
if (popupPage) {
148-
await popupPage.waitForLoadState();
149-
expect(await popupPage.title()).toBe("Page 2");
150-
}
140+
const get = (k: string) =>
141+
(popupPage as unknown as Record<string, unknown>)[k];
142+
143+
expect(typeof get("act")).toBe("function");
144+
expect(typeof get("extract")).toBe("function");
145+
expect(typeof get("observe")).toBe("function");
146+
147+
expect(await popupPage.title()).toBe("Page 2");
151148
});
152149

153150
/**

lib/StagehandContext.ts

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
import type {
22
BrowserContext as PlaywrightContext,
3+
CDPSession,
34
Page as PlaywrightPage,
45
} from "playwright";
56
import { Stagehand } from "./index";
67
import { StagehandPage } from "./StagehandPage";
78
import { Page } from "../types/page";
89
import { EnhancedContext } from "../types/context";
10+
import { Protocol } from "devtools-protocol";
911

1012
export class StagehandContext {
1113
private readonly stagehand: Stagehand;
1214
private readonly intContext: EnhancedContext;
1315
private pageMap: WeakMap<PlaywrightPage, StagehandPage>;
1416
private activeStagehandPage: StagehandPage | null = null;
17+
private readonly frameIdMap: Map<string, StagehandPage> = new Map();
1518

1619
private constructor(context: PlaywrightContext, stagehand: Stagehand) {
1720
this.stagehand = stagehand;
@@ -83,24 +86,51 @@ export class StagehandContext {
8386
const existingPages = context.pages();
8487
for (const page of existingPages) {
8588
const stagehandPage = await instance.createStagehandPage(page);
89+
await instance.attachFrameNavigatedListener(page);
8690
// Set the first page as active
8791
if (!instance.activeStagehandPage) {
8892
instance.setActivePage(stagehandPage);
8993
}
9094
}
9195

9296
context.on("page", (pwPage) => {
93-
instance.handleNewPlaywrightPage(pwPage).catch((err) =>
94-
stagehand.logger({
95-
category: "context",
96-
message: `Failed to initialise new page: ${err}`,
97-
level: 0,
98-
}),
99-
);
97+
instance
98+
.attachFrameNavigatedListener(pwPage)
99+
.catch((err) =>
100+
stagehand.logger({
101+
category: "cdp",
102+
message: `Failed to attach frameNavigated listener: ${err}`,
103+
level: 0,
104+
}),
105+
)
106+
.finally(() =>
107+
instance.handleNewPlaywrightPage(pwPage).catch((err) =>
108+
stagehand.logger({
109+
category: "context",
110+
message: `Failed to initialise new page: ${err}`,
111+
level: 0,
112+
}),
113+
),
114+
);
100115
});
101116

102117
return instance;
103118
}
119+
public get frameIdLookup(): ReadonlyMap<string, StagehandPage> {
120+
return this.frameIdMap;
121+
}
122+
123+
public registerFrameId(frameId: string, page: StagehandPage): void {
124+
this.frameIdMap.set(frameId, page);
125+
}
126+
127+
public unregisterFrameId(frameId: string): void {
128+
this.frameIdMap.delete(frameId);
129+
}
130+
131+
public getStagehandPageByFrameId(frameId: string): StagehandPage | undefined {
132+
return this.frameIdMap.get(frameId);
133+
}
104134

105135
public get context(): EnhancedContext {
106136
return this.intContext;
@@ -140,4 +170,33 @@ export class StagehandContext {
140170
}
141171
this.setActivePage(stagehandPage);
142172
}
173+
174+
private async attachFrameNavigatedListener(
175+
pwPage: PlaywrightPage,
176+
): Promise<void> {
177+
const shPage = this.pageMap.get(pwPage);
178+
if (!shPage) return;
179+
const session: CDPSession = await this.intContext.newCDPSession(pwPage);
180+
await session.send("Page.enable");
181+
182+
pwPage.once("close", () => {
183+
this.unregisterFrameId(shPage.frameId);
184+
});
185+
186+
session.on(
187+
"Page.frameNavigated",
188+
(evt: Protocol.Page.FrameNavigatedEvent): void => {
189+
const { frame } = evt;
190+
191+
if (!frame.parentId) {
192+
const oldId = shPage.frameId;
193+
if (frame.id !== oldId) {
194+
if (oldId) this.unregisterFrameId(oldId);
195+
this.registerFrameId(frame.id, shPage);
196+
shPage.updateRootFrameId(frame.id);
197+
}
198+
}
199+
},
200+
);
201+
}
143202
}

lib/StagehandPage.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ export class StagehandPage {
5656
[undefined, 0],
5757
]);
5858

59+
private rootFrameId!: string;
60+
61+
public get frameId(): string {
62+
return this.rootFrameId;
63+
}
64+
65+
public updateRootFrameId(newId: string): void {
66+
this.rootFrameId = newId;
67+
}
68+
5969
constructor(
6070
page: PlaywrightPage,
6171
stagehand: Stagehand,
@@ -354,9 +364,11 @@ ${scriptContent} \
354364
const rawGoto: typeof target.goto =
355365
Object.getPrototypeOf(target).goto.bind(target);
356366
return async (url: string, options: GotoOptions) => {
357-
this.intContext.setActivePage(this);
358367
const result = this.api
359-
? await this.api.goto(url, options)
368+
? await this.api.goto(url, {
369+
...options,
370+
frameId: this.frameId,
371+
})
360372
: await rawGoto(url, options);
361373

362374
this.stagehand.addToHistory("navigate", { url, options }, result);
@@ -629,7 +641,10 @@ ${scriptContent} \
629641
const observeResult = actionOrOptions as ObserveResult;
630642

631643
if (this.api) {
632-
const result = await this.api.act(observeResult);
644+
const result = await this.api.act({
645+
...observeResult,
646+
frameId: this.frameId,
647+
});
633648
await this._refreshPageFromAPI();
634649
this.stagehand.addToHistory("act", observeResult, result);
635650
return result;
@@ -661,7 +676,8 @@ ${scriptContent} \
661676
const { action, modelName, modelClientOptions } = actionOrOptions;
662677

663678
if (this.api) {
664-
const result = await this.api.act(actionOrOptions);
679+
const opts = { ...actionOrOptions, frameId: this.frameId };
680+
const result = await this.api.act(opts);
665681
await this._refreshPageFromAPI();
666682
this.stagehand.addToHistory("act", actionOrOptions, result);
667683
return result;
@@ -722,7 +738,7 @@ ${scriptContent} \
722738
if (!instructionOrOptions) {
723739
let result: ExtractResult<T>;
724740
if (this.api) {
725-
result = await this.api.extract<T>({});
741+
result = await this.api.extract<T>({ frameId: this.frameId });
726742
} else {
727743
result = await this.extractHandler.extract();
728744
}
@@ -759,7 +775,8 @@ ${scriptContent} \
759775
}
760776

761777
if (this.api) {
762-
const result = await this.api.extract<T>(options);
778+
const opts = { ...options, frameId: this.frameId };
779+
const result = await this.api.extract<T>(opts);
763780
this.stagehand.addToHistory("extract", instructionOrOptions, result);
764781
return result;
765782
}
@@ -866,7 +883,8 @@ ${scriptContent} \
866883
}
867884

868885
if (this.api) {
869-
const result = await this.api.observe(options);
886+
const opts = { ...options, frameId: this.frameId };
887+
const result = await this.api.observe(opts);
870888
this.stagehand.addToHistory("observe", instructionOrOptions, result);
871889
return result;
872890
}

lib/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export class StagehandAPI {
123123
if (!options.schema) {
124124
return this.execute<ExtractResult<T>>({
125125
method: "extract",
126-
args: {},
126+
args: { ...options },
127127
});
128128
}
129129
const parsedSchema = zodToJsonSchema(options.schema);

types/playwright.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ export interface GotoOptions {
1616
timeout?: number;
1717
waitUntil?: "load" | "domcontentloaded" | "networkidle" | "commit";
1818
referer?: string;
19+
frameId?: string;
1920
}

types/stagehand.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export interface ActOptions {
116116
domSettleTimeoutMs?: number;
117117
timeoutMs?: number;
118118
iframes?: boolean;
119+
frameId?: string;
119120
}
120121

121122
export interface ActResult {
@@ -136,6 +137,7 @@ export interface ExtractOptions<T extends z.AnyZodObject> {
136137
useTextExtract?: boolean;
137138
selector?: string;
138139
iframes?: boolean;
140+
frameId?: string;
139141
}
140142

141143
export type ExtractResult<T extends z.AnyZodObject> = z.infer<T>;
@@ -152,6 +154,7 @@ export interface ObserveOptions {
152154
onlyVisible?: boolean;
153155
drawOverlay?: boolean;
154156
iframes?: boolean;
157+
frameId?: string;
155158
}
156159

157160
export interface ObserveResult {

0 commit comments

Comments
 (0)