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/excessibility.ex
defmodule Mix.Tasks.Excessibility do
@shortdoc "Run accessibility checks on snapshots"
@moduledoc """
Runs Pa11y accessibility checks on HTML snapshots.
## Usage
With no arguments, checks ALL existing snapshots:
mix excessibility
With arguments, runs tests first then checks NEW snapshots only:
# Run a test file
mix excessibility test/my_app_web/live/page_live_test.exs
# Run a specific test by line number
mix excessibility test/my_app_web/live/page_live_test.exs:42
# Run tests with a tag
mix excessibility --only a11y
# Run a describe block
mix excessibility test/my_test.exs:10
## Configuration
- `:pa11y_path` - Custom path to Pa11y executable (auto-detected by default)
- `:pa11y_config` - Path to pa11y.json config file (default: `"pa11y.json"`)
- `:excessibility_output_path` - Base directory for snapshots (default: `"test/excessibility"`)
## Pa11y Configuration
If a `pa11y.json` file exists in your project root, it will be passed to Pa11y
via the `--config` flag. Use this to ignore specific WCAG rules:
{
"ignore": [
"WCAG2AA.Principle3.Guideline3_2.3_2_2.H32.2"
]
}
## Prerequisites
Run `mix excessibility.install` first to install Pa11y via npm.
"""
use Mix.Task
@requirements ["app.config"]
@impl Mix.Task
def run([]) do
# No args - check all existing snapshots
run_pa11y_on_all()
end
def run(args) do
# With args - run tests first, then check new snapshots
run_tests_then_check(args)
end
defp run_pa11y_on_all do
files = list_snapshots()
if Enum.empty?(files) do
Mix.shell().info("""
No snapshots found in #{snapshot_dir()}.
Run your tests first to generate snapshots:
mix test
Or run a specific test:
mix excessibility test/my_test.exs
""")
exit({:shutdown, 0})
end
Mix.shell().info("Checking #{length(files)} snapshot(s)...\n")
run_pa11y(files)
end
defp run_tests_then_check(args) do
# Get snapshot count before test
snapshots_before = list_snapshots()
# Run mix test with all args passed through
Mix.shell().info("Running: mix test #{Enum.join(args, " ")}\n")
{_output, exit_code} = System.cmd("mix", ["test" | args], into: IO.stream(:stdio, :line))
if exit_code != 0 do
Mix.shell().error("\nTests failed - skipping accessibility check")
exit({:shutdown, exit_code})
end
# Get new snapshots
snapshots_after = list_snapshots()
new_snapshots = snapshots_after -- snapshots_before
if Enum.empty?(new_snapshots) do
Mix.shell().info("""
No new snapshots generated. Make sure your test includes html_snapshot() calls:
use Excessibility
test "page is accessible", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
html_snapshot(view) # <-- Add this
end
""")
exit({:shutdown, 0})
end
Mix.shell().info("\n## Accessibility Check\n")
Mix.shell().info("Checking #{length(new_snapshots)} snapshot(s)...\n")
run_pa11y(new_snapshots)
end
defp list_snapshots do
snapshot_dir()
|> Path.join("*.html")
|> Path.wildcard()
|> Enum.reject(&String.ends_with?(&1, [".bad.html", ".good.html"]))
|> Enum.sort()
end
defp run_pa11y(files) do
pa11y = pa11y_path()
unless File.exists?(pa11y) do
Mix.shell().error("""
Pa11y not found at #{pa11y}.
Run `mix excessibility.install` first.
""")
exit({:shutdown, 1})
end
config_args = pa11y_config_args()
results =
Enum.map(files, fn file ->
file_url = "file://" <> Path.expand(file)
{output, status} = System.cmd("node", [pa11y | config_args] ++ [file_url], stderr_to_stdout: true)
{file, status, output}
end)
{passed, failed} = Enum.split_with(results, fn {_file, status, _output} -> status == 0 end)
if length(failed) > 0 do
Mix.shell().info("### Issues Found\n")
Enum.each(failed, fn {file, _status, output} ->
Mix.shell().info("**#{Path.basename(file)}**")
Mix.shell().info(output)
end)
Mix.shell().info("\n#{length(failed)} file(s) with issues, #{length(passed)} passed")
exit({:shutdown, 1})
else
Mix.shell().info("All #{length(passed)} snapshot(s) passed accessibility checks")
end
end
defp snapshot_dir do
Path.join([output_path(), "html_snapshots"])
end
defp output_path do
Application.get_env(:excessibility, :excessibility_output_path, "test/excessibility")
end
defp pa11y_path do
Application.get_env(:excessibility, :pa11y_path) ||
Path.join([dependency_root(), "assets/node_modules/pa11y/bin/pa11y.js"])
end
defp pa11y_config_args do
config_path = Application.get_env(:excessibility, :pa11y_config, "pa11y.json")
if File.exists?(config_path) do
["--config", config_path]
else
[]
end
end
defp dependency_root do
Mix.Project.deps_paths()[:excessibility] || File.cwd!()
end
end