diff options
| author | eunhhu <[email protected]> | 2026-09-28 13:10:25 +0100 |
|---|---|---|
| committer | eunhhu <[email protected]> | 2026-09-28 13:10:25 +0100 |
| commit | 0e9a48699dac240665ecea96b9a244dbde5dcac8 (patch) | |
| tree | 954b83f484de982a6570799e7ca63c4df0c60bd7 /tests | |
| parent | 202e6bf4677565c0cfbef5340119d0f9674bf17d (diff) | |
| download | vega-0e9a48699dac240665ecea96b9a244dbde5dcac8.tar.gz vega-0e9a48699dac240665ecea96b9a244dbde5dcac8.zip | |
Make the web surface accessible the way the say surface is, checked in Chromium's accessibility tree
Lists are listboxes that keep the keyboard focus, the focused row their
active descendant, each option with its place in the whole list though
only the visible rows are drawn; tables are grids with row indices;
tones other than normal and muted are said, not only coloured; keys are
named buttons. The page now updates in place instead of rebuilding each
list, so the focus a screen reader follows survives every redraw. The
browser test reads Chromium's accessibility tree: roles, names, focus,
selection and the active descendant moving within the same list.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/browser.mjs | 45 |
1 files changed, 44 insertions, 1 deletions
diff --git a/tests/browser.mjs b/tests/browser.mjs index 32cf65f..0f77585 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -1,6 +1,9 @@ // The web surface in a real browser: headless Chromium, driven over the // DevTools protocol with what a user does (keys, a click on a row, typing -// Korean text and Enter), each step waiting for the page to show its effect. +// Korean text and Enter), each step waiting for the page to show its effect; +// and what a screen reader gets from it, read from Chromium's accessibility +// tree: roles, names, the list's focus and selection, each option's place in +// the whole list, and a focused list that survives redraws. // // node tests/browser.mjs URL (the app: examples/todo.aup with tasks a, b, c) // Prints one JSON line: the steps that held. @@ -71,9 +74,42 @@ try { }; await until('three tasks, the last focused', `document.querySelectorAll('li').length === 3 && ${selected} === 2`); + await send('Accessibility.enable'); + const role = n => n.role?.value, name = n => n.name?.value; + const prop = (n, key) => n.properties?.find(p => p.name === key)?.value; + const tree = async () => { + const nodes = (await send('Accessibility.getFullAXTree')).nodes.filter(n => !n.ignored); + const listbox = nodes.find(n => role(n) === 'listbox'); + const options = nodes.filter(n => role(n) === 'option'); + const active = prop(listbox, 'activedescendant')?.relatedNodes?.[0]?.backendDOMNodeId; + return { nodes, listbox, options, active }; + }; + const check = (what, holds, seen) => { + if (!holds) throw new Error(`${what}: ${JSON.stringify(seen)}`); + steps.push(what); + }; + let a = await tree(); + const heading = a.nodes.find(n => role(n) === 'heading'); + check('the title is a heading of level 1', heading && name(heading) === 'To do' && prop(heading, 'level')?.value === 1, + heading); + check('the list is a focused listbox named for its view', name(a.listbox) === 'tasks_list' && + prop(a.listbox, 'focused')?.value === true, a.listbox); + // Set size and position are not in the DevTools tree: read what Chromium maps to the platform's. + const places = `[...document.querySelectorAll('[role=option]')].map(o => o.getAttribute('aria-posinset') + '/' + o.getAttribute('aria-setsize')).join()`; + check('its options are the tasks, each knowing its place in the whole list', a.options.map(name).join() === 'a,b,c' && + await evaluate(places) === '1/3,2/3,3/3', [a.options.map(name), await evaluate(places)]); + check('the focused task is selected and the active descendant', a.options.map(o => prop(o, 'selected')?.value).join() === + 'false,false,true' && a.active === a.options[2].backendDOMNodeId, a.options); + const keys = a.nodes.filter(n => role(n) === 'button' && name(n)); + check('keys are named buttons', keys.length >= 2 && keys.some(k => /up/i.test(name(k))), keys.map(name)); + const list = a.listbox.backendDOMNodeId; await until('title and field', `document.querySelector('h1').textContent === 'To do' && document.querySelector('dd').textContent === '3 of 3'`); await key('ArrowUp', 'ArrowUp', 38); await until('ArrowUp moves the focus', `${selected} === 1`); + a = await tree(); + check('a screen reader follows it: the same list, still focused, its active descendant moved', + a.listbox.backendDOMNodeId === list && prop(a.listbox, 'focused')?.value === true && + a.active === a.options[1].backendDOMNodeId && prop(a.options[1], 'selected')?.value === true, a.options); const box = await evaluate(`(() => { const r = document.querySelectorAll('li')[0].getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; })()`); await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: box[0], y: box[1], button: 'left', clickCount: 1 }); await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: box[0], y: box[1], button: 'left', clickCount: 1 }); @@ -86,6 +122,13 @@ try { await key('Enter', 'Enter', 13, '\r'); await until('typed text becomes a task', `document.querySelectorAll('li').length === 4 && document.querySelectorAll('li')[3].textContent === '브라우저' && ${selected} === 3`); await until('the input is emptied', `document.getElementById('entry').value === ''`); + a = await tree(); + const done = a.options[0]; + check('the added task is option 4 of 4, and the done one still says so', a.options.length === 4 && + await evaluate(places) === '1/4,2/4,3/4,4/4' && name(a.options[3]) === '브라우저' && /done/.test(name(done)), + [a.options.map(name), await evaluate(places)]); + const entry = a.nodes.find(n => role(n) === 'textbox'); + check('the text input is named for its input', entry && name(entry) === 'add', entry); await finish(true); } catch (error) { await finish(false, String(error.message || error)); |
