rails_accessibility_testing 1.5.3 → 1.5.4
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/ARCHITECTURE.md +376 -1
- data/CHANGELOG.md +50 -1
- data/GUIDES/getting_started.md +40 -5
- data/GUIDES/system_specs_for_accessibility.md +12 -4
- data/README.md +52 -8
- data/docs_site/Gemfile.lock +89 -0
- data/docs_site/_config.yml +9 -0
- data/docs_site/_includes/header.html +1 -0
- data/docs_site/_layouts/default.html +754 -15
- data/docs_site/architecture.md +533 -0
- data/docs_site/index.md +2 -1
- data/exe/a11y_live_scanner +10 -39
- data/exe/a11y_static_scanner +333 -0
- data/lib/generators/rails_a11y/install/install_generator.rb +19 -30
- data/lib/generators/rails_a11y/install/templates/accessibility.yml.erb +39 -0
- data/lib/generators/rails_a11y/install/templates/all_pages_accessibility_spec.rb.erb +132 -45
- data/lib/rails_accessibility_testing/accessibility_helper.rb +131 -126
- data/lib/rails_accessibility_testing/checks/base_check.rb +14 -5
- data/lib/rails_accessibility_testing/checks/form_errors_check.rb +1 -1
- data/lib/rails_accessibility_testing/checks/form_labels_check.rb +6 -4
- data/lib/rails_accessibility_testing/checks/heading_check.rb +7 -15
- data/lib/rails_accessibility_testing/checks/image_alt_text_check.rb +1 -1
- data/lib/rails_accessibility_testing/checks/interactive_elements_check.rb +12 -8
- data/lib/rails_accessibility_testing/config/yaml_loader.rb +20 -0
- data/lib/rails_accessibility_testing/erb_extractor.rb +141 -0
- data/lib/rails_accessibility_testing/error_message_builder.rb +11 -6
- data/lib/rails_accessibility_testing/file_change_tracker.rb +95 -0
- data/lib/rails_accessibility_testing/line_number_finder.rb +61 -0
- data/lib/rails_accessibility_testing/rspec_integration.rb +74 -33
- data/lib/rails_accessibility_testing/shared_examples.rb +2 -0
- data/lib/rails_accessibility_testing/static_file_scanner.rb +80 -0
- data/lib/rails_accessibility_testing/static_page_adapter.rb +116 -0
- data/lib/rails_accessibility_testing/static_scanning.rb +61 -0
- data/lib/rails_accessibility_testing/version.rb +3 -1
- data/lib/rails_accessibility_testing/violation_converter.rb +80 -0
- data/lib/rails_accessibility_testing.rb +9 -1
- metadata +26 -2
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAccessibilityTesting
|
|
4
|
+
# Extracts HTML from ERB templates by converting Rails helpers to HTML
|
|
5
|
+
# This allows static analysis of view files without rendering them
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class ErbExtractor
|
|
9
|
+
# Convert ERB template to HTML for static analysis
|
|
10
|
+
# @param content [String] ERB template content
|
|
11
|
+
# @return [String] Extracted HTML
|
|
12
|
+
def self.extract_html(content)
|
|
13
|
+
new(content).extract
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(content)
|
|
17
|
+
@content = content.dup
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def extract
|
|
21
|
+
convert_rails_helpers
|
|
22
|
+
remove_erb_tags
|
|
23
|
+
cleanup_whitespace
|
|
24
|
+
@content
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Convert Rails helpers to placeholder HTML
|
|
30
|
+
def convert_rails_helpers
|
|
31
|
+
convert_form_helpers
|
|
32
|
+
convert_image_helpers
|
|
33
|
+
convert_link_helpers
|
|
34
|
+
convert_button_helpers
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Convert form field helpers
|
|
38
|
+
def convert_form_helpers
|
|
39
|
+
# select_tag "name", options, id: "custom_id" or select_tag "name"
|
|
40
|
+
@content.gsub!(/<%=\s*select_tag\s+["']?(\w+)["']?[^%]*%>/) do |match|
|
|
41
|
+
name = $1
|
|
42
|
+
# Try to extract id from the match if present
|
|
43
|
+
id_match = match.match(/id:\s*["']([^"']+)["']/) || match.match(/id:\s*:(\w+)/)
|
|
44
|
+
id = id_match ? id_match[1] : name
|
|
45
|
+
"<select name=\"#{name}\" id=\"#{id}\"></select>"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# text_field_tag "name"
|
|
49
|
+
@content.gsub!(/<%=\s*text_field_tag\s+["']?(\w+)["']?[^%]*%>/) do
|
|
50
|
+
name = $1
|
|
51
|
+
"<input type=\"text\" name=\"#{name}\" id=\"#{name}\">"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# password_field_tag "name"
|
|
55
|
+
@content.gsub!(/<%=\s*password_field_tag\s+["']?(\w+)["']?[^%]*%>/) do
|
|
56
|
+
name = $1
|
|
57
|
+
"<input type=\"password\" name=\"#{name}\" id=\"#{name}\">"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# email_field_tag "name"
|
|
61
|
+
@content.gsub!(/<%=\s*email_field_tag\s+["']?(\w+)["']?[^%]*%>/) do
|
|
62
|
+
name = $1
|
|
63
|
+
"<input type=\"email\" name=\"#{name}\" id=\"#{name}\">"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# text_area_tag "name"
|
|
67
|
+
@content.gsub!(/<%=\s*text_area_tag\s+["']?(\w+)["']?[^%]*%>/) do
|
|
68
|
+
name = $1
|
|
69
|
+
"<textarea name=\"#{name}\" id=\"#{name}\"></textarea>"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# f.submit "text"
|
|
73
|
+
@content.gsub!(/<%=\s*f\.submit\s+["']([^"']+)["'][^%]*%>/) do
|
|
74
|
+
text = $1
|
|
75
|
+
"<input type=\"submit\" value=\"#{text}\">"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Convert image helpers
|
|
80
|
+
def convert_image_helpers
|
|
81
|
+
# image_tag "path"
|
|
82
|
+
@content.gsub!(/<%=\s*image_tag\s+["']([^"']+)["'][^%]*%>/) do
|
|
83
|
+
src = $1
|
|
84
|
+
"<img src=\"#{src}\">"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Convert link helpers
|
|
89
|
+
def convert_link_helpers
|
|
90
|
+
# link_to with block (do...end) - might have content, might be empty
|
|
91
|
+
@content.gsub!(/<%=\s*link_to\s+[^%]+do[^%]*%>.*?<%[\s]*end[\s]*%>/m) do |match|
|
|
92
|
+
# Check if block has visible content (text or images)
|
|
93
|
+
has_content = match.match?(/[^<%>]{3,}/) # At least 3 non-tag characters
|
|
94
|
+
has_content ? "<a href=\"#\">content</a>" : "<a href=\"#\"></a>"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# link_to "text", path
|
|
98
|
+
@content.gsub!(/<%=\s*link_to\s+["']([^"']+)["'],\s*[^%]+%>/) do
|
|
99
|
+
text = $1
|
|
100
|
+
"<a href=\"#\">#{text}</a>"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# link_to path, options (might be empty)
|
|
104
|
+
@content.gsub!(/<%=\s*link_to\s+[^,]+,\s*[^%]+%>/) do
|
|
105
|
+
"<a href=\"#\"></a>"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# link_to path (no text, no options)
|
|
109
|
+
@content.gsub!(/<%=\s*link_to\s+[^,\s%]+%>/) do
|
|
110
|
+
"<a href=\"#\"></a>"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Convert button helpers
|
|
115
|
+
def convert_button_helpers
|
|
116
|
+
# button_tag "text"
|
|
117
|
+
@content.gsub!(/<%=\s*button_tag\s+["']([^"']+)["'][^%]*%>/) do
|
|
118
|
+
text = $1
|
|
119
|
+
"<button>#{text}</button>"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# button "text"
|
|
123
|
+
@content.gsub!(/<%=\s*button\s+["']([^"']+)["'][^%]*%>/) do
|
|
124
|
+
text = $1
|
|
125
|
+
"<button>#{text}</button>"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Remove ERB tags
|
|
130
|
+
def remove_erb_tags
|
|
131
|
+
@content.gsub!(/<%[^%]*%>/, '')
|
|
132
|
+
@content.gsub!(/<%=.*?%>/, '')
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Clean up extra whitespace
|
|
136
|
+
def cleanup_whitespace
|
|
137
|
+
@content.gsub!(/\n\s*\n\s*\n/, "\n\n")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
@@ -27,15 +27,20 @@ module RailsAccessibilityTesting
|
|
|
27
27
|
# @param error_type [String] Type of accessibility error
|
|
28
28
|
# @param element_context [Hash] Context about the element
|
|
29
29
|
# @param page_context [Hash] Context about the page
|
|
30
|
+
# @param show_fixes [Boolean] Whether to show fix suggestions (default: true)
|
|
30
31
|
# @return [String] Formatted error message
|
|
31
|
-
def build(error_type:, element_context:, page_context:)
|
|
32
|
-
[
|
|
32
|
+
def build(error_type:, element_context:, page_context:, show_fixes: true)
|
|
33
|
+
parts = [
|
|
33
34
|
header(error_type),
|
|
34
35
|
page_info(page_context),
|
|
35
|
-
element_info(element_context)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
element_info(element_context)
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# Only add remediation section if show_fixes is true
|
|
40
|
+
parts << remediation_section(error_type, element_context) if show_fixes
|
|
41
|
+
|
|
42
|
+
parts << footer
|
|
43
|
+
parts.compact.join("\n")
|
|
39
44
|
end
|
|
40
45
|
|
|
41
46
|
private
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module RailsAccessibilityTesting
|
|
7
|
+
# Tracks file modification times to detect changes
|
|
8
|
+
# Used by static scanner to only scan files that have changed
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
class FileChangeTracker
|
|
12
|
+
class << self
|
|
13
|
+
# Get the path to the scan state file
|
|
14
|
+
# @return [String] Path to the state file
|
|
15
|
+
def state_file_path
|
|
16
|
+
return @state_file_path if defined?(@state_file_path) && @state_file_path
|
|
17
|
+
|
|
18
|
+
if defined?(Rails) && Rails.root
|
|
19
|
+
@state_file_path = Rails.root.join('tmp', '.rails_a11y_scanned_files.json').to_s
|
|
20
|
+
else
|
|
21
|
+
@state_file_path = File.join(Dir.pwd, 'tmp', '.rails_a11y_scanned_files.json')
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Load the last scan state
|
|
26
|
+
# @return [Hash] Hash of file paths to modification times
|
|
27
|
+
def load_state
|
|
28
|
+
return {} unless File.exist?(state_file_path)
|
|
29
|
+
|
|
30
|
+
JSON.parse(File.read(state_file_path))
|
|
31
|
+
rescue StandardError
|
|
32
|
+
{}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Save the scan state
|
|
36
|
+
# @param state [Hash] Hash of file paths to modification times
|
|
37
|
+
def save_state(state)
|
|
38
|
+
FileUtils.mkdir_p(File.dirname(state_file_path))
|
|
39
|
+
|
|
40
|
+
# Atomic write to avoid partial writes
|
|
41
|
+
temp_file = "#{state_file_path}.tmp"
|
|
42
|
+
File.write(temp_file, JSON.pretty_generate(state))
|
|
43
|
+
FileUtils.mv(temp_file, state_file_path)
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
# Silently fail - don't break scanning if state save fails
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Check which files have changed since last scan
|
|
49
|
+
# @param files [Array<String>] List of file paths to check
|
|
50
|
+
# @return [Array<String>] List of files that have changed or are new
|
|
51
|
+
def changed_files(files)
|
|
52
|
+
state = load_state
|
|
53
|
+
changed = []
|
|
54
|
+
|
|
55
|
+
files.each do |file|
|
|
56
|
+
next unless File.exist?(file)
|
|
57
|
+
|
|
58
|
+
current_mtime = File.mtime(file).to_f
|
|
59
|
+
last_mtime = state[file]&.to_f
|
|
60
|
+
|
|
61
|
+
# File is new or modified if mtime differs
|
|
62
|
+
if last_mtime.nil? || current_mtime != last_mtime
|
|
63
|
+
changed << file
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
changed
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Update state with current file modification times
|
|
71
|
+
# @param files [Array<String>] List of file paths to update
|
|
72
|
+
def update_state(files)
|
|
73
|
+
state = load_state
|
|
74
|
+
|
|
75
|
+
files.each do |file|
|
|
76
|
+
next unless File.exist?(file)
|
|
77
|
+
state[file] = File.mtime(file).to_f
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Remove files that no longer exist
|
|
81
|
+
state.delete_if { |file, _| !File.exist?(file) }
|
|
82
|
+
|
|
83
|
+
save_state(state)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Clear the scan state (useful for forcing full rescan)
|
|
87
|
+
def clear_state
|
|
88
|
+
File.delete(state_file_path) if File.exist?(state_file_path)
|
|
89
|
+
rescue StandardError
|
|
90
|
+
# Silently fail
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAccessibilityTesting
|
|
4
|
+
# Finds line numbers for elements in source files
|
|
5
|
+
# Used by static file scanner to report exact file locations
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
class LineNumberFinder
|
|
9
|
+
def initialize(file_content)
|
|
10
|
+
@file_content = file_content
|
|
11
|
+
@lines = file_content.split("\n")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Find line number for an element based on its context
|
|
15
|
+
# @param element_context [Hash] Element context with tag, id, src, href, etc.
|
|
16
|
+
# @return [Integer, nil] Line number (1-indexed) or nil if not found
|
|
17
|
+
def find_line_number(element_context)
|
|
18
|
+
return nil unless element_context && @file_content
|
|
19
|
+
|
|
20
|
+
tag_name = element_context[:tag]
|
|
21
|
+
id = element_context[:id]
|
|
22
|
+
src = element_context[:src]
|
|
23
|
+
href = element_context[:href]
|
|
24
|
+
type = element_context[:input_type] || element_context[:type]
|
|
25
|
+
|
|
26
|
+
@lines.each_with_index do |line, index|
|
|
27
|
+
# Must contain the tag name
|
|
28
|
+
next unless line.include?("<#{tag_name}") || (tag_name && line.include?("<#{tag_name.upcase}"))
|
|
29
|
+
|
|
30
|
+
# Try to match by ID first (most specific)
|
|
31
|
+
if id.present? && line.include?("id=") && line.include?(id)
|
|
32
|
+
id_match = line.match(/id=["']([^"']+)["']/)
|
|
33
|
+
return index + 1 if id_match && id_match[1] == id
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Try to match by src (for images)
|
|
37
|
+
if src.present? && line.include?("src=") && line.include?(src)
|
|
38
|
+
return index + 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Try to match by href (for links)
|
|
42
|
+
if href.present? && line.include?("href=") && line.include?(href)
|
|
43
|
+
return index + 1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Try to match by type (for inputs)
|
|
47
|
+
if type.present? && line.include?("type=") && line.include?(type)
|
|
48
|
+
return index + 1
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# If no specific attributes, check if this is likely the element
|
|
52
|
+
if tag_name && line.match(/<#{tag_name}[^>]*>/)
|
|
53
|
+
return index + 1
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
@@ -142,17 +142,50 @@ module RailsAccessibilityTesting
|
|
|
142
142
|
|
|
143
143
|
# Show overall summary after all tests complete
|
|
144
144
|
config.after(:suite) do
|
|
145
|
-
#
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
# Check if summary should be shown
|
|
146
|
+
begin
|
|
147
|
+
require 'rails_accessibility_testing/config/yaml_loader'
|
|
148
|
+
profile = defined?(Rails) && Rails.env.test? ? :test : :development
|
|
149
|
+
config_data = RailsAccessibilityTesting::Config::YamlLoader.load(profile: profile)
|
|
150
|
+
summary_config = config_data['summary'] || {}
|
|
151
|
+
show_summary = summary_config.fetch('show_summary', true)
|
|
152
|
+
errors_only = summary_config.fetch('errors_only', false)
|
|
153
|
+
|
|
154
|
+
return unless show_summary
|
|
155
|
+
|
|
156
|
+
# Show summary if we tested any pages
|
|
157
|
+
if @@accessibility_results && @@accessibility_results[:pages_tested].any?
|
|
158
|
+
show_overall_summary(@@accessibility_results, errors_only: errors_only)
|
|
159
|
+
end
|
|
160
|
+
rescue StandardError => e
|
|
161
|
+
# Silently fail if config can't be loaded
|
|
148
162
|
end
|
|
149
163
|
end
|
|
150
164
|
end
|
|
151
165
|
|
|
166
|
+
# Load summary configuration from YAML
|
|
167
|
+
def load_summary_config
|
|
168
|
+
require 'rails_accessibility_testing/config/yaml_loader'
|
|
169
|
+
profile = defined?(Rails) && Rails.env.test? ? :test : :development
|
|
170
|
+
config = RailsAccessibilityTesting::Config::YamlLoader.load(profile: profile)
|
|
171
|
+
summary_config = config['summary'] || {}
|
|
172
|
+
{
|
|
173
|
+
'show_summary' => summary_config.fetch('show_summary', true),
|
|
174
|
+
'errors_only' => summary_config.fetch('errors_only', false),
|
|
175
|
+
'show_fixes' => summary_config.fetch('show_fixes', true)
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
|
|
152
179
|
# Show overall summary of all pages tested
|
|
153
|
-
def show_overall_summary(results)
|
|
180
|
+
def show_overall_summary(results, config_data = nil)
|
|
154
181
|
return unless results && results[:pages_tested].any?
|
|
155
182
|
|
|
183
|
+
# Load config if not provided
|
|
184
|
+
config_data ||= load_summary_config
|
|
185
|
+
|
|
186
|
+
# Filter out warnings if errors_only is true
|
|
187
|
+
errors_only = config_data['errors_only']
|
|
188
|
+
|
|
156
189
|
puts "\n" + "="*80
|
|
157
190
|
puts "📊 COMPREHENSIVE ACCESSIBILITY TEST REPORT"
|
|
158
191
|
puts "="*80
|
|
@@ -161,11 +194,15 @@ module RailsAccessibilityTesting
|
|
|
161
194
|
puts " Total pages tested: #{results[:pages_tested].length}"
|
|
162
195
|
puts " ✅ Passed (no issues): #{results[:pages_passed]} page#{'s' if results[:pages_passed] != 1}"
|
|
163
196
|
puts " ❌ Failed (errors): #{results[:pages_failed]} page#{'s' if results[:pages_failed] != 1}"
|
|
164
|
-
|
|
197
|
+
unless errors_only
|
|
198
|
+
puts " ⚠️ Warnings only: #{results[:pages_with_warnings]} page#{'s' if results[:pages_with_warnings] != 1}"
|
|
199
|
+
end
|
|
165
200
|
puts ""
|
|
166
201
|
puts "📋 Total Issues Across All Pages:"
|
|
167
202
|
puts " ❌ Total errors: #{results[:total_errors]}"
|
|
168
|
-
|
|
203
|
+
unless errors_only
|
|
204
|
+
puts " ⚠️ Total warnings: #{results[:total_warnings]}"
|
|
205
|
+
end
|
|
169
206
|
puts ""
|
|
170
207
|
|
|
171
208
|
# Show pages with errors (highest priority)
|
|
@@ -175,49 +212,53 @@ module RailsAccessibilityTesting
|
|
|
175
212
|
pages_with_errors.each do |page|
|
|
176
213
|
view_file = page[:view_file] || page[:path]
|
|
177
214
|
puts " • #{view_file}"
|
|
178
|
-
puts " Errors: #{page[:errors]}#{", Warnings: #{page[:warnings]}" if page[:warnings] > 0}"
|
|
215
|
+
puts " Errors: #{page[:errors]}#{", Warnings: #{page[:warnings]}" if page[:warnings] > 0 && !errors_only}"
|
|
179
216
|
puts " Path: #{page[:path]}" if page[:view_file] && page[:path] != view_file
|
|
180
217
|
end
|
|
181
218
|
puts ""
|
|
182
219
|
end
|
|
183
220
|
|
|
184
|
-
# Show pages with warnings only
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
221
|
+
# Show pages with warnings only (only if not errors_only)
|
|
222
|
+
unless errors_only
|
|
223
|
+
pages_with_warnings_only = results[:pages_tested].select { |p| p[:status] == :warning }
|
|
224
|
+
if pages_with_warnings_only.any?
|
|
225
|
+
puts "⚠️ Pages with Warnings Only (#{pages_with_warnings_only.length}):"
|
|
226
|
+
pages_with_warnings_only.each do |page|
|
|
227
|
+
view_file = page[:view_file] || page[:path]
|
|
228
|
+
puts " • #{view_file}"
|
|
229
|
+
puts " Warnings: #{page[:warnings]}"
|
|
230
|
+
puts " Path: #{page[:path]}" if page[:view_file] && page[:path] != view_file
|
|
231
|
+
end
|
|
232
|
+
puts ""
|
|
193
233
|
end
|
|
194
|
-
puts ""
|
|
195
234
|
end
|
|
196
235
|
|
|
197
|
-
# Show summary of pages that passed
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if pages_passed.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
236
|
+
# Show summary of pages that passed (only if not errors_only)
|
|
237
|
+
unless errors_only
|
|
238
|
+
pages_passed = results[:pages_tested].select { |p| p[:status] == :passed }
|
|
239
|
+
if pages_passed.any?
|
|
240
|
+
if pages_passed.length <= 15
|
|
241
|
+
puts "✅ Pages Passed All Checks (#{pages_passed.length}):"
|
|
242
|
+
pages_passed.each do |page|
|
|
243
|
+
puts " ✓ #{page[:path]}"
|
|
244
|
+
end
|
|
245
|
+
else
|
|
246
|
+
puts "✅ #{pages_passed.length} pages passed all accessibility checks"
|
|
247
|
+
puts " (Showing first 10):"
|
|
248
|
+
pages_passed.first(10).each do |page|
|
|
249
|
+
puts " ✓ #{page[:path]}"
|
|
250
|
+
end
|
|
251
|
+
puts " ... and #{pages_passed.length - 10} more"
|
|
210
252
|
end
|
|
211
|
-
puts "
|
|
253
|
+
puts ""
|
|
212
254
|
end
|
|
213
|
-
puts ""
|
|
214
255
|
end
|
|
215
256
|
|
|
216
257
|
# Final summary
|
|
217
258
|
puts "="*80
|
|
218
259
|
if results[:total_errors] > 0
|
|
219
260
|
puts "❌ OVERALL STATUS: FAILED - #{results[:total_errors]} error#{'s' if results[:total_errors] != 1} found across #{results[:pages_failed]} page#{'s' if results[:pages_failed] != 1}"
|
|
220
|
-
elsif results[:total_warnings] > 0
|
|
261
|
+
elsif !errors_only && results[:total_warnings] > 0
|
|
221
262
|
puts "⚠️ OVERALL STATUS: PASSED WITH WARNINGS - #{results[:total_warnings]} warning#{'s' if results[:total_warnings] != 1} found"
|
|
222
263
|
else
|
|
223
264
|
puts "✅ OVERALL STATUS: PASSED - All #{results[:pages_tested].length} page#{'s' if results[:pages_tested].length != 1} passed accessibility checks!"
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require_relative 'static_page_adapter'
|
|
6
|
+
require_relative 'engine/rule_engine'
|
|
7
|
+
require_relative 'config/yaml_loader'
|
|
8
|
+
require_relative 'erb_extractor'
|
|
9
|
+
require_relative 'line_number_finder'
|
|
10
|
+
require_relative 'violation_converter'
|
|
11
|
+
|
|
12
|
+
module RailsAccessibilityTesting
|
|
13
|
+
# Static file scanner that scans view files directly without visiting pages
|
|
14
|
+
# Uses the existing RuleEngine and all 11 checks from the checks/ folder
|
|
15
|
+
# Reports errors with exact file locations and line numbers
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# scanner = StaticFileScanner.new('app/views/pages/home.html.erb')
|
|
19
|
+
# result = scanner.scan
|
|
20
|
+
# # => { errors: [...], warnings: [...] }
|
|
21
|
+
#
|
|
22
|
+
# @api public
|
|
23
|
+
class StaticFileScanner
|
|
24
|
+
attr_reader :view_file
|
|
25
|
+
|
|
26
|
+
def initialize(view_file)
|
|
27
|
+
@view_file = view_file
|
|
28
|
+
@file_content = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Scan the view file for accessibility issues using all checks via RuleEngine
|
|
32
|
+
# @return [Hash] Hash with :errors and :warnings arrays
|
|
33
|
+
def scan
|
|
34
|
+
return { errors: [], warnings: [] } unless File.exist?(@view_file)
|
|
35
|
+
|
|
36
|
+
@file_content = File.read(@view_file)
|
|
37
|
+
|
|
38
|
+
# Extract HTML from ERB template using modular extractor
|
|
39
|
+
html_content = ErbExtractor.extract_html(@file_content)
|
|
40
|
+
|
|
41
|
+
# Create static page adapter (makes Nokogiri look like Capybara)
|
|
42
|
+
static_page = StaticPageAdapter.new(html_content, view_file: @view_file)
|
|
43
|
+
|
|
44
|
+
# Load config and create engine (reuse existing infrastructure)
|
|
45
|
+
# This automatically loads and runs all 11 checks:
|
|
46
|
+
# FormLabelsCheck, ImageAltTextCheck, InteractiveElementsCheck,
|
|
47
|
+
# HeadingCheck, KeyboardAccessibilityCheck, AriaLandmarksCheck,
|
|
48
|
+
# FormErrorsCheck, TableStructureCheck, DuplicateIdsCheck,
|
|
49
|
+
# SkipLinksCheck, ColorContrastCheck
|
|
50
|
+
begin
|
|
51
|
+
config = Config::YamlLoader.load(profile: :test)
|
|
52
|
+
engine = Engine::RuleEngine.new(config: config)
|
|
53
|
+
|
|
54
|
+
# Context for violations
|
|
55
|
+
context = {
|
|
56
|
+
url: nil,
|
|
57
|
+
path: nil,
|
|
58
|
+
view_file: @view_file
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# Run all enabled checks using existing RuleEngine
|
|
62
|
+
violations = engine.check(static_page, context: context)
|
|
63
|
+
|
|
64
|
+
# Convert violations to errors/warnings format with line numbers
|
|
65
|
+
line_number_finder = LineNumberFinder.new(@file_content)
|
|
66
|
+
ViolationConverter.convert(
|
|
67
|
+
violations,
|
|
68
|
+
view_file: @view_file,
|
|
69
|
+
line_number_finder: line_number_finder,
|
|
70
|
+
config: config
|
|
71
|
+
)
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
# If engine fails, return empty results
|
|
74
|
+
# Could log error here if needed
|
|
75
|
+
{ errors: [], warnings: [] }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
|
|
5
|
+
module RailsAccessibilityTesting
|
|
6
|
+
# Adapter that makes a Nokogiri document look like a Capybara page
|
|
7
|
+
# This allows existing checks to work with static file scanning
|
|
8
|
+
class StaticPageAdapter
|
|
9
|
+
attr_reader :doc, :view_file
|
|
10
|
+
|
|
11
|
+
def initialize(html_content, view_file:)
|
|
12
|
+
@doc = Nokogiri::HTML::DocumentFragment.parse(html_content)
|
|
13
|
+
@view_file = view_file
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Capybara-like interface: page.all('selector', visible: :all)
|
|
17
|
+
def all(selector, visible: true)
|
|
18
|
+
elements = @doc.css(selector)
|
|
19
|
+
# Filter by visibility if needed (for now, return all)
|
|
20
|
+
elements.map { |el| StaticElementAdapter.new(el, self) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Capybara-like interface: page.has_css?('selector')
|
|
24
|
+
def has_css?(selector, wait: true)
|
|
25
|
+
@doc.css(selector).any?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Capybara-like interface: page.current_url
|
|
29
|
+
def current_url
|
|
30
|
+
nil # Not applicable for static files
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Capybara-like interface: page.current_path
|
|
34
|
+
def current_path
|
|
35
|
+
nil # Not applicable for static files
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Get line number for an element (delegated to scanner)
|
|
39
|
+
def line_number_for(element)
|
|
40
|
+
# This will be set by the scanner if needed
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Adapter that makes a Nokogiri element look like a Capybara element
|
|
46
|
+
class StaticElementAdapter
|
|
47
|
+
attr_reader :native, :adapter
|
|
48
|
+
|
|
49
|
+
def initialize(nokogiri_element, adapter)
|
|
50
|
+
@element = nokogiri_element
|
|
51
|
+
@adapter = adapter
|
|
52
|
+
@native = nokogiri_element
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Capybara-like interface: element.tag_name
|
|
56
|
+
def tag_name
|
|
57
|
+
@element.name
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Capybara-like interface: element[:id]
|
|
61
|
+
def [](attribute)
|
|
62
|
+
@element[attribute]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Capybara-like interface: element.text
|
|
66
|
+
def text
|
|
67
|
+
@element.text
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Capybara-like interface: element.visible?
|
|
71
|
+
def visible?
|
|
72
|
+
# For static scanning, assume all elements are visible
|
|
73
|
+
# Could be enhanced to check CSS display/visibility
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Capybara-like interface: element.find(:xpath, '..')
|
|
78
|
+
def find(selector_type, xpath)
|
|
79
|
+
if selector_type == :xpath && xpath == '..'
|
|
80
|
+
parent = @element.parent
|
|
81
|
+
return StaticElementAdapter.new(parent, @adapter) if parent && parent.element?
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
nil
|
|
85
|
+
rescue StandardError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Get line number for this element
|
|
90
|
+
def line_number
|
|
91
|
+
@adapter.line_number_for(@element)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Convert to HTML string
|
|
95
|
+
def to_html
|
|
96
|
+
@element.to_html
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Support for native.attribute() calls used by checks
|
|
100
|
+
def native
|
|
101
|
+
NativeWrapper.new(@element)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Wrapper for native element attribute access
|
|
105
|
+
class NativeWrapper
|
|
106
|
+
def initialize(element)
|
|
107
|
+
@element = element
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def attribute(name)
|
|
111
|
+
@element[name]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|