Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
7 changes: 7 additions & 0 deletions packages/extension/callbackBatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class InProcessCommandClient implements StagehandCommandClient {

export type CallbackStagehand = {
page: Page;
evaluateWithShadowRoots(pageId: string, functionSource: string): Promise<unknown>;
context: ExperimentalBatchBrowserContext;
act(instruction: string | Action, options?: StagehandClientActOptions): Promise<unknown>;
observe(instruction?: string, options?: StagehandClientObserveOptions): Promise<unknown>;
Expand Down Expand Up @@ -127,6 +128,12 @@ export function createCallbackBatchController(router: RPCRouter) {
const stagehand: CallbackStagehand = {
page,
context: createCallbackContextFacade(context),
evaluateWithShadowRoots: async (pageId, functionSource) => {
if (controller.signal.aborted) throw controller.signal.reason;
const result = await router.runtime.evaluateWithShadowRoots(pageId, functionSource);
if (controller.signal.aborted) throw controller.signal.reason;
return result;
},
act: async (instruction, operationOptions) => {
const { page: operationPage, ...clientOptions } = StagehandClientActOptionsSchema.parse(
operationOptions ?? {},
Expand Down
14 changes: 14 additions & 0 deletions packages/extension/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,17 @@ export class DuplicatePageEventSubscriptionError extends Error {
this.name = "DuplicatePageEventSubscriptionError";
}
}

export class ShadowRootEvaluationError extends Error {
constructor() {
super("Shadow-root evaluation failed");
this.name = "ShadowRootEvaluationError";
}
}

export class ShadowRootEvaluationUnavailableError extends Error {
constructor() {
super("Shadow-root evaluation is unavailable");
this.name = "ShadowRootEvaluationUnavailableError";
}
}
9 changes: 9 additions & 0 deletions packages/extension/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ShadowRootEvaluationUnavailableError } from "./errors.js";
import type {
ClearCookieOptions,
ContextActivePageResult,
Expand Down Expand Up @@ -148,6 +149,7 @@ export type UnderstudyRuntimePage = {
type(text: string, options?: PageTypeParams["options"]): Promise<void>;
keyPress(key: string, options?: PageKeyPressParams["options"]): Promise<void>;
evaluate(expression: string): Promise<unknown>;
evaluateWithShadowRoots?(functionSource: string): Promise<unknown>;
addInitScript(source: string): Promise<void>;
setExtraHTTPHeaders(headers: PageSetExtraHTTPHeadersParams["headers"]): Promise<void>;
setViewportSize(
Expand Down Expand Up @@ -624,6 +626,13 @@ export class StagehandRuntime {
return { ok: true };
}

async evaluateWithShadowRoots(pageId: string, functionSource: string): Promise<unknown> {
Comment thread
miguelg719 marked this conversation as resolved.
const page = this.resolvePage(pageId);
this.logger.debug("page.evaluateWithShadowRoots", { pageId });
if (!page.evaluateWithShadowRoots) throw new ShadowRootEvaluationUnavailableError();
return page.evaluateWithShadowRoots(functionSource);
}

async pageEvaluate(params: PageEvaluateParams): Promise<PageEvaluateResult> {
const value = await this.resolvePage(params.pageId).evaluate(params.expression);
return {
Expand Down
73 changes: 73 additions & 0 deletions packages/extension/tests/shadow-root-evaluation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from "vitest";
import { evaluateWithShadowRoots } from "../understudy/shadowRootEvaluation.js";

describe("main-world shadow-root evaluation", () => {
it("uses ordinary main-world evaluation when there are no author closed roots", async () => {
const send = vi.fn().mockResolvedValue({
root: { backendNodeId: 1, shadowRoots: [{ backendNodeId: 2, shadowRootType: "user-agent" }] },
});
const evaluate = vi.fn().mockResolvedValue(42);
await expect(
evaluateWithShadowRoots({ send } as never, evaluate, "roots => roots.length"),
).resolves.toBe(42);
expect(evaluate).toHaveBeenCalledWith("(roots => roots.length)([])");
expect(send).toHaveBeenCalledOnce();
});

it.each(["DOM.resolveNode", "Runtime.callFunctionOn"])(
"releases references after %s fails",
async (failureMethod) => {
let resolved = 0;
const send = vi.fn(async (method: string) => {
if (method === "DOM.getDocument")
return {
root: {
backendNodeId: 1,
shadowRoots: [
{ backendNodeId: 2, shadowRootType: "closed" },
{ backendNodeId: 3, shadowRootType: "closed" },
],
},
};
if (method === "DOM.resolveNode") {
resolved += 1;
if (method === failureMethod && resolved === 2) throw new Error("detached");
return { object: { objectId: `root-${resolved}` } };
}
if (method === failureMethod) throw new Error("detached");
return {};
});
await expect(
evaluateWithShadowRoots({ send } as never, vi.fn(), "roots => roots.length"),
).rejects.toThrow("detached");
expect(send.mock.calls.at(-1)?.[0]).toBe("Runtime.releaseObjectGroup");
},
);
it.each([false, true])(
"preserves query outcome when cleanup fails (callback throws: %s)",
async (throws) => {
const send = vi.fn(async (method: string) => {
if (method === "DOM.getDocument")
return {
root: {
backendNodeId: 1,
shadowRoots: [{ backendNodeId: 2, shadowRootType: "closed" }],
},
};
if (method === "DOM.resolveNode") return { object: { objectId: "root" } };
if (method === "Runtime.releaseObjectGroup") throw new Error("context destroyed");
return throws
? { exceptionDetails: { text: "sensitive page data" } }
: { result: { value: 42 } };
});
const result = evaluateWithShadowRoots({ send } as never, vi.fn(), "roots => roots.length");
if (throws)
await expect(result).rejects.toMatchObject({
name: "ShadowRootEvaluationError",
message: "Shadow-root evaluation failed",
});
else await expect(result).resolves.toBe(42);
expect(send.mock.calls.at(-1)?.[0]).toBe("Runtime.releaseObjectGroup");
},
);
});
10 changes: 10 additions & 0 deletions packages/extension/understudy/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Protocol } from "devtools-protocol";
import type { StagehandLogger } from "../logger.js";
import type { CDPSessionLike } from "./cdp.js";
import { CdpConnection } from "./cdp.js";
import { evaluateWithShadowRoots } from "./shadowRootEvaluation.js";
import { Frame } from "./frame.js";
import { FrameLocator } from "./frameLocator.js";
import { deepLocatorFromPage, resolveLocatorTarget } from "./deepLocator.js";
Expand Down Expand Up @@ -1435,6 +1436,15 @@ export class Page {
return targetFrame.evaluateInLocatorWorld(expression);
}

/** Internal batch evaluation; page.evaluate continues to use the main world unchanged. */
async evaluateWithShadowRoots(functionSource: string): Promise<unknown> {
return evaluateWithShadowRoots(
this.mainSession,
(expression) => this.evaluate(expression),
functionSource,
);
}

/**
* Evaluate a function or expression in the current main frame's main world.
* - If a string is provided, it is treated as a JS expression.
Expand Down
53 changes: 53 additions & 0 deletions packages/extension/understudy/shadowRootEvaluation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { ShadowRootEvaluationError } from "../errors.js";
import type { Protocol } from "devtools-protocol";
import type { CDPSessionLike } from "./cdp.js";

/** Evaluate in the main world with temporary references to author closed roots. */
export async function evaluateWithShadowRoots<Result>(
session: CDPSessionLike,
evaluate: (expression: string) => Promise<unknown>,
functionSource: string,
): Promise<Result> {
const { root } = await session.send<Protocol.DOM.GetDocumentResponse>("DOM.getDocument", {
depth: -1,
pierce: true,
});
const closedIds: number[] = [];
const visit = (node: Protocol.DOM.Node): void => {
if (node.shadowRootType === "closed") closedIds.push(node.backendNodeId);
for (const child of node.children ?? []) visit(child);
for (const shadow of node.shadowRoots ?? []) {
if (shadow.shadowRootType !== "user-agent") visit(shadow);
}
// Frame documents have separate main worlds and are resolved by frame locators.
};
visit(root);
if (!closedIds.length) return (await evaluate(`(${functionSource})([])`)) as Result;
const objectGroup = `stagehand-shadow-query-${crypto.randomUUID()}`;
try {
const objects: Array<{ objectId: string }> = [];
for (const backendNodeId of closedIds) {
const { object } = await session.send<Protocol.DOM.ResolveNodeResponse>("DOM.resolveNode", {
backendNodeId,
objectGroup,
});
if (!object.objectId) throw new ShadowRootEvaluationError();
objects.push({ objectId: object.objectId });
}
const response = await session.send<Protocol.Runtime.CallFunctionOnResponse>(
"Runtime.callFunctionOn",
{
objectId: objects[0]!.objectId,
functionDeclaration: `function(...roots) { return (${functionSource})(roots); }`,
arguments: objects,
awaitPromise: true,
returnByValue: true,
},
);
if (response.exceptionDetails) throw new ShadowRootEvaluationError();
return response.result.value as Result;
} finally {
// Navigation can destroy the group before cleanup; preserve the query outcome.
await session.send("Runtime.releaseObjectGroup", { objectGroup }).catch(() => {});
}
}
Loading
Loading