Excessibility.Scanner (Excessibility v0.17.0)

Copy Markdown View Source

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}")
end

On 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}} -> ...
end

Fallback 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"

To skip the bundled npm install entirely, point Excessibility at a host node_modules directory that provides both playwright and @axe-core/playwright:

config :excessibility, node_modules_path: "assets/node_modules"

Relative paths are expanded from the project root. Note that resolving @axe-core/playwright from the host also pins the axe-core version, so engine.axe_version in reports follows the host installation — an axe minor bump can change finding sets relative to earlier baselines.

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

clipped_element()

@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.

clipping_info()

@type clipping_info() ::
  %{page_overflow?: boolean(), clipped: [clipped_element()]} | nil

Clipping measurements, present when :check_clipping is set.

engine_info()

@type engine_info() :: %{
  axe_version: String.t() | nil,
  chromium_version: String.t() | nil
}

Engine metadata for a scan.

fallback_info()

@type fallback_info() :: %{method: atom(), original_error: term()} | nil

Metadata describing a curl fallback, when one was used.

impact()

@type impact() :: :critical | :serious | :moderate | :minor | nil

axe-core impact level, normalized to an atom.

multi_report()

@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.

node_info()

@type node_info() :: %{
  target: [String.t()],
  html: String.t(),
  failure_summary: String.t()
}

A single offending element within a violation.

report()

@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.

scan_error()

@type scan_error() ::
  :timeout
  | {:http_error, non_neg_integer()}
  | {:navigation_failed, String.t()}
  | {:playwright_error, String.t()}
  | {:invalid_url, atom()}

Structured scan failure.

scan_opts()

@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.

viewport_result()

@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.

violation()

@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

scan(url, opts \\ [])

@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: :load for remote, :domcontentloaded for 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 (see multi_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). Default false.
  • :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.