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
@@ -1,112 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAccessibilityTesting
4
- # Detects if relevant files have changed to determine if accessibility checks should run
4
+ # Simple helper to convert routes to testable paths
5
5
  class ChangeDetector
6
- # Time window for considering files as "recently changed" (in seconds)
7
- CHANGE_WINDOW = 300 # 5 minutes
8
-
9
- # Directories to monitor for changes
10
- MONITORED_DIRECTORIES = %w[app/views app/controllers app/helpers].freeze
11
-
12
- # View file extensions to check
13
- VIEW_EXTENSIONS = %w[erb haml slim].freeze
14
-
15
6
  class << self
16
- # Check if relevant files have changed
17
- # @param current_path [String] The current Rails path being tested
18
- # @return [Boolean] true if files have changed, false otherwise
19
- def files_changed?(current_path)
20
- return false unless current_path
21
-
22
- view_file = determine_view_file_from_path(current_path)
23
- return true if view_file_recently_modified?(view_file)
24
- return true if git_has_uncommitted_changes?
25
- return true if any_monitored_files_recently_modified?
26
-
27
- false
28
- end
29
-
30
- private
31
-
32
- # Check if a specific view file was recently modified
33
- def view_file_recently_modified?(view_file)
34
- return false unless view_file && File.exist?(view_file)
35
-
36
- File.mtime(view_file) > Time.now - CHANGE_WINDOW
37
- end
38
-
39
- # Check if git has uncommitted changes in monitored directories
40
- def git_has_uncommitted_changes?
41
- git_status = `git status --porcelain #{MONITORED_DIRECTORIES.join(' ')} 2>/dev/null`
42
- git_status.strip.length.positive?
43
- rescue StandardError
44
- false # Git not available or not a git repo
45
- end
46
-
47
- # Check if any monitored files were recently modified
48
- def any_monitored_files_recently_modified?
49
- monitored_files.any? do |file|
50
- File.exist?(file) && File.mtime(file) > Time.now - CHANGE_WINDOW
51
- end
52
- end
53
-
54
- # Get all monitored files
55
- def monitored_files
56
- view_files + controller_files + helper_files
57
- end
58
-
59
- # Get all view files
60
- def view_files
61
- VIEW_EXTENSIONS.flat_map do |ext|
62
- Dir.glob("app/views/**/*.#{ext}")
63
- end
64
- end
65
-
66
- # Get all controller files
67
- def controller_files
68
- Dir.glob('app/controllers/**/*.rb')
69
- end
70
-
71
- # Get all helper files
72
- def helper_files
73
- Dir.glob('app/helpers/**/*.rb')
74
- end
75
-
76
- # Determine view file from Rails path
77
- def determine_view_file_from_path(path)
78
- return nil unless path
79
-
80
- clean_path = path.split('?').first.split('#').first
81
- return nil unless clean_path.start_with?('/')
82
-
83
- parts = clean_path.sub(/\A\//, '').split('/')
84
- return nil if parts.empty?
85
-
86
- find_view_file(parts)
87
- end
88
-
89
- # Find view file based on path parts
90
- def find_view_file(parts)
91
- if parts.length >= 2
92
- controller = parts[0..-2].join('/')
93
- action = parts.last
94
- find_view_for_action(controller, action)
95
- elsif parts.length == 1
96
- find_view_for_action(parts[0], 'index')
97
- end
98
- end
99
-
100
- # Find view file for a specific controller and action
101
- def find_view_for_action(controller, action)
102
- view_paths = VIEW_EXTENSIONS.flat_map do |ext|
103
- [
104
- "app/views/#{controller}/#{action}.html.#{ext}",
105
- "app/views/#{controller}/_#{action}.html.#{ext}"
106
- ]
107
- end
108
-
109
- view_paths.find { |vp| File.exist?(vp) }
7
+ # Convert route to path string for testing
8
+ # @param route [ActionDispatch::Journey::Route] The route object
9
+ # @return [String, nil] The path string or nil if route can't be converted
10
+ def route_to_path(route)
11
+ return nil unless route.respond_to?(:path)
12
+ return nil unless route.verb.to_s.include?('GET')
13
+
14
+ path_spec = route.path.spec.to_s
15
+ path = path_spec.dup
16
+ path.gsub!(/\(\.:format\)/, '')
17
+ path.gsub!(/\(:id\)/, '1')
18
+ path.gsub!(/\(:(\w+)\)/, '1')
19
+ path.gsub!(/\([^)]*\)/, '')
20
+ path = '/' if path.empty? || path == '/'
21
+ path = "/#{path}" unless path.start_with?('/')
22
+ path.include?(':') ? nil : path
110
23
  end
111
24
  end
112
25
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../accessibility_helper'
4
+
3
5
  module RailsAccessibilityTesting
4
6
  module Checks
5
7
  # Base class for all accessibility checks
@@ -24,6 +26,9 @@ module RailsAccessibilityTesting
24
26
  #
25
27
  # @api private
26
28
  class BaseCheck
29
+ # Include partial detection methods from AccessibilityHelper
30
+ include AccessibilityHelper::PartialDetection
31
+
27
32
  attr_reader :page, :context
28
33
 
29
34
  # Initialize the check
@@ -65,19 +70,20 @@ module RailsAccessibilityTesting
65
70
  rule_name: self.class.rule_name,
66
71
  message: message,
67
72
  element_context: element_context,
68
- page_context: page_context,
73
+ page_context: page_context(element_context),
69
74
  wcag_reference: wcag_reference,
70
75
  remediation: remediation
71
76
  )
72
77
  end
73
78
 
74
79
  # Get page context
80
+ # @param element_context [Hash] Optional element context to help find partials
75
81
  # @return [Hash]
76
- def page_context
82
+ def page_context(element_context = nil)
77
83
  {
78
84
  url: safe_page_url,
79
85
  path: safe_page_path,
80
- view_file: determine_view_file
86
+ view_file: determine_view_file(element_context)
81
87
  }
82
88
  end
83
89
 
@@ -123,7 +129,8 @@ module RailsAccessibilityTesting
123
129
  end
124
130
 
125
131
  # Determine likely view file (simplified version)
126
- def determine_view_file
132
+ # Also checks for partials that might contain the element
133
+ def determine_view_file(element_context = nil)
127
134
  return nil unless safe_page_path
128
135
 
129
136
  path = safe_page_path.split('?').first.split('#').first
@@ -134,7 +141,21 @@ module RailsAccessibilityTesting
134
141
  controller = route[:controller]
135
142
  action = route[:action]
136
143
 
137
- find_view_file_for_controller_action(controller, action)
144
+ view_file = find_view_file_for_controller_action(controller, action)
145
+
146
+ # If we found the view file and have element context, check for partials
147
+ if view_file && element_context
148
+ # Scan the view file for rendered partials
149
+ partials_in_view = find_partials_in_view_file(view_file)
150
+
151
+ # Check if element matches any partial in the view
152
+ if partials_in_view.any?
153
+ partial_file = find_partial_for_element_in_list(controller, element_context, partials_in_view)
154
+ return partial_file if partial_file
155
+ end
156
+ end
157
+
158
+ view_file
138
159
  rescue StandardError
139
160
  nil
140
161
  end
@@ -142,12 +163,40 @@ module RailsAccessibilityTesting
142
163
  end
143
164
 
144
165
  # Find view file for controller and action
166
+ # Handles cases where action name doesn't match view file name
145
167
  def find_view_file_for_controller_action(controller, action)
146
168
  extensions = %w[erb haml slim]
169
+ controller_path = "app/views/#{controller}"
170
+
171
+ # First, try exact matches
147
172
  extensions.each do |ext|
148
- view_path = "app/views/#{controller}/#{action}.html.#{ext}"
149
- return view_path if File.exist?(view_path)
173
+ view_paths = [
174
+ "#{controller_path}/#{action}.html.#{ext}",
175
+ "#{controller_path}/_#{action}.html.#{ext}",
176
+ "#{controller_path}/#{action}.#{ext}"
177
+ ]
178
+
179
+ found = view_paths.find { |vp| File.exist?(vp) }
180
+ return found if found
150
181
  end
182
+
183
+ # If exact match not found, scan all view files in the controller directory
184
+ # This handles cases like: search action -> search_result.html.erb
185
+ if File.directory?(controller_path)
186
+ extensions.each do |ext|
187
+ # Look for files that might match the action (e.g., search_result, search_results, etc.)
188
+ pattern = "#{controller_path}/*#{action}*.html.#{ext}"
189
+ matching_files = Dir.glob(pattern)
190
+
191
+ # Prefer files that start with the action name
192
+ preferred = matching_files.find { |f| File.basename(f).start_with?("#{action}_") || File.basename(f).start_with?("#{action}.") }
193
+ return preferred if preferred
194
+
195
+ # Return first match if any found
196
+ return matching_files.first if matching_files.any?
197
+ end
198
+ end
199
+
151
200
  nil
152
201
  end
153
202
  end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAccessibilityTesting
4
+ module Checks
5
+ # Comprehensive heading accessibility checks
6
+ #
7
+ # WCAG 2.1 AA requirements for headings:
8
+ # - 1.3.1 Info and Relationships (Level A): Proper heading hierarchy
9
+ # - 2.4.6 Headings and Labels (Level AA): Descriptive headings
10
+ # - 4.1.2 Name, Role, Value (Level A): Headings must have accessible names
11
+ #
12
+ # @api private
13
+ class HeadingCheck < BaseCheck
14
+ def self.rule_name
15
+ :heading
16
+ end
17
+
18
+ def check
19
+ violations = []
20
+ headings = page.all('h1, h2, h3, h4, h5, h6', visible: true)
21
+
22
+ if headings.empty?
23
+ return violations # Warning only, not error
24
+ end
25
+
26
+ # Check 1: Missing H1
27
+ h1_count = headings.count { |h| h.tag_name == 'h1' }
28
+ first_heading = headings.first
29
+ first_heading_level = first_heading ? first_heading.tag_name[1].to_i : nil
30
+
31
+ if h1_count == 0
32
+ # If the first heading is h2 or higher, provide a more specific message
33
+ if first_heading_level && first_heading_level >= 2
34
+ element_ctx = element_context(first_heading)
35
+ violations << violation(
36
+ message: "Page has h#{first_heading_level} but no h1 heading",
37
+ element_context: element_ctx,
38
+ wcag_reference: "1.3.1",
39
+ remediation: "Add an <h1> heading before the first h#{first_heading_level}:\n\n<h1>Main Page Title</h1>\n<h#{first_heading_level}>#{first_heading.text}</h#{first_heading_level}>"
40
+ )
41
+ else
42
+ violations << violation(
43
+ message: "Page missing H1 heading",
44
+ element_context: { tag: 'page', text: 'Page has no H1 heading' },
45
+ wcag_reference: "1.3.1",
46
+ remediation: "Add an <h1> heading to your page:\n\n<h1>Main Page Title</h1>"
47
+ )
48
+ end
49
+ end
50
+
51
+ # Check 2: Multiple H1s (WCAG 1.3.1)
52
+ if h1_count > 1
53
+ # Report error for each h1 after the first one
54
+ h1_elements = headings.select { |h| h.tag_name == 'h1' }
55
+ h1_elements[1..-1].each do |h1|
56
+ element_ctx = element_context(h1)
57
+ violations << violation(
58
+ message: "Page has multiple h1 headings (#{h1_count} total) - only one h1 should be used per page",
59
+ element_context: element_ctx,
60
+ wcag_reference: "1.3.1",
61
+ remediation: "Use only one <h1> per page. Convert additional h1s to h2 or lower:\n\n<h1>Main Title</h1>\n<h2>Section Title</h2>"
62
+ )
63
+ end
64
+ end
65
+
66
+ # Check 3: Heading hierarchy skipped levels (WCAG 1.3.1)
67
+ previous_level = 0
68
+ headings.each do |heading|
69
+ current_level = heading.tag_name[1].to_i
70
+ if current_level > previous_level + 1
71
+ element_ctx = element_context(heading)
72
+ violations << violation(
73
+ message: "Heading hierarchy skipped (h#{previous_level} to h#{current_level})",
74
+ element_context: element_ctx,
75
+ wcag_reference: "1.3.1",
76
+ remediation: "Fix the heading hierarchy. Don't skip levels. Use h#{previous_level + 1} instead of h#{current_level}."
77
+ )
78
+ end
79
+ previous_level = current_level
80
+ end
81
+
82
+ # Check 4: Empty headings (WCAG 4.1.2)
83
+ headings.each do |heading|
84
+ heading_text = heading.text.strip
85
+ # Check if heading is empty or only contains whitespace/formatting
86
+ if heading_text.empty? || heading_text.match?(/^\s*$/)
87
+ element_ctx = element_context(heading)
88
+ violations << violation(
89
+ message: "Empty heading detected (<#{heading.tag_name}>) - headings must have accessible text",
90
+ element_context: element_ctx,
91
+ wcag_reference: "4.1.2",
92
+ remediation: "Add descriptive text to the heading:\n\n<#{heading.tag_name}>Descriptive Heading Text</#{heading.tag_name}>"
93
+ )
94
+ end
95
+ end
96
+
97
+ # Check 5: Headings with only images (no alt text or empty alt) (WCAG 4.1.2)
98
+ headings.each do |heading|
99
+ # Check if heading contains only images
100
+ images = heading.all('img', visible: true)
101
+ if images.any? && heading.text.strip.empty?
102
+ # Check if all images have alt text
103
+ images_without_alt = images.select { |img| img[:alt].nil? || img[:alt].strip.empty? }
104
+ if images_without_alt.any?
105
+ element_ctx = element_context(heading)
106
+ violations << violation(
107
+ message: "Heading contains images without alt text - headings must have accessible text",
108
+ element_context: element_ctx,
109
+ wcag_reference: "4.1.2",
110
+ remediation: "Add alt text to images in the heading, or add visible text alongside the images:\n\n<h1><img src=\"image.jpg\" alt=\"Descriptive text\">Heading Text</h1>"
111
+ )
112
+ end
113
+ end
114
+ end
115
+
116
+ # Check 6: Headings used only for styling (WCAG 2.4.6)
117
+ # This is harder to detect automatically, but we can check for very short or generic text
118
+ headings.each do |heading|
119
+ heading_text = heading.text.strip
120
+ # Very short headings (1-2 characters) might be styling-only
121
+ # Generic text like "•", "→", "..." are likely styling
122
+ if heading_text.length <= 2 && heading_text.match?(/^[•→…\s\-_=]+$/)
123
+ element_ctx = element_context(heading)
124
+ violations << violation(
125
+ message: "Heading appears to be used for styling only (text: '#{heading_text}') - headings should be descriptive",
126
+ element_context: element_ctx,
127
+ wcag_reference: "2.4.6",
128
+ remediation: "Use CSS for styling instead of headings. Replace with a <div> or <span> with appropriate CSS classes."
129
+ )
130
+ end
131
+ end
132
+
133
+ violations
134
+ end
135
+ end
136
+ end
137
+ end
138
+
@@ -15,14 +15,14 @@ module RailsAccessibilityTesting
15
15
  def check
16
16
  violations = []
17
17
 
18
+ # Use native attribute access instead of JavaScript evaluation for better performance
18
19
  page.all('img', visible: :all).each do |img|
19
- has_alt_attribute = page.evaluate_script("arguments[0].hasAttribute('alt')", img.native)
20
- # Get alt value - might be nil, empty string, or actual text
21
- alt_value = img[:alt] || ""
22
- # Also check via JavaScript to be sure
23
- alt_value_js = page.evaluate_script("arguments[0].getAttribute('alt')", img.native) || ""
20
+ # Get alt value directly from Capybara element (faster than JavaScript)
21
+ alt_value = img[:alt]
22
+ # Check if alt attribute exists (nil means missing, empty string means present but empty)
23
+ has_alt_attribute = img.native.attribute('alt') != nil rescue false
24
24
 
25
- if has_alt_attribute == false
25
+ if !has_alt_attribute
26
26
  element_ctx = element_context(img)
27
27
 
28
28
  violations << violation(
@@ -31,7 +31,7 @@ module RailsAccessibilityTesting
31
31
  wcag_reference: "1.1.1",
32
32
  remediation: generate_remediation(element_ctx)
33
33
  )
34
- elsif (alt_value.blank? || alt_value_js.blank?) && has_alt_attribute
34
+ elsif alt_value.blank? && has_alt_attribute
35
35
  # Image has alt attribute but it's empty - warn about this
36
36
  # Empty alt is valid for decorative images, but we should check if it's actually decorative
37
37
  element_ctx = element_context(img)
@@ -23,7 +23,17 @@ module RailsAccessibilityTesting
23
23
  aria_labelledby = element[:"aria-labelledby"]
24
24
  title = element[:title]
25
25
 
26
- if text.blank? && aria_label.blank? && aria_labelledby.blank? && title.blank?
26
+ # Check if element contains an image with alt text (common pattern for logo links)
27
+ has_image_with_alt = false
28
+ if text.blank?
29
+ images = element.all('img', visible: :all)
30
+ has_image_with_alt = images.any? do |img|
31
+ alt = img[:alt]
32
+ alt.present? && !alt.strip.empty?
33
+ end
34
+ end
35
+
36
+ if text.blank? && aria_label.blank? && aria_labelledby.blank? && title.blank? && !has_image_with_alt
27
37
  element_ctx = element_context(element)
28
38
  tag = element.tag_name
29
39
 
@@ -335,9 +335,11 @@ module RailsAccessibilityTesting
335
335
  end
336
336
 
337
337
  def generate_human_report(results)
338
+ timestamp = Time.now.strftime("%H:%M:%S")
339
+
338
340
  output = []
339
341
  output << "=" * 70
340
- output << "Rails A11y Accessibility Report"
342
+ output << "Rails A11y Accessibility Report • #{timestamp}"
341
343
  output << "=" * 70
342
344
  output << ""
343
345
  output << "Summary:"
@@ -116,7 +116,7 @@ module RailsAccessibilityTesting
116
116
  'form_labels' => true,
117
117
  'image_alt_text' => true,
118
118
  'interactive_elements' => true,
119
- 'heading_hierarchy' => true,
119
+ 'heading' => true,
120
120
  'keyboard_accessibility' => true,
121
121
  'aria_landmarks' => true,
122
122
  'form_errors' => true,
@@ -25,22 +25,45 @@ module RailsAccessibilityTesting
25
25
  # Run all enabled checks against a page
26
26
  # @param page [Capybara::Session] The page to check
27
27
  # @param context [Hash] Additional context (url, path, etc.)
28
+ # @param progress_callback [Proc] Optional callback for progress updates
28
29
  # @return [Array<Violation>] Array of violations found
29
- def check(page, context: {})
30
+ def check(page, context: {}, progress_callback: nil)
30
31
  @violation_collector.reset
31
32
 
32
- enabled_checks.each do |check_class|
33
- next if rule_ignored?(check_class.rule_name)
33
+ checks_to_run = enabled_checks.reject { |check_class| rule_ignored?(check_class.rule_name) }
34
+ total_checks = checks_to_run.length
35
+
36
+ checks_to_run.each_with_index do |check_class, index|
37
+ check_number = index + 1
38
+ check_name = humanize_check_name(check_class.rule_name)
39
+
40
+ # Report progress
41
+ if progress_callback
42
+ progress_callback.call(check_number, total_checks, check_name, :start)
43
+ end
34
44
 
35
45
  begin
36
46
  check_instance = check_class.new(page: page, context: context)
37
47
  violations = check_instance.run
38
- @violation_collector.add(violations) if violations.any?
48
+
49
+ if violations.any?
50
+ @violation_collector.add(violations)
51
+ if progress_callback
52
+ progress_callback.call(check_number, total_checks, check_name, :found_issues, violations.length)
53
+ end
54
+ else
55
+ if progress_callback
56
+ progress_callback.call(check_number, total_checks, check_name, :passed)
57
+ end
58
+ end
39
59
  rescue StandardError => e
40
60
  # Log but don't fail - one check error shouldn't stop others
41
61
  if defined?(RailsAccessibilityTesting) && RailsAccessibilityTesting.config.respond_to?(:logger) && RailsAccessibilityTesting.config.logger
42
62
  RailsAccessibilityTesting.config.logger.error("Check #{check_class.rule_name} failed: #{e.message}")
43
63
  end
64
+ if progress_callback
65
+ progress_callback.call(check_number, total_checks, check_name, :error, e.message)
66
+ end
44
67
  end
45
68
  end
46
69
 
@@ -55,7 +78,7 @@ module RailsAccessibilityTesting
55
78
  Checks::FormLabelsCheck,
56
79
  Checks::ImageAltTextCheck,
57
80
  Checks::InteractiveElementsCheck,
58
- Checks::HeadingHierarchyCheck,
81
+ Checks::HeadingCheck,
59
82
  Checks::KeyboardAccessibilityCheck,
60
83
  Checks::AriaLandmarksCheck,
61
84
  Checks::FormErrorsCheck,
@@ -93,6 +116,27 @@ module RailsAccessibilityTesting
93
116
  .gsub(/([a-z\d])([A-Z])/, '\1_\2')
94
117
  .downcase
95
118
  end
119
+
120
+ # Convert rule name to human-readable check name
121
+ def humanize_check_name(rule_name)
122
+ # Map of rule names to friendly display names
123
+ friendly_names = {
124
+ 'form_labels' => 'Form Labels',
125
+ 'image_alt_text' => 'Image Alt Text',
126
+ 'interactive_elements' => 'Interactive Elements',
127
+ 'heading' => 'Heading Hierarchy',
128
+ 'keyboard_accessibility' => 'Keyboard Accessibility',
129
+ 'aria_landmarks' => 'ARIA Landmarks',
130
+ 'form_errors' => 'Form Error Associations',
131
+ 'table_structure' => 'Table Structure',
132
+ 'duplicate_ids' => 'Duplicate IDs',
133
+ 'skip_links' => 'Skip Links',
134
+ 'color_contrast' => 'Color Contrast'
135
+ }
136
+
137
+ rule_str = rule_name.to_s
138
+ friendly_names[rule_str] || rule_str.split('_').map(&:capitalize).join(' ')
139
+ end
96
140
  end
97
141
  end
98
142
  end
@@ -45,14 +45,18 @@ module RailsAccessibilityTesting
45
45
  end
46
46
 
47
47
  def page_info(page_context)
48
- lines = [
49
- '📄 Page Being Tested:',
50
- " URL: #{page_context[:url] || '(unknown)'}",
51
- " Path: #{page_context[:path] || '(unknown)'}"
52
- ]
53
-
48
+ lines = ['📄 Page Being Tested:']
49
+
50
+ # Show view file first and prominently if available
54
51
  if page_context[:view_file]
55
- lines << " 📝 Likely View File: #{page_context[:view_file]}"
52
+ lines << " 📝 View File: #{page_context[:view_file]}"
53
+ lines << " 🔗 Path: #{page_context[:path] || '(unknown)'}"
54
+ lines << " 🌐 URL: #{page_context[:url] || '(unknown)'}" if page_context[:url] && !page_context[:url].include?('127.0.0.1')
55
+ else
56
+ # Fallback to path/URL if view file not found
57
+ lines << " 🔗 Path: #{page_context[:path] || '(unknown)'}"
58
+ lines << " 🌐 URL: #{page_context[:url] || '(unknown)'}" if page_context[:url]
59
+ lines << " ⚠️ View file not detected - check path: #{page_context[:path]}"
56
60
  end
57
61
 
58
62
  lines.join("\n") + "\n"
@@ -109,8 +113,14 @@ module RailsAccessibilityTesting
109
113
  interactive_element_remediation(error_type, element_context)
110
114
  when /Page missing H1 heading/i
111
115
  missing_h1_remediation
116
+ when /multiple h1|Multiple h1/i
117
+ multiple_h1_remediation(error_type, element_context)
112
118
  when /Heading hierarchy skipped/i
113
119
  heading_hierarchy_remediation(error_type, element_context)
120
+ when /Empty heading|heading.*empty/i
121
+ empty_heading_remediation(element_context)
122
+ when /heading.*styling|styling.*heading/i
123
+ heading_styling_remediation(element_context)
114
124
  when /Modal dialog has no focusable elements/i
115
125
  modal_remediation
116
126
  when /Page missing MAIN landmark/i
@@ -134,8 +144,14 @@ module RailsAccessibilityTesting
134
144
  interactive_element_remediation(error_type, element_context)
135
145
  when /missing.*H1/i
136
146
  missing_h1_remediation
147
+ when /multiple.*h1/i
148
+ multiple_h1_remediation(error_type, element_context)
137
149
  when /hierarchy.*skipped/i
138
150
  heading_hierarchy_remediation(error_type, element_context)
151
+ when /empty.*heading|heading.*empty/i
152
+ empty_heading_remediation(element_context)
153
+ when /heading.*styling|styling.*heading/i
154
+ heading_styling_remediation(element_context)
139
155
  when /modal.*focusable/i
140
156
  modal_remediation
141
157
  when /missing.*MAIN/i
@@ -228,6 +244,46 @@ module RailsAccessibilityTesting
228
244
  remediation
229
245
  end
230
246
 
247
+ def multiple_h1_remediation(error_type, element_context)
248
+ # Extract h1 count from error message if available
249
+ match = error_type.match(/(\d+)\s+total/)
250
+ h1_count = match ? match[1] : "multiple"
251
+
252
+ remediation = " Fix multiple h1 headings:\n\n"
253
+ remediation += " Current: Page has #{h1_count} <h1> headings\n"
254
+ remediation += " Should be: Only one <h1> per page\n\n"
255
+ remediation += " Example fix:\n"
256
+ remediation += " <h1>Main Page Title</h1>\n"
257
+ remediation += " <h2>Section Title</h2> ← Convert additional h1s to h2\n\n"
258
+ remediation += " 💡 Best Practice: Use only one <h1> for the main page title.\n"
259
+ remediation += " Use <h2> for major sections, <h3> for subsections, etc.\n"
260
+ remediation
261
+ end
262
+
263
+ def empty_heading_remediation(element_context)
264
+ tag = element_context[:tag] || "h1"
265
+ remediation = " Fix empty heading:\n\n"
266
+ remediation += " Current: <#{tag}></#{tag}>\n"
267
+ remediation += " Should be: <#{tag}>Descriptive Heading Text</#{tag}>\n\n"
268
+ remediation += " Example:\n"
269
+ remediation += " <#{tag}>Introduction to Our Services</#{tag}>\n\n"
270
+ remediation += " 💡 Best Practice: Headings must have accessible text.\n"
271
+ remediation += " Screen readers need text content to announce headings.\n"
272
+ remediation
273
+ end
274
+
275
+ def heading_styling_remediation(element_context)
276
+ remediation = " Fix heading used for styling:\n\n"
277
+ remediation += " Current: Using <h1> or <h2> for visual styling only\n"
278
+ remediation += " Should be: Use CSS classes for styling\n\n"
279
+ remediation += " Example fix:\n"
280
+ remediation += " ❌ <h1>•</h1> (bad - heading for styling)\n"
281
+ remediation += " ✅ <div class=\"decorative-bullet\">•</div> (good - div with CSS)\n\n"
282
+ remediation += " 💡 Best Practice: Headings should be semantic, not decorative.\n"
283
+ remediation += " Use <div> or <span> with CSS classes for visual styling.\n"
284
+ remediation
285
+ end
286
+
231
287
  def heading_hierarchy_remediation(error_type, element_context)
232
288
  # Extract heading levels from error_type: "HEADING hierarchy skipped (h1 to h3)"
233
289
  match = error_type.match(/h(\d+) to h(\d+)/)