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", "types": "dist/index.d.ts",
"repository": "https://github.com/remorses/playwriter", "repository": "https://github.com/remorses/playwriter",
"scripts": { "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", "prepublishOnly": "pnpm build",
"watch": "tsc -w", "watch": "tsc -w",
"cli": "vite-node src/cli.ts", "cli": "vite-node src/cli.ts",
@@ -46,6 +46,7 @@
"@modelcontextprotocol/sdk": "^1.25.2", "@modelcontextprotocol/sdk": "^1.25.2",
"@xmorse/cac": "^6.0.7", "@xmorse/cac": "^6.0.7",
"diff": "^8.0.2", "diff": "^8.0.2",
"dom-accessibility-api": "^0.7.1",
"hono": "^4.10.6", "hono": "^4.10.6",
"kill-port-process": "^3.2.1", "kill-port-process": "^3.2.1",
"picocolors": "^1.1.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 type { Page, Locator, ElementHandle } from 'playwright-core'
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url'
// Import sharp at module level - resolves to null if not available // Import sharp at module level - resolves to null if not available
const sharpPromise = import('sharp') const sharpPromise = import('sharp')
@@ -11,27 +12,22 @@ const sharpPromise = import('sharp')
// Aria Snapshot Format Documentation // Aria Snapshot Format Documentation
// ============================================================================ // ============================================================================
// //
// This module processes accessibility snapshots from Playwright's _snapshotForAI() method. // This module generates accessibility snapshots using dom-accessibility-api
// The expected format is a YAML-like tree structure: // running entirely in browser context. The output format is:
// //
// ``` // ```
// - role "accessible name" [ref=eXX] [cursor=pointer] [active]: // - role "accessible name" [ref=testid-or-e1]
// - childRole "child name" [ref=eYY]: // - button "Submit" [ref=submit-btn]
// - /url: https://example.com // - link "Home" [ref=nav-home]
// - text: "raw text content" // - textbox "Search" [ref=e3]
// ``` // ```
// //
// Key format assumptions: // Refs are generated from stable test IDs when available:
// - Lines start with "- " followed by a role name (e.g., link, button, row, cell) // - data-testid, data-test-id, data-test, data-cy, data-pw
// - Accessible names are in double quotes: "name" // - Stable id attributes (excluding auto-generated ones)
// - When names contain colons, the whole entry is single-quoted: - 'role "name: with colon"': // - Fallback: e1, e2, e3...
// - 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. // 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 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])) { if (!match || !interactiveRoles.has(match[1])) {
continue continue
} }
@@ -294,7 +291,7 @@ function extractInteractiveWithStructure(
// Clean line and strip refs from non-interactive // Clean line and strip refs from non-interactive
let cleanedLine = cleanSnapshotLine(lines[i]) let cleanedLine = cleanSnapshotLine(lines[i])
if (!lineIsInteractive[i]) { if (!lineIsInteractive[i]) {
cleanedLine = cleanedLine.replace(/\s*\[ref=\w+\]/g, '') cleanedLine = cleanedLine.replace(/\s*\[ref=[^\]]+\]/g, '')
} }
result.push(cleanedLine) 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: // 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) { if (!match) {
return { role: '', name: null, ref: null } 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 { export interface AriaRef {
@@ -545,27 +567,18 @@ export interface ScreenshotResult {
export interface AriaSnapshotResult { export interface AriaSnapshotResult {
snapshot: string snapshot: string
refToElement: Map<string, { role: string; name: 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>> getRefsForLocators: (locators: Array<Locator | ElementHandle>) => Promise<Array<AriaRef | null>>
getRefForLocator: (locator: Locator | ElementHandle) => Promise<AriaRef | null> getRefForLocator: (locator: Locator | ElementHandle) => Promise<AriaRef | null>
getRefStringForLocator: (locator: Locator | ElementHandle) => Promise<string | null> getRefStringForLocator: (locator: Locator | ElementHandle) => Promise<string | null>
} }
interface ElementLike { // Roles that represent interactive elements
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
const INTERACTIVE_ROLES = new Set([ const INTERACTIVE_ROLES = new Set([
'button', 'button',
'link', 'link',
@@ -583,214 +596,117 @@ const INTERACTIVE_ROLES = new Set([
'option', 'option',
'tab', 'tab',
'treeitem', 'treeitem',
// Media elements - useful for visual tasks
'img', 'img',
'video', 'video',
'audio', 'audio',
]) ])
// Color categories for different role types - warm color scheme // ============================================================================
// Format: [gradient-top, gradient-bottom, border] // Main Functions
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;
`
/** /**
* Get an accessibility snapshot with utilities to look up aria refs for elements. * Get an accessibility snapshot with utilities to look up refs for elements.
* Uses Playwright's internal aria-ref selector engine. * 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 * @example
* ```ts * ```ts
* const { snapshot, getRefsForLocators } = await getAriaSnapshot({ page }) * const { snapshot, getSelectorForRef } = await getAriaSnapshot({ page })
* const refs = await getRefsForLocators([page.locator('button'), page.locator('a')]) * // Snapshot shows refs like [ref=submit-btn] or [ref=e5]
* // refs[0].ref is e.g. "e5" - use page.locator('aria-ref=e5') to select * 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 page: Page
locator?: Locator
refFilter?: (info: { role: string; name: string }) => boolean refFilter?: (info: { role: string; name: string }) => boolean
}): Promise<AriaSnapshotResult> { }): Promise<AriaSnapshotResult> {
const snapshotMethod = (page as any)._snapshotForAI await ensureA11yClient(page)
if (!snapshotMethod) {
throw new Error('_snapshotForAI not available. Ensure you are using Playwright.')
}
const snapshot = await snapshotMethod.call(page) // Determine root element
// Sanitize to remove unpaired surrogates that break JSON encoding for Claude API const rootHandle = locator ? await locator.elementHandle() : null
const rawStr = typeof snapshot === 'string' ? snapshot : (snapshot.full || JSON.stringify(snapshot, null, 2))
const snapshotStr = rawStr.toWellFormed?.() ?? rawStr
const snapshotRefInfo = extractRefInfoFromSnapshot(snapshotStr) const result = await page.evaluate(
const snapshotRefs = [...snapshotRefInfo.entries()] ({ root, interactiveOnly }) => {
.filter(([_, info]) => !refFilter || refFilter(info)) const a11y = (globalThis as any).__a11y
.map(([ref]) => ref) 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 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> => { // Filter snapshot if refFilter provided
try { let snapshot = result.snapshot
const locator = page.locator(`aria-ref=${ref}`) if (refFilter) {
const handle = await locator.elementHandle({ timeout: 1000 }) const lines = snapshot.split('\n').filter((line) => {
if (!handle) { const match = line.match(/\[ref=([^\]]+)\]/)
return null if (!match) {
return true
} }
const info = await handle.evaluate((el) => { const ref = match[1]
const element = el as ElementLike return refToElement.has(ref)
const tagName = element.tagName.toLowerCase() })
const roleAttribute = element.getAttribute('role') snapshot = lines.join('\n')
const inputElement = element as InputElementLike }
const inputType = inputElement.type || ''
const placeholder = inputElement.placeholder || '' /**
const role = roleAttribute || { * Get a CSS selector for a ref.
a: element.hasAttribute('href') ? 'link' : 'generic', * For stable test IDs: [data-testid="value"] or [id="value"]
button: 'button', * For fallback refs: uses role + name matching
input: { */
button: 'button', const getSelectorForRef = (ref: string): string | null => {
checkbox: 'checkbox', const info = refToElement.get(ref)
radio: 'radio', if (!info) {
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 {
return null return null
} }
}
const fetchRefHandle = async (ref: string): Promise<{ ref: string; handle: ElementHandle } | null> => { // Check if ref looks like a stable test ID (not e1, e2, etc.)
try { if (!/^e\d+$/.test(ref)) {
const locator = page.locator(`aria-ref=${ref}`) // Try common test ID attributes
const handle = await locator.elementHandle({ timeout: 1000 }) return `[data-testid="${ref}"], [data-test-id="${ref}"], [data-test="${ref}"], [data-cy="${ref}"], [data-pw="${ref}"], [id="${ref}"]`
if (!handle) {
return null
}
return { ref, handle }
} catch {
return null
} }
// 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) * Find refs for locators by matching in browser context.
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
const getRefsForLocators = async (locators: Array<Locator | ElementHandle>): Promise<Array<AriaRef | null>> => { const getRefsForLocators = async (locators: Array<Locator | ElementHandle>): Promise<Array<AriaRef | null>> => {
if (locators.length === 0 || refHandles.length === 0) { if (locators.length === 0) {
return locators.map(() => null) return []
} }
// Get handles for target locators
const targetHandles = await Promise.all( const targetHandles = await Promise.all(
locators.map(async (loc) => { locators.map(async (loc) => {
try { try {
@@ -803,379 +719,151 @@ export async function getAriaSnapshot({ page, refFilter }: {
}) })
) )
// Match in browser context
const matchingRefs = await page.evaluate( const matchingRefs = await page.evaluate(
({ targets, candidates }) => targets.map((target) => { ({ targets, refData }) => {
if (!target) return null return targets.map((target) => {
return candidates.find(({ element }) => element === target)?.ref ?? null if (!target) {
}), return null
{ targets: targetHandles, candidates: refHandles.map(({ ref, handle }) => ({ ref, element: handle })) } }
// 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) => { return matchingRefs.map((ref) => {
if (!ref) return null if (!ref) {
return null
}
const info = refToElement.get(ref) const info = refToElement.get(ref)
return info ? { ...info, ref } : null return info ? { ...info, ref } : null
}) })
} }
return { return {
snapshot: snapshotStr, snapshot,
refToElement, refToElement,
refHandles, getSelectorForRef,
getRefsForLocators, getRefsForLocators,
getRefForLocator: async (loc) => (await getRefsForLocators([loc]))[0], getRefForLocator: async (loc) => (await getRefsForLocators([loc]))[0],
getRefStringForLocator: async (loc) => (await getRefsForLocators([loc]))[0]?.ref ?? null, 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. * 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. * 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. * 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.) * @param page - Playwright page
* to reduce visual clutter. Set `interactiveOnly: false` to show all elements with refs. * @param locator - Optional locator to scope labels to a subtree
* @param interactiveOnly - Only show labels for interactive elements (default: true)
* *
* @example * @example
* ```ts * ```ts
* const { snapshot, labelCount } = await showAriaRefLabels({ page }) * const { snapshot, labelCount } = await showAriaRefLabels({ page })
* await page.screenshot({ path: '/tmp/screenshot.png' }) * await page.screenshot({ path: '/tmp/screenshot.png' })
* // Agent sees [e5] label on "Submit" button * // Agent sees [submit-btn] label on "Submit" button
* await page.locator('aria-ref=e5').click() * await page.locator('[data-testid="submit-btn"]').click()
* // Labels auto-hide after 30 seconds, or call hideAriaRefLabels() manually
* ``` * ```
*/ */
export async function showAriaRefLabels({ page, interactiveOnly = true, logger }: { export async function showAriaRefLabels({ page, locator, interactiveOnly = true, logger }: {
page: Page page: Page
locator?: Locator
interactiveOnly?: boolean interactiveOnly?: boolean
logger?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void } logger?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }
}): Promise<{ }): Promise<{
snapshot: string snapshot: string
labelCount: number labelCount: number
}> { }> {
const getSnapshotStart = Date.now() const startTime = Date.now()
const { snapshot, refHandles, refToElement } = await getAriaSnapshot({ await ensureA11yClient(page)
page,
refFilter: interactiveOnly ? (info) => { return INTERACTIVE_ROLES.has(info.role) } : undefined,
})
const log = logger?.info ?? logger?.error const log = logger?.info ?? logger?.error
if (log) { if (log) {
log(`getAriaSnapshot: ${Date.now() - getSnapshotStart}ms`) log(`ensureA11yClient: ${Date.now() - startTime}ms`)
} }
// Filter to only interactive elements if requested // Determine root element
const filteredRefs = refHandles const rootHandle = locator ? await locator.elementHandle() : null
// Build refs with role info for color coding const computeStart = Date.now()
const refsWithRoles = filteredRefs.map(({ ref, handle }) => ({ const result = await page.evaluate(
ref, ({ root, interactiveOnly: intOnly }) => {
element: handle, const a11y = (globalThis as any).__a11y
role: refToElement.get(ref)?.role || 'generic', if (!a11y) {
})) throw new Error('a11y client not loaded')
// 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 rootElement = root || document.body
// Remove existing labels if present (idempotent) return a11y.computeA11ySnapshot({
doc.getElementById(containerId)?.remove() root: rootElement,
interactiveOnly: intOnly,
// Create container - absolute positioned, max z-index, no pointer events renderLabels: true,
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
}, },
{ {
refs: refsWithRoles.map(({ ref, role, element }) => ({ ref, role, element })), root: rootHandle,
containerId: LABELS_CONTAINER_ID, interactiveOnly,
containerStyles: CONTAINER_STYLES,
labelStyles: LABEL_STYLES,
roleColors: ROLE_COLORS,
defaultColors: DEFAULT_COLORS,
} }
) )
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. * Remove all aria ref labels from the page.
*/ */
export async function hideAriaRefLabels({ page }: { page: Page }): Promise<void> { export async function hideAriaRefLabels({ page }: { page: Page }): Promise<void> {
await page.evaluate((id) => { await page.evaluate(() => {
const doc = (globalThis as any).document const a11y = (globalThis as any).__a11y
const win = globalThis as any if (a11y) {
a11y.hideA11yLabels()
// Cancel any pending auto-hide timer } else {
const timerKey = '__playwriter_labels_timer__' // Fallback if client not loaded
if (win[timerKey]) { const doc = document
win.clearTimeout(win[timerKey]) const win = window as any
win[timerKey] = null 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. * Shows Vimium-style labels, captures the screenshot, then removes the labels.
* The screenshot is automatically included in the MCP response. * 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) * @param collector - Array to collect screenshots (passed by MCP execute tool)
* *
* @example * @example
* ```ts * ```ts
* await screenshotWithAccessibilityLabels({ page }) * await screenshotWithAccessibilityLabels({ page })
* // Screenshot is automatically included in the MCP response * // Screenshot is automatically included in the MCP response
* // Use aria-ref from the snapshot to interact with elements * // Use ref from the snapshot to interact with elements
* await page.locator('aria-ref=e5').click() * 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 page: Page
locator?: Locator
interactiveOnly?: boolean interactiveOnly?: boolean
collector: ScreenshotResult[] collector: ScreenshotResult[]
logger?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void } logger?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }
}): Promise<void> { }): Promise<void> {
const showLabelsStart = Date.now() 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 const log = logger?.info ?? logger?.error
if (log) { if (log) {
log(`showAriaRefLabels: ${Date.now() - showLabelsStart}ms`) log(`showAriaRefLabels: ${Date.now() - showLabelsStart}ms`)
@@ -1280,3 +971,6 @@ export async function screenshotWithAccessibilityLabels({ page, interactiveOnly
labelCount, 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. * 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 crypto from 'node:crypto'
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
@@ -21,7 +21,7 @@ import { Editor } from './editor.js'
import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js' import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js'
import { getReactSource, type ReactSourceLocation } from './react-source.js' import { getReactSource, type ReactSourceLocation } from './react-source.js'
import { ScopedFS } from './scoped-fs.js' import { ScopedFS } from './scoped-fs.js'
import { screenshotWithAccessibilityLabels, 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 } export type { SnapshotFormat }
import { getCleanHTML, type GetCleanHTMLOptions } from './clean-html.js' import { getCleanHTML, type GetCleanHTMLOptions } from './clean-html.js'
import { startRecording, stopRecording, isRecording, cancelRecording } from './screen-recording.js' import { startRecording, stopRecording, isRecording, cancelRecording } from './screen-recording.js'
@@ -406,75 +406,75 @@ export class PlaywrightExecutor {
const accessibilitySnapshot = async (options: { const accessibilitySnapshot = async (options: {
page: Page page: Page
/** Optional locator to scope the snapshot to a subtree */
locator?: Locator
search?: string | RegExp search?: string | RegExp
showDiffSinceLastCall?: boolean showDiffSinceLastCall?: boolean
/** Snapshot format: 'raw', 'compact', 'interactive', 'interactive-dedup' (default) */ /** Snapshot format: 'raw', 'compact', 'interactive', 'interactive-dedup' (default) */
format?: SnapshotFormat format?: SnapshotFormat
}) => { }) => {
const { page: targetPage, search, showDiffSinceLastCall = false, format = DEFAULT_SNAPSHOT_FORMAT } = options const { page: targetPage, locator, search, showDiffSinceLastCall = false, format = DEFAULT_SNAPSHOT_FORMAT } = options
if ((targetPage as any)._snapshotForAI) {
const snapshot = await (targetPage as any)._snapshotForAI() // Use new in-page implementation via getAriaSnapshot
const rawStr = typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2) const { snapshot: rawSnapshot } = await getAriaSnapshot({ page: targetPage, locator })
const sanitizedStr = rawStr.toWellFormed?.() ?? rawStr const sanitizedStr = rawSnapshot.toWellFormed?.() ?? rawSnapshot
// Apply format transformation // Apply format transformation
const snapshotStr = formatSnapshot(sanitizedStr, format, this.logger) const snapshotStr = formatSnapshot(sanitizedStr, format, this.logger)
if (showDiffSinceLastCall) { if (showDiffSinceLastCall) {
const previousSnapshot = this.lastSnapshots.get(targetPage) const previousSnapshot = this.lastSnapshots.get(targetPage)
if (!previousSnapshot) { if (!previousSnapshot) {
this.lastSnapshots.set(targetPage, snapshotStr) this.lastSnapshots.set(targetPage, snapshotStr)
return 'No previous snapshot available. This is the first call for this page. Full snapshot stored for next diff.' 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
} }
const patch = createPatch('snapshot', previousSnapshot, snapshotStr, 'previous', 'current', { context: 3 })
this.lastSnapshots.set(targetPage, snapshotStr) if (patch.split('\n').length <= 4) {
return 'No changes detected since last snapshot'
if (!search) {
return snapshotStr
} }
return patch
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')
} }
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) => { const getLocatorStringForElement = async (element: any) => {
+31 -43
View File
@@ -578,12 +578,13 @@ describe('Snapshot & Screenshot Tests', () => {
const serviceWorker = await getExtensionServiceWorker(browserContext) const serviceWorker = await getExtensionServiceWorker(browserContext)
const page = await browserContext.newPage() const page = await browserContext.newPage()
// Use data-testid for stable refs, regular id for the button
await page.setContent(` await page.setContent(`
<html> <html>
<body> <body>
<button id="submit-btn">Submit Form</button> <button data-testid="submit-btn">Submit Form</button>
<a href="/about">About Us</a> <a href="/about" data-testid="about-link">About Us</a>
<input type="text" placeholder="Enter your name" /> <input type="text" placeholder="Enter your name" data-testid="name-input" />
</body> </body>
</html> </html>
`) `)
@@ -612,55 +613,42 @@ describe('Snapshot & Screenshot Tests', () => {
expect(ariaResult.snapshot).toBeDefined() expect(ariaResult.snapshot).toBeDefined()
expect(ariaResult.snapshot.length).toBeGreaterThan(0) expect(ariaResult.snapshot.length).toBeGreaterThan(0)
expect(ariaResult.snapshot).toContain('Submit Form') 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) expect(ariaResult.refToElement.size).toBeGreaterThan(0)
console.log('RefToElement map size:', ariaResult.refToElement.size) console.log('RefToElement map size:', ariaResult.refToElement.size)
console.log('RefToElement entries:', [...ariaResult.refToElement.entries()]) console.log('RefToElement entries:', [...ariaResult.refToElement.entries()])
const btnViaAriaRef = cdpPage!.locator('aria-ref=e2') // Verify refs are stable test IDs
const btnTextViaRef = await btnViaAriaRef.textContent() expect(ariaResult.refToElement.has('submit-btn')).toBe(true)
console.log('Button text via aria-ref=e2:', btnTextViaRef) 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') expect(btnTextViaRef).toBe('Submit Form')
const submitBtn = cdpPage!.locator('#submit-btn') // Test role and name
const btnAriaRef = await ariaResult.getRefForLocator(submitBtn) const btnInfo = ariaResult.refToElement.get('submit-btn')
console.log('Button ariaRef:', btnAriaRef) expect(btnInfo?.role).toBe('button')
expect(btnAriaRef).toBeDefined() expect(btnInfo?.name).toBe('Submit Form')
expect(btnAriaRef?.role).toBe('button')
expect(btnAriaRef?.name).toBe('Submit Form')
expect(btnAriaRef?.ref).toMatch(/^e\d+$/)
const btnFromRef = cdpPage!.locator(`aria-ref=${btnAriaRef?.ref}`) const linkInfo = ariaResult.refToElement.get('about-link')
const btnText = await btnFromRef.textContent() expect(linkInfo?.role).toBe('link')
expect(btnText).toBe('Submit Form') expect(linkInfo?.name).toBe('About Us')
const btnRefStr = await ariaResult.getRefStringForLocator(submitBtn) const inputInfo = ariaResult.refToElement.get('name-input')
console.log('Button ref string:', btnRefStr) expect(inputInfo?.role).toBe('textbox')
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)
await browser.close() await browser.close()
await page.close() await page.close()
+2 -1
View File
@@ -5,5 +5,6 @@
"outDir": "dist", "outDir": "dist",
"types": ["node", "chrome"] "types": ["node", "chrome"]
}, },
"include": ["src"] "include": ["src"],
"exclude": ["src/a11y-client.ts"]
} }
+8
View File
@@ -73,6 +73,9 @@ importers:
diff: diff:
specifier: ^8.0.2 specifier: ^8.0.2
version: 8.0.2 version: 8.0.2
dom-accessibility-api:
specifier: ^0.7.1
version: 0.7.1
hono: hono:
specifier: ^4.10.6 specifier: ^4.10.6
version: 4.10.6 version: 4.10.6
@@ -2959,6 +2962,9 @@ packages:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'} engines: {node: '>=8'}
dom-accessibility-api@0.7.1:
resolution: {integrity: sha512-vdnCeZD+3wZ+8h8xXL/ZtBlvvoobOFyPzSiIfO6sGOZDqjFx4aLMAjZhl4rawj5xYz3UwP6Tgvyh0iH4IOCVnQ==}
dom-serializer@0.2.2: dom-serializer@0.2.2:
resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==} resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==}
@@ -8217,6 +8223,8 @@ snapshots:
dependencies: dependencies:
path-type: 4.0.0 path-type: 4.0.0
dom-accessibility-api@0.7.1: {}
dom-serializer@0.2.2: dom-serializer@0.2.2:
dependencies: dependencies:
domelementtype: 2.3.0 domelementtype: 2.3.0