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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +93 -0
- data/README.md +9 -1
- data/docs_site/architecture.md +94 -1
- data/docs_site/getting_started.md +9 -3
- data/docs_site/index.md +8 -4
- data/exe/a11y_live_scanner +4 -3
- data/exe/a11y_static_scanner +4 -3
- data/lib/generators/rails_a11y/install/templates/accessibility.yml.erb +2 -2
- data/lib/rails_accessibility_testing/accessibility_helper.rb +48 -6
- data/lib/rails_accessibility_testing/checks/duplicate_ids_check.rb +12 -1
- data/lib/rails_accessibility_testing/checks/form_labels_check.rb +20 -1
- data/lib/rails_accessibility_testing/checks/heading_check.rb +17 -10
- data/lib/rails_accessibility_testing/checks/interactive_elements_check.rb +20 -1
- data/lib/rails_accessibility_testing/composed_page_scanner.rb +330 -0
- data/lib/rails_accessibility_testing/config/yaml_loader.rb +3 -2
- data/lib/rails_accessibility_testing/erb_extractor.rb +55 -0
- data/lib/rails_accessibility_testing/rspec_integration.rb +2 -1
- data/lib/rails_accessibility_testing/static_file_scanner.rb +47 -4
- data/lib/rails_accessibility_testing/version.rb +1 -1
- data/lib/rails_accessibility_testing/view_composition_builder.rb +313 -0
- data/lib/rails_accessibility_testing.rb +2 -0
- metadata +4 -2
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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?
|