add snapshot format option with deduplication for accessibility snapshots
- add compactSnapshot, interactiveSnapshot, deduplicateSnapshot functions - add format option to accessibilitySnapshot() with default 'interactive-dedup' - deduplication removes redundant parent names that repeat child content - compression stats: HN 72% smaller, GitHub 68% smaller vs raw - add format documentation comment explaining expected aria snapshot structure
This commit is contained in:
@@ -2,7 +2,7 @@ import { describe, it, beforeAll, afterAll } from 'vitest'
|
|||||||
import { Page } from 'playwright-core'
|
import { Page } from 'playwright-core'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { compactSnapshot, interactiveSnapshot, getAriaSnapshot } from './aria-snapshot.js'
|
import { compactSnapshot, interactiveSnapshot, deduplicateSnapshot, getAriaSnapshot } from './aria-snapshot.js'
|
||||||
import { setupTestContext, cleanupTestContext, type TestContext } from './test-utils.js'
|
import { setupTestContext, cleanupTestContext, type TestContext } from './test-utils.js'
|
||||||
|
|
||||||
const TEST_PORT = 19986
|
const TEST_PORT = 19986
|
||||||
@@ -33,6 +33,7 @@ describe('aria-snapshot compression', () => {
|
|||||||
{ name: 'raw', transform: (s: string) => s },
|
{ name: 'raw', transform: (s: string) => s },
|
||||||
{ name: 'compact', transform: (s: string) => compactSnapshot(s) },
|
{ name: 'compact', transform: (s: string) => compactSnapshot(s) },
|
||||||
{ name: 'interactive', transform: (s: string) => interactiveSnapshot(s) },
|
{ name: 'interactive', transform: (s: string) => interactiveSnapshot(s) },
|
||||||
|
{ name: 'interactive-dedup', transform: (s: string) => deduplicateSnapshot(interactiveSnapshot(s)) },
|
||||||
{ name: 'interactive-flat', transform: (s: string) => interactiveSnapshot(s, { keepStructure: false }) },
|
{ name: 'interactive-flat', transform: (s: string) => interactiveSnapshot(s, { keepStructure: false }) },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,71 @@ const sharpPromise = import('sharp')
|
|||||||
.then((m) => { return m.default })
|
.then((m) => { return m.default })
|
||||||
.catch(() => { return null })
|
.catch(() => { return null })
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Aria Snapshot Format Documentation
|
||||||
|
// ============================================================================
|
||||||
|
//
|
||||||
|
// This module processes accessibility snapshots from Playwright's _snapshotForAI() method.
|
||||||
|
// The expected format is a YAML-like tree structure:
|
||||||
|
//
|
||||||
|
// ```
|
||||||
|
// - role "accessible name" [ref=eXX] [cursor=pointer] [active]:
|
||||||
|
// - childRole "child name" [ref=eYY]:
|
||||||
|
// - /url: https://example.com
|
||||||
|
// - text: "raw text content"
|
||||||
|
// ```
|
||||||
|
//
|
||||||
|
// Key format assumptions:
|
||||||
|
// - Lines start with "- " followed by a role name (e.g., link, button, row, cell)
|
||||||
|
// - Accessible names are in double quotes: "name"
|
||||||
|
// - When names contain colons, the whole entry is single-quoted: - 'role "name: with colon"':
|
||||||
|
// - Element refs are in brackets: [ref=e123]
|
||||||
|
// - Attributes like [cursor=pointer], [active] may appear after the name
|
||||||
|
// - Child elements are indented (2 spaces per level)
|
||||||
|
// - URL metadata appears as: - /url: https://...
|
||||||
|
// - Raw text appears as: - text: "content"
|
||||||
|
//
|
||||||
|
// If Playwright changes this format, the parsing functions below will need updates.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Snapshot Format Types and Processing
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type SnapshotFormat = 'raw' | 'compact' | 'interactive' | 'interactive-dedup'
|
||||||
|
|
||||||
|
export const DEFAULT_SNAPSHOT_FORMAT: SnapshotFormat = 'interactive-dedup'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a snapshot format transformation with error handling.
|
||||||
|
* If processing fails, logs the error and returns the raw snapshot.
|
||||||
|
*/
|
||||||
|
export function formatSnapshot(
|
||||||
|
snapshot: string,
|
||||||
|
format: SnapshotFormat,
|
||||||
|
logger?: { error: (...args: unknown[]) => void }
|
||||||
|
): string {
|
||||||
|
if (format === 'raw') {
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (format) {
|
||||||
|
case 'compact':
|
||||||
|
return compactSnapshot(snapshot)
|
||||||
|
case 'interactive':
|
||||||
|
return interactiveSnapshot(snapshot)
|
||||||
|
case 'interactive-dedup':
|
||||||
|
return deduplicateSnapshot(interactiveSnapshot(snapshot))
|
||||||
|
default:
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger?.error('[aria-snapshot] Failed to apply format', format, error)
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Snapshot Compression Functions
|
// Snapshot Compression Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -278,6 +343,187 @@ function cleanSnapshotLine(line: string): string {
|
|||||||
return line.replace(/ \[cursor=pointer\]/g, '').replace(/ \[active\]/g, '')
|
return line.replace(/ \[cursor=pointer\]/g, '').replace(/ \[active\]/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SnapshotNode {
|
||||||
|
indent: number
|
||||||
|
role: string
|
||||||
|
name: string | null
|
||||||
|
ref: string | null
|
||||||
|
rawLine: string
|
||||||
|
children: SnapshotNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove duplicate text from parent elements when the same text appears in descendants.
|
||||||
|
* For example, if a row's name is "upvote | story title" and it contains children with
|
||||||
|
* those exact names, the parent's name is redundant and can be removed.
|
||||||
|
*/
|
||||||
|
export function deduplicateSnapshot(snapshot: string): string {
|
||||||
|
const lines = snapshot.split('\n')
|
||||||
|
const nodes: SnapshotNode[] = []
|
||||||
|
const stack: SnapshotNode[] = []
|
||||||
|
|
||||||
|
// Parse lines into nodes with tree structure
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const indent = line.length - line.trimStart().length
|
||||||
|
const parsed = parseSnapshotLine(line)
|
||||||
|
|
||||||
|
const node: SnapshotNode = {
|
||||||
|
indent,
|
||||||
|
role: parsed.role,
|
||||||
|
name: parsed.name,
|
||||||
|
ref: parsed.ref,
|
||||||
|
rawLine: line,
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pop stack until we find parent (lower indent)
|
||||||
|
while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
|
||||||
|
stack.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stack.length > 0) {
|
||||||
|
stack[stack.length - 1].children.push(node)
|
||||||
|
} else {
|
||||||
|
nodes.push(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.push(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each root node
|
||||||
|
for (const node of nodes) {
|
||||||
|
deduplicateNode(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild snapshot
|
||||||
|
const result: string[] = []
|
||||||
|
for (const node of nodes) {
|
||||||
|
rebuildLines(node, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSnapshotLine(line: string): { role: string; name: string | null; ref: string | null } {
|
||||||
|
let trimmed = line.trim()
|
||||||
|
|
||||||
|
// Handle single-quote wrapped lines: - 'row "name with: colon"':
|
||||||
|
// These occur when the name contains a colon
|
||||||
|
if (trimmed.startsWith("- '") && trimmed.includes("':")) {
|
||||||
|
// Extract content between - ' and ':
|
||||||
|
const innerMatch = trimmed.match(/^-\s+'(.+)'/)
|
||||||
|
if (innerMatch) {
|
||||||
|
trimmed = '- ' + innerMatch[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match: - role "name" [ref=xxx]: or - role [ref=xxx]: or - role "name": or - role:
|
||||||
|
const match = trimmed.match(/^-\s+(\w+)(?:\s+"([^"]*)")?(?:\s+\[ref=(\w+)\])?/)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return { role: '', name: null, ref: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: match[1],
|
||||||
|
name: match[2] || null,
|
||||||
|
ref: match[3] || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDescendantNames(node: SnapshotNode): Set<string> {
|
||||||
|
const names = new Set<string>()
|
||||||
|
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (child.name) {
|
||||||
|
names.add(child.name)
|
||||||
|
}
|
||||||
|
// Recursively collect from grandchildren
|
||||||
|
for (const name of collectDescendantNames(child)) {
|
||||||
|
names.add(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNameRedundant(name: string, descendantNames: Set<string>): boolean {
|
||||||
|
if (descendantNames.size === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize the name - remove common separators and check if all parts exist in descendants
|
||||||
|
// Split by common separators: |, (, ), commas, and whitespace runs
|
||||||
|
const parts = name
|
||||||
|
.split(/[\|\(\),]+/)
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter((p) => p.length > 0)
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if each meaningful part is found in a descendant name
|
||||||
|
let matchedParts = 0
|
||||||
|
for (const part of parts) {
|
||||||
|
// Check if this part matches or is contained in any descendant name
|
||||||
|
for (const descName of descendantNames) {
|
||||||
|
if (descName === part || descName.includes(part) || part.includes(descName)) {
|
||||||
|
matchedParts++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If most parts (>= 50%) are found in descendants, the name is redundant
|
||||||
|
return matchedParts >= parts.length * 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
function deduplicateNode(node: SnapshotNode): void {
|
||||||
|
// First, recursively process children
|
||||||
|
for (const child of node.children) {
|
||||||
|
deduplicateNode(child)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check if this node's name is redundant
|
||||||
|
if (node.name && node.children.length > 0) {
|
||||||
|
const descendantNames = collectDescendantNames(node)
|
||||||
|
if (isNameRedundant(node.name, descendantNames)) {
|
||||||
|
node.name = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildLines(node: SnapshotNode, result: string[]): void {
|
||||||
|
// Rebuild the line with potentially stripped name
|
||||||
|
const indent = ' '.repeat(node.indent)
|
||||||
|
let line = `${indent}- ${node.role}`
|
||||||
|
|
||||||
|
if (node.name) {
|
||||||
|
// Use double quotes, escape if needed
|
||||||
|
const escaped = node.name.replace(/"/g, '\\"')
|
||||||
|
line += ` "${escaped}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.ref) {
|
||||||
|
line += ` [ref=${node.ref}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children.length > 0) {
|
||||||
|
line += ':'
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(line)
|
||||||
|
|
||||||
|
for (const child of node.children) {
|
||||||
|
rebuildLines(child, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Original Aria Snapshot Types and Functions
|
// Original Aria Snapshot Types and Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import { Editor } from './editor.js'
|
|||||||
import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js'
|
import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js'
|
||||||
import { getReactSource, type ReactSourceLocation } from './react-source.js'
|
import { getReactSource, type ReactSourceLocation } from './react-source.js'
|
||||||
import { ScopedFS } from './scoped-fs.js'
|
import { ScopedFS } from './scoped-fs.js'
|
||||||
import { screenshotWithAccessibilityLabels, type ScreenshotResult } from './aria-snapshot.js'
|
import { screenshotWithAccessibilityLabels, formatSnapshot, DEFAULT_SNAPSHOT_FORMAT, type ScreenshotResult, type SnapshotFormat } from './aria-snapshot.js'
|
||||||
|
export type { SnapshotFormat }
|
||||||
import { getCleanHTML, type GetCleanHTMLOptions } from './clean-html.js'
|
import { getCleanHTML, type GetCleanHTMLOptions } from './clean-html.js'
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
@@ -405,12 +406,16 @@ export class PlaywrightExecutor {
|
|||||||
page: Page
|
page: Page
|
||||||
search?: string | RegExp
|
search?: string | RegExp
|
||||||
showDiffSinceLastCall?: boolean
|
showDiffSinceLastCall?: boolean
|
||||||
|
/** Snapshot format: 'raw', 'compact', 'interactive', 'interactive-dedup' (default) */
|
||||||
|
format?: SnapshotFormat
|
||||||
}) => {
|
}) => {
|
||||||
const { page: targetPage, search, showDiffSinceLastCall = false } = options
|
const { page: targetPage, search, showDiffSinceLastCall = false, format = DEFAULT_SNAPSHOT_FORMAT } = options
|
||||||
if ((targetPage as any)._snapshotForAI) {
|
if ((targetPage as any)._snapshotForAI) {
|
||||||
const snapshot = await (targetPage as any)._snapshotForAI()
|
const snapshot = await (targetPage as any)._snapshotForAI()
|
||||||
const rawStr = typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2)
|
const rawStr = typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2)
|
||||||
const snapshotStr = rawStr.toWellFormed?.() ?? rawStr
|
const sanitizedStr = rawStr.toWellFormed?.() ?? rawStr
|
||||||
|
// Apply format transformation
|
||||||
|
const snapshotStr = formatSnapshot(sanitizedStr, format, this.logger)
|
||||||
|
|
||||||
if (showDiffSinceLastCall) {
|
if (showDiffSinceLastCall) {
|
||||||
const previousSnapshot = this.lastSnapshots.get(targetPage)
|
const previousSnapshot = this.lastSnapshots.get(targetPage)
|
||||||
|
|||||||
Reference in New Issue
Block a user