capybara_accessibility_audit 0.2.0 → 0.3.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: 6f3c291518b387bd0ebb540dd63aa42e7ff1740741ca5dbf1a25cc4be69cbcfc
4
- data.tar.gz: bf7184f0a2ac3b7014665cb58bb9bed8d5d8ef180974b78e4d852d2759cec221
3
+ metadata.gz: 152ffd3d124d45c7cc7cec829bcd30c18a2f97dbc988c23cb7629cfe0e8eaa09
4
+ data.tar.gz: c174479db34168f55e3526e011e833d8fb6d7282392ab1f706d56be2786f1ca4
5
5
  SHA512:
6
- metadata.gz: 7f6975cda1ae0f1465159dc662a171b1236eda9d8e3afaa605ce4dee7ee49cb29ed806f40b01c4394a9c37257ae689c825669371fc943aa3142d272f53c95eb2
7
- data.tar.gz: 40cff319bee4767c4e1e12d37b074728d24d84dc9ab302ec481104a22c87f30dad4c851ee96aaecf170188d821d27a09e793dba4455a6495f45c1afe6416b200
6
+ metadata.gz: fa0be00cf21080048e5fec94d3cabb82dbe61aca8a5d04ea341d8aef776aefe5fae2936e958e66de86d0c064cea2d61a2c4f22ac764808c9f6d28c09059f385b
7
+ data.tar.gz: 31d70ffeab59611501a8bbf3933137526273b2e871a716d636cf157168b299b0fa5145925b87be58aeebd314bd7937801fc184cff6d257a1979003893ec1a0d9
data/README.md CHANGED
@@ -60,6 +60,56 @@ end
60
60
  [aXe]: https://www.deque.com/axe/
61
61
  [axe-core-rspec]: https://github.com/dequelabs/axe-core-gems/blob/develop/packages/axe-core-rspec/README.md#matcher
62
62
 
63
+ ## Active Support instrumentation
64
+
65
+ Capybara Accessibility Audit integrates with [Active Support's
66
+ instrumentation][] through publishing [ActiveSupport::Notifications][].
67
+
68
+ ### `audit.capybara_accessibility_audit` notification
69
+
70
+ Subscribe to `audit.capybara_accessibility_audit` notifications emitted when an
71
+ accessibility audit is automatically conducted. In addition to the
72
+ metadata provided by default (like `name`, `duration`, and `allocations`, etc.),
73
+ the `payload` includes additional information:
74
+
75
+ | Payload | Type | Description |
76
+ | ------------- | ---------------------------------- | ----------- |
77
+ | method | Symbol | The Capybara method that triggered the audit
78
+ | options | [ActiveSupport::OrderedOptions][] | The audit's configuration
79
+ | test | [ActionDispatch::SystemTestCase][] | The test case that triggered the audit
80
+
81
+ > [!NOTE]
82
+ > The `audit.capybara_accessibility_audit` notifications are only published when
83
+ > an audit is conducted automatically. No `audit.capybara_accessibility_audit`
84
+ > notifications will be published when `assert_no_accessibility_violations` is
85
+ > invoked directly.
86
+
87
+ [Active Support's instrumentation]: https://guides.rubyonrails.org/active_support_instrumentation.html
88
+ [ActiveSupport::Notifications]: https://api.rubyonrails.org/classes/ActiveSupport/Notifications.html
89
+ [ActiveSupport::OrderedOptions]: https://api.rubyonrails.org/classes/ActiveSupport/OrderedOptions.html
90
+ [ActionDispatch::SystemTestCase]: https://api.rubyonrails.org/classes/ActionDispatch/SystemTestCase.html
91
+
92
+ ### `report.capybara_accessibility_audit` notification
93
+
94
+ Subscribe to `report.capybara_accessibility_audit` notifications emitted when an
95
+ accessibility audit detects violations. The `payload` includes:
96
+
97
+ | Payload | Type | Description |
98
+ | ------------- | ---------------------------------- | ----------- |
99
+ | report | [Axe::API::Results][] | The underlying axe.js [Results][] object
100
+ | options | [ActiveSupport::OrderedOptions][] | The audit's configuration
101
+ | test | [ActionDispatch::SystemTestCase][] | The test case that triggered the audit
102
+
103
+ [ActiveSupport::OrderedOptions]: https://api.rubyonrails.org/classes/ActiveSupport/OrderedOptions.html
104
+ [ActionDispatch::SystemTestCase]: https://api.rubyonrails.org/classes/ActionDispatch/SystemTestCase.html
105
+ [Axe::API::Results]: https://github.com/dequelabs/axe-core-gems/blob/v4.11.0/packages/axe-core-api/lib/axe/api/results.rb
106
+ [Results]: https://www.deque.com/axe/core-documentation/api-documentation/#results-object
107
+
108
+ > [!NOTE]
109
+ > The `report.capybara_accessibility_audit` notifications are only published when
110
+ > `config.capybara_accessibility_audit.reporter` is configured with
111
+ > `:notification` or `:log`.
112
+
63
113
  ## Frequently Asked Questions
64
114
 
65
115
  My application already exists, automated accessibility audits are uncovering violations left and right. Do I have to fix them all at once?
@@ -114,6 +164,28 @@ end
114
164
  As you resolve the violations, you can remove entries from the list of skipped
115
165
  rules.
116
166
 
167
+ How many I conduct a preliminary audit that comprehensively exercises my system test suite without failing tests that result in violations?
168
+ ---
169
+
170
+ You can configure the audit's reporting mechanism. By default, the
171
+ `config.capybara_accessibility_audit.reporter` value is set to `:raise`, which
172
+ will raise violation errors that will fail the test suite.
173
+
174
+ To log violations, rather than raise them, you can configure
175
+ `config.capybara_accessibility_audit.reporter` to `:log`:
176
+
177
+
178
+ ```ruby
179
+ class MySystemTest < ApplicationSystemTestCase
180
+ self.accessibility_audit_reporter = :log
181
+
182
+ test "with overridden accessibility :log reporter" do
183
+ visit examples_path
184
+ # ...
185
+ end
186
+ end
187
+ ```
188
+
117
189
  I've implemented a custom Capybara action to toggle a disclosure element. How can I automatically audit for violations after it's called?
118
190
  ---
119
191
 
@@ -204,6 +276,22 @@ class MySystemTest < ApplicationSystemTestCase
204
276
  end
205
277
  ```
206
278
 
279
+ My pages render on the client. How long does the audit wait for them?
280
+ ---
281
+
282
+ Capybara returns from an action as soon as the driver is done with it, and a
283
+ page that renders on the client keeps changing after that point. The auditor
284
+ therefore waits for the DOM to stay unchanged before it runs `axe`. It waits
285
+ for 100 milliseconds of quiet, and it audits the page as it is after 2000
286
+ milliseconds, so a page that never settles cannot block the suite.
287
+
288
+ To change those values, override them on the auditor:
289
+
290
+ ```ruby
291
+ CapybaraAccessibilityAudit::AxeAuditor.quiet_period_ms = 250
292
+ CapybaraAccessibilityAudit::AxeAuditor.quiet_period_timeout_ms = 5000
293
+ ```
294
+
207
295
  ## Installation
208
296
  Add this line to your application's Gemfile:
209
297
 
@@ -238,5 +326,4 @@ We are [available for hire][hire].
238
326
  [community]: https://thoughtbot.com/community?utm_source=github
239
327
  [hire]: https://thoughtbot.com/hire-us?utm_source=github
240
328
 
241
-
242
329
  <!-- END /templates/footer.md -->
@@ -1,5 +1,5 @@
1
1
  module CapybaraAccessibilityAudit
2
- class Auditor
2
+ class Adapter
3
3
  delegate_missing_to :@test
4
4
 
5
5
  def initialize(test)
@@ -8,7 +8,13 @@ module CapybaraAccessibilityAudit
8
8
 
9
9
  def audit!(method)
10
10
  if accessibility_audit_enabled && method.in?(accessibility_audit_after_methods) && javascript_enabled?
11
- assert_no_accessibility_violations(**accessibility_audit_options)
11
+ ActiveSupport::Notifications.instrument "audit.capybara_accessibility_audit" do |payload|
12
+ payload[:method] = method
13
+ payload[:options] = accessibility_audit_options
14
+ payload[:test] = @test
15
+
16
+ assert_no_accessibility_violations(**accessibility_audit_options)
17
+ end
12
18
  end
13
19
  end
14
20
 
@@ -1,5 +1,3 @@
1
- require "axe/matchers/be_axe_clean"
2
-
3
1
  module CapybaraAccessibilityAudit
4
2
  module AuditSystemTestExtensions
5
3
  extend ActiveSupport::Concern
@@ -15,11 +13,14 @@ module CapybaraAccessibilityAudit
15
13
  class_attribute :accessibility_audit_after_methods, default: Set.new
16
14
  class_attribute :accessibility_audit_enabled, default: true
17
15
  class_attribute :accessibility_audit_options, default: ActiveSupport::OrderedOptions.new
16
+ class_attribute :accessibility_audit_reporter, default: :raise
17
+
18
+ attr_accessor :accessibility_audit_auditor
18
19
 
19
20
  MODAL_METHODS.each do |method|
20
21
  define_method method do |*arguments, **options, &block|
21
22
  result = super(*arguments, **options) { skip_accessibility_audits(&block) }
22
- result.tap { Auditor.new(self).audit!(method) }
23
+ result.tap { Adapter.new(self).audit!(method) }
23
24
  end
24
25
  end
25
26
  end
@@ -35,7 +36,7 @@ module CapybaraAccessibilityAudit
35
36
  def accessibility_audit_after(*methods)
36
37
  (methods.flatten.to_set - accessibility_audit_after_methods).each do |method|
37
38
  define_method method do |*arguments, **options, &block|
38
- super(*arguments, **options, &block).tap { Auditor.new(self).audit!(method) }
39
+ super(*arguments, **options, &block).tap { Adapter.new(self).audit!(method) }
39
40
  end
40
41
 
41
42
  accessibility_audit_after_methods << method
@@ -89,7 +90,7 @@ module CapybaraAccessibilityAudit
89
90
  accessibility_audit_options.skipping = skipping
90
91
  end
91
92
 
92
- def assert_no_accessibility_violations(**options)
93
+ def assert_no_accessibility_violations(auditor: accessibility_audit_auditor, **options)
93
94
  options.assert_valid_keys(
94
95
  :according_to,
95
96
  :checking,
@@ -100,10 +101,7 @@ module CapybaraAccessibilityAudit
100
101
  )
101
102
  options.compact_blank!
102
103
 
103
- axe_matcher = Axe::Matchers::BeAxeClean.new
104
- axe_matcher = options.inject(axe_matcher) { |matcher, option| matcher.public_send(*option) }
105
-
106
- assert axe_matcher.matches?(page), axe_matcher.failure_message
104
+ auditor.audit(**options)
107
105
  end
108
106
  end
109
107
  end
@@ -0,0 +1,131 @@
1
+ require "axe/api/context"
2
+ require "axe/api/options"
3
+ require "axe/api/results"
4
+ require "axe/configuration"
5
+
6
+ module CapybaraAccessibilityAudit
7
+ class AxeAuditor
8
+ Error = Class.new(StandardError)
9
+
10
+ ERROR_KEY = "capybara_accessibility_audit_error"
11
+
12
+ class_attribute :source, instance_accessor: false, default: Axe::Configuration.instance.jslib
13
+
14
+ class_attribute :quiet_period_ms, instance_accessor: false, default: 100
15
+ class_attribute :quiet_period_timeout_ms, instance_accessor: false, default: 2000
16
+
17
+ def initialize(test, reporter)
18
+ @test = test
19
+ @reporter = reporter
20
+ end
21
+
22
+ def audit(**options)
23
+ settle
24
+ install
25
+
26
+ results = run(options)
27
+
28
+ @reporter.report Axe::API::Results.new(results)
29
+ end
30
+
31
+ private
32
+
33
+ def page
34
+ @test.page
35
+ end
36
+
37
+ def settle
38
+ quiet_period_ms = self.class.quiet_period_ms
39
+ earliest = monotonic_time + quiet_period_ms
40
+ deadline = monotonic_time + self.class.quiet_period_timeout_ms
41
+
42
+ observe_mutations
43
+
44
+ until monotonic_time >= deadline
45
+ if (duration = unchanged_for).nil?
46
+ observe_mutations
47
+ elsif monotonic_time >= earliest && duration >= quiet_period_ms
48
+ break
49
+ end
50
+
51
+ sleep 0.01
52
+ end
53
+ end
54
+
55
+ def monotonic_time
56
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
57
+ end
58
+
59
+ # Records the time of the last change to the DOM. The document keeps the
60
+ # observer that is already there.
61
+ def observe_mutations
62
+ page.execute_script <<~JS
63
+ if (!window.capybaraAccessibilityAudit) {
64
+ window.capybaraAccessibilityAudit = {changedAt: Date.now()}
65
+
66
+ new MutationObserver(() => {
67
+ window.capybaraAccessibilityAudit.changedAt = Date.now()
68
+ }).observe(document, {attributes: true, characterData: true, childList: true, subtree: true})
69
+ }
70
+ JS
71
+ end
72
+
73
+ # Answers how long the DOM has been unchanged, in milliseconds. Answers
74
+ # nil when a page load replaced the document that has the observer. Each
75
+ # call is a short round trip. A single long-lived script would lose its
76
+ # callback when a page load discards the document it runs in.
77
+ def unchanged_for
78
+ duration = page.evaluate_script <<~JS
79
+ window.capybaraAccessibilityAudit ? Date.now() - window.capybaraAccessibilityAudit.changedAt : null
80
+ JS
81
+
82
+ duration if duration.is_a?(Numeric) # :playwright returns null as {}
83
+ end
84
+
85
+ def run(config)
86
+ context, options = split(config)
87
+
88
+ json = page.evaluate_async_script <<~JS, context.as_json, options.as_json, ERROR_KEY
89
+ const [ context, options, errorKey, callback ] = arguments
90
+
91
+ axe.run(context, options)
92
+ .then(JSON.stringify)
93
+ .catch(error => JSON.stringify({ [errorKey]: error.message }))
94
+ .then(callback)
95
+ JS
96
+
97
+ case JSON.parse(json, symbolize_names: true)
98
+ in {capybara_accessibility_audit_error:}
99
+ raise Error, capybara_accessibility_audit_error
100
+ in Hash => report
101
+ report
102
+ else
103
+ raise Error, "Unexpected report structure"
104
+ end
105
+ end
106
+
107
+ def split(config)
108
+ context = Axe::API::Context.new
109
+ options = Axe::API::Options.new
110
+
111
+ config.each do |name, value|
112
+ case name
113
+ when :within, :excluding then context.public_send(name, value)
114
+ else options.public_send(name, value)
115
+ end
116
+ end
117
+
118
+ [context, options]
119
+ end
120
+
121
+ def install
122
+ page.execute_script(self.class.source) unless installed?
123
+ end
124
+
125
+ def installed?
126
+ page.evaluate_script <<~JS
127
+ "axe" in window && typeof axe.run === "function"
128
+ JS
129
+ end
130
+ end
131
+ end
@@ -1,6 +1,7 @@
1
1
  module CapybaraAccessibilityAudit
2
2
  class Engine < ::Rails::Engine
3
3
  config.capybara_accessibility_audit = ActiveSupport::OrderedOptions.new
4
+ config.capybara_accessibility_audit.auditor = AxeAuditor
4
5
  config.capybara_accessibility_audit.audit_after = %i[
5
6
  visit
6
7
  click_button
@@ -9,14 +10,23 @@ module CapybaraAccessibilityAudit
9
10
  click_on
10
11
  ]
11
12
  config.capybara_accessibility_audit.audit_enabled = true
13
+ config.capybara_accessibility_audit.reporter = :raise
12
14
 
13
15
  initializer "capybara_accessibility_audit.minitest" do |app|
14
16
  ActiveSupport.on_load :action_dispatch_system_test_case do
15
17
  include CapybaraAccessibilityAudit::AuditSystemTestExtensions
16
18
 
17
19
  self.accessibility_audit_enabled = app.config.capybara_accessibility_audit.audit_enabled
20
+ self.accessibility_audit_reporter = app.config.capybara_accessibility_audit.reporter
18
21
 
19
22
  accessibility_audit_after app.config.capybara_accessibility_audit.audit_after
23
+
24
+ setup do
25
+ auditor_class = app.config.capybara_accessibility_audit.auditor
26
+ reporter_class = CapybaraAccessibilityAudit.reporter_class(accessibility_audit_reporter)
27
+
28
+ self.accessibility_audit_auditor = auditor_class.new(self, reporter_class.new(self))
29
+ end
20
30
  end
21
31
  end
22
32
 
@@ -32,6 +42,11 @@ module CapybaraAccessibilityAudit
32
42
  self.accessibility_audit_enabled = app.config.capybara_accessibility_audit.audit_enabled
33
43
 
34
44
  accessibility_audit_after app.config.capybara_accessibility_audit.audit_after
45
+
46
+ auditor_class = app.config.capybara_accessibility_audit.auditor
47
+ reporter_class = CapybaraAccessibilityAudit.reporter_class(accessibility_audit_reporter)
48
+
49
+ self.accessibility_audit_auditor = auditor_class.new(self, reporter_class.new(self))
35
50
  end
36
51
 
37
52
  config.before(type: :system, &configure)
@@ -0,0 +1,17 @@
1
+ module CapybaraAccessibilityAudit
2
+ class LogSubscriber < ActiveSupport::LogSubscriber
3
+ def report(event)
4
+ report = event.payload[:report]
5
+ test = event.payload[:test]
6
+
7
+ if test.accessibility_audit_reporter == :log
8
+ error color(<<~ERROR, :red)
9
+ [capybara_accessibility_audit] Accessibility audit detected violations in "#{test.class.name}##{test.name}":
10
+ #{report.failure_message}
11
+ ERROR
12
+ end
13
+ end
14
+
15
+ attach_to :capybara_accessibility_audit
16
+ end
17
+ end
@@ -0,0 +1,24 @@
1
+ module CapybaraAccessibilityAudit
2
+ class NotificationReporter
3
+ def initialize(test)
4
+ @test = test
5
+ end
6
+
7
+ def report(results)
8
+ if results.violations.present?
9
+ publish(results)
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def publish(report)
16
+ ActiveSupport::Notifications.instrument "report.capybara_accessibility_audit", {
17
+ auditor: @test.accessibility_audit_auditor,
18
+ options: @test.accessibility_audit_options,
19
+ report: report,
20
+ test: @test
21
+ }
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,11 @@
1
+ module CapybaraAccessibilityAudit
2
+ class RaiseReporter
3
+ def initialize(test)
4
+ @test = test
5
+ end
6
+
7
+ def report(results)
8
+ @test.assert results.violations.empty?, -> { results.failure_message }
9
+ end
10
+ end
11
+ end
@@ -1,3 +1,3 @@
1
1
  module CapybaraAccessibilityAudit
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -3,7 +3,20 @@ loader = Zeitwerk::Loader.for_gem
3
3
  loader.setup
4
4
 
5
5
  module CapybaraAccessibilityAudit
6
- # Your code goes here...
6
+ extend self
7
+
8
+ def reporter_class(name)
9
+ case name
10
+ when :notification, :log
11
+ NotificationReporter
12
+ when :raise
13
+ RaiseReporter
14
+ when ::Class
15
+ name
16
+ else
17
+ raise ArgumentError.new("unsupported reporter: #{name}")
18
+ end
19
+ end
7
20
  end
8
21
 
9
22
  loader.eager_load
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: capybara_accessibility_audit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean Doyle
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-04-08 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rails
@@ -66,6 +65,48 @@ dependencies:
66
65
  - - ">="
67
66
  - !ruby/object:Gem::Version
68
67
  version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: bigdecimal
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: drb
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: mutex_m
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
69
110
  description: Accessibility tooling for Capybara
70
111
  email:
71
112
  - sean.p.doyle24@gmail.com
@@ -77,9 +118,13 @@ files:
77
118
  - README.md
78
119
  - Rakefile
79
120
  - lib/capybara_accessibility_audit.rb
121
+ - lib/capybara_accessibility_audit/adapter.rb
80
122
  - lib/capybara_accessibility_audit/audit_system_test_extensions.rb
81
- - lib/capybara_accessibility_audit/auditor.rb
123
+ - lib/capybara_accessibility_audit/axe_auditor.rb
82
124
  - lib/capybara_accessibility_audit/engine.rb
125
+ - lib/capybara_accessibility_audit/log_subscriber.rb
126
+ - lib/capybara_accessibility_audit/notification_reporter.rb
127
+ - lib/capybara_accessibility_audit/raise_reporter.rb
83
128
  - lib/capybara_accessibility_audit/version.rb
84
129
  - lib/tasks/capybara_accessibility_audit.rake
85
130
  homepage: https://github.com/thoughtbot/capybara_accessibility_audit
@@ -89,7 +134,7 @@ metadata:
89
134
  homepage_uri: https://github.com/thoughtbot/capybara_accessibility_audit
90
135
  source_code_uri: https://github.com/thoughtbot/capybara_accessibility_audit
91
136
  changelog_uri: https://github.com/thoughtbot/capybara_accessibility_audit/blob/main/CHANGELOG.md
92
- post_install_message:
137
+ rubygems_mfa_required: 'true'
93
138
  rdoc_options: []
94
139
  require_paths:
95
140
  - lib
@@ -104,8 +149,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
104
149
  - !ruby/object:Gem::Version
105
150
  version: '0'
106
151
  requirements: []
107
- rubygems_version: 3.4.1
108
- signing_key:
152
+ rubygems_version: 4.0.17
109
153
  specification_version: 4
110
154
  summary: Accessibility tooling for Capybara
111
155
  test_files: []