Runtime scanner for arbitrary URLs.
Unlike the ExUnit integration in Excessibility, this module is intended
to be called from application code — LiveViews, background jobs, CLI
wrappers, external HTTP APIs, etc. It launches Playwright (via
assets/axe-runner.js), navigates to the given URL, and runs axe-core
analysis, returning a structured report.
Usage
{:ok, report} = Excessibility.Scanner.scan("https://example.com")
for v <- report.violations do
IO.puts("[#{v.impact}] #{v.id}: #{v.description}")
endOn failure, returns {:error, reason} where reason is a typed tuple
(see scan_error/0). Pattern-match cleanly from LiveView handlers:
case Excessibility.Scanner.scan(url, timeout: 20_000) do
{:ok, report} -> send(self(), {:scan_complete, report})
{:error, :timeout} -> send(self(), {:scan_failed, :timeout})
{:error, {:http_error, status}} -> ...
{:error, {:navigation_failed, msg}} -> ...
{:error, {:invalid_url, _}} -> ...
{:error, {:playwright_error, msg}} -> ...
endFallback behavior
If Playwright fails to reach a remote URL (timeout, WAF block, or
navigation error), the scanner automatically retries by fetching the
HTML via curl and scanning it as a local file. This won't execute
JavaScript, so SPA content may be missing, but server-rendered pages
still get full results. When the fallback path is used, the returned
report has a non-nil :fallback field. Disable with fallback: false.
file:// URLs never fall back (curl can't fetch them).
Reusing an existing Playwright installation
By default the scanner uses the Playwright copy bundled under this
library's assets/ directory. Projects that already have Playwright
installed (with browsers downloaded) can point Excessibility at it and
skip the second browser download:
config :excessibility, playwright_path: "assets/node_modules/playwright"Relative paths are expanded from the project root.
Summary
Types
An interactive element that is mostly outside the visible area.
Clipping measurements, present when :check_clipping is set.
Engine metadata for a scan.
Metadata describing a curl fallback, when one was used.
axe-core impact level, normalized to an atom.
A multi-viewport scan report.
A single offending element within a violation.
A complete scan report.
Structured scan failure.
Options accepted by scan/2.
Per-viewport axe results, returned when :viewports is used.
A single axe-core violation.
Functions
Scan a URL and return a structured accessibility report.
Types
@type clipped_element() :: %{ selector: String.t(), width: non_neg_integer(), visible: non_neg_integer(), ratio: float(), html: String.t() }
An interactive element that is mostly outside the visible area.
@type clipping_info() :: %{page_overflow?: boolean(), clipped: [clipped_element()]} | nil
Clipping measurements, present when :check_clipping is set.
Engine metadata for a scan.
Metadata describing a curl fallback, when one was used.
@type impact() :: :critical | :serious | :moderate | :minor | nil
axe-core impact level, normalized to an atom.
@type multi_report() :: %{ url: String.t(), final_url: String.t(), results: [viewport_result()], timestamp: DateTime.t(), duration_ms: non_neg_integer(), engine: engine_info(), warnings: [String.t()], fallback: fallback_info() }
A multi-viewport scan report.
A single offending element within a violation.
@type report() :: %{ url: String.t(), final_url: String.t(), violations: [violation()], incomplete: [violation()], passes_count: non_neg_integer(), inapplicable_count: non_neg_integer(), timestamp: DateTime.t(), duration_ms: non_neg_integer(), engine: engine_info(), warnings: [String.t()], clipping: clipping_info(), fallback: fallback_info() }
A complete scan report.
@type scan_error() :: :timeout | {:http_error, non_neg_integer()} | {:navigation_failed, String.t()} | {:playwright_error, String.t()} | {:invalid_url, atom()}
Structured scan failure.
@type scan_opts() :: [ timeout: pos_integer(), wait_for: String.t(), wait_until: :load | :domcontentloaded | :networkidle, viewport: {pos_integer(), pos_integer()}, viewports: [{pos_integer(), pos_integer()}], check_clipping: boolean(), clipping_ratio: float(), tags: [String.t()], user_agent: String.t() | nil, screenshot: Path.t() | nil, disable_rules: [String.t()], fallback: boolean() ]
Options accepted by scan/2.
@type viewport_result() :: %{ viewport: {pos_integer(), pos_integer()}, violations: [violation()], incomplete: [violation()], passes_count: non_neg_integer(), inapplicable_count: non_neg_integer(), clipping: clipping_info() }
Per-viewport axe results, returned when :viewports is used.
@type violation() :: %{ id: String.t(), impact: impact(), description: String.t(), help: String.t(), help_url: String.t(), tags: [String.t()], nodes: [node_info()] }
A single axe-core violation.
Functions
@spec scan(String.t(), scan_opts()) :: {:ok, report() | multi_report()} | {:error, scan_error()}
Scan a URL and return a structured accessibility report.
Options
:timeout— Navigation/analysis timeout in ms (default:30_000):wait_for— CSS selector to wait for before running axe:wait_until— Playwright wait state::load|:domcontentloaded|:networkidle(default::loadfor remote,:domcontentloadedfor file):viewport—{width, height}tuple (default:{1280, 720}):viewports— list of{width, height}tuples; runs axe once per viewport in a single browser session and returns per-viewport results (seemulti_report/0). WCAG 1.4.10 Reflow only shows up at narrow widths, so[{1440, 900}, {320, 800}]is the recommended pair for snapshot scanning. Screenshots are suffixed per viewport (name.1440x900.png). Takes precedence over:viewport.:check_clipping— measure interactive elements (a,button,input,select,textarea,[phx-click],[role="button"]) whose visible width falls below:clipping_ratio, plus page-level horizontal overflow. axe has no rule for content that is technically in the DOM but slid outside the visible area, yet that is the actual user-facing WCAG 1.4.10 failure. Results land in:clipping(per viewport with:viewports). Defaultfalse.:clipping_ratio— minimum visible-width ratio before an element counts as clipped (default:0.9):tags— axe-core tag filter (default:["wcag2a", "wcag2aa"]):user_agent— Override the default Chrome UA string:screenshot— Path to save a full-page PNG:disable_rules— List of axe rule IDs to skip:fallback— Fall back to curl + file:// on Playwright failure (default:true, remote URLs only)
Returns
{:ok, report} on success or {:error, reason} where reason is one of
the scan_error/0 tuples.