rails_accessibility_testing 1.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.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +307 -0
  3. data/CHANGELOG.md +81 -0
  4. data/CODE_OF_CONDUCT.md +125 -0
  5. data/CONTRIBUTING.md +225 -0
  6. data/GUIDES/continuous_integration.md +326 -0
  7. data/GUIDES/getting_started.md +205 -0
  8. data/GUIDES/working_with_designers_and_content_authors.md +398 -0
  9. data/GUIDES/writing_accessible_views_in_rails.md +412 -0
  10. data/LICENSE +22 -0
  11. data/README.md +350 -0
  12. data/docs_site/404.html +11 -0
  13. data/docs_site/Gemfile +11 -0
  14. data/docs_site/Makefile +14 -0
  15. data/docs_site/_config.yml +41 -0
  16. data/docs_site/_includes/header.html +13 -0
  17. data/docs_site/_layouts/default.html +130 -0
  18. data/docs_site/assets/main.scss +4 -0
  19. data/docs_site/ci_integration.md +76 -0
  20. data/docs_site/configuration.md +114 -0
  21. data/docs_site/contributing.md +69 -0
  22. data/docs_site/getting_started.md +57 -0
  23. data/docs_site/index.md +57 -0
  24. data/exe/rails_a11y +12 -0
  25. data/exe/rails_server_safe +41 -0
  26. data/lib/generators/rails_a11y/install/generator.rb +51 -0
  27. data/lib/rails_accessibility_testing/accessibility_helper.rb +701 -0
  28. data/lib/rails_accessibility_testing/change_detector.rb +114 -0
  29. data/lib/rails_accessibility_testing/checks/aria_landmarks_check.rb +33 -0
  30. data/lib/rails_accessibility_testing/checks/base_check.rb +156 -0
  31. data/lib/rails_accessibility_testing/checks/color_contrast_check.rb +56 -0
  32. data/lib/rails_accessibility_testing/checks/duplicate_ids_check.rb +49 -0
  33. data/lib/rails_accessibility_testing/checks/form_errors_check.rb +40 -0
  34. data/lib/rails_accessibility_testing/checks/form_labels_check.rb +62 -0
  35. data/lib/rails_accessibility_testing/checks/heading_hierarchy_check.rb +53 -0
  36. data/lib/rails_accessibility_testing/checks/image_alt_text_check.rb +52 -0
  37. data/lib/rails_accessibility_testing/checks/interactive_elements_check.rb +66 -0
  38. data/lib/rails_accessibility_testing/checks/keyboard_accessibility_check.rb +36 -0
  39. data/lib/rails_accessibility_testing/checks/skip_links_check.rb +24 -0
  40. data/lib/rails_accessibility_testing/checks/table_structure_check.rb +36 -0
  41. data/lib/rails_accessibility_testing/cli/command.rb +259 -0
  42. data/lib/rails_accessibility_testing/config/yaml_loader.rb +131 -0
  43. data/lib/rails_accessibility_testing/configuration.rb +30 -0
  44. data/lib/rails_accessibility_testing/engine/rule_engine.rb +97 -0
  45. data/lib/rails_accessibility_testing/engine/violation.rb +58 -0
  46. data/lib/rails_accessibility_testing/engine/violation_collector.rb +59 -0
  47. data/lib/rails_accessibility_testing/error_message_builder.rb +354 -0
  48. data/lib/rails_accessibility_testing/integration/minitest_integration.rb +74 -0
  49. data/lib/rails_accessibility_testing/rspec_integration.rb +58 -0
  50. data/lib/rails_accessibility_testing/shared_examples.rb +93 -0
  51. data/lib/rails_accessibility_testing/version.rb +4 -0
  52. data/lib/rails_accessibility_testing.rb +83 -0
  53. data/lib/tasks/accessibility.rake +28 -0
  54. metadata +218 -0
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAccessibilityTesting
4
+ module Engine
5
+ # Collects and manages accessibility violations
6
+ #
7
+ # Aggregates violations from multiple checks and provides
8
+ # summary statistics and formatted output.
9
+ #
10
+ # @api private
11
+ class ViolationCollector
12
+ attr_reader :violations
13
+
14
+ def initialize
15
+ @violations = []
16
+ end
17
+
18
+ # Add violations to the collection
19
+ # @param new_violations [Array<Violation>] Violations to add
20
+ def add(new_violations)
21
+ @violations.concat(Array(new_violations))
22
+ end
23
+
24
+ # Reset the collection
25
+ def reset
26
+ @violations = []
27
+ end
28
+
29
+ # Check if any violations exist
30
+ # @return [Boolean]
31
+ def any?
32
+ @violations.any?
33
+ end
34
+
35
+ # Get count of violations
36
+ # @return [Integer]
37
+ def count
38
+ @violations.count
39
+ end
40
+
41
+ # Get violations grouped by rule
42
+ # @return [Hash<String, Array<Violation>>]
43
+ def grouped_by_rule
44
+ @violations.group_by(&:rule_name)
45
+ end
46
+
47
+ # Get summary statistics
48
+ # @return [Hash]
49
+ def summary
50
+ {
51
+ total: count,
52
+ by_rule: grouped_by_rule.transform_values(&:count),
53
+ rules_affected: grouped_by_rule.keys.count
54
+ }
55
+ end
56
+ end
57
+ end
58
+ end
59
+
@@ -0,0 +1,354 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAccessibilityTesting
4
+ # Builds formatted error messages for accessibility issues
5
+ #
6
+ # Formats comprehensive error messages with:
7
+ # - Error type and header
8
+ # - Page context (URL, path, view file)
9
+ # - Element details (tag, id, classes, etc.)
10
+ # - Specific remediation steps
11
+ # - WCAG references
12
+ #
13
+ # @example
14
+ # ErrorMessageBuilder.build(
15
+ # error_type: "Image missing alt attribute",
16
+ # element_context: { tag: "img", src: "logo.png" },
17
+ # page_context: { url: "http://example.com", path: "/" }
18
+ # )
19
+ #
20
+ # @api private
21
+ class ErrorMessageBuilder
22
+ SEPARATOR = '=' * 70
23
+ WCAG_REFERENCE = 'https://www.w3.org/WAI/WCAG21/Understanding/'
24
+
25
+ class << self
26
+ # Build a comprehensive error message
27
+ # @param error_type [String] Type of accessibility error
28
+ # @param element_context [Hash] Context about the element
29
+ # @param page_context [Hash] Context about the page
30
+ # @return [String] Formatted error message
31
+ def build(error_type:, element_context:, page_context:)
32
+ [
33
+ header(error_type),
34
+ page_info(page_context),
35
+ element_info(element_context),
36
+ remediation_section(error_type, element_context),
37
+ footer
38
+ ].compact.join("\n")
39
+ end
40
+
41
+ private
42
+
43
+ def header(error_type)
44
+ "\n#{SEPARATOR}\n❌ ACCESSIBILITY ERROR: #{error_type}\n#{SEPARATOR}\n"
45
+ end
46
+
47
+ def page_info(page_context)
48
+ lines = [
49
+ '📄 Page Being Tested:',
50
+ " URL: #{page_context[:url] || '(unknown)'}",
51
+ " Path: #{page_context[:path] || '(unknown)'}"
52
+ ]
53
+
54
+ if page_context[:view_file]
55
+ lines << " 📝 Likely View File: #{page_context[:view_file]}"
56
+ end
57
+
58
+ lines.join("\n") + "\n"
59
+ end
60
+
61
+ def element_info(element_context)
62
+ lines = ['📍 Element Details:']
63
+ lines << " Tag: <#{element_context[:tag]}>"
64
+ lines << " ID: #{element_context[:id] || '(none)'}"
65
+
66
+ if element_context[:duplicate_ids] && element_context[:duplicate_ids].any?
67
+ lines << " Duplicate IDs: #{element_context[:duplicate_ids].join(', ')}"
68
+ end
69
+
70
+ lines << " Classes: #{element_context[:classes] || '(none)'}"
71
+ lines << " Href: #{element_context[:href] || '(none)'}" if element_context[:href]
72
+ lines << " Src: #{element_context[:src] || '(none)'}" if element_context[:src]
73
+ lines << " Visible text: #{format_text(element_context[:text])}"
74
+
75
+ if element_context[:parent]
76
+ lines << format_parent(element_context[:parent])
77
+ end
78
+
79
+ lines.join("\n") + "\n"
80
+ end
81
+
82
+ def format_text(text)
83
+ text.to_s.empty? ? '(empty)' : text
84
+ end
85
+
86
+ def format_parent(parent)
87
+ parts = [" Parent: <#{parent[:tag]}"]
88
+ parts << " id=\"#{parent[:id]}\"" if parent[:id]
89
+ parts << " class=\"#{parent[:classes]}\"" if parent[:classes]
90
+ parts << '>'
91
+ parts.join
92
+ end
93
+
94
+ def remediation_section(error_type, element_context)
95
+ remediation = generate_remediation(error_type, element_context)
96
+ "🔧 HOW TO FIX:\n#{remediation}\n"
97
+ end
98
+
99
+ def generate_remediation(error_type, element_context)
100
+ # Extract base error type (remove details in parentheses for matching)
101
+ base_error_type = error_type.to_s.split('(').first.strip
102
+
103
+ case base_error_type
104
+ when /Form input missing label/i
105
+ form_input_remediation(element_context)
106
+ when /Image missing alt attribute/i
107
+ image_alt_remediation(element_context)
108
+ when /^(Link|Button) missing accessible name/i
109
+ interactive_element_remediation(error_type, element_context)
110
+ when /Page missing H1 heading/i
111
+ missing_h1_remediation
112
+ when /Heading hierarchy skipped/i
113
+ heading_hierarchy_remediation(error_type, element_context)
114
+ when /Modal dialog has no focusable elements/i
115
+ modal_remediation
116
+ when /Page missing MAIN landmark/i
117
+ missing_main_remediation
118
+ when /Form input error message not associated/i
119
+ form_error_remediation(element_context)
120
+ when /Table missing headers/i
121
+ table_remediation
122
+ when /Custom element/i
123
+ custom_element_remediation(error_type, element_context)
124
+ when /Duplicate IDs found/i
125
+ duplicate_ids_remediation(element_context)
126
+ else
127
+ # Fallback: try to match on key phrases even if format is slightly different
128
+ case error_type.to_s
129
+ when /missing.*label/i
130
+ form_input_remediation(element_context)
131
+ when /missing.*alt/i
132
+ image_alt_remediation(element_context)
133
+ when /missing.*accessible.*name/i
134
+ interactive_element_remediation(error_type, element_context)
135
+ when /missing.*H1/i
136
+ missing_h1_remediation
137
+ when /hierarchy.*skipped/i
138
+ heading_hierarchy_remediation(error_type, element_context)
139
+ when /modal.*focusable/i
140
+ modal_remediation
141
+ when /missing.*MAIN/i
142
+ missing_main_remediation
143
+ when /error.*message.*not.*associated/i
144
+ form_error_remediation(element_context)
145
+ when /table.*missing.*header/i
146
+ table_remediation
147
+ when /custom.*element/i
148
+ custom_element_remediation(error_type, element_context)
149
+ when /duplicate.*id/i
150
+ duplicate_ids_remediation(element_context)
151
+ else
152
+ " Please review the element details above and fix the accessibility issue."
153
+ end
154
+ end
155
+ end
156
+
157
+ def form_input_remediation(element_context)
158
+ id = element_context[:id]
159
+ input_type = element_context[:input_type] || 'text'
160
+
161
+ remediation = " Choose ONE of these solutions:\n\n"
162
+ remediation += " 1. Add a <label> element:\n"
163
+ remediation += " <label for=\"#{id}\">Field Label</label>\n"
164
+ remediation += " <input type=\"#{input_type}\" id=\"#{id}\" name=\"field_name\">\n\n"
165
+ remediation += " 2. Add aria-label attribute:\n"
166
+ remediation += " <input type=\"#{input_type}\" id=\"#{id}\" aria-label=\"Field Label\">\n\n"
167
+ remediation += " 3. Wrap input in label (Rails helper):\n"
168
+ remediation += " <%= label_tag :field_name, 'Field Label' %>\n"
169
+ remediation += " <%= text_field_tag :field_name, nil, id: '#{id}' %>\n\n"
170
+ remediation += " 💡 Best Practice: Use <label> elements when possible.\n"
171
+ remediation += " They provide better UX (clicking label focuses input).\n"
172
+ remediation
173
+ end
174
+
175
+ def image_alt_remediation(element_context)
176
+ src = element_context[:src] || 'image.png'
177
+
178
+ remediation = " Choose ONE of these solutions:\n\n"
179
+ remediation += " 1. Add alt text for informative images:\n"
180
+ remediation += " <img src=\"#{src}\" alt=\"Description of image\">\n\n"
181
+ remediation += " 2. Add empty alt for decorative images:\n"
182
+ remediation += " <img src=\"#{src}\" alt=\"\">\n\n"
183
+ remediation += " 3. Use Rails image_tag helper:\n"
184
+ remediation += " <%= image_tag 'image.png', alt: 'Description' %>\n\n"
185
+ remediation += " 💡 Best Practice: All images must have alt attribute.\n"
186
+ remediation += " Use empty alt=\"\" only for purely decorative images.\n"
187
+ remediation
188
+ end
189
+
190
+ def interactive_element_remediation(error_type, element_context)
191
+ tag = element_context[:tag]
192
+
193
+ remediation = " Choose ONE of these solutions:\n\n"
194
+
195
+ if tag == 'a'
196
+ remediation += " 1. Add visible link text:\n"
197
+ remediation += " <%= link_to 'Descriptive Link Text', path %>\n\n"
198
+ remediation += " 2. Add aria-label (for icon-only links):\n"
199
+ remediation += " <%= link_to path, aria: { label: 'Descriptive action' } do %>\n"
200
+ remediation += " <i class='icon'></i>\n"
201
+ remediation += " <% end %>\n\n"
202
+ remediation += " 3. Add visually hidden text:\n"
203
+ remediation += " <%= link_to path do %>\n"
204
+ remediation += " <i class='icon'></i>\n"
205
+ remediation += " <span class='visually-hidden'>Descriptive action</span>\n"
206
+ remediation += " <% end %>\n\n"
207
+ else
208
+ remediation += " 1. Add visible button text:\n"
209
+ remediation += " <button>Descriptive Button Text</button>\n\n"
210
+ remediation += " 2. Add aria-label (for icon-only buttons):\n"
211
+ remediation += " <button aria-label='Descriptive action'>\n"
212
+ remediation += " <i class='icon'></i>\n"
213
+ remediation += " </button>\n\n"
214
+ end
215
+
216
+ remediation += " 💡 Best Practice: Use visible text when possible.\n"
217
+ remediation += " Use aria-label only for icon-only buttons/links.\n"
218
+ remediation
219
+ end
220
+
221
+ def missing_h1_remediation
222
+ remediation = " Add an <h1> heading to your page:\n\n"
223
+ remediation += " <h1>Main Page Title</h1>\n\n"
224
+ remediation += " Or in Rails ERB:\n"
225
+ remediation += " <h1><%= @page_title || 'Default Title' %></h1>\n\n"
226
+ remediation += " 💡 Best Practice: Every page should have exactly one <h1>.\n"
227
+ remediation += " It should describe the main purpose of the page.\n"
228
+ remediation
229
+ end
230
+
231
+ def heading_hierarchy_remediation(error_type, element_context)
232
+ # Extract heading levels from error_type: "HEADING hierarchy skipped (h1 to h3)"
233
+ match = error_type.match(/h(\d+) to h(\d+)/)
234
+ previous_level = match ? match[1].to_i : 1
235
+ current_level = match ? match[2].to_i : 3
236
+
237
+ remediation = " Fix the heading hierarchy:\n\n"
238
+ remediation += " Current: <h#{previous_level}> ... <h#{current_level}>\n"
239
+ remediation += " Should be: <h#{previous_level}> ... <h#{previous_level + 1}>\n\n"
240
+ remediation += " Example:\n"
241
+ remediation += " <h#{previous_level}>Section Title</h#{previous_level}>\n"
242
+ remediation += " <h#{previous_level + 1}>Subsection Title</h#{previous_level + 1}>\n\n"
243
+ remediation += " 💡 Best Practice: Don't skip heading levels.\n"
244
+ remediation += " Use h1 → h2 → h3 in order.\n"
245
+ remediation
246
+ end
247
+
248
+ def modal_remediation
249
+ remediation = " Add focusable elements to the modal:\n\n"
250
+ remediation += " <div role=\"dialog\">\n"
251
+ remediation += " <button>Close</button>\n"
252
+ remediation += " <!-- Modal content -->\n"
253
+ remediation += " </div>\n\n"
254
+ remediation += " 💡 Best Practice: Modals must have at least one focusable element.\n"
255
+ remediation += " Focus should be trapped within the modal when open.\n"
256
+ remediation
257
+ end
258
+
259
+ def missing_main_remediation
260
+ remediation = " Wrap main content in <main> tag:\n\n"
261
+ remediation += " <main>\n"
262
+ remediation += " <!-- Main page content -->\n"
263
+ remediation += " </main>\n\n"
264
+ remediation += " Or in Rails ERB layout:\n"
265
+ remediation += " <main>\n"
266
+ remediation += " <%= yield %>\n"
267
+ remediation += " </main>\n\n"
268
+ remediation += " 💡 Best Practice: Every page should have one <main> element.\n"
269
+ remediation += " It identifies the primary content area.\n"
270
+ remediation
271
+ end
272
+
273
+ def form_error_remediation(element_context)
274
+ id = element_context[:id]
275
+
276
+ remediation = " Associate error message with input:\n\n"
277
+ remediation += " 1. Use aria-describedby:\n"
278
+ remediation += " <input id=\"#{id}\" aria-describedby=\"#{id}-error\" aria-invalid=\"true\">\n"
279
+ remediation += " <div id=\"#{id}-error\" class=\"error\">Error message</div>\n\n"
280
+ remediation += " 2. Use Rails form helpers with error display:\n"
281
+ remediation += " <%= form_with model: @model do |f| %>\n"
282
+ remediation += " <%= f.label :field %>\n"
283
+ remediation += " <%= f.text_field :field, class: 'form-control', aria: { describedby: \"#{id}-error\" } %>\n"
284
+ remediation += " <%= f.error_message :field, class: 'error', id: \"#{id}-error\" %>\n"
285
+ remediation += " <% end %>\n\n"
286
+ remediation += " 💡 Best Practice: Error messages must be associated with inputs.\n"
287
+ remediation += " Screen readers need to announce errors when they occur.\n"
288
+ remediation
289
+ end
290
+
291
+ def table_remediation
292
+ remediation = " Add table headers:\n\n"
293
+ remediation += " <table>\n"
294
+ remediation += " <thead>\n"
295
+ remediation += " <tr>\n"
296
+ remediation += " <th>Column 1</th>\n"
297
+ remediation += " <th>Column 2</th>\n"
298
+ remediation += " </tr>\n"
299
+ remediation += " </thead>\n"
300
+ remediation += " <tbody>\n"
301
+ remediation += " <tr>\n"
302
+ remediation += " <td>Data 1</td>\n"
303
+ remediation += " <td>Data 2</td>\n"
304
+ remediation += " </tr>\n"
305
+ remediation += " </tbody>\n"
306
+ remediation += " </table>\n\n"
307
+ remediation += " 💡 Best Practice: Tables must have <th> headers.\n"
308
+ remediation += " Use <caption> for table descriptions.\n"
309
+ remediation
310
+ end
311
+
312
+ def custom_element_remediation(error_type, element_context)
313
+ # Extract selector from error_type: "CUSTOM ELEMENT 'trix-editor' missing label"
314
+ match = error_type.match(/CUSTOM ELEMENT '([^']+)'/)
315
+ selector = match ? match[1] : 'custom-element'
316
+ id = element_context[:id]
317
+
318
+ remediation = " Choose ONE of these solutions:\n\n"
319
+ remediation += " 1. Add a <label> element:\n"
320
+ remediation += " <label for=\"#{id}\">#{selector} Label</label>\n"
321
+ remediation += " <#{selector} id=\"#{id}\"></#{selector}>\n\n"
322
+ remediation += " 2. Add aria-label attribute:\n"
323
+ remediation += " <#{selector} id=\"#{id}\" aria-label=\"#{selector} Label\"></#{selector}>\n\n"
324
+ remediation += " 💡 Best Practice: Custom elements need labels just like form inputs.\n"
325
+ remediation
326
+ end
327
+
328
+ def duplicate_ids_remediation(element_context)
329
+ duplicates = element_context[:duplicate_ids] || []
330
+
331
+ remediation = " Ensure each ID is unique on the page:\n\n"
332
+ remediation += " Duplicate IDs found:\n"
333
+ duplicates.each { |id| remediation += " - #{id}\n" }
334
+ remediation += "\n"
335
+ remediation += " <!-- Bad -->\n"
336
+ remediation += " <div id=\"content\">...</div>\n"
337
+ remediation += " <div id=\"content\">...</div>\n\n"
338
+ remediation += " <!-- Good -->\n"
339
+ remediation += " <div id=\"main-content\">...</div>\n"
340
+ remediation += " <div id=\"sidebar-content\">...</div>\n\n"
341
+ remediation += " Or in Rails ERB, use unique IDs:\n"
342
+ remediation += " <div id=\"<%= dom_id(@item) %>\">...</div>\n\n"
343
+ remediation += " 💡 Best Practice: IDs must be unique within a page.\n"
344
+ remediation += " Screen readers and JavaScript rely on unique IDs.\n"
345
+ remediation
346
+ end
347
+
348
+ def footer
349
+ "📖 WCAG Reference: #{WCAG_REFERENCE}\n#{SEPARATOR}"
350
+ end
351
+ end
352
+ end
353
+ end
354
+
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAccessibilityTesting
4
+ module Integration
5
+ # Minitest integration for accessibility testing
6
+ #
7
+ # Provides helpers and automatic checks for Minitest system tests.
8
+ #
9
+ # @example
10
+ # # In test/test_helper.rb
11
+ # require 'rails_accessibility_testing/integration/minitest_integration'
12
+ # RailsAccessibilityTesting::Integration::MinitestIntegration.setup!
13
+ #
14
+ # @example In a system test
15
+ # class HomePageTest < ActionDispatch::SystemTestCase
16
+ # test "home page is accessible" do
17
+ # visit root_path
18
+ # # Accessibility checks run automatically
19
+ # end
20
+ # end
21
+ #
22
+ module MinitestIntegration
23
+ class << self
24
+ # Setup Minitest integration
25
+ # @param config [Hash] Optional configuration
26
+ def setup!(config: {})
27
+ return unless defined?(ActionDispatch::SystemTestCase)
28
+
29
+ include_helpers
30
+ setup_automatic_checks if RailsAccessibilityTesting.config.auto_run_checks
31
+ end
32
+
33
+ private
34
+
35
+ # Include accessibility helpers in system tests
36
+ def include_helpers
37
+ ActionDispatch::SystemTestCase.class_eval do
38
+ include RailsAccessibilityTesting::AccessibilityHelper
39
+ end
40
+ end
41
+
42
+ # Setup automatic checks after each system test
43
+ def setup_automatic_checks
44
+ ActionDispatch::SystemTestCase.class_eval do
45
+ teardown do
46
+ # Skip if test failed or explicitly skipped
47
+ next if failure || skip_a11y?
48
+
49
+ # Skip if page wasn't visited
50
+ begin
51
+ current_path = page.current_path
52
+ next unless current_path
53
+ rescue StandardError
54
+ next
55
+ end
56
+
57
+ # Run comprehensive accessibility checks
58
+ check_comprehensive_accessibility
59
+ rescue StandardError => e
60
+ flunk("Accessibility check failed: #{e.message}")
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ # Helper method to check if a11y should be skipped
67
+ def skip_a11y?
68
+ # Check for skip_a11y metadata or method
69
+ respond_to?(:skip_a11y) && skip_a11y
70
+ end
71
+ end
72
+ end
73
+ end
74
+
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAccessibilityTesting
4
+ # RSpec integration and auto-configuration
5
+ class RSpecIntegration
6
+ class << self
7
+ # Configure RSpec for accessibility testing
8
+ def configure!(config)
9
+ enable_spec_type_inference(config)
10
+ include_matchers(config)
11
+ include_helpers(config)
12
+ setup_automatic_checks(config) if RailsAccessibilityTesting.config.auto_run_checks
13
+ end
14
+
15
+ private
16
+
17
+ # Enable automatic spec type inference from file location
18
+ def enable_spec_type_inference(config)
19
+ # Only call if the method exists (requires rspec-rails to be loaded)
20
+ config.infer_spec_type_from_file_location! if config.respond_to?(:infer_spec_type_from_file_location!)
21
+ end
22
+
23
+ # Include Axe matchers for system specs
24
+ def include_matchers(config)
25
+ config.include Axe::Matchers, type: :system
26
+ end
27
+
28
+ # Include accessibility helpers for system specs
29
+ def include_helpers(config)
30
+ config.include AccessibilityHelper, type: :system
31
+ end
32
+
33
+ # Setup automatic accessibility checks
34
+ def setup_automatic_checks(config)
35
+ config.after(:each, type: :system) do |example|
36
+ # Skip if test failed or explicitly skipped
37
+ next if example.exception
38
+ next if example.metadata[:skip_a11y]
39
+
40
+ # Skip if page wasn't visited
41
+ begin
42
+ current_path = example.example_group_instance.page.current_path
43
+ next unless current_path
44
+ rescue StandardError
45
+ next
46
+ end
47
+
48
+ # Run comprehensive accessibility checks
49
+ instance = example.example_group_instance
50
+ instance.check_comprehensive_accessibility
51
+ rescue StandardError => e
52
+ example.set_exception(e)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+
@@ -0,0 +1,93 @@
1
+ # Shared examples for accessibility testing
2
+ # Automatically available when rails_accessibility_testing is required
3
+
4
+ RSpec.shared_examples "a page with basic accessibility" do
5
+ it "passes automated accessibility checks" do
6
+ expect(page).to be_axe_clean
7
+ end
8
+
9
+ it "has proper form labels" do
10
+ check_form_labels
11
+ end
12
+
13
+ it "has alt text on images" do
14
+ check_image_alt_text
15
+ end
16
+
17
+ it "has accessible names on interactive elements" do
18
+ check_interactive_elements_have_names
19
+ end
20
+
21
+ it "has proper heading hierarchy" do
22
+ check_heading_hierarchy
23
+ end
24
+
25
+ it "has keyboard accessibility" do
26
+ check_keyboard_accessibility
27
+ end
28
+ end
29
+
30
+ RSpec.shared_examples "a page with comprehensive accessibility" do
31
+ include_examples "a page with basic accessibility"
32
+
33
+ it "has proper ARIA landmarks" do
34
+ check_aria_landmarks
35
+ end
36
+
37
+ it "has form error associations" do
38
+ check_form_error_associations
39
+ end
40
+
41
+ it "has proper table structure" do
42
+ check_table_structure
43
+ end
44
+
45
+ it "has no duplicate IDs" do
46
+ check_duplicate_ids
47
+ end
48
+
49
+ it "has skip links" do
50
+ check_skip_links
51
+ end
52
+ end
53
+
54
+ RSpec.shared_examples "an accessible form" do
55
+ it "has all inputs properly labeled" do
56
+ check_form_labels
57
+ end
58
+
59
+ it "has accessible error messages" do
60
+ page.all('.field_with_errors input, .field_with_errors textarea, .field_with_errors select').each do |input|
61
+ id = input[:id]
62
+ next if id.blank?
63
+
64
+ has_error_message = page.has_css?("[aria-describedby*='#{id}'], .field_with_errors label[for='#{id}'] + .error", wait: false)
65
+ unless has_error_message
66
+ warn "Input #{id} has validation errors but error message may not be properly associated"
67
+ end
68
+ end
69
+ end
70
+
71
+ it "passes automated accessibility checks" do
72
+ expect(page).to be_axe_clean
73
+ end
74
+ end
75
+
76
+ RSpec.shared_examples "an accessible navigation" do
77
+ it "has proper ARIA landmarks" do
78
+ navs = page.all('nav, [role="navigation"]', visible: true)
79
+ expect(navs.length).to be > 0
80
+ end
81
+
82
+ it "has accessible skip links" do
83
+ skip_link = page.find('a[href="#main"], a.skip-link, a[href*="main-content"]', visible: false, match: :first, wait: false) rescue nil
84
+ if skip_link.nil?
85
+ warn "Consider adding a 'skip to main content' link for keyboard users"
86
+ end
87
+ end
88
+
89
+ it "passes automated accessibility checks" do
90
+ expect(page).to be_axe_clean
91
+ end
92
+ end
93
+
@@ -0,0 +1,4 @@
1
+ module RailsAccessibilityTesting
2
+ VERSION = "1.1.0"
3
+ end
4
+