axe-cuprite 0.1.1 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1005023d8e3178edd24de983e954208d297ee0ca720f33ba822f14c33861b17f
4
- data.tar.gz: 5b0b13f67c92e8b89d1d4b4f2f9b4957ae71690d60eeb006edd2e9239bd89dd7
3
+ metadata.gz: e56b57fadf23c8ab623bd1a3005d94ce158832160a9ec3bfd1591b070312d953
4
+ data.tar.gz: 2cdad2a18469b669266938df7476040d702b53419bb8a80076e918c282bf218e
5
5
  SHA512:
6
- metadata.gz: 439317e53b00d1f6a4f4c275f02b5caa9c6d0add981079b809a3363c937fab3431226db540f6163ad908a47b0869782db0b0bfd98f5b229bb121b45b9733acb6
7
- data.tar.gz: 9ade47a886507d6349a680abd9446fc5191048172b1a40f660a694dc93afa0841528d73a5eb4e281d30960fa4fad4b72e87d359a969c3e15274f9213c76c9587
6
+ metadata.gz: 2262ff93c4a88cda10c54d650b74f9353d8d1ae98f0f0a4df82b1483cc755e6347e23be923dedee4fffa726de3229ae4f4e9a00d18f704750f21513038d2515e
7
+ data.tar.gz: 8f001777544278d53266271b66c9d93bb32e0ed2d9e8969fa2413d69342649028be46c78c40706ec526e863c2fa13106a451856b7dfa7db8d23e0852b3367a9f
data/CHANGELOG.md CHANGED
@@ -7,6 +7,107 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.0] - 2026-06-16
11
+
12
+ First stable release. No functional changes since `0.2.0` — this tags the API as
13
+ stable under [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The
14
+ public API is the documented surface of `AxeCuprite`, `AxeCuprite::Configuration`,
15
+ `AxeCuprite::Runner`, the result wrappers (`Results`/`Violation`/`Node`/
16
+ `ContrastData`), the error classes, and the RSpec matcher DSL; classes such as
17
+ `AxeCuprite::Injector` and `AxeCuprite::DeepFreeze` are internal and not covered
18
+ by the stability guarantee.
19
+
20
+ ### Fixed
21
+ - The packaged gem no longer ships the dev-only vendoring rake tasks
22
+ (`lib/axe/cuprite/tasks/*.rake` — `axe:update` / `axe:verify` / `axe:version`).
23
+ These operate on the gem's read-only, version-pinned `vendor/` directory and
24
+ `axe:verify` couldn't even run from an installed gem (its recorded checksum
25
+ isn't shipped), so they were broken for consumers. They remain available in a
26
+ repo checkout for development
27
+ ([#32](https://github.com/Guided-Rails/axe-cuprite/issues/32)).
28
+
29
+ ### Changed
30
+ - Repositioned as **Cuprite-only**: the README, gemspec `description`, and
31
+ `AGENTS.md` no longer advertise support for "any real-browser Capybara driver."
32
+ Cuprite is the only supported and tested driver; the driver-neutral JS approach
33
+ is described as the implementation detail that makes the gem work on Cuprite
34
+ (and avoid Selenium internals), not a promise of broad driver support. The
35
+ best-effort non-Ferrum fallback in `Injector#evaluate_axe` is kept but is now
36
+ documented as unsupported/untested
37
+ ([#31](https://github.com/Guided-Rails/axe-cuprite/issues/31)).
38
+ - Documented that `AxeCuprite::Results` (and its nested wrappers) takes ownership
39
+ of the hash passed in and deep-freezes it **in place** — so a caller
40
+ constructing `Results.new(hash)` shouldn't pass or reuse a hash they intend to
41
+ mutate afterward. No behavior change; clarifies the existing read-only contract
42
+ in the `Results` doc comment and the README runner section
43
+ ([#33](https://github.com/Guided-Rails/axe-cuprite/issues/33)).
44
+
45
+ ### Added
46
+ - A unit test pinning the best-effort non-Ferrum fallback in
47
+ `Injector#evaluate_axe`: with no Ferrum page it routes through
48
+ `evaluate_async_script` under the explicit timeout rather than
49
+ `Capybara.default_max_wait_time`, so the path can't silently break
50
+ ([#31](https://github.com/Guided-Rails/axe-cuprite/issues/31)).
51
+
52
+ ## [0.2.0] - 2026-06-10
53
+
54
+ ### Added
55
+ - `config.include_html` (default `true`) — set to `false` to suppress the
56
+ truncated outer-HTML snippets that failure messages and `report_only` logs
57
+ print for each offending element. Useful for suites that render sensitive data
58
+ (staging-backed tests, seeded PII), keeping page content out of CI logs while
59
+ still reporting rule id, selector, and check message. Documented the snippet
60
+ behavior and `logger` guidance in the README
61
+ ([#14](https://github.com/Guided-Rails/axe-cuprite/issues/14)).
62
+
63
+ ### Fixed
64
+ - The result wrappers (`Results`/`Violation`/`Node`/`ContrastData`) now honor
65
+ their documented read-only contract: `@raw` is deep-frozen at construction, so
66
+ `raw` and `to_h` can safely expose the live underlying hash without a caller
67
+ being able to mutate the wrapper's internal state (which, via memoization,
68
+ could previously desync `violations`/`incomplete` from `raw`)
69
+ ([#17](https://github.com/Guided-Rails/axe-cuprite/issues/17)).
70
+ - `Injector#inject_source!` no longer hides the real cause of an injection
71
+ failure behind a blanket Content-Security-Policy message. When both the
72
+ primary `execute_script` path and the `add_script_tag` fallback fail, the
73
+ exceptions they raised are now captured and appended to the `InjectionError`
74
+ message (`Underlying errors: execute_script: ...; add_script_tag: ...`), so a
75
+ dead browser, dead CDP session, or misconfigured driver is no longer
76
+ misattributed to CSP ([#15](https://github.com/Guided-Rails/axe-cuprite/issues/15)).
77
+ - `Injector#timeout_error?` no longer reclassifies arbitrary failures as
78
+ `AxeCuprite::TimeoutError` just because the error message mentions a timeout.
79
+ A genuine page-side JavaScript error (e.g. a `Ferrum::JavaScriptError` from axe
80
+ or the app whose text happens to contain "timeout") now propagates untouched
81
+ instead of being rewritten with misleading "increase the timeout / scope the
82
+ run" guidance. Classification is driven by error class (Ferrum's
83
+ timeout/script-timeout classes), with a narrow class-gated message check only
84
+ for Ferrum's async-evaluation "timed out promise" case; this also removes a
85
+ dead code branch that could never affect the result
86
+ ([#16](https://github.com/Guided-Rails/axe-cuprite/issues/16)).
87
+
88
+ ### Security
89
+ - `rake axe:update` now vendors axe-core from the official npm registry tarball
90
+ (`registry.npmjs.org`) instead of the unpkg CDN, verifies the tarball against
91
+ the registry-published sha512 `dist.integrity` (and legacy `dist.shasum`)
92
+ **before writing anything**, and treats a banner/version mismatch as fatal
93
+ instead of a warning ([#11](https://github.com/Guided-Rails/axe-cuprite/issues/11)).
94
+ - The sha512 of the vendored `axe.min.js` is now recorded in
95
+ `lib/axe/cuprite/vendor/axe.min.js.sha512`; a new `rake axe:verify` task
96
+ re-checks the vendored engine against it, and CI runs it on every build.
97
+ - Hardened the CI workflow: added a least-privilege `permissions: contents: read`
98
+ block (the `GITHUB_TOKEN` previously inherited the repo default, potentially
99
+ write-all) and pinned `actions/checkout` and `ruby/setup-ruby` to full commit
100
+ SHAs instead of mutable major-version tags. Added a Dependabot config
101
+ (`.github/dependabot.yml`) for the `github-actions` and `bundler` ecosystems so
102
+ the pinned SHAs and gem dependencies get automated update PRs
103
+ ([#12](https://github.com/Guided-Rails/axe-cuprite/issues/12)).
104
+ - `rake axe:update[VERSION]` now validates the `VERSION` argument against a
105
+ semver-ish pattern **before** any network call or file write. The value was
106
+ previously spliced unvalidated into the registry/download URL and into
107
+ `version.rb`, so a crafted string (e.g. `4.12.0/../other-pkg`, or one
108
+ containing a quote) could vendor a different package or break out of the
109
+ version string literal ([#13](https://github.com/Guided-Rails/axe-cuprite/issues/13)).
110
+
10
111
  ## [0.1.1] - 2026-06-09
11
112
 
12
113
  ### Added
@@ -38,3 +139,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
38
139
  auto-inject toggle, and report-only mode.
39
140
  - `rake axe:update[VERSION]` to refresh the vendored axe-core engine and bump
40
141
  the `AXE_CORE_VERSION` constant.
142
+
143
+ [Unreleased]: https://github.com/Guided-Rails/axe-cuprite/compare/v1.0.0...HEAD
144
+ [1.0.0]: https://github.com/Guided-Rails/axe-cuprite/compare/v0.2.0...v1.0.0
145
+ [0.2.0]: https://github.com/Guided-Rails/axe-cuprite/compare/v0.1.1...v0.2.0
146
+ [0.1.1]: https://github.com/Guided-Rails/axe-cuprite/compare/v0.1.0...v0.1.1
147
+ [0.1.0]: https://github.com/Guided-Rails/axe-cuprite/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # axe-cuprite
2
2
 
3
+ [![Gem Version](https://img.shields.io/gem/v/axe-cuprite)](https://rubygems.org/gems/axe-cuprite)
4
+ [![CI](https://github.com/Guided-Rails/axe-cuprite/actions/workflows/ci.yml/badge.svg)](https://github.com/Guided-Rails/axe-cuprite/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
6
+
3
7
  Run the [axe-core](https://github.com/dequelabs/axe-core) accessibility engine
4
8
  against pages in your **Capybara system/feature tests driven by
5
9
  [Cuprite](https://github.com/rubycdp/cuprite)** (the CDP/Ferrum headless-Chrome
@@ -23,13 +27,24 @@ they break.
23
27
  just a JavaScript file — a driver-agnostic engine — so we drive it exclusively
24
28
  through Capybara's **driver-neutral** JavaScript API (`execute_script`,
25
29
  `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.**
30
+ what makes this gem work where the official one doesn't.
31
+
32
+ **Cuprite is the only supported and tested driver.** Driving axe through the
33
+ driver-neutral JS API is what lets the gem avoid Selenium internals — not a promise
34
+ of broad driver support. Other real-browser drivers may happen to work (a
35
+ best-effort fallback exists), but they are unsupported and untested.
29
36
 
30
37
  There is no runtime dependency on Selenium, Cuprite, or Ferrum — the only runtime
31
38
  dependency is Capybara. You bring your own driver.
32
39
 
40
+ ## Requirements
41
+
42
+ - **Ruby** >= 3.0
43
+ - **Capybara** >= 3.0, < 4.0 (the only runtime dependency)
44
+ - **[Cuprite](https://github.com/rubycdp/cuprite)** as the Capybara driver — the
45
+ only supported and tested driver
46
+ - **Chrome or Chromium**, which Cuprite drives over CDP
47
+
33
48
  ## Installation
34
49
 
35
50
  ```ruby
@@ -146,6 +161,13 @@ on top of your configured defaults. Only `violations` and `incomplete` are
146
161
  carried back across the CDP boundary — the full results object (with
147
162
  `passes`/`inapplicable`) can be huge.
148
163
 
164
+ `AxeCuprite::Results` is read-only: it **takes ownership of the hash it's given
165
+ and deep-freezes it in place** (this backs the read-only `raw`/`to_h` contract).
166
+ You normally get one straight from `Runner#run`, so this is transparent — but if
167
+ you construct `Results.new(hash)` yourself, don't pass or keep a reference to a
168
+ hash you intend to mutate afterward, or you'll hit a `FrozenError`. Dup it first
169
+ if you need a mutable copy.
170
+
149
171
  ## Configuration
150
172
 
151
173
  ```ruby
@@ -156,6 +178,7 @@ AxeCuprite.configure do |c|
156
178
  c.skip_rules = [:region] # globally disabled rules
157
179
  c.auto_inject = true # (re)inject axe on demand inside #run
158
180
  c.report_only = false # log violations instead of failing (see below)
181
+ c.include_html = true # include element HTML snippets in output (see below)
159
182
  c.logger = Logger.new($stdout)
160
183
  end
161
184
  ```
@@ -167,6 +190,22 @@ This eases incremental adoption on an existing app — you can see what axe find
167
190
  without turning the suite red. Negated assertions (`expect(page).not_to
168
191
  be_axe_clean`) ignore this flag.
169
192
 
193
+ ### Sensitive page content in output (`include_html`)
194
+
195
+ Both failure messages and `report_only` logs include a **truncated outer-HTML
196
+ snippet** (capped at 200 chars) of each offending element, so you can see *what*
197
+ failed at a glance. On suites that render real data — staging-backed tests,
198
+ seeded PII — those snippets can carry page content into CI logs or log
199
+ aggregation. This is normal for a testing tool, but if it matters to you:
200
+
201
+ - Point `c.logger` somewhere appropriate (a redacted sink, a dropped stream)
202
+ rather than `$stdout`, so `report_only` output doesn't fan out to aggregation.
203
+ - Set `c.include_html = false` to drop the HTML snippets entirely. Output then
204
+ carries only the rule id, impact, help URL, element **selector**, and the axe
205
+ check message (for color-contrast, the fg/bg colors and ratio) — enough to
206
+ locate and fix a violation without echoing page content. Note the selector
207
+ itself can still reflect ids/classes from your markup.
208
+
170
209
  ## Caveats & engineering notes
171
210
 
172
211
  ### Timeout (decoupled from `default_max_wait_time`)
@@ -180,10 +219,11 @@ page routinely exceeds.
180
219
  axe-cuprite avoids this trap: on Cuprite it calls Ferrum's
181
220
  `page.evaluate_async(script, explicit_wait, *args)` **directly**, with its own
182
221
  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`.
222
+ `default_max_wait_time`**. (On a non-Ferrum driver there is a best-effort,
223
+ untested fallback to `evaluate_async_script` under a temporarily-raised wait time
224
+ but Cuprite is the only supported driver.) If axe still doesn't finish, you get a
225
+ clear `AxeCuprite::TimeoutError` telling you to raise the timeout or scope the run
226
+ with `.within`.
187
227
 
188
228
  Tune it globally (`c.timeout = 60`) or per assertion (`.with_timeout(60)`).
189
229
 
@@ -223,10 +263,33 @@ To refresh it:
223
263
  rake 'axe:update[4.12.0]' # pin a version
224
264
  rake axe:update # or grab the latest from npm
225
265
  rake axe:version # print the currently vendored version
266
+ rake axe:verify # check axe.min.js against its recorded sha512
226
267
  ```
227
268
 
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`.
269
+ `axe:update` downloads the official `axe-core` tarball from
270
+ **registry.npmjs.org**, verifies it against the sha512 integrity the registry
271
+ publishes (aborting on any mismatch before writing a byte), extracts
272
+ `axe.min.js` and its `LICENSE`, and bumps the `AXE_CORE_VERSION` constant. The
273
+ sha512 of the vendored engine is recorded in
274
+ `lib/axe/cuprite/vendor/axe.min.js.sha512` so it can be re-checked at any time
275
+ with `rake axe:verify` (CI does this on every run). Note the bump in
276
+ `CHANGELOG.md`.
277
+
278
+ ## Versioning
279
+
280
+ axe-cuprite follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
281
+ The **public API** covered by that guarantee is the documented surface:
282
+ `AxeCuprite` (module methods), `AxeCuprite::Configuration`, `AxeCuprite::Runner`,
283
+ the result wrappers (`Results` / `Violation` / `Node` / `ContrastData`), the error
284
+ classes, and the RSpec matcher DSL (`be_axe_clean` / `be_accessible` and its
285
+ chainers). Classes like `AxeCuprite::Injector` and `AxeCuprite::DeepFreeze` are
286
+ internal implementation details and may change in any release. Breaking changes to
287
+ the public API only land in a major version, and every release is noted in
288
+ [`CHANGELOG.md`](CHANGELOG.md).
289
+
290
+ Note that the **vendored axe-core version** can change in a minor release; axe may
291
+ report new violations after an engine bump, which can turn a previously-green
292
+ suite red. Such bumps are always called out in the changelog.
230
293
 
231
294
  ## Licensing
232
295
 
@@ -34,6 +34,13 @@ module AxeCuprite
34
34
  # Logger used for report_only output and warnings.
35
35
  attr_accessor :logger
36
36
 
37
+ # When true (default), failure messages and report_only logs include a
38
+ # truncated outer-HTML snippet of each offending element. Set to false to
39
+ # suppress those snippets (rule id + selector + check message only) on
40
+ # suites that render sensitive data, so page content can't leak into CI
41
+ # logs. See BeAxeClean#format_node.
42
+ attr_accessor :include_html
43
+
37
44
  def initialize
38
45
  @timeout = 30
39
46
  @default_options = {}
@@ -41,6 +48,7 @@ module AxeCuprite
41
48
  @skip_rules = []
42
49
  @auto_inject = true
43
50
  @report_only = false
51
+ @include_html = true
44
52
  @logger = default_logger
45
53
  end
46
54
 
@@ -46,6 +46,17 @@ module AxeCuprite
46
46
  # JS expression that reports whether axe is loaded and runnable.
47
47
  PRESENCE_JS = "typeof window.axe !== 'undefined' && typeof window.axe.run === 'function'"
48
48
 
49
+ # Dedicated timeout classes that mean "the evaluation timed out" regardless
50
+ # of message. Matched by name (not constant) so we keep no hard dependency on
51
+ # ferrum — it's a dev-only dep. Covers Ferrum's own timeouts (the Cuprite
52
+ # fast-path, via page.evaluate_async). A non-Ferrum driver's own script-timeout
53
+ # error propagates raw rather than being wrapped — we don't depend on or test
54
+ # any such driver here, so we don't guess at its class names. See #timeout_error?.
55
+ TIMEOUT_ERROR_CLASS_NAMES = [
56
+ "Ferrum::TimeoutError",
57
+ "Ferrum::ScriptTimeoutError"
58
+ ].freeze
59
+
49
60
  def initialize(page, configuration = AxeCuprite.configuration)
50
61
  @page = page
51
62
  @config = configuration
@@ -73,20 +84,23 @@ module AxeCuprite
73
84
  # to Ferrum's add_script_tag.
74
85
  def inject_source!
75
86
  source = AxeCuprite.axe_source
87
+ errors = {}
76
88
 
77
89
  begin
78
90
  @page.execute_script(source)
79
- rescue StandardError
80
- nil
91
+ rescue StandardError => e
92
+ errors["execute_script"] = e
81
93
  end
82
94
  return true if injected?
83
95
 
84
- try_add_script_tag(source)
96
+ begin
97
+ try_add_script_tag(source)
98
+ rescue StandardError => e
99
+ errors["add_script_tag"] = e
100
+ end
85
101
  return true if injected?
86
102
 
87
- raise InjectionError,
88
- "axe-core did not load after injection. The page may be blocking " \
89
- "script injection (e.g. a strict Content-Security-Policy)."
103
+ raise InjectionError, injection_failure_message(errors)
90
104
  end
91
105
 
92
106
  # Run axe and return a Results object. Injects on demand if needed.
@@ -154,24 +168,47 @@ module AxeCuprite
154
168
  nil
155
169
  end
156
170
 
157
- # Best-effort CSP fallback via Ferrum's add_script_tag(content:).
171
+ # Best-effort CSP fallback via Ferrum's add_script_tag(content:). Returns
172
+ # false when no Ferrum add_script_tag is available (non-Ferrum drivers); lets
173
+ # a genuine injection failure propagate so inject_source! can report it.
158
174
  def try_add_script_tag(source)
159
175
  fpage = ferrum_page
160
176
  return false unless fpage.respond_to?(:add_script_tag)
161
177
 
162
178
  fpage.add_script_tag(content: source)
163
179
  true
164
- rescue StandardError
165
- false
166
180
  end
167
181
 
182
+ # Build the InjectionError message, appending whatever the injection paths
183
+ # actually raised so a non-CSP failure (dead browser, dead CDP session,
184
+ # misconfigured driver) isn't silently blamed on Content-Security-Policy.
185
+ def injection_failure_message(errors)
186
+ message = "axe-core did not load after injection. The page may be blocking " \
187
+ "script injection (e.g. a strict Content-Security-Policy)."
188
+ return message if errors.empty?
189
+
190
+ detail = errors.map { |path, e| "#{path}: #{e.class}: #{e.message}" }.join("; ")
191
+ "#{message}\nUnderlying errors: #{detail}"
192
+ end
193
+
194
+ # Did `evaluate_axe` fail because axe.run genuinely timed out (so the
195
+ # "increase the timeout / scope the run" guidance is right), or did it hit a
196
+ # real page-side error that must propagate untouched?
197
+ #
198
+ # Classification is driven by error CLASS, not message. A message that merely
199
+ # mentions a timeout is deliberately NOT sufficient: a real Ferrum::JavaScriptError
200
+ # from axe or the app whose text happens to contain "timeout" must not be
201
+ # rewritten as an AxeCuprite::TimeoutError with misleading guidance.
202
+ #
203
+ # The one message check is narrow and class-gated: Ferrum reports its own
204
+ # async-evaluation timeout (page.evaluate_async) as a generic JavaScriptError
205
+ # carrying a "timed out promise" message, so for that class — and only that
206
+ # class — the message is what tells a timeout apart from a real JS error.
168
207
  def timeout_error?(error)
169
- return true if error.message.to_s =~ /tim(e|ed)\s*out/i
208
+ name = error.class.name.to_s
209
+ return true if TIMEOUT_ERROR_CLASS_NAMES.include?(name)
170
210
 
171
- # Ferrum raises its own timeout/JS errors; match by class name without a
172
- # hard dependency on the Ferrum constants (ferrum is only a dev dep here).
173
- error.class.name.to_s =~ /Ferrum::(Timeout|JavaScript)Error/ &&
174
- error.message.to_s =~ /timed out promise/i
211
+ name == "Ferrum::JavaScriptError" && error.message.to_s.match?(/timed out promise/i)
175
212
  end
176
213
  end
177
214
  end
@@ -1,14 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AxeCuprite
4
+ # Recursively freezes a parsed-JSON tree (hashes/arrays of scalars) so the
5
+ # read-only wrappers below can hand out `raw`/`to_h` without a caller being
6
+ # able to mutate internal state (which, via memoization, could otherwise
7
+ # desync `violations`/`incomplete` from `raw`). Idempotent and cheap on the
8
+ # slimmed payload we carry back.
9
+ module DeepFreeze
10
+ module_function
11
+
12
+ def call(obj)
13
+ case obj
14
+ when Hash
15
+ obj.each { |k, v| [k, v].each { |o| call(o) } }
16
+ when Array
17
+ obj.each { |v| call(v) }
18
+ end
19
+ obj.freeze
20
+ end
21
+ end
22
+
4
23
  # Wraps the (slimmed) payload returned by axe.run. We deliberately only carry
5
24
  # `violations` and `incomplete` across the CDP boundary plus a little metadata
6
25
  # — the full results object (with `passes`/`inapplicable`) can be huge.
26
+ #
27
+ # The wrappers are read-only: `@raw` is deep-frozen at construction, so `raw`
28
+ # and `to_h` expose the live underlying hash safely (callers cannot mutate it).
29
+ #
30
+ # Ownership note: `Results` (and the nested `Violation`/`Node`/`ContrastData`
31
+ # wrappers) **take ownership of the hash passed in and deep-freeze it in place**
32
+ # — they do not copy first. For the internal flow this is safe (the payload
33
+ # comes fresh off the CDP boundary), but if you construct `Results.new(hash)`
34
+ # yourself, don't pass — or hold onto — a hash you intend to mutate afterward,
35
+ # or you'll hit a `FrozenError`. Dup it first if you need a mutable copy.
7
36
  class Results
8
37
  attr_reader :raw
9
38
 
10
39
  def initialize(raw)
11
- @raw = raw || {}
40
+ @raw = DeepFreeze.call(raw || {})
12
41
  end
13
42
 
14
43
  def violations
@@ -47,7 +76,7 @@ module AxeCuprite
47
76
  attr_reader :raw
48
77
 
49
78
  def initialize(raw)
50
- @raw = raw || {}
79
+ @raw = DeepFreeze.call(raw || {})
51
80
  end
52
81
 
53
82
  def id
@@ -88,7 +117,7 @@ module AxeCuprite
88
117
  attr_reader :raw
89
118
 
90
119
  def initialize(raw)
91
- @raw = raw || {}
120
+ @raw = DeepFreeze.call(raw || {})
92
121
  end
93
122
 
94
123
  # axe gives `target` as an array of CSS selectors (one per frame depth).
@@ -137,7 +166,7 @@ module AxeCuprite
137
166
  attr_reader :raw
138
167
 
139
168
  def initialize(raw)
140
- @raw = raw || {}
169
+ @raw = DeepFreeze.call(raw || {})
141
170
  end
142
171
 
143
172
  def fg_color
@@ -193,7 +193,7 @@ module AxeCuprite
193
193
 
194
194
  def format_node(node)
195
195
  lines = [" - #{node.selector}"]
196
- lines << " #{truncate(node.html)}" if node.html
196
+ lines << " #{truncate(node.html)}" if node.html && config.include_html
197
197
 
198
198
  if (cd = node.contrast_data)
199
199
  lines << " contrast #{cd.contrast_ratio}:1 (needs #{cd.expected_contrast_ratio}:1) — " \
@@ -73,11 +73,7 @@ module AxeCuprite
73
73
  # axe accepts a selector string, or {include:, exclude:}. Stringify hash
74
74
  # keys so Ferrum serializes them predictably across CDP.
75
75
  def normalize_context(context)
76
- case context
77
- when nil, String then context
78
- when Hash then deep_stringify(context)
79
- else context
80
- end
76
+ context.is_a?(Hash) ? deep_stringify(context) : context
81
77
  end
82
78
 
83
79
  def deep_stringify(value)
@@ -2,7 +2,7 @@
2
2
 
3
3
  module AxeCuprite
4
4
  # Version of the axe-cuprite gem itself.
5
- VERSION = "0.1.1"
5
+ VERSION = "1.0.0"
6
6
 
7
7
  # Version of the axe-core engine vendored under lib/axe/cuprite/vendor/axe.min.js.
8
8
  # Keep this in sync with the vendored file via `rake axe:update`.
data/lib/axe/cuprite.rb CHANGED
@@ -8,8 +8,10 @@ require "axe/cuprite/injector"
8
8
  require "axe/cuprite/runner"
9
9
 
10
10
  # axe-cuprite drives the axe-core accessibility engine through Capybara's
11
- # driver-neutral JavaScript API, so it works with Cuprite (Ferrum/CDP) and any
12
- # other real-browser Capybara driverwithout ever touching Selenium internals.
11
+ # driver-neutral JavaScript API. That approach is what lets it avoid Selenium
12
+ # internals and work on Cuprite (Ferrum/CDP) the only supported and tested
13
+ # driver. Other real-browser drivers may work via a best-effort, untested
14
+ # fallback, but they are unsupported.
13
15
  #
14
16
  # Quick start:
15
17
  #
data/lib/axe-cuprite.rb CHANGED
@@ -1,5 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Convenience entry point so `require "axe-cuprite"` works.
4
- # The canonical require path is "axe/cuprite".
5
3
  require "axe/cuprite"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: axe-cuprite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdullah Hashim
@@ -29,165 +29,13 @@ dependencies:
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '4.0'
32
- - !ruby/object:Gem::Dependency
33
- name: cuprite
34
- requirement: !ruby/object:Gem::Requirement
35
- requirements:
36
- - - "~>"
37
- - !ruby/object:Gem::Version
38
- version: '0.17'
39
- type: :development
40
- prerelease: false
41
- version_requirements: !ruby/object:Gem::Requirement
42
- requirements:
43
- - - "~>"
44
- - !ruby/object:Gem::Version
45
- version: '0.17'
46
- - !ruby/object:Gem::Dependency
47
- name: ferrum
48
- requirement: !ruby/object:Gem::Requirement
49
- requirements:
50
- - - "~>"
51
- - !ruby/object:Gem::Version
52
- version: '0.17'
53
- type: :development
54
- prerelease: false
55
- version_requirements: !ruby/object:Gem::Requirement
56
- requirements:
57
- - - "~>"
58
- - !ruby/object:Gem::Version
59
- version: '0.17'
60
- - !ruby/object:Gem::Dependency
61
- name: puma
62
- requirement: !ruby/object:Gem::Requirement
63
- requirements:
64
- - - ">="
65
- - !ruby/object:Gem::Version
66
- version: '5.0'
67
- - - "<"
68
- - !ruby/object:Gem::Version
69
- version: '9.0'
70
- type: :development
71
- prerelease: false
72
- version_requirements: !ruby/object:Gem::Requirement
73
- requirements:
74
- - - ">="
75
- - !ruby/object:Gem::Version
76
- version: '5.0'
77
- - - "<"
78
- - !ruby/object:Gem::Version
79
- version: '9.0'
80
- - !ruby/object:Gem::Dependency
81
- name: rack
82
- requirement: !ruby/object:Gem::Requirement
83
- requirements:
84
- - - ">="
85
- - !ruby/object:Gem::Version
86
- version: '2.0'
87
- - - "<"
88
- - !ruby/object:Gem::Version
89
- version: '4.0'
90
- type: :development
91
- prerelease: false
92
- version_requirements: !ruby/object:Gem::Requirement
93
- requirements:
94
- - - ">="
95
- - !ruby/object:Gem::Version
96
- version: '2.0'
97
- - - "<"
98
- - !ruby/object:Gem::Version
99
- version: '4.0'
100
- - !ruby/object:Gem::Dependency
101
- name: rackup
102
- requirement: !ruby/object:Gem::Requirement
103
- requirements:
104
- - - "~>"
105
- - !ruby/object:Gem::Version
106
- version: '2.0'
107
- type: :development
108
- prerelease: false
109
- version_requirements: !ruby/object:Gem::Requirement
110
- requirements:
111
- - - "~>"
112
- - !ruby/object:Gem::Version
113
- version: '2.0'
114
- - !ruby/object:Gem::Dependency
115
- name: rake
116
- requirement: !ruby/object:Gem::Requirement
117
- requirements:
118
- - - "~>"
119
- - !ruby/object:Gem::Version
120
- version: '13.0'
121
- type: :development
122
- prerelease: false
123
- version_requirements: !ruby/object:Gem::Requirement
124
- requirements:
125
- - - "~>"
126
- - !ruby/object:Gem::Version
127
- version: '13.0'
128
- - !ruby/object:Gem::Dependency
129
- name: rspec
130
- requirement: !ruby/object:Gem::Requirement
131
- requirements:
132
- - - "~>"
133
- - !ruby/object:Gem::Version
134
- version: '3.0'
135
- type: :development
136
- prerelease: false
137
- version_requirements: !ruby/object:Gem::Requirement
138
- requirements:
139
- - - "~>"
140
- - !ruby/object:Gem::Version
141
- version: '3.0'
142
- - !ruby/object:Gem::Dependency
143
- name: rubocop
144
- requirement: !ruby/object:Gem::Requirement
145
- requirements:
146
- - - "~>"
147
- - !ruby/object:Gem::Version
148
- version: '1.86'
149
- type: :development
150
- prerelease: false
151
- version_requirements: !ruby/object:Gem::Requirement
152
- requirements:
153
- - - "~>"
154
- - !ruby/object:Gem::Version
155
- version: '1.86'
156
- - !ruby/object:Gem::Dependency
157
- name: rubocop-rake
158
- requirement: !ruby/object:Gem::Requirement
159
- requirements:
160
- - - "~>"
161
- - !ruby/object:Gem::Version
162
- version: '0.7'
163
- type: :development
164
- prerelease: false
165
- version_requirements: !ruby/object:Gem::Requirement
166
- requirements:
167
- - - "~>"
168
- - !ruby/object:Gem::Version
169
- version: '0.7'
170
- - !ruby/object:Gem::Dependency
171
- name: rubocop-rspec
172
- requirement: !ruby/object:Gem::Requirement
173
- requirements:
174
- - - "~>"
175
- - !ruby/object:Gem::Version
176
- version: '3.0'
177
- type: :development
178
- prerelease: false
179
- version_requirements: !ruby/object:Gem::Requirement
180
- requirements:
181
- - - "~>"
182
- - !ruby/object:Gem::Version
183
- version: '3.0'
184
32
  description: |
185
33
  axe-cuprite runs the axe-core accessibility engine against pages rendered in
186
34
  Capybara system/feature tests and exposes the results as RSpec matchers
187
35
  (be_axe_clean / be_accessible). Unlike Deque's official axe-core-capybara gem,
188
36
  it never touches Selenium-specific driver internals: axe is driven entirely
189
- through Capybara's driver-neutral JavaScript API, so it works with Cuprite
190
- and any other real-browser driver.
37
+ through Capybara's driver-neutral JavaScript API, which is what makes it work
38
+ on Cuprite. Cuprite is the only supported and tested driver.
191
39
  email:
192
40
  - abdullah@guidedrails.com
193
41
  executables: []
@@ -207,7 +55,6 @@ files:
207
55
  - lib/axe/cuprite/rspec.rb
208
56
  - lib/axe/cuprite/rspec/matchers.rb
209
57
  - lib/axe/cuprite/runner.rb
210
- - lib/axe/cuprite/tasks/axe.rake
211
58
  - lib/axe/cuprite/vendor/axe-core-LICENSE.txt
212
59
  - lib/axe/cuprite/vendor/axe.min.js
213
60
  - lib/axe/cuprite/version.rb
@@ -217,6 +64,8 @@ licenses:
217
64
  metadata:
218
65
  source_code_uri: https://github.com/Guided-Rails/axe-cuprite
219
66
  changelog_uri: https://github.com/Guided-Rails/axe-cuprite/blob/main/CHANGELOG.md
67
+ bug_tracker_uri: https://github.com/Guided-Rails/axe-cuprite/issues
68
+ documentation_uri: https://github.com/Guided-Rails/axe-cuprite/blob/main/README.md
220
69
  rubygems_mfa_required: 'true'
221
70
  rdoc_options: []
222
71
  require_paths:
@@ -1,73 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "open-uri"
4
- require "fileutils"
5
- require "json"
6
-
7
- namespace :axe do
8
- desc "Refresh the vendored axe-core engine. Usage: rake 'axe:update[4.12.0]' or VERSION=4.12.0 rake axe:update (default: latest)"
9
- task :update, [:version] do |_task, args|
10
- version = args[:version] || ENV["VERSION"] || AxeCupriteVendor.latest_version
11
- AxeCupriteVendor.update!(version)
12
- end
13
-
14
- desc "Print the currently vendored axe-core version"
15
- task :version do
16
- puts AxeCupriteVendor.vendored_version
17
- end
18
- end
19
-
20
- # Helpers for vendoring axe-core. Kept in a module so the rake tasks stay thin
21
- # and the logic is easy to read. Never used at runtime — vendoring is a
22
- # development-time step; the engine ships in the gem.
23
- module AxeCupriteVendor
24
- VENDOR_DIR = File.expand_path("../vendor", __dir__)
25
- AXE_JS = File.join(VENDOR_DIR, "axe.min.js")
26
- AXE_LICENSE = File.join(VENDOR_DIR, "axe-core-LICENSE.txt")
27
- VERSION_FILE = File.expand_path("../version.rb", __dir__)
28
- REGISTRY = "https://registry.npmjs.org/axe-core"
29
- CDN = "https://unpkg.com/axe-core"
30
-
31
- module_function
32
-
33
- def update!(version)
34
- FileUtils.mkdir_p(VENDOR_DIR)
35
-
36
- puts "Fetching axe-core@#{version} ..."
37
- js = download("#{CDN}@#{version}/axe.min.js")
38
- unless js =~ /axe v#{Regexp.escape(version)}\b/
39
- warn "Warning: downloaded axe.min.js banner does not mention v#{version} — continuing anyway."
40
- end
41
- license = download("#{CDN}@#{version}/LICENSE")
42
-
43
- File.write(AXE_JS, js)
44
- File.write(AXE_LICENSE, license)
45
- bump_version_constant(version)
46
-
47
- puts "Vendored axe-core #{version}:"
48
- puts " #{AXE_JS} (#{js.bytesize} bytes)"
49
- puts " #{AXE_LICENSE}"
50
- puts " updated AXE_CORE_VERSION in #{VERSION_FILE}"
51
- puts "Remember to note the version bump in CHANGELOG.md."
52
- end
53
-
54
- def latest_version
55
- JSON.parse(download("#{REGISTRY}/latest")).fetch("version")
56
- end
57
-
58
- def vendored_version
59
- File.read(AXE_JS)[/axe v([0-9][0-9.]*)/, 1] || "unknown"
60
- end
61
-
62
- def download(url)
63
- URI.parse(url).open(&:read)
64
- rescue OpenURI::HTTPError => e
65
- abort "Failed to download #{url}: #{e.message}"
66
- end
67
-
68
- def bump_version_constant(version)
69
- contents = File.read(VERSION_FILE)
70
- updated = contents.sub(/(AXE_CORE_VERSION\s*=\s*)"[^"]*"/, %(\\1"#{version}"))
71
- File.write(VERSION_FILE, updated)
72
- end
73
- end