screen-reader-cli
v0.11.0
Published
Screen reader testing CLI — scan pages for accessibility violations with axe-core + Virtual Screen Reader, drive real VoiceOver/NVDA, generate regression tests
Maintainers
Readme
screen-reader-cli
A command-line screen reader testing tool. Scan any page for accessibility violations, generate regression tests, or drive a real screen reader (VoiceOver/NVDA) programmatically.
New here, or not a developer? Start with the friendly guide: elizabeth1979.github.io/screen-reader-cli — what the tool does, who it's for, and how to run your first check in three steps.
Powered by Virtual Screen Reader, Guidepup, axe-core, and Playwright.
What it does
- Scans pages for screen-reader-specific violations (heading skips, missing alt text, missing accessible names, hidden focusable elements, missing landmarks, missing form labels) + axe-core rules
- Drives real screen readers — VoiceOver on Mac, NVDA on Windows — to hear what gets announced
- Generates regression tests — Playwright Test or Vitest files from scan results
- Visual reports — HTML reports with issue tables, heading structure, DOM reading order, and screenshots
- Navigates by element, heading, landmark, link, or form (virtual mode)
- Works offline — everything runs locally, no CDN dependencies
Platform support
| Command | macOS | Windows | Linux |
| ------------------------------------------------ | ----- | ------- | ----- |
| scan, audit, page, nav, speak, repl | ✅ | ✅ | ✅ |
| screenshot, --visual reports | ✅ | ✅ | ✅ |
| live (real VoiceOver/NVDA) | ✅ | ✅ | ❌ |
Everything except live uses a virtual screen reader in headless Chromium and
runs anywhere Node 20+ and Playwright do — including CI. live drives the OS
screen reader, which only exists on macOS (VoiceOver) and Windows (NVDA).
Install
# Requires Node.js 20+
npm install -g screen-reader-cli
npx playwright install chromium # one-time browser downloadThen type sr to open the dashboard, or use the screenreader CLI directly.
git clone https://github.com/Elizabeth1979/screen-reader-cli.git
cd screen-reader-cli
npm install
npx playwright install chromium
npm linkTesting backward navigation
Some defects only appear in reverse. A focus guard that catches focus without direction awareness behaves perfectly going forward and misbehaves only going back, so a forward-only traversal reports a clean pass on a broken page.
screenreader live read <url> --direction backward # walk to the end, then reverse
screenreader live read <url> --direction both # both, and compareboth prints each log and names any element one direction reached and the other
did not — the shape of a reverse-navigation wrap. It stops short of judging
ordering, because a screen reader legitimately phrases the same element
differently depending on which way you arrive at it.
macOS 27 note. Live mode cannot start VoiceOver on macOS 27. The driver launches it through a hardcoded path that no longer exists there, and the newest release has the same path (guidepup#149). This is not a permissions problem and not fixable here.
The CLI checks for this before launching anything and exits immediately with an explanation, rather than timing out and leaving VoiceOver half-started. It tests for the missing launcher rather than the OS version, so live mode starts working again on its own once the driver adds support — nothing to change here.
scanandaudituse a virtual screen reader and are unaffected.
Staying up to date
When a newer version is published, the CLI mentions it once a day:
▲ Update available 0.4.0 → 0.5.0
Run npm install -g screen-reader-cli
▁▃▅▇▅▃▁▃▅▇▅▃▁ v0.5.0 is outIt only speaks when a person is watching. The notice is written to stderr and
suppressed whenever stderr is not an interactive terminal, so piped output,
--json results, CI logs and anything driving the CLI programmatically are
untouched. It is also skipped in CI, checked at most once a day, given a short
timeout so a slow registry never delays a command, and silently abandoned if
the network is unavailable.
To turn it off entirely, set NO_UPDATE_NOTIFIER to any value.
To check by hand at any time:
npm outdated -g screen-reader-cli # am I behind?
npm update -g screen-reader-cli # get the latestLive mode setup (optional — for real screen reader testing)
To use the live command with a real screen reader, run the one-time setup:
npx @guidepup/setupThis grants the OS permissions needed for screen reader automation:
- macOS: Enables VoiceOver's AppleScript API and adds your terminal to Accessibility permissions
- Windows: Configures NVDA for programmatic control
You only need to do this once per machine. The scan command (virtual mode) works without this step.
Quick start
# Scan a page for accessibility issues (the main command)
screenreader scan https://example.com
# JSON output (for CI pipelines)
screenreader scan https://example.com --json
# Visual HTML report (opens in browser)
screenreader scan https://example.com --visual
# Generate a Playwright regression test file
screenreader scan https://example.com --test
# Generate Vitest stubs instead
screenreader scan https://example.com --test --framework vitest
# Drive the real screen reader on a page
screenreader live read https://example.com
# Interactive REPL
screenreaderCommands
scan — The main command
Scans a page with axe-core (WCAG 2.0–2.2 A/AA + best practices) and adds the page's heading outline, landmarks and reading order — structure axe does not report.
screenreader scan <url> # Text report in terminal
screenreader scan <url> --json # JSON (for CI)
screenreader scan <url> --fail-on critical # Exit 1 if critical violations (CI gate)
screenreader scan <url> --visual # HTML report opens in browser
screenreader scan <url> --test # Generate Playwright test file (needs @axe-core/playwright)
screenreader scan <url> --test --framework vitest # Generate Vitest stubs
screenreader scan <url> --test --output my-tests.test.js # Custom output path
screenreader scan <url> --ai # AI analysis of results
screenreader scan <url> --ai --model sonnet # Use Claude Sonnet
screenreader scan <url> --ai --model gpt-4o # Use GPT-4o
screenreader scan <url> --ai --provider ollama # Use local model (free)What the screen reader says
Every finding shows what a screen reader user hears at that element. The scan walks the page once with the virtual screen reader, so you hear the defect instead of reading a rule name:
[CRITICAL] Buttons must have discernible text
Element: body > button → screen reader says "button"
[CRITICAL] Images must have alternative text
Element: img → screen reader reads it only as part of "button"
[CRITICAL] ARIA hidden element must not be focusable or contain focusable elements
Element: div → screen reader skips it — never announcedThe same line appears in the --visual report. In --json it is
element.announcement: the phrase, or null when the screen reader never
reaches the element, plus element.announcedWithin: true when the phrase
belongs to an ancestor read as one item. Page-level findings (<html>) have
no announcement. The walk is capped at 2,000 steps and 15 seconds; on a page
bigger than that, elements past the cap simply show no announcement.
Regression tests (--test)
Writes one Playwright test per axe rule the scan failed. Each test runs that
rule with @axe-core/playwright —
the same engine as the scan, so a test fails exactly when the scan would. It
stays red until the rule passes, then guards against regressions.
npm install -D @playwright/test @axe-core/playwright
screenreader scan https://example.com --test
npx playwright test a11y-regression.test.jsAI analysis (--ai)
Get plain-language explanations, prioritized fix suggestions with code examples, and a screen reader experience score. Supports multiple providers:
| Provider | Flag | Models | API Key |
| --------- | ---------------------- | -------------------------------------------- | -------------------------------------- |
| Gemini | --provider gemini | flash (default), pro | GEMINI_API_KEY (free tier available) |
| Anthropic | --provider anthropic | haiku, sonnet (default), opus | ANTHROPIC_API_KEY |
| OpenAI | --provider openai | gpt-4o-mini (default), gpt-4o, o3-mini | OPENAI_API_KEY |
| Ollama | --provider ollama | Any installed model | None (free, local) |
Auto-detects provider from model name: --model sonnet → Anthropic, --model gpt-4o → OpenAI, --model flash → Gemini. Unknown models default to Ollama. If no provider or model is specified, picks the first available API key (priority: Gemini → Anthropic → OpenAI → Ollama).
What it checks:
- Heading hierarchy (no skips, e.g. h1 → h3)
- Missing alt text on images
- Missing accessible names on buttons and links (including icon-only buttons/links)
- Missing form labels
- Missing main landmark
- Focusable elements inside
aria-hidden="true" - All axe-core WCAG 2.0–2.2 A/AA rules, each tagged with its success criterion (e.g.
4.1.2 (A))
Works with URLs and local files:
screenreader scan test/fixtures/violations.htmlCI usage (--fail-on)
--fail-on <severity> makes scan exit with code 1 when violations at or
above that severity are found, so it can gate a pipeline:
--fail-on critical— fail only on critical violations--fail-on moderate— fail on critical or moderate--fail-on minor— fail on any violation
GitHub Actions example:
jobs:
a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install -g screen-reader-cli
- run: npx playwright install --with-deps chromium
- name: Accessibility gate
run: screenreader scan https://staging.example.com --fail-on criticalCombine with --json to also archive the full results as a build artifact.
Gate only new issues (--baseline)
A page that already has issues can't turn on --fail-on without the build
going red on day one. Accept today's findings once, commit the file, and CI
fails only on what is added after:
# once: accept what is there today, then commit a11y-baseline.json
screenreader scan https://staging.example.com --baseline a11y-baseline.json --update-baseline
# in CI: fail only on findings that are not in the baseline
screenreader scan https://staging.example.com --baseline a11y-baseline.json --fail-on criticalBaseline a11y-baseline.json: 1 new, 12 known (not listed), 1 fixed
fixed: Images must have alternative text (img)
Run with --update-baseline to lock these in.
Found 1 new issues in 1 rules (1 critical, 0 moderate, 0 minor)
[CRITICAL] Buttons must have discernible text
Element: button:nth-child(8) → screen reader says "button"- A finding is matched by rule, selector and markup. It stays "known" when it only moves (new siblings shift its selector) or only its markup is edited; it is "new" when both change.
- Matching counts: a fourth copy of an accepted issue is new.
- A missing or unreadable baseline file fails the run rather than passing.
- When issues are fixed, re-run with
--update-baselineso they can't quietly come back. - In
--json, each violation carries"baseline": "new" | "known"and the result has abaselinesummary with thefixedlist. The--visualreport still shows every finding.
mcp — For AI coding assistants
Lets an AI coding assistant (Claude Code, Cursor, VS Code, …) check a page's screen reader experience while it writes the code, instead of someone running a scan after. It runs as an MCP server and gives the assistant two tools:
| Tool | What the assistant gets back |
| ----------- | -------------------------------------------------------------------------------------------- |
| scan_page | Each accessibility issue, its elements, and what a screen reader says there ("button") |
| read_page | The whole page as a screen reader reads it, in order: what a blind user hears |
Both take a URL (a local dev server works) or a path to an HTML file.
Claude Code:
claude mcp add screen-reader -- npx -y screen-reader-cli mcpAny other MCP client (Cursor, VS Code, Claude Desktop), in its MCP config:
{
"mcpServers": {
"screen-reader": {
"command": "npx",
"args": ["-y", "screen-reader-cli", "mcp"]
}
}
}Needs the Chromium download from the install step
(npx playwright install chromium).
dashboard — Point-and-click scanning
Prefer buttons over commands? Start the dashboard once and run every scan from your browser:
sr # shortcut — opens the dashboard
screenreader dashboard # same thing, full command
screenreader dashboard --port 5000 # pick another portsr is a short alias for the whole CLI: on its own it opens the dashboard,
and sr scan <url> works exactly like screenreader scan <url>.
Type a page address, press Scan, and the results appear with a link to the full visual report. A history of past scans is kept while the dashboard is running. Everything stays on your machine — the dashboard only listens on localhost.
live — Real screen reader testing
Drives VoiceOver (macOS) or NVDA (Windows) on a real page. Auto-detects your OS, or override with --reader.
# Read the full page — logs every announcement
screenreader live read <url>
screenreader live read <url> --json
screenreader live read <url> --steps 50 # Limit traversal steps
# Test mode — traverses and detects issues (empty announcements, focus traps)
screenreader live test <url>
screenreader live test <url> --json
# Interactive — opens browser + screen reader, keeps it running
screenreader live open <url>
# Force a specific reader
screenreader live read <url> --reader nvda
screenreader live read <url> --reader voiceoverRequires one-time setup: npx @guidepup/setup (see Install section above).
page — Page navigation
screenreader page open <url> # Open a URL or local HTML file
screenreader page info # Show current page title and URLnav — Navigate elements
screenreader nav next # Move to next element
screenreader nav previous # Move to previous element
screenreader nav heading # Jump to next heading
screenreader nav landmark # Jump to next landmark
screenreader nav link # Jump to next link
screenreader nav form # Jump to next formAll nav commands accept --url <url> to open a page first and --json for structured output.
speak — Query spoken phrases
screenreader speak last # Last spoken phrase
screenreader speak log --steps 10 # Navigate 10 steps, show log
screenreader speak find "Sign up" --url https://example.comaudit — Full page traversal
screenreader audit <url> # Traverse entire page (URL or local file path)
screenreader audit <url> --summary # Include heading/landmark summary
screenreader audit <url> --json # JSON output
screenreader audit <url> --max 1000 # Increase element limit (default: 500)
screenreader audit <url> --max 60 --record run.webm # Video: box on each element + what is saidAuditing mobile — --device and --user-agent
Both audit and scan send a desktop Chrome user agent by default. If a page
renders different markup for phones, that default traverses the desktop
branch and reports a clean result for a page that has a real defect — and a
false clean looks exactly like a real one.
screenreader audit <url> --device iphone # iPhone UA + 390x844 viewport
screenreader audit <url> --device android # Pixel UA + 412x915 viewport
screenreader scan <url> --device iphone
screenreader audit <url> --user-agent "Mozilla/5.0 (iPhone; ...)"--device sets the user agent, the viewport and touch emulation, because
responsive code branches on any of the three — changing only one still misses
the others. --user-agent overrides a preset's UA while keeping its viewport and
touch, so --device iphone --user-agent "..." is a phone-sized touch window with
your own UA.
With --chrome-profile, only the user agent is applied: that path opens a real,
visible browser window against a real profile, and resizing it is left alone
deliberately.
audit --summary and every scan report print the device and the exact user
agent that was sent, so a run always records the conditions it ran under.
--device desktop is not the same as passing nothing: it pins the viewport to
1280x800, where the default leaves Playwright's own size in place. Use it when a
run needs a fixed desktop size to compare against.
Real example. A design-system sheet adds a visually-hidden copy of its header only when the user agent looks like a phone, which exposes the title twice:
$ screenreader audit <url> --summary
Headings (0): # desktop branch — nothing found
$ screenreader audit <url> --summary --device iphone
Headings (2):
heading, Filters, level 2
heading, Filters, level 2 # the duplicateReaching a component's real state — --open, --type, --session-storage
A component that is closed, empty or logged-out reports clean, because there is
nothing in it to find. Both audit and scan accept the same flags for driving
a page into the state worth measuring first.
# Open an overlay before measuring it
screenreader audit <url> --open "button:has-text('Open')" --open-target "[role=dialog]"
screenreader scan <url> --open "#menu-trigger" --open-target "[role=menu]"
# Type into a field, for content that renders only once a query exists
screenreader audit <url> --open "#search" --type "#q=invoice"
# Seed browser storage an app reads on boot
screenreader scan <url> --local-storage "token=abc123"
screenreader scan <url> --session-storage 'recent=["invoice","vendor"]'| Flag | What it does |
| --- | --- |
| --open <selector> | Clicks to reveal an overlay. Comma-separate fallbacks; first match wins. |
| --open-target <selector> | Waits for this to appear before measuring — portal-mounted overlays arrive a beat after the click. |
| --open-wait <ms> | Fixed fallback wait when --open-target is absent or times out (default 900). |
| --type <selector>=<text> | Types into a field. Repeatable. Keys are sent one at a time, so components that branch on each keystroke react as they would for a person. |
| --type-wait <ms> | Wait after the last keystroke, for components that debounce or fetch (default 600). |
| --local-storage <key=value> | Seeds localStorage before any page script runs. Repeatable. |
| --session-storage <key=value> | Same for sessionStorage — the store used for prior-session data like recent searches. Repeatable. |
= inside a selector is safe: --type '[role=combobox]=hello' splits at the
separator outside the brackets, not the one inside them.
Why this matters more than it sounds. Traversing a closed modal produces:
$ screenreader audit <url> --summary
button, Open Modal, has popup dialog
end of documentThat is a clean result for a component nobody looked at. With the overlay open,
the traversal reaches the content the audit was actually about — and a search
component that renders nothing until you type stays invisible until --type
puts a query in it.
screenshot — Capture elements or pages
screenreader screenshot --url <url> --full --output page.png
screenreader screenshot --url <url> --navigate 5 --output element.pngrepl — Interactive mode
screenreader # Enters REPL (default when no command given)
screenreader repl # Same thing, explicitIn the REPL, navigation state persists across commands. You can also start a real screen reader session:
screenreader> page open https://example.com
Opened: Example Domain (https://example.com/)
screenreader> nav heading
heading, Example Domain, level 1
screenreader> nav next
More information...
screenreader> screenshot element.png
Screenshot saved: element.png
screenreader> live start
VoiceOver started. Use "live next", "live previous", "live log", etc.
screenreader> live next
Example Domain, heading level 1
screenreader> live log
1. Example Domain, heading level 1
screenreader> live stop
VoiceOver stopped.
screenreader> quitJSON output
All commands support --json for machine-readable output:
screenreader audit https://example.com --summary --json{
"phrases": ["document", "heading, Example Domain, level 1", "..."],
"total": 12,
"headings": ["heading, Example Domain, level 1"],
"landmarks": ["main"],
"links": ["link, More information..."],
"summary": {
"totalElements": 12,
"headingCount": 1,
"landmarkCount": 1,
"linkCount": 1
}
}How it works
Virtual mode (scan, page, nav, audit, etc.)
- Playwright launches a headless Chromium browser
- Virtual Screen Reader is injected into the page context for DOM traversal
- Custom violation checks run against the DOM (heading hierarchy, accessible names, etc.)
- axe-core is injected for comprehensive WCAG 2 AA rule coverage
- Results are merged and deduplicated
The Virtual Screen Reader implements the same W3C accessibility specifications that real screen readers follow — ACCNAME, CORE-AAM, HTML-AAM, WAI-ARIA 1.2, and more.
Live mode (live)
- Playwright launches a visible browser (screen readers need a real window)
- Guidepup starts VoiceOver (macOS) or NVDA (Windows)
- The screen reader traverses the page — you hear what it actually announces
- Results are captured via guidepup's API (
lastSpokenPhrase(),spokenPhraseLog())
Security notes
--chrome-profilereuses your real Chrome profile — cookies and logged-in sessions included. Only use it against URLs you trust (your own staging environments); scanning an untrusted site with it exposes your authenticated sessions to that site.- Virtual mode disables the target page's Content-Security-Policy
(
bypassCSP) so the Virtual Screen Reader bundle can be injected. This is required for the tool to work on CSP-strict sites, but it means the page runs with weaker protections during the scan — again, point it at pages you trust. - AI analysis (
--ai) sends scan results (violation messages, selectors, page title/URL) to the provider you select. Use--provider ollamato keep everything local.
Architecture
flowchart LR
CLI["bin/cli.js\n(commander)"]
CLI --> SCAN["scan"]
CLI --> VIRT["page · nav · speak\naudit · repl"]
CLI --> LIVE["live"]
SCAN --> PW["Playwright\nheadless Chromium"]
PW --> AXE["axe-core\nWCAG 2 AA rules"]
PW --> STRUCT["headings · landmarks\nDOM reading order"]
AXE --> MERGE["merge + dedupe\n(scanner.js)"]
STRUCT --> MERGE
MERGE --> TEXT["text report"]
MERGE --> JSON["--json"]
MERGE --> HTML["--visual HTML report"]
MERGE --> TESTS["--test generated tests"]
MERGE --> AI["--ai analysis\nGemini · Claude · GPT · Ollama"]
VIRT --> BRIDGE["bridge.js\nVirtual Screen Reader"]
BRIDGE --> PW2["Playwright\nheadless Chromium"]
LIVE --> GP["Guidepup"]
GP --> VO["VoiceOver (macOS)"]
GP --> NVDA["NVDA (Windows)"]Three engines, one CLI:
- Scan — Playwright loads the page, axe-core finds violations, custom code extracts structure, and everything merges into one report (text, JSON, HTML, generated tests, or AI analysis).
- Virtual — the Virtual Screen Reader is injected into the page so
nav/audit/speakcan traverse it the way a screen reader would. - Live — Guidepup drives the real OS screen reader so you hear actual announcements.
Roadmap
Recently shipped:
- [x]
--fail-on <severity>exit-code gate for CI pipelines - [x] Cross-platform support for
--visualreports (macOS/Windows/Linux) - [x] Real assertions in
--testgenerated files - [x] Local dashboard (
screenreader dashboard) — scan from the browser, no terminal after launch
Planned (roughly in order):
- [x] Element screenshots — each failing element is photographed and shown in the visual report, grouped by rule
- [ ] Flow capture — screenshot each step of a multi-page/multi-step flow as it's scanned
- [ ] Violation context — include the DOM path and accessibility-tree node for each violation in reports (today: selector + HTML snippet)
- [ ] Richer AI fix suggestions — per-violation code-level fixes with the
element's full context (today:
--aigives prioritized fixes + a score) - [ ] Asset capture — download page images during a scan for audit evidence
- [ ] Multi-page crawling — scan a whole site from a sitemap or crawl
- [x] Baseline & diff — fail CI only on new violations (
--baseline) - [x] AI assistant integration —
screenreader mcp, an MCP server for coding assistants - [ ] GitHub Action — a published action wrapping
scan --fail-on - [ ] Screen reader transcript diff — compare what's announced before vs. after a change
Suggestions welcome — open an issue.
Use cases
- Accessibility testing — audit any site's screen reader experience from CI/CD
- AI agents — give agents structured, semantic understanding of web pages
- Developer workflows — quickly check heading hierarchy, landmark structure, ARIA usage
- Automated QA — validate accessibility in build pipelines with JSON output
Claude Code companion skill
If you use Claude Code, this repo ships a companion skill + slash command that lets Claude run audits on demand:
/sr-audit https://example.comClaude runs scan + audit paired, saves text + JSON to disk, and surfaces the top issues in chat. See skills/claude-code/README.md for install instructions and what it does.
Testing
# Run all local tests (headless Chromium, no network needed)
npm test
# E2E tests (requires network — scans https://example.com)
npm run test:e2e
# Individual suites
node --test test/scan.test.js # Scan command
node --test test/live.test.js # Live command
node --test test/commands.test.js # Virtual mode commands
node --test test/bridge.test.js # VSR bridge
node --test test/audit.test.js # Audit command
node --test test/daemon.test.js # Browser lifecycleContributions welcome — see CONTRIBUTING.md.
Built with
- axe-core — Accessibility rule engine (WCAG 2 AA)
- @guidepup/virtual-screen-reader — Screen reader simulation engine
- @guidepup/guidepup — Real screen reader driver (VoiceOver + NVDA)
- Playwright — Browser automation
- Commander.js — CLI framework
License
MIT
