refactor: replace Playwright internals with in-page dom-accessibility-api

- Add a11y-client.ts: browser-side code using dom-accessibility-api (41KB)
- Single page.evaluate() call instead of multiple CDP roundtrips
- Zero element handles - everything runs in page context
- Stable refs from test IDs (data-testid, data-test-id, id) instead of e1, e2
- Add locator param to scope snapshots to subtrees
- Fixes cross-frame 'Unable to adopt element handle' error (issue #39)
This commit is contained in:
Tommy D. Rossi
2026-02-01 16:58:29 +01:00
parent ec7ed4e590
commit a2fa91584d
8 changed files with 950 additions and 643 deletions
+2 -1
View File
@@ -7,7 +7,7 @@
"types": "dist/index.d.ts",
"repository": "https://github.com/remorses/playwriter",
"scripts": {
"build": "rm -rf dist *.tsbuildinfo && mkdir dist && bun scripts/build-selector-generator.ts && bun scripts/build-bippy.ts && tsc && bun scripts/build-resources.ts",
"build": "rm -rf dist *.tsbuildinfo && mkdir dist && bun scripts/build-selector-generator.ts && bun scripts/build-bippy.ts && bun scripts/build-a11y-client.ts && tsc && bun scripts/build-resources.ts",
"prepublishOnly": "pnpm build",
"watch": "tsc -w",
"cli": "vite-node src/cli.ts",
@@ -46,6 +46,7 @@
"@modelcontextprotocol/sdk": "^1.25.2",
"@xmorse/cac": "^6.0.7",
"diff": "^8.0.2",
"dom-accessibility-api": "^0.7.1",
"hono": "^4.10.6",
"kill-port-process": "^3.2.1",
"picocolors": "^1.1.1",
+39
View File
@@ -0,0 +1,39 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const distDir = path.join(__dirname, '..', 'dist')
const srcDir = path.join(__dirname, '..', 'src')
async function main() {
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true })
}
console.log('Bundling a11y-client...')
const result = await Bun.build({
entrypoints: [path.join(srcDir, 'a11y-client.ts')],
target: 'browser',
format: 'iife',
define: {
'process.env.NODE_ENV': '"development"',
},
})
if (!result.success) {
console.error('Bundle errors:', result.logs)
process.exit(1)
}
const bundledCode = await result.outputs[0].text()
const outputPath = path.join(distDir, 'a11y-client.js')
fs.writeFileSync(outputPath, bundledCode)
console.log(`Saved to ${outputPath} (${Math.round(bundledCode.length / 1024)}kb)`)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
+576
View File
@@ -0,0 +1,576 @@
/**
* Browser-side accessibility snapshot code.
* Bundled and injected into page context via CDP.
* Uses dom-accessibility-api for spec-compliant accessible name computation.
*/
import { computeAccessibleName, getRole } from 'dom-accessibility-api'
// ============================================================================
// Types
// ============================================================================
export interface A11yElement {
ref: string
role: string
name: string
element: Element
}
export interface A11ySnapshotResult {
snapshot: string
labelCount: number
refs: Array<{ ref: string; role: string; name: string }>
}
export interface ComputeSnapshotOptions {
root: Element
interactiveOnly: boolean
renderLabels: boolean
}
// ============================================================================
// Constants
// ============================================================================
const LABELS_CONTAINER_ID = '__playwriter_labels__'
const LABELS_TIMER_KEY = '__playwriter_labels_timer__'
// Interactive roles - elements users can click, type into, or interact with
const INTERACTIVE_ROLES = new Set([
'button',
'link',
'textbox',
'combobox',
'searchbox',
'checkbox',
'radio',
'slider',
'spinbutton',
'switch',
'menuitem',
'menuitemcheckbox',
'menuitemradio',
'option',
'tab',
'treeitem',
// Media elements
'img',
'video',
'audio',
])
// CSS selectors for interactive elements
const INTERACTIVE_SELECTORS = [
'button',
'a[href]',
'input:not([type="hidden"])',
'select',
'textarea',
'[role="button"]',
'[role="link"]',
'[role="checkbox"]',
'[role="radio"]',
'[role="switch"]',
'[role="slider"]',
'[role="spinbutton"]',
'[role="combobox"]',
'[role="searchbox"]',
'[role="textbox"]',
'[role="menuitem"]',
'[role="menuitemcheckbox"]',
'[role="menuitemradio"]',
'[role="option"]',
'[role="tab"]',
'[role="treeitem"]',
'img[alt]',
'img[aria-label]',
'[role="img"]',
'video',
'audio',
// Contenteditable
'[contenteditable="true"]',
'[contenteditable=""]',
].join(', ')
// Color scheme for labels by role
const ROLE_COLORS: Record<string, [string, string, string]> = {
link: ['#FFF785', '#FFC542', '#E3BE23'],
button: ['#FFE0B2', '#FFCC80', '#FFB74D'],
textbox: ['#FFCDD2', '#EF9A9A', '#E57373'],
combobox: ['#FFCDD2', '#EF9A9A', '#E57373'],
searchbox: ['#FFCDD2', '#EF9A9A', '#E57373'],
spinbutton: ['#FFCDD2', '#EF9A9A', '#E57373'],
checkbox: ['#F8BBD0', '#F48FB1', '#EC407A'],
radio: ['#F8BBD0', '#F48FB1', '#EC407A'],
switch: ['#F8BBD0', '#F48FB1', '#EC407A'],
slider: ['#FFCCBC', '#FFAB91', '#FF8A65'],
menuitem: ['#FFAB91', '#FF8A65', '#FF7043'],
menuitemcheckbox: ['#FFAB91', '#FF8A65', '#FF7043'],
menuitemradio: ['#FFAB91', '#FF8A65', '#FF7043'],
tab: ['#FFE082', '#FFD54F', '#FFC107'],
option: ['#FFE082', '#FFD54F', '#FFC107'],
treeitem: ['#FFE082', '#FFD54F', '#FFC107'],
img: ['#B3E5FC', '#81D4FA', '#4FC3F7'],
video: ['#B3E5FC', '#81D4FA', '#4FC3F7'],
audio: ['#B3E5FC', '#81D4FA', '#4FC3F7'],
}
const DEFAULT_COLORS: [string, string, string] = ['#FFF785', '#FFC542', '#E3BE23']
// ============================================================================
// Ref Generation - Prefer stable test IDs
// ============================================================================
// Test ID attributes to check, in priority order
const TEST_ID_ATTRS = [
'data-testid',
'data-test-id',
'data-test',
'data-cy', // Cypress
'data-pw', // Playwright
]
// Patterns that indicate auto-generated/unstable IDs
const UNSTABLE_ID_PATTERNS = [
/^:r[a-z0-9]+:$/i, // React useId() pattern like :r0:, :r1a:
/^radix-/i, // Radix UI
/^headlessui-/i, // Headless UI
/^react-select-/i, // React Select
/^rc-/i, // Ant Design
/^mui-/i, // Material UI
/^\d+$/, // Pure numbers
/^[a-f0-9-]{36}$/i, // UUIDs
/^[a-f0-9]{8,}$/i, // Long hex strings
]
function isStableId(id: string): boolean {
if (!id || id.length < 2) {
return false
}
return !UNSTABLE_ID_PATTERNS.some((pattern) => pattern.test(id))
}
function getStableRef(element: Element): string | null {
// Check test ID attributes first
for (const attr of TEST_ID_ATTRS) {
const value = element.getAttribute(attr)
if (value && value.length > 0) {
return value
}
}
// Check regular id if it looks stable
const id = element.getAttribute('id')
if (id && isStableId(id)) {
return id
}
return null
}
// ============================================================================
// Role Computation
// ============================================================================
function computeRole(element: Element): string {
// First try dom-accessibility-api
const computedRole = getRole(element)
if (computedRole) {
return computedRole
}
// Fallback for common elements
const tagName = element.tagName.toLowerCase()
const type = (element as HTMLInputElement).type?.toLowerCase() || ''
const roleMap: Record<string, string | Record<string, string>> = {
a: (element as HTMLAnchorElement).href ? 'link' : 'generic',
button: 'button',
input: {
button: 'button',
submit: 'button',
reset: 'button',
checkbox: 'checkbox',
radio: 'radio',
text: 'textbox',
email: 'textbox',
password: 'textbox',
search: 'searchbox',
tel: 'textbox',
url: 'textbox',
number: 'spinbutton',
range: 'slider',
},
select: 'combobox',
textarea: 'textbox',
img: 'img',
video: 'video',
audio: 'audio',
}
const mapping = roleMap[tagName]
if (typeof mapping === 'string') {
return mapping
}
if (typeof mapping === 'object' && type in mapping) {
return mapping[type]
}
if (tagName === 'input') {
return 'textbox'
}
return 'generic'
}
// ============================================================================
// Visibility Checks
// ============================================================================
function isElementVisible(element: Element): boolean {
const rect = element.getBoundingClientRect()
// Skip elements with no size
if (rect.width === 0 || rect.height === 0) {
return false
}
// Skip elements outside viewport
if (rect.bottom < 0 || rect.top > window.innerHeight) {
return false
}
if (rect.right < 0 || rect.left > window.innerWidth) {
return false
}
// Check computed style
const style = window.getComputedStyle(element)
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return false
}
return true
}
function isElementCovered(element: Element, rect: DOMRect): boolean {
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
const stack = document.elementsFromPoint(centerX, centerY)
// Find our element in the stack
let targetIndex = -1
for (let i = 0; i < stack.length; i++) {
if (element.contains(stack[i]) || stack[i].contains(element) || stack[i] === element) {
targetIndex = i
break
}
}
if (targetIndex === -1) {
return true // Not found = covered
}
// Check if any opaque element is above our target
for (let i = 0; i < targetIndex; i++) {
const el = stack[i]
if ((el as HTMLElement).id === LABELS_CONTAINER_ID) {
continue
}
const elStyle = window.getComputedStyle(el)
if (elStyle.pointerEvents === 'none') {
continue
}
// Check if element has opaque background
const bgAlpha = parseColorAlpha(elStyle.backgroundColor)
if (bgAlpha > 0.1) {
return true
}
if (elStyle.backgroundImage !== 'none') {
return true
}
}
return false
}
function parseColorAlpha(color: string): number {
if (color === 'transparent') {
return 0
}
const match = color.match(/rgba?\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*(?:,\s*([\d.]+)\s*)?\)/)
if (match) {
return match[1] !== undefined ? parseFloat(match[1]) : 1
}
return 1
}
// ============================================================================
// Label Rendering
// ============================================================================
function renderLabels(elements: A11yElement[]): number {
const doc = document
const win = window as any
// Cancel any pending auto-hide timer
if (win[LABELS_TIMER_KEY]) {
win.clearTimeout(win[LABELS_TIMER_KEY])
win[LABELS_TIMER_KEY] = null
}
// Remove existing labels
doc.getElementById(LABELS_CONTAINER_ID)?.remove()
// Create container
const container = doc.createElement('div')
container.id = LABELS_CONTAINER_ID
container.style.cssText = 'position:absolute;left:0;top:0;z-index:2147483647;pointer-events:none;'
// Inject styles
const style = doc.createElement('style')
style.textContent = `
.__pw_label__ {
position: absolute;
font: bold 12px Helvetica, Arial, sans-serif;
padding: 1px 4px;
border-radius: 3px;
color: black;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.6);
white-space: nowrap;
}
`
container.appendChild(style)
// Create SVG for connector lines
const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.style.cssText = 'position:absolute;left:0;top:0;pointer-events:none;overflow:visible;'
svg.setAttribute('width', `${doc.documentElement.scrollWidth}`)
svg.setAttribute('height', `${doc.documentElement.scrollHeight}`)
// Arrow markers
const defs = doc.createElementNS('http://www.w3.org/2000/svg', 'defs')
svg.appendChild(defs)
const markerCache: Record<string, string> = {}
function getArrowMarkerId(color: string): string {
if (markerCache[color]) {
return markerCache[color]
}
const markerId = `arrow-${color.replace('#', '')}`
const marker = doc.createElementNS('http://www.w3.org/2000/svg', 'marker')
marker.setAttribute('id', markerId)
marker.setAttribute('viewBox', '0 0 10 10')
marker.setAttribute('refX', '9')
marker.setAttribute('refY', '5')
marker.setAttribute('markerWidth', '6')
marker.setAttribute('markerHeight', '6')
marker.setAttribute('orient', 'auto-start-reverse')
const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z')
path.setAttribute('fill', color)
marker.appendChild(path)
defs.appendChild(marker)
markerCache[color] = markerId
return markerId
}
container.appendChild(svg)
// Track placed labels for overlap detection
const placedLabels: Array<{ left: number; top: number; right: number; bottom: number }> = []
const LABEL_HEIGHT = 17
const LABEL_CHAR_WIDTH = 7
let count = 0
for (const { ref, role, element } of elements) {
const rect = element.getBoundingClientRect()
// Skip if covered
if (isElementCovered(element, rect)) {
continue
}
// Calculate label position
const labelWidth = ref.length * LABEL_CHAR_WIDTH + 8
const labelLeft = rect.left
const labelTop = Math.max(0, rect.top - LABEL_HEIGHT)
const labelRect = {
left: labelLeft,
top: labelTop,
right: labelLeft + labelWidth,
bottom: labelTop + LABEL_HEIGHT,
}
// Check overlap
let overlaps = false
for (const placed of placedLabels) {
if (
labelRect.left < placed.right &&
labelRect.right > placed.left &&
labelRect.top < placed.bottom &&
labelRect.bottom > placed.top
) {
overlaps = true
break
}
}
if (overlaps) {
continue
}
// Get colors
const [gradTop, gradBottom, border] = ROLE_COLORS[role] || DEFAULT_COLORS
// Create label
const label = doc.createElement('div')
label.className = '__pw_label__'
label.textContent = ref
label.style.background = `linear-gradient(to bottom, ${gradTop} 0%, ${gradBottom} 100%)`
label.style.border = `1px solid ${border}`
label.style.left = `${win.scrollX + labelLeft}px`
label.style.top = `${win.scrollY + labelTop}px`
container.appendChild(label)
// Draw connector line
const line = doc.createElementNS('http://www.w3.org/2000/svg', 'line')
const labelCenterX = win.scrollX + labelLeft + labelWidth / 2
const labelBottomY = win.scrollY + labelTop + LABEL_HEIGHT
const elementCenterX = win.scrollX + rect.left + rect.width / 2
const elementCenterY = win.scrollY + rect.top + rect.height / 2
line.setAttribute('x1', `${labelCenterX}`)
line.setAttribute('y1', `${labelBottomY}`)
line.setAttribute('x2', `${elementCenterX}`)
line.setAttribute('y2', `${elementCenterY}`)
line.setAttribute('stroke', border)
line.setAttribute('stroke-width', '1.5')
line.setAttribute('marker-end', `url(#${getArrowMarkerId(border)})`)
svg.appendChild(line)
placedLabels.push(labelRect)
count++
}
doc.documentElement.appendChild(container)
// Auto-hide after 30 seconds
win[LABELS_TIMER_KEY] = win.setTimeout(() => {
doc.getElementById(LABELS_CONTAINER_ID)?.remove()
win[LABELS_TIMER_KEY] = null
}, 30000)
return count
}
// ============================================================================
// Snapshot Generation
// ============================================================================
function buildSnapshotLine(role: string, name: string, ref: string, indent: number): string {
const prefix = ' '.repeat(indent)
let line = `${prefix}- ${role}`
if (name) {
// Escape quotes in name
const escapedName = name.replace(/"/g, '\\"')
line += ` "${escapedName}"`
}
line += ` [ref=${ref}]`
return line
}
// ============================================================================
// Main Entry Point
// ============================================================================
export function computeA11ySnapshot(options: ComputeSnapshotOptions): A11ySnapshotResult {
const { root, interactiveOnly, renderLabels: shouldRenderLabels } = options
// Query all interactive elements within root
const elements = root.querySelectorAll(INTERACTIVE_SELECTORS)
// Track refs for deduplication
const refCounts = new Map<string, number>()
const a11yElements: A11yElement[] = []
let fallbackCounter = 0
for (const element of elements) {
// Skip invisible elements
if (!isElementVisible(element)) {
continue
}
// Compute role
const role = computeRole(element)
// Filter to interactive only if requested
if (interactiveOnly && !INTERACTIVE_ROLES.has(role)) {
continue
}
// Compute accessible name
let name = ''
try {
name = computeAccessibleName(element) || ''
} catch {
// Fallback to basic name computation
name =
element.getAttribute('aria-label') ||
element.getAttribute('alt') ||
element.getAttribute('title') ||
(element.textContent || '').trim().slice(0, 100) ||
''
}
// Generate ref - prefer stable test IDs
let baseRef = getStableRef(element)
if (!baseRef) {
fallbackCounter++
baseRef = `e${fallbackCounter}`
}
// Handle duplicates by appending count
const count = refCounts.get(baseRef) || 0
refCounts.set(baseRef, count + 1)
const ref = count === 0 ? baseRef : `${baseRef}-${count + 1}`
a11yElements.push({ ref, role, name, element })
}
// Build snapshot string
const snapshotLines = a11yElements.map(({ ref, role, name }) => {
return buildSnapshotLine(role, name, ref, 0)
})
const snapshot = snapshotLines.join('\n')
// Render labels if requested
let labelCount = 0
if (shouldRenderLabels) {
labelCount = renderLabels(a11yElements)
}
// Return refs without element references (can't serialize DOM elements)
const refs = a11yElements.map(({ ref, role, name }) => ({ ref, role, name }))
return { snapshot, labelCount, refs }
}
// ============================================================================
// Hide Labels
// ============================================================================
export function hideA11yLabels(): void {
const win = window as any
if (win[LABELS_TIMER_KEY]) {
win.clearTimeout(win[LABELS_TIMER_KEY])
win[LABELS_TIMER_KEY] = null
}
document.getElementById(LABELS_CONTAINER_ID)?.remove()
}
// ============================================================================
// Expose on globalThis for injection
// ============================================================================
;(globalThis as any).__a11y = {
computeA11ySnapshot,
hideA11yLabels,
}
+230 -536
View File
@@ -1,6 +1,7 @@
import type { Page, Locator, ElementHandle } from 'playwright-core'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// Import sharp at module level - resolves to null if not available
const sharpPromise = import('sharp')
@@ -11,27 +12,22 @@ const sharpPromise = import('sharp')
// Aria Snapshot Format Documentation
// ============================================================================
//
// This module processes accessibility snapshots from Playwright's _snapshotForAI() method.
// The expected format is a YAML-like tree structure:
// This module generates accessibility snapshots using dom-accessibility-api
// running entirely in browser context. The output format is:
//
// ```
// - role "accessible name" [ref=eXX] [cursor=pointer] [active]:
// - childRole "child name" [ref=eYY]:
// - /url: https://example.com
// - text: "raw text content"
// - role "accessible name" [ref=testid-or-e1]
// - button "Submit" [ref=submit-btn]
// - link "Home" [ref=nav-home]
// - textbox "Search" [ref=e3]
// ```
//
// 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"
// Refs are generated from stable test IDs when available:
// - data-testid, data-test-id, data-test, data-cy, data-pw
// - Stable id attributes (excluding auto-generated ones)
// - Fallback: e1, e2, e3...
//
// If Playwright changes this format, the parsing functions below will need updates.
// Duplicate refs get a suffix: submit-btn, submit-btn-2, submit-btn-3
// ============================================================================
// ============================================================================
@@ -208,7 +204,8 @@ function extractInteractiveFlat(lines: string[], interactiveRoles: Set<string>,
continue
}
const match = trimmed.match(/^-\s+(\w+)(?:\s+"[^"]*")?(?:\s+\[[^\]]+\])*\s*\[ref=(\w+)\]/)
// Match ref pattern - now supports any ref format (not just e123)
const match = trimmed.match(/^-\s+(\w+)(?:\s+"[^"]*")?(?:\s+\[[^\]]+\])*\s*\[ref=([^\]]+)\]/)
if (!match || !interactiveRoles.has(match[1])) {
continue
}
@@ -294,7 +291,7 @@ function extractInteractiveWithStructure(
// Clean line and strip refs from non-interactive
let cleanedLine = cleanSnapshotLine(lines[i])
if (!lineIsInteractive[i]) {
cleanedLine = cleanedLine.replace(/\s*\[ref=\w+\]/g, '')
cleanedLine = cleanedLine.replace(/\s*\[ref=[^\]]+\]/g, '')
}
result.push(cleanedLine)
@@ -422,7 +419,8 @@ function parseSnapshotLine(line: string): { role: string; name: string | null; r
}
// Match: - role "name" [ref=xxx]: or - role [ref=xxx]: or - role "name": or - role:
const match = trimmed.match(/^-\s+(\w+)(?:\s+"([^"]*)")?(?:\s+\[ref=(\w+)\])?/)
// Updated to support any ref format (not just e123)
const match = trimmed.match(/^-\s+(\w+)(?:\s+"([^"]*)")?(?:\s+\[ref=([^\]]+)\])?/)
if (!match) {
return { role: '', name: null, ref: null }
@@ -525,7 +523,31 @@ function rebuildLines(node: SnapshotNode, result: string[]): void {
}
// ============================================================================
// Original Aria Snapshot Types and Functions
// A11y Client Code Loading
// ============================================================================
let a11yClientCode: string | null = null
function getA11yClientCode(): string {
if (a11yClientCode) {
return a11yClientCode
}
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const a11yClientPath = path.join(currentDir, '..', 'dist', 'a11y-client.js')
a11yClientCode = fs.readFileSync(a11yClientPath, 'utf-8')
return a11yClientCode
}
async function ensureA11yClient(page: Page): Promise<void> {
const hasA11y = await page.evaluate(() => !!(globalThis as any).__a11y)
if (!hasA11y) {
const code = getA11yClientCode()
await page.evaluate(code)
}
}
// ============================================================================
// Types
// ============================================================================
export interface AriaRef {
@@ -545,27 +567,18 @@ export interface ScreenshotResult {
export interface AriaSnapshotResult {
snapshot: string
refToElement: Map<string, { role: string; name: string }>
refHandles: Array<{ ref: string; handle: ElementHandle }>
/**
* Get a CSS selector for a ref. Use with page.locator().
* For stable test IDs, returns [data-testid="..."] or [id="..."]
* For fallback refs (e1, e2), returns a role-based selector.
*/
getSelectorForRef: (ref: string) => string | null
getRefsForLocators: (locators: Array<Locator | ElementHandle>) => Promise<Array<AriaRef | null>>
getRefForLocator: (locator: Locator | ElementHandle) => Promise<AriaRef | null>
getRefStringForLocator: (locator: Locator | ElementHandle) => Promise<string | null>
}
interface ElementLike {
tagName: string
textContent: string | null
getAttribute: (name: string) => string | null
hasAttribute: (name: string) => boolean
}
interface InputElementLike extends ElementLike {
type?: string
placeholder?: string
}
const LABELS_CONTAINER_ID = '__playwriter_labels__'
// Roles that represent interactive elements (clickable, typeable) and media elements
// Roles that represent interactive elements
const INTERACTIVE_ROLES = new Set([
'button',
'link',
@@ -583,214 +596,117 @@ const INTERACTIVE_ROLES = new Set([
'option',
'tab',
'treeitem',
// Media elements - useful for visual tasks
'img',
'video',
'audio',
])
// Color categories for different role types - warm color scheme
// Format: [gradient-top, gradient-bottom, border]
const ROLE_COLORS: Record<string, [string, string, string]> = {
// Links - yellow (Vimium-style)
link: ['#FFF785', '#FFC542', '#E3BE23'],
// Buttons - orange
button: ['#FFE0B2', '#FFCC80', '#FFB74D'],
// Text inputs - coral/red
textbox: ['#FFCDD2', '#EF9A9A', '#E57373'],
combobox: ['#FFCDD2', '#EF9A9A', '#E57373'],
searchbox: ['#FFCDD2', '#EF9A9A', '#E57373'],
spinbutton: ['#FFCDD2', '#EF9A9A', '#E57373'],
// Checkboxes/Radios/Switches - warm pink
checkbox: ['#F8BBD0', '#F48FB1', '#EC407A'],
radio: ['#F8BBD0', '#F48FB1', '#EC407A'],
switch: ['#F8BBD0', '#F48FB1', '#EC407A'],
// Sliders - peach
slider: ['#FFCCBC', '#FFAB91', '#FF8A65'],
// Menu items - salmon
menuitem: ['#FFAB91', '#FF8A65', '#FF7043'],
menuitemcheckbox: ['#FFAB91', '#FF8A65', '#FF7043'],
menuitemradio: ['#FFAB91', '#FF8A65', '#FF7043'],
// Tabs/Options - amber
tab: ['#FFE082', '#FFD54F', '#FFC107'],
option: ['#FFE082', '#FFD54F', '#FFC107'],
treeitem: ['#FFE082', '#FFD54F', '#FFC107'],
// Media elements - light blue
img: ['#B3E5FC', '#81D4FA', '#4FC3F7'],
video: ['#B3E5FC', '#81D4FA', '#4FC3F7'],
audio: ['#B3E5FC', '#81D4FA', '#4FC3F7'],
}
// Default yellow for unknown roles
const DEFAULT_COLORS: [string, string, string] = ['#FFF785', '#FFC542', '#E3BE23']
// Use String.raw for CSS syntax highlighting in editors
const css = String.raw
const LABEL_STYLES = css`
.__pw_label__ {
position: absolute;
font: bold 12px Helvetica, Arial, sans-serif;
padding: 1px 4px;
border-radius: 3px;
color: black;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.6);
white-space: nowrap;
}
`
const CONTAINER_STYLES = css`
position: absolute;
left: 0;
top: 0;
z-index: 2147483647;
pointer-events: none;
`
// ============================================================================
// Main Functions
// ============================================================================
/**
* Get an accessibility snapshot with utilities to look up aria refs for elements.
* Uses Playwright's internal aria-ref selector engine.
* Get an accessibility snapshot with utilities to look up refs for elements.
* Uses dom-accessibility-api running entirely in browser context.
*
* Refs are generated from stable test IDs when available (data-testid, data-test-id, etc.)
* or fall back to e1, e2, e3...
*
* @param page - Playwright page
* @param locator - Optional locator to scope the snapshot to a subtree
* @param refFilter - Optional filter for which elements get refs
*
* @example
* ```ts
* const { snapshot, getRefsForLocators } = await getAriaSnapshot({ page })
* const refs = await getRefsForLocators([page.locator('button'), page.locator('a')])
* // refs[0].ref is e.g. "e5" - use page.locator('aria-ref=e5') to select
* const { snapshot, getSelectorForRef } = await getAriaSnapshot({ page })
* // Snapshot shows refs like [ref=submit-btn] or [ref=e5]
* const selector = getSelectorForRef('submit-btn')
* await page.locator(selector).click()
* ```
*/
export async function getAriaSnapshot({ page, refFilter }: {
export async function getAriaSnapshot({ page, locator, refFilter }: {
page: Page
locator?: Locator
refFilter?: (info: { role: string; name: string }) => boolean
}): Promise<AriaSnapshotResult> {
const snapshotMethod = (page as any)._snapshotForAI
if (!snapshotMethod) {
throw new Error('_snapshotForAI not available. Ensure you are using Playwright.')
}
await ensureA11yClient(page)
const snapshot = await snapshotMethod.call(page)
// Sanitize to remove unpaired surrogates that break JSON encoding for Claude API
const rawStr = typeof snapshot === 'string' ? snapshot : (snapshot.full || JSON.stringify(snapshot, null, 2))
const snapshotStr = rawStr.toWellFormed?.() ?? rawStr
// Determine root element
const rootHandle = locator ? await locator.elementHandle() : null
const snapshotRefInfo = extractRefInfoFromSnapshot(snapshotStr)
const snapshotRefs = [...snapshotRefInfo.entries()]
.filter(([_, info]) => !refFilter || refFilter(info))
.map(([ref]) => ref)
const result = await page.evaluate(
({ root, interactiveOnly }) => {
const a11y = (globalThis as any).__a11y
if (!a11y) {
throw new Error('a11y client not loaded')
}
const rootElement = root || document.body
return a11y.computeA11ySnapshot({
root: rootElement,
interactiveOnly,
renderLabels: false,
})
},
{
root: rootHandle,
interactiveOnly: !!refFilter,
}
)
// Discover refs by probing aria-ref=e1, e2, e3... until 10 consecutive misses
// Build refToElement map
const refToElement = new Map<string, { role: string; name: string }>()
const refHandles: Array<{ ref: string; handle: ElementHandle }> = []
for (const { ref, role, name } of result.refs) {
if (!refFilter || refFilter({ role, name })) {
refToElement.set(ref, { role, name })
}
}
const fetchRefInfo = async (ref: string): Promise<{ ref: string; handle: ElementHandle; info: { role: string; name: string } } | null> => {
try {
const locator = page.locator(`aria-ref=${ref}`)
const handle = await locator.elementHandle({ timeout: 1000 })
if (!handle) {
return null
// Filter snapshot if refFilter provided
let snapshot = result.snapshot
if (refFilter) {
const lines = snapshot.split('\n').filter((line) => {
const match = line.match(/\[ref=([^\]]+)\]/)
if (!match) {
return true
}
const info = await handle.evaluate((el) => {
const element = el as ElementLike
const tagName = element.tagName.toLowerCase()
const roleAttribute = element.getAttribute('role')
const inputElement = element as InputElementLike
const inputType = inputElement.type || ''
const placeholder = inputElement.placeholder || ''
const role = roleAttribute || {
a: element.hasAttribute('href') ? 'link' : 'generic',
button: 'button',
input: {
button: 'button',
checkbox: 'checkbox',
radio: 'radio',
text: 'textbox',
search: 'searchbox',
number: 'spinbutton',
range: 'slider',
}[inputType] || 'textbox',
select: 'combobox',
textarea: 'textbox',
img: 'img',
nav: 'navigation',
main: 'main',
header: 'banner',
footer: 'contentinfo',
}[tagName] || 'generic'
const name = element.getAttribute('aria-label') || element.textContent?.trim() || placeholder || ''
return { role, name }
})
return { ref, handle, info }
} catch {
const ref = match[1]
return refToElement.has(ref)
})
snapshot = lines.join('\n')
}
/**
* Get a CSS selector for a ref.
* For stable test IDs: [data-testid="value"] or [id="value"]
* For fallback refs: uses role + name matching
*/
const getSelectorForRef = (ref: string): string | null => {
const info = refToElement.get(ref)
if (!info) {
return null
}
}
const fetchRefHandle = async (ref: string): Promise<{ ref: string; handle: ElementHandle } | null> => {
try {
const locator = page.locator(`aria-ref=${ref}`)
const handle = await locator.elementHandle({ timeout: 1000 })
if (!handle) {
return null
}
return { ref, handle }
} catch {
return null
// Check if ref looks like a stable test ID (not e1, e2, etc.)
if (!/^e\d+$/.test(ref)) {
// Try common test ID attributes
return `[data-testid="${ref}"], [data-test-id="${ref}"], [data-test="${ref}"], [data-cy="${ref}"], [data-pw="${ref}"], [id="${ref}"]`
}
// For fallback refs, use role-based selector
// This is less reliable but works for simple cases
const escapedName = info.name.replace(/"/g, '\\"')
return `[role="${info.role}"][aria-label="${escapedName}"], ${info.role}:has-text("${escapedName}")`
}
const addRefInfo = ({ ref, handle, info }: { ref: string; handle: ElementHandle; info: { role: string; name: string } }) => {
refToElement.set(ref, info)
refHandles.push({ ref, handle })
}
const probeRefsSequentially = async () => {
let consecutiveMisses = 0
let refNum = 1
while (consecutiveMisses < 10) {
const ref = `e${refNum++}`
const result = await fetchRefInfo(ref)
if (!result) {
consecutiveMisses++
continue
}
consecutiveMisses = 0
addRefInfo(result)
}
}
const probeRefsFromSnapshot = async (refs: string[]) => {
const concurrency = 20
const chunks = refs.reduce((acc, ref, index) => {
const chunkIndex = Math.floor(index / concurrency)
if (!acc[chunkIndex]) {
acc[chunkIndex] = []
}
acc[chunkIndex].push(ref)
return acc
}, [] as string[][])
await chunks.reduce(async (previous, chunk) => {
await previous
const results = await Promise.all(chunk.map(async (ref) => fetchRefHandle(ref)))
const successfulResults = results.filter((result): result is { ref: string; handle: ElementHandle } => result !== null)
successfulResults.map((result) => {
const info = snapshotRefInfo.get(result.ref) || { role: 'generic', name: '' }
addRefInfo({ ...result, info })
})
}, Promise.resolve())
}
if (snapshotRefs.length > 0) {
await probeRefsFromSnapshot(snapshotRefs)
} else {
await probeRefsSequentially()
}
// Find refs for multiple locators in a single evaluate call
/**
* Find refs for locators by matching in browser context.
*/
const getRefsForLocators = async (locators: Array<Locator | ElementHandle>): Promise<Array<AriaRef | null>> => {
if (locators.length === 0 || refHandles.length === 0) {
return locators.map(() => null)
if (locators.length === 0) {
return []
}
// Get handles for target locators
const targetHandles = await Promise.all(
locators.map(async (loc) => {
try {
@@ -803,379 +719,151 @@ export async function getAriaSnapshot({ page, refFilter }: {
})
)
// Match in browser context
const matchingRefs = await page.evaluate(
({ targets, candidates }) => targets.map((target) => {
if (!target) return null
return candidates.find(({ element }) => element === target)?.ref ?? null
}),
{ targets: targetHandles, candidates: refHandles.map(({ ref, handle }) => ({ ref, element: handle })) }
({ targets, refData }) => {
return targets.map((target) => {
if (!target) {
return null
}
// Try to find this element's ref by checking test ID attributes
const testIdAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-pw']
for (const attr of testIdAttrs) {
const value = (target as any).getAttribute(attr)
if (value && refData.some((r: any) => r.ref === value || r.ref.startsWith(value))) {
const match = refData.find((r: any) => r.ref === value || r.ref.startsWith(value))
return match ? match.ref : null
}
}
// Check id attribute
const id = (target as any).getAttribute('id')
if (id) {
const match = refData.find((r: any) => r.ref === id || r.ref.startsWith(id))
if (match) {
return match.ref
}
}
return null
})
},
{
targets: targetHandles,
refData: result.refs,
}
)
return matchingRefs.map((ref) => {
if (!ref) return null
if (!ref) {
return null
}
const info = refToElement.get(ref)
return info ? { ...info, ref } : null
})
}
return {
snapshot: snapshotStr,
snapshot,
refToElement,
refHandles,
getSelectorForRef,
getRefsForLocators,
getRefForLocator: async (loc) => (await getRefsForLocators([loc]))[0],
getRefStringForLocator: async (loc) => (await getRefsForLocators([loc]))[0]?.ref ?? null,
}
}
function extractRefInfoFromSnapshot(snapshot: string): Map<string, { role: string; name: string }> {
const lines = snapshot.split('\n')
return lines.reduce((map, line) => {
if (!line.trim()) {
return map
}
const parsed = parseSnapshotLine(line)
if (!parsed.ref || map.has(parsed.ref)) {
return map
}
map.set(parsed.ref, { role: parsed.role, name: parsed.name ?? '' })
return map
}, new Map<string, { role: string; name: string }>())
}
/**
* Show Vimium-style labels on interactive elements.
* Labels are yellow badges positioned above each element showing the aria ref (e.g., "e1", "e2").
* Labels are colored badges positioned above each element showing the ref.
* Use with screenshots so agents can see which elements are interactive.
*
* Labels auto-hide after 30 seconds to prevent stale labels remaining on the page.
* Labels auto-hide after 30 seconds to prevent stale labels.
* Call this function again if the page HTML changes to get fresh labels.
*
* By default, only shows labels for truly interactive roles (button, link, textbox, etc.)
* to reduce visual clutter. Set `interactiveOnly: false` to show all elements with refs.
* @param page - Playwright page
* @param locator - Optional locator to scope labels to a subtree
* @param interactiveOnly - Only show labels for interactive elements (default: true)
*
* @example
* ```ts
* const { snapshot, labelCount } = await showAriaRefLabels({ page })
* await page.screenshot({ path: '/tmp/screenshot.png' })
* // Agent sees [e5] label on "Submit" button
* await page.locator('aria-ref=e5').click()
* // Labels auto-hide after 30 seconds, or call hideAriaRefLabels() manually
* // Agent sees [submit-btn] label on "Submit" button
* await page.locator('[data-testid="submit-btn"]').click()
* ```
*/
export async function showAriaRefLabels({ page, interactiveOnly = true, logger }: {
export async function showAriaRefLabels({ page, locator, interactiveOnly = true, logger }: {
page: Page
locator?: Locator
interactiveOnly?: boolean
logger?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }
}): Promise<{
snapshot: string
labelCount: number
}> {
const getSnapshotStart = Date.now()
const { snapshot, refHandles, refToElement } = await getAriaSnapshot({
page,
refFilter: interactiveOnly ? (info) => { return INTERACTIVE_ROLES.has(info.role) } : undefined,
})
const startTime = Date.now()
await ensureA11yClient(page)
const log = logger?.info ?? logger?.error
if (log) {
log(`getAriaSnapshot: ${Date.now() - getSnapshotStart}ms`)
log(`ensureA11yClient: ${Date.now() - startTime}ms`)
}
// Filter to only interactive elements if requested
const filteredRefs = refHandles
// Determine root element
const rootHandle = locator ? await locator.elementHandle() : null
// Build refs with role info for color coding
const refsWithRoles = filteredRefs.map(({ ref, handle }) => ({
ref,
element: handle,
role: refToElement.get(ref)?.role || 'generic',
}))
// Single evaluate call: create container, styles, and all labels
// ElementHandles get unwrapped to DOM elements in browser context
const labelCount = await page.evaluate(
// Using 'any' for browser types since this runs in browser context
function ({ refs, containerId, containerStyles, labelStyles, roleColors, defaultColors }: {
refs: Array<{
ref: string
role: string
element: any // Element in browser context
}>
containerId: string
containerStyles: string
labelStyles: string
roleColors: Record<string, [string, string, string]>
defaultColors: [string, string, string]
}) {
// Polyfill esbuild's __name helper which gets injected by vite-node but doesn't exist in browser
;(globalThis as any).__name ||= (fn: any) => fn
const doc = (globalThis as any).document
const win = globalThis as any
// Cancel any pending auto-hide timer from previous call
const timerKey = '__playwriter_labels_timer__'
if (win[timerKey]) {
win.clearTimeout(win[timerKey])
win[timerKey] = null
const computeStart = Date.now()
const result = await page.evaluate(
({ root, interactiveOnly: intOnly }) => {
const a11y = (globalThis as any).__a11y
if (!a11y) {
throw new Error('a11y client not loaded')
}
// Remove existing labels if present (idempotent)
doc.getElementById(containerId)?.remove()
// Create container - absolute positioned, max z-index, no pointer events
const container = doc.createElement('div')
container.id = containerId
container.style.cssText = containerStyles
// Inject base label CSS
const style = doc.createElement('style')
style.textContent = labelStyles
container.appendChild(style)
// Track placed label rectangles for overlap detection
// Each rect is { left, top, right, bottom } in viewport coordinates
const placedLabels: Array<{ left: number; top: number; right: number; bottom: number }> = []
// Estimate label dimensions (12px font + padding)
const LABEL_HEIGHT = 17
const LABEL_CHAR_WIDTH = 7 // approximate width per character
// Parse alpha from rgb/rgba color string (getComputedStyle always returns these formats)
function getColorAlpha(color: string): number {
if (color === 'transparent') {
return 0
}
// Match rgba(r, g, b, a) or rgb(r, g, b)
const match = color.match(/rgba?\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*(?:,\s*([\d.]+)\s*)?\)/)
if (match) {
return match[1] !== undefined ? parseFloat(match[1]) : 1
}
return 1 // Default to opaque for unrecognized formats
}
// Check if an element has an opaque background that would block elements behind it
function isOpaqueElement(el: any): boolean {
const style = win.getComputedStyle(el)
// Check element opacity
const opacity = parseFloat(style.opacity)
if (opacity < 0.1) {
return false
}
// Check background-color alpha
const bgAlpha = getColorAlpha(style.backgroundColor)
if (bgAlpha > 0.1) {
return true
}
// Check if has background-image (usually opaque)
if (style.backgroundImage !== 'none') {
return true
}
return false
}
// Check if element is visible (not covered by opaque overlay)
function isElementVisible(element: any, rect: any): boolean {
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
// Get all elements at this point, from top to bottom
const stack = doc.elementsFromPoint(centerX, centerY) as any[]
// Find our target element in the stack
let targetIndex = -1
for (let i = 0; i < stack.length; i++) {
if (element.contains(stack[i]) || stack[i].contains(element)) {
targetIndex = i
break
}
}
// Element not in stack at all - not visible
if (targetIndex === -1) {
return false
}
// Check if any opaque element is above our target
for (let i = 0; i < targetIndex; i++) {
const el = stack[i]
// Skip our own overlay container
if (el.id === containerId) {
continue
}
// Skip pointer-events: none elements (decorative overlays)
if (win.getComputedStyle(el).pointerEvents === 'none') {
continue
}
// If this element is opaque, our target is blocked
if (isOpaqueElement(el)) {
return false
}
}
return true
}
// Check if two rectangles overlap
function rectsOverlap(
a: { left: number; top: number; right: number; bottom: number },
b: { left: number; top: number; right: number; bottom: number }
) {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
}
// Create SVG for connector lines
const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.style.cssText = 'position:absolute;left:0;top:0;pointer-events:none;overflow:visible;'
svg.setAttribute('width', `${doc.documentElement.scrollWidth}`)
svg.setAttribute('height', `${doc.documentElement.scrollHeight}`)
// Create defs for arrow markers (one per color)
const defs = doc.createElementNS('http://www.w3.org/2000/svg', 'defs')
svg.appendChild(defs)
const markerCache: Record<string, string> = {}
function getArrowMarkerId(color: string): string {
if (markerCache[color]) {
return markerCache[color]
}
const markerId = `arrow-${color.replace('#', '')}`
const marker = doc.createElementNS('http://www.w3.org/2000/svg', 'marker')
marker.setAttribute('id', markerId)
marker.setAttribute('viewBox', '0 0 10 10')
marker.setAttribute('refX', '9')
marker.setAttribute('refY', '5')
marker.setAttribute('markerWidth', '6')
marker.setAttribute('markerHeight', '6')
marker.setAttribute('orient', 'auto-start-reverse')
const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z')
path.setAttribute('fill', color)
marker.appendChild(path)
defs.appendChild(marker)
markerCache[color] = markerId
return markerId
}
container.appendChild(svg)
// Create label for each interactive element
let count = 0
for (const { ref, role, element } of refs) {
const rect = element.getBoundingClientRect()
// Skip elements with no size (hidden)
if (rect.width === 0 || rect.height === 0) {
continue
}
// Skip elements that are covered by opaque overlays
if (!isElementVisible(element, rect)) {
continue
}
// Calculate label position and dimensions
const labelWidth = ref.length * LABEL_CHAR_WIDTH + 8 // +8 for padding
const labelLeft = rect.left
const labelTop = Math.max(0, rect.top - LABEL_HEIGHT)
const labelRect = {
left: labelLeft,
top: labelTop,
right: labelLeft + labelWidth,
bottom: labelTop + LABEL_HEIGHT,
}
// Skip if this label would overlap with any already-placed label
let overlaps = false
for (const placed of placedLabels) {
if (rectsOverlap(labelRect, placed)) {
overlaps = true
break
}
}
if (overlaps) {
continue
}
// Get colors for this role
const [gradTop, gradBottom, border] = roleColors[role] || defaultColors
// Place the label
const label = doc.createElement('div')
label.className = '__pw_label__'
label.textContent = ref
label.style.background = `linear-gradient(to bottom, ${gradTop} 0%, ${gradBottom} 100%)`
label.style.border = `1px solid ${border}`
// Position above element, accounting for scroll
label.style.left = `${win.scrollX + labelLeft}px`
label.style.top = `${win.scrollY + labelTop}px`
container.appendChild(label)
// Draw connector line from label bottom-center to element center with arrow
const line = doc.createElementNS('http://www.w3.org/2000/svg', 'line')
const labelCenterX = win.scrollX + labelLeft + labelWidth / 2
const labelBottomY = win.scrollY + labelTop + LABEL_HEIGHT
const elementCenterX = win.scrollX + rect.left + rect.width / 2
const elementCenterY = win.scrollY + rect.top + rect.height / 2
line.setAttribute('x1', `${labelCenterX}`)
line.setAttribute('y1', `${labelBottomY}`)
line.setAttribute('x2', `${elementCenterX}`)
line.setAttribute('y2', `${elementCenterY}`)
line.setAttribute('stroke', border)
line.setAttribute('stroke-width', '1.5')
line.setAttribute('marker-end', `url(#${getArrowMarkerId(border)})`)
svg.appendChild(line)
placedLabels.push(labelRect)
count++
}
doc.documentElement.appendChild(container)
// Auto-hide labels after 30 seconds to prevent stale labels
// Store timer ID so it can be cancelled if showAriaRefLabels is called again
win[timerKey] = win.setTimeout(function() {
doc.getElementById(containerId)?.remove()
win[timerKey] = null
}, 30000)
return count
const rootElement = root || document.body
return a11y.computeA11ySnapshot({
root: rootElement,
interactiveOnly: intOnly,
renderLabels: true,
})
},
{
refs: refsWithRoles.map(({ ref, role, element }) => ({ ref, role, element })),
containerId: LABELS_CONTAINER_ID,
containerStyles: CONTAINER_STYLES,
labelStyles: LABEL_STYLES,
roleColors: ROLE_COLORS,
defaultColors: DEFAULT_COLORS,
root: rootHandle,
interactiveOnly,
}
)
return { snapshot, labelCount }
if (log) {
log(`computeA11ySnapshot: ${Date.now() - computeStart}ms (${result.labelCount} labels)`)
}
return {
snapshot: result.snapshot,
labelCount: result.labelCount,
}
}
/**
* Remove all aria ref labels from the page.
*/
export async function hideAriaRefLabels({ page }: { page: Page }): Promise<void> {
await page.evaluate((id) => {
const doc = (globalThis as any).document
const win = globalThis as any
// Cancel any pending auto-hide timer
const timerKey = '__playwriter_labels_timer__'
if (win[timerKey]) {
win.clearTimeout(win[timerKey])
win[timerKey] = null
await page.evaluate(() => {
const a11y = (globalThis as any).__a11y
if (a11y) {
a11y.hideA11yLabels()
} else {
// Fallback if client not loaded
const doc = document
const win = window as any
const timerKey = '__playwriter_labels_timer__'
if (win[timerKey]) {
win.clearTimeout(win[timerKey])
win[timerKey] = null
}
doc.getElementById('__playwriter_labels__')?.remove()
}
doc.getElementById(id)?.remove()
}, LABELS_CONTAINER_ID)
})
}
/**
@@ -1183,24 +871,27 @@ export async function hideAriaRefLabels({ page }: { page: Page }): Promise<void>
* Shows Vimium-style labels, captures the screenshot, then removes the labels.
* The screenshot is automatically included in the MCP response.
*
* @param page - Playwright page
* @param locator - Optional locator to scope labels to a subtree
* @param collector - Array to collect screenshots (passed by MCP execute tool)
*
* @example
* ```ts
* await screenshotWithAccessibilityLabels({ page })
* // Screenshot is automatically included in the MCP response
* // Use aria-ref from the snapshot to interact with elements
* await page.locator('aria-ref=e5').click()
* // Use ref from the snapshot to interact with elements
* await page.locator('[data-testid="submit-btn"]').click()
* ```
*/
export async function screenshotWithAccessibilityLabels({ page, interactiveOnly = true, collector, logger }: {
export async function screenshotWithAccessibilityLabels({ page, locator, interactiveOnly = true, collector, logger }: {
page: Page
locator?: Locator
interactiveOnly?: boolean
collector: ScreenshotResult[]
logger?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }
}): Promise<void> {
const showLabelsStart = Date.now()
const { snapshot, labelCount } = await showAriaRefLabels({ page, interactiveOnly, logger })
const { snapshot, labelCount } = await showAriaRefLabels({ page, locator, interactiveOnly, logger })
const log = logger?.info ?? logger?.error
if (log) {
log(`showAriaRefLabels: ${Date.now() - showLabelsStart}ms`)
@@ -1280,3 +971,6 @@ export async function screenshotWithAccessibilityLabels({ page, interactiveOnly
labelCount,
})
}
// Re-export for backward compatibility
export { getAriaSnapshot as getAriaSnapshotWithRefs }
+62 -62
View File
@@ -3,7 +3,7 @@
* Used by both MCP and CLI to execute Playwright code with persistent state.
*/
import { Page, Browser, BrowserContext, chromium } from 'playwright-core'
import { Page, Browser, BrowserContext, chromium, Locator } from 'playwright-core'
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
@@ -21,7 +21,7 @@ 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, formatSnapshot, DEFAULT_SNAPSHOT_FORMAT, type ScreenshotResult, type SnapshotFormat } from './aria-snapshot.js'
import { screenshotWithAccessibilityLabels, formatSnapshot, DEFAULT_SNAPSHOT_FORMAT, getAriaSnapshot, type ScreenshotResult, type SnapshotFormat } from './aria-snapshot.js'
export type { SnapshotFormat }
import { getCleanHTML, type GetCleanHTMLOptions } from './clean-html.js'
import { startRecording, stopRecording, isRecording, cancelRecording } from './screen-recording.js'
@@ -406,75 +406,75 @@ export class PlaywrightExecutor {
const accessibilitySnapshot = async (options: {
page: Page
/** Optional locator to scope the snapshot to a subtree */
locator?: Locator
search?: string | RegExp
showDiffSinceLastCall?: boolean
/** Snapshot format: 'raw', 'compact', 'interactive', 'interactive-dedup' (default) */
format?: SnapshotFormat
}) => {
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 sanitizedStr = rawStr.toWellFormed?.() ?? rawStr
// Apply format transformation
const snapshotStr = formatSnapshot(sanitizedStr, format, this.logger)
const { page: targetPage, locator, search, showDiffSinceLastCall = false, format = DEFAULT_SNAPSHOT_FORMAT } = options
// Use new in-page implementation via getAriaSnapshot
const { snapshot: rawSnapshot } = await getAriaSnapshot({ page: targetPage, locator })
const sanitizedStr = rawSnapshot.toWellFormed?.() ?? rawSnapshot
// Apply format transformation
const snapshotStr = formatSnapshot(sanitizedStr, format, this.logger)
if (showDiffSinceLastCall) {
const previousSnapshot = this.lastSnapshots.get(targetPage)
if (!previousSnapshot) {
this.lastSnapshots.set(targetPage, snapshotStr)
return 'No previous snapshot available. This is the first call for this page. Full snapshot stored for next diff.'
}
const patch = createPatch('snapshot', previousSnapshot, snapshotStr, 'previous', 'current', { context: 3 })
if (patch.split('\n').length <= 4) {
return 'No changes detected since last snapshot'
}
return patch
if (showDiffSinceLastCall) {
const previousSnapshot = this.lastSnapshots.get(targetPage)
if (!previousSnapshot) {
this.lastSnapshots.set(targetPage, snapshotStr)
return 'No previous snapshot available. This is the first call for this page. Full snapshot stored for next diff.'
}
this.lastSnapshots.set(targetPage, snapshotStr)
if (!search) {
return snapshotStr
const patch = createPatch('snapshot', previousSnapshot, snapshotStr, 'previous', 'current', { context: 3 })
if (patch.split('\n').length <= 4) {
return 'No changes detected since last snapshot'
}
const lines = snapshotStr.split('\n')
const matchIndices: number[] = []
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const isMatch = isRegExp(search) ? search.test(line) : line.includes(search)
if (isMatch) {
matchIndices.push(i)
if (matchIndices.length >= 10) break
}
}
if (matchIndices.length === 0) {
return 'No matches found'
}
const CONTEXT_LINES = 5
const includedLines = new Set<number>()
for (const idx of matchIndices) {
const start = Math.max(0, idx - CONTEXT_LINES)
const end = Math.min(lines.length - 1, idx + CONTEXT_LINES)
for (let i = start; i <= end; i++) {
includedLines.add(i)
}
}
const sortedIndices = [...includedLines].sort((a, b) => a - b)
const result: string[] = []
for (let i = 0; i < sortedIndices.length; i++) {
const lineIdx = sortedIndices[i]
if (i > 0 && sortedIndices[i - 1] !== lineIdx - 1) {
result.push('---')
}
result.push(lines[lineIdx])
}
return result.join('\n')
return patch
}
throw new Error('accessibilitySnapshot is not available on this page')
this.lastSnapshots.set(targetPage, snapshotStr)
if (!search) {
return snapshotStr
}
const lines = snapshotStr.split('\n')
const matchIndices: number[] = []
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const isMatch = isRegExp(search) ? search.test(line) : line.includes(search)
if (isMatch) {
matchIndices.push(i)
if (matchIndices.length >= 10) break
}
}
if (matchIndices.length === 0) {
return 'No matches found'
}
const CONTEXT_LINES = 5
const includedLines = new Set<number>()
for (const idx of matchIndices) {
const start = Math.max(0, idx - CONTEXT_LINES)
const end = Math.min(lines.length - 1, idx + CONTEXT_LINES)
for (let i = start; i <= end; i++) {
includedLines.add(i)
}
}
const sortedIndices = [...includedLines].sort((a, b) => a - b)
const result: string[] = []
for (let i = 0; i < sortedIndices.length; i++) {
const lineIdx = sortedIndices[i]
if (i > 0 && sortedIndices[i - 1] !== lineIdx - 1) {
result.push('---')
}
result.push(lines[lineIdx])
}
return result.join('\n')
}
const getLocatorStringForElement = async (element: any) => {
+31 -43
View File
@@ -578,12 +578,13 @@ describe('Snapshot & Screenshot Tests', () => {
const serviceWorker = await getExtensionServiceWorker(browserContext)
const page = await browserContext.newPage()
// Use data-testid for stable refs, regular id for the button
await page.setContent(`
<html>
<body>
<button id="submit-btn">Submit Form</button>
<a href="/about">About Us</a>
<input type="text" placeholder="Enter your name" />
<button data-testid="submit-btn">Submit Form</button>
<a href="/about" data-testid="about-link">About Us</a>
<input type="text" placeholder="Enter your name" data-testid="name-input" />
</body>
</html>
`)
@@ -612,55 +613,42 @@ describe('Snapshot & Screenshot Tests', () => {
expect(ariaResult.snapshot).toBeDefined()
expect(ariaResult.snapshot.length).toBeGreaterThan(0)
expect(ariaResult.snapshot).toContain('Submit Form')
// New implementation uses stable test IDs as refs
expect(ariaResult.snapshot).toContain('[ref=submit-btn]')
expect(ariaResult.snapshot).toContain('[ref=about-link]')
expect(ariaResult.snapshot).toContain('[ref=name-input]')
expect(ariaResult.refToElement.size).toBeGreaterThan(0)
console.log('RefToElement map size:', ariaResult.refToElement.size)
console.log('RefToElement entries:', [...ariaResult.refToElement.entries()])
const btnViaAriaRef = cdpPage!.locator('aria-ref=e2')
const btnTextViaRef = await btnViaAriaRef.textContent()
console.log('Button text via aria-ref=e2:', btnTextViaRef)
// Verify refs are stable test IDs
expect(ariaResult.refToElement.has('submit-btn')).toBe(true)
expect(ariaResult.refToElement.has('about-link')).toBe(true)
expect(ariaResult.refToElement.has('name-input')).toBe(true)
// Use getSelectorForRef to get CSS selector for a ref
const btnSelector = ariaResult.getSelectorForRef('submit-btn')
expect(btnSelector).toBeDefined()
console.log('Button selector:', btnSelector)
// Verify the selector works
const btnViaSelector = cdpPage!.locator('[data-testid="submit-btn"]')
const btnTextViaRef = await btnViaSelector.textContent()
console.log('Button text via selector:', btnTextViaRef)
expect(btnTextViaRef).toBe('Submit Form')
const submitBtn = cdpPage!.locator('#submit-btn')
const btnAriaRef = await ariaResult.getRefForLocator(submitBtn)
console.log('Button ariaRef:', btnAriaRef)
expect(btnAriaRef).toBeDefined()
expect(btnAriaRef?.role).toBe('button')
expect(btnAriaRef?.name).toBe('Submit Form')
expect(btnAriaRef?.ref).toMatch(/^e\d+$/)
// Test role and name
const btnInfo = ariaResult.refToElement.get('submit-btn')
expect(btnInfo?.role).toBe('button')
expect(btnInfo?.name).toBe('Submit Form')
const btnFromRef = cdpPage!.locator(`aria-ref=${btnAriaRef?.ref}`)
const btnText = await btnFromRef.textContent()
expect(btnText).toBe('Submit Form')
const linkInfo = ariaResult.refToElement.get('about-link')
expect(linkInfo?.role).toBe('link')
expect(linkInfo?.name).toBe('About Us')
const btnRefStr = await ariaResult.getRefStringForLocator(submitBtn)
console.log('Button ref string:', btnRefStr)
expect(btnRefStr).toBe(btnAriaRef?.ref)
const aboutLink = cdpPage!.locator('a')
const linkAriaRef = await ariaResult.getRefForLocator(aboutLink)
console.log('Link ariaRef:', linkAriaRef)
expect(linkAriaRef).toBeDefined()
expect(linkAriaRef?.role).toBe('link')
expect(linkAriaRef?.name).toBe('About Us')
const linkFromRef = cdpPage!.locator(`aria-ref=${linkAriaRef?.ref}`)
const linkText = await linkFromRef.textContent()
expect(linkText).toBe('About Us')
const inputField = cdpPage!.locator('input')
const inputAriaRef = await ariaResult.getRefForLocator(inputField)
console.log('Input ariaRef:', inputAriaRef)
expect(inputAriaRef).toBeDefined()
expect(inputAriaRef?.role).toBe('textbox')
const batchRefs = await ariaResult.getRefsForLocators([submitBtn, aboutLink, inputField])
console.log('Batch refs:', batchRefs)
expect(batchRefs).toHaveLength(3)
expect(batchRefs[0]?.ref).toBe(btnAriaRef?.ref)
expect(batchRefs[1]?.ref).toBe(linkAriaRef?.ref)
expect(batchRefs[2]?.ref).toBe(inputAriaRef?.ref)
const inputInfo = ariaResult.refToElement.get('name-input')
expect(inputInfo?.role).toBe('textbox')
await browser.close()
await page.close()
+2 -1
View File
@@ -5,5 +5,6 @@
"outDir": "dist",
"types": ["node", "chrome"]
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/a11y-client.ts"]
}
+8
View File
@@ -73,6 +73,9 @@ importers:
diff:
specifier: ^8.0.2
version: 8.0.2
dom-accessibility-api:
specifier: ^0.7.1
version: 0.7.1
hono:
specifier: ^4.10.6
version: 4.10.6
@@ -2959,6 +2962,9 @@ packages:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
dom-accessibility-api@0.7.1:
resolution: {integrity: sha512-vdnCeZD+3wZ+8h8xXL/ZtBlvvoobOFyPzSiIfO6sGOZDqjFx4aLMAjZhl4rawj5xYz3UwP6Tgvyh0iH4IOCVnQ==}
dom-serializer@0.2.2:
resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==}
@@ -8217,6 +8223,8 @@ snapshots:
dependencies:
path-type: 4.0.0
dom-accessibility-api@0.7.1: {}
dom-serializer@0.2.2:
dependencies:
domelementtype: 2.3.0