Accessibility Snapshot Testing for Elixir + Phoenix
Excessibility helps you test your Phoenix apps for accessibility (WCAG compliance) by taking HTML snapshots during tests and running them through Pa11y.
- Keep accessibility in your existing test feedback loop. Snapshots are captured inside ExUnit, Wallaby, and LiveView tests, so regressions surface together with your functional failures.
- Ship safer refactors. Explicit baseline locking and comparison lets reviewers see exactly what changed and approve intentionally.
- Debug CI-only failures quickly. Pa11y output points to the failing snapshot, and the saved artifacts make it easy to reproduce locally.
- During tests, call
html_snapshot(conn)to capture HTML from your Phoenix responses, LiveViews, or Wallaby sessions - After tests, run
mix excessibilityto check all snapshots with Pa11y for WCAG violations - Lock baselines with
mix excessibility.baselinewhen snapshots represent a known-good state - Compare changes with
mix excessibility.compareto review what changed and approve/reject - In CI, Pa11y reports accessibility violations alongside your test failures
- Snapshot HTML from
Plug.Conn,Wallaby.Session,Phoenix.LiveViewTest.View, andPhoenix.LiveViewTest.Element - Explicit baseline locking and comparison workflow
- Interactive good/bad approval when comparing snapshots
- Optional PNG screenshots via ChromicPDF
- Mockable system/browser calls for CI
- Pa11y configuration with sensible LiveView defaults
Excessibility includes powerful features for debugging Phoenix apps with AI assistance (Claude, Cursor, etc.).
Debug any existing LiveView test with automatic snapshot capture - no test changes required:
# Your test - completely vanilla, zero Excessibility codetest"user interaction flow",%{conn: conn}do{:ok,view,_html}=live(conn,"/dashboard")view|>element("#button")|>render_click()view|>element("#form")|>render_submit(%{name: "Alice"})assertrender(view)=~"Welcome Alice"endDebug it:
mix excessibility.debug test/my_test.exs🚀 Rich Timeline Capture
mix excessibility.debugautomatically enables telemetry capture, dramatically increasing event visibility:
- Without telemetry: ~4 events (mount, handle_params only)
- With telemetry: 10-20x more events including all render cycles
Example from real test:
- Basic test: 4 events → 11 events (added 7 render events)
- Complex test: Limited snapshots → 236 timeline events with rich analyzer insights
Render events enable powerful pattern detection:
- 🔴 Memory leak detected (2.3x growth over render cycles)
⚠️ 7 consecutive renders without user interaction- 🔴 Performance bottleneck (15ms render blocking)
⚠️ Rapid state changes (potential infinite loop)This happens automatically - no test changes needed!
Automatically captures:
- LiveView mount events
- All handle_event calls (clicks, submits, etc.)
- All render cycles (form updates, state changes triggered by
render_change,render_click,render_submit) - Real LiveView assigns at each step
- Complete state timeline with memory tracking and performance metrics
Example captured snapshot:
<!--Excessibility Telemetry SnapshotTest: test user interaction flowSequence: 2Event: handle_event:submit_formTimestamp: 2026-01-25T10:30:12.345ZView Module: MyAppWeb.DashboardLiveAssigns: %{ current_user: %User{name: "Alice"}, form_data: %{name: "Alice"}, submitted: true}-->The debug command outputs a comprehensive markdown report with:
- Test results and error output
- All captured snapshots with inline HTML
- Event timeline showing state changes
- Real LiveView assigns at each snapshot
- Metadata (timestamps, event sequence, view modules)
The report is both human-readable and AI-parseable, perfect for pasting into Claude.
Available formats (all args pass through to mix test):
mix excessibility.debug test/my_test.exs # Markdown report (default)
mix excessibility.debug test/my_test.exs:42 # Run specific test
mix excessibility.debug --only live_view # Run tests with tag
mix excessibility.debug test/my_test.exs --format=json # Structured JSON
mix excessibility.debug test/my_test.exs --format=package # Directory with MANIFEST
mix excessibility.latest # Re-display last reportAutomatically captures LiveView state throughout test execution and generates scannable timeline reports:
- Smart Filtering - Removes Ecto metadata, Phoenix internals, and other noise
- Diff Detection - Shows what changed between events
- Multiple Formats - JSON for automation, Markdown for humans/AI
- CLI Control - Override filtering with flags for deep debugging
mix excessibility.debug test/my_test.exsSee CLAUDE.md for detailed usage.
Excessibility hooks into Phoenix LiveView's built-in telemetry events:
[:phoenix, :live_view, :mount, :stop][:phoenix, :live_view, :handle_event, :stop][:phoenix, :live_view, :handle_params, :stop][:phoenix, :live_view, :render, :stop]- Captures all render cycles (form updates, state changes)
When you run mix excessibility.debug, it:
- Enables telemetry capture via environment variable
- Attaches telemetry handlers to LiveView events
- Runs your test
- Captures snapshots with real assigns from the LiveView process
- Generates a complete debug report
No test changes needed - it works with vanilla Phoenix LiveView tests!
For fine-grained control, you can also manually capture snapshots:
useExcessibility@tagcapture_snapshots: truetest"manual capture",%{conn: conn}do{:ok,view,_}=live(conn,"/")html_snapshot(view)# Manual snapshot with auto-tracked metadataview|>element("#btn")|>render_click()html_snapshot(view)# Another snapshotendCreate .claude_docs/excessibility.md to teach Claude how to use these debugging features:
mix excessibility.setup_claude_docsExcessibility includes an MCP (Model Context Protocol) server and Claude Code skills plugin for AI-assisted development.
The MCP server provides tools for AI assistants to run accessibility checks and debug LiveView state.
Available tools:
| Tool | Speed | Description |
|---|---|---|
check_route | Fast | Run Pa11y on a live route (requires app running) |
explain_issue | Fast | Get explanation and fix suggestions for a WCAG violation code |
suggest_fixes | Fast | Get Phoenix-specific code fixes for accessibility issues |
generate_test | Fast | Generate test code with html_snapshot() calls for a route |
list_analyzers | Fast | List available timeline analyzers |
get_timeline | Fast | Read captured timeline showing LiveView state evolution |
get_snapshots | Fast | List or read HTML snapshots captured during tests |
analyze_timeline | Fast | Run analyzers on captured timeline data |
list_violations | Fast | List recent Pa11y violations from snapshots |
e11y_check | Slow | Run tests and/or Pa11y accessibility checks on HTML snapshots |
e11y_debug | Slow | Run tests with telemetry capture - returns timeline for analysis |
Recommended workflow:
check_route- Quick accessibility check on running appexplain_issue- Understand what violations meangenerate_test- Create test withhtml_snapshot()callse11y_debug- Run test to capture timelineanalyze_timeline- Find performance issues
Automatic Setup:
MCP server support is configured automatically when you run the installer:
mix excessibility.install
mix deps.getThis creates .claude/mcp_servers.json with the excessibility MCP server configuration.
Use --no-mcp to skip MCP setup if you don't need AI assistant integration.
Manual Setup:
Configure Claude Code's mcp_servers.json:
{
"excessibility": {
"command": "mix",
"args": ["run", "--no-halt", "-e", "Excessibility.MCP.Server.start()"],
"cwd": "/path/to/your/project"
}
}The MCP server is now available in Claude Code.
Install the skills plugin for structured accessibility workflows:
claude plugins add /path/to/excessibility/priv/claude-pluginAvailable skills:
| Skill | Description |
|---|---|
/e11y-tdd | TDD workflow with html_snapshot and Pa11y - sprinkle snapshots to see what's rendered, delete when done |
/e11y-debug | Debug workflow with timeline analysis - inspect state at each event, correlate with Pa11y failures |
/e11y-fix | Reference guide for fixing Pa11y/WCAG errors with Phoenix-specific patterns |
Example workflow:
/e11y-tdd
# Claude will guide you through:
# 1. EXPLORE - Add html_snapshot() calls to see what's rendered
# 2. RED - Write test with snapshot at key moment
# 3. GREEN - Implement feature, use snapshots to debug
# 4. CHECK - Run mix excessibility for Pa11y validation
# 5. CLEAN - Remove temporary snapshots
Add to mix.exs:
defdepsdo[{:excessibility,"~> 0.10",only: [:dev,:test]}]endFetch dependencies and run the installer:
mix deps.get
mix excessibility.installApps with authentication: If your app requires login to access most pages, specify a public route for extracting <head> content:
mix excessibility.install --head-render-path /loginThe installer will:
- Add configuration to
config/test.exs - Create a
pa11y.jsonwith sensible defaults for Phoenix/LiveView - Install Pa11y via npm in your assets directory
Configure the endpoint and helper modules in
config/test.exs. The installer does this automatically, or add manually:config:excessibility,endpoint: MyAppWeb.Endpoint,head_render_path: "/",# use "/login" for apps with authsystem_mod: Excessibility.System,browser_mod: Wallaby.Browser,live_view_mod: Excessibility.LiveView
Add
use Excessibilityin tests where you want snapshots:defmoduleMyAppWeb.PageControllerTestdouseMyAppWeb.ConnCase,async: trueuseExcessibilitytest"renders home page",%{conn: conn}doconn=get(conn,"/")html_snapshot(conn,screenshot?: true)asserthtml_response(conn,200)=~"Welcome!"endend
Typical workflow:
# Run specific test + Pa11y in one command mix excessibility test/my_test.exs mix excessibility test/my_test.exs:42 mix excessibility --only a11y # Or run tests separately, then check all snapshots mix test# Generates snapshots in test/excessibility/ mix excessibility # Runs Pa11y against all snapshots# Lock current snapshots as known-good baseline mix excessibility.baseline # After making UI changes, run tests again, then compare mix test mix excessibility.compare # Review diffs, choose good (baseline) or bad (new)
useExcessibilityhtml_snapshot(conn,name: "homepage.html",screenshot?: true)The html_snapshot/2 macro works with:
Plug.ConnWallaby.SessionPhoenix.LiveViewTest.ViewPhoenix.LiveViewTest.Element
It returns the source unchanged, so you can use it in pipelines.
| Option | Type | Default | Description |
|---|---|---|---|
:name | string | auto-generated | Custom filename (e.g., "login_form.html"). Default is ModuleName_LineNumber.html |
:screenshot? | boolean | false | Generate PNG screenshots (requires ChromicPDF) |
:open_browser? | boolean | false | Open the snapshot in your browser after writing |
:cleanup? | boolean | false | Delete existing snapshots for the current test module before writing |
Snapshots are saved to test/excessibility/html_snapshots/ and baselines live in test/excessibility/baseline/.
Setting a baseline:
mix excessibility.baselineThis copies all current snapshots to the baseline directory. Run this when your snapshots represent a known-good, accessible state.
Comparing against baseline:
mix excessibility.compareFor each snapshot that differs from its baseline:
- Diff files are created —
.good.html(baseline) and.bad.html(new) - Both open in your browser for visual comparison
- You choose which to keep — "good" to reject changes, "bad" to accept as new baseline
- Diff files are cleaned up after resolution
Batch options:
mix excessibility.compare --keep good # Keep all baselines (reject all changes)
mix excessibility.compare --keep bad # Accept all new versions as baselineAll configuration goes in test/test_helper.exs or config/test.exs:
| Config Key | Required | Default | Description |
|---|---|---|---|
:endpoint | Yes | — | Your Phoenix endpoint module (e.g., MyAppWeb.Endpoint) |
:system_mod | No | Excessibility.System | Module for system commands (mockable) |
:browser_mod | No | Wallaby.Browser | Module for browser interactions |
:live_view_mod | No | Excessibility.LiveView | Module for LiveView rendering |
:excessibility_output_path | No | "test/excessibility" | Base directory for snapshots |
:pa11y_path | No | auto-detected | Path to Pa11y executable |
:pa11y_config | No | "pa11y.json" | Path to Pa11y config file |
:head_render_path | No | "/" | Route used for rendering <head> content |
:custom_enrichers | No | [] | List of custom enricher modules (see Telemetry Analysis) |
:custom_analyzers | No | [] | List of custom analyzer modules (see Telemetry Analysis) |
Example:
# test/test_helper.exsApplication.put_env(:excessibility,:endpoint,MyAppWeb.Endpoint)Application.put_env(:excessibility,:system_mod,Excessibility.System)Application.put_env(:excessibility,:browser_mod,Wallaby.Browser)Application.put_env(:excessibility,:live_view_mod,Excessibility.LiveView)Application.put_env(:excessibility,:excessibility_output_path,"test/accessibility")ExUnit.start()The installer creates a pa11y.json in your project root with sensible defaults for Phoenix/LiveView:
{
"ignore": [
"WCAG2AA.Principle3.Guideline3_2.3_2_2.H32.2"
]
}The ignored rule (H32.2) is "Form does not contain a submit button" — a common false positive for LiveView forms that use phx-submit without traditional submit buttons.
Add additional rules to ignore as needed for your project:
{
"ignore": [
"WCAG2AA.Principle3.Guideline3_2.3_2_2.H32.2",
"WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail"
]
}To enable PNG screenshots, start ChromicPDF in your test helper:
# test/test_helper.exs{:ok,_}=ChromicPDF.start_link(name: ChromicPDF)ExUnit.start()Then use screenshot?: true in your snapshots:
html_snapshot(conn,screenshot?: true)Screenshots are saved alongside HTML files with .png extension.
| Task | Description |
|---|---|
mix excessibility.install | Configure config/test.exs, create pa11y.json, install Pa11y via npm |
mix excessibility | Run Pa11y against all existing snapshots |
mix excessibility [test args] | Run tests, then Pa11y on new snapshots (passthrough to mix test) |
mix excessibility.baseline | Lock current snapshots as baseline |
mix excessibility.compare | Compare snapshots against baseline, resolve diffs interactively |
mix excessibility.compare --keep good | Keep all baseline versions (reject changes) |
mix excessibility.compare --keep bad | Accept all new versions as baseline |
mix excessibility.debug [test args] | Run tests with telemetry, generate debug report (passthrough to mix test) |
mix excessibility.debug [test args] --format=json | Output debug report as JSON |
mix excessibility.debug [test args] --format=package | Create debug package directory |
mix excessibility.latest | Display most recent debug report |
mix excessibility.package [test] | Create debug package (alias for --format=package) |
mix excessibility.setup_claude_docs | Create/update .claude_docs/excessibility.md |
For CI or headless environments where you don't want interactive prompts or browser opens, mock the system module:
# test/test_helper.exsMox.defmock(Excessibility.SystemMock,for: Excessibility.SystemBehaviour)Application.put_env(:excessibility,:system_mod,Excessibility.SystemMock)Then stub in your tests:
importMoxsetup:verify_on_exit!test"snapshot without browser open",%{conn: conn}doExcessibility.SystemMock|>stub(:open_with_system_cmd,fn_path->:okend)conn=get(conn,"/")html_snapshot(conn,open_browser?: true)# Won't actually openendtest/
└── excessibility/
├── html_snapshots/ # Current test snapshots
│ ├── MyApp_PageTest_42.html
│ └── MyApp_PageTest_42.png # (if screenshot?: true)
└── baseline/ # Locked baselines (via mix excessibility.baseline)
└── MyApp_PageTest_42.html
During mix excessibility.compare, temporary .good.html and .bad.html files are created for diffing, then cleaned up after resolution.
MIT © Andrew Moore