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 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