axe-cuprite 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE.txt +30 -0
- data/README.md +257 -0
- data/lib/axe/cuprite/configuration.rb +56 -0
- data/lib/axe/cuprite/errors.rb +19 -0
- data/lib/axe/cuprite/injector.rb +182 -0
- data/lib/axe/cuprite/normalize.rb +27 -0
- data/lib/axe/cuprite/results.rb +167 -0
- data/lib/axe/cuprite/rspec/matchers.rb +227 -0
- data/lib/axe/cuprite/rspec.rb +27 -0
- data/lib/axe/cuprite/runner.rb +102 -0
- data/lib/axe/cuprite/tasks/axe.rake +73 -0
- data/lib/axe/cuprite/vendor/axe-core-LICENSE.txt +362 -0
- data/lib/axe/cuprite/vendor/axe.min.js +12 -0
- data/lib/axe/cuprite/version.rb +10 -0
- data/lib/axe/cuprite.rb +54 -0
- data/lib/axe-cuprite.rb +5 -0
- metadata +197 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AxeCuprite
|
|
4
|
+
# Wraps the (slimmed) payload returned by axe.run. We deliberately only carry
|
|
5
|
+
# `violations` and `incomplete` across the CDP boundary plus a little metadata
|
|
6
|
+
# — the full results object (with `passes`/`inapplicable`) can be huge.
|
|
7
|
+
class Results
|
|
8
|
+
attr_reader :raw
|
|
9
|
+
|
|
10
|
+
def initialize(raw)
|
|
11
|
+
@raw = raw || {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def violations
|
|
15
|
+
@violations ||= Array(@raw["violations"]).map { |v| Violation.new(v) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Nodes axe could not decide on (needs review). Surfaced but never fails.
|
|
19
|
+
def incomplete
|
|
20
|
+
@incomplete ||= Array(@raw["incomplete"]).map { |v| Violation.new(v) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def passes?
|
|
24
|
+
violations.empty?
|
|
25
|
+
end
|
|
26
|
+
alias clean? passes?
|
|
27
|
+
|
|
28
|
+
def url
|
|
29
|
+
@raw["url"]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def timestamp
|
|
33
|
+
@raw["timestamp"]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def test_engine
|
|
37
|
+
@raw["testEngine"]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_h
|
|
41
|
+
@raw
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A single axe rule violation, with one or more offending nodes.
|
|
46
|
+
class Violation
|
|
47
|
+
attr_reader :raw
|
|
48
|
+
|
|
49
|
+
def initialize(raw)
|
|
50
|
+
@raw = raw || {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def id
|
|
54
|
+
@raw["id"]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def impact
|
|
58
|
+
@raw["impact"]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def description
|
|
62
|
+
@raw["description"]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def help
|
|
66
|
+
@raw["help"]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def help_url
|
|
70
|
+
@raw["helpUrl"]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def tags
|
|
74
|
+
Array(@raw["tags"])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def nodes
|
|
78
|
+
@nodes ||= Array(@raw["nodes"]).map { |n| Node.new(n) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def color_contrast?
|
|
82
|
+
id == "color-contrast"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A single offending DOM node within a violation.
|
|
87
|
+
class Node
|
|
88
|
+
attr_reader :raw
|
|
89
|
+
|
|
90
|
+
def initialize(raw)
|
|
91
|
+
@raw = raw || {}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# axe gives `target` as an array of CSS selectors (one per frame depth).
|
|
95
|
+
def target
|
|
96
|
+
Array(@raw["target"])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def selector
|
|
100
|
+
target.join(" ")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The offending element's outer HTML (already truncated by axe).
|
|
104
|
+
def html
|
|
105
|
+
@raw["html"]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def failure_summary
|
|
109
|
+
@raw["failureSummary"]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The check results that caused/contributed to the failure.
|
|
113
|
+
def any
|
|
114
|
+
Array(@raw["any"])
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def all
|
|
118
|
+
Array(@raw["all"])
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def none
|
|
122
|
+
Array(@raw["none"])
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# For color-contrast violations axe stores rich data under any[].data.
|
|
126
|
+
# Returns a ContrastData (or nil if this node has no contrast data).
|
|
127
|
+
def contrast_data
|
|
128
|
+
check = any.find { |c| c.is_a?(Hash) && c["data"].is_a?(Hash) && c["data"].key?("contrastRatio") }
|
|
129
|
+
return nil unless check
|
|
130
|
+
|
|
131
|
+
ContrastData.new(check["data"])
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Typed view over a color-contrast check's `data` hash.
|
|
136
|
+
class ContrastData
|
|
137
|
+
attr_reader :raw
|
|
138
|
+
|
|
139
|
+
def initialize(raw)
|
|
140
|
+
@raw = raw || {}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def fg_color
|
|
144
|
+
@raw["fgColor"]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def bg_color
|
|
148
|
+
@raw["bgColor"]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def contrast_ratio
|
|
152
|
+
@raw["contrastRatio"]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def expected_contrast_ratio
|
|
156
|
+
@raw["expectedContrastRatio"]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def font_size
|
|
160
|
+
@raw["fontSize"]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def font_weight
|
|
164
|
+
@raw["fontWeight"]
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "axe/cuprite"
|
|
4
|
+
require "axe/cuprite/normalize"
|
|
5
|
+
|
|
6
|
+
module AxeCuprite
|
|
7
|
+
module RSpec
|
|
8
|
+
# The matcher behind `be_axe_clean` / `be_accessible`.
|
|
9
|
+
#
|
|
10
|
+
# expect(page).to be_axe_clean
|
|
11
|
+
# expect(page).to be_axe_clean.checking_only(:color_contrast).within("#main")
|
|
12
|
+
# expect(page).to be_axe_clean.according_to(:wcag2aa).excluding(".third-party")
|
|
13
|
+
# expect(page).to be_axe_clean.skipping(:region)
|
|
14
|
+
#
|
|
15
|
+
# All chainers return self so they compose in any order.
|
|
16
|
+
class BeAxeClean
|
|
17
|
+
# Cap how many offending elements we print per rule, to keep failure
|
|
18
|
+
# messages readable on noisy pages.
|
|
19
|
+
MAX_NODES = 5
|
|
20
|
+
# Cap HTML snippet length per node.
|
|
21
|
+
HTML_SNIPPET = 200
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@includes = []
|
|
25
|
+
@excludes = []
|
|
26
|
+
@only_rules = []
|
|
27
|
+
@skip_rules = []
|
|
28
|
+
@tags = []
|
|
29
|
+
@run_options = {}
|
|
30
|
+
@timeout = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# --- chainable DSL ----------------------------------------------------
|
|
34
|
+
|
|
35
|
+
# Scope the run to one or more selectors (axe context "include").
|
|
36
|
+
def within(*selectors)
|
|
37
|
+
@includes.concat(selectors.flatten)
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Exclude one or more selectors from the run (axe context "exclude").
|
|
42
|
+
def excluding(*selectors)
|
|
43
|
+
@excludes.concat(selectors.flatten)
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Run ONLY these rules. Accepts symbols or axe ids; underscores -> hyphens.
|
|
48
|
+
def checking_only(*rules)
|
|
49
|
+
@only_rules.concat(rules.flatten)
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Disable these rules for this run (merged with the global skip-list).
|
|
54
|
+
def skipping(*rules)
|
|
55
|
+
@skip_rules.concat(rules.flatten)
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Restrict the run to axe tags, e.g. :wcag2aa, :wcag21aa, :best_practice.
|
|
60
|
+
def according_to(*tags)
|
|
61
|
+
@tags.concat(tags.flatten)
|
|
62
|
+
self
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Escape hatch: merge raw axe run options (deep-merged, wins on conflict).
|
|
66
|
+
def with_options(options)
|
|
67
|
+
@run_options = deep_merge(@run_options, options)
|
|
68
|
+
self
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Override the axe timeout (seconds) for this assertion only.
|
|
72
|
+
def with_timeout(seconds)
|
|
73
|
+
@timeout = seconds
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# --- RSpec protocol ---------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def matches?(page)
|
|
80
|
+
run!(page)
|
|
81
|
+
|
|
82
|
+
if config.report_only && !@violations.empty?
|
|
83
|
+
config.logger.warn("report_only: #{summary_line}\n#{details}")
|
|
84
|
+
return true
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
@violations.empty?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Negation ignores report_only: `to_not be_axe_clean` passes iff there
|
|
91
|
+
# really are violations.
|
|
92
|
+
def does_not_match?(page)
|
|
93
|
+
run!(page)
|
|
94
|
+
!@violations.empty?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def failure_message
|
|
98
|
+
"#{summary_line}\n\n#{details}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def failure_message_when_negated
|
|
102
|
+
"expected page to have axe-core accessibility violations, but it was clean " \
|
|
103
|
+
"(0 violations for the selected rules/scope)."
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def description
|
|
107
|
+
"be free of axe-core accessibility violations"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Exposed so callers/tests can inspect the full result after matching.
|
|
111
|
+
attr_reader :results, :violations
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def run!(page)
|
|
116
|
+
@results = Runner.new(page, configuration: config).run(
|
|
117
|
+
context: build_context,
|
|
118
|
+
options: build_options,
|
|
119
|
+
timeout: @timeout
|
|
120
|
+
)
|
|
121
|
+
@violations = @results.violations
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def config
|
|
125
|
+
AxeCuprite.configuration
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# --- context / options builders --------------------------------------
|
|
129
|
+
|
|
130
|
+
def build_context
|
|
131
|
+
if @excludes.empty?
|
|
132
|
+
return nil if @includes.empty?
|
|
133
|
+
|
|
134
|
+
@includes.length == 1 ? @includes.first : { include: @includes }
|
|
135
|
+
else
|
|
136
|
+
ctx = { exclude: @excludes }
|
|
137
|
+
ctx[:include] = @includes unless @includes.empty?
|
|
138
|
+
ctx
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def build_options
|
|
143
|
+
if !@only_rules.empty? && !@tags.empty?
|
|
144
|
+
raise ArgumentError,
|
|
145
|
+
"Use either .checking_only (specific rules) or .according_to (tags), not both."
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
opts = {}
|
|
149
|
+
if !@only_rules.empty?
|
|
150
|
+
opts[:runOnly] = { type: "rule", values: Normalize.rules(@only_rules) }
|
|
151
|
+
elsif !@tags.empty?
|
|
152
|
+
opts[:runOnly] = { type: "tag", values: Normalize.tags(@tags) }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
unless @skip_rules.empty?
|
|
156
|
+
opts[:rules] = Normalize.rules(@skip_rules).each_with_object({}) do |id, h|
|
|
157
|
+
h[id] = { enabled: false }
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
deep_merge(opts, @run_options)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# --- failure message formatting --------------------------------------
|
|
165
|
+
|
|
166
|
+
def summary_line
|
|
167
|
+
rule_count = @violations.length
|
|
168
|
+
node_count = @violations.sum { |v| v.nodes.length }
|
|
169
|
+
"expected page to be axe-clean, but found #{rule_count} " \
|
|
170
|
+
"#{pluralize(rule_count, 'violation')} across #{node_count} " \
|
|
171
|
+
"#{pluralize(node_count, 'element')}:"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def details
|
|
175
|
+
@violations.map { |v| format_violation(v) }.join("\n\n")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def format_violation(violation)
|
|
179
|
+
lines = []
|
|
180
|
+
impact = violation.impact || "n/a"
|
|
181
|
+
lines << " ● [#{impact}] #{violation.id} — #{violation.help} " \
|
|
182
|
+
"(#{violation.nodes.length} #{pluralize(violation.nodes.length, 'element')})"
|
|
183
|
+
lines << " #{violation.help_url}" if violation.help_url
|
|
184
|
+
|
|
185
|
+
violation.nodes.first(MAX_NODES).each do |node|
|
|
186
|
+
lines.concat(format_node(node))
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
remaining = violation.nodes.length - MAX_NODES
|
|
190
|
+
lines << " … and #{remaining} more #{pluralize(remaining, 'element')}" if remaining.positive?
|
|
191
|
+
lines.join("\n")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def format_node(node)
|
|
195
|
+
lines = [" - #{node.selector}"]
|
|
196
|
+
lines << " #{truncate(node.html)}" if node.html
|
|
197
|
+
|
|
198
|
+
if (cd = node.contrast_data)
|
|
199
|
+
lines << " contrast #{cd.contrast_ratio}:1 (needs #{cd.expected_contrast_ratio}:1) — " \
|
|
200
|
+
"fg #{cd.fg_color} on bg #{cd.bg_color}, font #{cd.font_size}/#{cd.font_weight}"
|
|
201
|
+
elsif (msg = first_check_message(node))
|
|
202
|
+
lines << " #{msg}"
|
|
203
|
+
end
|
|
204
|
+
lines
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def first_check_message(node)
|
|
208
|
+
(node.any + node.none).map { |c| c["message"] if c.is_a?(Hash) }.compact.first
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def truncate(text)
|
|
212
|
+
text = text.to_s.gsub(/\s+/, " ").strip
|
|
213
|
+
text.length > HTML_SNIPPET ? "#{text[0, HTML_SNIPPET]}…" : text
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def pluralize(count, word)
|
|
217
|
+
count == 1 ? word : "#{word}s"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def deep_merge(base, override)
|
|
221
|
+
base.merge(override) do |_key, a, b|
|
|
222
|
+
a.is_a?(Hash) && b.is_a?(Hash) ? deep_merge(a, b) : b
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "axe/cuprite"
|
|
4
|
+
require "axe/cuprite/rspec/matchers"
|
|
5
|
+
|
|
6
|
+
module AxeCuprite
|
|
7
|
+
module RSpec
|
|
8
|
+
# Methods mixed into RSpec example groups to expose the matchers.
|
|
9
|
+
module DSL
|
|
10
|
+
# expect(page).to be_axe_clean ...
|
|
11
|
+
def be_axe_clean
|
|
12
|
+
AxeCuprite::RSpec::BeAxeClean.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Alias: expect(page).to be_accessible ...
|
|
16
|
+
def be_accessible
|
|
17
|
+
AxeCuprite::RSpec::BeAxeClean.new
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
if defined?(::RSpec) && ::RSpec.respond_to?(:configure)
|
|
24
|
+
::RSpec.configure do |config|
|
|
25
|
+
config.include AxeCuprite::RSpec::DSL
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "axe/cuprite/normalize"
|
|
4
|
+
|
|
5
|
+
module AxeCuprite
|
|
6
|
+
# Framework-agnostic entry point. No RSpec required.
|
|
7
|
+
#
|
|
8
|
+
# results = AxeCuprite::Runner.new(page).run(
|
|
9
|
+
# context: "#main",
|
|
10
|
+
# options: { runOnly: { type: "rule", values: ["color-contrast"] } }
|
|
11
|
+
# )
|
|
12
|
+
# results.violations # => [AxeCuprite::Violation, ...]
|
|
13
|
+
#
|
|
14
|
+
# `page` is a Capybara session (e.g. the `page` in a Capybara test).
|
|
15
|
+
class Runner
|
|
16
|
+
attr_reader :page, :configuration
|
|
17
|
+
|
|
18
|
+
def initialize(page, configuration: AxeCuprite.configuration)
|
|
19
|
+
@page = page
|
|
20
|
+
@configuration = configuration
|
|
21
|
+
@injector = Injector.new(page, configuration)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Inject axe if not already present, run axe.run(context, options) via the
|
|
25
|
+
# async path, and return an AxeCuprite::Results.
|
|
26
|
+
#
|
|
27
|
+
# - context: axe context arg — a CSS selector String, or a Hash with
|
|
28
|
+
# :include / :exclude, or nil for the whole document.
|
|
29
|
+
# - options: axe run options Hash (runOnly, rules, resultTypes, ...).
|
|
30
|
+
# Deep-merged on top of the configured default_options.
|
|
31
|
+
# - timeout: override the configured axe timeout (seconds) for this run.
|
|
32
|
+
def run(context: nil, options: {}, timeout: nil)
|
|
33
|
+
merged = merge_options(options)
|
|
34
|
+
@injector.run(context: normalize_context(context), options: merged, timeout: timeout)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Force or ensure axe is injected (idempotent unless force: true). Useful
|
|
38
|
+
# when auto_inject is disabled, or to re-inject after a navigation.
|
|
39
|
+
def inject!(force: false)
|
|
40
|
+
@injector.ensure_injected!(force: force)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Is axe-core present on the current page?
|
|
44
|
+
def injected?
|
|
45
|
+
@injector.injected?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Apply global configuration (default_options, default_tags, skip_rules)
|
|
51
|
+
# underneath the caller-supplied options, which always win on conflict.
|
|
52
|
+
def merge_options(caller_options)
|
|
53
|
+
opts = deep_stringify(@configuration.default_options)
|
|
54
|
+
opts = deep_merge(opts, deep_stringify(caller_options))
|
|
55
|
+
|
|
56
|
+
# default_tags become a tag-based runOnly, but only if nothing already
|
|
57
|
+
# scopes runOnly (an explicit rule/tag selection takes precedence).
|
|
58
|
+
tags = Normalize.tags(@configuration.default_tags)
|
|
59
|
+
if !opts.key?("runOnly") && !tags.empty?
|
|
60
|
+
opts["runOnly"] = { "type" => "tag", "values" => tags }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# global skip_rules disable rules, without clobbering an explicit caller
|
|
64
|
+
# setting for the same rule.
|
|
65
|
+
skip = Normalize.rules(@configuration.skip_rules)
|
|
66
|
+
unless skip.empty?
|
|
67
|
+
rules = opts["rules"] || {}
|
|
68
|
+
skip.each { |id| rules[id] ||= { "enabled" => false } }
|
|
69
|
+
opts["rules"] = rules
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
opts
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# axe accepts a selector string, or {include:, exclude:}. Stringify hash
|
|
76
|
+
# keys so Ferrum serializes them predictably across CDP.
|
|
77
|
+
def normalize_context(context)
|
|
78
|
+
case context
|
|
79
|
+
when nil, String then context
|
|
80
|
+
when Hash then deep_stringify(context)
|
|
81
|
+
else context
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def deep_stringify(value)
|
|
86
|
+
case value
|
|
87
|
+
when Hash
|
|
88
|
+
value.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify(v) }
|
|
89
|
+
when Array
|
|
90
|
+
value.map { |v| deep_stringify(v) }
|
|
91
|
+
else
|
|
92
|
+
value
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def deep_merge(base, override)
|
|
97
|
+
base.merge(override) do |_key, a, b|
|
|
98
|
+
a.is_a?(Hash) && b.is_a?(Hash) ? deep_merge(a, b) : b
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|