Packages
Library for accessibility snapshot testing in Phoenix applications using axe-core and Playwright.
Current section
Files
Jump to
Current section
Files
lib/mix/tasks/install.ex
defmodule Mix.Tasks.Excessibility.Install do
@shortdoc "Installs Excessibility configuration into your project"
@moduledoc """
Installs Excessibility into a host project using Igniter.
This task can be invoked directly or via `mix igniter.install excessibility`. It adds the
recommended configuration to the target project's `config/test.exs` and (by default) runs
`npm install` inside the vendored Excessibility assets directory to fetch Pa11y.
Requires Igniter to be installed in the host project.
"""
use Igniter.Mix.Task
alias Igniter.Libs.Phoenix
alias Igniter.Mix.Task.Info
alias Igniter.Project.Config
alias Igniter.Project.Module, as: ProjectModule
@impl true
def info(_argv, _source) do
%Info{
group: :excessibility,
schema: [
endpoint: :string,
head_render_path: :string,
assets_dir: :string,
skip_npm: :boolean,
no_mcp: :boolean
],
defaults: [skip_npm: false, head_render_path: "/", no_mcp: false],
example: "mix excessibility.install --endpoint MyAppWeb.Endpoint"
}
end
@impl true
def igniter(igniter) do
opts = igniter.args.options
endpoint = fallback_endpoint(opts[:endpoint], igniter)
head_render_path = opts[:head_render_path] || "/"
assets_dir = opts[:assets_dir] || default_assets_dir()
skip_npm? = opts[:skip_npm]
skip_mcp? = opts[:no_mcp]
igniter
|> ensure_test_config(endpoint, head_render_path)
|> ensure_test_helper()
|> ensure_pa11y_config()
|> maybe_install_pa11y(assets_dir, skip_npm?)
|> maybe_create_claude_docs()
|> maybe_setup_mcp(skip_mcp?)
end
defp fallback_endpoint(nil, igniter) do
igniter
|> Phoenix.web_module()
|> Module.concat("Endpoint")
rescue
_ -> "MyAppWeb.Endpoint"
end
defp fallback_endpoint(endpoint, _igniter) when is_binary(endpoint), do: ProjectModule.parse(endpoint)
defp fallback_endpoint(module, _igniter), do: module
defp ensure_test_config(igniter, endpoint, head_render_path) do
igniter
|> Config.configure("test.exs", :excessibility, [:endpoint], endpoint)
|> Config.configure("test.exs", :excessibility, [:head_render_path], head_render_path)
|> Config.configure("test.exs", :excessibility, [:browser_mod], Wallaby.Browser)
|> Config.configure("test.exs", :excessibility, [:live_view_mod], Excessibility.LiveView)
|> Config.configure("test.exs", :excessibility, [:system_mod], Excessibility.System)
end
defp ensure_test_helper(igniter) do
test_helper_path = "test/test_helper.exs"
telemetry_code = """
# Enable Excessibility telemetry-based auto-capture for debugging
# This is automatically enabled when running: mix excessibility.debug
if System.get_env("EXCESSIBILITY_TELEMETRY_CAPTURE") == "true" do
Excessibility.TelemetryCapture.attach()
end
"""
Igniter.update_file(igniter, test_helper_path, fn source ->
content = Rewrite.Source.get(source, :content)
if String.contains?(content, "Excessibility.TelemetryCapture.attach") do
source
else
updated_content = inject_telemetry_code(content, telemetry_code)
Rewrite.Source.update(source, :content, updated_content)
end
end)
end
defp inject_telemetry_code(content, telemetry_code) do
if String.contains?(content, "ExUnit.start()") do
String.replace(content, "ExUnit.start()", telemetry_code <> "\nExUnit.start()")
else
content <> "\n\n" <> telemetry_code
end
end
defp ensure_pa11y_config(igniter) do
Igniter.create_or_update_file(igniter, "pa11y.json", pa11y_config(), fn source ->
# Don't overwrite existing config
source
end)
end
defp pa11y_config do
"""
{
"ignore": [
"WCAG2AA.Principle3.Guideline3_2.3_2_2.H32.2"
]
}
"""
end
defp maybe_install_pa11y(igniter, assets_dir, true), do: add_npm_notice(igniter, assets_dir)
defp maybe_install_pa11y(igniter, assets_dir, _skip?) do
package_json = Path.join(assets_dir, "package.json")
cond do
igniter.args.options[:dry_run] ->
add_npm_notice(igniter, assets_dir)
not File.exists?(package_json) ->
Igniter.add_warning(
igniter,
"Could not find package.json inside #{assets_dir}. Skipping npm install."
)
true ->
Mix.shell().info("Installing npm packages in #{assets_dir}...")
case System.cmd("npm", ["install"], cd: assets_dir, into: IO.stream(:stdio, :line)) do
{_, 0} ->
Mix.shell().info("✔ Pa11y installed under #{assets_dir}")
igniter
{_, status} ->
Igniter.add_warning(
igniter,
"npm install exited with status #{status}. Run it manually in #{assets_dir}."
)
end
end
end
defp add_npm_notice(igniter, assets_dir) do
Igniter.add_notice(
igniter,
"Run `npm install` inside #{assets_dir} to install Pa11y dependencies."
)
end
defp default_assets_dir do
dep_path = Mix.Project.deps_paths()[:excessibility] || File.cwd!()
Path.join(dep_path, "assets")
end
defp maybe_create_claude_docs(igniter) do
claude_docs_path = ".claude_docs/excessibility.md"
cond do
File.exists?(claude_docs_path) ->
Igniter.add_notice(
igniter,
"""
Found existing #{claude_docs_path}
To update with latest Excessibility features, run:
mix excessibility.setup_claude_docs
"""
)
File.exists?(".claude_docs") ->
Igniter.create_or_update_file(igniter, claude_docs_path, claude_docs_content(), fn source ->
source
end)
true ->
Igniter.add_notice(
igniter,
"""
💡 Using Excessibility with Claude?
Create .claude_docs/excessibility.md to teach Claude how to:
- Use mix excessibility.debug for instant context
- Automatically capture LiveView state changes
- Analyze snapshots without manual file management
Run: mix excessibility.setup_claude_docs
"""
)
end
end
# Skip MCP setup if --no-mcp flag is passed
defp maybe_setup_mcp(igniter, true), do: igniter
# By default, set up MCP server
defp maybe_setup_mcp(igniter, _skip?) do
if igniter.args.options[:dry_run] do
add_mcp_manual_setup_notice(igniter)
else
igniter
|> install_mcp_server()
|> create_mcp_json()
|> install_skills_plugin()
end
end
defp install_mcp_server(igniter) do
project_path = File.cwd!()
Mix.shell().info("Setting up MCP server for Claude Code...")
case System.cmd(
"claude",
[
"mcp",
"add",
"excessibility",
"-s",
"project",
"--",
"mix",
"run",
"--no-halt",
"-e",
"Excessibility.MCP.Server.start()"
],
cd: project_path,
stderr_to_stdout: true
) do
{output, 0} ->
Mix.shell().info("✅ MCP server registered with Claude Code")
if String.contains?(output, "already exists") do
Mix.shell().info(" (server was already configured)")
end
igniter
{output, _status} ->
if String.contains?(output, "command not found") or String.contains?(output, "not found") do
Igniter.add_warning(
igniter,
"""
Could not find 'claude' CLI. Install from: https://github.com/anthropics/claude-code
Or manually add MCP server:
claude mcp add excessibility -s project -- mix run --no-halt -e "Excessibility.MCP.Server.start()"
"""
)
else
Igniter.add_warning(
igniter,
"""
MCP server registration failed: #{output}
Manually add with:
claude mcp add excessibility -s project -- mix run --no-halt -e "Excessibility.MCP.Server.start()"
"""
)
end
end
end
defp create_mcp_json(igniter) do
mcp_json_path = ".mcp.json"
mcp_config = mcp_json_content()
if File.exists?(mcp_json_path) do
update_existing_mcp_json(igniter, mcp_json_path, mcp_config)
else
Igniter.create_or_update_file(igniter, mcp_json_path, mcp_config, fn source -> source end)
end
end
defp update_existing_mcp_json(igniter, path, _new_config) do
with {:ok, content} <- File.read(path),
{:ok, existing} <- Jason.decode(content) do
maybe_add_excessibility_server(igniter, path, existing)
else
{:error, %Jason.DecodeError{}} ->
Igniter.add_warning(igniter, "Could not parse existing .mcp.json - skipping MCP config")
{:error, _} ->
igniter
end
end
defp maybe_add_excessibility_server(igniter, path, existing) do
servers = Map.get(existing, "mcpServers", %{})
if Map.has_key?(servers, "excessibility") do
igniter
else
updated = Map.put(existing, "mcpServers", Map.put(servers, "excessibility", mcp_server_entry()))
Igniter.create_or_update_file(igniter, path, Jason.encode!(updated, pretty: true), fn source -> source end)
end
end
defp mcp_server_entry do
%{
"command" => "deps/excessibility/bin/mcp-server",
"args" => []
}
end
defp mcp_json_content do
Jason.encode!(%{"mcpServers" => %{"excessibility" => mcp_server_entry()}}, pretty: true)
end
defp install_skills_plugin(igniter) do
dep_path = Mix.Project.deps_paths()[:excessibility] || File.cwd!()
plugin_path = Path.join(dep_path, "priv/claude-plugin")
if File.dir?(plugin_path) do
do_install_skills_plugin(igniter, plugin_path)
else
igniter
end
end
defp do_install_skills_plugin(igniter, plugin_path) do
Mix.shell().info("Installing Claude Code skills plugin...")
case System.cmd("claude", ["plugins", "add", plugin_path], stderr_to_stdout: true) do
{output, 0} ->
handle_plugin_success(igniter, output)
{output, _status} ->
handle_plugin_failure(igniter, output, plugin_path)
end
end
defp handle_plugin_success(igniter, output) do
Mix.shell().info("✅ Skills plugin installed (/e11y-tdd, /e11y-debug, /e11y-fix)")
if String.contains?(output, "already installed") do
Mix.shell().info(" (plugin was already installed)")
end
igniter
end
defp handle_plugin_failure(igniter, output, plugin_path) do
not_found? =
String.contains?(output, "command not found") or String.contains?(output, "not found")
message =
if not_found? do
"""
Install skills plugin manually:
claude plugins add #{plugin_path}
"""
else
"""
Skills plugin installation failed: #{output}
Install manually:
claude plugins add #{plugin_path}
"""
end
Igniter.add_notice(igniter, message)
end
defp add_mcp_manual_setup_notice(igniter) do
dep_path = Mix.Project.deps_paths()[:excessibility] || File.cwd!()
plugin_path = Path.join(dep_path, "priv/claude-plugin")
Igniter.add_notice(
igniter,
"""
🔌 MCP Server Setup (dry run - run these manually):
1. Add MCP server to Claude Code:
claude mcp add excessibility -s project -- mix run --no-halt -e "Excessibility.MCP.Server.start()"
2. Install skills plugin:
claude plugins add #{plugin_path}
Available tools: e11y_check, e11y_debug, get_timeline, get_snapshots
Available skills: /e11y-tdd, /e11y-debug, /e11y-fix
"""
)
end
defp claude_docs_content do
"""
# Excessibility - Debugging Phoenix LiveView Tests
**Zero-code-change LiveView debugging for AI assistants.**
Excessibility automatically captures LiveView state during tests using telemetry,
giving you complete execution context without modifying test code.
## When to Use Excessibility Skills
**The excessibility plugin provides specialized skills - use them proactively:**
- **Implementing LiveView features** (forms, modals, dynamic content)
→ Use `/e11y-tdd` skill for test-driven development with accessibility checking
- **Debugging LiveView test failures or state issues**
→ Use `/e11y-debug` skill for timeline analysis and state inspection
- **Fixing Pa11y or WCAG accessibility violations**
→ Use `/e11y-fix` skill for Phoenix-specific accessibility patterns
**When these patterns match, using the skill is not optional** - it provides the workflow and tools to see actual rendered HTML and LiveView state, not just guesses.
## Key Feature: Telemetry-Based Auto-Capture
Debug **any existing LiveView test** with automatic snapshot capture:
```elixir
# Your test - completely vanilla, zero Excessibility code
test "user interaction flow", %{conn: conn} do
{:ok, view, _html} = live(conn, "/dashboard")
view |> element("#button") |> render_click()
view |> element("#form") |> render_submit(%{name: "Alice"})
assert render(view) =~ "Welcome Alice"
end
```
Debug it:
```bash
mix excessibility.debug test/my_test.exs
```
**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
## What Gets Captured
Example telemetry snapshot:
```html
<!--
Excessibility Telemetry Snapshot
Test: test user interaction flow
Sequence: 2
Event: handle_event:submit_form
Timestamp: 2026-01-25T10:30:12.345Z
View Module: MyAppWeb.DashboardLive
Assigns: %{
current_user: %User{name: "Alice"},
form_data: %{name: "Alice"},
submitted: true
}
-->
```
Each snapshot includes:
- Real LiveView assigns (state at that moment)
- Event sequence and type
- Timestamp
- View module
## Quick Commands
### Debug a failing test
```bash
mix excessibility.debug test/my_test.exs
```
Generates markdown report with:
- Test results and errors
- All captured snapshots with inline HTML
- Event timeline showing state changes
- Real LiveView assigns at each snapshot
### Show latest debug report
```bash
mix excessibility.latest
```
Re-displays most recent debug without re-running test.
### Create shareable package
```bash
mix excessibility.package test/my_test.exs
```
Creates directory with MANIFEST, timeline.json, and all snapshots.
## How It Works
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`:
1. Sets environment variable to enable telemetry capture
2. Attaches telemetry handlers
3. Runs your test (unchanged)
4. Captures snapshots with real assigns from LiveView process
5. Generates complete debug report
**No test changes needed** - works with vanilla Phoenix LiveView tests!
## Typical Workflow
1. User reports failing test
2. Run: `mix excessibility.debug test/failing_test.exs`
3. Read the generated `test/excessibility/latest_debug.md`
4. Analyze snapshots showing LiveView state at each step
5. Identify the issue from real execution context
6. Suggest fixes based on actual state changes
## Why This Helps Claude
Without snapshots, I'm guessing based on code. With Excessibility:
- See actual LiveView assigns at each step
- Track state changes through event sequence
- Compare expected vs actual DOM output
- Understand real execution flow, not theoretical
## Alternative: Manual Capture
For fine-grained control, you can manually capture:
```elixir
use Excessibility
@tag capture_snapshots: true
test "manual capture", %{conn: conn} do
{:ok, view, _} = live(conn, "/")
html_snapshot(view) # Manual snapshot with metadata
view |> element("#btn") |> render_click()
html_snapshot(view) # Another snapshot
end
```
## Tips for Using This with Claude
- Point me to `test/excessibility/latest_debug.md` after running debug command
- The telemetry snapshots show real LiveView state, not just rendered HTML
- Timeline shows event sequence - useful for complex interactions
- Assigns help understand what changed between events
"""
end
end