Install
npm install --save-dev a11ied a11ied/testscreenReader(), queuedScreenReader(), and the ScreenReader class. It depends on no test runner and works under Vitest, Jest, Mocha, Playwright Test, or a script.a11ied/vitestThe same, plus expect matchers and a test whose sr fixture is a queued screen reader. See Test with Vitest.a11ied/browserThe same again, for a component the test mounted in Vitest browser mode. See Test with Vitest.
A test that opens a URL runs the simulated screen reader inside a headless Chromium page,
so the page's scripts run. Chrome, Edge, Brave, or Chromium on the machine is used first, and
npx playwright install chromium adds one when none is installed. Inline
HTML runs in jsdom and needs no browser.
Start a screen reader
import { screenReader } from 'a11ied/test';
await using sr = await screenReader({ url: 'http://localhost:3000/checkout' }); screenReader takes sr (virtual by default,
voiceover, or nvda), a url or inline
html, and mode. The default mode, in-process, runs
the screen reader inside the test process and never touches the per-user state directory, so
tests in parallel workers do not compete for the one active a1 sr session.
mode: 'broker' starts the detached process that a1 sr shares
instead, so a1 sr transcript in another terminal reads the same session.
The ScreenReader object implements Symbol.asyncDispose. With await using, the
session stops when the block ends, whether the test passed or threw. A runner without
explicit resource management calls await sr.stop() in its cleanup hook
instead. Every call after a stop throws session-stopped.
sr.session reports what you got: sr, mode,
url, and for the simulated screen reader the engine, which is
browser for a Playwright page and jsdom for inline HTML or a
machine with no Chromium.
queuedScreenReader takes the same options and returns a
QueuedScreenReader. One await starts it. After that a method
call queues a command and returns at once, and the queued commands run one at a time in
the order they were queued. Await a call only when the test needs its value.
import { queuedScreenReader } from 'a11ied/test';
await using sr = await queuedScreenReader({ url: 'http://localhost:3000/checkout' });
sr.goTo({ role: 'button', name: 'Pay' });
sr.activate();
sr.expectSpoken(/order placed/iu);
const item = await sr.read(); await using calls sr.stop(), which runs every queued command,
stops the reader even when one of them threw, and then rethrows that error.
sr.drain() waits for the queued commands without stopping the reader.
sr.session is a plain property on a queued reader and needs no
await. sr.reader is the underlying ScreenReader,
for a call that has to run before the queue empties.
Move, type, and read
The methods mirror the sr subcommands. A screen reader moves through a page
one element at a time: a heading, a link, a button, a form field, a paragraph, a
landmark (a section such as main or navigation). Every method
that moves the cursor or sends input returns the announcement, the text the screen
reader spoke when it reached the element.
next(kind?, options?), previous(kind?, options?)heading, link, landmark, control, button, table, list, graphic, region, or form-field. Options: level for headings, times.top(), bottom(), interact(), stopInteracting(), activate(), escape()press(...chords), type(text)press('Tab', 'Enter') sends two chords in order.read()The element under the cursor: role, name, value, states, level, phrase, and source, which says where each field came from.title()The page title, or the window summary on VoiceOver and the window title on NVDA.find(text), goTo({ role, name })text, or forward until the current element has the role, the name, or both. Both throw when nothing matches.elements(kind), readAll(), walk()max.table(move)next-cell and the other moves return the announcement. row-header and column-header return the header text.open(url), open({ html })checkpoint(label), transcript({ since, tail })wait({ for, timeoutMs })Polls the transcript for an announcement made after the call and returns it, or throws at the timeout, 5000 ms by default. wait({ ms }) pauses.perform(command), screenshot(path), focus()a1 sr list, a VoiceOver cursor screenshot, and a refocus of the session's window.
A QueuedScreenReader has every one of these methods, with the same
parameters. Each returns a thenable instead of a promise, and the command runs after
the ones queued before it.
Assertions
await sr.checkpoint('opened');
await sr.activate();
await sr.expectSpoken('dialog, Preferences', { since: 'opened' });
await sr.expectSpoken(/error/iu, { not: true });
await sr.expectCursorOn({ role: 'textbox', name: 'Nickname' });
await sr.expectSpokenInOrder(['dialog, Preferences', 'textbox, Nickname'], { since: 'opened' }); expectSpoken takes text, matched without regard to case, or a
RegExp. since searches only the announcements after that
checkpoint, and not passes when nothing matches. On failure it throws a
ScreenReaderAssertionError whose message says what was expected, how many
announcements were searched, and what the screen reader said:
ScreenReaderAssertionError: "button, Pay now" was not announced in the transcript (3 phrases checked).
The reader said:
"document"
"heading, Sign up, level 1"
"button, Create account"
The error has exitCode: 4, the CLI's assertion code, plus
expected and phrases. A script can set
process.exitCode from it.
expectCursorOn({ role, name }) throws when the element under the cursor does not have
the role or the name given. The message says which element the cursor is on, the same
text the toHaveCursorOn matcher prints. expectSpokenInOrder([a, b, c], { since })
throws when a match is not found after the previous one, with the same text the
toHaveSpokenInOrder matcher prints. Both throw
ScreenReaderAssertionError.
Each check waits. It reads the reader again every 100 ms until it passes or
timeoutMs runs out, 5000 ms by default, so an announcement that lands a
moment after the key that caused it still counts. Pass timeoutMs: 0 to
check once. A check with not never waits.
Example test file
The page is a combobox labeled Select country with three options, Canada first. Arrow Down from the collapsed combobox expands it and moves to the first option. The dev server runs on port 3000.
import { queuedScreenReader } from 'a11ied/test';
it('announces active option and expanded state', async () => {
await using sr = await queuedScreenReader({
url: 'http://localhost:3000/components/combobox',
});
sr.press('Tab');
sr.expectSpoken('combobox, Select country, collapsed');
sr.press('ArrowDown');
sr.expectSpoken('combobox, Select country, expanded');
sr.expectSpoken('option, Canada, 1 of 3');
});
Under Vitest the same test drops queuedScreenReader and
await using for the sr fixture. See
Test with Vitest.
VoiceOver and NVDA
screenReader({ sr: 'voiceover', url }) opens the URL in the system browser,
waits for that window to come to the front, starts VoiceOver, and refocuses the window,
the same steps a1 sr start takes. VoiceOver needs the one-time
a1 setup on the machine, takes over the screen while the test runs, and
reads whatever is in front, so run these tests one at a time and never in a worker pool.
Prefer mode: 'broker' there: a session that outlives a crashed test can
be stopped with a1 sr stop.
