rails_accessibility_testing 1.4.3 → 1.5.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 +4 -4
- data/ARCHITECTURE.md +212 -53
- data/CHANGELOG.md +118 -0
- data/GUIDES/getting_started.md +105 -77
- data/GUIDES/system_specs_for_accessibility.md +13 -12
- data/README.md +136 -36
- data/docs_site/getting_started.md +59 -69
- data/exe/a11y_live_scanner +361 -0
- data/exe/rails_server_safe +18 -1
- data/lib/generators/rails_a11y/install/install_generator.rb +137 -0
- data/lib/rails_accessibility_testing/accessibility_helper.rb +547 -24
- data/lib/rails_accessibility_testing/change_detector.rb +17 -104
- data/lib/rails_accessibility_testing/checks/base_check.rb +56 -7
- data/lib/rails_accessibility_testing/checks/heading_check.rb +138 -0
- data/lib/rails_accessibility_testing/checks/image_alt_text_check.rb +7 -7
- data/lib/rails_accessibility_testing/checks/interactive_elements_check.rb +11 -1
- data/lib/rails_accessibility_testing/cli/command.rb +3 -1
- data/lib/rails_accessibility_testing/config/yaml_loader.rb +1 -1
- data/lib/rails_accessibility_testing/engine/rule_engine.rb +49 -5
- data/lib/rails_accessibility_testing/error_message_builder.rb +63 -7
- data/lib/rails_accessibility_testing/middleware/page_visit_logger.rb +81 -0
- data/lib/rails_accessibility_testing/railtie.rb +22 -0
- data/lib/rails_accessibility_testing/rspec_integration.rb +176 -10
- data/lib/rails_accessibility_testing/version.rb +1 -1
- data/lib/rails_accessibility_testing.rb +8 -3
- metadata +7 -3
- data/lib/generators/rails_a11y/install/generator.rb +0 -51
- data/lib/rails_accessibility_testing/checks/heading_hierarchy_check.rb +0 -53
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
# Provides comprehensive accessibility checks with detailed error messages.
|
|
6
6
|
# This module is automatically included in all system specs when the gem is required.
|
|
7
7
|
#
|
|
8
|
+
# NOTE: This helper uses the RuleEngine and checks from the checks/ folder
|
|
9
|
+
# to ensure consistency between RSpec tests and CLI commands.
|
|
10
|
+
#
|
|
8
11
|
# @example Using in a system spec
|
|
9
12
|
# it 'has no accessibility issues' do
|
|
10
13
|
# visit root_path
|
|
@@ -17,7 +20,106 @@
|
|
|
17
20
|
# check_interactive_elements_have_names
|
|
18
21
|
#
|
|
19
22
|
# @see RailsAccessibilityTesting::ErrorMessageBuilder For error message formatting
|
|
20
|
-
|
|
23
|
+
# @see RailsAccessibilityTesting::Engine::RuleEngine For the underlying check engine
|
|
24
|
+
module RailsAccessibilityTesting
|
|
25
|
+
module AccessibilityHelper
|
|
26
|
+
# Track scanned pages to avoid duplicate checks
|
|
27
|
+
@scanned_pages = {}
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
attr_accessor :scanned_pages
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Module for partial detection methods (can be included in BaseCheck)
|
|
34
|
+
module PartialDetection
|
|
35
|
+
# Find partials rendered in a view file by scanning its content
|
|
36
|
+
def find_partials_in_view_file(view_file)
|
|
37
|
+
return [] unless view_file && File.exist?(view_file)
|
|
38
|
+
|
|
39
|
+
content = File.read(view_file)
|
|
40
|
+
partials = []
|
|
41
|
+
|
|
42
|
+
# Match various Rails partial render patterns:
|
|
43
|
+
# render 'partial_name'
|
|
44
|
+
# render partial: 'partial_name'
|
|
45
|
+
# render "partial_name"
|
|
46
|
+
# render partial: "partial_name"
|
|
47
|
+
# render 'path/to/partial'
|
|
48
|
+
# render partial: 'path/to/partial'
|
|
49
|
+
# <%= render 'partial_name' %>
|
|
50
|
+
# <%= render partial: 'partial_name' %>
|
|
51
|
+
|
|
52
|
+
patterns = [
|
|
53
|
+
/render\s+(?:partial:\s*)?['"]([^'"]+)['"]/,
|
|
54
|
+
/render\s+(?:partial:\s*)?:(\w+)/,
|
|
55
|
+
/<%=?\s*render\s+(?:partial:\s*)?['"]([^'"]+)['"]/,
|
|
56
|
+
/<%=?\s*render\s+(?:partial:\s*)?:(\w+)/
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
patterns.each do |pattern|
|
|
60
|
+
content.scan(pattern) do |match|
|
|
61
|
+
partial_name = match[0] || match[1]
|
|
62
|
+
next unless partial_name
|
|
63
|
+
|
|
64
|
+
# Normalize partial name (remove leading slash, handle paths)
|
|
65
|
+
partial_name = partial_name.strip
|
|
66
|
+
partial_name = partial_name[1..-1] if partial_name.start_with?('/')
|
|
67
|
+
|
|
68
|
+
# Handle namespaced partials (e.g., 'layouts/navbar' -> 'layouts/_navbar')
|
|
69
|
+
if partial_name.include?('/')
|
|
70
|
+
parts = partial_name.split('/')
|
|
71
|
+
partial_name = "#{parts[0..-2].join('/')}/_#{parts.last}"
|
|
72
|
+
else
|
|
73
|
+
partial_name = "_#{partial_name}" unless partial_name.start_with?('_')
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
partials << partial_name unless partials.include?(partial_name)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
partials
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Find partial file that might contain the element, checking a specific list of partials
|
|
84
|
+
def find_partial_for_element_in_list(controller, element_context, partial_list)
|
|
85
|
+
return nil unless element_context && partial_list.any?
|
|
86
|
+
|
|
87
|
+
extensions = %w[erb haml slim]
|
|
88
|
+
|
|
89
|
+
# Check each partial in the list
|
|
90
|
+
partial_list.each do |partial_name|
|
|
91
|
+
# Remove leading underscore if present (we'll add it back)
|
|
92
|
+
clean_name = partial_name.start_with?('_') ? partial_name[1..-1] : partial_name
|
|
93
|
+
|
|
94
|
+
extensions.each do |ext|
|
|
95
|
+
# Handle namespaced partials (e.g., 'layouts/navbar')
|
|
96
|
+
if clean_name.include?('/')
|
|
97
|
+
partial_path = "app/views/#{clean_name}.html.#{ext}"
|
|
98
|
+
else
|
|
99
|
+
# Check in controller directory, shared, and layouts
|
|
100
|
+
partial_paths = [
|
|
101
|
+
"app/views/#{controller}/_#{clean_name}.html.#{ext}",
|
|
102
|
+
"app/views/shared/_#{clean_name}.html.#{ext}",
|
|
103
|
+
"app/views/layouts/_#{clean_name}.html.#{ext}"
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
partial_paths.each do |pp|
|
|
107
|
+
return pp if File.exist?(pp)
|
|
108
|
+
end
|
|
109
|
+
next
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
return partial_path if File.exist?(partial_path)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Include PartialDetection in AccessibilityHelper so methods are available
|
|
121
|
+
include PartialDetection
|
|
122
|
+
|
|
21
123
|
# Get current page context for error messages
|
|
22
124
|
# @param element_context [Hash] Optional element context to help determine exact view file
|
|
23
125
|
# @return [Hash] Page context with url, path, and view_file
|
|
@@ -73,6 +175,7 @@ module AccessibilityHelper
|
|
|
73
175
|
# Basic accessibility check - runs 5 basic checks
|
|
74
176
|
def check_basic_accessibility
|
|
75
177
|
@accessibility_errors ||= []
|
|
178
|
+
@accessibility_warnings ||= []
|
|
76
179
|
|
|
77
180
|
check_form_labels
|
|
78
181
|
check_image_alt_text
|
|
@@ -82,38 +185,194 @@ module AccessibilityHelper
|
|
|
82
185
|
|
|
83
186
|
# If we collected any errors and this was called directly (not from comprehensive), raise them
|
|
84
187
|
if @accessibility_errors.any? && !@in_comprehensive_check
|
|
188
|
+
# Show warnings first if any
|
|
189
|
+
if @accessibility_warnings.any?
|
|
190
|
+
puts format_all_warnings(@accessibility_warnings)
|
|
191
|
+
end
|
|
85
192
|
raise format_all_errors(@accessibility_errors)
|
|
86
193
|
elsif @accessibility_errors.empty? && !@in_comprehensive_check
|
|
194
|
+
# Show warnings if any (non-blocking)
|
|
195
|
+
if @accessibility_warnings.any?
|
|
196
|
+
puts format_all_warnings(@accessibility_warnings)
|
|
197
|
+
end
|
|
87
198
|
# Show success message when all checks pass
|
|
199
|
+
timestamp = format_timestamp_for_terminal
|
|
88
200
|
puts "\n✅ All basic accessibility checks passed! (5 checks: form labels, images, interactive elements, headings, keyboard)"
|
|
201
|
+
puts " ✓ #{timestamp}"
|
|
89
202
|
end
|
|
90
203
|
end
|
|
91
204
|
|
|
92
205
|
# Full comprehensive check - runs all 11 checks including advanced
|
|
206
|
+
# Uses the RuleEngine and checks from the checks/ folder for consistency
|
|
207
|
+
# @return [Hash] Hash with :errors and :warnings counts
|
|
93
208
|
def check_comprehensive_accessibility
|
|
209
|
+
# Note: Page scanning cache is disabled for RSpec tests to ensure accurate error reporting
|
|
210
|
+
# The cache is only used in live scanner to avoid duplicate scans
|
|
211
|
+
|
|
94
212
|
@accessibility_errors = []
|
|
213
|
+
@accessibility_warnings = []
|
|
95
214
|
@in_comprehensive_check = true
|
|
96
215
|
|
|
216
|
+
# Show page being checked
|
|
217
|
+
page_path = safe_page_path || 'current page'
|
|
218
|
+
page_url = safe_page_url || 'current URL'
|
|
219
|
+
puts "\n" + "="*70
|
|
220
|
+
puts "🔍 Scanning page for accessibility issues..."
|
|
221
|
+
puts "="*70
|
|
222
|
+
puts "📍 Page: #{page_path}"
|
|
223
|
+
puts "🔗 URL: #{page_url}"
|
|
224
|
+
puts "="*70
|
|
225
|
+
puts ""
|
|
226
|
+
|
|
227
|
+
# Use RuleEngine to run checks from checks/ folder
|
|
228
|
+
begin
|
|
229
|
+
config = RailsAccessibilityTesting::Config::YamlLoader.load(profile: :development)
|
|
230
|
+
engine = RailsAccessibilityTesting::Engine::RuleEngine.new(config: config)
|
|
231
|
+
|
|
232
|
+
context = {
|
|
233
|
+
url: safe_page_url,
|
|
234
|
+
path: safe_page_path
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
# Progress callback for real-time feedback
|
|
238
|
+
progress_callback = lambda do |check_number, total_checks, check_name, status, data = nil|
|
|
239
|
+
case status
|
|
240
|
+
when :start
|
|
241
|
+
print " [#{check_number}/#{total_checks}] Checking #{check_name}... "
|
|
242
|
+
$stdout.flush
|
|
243
|
+
when :passed
|
|
244
|
+
puts "✓"
|
|
245
|
+
when :found_issues
|
|
246
|
+
puts "✗ Found #{data} issue#{'s' if data != 1}"
|
|
247
|
+
when :error
|
|
248
|
+
puts "⚠ Error: #{data}"
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
violations = engine.check(page, context: context, progress_callback: progress_callback)
|
|
253
|
+
|
|
254
|
+
# Convert violations to our error/warning format
|
|
255
|
+
violations.each do |violation|
|
|
256
|
+
element_context = violation.element_context || {}
|
|
257
|
+
page_context = {
|
|
258
|
+
url: context[:url],
|
|
259
|
+
path: context[:path],
|
|
260
|
+
view_file: determine_view_file(context[:path], context[:url], element_context)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
# Skip links and aria landmarks are warnings, everything else is an error
|
|
264
|
+
# Multiple h1s should be errors, not warnings
|
|
265
|
+
if violation.message.include?('skip link') || violation.message.include?('Skip link') ||
|
|
266
|
+
(violation.rule_name == 'aria_landmarks')
|
|
267
|
+
collect_warning(violation.message, element_context, page_context)
|
|
268
|
+
else
|
|
269
|
+
collect_error(violation.message, element_context, page_context)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
rescue StandardError => e
|
|
273
|
+
# Fallback to old method if RuleEngine fails
|
|
97
274
|
check_basic_accessibility
|
|
98
275
|
check_aria_landmarks
|
|
99
276
|
check_form_error_associations
|
|
100
277
|
check_table_structure
|
|
101
278
|
check_duplicate_ids
|
|
102
|
-
|
|
279
|
+
check_skip_links
|
|
280
|
+
end
|
|
103
281
|
|
|
104
282
|
@in_comprehensive_check = false
|
|
105
283
|
|
|
106
|
-
#
|
|
284
|
+
# Get page context for success messages
|
|
285
|
+
page_context_info = {
|
|
286
|
+
path: safe_page_path,
|
|
287
|
+
url: safe_page_url,
|
|
288
|
+
view_file: determine_view_file(safe_page_path, safe_page_url, {})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
# Show summary separator
|
|
292
|
+
puts ""
|
|
293
|
+
puts "="*70
|
|
294
|
+
|
|
295
|
+
# Centralized report: Show everything together in one unified report
|
|
296
|
+
timestamp = format_timestamp_for_terminal
|
|
297
|
+
|
|
298
|
+
# Build unified report - errors first, then warnings, then success
|
|
107
299
|
if @accessibility_errors.any?
|
|
108
|
-
|
|
300
|
+
# Store counts before raising (so they're available even if exception is caught)
|
|
301
|
+
error_count = @accessibility_errors.length
|
|
302
|
+
warning_count = @accessibility_warnings.length
|
|
303
|
+
|
|
304
|
+
# Show errors first (most critical)
|
|
305
|
+
error_output = format_all_errors(@accessibility_errors)
|
|
306
|
+
puts error_output
|
|
307
|
+
$stdout.flush # Flush immediately to ensure errors are visible
|
|
308
|
+
|
|
309
|
+
# Show warnings after errors (if any)
|
|
310
|
+
if @accessibility_warnings.any?
|
|
311
|
+
warning_output = format_all_warnings(@accessibility_warnings)
|
|
312
|
+
puts warning_output
|
|
313
|
+
$stdout.flush
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Summary - make it very clear
|
|
317
|
+
puts "\n" + "="*70
|
|
318
|
+
puts "📊 SUMMARY: Found #{error_count} ERROR#{'S' if error_count != 1}"
|
|
319
|
+
puts " #{warning_count} warning#{'s' if warning_count != 1}" if warning_count > 0
|
|
320
|
+
puts "="*70
|
|
321
|
+
$stdout.flush
|
|
322
|
+
|
|
323
|
+
# Raise to fail the test (errors already formatted above)
|
|
324
|
+
# Include error and warning counts in message so they can be extracted even if exception is caught
|
|
325
|
+
raise "ACCESSIBILITY ERRORS FOUND: #{error_count} error(s), #{warning_count} warning(s) - see details above"
|
|
326
|
+
elsif @accessibility_warnings.any?
|
|
327
|
+
# Only warnings, no errors - show warnings and indicate test passed with warnings
|
|
328
|
+
puts format_all_warnings(@accessibility_warnings)
|
|
329
|
+
puts "\n" + "="*70
|
|
330
|
+
puts "📊 SUMMARY: Test passed with #{@accessibility_warnings.length} warning#{'s' if @accessibility_warnings.length != 1}"
|
|
331
|
+
puts " ✓ #{timestamp}"
|
|
332
|
+
puts "="*70
|
|
333
|
+
puts "\n✅ Accessibility checks completed with warnings (test passed, but please address warnings above)"
|
|
334
|
+
puts " 📄 Page: #{page_context_info[:path] || 'current page'}"
|
|
335
|
+
puts " 📝 View: #{page_context_info[:view_file] || 'unknown'}"
|
|
109
336
|
else
|
|
110
|
-
#
|
|
337
|
+
# All checks passed with no errors and no warnings - show success message
|
|
338
|
+
puts "📊 SUMMARY: All checks passed!"
|
|
339
|
+
puts "="*70
|
|
111
340
|
puts "\n✅ All comprehensive accessibility checks passed! (11 checks: form labels, images, interactive elements, headings, keyboard, ARIA landmarks, form errors, table structure, duplicate IDs, skip links, color contrast)"
|
|
341
|
+
puts " 📄 Page: #{page_context_info[:path] || 'current page'}"
|
|
342
|
+
puts " 📝 View: #{page_context_info[:view_file] || 'unknown'}"
|
|
343
|
+
puts " ✓ #{timestamp}"
|
|
112
344
|
end
|
|
345
|
+
|
|
346
|
+
# Return counts for tracking and page context
|
|
347
|
+
# Note: This return statement only executes if no exception was raised above
|
|
348
|
+
# If errors were found, an exception is raised and this never executes
|
|
349
|
+
result = {
|
|
350
|
+
errors: @accessibility_errors.length,
|
|
351
|
+
warnings: @accessibility_warnings.length,
|
|
352
|
+
skipped: false,
|
|
353
|
+
page_context: page_context_info
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
# Ensure output is flushed before returning
|
|
357
|
+
$stdout.flush
|
|
358
|
+
|
|
359
|
+
result
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Reset the scanned pages cache (useful for testing or when you want to rescan)
|
|
363
|
+
def reset_scanned_pages_cache
|
|
364
|
+
AccessibilityHelper.scanned_pages.clear
|
|
113
365
|
end
|
|
114
366
|
|
|
115
367
|
private
|
|
116
368
|
|
|
369
|
+
# Format timestamp for terminal output (shorter, more readable)
|
|
370
|
+
def format_timestamp_for_terminal
|
|
371
|
+
# Use just time for same-day reports, or full date if different day
|
|
372
|
+
# Format: "13:27:43" (cleaner for terminal)
|
|
373
|
+
Time.now.strftime("%H:%M:%S")
|
|
374
|
+
end
|
|
375
|
+
|
|
117
376
|
# Collect an error instead of raising immediately
|
|
118
377
|
def collect_error(error_type, element_context, page_context)
|
|
119
378
|
error_message = build_error_message(error_type, element_context, page_context)
|
|
@@ -125,13 +384,27 @@ module AccessibilityHelper
|
|
|
125
384
|
}
|
|
126
385
|
end
|
|
127
386
|
|
|
387
|
+
# Collect a warning (non-blocking, but formatted like errors)
|
|
388
|
+
def collect_warning(warning_type, element_context, page_context)
|
|
389
|
+
@accessibility_warnings ||= []
|
|
390
|
+
warning_message = build_error_message(warning_type, element_context, page_context)
|
|
391
|
+
@accessibility_warnings << {
|
|
392
|
+
warning_type: warning_type,
|
|
393
|
+
element_context: element_context,
|
|
394
|
+
page_context: page_context,
|
|
395
|
+
message: warning_message
|
|
396
|
+
}
|
|
397
|
+
end
|
|
398
|
+
|
|
128
399
|
# Format all collected errors with summary at top and details at bottom
|
|
129
400
|
def format_all_errors(errors)
|
|
130
401
|
return "" if errors.empty?
|
|
131
402
|
|
|
403
|
+
timestamp = format_timestamp_for_terminal
|
|
404
|
+
|
|
132
405
|
output = []
|
|
133
406
|
output << "\n" + "="*70
|
|
134
|
-
output << "❌ ACCESSIBILITY ERRORS FOUND: #{errors.length} issue(s)"
|
|
407
|
+
output << "❌ ACCESSIBILITY ERRORS FOUND: #{errors.length} issue(s) • #{timestamp}"
|
|
135
408
|
output << "="*70
|
|
136
409
|
output << ""
|
|
137
410
|
output << "📋 SUMMARY OF ISSUES:"
|
|
@@ -143,23 +416,28 @@ module AccessibilityHelper
|
|
|
143
416
|
page_context = error[:page_context]
|
|
144
417
|
element_context = error[:element_context]
|
|
145
418
|
|
|
146
|
-
# Build summary line
|
|
419
|
+
# Build summary line - prioritize view file
|
|
147
420
|
summary = " #{index + 1}. #{error_type}"
|
|
148
421
|
|
|
149
|
-
# Add
|
|
422
|
+
# Add view file prominently first
|
|
150
423
|
if page_context[:view_file]
|
|
151
|
-
summary += "
|
|
424
|
+
summary += "\n 📝 File: #{page_context[:view_file]}"
|
|
152
425
|
end
|
|
153
426
|
|
|
154
427
|
# Add element identifier if available
|
|
155
428
|
if element_context[:id].present?
|
|
156
|
-
summary += " [id: #{element_context[:id]}]"
|
|
429
|
+
summary += "\n 🔍 Element: [id: #{element_context[:id]}]"
|
|
157
430
|
elsif element_context[:href].present?
|
|
158
431
|
href_display = element_context[:href].length > 40 ? "#{element_context[:href][0..37]}..." : element_context[:href]
|
|
159
|
-
summary += " [href: #{href_display}]"
|
|
432
|
+
summary += "\n 🔍 Element: [href: #{href_display}]"
|
|
160
433
|
elsif element_context[:src].present?
|
|
161
434
|
src_display = element_context[:src].length > 40 ? "#{element_context[:src][0..37]}..." : element_context[:src]
|
|
162
|
-
summary += " [src: #{src_display}]"
|
|
435
|
+
summary += "\n 🔍 Element: [src: #{src_display}]"
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Add path as fallback if no view file
|
|
439
|
+
if !page_context[:view_file] && page_context[:path]
|
|
440
|
+
summary += "\n 🔗 Path: #{page_context[:path]}"
|
|
163
441
|
end
|
|
164
442
|
|
|
165
443
|
output << summary
|
|
@@ -183,6 +461,77 @@ module AccessibilityHelper
|
|
|
183
461
|
output << "="*70
|
|
184
462
|
output << "💡 Fix all issues above, then re-run the accessibility checks"
|
|
185
463
|
output << "="*70
|
|
464
|
+
output << ""
|
|
465
|
+
|
|
466
|
+
output.join("\n")
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Format all collected warnings with summary at top and details at bottom (same format as errors)
|
|
470
|
+
def format_all_warnings(warnings)
|
|
471
|
+
return "" if warnings.empty?
|
|
472
|
+
|
|
473
|
+
timestamp = format_timestamp_for_terminal
|
|
474
|
+
|
|
475
|
+
output = []
|
|
476
|
+
output << "\n" + "="*70
|
|
477
|
+
output << "⚠️ ACCESSIBILITY WARNINGS FOUND: #{warnings.length} warning(s) • #{timestamp}"
|
|
478
|
+
output << "="*70
|
|
479
|
+
output << ""
|
|
480
|
+
output << "📋 SUMMARY OF WARNINGS:"
|
|
481
|
+
output << ""
|
|
482
|
+
|
|
483
|
+
# Summary list at top
|
|
484
|
+
warnings.each_with_index do |warning, index|
|
|
485
|
+
warning_type = warning[:warning_type]
|
|
486
|
+
page_context = warning[:page_context]
|
|
487
|
+
element_context = warning[:element_context]
|
|
488
|
+
|
|
489
|
+
# Build summary line - prioritize view file
|
|
490
|
+
summary = " #{index + 1}. #{warning_type}"
|
|
491
|
+
|
|
492
|
+
# Add view file prominently first
|
|
493
|
+
if page_context[:view_file]
|
|
494
|
+
summary += "\n 📝 File: #{page_context[:view_file]}"
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Add element identifier if available
|
|
498
|
+
if element_context[:id].present?
|
|
499
|
+
summary += "\n 🔍 Element: [id: #{element_context[:id]}]"
|
|
500
|
+
elsif element_context[:href].present?
|
|
501
|
+
href_display = element_context[:href].length > 40 ? "#{element_context[:href][0..37]}..." : element_context[:href]
|
|
502
|
+
summary += "\n 🔍 Element: [href: #{href_display}]"
|
|
503
|
+
elsif element_context[:src].present?
|
|
504
|
+
src_display = element_context[:src].length > 40 ? "#{element_context[:src][0..37]}..." : element_context[:src]
|
|
505
|
+
summary += "\n 🔍 Element: [src: #{src_display}]"
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# Add path as fallback if no view file
|
|
509
|
+
if !page_context[:view_file] && page_context[:path]
|
|
510
|
+
summary += "\n 🔗 Path: #{page_context[:path]}"
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
output << summary
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
output << ""
|
|
517
|
+
output << "="*70
|
|
518
|
+
output << "📝 DETAILED WARNING DESCRIPTIONS:"
|
|
519
|
+
output << "="*70
|
|
520
|
+
output << ""
|
|
521
|
+
|
|
522
|
+
# Detailed descriptions at bottom
|
|
523
|
+
warnings.each_with_index do |warning, index|
|
|
524
|
+
output << "\n" + "-"*70
|
|
525
|
+
output << "WARNING #{index + 1} of #{warnings.length}:"
|
|
526
|
+
output << "-"*70
|
|
527
|
+
output << warning[:message]
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
output << ""
|
|
531
|
+
output << "="*70
|
|
532
|
+
output << "💡 Consider addressing these warnings to improve accessibility"
|
|
533
|
+
output << "="*70
|
|
534
|
+
output << ""
|
|
186
535
|
|
|
187
536
|
output.join("\n")
|
|
188
537
|
end
|
|
@@ -259,12 +608,26 @@ module AccessibilityHelper
|
|
|
259
608
|
# Try to find the exact view file
|
|
260
609
|
view_file = find_view_file_for_controller_action(controller, action)
|
|
261
610
|
|
|
611
|
+
# If we found the view file, check for partials that might contain the element
|
|
612
|
+
if view_file && element_context
|
|
613
|
+
# Scan the view file for rendered partials
|
|
614
|
+
partials_in_view = find_partials_in_view_file(view_file)
|
|
615
|
+
|
|
616
|
+
# Check if element matches any partial in the view
|
|
617
|
+
partial_file = find_partial_for_element_in_list(controller, element_context, partials_in_view)
|
|
618
|
+
return partial_file if partial_file
|
|
619
|
+
end
|
|
620
|
+
|
|
262
621
|
# If element might be in a partial or layout, check those too
|
|
263
622
|
if element_context
|
|
264
623
|
# Check if element is likely in a layout (navbar, footer, etc.)
|
|
265
624
|
if element_in_layout?(element_context)
|
|
266
625
|
layout_file = find_layout_file
|
|
267
626
|
return layout_file if layout_file
|
|
627
|
+
|
|
628
|
+
# Also check layout partials
|
|
629
|
+
layout_partial = find_partial_in_layouts(element_context)
|
|
630
|
+
return layout_partial if layout_partial
|
|
268
631
|
end
|
|
269
632
|
|
|
270
633
|
# Check if element is in a partial based on context
|
|
@@ -300,19 +663,47 @@ module AccessibilityHelper
|
|
|
300
663
|
end
|
|
301
664
|
|
|
302
665
|
# Find view file for controller and action
|
|
666
|
+
# Handles cases where action name doesn't match view file name (e.g., search action -> search_result.html.erb)
|
|
303
667
|
def find_view_file_for_controller_action(controller, action)
|
|
304
668
|
extensions = %w[erb haml slim]
|
|
669
|
+
controller_path = "app/views/#{controller}"
|
|
670
|
+
|
|
671
|
+
# First, try exact matches
|
|
305
672
|
extensions.each do |ext|
|
|
306
673
|
view_paths = [
|
|
307
|
-
"
|
|
308
|
-
"
|
|
309
|
-
"
|
|
674
|
+
"#{controller_path}/#{action}.html.#{ext}",
|
|
675
|
+
"#{controller_path}/_#{action}.html.#{ext}",
|
|
676
|
+
"#{controller_path}/#{action}.#{ext}"
|
|
310
677
|
]
|
|
311
678
|
|
|
312
679
|
found = view_paths.find { |vp| File.exist?(vp) }
|
|
313
680
|
return found if found
|
|
314
681
|
end
|
|
315
682
|
|
|
683
|
+
# If exact match not found, scan all view files in the controller directory
|
|
684
|
+
# This handles cases like: search action -> search_result.html.erb
|
|
685
|
+
if File.directory?(controller_path)
|
|
686
|
+
extensions.each do |ext|
|
|
687
|
+
# Look for files that might match the action (e.g., search_result, search_results, etc.)
|
|
688
|
+
pattern = "#{controller_path}/*#{action}*.html.#{ext}"
|
|
689
|
+
matching_files = Dir.glob(pattern)
|
|
690
|
+
|
|
691
|
+
# Prefer files that start with the action name
|
|
692
|
+
preferred = matching_files.find { |f| File.basename(f).start_with?("#{action}_") || File.basename(f).start_with?("#{action}.") }
|
|
693
|
+
return preferred if preferred
|
|
694
|
+
|
|
695
|
+
# Return first match if any found
|
|
696
|
+
return matching_files.first if matching_files.any?
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# Last resort: check if there's only one view file in the controller directory
|
|
700
|
+
# (common for single-action controllers or when action name is very different)
|
|
701
|
+
all_views = extensions.flat_map { |ext| Dir.glob("#{controller_path}/*.html.#{ext}") }
|
|
702
|
+
if all_views.length == 1
|
|
703
|
+
return all_views.first
|
|
704
|
+
end
|
|
705
|
+
end
|
|
706
|
+
|
|
316
707
|
nil
|
|
317
708
|
end
|
|
318
709
|
|
|
@@ -374,16 +765,18 @@ module AccessibilityHelper
|
|
|
374
765
|
# e.g., "navbar" from id="navbar" or class="navbar"
|
|
375
766
|
partial_names << id.split('-').first
|
|
376
767
|
partial_names << id.split('_').first
|
|
768
|
+
partial_names << id # Also try the full ID
|
|
377
769
|
end
|
|
378
770
|
|
|
379
771
|
if classes.present?
|
|
380
772
|
classes.split(/\s+/).each do |cls|
|
|
381
773
|
partial_names << cls.split('-').first
|
|
382
774
|
partial_names << cls.split('_').first
|
|
775
|
+
partial_names << cls # Also try the full class name
|
|
383
776
|
end
|
|
384
777
|
end
|
|
385
778
|
|
|
386
|
-
# Check partials in controller directory
|
|
779
|
+
# Check partials in controller directory, shared, and layouts
|
|
387
780
|
partial_names.uniq.each do |partial_name|
|
|
388
781
|
next if partial_name.blank?
|
|
389
782
|
|
|
@@ -391,7 +784,9 @@ module AccessibilityHelper
|
|
|
391
784
|
partial_paths = [
|
|
392
785
|
"app/views/#{controller}/_#{partial_name}.html.#{ext}",
|
|
393
786
|
"app/views/shared/_#{partial_name}.html.#{ext}",
|
|
394
|
-
"app/views/layouts/_#{partial_name}.html.#{ext}"
|
|
787
|
+
"app/views/layouts/_#{partial_name}.html.#{ext}",
|
|
788
|
+
"app/views/#{controller}/#{partial_name}.html.#{ext}", # Sometimes partials don't have underscore
|
|
789
|
+
"app/views/shared/#{partial_name}.html.#{ext}"
|
|
395
790
|
]
|
|
396
791
|
|
|
397
792
|
found = partial_paths.find { |pp| File.exist?(pp) }
|
|
@@ -401,6 +796,43 @@ module AccessibilityHelper
|
|
|
401
796
|
|
|
402
797
|
nil
|
|
403
798
|
end
|
|
799
|
+
|
|
800
|
+
# Find partial in layouts directory based on element context
|
|
801
|
+
def find_partial_in_layouts(element_context)
|
|
802
|
+
return nil unless element_context
|
|
803
|
+
|
|
804
|
+
extensions = %w[erb haml slim]
|
|
805
|
+
id = element_context[:id].to_s
|
|
806
|
+
classes = element_context[:classes].to_s
|
|
807
|
+
|
|
808
|
+
# Common layout partial names
|
|
809
|
+
partial_names = []
|
|
810
|
+
partial_names << id.split('-').first if id.present?
|
|
811
|
+
partial_names << id.split('_').first if id.present?
|
|
812
|
+
|
|
813
|
+
if classes.present?
|
|
814
|
+
classes.split(/\s+/).each do |cls|
|
|
815
|
+
partial_names << cls.split('-').first
|
|
816
|
+
partial_names << cls.split('_').first
|
|
817
|
+
end
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
# Check all partials in layouts directory
|
|
821
|
+
extensions.each do |ext|
|
|
822
|
+
# First try specific names
|
|
823
|
+
partial_names.uniq.each do |partial_name|
|
|
824
|
+
next if partial_name.blank?
|
|
825
|
+
partial_path = "app/views/layouts/_#{partial_name}.html.#{ext}"
|
|
826
|
+
return partial_path if File.exist?(partial_path)
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
# If no match, scan all layout partials and try to match by content
|
|
830
|
+
layout_partials = Dir.glob("app/views/layouts/_*.html.#{ext}")
|
|
831
|
+
# Could add content-based matching here if needed
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
nil
|
|
835
|
+
end
|
|
404
836
|
|
|
405
837
|
# Check that form inputs have associated labels
|
|
406
838
|
def check_form_labels
|
|
@@ -436,7 +868,8 @@ module AccessibilityHelper
|
|
|
436
868
|
page.all('img', visible: :all).each do |img|
|
|
437
869
|
# Check if alt attribute exists in the HTML
|
|
438
870
|
# Use JavaScript hasAttribute which is the most reliable way to check
|
|
439
|
-
|
|
871
|
+
# Use native attribute access instead of JavaScript evaluation for better performance
|
|
872
|
+
has_alt_attribute = img.native.attribute('alt') != nil rescue false
|
|
440
873
|
|
|
441
874
|
# If alt attribute doesn't exist, that's an error
|
|
442
875
|
# If it exists but is empty (alt=""), that's valid for decorative images
|
|
@@ -464,7 +897,17 @@ module AccessibilityHelper
|
|
|
464
897
|
aria_labelledby = element[:"aria-labelledby"]
|
|
465
898
|
title = element[:title]
|
|
466
899
|
|
|
467
|
-
if text
|
|
900
|
+
# Check if element contains an image with alt text (common pattern for logo links)
|
|
901
|
+
has_image_with_alt = false
|
|
902
|
+
if text.blank?
|
|
903
|
+
images = element.all('img', visible: :all)
|
|
904
|
+
has_image_with_alt = images.any? do |img|
|
|
905
|
+
alt = img[:alt]
|
|
906
|
+
alt.present? && !alt.strip.empty?
|
|
907
|
+
end
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
if text.blank? && aria_label.blank? && aria_labelledby.blank? && title.blank? && !has_image_with_alt
|
|
468
911
|
element_context = get_element_context(element)
|
|
469
912
|
tag = element.tag_name
|
|
470
913
|
|
|
@@ -488,12 +931,33 @@ module AccessibilityHelper
|
|
|
488
931
|
headings = page.all('h1, h2, h3, h4, h5, h6', visible: true)
|
|
489
932
|
|
|
490
933
|
if headings.empty?
|
|
491
|
-
|
|
934
|
+
element_context = {
|
|
935
|
+
tag: 'page',
|
|
936
|
+
id: nil,
|
|
937
|
+
classes: nil,
|
|
938
|
+
href: nil,
|
|
939
|
+
src: nil,
|
|
940
|
+
text: 'Page has no visible headings',
|
|
941
|
+
parent: nil
|
|
942
|
+
}
|
|
943
|
+
collect_warning("Page has no visible headings - consider adding at least an h1", element_context, page_context)
|
|
492
944
|
return
|
|
493
945
|
end
|
|
494
946
|
|
|
495
947
|
h1_count = headings.count { |h| h.tag_name == 'h1' }
|
|
948
|
+
first_heading = headings.first
|
|
949
|
+
first_heading_level = first_heading ? first_heading.tag_name[1].to_i : nil
|
|
950
|
+
|
|
496
951
|
if h1_count == 0
|
|
952
|
+
# If the first heading is h2 or higher, provide a more specific message
|
|
953
|
+
if first_heading_level && first_heading_level >= 2
|
|
954
|
+
element_context = get_element_context(first_heading)
|
|
955
|
+
collect_error(
|
|
956
|
+
"Page has h#{first_heading_level} but no h1 heading",
|
|
957
|
+
element_context,
|
|
958
|
+
page_context
|
|
959
|
+
)
|
|
960
|
+
else
|
|
497
961
|
# Create element context for page-level issue
|
|
498
962
|
element_context = {
|
|
499
963
|
tag: 'page',
|
|
@@ -510,8 +974,22 @@ module AccessibilityHelper
|
|
|
510
974
|
element_context,
|
|
511
975
|
page_context
|
|
512
976
|
)
|
|
977
|
+
end
|
|
513
978
|
elsif h1_count > 1
|
|
514
|
-
|
|
979
|
+
# Find all h1 elements to provide context
|
|
980
|
+
h1_elements = headings.select { |h| h.tag_name == 'h1' }
|
|
981
|
+
|
|
982
|
+
# Report error for each h1 after the first one
|
|
983
|
+
h1_elements[1..-1].each do |h1|
|
|
984
|
+
element_context = get_element_context(h1)
|
|
985
|
+
page_context = get_page_context(element_context)
|
|
986
|
+
|
|
987
|
+
collect_error(
|
|
988
|
+
"Page has multiple h1 headings (#{h1_count} total) - only one h1 should be used per page",
|
|
989
|
+
element_context,
|
|
990
|
+
page_context
|
|
991
|
+
)
|
|
992
|
+
end
|
|
515
993
|
end
|
|
516
994
|
|
|
517
995
|
previous_level = 0
|
|
@@ -559,7 +1037,16 @@ module AccessibilityHelper
|
|
|
559
1037
|
landmarks = page.all('main, nav, [role="main"], [role="navigation"], [role="banner"], [role="contentinfo"], [role="complementary"], [role="search"]', visible: true)
|
|
560
1038
|
|
|
561
1039
|
if landmarks.empty?
|
|
562
|
-
|
|
1040
|
+
element_context = {
|
|
1041
|
+
tag: 'page',
|
|
1042
|
+
id: nil,
|
|
1043
|
+
classes: nil,
|
|
1044
|
+
href: nil,
|
|
1045
|
+
src: nil,
|
|
1046
|
+
text: 'Page has no ARIA landmarks',
|
|
1047
|
+
parent: nil
|
|
1048
|
+
}
|
|
1049
|
+
collect_warning("Page has no ARIA landmarks - consider adding <main> and <nav> elements", element_context, page_context)
|
|
563
1050
|
end
|
|
564
1051
|
|
|
565
1052
|
# Check for main landmark
|
|
@@ -587,10 +1074,45 @@ module AccessibilityHelper
|
|
|
587
1074
|
# Check for skip links
|
|
588
1075
|
def check_skip_links
|
|
589
1076
|
page_context = get_page_context
|
|
590
|
-
|
|
1077
|
+
|
|
1078
|
+
# Look for skip links with various common patterns:
|
|
1079
|
+
# - href="#main", "#maincontent", "#main-content", "#content", etc.
|
|
1080
|
+
# - class="skip-link", "skiplink", "skip_link", or any class containing "skip"
|
|
1081
|
+
# - text content containing "skip" (case-insensitive)
|
|
1082
|
+
skip_link_selectors = [
|
|
1083
|
+
'a[href="#main"]',
|
|
1084
|
+
'a[href*="main"]', # Contains "main" (covers #maincontent, #main-content, etc.)
|
|
1085
|
+
'a[href^="#content"]',
|
|
1086
|
+
'a[class*="skip"]', # Any class containing "skip" (covers skip-link, skiplink, skip_link)
|
|
1087
|
+
'a.skip-link',
|
|
1088
|
+
'a.skiplink',
|
|
1089
|
+
'a.skip_link'
|
|
1090
|
+
]
|
|
1091
|
+
|
|
1092
|
+
skip_links = page.all(skip_link_selectors.join(', '), visible: false)
|
|
1093
|
+
|
|
1094
|
+
# Also check for links with "skip" in their text content
|
|
1095
|
+
if skip_links.empty?
|
|
1096
|
+
all_links = page.all('a', visible: false)
|
|
1097
|
+
skip_links = all_links.select do |link|
|
|
1098
|
+
link_text = link.text.to_s.downcase
|
|
1099
|
+
href = link[:href].to_s.downcase
|
|
1100
|
+
(link_text.include?('skip') && (href.include?('main') || href.include?('content'))) ||
|
|
1101
|
+
(link[:class].to_s.downcase.include?('skip') && (href.include?('main') || href.include?('content')))
|
|
1102
|
+
end
|
|
1103
|
+
end
|
|
591
1104
|
|
|
592
1105
|
if skip_links.empty?
|
|
593
|
-
|
|
1106
|
+
element_context = {
|
|
1107
|
+
tag: 'page',
|
|
1108
|
+
id: nil,
|
|
1109
|
+
classes: nil,
|
|
1110
|
+
href: nil,
|
|
1111
|
+
src: nil,
|
|
1112
|
+
text: 'Page missing skip link',
|
|
1113
|
+
parent: nil
|
|
1114
|
+
}
|
|
1115
|
+
collect_warning("Page missing skip link - consider adding 'skip to main content' link", element_context, page_context)
|
|
594
1116
|
end
|
|
595
1117
|
end
|
|
596
1118
|
|
|
@@ -705,3 +1227,4 @@ module AccessibilityHelper
|
|
|
705
1227
|
end
|
|
706
1228
|
end
|
|
707
1229
|
end
|
|
1230
|
+
end
|