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.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +212 -53
  3. data/CHANGELOG.md +118 -0
  4. data/GUIDES/getting_started.md +105 -77
  5. data/GUIDES/system_specs_for_accessibility.md +13 -12
  6. data/README.md +136 -36
  7. data/docs_site/getting_started.md +59 -69
  8. data/exe/a11y_live_scanner +361 -0
  9. data/exe/rails_server_safe +18 -1
  10. data/lib/generators/rails_a11y/install/install_generator.rb +137 -0
  11. data/lib/rails_accessibility_testing/accessibility_helper.rb +547 -24
  12. data/lib/rails_accessibility_testing/change_detector.rb +17 -104
  13. data/lib/rails_accessibility_testing/checks/base_check.rb +56 -7
  14. data/lib/rails_accessibility_testing/checks/heading_check.rb +138 -0
  15. data/lib/rails_accessibility_testing/checks/image_alt_text_check.rb +7 -7
  16. data/lib/rails_accessibility_testing/checks/interactive_elements_check.rb +11 -1
  17. data/lib/rails_accessibility_testing/cli/command.rb +3 -1
  18. data/lib/rails_accessibility_testing/config/yaml_loader.rb +1 -1
  19. data/lib/rails_accessibility_testing/engine/rule_engine.rb +49 -5
  20. data/lib/rails_accessibility_testing/error_message_builder.rb +63 -7
  21. data/lib/rails_accessibility_testing/middleware/page_visit_logger.rb +81 -0
  22. data/lib/rails_accessibility_testing/railtie.rb +22 -0
  23. data/lib/rails_accessibility_testing/rspec_integration.rb +176 -10
  24. data/lib/rails_accessibility_testing/version.rb +1 -1
  25. data/lib/rails_accessibility_testing.rb +8 -3
  26. metadata +7 -3
  27. data/lib/generators/rails_a11y/install/generator.rb +0 -51
  28. 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
- module AccessibilityHelper
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
- check_skip_links # Warning only, not error
279
+ check_skip_links
280
+ end
103
281
 
104
282
  @in_comprehensive_check = false
105
283
 
106
- # If we collected any errors, raise them all together
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
- raise format_all_errors(@accessibility_errors)
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
- # Show success message when all checks pass
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 location info
422
+ # Add view file prominently first
150
423
  if page_context[:view_file]
151
- summary += " (#{page_context[:view_file]})"
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
- "app/views/#{controller}/#{action}.html.#{ext}",
308
- "app/views/#{controller}/_#{action}.html.#{ext}",
309
- "app/views/#{controller}/#{action}.#{ext}"
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
- has_alt_attribute = page.evaluate_script("arguments[0].hasAttribute('alt')", img.native)
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.blank? && aria_label.blank? && aria_labelledby.blank? && title.blank?
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
- warn "⚠️ Page has no visible headings - consider adding at least an h1"
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
- warn "⚠️ Page has multiple h1 headings (#{h1_count}) - consider using only one"
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
- warn "⚠️ Page has no ARIA landmarks - consider adding <main> and <nav> elements"
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
- skip_links = page.all('a[href="#main"], a[href*="main-content"], a.skip-link, a[href^="#content"]', visible: false)
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
- warn "⚠️ Page missing skip link - consider adding 'skip to main content' link"
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