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 axe-core 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
- `:axe_disable_rules` - List of axe rule IDs to disable (default: `[]`)
- `:excessibility_output_path` - Base directory for snapshots (default: `"test/excessibility"`)
## Prerequisites
Run `mix excessibility.install` first to install axe-core and Playwright via npm.
"""
use Mix.Task
@requirements ["app.config"]
@impl Mix.Task
def run([]) do
# No args - check all existing snapshots
run_axe_on_all()
end
def run(args) do
# With args - run tests first, then check new snapshots
run_tests_then_check(args)
end
defp run_axe_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_axe(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_axe(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_axe(files) do
disable_rules = Application.get_env(:excessibility, :axe_disable_rules, [])
opts = if disable_rules == [], do: [], else: [disable_rules: disable_rules]
results =
Enum.map(files, fn file ->
file_url = "file://" <> Path.expand(file)
result = Excessibility.AxeRunner.run(file_url, opts)
{file, result}
end)
{passed, failed} =
Enum.split_with(results, fn
{_file, {:ok, %{violations: []}}} -> true
{_file, {:ok, _}} -> false
{_file, {:error, _}} -> false
end)
if length(failed) > 0 do
Mix.shell().info("### Issues Found\n")
Enum.each(failed, fn
{file, {:ok, %{violations: violations}}} ->
Mix.shell().info("**#{Path.basename(file)}**")
format_violations(violations)
{file, {:error, reason}} ->
Mix.shell().info("**#{Path.basename(file)}**")
Mix.shell().info(" Error: #{reason}\n")
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 format_violations(violations) do
Enum.each(violations, fn violation ->
impact = violation["impact"] || "unknown"
id = violation["id"] || "unknown"
description = violation["description"] || ""
help_url = violation["helpUrl"] || ""
nodes = violation["nodes"] || []
Mix.shell().info(" [#{String.upcase(impact)}] #{id}: #{description}")
if help_url != "" do
Mix.shell().info(" Help: #{help_url}")
end
Mix.shell().info(" #{length(nodes)} element(s) affected\n")
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
end