axe-cuprite 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE.txt +30 -0
- data/README.md +257 -0
- data/lib/axe/cuprite/configuration.rb +56 -0
- data/lib/axe/cuprite/errors.rb +19 -0
- data/lib/axe/cuprite/injector.rb +182 -0
- data/lib/axe/cuprite/normalize.rb +27 -0
- data/lib/axe/cuprite/results.rb +167 -0
- data/lib/axe/cuprite/rspec/matchers.rb +227 -0
- data/lib/axe/cuprite/rspec.rb +27 -0
- data/lib/axe/cuprite/runner.rb +102 -0
- data/lib/axe/cuprite/tasks/axe.rake +73 -0
- data/lib/axe/cuprite/vendor/axe-core-LICENSE.txt +362 -0
- data/lib/axe/cuprite/vendor/axe.min.js +12 -0
- data/lib/axe/cuprite/version.rb +10 -0
- data/lib/axe/cuprite.rb +54 -0
- data/lib/axe-cuprite.rb +5 -0
- metadata +197 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8cd30e650ead2d72475783c40ed7cc6d7d251527913d2b28ae6643402d9d032b
|
|
4
|
+
data.tar.gz: a5624b4d65f5859b7fd4a710f79a5bf6fb85b6c9f409e2bc50de393f8563ad86
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 89f99c88d792b73c748f25c4b7a33d0b136a5a939e77f14b6fcbc437f88ab5eb85e4ebf0f182843ce23ea1a04857e619104db06aaf476eb065c9252aebe2c22b
|
|
7
|
+
data.tar.gz: 274b40778c354c1ee42a04958fdda2b1f9513f9220d94b300495a62cf86c86d862655d504c1366ead311e65ba1410104f3eec9fc51f280faee098464113d8af9
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-06-06
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Initial release of **axe-cuprite**.
|
|
12
|
+
- Vendored **axe-core 4.12.0** (`lib/axe/cuprite/vendor/axe.min.js`, MPL-2.0).
|
|
13
|
+
- `AxeCuprite::Runner` — framework-agnostic runner that injects axe and runs it
|
|
14
|
+
through Capybara's driver-neutral JavaScript API (works on Cuprite/Ferrum with
|
|
15
|
+
**zero Selenium dependency**), with a configurable timeout decoupled from
|
|
16
|
+
`Capybara.default_max_wait_time`.
|
|
17
|
+
- Typed result objects: `Results`, `Violation`, `Node`, `ContrastData`.
|
|
18
|
+
- RSpec matcher `be_axe_clean` (alias `be_accessible`) with chainable DSL:
|
|
19
|
+
`.within`, `.excluding`, `.checking_only`, `.skipping`, `.according_to`,
|
|
20
|
+
`.with_options`, `.with_timeout`. Actionable, grouped failure messages that
|
|
21
|
+
surface fg/bg color and contrast ratio for color-contrast violations.
|
|
22
|
+
- `AxeCuprite.configure` for timeout, default options/tags, global skip-list,
|
|
23
|
+
auto-inject toggle, and report-only mode.
|
|
24
|
+
- `rake axe:update[VERSION]` to refresh the vendored axe-core engine and bump
|
|
25
|
+
the `AXE_CORE_VERSION` constant.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Abdullah Hashim / Guided Rails
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
This gem vendors the axe-core JavaScript engine
|
|
26
|
+
(lib/axe/cuprite/vendor/axe.min.js), which is licensed separately under the
|
|
27
|
+
Mozilla Public License, version 2.0 (MPL-2.0). axe-core is Copyright (c)
|
|
28
|
+
Deque Systems, Inc. The full MPL-2.0 text is included alongside the vendored
|
|
29
|
+
file at lib/axe/cuprite/vendor/axe-core-LICENSE.txt. The MIT license above
|
|
30
|
+
applies only to the axe-cuprite gem's own source code.
|
data/README.md
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# axe-cuprite
|
|
2
|
+
|
|
3
|
+
Run the [axe-core](https://github.com/dequelabs/axe-core) accessibility engine
|
|
4
|
+
against pages in your **Capybara system/feature tests driven by
|
|
5
|
+
[Cuprite](https://github.com/rubycdp/cuprite)** (the CDP/Ferrum headless-Chrome
|
|
6
|
+
driver) — and assert on the results with readable RSpec matchers.
|
|
7
|
+
|
|
8
|
+
The headline use case is **catching WCAG color-contrast regressions in CI**, but
|
|
9
|
+
you can run any axe rule.
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
expect(page).to be_axe_clean.checking_only(:color_contrast)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Why this gem exists
|
|
16
|
+
|
|
17
|
+
Deque's official `axe-core-capybara` / `axe-core-rspec` gems **do not work with
|
|
18
|
+
Cuprite**. To inject and run axe they reach for the underlying **Selenium**
|
|
19
|
+
`browser` object inside the Capybara driver. Cuprite has no Selenium browser, so
|
|
20
|
+
they break.
|
|
21
|
+
|
|
22
|
+
**axe-cuprite never touches Selenium-specific driver internals.** axe-core is
|
|
23
|
+
just a JavaScript file — a driver-agnostic engine — so we drive it exclusively
|
|
24
|
+
through Capybara's **driver-neutral** JavaScript API (`execute_script`,
|
|
25
|
+
`evaluate_async_script`), which Cuprite fully implements. That single decision is
|
|
26
|
+
what makes this gem work where the official one doesn't. As a bonus it stays
|
|
27
|
+
driver-agnostic (it works on any real-browser Capybara driver), but **Cuprite is
|
|
28
|
+
the primary, must-pass target.**
|
|
29
|
+
|
|
30
|
+
There is no runtime dependency on Selenium, Cuprite, or Ferrum — the only runtime
|
|
31
|
+
dependency is Capybara. You bring your own driver.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
# Gemfile
|
|
37
|
+
group :test do
|
|
38
|
+
gem "axe-cuprite"
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
bundle install
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then load the RSpec matchers from your `spec/spec_helper.rb` (or `rails_helper.rb`):
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
require "axe/cuprite/rspec"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
That mixes `be_axe_clean` / `be_accessible` into your example groups. If you only
|
|
53
|
+
want the framework-agnostic runner (no RSpec), `require "axe/cuprite"` instead.
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
### The matcher
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
RSpec.describe "Dashboard", type: :system do
|
|
61
|
+
it "has no accessibility violations" do
|
|
62
|
+
visit dashboard_path
|
|
63
|
+
expect(page).to be_axe_clean
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it "passes WCAG AA color contrast" do
|
|
67
|
+
visit dashboard_path
|
|
68
|
+
expect(page).to be_axe_clean.checking_only(:color_contrast)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`be_accessible` is an alias for `be_axe_clean` — use whichever reads better.
|
|
74
|
+
|
|
75
|
+
### Chainable DSL
|
|
76
|
+
|
|
77
|
+
All chainers return the matcher, so they compose in any order:
|
|
78
|
+
|
|
79
|
+
| Method | What it does | axe concept |
|
|
80
|
+
| --- | --- | --- |
|
|
81
|
+
| `.within(*selectors)` | Only test inside these elements | context `include` |
|
|
82
|
+
| `.excluding(*selectors)` | Skip these elements | context `exclude` |
|
|
83
|
+
| `.checking_only(*rules)` | Run only these rules | `runOnly` type `rule` |
|
|
84
|
+
| `.according_to(*tags)` | Run only rules with these tags | `runOnly` type `tag` |
|
|
85
|
+
| `.skipping(*rules)` | Disable these rules | `rules: { id: { enabled: false } }` |
|
|
86
|
+
| `.with_options(hash)` | Merge raw axe run options (escape hatch) | — |
|
|
87
|
+
| `.with_timeout(seconds)` | Override the axe timeout for this assertion | — |
|
|
88
|
+
|
|
89
|
+
Rule ids and tags accept **either** friendly Ruby symbols **or** axe's own ids —
|
|
90
|
+
underscores and hyphens are normalized for you, so `:color_contrast` and
|
|
91
|
+
`"color-contrast"` are equivalent, as are `:best_practice` and `"best-practice"`.
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
expect(page).to be_axe_clean
|
|
95
|
+
.within("#main")
|
|
96
|
+
.excluding(".third-party-widget")
|
|
97
|
+
.according_to(:wcag2a, :wcag2aa)
|
|
98
|
+
.skipping(:region)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
> Note: `.checking_only` (specific rules) and `.according_to` (tags) are mutually
|
|
102
|
+
> exclusive — axe's `runOnly` accepts one or the other. Combining them raises an
|
|
103
|
+
> `ArgumentError`.
|
|
104
|
+
|
|
105
|
+
### Actionable failure messages
|
|
106
|
+
|
|
107
|
+
Failures are grouped by rule, with impact, help URL, the offending selector, and
|
|
108
|
+
an HTML snippet. For **color-contrast** violations they surface exactly what you
|
|
109
|
+
need to fix it:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
expected page to be axe-clean, but found 1 violation across 1 element:
|
|
113
|
+
|
|
114
|
+
● [serious] color-contrast — Elements must meet minimum color contrast ratio thresholds (1 element)
|
|
115
|
+
https://dequeuniversity.com/rules/axe/4.12/color-contrast
|
|
116
|
+
- #faded
|
|
117
|
+
<p id="faded" style="color:#585858; opacity:0.5; background:#ffffff;"> This text token passes…
|
|
118
|
+
contrast 2.34:1 (needs 4.5:1) — fg #acacac on bg #ffffff, font 12pt/normal
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### The runner (no RSpec required)
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
results = AxeCuprite::Runner.new(page).run(
|
|
125
|
+
context: "#main",
|
|
126
|
+
options: { runOnly: { type: "rule", values: ["color-contrast"] } }
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
results.passes? # => false
|
|
130
|
+
results.violations # => [AxeCuprite::Violation, ...]
|
|
131
|
+
v = results.violations.first
|
|
132
|
+
v.id # => "color-contrast"
|
|
133
|
+
v.impact # => "serious"
|
|
134
|
+
node = v.nodes.first
|
|
135
|
+
node.selector # => "#faded"
|
|
136
|
+
cd = node.contrast_data # => AxeCuprite::ContrastData (nil for non-contrast rules)
|
|
137
|
+
cd.contrast_ratio # => 2.34
|
|
138
|
+
cd.expected_contrast_ratio # => 4.5
|
|
139
|
+
cd.fg_color # => "#acacac"
|
|
140
|
+
cd.bg_color # => "#ffffff"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`context` maps to axe's context arg (a CSS selector string, or a hash with
|
|
144
|
+
`:include` / `:exclude`). `options` maps to axe's run options and is deep-merged
|
|
145
|
+
on top of your configured defaults. Only `violations` and `incomplete` are
|
|
146
|
+
carried back across the CDP boundary — the full results object (with
|
|
147
|
+
`passes`/`inapplicable`) can be huge.
|
|
148
|
+
|
|
149
|
+
## Configuration
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
AxeCuprite.configure do |c|
|
|
153
|
+
c.timeout = 30 # seconds to wait for axe.run (see caveats)
|
|
154
|
+
c.default_options = {} # axe run options merged into every run
|
|
155
|
+
c.default_tags = %w[wcag2a wcag2aa] # applied when no rule/tag scope is given
|
|
156
|
+
c.skip_rules = [:region] # globally disabled rules
|
|
157
|
+
c.auto_inject = true # (re)inject axe on demand inside #run
|
|
158
|
+
c.report_only = false # log violations instead of failing (see below)
|
|
159
|
+
c.logger = Logger.new($stdout)
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Report-only mode
|
|
164
|
+
|
|
165
|
+
Set `report_only = true` to **log** violations instead of failing the example.
|
|
166
|
+
This eases incremental adoption on an existing app — you can see what axe finds
|
|
167
|
+
without turning the suite red. Negated assertions (`expect(page).not_to
|
|
168
|
+
be_axe_clean`) ignore this flag.
|
|
169
|
+
|
|
170
|
+
## Caveats & engineering notes
|
|
171
|
+
|
|
172
|
+
### Timeout (decoupled from `default_max_wait_time`)
|
|
173
|
+
|
|
174
|
+
This is the subtle one. Ferrum's async evaluation wraps your promise in a
|
|
175
|
+
`setTimeout(reject, wait * 1000)` and **rejects with "timed out promise"** if it
|
|
176
|
+
doesn't resolve in time. Through Capybara's `evaluate_async_script`, that `wait`
|
|
177
|
+
is `Capybara.default_max_wait_time` — often 2 seconds, which `axe.run` on a real
|
|
178
|
+
page routinely exceeds.
|
|
179
|
+
|
|
180
|
+
axe-cuprite avoids this trap: on Cuprite it calls Ferrum's
|
|
181
|
+
`page.evaluate_async(script, explicit_wait, *args)` **directly**, with its own
|
|
182
|
+
timeout (default **30s**, configurable) that is completely **decoupled from
|
|
183
|
+
`default_max_wait_time`**. On non-Ferrum drivers it falls back to
|
|
184
|
+
`evaluate_async_script` under a temporarily-raised wait time. If axe still
|
|
185
|
+
doesn't finish, you get a clear `AxeCuprite::TimeoutError` telling you to raise
|
|
186
|
+
the timeout or scope the run with `.within`.
|
|
187
|
+
|
|
188
|
+
Tune it globally (`c.timeout = 60`) or per assertion (`.with_timeout(60)`).
|
|
189
|
+
|
|
190
|
+
### Content-Security-Policy
|
|
191
|
+
|
|
192
|
+
axe-cuprite's primary injection path is `execute_script`, which on Cuprite runs
|
|
193
|
+
via CDP `Runtime.evaluate` and is **not subject to the page's CSP** — so a strict
|
|
194
|
+
`script-src` policy generally doesn't block it (there's a test proving this). If
|
|
195
|
+
injection ever fails to land axe, the gem falls back to Ferrum's
|
|
196
|
+
`add_script_tag(content:)` and raises `AxeCuprite::InjectionError` with a
|
|
197
|
+
CSP-pointed message if even that fails.
|
|
198
|
+
|
|
199
|
+
### Idempotent injection
|
|
200
|
+
|
|
201
|
+
axe (~500KB) is injected once per page and guarded on
|
|
202
|
+
`typeof window.axe === 'undefined'`, so repeated assertions on the same page don't
|
|
203
|
+
re-inject. After a full navigation axe is gone; the runner detects the
|
|
204
|
+
"axe not defined" case and re-injects on demand. You can force a re-inject with
|
|
205
|
+
`AxeCuprite::Runner.new(page).inject!(force: true)`.
|
|
206
|
+
|
|
207
|
+
### Page readiness & iframes
|
|
208
|
+
|
|
209
|
+
axe runs against the DOM as it is the moment you assert — there are no implicit
|
|
210
|
+
sleeps. Assert on a fully loaded/settled page (after Turbo frames, etc.). axe
|
|
211
|
+
traverses **same-origin** iframes by default; cross-origin frames are inaccessible
|
|
212
|
+
to the engine.
|
|
213
|
+
|
|
214
|
+
## Updating the vendored axe-core engine
|
|
215
|
+
|
|
216
|
+
axe-core is **vendored** into the gem (`lib/axe/cuprite/vendor/axe.min.js`) — it
|
|
217
|
+
is never fetched at runtime. The current version is recorded in
|
|
218
|
+
`AxeCuprite::AXE_CORE_VERSION` and printed in the banner of the vendored file.
|
|
219
|
+
|
|
220
|
+
To refresh it:
|
|
221
|
+
|
|
222
|
+
```sh
|
|
223
|
+
rake 'axe:update[4.12.0]' # pin a version
|
|
224
|
+
rake axe:update # or grab the latest from npm
|
|
225
|
+
rake axe:version # print the currently vendored version
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`axe:update` downloads `axe.min.js` and its `LICENSE` from unpkg and bumps the
|
|
229
|
+
`AXE_CORE_VERSION` constant. Note the bump in `CHANGELOG.md`.
|
|
230
|
+
|
|
231
|
+
## Licensing
|
|
232
|
+
|
|
233
|
+
- **axe-cuprite's own code** is licensed under the **MIT** license — see
|
|
234
|
+
[`LICENSE.txt`](LICENSE.txt).
|
|
235
|
+
- The **vendored axe-core engine** (`lib/axe/cuprite/vendor/axe.min.js`) is a
|
|
236
|
+
separate work by Deque Systems, licensed under the **Mozilla Public License,
|
|
237
|
+
version 2.0 (MPL-2.0)**. Its full license text ships alongside it at
|
|
238
|
+
[`lib/axe/cuprite/vendor/axe-core-LICENSE.txt`](lib/axe/cuprite/vendor/axe-core-LICENSE.txt).
|
|
239
|
+
|
|
240
|
+
The two licenses are kept distinct and apply to their respective files.
|
|
241
|
+
|
|
242
|
+
## Development
|
|
243
|
+
|
|
244
|
+
```sh
|
|
245
|
+
bundle install
|
|
246
|
+
bundle exec rspec # runs the suite under Cuprite (requires Chrome/Chromium)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The gem's own test suite uses Capybara + Cuprite against a tiny static fixture
|
|
250
|
+
app, including a known-bad `opacity`-induced contrast page, to prove the
|
|
251
|
+
no-Selenium path end to end.
|
|
252
|
+
|
|
253
|
+
## Out of scope
|
|
254
|
+
|
|
255
|
+
- Static template/CSS analysis (this is render-time only, by design).
|
|
256
|
+
- Selenium/Watir support (the official `axe-core-*` gems already cover those).
|
|
257
|
+
- Auto-fixing violations.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module AxeCuprite
|
|
6
|
+
# Global configuration for axe-cuprite. Accessed via AxeCuprite.configure.
|
|
7
|
+
class Configuration
|
|
8
|
+
# Maximum time (seconds) to wait for axe.run to resolve. This is decoupled
|
|
9
|
+
# from Capybara.default_max_wait_time — see Injector. Default 30s because
|
|
10
|
+
# axe.run on a large page routinely exceeds Capybara's 2s default.
|
|
11
|
+
attr_accessor :timeout
|
|
12
|
+
|
|
13
|
+
# Default axe run options merged into every run (e.g. { resultTypes: [...] }).
|
|
14
|
+
# Caller / matcher options are deep-merged on top of these.
|
|
15
|
+
attr_accessor :default_options
|
|
16
|
+
|
|
17
|
+
# Default axe tags applied when no explicit rule/tag scoping is given,
|
|
18
|
+
# e.g. ["wcag2a", "wcag2aa"]. Empty means "run all default rules".
|
|
19
|
+
attr_accessor :default_tags
|
|
20
|
+
|
|
21
|
+
# Global list of rule ids (or symbols) to disable on every run, e.g.
|
|
22
|
+
# [:color_contrast, "region"]. Normalized underscores -> hyphens.
|
|
23
|
+
attr_accessor :skip_rules
|
|
24
|
+
|
|
25
|
+
# When true, axe is (re)injected on every #run if missing. Injection is
|
|
26
|
+
# always idempotent (guarded on `typeof window.axe`), so this mainly
|
|
27
|
+
# controls whether a stale axe (after navigation) is re-injected.
|
|
28
|
+
attr_accessor :auto_inject
|
|
29
|
+
|
|
30
|
+
# When true, the matcher logs violations instead of failing the example.
|
|
31
|
+
# Eases incremental adoption on an existing app.
|
|
32
|
+
attr_accessor :report_only
|
|
33
|
+
|
|
34
|
+
# Logger used for report_only output and warnings.
|
|
35
|
+
attr_accessor :logger
|
|
36
|
+
|
|
37
|
+
def initialize
|
|
38
|
+
@timeout = 30
|
|
39
|
+
@default_options = {}
|
|
40
|
+
@default_tags = []
|
|
41
|
+
@skip_rules = []
|
|
42
|
+
@auto_inject = true
|
|
43
|
+
@report_only = false
|
|
44
|
+
@logger = default_logger
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def default_logger
|
|
50
|
+
Logger.new($stdout).tap do |log|
|
|
51
|
+
log.progname = "axe-cuprite"
|
|
52
|
+
log.formatter = proc { |severity, _time, progname, msg| "[#{progname}] #{severity}: #{msg}\n" }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AxeCuprite
|
|
4
|
+
# Base class for all axe-cuprite errors.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when axe.run does not resolve within the configured timeout.
|
|
8
|
+
# Usually means the page is very large or axe is stuck — bump the timeout
|
|
9
|
+
# via AxeCuprite.configure { |c| c.timeout = ... } or the matcher/runner.
|
|
10
|
+
class TimeoutError < Error; end
|
|
11
|
+
|
|
12
|
+
# Raised when axe-core itself reports an error while running (e.g. an
|
|
13
|
+
# invalid context selector or run option).
|
|
14
|
+
class AxeRunError < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised when axe could not be injected into the page (e.g. a strict
|
|
17
|
+
# Content-Security-Policy blocked both inline injection and add_script_tag).
|
|
18
|
+
class InjectionError < Error; end
|
|
19
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AxeCuprite
|
|
4
|
+
# Handles getting axe-core onto the page and running it — entirely through
|
|
5
|
+
# Capybara's driver-neutral JS API, with a Ferrum fast-path for Cuprite.
|
|
6
|
+
#
|
|
7
|
+
# This is the make-or-break class. Two things are easy to get wrong:
|
|
8
|
+
#
|
|
9
|
+
# 1. The async-callback convention. Ferrum's `evaluate_async` (and Capybara's
|
|
10
|
+
# `evaluate_async_script`, which Cuprite delegates to it) wraps your
|
|
11
|
+
# expression in a Promise and appends the resolve callback as the LAST
|
|
12
|
+
# entry of `arguments`. So we resolve via `arguments[arguments.length - 1]`.
|
|
13
|
+
#
|
|
14
|
+
# 2. The timeout. Ferrum wraps the promise in a `setTimeout(reject, wait*1000)`.
|
|
15
|
+
# Through `evaluate_async_script` that `wait` is Capybara.default_max_wait_time
|
|
16
|
+
# (often 2s) — far too short for `axe.run` on a real page. So on Cuprite we
|
|
17
|
+
# call Ferrum's `page.evaluate_async(expr, explicit_wait, *args)` DIRECTLY
|
|
18
|
+
# with our own timeout, decoupled from default_max_wait_time entirely.
|
|
19
|
+
class Injector
|
|
20
|
+
# JS run inside Ferrum's promise wrapper. `arguments[0]` is the axe context,
|
|
21
|
+
# `arguments[1]` the run options, and the LAST argument is the resolve
|
|
22
|
+
# callback Ferrum appended. We slim the result down to a JSON-safe payload —
|
|
23
|
+
# never returning the full results object (passes/inapplicable can be huge).
|
|
24
|
+
RUN_JS = <<~JS
|
|
25
|
+
var ctx = arguments[0];
|
|
26
|
+
var opts = arguments[1] || {};
|
|
27
|
+
var done = arguments[arguments.length - 1];
|
|
28
|
+
if (typeof window.axe === 'undefined' || typeof window.axe.run !== 'function') {
|
|
29
|
+
done({ error: 'axe-core is not present on the page' });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
var promise = ctx ? window.axe.run(ctx, opts) : window.axe.run(opts);
|
|
33
|
+
promise.then(function (results) {
|
|
34
|
+
done({
|
|
35
|
+
violations: results.violations,
|
|
36
|
+
incomplete: results.incomplete,
|
|
37
|
+
url: results.url,
|
|
38
|
+
timestamp: results.timestamp,
|
|
39
|
+
testEngine: results.testEngine
|
|
40
|
+
});
|
|
41
|
+
}).catch(function (err) {
|
|
42
|
+
done({ error: (err && err.message) ? err.message : String(err) });
|
|
43
|
+
});
|
|
44
|
+
JS
|
|
45
|
+
|
|
46
|
+
# JS expression that reports whether axe is loaded and runnable.
|
|
47
|
+
PRESENCE_JS = "typeof window.axe !== 'undefined' && typeof window.axe.run === 'function'"
|
|
48
|
+
|
|
49
|
+
def initialize(page, configuration = AxeCuprite.configuration)
|
|
50
|
+
@page = page
|
|
51
|
+
@config = configuration
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Is axe-core present and runnable on the current page?
|
|
55
|
+
def injected?
|
|
56
|
+
@page.evaluate_script(PRESENCE_JS) == true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Ensure axe is present. Idempotent: does nothing if already injected
|
|
60
|
+
# (so repeated assertions on one page don't re-send ~500KB), unless force:.
|
|
61
|
+
# Returns true if it actually injected, false if it was already there.
|
|
62
|
+
def ensure_injected!(force: false)
|
|
63
|
+
return false if !force && injected?
|
|
64
|
+
|
|
65
|
+
inject_source!
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Inject the vendored axe-core source into the page. Primary path is
|
|
70
|
+
# Capybara's driver-neutral execute_script (which, on Cuprite, runs via
|
|
71
|
+
# CDP Runtime.evaluate and is not subject to the page's CSP). If that path
|
|
72
|
+
# fails to land axe — e.g. a strict Content-Security-Policy — we fall back
|
|
73
|
+
# to Ferrum's add_script_tag.
|
|
74
|
+
def inject_source!
|
|
75
|
+
source = AxeCuprite.axe_source
|
|
76
|
+
|
|
77
|
+
begin
|
|
78
|
+
@page.execute_script(source)
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
raise InjectionError, "Failed to inject axe-core: #{e.message}" unless try_add_script_tag(source)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
return true if injected?
|
|
84
|
+
|
|
85
|
+
# execute_script silently no-op'd (CSP, sandbox, ...). Try the tag fallback.
|
|
86
|
+
if try_add_script_tag(source) && injected?
|
|
87
|
+
true
|
|
88
|
+
else
|
|
89
|
+
raise InjectionError,
|
|
90
|
+
"axe-core did not load after injection. A strict Content-Security-Policy " \
|
|
91
|
+
"may be blocking script injection on the page under test."
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Run axe and return a Results object. Injects on demand if needed.
|
|
96
|
+
def run(context:, options:, timeout: nil)
|
|
97
|
+
timeout ||= @config.timeout
|
|
98
|
+
ensure_present!
|
|
99
|
+
|
|
100
|
+
raw = evaluate_axe(context, options, timeout)
|
|
101
|
+
if raw.is_a?(Hash) && raw["error"]
|
|
102
|
+
raise AxeRunError, "axe.run failed: #{raw["error"]}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
Results.new(raw)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# Guarantee axe is on the page before running. Honors the auto_inject toggle:
|
|
111
|
+
# when disabled, the caller is responsible for injecting first.
|
|
112
|
+
def ensure_present!
|
|
113
|
+
if @config.auto_inject
|
|
114
|
+
ensure_injected!(force: false)
|
|
115
|
+
elsif !injected?
|
|
116
|
+
raise InjectionError,
|
|
117
|
+
"axe-core is not present and auto_inject is disabled. Call Runner#inject! " \
|
|
118
|
+
"(or AxeCuprite.configure { |c| c.auto_inject = true })."
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Run axe asynchronously with an explicit timeout decoupled from
|
|
123
|
+
# Capybara.default_max_wait_time. Uses Ferrum's evaluate_async directly when
|
|
124
|
+
# available (Cuprite), else falls back to Capybara's evaluate_async_script
|
|
125
|
+
# under a temporarily-bumped wait time for other drivers.
|
|
126
|
+
def evaluate_axe(context, options, timeout)
|
|
127
|
+
fpage = ferrum_page
|
|
128
|
+
if fpage
|
|
129
|
+
fpage.evaluate_async(RUN_JS, timeout, context, options)
|
|
130
|
+
else
|
|
131
|
+
Capybara.using_wait_time(timeout) do
|
|
132
|
+
@page.evaluate_async_script(RUN_JS, context, options)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
if timeout_error?(e)
|
|
137
|
+
raise TimeoutError,
|
|
138
|
+
"axe.run did not finish within #{timeout}s. Increase the timeout " \
|
|
139
|
+
"(AxeCuprite.configure { |c| c.timeout = N } or the matcher/runner timeout:), " \
|
|
140
|
+
"or scope the run with .within(selector). Underlying: #{e.message}"
|
|
141
|
+
end
|
|
142
|
+
raise
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The underlying Ferrum::Page, if this Capybara session is driven by Cuprite
|
|
146
|
+
# (or any Ferrum-based driver). nil for non-Ferrum drivers.
|
|
147
|
+
def ferrum_page
|
|
148
|
+
driver = @page.driver
|
|
149
|
+
return nil unless driver.respond_to?(:browser)
|
|
150
|
+
|
|
151
|
+
browser = driver.browser
|
|
152
|
+
return nil unless browser.respond_to?(:page)
|
|
153
|
+
|
|
154
|
+
fpage = browser.page
|
|
155
|
+
return nil unless fpage.respond_to?(:evaluate_async)
|
|
156
|
+
|
|
157
|
+
fpage
|
|
158
|
+
rescue StandardError
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Best-effort CSP fallback via Ferrum's add_script_tag(content:).
|
|
163
|
+
def try_add_script_tag(source)
|
|
164
|
+
fpage = ferrum_page
|
|
165
|
+
return false unless fpage.respond_to?(:add_script_tag)
|
|
166
|
+
|
|
167
|
+
fpage.add_script_tag(content: source)
|
|
168
|
+
true
|
|
169
|
+
rescue StandardError
|
|
170
|
+
false
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def timeout_error?(error)
|
|
174
|
+
return true if error.message.to_s =~ /tim(e|ed)\s*out/i
|
|
175
|
+
|
|
176
|
+
# Ferrum raises its own timeout/JS errors; match by class name without a
|
|
177
|
+
# hard dependency on the Ferrum constants (ferrum is only a dev dep here).
|
|
178
|
+
error.class.name.to_s =~ /Ferrum::(Timeout|JavaScript)Error/ &&
|
|
179
|
+
error.message.to_s =~ /timed out promise/i
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AxeCuprite
|
|
4
|
+
# Normalizes rule ids and tags so callers can use friendly Ruby symbols
|
|
5
|
+
# (:color_contrast) interchangeably with axe's own ids ("color-contrast").
|
|
6
|
+
module Normalize
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# :color_contrast / "color_contrast" / "color-contrast" -> "color-contrast"
|
|
10
|
+
def rule(id)
|
|
11
|
+
id.to_s.strip.tr("_", "-")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# :wcag2aa -> "wcag2aa"; :best_practice -> "best-practice"
|
|
15
|
+
def tag(value)
|
|
16
|
+
value.to_s.strip.tr("_", "-")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def rules(list)
|
|
20
|
+
Array(list).map { |r| rule(r) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def tags(list)
|
|
24
|
+
Array(list).map { |t| tag(t) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|