Accessibility testing for Phoenix LiveView, in pure Elixir.

farol is portuguese for lighthouse, and Lighthouse is the audit tool most of the web knows. This one speaks LiveView natively: it knows what phx-click on a <div> means, and it explains every finding instead of just pointing at it. No node, no browser, no axe-core port. Just Elixir, running inside the test suite you already have.

test "user card is accessible" do
  html = render_component(&user_card/1, user: user)
  assert_accessible html
end

When it fails, it teaches:

x [img-alt] <img src="avatar.jpg"> has no alt text
  wcag 1.1.1 (level a) - error

  why: screen readers announce the filename ("i m g underscore avatar
    dot jay peg") or silence. 2.2 billion people live with some form of
    vision impairment; alt text is how the image reaches them.

  fix: add alt="..." describing what the image conveys - or alt=""
    (empty) when the image is purely decorative, so screen readers skip
    it.

Every finding carries three layers: what was found, why it matters to humans, and how to fix it. The "why" is written for the developer reading a failing test at 5pm, not for a compliance auditor.

installation

def deps do
  [
    {:farol, "~> 0.2", only: :test} # x-release-please-version
  ]
end

Then import the assertion in your test case (or in ConnCase/DataCase template, to have it everywhere):

import Farol.Assertions

using it

assert_accessible/2 takes any rendered HTML string, which is exactly what render_component/2 and render/1 return in LiveView tests:

test "settings page is accessible" do
  {:ok, view, html} = live(conn, "/settings")
  assert_accessible html
end

Escape hatches are explicit on purpose, so exceptions stay greppable in code review:

assert_accessible html, except: ["landmark-regions"]
assert_accessible html, only: [:img_alt, :label_association]

Want the raw findings instead of an assertion? Farol.check/2 returns them as data, and Farol.Report.format/1 renders the same report the assertion prints.

static analysis: mix farol

The second engine audits HEEx template source — no running app, no test to write. It walks the same parser LiveView compiles with, so attr={@expr} and <%= if %> blocks are understood, and every finding carries the file and line it came from:

$ mix farol
4 accessibility finding(s): 3 error(s), 1 warning(s)

x [img-alt] <img src="@user.avatar"> has no alt text
  wcag 1.1.1 (level a) - error - lib/my_app_web/components/card.ex:2
  ...

It audits lib/**/*.heex by default, takes paths or globs as arguments, and exits non-zero on error-severity findings or unparseable templates:

mix farol lib/my_app_web/live --except landmark-regions
mix farol --format sarif --output farol.sarif

--format sarif emits SARIF 2.1.0 for GitHub code scanning. The farol GitHub Action wraps the whole flow: run the audit, upload the SARIF so findings annotate the pull request, and fail the workflow on errors:

- uses: erlef/setup-beam@v1
  with: {elixir-version: "1.18", otp-version: "27"}
- run: mix deps.get
- uses: zeetech/farol@v0.2

The static engine needs phoenix_live_view (that is where the HEEx parser lives). It is an optional dependency: if your app already renders LiveViews, there is nothing to add.

the rule catalog

The catalog is the product. Every rule is a plain module implementing the Farol.Rule behaviour, which makes the catalog the extension point too.

structure

rulewcagseveritywhat it checks
img-alt1.1.1errorimages without alt text (or explicit decorative marker)
label-association1.3.1errorform controls without a programmatic label
landmark-regions1.3.1warningdocuments with no <main> landmark
heading-order1.3.1warningskipped heading levels (h1 straight to h3)
duplicate-id4.1.1errorids repeated in the document
html-lang3.1.1error<html> without a declared language
document-title2.4.2warningdocuments with a missing or empty <title>

aria validity

rulewcagseveritywhat it checks
valid-role4.1.2errorrole values outside the ARIA spec
valid-aria-attr4.1.2errormisspelled or invented aria attributes
no-aria-on-hidden4.1.2warningaria on elements nothing can announce

accessible names

rulewcagseveritywhat it checks
button-name4.1.2errorbuttons (including icon buttons) with no name
link-name2.4.4errorlinks with no name
iframe-title4.1.2erroriframes without a title

liveview-aware (the reason farol exists; nobody else checks these)

rulewcagseveritywhat it checks
phx-click-interactive2.1.1errorphx-click on elements keyboard users cannot reach
toggle-aria-pairing4.1.2warningJS.toggle/JS.show/JS.hide triggers without aria-expanded/aria-controls
focus-after-patch2.4.3warningphx-update containers that can swallow focus, with no hook to restore it
live-region-usage4.1.3warningflash containers outside an aria-live region

contrast (opt-in)

rulewcagseveritywhat it checks
contrast-token1.4.3errorinline colors below WCAG AA ratios, resolved through your design tokens

contrast, driven by your design tokens

contrast-token only runs when you declare a token map, because without it the rule cannot know what your color names mean. Declare it once and your design system becomes the test fixture:

config :farol, :tokens, %{
  "bg" => "#000A0F",
  "fg" => "#F7F7FF",
  "accent" => "#9655FF"
}

Inline styles then check against WCAG AA: 4.5:1 for text, 3:1 for large text (24px, or 18.66px bold). Values can be hex literals, token names, or var(--token) references. For the zeetech palette above, farol can tell you that #9655FF on #000A0F sits at about 4.8:1, and show the math.

Deliberate scope line: tokens plus inline styles only. Resolving CSS classes means writing a CSS engine, and that is a later conversation.

writing your own rules

A rule is a plain module with a behaviour, no macros:

defmodule MyApp.Rules.NoTargetBlank do
  @behaviour Farol.Rule

  alias Farol.{Finding, Node}

  def id, do: "no-target-blank"
  def wcag, do: "3.2.5"
  def level, do: "a"
  def severity, do: :warning
  def why, do: "new tabs break the back button and disorient screen reader users."
  def fix, do: "drop target=\"_blank\", or warn in the link text that it opens a new tab."

  def check(nodes) do
    nodes
    |> Node.find(&(Node.attr(&1, "target") == "_blank"))
    |> Enum.map(fn node ->
      %Finding{
        rule: id(),
        wcag: wcag(),
        level: level(),
        severity: severity(),
        message: "#{Node.snippet(node)} opens a new tab without warning",
        snippet: Node.snippet(node)
      }
    end)
  end
end

Rules receive the parsed document as Farol.Node trees and return findings. They never raise and never do IO, which keeps them trivially testable: pass a string through Farol.check/2 with only: and assert on the findings.

license

MIT. Built by zeetech, de minoria pra minoria.