rails_accessibility_testing 1.5.7 → 1.5.10

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.
@@ -0,0 +1,326 @@
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
+
207
+ id_map[id] ||= []
208
+ line = find_line_number_for_element(content, element)
209
+ id_map[id] << {
210
+ file: file_path, # Use resolved path
211
+ line: line,
212
+ element: element.name
213
+ }
214
+ end
215
+ end
216
+
217
+ errors = []
218
+
219
+ # Find duplicate IDs
220
+ id_map.each do |id, occurrences|
221
+ if occurrences.length > 1
222
+ # Report all occurrences after the first one
223
+ occurrences[1..-1].each do |occurrence|
224
+ errors << {
225
+ type: "Duplicate ID '#{id}' found (checked complete page: layout + view + partials) - IDs must be unique across the entire page",
226
+ element: {
227
+ tag: occurrence[:element],
228
+ id: id,
229
+ file: occurrence[:file]
230
+ },
231
+ file: occurrence[:file],
232
+ line: occurrence[:line],
233
+ wcag: "4.1.1",
234
+ 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>",
235
+ page_level_check: true,
236
+ check_type: 'duplicate_ids'
237
+ }
238
+ end
239
+ end
240
+ end
241
+
242
+ { errors: errors, warnings: [] }
243
+ end
244
+
245
+ # Helper to find line number for an element
246
+ def find_line_number_for_element(content, element)
247
+ tag_name = element.name
248
+ id = element[:id]
249
+
250
+ lines = content.split("\n")
251
+ lines.each_with_index do |line, index|
252
+ if line.include?("<#{tag_name}") && (id.nil? || line.include?("id=\"#{id}\"") || line.include?("id='#{id}'"))
253
+ return index + 1
254
+ end
255
+ end
256
+
257
+ 1
258
+ end
259
+
260
+ # Scan the complete composed page for ARIA landmarks
261
+ # This ensures landmarks in layout are detected when scanning view files
262
+ # @return [Hash] Hash with :errors and :warnings arrays
263
+ def scan_aria_landmarks
264
+ return { errors: [], warnings: [] } unless @view_file && File.exist?(@view_file)
265
+
266
+ # Build composition
267
+ all_files = @builder.build
268
+ return { errors: [], warnings: [] } if all_files.empty?
269
+
270
+ # Check for main landmark across all files
271
+ has_main = false
272
+ main_location = nil
273
+
274
+ all_files.each do |file|
275
+ next unless File.exist?(file)
276
+
277
+ content = File.read(file)
278
+ html_content = ErbExtractor.extract_html(content)
279
+ doc = Nokogiri::HTML::DocumentFragment.parse(html_content)
280
+
281
+ # Check for <main> tag or [role="main"]
282
+ main_elements = doc.css('main, [role="main"]')
283
+ if main_elements.any?
284
+ has_main = true
285
+ main_location = file
286
+ break
287
+ end
288
+ end
289
+
290
+ warnings = []
291
+
292
+ # Only report missing main if it's truly missing from the complete page
293
+ unless has_main
294
+ warnings << {
295
+ type: "Page missing MAIN landmark (checked complete page: layout + view + partials)",
296
+ element: {
297
+ tag: 'page',
298
+ text: 'Page-level ARIA landmark check'
299
+ },
300
+ file: @view_file,
301
+ line: 1,
302
+ wcag: "1.3.1",
303
+ 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>",
304
+ page_level_check: true,
305
+ check_type: 'aria_landmarks'
306
+ }
307
+ end
308
+
309
+ { errors: [], warnings: warnings }
310
+ end
311
+
312
+ private
313
+
314
+ def create_violation(message:, heading:, wcag_reference:, remediation:)
315
+ {
316
+ message: message,
317
+ file: heading ? heading[:file] : @view_file,
318
+ line: heading ? heading[:line] : 1,
319
+ wcag_reference: wcag_reference,
320
+ remediation: remediation,
321
+ heading: heading # Keep heading info for element context
322
+ }
323
+ end
324
+ end
325
+ end
326
+
@@ -84,6 +84,8 @@ module RailsAccessibilityTesting
84
84
  merged_system_specs = base_system_specs.merge(profile_system_specs)
85
85
 
86
86
  base_config.merge(
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
87
89
  'checks' => merged_checks,
88
90
  'summary' => merged_summary,
89
91
  'scan_strategy' => profile_config['scan_strategy'] || base_config['scan_strategy'] || 'paths',
@@ -122,6 +124,7 @@ module RailsAccessibilityTesting
122
124
  # Default configuration when no file exists
123
125
  def default_config
124
126
  {
127
+ 'accessibility_enabled' => true, # Global enable/disable flag for all accessibility checks
125
128
  'wcag_level' => 'AA',
126
129
  'checks' => default_checks,
127
130
  'summary' => {
@@ -126,10 +126,14 @@ module RailsAccessibilityTesting
126
126
  end
127
127
  end
128
128
 
129
- # Remove ERB tags
129
+ # Remove or replace ERB tags
130
+ # Replace ERB output tags (<%= ... %>) with placeholder text so checks can detect non-empty content
131
+ # Remove ERB logic tags (<% ... %>) as they don't produce output
130
132
  def remove_erb_tags
131
- @content.gsub!(/<%[^%]*%>/, '')
132
- @content.gsub!(/<%=.*?%>/, '')
133
+ # Replace ERB output tags with placeholder - this allows checks to detect that content will be present
134
+ @content.gsub!(/<%=(.*?)%>/m, 'ERB_CONTENT')
135
+ # Remove ERB logic tags (they don't produce visible content)
136
+ @content.gsub!(/<%[^=][^%]*%>/, '')
133
137
  end
134
138
 
135
139
  # Clean up extra whitespace
@@ -46,6 +46,20 @@ module RailsAccessibilityTesting
46
46
 
47
47
  private
48
48
 
49
+ # Check if accessibility checks are globally disabled via config
50
+ def accessibility_globally_disabled?
51
+ begin
52
+ require 'rails_accessibility_testing/config/yaml_loader'
53
+ profile = defined?(Rails) && Rails.env.test? ? :test : :development
54
+ config = Config::YamlLoader.load(profile: profile)
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
57
+ !enabled # Return true if disabled
58
+ rescue StandardError
59
+ false # If config can't be loaded, assume enabled
60
+ end
61
+ end
62
+
49
63
  # Enable automatic spec type inference from file location
50
64
  def enable_spec_type_inference(config)
51
65
  # Only call if the method exists (requires rspec-rails to be loaded)
@@ -64,6 +78,9 @@ module RailsAccessibilityTesting
64
78
 
65
79
  # Setup automatic accessibility checks
66
80
  def setup_automatic_checks(config)
81
+ # Check if accessibility checks are globally disabled
82
+ return if accessibility_globally_disabled?
83
+
67
84
  # Use class variable to track results across all examples
68
85
  @@accessibility_results = {
69
86
  pages_tested: [],
@@ -32,6 +32,16 @@ module RailsAccessibilityTesting
32
32
  # @return [Hash] Hash with :errors and :warnings arrays
33
33
  def scan
34
34
  return { errors: [], warnings: [] } unless File.exist?(@view_file)
35
+
36
+ # Check if accessibility checks are globally disabled
37
+ begin
38
+ config = Config::YamlLoader.load(profile: :test)
39
+ # Support both 'accessibility_enabled' (new) and 'enabled' (legacy) for backward compatibility
40
+ enabled = config.fetch('accessibility_enabled', config.fetch('enabled', true))
41
+ return { errors: [], warnings: [] } unless enabled
42
+ rescue StandardError
43
+ # If config can't be loaded, continue (assume enabled)
44
+ end
35
45
 
36
46
  @file_content = File.read(@view_file)
37
47
 
@@ -61,17 +71,62 @@ module RailsAccessibilityTesting
61
71
  # Run all enabled checks using existing RuleEngine
62
72
  violations = engine.check(static_page, context: context)
63
73
 
64
- # 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
65
104
  line_number_finder = LineNumberFinder.new(@file_content)
66
- ViolationConverter.convert(
67
- violations,
105
+ result = ViolationConverter.convert(
106
+ other_violations,
68
107
  view_file: @view_file,
69
108
  line_number_finder: line_number_finder,
70
109
  config: config
71
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
72
124
  rescue StandardError => e
73
- # If engine fails, return empty results
74
- # Could log error here if needed
125
+ # If engine fails, log error and return empty results
126
+ if defined?(Rails) && Rails.env.development?
127
+ puts "Error in static scanner: #{e.message}"
128
+ puts e.backtrace.first(3).join("\n")
129
+ end
75
130
  { errors: [], warnings: [] }
76
131
  end
77
132
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAccessibilityTesting
4
- VERSION = "1.5.7"
4
+ VERSION = "1.5.10"
5
5
  end
6
6