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> { const sessionToIndex = new Map(); const sessionById = new Map(); @@ -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; @@ -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); } @@ -360,6 +376,7 @@ export async function collectPerFrameMaps( const tagNameMap: Record = {}; const xpathMap: Record = {}; const scrollableMap: Record = {}; + const locatorHintsMap: FrameDomMaps["locatorHintsMap"] = {}; const isIgnoredBackendNode = makeIsIgnoredBackendNode(frameId, idx, exclusionIntervalsByFrame); const enc = (be: number) => `${page.getOrdinal(frameId)}-${be}`; const baseAbs = idx.absByBe.get(docRootBe) ?? "/"; @@ -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 }; diff --git a/packages/extension/understudy/a11y/snapshot/domTree.test.ts b/packages/extension/understudy/a11y/snapshot/domTree.test.ts index 719d3168a..6d24b7ac9 100644 --- a/packages/extension/understudy/a11y/snapshot/domTree.test.ts +++ b/packages/extension/understudy/a11y/snapshot/domTree.test.ts @@ -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 () => { @@ -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 & Pick, +): Protocol.DOM.Node { + return { + localName: node.nodeName.toLowerCase(), + nodeValue: "", + childNodeCount: node.children?.length ?? 0, + ...node, + } as Protocol.DOM.Node; +} diff --git a/packages/extension/understudy/a11y/snapshot/domTree.ts b/packages/extension/understudy/a11y/snapshot/domTree.ts index 17fe64691..f5515d14f 100644 --- a/packages/extension/understudy/a11y/snapshot/domTree.ts +++ b/packages/extension/understudy/a11y/snapshot/domTree.ts @@ -1,4 +1,5 @@ import type { Protocol } from "devtools-protocol"; +import type { LocatorHint } from "../../../types/private/snapshot.js"; import type { CDPSessionLike } from "../../cdp.js"; import type { SessionDomIndex } from "../../../types/private/snapshot.js"; import { buildChildXPathSegments, joinXPath, normalizeXPath } from "./xpathUtils.js"; @@ -157,10 +158,12 @@ export async function domMapsForSession( pierce: boolean, encode: (fid: string, backendNodeId: number) => string, attemptOwnerLookup = true, + locatorHints = false, ): Promise<{ tagNameMap: Record; xpathMap: Record; scrollableMap: Record; + locatorHintsMap: Record; }> { await session.send("DOM.enable").catch(() => {}); const root = await getDomTreeWithFallback(session, pierce); @@ -186,6 +189,8 @@ export async function domMapsForSession( const tagNameMap: Record = {}; const xpathMap: Record = {}; const scrollableMap: Record = {}; + const locatorHintsMap: Record = {}; + const hintsByBe = locatorHints && pierce ? collectLocatorHints(startNode) : new Map(); type StackEntry = { node: Protocol.DOM.Node; xpath: string }; const stack: StackEntry[] = [{ node: startNode, xpath: "" }]; @@ -199,6 +204,8 @@ export async function domMapsForSession( xpathMap[encId] = xpath || "/"; const isScrollable = node?.isScrollable === true; if (isScrollable) scrollableMap[encId] = true; + const hints = hintsByBe.get(node.backendNodeId); + if (hints) locatorHintsMap[encId] = hints; } const kids = node.children ?? []; @@ -222,7 +229,7 @@ export async function domMapsForSession( } } - return { tagNameMap, xpathMap, scrollableMap }; + return { tagNameMap, xpathMap, scrollableMap, locatorHintsMap }; } /** @@ -233,6 +240,7 @@ export async function domMapsForSession( export async function buildSessionDomIndex( session: CDPSessionLike, pierce: boolean, + locatorHints = false, ): Promise { await session.send("DOM.enable").catch(() => {}); const root = await getDomTreeWithFallback(session, pierce); @@ -240,6 +248,7 @@ export async function buildSessionDomIndex( const absByBe = new Map(); const tagByBe = new Map(); const scrollByBe = new Map(); + const locatorHintsByBe = locatorHints && pierce ? collectLocatorHints(root) : new Map(); const docRootOf = new Map(); const contentDocRootByIframe = new Map(); const enterByBe = new Map(); @@ -315,6 +324,7 @@ export async function buildSessionDomIndex( absByBe, tagByBe, scrollByBe, + locatorHintsByBe, docRootOf, contentDocRootByIframe, enterByBe, @@ -351,6 +361,104 @@ function getAttr(attrs: string[] | undefined, name: string): string | undefined return undefined; } +function collectLocatorHints(root: Protocol.DOM.Node): Map { + const attributes: Array<[string, string]> = [ + ["id", "#"], + ["data-testid", "testid="], + ["data-test", "test="], + ["data-cy", "cy="], + ["data-qa", "qa="], + ["data-automation-id", "automation-id="], + ["name", "name="], + ["autocomplete", "autocomplete="], + ["aria-label", "aria-label="], + ["href", "href="], + ["alt", "alt="], + ["placeholder", "placeholder="], + ]; + const counts = new Map>(); + const elements: Protocol.DOM.Node[] = []; + const stack: Array<{ node: Protocol.DOM.Node; eligible: boolean }> = [ + { node: root, eligible: true }, + ]; + while (stack.length) { + const { node, eligible } = stack.pop()!; + if (node.nodeType === 1) { + if (eligible) elements.push(node); + for (const [attribute] of attributes) { + const value = getAttr(node.attributes, attribute); + if (!value || value.length > 64) continue; + const key = + attribute === "autocomplete" || attribute === "id" ? value.toLowerCase() : value; + const values = counts.get(attribute) ?? new Map(); + values.set(key, (values.get(key) ?? 0) + 1); + counts.set(attribute, values); + } + } + for (const child of node.children ?? []) stack.push({ node: child, eligible }); + for (const shadow of node.shadowRoots ?? []) { + stack.push({ node: shadow, eligible: eligible && shadow.shadowRootType === "open" }); + } + } + const hintsByBe = new Map(); + for (const node of elements) { + const tag = node.nodeName.toLowerCase(); + if (tag === "input" && getAttr(node.attributes, "type")?.toLowerCase() === "password") continue; + const hints: LocatorHint[] = []; + for (const [attribute, prefix] of attributes) { + const value = getAttr(node.attributes, attribute); + if (!value || value.length > 64 || /[\0\uD800-\uDFFF]/u.test(value)) continue; + const key = attribute === "autocomplete" || attribute === "id" ? value.toLowerCase() : value; + if (counts.get(attribute)?.get(key) !== 1) continue; + if ( + attribute === "id" && + (/\s$/.test(value) || + /^(?::|radix-|headlessui-|mui-|mantine-|chakra-|ember|ext-|yui_)|[a-f\d]{8}|\d{4}/i.test( + value, + )) + ) { + continue; + } + if ( + (attribute === "name" || attribute === "autocomplete") && + !["input", "textarea", "select", "button"].includes(tag) + ) { + continue; + } + if ( + attribute === "href" && + (!value.startsWith("/") || + value.startsWith("//") || + /[?#\\\s]/.test(value) || + new URL(value, "https://stagehand.invalid").pathname !== value) + ) { + continue; + } + if ( + attribute === "alt" && + tag !== "img" && + !(tag === "input" && getAttr(node.attributes, "type")?.toLowerCase() === "image") + ) { + continue; + } + const encoded = /[\s{}"'\\]|\p{Cc}/u.test(value) + ? JSON.stringify(value).replace( + /[\x7f-\x9f\u2028\u2029]/g, + (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`, + ) + : value; + hints.push({ + text: `${prefix}${encoded}`, + ...(attribute === "href" ? { linkOnly: true } : {}), + }); + } + if (hints.length && typeof node.backendNodeId === "number") { + hintsByBe.set(node.backendNodeId, hints); + } + } + return hintsByBe; +} + /** Build an enriched tag name that includes the type attribute for inputs. */ function enrichedTagName(node: Protocol.DOM.Node): string { const tag = String(node.nodeName).toLowerCase(); diff --git a/packages/extension/understudy/a11y/snapshot/treeFormatUtils.ts b/packages/extension/understudy/a11y/snapshot/treeFormatUtils.ts index 4928894ca..a5b3e9f21 100644 --- a/packages/extension/understudy/a11y/snapshot/treeFormatUtils.ts +++ b/packages/extension/understudy/a11y/snapshot/treeFormatUtils.ts @@ -9,7 +9,14 @@ export function formatTreeLine(node: A11yNode, level = 0): string { const indent = " ".repeat(level); const labelId = node.encodedId ?? node.nodeId; const stateFlags = formatStateFlags(node); - const label = `[${labelId}] ${node.role}${node.name ? `: ${cleanText(node.name)}` : ""}${stateFlags}`; + const role = node.role.toLowerCase(); + const hint = + /^(button|link|textbox|searchbox|checkbox|radio|combobox|select|listbox|option|slider|spinbutton|switch|tab|menuitem\w*|treeitem|input|file|input, file)$/.test( + role, + ) + ? node.locatorHints?.find((candidate) => !candidate.linkOnly || role === "link")?.text + : undefined; + const label = `[${labelId}] ${node.role}${node.name ? `: ${cleanText(node.name)}` : ""}${hint ? ` {${hint}}` : ""}${stateFlags}`; const kids = node.children?.map((c) => formatTreeLine(c, level + 1)).join("\n") ?? ""; return kids ? `${indent}${label}\n${kids}` : `${indent}${label}`; } diff --git a/packages/extension/understudy/page.ts b/packages/extension/understudy/page.ts index 6ec5cb30a..e0fd10690 100644 --- a/packages/extension/understudy/page.ts +++ b/packages/extension/understudy/page.ts @@ -1884,6 +1884,7 @@ export class Page { const { combinedTree, combinedXpathMap, combinedUrlMap } = await this.captureSnapshot({ pierceShadow: true, includeIframes: options?.includeIframes, + locatorHints: options?.expanded === true, }); return { diff --git a/packages/protocol/schemas.ts b/packages/protocol/schemas.ts index 87e0be02a..7425d76cb 100644 --- a/packages/protocol/schemas.ts +++ b/packages/protocol/schemas.ts @@ -1339,6 +1339,7 @@ export const SnapshotResultSchema = z export const PageSnapshotOptionsSchema = z .strictObject({ includeIframes: z.boolean().optional(), + expanded: z.boolean().optional(), }) .meta({ id: "PageSnapshotOptions" }); diff --git a/packages/protocol/stagehand.v4.json b/packages/protocol/stagehand.v4.json index c91ab6cc0..01cee5003 100644 --- a/packages/protocol/stagehand.v4.json +++ b/packages/protocol/stagehand.v4.json @@ -3802,6 +3802,9 @@ "properties": { "include_iframes": { "type": "boolean" + }, + "expanded": { + "type": "boolean" } }, "additionalProperties": false diff --git a/packages/protocol/tests/protocol/page-command-schemas.test.ts b/packages/protocol/tests/protocol/page-command-schemas.test.ts index f5f2509fc..cbda15f5c 100644 --- a/packages/protocol/tests/protocol/page-command-schemas.test.ts +++ b/packages/protocol/tests/protocol/page-command-schemas.test.ts @@ -263,8 +263,8 @@ describe("page command schemas", () => { }); expect(() => PageScreenshotResultSchema.parse({ data: "iVBORw==", type: "png" })).toThrow(); expect( - PageSnapshotParamsSchema.parse({ pageId, options: { includeIframes: true } }), - ).toStrictEqual({ pageId, options: { includeIframes: true } }); + PageSnapshotParamsSchema.parse({ pageId, options: { includeIframes: true, expanded: true } }), + ).toStrictEqual({ pageId, options: { includeIframes: true, expanded: true } }); expect(() => PageScreenshotParamsSchema.parse({ pageId, options: { type: "png", quality: 80 } }), ).toThrow(); diff --git a/packages/protocol/tests/protocol/page-shared-schemas.test.ts b/packages/protocol/tests/protocol/page-shared-schemas.test.ts index 1c49b60cc..63dd9d435 100644 --- a/packages/protocol/tests/protocol/page-shared-schemas.test.ts +++ b/packages/protocol/tests/protocol/page-shared-schemas.test.ts @@ -50,9 +50,12 @@ describe("shared page protocol schemas", () => { }); it("parses snapshot options and results", () => { - expect(PageSnapshotOptionsSchema.parse({ includeIframes: true })).toStrictEqual({ - includeIframes: true, - }); + expect(PageSnapshotOptionsSchema.parse({ includeIframes: true, expanded: true })).toStrictEqual( + { + includeIframes: true, + expanded: true, + }, + ); expect( SnapshotResultSchema.parse({ formattedTree: "root", diff --git a/packages/sdk-go/internal/extensionassets/stagehand-extension.zip b/packages/sdk-go/internal/extensionassets/stagehand-extension.zip index 3833853d8..c3d822f50 100644 Binary files a/packages/sdk-go/internal/extensionassets/stagehand-extension.zip and b/packages/sdk-go/internal/extensionassets/stagehand-extension.zip differ diff --git a/packages/sdk-go/models.gen.go b/packages/sdk-go/models.gen.go index b9b564a1d..95b7801f8 100644 --- a/packages/sdk-go/models.gen.go +++ b/packages/sdk-go/models.gen.go @@ -1569,6 +1569,9 @@ type PageSetViewportSizeParams struct { } type PageSnapshotOptions struct { + // Expanded corresponds to the JSON schema field "expanded". + Expanded *bool `json:"expanded,omitempty,omitzero"` + // IncludeIframes corresponds to the JSON schema field "include_iframes". IncludeIframes *bool `json:"include_iframes,omitempty,omitzero"` } diff --git a/packages/sdk-python/src/stagehand/_generated/input_types.py b/packages/sdk-python/src/stagehand/_generated/input_types.py index a271ac6fe..bef036428 100644 --- a/packages/sdk-python/src/stagehand/_generated/input_types.py +++ b/packages/sdk-python/src/stagehand/_generated/input_types.py @@ -872,6 +872,7 @@ class PageSetViewportSizeParams(TypedDict): class PageSnapshotOptions(TypedDict): include_iframes: NotRequired[bool] + expanded: NotRequired[bool] class PageSnapshotParams(TypedDict): diff --git a/packages/sdk-python/src/stagehand/_generated/models.py b/packages/sdk-python/src/stagehand/_generated/models.py index f9ecf9cda..b05f93aae 100644 --- a/packages/sdk-python/src/stagehand/_generated/models.py +++ b/packages/sdk-python/src/stagehand/_generated/models.py @@ -1813,6 +1813,7 @@ class PageSnapshotOptions(WireModel): validate_by_name=True, ) include_iframes: Optional[StrictBool] = None + expanded: Optional[StrictBool] = None class PageSnapshotParams(WireModel): diff --git a/packages/sdk-python/src/stagehand/page.py b/packages/sdk-python/src/stagehand/page.py index d7df05010..3402956d2 100644 --- a/packages/sdk-python/src/stagehand/page.py +++ b/packages/sdk-python/src/stagehand/page.py @@ -555,10 +555,18 @@ async def screenshot( Path(path).write_bytes(data) return data - async def snapshot(self, *, include_iframes: bool | None = None) -> SnapshotResult: + async def snapshot( + self, + *, + include_iframes: bool | None = None, + expanded: bool | None = None, + ) -> SnapshotResult: params = PageSnapshotParams(page_id=self.page_id) - if include_iframes is not None: - params.options = PageSnapshotOptions(include_iframes=include_iframes) + if include_iframes is not None or expanded is not None: + params.options = PageSnapshotOptions( + include_iframes=include_iframes, + expanded=expanded, + ) return await self._rpc_client.send("page.snapshot", params, SnapshotResult) async def tools(self, *, timeout: float | None = None) -> list[WebMCPTool]: diff --git a/packages/sdk-ts/tests/objectWrapper.test.ts b/packages/sdk-ts/tests/objectWrapper.test.ts index a399251cc..a0bd2c392 100644 --- a/packages/sdk-ts/tests/objectWrapper.test.ts +++ b/packages/sdk-ts/tests/objectWrapper.test.ts @@ -1022,11 +1022,13 @@ describe("Stagehand TS object wrapper", () => { client.queueResponse(StagehandMethods.pageSnapshot, snapshot); const page = new Page(client, { pageId: "page-1" }); - await expect(page.snapshot({ includeIframes: true })).resolves.toStrictEqual(snapshot); + await expect(page.snapshot({ includeIframes: true, expanded: true })).resolves.toStrictEqual( + snapshot, + ); expect(client.calls).toStrictEqual([ requestCall(StagehandMethods.pageSnapshot, { pageId: "page-1", - options: { includeIframes: true }, + options: { includeIframes: true, expanded: true }, }), ]); }); diff --git a/packages/sdk-ts/tests/packageContract.test.ts b/packages/sdk-ts/tests/packageContract.test.ts index 58f5e1228..9f56b39f0 100644 --- a/packages/sdk-ts/tests/packageContract.test.ts +++ b/packages/sdk-ts/tests/packageContract.test.ts @@ -139,7 +139,7 @@ describe("published TypeScript SDK", () => { const pageKeyPress: PageKeyPressOptions = { delay: 0 }; const pageReload: PageReloadOptions = navigation; const pageViewport: PageSetViewportSizeOptions = { deviceScaleFactor: 2 }; - const pageSnapshot: PageSnapshotOptions = { includeIframes: true }; + const pageSnapshot: PageSnapshotOptions = { includeIframes: true, expanded: true }; const pageType: PageTypeOptions = { delay: 0, withMistakes: false }; const pageWait: PageWaitForSelectorOptions = { state: "visible", timeout: 1_000 }; const locatorClick: LocatorClickOptions = pageClick;