diff --git a/.changeset/steady-page-evaluation.md b/.changeset/steady-page-evaluation.md new file mode 100644 index 0000000000..4c84a3ba81 --- /dev/null +++ b/.changeset/steady-page-evaluation.md @@ -0,0 +1,8 @@ +--- +"@browserbasehq/stagehand": patch +"@browserbasehq/stagehand-python": patch +"@browserbasehq/stagehand-go": patch +"@browserbasehq/stagehand-extension": patch +--- + +Keep page evaluation results alive while Chrome awaits and serializes them, preventing intermittent `Promise was collected` failures during page churn. diff --git a/packages/extension/tests/page-evaluate-promise-lifetime.test.ts b/packages/extension/tests/page-evaluate-promise-lifetime.test.ts new file mode 100644 index 0000000000..7c110e794c --- /dev/null +++ b/packages/extension/tests/page-evaluate-promise-lifetime.test.ts @@ -0,0 +1,170 @@ +import type { Protocol } from "devtools-protocol"; +import { describe, expect, it } from "vitest"; +import type { StagehandLogger } from "../logger.js"; +import type { CDPSessionLike, CdpConnection } from "../understudy/cdp.js"; +import { executionContexts } from "../understudy/executionContextRegistry.js"; +import { Page } from "../understudy/page.js"; + +type CDPCall = { method: string; params?: object }; +type SendHandler = (method: string, params?: object) => unknown; + +class EvaluationSession implements CDPSessionLike { + private static nextId = 1; + readonly id = `evaluation-session-${EvaluationSession.nextId++}`; + readonly calls: CDPCall[] = []; + + constructor(private readonly handler: SendHandler) {} + + async send(method: string, params?: object): Promise { + this.calls.push({ method, params }); + return (await this.handler(method, params)) as Result; + } + + on(): void {} + + off(): void {} + + async close(): Promise {} +} + +function createPage(session: EvaluationSession): Page { + executionContexts.register(session, "frame-a", 7); + return new Page({} as CdpConnection, session, "page-a", "frame-a", {} as StagehandLogger); +} + +function runtimeMethods(session: EvaluationSession): string[] { + return session.calls + .map(({ method }) => method) + .filter((method) => method.startsWith("Runtime.")); +} + +describe("Page.evaluate", () => { + it("retains a returned promise by object id while awaiting its value", async () => { + const session = new EvaluationSession((method, params) => { + if (method === "Runtime.enable") return {}; + if (method === "Runtime.evaluate") { + expect(params).toMatchObject({ + contextId: 7, + awaitPromise: false, + returnByValue: false, + }); + return { + result: { + type: "object", + subtype: "promise", + className: "Promise", + description: "Promise", + objectId: "promise-1", + }, + } satisfies Protocol.Runtime.EvaluateResponse; + } + if (method === "Runtime.awaitPromise") { + expect(params).toStrictEqual({ + promiseObjectId: "promise-1", + returnByValue: true, + }); + return { + result: { type: "object", value: { answer: 42 } }, + } satisfies Protocol.Runtime.EvaluateResponse; + } + if (method === "Runtime.releaseObject") { + expect(params).toStrictEqual({ objectId: "promise-1" }); + return {}; + } + throw new Error(`Unexpected CDP method ${method}`); + }); + + await expect(createPage(session).evaluate("Promise.resolve({ answer: 42 })")).resolves.toEqual({ + answer: 42, + }); + expect(runtimeMethods(session)).toStrictEqual([ + "Runtime.enable", + "Runtime.evaluate", + "Runtime.awaitPromise", + "Runtime.releaseObject", + ]); + }); + + it("serializes a synchronous object through its retained remote handle", async () => { + const session = new EvaluationSession((method, params) => { + if (method === "Runtime.enable") return {}; + if (method === "Runtime.evaluate") { + return { + result: { + type: "object", + className: "Object", + description: "Object", + objectId: "object-1", + }, + } satisfies Protocol.Runtime.EvaluateResponse; + } + if (method === "Runtime.callFunctionOn") { + expect(params).toStrictEqual({ + objectId: "object-1", + functionDeclaration: "function() { return this; }", + returnByValue: true, + }); + return { + result: { type: "object", value: { answer: 42 } }, + } satisfies Protocol.Runtime.EvaluateResponse; + } + if (method === "Runtime.releaseObject") return {}; + throw new Error(`Unexpected CDP method ${method}`); + }); + + await expect(createPage(session).evaluate("({ answer: 42 })")).resolves.toEqual({ + answer: 42, + }); + expect(runtimeMethods(session)).toStrictEqual([ + "Runtime.enable", + "Runtime.evaluate", + "Runtime.callFunctionOn", + "Runtime.releaseObject", + ]); + }); + + it("returns primitive results without creating a remote handle", async () => { + const session = new EvaluationSession((method) => { + if (method === "Runtime.enable") return {}; + if (method === "Runtime.evaluate") { + return { + result: { type: "number", value: 42 }, + } satisfies Protocol.Runtime.EvaluateResponse; + } + throw new Error(`Unexpected CDP method ${method}`); + }); + + await expect(createPage(session).evaluate("40 + 2")).resolves.toBe(42); + expect(runtimeMethods(session)).toStrictEqual(["Runtime.enable", "Runtime.evaluate"]); + }); + + it("releases the retained object when promise materialization fails", async () => { + const session = new EvaluationSession((method) => { + if (method === "Runtime.enable") return {}; + if (method === "Runtime.evaluate") { + return { + result: { + type: "object", + subtype: "promise", + className: "Promise", + description: "Promise", + objectId: "promise-1", + }, + } satisfies Protocol.Runtime.EvaluateResponse; + } + if (method === "Runtime.awaitPromise") { + throw new Error("await failed"); + } + if (method === "Runtime.releaseObject") return {}; + throw new Error(`Unexpected CDP method ${method}`); + }); + + await expect(createPage(session).evaluate("new Promise(() => {})")).rejects.toThrow( + "await failed", + ); + expect(session.calls.at(-1)).toStrictEqual({ + method: "Runtime.releaseObject", + params: { objectId: "promise-1" }, + }); + }); +}); diff --git a/packages/extension/understudy/frame.ts b/packages/extension/understudy/frame.ts index eae60f09d5..ffdf05af8f 100644 --- a/packages/extension/understudy/frame.ts +++ b/packages/extension/understudy/frame.ts @@ -20,6 +20,8 @@ interface FrameManager { pageId: string; } +const RETURN_REMOTE_OBJECT_BY_VALUE = "function() { return this; }"; + /** * Frame * @@ -155,11 +157,13 @@ export class Frame implements FrameManager { let res: Protocol.Runtime.EvaluateResponse; try { + // Keep object results alive across evaluation and serialization. Asking + // Runtime.evaluate to await directly can let V8 collect the promise first. res = await this.session.send("Runtime.evaluate", { expression, contextId, - awaitPromise: true, - returnByValue: true, + awaitPromise: false, + returnByValue: false, }); } catch (error) { // Execution contexts can be recreated between context lookup and @@ -170,16 +174,47 @@ export class Frame implements FrameManager { res = await this.session.send("Runtime.evaluate", { expression, contextId: freshContextId, - awaitPromise: true, - returnByValue: true, + awaitPromise: false, + returnByValue: false, }); } + res = await this.materializeEvaluationResult(res); if (res.exceptionDetails) { - throw new Error(res.exceptionDetails.text ?? "Evaluation failed"); + throw new Error( + res.exceptionDetails.text || + res.exceptionDetails.exception?.description || + "Evaluation failed", + ); } return res.result.value as R; } + private async materializeEvaluationResult( + response: Protocol.Runtime.EvaluateResponse, + ): Promise { + const objectId = response.result.objectId; + if (!objectId) return response; + + try { + if (response.exceptionDetails) return response; + + if (response.result.subtype === "promise") { + return await this.session.send("Runtime.awaitPromise", { + promiseObjectId: objectId, + returnByValue: true, + }); + } + + return await this.session.send("Runtime.callFunctionOn", { + objectId, + functionDeclaration: RETURN_REMOTE_OBJECT_BY_VALUE, + returnByValue: true, + }); + } finally { + await this.session.send("Runtime.releaseObject", { objectId }).catch(() => {}); + } + } + /** Evaluate an internal expression in Stagehand's selected locator world. */ async evaluateInLocatorWorld(expression: string): Promise { await this.session.send("Runtime.enable").catch(() => {}); diff --git a/packages/extension/understudy/page.ts b/packages/extension/understudy/page.ts index 936aebc833..9c86467cbe 100644 --- a/packages/extension/understudy/page.ts +++ b/packages/extension/understudy/page.ts @@ -1359,44 +1359,7 @@ export class Page { pageFunctionOrExpression: string | ((arg: Arg) => R | Promise), arg?: Arg, ): Promise { - await this.mainSession.send("Runtime.enable").catch(() => {}); - const ctxId = await this.mainWorldExecutionContextId(); - - const isString = typeof pageFunctionOrExpression === "string"; - let expression: string; - - if (isString) { - expression = String(pageFunctionOrExpression); - } else { - const fnSrc = pageFunctionOrExpression.toString(); - const argJson = JSON.stringify(arg); - expression = `(() => { - const __fn = ${fnSrc}; - const __arg = ${argJson}; - try { - const __res = __fn(__arg); - return Promise.resolve(__res).then(v => { - try { return JSON.parse(JSON.stringify(v)); } catch { return v; } - }); - } catch (e) { throw e; } - })()`; - } - - const { result, exceptionDetails } = - await this.mainSession.send("Runtime.evaluate", { - expression, - contextId: ctxId, - returnByValue: true, - awaitPromise: true, - }); - - if (exceptionDetails) { - const msg = - exceptionDetails.text || exceptionDetails.exception?.description || "Evaluation failed"; - throw new Error(msg); - } - - return result?.value as R; + return this.mainFrameWrapper.evaluate(pageFunctionOrExpression, arg); } /** diff --git a/packages/sdk-go/internal/extensionassets/stagehand-extension.zip b/packages/sdk-go/internal/extensionassets/stagehand-extension.zip index c4f5890ddf..739bb4506d 100644 Binary files a/packages/sdk-go/internal/extensionassets/stagehand-extension.zip and b/packages/sdk-go/internal/extensionassets/stagehand-extension.zip differ