axe-cuprite
Run the axe-core accessibility engine against pages in your Capybara system/feature tests driven by Cuprite (the CDP/Ferrum headless-Chrome driver) — and assert on the results with readable RSpec matchers.
The headline use case is catching WCAG color-contrast regressions in CI, but you can run any axe rule.
expect(page).to be_axe_clean.checking_only(:color_contrast)
Why this gem exists
Deque's official axe-core-capybara / axe-core-rspec gems do not work with
Cuprite. To inject and run axe they reach for the underlying Selenium
browser object inside the Capybara driver. Cuprite has no Selenium browser, so
they break.
axe-cuprite never touches Selenium-specific driver internals. axe-core is
just a JavaScript file — a driver-agnostic engine — so we drive it exclusively
through Capybara's driver-neutral JavaScript API (execute_script,
evaluate_async_script), which Cuprite fully implements. That single decision is
what makes this gem work where the official one doesn't. As a bonus it stays
driver-agnostic (it works on any real-browser Capybara driver), but Cuprite is
the primary, must-pass target.
There is no runtime dependency on Selenium, Cuprite, or Ferrum — the only runtime dependency is Capybara. You bring your own driver.
Installation
# Gemfile
group :test do
gem "axe-cuprite"
end
bundle install
Then load the RSpec matchers from your spec/spec_helper.rb (or rails_helper.rb):
require "axe/cuprite/rspec"
That mixes be_axe_clean / be_accessible into your example groups. If you only
want the framework-agnostic runner (no RSpec), require "axe/cuprite" instead.
Usage
The matcher
RSpec.describe "Dashboard", type: :system do
it "has no accessibility violations" do
visit dashboard_path
expect(page).to be_axe_clean
end
it "passes WCAG AA color contrast" do
visit dashboard_path
expect(page).to be_axe_clean.checking_only(:color_contrast)
end
end
be_accessible is an alias for be_axe_clean — use whichever reads better.
Chainable DSL
All chainers return the matcher, so they compose in any order:
| Method | What it does | axe concept |
|---|---|---|
.within(*selectors) |
Only test inside these elements | context include |
.excluding(*selectors) |
Skip these elements | context exclude |
.checking_only(*rules) |
Run only these rules | runOnly type rule |
.according_to(*tags) |
Run only rules with these tags | runOnly type tag |
.skipping(*rules) |
Disable these rules | rules: { id: { enabled: false } } |
.with_options(hash) |
Merge raw axe run options (escape hatch) | — |
.with_timeout(seconds) |
Override the axe timeout for this assertion | — |
Rule ids and tags accept either friendly Ruby symbols or axe's own ids —
underscores and hyphens are normalized for you, so :color_contrast and
"color-contrast" are equivalent, as are :best_practice and "best-practice".
expect(page).to be_axe_clean
.within("#main")
.excluding(".third-party-widget")
.according_to(:wcag2a, :wcag2aa)
.skipping(:region)
Note:
.checking_only(specific rules) and.according_to(tags) are mutually exclusive — axe'srunOnlyaccepts one or the other. Combining them raises anArgumentError.
Actionable failure messages
Failures are grouped by rule, with impact, help URL, the offending selector, and an HTML snippet. For color-contrast violations they surface exactly what you need to fix it:
expected page to be axe-clean, but found 1 violation across 1 element:
● [serious] color-contrast — Elements must meet minimum color contrast ratio thresholds (1 element)
https://dequeuniversity.com/rules/axe/4.12/color-contrast
- #faded
<p id="faded" style="color:#585858; opacity:0.5; background:#ffffff;"> This text token passes…
contrast 2.34:1 (needs 4.5:1) — fg #acacac on bg #ffffff, font 12pt/normal
The runner (no RSpec required)
results = AxeCuprite::Runner.new(page).run(
context: "#main",
options: { runOnly: { type: "rule", values: ["color-contrast"] } }
)
results.passes? # => false
results.violations # => [AxeCuprite::Violation, ...]
v = results.violations.first
v.id # => "color-contrast"
v.impact # => "serious"
node = v.nodes.first
node.selector # => "#faded"
cd = node.contrast_data # => AxeCuprite::ContrastData (nil for non-contrast rules)
cd.contrast_ratio # => 2.34
cd.expected_contrast_ratio # => 4.5
cd.fg_color # => "#acacac"
cd.bg_color # => "#ffffff"
context maps to axe's context arg (a CSS selector string, or a hash with
:include / :exclude). options maps to axe's run options and is deep-merged
on top of your configured defaults. Only violations and incomplete are
carried back across the CDP boundary — the full results object (with
passes/inapplicable) can be huge.
Configuration
AxeCuprite.configure do |c|
c.timeout = 30 # seconds to wait for axe.run (see caveats)
c. = {} # axe run options merged into every run
c. = %w[wcag2a wcag2aa] # applied when no rule/tag scope is given
c.skip_rules = [:region] # globally disabled rules
c.auto_inject = true # (re)inject axe on demand inside #run
c.report_only = false # log violations instead of failing (see below)
c.logger = Logger.new($stdout)
end
Report-only mode
Set report_only = true to log violations instead of failing the example.
This eases incremental adoption on an existing app — you can see what axe finds
without turning the suite red. Negated assertions (expect(page).not_to
be_axe_clean) ignore this flag.
Caveats & engineering notes
Timeout (decoupled from default_max_wait_time)
This is the subtle one. Ferrum's async evaluation wraps your promise in a
setTimeout(reject, wait * 1000) and rejects with "timed out promise" if it
doesn't resolve in time. Through Capybara's evaluate_async_script, that wait
is Capybara.default_max_wait_time — often 2 seconds, which axe.run on a real
page routinely exceeds.
axe-cuprite avoids this trap: on Cuprite it calls Ferrum's
page.evaluate_async(script, explicit_wait, *args) directly, with its own
timeout (default 30s, configurable) that is completely decoupled from
default_max_wait_time. On non-Ferrum drivers it falls back to
evaluate_async_script under a temporarily-raised wait time. If axe still
doesn't finish, you get a clear AxeCuprite::TimeoutError telling you to raise
the timeout or scope the run with .within.
Tune it globally (c.timeout = 60) or per assertion (.with_timeout(60)).
Content-Security-Policy
axe-cuprite's primary injection path is execute_script, which on Cuprite runs
via CDP Runtime.evaluate and is not subject to the page's CSP — so a strict
script-src policy generally doesn't block it (there's a test proving this). If
injection ever fails to land axe, the gem falls back to Ferrum's
add_script_tag(content:) and raises AxeCuprite::InjectionError with a
CSP-pointed message if even that fails.
Idempotent injection
axe (~500KB) is injected once per page and guarded on
typeof window.axe === 'undefined', so repeated assertions on the same page don't
re-inject. After a full navigation axe is gone; the runner detects the
"axe not defined" case and re-injects on demand. You can force a re-inject with
AxeCuprite::Runner.new(page).inject!(force: true).
Page readiness & iframes
axe runs against the DOM as it is the moment you assert — there are no implicit sleeps. Assert on a fully loaded/settled page (after Turbo frames, etc.). axe traverses same-origin iframes by default; cross-origin frames are inaccessible to the engine.
Updating the vendored axe-core engine
axe-core is vendored into the gem (lib/axe/cuprite/vendor/axe.min.js) — it
is never fetched at runtime. The current version is recorded in
AxeCuprite::AXE_CORE_VERSION and printed in the banner of the vendored file.
To refresh it:
rake 'axe:update[4.12.0]' # pin a version
rake axe:update # or grab the latest from npm
rake axe:version # print the currently vendored version
axe:update downloads axe.min.js and its LICENSE from unpkg and bumps the
AXE_CORE_VERSION constant. Note the bump in CHANGELOG.md.
Licensing
- axe-cuprite's own code is licensed under the MIT license — see
LICENSE.txt. - The vendored axe-core engine (
lib/axe/cuprite/vendor/axe.min.js) is a separate work by Deque Systems, licensed under the Mozilla Public License, version 2.0 (MPL-2.0). Its full license text ships alongside it atlib/axe/cuprite/vendor/axe-core-LICENSE.txt.
The two licenses are kept distinct and apply to their respective files.
Development
bundle install
bundle exec rspec # runs the suite under Cuprite (requires Chrome/Chromium)
The gem's own test suite uses Capybara + Cuprite against a tiny static fixture
app, including a known-bad opacity-induced contrast page, to prove the
no-Selenium path end to end.
Out of scope
- Static template/CSS analysis (this is render-time only, by design).
- Selenium/Watir support (the official
axe-core-*gems already cover those). - Auto-fixing violations.