Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/expanded-snapshots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@browserbasehq/stagehand": minor
---

Add opt-in expanded page snapshots with safe, unique locator hints for interactive elements.
15 changes: 15 additions & 0 deletions packages/docs/v4/reference/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,11 @@ const snapshot = await page.snapshot();
<ParamField path="options.includeIframes" type="boolean" optional>
Whether to include iframe content.
</ParamField>

<ParamField path="options.expanded" type="boolean" optional>
Add safe, unique DOM attribute hints to interactive nodes in the formatted tree.
This is disabled by default and excludes password fields.
</ParamField>
</ParamField>

<ResponseField name="result" type="Promise<SnapshotResult>">
Expand Down Expand Up @@ -1264,6 +1269,11 @@ snapshot = await page.snapshot()
Whether to include iframe content.
</ParamField>

<ParamField path="expanded" type="bool | None" optional>
Add safe, unique DOM attribute hints to interactive nodes in the formatted tree.
This is disabled by default and excludes password fields.
</ParamField>

<ResponseField name="result" type="SnapshotResult">
The operation result.

Expand Down Expand Up @@ -2017,6 +2027,11 @@ fmt.Println(snapshot.FormattedTree)
<ParamField path="options.IncludeIframes" type="*bool" optional>
Whether to include iframe content.
</ParamField>

<ParamField path="options.Expanded" type="*bool" optional>
Add safe, unique DOM attribute hints to interactive nodes in the formatted tree.
This is disabled by default and excludes password fields.
</ParamField>
</ParamField>

<ResponseField name="result" type="(SnapshotResult, error)">
Expand Down
18 changes: 18 additions & 0 deletions packages/extension/tests/stagehand-clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1972,6 +1972,24 @@ describe("Stagehand worker clients", () => {
},
]);
expect(page.snapshotCalls).toStrictEqual([{ includeIframes: true }]);

await expect(
handle({
jsonrpc: "2.0",
id: 32,
method: "page.snapshot",
params: { page_id: "page-a", options: { expanded: true } },
}),
).resolves.toStrictEqual({
jsonrpc: "2.0",
id: 32,
result: {
formatted_tree: "root",
xpath_map: { frameOne: "/html/body" },
url_map: { frameOne: "https://example.test" },
},
});
expect(page.snapshotCalls).toStrictEqual([{ includeIframes: true }, { expanded: true }]);
});

it("routes WebMCP discovery and invocation operations through the owning page", async () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/extension/types/private/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export type SnapshotOptions = {
* Toggle whether iframe subtrees are included in the merged snapshot. Defaults to true.
*/
includeIframes?: boolean;
/** Add unique, safe DOM attribute hints to supported interactive nodes. */
locatorHints?: boolean;
};

/**
Expand Down Expand Up @@ -55,6 +57,7 @@ export type SessionDomIndex = {
absByBe: Map<number, string>;
tagByBe: Map<number, string>;
scrollByBe: Map<number, boolean>;
locatorHintsByBe: Map<number, LocatorHint[]>;
docRootOf: Map<number, number>;
contentDocRootByIframe: Map<number, number>;
enterByBe: Map<number, number>;
Expand All @@ -65,9 +68,15 @@ export type FrameDomMaps = {
tagNameMap: Record<string, string>;
xpathMap: Record<string, string>;
scrollableMap: Record<string, boolean>;
locatorHintsMap: Record<string, LocatorHint[]>;
urlMap: Record<string, string>;
};

export type LocatorHint = {
text: string;
linkOnly?: true;
};

export type ResolvedLocation = {
frameId: string;
backendNodeId: number;
Expand Down Expand Up @@ -107,13 +116,15 @@ export type A11yNode = {
childIds?: string[];
children?: A11yNode[];
encodedId?: string;
locatorHints?: LocatorHint[];
};

export type A11yOptions = {
focusLocator?: Locator;
isIgnoredBackendNode?: (backendNodeId: number) => boolean;
tagNameMap: Record<string, string>;
scrollableMap: Record<string, boolean>;
locatorHintsMap?: Record<string, LocatorHint[]>;
encode: (backendNodeId: number) => string;
};

Expand Down
1 change: 1 addition & 0 deletions packages/extension/understudy/a11y/snapshot/a11yTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export function decorateRoles(
parentId: n.parentId,
childIds: n.childIds,
encodedId,
locatorHints: encodedId ? opts.locatorHintsMap?.[encodedId] : undefined,
};
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const emptyMaps = (): FrameDomMaps => ({
tagNameMap: {},
xpathMap: {},
scrollableMap: {},
locatorHintsMap: {},
urlMap: {},
});

Expand Down
32 changes: 27 additions & 5 deletions packages/extension/understudy/a11y/snapshot/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,12 @@ export async function captureHybridSnapshot(
if (scopedSnapshot) return scopedSnapshot;
}

const sessionToIndex = await buildSessionIndexes(page, framesInScope, pierce);
const sessionToIndex = await buildSessionIndexes(
page,
framesInScope,
pierce,
options?.locatorHints,
);
const ignoredNodesByFrame = await resolveIgnoredNodes(
page,
options?.ignoreLocators,
Expand Down Expand Up @@ -216,12 +221,13 @@ export async function tryScopedSnapshot(
const parentId = context.parentByFrame.get(targetFrameId);
const sameSessionAsParent =
!!parentId && ownerSession(page, parentId) === ownerSession(page, targetFrameId);
const { tagNameMap, xpathMap, scrollableMap } = await domMapsForSession(
const { tagNameMap, xpathMap, scrollableMap, locatorHintsMap } = await domMapsForSession(
owningSess,
targetFrameId,
pierce,
(fid, be) => `${page.getOrdinal(fid)}-${be}`,
sameSessionAsParent,
options?.locatorHints === true && targetFrameId === context.rootId,
);

const { outline, urlMap, scopeApplied } = await a11yForFrame(owningSess, targetFrameId, {
Expand All @@ -238,6 +244,7 @@ export async function tryScopedSnapshot(
),
tagNameMap,
scrollableMap,
locatorHintsMap,
encode: (backendNodeId) => `${page.getOrdinal(targetFrameId)}-${backendNodeId}`,
});

Expand Down Expand Up @@ -305,6 +312,7 @@ export async function buildSessionIndexes(
page: Page,
frames: string[],
pierce: boolean,
locatorHints = false,
): Promise<Map<string, SessionDomIndex>> {
const sessionToIndex = new Map<string, SessionDomIndex>();
const sessionById = new Map<string, CDPSessionLike>();
Expand All @@ -314,7 +322,11 @@ export async function buildSessionIndexes(
if (!sessionById.has(sid)) sessionById.set(sid, sess);
}
for (const [sid, sess] of sessionById.entries()) {
const idx = await buildSessionDomIndex(sess, pierce);
const idx = await buildSessionDomIndex(
sess,
pierce,
locatorHints && sess === ownerSession(page, page.mainFrameId()),
);
sessionToIndex.set(sid, idx);
}
return sessionToIndex;
Expand Down Expand Up @@ -348,7 +360,11 @@ export async function collectPerFrameMaps(
const sid = sess.id ?? "root";
let idx = sessionToIndex.get(sid);
if (!idx) {
idx = await buildSessionDomIndex(sess, pierce);
idx = await buildSessionDomIndex(
sess,
pierce,
options?.locatorHints === true && frameId === context.rootId,
);
sessionToIndex.set(sid, idx);
}

Expand All @@ -360,6 +376,7 @@ export async function collectPerFrameMaps(
const tagNameMap: Record<string, string> = {};
const xpathMap: Record<string, string> = {};
const scrollableMap: Record<string, boolean> = {};
const locatorHintsMap: FrameDomMaps["locatorHintsMap"] = {};
const isIgnoredBackendNode = makeIsIgnoredBackendNode(frameId, idx, exclusionIntervalsByFrame);
const enc = (be: number) => `${page.getOrdinal(frameId)}-${be}`;
const baseAbs = idx.absByBe.get(docRootBe) ?? "/";
Expand All @@ -376,17 +393,22 @@ export async function collectPerFrameMaps(
const tag = idx.tagByBe.get(be);
if (tag) tagNameMap[key] = tag;
if (idx.scrollByBe.get(be)) scrollableMap[key] = true;
if (options?.locatorHints && frameId === context.rootId) {
const hints = idx.locatorHintsByBe.get(be);
if (hints) locatorHintsMap[key] = hints;
}
}

const { outline, urlMap } = await a11yForFrame(sess, frameId, {
isIgnoredBackendNode,
tagNameMap,
scrollableMap,
locatorHintsMap,
encode: (backendNodeId) => `${page.getOrdinal(frameId)}-${backendNodeId}`,
});

perFrameOutlines.push({ frameId, outline });
perFrameMaps.set(frameId, { tagNameMap, xpathMap, scrollableMap, urlMap });
perFrameMaps.set(frameId, { tagNameMap, xpathMap, scrollableMap, locatorHintsMap, urlMap });
}

return { perFrameMaps, perFrameOutlines };
Expand Down
79 changes: 78 additions & 1 deletion packages/extension/understudy/a11y/snapshot/domTree.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Protocol } from "devtools-protocol";
import { describe, expect, it, vi } from "vitest";
import type { CDPSessionLike } from "../../cdp.js";
import { getDomTreeWithFallback, hydrateDomTree } from "./domTree.js";
import { domMapsForSession, getDomTreeWithFallback, hydrateDomTree } from "./domTree.js";

describe("DOM tree adaptive retries", () => {
it("throws the last original DOM.getDocument retry error", async () => {
Expand Down Expand Up @@ -53,3 +53,80 @@ describe("DOM tree adaptive retries", () => {
expect(send).toHaveBeenCalledOnce();
});
});

describe("locator hints", () => {
it("collects safe unique hints only when requested", async () => {
const root = domNode({
nodeId: 1,
backendNodeId: 1,
nodeType: 9,
nodeName: "#document",
children: [
domNode({
nodeId: 2,
backendNodeId: 2,
nodeType: 1,
nodeName: "HTML",
children: [
domNode({
nodeId: 3,
backendNodeId: 3,
nodeType: 1,
nodeName: "BUTTON",
attributes: ["id", "submit-order", "data-testid", "submit"],
}),
domNode({
nodeId: 4,
backendNodeId: 4,
nodeType: 1,
nodeName: "INPUT",
attributes: ["type", "password", "id", "password"],
}),
],
}),
],
});
const session = {
send: vi.fn(async (method: string) => {
if (method === "DOM.enable") return {};
if (method === "DOM.getDocument") return { root };
throw new Error(`Unexpected method: ${method}`);
}),
} as unknown as CDPSessionLike;

const enabled = await domMapsForSession(
session,
"root",
true,
(_frame, id) => `0-${id}`,
false,
true,
);
const disabled = await domMapsForSession(
session,
"root",
true,
(_frame, id) => `0-${id}`,
false,
false,
);

expect(enabled.locatorHintsMap["0-3"]).toEqual([
{ text: "#submit-order" },
{ text: "testid=submit" },
]);
expect(enabled.locatorHintsMap["0-4"]).toBeUndefined();
expect(disabled.locatorHintsMap).toEqual({});
});
});

function domNode(
node: Partial<Protocol.DOM.Node> & Pick<Protocol.DOM.Node, "nodeId" | "nodeType" | "nodeName">,
): Protocol.DOM.Node {
return {
localName: node.nodeName.toLowerCase(),
nodeValue: "",
childNodeCount: node.children?.length ?? 0,
...node,
} as Protocol.DOM.Node;
}
Loading
Loading