Skip to content

proxy page.evaluate() #688

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

Merged
merged 5 commits into from
Apr 23, 2025
Merged
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
5 changes: 5 additions & 0 deletions .changeset/empty-bugs-occur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@browserbasehq/stagehand": patch
---

wrap page.evaluate to make sure we have injected browser side scripts before calling them
28 changes: 28 additions & 0 deletions evals/deterministic/tests/page/addInitScript.test.ts
Original file line number Diff line number Diff line change
@@ -37,4 +37,32 @@ test.describe("StagehandPage - addInitScript", () => {

await stagehand.close();
});

test("checks if init scripts are re-added and available even if they've been deleted", async () => {
const stagehand = new Stagehand(StagehandConfig);
await stagehand.init();

const page = stagehand.page;
await page.goto(
"https://browserbase.github.io/stagehand-eval-sites/sites/aigrant/",
);

// delete the __stagehandInjected flag, and delete the
// getScrollableElementXpaths function
await page.evaluate(() => {
delete window.getScrollableElementXpaths;
delete window.__stagehandInjected;
});

// attempt to call the getScrollableElementXpaths function
// which we previously deleted. page.evaluate should realize
// its been deleted and re-inject it
const xpaths = await page.evaluate(() => {
return window.getScrollableElementXpaths();
});

await stagehand.close();
// this is the only scrollable element on the page
expect(xpaths).toContain("/html");
});
});
52 changes: 51 additions & 1 deletion lib/StagehandPage.ts
Original file line number Diff line number Diff line change
@@ -31,9 +31,11 @@ import {
StagehandDefaultError,
} from "../types/stagehandErrors";
import { StagehandAPIError } from "@/types/stagehandApiErrors";
import { scriptContent } from "@/lib/dom/build/scriptContent";

export class StagehandPage {
private stagehand: Stagehand;
private rawPage: PlaywrightPage;
private intPage: Page;
private intContext: StagehandContext;
private actHandler: StagehandActHandler;
@@ -60,6 +62,7 @@ export class StagehandPage {
api?: StagehandAPI,
waitForCaptchaSolves?: boolean,
) {
this.rawPage = page;
// Create a proxy to intercept all method calls and property access
this.intPage = new Proxy(page, {
get: (target: PlaywrightPage, prop: keyof PlaywrightPage) => {
@@ -117,6 +120,35 @@ export class StagehandPage {
}
}

private async ensureStagehandScript(): Promise<void> {
try {
const injected = await this.rawPage.evaluate(
() => !!window.__stagehandInjected,
);

if (injected) return;

const guardedScript = `if (!window.__stagehandInjected) { \
window.__stagehandInjected = true; \
${scriptContent} \
}`;

await this.rawPage.addInitScript({ content: guardedScript });
await this.rawPage.evaluate(guardedScript);
} catch (err) {
this.stagehand.log({
category: "dom",
message: "Failed to inject Stagehand helper script",
level: 1,
auxiliary: {
error: { value: (err as Error).message, type: "string" },
trace: { value: (err as Error).stack, type: "string" },
},
});
throw err;
}
}

private async _refreshPageFromAPI() {
if (!this.api) return;

@@ -217,14 +249,32 @@ export class StagehandPage {

async init(): Promise<StagehandPage> {
try {
const page = this.intPage;
const page = this.rawPage;
const stagehand = this.stagehand;

// Create a proxy that updates active page on method calls
const handler = {
get: (target: PlaywrightPage, prop: string | symbol) => {
const value = target[prop as keyof PlaywrightPage];

// Inject-on-demand for evaluate
if (
prop === "evaluate" ||
prop === "evaluateHandle" ||
prop === "$eval" ||
prop === "$$eval"
) {
return async (...args: unknown[]) => {
this.intContext.setActivePage(this);
// Make sure helpers exist
await this.ensureStagehandScript();
return (value as (...a: unknown[]) => unknown).apply(
target,
args,
);
};
}

// Handle enhanced methods
if (prop === "act" || prop === "extract" || prop === "observe") {
if (!this.llmClient) {
1 change: 1 addition & 0 deletions lib/dom/global.d.ts
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ import { StagehandContainer } from "./StagehandContainer";
export {};
declare global {
interface Window {
__stagehandInjected?: boolean;
chunkNumber: number;
showChunks?: boolean;
processDom: (chunksSeen: Array<number>) => Promise<{
8 changes: 7 additions & 1 deletion lib/index.ts
Original file line number Diff line number Diff line change
@@ -715,8 +715,14 @@ export class Stagehand {
await this.page.setViewportSize({ width: 1280, height: 720 });
}

const guardedScript = `
if (!window.__stagehandInjected) {
window.__stagehandInjected = true;
${scriptContent}
}
`;
await this.context.addInitScript({
content: scriptContent,
content: guardedScript,
});

this.browserbaseSessionID = sessionId;