Accessibility test toolkit

Test with a screen reader

Start a screen reader session, move through the page one element at a time, and check what the screen reader announced. The same commands drive VoiceOver, NVDA, and the simulated screen reader.

Reader and browser support

These combinations describe the available integrations. A detected browser version confirms installation; it does not prove that a website works with that reader. Run a reader smoke check against the build being assessed and keep its transcript.

Reader and hostBrowserAvailable checks and limits
VoiceOver on macOSChrome, Edge, Brave, ChromiumGuidepup controls the real reader with observed target checks. Playwright supports separate DOM scans in Chromium browsers. Correlate scans and reader observations with the tested page and state.
VoiceOver on macOSSafariSelect Safari with --browser Safari. The current scan integration uses Chromium; a scan in another browser does not prove Safari behavior.
NVDA on WindowsChrome, Edge, Brave, ChromiumCommands check the observed foreground target before controlling the installed NVDA bundle. DOM scans use Chromium. Acceptance still requires a Windows transcript; macOS and simulated tests cannot verify this pairing.
Virtual reader on any supported hostHeadless Chromium or jsdomSimulation for development. jsdom does not run page scripts. Neither engine provides real assistive technology evidence.
VoiceOver on Windows or Linux; NVDA on macOS or LinuxAnyUnsupported: these readers require their native operating system.
Other readers or browser combinationsIncluding FirefoxNo verified audit integration. Opening an application by name does not establish support for a complete assessment.

Start a session

A session is a running screen reader that later a1 sr commands control. sr start takes the page URL as its argument and the screen reader as --sr: voiceover, nvda, or virtual.

For VoiceOver or NVDA, a11ied opens the URL in a browser, waits until that window is in front, after starting the screen reader under its desktop ownership lease. --browser chrome, edge, brave, chromium, or an app name such as Safari picks the browser. Without it, a11ied uses the system automation browser.

Native sessions default to guarded. Commands check the observed foreground target, and typing checks between characters. Read the fresh current item, focus observations, and transcript after each action. If focus changes, refocus deliberately and inspect state before continuing. require-binding is an optional strict mode that refuses unavailable native binding. development bypasses target checks. Foreground checks do not guarantee that focus cannot change during dispatch.

a1 sr start --sr voiceover http://localhost:3000/checkout --json
a1 sr start --sr voiceover --browser Safari http://localhost:3000/checkout

--app <name> reads a native app that is already open instead of a page. The session brings the app to the front by name and waits for it to get there.

a1 sr start --sr voiceover --app Calendar

--sr virtual starts the simulated screen reader: a screen reader implemented in software that runs inside a headless Chromium page and produces the same kind of announcements as VoiceOver or NVDA, with neither installed.

a1 sr start --sr virtual http://localhost:3000/dialog.html
Session ready

  Target:        virtual
  Engine:        browser (a headless Chromium page; the page scripts run)
  URL:           http://localhost:3000/dialog.html
  Recording:     none
  Idle timeout:  30 min

Every sr command now uses this session. Stop it with: a1 sr stop

Target: is the screen reader that started. Engine: is browser when the page runs in headless Chromium, so a live region, a part of the page marked to announce its own content changes, is heard when a script fills it, and a dialog is heard when a click opens it. When no Chrome, Edge, Brave, or Chromium is installed, the engine is jsdom, an in-memory document where page scripts do not run, and only what is in the markup is announced. See screen readers for the details of both engines.

Commands use the active session. A second sr start stops that session and reports its replacement. To retry cleanup for a retained recovery owner, use a1 sr stop --session-id <id>. Failed browser focus confirmation returns browser-focus-unconfirmed and cancels startup.

In library code, only one in-process JSDOM session can own the document. Starting another returns virtual-session-conflict without changing the existing reader. Stop that reader or use a separate process or browser engine.

Move and read

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. The element under its cursor is the current element. Each time the cursor moves, the screen reader announces the element it reached. The CLI prints that announcement as Phrase:, and the element's text as Item:.

a1 sr next
a1 sr next heading
sr next  virtual
  Phrase:  main
  Item:    none
  Role:    main

sr next heading  virtual
  Phrase:  heading, Dialog demo, level 1
  Item:    Dialog demo
  Role:    heading level 1
  Name:    Dialog demo

The first command moved the cursor onto the main landmark, which has no text of its own. The second jumped to the next heading and printed its level and name.

sr next, sr previous
Move the cursor to the next or previous element and print the announcement.
sr top, sr bottom
Move the cursor to the first or last element of the document, or of the group the cursor is inside.
sr interact, sr stop-interacting, sr activate
Enter the group or control under the cursor, leave it, or press the element under the cursor as a click would.
sr escape Press Escape, which closes a menu or a dialog.
sr read Print the current element without moving: role, name, value, states, the announcement, and which of those came from the page and which from the announcement.
sr title Print the page title: document.title on the simulated screen reader, the window summary (VO-F2) on VoiceOver, the window title (NVDA-T) on NVDA.
sr find "Learn more" Move the cursor to the next element whose text contains the argument. Exits 4 when no element does. Needs an active session.
sr press Tab Tab Press key chords in order, one chord per argument.
sr type "test@example.com" Type into the focused form field.
sr open <url> Load another page in the running session.
a1 sr read
sr read  virtual
  Phrase:  heading, Dialog demo, level 1
  Item:    Dialog demo
  Role:    heading level 1
  Name:    Dialog demo
  Source:  virtual: role and name from the active node (dom-accessibility-api), states from its attributes

Source: says where the role, name, and states came from. The simulated screen reader reads them from the DOM node under its cursor. VoiceOver reports the element's text and an announcement in the form name, role, so the role and states are parsed from the announcement and the name is the element's text. NVDA reports only the announcement, so all three are parsed from it.

With --json, every response has a state object with lastSpokenPhrase (the announcement), currentItemText (the element's text), currentItem (the role, name, value, states, and heading level, plus a source field), the running spokenPhraseLog (every announcement so far), and the timestamped transcript. state.observations reports availability separately for keyboard focus, reader-cursor identity, and target identity. An unavailable observation includes a reason and, for query failures, an error code. Keyboard-focus properties can differ from the reader's current item. Native reader-cursor identity is unsupported by the installed reader connection; speech text alone cannot prove which native element or document holds that cursor.

Add --phrase to print the announcement alone, one line, for shell loops. Add --timeout <ms> to bound how long the screen reader command may take.

Navigate by heading, link, or landmark

Screen reader users do not read pages one element at a time. They jump by heading, link, or landmark: a section of the page marked with a role such as main, navigation, or banner, so a screen reader can skip straight to it. sr next <kind> and sr previous <kind> do the same on every screen reader. The kinds are heading, link, landmark, control, button, table, list, graphic, region, and form-field.

a1 sr next heading
a1 sr next heading --level 2
a1 sr previous link
a1 sr next control --times 3

sr elements <kind> works like the VoiceOver rotor or the NVDA elements list: it moves the cursor to the top of the page and prints every element of one kind with its announcement. It stops at the end of the page or at --max, which defaults to 200, and says which. elements, read-all, goto, and find need an active session and exit with missing-session without one. Run sr start --sr virtual <url> first for the simulated screen reader, since a plain sr start starts VoiceOver on macOS.

sr elements heading  virtual
4 heading elements from the top of the page.

1. heading level 1: Structure page  heading, Structure page, level 1
2. heading level 2: Products  heading, Products, level 2
3. heading level 3: Pricing  heading, Pricing, level 3
4. heading level 2: Sign up  heading, Sign up, level 2

Stopped at the end of the document.

Each kind maps to Guidepup's jump method or a VoiceOver Commander command, to an NVDA single-letter key, and to a virtual.commands.moveToNext* command. The simulated screen reader has no command for button, control, table, list, graphic, and form-field, so it moves element by element until the announced role matches, and reports moved: false when the page has none.

sr goto --role button --name "Open dialog" moves the cursor forward until the current element has that role, that name, or both, and exits 4 when nothing matches before the end of the page.

sr goto --role button --name Open dialog  virtual
  Phrase:  button, Open dialog
  Item:    Open dialog
  Role:    button
  Name:    Open dialog
  Found:   yes

Inside a table, sr table next-cell, previous-cell, next-row, previous-row, next-column, and previous-column move between cells. sr table column-header and row-header print the headers of the current cell. VoiceOver moves with VO-arrows inside an interacted table and reads the column header with VO-C. NVDA moves with Control-Alt-arrows and speaks headers as the cursor enters a cell, so the two header commands exit 2 on NVDA with that explanation.

Read the whole page

Two commands read a page. sr walk reads a whole one and sets up the session for you. sr read-all reads from wherever the cursor already is, in a session you already started.

sr read-all moves the cursor element by element from its current position to the end of the document, bounded by --max (default 500), and prints the transcript: every announcement in order, with a timestamp each. It says whether it stopped at the end or at the cap.

sr walk [url] does all of this in one command: it starts a session when none is active, or loads the URL in the active one, moves the cursor to the top, runs read-all, and prints the transcript. With no session and no --sr, walk starts VoiceOver on macOS. Pass --sr virtual for the simulated screen reader instead.

a1 sr walk http://localhost:3000/dialog.html --sr virtual
a1 sr walk --max 40 --out ./transcripts/checkout-walk.md
! No session was active, so one was started.
# Transcript: virtual on http://localhost:3000/dialog.html

Started 2026-09-06T11:07:17.840Z. Exported 2026-09-06T11:07:18.044Z. 5 phrases.

1. [11:07:18.043] main
2. [11:07:18.043] heading, Dialog demo, level 1 (Dialog demo)
3. [11:07:18.043] button, Open dialog (Open dialog)
4. [11:07:18.043] end of main
5. [11:07:18.043] end of document

Stopped at the end of the document.

Each numbered line is one element the cursor stopped at and what the screen reader said there. The text in parentheses is the element's text, when it has any.

Transcript assertions

sr expect searches the transcript for an announcement and exits 4 when none matches. Plain text matches without regard to case. /pattern/flags is a regular expression. --since <checkpoint> searches only the announcements after that checkpoint, and --not passes when nothing matches.

a1 sr expect "dialog, Preferences" --since opened
a1 sr expect /error/i --not
sr expect
  Expected:  "dialog, Preferences"
  Checked:   2 phrases since "opened"
  Matched:   dialog, Preferences, modal
  Result:    pass

sr wait --for <text|/regex/> polls the transcript until an announcement made after the wait began matches, and exits 4 at --timeout (default 5000 ms). It reads the whole transcript rather than the last announcement, so an announcement that arrives and is replaced before the next poll still counts.

Use it for an announcement that arrives later than the command that caused it, such as a live region a network response fills, or the delay before VoiceOver finishes speaking. Use --since <checkpoint> to include announcements after a checkpoint, including those that arrived before the wait command started. Without --for, wait pauses for --ms.

a1 sr checkpoint before-save
a1 sr press Enter
a1 sr wait --for /saved/i --since before-save --timeout 3000
Exit codes

find, goto, wait --for, expect, and a batch with a failed expect exit 4. Usage errors, including a command the screen reader cannot run or a command run with no active session, exit 2. A browser or broker process that fails to start exits 3.

Run a batch of actions

sr batch [file] reads one action per line, from the file or from stdin, and runs them all over one connection to the session process. Each line is the JSON request the MCP sr_action tool takes, or an expect line. Blank lines and lines starting with # are skipped. The batch stops at the first failed expect unless --continue is set.

a1 sr batch <<'EOF'
{"action":"goto","payload":{"role":"button","name":"Open dialog"}}
{"action":"checkpoint","payload":{"label":"opened"}}
{"action":"activate"}
{"action":"expect","payload":{"match":"Preferences","since":"opened"}}
{"action":"escape"}
{"action":"expect","payload":{"match":"Open dialog"}}
EOF
sr batch
✓ 1. goto {"role":"button","name":"Open dialog","max":500}  button, Open dialog
✓ 2. checkpoint {"label":"opened"}  button, Open dialog
✓ 3. activate  textbox, Nickname
✓ 4. expect Preferences since "opened"  pass
✓ 5. escape  button, Open dialog
✓ 6. expect Open dialog  pass

6 steps passed.

The checkpoint runs before activate, not after, so expect --since searches the dialog's announcement. An announcement already printed by the triggering action happened before a later checkpoint and would not count.

Run named screen reader commands

sr do runs a command by name. The portable set works on every screen reader and resolves through the same table the subcommands use: next, previous, interact, stop-interacting, activate, top, bottom, escape, and next-<kind> and previous-<kind> for every kind above.

a1 sr do interact --json
a1 sr do next-heading --json
a1 sr do stop-interacting --json

VoiceOver and NVDA add their own sets. sr list prints them grouped by command set and by what the commands do. Start with --query: the full list is over 400 lines.

a1 sr list --query heading
a1 sr list --query "VO-Command" --sr voiceover

When two sets share a name, prefix it with the set: sr do voiceover-keycode:move-to-next or sr do voiceover-commander:move-right. --command-set picks one set for the whole call. The simulated screen reader accepts the portable set only.

macOS VoiceOver

VoiceOver Commander vs. Keycodes

When driving VoiceOver on macOS, a11ied supports two distinct command sets for controlling the screen reader: VoiceOver Commander (voiceover-commander) and VoiceOver Keycodes (voiceover-keycode). Choose a command set based on whether the test needs VoiceOver commands or simulated keystrokes.

What is VoiceOver Commander?

VoiceOver Commander is an internal, semantic automation interface built directly into macOS VoiceOver. Instead of simulating physical keys, a11ied communicates directly with VoiceOver via Apple Events (AppleScript):

tell application "VoiceOver"
   tell commander to perform command "move down"
end tell

Because Commander interacts directly with VoiceOver's internal automation dictionary:

  • Layout-independent: It is completely immune to physical or virtual keyboard layout differences (e.g., QWERTY, Dvorak, AZERTY, or international input sources).
  • Modifier-independent: It does not require synthesizing or holding the VoiceOver modifier keys (Control+Option or Caps Lock) and is unaffected by the VO-Lock state (VO-;).
  • Fast and deterministic: Commands execute as direct AppleScript transactions without waiting for OS keyboard event queues, key-repeat delays, or modifier release transitions.
  • Default choice: In a11ied, --command-set auto resolves to voiceover-commander for all VoiceOver actions because it provides maximum reliability.

What are VoiceOver Keycodes?

VoiceOver Keycodes control VoiceOver by simulating literal hardware key events through the macOS System Events API (or native CoreGraphics accessibility event taps). When you issue a keycode command like move-to-next, a11ied instructs macOS to simulate pressing the physical VoiceOver chord:

tell application "System Events"
   key code 124 using {control down, option down} -- Right Arrow with VO keys
end tell

Keycodes replicate the physical user experience of pressing keys on an Apple keyboard:

  • Physical fidelity: Simulates physical key-down, modifier hold, and key-up event cycles, identical to a human pressing Control+Option+ArrowRight.
  • Keyboard layout dependent: Keycodes correspond to physical key positions (scan codes). Mismatches between hardware layouts or active OS input sources can affect command interpretation.
  • Focus sensitive: Requires the target application window to be frontmost and actively receiving input focus. If focus shifts during key dispatch, keystrokes may be lost or misrouted.
  • Timing considerations: Subject to OS event loop dispatch latency and modifier state synchronization.

Comparison & Contrast

The table compares how the two command sets execute commands.

Execution path Commander sends semantic AppleScript IPC straight to VoiceOver.app; Keycodes send synthetic key events to macOS System Events.
VO modifier keys Commander requires no modifier keys or VO-lock; Keycodes explicitly simulate holding Control+Option.
Keyboard layout Commander is 100% layout-independent; Keycodes depend on macOS keyboard mapping and physical scan codes.
Speed & determinism Commander executes instantaneously in synchronous transactions; Keycodes require event-queue dispatch and key stabilization.
Window focus requirement Commander commands the VoiceOver cursor directly; Keycodes require the target window to be active and frontmost to receive input.
Recommended for Commander suits scripted screen reader commands in CI and test suites; Keycodes suit tests of keyboard shortcuts and custom chord mappings.

Using both in the CLI

By default, a1 sr do runs Commander commands on macOS. To run a keycode command, either qualify the command name or set --command-set voiceover-keycode:

# Run via VoiceOver Commander (default)
a1 sr do move-right

# Run explicitly via VoiceOver Keycodes
a1 sr do voiceover-keycode:move-to-next

# Select the keycode command set for the invocation
a1 sr do move-to-next --command-set voiceover-keycode

# Press literal key chords directly
a1 sr press "VO+ArrowRight"

Use a1 sr list --sr voiceover to explore the complete catalog of available commands across both sets.

Checkpoints and transcripts

A checkpoint is a named marker in the transcript. sr transcript --since and sr expect --since then read only the announcements after it.

a1 sr checkpoint "opened the dialog"
a1 sr next
a1 sr transcript --since "opened the dialog"

Each transcript entry has an index, an ISO timestamp, the announcement, and the element's text when the screen reader reported one. The Markdown output numbers the announcements 1, 2, 3 and prints a checkpoint as a heading between them. --tail <count> keeps the last N announcements. Checkpoints between those announcements are also included. To cap all entries, use --limit <count> (up to 1000). For index paging, use --after-index <index>; -1 starts at the beginning. Index paging defaults to 200 entries. Use transcript.window.nextAfterIndex from the JSON output for the next page. hasMore reports whether more selected entries remain. complete reports whether this response includes every session entry. Use a cursor only with the session that returned it. Selected responses omit duplicate raw speech logs and include only their checkpoints. A transcript without selection options includes every retained entry. --out <path> writes the transcript to a file, as JSON for a .json path and as Markdown for a .md path. Pass --format json or --format md to pick the format yourself.

a1 sr transcript --out ./transcripts/checkout.md

Stream announcements in real time with sr tail

sr tail streams announcements continuously in real time as the screen reader speaks them, similar to tail -f on a log file. It prints existing entries (default last 10, or --lines 0 to start fresh) and then outputs every new spoken phrase as it arrives.

a1 sr tail
a1 sr tail --lines 0
a1 sr tail --since "opened the dialog"
a1 sr tail --json

--phrase outputs just the spoken text without timestamp headers. --json streams structured newline-delimited JSON (NDJSON) objects, one per line. Aliases for tail are follow, stream, and watch.

On VoiceOver, sr screenshot <path> saves a picture of the element the cursor is on. NVDA has no cursor screenshot and the simulated screen reader has no screen, so both exit 2 and say so.

Example: a dialog

The page has one heading and a button named Open dialog. The button opens a modal dialog named Preferences and moves focus to its first field. This transcript comes from sr start, sr next, sr next heading, sr goto --role button --name "Open dialog", sr checkpoint opened, and sr activate, in that order, followed by sr transcript.

# Transcript: virtual on http://localhost:3000/dialog.html

Started 2026-09-06T11:07:38.042Z. Exported 2026-09-06T11:07:44.590Z. 6 phrases.

1. [11:07:38.042] document
2. [11:07:38.876] main
3. [11:07:39.749] heading, Dialog demo, level 1 (Dialog demo)
4. [11:07:41.203] button, Open dialog (Open dialog)

## opened (11:07:41.880)

5. [11:07:42.555] dialog, Preferences, modal (Preferences)
6. [11:07:42.555] textbox, Nickname (Nickname)

Lines 1 to 4 are the elements the cursor stopped at on the way to the button. The opened heading is the checkpoint, placed before activate. Lines 5 and 6 are what the screen reader announced when the button was pressed: the dialog, with its name and its modal state, and then the text field that received focus. Three things to check in a transcript like this. Every control has a name, so a line that reads button alone is a defect. The order matches the visual order. The states that matter are announced, which is why modal appears on line 5.

For a validation message, submit the form with sr press Enter, then run sr wait --for /error/i to find out whether the message was announced. Status Messages is criterion 4.1.3, and its test method is hybrid because the announcement is the part a person has to judge.

Stop the session

a1 sr stop --out ./transcripts/checkout.json

Stopping quits the screen reader process, finishes any recording, and deletes the session state file. A session left running keeps VoiceOver or NVDA under automation control, so stop each one you start. A session nobody has used for 30 minutes stops itself. Pass --idle-timeout <minutes> on sr start to change that, or --idle-timeout 0 to turn it off.

sr stop --out writes the transcript on the way out. When the session recorded video, a11ied also writes the Markdown transcript next to the recording file, so you can read the two together. When cleanup is unconfirmed, retry sr stop --session-id <id> with the session ID from the error. The owner blocks further input until cleanup succeeds.

For a single action with no session, add --ephemeral to press, type, or do. It starts a session, runs the action, and stops the session in one command. Native actions use a detached broker so a failed shutdown can be retried after the command exits.