add aria snapshots functions
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -7,6 +7,281 @@ const sharpPromise = import('sharp')
|
|||||||
.then((m) => { return m.default })
|
.then((m) => { return m.default })
|
||||||
.catch(() => { return null })
|
.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<string>, 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<string>,
|
||||||
|
contentRoles: Set<string>,
|
||||||
|
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 {
|
export interface AriaRef {
|
||||||
role: string
|
role: string
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
Reference in New Issue
Block a user