Skip to content

Commit 3e5a058

Browse files
DavertMikclaude
andcommitted
refactor(aria): render accessibility trees as-is instead of mimicking Playwright
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f1c5875 commit 3e5a058

2 files changed

Lines changed: 50 additions & 150 deletions

File tree

‎lib/aria.js‎

Lines changed: 23 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -261,83 +261,35 @@ function diffAriaSnapshots(previous, current) {
261261
// Build: accessibility tree → YAML snapshot
262262
// ─────────────────────────────────────────────────────────────────
263263

264-
const TRANSPARENT_ROLES = new Set(['RootWebArea', 'WebArea', 'generic', 'none', 'presentation', 'Ignored', 'LabelText', 'Section', 'strong', 'emphasis', 'superscript', 'subscript', 'mark', 'time', 'abbr'])
265-
const DROPPED_ROLES = new Set(['InlineTextBox', 'LineBreak', 'ListMarker'])
266-
const NAME_FROM_CONTENT_ROLES = new Set(['button', 'link', 'heading', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'treeitem', 'cell', 'gridcell', 'columnheader', 'rowheader', 'switch', 'checkbox', 'radio'])
267-
const VALUE_ROLES = new Set(['textbox', 'searchbox', 'combobox', 'spinbutton', 'slider'])
268-
const ROLE_ALIASES = { image: 'img', StaticText: 'text' }
269-
270-
function textOf(node) {
271-
if (node.role === 'StaticText') return node.name || ''
272-
return (node.children || []).map(textOf).join(' ')
264+
const HIDDEN_ROLES = new Set(['RootWebArea', 'generic', 'none', 'LabelText', 'InlineTextBox', 'LineBreak', 'ListMarker'])
265+
266+
function renderAXTree(node, depth = 0, parentName = '') {
267+
if (!node) return []
268+
const indent = ' '.repeat(depth)
269+
const name = (node.name || '').trim()
270+
if (node.role === 'StaticText') return name && name !== parentName ? [`${indent}- text: ${JSON.stringify(name)}`] : []
271+
const hidden = HIDDEN_ROLES.has(node.role)
272+
const children = (node.children || []).flatMap(child => renderAXTree(child, hidden ? depth : depth + 1, hidden ? parentName : name))
273+
if (hidden) return children
274+
const line = `${indent}- ${node.role}${name ? ` ${JSON.stringify(name)}` : ''}`
275+
return [children.length ? `${line}:` : line, ...children]
273276
}
274277

275-
function quoteValue(value) {
276-
if (/^[\s\-?:,[\]{}#&*!|>'"%@`]|: | #|\s$/.test(value)) return JSON.stringify(value)
277-
return value
278-
}
279-
280-
function toAriaNodes(node) {
281-
if (!node || DROPPED_ROLES.has(node.role)) return []
282-
if (node.role === 'StaticText') {
283-
const text = (node.name || '').trim()
284-
if (!text) return []
285-
return [{ role: 'text', attributes: {}, text, value: quoteValue(text), children: [] }]
286-
}
287-
288-
let children = (node.children || []).flatMap(toAriaNodes)
289-
if (TRANSPARENT_ROLES.has(node.role)) return children
290-
291-
let name = (node.name || '').trim()
292-
if (!name && NAME_FROM_CONTENT_ROLES.has(node.role)) name = textOf(node).replace(/\s+/g, ' ').trim()
293-
if (name) children = children.filter(child => child.role !== 'text' || !name.includes(child.text))
294-
295-
const attributes = {}
296-
if (node.level && node.role === 'heading') attributes.level = node.level
297-
for (const key of ['checked', 'pressed']) {
298-
if (node[key] === true || node[key] === 'true') attributes[key] = true
299-
if (node[key] === 'mixed') attributes[key] = 'mixed'
300-
}
301-
for (const key of ['disabled', 'expanded', 'selected']) {
302-
if (node[key] === true || node[key] === 'true') attributes[key] = true
303-
}
304-
305-
const ariaNode = { role: ROLE_ALIASES[node.role] || node.role, name: name.replace(/"/g, '\\"'), attributes, children }
306-
if (VALUE_ROLES.has(node.role) && node.value !== undefined && node.value !== null && String(node.value).trim()) {
307-
ariaNode.value = quoteValue(String(node.value).trim())
308-
}
309-
return [ariaNode]
310-
}
311-
312-
function renderSnapshot(nodes, depth = 0) {
313-
return nodes
314-
.map(node => {
315-
const head = `${' '.repeat(depth)}- ${formatNode(node)}`
316-
if (node.children.length === 0) return head
317-
return `${head}:\n${renderSnapshot(node.children, depth + 1)}`
318-
})
319-
.join('\n')
320-
}
321-
322-
// Accepts a nested tree shaped like Puppeteer's SerializedAXNode: { role, name, value, level, checked, ..., children }
278+
// Nested tree shaped like Puppeteer's SerializedAXNode: { role, name, children }
323279
function ariaSnapshotFromTree(root) {
324-
return renderSnapshot(toAriaNodes(root))
280+
return renderAXTree(root).join('\n')
325281
}
326282

327-
// Accepts the flat `nodes` list returned by CDP `Accessibility.getFullAXTree`
328-
function ariaSnapshotFromAXNodes(axNodes, rootBackendNodeId) {
283+
// Flat `nodes` list from CDP `Accessibility.getFullAXTree`, rooted at a DOM node
284+
function ariaSnapshotFromAXNodes(axNodes, backendNodeId) {
329285
const byId = new Map(axNodes.map(node => [node.nodeId, node]))
330-
const build = axNode => {
331-
const node = { role: axNode.role?.value, name: axNode.name?.value, value: axNode.value?.value }
332-
if (axNode.ignored) node.role = 'Ignored'
333-
for (const prop of axNode.properties || []) node[prop.name] = prop.value?.value
334-
node.children = (axNode.childIds || []).map(id => byId.get(id)).filter(Boolean).map(build)
335-
return node
336-
}
337-
let root = axNodes.find(node => node.role?.value === 'RootWebArea') || axNodes[0]
338-
if (rootBackendNodeId !== undefined) root = axNodes.find(node => node.backendDOMNodeId === rootBackendNodeId)
339-
if (!root) return ''
340-
return ariaSnapshotFromTree(build(root))
286+
const toTree = node => ({
287+
role: node.ignored ? 'none' : node.role?.value,
288+
name: node.name?.value,
289+
children: (node.childIds || []).map(id => byId.get(id)).filter(Boolean).map(toTree),
290+
})
291+
const root = axNodes.find(node => node.backendDOMNodeId === backendNodeId)
292+
return root ? ariaSnapshotFromTree(toTree(root)) : ''
341293
}
342294

343295
export { diffAriaSnapshots, compactAriaSnapshot, ariaSnapshotFromTree, ariaSnapshotFromAXNodes }

‎test/unit/aria_test.js‎

Lines changed: 27 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,38 @@
11
import { expect } from 'chai'
22
import { ariaSnapshotFromAXNodes, ariaSnapshotFromTree, compactAriaSnapshot, diffAriaSnapshots } from '../../lib/aria.js'
33

4-
const role = value => ({ type: 'role', value })
5-
const name = value => ({ type: 'computedString', value })
6-
const prop = (n, value) => ({ name: n, value: { type: 'token', value } })
7-
8-
const lightpandaNodes = [
9-
{ nodeId: '1', backendDOMNodeId: 1, role: role('RootWebArea'), name: name('Checkout'), childIds: ['2'] },
10-
{ nodeId: '2', backendDOMNodeId: 2, role: role('none'), childIds: ['3', '5', '7', '8'] },
11-
{ nodeId: '3', backendDOMNodeId: 3, role: role('heading'), name: name('Checkout form'), properties: [{ name: 'level', value: { type: 'integer', value: 4 } }], childIds: ['4'] },
12-
{ nodeId: '4', backendDOMNodeId: 4, role: role('StaticText'), name: name('Checkout form'), childIds: [] },
13-
{ nodeId: '5', backendDOMNodeId: 5, role: role('form'), name: name(''), childIds: ['6', '9'] },
14-
{ nodeId: '6', backendDOMNodeId: 6, role: role('textbox'), name: name('Promo code'), value: { type: 'string', value: 'SALE' }, properties: [prop('invalid', 'false')], childIds: [] },
15-
{ nodeId: '7', backendDOMNodeId: 7, role: role('checkbox'), name: name('Same address'), value: { type: 'string', value: 'on' }, properties: [prop('checked', 'true')], childIds: [] },
16-
{ nodeId: '8', backendDOMNodeId: 8, role: role('paragraph'), childIds: ['10'] },
17-
{ nodeId: '9', backendDOMNodeId: 9, role: role('button'), name: name('Redeem'), childIds: ['11'] },
18-
{ nodeId: '10', backendDOMNodeId: 10, role: role('StaticText'), name: name('Total: $20'), childIds: [] },
19-
{ nodeId: '11', backendDOMNodeId: 11, role: role('StaticText'), name: name('Redeem'), childIds: [] },
20-
]
21-
22-
const obscuraNodes = [
23-
{ nodeId: '1', ignored: false, role: role('RootWebArea'), childIds: ['2'], backendDOMNodeId: 1 },
24-
{ nodeId: '2', ignored: false, role: role('generic'), childIds: ['3', '5'], backendDOMNodeId: 2 },
25-
{ nodeId: '3', ignored: false, role: role('button'), properties: [{ name: 'focusable', value: { type: 'boolean', value: true } }], childIds: ['4'], backendDOMNodeId: 3 },
26-
{ nodeId: '4', ignored: false, role: role('StaticText'), name: { type: 'string', value: 'Redeem' }, backendDOMNodeId: 4 },
27-
{ nodeId: '5', ignored: false, role: role('heading'), properties: [{ name: 'level', value: { type: 'integer', value: 2 } }], childIds: ['6'], backendDOMNodeId: 5 },
28-
{ nodeId: '6', ignored: false, role: role('LabelText'), childIds: ['7'], backendDOMNodeId: 6 },
29-
{ nodeId: '7', ignored: false, role: role('StaticText'), name: { type: 'string', value: 'Billing address' }, backendDOMNodeId: 7 },
4+
const axNode = (nodeId, role, name, childIds = []) => ({ nodeId, backendDOMNodeId: Number(nodeId), role: { value: role }, name: name === undefined ? undefined : { value: name }, childIds })
5+
6+
const axNodes = [
7+
axNode('1', 'generic', undefined, ['2', '4', '6']),
8+
axNode('2', 'heading', 'Checkout', ['3']),
9+
axNode('3', 'StaticText', 'Checkout'),
10+
axNode('4', 'form', '', ['5', '7']),
11+
axNode('5', 'textbox', 'Promo code'),
12+
axNode('6', 'StaticText', 'Total: $20'),
13+
axNode('7', 'button', undefined, ['8']),
14+
axNode('8', 'StaticText', 'Redeem'),
3015
]
3116

3217
describe('aria', () => {
33-
describe('#ariaSnapshotFromAXNodes', () => {
34-
it('renders a Lightpanda accessibility tree as an ARIA snapshot', () => {
35-
expect(ariaSnapshotFromAXNodes(lightpandaNodes)).to.equal(
36-
[
37-
'- heading "Checkout form" [level=4]',
38-
'- form:',
39-
' - textbox "Promo code": SALE',
40-
' - button "Redeem"',
41-
'- checkbox "Same address" [checked]',
42-
'- paragraph:',
43-
' - text: "Total: $20"',
44-
].join('\n'),
45-
)
46-
})
47-
48-
it('names buttons and headings from their text when the engine leaves names empty', () => {
49-
expect(ariaSnapshotFromAXNodes(obscuraNodes)).to.equal(['- button "Redeem"', '- heading "Billing address" [level=2]'].join('\n'))
50-
})
51-
52-
it('scopes the snapshot to an element by its backend node id', () => {
53-
expect(ariaSnapshotFromAXNodes(lightpandaNodes, 5)).to.equal(['- form:', ' - textbox "Promo code": SALE', ' - button "Redeem"'].join('\n'))
54-
})
55-
56-
it('returns an empty snapshot when the element is not in the tree', () => {
57-
expect(ariaSnapshotFromAXNodes(lightpandaNodes, 999)).to.equal('')
58-
})
59-
60-
it('produces snapshots that compact and diff like Playwright ones', () => {
61-
const before = ariaSnapshotFromAXNodes(lightpandaNodes)
62-
const after = ariaSnapshotFromAXNodes(lightpandaNodes.filter(node => node.nodeId !== '9'))
63-
expect(compactAriaSnapshot(before)).to.include('button "Redeem"')
64-
expect(diffAriaSnapshots(before, after)).to.include('removed: 1 interactive elements')
65-
})
18+
it('renders a CDP accessibility tree from a DOM node', () => {
19+
expect(ariaSnapshotFromAXNodes(axNodes, 1)).to.equal(
20+
['- heading "Checkout"', '- form:', ' - textbox "Promo code"', ' - button:', ' - text: "Redeem"', '- text: "Total: $20"'].join('\n'),
21+
)
22+
expect(ariaSnapshotFromAXNodes(axNodes, 4)).to.equal(['- form:', ' - textbox "Promo code"', ' - button:', ' - text: "Redeem"'].join('\n'))
23+
expect(ariaSnapshotFromAXNodes(axNodes, 999)).to.equal('')
6624
})
6725

68-
describe('#ariaSnapshotFromTree', () => {
69-
it('renders a Puppeteer accessibility snapshot', () => {
70-
const tree = {
71-
role: 'RootWebArea',
72-
name: 'Login',
73-
children: [
74-
{ role: 'heading', name: 'Sign "in"', level: 1, children: [{ role: 'StaticText', name: 'Sign "in"', children: [{ role: 'InlineTextBox', name: 'Sign "in"' }] }] },
75-
{ role: 'textbox', name: 'Email', value: '' },
76-
{ role: 'checkbox', name: 'Remember me', checked: 'mixed' },
77-
{ role: 'button', name: 'Submit', disabled: true },
78-
{ role: 'image', name: 'Logo' },
79-
],
80-
}
81-
expect(ariaSnapshotFromTree(tree)).to.equal(
82-
['- heading "Sign \\"in\\"" [level=1]', '- textbox "Email"', '- checkbox "Remember me" [checked=mixed]', '- button "Submit" [disabled]', '- img "Logo"'].join('\n'),
83-
)
84-
})
26+
it('renders a Puppeteer accessibility tree', () => {
27+
const tree = { role: 'RootWebArea', children: [{ role: 'button', name: 'Submit', children: [{ role: 'StaticText', name: 'Submit' }] }] }
28+
expect(ariaSnapshotFromTree(tree)).to.equal('- button "Submit"')
29+
expect(ariaSnapshotFromTree(null)).to.equal('')
30+
})
8531

86-
it('returns an empty snapshot for an empty tree', () => {
87-
expect(ariaSnapshotFromTree(null)).to.equal('')
88-
})
32+
it('produces snapshots that compact and diff', () => {
33+
const before = ariaSnapshotFromAXNodes(axNodes, 1)
34+
const after = ariaSnapshotFromAXNodes(axNodes.filter(node => node.nodeId !== '5'), 1)
35+
expect(compactAriaSnapshot(before)).to.include('textbox "Promo code"')
36+
expect(diffAriaSnapshots(before, after)).to.include('removed: 1 interactive elements')
8937
})
9038
})

0 commit comments

Comments
 (0)