Skip to content
Use this GitHub action with your project
Add this Action to an existing workflow or create a new one
View on Marketplace

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

farol

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

rule wcag severity what it checks
img-alt 1.1.1 error images without alt text (or explicit decorative marker)
label-association 1.3.1 error form controls without a programmatic label
landmark-regions 1.3.1 warning documents with no <main> landmark
heading-order 1.3.1 warning skipped heading levels (h1 straight to h3)
duplicate-id 4.1.1 error ids repeated in the document
html-lang 3.1.1 error <html> without a declared language
document-title 2.4.2 warning documents with a missing or empty <title>

aria validity

rule wcag severity what it checks
valid-role 4.1.2 error role values outside the ARIA spec
valid-aria-attr 4.1.2 error misspelled or invented aria attributes
no-aria-on-hidden 4.1.2 warning aria on elements nothing can announce

accessible names

rule wcag severity what it checks
button-name 4.1.2 error buttons (including icon buttons) with no name
link-name 2.4.4 error links with no name
iframe-title 4.1.2 error iframes without a title

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

rule wcag severity what it checks
phx-click-interactive 2.1.1 error phx-click on elements keyboard users cannot reach
toggle-aria-pairing 4.1.2 warning JS.toggle/JS.show/JS.hide triggers without aria-expanded/aria-controls
focus-after-patch 2.4.3 warning phx-update containers that can swallow focus, with no hook to restore it
live-region-usage 4.1.3 warning flash containers outside an aria-live region

contrast (opt-in)

rule wcag severity what it checks
contrast-token 1.4.3 error inline 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.

About

Accessibility testing for Phoenix LiveView, in pure Elixir.

Resources

Stars

12 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages