rails_accessibility_testing 1.5.8 → 1.6.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.
@@ -6,6 +6,12 @@ module RailsAccessibilityTesting
6
6
  #
7
7
  # WCAG 2.1 AA: 2.4.4 Link Purpose (Level A), 4.1.2 Name, Role, Value (Level A)
8
8
  #
9
+ # @note Links with href="#":
10
+ # - Only flags anchors with href="#" that have NO accessible name
11
+ # - An accessible name can be: visible text, aria-label, or aria-labelledby
12
+ # - Links with href="#" that have visible text or ARIA attributes are valid and NOT flagged
13
+ # - This avoids false positives for valid anchor links that use href="#" with proper labeling
14
+ #
9
15
  # @api private
10
16
  class InteractiveElementsCheck < BaseCheck
11
17
  def self.rule_name
@@ -45,6 +51,7 @@ module RailsAccessibilityTesting
45
51
  aria_label = element[:"aria-label"]
46
52
  aria_labelledby = element[:"aria-labelledby"]
47
53
  title = element[:title]
54
+ href = element[:href]
48
55
 
49
56
  # Check if element contains ERB placeholder (for static scanning)
50
57
  # ErbExtractor replaces <%= ... %> with "ERB_CONTENT" so we can detect it
@@ -68,12 +75,24 @@ module RailsAccessibilityTesting
68
75
  aria_labelledby_empty = aria_labelledby.nil? || aria_labelledby.to_s.strip.empty?
69
76
  title_empty = title.nil? || title.to_s.strip.empty?
70
77
 
78
+ # Only report violation if element has no accessible name
79
+ # For links with href="#", we only flag if they have no text AND no aria-label AND no aria-labelledby
80
+ # Links with href="#" that have visible text or ARIA attributes are valid and should not be flagged
71
81
  if text_empty && aria_label_empty && aria_labelledby_empty && title_empty && !has_image_with_alt
72
82
  element_ctx = element_context(element)
73
83
  tag = element.tag_name
74
84
 
85
+ # Special message for empty links with href="#"
86
+ # This rule correctly detects anchors with href="#" that have no accessible name,
87
+ # but avoids false positives when they have visible text or aria-label/aria-labelledby
88
+ message = if tag == 'a' && (href == '#' || href.to_s.strip == '#')
89
+ "Link missing accessible name [href: #]"
90
+ else
91
+ "#{tag.capitalize} missing accessible name"
92
+ end
93
+
75
94
  violations << violation(
76
- message: "#{tag.capitalize} missing accessible name",
95
+ message: message,
77
96
  element_context: element_ctx,
78
97
  wcag_reference: tag == 'a' ? "2.4.4" : "4.1.2",
79
98
  remediation: generate_remediation(tag, element_ctx)
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+ require_relative 'view_composition_builder'
5
+ require_relative 'erb_extractor'
6
+
7
+ module RailsAccessibilityTesting
8
+ # Scans a complete composed page (layout + view + partials) for heading hierarchy
9
+ # This ensures heading hierarchy is checked across the entire page, not just individual files
10
+ #
11
+ # @api private
12
+ class ComposedPageScanner
13
+ def initialize(view_file)
14
+ @view_file = view_file
15
+ @builder = ViewCompositionBuilder.new(view_file)
16
+ end
17
+
18
+ # Scan the complete composed page for heading hierarchy violations
19
+ # @return [Hash] Hash with :errors and :warnings arrays
20
+ def scan_heading_hierarchy
21
+ return { errors: [], warnings: [] } unless @view_file && File.exist?(@view_file)
22
+
23
+ # Build composition
24
+ all_files = @builder.build
25
+ return { errors: [], warnings: [] } if all_files.empty?
26
+
27
+ # Get all headings from complete composition
28
+ headings = @builder.all_headings
29
+ return { errors: [], warnings: [] } if headings.empty?
30
+
31
+ violations = []
32
+
33
+ # Check 1: Missing H1
34
+ h1_count = headings.count { |h| h[:level] == 1 }
35
+ first_heading = headings.first
36
+
37
+ if h1_count == 0
38
+ if first_heading
39
+ violations << create_violation(
40
+ message: "Page has h#{first_heading[:level]} but no h1 heading (checked complete page: layout + view + partials)",
41
+ heading: first_heading,
42
+ wcag_reference: "1.3.1",
43
+ remediation: "Add an <h1> heading to your page. The H1 can be in the layout, view, or a partial:\n\n<h1>Main Page Title</h1>"
44
+ )
45
+ else
46
+ violations << create_violation(
47
+ message: "Page missing H1 heading (checked complete page: layout + view + partials)",
48
+ heading: nil,
49
+ wcag_reference: "1.3.1",
50
+ remediation: "Add an <h1> heading to your page. The H1 can be in the layout, view, or a partial:\n\n<h1>Main Page Title</h1>"
51
+ )
52
+ end
53
+ end
54
+
55
+ # Check 2: Multiple H1s
56
+ if h1_count > 1
57
+ h1_headings = headings.select { |h| h[:level] == 1 }
58
+ h1_headings[1..-1].each do |h1|
59
+ violations << create_violation(
60
+ message: "Page has multiple h1 headings (#{h1_count} total) - only one h1 should be used per page (checked complete page: layout + view + partials)",
61
+ heading: h1,
62
+ wcag_reference: "1.3.1",
63
+ 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>"
64
+ )
65
+ end
66
+ end
67
+
68
+ # Check 3: Heading hierarchy skipped levels
69
+ # Only flag if we're skipping from a real heading (not from h0)
70
+ # If the first heading is h2, that's fine - it just means no h1 exists (handled by Check 1)
71
+ previous_level = nil
72
+ headings.each do |heading|
73
+ current_level = heading[:level]
74
+ if previous_level && current_level > previous_level + 1
75
+ # Only flag if we're skipping from a real heading level
76
+ violations << create_violation(
77
+ message: "Heading hierarchy skipped (h#{previous_level} to h#{current_level}) - checked complete page: layout + view + partials",
78
+ heading: heading,
79
+ wcag_reference: "1.3.1",
80
+ remediation: "Fix the heading hierarchy. Don't skip levels. Use h#{previous_level + 1} instead of h#{current_level}."
81
+ )
82
+ end
83
+ previous_level = current_level
84
+ end
85
+
86
+ # Convert to errors/warnings format (matching ViolationConverter format)
87
+ errors = violations.map do |v|
88
+ {
89
+ type: v[:message],
90
+ element: {
91
+ tag: v[:heading] ? "h#{v[:heading][:level]}" : 'page',
92
+ text: v[:heading] ? v[:heading][:text] : 'Page-level heading hierarchy check',
93
+ file: v[:file] || @view_file
94
+ },
95
+ file: v[:file] || @view_file,
96
+ line: v[:line] || 1,
97
+ wcag: v[:wcag_reference],
98
+ remediation: v[:remediation],
99
+ page_level_check: true,
100
+ check_type: 'heading_hierarchy'
101
+ }
102
+ end
103
+
104
+ { errors: errors, warnings: [] }
105
+ end
106
+
107
+ # Scan the complete composed page for all heading issues (not just hierarchy)
108
+ # This includes: empty headings, styling-only headings, etc.
109
+ # @return [Hash] Hash with :errors and :warnings arrays
110
+ def scan_all_headings
111
+ return { errors: [], warnings: [] } unless @view_file && File.exist?(@view_file)
112
+
113
+ # Build composition
114
+ all_files = @builder.build
115
+ return { errors: [], warnings: [] } if all_files.empty?
116
+
117
+ # Get all headings from complete composition
118
+ headings = @builder.all_headings
119
+ return { errors: [], warnings: [] } if headings.empty?
120
+
121
+ errors = []
122
+ warnings = []
123
+
124
+ # Check for empty headings (across complete page)
125
+ headings.each do |heading|
126
+ heading_text = heading[:text]
127
+
128
+ # Check if heading contains ERB placeholder
129
+ has_erb_content = heading_text.include?('ERB_CONTENT')
130
+
131
+ # Check if heading is empty or only contains whitespace
132
+ if (heading_text.empty? || heading_text.match?(/^\s*$/)) && !has_erb_content
133
+ errors << {
134
+ type: "Empty heading detected (<h#{heading[:level]}>) - headings must have accessible text (checked complete page: layout + view + partials)",
135
+ element: {
136
+ tag: "h#{heading[:level]}",
137
+ text: 'Empty heading',
138
+ file: heading[:file]
139
+ },
140
+ file: heading[:file],
141
+ line: heading[:line],
142
+ wcag: "4.1.2",
143
+ remediation: "Add descriptive text to the heading:\n\n<h#{heading[:level]}>Descriptive Heading Text</h#{heading[:level]}>",
144
+ page_level_check: true,
145
+ check_type: 'heading_empty'
146
+ }
147
+ end
148
+
149
+ # Check for styling-only headings (very short or generic text)
150
+ if heading_text.length <= 2 && heading_text.match?(/^[•→…\s\-_=]+$/)
151
+ warnings << {
152
+ type: "Heading appears to be used for styling only (text: '#{heading_text}') - headings should be descriptive (checked complete page: layout + view + partials)",
153
+ element: {
154
+ tag: "h#{heading[:level]}",
155
+ text: heading_text,
156
+ file: heading[:file]
157
+ },
158
+ file: heading[:file],
159
+ line: heading[:line],
160
+ wcag: "2.4.6",
161
+ remediation: "Use CSS for styling instead of headings. Replace with a <div> or <span> with appropriate CSS classes.",
162
+ page_level_check: true,
163
+ check_type: 'heading_styling'
164
+ }
165
+ end
166
+ end
167
+
168
+ { errors: errors, warnings: warnings }
169
+ end
170
+
171
+ # Scan the complete composed page for duplicate IDs
172
+ # IDs must be unique across the entire page (layout + view + partials)
173
+ # @return [Hash] Hash with :errors and :warnings arrays
174
+ def scan_duplicate_ids
175
+ return { errors: [], warnings: [] } unless @view_file && File.exist?(@view_file)
176
+
177
+ # Build composition
178
+ all_files = @builder.build
179
+ return { errors: [], warnings: [] } if all_files.empty?
180
+
181
+ # Collect all IDs from complete composition
182
+ id_map = {} # id => [{ file: String, line: Integer, element: String }]
183
+
184
+ all_files.each do |file|
185
+ # Handle both relative and absolute paths
186
+ file_path = if File.exist?(file)
187
+ file
188
+ elsif defined?(Rails) && Rails.root
189
+ rails_path = Rails.root.join(file)
190
+ rails_path.exist? ? rails_path.to_s : nil
191
+ else
192
+ nil
193
+ end
194
+ next unless file_path && File.exist?(file_path)
195
+
196
+ content = File.read(file_path)
197
+ html_content = ErbExtractor.extract_html(content)
198
+ doc = Nokogiri::HTML::DocumentFragment.parse(html_content)
199
+
200
+ # Find all elements with IDs
201
+ doc.css('[id]').each do |element|
202
+ id = element[:id]
203
+ next if id.nil? || id.to_s.strip.empty?
204
+ # Skip ERB_CONTENT placeholder - it's not a real ID
205
+ next if id == 'ERB_CONTENT'
206
+ # Skip IDs that contain ERB_CONTENT - these are dynamic IDs that can't be statically verified
207
+ # Example: "collection_answers_ERB_CONTENT_ERB_CONTENT_" - the actual IDs will be different at runtime
208
+ # because the ERB expressions will evaluate to different values
209
+ next if id.include?('ERB_CONTENT')
210
+
211
+ id_map[id] ||= []
212
+ line = find_line_number_for_element(content, element)
213
+ id_map[id] << {
214
+ file: file_path, # Use resolved path
215
+ line: line,
216
+ element: element.name
217
+ }
218
+ end
219
+ end
220
+
221
+ errors = []
222
+
223
+ # Find duplicate IDs
224
+ id_map.each do |id, occurrences|
225
+ if occurrences.length > 1
226
+ # Report all occurrences after the first one
227
+ occurrences[1..-1].each do |occurrence|
228
+ errors << {
229
+ type: "Duplicate ID '#{id}' found (checked complete page: layout + view + partials) - IDs must be unique across the entire page",
230
+ element: {
231
+ tag: occurrence[:element],
232
+ id: id,
233
+ file: occurrence[:file]
234
+ },
235
+ file: occurrence[:file],
236
+ line: occurrence[:line],
237
+ wcag: "4.1.1",
238
+ remediation: "Remove or rename the duplicate ID. Each ID must be unique on the page:\n\n<!-- Change one of these -->\n<div id=\"#{id}\">...</div>\n<div id=\"#{id}\">...</div>\n\n<!-- To -->\n<div id=\"#{id}\">...</div>\n<div id=\"#{id}-2\">...</div>",
239
+ page_level_check: true,
240
+ check_type: 'duplicate_ids'
241
+ }
242
+ end
243
+ end
244
+ end
245
+
246
+ { errors: errors, warnings: [] }
247
+ end
248
+
249
+ # Helper to find line number for an element
250
+ def find_line_number_for_element(content, element)
251
+ tag_name = element.name
252
+ id = element[:id]
253
+
254
+ lines = content.split("\n")
255
+ lines.each_with_index do |line, index|
256
+ if line.include?("<#{tag_name}") && (id.nil? || line.include?("id=\"#{id}\"") || line.include?("id='#{id}'"))
257
+ return index + 1
258
+ end
259
+ end
260
+
261
+ 1
262
+ end
263
+
264
+ # Scan the complete composed page for ARIA landmarks
265
+ # This ensures landmarks in layout are detected when scanning view files
266
+ # @return [Hash] Hash with :errors and :warnings arrays
267
+ def scan_aria_landmarks
268
+ return { errors: [], warnings: [] } unless @view_file && File.exist?(@view_file)
269
+
270
+ # Build composition
271
+ all_files = @builder.build
272
+ return { errors: [], warnings: [] } if all_files.empty?
273
+
274
+ # Check for main landmark across all files
275
+ has_main = false
276
+ main_location = nil
277
+
278
+ all_files.each do |file|
279
+ next unless File.exist?(file)
280
+
281
+ content = File.read(file)
282
+ html_content = ErbExtractor.extract_html(content)
283
+ doc = Nokogiri::HTML::DocumentFragment.parse(html_content)
284
+
285
+ # Check for <main> tag or [role="main"]
286
+ main_elements = doc.css('main, [role="main"]')
287
+ if main_elements.any?
288
+ has_main = true
289
+ main_location = file
290
+ break
291
+ end
292
+ end
293
+
294
+ warnings = []
295
+
296
+ # Only report missing main if it's truly missing from the complete page
297
+ unless has_main
298
+ warnings << {
299
+ type: "Page missing MAIN landmark (checked complete page: layout + view + partials)",
300
+ element: {
301
+ tag: 'page',
302
+ text: 'Page-level ARIA landmark check'
303
+ },
304
+ file: @view_file,
305
+ line: 1,
306
+ wcag: "1.3.1",
307
+ remediation: "Add a <main> landmark to your page. It can be in the layout, view, or a partial:\n\n<main id=\"maincontent\">\n <!-- Page content -->\n</main>",
308
+ page_level_check: true,
309
+ check_type: 'aria_landmarks'
310
+ }
311
+ end
312
+
313
+ { errors: [], warnings: warnings }
314
+ end
315
+
316
+ private
317
+
318
+ def create_violation(message:, heading:, wcag_reference:, remediation:)
319
+ {
320
+ message: message,
321
+ file: heading ? heading[:file] : @view_file,
322
+ line: heading ? heading[:line] : 1,
323
+ wcag_reference: wcag_reference,
324
+ remediation: remediation,
325
+ heading: heading # Keep heading info for element context
326
+ }
327
+ end
328
+ end
329
+ end
330
+
@@ -84,7 +84,8 @@ module RailsAccessibilityTesting
84
84
  merged_system_specs = base_system_specs.merge(profile_system_specs)
85
85
 
86
86
  base_config.merge(
87
- 'enabled' => profile_config.fetch('enabled', base_config.fetch('enabled', true)), # Profile can override enabled, default to true
87
+ # Support both 'accessibility_enabled' (new) and 'enabled' (legacy) for backward compatibility
88
+ 'accessibility_enabled' => profile_config.fetch('accessibility_enabled', profile_config.fetch('enabled', base_config.fetch('accessibility_enabled', base_config.fetch('enabled', true)))), # Profile can override, supports legacy 'enabled' key
88
89
  'checks' => merged_checks,
89
90
  'summary' => merged_summary,
90
91
  'scan_strategy' => profile_config['scan_strategy'] || base_config['scan_strategy'] || 'paths',
@@ -123,7 +124,7 @@ module RailsAccessibilityTesting
123
124
  # Default configuration when no file exists
124
125
  def default_config
125
126
  {
126
- 'enabled' => true, # Global enable/disable flag for all accessibility checks
127
+ 'accessibility_enabled' => true, # Global enable/disable flag for all accessibility checks
127
128
  'wcag_level' => 'AA',
128
129
  'checks' => default_checks,
129
130
  'summary' => {
@@ -4,6 +4,12 @@ module RailsAccessibilityTesting
4
4
  # Extracts HTML from ERB templates by converting Rails helpers to HTML
5
5
  # This allows static analysis of view files without rendering them
6
6
  #
7
+ # @note ERB and ID handling:
8
+ # - Dynamic IDs like "collection_answers_<%= question.id %>_<%= option.id %>_" are preserved
9
+ # - ERB expressions are replaced with "ERB_CONTENT" placeholder to maintain structure
10
+ # - This ensures IDs like "collection_answers_ERB_CONTENT_ERB_CONTENT_" are not collapsed
11
+ # - Labels with matching ERB structure will also have "ERB_CONTENT" and can be matched
12
+ #
7
13
  # @api private
8
14
  class ErbExtractor
9
15
  # Convert ERB template to HTML for static analysis
@@ -26,8 +32,57 @@ module RailsAccessibilityTesting
26
32
 
27
33
  private
28
34
 
35
+ # Convert raw HTML elements that have ERB in their attributes
36
+ # This handles cases like: <input id="collection_answers_<%= question.id %>_<%= option.id %>_">
37
+ # We need to preserve the structure of dynamic IDs so they don't get collapsed
38
+ #
39
+ # @note This must run BEFORE remove_erb_tags to preserve ERB structure in attributes
40
+ def convert_raw_html_with_erb
41
+ # Handle input elements (checkbox, radio, text, etc.) with ERB in attributes
42
+ # Pattern: <input ... id="...<%= ... %>..." ... />
43
+ @content.gsub!(/<input\s+([^>]*?)>/i) do |match|
44
+ attrs = $1
45
+ # Replace ERB in attributes with placeholder, preserving structure
46
+ # This ensures "id='collection_answers_<%= question.id %>_<%= option.id %>_'"
47
+ # becomes "id='collection_answers_ERB_CONTENT_ERB_CONTENT_'"
48
+ attrs_with_placeholders = attrs.gsub(/<%=(.*?)%>/m, 'ERB_CONTENT')
49
+ "<input #{attrs_with_placeholders}>"
50
+ end
51
+
52
+ # Handle label_tag with string interpolation in first argument: <%= label_tag "collection_answers_#{question.id}_#{option.id}_", option.value %>
53
+ # This handles string interpolation like "collection_answers_#{question.id}_#{option.id}_"
54
+ # Must match BEFORE the simple string pattern
55
+ @content.gsub!(/<%=\s*label_tag\s+["']([^"']*#\{[^}]+\}[^"']*)["'],\s*([^,]+)(?:,\s*[^%]*)?%>/) do
56
+ id_template = $1
57
+ text_expr = $2
58
+ # Replace Ruby interpolation with placeholder
59
+ id_with_placeholder = id_template.gsub(/#\{[^}]+\}/, 'ERB_CONTENT')
60
+ # Handle text expression - could be a variable, string, or method call
61
+ text_placeholder = if text_expr.strip.match?(/^["']/)
62
+ # It's a string literal
63
+ text_expr.strip.gsub(/^["']|["']$/, '')
64
+ else
65
+ # It's a variable or method call - will produce content at runtime
66
+ 'ERB_CONTENT'
67
+ end
68
+ "<label for=\"#{id_with_placeholder}\">#{text_placeholder}</label>"
69
+ end
70
+
71
+ # Handle label_tag helper with simple string: <%= label_tag "id_string", "text", options %>
72
+ # Pattern: label_tag "id_string", "text", options
73
+ @content.gsub!(/<%=\s*label_tag\s+["']([^"']+)["'],\s*["']?([^"']*?)["']?[^%]*%>/) do
74
+ id = $1
75
+ text = $2
76
+ # Replace ERB in id string with placeholder (if any)
77
+ id_with_placeholder = id.gsub(/<%=(.*?)%>/m, 'ERB_CONTENT')
78
+ "<label for=\"#{id_with_placeholder}\">#{text}</label>"
79
+ end
80
+ end
81
+
29
82
  # Convert Rails helpers to placeholder HTML
30
83
  def convert_rails_helpers
84
+ # First, handle raw HTML elements with ERB in attributes (before removing ERB tags)
85
+ convert_raw_html_with_erb
31
86
  convert_form_helpers
32
87
  convert_image_helpers
33
88
  convert_link_helpers
@@ -52,7 +52,8 @@ module RailsAccessibilityTesting
52
52
  require 'rails_accessibility_testing/config/yaml_loader'
53
53
  profile = defined?(Rails) && Rails.env.test? ? :test : :development
54
54
  config = Config::YamlLoader.load(profile: profile)
55
- enabled = config.fetch('enabled', true) # Default to enabled if not specified
55
+ # Support both 'accessibility_enabled' (new) and 'enabled' (legacy) for backward compatibility
56
+ enabled = config.fetch('accessibility_enabled', config.fetch('enabled', true)) # Default to enabled if not specified
56
57
  !enabled # Return true if disabled
57
58
  rescue StandardError
58
59
  false # If config can't be loaded, assume enabled
@@ -36,7 +36,8 @@ module RailsAccessibilityTesting
36
36
  # Check if accessibility checks are globally disabled
37
37
  begin
38
38
  config = Config::YamlLoader.load(profile: :test)
39
- enabled = config.fetch('enabled', true)
39
+ # Support both 'accessibility_enabled' (new) and 'enabled' (legacy) for backward compatibility
40
+ enabled = config.fetch('accessibility_enabled', config.fetch('enabled', true))
40
41
  return { errors: [], warnings: [] } unless enabled
41
42
  rescue StandardError
42
43
  # If config can't be loaded, continue (assume enabled)
@@ -70,14 +71,56 @@ module RailsAccessibilityTesting
70
71
  # Run all enabled checks using existing RuleEngine
71
72
  violations = engine.check(static_page, context: context)
72
73
 
73
- # Convert violations to errors/warnings format with line numbers
74
+ # For page-level checks, use composed page scanner
75
+ # This checks the complete page (layout + view + partials) for:
76
+ # - All heading checks (hierarchy, empty, styling-only)
77
+ # - ARIA landmarks (main, etc.)
78
+ # - Duplicate IDs (must be unique across entire page)
79
+ require_relative 'composed_page_scanner'
80
+ composed_scanner = ComposedPageScanner.new(@view_file)
81
+ hierarchy_result = composed_scanner.scan_heading_hierarchy
82
+ all_headings_result = composed_scanner.scan_all_headings
83
+ landmarks_result = composed_scanner.scan_aria_landmarks
84
+ duplicate_ids_result = composed_scanner.scan_duplicate_ids
85
+
86
+ # Filter out page-level violations from regular violations
87
+ # (we'll use the composed page scanner results instead)
88
+ heading_violations = violations.select { |v|
89
+ v.rule_name.to_s == 'heading'
90
+ }
91
+ landmarks_violations = violations.select { |v|
92
+ v.rule_name.to_s == 'aria_landmarks' &&
93
+ (v.message.include?('missing MAIN') || v.message.include?('missing main') ||
94
+ v.message.include?('MAIN landmark'))
95
+ }
96
+ duplicate_ids_violations = violations.select { |v|
97
+ v.rule_name.to_s == 'duplicate_ids'
98
+ }
99
+ other_violations = violations.reject { |v|
100
+ heading_violations.include?(v) || landmarks_violations.include?(v) || duplicate_ids_violations.include?(v)
101
+ }
102
+
103
+ # Convert non-page-level violations to errors/warnings format
74
104
  line_number_finder = LineNumberFinder.new(@file_content)
75
- ViolationConverter.convert(
76
- violations,
105
+ result = ViolationConverter.convert(
106
+ other_violations,
77
107
  view_file: @view_file,
78
108
  line_number_finder: line_number_finder,
79
109
  config: config
80
110
  )
111
+
112
+ # Add composed page results (all heading checks + landmarks + duplicate IDs)
113
+ result[:errors] = (result[:errors] || []) +
114
+ hierarchy_result[:errors] +
115
+ all_headings_result[:errors] +
116
+ duplicate_ids_result[:errors]
117
+ result[:warnings] = (result[:warnings] || []) +
118
+ hierarchy_result[:warnings] +
119
+ all_headings_result[:warnings] +
120
+ landmarks_result[:warnings] +
121
+ duplicate_ids_result[:warnings]
122
+
123
+ result
81
124
  rescue StandardError => e
82
125
  # If engine fails, log error and return empty results
83
126
  if defined?(Rails) && Rails.env.development?
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAccessibilityTesting
4
- VERSION = "1.5.8"
4
+ VERSION = "1.6.0"
5
5
  end
6
6