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.
- checksums.yaml +7 -0
- data/ARCHITECTURE.md +307 -0
- data/CHANGELOG.md +81 -0
- data/CODE_OF_CONDUCT.md +125 -0
- data/CONTRIBUTING.md +225 -0
- data/GUIDES/continuous_integration.md +326 -0
- data/GUIDES/getting_started.md +205 -0
- data/GUIDES/working_with_designers_and_content_authors.md +398 -0
- data/GUIDES/writing_accessible_views_in_rails.md +412 -0
- data/LICENSE +22 -0
- data/README.md +350 -0
- data/docs_site/404.html +11 -0
- data/docs_site/Gemfile +11 -0
- data/docs_site/Makefile +14 -0
- data/docs_site/_config.yml +41 -0
- data/docs_site/_includes/header.html +13 -0
- data/docs_site/_layouts/default.html +130 -0
- data/docs_site/assets/main.scss +4 -0
- data/docs_site/ci_integration.md +76 -0
- data/docs_site/configuration.md +114 -0
- data/docs_site/contributing.md +69 -0
- data/docs_site/getting_started.md +57 -0
- data/docs_site/index.md +57 -0
- data/exe/rails_a11y +12 -0
- data/exe/rails_server_safe +41 -0
- data/lib/generators/rails_a11y/install/generator.rb +51 -0
- data/lib/rails_accessibility_testing/accessibility_helper.rb +701 -0
- data/lib/rails_accessibility_testing/change_detector.rb +114 -0
- data/lib/rails_accessibility_testing/checks/aria_landmarks_check.rb +33 -0
- data/lib/rails_accessibility_testing/checks/base_check.rb +156 -0
- data/lib/rails_accessibility_testing/checks/color_contrast_check.rb +56 -0
- data/lib/rails_accessibility_testing/checks/duplicate_ids_check.rb +49 -0
- data/lib/rails_accessibility_testing/checks/form_errors_check.rb +40 -0
- data/lib/rails_accessibility_testing/checks/form_labels_check.rb +62 -0
- data/lib/rails_accessibility_testing/checks/heading_hierarchy_check.rb +53 -0
- data/lib/rails_accessibility_testing/checks/image_alt_text_check.rb +52 -0
- data/lib/rails_accessibility_testing/checks/interactive_elements_check.rb +66 -0
- data/lib/rails_accessibility_testing/checks/keyboard_accessibility_check.rb +36 -0
- data/lib/rails_accessibility_testing/checks/skip_links_check.rb +24 -0
- data/lib/rails_accessibility_testing/checks/table_structure_check.rb +36 -0
- data/lib/rails_accessibility_testing/cli/command.rb +259 -0
- data/lib/rails_accessibility_testing/config/yaml_loader.rb +131 -0
- data/lib/rails_accessibility_testing/configuration.rb +30 -0
- data/lib/rails_accessibility_testing/engine/rule_engine.rb +97 -0
- data/lib/rails_accessibility_testing/engine/violation.rb +58 -0
- data/lib/rails_accessibility_testing/engine/violation_collector.rb +59 -0
- data/lib/rails_accessibility_testing/error_message_builder.rb +354 -0
- data/lib/rails_accessibility_testing/integration/minitest_integration.rb +74 -0
- data/lib/rails_accessibility_testing/rspec_integration.rb +58 -0
- data/lib/rails_accessibility_testing/shared_examples.rb +93 -0
- data/lib/rails_accessibility_testing/version.rb +4 -0
- data/lib/rails_accessibility_testing.rb +83 -0
- data/lib/tasks/accessibility.rake +28 -0
- 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
|
+
|