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