Install
npm install --save-dev a11ied vitest
Vitest is an optional peer dependency of a11ied, range
>=4.0.0, so the package installs and runs without it.
a11ied/vitest exports test and it with the
sr fixture, the three expect matchers, and everything
a11ied/test exports.
The sr fixture
The exported test provides sr, a
QueuedScreenReader. The fixture starts it from the srOptions
fixture before each test and stops it after the test, whether the test passed or
failed. Override the options for a file with
test.extend({ srOptions: { url } }), or for a block with
test.scoped.
import { test } from 'a11ied/vitest';
const checkout = test.extend({
srOptions: { url: 'http://localhost:3000/checkout' },
}); srOptions takes the ScreenReaderOptions fields:
sr (virtual by default, voiceover, or
nvda), a url or inline html, mode,
engine, recordingPath, timeoutMs, and
idleTimeoutMinutes. The default mode, in-process, runs the
screen reader inside the test process, so tests in parallel workers do not compete for
the one active a1 sr session.
The command queue
A method call queues a command and returns at once. The queued commands run one at a
time in the order they were queued. A test calls the methods with no
await.
Await a call when the test needs its value, such as
const item = await sr.read();. That await runs every command queued before
it, then that call, and gives its value.
Each call returns a chain with every method on it, so commands and checks may follow
one another with dots. expect starts a check, and and adds
another. The chain checks what expectSpoken,
expectSpokenInOrder, and expectCursorOn check, under the
names spoken, spokenInOrder, and cursorOn.
sr.goTo({ role: 'button', name: 'Is it unstyled?' })
.checkpoint('enter')
.press('Enter')
.expect.spoken(/expanded$/u, { since: 'enter' })
.and.cursorOn({ role: 'button', name: 'Is it unstyled?' });
A check waits. It reads the reader again every 100 ms until it passes or
timeoutMs runs out, 5000 ms by default, so an announcement or a focus move
that lands a moment after the key that caused it still counts, and a test needs no pause
before it. Pass timeoutMs: 0 to check once. A check with not
never waits: absence now is its answer. A failure after waiting says how long it waited.
Await a call the test expects to fail, such as
await expect(sr.expectSpoken('Refund')).rejects.toThrow('Refund');. The
rejection goes to that await and the queue runs the next command.
When a command throws and nothing awaited it, every command queued after it is skipped
and rejects with the same error, and the test fails. The fixture records each queued
command on the test task. After the test function returns, Vitest awaits those and
reports each rejection as an error of the test, with the message and a code frame at
the line that queued the command. The error keeps its class,
ScreenReaderAssertionError for a failed check, its message,
and its expected, phrases, and exitCode fields.
A failed check in a test over a native <select> labeled Select country,
run with the simulated screen reader, prints:
FAIL combobox.test.ts > combobox component > announces active option and expanded state
ScreenReaderAssertionError: "option, Canada, 1 of 3" was not announced in the transcript (5 phrases checked).
The reader said:
"document"
"main"
"Select country"
"combobox, Select country, Australia, has popup listbox, not expanded"
"option, Australia, not selected, position 1, set size 3"
> combobox.test.ts:23:10
21| sr.expectSpoken('combobox, Select country');
22| sr.next();
23| sr.expectSpoken('option, Canada, 1 of 3');
| ^ Timeouts
Queued commands run inside Vitest's testTimeout, 5000 ms by default, and
before afterEach. A test that queues a long sequence needs a longer
testTimeout. Pass it as the third argument to one test, or set it in
vitest.config.ts for the run. timeoutMs in
srOptions bounds each single command.
When the test times out or the run is canceled, Vitest aborts the test's signal and the queue skips every command not yet started. The command already running finishes on its own.
Matchers
Importing a11ied/vitest installs three matchers on expect and
adds their types.
toHaveSpoken(match, { since, not })An announcement matches. The failure message is the one expectSpoken throws.toHaveSpokenInOrder([a, b, c])Each match is found after the previous one, with any announcements between. The failure says which match it did not find and which announcement it looked for after.toHaveCursorOn({ role, name })The element under the cursor has the role, the name, or both. The failure says which element the cursor is on.
Given a ScreenReader or a QueuedScreenReader, each matcher
reads the transcript first, so it returns a promise: await it. Given an array of
announcements, transcript entries, or the element read() returned, it
answers at once. The matchers check once. The reader methods
expectSpoken, expectCursorOn, and
expectSpokenInOrder check the same things without an await,
and wait for them.
For an announcement the page makes later, pass the transcript to
expect.poll. It retries the matcher until Vitest's poll timeout, 1000 ms by
default, and takes the timeout and interval options.
sr.wait({ for }) does the same from the reader.
await expect.poll(() => sr.transcript()).toHaveSpoken('Draft saved'); 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.
// combobox.test.ts
import { describe, expect } from 'vitest';
import { test } from 'a11ied/vitest';
const combobox = test.extend({
srOptions: { url: 'http://localhost:3000/components/combobox' },
});
describe('combobox component', () => {
combobox('announces active option and expanded state', ({ sr }) => {
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');
});
combobox('reports the option under the cursor', async ({ sr }) => {
sr.press('Tab');
sr.press('ArrowDown');
const item = await sr.read();
expect(item).toMatchObject({ role: 'option', name: 'Canada' });
});
}); Vitest browser mode
In Vitest browser mode the test already runs in a real browser, so
a11ied/browser starts the simulated screen reader on that page. There is no
separate process, no second page load, and no open(): mount the component, then read it.
The reader covers the document body, or container.
// vitest.config.ts
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
},
},
}); // pay-button.browser.test.ts
import { test } from 'a11ied/browser';
import { render } from 'vitest-browser-react';
import { PayButton } from './pay-button';
test('announces the price', ({ sr }) => {
render(<PayButton amount="12.00" />);
sr.next('button');
sr.expectCursorOn({ role: 'button', name: 'Pay 12.00' });
});
The exported test provides an sr fixture over the document
body, a QueuedScreenReader like the one in a11ied/vitest, and
a11ied/browser installs the same three matchers. A test that starts its own
reader over one mounted component calls
queuedScreenReader({ container }), or screenReader({ container })
for the unqueued ScreenReader.
@vitest/browser-playwright needs a Chromium that
npx playwright install chromium provides.
Two methods are not available in browser mode. open() throws, because the
page is the test's own. perform() throws too, because the command registry
that resolves names loads Guidepup's Node-only tables. Every typed method is available.
Other runners
Under Jest, Mocha, Playwright Test, or a script, a11ied/test exports
queuedScreenReader and screenReader and depends on no test
runner. The queue rules above are the same there. await using, or
await sr.stop() in the cleanup hook, runs every queued command, stops the
reader, and rethrows a failure nobody awaited. See the
testing guide.
