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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8cd30e650ead2d72475783c40ed7cc6d7d251527913d2b28ae6643402d9d032b
4
- data.tar.gz: a5624b4d65f5859b7fd4a710f79a5bf6fb85b6c9f409e2bc50de393f8563ad86
3
+ metadata.gz: 91d61bb330869ef24f40d7b9c09859daf23212f99dd8b6c14506238d48cfe9f2
4
+ data.tar.gz: 0d023ead7e8245bf03d428ef4912c093a32888c7528d0e95307ca3036a77db2b
5
5
  SHA512:
6
- metadata.gz: 89f99c88d792b73c748f25c4b7a33d0b136a5a939e77f14b6fcbc437f88ab5eb85e4ebf0f182843ce23ea1a04857e619104db06aaf476eb065c9252aebe2c22b
7
- data.tar.gz: 274b40778c354c1ee42a04958fdda2b1f9513f9220d94b300495a62cf86c86d862655d504c1366ead311e65ba1410104f3eec9fc51f280faee098464113d8af9
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.min.js` and its `LICENSE` from unpkg and bumps the
229
- `AXE_CORE_VERSION` constant. Note the bump in `CHANGELOG.md`.
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
 
@@ -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
- raise InjectionError, "Failed to inject axe-core: #{e.message}" unless try_add_script_tag(source)
92
+ errors["execute_script"] = e
81
93
  end
82
-
83
94
  return true if injected?
84
95
 
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."
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
- 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)
175
210
 
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
211
+ name == "Ferrum::JavaScriptError" && error.message.to_s.match?(/timed out promise/i)
180
212
  end
181
213
  end
182
214
  end
@@ -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 = nil
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).each_with_object({}) do |id, h|
157
- h[id] = { enabled: false }
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, 'violation')} across #{node_count} " \
171
- "#{pluralize(node_count, 'element')}:"
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, 'element')})"
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, 'element')}" if remaining.positive?
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) — " \
@@ -20,8 +20,8 @@ module AxeCuprite
20
20
  end
21
21
  end
22
22
 
23
- if defined?(::RSpec) && ::RSpec.respond_to?(:configure)
24
- ::RSpec.configure do |config|
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
@@ -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 "open-uri"
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
- 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")
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
- 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."
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  module AxeCuprite
4
4
  # Version of the axe-cuprite gem itself.
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.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`.
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.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: rspec
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: '3.0'
74
- - !ruby/object:Gem::Dependency
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: '13.0'
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: '13.0'
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: puma
115
+ name: rake
124
116
  requirement: !ruby/object:Gem::Requirement
125
117
  requirements:
126
- - - ">="
118
+ - - "~>"
127
119
  - !ruby/object:Gem::Version
128
- version: '5.0'
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: '9.0'
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: '5.0'
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: '9.0'
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
- (the Ferrum/CDP headless-Chrome driver) and any other real-browser driver.
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: []