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 ``` 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