diff --git a/.changeset/expanded-snapshots.md b/.changeset/expanded-snapshots.md
new file mode 100644
index 000000000..7d5bc55cf
--- /dev/null
+++ b/.changeset/expanded-snapshots.md
@@ -0,0 +1,5 @@
+---
+"@browserbasehq/stagehand": minor
+---
+
+Add opt-in expanded page snapshots with safe, unique locator hints for interactive elements.
diff --git a/packages/docs/v4/reference/page.mdx b/packages/docs/v4/reference/page.mdx
index fff407414..9b0d8ed8a 100644
--- a/packages/docs/v4/reference/page.mdx
+++ b/packages/docs/v4/reference/page.mdx
@@ -600,6 +600,11 @@ const snapshot = await page.snapshot();
Whether to include iframe content.
+
+
+ Add safe, unique DOM attribute hints to interactive nodes in the formatted tree.
+ This is disabled by default and excludes password fields.
+
@@ -1264,6 +1269,11 @@ snapshot = await page.snapshot()
Whether to include iframe content.
+
+ Add safe, unique DOM attribute hints to interactive nodes in the formatted tree.
+ This is disabled by default and excludes password fields.
+
+
The operation result.
@@ -2017,6 +2027,11 @@ fmt.Println(snapshot.FormattedTree)
Whether to include iframe content.
+
+
+ Add safe, unique DOM attribute hints to interactive nodes in the formatted tree.
+ This is disabled by default and excludes password fields.
+
diff --git a/packages/extension/tests/stagehand-clients.test.ts b/packages/extension/tests/stagehand-clients.test.ts
index e5dfe2971..1206797b7 100644
--- a/packages/extension/tests/stagehand-clients.test.ts
+++ b/packages/extension/tests/stagehand-clients.test.ts
@@ -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 () => {
diff --git a/packages/extension/types/private/snapshot.ts b/packages/extension/types/private/snapshot.ts
index 70a447645..63cdc3380 100644
--- a/packages/extension/types/private/snapshot.ts
+++ b/packages/extension/types/private/snapshot.ts
@@ -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;
};
/**
@@ -55,6 +57,7 @@ export type SessionDomIndex = {
absByBe: Map;
tagByBe: Map;
scrollByBe: Map;
+ locatorHintsByBe: Map;
docRootOf: Map;
contentDocRootByIframe: Map;
enterByBe: Map;
@@ -65,9 +68,15 @@ export type FrameDomMaps = {
tagNameMap: Record;
xpathMap: Record;
scrollableMap: Record;
+ locatorHintsMap: Record;
urlMap: Record;
};
+export type LocatorHint = {
+ text: string;
+ linkOnly?: true;
+};
+
export type ResolvedLocation = {
frameId: string;
backendNodeId: number;
@@ -107,6 +116,7 @@ export type A11yNode = {
childIds?: string[];
children?: A11yNode[];
encodedId?: string;
+ locatorHints?: LocatorHint[];
};
export type A11yOptions = {
@@ -114,6 +124,7 @@ export type A11yOptions = {
isIgnoredBackendNode?: (backendNodeId: number) => boolean;
tagNameMap: Record;
scrollableMap: Record;
+ locatorHintsMap?: Record;
encode: (backendNodeId: number) => string;
};
diff --git a/packages/extension/understudy/a11y/snapshot/a11yTree.ts b/packages/extension/understudy/a11y/snapshot/a11yTree.ts
index 4e0c4d5c7..1dc4d29ba 100644
--- a/packages/extension/understudy/a11y/snapshot/a11yTree.ts
+++ b/packages/extension/understudy/a11y/snapshot/a11yTree.ts
@@ -145,6 +145,7 @@ export function decorateRoles(
parentId: n.parentId,
childIds: n.childIds,
encodedId,
+ locatorHints: encodedId ? opts.locatorHintsMap?.[encodedId] : undefined,
};
});
}
diff --git a/packages/extension/understudy/a11y/snapshot/capture.test.ts b/packages/extension/understudy/a11y/snapshot/capture.test.ts
index b67bd73ea..ab1d343d2 100644
--- a/packages/extension/understudy/a11y/snapshot/capture.test.ts
+++ b/packages/extension/understudy/a11y/snapshot/capture.test.ts
@@ -30,6 +30,7 @@ const emptyMaps = (): FrameDomMaps => ({
tagNameMap: {},
xpathMap: {},
scrollableMap: {},
+ locatorHintsMap: {},
urlMap: {},
});
diff --git a/packages/extension/understudy/a11y/snapshot/capture.ts b/packages/extension/understudy/a11y/snapshot/capture.ts
index 0335ed639..1c2607d95 100644
--- a/packages/extension/understudy/a11y/snapshot/capture.ts
+++ b/packages/extension/understudy/a11y/snapshot/capture.ts
@@ -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,
@@ -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, {
@@ -238,6 +244,7 @@ export async function tryScopedSnapshot(
),
tagNameMap,
scrollableMap,
+ locatorHintsMap,
encode: (backendNodeId) => `${page.getOrdinal(targetFrameId)}-${backendNodeId}`,
});
@@ -305,6 +312,7 @@ export async function buildSessionIndexes(
page: Page,
frames: string[],
pierce: boolean,
+ locatorHints = false,
): Promise