From de3941da4868db1761b97d3abd164dbdbe4e9247 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sat, 24 Jan 2026 13:19:13 +0100 Subject: [PATCH] add aria snapshots functions --- playwriter/src/aria-snapshot.test.ts | 61 ++++++ playwriter/src/aria-snapshot.ts | 275 +++++++++++++++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 playwriter/src/aria-snapshot.test.ts diff --git a/playwriter/src/aria-snapshot.test.ts b/playwriter/src/aria-snapshot.test.ts new file mode 100644 index 0000000..3cdf6f4 --- /dev/null +++ b/playwriter/src/aria-snapshot.test.ts @@ -0,0 +1,61 @@ +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 { setupTestContext, cleanupTestContext, type TestContext } from './test-utils.js' + +const TEST_PORT = 19986 +const SNAPSHOTS_DIR = path.join(import.meta.dirname, 'aria-snapshots') + +describe('aria-snapshot compression', () => { + let ctx: TestContext + let page: Page + + beforeAll(async () => { + ctx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'aria-snapshot-test-' }) + page = await ctx.browserContext.newPage() + if (!fs.existsSync(SNAPSHOTS_DIR)) { + fs.mkdirSync(SNAPSHOTS_DIR, { recursive: true }) + } + }, 60000) + + afterAll(async () => { + await cleanupTestContext(ctx) + }) + + const sites = [ + { name: 'hackernews', url: 'https://news.ycombinator.com' }, + { name: 'github', url: 'https://github.com' }, + ] + + const formats = [ + { name: 'raw', transform: (s: string) => s }, + { name: 'compact', transform: (s: string) => compactSnapshot(s) }, + { name: 'interactive', transform: (s: string) => interactiveSnapshot(s) }, + { name: 'interactive-flat', transform: (s: string) => interactiveSnapshot(s, { keepStructure: false }) }, + ] + + for (const site of sites) { + it(`${site.name} - compression stats`, async () => { + await page.goto(site.url, { waitUntil: 'domcontentloaded' }) + await page.waitForTimeout(1000) + + const { snapshot } = await getAriaSnapshot({ page }) + const rawSize = snapshot.length + + console.log(`\n๐Ÿ“Š ${site.name.toUpperCase()} compression stats:`) + + for (const format of formats) { + const processed = format.transform(snapshot) + const size = processed.length + const savings = format.name === 'raw' ? 0 : Math.round((1 - size / rawSize) * 100) + + fs.writeFileSync(path.join(SNAPSHOTS_DIR, `${site.name}-${format.name}.txt`), processed) + + const savingsStr = format.name === 'raw' ? '(baseline)' : `${savings}% smaller` + console.log(` ${format.name}: ${size} bytes ${savingsStr}`) + } + }, 30000) + } +}) diff --git a/playwriter/src/aria-snapshot.ts b/playwriter/src/aria-snapshot.ts index 301cc58..3354310 100644 --- a/playwriter/src/aria-snapshot.ts +++ b/playwriter/src/aria-snapshot.ts @@ -7,6 +7,281 @@ const sharpPromise = import('sharp') .then((m) => { return m.default }) .catch(() => { return null }) +// ============================================================================ +// Snapshot Compression Functions +// ============================================================================ + +export interface CompactSnapshotOptions { + /** Remove [cursor=pointer] hints (default: true) */ + removeCursorPointer?: boolean + /** Remove [active] markers (default: true) */ + removeActive?: boolean + /** Remove empty structural rows/cells (default: true) */ + removeEmptyStructural?: boolean + /** Remove text separators like "|" (default: true) */ + removeTextSeparators?: boolean + /** Remove /url: metadata lines (default: false) */ + removeUrls?: boolean +} + +export interface InteractiveSnapshotOptions { + /** Keep /url: metadata for links (default: false) */ + keepUrls?: boolean + /** Keep image elements (default: true) */ + keepImages?: boolean + /** Keep tree structure, removing only empty branches (default: true) */ + keepStructure?: boolean + /** Keep headings for context (default: true) */ + keepHeadings?: boolean + /** Remove unnamed generic/group wrappers (default: true) */ + removeGenericWrappers?: boolean +} + +/** + * Post-process a snapshot to make it more compact. + * Removes noise while preserving structure. + * Typical reduction: 15-25% + */ +export function compactSnapshot(snapshot: string, options: CompactSnapshotOptions = {}): string { + const { + removeCursorPointer = true, + removeActive = true, + removeEmptyStructural = true, + removeTextSeparators = true, + removeUrls = false, + } = options + + let lines = snapshot.split('\n') + + // Line-by-line transformations + lines = lines.map((line) => { + let result = line + if (removeCursorPointer) { + result = result.replace(/ \[cursor=pointer\]/g, '') + } + if (removeActive) { + result = result.replace(/ \[active\]/g, '') + } + return result + }) + + // Filter out unwanted lines + lines = lines.filter((line) => { + const trimmed = line.trim() + if (!trimmed) { + return false + } + // Remove text separators + if (removeTextSeparators && /^- text: ["']?[|ยทโ€ขโ€“โ€”]/.test(trimmed)) { + return false + } + // Remove empty structural elements + if (removeEmptyStructural) { + if (/^- (row|cell|rowgroup|generic|listitem|group)\s*(\[ref=\w+\])?\s*$/.test(trimmed)) { + return false + } + } + // Remove /url: lines + if (removeUrls && /^- \/url:/.test(trimmed)) { + return false + } + return true + }) + + return lines.join('\n') +} + +/** + * Post-process a snapshot to show only interactive elements. + * Like agent-browser's compact mode - keeps structure but only refs on interactive elements. + * Typical reduction: 50-65% with structure, 80-90% flat + */ +export function interactiveSnapshot(snapshot: string, options: InteractiveSnapshotOptions = {}): string { + const { + keepUrls = false, + keepImages = true, + keepStructure = true, + keepHeadings = true, + removeGenericWrappers = true, + } = options + + const interactiveRoles = new Set([ + 'link', 'button', 'textbox', 'combobox', 'searchbox', 'checkbox', 'radio', + 'slider', 'spinbutton', 'switch', 'menuitem', 'menuitemcheckbox', + 'menuitemradio', 'option', 'tab', 'treeitem', + ]) + + if (keepImages) { + interactiveRoles.add('img') + interactiveRoles.add('video') + interactiveRoles.add('audio') + } + + const contentRoles = new Set(keepHeadings ? ['heading'] : []) + + const lines = snapshot.split('\n') + + if (!keepStructure) { + return extractInteractiveFlat(lines, interactiveRoles, keepUrls) + } + + let result = extractInteractiveWithStructure(lines, interactiveRoles, contentRoles, keepUrls) + + if (removeGenericWrappers) { + result = collapseGenericWrappers(result) + } + + return result +} + +function extractInteractiveFlat(lines: string[], interactiveRoles: Set, keepUrls: boolean): string { + const result: string[] = [] + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim() + if (!trimmed) { + continue + } + + const match = trimmed.match(/^-\s+(\w+)(?:\s+"[^"]*")?(?:\s+\[[^\]]+\])*\s*\[ref=(\w+)\]/) + if (!match || !interactiveRoles.has(match[1])) { + continue + } + + let cleanLine = trimmed + .replace(/ \[cursor=pointer\]/g, '') + .replace(/ \[active\]/g, '') + .replace(/:$/, '') + + result.push(cleanLine) + + if (keepUrls && match[1] === 'link' && i + 1 < lines.length) { + const nextLine = lines[i + 1].trim() + if (nextLine.startsWith('- /url:')) { + result.push(' ' + nextLine) + } + } + } + + return result.join('\n') +} + +function extractInteractiveWithStructure( + lines: string[], + interactiveRoles: Set, + contentRoles: Set, + keepUrls: boolean +): string { + const lineHasInteractive = new Array(lines.length).fill(false) + const lineIsInteractive = new Array(lines.length).fill(false) + const lineIndents = lines.map((l) => l.length - l.trimStart().length) + const lineRoles = lines.map((l) => { + const m = l.trim().match(/^-\s+(\w+)/) + return m ? m[1] : null + }) + + // Mark interactive lines + for (let i = 0; i < lines.length; i++) { + const role = lineRoles[i] + if (role && interactiveRoles.has(role)) { + lineHasInteractive[i] = true + lineIsInteractive[i] = true + } else if (role && contentRoles.has(role)) { + lineHasInteractive[i] = true + } + } + + // Propagate up to ancestors + for (let i = lines.length - 1; i >= 0; i--) { + if (!lineHasInteractive[i]) { + continue + } + const myIndent = lineIndents[i] + for (let j = i - 1; j >= 0; j--) { + if (lineIndents[j] < myIndent && lines[j].trim()) { + lineHasInteractive[j] = true + break + } + } + } + + // Build result + const result: string[] = [] + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim() + if (!trimmed || !lineHasInteractive[i]) { + continue + } + + // Skip /url: unless wanted + if (trimmed.startsWith('- /url:')) { + if (keepUrls && i > 0 && lineRoles[i - 1] === 'link') { + result.push(cleanSnapshotLine(lines[i])) + } + continue + } + + // Skip text nodes + if (trimmed.startsWith('- text:')) { + continue + } + + // Clean line and strip refs from non-interactive + let cleanedLine = cleanSnapshotLine(lines[i]) + if (!lineIsInteractive[i]) { + cleanedLine = cleanedLine.replace(/\s*\[ref=\w+\]/g, '') + } + + result.push(cleanedLine) + } + + return result.join('\n') +} + +function collapseGenericWrappers(snapshot: string): string { + const lines = snapshot.split('\n') + const result: string[] = [] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const trimmed = line.trim() + + if (!trimmed) { + continue + } + + // Check for unnamed wrapper: - generic: or - group: + if (/^-\s+(generic|group|region):$/.test(trimmed)) { + const currentIndent = line.length - line.trimStart().length + // Dedent children + for (let j = i + 1; j < lines.length; j++) { + const nextLine = lines[j] + if (!nextLine.trim()) { + continue + } + const nextIndent = nextLine.length - nextLine.trimStart().length + if (nextIndent <= currentIndent) { + break + } + lines[j] = nextLine.slice(0, currentIndent) + nextLine.slice(currentIndent + 2) + } + continue + } + + result.push(line) + } + + return result.join('\n') +} + +function cleanSnapshotLine(line: string): string { + return line.replace(/ \[cursor=pointer\]/g, '').replace(/ \[active\]/g, '') +} + +// ============================================================================ +// Original Aria Snapshot Types and Functions +// ============================================================================ + export interface AriaRef { role: string name: string