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
138 changes: 138 additions & 0 deletions packages/core/lib/v3/tests/page-snapshot.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<!doctype html>
<html>
<head><title>Snapshot Test</title></head>
<body>
<h1>Hello World</h1>
<button id="submit-btn">Submit</button>
<a href="https://example.com">Link</a>
</body>
</html>
`;

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 = `
<!doctype html>
<html>
<head><title>Scoped Snapshot Test</title></head>
<body>
<div id="outside">Outside Content</div>
<main id="main-content">
<h2>Main Heading</h2>
<p>Main paragraph</p>
</main>
</body>
</html>
`;

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");
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Test verifies scoped content is present but doesn't verify that out-of-scope content (#outside) is excluded. Add a negative assertion to actually test the scoping behavior, otherwise this test provides false confidence.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/lib/v3/tests/page-snapshot.spec.ts, line 83:

<comment>Test verifies scoped content is present but doesn&#39;t verify that out-of-scope content (`#outside`) is excluded. Add a negative assertion to actually test the scoping behavior, otherwise this test provides false confidence.</comment>

<file context>
@@ -0,0 +1,138 @@
+
+    expect(scopedSnapshot).toBeDefined();
+    expect(scopedSnapshot.combinedTree).toContain(&quot;Main Heading&quot;);
+    expect(scopedSnapshot.combinedTree).toContain(&quot;Main paragraph&quot;);
+  });
+
</file context>
Fix with Cubic

});

test("supports XPath focusSelector", async () => {
const page = v3.context.pages()[0];

const html = `
<!doctype html>
<html>
<body>
<div id="container">
<span>Target Text</span>
</div>
</body>
</html>
`;

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 = `
<!doctype html>
<html>
<body>
<p>Simple page</p>
</body>
</html>
`;

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");
});
});
7 changes: 7 additions & 0 deletions packages/core/lib/v3/types/public/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
23 changes: 22 additions & 1 deletion packages/core/lib/v3/understudy/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<HybridSnapshot> {
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. */
Expand Down
41 changes: 41 additions & 0 deletions packages/docs/v3/references/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,28 @@ await page.screenshot(options?: ScreenshotOptions): Promise<Buffer>

**Returns:** A `Promise<Buffer>` containing the screenshot image data.

## Snapshot

### snapshot()

Capture a hybrid DOM + Accessibility snapshot of the page.

```typescript
await page.snapshot(options?: SnapshotOptions): Promise<HybridSnapshot>
```

<ParamField path="focusSelector" type="string" optional>
Filter the snapshot to a specific element using a selector.
</ParamField>

<ParamField path="pierceShadow" type="boolean" optional>
Pierce shadow DOM when capturing.

**Default:** `true`
</ParamField>

**Returns:** A `Promise<HybridSnapshot>` containing the snapshot data.

## Viewport

### setViewportSize()
Expand Down Expand Up @@ -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<string, string>;
combinedUrlMap: Record<string, string>;
}
```

## Error Handling

Page methods may throw the following errors:
Expand Down