Files
playwriter/playwriter/src/aria-snapshot.ts

1386 lines
42 KiB
TypeScript

// Accessibility snapshot pipeline: build raw AX tree, filter to a
// tree (interactive-only, labels/contexts, wrapper hoisting, ignored
// indent preservation), then render lines and locators.
import type { Page, Locator, ElementHandle, Frame } from '@xmorse/playwright-core'
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Protocol } from 'devtools-protocol'
import { Sema } from 'async-sema'
import type { ICDPSession } from './cdp-session.js'
import { getCDPSessionForPage } from './cdp-session.js'
// Import sharp at module level - resolves to null if not available
const sharpPromise = import('sharp')
.then((m) => { return m.default })
.catch(() => { return null })
// ============================================================================
// Snapshot Format Types
// ============================================================================
export type SnapshotFormat = 'raw'
export const DEFAULT_SNAPSHOT_FORMAT: SnapshotFormat = 'raw'
// ============================================================================
// 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 {
role: string
name: string
ref: string
shortRef: string
backendNodeId?: Protocol.DOM.BackendNodeId
}
type AriaRefDraft = Omit<AriaRef, 'shortRef'> & { selector?: string }
export type AriaSnapshotNode = {
role: string
name: string
locator?: string
ref?: string
shortRef?: string
backendNodeId?: Protocol.DOM.BackendNodeId
children: AriaSnapshotNode[]
}
export interface ScreenshotResult {
path: string
base64: string
mimeType: 'image/jpeg'
snapshot: string
labelCount: number
}
export interface AriaSnapshotResult {
snapshot: string
tree: AriaSnapshotNode[]
refs: AriaRef[]
refToElement: Map<string, { role: string; name: string; shortRef: string }>
refToSelector: Map<string, string>
/**
* Get a CSS selector for a ref. Use with page.locator().
* For stable test IDs, returns [data-testid="..."] or [id="..."]
* For fallback refs, 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>
}
type LabelBox = {
x: number
y: number
width: number
height: number
}
type AriaLabel = {
ref: string
role: string
box: LabelBox
}
export function buildShortRefMap({ refs }: { refs: Array<{ ref: string }> }): Map<string, string> {
const map = new Map<string, string>()
refs.forEach((entry, index) => {
map.set(entry.ref, `e${index + 1}`)
})
return map
}
// Roles that represent interactive elements
const INTERACTIVE_ROLES = new Set([
'button',
'link',
'textbox',
'combobox',
'searchbox',
'checkbox',
'radio',
'slider',
'spinbutton',
'switch',
'menuitem',
'menuitemcheckbox',
'menuitemradio',
'option',
'tab',
'treeitem',
'img',
'video',
'audio',
])
const LABEL_ROLES = new Set([
'labeltext',
])
const MAX_LABEL_POSITION_CONCURRENCY = 24
const BOX_MODEL_TIMEOUT_MS = 5000
const CONTEXT_ROLES = new Set([
'navigation',
'main',
'contentinfo',
'banner',
'form',
'section',
'region',
'list',
'listitem',
'table',
'rowgroup',
'row',
'cell',
])
const SKIP_WRAPPER_ROLES = new Set([
'generic',
'group',
'none',
'presentation',
])
const TEST_ID_ATTRS = [
'data-testid',
'data-test-id',
'data-test',
'data-cy',
'data-pw',
'data-qa',
'data-e2e',
'data-automation-id',
]
type DomNodeInfo = {
nodeId: Protocol.DOM.NodeId
parentId?: Protocol.DOM.NodeId
backendNodeId: Protocol.DOM.BackendNodeId
nodeName: string
attributes: Map<string, string>
}
function toAttributeMap(attributes?: string[]): Map<string, string> {
const result = new Map<string, string>()
if (!attributes) {
return result
}
for (let i = 0; i < attributes.length; i += 2) {
const name = attributes[i]
const value = attributes[i + 1]
if (name) {
result.set(name, value ?? '')
}
}
return result
}
function getStableRefFromAttributes(attributes: Map<string, string>): { value: string; attr: string } | null {
const id = attributes.get('id')
if (id) {
return { value: id, attr: 'id' }
}
for (const attr of TEST_ID_ATTRS) {
const value = attributes.get(attr)
if (value) {
return { value, attr }
}
}
return null
}
function escapeLocatorValue(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
}
function buildLocatorFromStable(stable: { value: string; attr: string }): string {
const escaped = escapeLocatorValue(stable.value)
return `[${stable.attr}="${escaped}"]`
}
function buildBaseLocator({ role, name, stable }: { role: string; name: string; stable: { value: string; attr: string } | null }): string {
if (stable) {
return buildLocatorFromStable(stable)
}
const trimmedName = name.trim()
if (trimmedName.length > 0) {
const escapedName = escapeLocatorValue(trimmedName)
return `role=${role}[name="${escapedName}"]`
}
return `role=${role}`
}
function getAxValueString(value?: Protocol.Accessibility.AXValue): string {
if (!value) {
return ''
}
const raw = value.value
if (typeof raw === 'string') {
return raw
}
if (raw === undefined || raw === null) {
return ''
}
return String(raw)
}
function getAxRole(node: Protocol.Accessibility.AXNode): string {
const role = getAxValueString(node.role)
return role.toLowerCase()
}
export type SnapshotLine = {
text: string
baseLocator?: string
hasChildren?: boolean
role?: string
name?: string
indent?: number
}
export type SnapshotNode = {
role: string
name: string
baseLocator?: string
ref?: string
backendNodeId?: Protocol.DOM.BackendNodeId
indentOffset?: number
ignored?: boolean
children: SnapshotNode[]
}
function buildSnapshotLine({ role, name, baseLocator, indent, hasChildren }: {
role: string
name: string
baseLocator?: string
indent: number
hasChildren: boolean
}): SnapshotLine {
const prefix = ' '.repeat(indent)
let text = `${prefix}- ${role}`
if (name) {
const escapedName = name.replace(/"/g, '\\"')
text += ` "${escapedName}"`
}
return { text, baseLocator, hasChildren, role, name, indent }
}
function buildTextLine(text: string, indent: number): SnapshotLine {
const prefix = ' '.repeat(indent)
const escaped = text.replace(/"/g, '\\"')
return { text: `${prefix}- text: "${escaped}"` }
}
export function buildSnapshotLines(nodes: SnapshotNode[], indent = 0): SnapshotLine[] {
return nodes.flatMap((node) => {
const nodeIndent = indent + (node.indentOffset ?? 0)
const line = node.role === 'text'
? buildTextLine(node.name, nodeIndent)
: buildSnapshotLine({
role: node.role,
name: node.name,
baseLocator: node.baseLocator,
indent: nodeIndent,
hasChildren: node.children.length > 0,
})
return [line, ...buildSnapshotLines(node.children, nodeIndent + 1)]
})
}
function shiftIndent(nodes: SnapshotNode[], offset: number): SnapshotNode[] {
return nodes.map((node) => {
return { ...node, indentOffset: (node.indentOffset ?? 0) + offset }
})
}
export function buildRawSnapshotTree(options: {
nodeId: Protocol.Accessibility.AXNodeId
axById: Map<Protocol.Accessibility.AXNodeId, Protocol.Accessibility.AXNode>
isNodeInScope: (node: Protocol.Accessibility.AXNode) => boolean
}): SnapshotNode | null {
const node = options.axById.get(options.nodeId)
if (!node) {
return null
}
const role = getAxRole(node)
const name = getAxValueString(node.name).trim()
const children = (node.childIds ?? []).map((childId) => {
return buildRawSnapshotTree({
nodeId: childId,
axById: options.axById,
isNodeInScope: options.isNodeInScope,
})
}).filter(isTruthy)
const inScope = options.isNodeInScope(node) || children.length > 0
if (!inScope) {
return null
}
return {
role,
name,
backendNodeId: node.backendDOMNodeId,
ignored: node.ignored,
children,
}
}
export function filterInteractiveSnapshotTree(options: {
node: SnapshotNode
ancestorNames: string[]
labelContext: boolean
refFilter?: (entry: { role: string; name: string }) => boolean
domByBackendId: Map<Protocol.DOM.BackendNodeId, DomNodeInfo>
createRefForNode: (options: { backendNodeId?: Protocol.DOM.BackendNodeId; role: string; name: string }) => string | null
}): { nodes: SnapshotNode[]; names: Set<string> } {
const role = options.node.role
const name = options.node.name
const hasName = name.length > 0
const nextAncestors = hasName ? [...options.ancestorNames, name] : options.ancestorNames
const isLabel = LABEL_ROLES.has(role)
const nextLabelContext = options.labelContext || isLabel
const childResults = options.node.children.map((child) => {
return filterInteractiveSnapshotTree({
node: child,
ancestorNames: nextAncestors,
labelContext: nextLabelContext,
refFilter: options.refFilter,
domByBackendId: options.domByBackendId,
createRefForNode: options.createRefForNode,
})
})
const childNodes = childResults.flatMap((result) => {
return result.nodes
})
const childNames = childResults.reduce((acc, result) => {
result.names.forEach((childName) => {
acc.add(childName)
})
return acc
}, new Set<string>())
if (options.node.ignored) {
return { nodes: shiftIndent(childNodes, 1), names: childNames }
}
if (isTextRole(role)) {
if (!hasName) {
return { nodes: childNodes, names: childNames }
}
if (!options.labelContext) {
return { nodes: childNodes, names: childNames }
}
const isRedundantText = options.ancestorNames.some((ancestor) => {
return ancestor.includes(name) || name.includes(ancestor)
})
if (isRedundantText) {
return { nodes: childNodes, names: childNames }
}
const names = new Set(childNames)
names.add(name)
const textNode: SnapshotNode = { role: 'text', name, children: [] }
return { nodes: [textNode], names }
}
const hasChildren = childNodes.length > 0
const nameToUse = hasName && (childNames.has(name) || isSubstringOfAny(name, childNames)) ? '' : name
const hasNameToUse = nameToUse.length > 0
const isWrapper = SKIP_WRAPPER_ROLES.has(role)
const isInteractive = INTERACTIVE_ROLES.has(role)
const isContext = CONTEXT_ROLES.has(role)
const passesRefFilter = !options.refFilter || options.refFilter({ role, name })
const includeInteractive = isInteractive && passesRefFilter
const shouldInclude = includeInteractive || isLabel || isContext || hasChildren
if (!shouldInclude) {
return { nodes: childNodes, names: childNames }
}
if (!includeInteractive && !isLabel && !isContext) {
if (!hasChildren) {
return { nodes: [], names: childNames }
}
return { nodes: childNodes, names: childNames }
}
if (isWrapper && !hasNameToUse) {
if (!hasChildren) {
return { nodes: [], names: childNames }
}
return { nodes: childNodes, names: childNames }
}
let baseLocator: string | undefined
let ref: string | null = null
if (includeInteractive) {
const domInfo = options.node.backendNodeId ? options.domByBackendId.get(options.node.backendNodeId) : undefined
const stable = domInfo ? getStableRefFromAttributes(domInfo.attributes) : null
baseLocator = buildBaseLocator({ role, name, stable })
ref = options.createRefForNode({ backendNodeId: options.node.backendNodeId, role, name })
}
const nodeEntry: SnapshotNode = {
role,
name: nameToUse,
baseLocator,
ref: ref ?? undefined,
backendNodeId: options.node.backendNodeId,
children: childNodes,
}
const names = new Set(childNames)
if (hasNameToUse) {
names.add(nameToUse)
}
return { nodes: [nodeEntry], names }
}
export function filterFullSnapshotTree(options: {
node: SnapshotNode
ancestorNames: string[]
refFilter?: (entry: { role: string; name: string }) => boolean
domByBackendId: Map<Protocol.DOM.BackendNodeId, DomNodeInfo>
createRefForNode: (options: { backendNodeId?: Protocol.DOM.BackendNodeId; role: string; name: string }) => string | null
}): { nodes: SnapshotNode[]; names: Set<string> } {
const role = options.node.role
const name = options.node.name
const hasName = name.length > 0
const nextAncestors = hasName ? [...options.ancestorNames, name] : options.ancestorNames
const childResults = options.node.children.map((child) => {
return filterFullSnapshotTree({
node: child,
ancestorNames: nextAncestors,
refFilter: options.refFilter,
domByBackendId: options.domByBackendId,
createRefForNode: options.createRefForNode,
})
})
const childNodes = childResults.flatMap((result) => {
return result.nodes
})
const childNames = childResults.reduce((acc, result) => {
result.names.forEach((childName) => {
acc.add(childName)
})
return acc
}, new Set<string>())
if (options.node.ignored) {
return { nodes: shiftIndent(childNodes, 1), names: childNames }
}
if (isTextRole(role)) {
if (!hasName) {
return { nodes: childNodes, names: childNames }
}
const isRedundantText = options.ancestorNames.some((ancestor) => {
return ancestor.includes(name) || name.includes(ancestor)
})
if (isRedundantText) {
return { nodes: childNodes, names: childNames }
}
const names = new Set(childNames)
names.add(name)
const textNode: SnapshotNode = { role: 'text', name, children: [] }
return { nodes: [textNode], names }
}
const hasChildren = childNodes.length > 0
const nameToUse = hasName && (childNames.has(name) || isSubstringOfAny(name, childNames)) ? '' : name
const hasNameToUse = nameToUse.length > 0
const isWrapper = SKIP_WRAPPER_ROLES.has(role)
const isInteractive = INTERACTIVE_ROLES.has(role)
const passesRefFilter = !options.refFilter || options.refFilter({ role, name })
const includeInteractive = isInteractive && passesRefFilter
const shouldInclude = includeInteractive || hasNameToUse || hasChildren
if (!shouldInclude) {
return { nodes: childNodes, names: childNames }
}
if (isWrapper && !hasNameToUse) {
if (!hasChildren) {
return { nodes: [], names: childNames }
}
return { nodes: childNodes, names: childNames }
}
let baseLocator: string | undefined
let ref: string | null = null
if (includeInteractive) {
const domInfo = options.node.backendNodeId ? options.domByBackendId.get(options.node.backendNodeId) : undefined
const stable = domInfo ? getStableRefFromAttributes(domInfo.attributes) : null
baseLocator = buildBaseLocator({ role, name, stable })
ref = options.createRefForNode({ backendNodeId: options.node.backendNodeId, role, name })
}
const nodeEntry: SnapshotNode = {
role,
name: nameToUse,
baseLocator,
ref: ref ?? undefined,
backendNodeId: options.node.backendNodeId,
children: childNodes,
}
const names = new Set(childNames)
if (hasNameToUse) {
names.add(nameToUse)
}
return { nodes: [nodeEntry], names }
}
function buildLocatorLineText({ line, locator }: { line: SnapshotLine; locator: string }): string {
const prefix = ' '.repeat(line.indent ?? 0)
const role = line.role ?? ''
const name = line.name ?? ''
const escapedName = name.replace(/"/g, '\\"')
const hasRoleInLocator = role ? locator.includes(role) : false
const hasNameInLocator = name ? locator.includes(escapedName) : false
const parts: string[] = []
if (role && !hasRoleInLocator) {
parts.push(role)
}
if (name && !hasNameInLocator) {
parts.push(`"${escapedName}"`)
}
const base = parts.length > 0 ? `${prefix}- ${parts.join(' ')}` : `${prefix}-`
return `${base} ${locator}`
}
export function finalizeSnapshotOutput(
lines: SnapshotLine[],
nodes: SnapshotNode[],
shortRefMap: Map<string, string>,
): { snapshot: string; tree: AriaSnapshotNode[] } {
const locatorCounts = lines.reduce<Map<string, number>>((acc, line) => {
if (!line.baseLocator) {
return acc
}
acc.set(line.baseLocator, (acc.get(line.baseLocator) ?? 0) + 1)
return acc
}, new Map<string, number>())
const locatorIndices = new Map<string, number>()
const locatorSequence = lines.reduce<string[]>((acc, line) => {
if (!line.baseLocator) {
return acc
}
const count = locatorCounts.get(line.baseLocator) ?? 0
const index = locatorIndices.get(line.baseLocator) ?? 0
locatorIndices.set(line.baseLocator, index + 1)
const locator = count > 1 ? `${line.baseLocator} >> nth=${index}` : line.baseLocator
acc.push(locator)
return acc
}, [])
let lineLocatorIndex = 0
const snapshot = lines.map((line) => {
let text = line.text
if (line.baseLocator) {
const locator = locatorSequence[lineLocatorIndex]
lineLocatorIndex += 1
text = buildLocatorLineText({ line, locator })
}
if (line.hasChildren) {
text += ':'
}
return text
}).join('\n')
let nodeLocatorIndex = 0
const applyLocators = (items: SnapshotNode[]): AriaSnapshotNode[] => {
return items.map((item) => {
const locator = item.baseLocator ? locatorSequence[nodeLocatorIndex++] : undefined
const children = applyLocators(item.children)
return {
role: item.role,
name: item.name,
locator,
ref: item.ref,
shortRef: item.ref ? (shortRefMap.get(item.ref) ?? item.ref) : undefined,
backendNodeId: item.backendNodeId,
children,
}
})
}
return { snapshot, tree: applyLocators(nodes) }
}
function buildDomIndex(nodes: Protocol.DOM.Node[]): {
domById: Map<Protocol.DOM.NodeId, DomNodeInfo>
domByBackendId: Map<Protocol.DOM.BackendNodeId, DomNodeInfo>
childrenByParent: Map<Protocol.DOM.NodeId, Protocol.DOM.NodeId[]>
} {
const domById = new Map<Protocol.DOM.NodeId, DomNodeInfo>()
const domByBackendId = new Map<Protocol.DOM.BackendNodeId, DomNodeInfo>()
const childrenByParent = new Map<Protocol.DOM.NodeId, Protocol.DOM.NodeId[]>()
for (const node of nodes) {
const info: DomNodeInfo = {
nodeId: node.nodeId,
parentId: node.parentId,
backendNodeId: node.backendNodeId,
nodeName: node.nodeName,
attributes: toAttributeMap(node.attributes),
}
domById.set(node.nodeId, info)
domByBackendId.set(node.backendNodeId, info)
if (node.parentId) {
if (!childrenByParent.has(node.parentId)) {
childrenByParent.set(node.parentId, [])
}
childrenByParent.get(node.parentId)!.push(node.nodeId)
}
}
return { domById, domByBackendId, childrenByParent }
}
function findScopeRootNodeId(nodes: Protocol.DOM.Node[], attrName: string, attrValue: string): Protocol.DOM.NodeId | null {
for (const node of nodes) {
if (!node.attributes) {
continue
}
for (let i = 0; i < node.attributes.length; i += 2) {
const name = node.attributes[i]
const value = node.attributes[i + 1]
if (name === attrName && value === attrValue) {
return node.nodeId
}
}
}
return null
}
function buildBackendIdSet(rootNodeId: Protocol.DOM.NodeId, childrenByParent: Map<Protocol.DOM.NodeId, Protocol.DOM.NodeId[]>, domById: Map<Protocol.DOM.NodeId, DomNodeInfo>): Set<Protocol.DOM.BackendNodeId> {
const result = new Set<Protocol.DOM.BackendNodeId>()
const stack: Protocol.DOM.NodeId[] = [rootNodeId]
while (stack.length > 0) {
const current = stack.pop()
if (current === undefined) {
continue
}
const node = domById.get(current)
if (node) {
result.add(node.backendNodeId)
}
const children = childrenByParent.get(current)
if (children && children.length > 0) {
stack.push(...children)
}
}
return result
}
function isTextRole(role: string): boolean {
return role === 'statictext' || role === 'inlinetextbox'
}
function isSubstringOfAny(needle: string, haystack: Set<string>): boolean {
for (const str of haystack) {
if (str.includes(needle)) {
return true
}
}
return false
}
// ============================================================================
// Main Functions
// ============================================================================
/**
* Get an accessibility snapshot with utilities to look up refs for elements.
* Uses the browser accessibility tree (CDP) and maps nodes to DOM attributes.
*
* 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, getSelectorForRef } = await getAriaSnapshot({ page })
* // Snapshot shows locators like [id="submit-btn"] or role=button[name="Submit"]
* const selector = getSelectorForRef('submit-btn')
* await page.locator(selector).click()
* ```
*/
export async function getAriaSnapshot({ page, frame, locator, refFilter, wsUrl, interactiveOnly = false, cdp }: {
page: Page
frame?: Frame
locator?: Locator
refFilter?: (info: { role: string; name: string }) => boolean
wsUrl?: string
interactiveOnly?: boolean
cdp?: ICDPSession
}): Promise<AriaSnapshotResult> {
const session = cdp || await getCDPSessionForPage({ page, wsUrl })
// For cross-origin iframes (OOPIFs), we need to attach to the iframe's target
// to get a separate CDP session. Same-origin iframes can use frameId directly.
let oopifSessionId: string | null = null
const frameId = frame?.frameId() ?? null
if (frameId) {
const { targetInfos } = await session.send('Target.getTargets') as Protocol.Target.GetTargetsResponse
const frameUrl = frame!.url()
const iframeTarget = targetInfos.find((t) => {
return t.type === 'iframe' && t.url === frameUrl
})
if (iframeTarget) {
const { sessionId } = await session.send('Target.attachToTarget', {
targetId: iframeTarget.targetId,
flatten: true,
}) as Protocol.Target.AttachToTargetResponse
oopifSessionId = sessionId
await session.send('Runtime.runIfWaitingForDebugger', undefined, oopifSessionId)
}
}
await session.send('DOM.enable', undefined, oopifSessionId)
await session.send('Accessibility.enable', undefined, oopifSessionId)
const scopeAttr = 'data-pw-scope'
const scopeValue = crypto.randomUUID()
let scopeApplied = false
const scopeLocator = locator
try {
if (scopeLocator) {
await scopeLocator.evaluate((element, data) => {
element.setAttribute(data.attr, data.value)
}, { attr: scopeAttr, value: scopeValue })
scopeApplied = true
}
const { nodes: domNodes } = await session.send('DOM.getFlattenedDocument', { depth: -1, pierce: true }, oopifSessionId) as Protocol.DOM.GetFlattenedDocumentResponse
const { domById, domByBackendId, childrenByParent } = buildDomIndex(domNodes)
let scopeRootNodeId: Protocol.DOM.NodeId | null = null
let scopeRootBackendId: Protocol.DOM.BackendNodeId | null = null
if (scopeLocator) {
scopeRootNodeId = findScopeRootNodeId(domNodes, scopeAttr, scopeValue)
if (scopeRootNodeId) {
const scopeNode = domById.get(scopeRootNodeId)
if (scopeNode) {
scopeRootBackendId = scopeNode.backendNodeId
}
}
}
const allowedBackendIds = scopeRootNodeId
? buildBackendIdSet(scopeRootNodeId, childrenByParent, domById)
: null
const axParams = !oopifSessionId && frameId ? { frameId } : undefined
const { nodes: axNodes } = await session.send('Accessibility.getFullAXTree', axParams, oopifSessionId) as Protocol.Accessibility.GetFullAXTreeResponse
const axById = new Map<Protocol.Accessibility.AXNodeId, Protocol.Accessibility.AXNode>()
for (const node of axNodes) {
axById.set(node.nodeId, node)
}
const findRootAxNodeId = (): Protocol.Accessibility.AXNodeId | null => {
if (scopeRootBackendId) {
const scoped = axNodes.find((node) => {
return node.backendDOMNodeId === scopeRootBackendId
})
if (scoped) {
return scoped.nodeId
}
}
const rootWebArea = axNodes.find((node) => {
return getAxRole(node) === 'rootwebarea'
})
if (rootWebArea) {
return rootWebArea.nodeId
}
const webArea = axNodes.find((node) => {
return getAxRole(node) === 'webarea'
})
if (webArea) {
return webArea.nodeId
}
const topLevel = axNodes.find((node) => {
return !node.parentId
})
return topLevel ? topLevel.nodeId : null
}
const rootAxNodeId = findRootAxNodeId()
const refCounts = new Map<string, number>()
let fallbackCounter = 0
const refs: AriaRefDraft[] = []
const createRefForNode = (options: {
backendNodeId?: Protocol.DOM.BackendNodeId
role: string
name: string
}): string | null => {
if (!INTERACTIVE_ROLES.has(options.role)) {
return null
}
const domInfo = options.backendNodeId ? domByBackendId.get(options.backendNodeId) : undefined
const stable = domInfo ? getStableRefFromAttributes(domInfo.attributes) : null
let baseRef = stable?.value
if (!baseRef) {
fallbackCounter += 1
baseRef = `e${fallbackCounter}`
}
const count = refCounts.get(baseRef) ?? 0
refCounts.set(baseRef, count + 1)
const ref = count === 0 ? baseRef : `${baseRef}-${count + 1}`
let selector: string | undefined
if (stable && count === 0) {
selector = buildLocatorFromStable(stable)
}
refs.push({ ref, role: options.role, name: options.name, selector, backendNodeId: options.backendNodeId })
return ref
}
const isNodeInScope = (node: Protocol.Accessibility.AXNode): boolean => {
if (!allowedBackendIds) {
return true
}
if (!node.backendDOMNodeId) {
return false
}
return allowedBackendIds.has(node.backendDOMNodeId)
}
let snapshotNodes: SnapshotNode[] = []
if (rootAxNodeId) {
const rootNode = axById.get(rootAxNodeId)
const rootRole = rootNode ? getAxRole(rootNode) : ''
const rawRoots = rootNode && (rootRole === 'rootwebarea' || rootRole === 'webarea') && rootNode.childIds
? rootNode.childIds.map((childId) => {
return buildRawSnapshotTree({ nodeId: childId, axById, isNodeInScope })
}).filter(isTruthy)
: [buildRawSnapshotTree({ nodeId: rootAxNodeId, axById, isNodeInScope })].filter(isTruthy)
const filtered = rawRoots.flatMap((rawNode) => {
if (interactiveOnly) {
return filterInteractiveSnapshotTree({
node: rawNode,
ancestorNames: [],
labelContext: false,
refFilter,
domByBackendId,
createRefForNode,
}).nodes
}
return filterFullSnapshotTree({
node: rawNode,
ancestorNames: [],
refFilter,
domByBackendId,
createRefForNode,
}).nodes
})
snapshotNodes = filtered
}
const snapshotLines = buildSnapshotLines(snapshotNodes)
const shortRefMap = buildShortRefMap({ refs })
const finalized = finalizeSnapshotOutput(snapshotLines, snapshotNodes, shortRefMap)
const refsWithShortRef: Array<AriaRef & { selector?: string }> = refs.map((entry) => {
return {
...entry,
shortRef: shortRefMap.get(entry.ref) ?? entry.ref,
}
})
const result = { snapshot: finalized.snapshot, tree: finalized.tree, refs: refsWithShortRef }
// Build refToElement map
const refToElement = new Map<string, { role: string; name: string; shortRef: string }>()
const refToSelector = new Map<string, string>()
for (const { ref, role, name, shortRef } of result.refs) {
if (!refFilter || refFilter({ role, name })) {
refToElement.set(ref, { role, name, shortRef })
}
}
for (const { ref, selector } of result.refs) {
if (!selector) {
continue
}
refToSelector.set(ref, selector)
}
const snapshot = result.snapshot
const getSelectorForRef = (ref: string): string | null => {
const mapped = refToSelector.get(ref)
if (mapped) {
return mapped
}
const info = refToElement.get(ref)
if (!info) {
return null
}
const escapedName = info.name.replace(/"/g, '\\"')
return `role=${info.role}[name="${escapedName}"]`
}
const getRefsForLocators = async (locators: Array<Locator | ElementHandle>): Promise<Array<AriaRef | null>> => {
if (locators.length === 0) {
return []
}
const targetHandles = await Promise.all(
locators.map(async (loc) => {
try {
return 'elementHandle' in loc
? await (loc as Locator).elementHandle({ timeout: 1000 })
: (loc as ElementHandle)
} catch {
return null
}
})
)
const matchingRefs = await page.evaluate(
({ targets, refData }) => {
return targets.map((target) => {
if (!target) {
return null
}
const testIdAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-pw', 'data-qa', 'data-e2e', 'data-automation-id']
for (const attr of testIdAttrs) {
const value = target.getAttribute(attr)
if (value) {
const match = refData.find((ref) => {
return ref.ref === value || ref.ref.startsWith(value)
})
if (match) {
return match.ref
}
}
}
const id = target.getAttribute('id')
if (id) {
const match = refData.find((ref) => {
return ref.ref === id || ref.ref.startsWith(id)
})
if (match) {
return match.ref
}
}
return null
})
},
{
targets: targetHandles,
refData: result.refs,
}
)
return matchingRefs.map((ref) => {
if (!ref) {
return null
}
const info = refToElement.get(ref)
return info ? { ...info, ref } : null
})
}
return {
snapshot,
tree: result.tree,
refs: result.refs,
refToElement,
refToSelector,
getSelectorForRef,
getRefsForLocators,
getRefForLocator: async (loc) => (await getRefsForLocators([loc]))[0],
getRefStringForLocator: async (loc) => (await getRefsForLocators([loc]))[0]?.ref ?? null,
}
} finally {
if (scopeApplied && scopeLocator) {
await scopeLocator.evaluate((element, attr) => {
element.removeAttribute(attr)
}, scopeAttr)
}
if (oopifSessionId) {
await session.send('Target.detachFromTarget', { sessionId: oopifSessionId }).catch(() => {})
}
if (!cdp) {
await session.detach()
}
}
}
function buildBoxFromQuad(quad?: number[]): LabelBox | null {
if (!quad || quad.length < 8) {
return null
}
const xs = [quad[0], quad[2], quad[4], quad[6]]
const ys = [quad[1], quad[3], quad[5], quad[7]]
const left = Math.min(...xs)
const right = Math.max(...xs)
const top = Math.min(...ys)
const bottom = Math.max(...ys)
return {
x: left,
y: top,
width: Math.max(0, right - left),
height: Math.max(0, bottom - top),
}
}
function isTruthy<T>(value: T): value is NonNullable<T> {
return Boolean(value)
}
async function getLabelBoxesForRefs({
page,
refs,
wsUrl,
maxConcurrency = MAX_LABEL_POSITION_CONCURRENCY,
logger,
cdp,
}: {
page: Page
refs: AriaRef[]
wsUrl?: string
maxConcurrency?: number
logger?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }
cdp?: ICDPSession
}): Promise<AriaLabel[]> {
const log = logger?.info ?? logger?.error ?? console.error
const session = cdp || await getCDPSessionForPage({ page, wsUrl })
const sema = new Sema(maxConcurrency)
const labelRefs = refs.filter((ref) => {
return Boolean(ref.backendNodeId) && INTERACTIVE_ROLES.has(ref.role)
})
log(`[getLabelBoxesForRefs] processing ${labelRefs.length} interactive refs (concurrency: ${maxConcurrency})`)
const startTime = Date.now()
let completed = 0
let timedOut = 0
let failed = 0
try {
const labels = await Promise.all(
labelRefs.map(async (ref) => {
if (!ref.backendNodeId) {
return null
}
await sema.acquire()
try {
const response = await Promise.race([
session.send('DOM.getBoxModel', { backendNodeId: ref.backendNodeId }) as Promise<Protocol.DOM.GetBoxModelResponse>,
new Promise<null>((resolve) => {
setTimeout(() => { resolve(null) }, BOX_MODEL_TIMEOUT_MS)
}),
])
completed++
if (completed % 50 === 0 || completed === labelRefs.length) {
log(`[getLabelBoxesForRefs] progress: ${completed}/${labelRefs.length} (${timedOut} timeouts, ${failed} errors) - ${Date.now() - startTime}ms`)
}
if (!response) {
timedOut++
return null
}
const box = buildBoxFromQuad(response.model.border)
if (!box) {
return null
}
return { ref: ref.ref, role: ref.role, box }
} catch (error) {
completed++
failed++
return null
} finally {
sema.release()
}
})
)
log(`[getLabelBoxesForRefs] done: ${completed} completed, ${timedOut} timeouts, ${failed} errors - ${Date.now() - startTime}ms`)
return labels.filter(isTruthy)
} finally {
if (!cdp) {
await session.detach()
}
}
}
/**
* Show Vimium-style labels on interactive elements.
* 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.
* Call this function again if the page HTML changes to get fresh labels.
*
* @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 [submit-btn] label on "Submit" button
* await page.locator('[data-testid="submit-btn"]').click()
* ```
*/
export async function showAriaRefLabels({ page, locator, interactiveOnly = true, wsUrl, logger }: {
page: Page
locator?: Locator
interactiveOnly?: boolean
wsUrl?: string
logger?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }
}): Promise<{
snapshot: string
labelCount: number
}> {
const startTime = Date.now()
const log = logger?.info ?? logger?.error ?? console.error
log(`[showAriaRefLabels] starting...`)
await ensureA11yClient(page)
log(`[showAriaRefLabels] ensureA11yClient: ${Date.now() - startTime}ms`)
const cdpStart = Date.now()
const cdp = await getCDPSessionForPage({ page, wsUrl })
log(`[showAriaRefLabels] getCDPSessionForPage: ${Date.now() - cdpStart}ms`)
try {
const snapshotStart = Date.now()
const { snapshot, refs } = await getAriaSnapshot({ page, locator, interactiveOnly, wsUrl, cdp })
const shortRefMap = new Map(refs.map((entry) => {
return [entry.ref, entry.shortRef]
}))
const interactiveRefs = refs.filter((ref) => Boolean(ref.backendNodeId) && INTERACTIVE_ROLES.has(ref.role))
log(`[showAriaRefLabels] getAriaSnapshot: ${Date.now() - snapshotStart}ms (${refs.length} refs, ${interactiveRefs.length} interactive)`)
const rootHandle = locator ? await locator.elementHandle() : null
const labelsStart = Date.now()
const labels = await getLabelBoxesForRefs({ page, refs, wsUrl, logger, cdp })
const shortLabels = labels.map((label) => {
return {
...label,
ref: shortRefMap.get(label.ref) ?? label.ref,
}
})
log(`[showAriaRefLabels] getLabelBoxesForRefs: ${Date.now() - labelsStart}ms (${labels.length} boxes)`)
const renderStart = Date.now()
const labelCount = await page.evaluate(({ entries, root, interactiveOnly: intOnly }) => {
const a11y = (globalThis as {
__a11y?: {
renderA11yLabels?: (labels: typeof entries) => number
computeA11ySnapshot?: (options: { root: unknown; interactiveOnly: boolean; renderLabels: boolean }) => { labelCount: number }
}
}).__a11y
if (a11y?.renderA11yLabels) {
return a11y.renderA11yLabels(entries)
}
if (a11y?.computeA11ySnapshot) {
const rootElement = root || document.body
return a11y.computeA11ySnapshot({ root: rootElement, interactiveOnly: intOnly, renderLabels: true }).labelCount
}
throw new Error('a11y client not loaded')
}, { entries: shortLabels, root: rootHandle, interactiveOnly })
log(`[showAriaRefLabels] renderA11yLabels: ${Date.now() - renderStart}ms (${labelCount} labels)`)
log(`[showAriaRefLabels] total: ${Date.now() - startTime}ms`)
return { snapshot, labelCount }
} finally {
await cdp.detach()
}
}
/**
* Remove all aria ref labels from the page.
*/
export async function hideAriaRefLabels({ page }: { page: Page }): Promise<void> {
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()
}
})
}
/**
* Take a screenshot with accessibility labels overlaid on interactive elements.
* 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 ref from the snapshot to interact with elements
* await page.locator('[data-testid="submit-btn"]').click()
* ```
*/
export async function screenshotWithAccessibilityLabels({ page, locator, interactiveOnly = true, wsUrl, collector, logger }: {
page: Page
locator?: Locator
interactiveOnly?: boolean
wsUrl?: string
collector: ScreenshotResult[]
logger?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }
}): Promise<void> {
const log = logger?.info ?? logger?.error
const showLabelsStart = Date.now()
const { snapshot, labelCount } = await showAriaRefLabels({ page, locator, interactiveOnly, wsUrl, logger })
if (log) {
log(`showAriaRefLabels: ${Date.now() - showLabelsStart}ms`)
}
// Generate unique filename with timestamp
const timestamp = Date.now()
const random = Math.random().toString(36).slice(2, 6)
const filename = `playwriter-screenshot-${timestamp}-${random}.jpg`
// Use ./tmp folder (gitignored) instead of system temp
const tmpDir = path.join(process.cwd(), 'tmp')
if (!fs.existsSync(tmpDir)) {
fs.mkdirSync(tmpDir, { recursive: true })
}
const screenshotPath = path.join(tmpDir, filename)
// Get viewport size to clip screenshot to visible area
const viewport = await page.evaluate('({ width: window.innerWidth, height: window.innerHeight })') as { width: number; height: number }
// Max 1568px on any edge (larger gets auto-resized by Claude, adding latency)
// Token formula: tokens = (width * height) / 750
const MAX_DIMENSION = 1568
// Check if sharp is available for resizing
const sharp = await sharpPromise
// Clip dimensions: if sharp unavailable, limit capture area to MAX_DIMENSION
const clipWidth = sharp ? viewport.width : Math.min(viewport.width, MAX_DIMENSION)
const clipHeight = sharp ? viewport.height : Math.min(viewport.height, MAX_DIMENSION)
// Take viewport screenshot with scale: 'css' to ignore device pixel ratio
const screenshotStart = Date.now()
const rawBuffer = await page.screenshot({
type: 'jpeg',
quality: 80,
scale: 'css',
clip: { x: 0, y: 0, width: clipWidth, height: clipHeight },
})
if (log) {
log(`page.screenshot: ${Date.now() - screenshotStart}ms`)
}
// Resize with sharp if available, otherwise use clipped raw buffer
const resizeStart = Date.now()
const buffer = await (async () => {
if (!sharp) {
logger?.error?.('[playwriter] sharp not available, using clipped screenshot (max', MAX_DIMENSION, 'px)')
return rawBuffer
}
try {
return await sharp(rawBuffer)
.resize({
width: MAX_DIMENSION,
height: MAX_DIMENSION,
fit: 'inside', // Scale down to fit, preserving aspect ratio
withoutEnlargement: true, // Don't upscale small images
})
.jpeg({ quality: 80 })
.toBuffer()
} catch (err) {
logger?.error?.('[playwriter] sharp resize failed, using raw buffer:', err)
return rawBuffer
}
})()
if (log) {
log(`screenshot resize: ${Date.now() - resizeStart}ms`)
}
// Save to file
fs.writeFileSync(screenshotPath, buffer)
// Convert to base64
const base64 = buffer.toString('base64')
// Hide labels
await hideAriaRefLabels({ page })
// Add to collector array
collector.push({
path: screenshotPath,
base64,
mimeType: 'image/jpeg',
snapshot,
labelCount,
})
}
// Re-export for backward compatibility
export { getAriaSnapshot as getAriaSnapshotWithRefs }