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 axe-core dependencies.
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()
|> maybe_install_deps(assets_dir, skip_npm?)
|> maybe_setup_claude_md()
|> 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 maybe_install_deps(igniter, assets_dir, true), do: add_npm_notice(igniter, assets_dir)
defp maybe_install_deps(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 ->
install_npm_and_playwright(igniter, assets_dir)
end
end
defp install_npm_and_playwright(igniter, assets_dir) do
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("✔ axe-core dependencies installed under #{assets_dir}")
install_playwright_browser(igniter, assets_dir)
{_, status} ->
Igniter.add_warning(
igniter,
"npm install exited with status #{status}. Run it manually in #{assets_dir}."
)
end
end
defp install_playwright_browser(igniter, assets_dir) do
Mix.shell().info("Installing Playwright Chromium browser...")
case System.cmd("npx", ["playwright", "install", "chromium"],
cd: assets_dir,
into: IO.stream(:stdio, :line)
) do
{_, 0} ->
Mix.shell().info("✔ Playwright Chromium browser installed")
igniter
{_, status} ->
Igniter.add_warning(
igniter,
"npx playwright install chromium exited with status #{status}. " <>
"Run `npx playwright install chromium` manually in #{assets_dir}."
)
end
end
defp add_npm_notice(igniter, assets_dir) do
Igniter.add_notice(
igniter,
"Run `npm install && npx playwright install chromium` inside #{assets_dir} to install axe-core dependencies."
)
end
defp default_assets_dir do
dep_path = Mix.Project.deps_paths()[:excessibility] || File.cwd!()
Path.join(dep_path, "assets")
end
defp maybe_setup_claude_md(igniter) do
claude_md_path = "CLAUDE.md"
section = claude_md_section()
if File.exists?(claude_md_path) do
Igniter.update_file(igniter, claude_md_path, &append_claude_md_section(&1, section))
else
Igniter.create_or_update_file(igniter, claude_md_path, section, fn source -> source end)
end
end
defp append_claude_md_section(source, section) do
content = Rewrite.Source.get(source, :content)
if String.contains?(content, "## Excessibility") do
source
else
Rewrite.Source.update(source, :content, content <> "\n" <> section)
end
end
@doc """
Returns the CLAUDE.md section content for Excessibility.
This section is appended to existing CLAUDE.md files or used as the initial
content when creating a new one during installation.
"""
def claude_md_section do
"""
## Excessibility
### Automatic Accessibility Checking
After modifying any LiveView, controller, or template code, run the `check_work` MCP tool
with the relevant test file before reporting work as complete. Fix critical violations
before moving on.
```
check_work(test_file: "test/my_app_web/live/page_live_test.exs")
```
When working on performance-sensitive code or investigating LiveView state issues,
include performance analysis:
```
check_work(test_file: "test/my_app_web/live/page_live_test.exs", include_perf: true)
```
### Available Skills
- `/e11y-tdd` — TDD workflow with html_snapshot and axe-core accessibility checking
- `/e11y-debug` — Debug workflow with timeline analysis for LiveView state inspection
- `/e11y-fix` — Reference guide for fixing WCAG violations with Phoenix-specific patterns
### MCP Tools
- `check_work` — Run tests + a11y check + optional perf analysis (use this automatically)
- `a11y_check` — Run axe-core accessibility checks on snapshots or URLs
- `debug` — Run tests with telemetry capture, returns timeline data
- `get_snapshots` — List or read HTML snapshots from tests
- `get_timeline` — Read captured timeline data
- `generate_test` — Generate test code with html_snapshot() calls
"""
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
end