axe-cuprite 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +74 -0
- data/README.md +26 -2
- data/lib/axe/cuprite/configuration.rb +8 -0
- data/lib/axe/cuprite/injector.rb +52 -20
- data/lib/axe/cuprite/results.rb +26 -4
- data/lib/axe/cuprite/rspec/matchers.rb +8 -8
- data/lib/axe/cuprite/rspec.rb +2 -2
- data/lib/axe/cuprite/runner.rb +1 -3
- data/lib/axe/cuprite/tasks/axe.rake +110 -14
- data/lib/axe/cuprite/version.rb +1 -1
- metadata +71 -30
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 91d61bb330869ef24f40d7b9c09859daf23212f99dd8b6c14506238d48cfe9f2
|
|
4
|
+
data.tar.gz: 0d023ead7e8245bf03d428ef4912c093a32888c7528d0e95307ca3036a77db2b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b41901a481d94378f0945a89cc4e7e4d6c3f198d45e06737d910841657aaaba6b4f8ccb32538cfb7af45607ea25049787e8a47b85e6e63a45845af7744a1033c
|
|
7
|
+
data.tar.gz: 906bb0a2bc25c10140185a9385192c1f629ef3803447cf31862f96e0c9f9d41589da8cb317b8220c6d3ffa37390eb8fe33f1f72729e243c8034104df12e4ae6a
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,80 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.2.0] - 2026-06-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `config.include_html` (default `true`) — set to `false` to suppress the
|
|
14
|
+
truncated outer-HTML snippets that failure messages and `report_only` logs
|
|
15
|
+
print for each offending element. Useful for suites that render sensitive data
|
|
16
|
+
(staging-backed tests, seeded PII), keeping page content out of CI logs while
|
|
17
|
+
still reporting rule id, selector, and check message. Documented the snippet
|
|
18
|
+
behavior and `logger` guidance in the README
|
|
19
|
+
([#14](https://github.com/Guided-Rails/axe-cuprite/issues/14)).
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- The result wrappers (`Results`/`Violation`/`Node`/`ContrastData`) now honor
|
|
23
|
+
their documented read-only contract: `@raw` is deep-frozen at construction, so
|
|
24
|
+
`raw` and `to_h` can safely expose the live underlying hash without a caller
|
|
25
|
+
being able to mutate the wrapper's internal state (which, via memoization,
|
|
26
|
+
could previously desync `violations`/`incomplete` from `raw`)
|
|
27
|
+
([#17](https://github.com/Guided-Rails/axe-cuprite/issues/17)).
|
|
28
|
+
- `Injector#inject_source!` no longer hides the real cause of an injection
|
|
29
|
+
failure behind a blanket Content-Security-Policy message. When both the
|
|
30
|
+
primary `execute_script` path and the `add_script_tag` fallback fail, the
|
|
31
|
+
exceptions they raised are now captured and appended to the `InjectionError`
|
|
32
|
+
message (`Underlying errors: execute_script: ...; add_script_tag: ...`), so a
|
|
33
|
+
dead browser, dead CDP session, or misconfigured driver is no longer
|
|
34
|
+
misattributed to CSP ([#15](https://github.com/Guided-Rails/axe-cuprite/issues/15)).
|
|
35
|
+
- `Injector#timeout_error?` no longer reclassifies arbitrary failures as
|
|
36
|
+
`AxeCuprite::TimeoutError` just because the error message mentions a timeout.
|
|
37
|
+
A genuine page-side JavaScript error (e.g. a `Ferrum::JavaScriptError` from axe
|
|
38
|
+
or the app whose text happens to contain "timeout") now propagates untouched
|
|
39
|
+
instead of being rewritten with misleading "increase the timeout / scope the
|
|
40
|
+
run" guidance. Classification is driven by error class (Ferrum's
|
|
41
|
+
timeout/script-timeout classes), with a narrow class-gated message check only
|
|
42
|
+
for Ferrum's async-evaluation "timed out promise" case; this also removes a
|
|
43
|
+
dead code branch that could never affect the result
|
|
44
|
+
([#16](https://github.com/Guided-Rails/axe-cuprite/issues/16)).
|
|
45
|
+
|
|
46
|
+
### Security
|
|
47
|
+
- `rake axe:update` now vendors axe-core from the official npm registry tarball
|
|
48
|
+
(`registry.npmjs.org`) instead of the unpkg CDN, verifies the tarball against
|
|
49
|
+
the registry-published sha512 `dist.integrity` (and legacy `dist.shasum`)
|
|
50
|
+
**before writing anything**, and treats a banner/version mismatch as fatal
|
|
51
|
+
instead of a warning ([#11](https://github.com/Guided-Rails/axe-cuprite/issues/11)).
|
|
52
|
+
- The sha512 of the vendored `axe.min.js` is now recorded in
|
|
53
|
+
`lib/axe/cuprite/vendor/axe.min.js.sha512`; a new `rake axe:verify` task
|
|
54
|
+
re-checks the vendored engine against it, and CI runs it on every build.
|
|
55
|
+
- Hardened the CI workflow: added a least-privilege `permissions: contents: read`
|
|
56
|
+
block (the `GITHUB_TOKEN` previously inherited the repo default, potentially
|
|
57
|
+
write-all) and pinned `actions/checkout` and `ruby/setup-ruby` to full commit
|
|
58
|
+
SHAs instead of mutable major-version tags. Added a Dependabot config
|
|
59
|
+
(`.github/dependabot.yml`) for the `github-actions` and `bundler` ecosystems so
|
|
60
|
+
the pinned SHAs and gem dependencies get automated update PRs
|
|
61
|
+
([#12](https://github.com/Guided-Rails/axe-cuprite/issues/12)).
|
|
62
|
+
- `rake axe:update[VERSION]` now validates the `VERSION` argument against a
|
|
63
|
+
semver-ish pattern **before** any network call or file write. The value was
|
|
64
|
+
previously spliced unvalidated into the registry/download URL and into
|
|
65
|
+
`version.rb`, so a crafted string (e.g. `4.12.0/../other-pkg`, or one
|
|
66
|
+
containing a quote) could vendor a different package or break out of the
|
|
67
|
+
version string literal ([#13](https://github.com/Guided-Rails/axe-cuprite/issues/13)).
|
|
68
|
+
|
|
69
|
+
## [0.1.1] - 2026-06-09
|
|
70
|
+
|
|
71
|
+
### Added
|
|
72
|
+
- GitHub Actions CI (`.github/workflows/ci.yml`): runs the RSpec suite under
|
|
73
|
+
Cuprite/headless Chrome across Ruby 3.0–3.4 and a RuboCop lint job, on every
|
|
74
|
+
push to `main` and all pull requests.
|
|
75
|
+
|
|
76
|
+
### Fixed
|
|
77
|
+
- `Injector#inject_source!` no longer appends a second `<script>` tag when a hard
|
|
78
|
+
injection failure occurs. The retry flow is now a single linear path
|
|
79
|
+
(`execute_script` → check → `add_script_tag` fallback → check → raise), so the
|
|
80
|
+
fallback runs at most once.
|
|
81
|
+
|
|
8
82
|
## [0.1.0] - 2026-06-06
|
|
9
83
|
|
|
10
84
|
### Added
|
data/README.md
CHANGED
|
@@ -156,6 +156,7 @@ AxeCuprite.configure do |c|
|
|
|
156
156
|
c.skip_rules = [:region] # globally disabled rules
|
|
157
157
|
c.auto_inject = true # (re)inject axe on demand inside #run
|
|
158
158
|
c.report_only = false # log violations instead of failing (see below)
|
|
159
|
+
c.include_html = true # include element HTML snippets in output (see below)
|
|
159
160
|
c.logger = Logger.new($stdout)
|
|
160
161
|
end
|
|
161
162
|
```
|
|
@@ -167,6 +168,22 @@ This eases incremental adoption on an existing app — you can see what axe find
|
|
|
167
168
|
without turning the suite red. Negated assertions (`expect(page).not_to
|
|
168
169
|
be_axe_clean`) ignore this flag.
|
|
169
170
|
|
|
171
|
+
### Sensitive page content in output (`include_html`)
|
|
172
|
+
|
|
173
|
+
Both failure messages and `report_only` logs include a **truncated outer-HTML
|
|
174
|
+
snippet** (capped at 200 chars) of each offending element, so you can see *what*
|
|
175
|
+
failed at a glance. On suites that render real data — staging-backed tests,
|
|
176
|
+
seeded PII — those snippets can carry page content into CI logs or log
|
|
177
|
+
aggregation. This is normal for a testing tool, but if it matters to you:
|
|
178
|
+
|
|
179
|
+
- Point `c.logger` somewhere appropriate (a redacted sink, a dropped stream)
|
|
180
|
+
rather than `$stdout`, so `report_only` output doesn't fan out to aggregation.
|
|
181
|
+
- Set `c.include_html = false` to drop the HTML snippets entirely. Output then
|
|
182
|
+
carries only the rule id, impact, help URL, element **selector**, and the axe
|
|
183
|
+
check message (for color-contrast, the fg/bg colors and ratio) — enough to
|
|
184
|
+
locate and fix a violation without echoing page content. Note the selector
|
|
185
|
+
itself can still reflect ids/classes from your markup.
|
|
186
|
+
|
|
170
187
|
## Caveats & engineering notes
|
|
171
188
|
|
|
172
189
|
### Timeout (decoupled from `default_max_wait_time`)
|
|
@@ -223,10 +240,17 @@ To refresh it:
|
|
|
223
240
|
rake 'axe:update[4.12.0]' # pin a version
|
|
224
241
|
rake axe:update # or grab the latest from npm
|
|
225
242
|
rake axe:version # print the currently vendored version
|
|
243
|
+
rake axe:verify # check axe.min.js against its recorded sha512
|
|
226
244
|
```
|
|
227
245
|
|
|
228
|
-
`axe:update` downloads `axe
|
|
229
|
-
|
|
246
|
+
`axe:update` downloads the official `axe-core` tarball from
|
|
247
|
+
**registry.npmjs.org**, verifies it against the sha512 integrity the registry
|
|
248
|
+
publishes (aborting on any mismatch before writing a byte), extracts
|
|
249
|
+
`axe.min.js` and its `LICENSE`, and bumps the `AXE_CORE_VERSION` constant. The
|
|
250
|
+
sha512 of the vendored engine is recorded in
|
|
251
|
+
`lib/axe/cuprite/vendor/axe.min.js.sha512` so it can be re-checked at any time
|
|
252
|
+
with `rake axe:verify` (CI does this on every run). Note the bump in
|
|
253
|
+
`CHANGELOG.md`.
|
|
230
254
|
|
|
231
255
|
## Licensing
|
|
232
256
|
|
|
@@ -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
|
|
data/lib/axe/cuprite/injector.rb
CHANGED
|
@@ -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,23 +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
91
|
rescue StandardError => e
|
|
80
|
-
|
|
92
|
+
errors["execute_script"] = e
|
|
81
93
|
end
|
|
82
|
-
|
|
83
94
|
return true if injected?
|
|
84
95
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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."
|
|
96
|
+
begin
|
|
97
|
+
try_add_script_tag(source)
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
errors["add_script_tag"] = e
|
|
92
100
|
end
|
|
101
|
+
return true if injected?
|
|
102
|
+
|
|
103
|
+
raise InjectionError, injection_failure_message(errors)
|
|
93
104
|
end
|
|
94
105
|
|
|
95
106
|
# Run axe and return a Results object. Injects on demand if needed.
|
|
@@ -98,9 +109,7 @@ module AxeCuprite
|
|
|
98
109
|
ensure_present!
|
|
99
110
|
|
|
100
111
|
raw = evaluate_axe(context, options, timeout)
|
|
101
|
-
if raw.is_a?(Hash) && raw["error"]
|
|
102
|
-
raise AxeRunError, "axe.run failed: #{raw["error"]}"
|
|
103
|
-
end
|
|
112
|
+
raise AxeRunError, "axe.run failed: #{raw["error"]}" if raw.is_a?(Hash) && raw["error"]
|
|
104
113
|
|
|
105
114
|
Results.new(raw)
|
|
106
115
|
end
|
|
@@ -159,24 +168,47 @@ module AxeCuprite
|
|
|
159
168
|
nil
|
|
160
169
|
end
|
|
161
170
|
|
|
162
|
-
# 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.
|
|
163
174
|
def try_add_script_tag(source)
|
|
164
175
|
fpage = ferrum_page
|
|
165
176
|
return false unless fpage.respond_to?(:add_script_tag)
|
|
166
177
|
|
|
167
178
|
fpage.add_script_tag(content: source)
|
|
168
179
|
true
|
|
169
|
-
rescue StandardError
|
|
170
|
-
false
|
|
171
180
|
end
|
|
172
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.
|
|
173
207
|
def timeout_error?(error)
|
|
174
|
-
|
|
208
|
+
name = error.class.name.to_s
|
|
209
|
+
return true if TIMEOUT_ERROR_CLASS_NAMES.include?(name)
|
|
175
210
|
|
|
176
|
-
|
|
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
|
|
211
|
+
name == "Ferrum::JavaScriptError" && error.message.to_s.match?(/timed out promise/i)
|
|
180
212
|
end
|
|
181
213
|
end
|
|
182
214
|
end
|
data/lib/axe/cuprite/results.rb
CHANGED
|
@@ -1,14 +1,36 @@
|
|
|
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).
|
|
7
29
|
class Results
|
|
8
30
|
attr_reader :raw
|
|
9
31
|
|
|
10
32
|
def initialize(raw)
|
|
11
|
-
@raw = raw || {}
|
|
33
|
+
@raw = DeepFreeze.call(raw || {})
|
|
12
34
|
end
|
|
13
35
|
|
|
14
36
|
def violations
|
|
@@ -47,7 +69,7 @@ module AxeCuprite
|
|
|
47
69
|
attr_reader :raw
|
|
48
70
|
|
|
49
71
|
def initialize(raw)
|
|
50
|
-
@raw = raw || {}
|
|
72
|
+
@raw = DeepFreeze.call(raw || {})
|
|
51
73
|
end
|
|
52
74
|
|
|
53
75
|
def id
|
|
@@ -88,7 +110,7 @@ module AxeCuprite
|
|
|
88
110
|
attr_reader :raw
|
|
89
111
|
|
|
90
112
|
def initialize(raw)
|
|
91
|
-
@raw = raw || {}
|
|
113
|
+
@raw = DeepFreeze.call(raw || {})
|
|
92
114
|
end
|
|
93
115
|
|
|
94
116
|
# axe gives `target` as an array of CSS selectors (one per frame depth).
|
|
@@ -137,7 +159,7 @@ module AxeCuprite
|
|
|
137
159
|
attr_reader :raw
|
|
138
160
|
|
|
139
161
|
def initialize(raw)
|
|
140
|
-
@raw = raw || {}
|
|
162
|
+
@raw = DeepFreeze.call(raw || {})
|
|
141
163
|
end
|
|
142
164
|
|
|
143
165
|
def fg_color
|
|
@@ -27,7 +27,7 @@ module AxeCuprite
|
|
|
27
27
|
@skip_rules = []
|
|
28
28
|
@tags = []
|
|
29
29
|
@run_options = {}
|
|
30
|
-
@timeout
|
|
30
|
+
@timeout = nil
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
# --- chainable DSL ----------------------------------------------------
|
|
@@ -153,8 +153,8 @@ module AxeCuprite
|
|
|
153
153
|
end
|
|
154
154
|
|
|
155
155
|
unless @skip_rules.empty?
|
|
156
|
-
opts[:rules] = Normalize.rules(@skip_rules).
|
|
157
|
-
|
|
156
|
+
opts[:rules] = Normalize.rules(@skip_rules).to_h do |id|
|
|
157
|
+
[id, { enabled: false }]
|
|
158
158
|
end
|
|
159
159
|
end
|
|
160
160
|
|
|
@@ -167,8 +167,8 @@ module AxeCuprite
|
|
|
167
167
|
rule_count = @violations.length
|
|
168
168
|
node_count = @violations.sum { |v| v.nodes.length }
|
|
169
169
|
"expected page to be axe-clean, but found #{rule_count} " \
|
|
170
|
-
"#{pluralize(rule_count,
|
|
171
|
-
"#{pluralize(node_count,
|
|
170
|
+
"#{pluralize(rule_count, "violation")} across #{node_count} " \
|
|
171
|
+
"#{pluralize(node_count, "element")}:"
|
|
172
172
|
end
|
|
173
173
|
|
|
174
174
|
def details
|
|
@@ -179,7 +179,7 @@ module AxeCuprite
|
|
|
179
179
|
lines = []
|
|
180
180
|
impact = violation.impact || "n/a"
|
|
181
181
|
lines << " ● [#{impact}] #{violation.id} — #{violation.help} " \
|
|
182
|
-
"(#{violation.nodes.length} #{pluralize(violation.nodes.length,
|
|
182
|
+
"(#{violation.nodes.length} #{pluralize(violation.nodes.length, "element")})"
|
|
183
183
|
lines << " #{violation.help_url}" if violation.help_url
|
|
184
184
|
|
|
185
185
|
violation.nodes.first(MAX_NODES).each do |node|
|
|
@@ -187,13 +187,13 @@ module AxeCuprite
|
|
|
187
187
|
end
|
|
188
188
|
|
|
189
189
|
remaining = violation.nodes.length - MAX_NODES
|
|
190
|
-
lines << " … and #{remaining} more #{pluralize(remaining,
|
|
190
|
+
lines << " … and #{remaining} more #{pluralize(remaining, "element")}" if remaining.positive?
|
|
191
191
|
lines.join("\n")
|
|
192
192
|
end
|
|
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) — " \
|
data/lib/axe/cuprite/rspec.rb
CHANGED
|
@@ -20,8 +20,8 @@ module AxeCuprite
|
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
if defined?(
|
|
24
|
-
|
|
23
|
+
if defined?(RSpec) && RSpec.respond_to?(:configure)
|
|
24
|
+
RSpec.configure do |config|
|
|
25
25
|
config.include AxeCuprite::RSpec::DSL
|
|
26
26
|
end
|
|
27
27
|
end
|
data/lib/axe/cuprite/runner.rb
CHANGED
|
@@ -56,9 +56,7 @@ module AxeCuprite
|
|
|
56
56
|
# default_tags become a tag-based runOnly, but only if nothing already
|
|
57
57
|
# scopes runOnly (an explicit rule/tag selection takes precedence).
|
|
58
58
|
tags = Normalize.tags(@configuration.default_tags)
|
|
59
|
-
if !opts.key?("runOnly") && !tags.empty?
|
|
60
|
-
opts["runOnly"] = { "type" => "tag", "values" => tags }
|
|
61
|
-
end
|
|
59
|
+
opts["runOnly"] = { "type" => "tag", "values" => tags } if !opts.key?("runOnly") && !tags.empty?
|
|
62
60
|
|
|
63
61
|
# global skip_rules disable rules, without clobbering an explicit caller
|
|
64
62
|
# setting for the same rule.
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "digest"
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "json"
|
|
6
|
+
require "open-uri"
|
|
7
|
+
require "rubygems/package"
|
|
8
|
+
require "stringio"
|
|
9
|
+
require "zlib"
|
|
6
10
|
|
|
7
11
|
namespace :axe do
|
|
8
12
|
desc "Refresh the vendored axe-core engine. Usage: rake 'axe:update[4.12.0]' or VERSION=4.12.0 rake axe:update (default: latest)"
|
|
@@ -15,40 +19,60 @@ namespace :axe do
|
|
|
15
19
|
task :version do
|
|
16
20
|
puts AxeCupriteVendor.vendored_version
|
|
17
21
|
end
|
|
22
|
+
|
|
23
|
+
desc "Verify the vendored axe.min.js against its recorded sha512 checksum"
|
|
24
|
+
task :verify do
|
|
25
|
+
AxeCupriteVendor.verify_vendored!
|
|
26
|
+
end
|
|
18
27
|
end
|
|
19
28
|
|
|
20
29
|
# Helpers for vendoring axe-core. Kept in a module so the rake tasks stay thin
|
|
21
30
|
# and the logic is easy to read. Never used at runtime — vendoring is a
|
|
22
31
|
# development-time step; the engine ships in the gem.
|
|
32
|
+
#
|
|
33
|
+
# Supply-chain note: the vendored axe.min.js is the JavaScript this gem injects
|
|
34
|
+
# into every consumer's browser session, so its integrity is the gem's most
|
|
35
|
+
# important supply-chain property. We therefore download the *official npm
|
|
36
|
+
# tarball* from registry.npmjs.org (not a CDN re-serving it), verify it against
|
|
37
|
+
# the sha512 integrity the registry publishes in its metadata, and abort on any
|
|
38
|
+
# mismatch before a single byte is written. The verified content hash of the
|
|
39
|
+
# extracted axe.min.js is recorded next to it (axe.min.js.sha512) so reviewers
|
|
40
|
+
# and CI can re-check the vendored artifact without re-downloading.
|
|
23
41
|
module AxeCupriteVendor
|
|
24
42
|
VENDOR_DIR = File.expand_path("../vendor", __dir__)
|
|
25
43
|
AXE_JS = File.join(VENDOR_DIR, "axe.min.js")
|
|
44
|
+
AXE_SHA512 = File.join(VENDOR_DIR, "axe.min.js.sha512")
|
|
26
45
|
AXE_LICENSE = File.join(VENDOR_DIR, "axe-core-LICENSE.txt")
|
|
27
46
|
VERSION_FILE = File.expand_path("../version.rb", __dir__)
|
|
28
47
|
REGISTRY = "https://registry.npmjs.org/axe-core"
|
|
29
|
-
CDN = "https://unpkg.com/axe-core"
|
|
30
48
|
|
|
31
49
|
module_function
|
|
32
50
|
|
|
51
|
+
# A semver-ish version string: three dot-separated numbers with an optional
|
|
52
|
+
# prerelease suffix. Rejecting anything else keeps the value safe to splice
|
|
53
|
+
# into the registry URL and into version.rb, and catches honest typos before
|
|
54
|
+
# they corrupt the vendor directory.
|
|
55
|
+
VERSION_FORMAT = /\A\d+\.\d+\.\d+(-[\w.]+)?\z/
|
|
56
|
+
|
|
33
57
|
def update!(version)
|
|
58
|
+
abort "Invalid version: #{version.inspect}" unless version.match?(VERSION_FORMAT)
|
|
59
|
+
|
|
34
60
|
FileUtils.mkdir_p(VENDOR_DIR)
|
|
35
61
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
license =
|
|
62
|
+
dist = registry_dist(version)
|
|
63
|
+
puts "Downloading #{dist.fetch("tarball")} ..."
|
|
64
|
+
tarball = download(dist.fetch("tarball"))
|
|
65
|
+
verify_tarball!(tarball, dist)
|
|
66
|
+
|
|
67
|
+
js, license = extract(tarball, "package/axe.min.js", "package/LICENSE")
|
|
68
|
+
verify_banner!(js, version)
|
|
42
69
|
|
|
43
70
|
File.write(AXE_JS, js)
|
|
44
71
|
File.write(AXE_LICENSE, license)
|
|
72
|
+
File.write(AXE_SHA512, "#{Digest::SHA512.hexdigest(js)} axe.min.js\n")
|
|
45
73
|
bump_version_constant(version)
|
|
46
74
|
|
|
47
|
-
|
|
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."
|
|
75
|
+
report(version, js)
|
|
52
76
|
end
|
|
53
77
|
|
|
54
78
|
def latest_version
|
|
@@ -59,8 +83,71 @@ module AxeCupriteVendor
|
|
|
59
83
|
File.read(AXE_JS)[/axe v([0-9][0-9.]*)/, 1] || "unknown"
|
|
60
84
|
end
|
|
61
85
|
|
|
86
|
+
def verify_vendored!
|
|
87
|
+
abort "No recorded checksum at #{AXE_SHA512} — run rake axe:update first." unless File.exist?(AXE_SHA512)
|
|
88
|
+
|
|
89
|
+
expected = File.read(AXE_SHA512).split.first
|
|
90
|
+
actual = Digest::SHA512.hexdigest(File.binread(AXE_JS))
|
|
91
|
+
unless actual == expected
|
|
92
|
+
abort <<~MSG
|
|
93
|
+
MISMATCH: #{AXE_JS} does not match its recorded sha512.
|
|
94
|
+
recorded: #{expected}
|
|
95
|
+
actual: #{actual}
|
|
96
|
+
MSG
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
puts "OK: axe.min.js matches its recorded sha512."
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def registry_dist(version)
|
|
103
|
+
puts "Fetching axe-core@#{version} metadata from #{REGISTRY} ..."
|
|
104
|
+
JSON.parse(download("#{REGISTRY}/#{version}")).fetch("dist")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Abort unless the downloaded tarball matches the integrity values the npm
|
|
108
|
+
# registry publishes for it (dist.integrity sha512, plus legacy dist.shasum).
|
|
109
|
+
def verify_tarball!(tarball, dist)
|
|
110
|
+
integrity = dist["integrity"]
|
|
111
|
+
abort "Registry metadata has no sha512 integrity for the tarball — refusing to vendor." unless integrity&.start_with?("sha512-")
|
|
112
|
+
|
|
113
|
+
actual = "sha512-#{Digest::SHA512.base64digest(tarball)}"
|
|
114
|
+
unless actual == integrity
|
|
115
|
+
abort <<~MSG
|
|
116
|
+
Tarball integrity check FAILED — refusing to vendor.
|
|
117
|
+
expected (registry dist.integrity): #{integrity}
|
|
118
|
+
actual (downloaded tarball): #{actual}
|
|
119
|
+
MSG
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
shasum = dist["shasum"]
|
|
123
|
+
if shasum && Digest::SHA1.hexdigest(tarball) != shasum
|
|
124
|
+
abort "Tarball sha1 shasum mismatch (registry says #{shasum}) — refusing to vendor."
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
puts " tarball integrity verified (#{integrity})"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Secondary sanity check on the extracted engine itself; fatal on mismatch.
|
|
131
|
+
def verify_banner!(engine_js, version)
|
|
132
|
+
return if engine_js.match?(/axe v#{Regexp.escape(version)}\b/)
|
|
133
|
+
|
|
134
|
+
abort "Extracted axe.min.js banner does not mention v#{version} — refusing to vendor."
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def extract(tarball, *paths)
|
|
138
|
+
found = {}
|
|
139
|
+
Zlib::GzipReader.wrap(StringIO.new(tarball)) do |gz|
|
|
140
|
+
Gem::Package::TarReader.new(gz) do |tar|
|
|
141
|
+
tar.each { |entry| found[entry.full_name] = entry.read if paths.include?(entry.full_name) }
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
missing = paths - found.keys
|
|
145
|
+
abort "Tarball is missing expected file(s): #{missing.join(", ")}" unless missing.empty?
|
|
146
|
+
found.values_at(*paths)
|
|
147
|
+
end
|
|
148
|
+
|
|
62
149
|
def download(url)
|
|
63
|
-
URI.parse(url).open(&:read)
|
|
150
|
+
URI.parse(url).open("rb", &:read)
|
|
64
151
|
rescue OpenURI::HTTPError => e
|
|
65
152
|
abort "Failed to download #{url}: #{e.message}"
|
|
66
153
|
end
|
|
@@ -70,4 +157,13 @@ module AxeCupriteVendor
|
|
|
70
157
|
updated = contents.sub(/(AXE_CORE_VERSION\s*=\s*)"[^"]*"/, %(\\1"#{version}"))
|
|
71
158
|
File.write(VERSION_FILE, updated)
|
|
72
159
|
end
|
|
160
|
+
|
|
161
|
+
def report(version, engine_js)
|
|
162
|
+
puts "Vendored axe-core #{version} (tarball integrity verified):"
|
|
163
|
+
puts " #{AXE_JS} (#{engine_js.bytesize} bytes)"
|
|
164
|
+
puts " #{AXE_SHA512}"
|
|
165
|
+
puts " #{AXE_LICENSE}"
|
|
166
|
+
puts " updated AXE_CORE_VERSION in #{VERSION_FILE}"
|
|
167
|
+
puts "Remember to note the version bump in CHANGELOG.md."
|
|
168
|
+
end
|
|
73
169
|
end
|
data/lib/axe/cuprite/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Abdullah Hashim
|
|
@@ -58,33 +58,25 @@ dependencies:
|
|
|
58
58
|
- !ruby/object:Gem::Version
|
|
59
59
|
version: '0.17'
|
|
60
60
|
- !ruby/object:Gem::Dependency
|
|
61
|
-
name:
|
|
61
|
+
name: puma
|
|
62
62
|
requirement: !ruby/object:Gem::Requirement
|
|
63
63
|
requirements:
|
|
64
|
-
- - "
|
|
65
|
-
- !ruby/object:Gem::Version
|
|
66
|
-
version: '3.0'
|
|
67
|
-
type: :development
|
|
68
|
-
prerelease: false
|
|
69
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
70
|
-
requirements:
|
|
71
|
-
- - "~>"
|
|
64
|
+
- - ">="
|
|
72
65
|
- !ruby/object:Gem::Version
|
|
73
|
-
version: '
|
|
74
|
-
-
|
|
75
|
-
name: rake
|
|
76
|
-
requirement: !ruby/object:Gem::Requirement
|
|
77
|
-
requirements:
|
|
78
|
-
- - "~>"
|
|
66
|
+
version: '5.0'
|
|
67
|
+
- - "<"
|
|
79
68
|
- !ruby/object:Gem::Version
|
|
80
|
-
version: '
|
|
69
|
+
version: '9.0'
|
|
81
70
|
type: :development
|
|
82
71
|
prerelease: false
|
|
83
72
|
version_requirements: !ruby/object:Gem::Requirement
|
|
84
73
|
requirements:
|
|
85
|
-
- - "
|
|
74
|
+
- - ">="
|
|
86
75
|
- !ruby/object:Gem::Version
|
|
87
|
-
version: '
|
|
76
|
+
version: '5.0'
|
|
77
|
+
- - "<"
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '9.0'
|
|
88
80
|
- !ruby/object:Gem::Dependency
|
|
89
81
|
name: rack
|
|
90
82
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -120,33 +112,82 @@ dependencies:
|
|
|
120
112
|
- !ruby/object:Gem::Version
|
|
121
113
|
version: '2.0'
|
|
122
114
|
- !ruby/object:Gem::Dependency
|
|
123
|
-
name:
|
|
115
|
+
name: rake
|
|
124
116
|
requirement: !ruby/object:Gem::Requirement
|
|
125
117
|
requirements:
|
|
126
|
-
- - "
|
|
118
|
+
- - "~>"
|
|
127
119
|
- !ruby/object:Gem::Version
|
|
128
|
-
version: '
|
|
129
|
-
|
|
120
|
+
version: '13.0'
|
|
121
|
+
type: :development
|
|
122
|
+
prerelease: false
|
|
123
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
124
|
+
requirements:
|
|
125
|
+
- - "~>"
|
|
130
126
|
- !ruby/object:Gem::Version
|
|
131
|
-
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'
|
|
132
135
|
type: :development
|
|
133
136
|
prerelease: false
|
|
134
137
|
version_requirements: !ruby/object:Gem::Requirement
|
|
135
138
|
requirements:
|
|
136
|
-
- - "
|
|
139
|
+
- - "~>"
|
|
137
140
|
- !ruby/object:Gem::Version
|
|
138
|
-
version: '
|
|
139
|
-
|
|
141
|
+
version: '3.0'
|
|
142
|
+
- !ruby/object:Gem::Dependency
|
|
143
|
+
name: rubocop
|
|
144
|
+
requirement: !ruby/object:Gem::Requirement
|
|
145
|
+
requirements:
|
|
146
|
+
- - "~>"
|
|
140
147
|
- !ruby/object:Gem::Version
|
|
141
|
-
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'
|
|
142
184
|
description: |
|
|
143
185
|
axe-cuprite runs the axe-core accessibility engine against pages rendered in
|
|
144
186
|
Capybara system/feature tests and exposes the results as RSpec matchers
|
|
145
187
|
(be_axe_clean / be_accessible). Unlike Deque's official axe-core-capybara gem,
|
|
146
188
|
it never touches Selenium-specific driver internals: axe is driven entirely
|
|
147
189
|
through Capybara's driver-neutral JavaScript API, so it works with Cuprite
|
|
148
|
-
|
|
149
|
-
The headline use case is catching WCAG color-contrast failures in CI.
|
|
190
|
+
and any other real-browser driver.
|
|
150
191
|
email:
|
|
151
192
|
- abdullah@guidedrails.com
|
|
152
193
|
executables: []
|