diff --git a/packages/core/lib/v3/tests/page-snapshot.spec.ts b/packages/core/lib/v3/tests/page-snapshot.spec.ts new file mode 100644 index 000000000..414343cd8 --- /dev/null +++ b/packages/core/lib/v3/tests/page-snapshot.spec.ts @@ -0,0 +1,138 @@ +import { test, expect } from "@playwright/test"; +import { V3 } from "../v3"; +import { v3TestConfig } from "./v3.config"; + +test.describe("Page.snapshot", () => { + let v3: V3; + + test.beforeEach(async () => { + v3 = new V3(v3TestConfig); + await v3.init(); + }); + + test.afterEach(async () => { + await v3?.close?.().catch(() => {}); + }); + + test("returns a hybrid snapshot with combined tree and maps", async () => { + const page = v3.context.pages()[0]; + + const html = ` + + + Snapshot Test + +

Hello World

+ + Link + + + `; + + await page.goto("data:text/html," + encodeURIComponent(html)); + + // Call the new snapshot method + const snapshot = await page.snapshot(); + + // Verify structure matches HybridSnapshot type + expect(snapshot).toBeDefined(); + expect(typeof snapshot.combinedTree).toBe("string"); + expect(typeof snapshot.combinedXpathMap).toBe("object"); + expect(typeof snapshot.combinedUrlMap).toBe("object"); + + // The combined tree should contain our page content + expect(snapshot.combinedTree).toContain("Hello World"); + expect(snapshot.combinedTree).toContain("Submit"); + expect(snapshot.combinedTree).toContain("Link"); + + // XPath map should have entries + expect(Object.keys(snapshot.combinedXpathMap).length).toBeGreaterThan(0); + + // URL map should contain the link URL + expect(Object.values(snapshot.combinedUrlMap)).toContain( + "https://example.com/", + ); + }); + + test("supports focusSelector option to scope the snapshot", async () => { + const page = v3.context.pages()[0]; + + const html = ` + + + Scoped Snapshot Test + +
Outside Content
+
+

Main Heading

+

Main paragraph

+
+ + + `; + + await page.goto("data:text/html," + encodeURIComponent(html)); + + // Snapshot with focusSelector should scope to that element + const scopedSnapshot = await page.snapshot({ + focusSelector: "#main-content", + }); + + expect(scopedSnapshot).toBeDefined(); + expect(scopedSnapshot.combinedTree).toContain("Main Heading"); + expect(scopedSnapshot.combinedTree).toContain("Main paragraph"); + }); + + test("supports XPath focusSelector", async () => { + const page = v3.context.pages()[0]; + + const html = ` + + + +
+ Target Text +
+ + + `; + + await page.goto("data:text/html," + encodeURIComponent(html)); + + // Use XPath-style focusSelector + const snapshot = await page.snapshot({ + focusSelector: "//div[@id='container']", + }); + + expect(snapshot).toBeDefined(); + expect(snapshot.combinedTree).toContain("Target Text"); + }); + + test("returns perFrame data when available", async () => { + const page = v3.context.pages()[0]; + + const html = ` + + + +

Simple page

+ + + `; + + await page.goto("data:text/html," + encodeURIComponent(html)); + + const snapshot = await page.snapshot(); + + // perFrame should be present and contain at least one frame entry + expect(snapshot.perFrame).toBeDefined(); + expect(Array.isArray(snapshot.perFrame)).toBe(true); + expect(snapshot.perFrame!.length).toBeGreaterThanOrEqual(1); + + const mainFrame = snapshot.perFrame![0]; + expect(mainFrame.frameId).toBeDefined(); + expect(typeof mainFrame.outline).toBe("string"); + expect(typeof mainFrame.xpathMap).toBe("object"); + expect(typeof mainFrame.urlMap).toBe("object"); + }); +}); diff --git a/packages/core/lib/v3/types/public/page.ts b/packages/core/lib/v3/types/public/page.ts index f141f3d67..3273740c7 100644 --- a/packages/core/lib/v3/types/public/page.ts +++ b/packages/core/lib/v3/types/public/page.ts @@ -11,3 +11,10 @@ export type { ConsoleListener } from "../../understudy/consoleMessage"; export type LoadState = "load" | "domcontentloaded" | "networkidle"; export { Response } from "../../understudy/response"; + +// Snapshot types for page.snapshot() method +export type { + HybridSnapshot, + SnapshotOptions, + PerFrameSnapshot, +} from "../private/snapshot"; diff --git a/packages/core/lib/v3/understudy/page.ts b/packages/core/lib/v3/understudy/page.ts index 6943583b4..081322739 100644 --- a/packages/core/lib/v3/understudy/page.ts +++ b/packages/core/lib/v3/understudy/page.ts @@ -7,7 +7,8 @@ import { CdpConnection } from "./cdp"; import { Frame } from "./frame"; import { FrameLocator } from "./frameLocator"; import { deepLocatorFromPage } from "./deepLocator"; -import { resolveXpathForLocation } from "./a11y/snapshot"; +import { resolveXpathForLocation, captureHybridSnapshot } from "./a11y/snapshot"; +import type { HybridSnapshot, SnapshotOptions } from "../types/private"; import { FrameRegistry } from "./frameRegistry"; import { executionContexts } from "./executionContextRegistry"; import { LoadState } from "../types/public/page"; @@ -2066,6 +2067,26 @@ export class Page { .map((c) => c.substring(0, c.length - 1)); } + // ---- Snapshot API ---- + + /** + * Capture a hybrid DOM + Accessibility snapshot of the page. + * + * Returns a combined tree representation with XPath and URL maps for every + * element. This is the same snapshot format used internally by act/extract/observe. + * + * @param options Optional configuration for the snapshot capture. + * @param options.focusSelector Filter the snapshot to a specific element/subtree. + * Supports XPath (prefixed with `xpath=` or starting with `/`) and CSS with iframe hops via `>>`. + * @param options.pierceShadow Pierce shadow DOM when walking the document (default: true). + * @param options.experimental Enable experimental traversal tweaks in the Accessibility layer. + * @returns A HybridSnapshot containing the combined tree, XPath map, URL map, and per-frame data. + */ + @logAction("Page.snapshot") + async snapshot(options?: SnapshotOptions): Promise { + return captureHybridSnapshot(this, options); + } + // ---- Page-level lifecycle waiter that follows main frame id swaps ---- /** Resolve the main-world execution context for the current main frame. */ diff --git a/packages/docs/v3/references/page.mdx b/packages/docs/v3/references/page.mdx index bda6f5274..17525061a 100644 --- a/packages/docs/v3/references/page.mdx +++ b/packages/docs/v3/references/page.mdx @@ -388,6 +388,28 @@ await page.screenshot(options?: ScreenshotOptions): Promise **Returns:** A `Promise` containing the screenshot image data. +## Snapshot + +### snapshot() + +Capture a hybrid DOM + Accessibility snapshot of the page. + +```typescript +await page.snapshot(options?: SnapshotOptions): Promise +``` + + + Filter the snapshot to a specific element using a selector. + + + + Pierce shadow DOM when capturing. + + **Default:** `true` + + +**Returns:** A `Promise` containing the snapshot data. + ## Viewport ### setViewportSize() @@ -676,6 +698,25 @@ interface ScreenshotOptions { Matches Playwright's screenshot signature with sensible defaults to control how a capture is produced. +### SnapshotOptions + +```typescript +interface SnapshotOptions { + focusSelector?: string; + pierceShadow?: boolean; +} +``` + +### HybridSnapshot + +```typescript +interface HybridSnapshot { + combinedTree: string; + combinedXpathMap: Record; + combinedUrlMap: Record; +} +``` + ## Error Handling Page methods may throw the following errors: