rails_accessibility_testing 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +307 -0
  3. data/CHANGELOG.md +81 -0
  4. data/CODE_OF_CONDUCT.md +125 -0
  5. data/CONTRIBUTING.md +225 -0
  6. data/GUIDES/continuous_integration.md +326 -0
  7. data/GUIDES/getting_started.md +205 -0
  8. data/GUIDES/working_with_designers_and_content_authors.md +398 -0
  9. data/GUIDES/writing_accessible_views_in_rails.md +412 -0
  10. data/LICENSE +22 -0
  11. data/README.md +350 -0
  12. data/docs_site/404.html +11 -0
  13. data/docs_site/Gemfile +11 -0
  14. data/docs_site/Makefile +14 -0
  15. data/docs_site/_config.yml +41 -0
  16. data/docs_site/_includes/header.html +13 -0
  17. data/docs_site/_layouts/default.html +130 -0
  18. data/docs_site/assets/main.scss +4 -0
  19. data/docs_site/ci_integration.md +76 -0
  20. data/docs_site/configuration.md +114 -0
  21. data/docs_site/contributing.md +69 -0
  22. data/docs_site/getting_started.md +57 -0
  23. data/docs_site/index.md +57 -0
  24. data/exe/rails_a11y +12 -0
  25. data/exe/rails_server_safe +41 -0
  26. data/lib/generators/rails_a11y/install/generator.rb +51 -0
  27. data/lib/rails_accessibility_testing/accessibility_helper.rb +701 -0
  28. data/lib/rails_accessibility_testing/change_detector.rb +114 -0
  29. data/lib/rails_accessibility_testing/checks/aria_landmarks_check.rb +33 -0
  30. data/lib/rails_accessibility_testing/checks/base_check.rb +156 -0
  31. data/lib/rails_accessibility_testing/checks/color_contrast_check.rb +56 -0
  32. data/lib/rails_accessibility_testing/checks/duplicate_ids_check.rb +49 -0
  33. data/lib/rails_accessibility_testing/checks/form_errors_check.rb +40 -0
  34. data/lib/rails_accessibility_testing/checks/form_labels_check.rb +62 -0
  35. data/lib/rails_accessibility_testing/checks/heading_hierarchy_check.rb +53 -0
  36. data/lib/rails_accessibility_testing/checks/image_alt_text_check.rb +52 -0
  37. data/lib/rails_accessibility_testing/checks/interactive_elements_check.rb +66 -0
  38. data/lib/rails_accessibility_testing/checks/keyboard_accessibility_check.rb +36 -0
  39. data/lib/rails_accessibility_testing/checks/skip_links_check.rb +24 -0
  40. data/lib/rails_accessibility_testing/checks/table_structure_check.rb +36 -0
  41. data/lib/rails_accessibility_testing/cli/command.rb +259 -0
  42. data/lib/rails_accessibility_testing/config/yaml_loader.rb +131 -0
  43. data/lib/rails_accessibility_testing/configuration.rb +30 -0
  44. data/lib/rails_accessibility_testing/engine/rule_engine.rb +97 -0
  45. data/lib/rails_accessibility_testing/engine/violation.rb +58 -0
  46. data/lib/rails_accessibility_testing/engine/violation_collector.rb +59 -0
  47. data/lib/rails_accessibility_testing/error_message_builder.rb +354 -0
  48. data/lib/rails_accessibility_testing/integration/minitest_integration.rb +74 -0
  49. data/lib/rails_accessibility_testing/rspec_integration.rb +58 -0
  50. data/lib/rails_accessibility_testing/shared_examples.rb +93 -0
  51. data/lib/rails_accessibility_testing/version.rb +4 -0
  52. data/lib/rails_accessibility_testing.rb +83 -0
  53. data/lib/tasks/accessibility.rake +28 -0
  54. metadata +218 -0
@@ -0,0 +1,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