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,701 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Accessibility helper methods for system specs
|
|
4
|
+
#
|
|
5
|
+
# Provides comprehensive accessibility checks with detailed error messages.
|
|
6
|
+
# This module is automatically included in all system specs when the gem is required.
|
|
7
|
+
#
|
|
8
|
+
# @example Using in a system spec
|
|
9
|
+
# it 'has no accessibility issues' do
|
|
10
|
+
# visit root_path
|
|
11
|
+
# check_comprehensive_accessibility
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# @example Individual checks
|
|
15
|
+
# check_image_alt_text
|
|
16
|
+
# check_form_labels
|
|
17
|
+
# check_interactive_elements_have_names
|
|
18
|
+
#
|
|
19
|
+
# @see RailsAccessibilityTesting::ErrorMessageBuilder For error message formatting
|
|
20
|
+
module AccessibilityHelper
|
|
21
|
+
# Get current page context for error messages
|
|
22
|
+
# @param element_context [Hash] Optional element context to help determine exact view file
|
|
23
|
+
# @return [Hash] Page context with url, path, and view_file
|
|
24
|
+
def get_page_context(element_context = nil)
|
|
25
|
+
{
|
|
26
|
+
url: safe_page_url,
|
|
27
|
+
path: safe_page_path,
|
|
28
|
+
view_file: determine_view_file(safe_page_path, safe_page_url, element_context)
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Get element context for error messages
|
|
33
|
+
# @param element [Capybara::Node::Element] The element to get context for
|
|
34
|
+
# @return [Hash] Element context with tag, id, classes, etc.
|
|
35
|
+
def get_element_context(element)
|
|
36
|
+
{
|
|
37
|
+
tag: element.tag_name,
|
|
38
|
+
id: element[:id],
|
|
39
|
+
classes: element[:class],
|
|
40
|
+
href: element[:href],
|
|
41
|
+
src: element[:src],
|
|
42
|
+
text: element.text.strip,
|
|
43
|
+
parent: safe_parent_info(element)
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Safely get page URL
|
|
48
|
+
def safe_page_url
|
|
49
|
+
page.current_url
|
|
50
|
+
rescue StandardError
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Safely get page path
|
|
55
|
+
def safe_page_path
|
|
56
|
+
page.current_path
|
|
57
|
+
rescue StandardError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Safely get parent element info
|
|
62
|
+
def safe_parent_info(element)
|
|
63
|
+
parent = element.find(:xpath, '..')
|
|
64
|
+
{
|
|
65
|
+
tag: parent.tag_name,
|
|
66
|
+
id: parent[:id],
|
|
67
|
+
classes: parent[:class]
|
|
68
|
+
}
|
|
69
|
+
rescue StandardError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Basic accessibility check - runs 5 basic checks
|
|
74
|
+
def check_basic_accessibility
|
|
75
|
+
@accessibility_errors ||= []
|
|
76
|
+
|
|
77
|
+
check_form_labels
|
|
78
|
+
check_image_alt_text
|
|
79
|
+
check_interactive_elements_have_names
|
|
80
|
+
check_heading_hierarchy
|
|
81
|
+
check_keyboard_accessibility
|
|
82
|
+
|
|
83
|
+
# If we collected any errors and this was called directly (not from comprehensive), raise them
|
|
84
|
+
if @accessibility_errors.any? && !@in_comprehensive_check
|
|
85
|
+
raise format_all_errors(@accessibility_errors)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Full comprehensive check - runs all 11 checks including advanced
|
|
90
|
+
def check_comprehensive_accessibility
|
|
91
|
+
@accessibility_errors = []
|
|
92
|
+
@in_comprehensive_check = true
|
|
93
|
+
|
|
94
|
+
check_basic_accessibility
|
|
95
|
+
check_aria_landmarks
|
|
96
|
+
check_form_error_associations
|
|
97
|
+
check_table_structure
|
|
98
|
+
check_duplicate_ids
|
|
99
|
+
check_skip_links # Warning only, not error
|
|
100
|
+
|
|
101
|
+
@in_comprehensive_check = false
|
|
102
|
+
|
|
103
|
+
# If we collected any errors, raise them all together
|
|
104
|
+
if @accessibility_errors.any?
|
|
105
|
+
raise format_all_errors(@accessibility_errors)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
# Collect an error instead of raising immediately
|
|
112
|
+
def collect_error(error_type, element_context, page_context)
|
|
113
|
+
error_message = build_error_message(error_type, element_context, page_context)
|
|
114
|
+
@accessibility_errors << {
|
|
115
|
+
error_type: error_type,
|
|
116
|
+
element_context: element_context,
|
|
117
|
+
page_context: page_context,
|
|
118
|
+
message: error_message
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Format all collected errors with summary at top and details at bottom
|
|
123
|
+
def format_all_errors(errors)
|
|
124
|
+
return "" if errors.empty?
|
|
125
|
+
|
|
126
|
+
output = []
|
|
127
|
+
output << "\n" + "="*70
|
|
128
|
+
output << "❌ ACCESSIBILITY ERRORS FOUND: #{errors.length} issue(s)"
|
|
129
|
+
output << "="*70
|
|
130
|
+
output << ""
|
|
131
|
+
output << "📋 SUMMARY OF ISSUES:"
|
|
132
|
+
output << ""
|
|
133
|
+
|
|
134
|
+
# Summary list at top
|
|
135
|
+
errors.each_with_index do |error, index|
|
|
136
|
+
error_type = error[:error_type]
|
|
137
|
+
page_context = error[:page_context]
|
|
138
|
+
element_context = error[:element_context]
|
|
139
|
+
|
|
140
|
+
# Build summary line
|
|
141
|
+
summary = " #{index + 1}. #{error_type}"
|
|
142
|
+
|
|
143
|
+
# Add location info
|
|
144
|
+
if page_context[:view_file]
|
|
145
|
+
summary += " (#{page_context[:view_file]})"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Add element identifier if available
|
|
149
|
+
if element_context[:id].present?
|
|
150
|
+
summary += " [id: #{element_context[:id]}]"
|
|
151
|
+
elsif element_context[:href].present?
|
|
152
|
+
href_display = element_context[:href].length > 40 ? "#{element_context[:href][0..37]}..." : element_context[:href]
|
|
153
|
+
summary += " [href: #{href_display}]"
|
|
154
|
+
elsif element_context[:src].present?
|
|
155
|
+
src_display = element_context[:src].length > 40 ? "#{element_context[:src][0..37]}..." : element_context[:src]
|
|
156
|
+
summary += " [src: #{src_display}]"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
output << summary
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
output << ""
|
|
163
|
+
output << "="*70
|
|
164
|
+
output << "📝 DETAILED ERROR DESCRIPTIONS:"
|
|
165
|
+
output << "="*70
|
|
166
|
+
output << ""
|
|
167
|
+
|
|
168
|
+
# Detailed descriptions at bottom
|
|
169
|
+
errors.each_with_index do |error, index|
|
|
170
|
+
output << "\n" + "-"*70
|
|
171
|
+
output << "ERROR #{index + 1} of #{errors.length}:"
|
|
172
|
+
output << "-"*70
|
|
173
|
+
output << error[:message]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
output << ""
|
|
177
|
+
output << "="*70
|
|
178
|
+
output << "💡 Fix all issues above, then re-run the accessibility checks"
|
|
179
|
+
output << "="*70
|
|
180
|
+
|
|
181
|
+
output.join("\n")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Build comprehensive error message
|
|
185
|
+
# @param error_type [String] Type of accessibility error
|
|
186
|
+
# @param element_context [Hash] Context about the element with the issue
|
|
187
|
+
# @param page_context [Hash] Context about the page being tested
|
|
188
|
+
# @return [String] Formatted error message
|
|
189
|
+
def build_error_message(error_type, element_context, page_context)
|
|
190
|
+
RailsAccessibilityTesting::ErrorMessageBuilder.build(
|
|
191
|
+
error_type: error_type,
|
|
192
|
+
element_context: element_context,
|
|
193
|
+
page_context: page_context
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Build specific error title for interactive elements (links/buttons)
|
|
198
|
+
# @param tag [String] HTML tag name (a, button, etc.)
|
|
199
|
+
# @param element_context [Hash] Context about the element
|
|
200
|
+
# @return [String] Specific error title
|
|
201
|
+
def build_interactive_element_error_title(tag, element_context)
|
|
202
|
+
# Use semantic terms instead of tag names
|
|
203
|
+
semantic_name = case tag
|
|
204
|
+
when 'a'
|
|
205
|
+
'link'
|
|
206
|
+
when 'button'
|
|
207
|
+
'button'
|
|
208
|
+
else
|
|
209
|
+
"#{tag} element"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
base_title = "#{semantic_name.capitalize} missing accessible name"
|
|
213
|
+
|
|
214
|
+
# Add specific identifying information
|
|
215
|
+
details = []
|
|
216
|
+
|
|
217
|
+
if tag == 'a' && element_context[:href].present?
|
|
218
|
+
# For links, include the href
|
|
219
|
+
href = element_context[:href]
|
|
220
|
+
# Truncate long URLs for readability
|
|
221
|
+
href_display = href.length > 50 ? "#{href[0..47]}..." : href
|
|
222
|
+
details << "href: #{href_display}"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
if element_context[:id].present?
|
|
226
|
+
details << "id: #{element_context[:id]}"
|
|
227
|
+
elsif element_context[:classes].present?
|
|
228
|
+
# Use first class if no ID
|
|
229
|
+
first_class = element_context[:classes].split(' ').first
|
|
230
|
+
details << "class: #{first_class}" if first_class
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
if details.any?
|
|
234
|
+
"#{base_title} (#{details.join(', ')})"
|
|
235
|
+
else
|
|
236
|
+
base_title
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Determine likely view file based on Rails path and element context
|
|
241
|
+
def determine_view_file(path, url, element_context = nil)
|
|
242
|
+
return nil unless path
|
|
243
|
+
|
|
244
|
+
clean_path = path.split('?').first.split('#').first
|
|
245
|
+
|
|
246
|
+
# Get route info from Rails (most accurate)
|
|
247
|
+
if defined?(Rails) && Rails.application
|
|
248
|
+
begin
|
|
249
|
+
route = Rails.application.routes.recognize_path(clean_path)
|
|
250
|
+
controller = route[:controller]
|
|
251
|
+
action = route[:action]
|
|
252
|
+
|
|
253
|
+
# Try to find the exact view file
|
|
254
|
+
view_file = find_view_file_for_controller_action(controller, action)
|
|
255
|
+
|
|
256
|
+
# If element might be in a partial or layout, check those too
|
|
257
|
+
if element_context
|
|
258
|
+
# Check if element is likely in a layout (navbar, footer, etc.)
|
|
259
|
+
if element_in_layout?(element_context)
|
|
260
|
+
layout_file = find_layout_file
|
|
261
|
+
return layout_file if layout_file
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Check if element is in a partial based on context
|
|
265
|
+
partial_file = find_partial_for_element(controller, element_context)
|
|
266
|
+
return partial_file if partial_file
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
return view_file if view_file
|
|
270
|
+
rescue StandardError => e
|
|
271
|
+
# Fall through to path-based detection
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Fallback: path-based detection
|
|
276
|
+
if clean_path.match?(/\A\//)
|
|
277
|
+
parts = clean_path.sub(/\A\//, '').split('/').reject(&:empty?)
|
|
278
|
+
|
|
279
|
+
if parts.empty? || clean_path == '/'
|
|
280
|
+
# Root path - try common locations
|
|
281
|
+
return find_view_file_for_controller_action('home', 'about') ||
|
|
282
|
+
find_view_file_for_controller_action('home', 'index') ||
|
|
283
|
+
find_view_file_for_controller_action('pages', 'home')
|
|
284
|
+
elsif parts.length >= 2
|
|
285
|
+
controller = parts[0..-2].join('/')
|
|
286
|
+
action = parts.last
|
|
287
|
+
return find_view_file_for_controller_action(controller, action)
|
|
288
|
+
elsif parts.length == 1
|
|
289
|
+
return find_view_file_for_controller_action(parts[0], 'index')
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
nil
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Find view file for controller and action
|
|
297
|
+
def find_view_file_for_controller_action(controller, action)
|
|
298
|
+
extensions = %w[erb haml slim]
|
|
299
|
+
extensions.each do |ext|
|
|
300
|
+
view_paths = [
|
|
301
|
+
"app/views/#{controller}/#{action}.html.#{ext}",
|
|
302
|
+
"app/views/#{controller}/_#{action}.html.#{ext}",
|
|
303
|
+
"app/views/#{controller}/#{action}.#{ext}"
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
found = view_paths.find { |vp| File.exist?(vp) }
|
|
307
|
+
return found if found
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
nil
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Check if element is likely in a layout (navbar, footer, etc.)
|
|
314
|
+
def element_in_layout?(element_context)
|
|
315
|
+
return false unless element_context
|
|
316
|
+
|
|
317
|
+
# Check parent context for layout indicators
|
|
318
|
+
parent = element_context[:parent]
|
|
319
|
+
return false unless parent
|
|
320
|
+
|
|
321
|
+
# Common layout class/id patterns
|
|
322
|
+
layout_indicators = ['navbar', 'nav', 'footer', 'header', 'main-nav', 'sidebar']
|
|
323
|
+
|
|
324
|
+
classes = parent[:classes].to_s.downcase
|
|
325
|
+
id = parent[:id].to_s.downcase
|
|
326
|
+
|
|
327
|
+
layout_indicators.any? { |indicator| classes.include?(indicator) || id.include?(indicator) }
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Find layout file
|
|
331
|
+
def find_layout_file
|
|
332
|
+
extensions = %w[erb haml slim]
|
|
333
|
+
|
|
334
|
+
# Check common layout files
|
|
335
|
+
layout_names = ['application', 'main', 'default']
|
|
336
|
+
|
|
337
|
+
layout_names.each do |layout_name|
|
|
338
|
+
extensions.each do |ext|
|
|
339
|
+
layout_path = "app/views/layouts/#{layout_name}.html.#{ext}"
|
|
340
|
+
return layout_path if File.exist?(layout_path)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Check for any layout file
|
|
345
|
+
extensions.each do |ext|
|
|
346
|
+
layout_files = Dir.glob("app/views/layouts/*.html.#{ext}")
|
|
347
|
+
return layout_files.first if layout_files.any?
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
nil
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Find partial file that might contain the element
|
|
354
|
+
def find_partial_for_element(controller, element_context)
|
|
355
|
+
return nil unless element_context
|
|
356
|
+
|
|
357
|
+
extensions = %w[erb haml slim]
|
|
358
|
+
|
|
359
|
+
# Check for common partial names based on element context
|
|
360
|
+
id = element_context[:id].to_s
|
|
361
|
+
classes = element_context[:classes].to_s
|
|
362
|
+
|
|
363
|
+
# Try to match partial names from element attributes
|
|
364
|
+
partial_names = []
|
|
365
|
+
|
|
366
|
+
# Extract potential partial names from IDs/classes
|
|
367
|
+
if id.present?
|
|
368
|
+
# e.g., "navbar" from id="navbar" or class="navbar"
|
|
369
|
+
partial_names << id.split('-').first
|
|
370
|
+
partial_names << id.split('_').first
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
if classes.present?
|
|
374
|
+
classes.split(/\s+/).each do |cls|
|
|
375
|
+
partial_names << cls.split('-').first
|
|
376
|
+
partial_names << cls.split('_').first
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Check partials in controller directory
|
|
381
|
+
partial_names.uniq.each do |partial_name|
|
|
382
|
+
next if partial_name.blank?
|
|
383
|
+
|
|
384
|
+
extensions.each do |ext|
|
|
385
|
+
partial_paths = [
|
|
386
|
+
"app/views/#{controller}/_#{partial_name}.html.#{ext}",
|
|
387
|
+
"app/views/shared/_#{partial_name}.html.#{ext}",
|
|
388
|
+
"app/views/layouts/_#{partial_name}.html.#{ext}"
|
|
389
|
+
]
|
|
390
|
+
|
|
391
|
+
found = partial_paths.find { |pp| File.exist?(pp) }
|
|
392
|
+
return found if found
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
nil
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Check that form inputs have associated labels
|
|
400
|
+
def check_form_labels
|
|
401
|
+
page_context = get_page_context
|
|
402
|
+
|
|
403
|
+
page.all('input[type="text"], input[type="email"], input[type="password"], input[type="number"], input[type="tel"], input[type="url"], input[type="search"], input[type="date"], input[type="time"], input[type="datetime-local"], textarea, select').each do |input|
|
|
404
|
+
id = input[:id]
|
|
405
|
+
next if id.blank?
|
|
406
|
+
|
|
407
|
+
has_label = page.has_css?("label[for='#{id}']", wait: false)
|
|
408
|
+
aria_label = input[:"aria-label"].present?
|
|
409
|
+
aria_labelledby = input[:"aria-labelledby"].present?
|
|
410
|
+
|
|
411
|
+
unless has_label || aria_label || aria_labelledby
|
|
412
|
+
element_context = get_element_context(input)
|
|
413
|
+
element_context[:input_type] = input[:type] || input.tag_name
|
|
414
|
+
# Get page context with element context to find exact view file
|
|
415
|
+
page_context = get_page_context(element_context)
|
|
416
|
+
|
|
417
|
+
collect_error(
|
|
418
|
+
"Form input missing label",
|
|
419
|
+
element_context,
|
|
420
|
+
page_context
|
|
421
|
+
)
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Check that images have alt text
|
|
427
|
+
def check_image_alt_text
|
|
428
|
+
# Check all images, including hidden ones
|
|
429
|
+
# Rails' image_tag helper generates <img> tags, so this will catch all images
|
|
430
|
+
page.all('img', visible: :all).each do |img|
|
|
431
|
+
# Check if alt attribute exists in the HTML
|
|
432
|
+
# Use JavaScript hasAttribute which is the most reliable way to check
|
|
433
|
+
has_alt_attribute = page.evaluate_script("arguments[0].hasAttribute('alt')", img.native)
|
|
434
|
+
|
|
435
|
+
# If alt attribute doesn't exist, that's an error
|
|
436
|
+
# If it exists but is empty (alt=""), that's valid for decorative images
|
|
437
|
+
if has_alt_attribute == false
|
|
438
|
+
element_context = get_element_context(img)
|
|
439
|
+
# Get page context with element context to find exact view file
|
|
440
|
+
page_context = get_page_context(element_context)
|
|
441
|
+
|
|
442
|
+
collect_error(
|
|
443
|
+
"Image missing alt attribute",
|
|
444
|
+
element_context,
|
|
445
|
+
page_context
|
|
446
|
+
)
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Check that buttons and links have accessible names
|
|
452
|
+
def check_interactive_elements_have_names
|
|
453
|
+
page.all('button, a[href], [role="button"], [role="link"]').each do |element|
|
|
454
|
+
next unless element.visible?
|
|
455
|
+
|
|
456
|
+
text = element.text.strip
|
|
457
|
+
aria_label = element[:"aria-label"]
|
|
458
|
+
aria_labelledby = element[:"aria-labelledby"]
|
|
459
|
+
title = element[:title]
|
|
460
|
+
|
|
461
|
+
if text.blank? && aria_label.blank? && aria_labelledby.blank? && title.blank?
|
|
462
|
+
element_context = get_element_context(element)
|
|
463
|
+
tag = element.tag_name
|
|
464
|
+
|
|
465
|
+
# Build more specific error title
|
|
466
|
+
error_title = build_interactive_element_error_title(tag, element_context)
|
|
467
|
+
# Get page context with element context to find exact view file
|
|
468
|
+
page_context = get_page_context(element_context)
|
|
469
|
+
|
|
470
|
+
collect_error(
|
|
471
|
+
error_title,
|
|
472
|
+
element_context,
|
|
473
|
+
page_context
|
|
474
|
+
)
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Check for proper heading hierarchy
|
|
480
|
+
def check_heading_hierarchy
|
|
481
|
+
page_context = get_page_context
|
|
482
|
+
headings = page.all('h1, h2, h3, h4, h5, h6', visible: true)
|
|
483
|
+
|
|
484
|
+
if headings.empty?
|
|
485
|
+
warn "⚠️ Page has no visible headings - consider adding at least an h1"
|
|
486
|
+
return
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
h1_count = headings.count { |h| h.tag_name == 'h1' }
|
|
490
|
+
if h1_count == 0
|
|
491
|
+
# Create element context for page-level issue
|
|
492
|
+
element_context = {
|
|
493
|
+
tag: 'page',
|
|
494
|
+
id: nil,
|
|
495
|
+
classes: nil,
|
|
496
|
+
href: nil,
|
|
497
|
+
src: nil,
|
|
498
|
+
text: 'Page has no H1 heading',
|
|
499
|
+
parent: nil
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
collect_error(
|
|
503
|
+
"Page missing H1 heading",
|
|
504
|
+
element_context,
|
|
505
|
+
page_context
|
|
506
|
+
)
|
|
507
|
+
elsif h1_count > 1
|
|
508
|
+
warn "⚠️ Page has multiple h1 headings (#{h1_count}) - consider using only one"
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
previous_level = 0
|
|
512
|
+
headings.each do |heading|
|
|
513
|
+
current_level = heading.tag_name[1].to_i
|
|
514
|
+
if current_level > previous_level + 1
|
|
515
|
+
element_context = get_element_context(heading)
|
|
516
|
+
# Get page context with element context to find exact view file
|
|
517
|
+
page_context = get_page_context(element_context)
|
|
518
|
+
|
|
519
|
+
collect_error(
|
|
520
|
+
"Heading hierarchy skipped (h#{previous_level} to h#{current_level})",
|
|
521
|
+
element_context,
|
|
522
|
+
page_context
|
|
523
|
+
)
|
|
524
|
+
end
|
|
525
|
+
previous_level = current_level
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# Check that focusable elements are keyboard accessible
|
|
530
|
+
def check_keyboard_accessibility
|
|
531
|
+
page_context = get_page_context
|
|
532
|
+
modals = page.all('[role="dialog"], [role="alertdialog"]', visible: true)
|
|
533
|
+
|
|
534
|
+
modals.each do |modal|
|
|
535
|
+
focusable = modal.all('button, a, input, textarea, select, [tabindex]:not([tabindex="-1"])', visible: true)
|
|
536
|
+
if focusable.empty?
|
|
537
|
+
element_context = get_element_context(modal)
|
|
538
|
+
# Get page context with element context to find exact view file
|
|
539
|
+
page_context = get_page_context(element_context)
|
|
540
|
+
|
|
541
|
+
collect_error(
|
|
542
|
+
"Modal dialog has no focusable elements",
|
|
543
|
+
element_context,
|
|
544
|
+
page_context
|
|
545
|
+
)
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# Check for proper ARIA landmarks
|
|
551
|
+
def check_aria_landmarks
|
|
552
|
+
page_context = get_page_context
|
|
553
|
+
landmarks = page.all('main, nav, [role="main"], [role="navigation"], [role="banner"], [role="contentinfo"], [role="complementary"], [role="search"]', visible: true)
|
|
554
|
+
|
|
555
|
+
if landmarks.empty?
|
|
556
|
+
warn "⚠️ Page has no ARIA landmarks - consider adding <main> and <nav> elements"
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Check for main landmark
|
|
560
|
+
main_landmarks = page.all('main, [role="main"]', visible: true)
|
|
561
|
+
if main_landmarks.empty?
|
|
562
|
+
# Create element context for page-level issue
|
|
563
|
+
element_context = {
|
|
564
|
+
tag: 'page',
|
|
565
|
+
id: nil,
|
|
566
|
+
classes: nil,
|
|
567
|
+
href: nil,
|
|
568
|
+
src: nil,
|
|
569
|
+
text: 'Page has no MAIN landmark',
|
|
570
|
+
parent: nil
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
collect_error(
|
|
574
|
+
"Page missing MAIN landmark",
|
|
575
|
+
element_context,
|
|
576
|
+
page_context
|
|
577
|
+
)
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# Check for skip links
|
|
582
|
+
def check_skip_links
|
|
583
|
+
page_context = get_page_context
|
|
584
|
+
skip_links = page.all('a[href="#main"], a[href*="main-content"], a.skip-link, a[href^="#content"]', visible: false)
|
|
585
|
+
|
|
586
|
+
if skip_links.empty?
|
|
587
|
+
warn "⚠️ Page missing skip link - consider adding 'skip to main content' link"
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# Check form error messages are associated
|
|
592
|
+
def check_form_error_associations
|
|
593
|
+
page_context = get_page_context
|
|
594
|
+
|
|
595
|
+
page.all('.field_with_errors input, .field_with_errors textarea, .field_with_errors select, .is-invalid, [aria-invalid="true"]').each do |input|
|
|
596
|
+
id = input[:id]
|
|
597
|
+
next if id.blank?
|
|
598
|
+
|
|
599
|
+
has_error_message = page.has_css?("[aria-describedby*='#{id}'], .field_with_errors label[for='#{id}'] + .error, .field_with_errors label[for='#{id}'] + .invalid-feedback", wait: false)
|
|
600
|
+
|
|
601
|
+
unless has_error_message
|
|
602
|
+
element_context = get_element_context(input)
|
|
603
|
+
# Get page context with element context to find exact view file
|
|
604
|
+
page_context = get_page_context(element_context)
|
|
605
|
+
|
|
606
|
+
collect_error(
|
|
607
|
+
"Form input error message not associated",
|
|
608
|
+
element_context,
|
|
609
|
+
page_context
|
|
610
|
+
)
|
|
611
|
+
end
|
|
612
|
+
end
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# Check for proper table structure
|
|
616
|
+
def check_table_structure
|
|
617
|
+
page_context = get_page_context
|
|
618
|
+
|
|
619
|
+
page.all('table').each do |table|
|
|
620
|
+
has_headers = table.all('th').any?
|
|
621
|
+
has_caption = table.all('caption').any?
|
|
622
|
+
|
|
623
|
+
if !has_headers
|
|
624
|
+
element_context = get_element_context(table)
|
|
625
|
+
# Get page context with element context to find exact view file
|
|
626
|
+
page_context = get_page_context(element_context)
|
|
627
|
+
|
|
628
|
+
collect_error(
|
|
629
|
+
"Table missing headers",
|
|
630
|
+
element_context,
|
|
631
|
+
page_context
|
|
632
|
+
)
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
# Check custom elements (like trix-editor, web components) have proper labels
|
|
638
|
+
def check_custom_element_labels(selector)
|
|
639
|
+
page_context = get_page_context
|
|
640
|
+
|
|
641
|
+
page.all(selector).each do |element|
|
|
642
|
+
id = element[:id]
|
|
643
|
+
next if id.blank?
|
|
644
|
+
|
|
645
|
+
has_label = page.has_css?("label[for='#{id}']", wait: false)
|
|
646
|
+
aria_label = element[:"aria-label"].present?
|
|
647
|
+
aria_labelledby = element[:"aria-labelledby"].present?
|
|
648
|
+
|
|
649
|
+
unless has_label || aria_label || aria_labelledby
|
|
650
|
+
element_context = get_element_context(element)
|
|
651
|
+
# Get page context with element context to find exact view file
|
|
652
|
+
page_context = get_page_context(element_context)
|
|
653
|
+
|
|
654
|
+
collect_error(
|
|
655
|
+
"Custom element '#{selector}' missing label",
|
|
656
|
+
element_context,
|
|
657
|
+
page_context
|
|
658
|
+
)
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
# Check for duplicate IDs
|
|
664
|
+
def check_duplicate_ids
|
|
665
|
+
page_context = get_page_context
|
|
666
|
+
all_ids = page.all('[id]').map { |el| el[:id] }.compact
|
|
667
|
+
duplicates = all_ids.group_by(&:itself).select { |k, v| v.length > 1 }.keys
|
|
668
|
+
|
|
669
|
+
if duplicates.any?
|
|
670
|
+
# Get first occurrence of first duplicate ID for element context
|
|
671
|
+
first_duplicate_id = duplicates.first
|
|
672
|
+
first_element = page.first("[id='#{first_duplicate_id}']", wait: false)
|
|
673
|
+
|
|
674
|
+
element_context = if first_element
|
|
675
|
+
ctx = get_element_context(first_element)
|
|
676
|
+
ctx[:duplicate_ids] = duplicates
|
|
677
|
+
ctx
|
|
678
|
+
else
|
|
679
|
+
{
|
|
680
|
+
tag: 'multiple',
|
|
681
|
+
id: first_duplicate_id,
|
|
682
|
+
classes: nil,
|
|
683
|
+
href: nil,
|
|
684
|
+
src: nil,
|
|
685
|
+
text: "Found #{duplicates.length} duplicate ID(s)",
|
|
686
|
+
parent: nil,
|
|
687
|
+
duplicate_ids: duplicates
|
|
688
|
+
}
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# Get page context with element context to find exact view file
|
|
692
|
+
page_context = get_page_context(element_context)
|
|
693
|
+
|
|
694
|
+
collect_error(
|
|
695
|
+
"Duplicate IDs found",
|
|
696
|
+
element_context,
|
|
697
|
+
page_context
|
|
698
|
+
)
|
|
699
|
+
end
|
|
700
|
+
end
|
|
701
|
+
end
|