Accessibility test toolkit

Write screen reader tests

Start a screen reader from a test, move through the page one element at a time, and assert on what the screen reader announced. a11ied/test depends on no test runner and works under Vitest, Jest, Mocha, Playwright Test, or a script. a11ied/vitest and a11ied/browser have their own guide.

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?)
The next or previous element, or a jump by heading, link, landmark, control, button, table, list, graphic, region, or form-field. Options: level for headings, times.
top(), bottom(), interact(), stopInteracting(), activate(), escape()
The portable verbs.
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 })
Move the cursor to the next element whose text contains text, or forward until the current element has the role, the name, or both. Both throw when nothing matches.
elements(kind), readAll(), walk()
Every element of one kind from the top of the page, every element from the cursor to the end, and the same from the top. Each returns the elements announced, capped by 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 })
Another page in the same session.
checkpoint(label), transcript({ since, tail })
Mark a point, and read the timestamped entries since it.
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()
A named command from 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.