Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.13.0] - 2026-04-10

### Added
- **`Excessibility.Scanner` — public runtime scanning API** ([#107](https://github.com/lessthanseventy/excessibility/issues/107)). Call `Excessibility.Scanner.scan(url, opts)` from LiveViews, Oban jobs, CLI wrappers, or any application code to get a structured axe-core report. Returns `{:ok, report}` with atom-keyed fields (`:violations`, `:final_url`, `:duration_ms`, `:engine`, `:timestamp`, `:passes_count`, `:inapplicable_count`) or `{:error, reason}` with typed tuples (`:timeout`, `{:http_error, status}`, `{:navigation_failed, msg}`, `{:playwright_error, msg}`, `{:invalid_url, reason}`).
- Scanner options: `:timeout`, `:wait_for`, `:wait_until`, `:viewport`, `:tags`, `:user_agent`, `:screenshot`, `:disable_rules`, `:fallback`.
- Richer `axe-runner.js` output: `final_url` (after redirects), `duration_ms`, `engine.axe_version`, `engine.chromium_version`, `passes_count`, `inapplicable_count`.
- `mix excessibility.check` gains `--wait-until`, `--tags`, `--timeout`, `--viewport`, `--user-agent` flags.
- **`Excessibility.LiveViewRules` — LiveView-aware accessibility rules** that complement axe-core on Phoenix-specific patterns. Rules auto-discovered from `lib/excessibility/live_view_rules/rules/`; custom rules registered via `config :excessibility, custom_live_view_rules: [...]`. `mix excessibility` now runs both axe-core AND these rules on each snapshot and fails if either finds issues. Rules are no-ops on HTML without `phx-*` attributes, so the feature is safe on non-Phoenix snapshots.
- **Rule: `:phx_click_on_non_interactive`** ([#101](https://github.com/lessthanseventy/excessibility/issues/101)) — flags `phx-click` / `phx-click-away` on elements that are not natively keyboard-accessible (anything other than `<a>`/`<button>`/`<input>`/`<select>`/`<textarea>`/`<summary>`/`<details>`, or elements with `tabindex` or an interactive `role`).
- Config knobs: `:lv_rules_enabled?` (default `true`) and `:lv_rules_disabled` (list of rule ids to skip).

### Changed
- **`Excessibility.AxeRunner` removed and folded into `Excessibility.Scanner`.** The old module was an internal helper; all callers (`mix excessibility`, `mix excessibility.check`, the `a11y_check` MCP tool, snapshot screenshotting) now go through `Scanner.scan/2`. No end-user behavior change for existing Mix task users.
- Violation shape returned from `Scanner.scan/2` is now atom-keyed with normalized `:impact` atoms (`:critical | :serious | :moderate | :minor`), not the raw string-keyed axe-core output.

## [0.12.0] - 2026-03-25

### Breaking Changes
Expand Down
83 changes: 82 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,87 @@ Excessibility helps you test your Phoenix apps for accessibility (WCAG complianc
4. **Compare changes** with `mix excessibility.compare` to review what changed and approve/reject
5. **In CI**, axe-core reports accessibility violations alongside your test failures

## LiveView-Aware Rules

axe-core can't catch accessibility issues that depend on Phoenix-specific
attributes like `phx-click`, `phx-submit`, or `phx-debounce`. Excessibility
ships a complementary set of **LiveView rules** that inspect snapshot HTML
for these patterns and run automatically alongside axe-core when you call
`mix excessibility`.

Built-in rules:

| Rule | What it flags |
| --- | --- |
| `:phx_click_on_non_interactive` | `phx-click` / `phx-click-away` on `<div>`, `<li>`, `<span>`, `<tr>`, etc. without `tabindex` or an interactive `role` — visually clickable but unreachable by keyboard |

On non-Phoenix HTML (no `phx-*` attributes) these rules are no-ops, so
enabling them never adds noise for projects that don't use LiveView.

**Config:**

```elixir
# config/test.exs
config :excessibility,
lv_rules_enabled?: true, # default
lv_rules_disabled: [:phx_click_on_non_interactive] # skip specific rules
```

**Custom rules:** implement `Excessibility.LiveViewRules.Rule` and register:

```elixir
config :excessibility, custom_live_view_rules: [MyApp.Rules.MyRule]
```

## Runtime Usage (Scanner API)

In addition to the snapshot-testing workflow, Excessibility exposes
`Excessibility.Scanner.scan/2` for runtime use — call it from LiveView
handlers, background jobs, CLI wrappers, or any plain application code
to scan an arbitrary URL and get a structured axe-core report:

```elixir
case Excessibility.Scanner.scan("https://example.com") do
{:ok, report} ->
IO.puts("Found #{length(report.violations)} violations on #{report.final_url}")

for v <- report.violations do
IO.puts(" [#{v.impact}] #{v.id}: #{v.description}")
IO.puts(" #{v.help_url}")
end

{:error, :timeout} ->
Logger.warning("Scan timed out")

{:error, {:http_error, status}} ->
Logger.warning("Target returned HTTP #{status}")

{:error, {:navigation_failed, msg}} ->
Logger.warning("Navigation failed: #{msg}")

{:error, {:invalid_url, reason}} ->
Logger.warning("Invalid URL: #{reason}")
end
```

Pass options to control the scan:

```elixir
Excessibility.Scanner.scan("https://example.com",
timeout: 20_000,
wait_for: "#main",
tags: ["wcag2a", "wcag2aa", "wcag21aa"],
viewport: {1440, 900},
screenshot: "/tmp/example.png"
)
```

See `Excessibility.Scanner` for the full report type and options list.
Unlike Mix tasks, the Scanner is safe to call from production Phoenix
releases, which makes it easy to build things like a public URL
accessibility scanner, background monitoring jobs, or an internal
scanning service on top of the library.

## Features

- Snapshot HTML from `Plug.Conn`, `Wallaby.Session`, `Phoenix.LiveViewTest.View`, and `Phoenix.LiveViewTest.Element`
Expand Down Expand Up @@ -282,7 +363,7 @@ Add to `mix.exs`:
```elixir
def deps do
[
{:excessibility, "~> 0.12", only: [:dev, :test]}
{:excessibility, "~> 0.13", only: [:dev, :test]}
]
end
```
Expand Down
247 changes: 201 additions & 46 deletions assets/axe-runner.js
Original file line number Diff line number Diff line change
@@ -1,77 +1,232 @@
// Runs axe-core against a URL via Playwright and emits a structured JSON
// report to stdout. On failure, emits {error: <code>, message: <str>, ...}
// to stdout and exits 1. The Elixir Excessibility.Scanner module parses
// both shapes.

const path = require("path");
const modulesDir = path.join(__dirname, "node_modules");
const { chromium } = require(path.join(modulesDir, "playwright"));
const { AxeBuilder } = require(path.join(modulesDir, "@axe-core", "playwright"));

const USAGE =
"Usage: node axe-runner.js <url> " +
"[--screenshot path] [--wait-for selector] [--wait-until load|domcontentloaded|networkidle] " +
"[--disable-rules r1,r2] [--tags t1,t2] [--timeout ms] [--viewport WxH] [--user-agent ua]";

const DEFAULT_UA =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";

function emitError(code, message, extra = {}) {
console.log(JSON.stringify({ error: code, message: message || "", ...extra }));
}

async function closeQuietly(browser) {
try {
if (browser) await browser.close();
} catch {
// nothing useful we can do
}
}

function parseArgs(argv) {
const url = argv[0];
const opts = {
url,
screenshotPath: null,
waitFor: null,
waitUntil: null,
disableRules: [],
tags: ["wcag2a", "wcag2aa"],
timeout: 30000,
viewport: { width: 1280, height: 720 },
userAgent: null,
};

for (let i = 1; i < argv.length; i++) {
const arg = argv[i];
const next = argv[i + 1];
switch (arg) {
case "--screenshot":
opts.screenshotPath = next;
i++;
break;
case "--wait-for":
opts.waitFor = next;
i++;
break;
case "--wait-until":
opts.waitUntil = next;
i++;
break;
case "--disable-rules":
opts.disableRules = next ? next.split(",").filter(Boolean) : [];
i++;
break;
case "--tags":
opts.tags = next ? next.split(",").filter(Boolean) : opts.tags;
i++;
break;
case "--timeout": {
const n = parseInt(next, 10);
if (!Number.isNaN(n) && n > 0) opts.timeout = n;
i++;
break;
}
case "--viewport": {
if (next) {
const [w, h] = next.split("x").map((s) => parseInt(s, 10));
if (w > 0 && h > 0) opts.viewport = { width: w, height: h };
}
i++;
break;
}
case "--user-agent":
opts.userAgent = next;
i++;
break;
}
}

return opts;
}

async function waitForContent(page, maxWait = 8000) {
const start = Date.now();
while (Date.now() - start < maxWait) {
const bodyText = await page
.evaluate(() => (document.body && document.body.innerText ? document.body.innerText.trim() : ""))
.catch(() => "");
if (bodyText.length > 50) return;
await new Promise((r) => setTimeout(r, 500));
}
}

async function main() {
const args = process.argv.slice(2);
const url = args[0];
if (!url) {
console.error(JSON.stringify({ error: "Usage: node axe-runner.js <url> [--screenshot path] [--wait-for selector] [--disable-rules rule1,rule2]" }));
const argv = process.argv.slice(2);
if (!argv[0]) {
emitError("invalid_args", USAGE);
process.exit(1);
}

let screenshotPath = null;
let waitFor = null;
let disableRules = [];
const opts = parseArgs(argv);
const { url, screenshotPath, waitFor, waitUntil, disableRules, tags, timeout, viewport, userAgent } = opts;

const isFileUrl = url.startsWith("file://");
const startTime = Date.now();

for (let i = 1; i < args.length; i++) {
if (args[i] === "--screenshot" && args[i + 1]) screenshotPath = args[++i];
else if (args[i] === "--wait-for" && args[i + 1]) waitFor = args[++i];
else if (args[i] === "--disable-rules" && args[i + 1]) disableRules = args[++i].split(",");
let browser;
try {
browser = await chromium.launch();
} catch (err) {
emitError("playwright_error", `failed to launch chromium: ${err.message}`);
process.exit(1);
}

const isFileUrl = url.startsWith("file://");
const chromiumVersion = browser.version();

const browser = await chromium.launch();
const context = await browser.newContext({
// Look like a real browser for remote URLs
...(!isFileUrl && {
userAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
viewport: { width: 1280, height: 720 },
locale: "en-US",
}),
});
const page = await context.newPage();
const contextOptions = {
viewport,
locale: "en-US",
};
if (!isFileUrl) {
contextOptions.userAgent = userAgent || DEFAULT_UA;
} else if (userAgent) {
contextOptions.userAgent = userAgent;
}

let context;
let page;
try {
// file:// URLs just need DOM; remote URLs wait for load event then we poll for content
const waitUntil = isFileUrl ? "domcontentloaded" : "load";
await page.goto(url, { waitUntil, timeout: 30000 });
context = await browser.newContext(contextOptions);
page = await context.newPage();
} catch (err) {
emitError("playwright_error", `failed to create browser context: ${err.message}`);
await closeQuietly(browser);
process.exit(1);
}

try {
const effectiveWaitUntil = waitUntil || (isFileUrl ? "domcontentloaded" : "load");

let response;
try {
response = await page.goto(url, { waitUntil: effectiveWaitUntil, timeout });
} catch (err) {
if (err.name === "TimeoutError") {
emitError("timeout", err.message);
} else {
emitError("navigation_failed", err.message);
}
await closeQuietly(browser);
process.exit(1);
}

if (response && !isFileUrl) {
const status = response.status();
if (status >= 400) {
emitError("http_error", `HTTP ${status}`, { status });
await closeQuietly(browser);
process.exit(1);
}
}

if (waitFor) {
await page.waitForSelector(waitFor, { timeout: 10000 });
try {
await page.waitForSelector(waitFor, { timeout: Math.min(timeout, 10000) });
} catch (err) {
if (err.name === "TimeoutError") {
emitError("timeout", `wait_for '${waitFor}' timed out`);
} else {
emitError("playwright_error", err.message);
}
await closeQuietly(browser);
process.exit(1);
}
} else if (!isFileUrl) {
// For SPAs: if body looks empty after load, give it more time
await waitForContent(page);
}

let builder = new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]);
let builder = new AxeBuilder({ page }).withTags(tags);
if (disableRules.length > 0) builder = builder.disableRules(disableRules);

const results = await builder.analyze();
let results;
try {
results = await builder.analyze();
} catch (err) {
emitError("playwright_error", `axe-core analyze failed: ${err.message}`);
await closeQuietly(browser);
process.exit(1);
}

if (screenshotPath) {
try {
await page.screenshot({ path: screenshotPath, fullPage: true });
} catch {
// screenshot failure is non-fatal
}
}

if (screenshotPath) await page.screenshot({ path: screenshotPath, fullPage: true });
const output = {
final_url: page.url(),
timestamp: new Date().toISOString(),
duration_ms: Date.now() - startTime,
engine: {
axe_version: results && results.testEngine ? results.testEngine.version || null : null,
chromium_version: chromiumVersion,
},
violations: results.violations || [],
incomplete: results.incomplete || [],
passes_count: (results.passes || []).length,
inapplicable_count: (results.inapplicable || []).length,
};

console.log(JSON.stringify(results));
console.log(JSON.stringify(output));
await closeQuietly(browser);
} catch (err) {
console.error(JSON.stringify({ error: err.message }));
emitError("playwright_error", err.message);
await closeQuietly(browser);
process.exit(1);
} finally {
await browser.close();
}
}

// Wait for SPA content to render — checks that body has meaningful content
async function waitForContent(page, maxWait = 8000) {
const start = Date.now();
while (Date.now() - start < maxWait) {
const bodyText = await page.evaluate(() => document.body?.innerText?.trim() || "");
// If body has more than a few chars of text, content has rendered
if (bodyText.length > 50) return;
await new Promise((r) => setTimeout(r, 500));
}
// Timed out waiting for content — run axe anyway on whatever's there
}

main();
Loading
Loading