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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -0
- data/README.md +9 -1
- data/docs_site/architecture.md +39 -1
- data/docs_site/getting_started.md +3 -3
- data/docs_site/index.md +1 -1
- data/exe/a11y_live_scanner +22 -0
- data/exe/a11y_static_scanner +22 -0
- data/lib/generators/rails_a11y/install/templates/accessibility.yml.erb +6 -0
- data/lib/generators/rails_a11y/install/templates/all_pages_accessibility_spec.rb.erb +1 -1
- data/lib/rails_accessibility_testing/accessibility_helper.rb +68 -3
- data/lib/rails_accessibility_testing/checks/heading_check.rb +24 -11
- data/lib/rails_accessibility_testing/checks/interactive_elements_check.rb +10 -2
- data/lib/rails_accessibility_testing/composed_page_scanner.rb +326 -0
- data/lib/rails_accessibility_testing/config/yaml_loader.rb +3 -0
- data/lib/rails_accessibility_testing/erb_extractor.rb +7 -3
- data/lib/rails_accessibility_testing/rspec_integration.rb +17 -0
- data/lib/rails_accessibility_testing/static_file_scanner.rb +60 -5
- 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 +3 -1
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
require 'nokogiri'
|
|
5
|
+
require_relative 'accessibility_helper'
|
|
6
|
+
require_relative 'erb_extractor'
|
|
7
|
+
|
|
8
|
+
# Builds a composition graph of a Rails page (layout + view + partials)
|
|
9
|
+
# This allows us to check heading hierarchy across the complete composed page
|
|
10
|
+
# rather than individual files
|
|
11
|
+
module RailsAccessibilityTesting
|
|
12
|
+
# Builds the composition of a Rails page by tracing:
|
|
13
|
+
# - Layout file (application.html.erb)
|
|
14
|
+
# - View file (yield content)
|
|
15
|
+
# - All partials rendered (recursively)
|
|
16
|
+
#
|
|
17
|
+
# @api private
|
|
18
|
+
class ViewCompositionBuilder
|
|
19
|
+
include AccessibilityHelper::PartialDetection
|
|
20
|
+
|
|
21
|
+
attr_reader :view_file, :layout_file, :all_files
|
|
22
|
+
|
|
23
|
+
def initialize(view_file)
|
|
24
|
+
@view_file = view_file
|
|
25
|
+
@layout_file = nil
|
|
26
|
+
@all_files = []
|
|
27
|
+
@visited_files = Set.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Build the complete composition
|
|
31
|
+
# @return [Array<String>] Array of all file paths in the composition
|
|
32
|
+
def build
|
|
33
|
+
# Resolve view file path (handle relative/absolute)
|
|
34
|
+
view_file_path = normalize_path(@view_file)
|
|
35
|
+
return [] unless view_file_path && File.exist?(view_file_path)
|
|
36
|
+
|
|
37
|
+
@all_files = []
|
|
38
|
+
@visited_files = Set.new
|
|
39
|
+
|
|
40
|
+
# Find layout file
|
|
41
|
+
@layout_file = find_layout_file_for_view(view_file_path)
|
|
42
|
+
if @layout_file
|
|
43
|
+
layout_path = normalize_path(@layout_file)
|
|
44
|
+
@all_files << layout_path if layout_path && File.exist?(layout_path)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Recursively find all partials (handles nested partials, collections, etc.)
|
|
48
|
+
# Note: find_all_partials_recursive adds files to @all_files and @visited_files
|
|
49
|
+
# We call it BEFORE manually adding to ensure it processes the file
|
|
50
|
+
find_all_partials_recursive(view_file_path)
|
|
51
|
+
if @layout_file
|
|
52
|
+
layout_path = normalize_path(@layout_file)
|
|
53
|
+
find_all_partials_recursive(layout_path) if layout_path
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Ensure view file is in @all_files (it should be added by find_all_partials_recursive)
|
|
57
|
+
@all_files << view_file_path unless @all_files.include?(view_file_path)
|
|
58
|
+
|
|
59
|
+
@all_files.uniq
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Normalize file path to consistent format (absolute if possible)
|
|
63
|
+
def normalize_path(file_path)
|
|
64
|
+
return nil unless file_path
|
|
65
|
+
|
|
66
|
+
# Try with Rails.root first (most reliable for Rails apps)
|
|
67
|
+
if defined?(Rails) && Rails.root
|
|
68
|
+
rails_path = Rails.root.join(file_path)
|
|
69
|
+
return rails_path.to_s if File.exist?(rails_path)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# If file exists as-is (relative or absolute), return it
|
|
73
|
+
return file_path if File.exist?(file_path)
|
|
74
|
+
|
|
75
|
+
# Try making it absolute if it's relative
|
|
76
|
+
if !file_path.start_with?('/')
|
|
77
|
+
expanded = File.expand_path(file_path)
|
|
78
|
+
return expanded if File.exist?(expanded)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Return original if nothing works (will be checked later)
|
|
82
|
+
file_path
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Get all headings from the complete composition
|
|
86
|
+
# @return [Array<Hash>] Array of heading info: { level: 1-6, text: String, file: String, line: Integer }
|
|
87
|
+
def all_headings
|
|
88
|
+
headings = []
|
|
89
|
+
|
|
90
|
+
@all_files.each do |file|
|
|
91
|
+
# Handle both relative and absolute paths
|
|
92
|
+
file_path = if File.exist?(file)
|
|
93
|
+
file
|
|
94
|
+
elsif defined?(Rails) && Rails.root
|
|
95
|
+
rails_path = Rails.root.join(file)
|
|
96
|
+
rails_path.exist? ? rails_path.to_s : nil
|
|
97
|
+
else
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
next unless file_path && File.exist?(file_path)
|
|
102
|
+
|
|
103
|
+
content = File.read(file_path)
|
|
104
|
+
html_content = ErbExtractor.extract_html(content)
|
|
105
|
+
doc = Nokogiri::HTML::DocumentFragment.parse(html_content)
|
|
106
|
+
|
|
107
|
+
doc.css('h1, h2, h3, h4, h5, h6').each do |heading|
|
|
108
|
+
level = heading.name[1].to_i
|
|
109
|
+
text = heading.text.strip
|
|
110
|
+
line = find_line_number(content, heading)
|
|
111
|
+
|
|
112
|
+
headings << {
|
|
113
|
+
level: level,
|
|
114
|
+
text: text,
|
|
115
|
+
file: file_path, # Use resolved path
|
|
116
|
+
line: line
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Sort by file order (layout first, then view, then partials)
|
|
122
|
+
# This preserves the DOM order
|
|
123
|
+
headings.sort_by do |h|
|
|
124
|
+
file_index = @all_files.index(h[:file]) || 999
|
|
125
|
+
[file_index, h[:line]]
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# Find layout file for a view
|
|
132
|
+
# Handles:
|
|
133
|
+
# - Explicit layout declaration in view: layout 'custom_layout'
|
|
134
|
+
# - Controller-level layout (via ApplicationController)
|
|
135
|
+
# - Default application layout
|
|
136
|
+
def find_layout_file_for_view(view_file)
|
|
137
|
+
return nil unless view_file && File.exist?(view_file)
|
|
138
|
+
|
|
139
|
+
# Check for layout declaration in view file
|
|
140
|
+
content = File.read(view_file)
|
|
141
|
+
layout_match = content.match(/layout\s+['"]([^'"]+)['"]/)
|
|
142
|
+
layout_name = layout_match ? layout_match[1] : 'application'
|
|
143
|
+
|
|
144
|
+
# Try to find layout file
|
|
145
|
+
extensions = %w[erb haml slim]
|
|
146
|
+
extensions.each do |ext|
|
|
147
|
+
layout_path = "app/views/layouts/#{layout_name}.html.#{ext}"
|
|
148
|
+
if File.exist?(layout_path)
|
|
149
|
+
return layout_path
|
|
150
|
+
elsif defined?(Rails) && Rails.root
|
|
151
|
+
rails_path = Rails.root.join(layout_path)
|
|
152
|
+
return rails_path.to_s if File.exist?(rails_path)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Default to application layout
|
|
157
|
+
extensions.each do |ext|
|
|
158
|
+
layout_path = "app/views/layouts/application.html.#{ext}"
|
|
159
|
+
if File.exist?(layout_path)
|
|
160
|
+
return layout_path
|
|
161
|
+
elsif defined?(Rails) && Rails.root
|
|
162
|
+
rails_path = Rails.root.join(layout_path)
|
|
163
|
+
return rails_path.to_s if File.exist?(rails_path)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Recursively find all partials rendered in a file
|
|
171
|
+
# Handles nested partials, collections, and all Rails render patterns
|
|
172
|
+
def find_all_partials_recursive(file)
|
|
173
|
+
# Normalize file path to consistent format
|
|
174
|
+
file_path = normalize_path(file)
|
|
175
|
+
return unless file_path && File.exist?(file_path)
|
|
176
|
+
return if @visited_files.include?(file_path)
|
|
177
|
+
|
|
178
|
+
@visited_files.add(file_path)
|
|
179
|
+
content = File.read(file_path)
|
|
180
|
+
|
|
181
|
+
# Find all partials rendered in this file
|
|
182
|
+
# Use the PartialDetection module method (handles all Rails patterns)
|
|
183
|
+
partials = find_partials_in_view_file(file_path)
|
|
184
|
+
|
|
185
|
+
# Also check for Rails shorthand: render @model (renders _model.html.erb)
|
|
186
|
+
content.scan(/render\s+@(\w+)/) do |match|
|
|
187
|
+
model_name = match[0]
|
|
188
|
+
partial_name = model_name.underscore
|
|
189
|
+
partials << partial_name unless partials.include?(partial_name)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
partials.each do |partial_name|
|
|
193
|
+
partial_file = find_partial_file(partial_name)
|
|
194
|
+
next unless partial_file
|
|
195
|
+
|
|
196
|
+
# Normalize partial file path to consistent format
|
|
197
|
+
full_partial_path = normalize_path(partial_file)
|
|
198
|
+
next unless full_partial_path && File.exist?(full_partial_path)
|
|
199
|
+
next if @all_files.include?(full_partial_path)
|
|
200
|
+
|
|
201
|
+
@all_files << full_partial_path
|
|
202
|
+
|
|
203
|
+
# Recursively find partials within this partial (handles nested partials)
|
|
204
|
+
find_all_partials_recursive(full_partial_path)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Find the actual file path for a partial name
|
|
209
|
+
def find_partial_file(partial_name)
|
|
210
|
+
extensions = %w[erb haml slim]
|
|
211
|
+
|
|
212
|
+
# Handle namespaced partials (e.g., 'layouts/_navbar', 'layouts/navbar')
|
|
213
|
+
if partial_name.include?('/')
|
|
214
|
+
parts = partial_name.split('/')
|
|
215
|
+
dir = parts[0..-2].join('/')
|
|
216
|
+
name = parts.last
|
|
217
|
+
# Remove leading underscore from name if present
|
|
218
|
+
name = name.start_with?('_') ? name[1..-1] : name
|
|
219
|
+
partial_path = "app/views/#{dir}/_#{name}"
|
|
220
|
+
|
|
221
|
+
extensions.each do |ext|
|
|
222
|
+
full_path = "#{partial_path}.html.#{ext}"
|
|
223
|
+
# Try with Rails.root first (most reliable)
|
|
224
|
+
if defined?(Rails) && Rails.root
|
|
225
|
+
rails_path = Rails.root.join(full_path)
|
|
226
|
+
return rails_path.to_s if File.exist?(rails_path)
|
|
227
|
+
end
|
|
228
|
+
# Fallback to relative path
|
|
229
|
+
if File.exist?(full_path)
|
|
230
|
+
return full_path
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
else
|
|
234
|
+
# Non-namespaced partial - remove leading underscore if present
|
|
235
|
+
clean_name = partial_name.start_with?('_') ? partial_name[1..-1] : partial_name
|
|
236
|
+
|
|
237
|
+
# Try view directory first (same directory as view file)
|
|
238
|
+
# Handle both absolute and relative paths
|
|
239
|
+
view_dir_path = File.dirname(@view_file)
|
|
240
|
+
if view_dir_path.include?('app/views/')
|
|
241
|
+
# Extract directory relative to app/views
|
|
242
|
+
view_dir = view_dir_path.sub(/^.*\/app\/views\//, '')
|
|
243
|
+
else
|
|
244
|
+
view_dir = view_dir_path.sub('app/views/', '')
|
|
245
|
+
end
|
|
246
|
+
partial_path = "app/views/#{view_dir}/_#{clean_name}"
|
|
247
|
+
|
|
248
|
+
extensions.each do |ext|
|
|
249
|
+
full_path = "#{partial_path}.html.#{ext}"
|
|
250
|
+
# Try with Rails.root if file doesn't exist (for absolute paths)
|
|
251
|
+
if File.exist?(full_path)
|
|
252
|
+
return full_path
|
|
253
|
+
elsif defined?(Rails) && Rails.root
|
|
254
|
+
rails_path = Rails.root.join(full_path)
|
|
255
|
+
return rails_path.to_s if File.exist?(rails_path)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Try standard directories first (most common locations)
|
|
260
|
+
standard_dirs = ['layouts', 'shared', 'application']
|
|
261
|
+
standard_dirs.each do |dir|
|
|
262
|
+
partial_path = "app/views/#{dir}/_#{clean_name}"
|
|
263
|
+
extensions.each do |ext|
|
|
264
|
+
full_path = "#{partial_path}.html.#{ext}"
|
|
265
|
+
# Try with Rails.root first
|
|
266
|
+
if defined?(Rails) && Rails.root
|
|
267
|
+
rails_path = Rails.root.join(full_path)
|
|
268
|
+
return rails_path.to_s if File.exist?(rails_path)
|
|
269
|
+
end
|
|
270
|
+
# Fallback to relative path
|
|
271
|
+
if File.exist?(full_path)
|
|
272
|
+
return full_path
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Exhaustive search: traverse ALL folders in app/views recursively
|
|
278
|
+
# This ensures we find partials in any subdirectory (collections, items, profiles, loan_requests, etc.)
|
|
279
|
+
# Only do this if partial wasn't found in standard locations (performance optimization)
|
|
280
|
+
if defined?(Rails) && Rails.root
|
|
281
|
+
views_dir = Rails.root.join('app', 'views')
|
|
282
|
+
if File.exist?(views_dir)
|
|
283
|
+
# Search for partial in all subdirectories (use first match found)
|
|
284
|
+
extensions.each do |ext|
|
|
285
|
+
pattern = views_dir.join('**', "_#{clean_name}.html.#{ext}")
|
|
286
|
+
found_path = Dir.glob(pattern).first
|
|
287
|
+
return found_path if found_path && File.exist?(found_path)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
nil
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Find line number for an element in the original ERB file
|
|
297
|
+
def find_line_number(content, element)
|
|
298
|
+
# Simple approach: find the line containing the tag
|
|
299
|
+
tag_name = element.name
|
|
300
|
+
text = element.text.strip[0..50] # First 50 chars for matching
|
|
301
|
+
|
|
302
|
+
lines = content.split("\n")
|
|
303
|
+
lines.each_with_index do |line, index|
|
|
304
|
+
if line.include?("<#{tag_name}") && (text.empty? || line.include?(text[0..20]))
|
|
305
|
+
return index + 1
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
1 # Default to line 1 if not found
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
@@ -55,6 +55,8 @@ require_relative 'rails_accessibility_testing/line_number_finder'
|
|
|
55
55
|
require_relative 'rails_accessibility_testing/violation_converter'
|
|
56
56
|
require_relative 'rails_accessibility_testing/static_file_scanner'
|
|
57
57
|
require_relative 'rails_accessibility_testing/static_scanning'
|
|
58
|
+
require_relative 'rails_accessibility_testing/view_composition_builder'
|
|
59
|
+
require_relative 'rails_accessibility_testing/composed_page_scanner'
|
|
58
60
|
# Only load RSpec-specific components when RSpec is available
|
|
59
61
|
if defined?(RSpec)
|
|
60
62
|
require_relative 'rails_accessibility_testing/shared_examples'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_accessibility_testing
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.5.
|
|
4
|
+
version: 1.5.10
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Regan Maharjan
|
|
@@ -143,6 +143,7 @@ files:
|
|
|
143
143
|
- lib/rails_accessibility_testing/checks/skip_links_check.rb
|
|
144
144
|
- lib/rails_accessibility_testing/checks/table_structure_check.rb
|
|
145
145
|
- lib/rails_accessibility_testing/cli/command.rb
|
|
146
|
+
- lib/rails_accessibility_testing/composed_page_scanner.rb
|
|
146
147
|
- lib/rails_accessibility_testing/config/yaml_loader.rb
|
|
147
148
|
- lib/rails_accessibility_testing/configuration.rb
|
|
148
149
|
- lib/rails_accessibility_testing/engine/rule_engine.rb
|
|
@@ -161,6 +162,7 @@ files:
|
|
|
161
162
|
- lib/rails_accessibility_testing/static_page_adapter.rb
|
|
162
163
|
- lib/rails_accessibility_testing/static_scanning.rb
|
|
163
164
|
- lib/rails_accessibility_testing/version.rb
|
|
165
|
+
- lib/rails_accessibility_testing/view_composition_builder.rb
|
|
164
166
|
- lib/rails_accessibility_testing/violation_converter.rb
|
|
165
167
|
- lib/tasks/accessibility.rake
|
|
166
168
|
homepage: https://rayraycodes.github.io/rails-accessibility-testing/
|