From 2a62fa60d6e85a734f55963cf412e81361edff37 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sat, 24 Jan 2026 13:32:25 +0100 Subject: [PATCH] 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 --- playwriter/src/aria-snapshot.test.ts | 3 +- playwriter/src/aria-snapshot.ts | 246 +++++++++++++++++++++++++++ playwriter/src/executor.ts | 11 +- 3 files changed, 256 insertions(+), 4 deletions(-) diff --git a/playwriter/src/aria-snapshot.test.ts b/playwriter/src/aria-snapshot.test.ts index 3cdf6f4..0bb91e9 100644 --- a/playwriter/src/aria-snapshot.test.ts +++ b/playwriter/src/aria-snapshot.test.ts @@ -2,7 +2,7 @@ import { describe, it, beforeAll, afterAll } from 'vitest' import { Page } from 'playwright-core' import fs from 'node:fs' 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' const TEST_PORT = 19986 @@ -33,6 +33,7 @@ describe('aria-snapshot compression', () => { { name: 'raw', transform: (s: string) => s }, { name: 'compact', transform: (s: string) => compactSnapshot(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 }) }, ] diff --git a/playwriter/src/aria-snapshot.ts b/playwriter/src/aria-snapshot.ts index 3354310..fe9110a 100644 --- a/playwriter/src/aria-snapshot.ts +++ b/playwriter/src/aria-snapshot.ts @@ -7,6 +7,71 @@ const sharpPromise = import('sharp') .then((m) => { return m.default }) .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 // ============================================================================ @@ -278,6 +343,187 @@ function cleanSnapshotLine(line: string): string { 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 { + const names = new Set() + + 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): 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 // ============================================================================ diff --git a/playwriter/src/executor.ts b/playwriter/src/executor.ts index fc52226..a589a73 100644 --- a/playwriter/src/executor.ts +++ b/playwriter/src/executor.ts @@ -20,7 +20,8 @@ import { Editor } from './editor.js' import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js' import { getReactSource, type ReactSourceLocation } from './react-source.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' const __filename = fileURLToPath(import.meta.url) @@ -405,12 +406,16 @@ export class PlaywrightExecutor { page: Page search?: string | RegExp 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) { const snapshot = await (targetPage as any)._snapshotForAI() 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) { const previousSnapshot = this.lastSnapshots.get(targetPage)