# `Excessibility.Scanner`
[🔗](https://github.com/lessthanseventy/excessibility/blob/v0.18.1/lib/excessibility/scanner.ex#L1)

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 `t: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.

# `clipped_element`

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

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

Clipping measurements, present when `:check_clipping` is set.

# `engine_info`

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

Engine metadata for a scan.

# `fallback_info`

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

Metadata describing a curl fallback, when one was used.

# `impact`

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

axe-core impact level, normalized to an atom.

# `multi_report`

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

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

A single offending element within a violation.

# `report`

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

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

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

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

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

# `scan`

```elixir
@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 `t: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 `t:scan_error/0` tuples.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
