rails_accessibility_testing 1.4.3 → 1.5.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/ARCHITECTURE.md +212 -53
- data/CHANGELOG.md +118 -0
- data/GUIDES/getting_started.md +105 -77
- data/GUIDES/system_specs_for_accessibility.md +13 -12
- data/README.md +136 -36
- data/docs_site/getting_started.md +59 -69
- data/exe/a11y_live_scanner +361 -0
- data/exe/rails_server_safe +18 -1
- data/lib/generators/rails_a11y/install/install_generator.rb +137 -0
- data/lib/rails_accessibility_testing/accessibility_helper.rb +547 -24
- data/lib/rails_accessibility_testing/change_detector.rb +17 -104
- data/lib/rails_accessibility_testing/checks/base_check.rb +56 -7
- data/lib/rails_accessibility_testing/checks/heading_check.rb +138 -0
- data/lib/rails_accessibility_testing/checks/image_alt_text_check.rb +7 -7
- data/lib/rails_accessibility_testing/checks/interactive_elements_check.rb +11 -1
- data/lib/rails_accessibility_testing/cli/command.rb +3 -1
- data/lib/rails_accessibility_testing/config/yaml_loader.rb +1 -1
- data/lib/rails_accessibility_testing/engine/rule_engine.rb +49 -5
- data/lib/rails_accessibility_testing/error_message_builder.rb +63 -7
- data/lib/rails_accessibility_testing/middleware/page_visit_logger.rb +81 -0
- data/lib/rails_accessibility_testing/railtie.rb +22 -0
- data/lib/rails_accessibility_testing/rspec_integration.rb +176 -10
- data/lib/rails_accessibility_testing/version.rb +1 -1
- data/lib/rails_accessibility_testing.rb +8 -3
- metadata +7 -3
- data/lib/generators/rails_a11y/install/generator.rb +0 -51
- data/lib/rails_accessibility_testing/checks/heading_hierarchy_check.rb +0 -53
|
@@ -1,112 +1,25 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module RailsAccessibilityTesting
|
|
4
|
-
#
|
|
4
|
+
# Simple helper to convert routes to testable paths
|
|
5
5
|
class ChangeDetector
|
|
6
|
-
# Time window for considering files as "recently changed" (in seconds)
|
|
7
|
-
CHANGE_WINDOW = 300 # 5 minutes
|
|
8
|
-
|
|
9
|
-
# Directories to monitor for changes
|
|
10
|
-
MONITORED_DIRECTORIES = %w[app/views app/controllers app/helpers].freeze
|
|
11
|
-
|
|
12
|
-
# View file extensions to check
|
|
13
|
-
VIEW_EXTENSIONS = %w[erb haml slim].freeze
|
|
14
|
-
|
|
15
6
|
class << self
|
|
16
|
-
#
|
|
17
|
-
# @param
|
|
18
|
-
# @return [
|
|
19
|
-
def
|
|
20
|
-
return
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
# Check if a specific view file was recently modified
|
|
33
|
-
def view_file_recently_modified?(view_file)
|
|
34
|
-
return false unless view_file && File.exist?(view_file)
|
|
35
|
-
|
|
36
|
-
File.mtime(view_file) > Time.now - CHANGE_WINDOW
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
# Check if git has uncommitted changes in monitored directories
|
|
40
|
-
def git_has_uncommitted_changes?
|
|
41
|
-
git_status = `git status --porcelain #{MONITORED_DIRECTORIES.join(' ')} 2>/dev/null`
|
|
42
|
-
git_status.strip.length.positive?
|
|
43
|
-
rescue StandardError
|
|
44
|
-
false # Git not available or not a git repo
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# Check if any monitored files were recently modified
|
|
48
|
-
def any_monitored_files_recently_modified?
|
|
49
|
-
monitored_files.any? do |file|
|
|
50
|
-
File.exist?(file) && File.mtime(file) > Time.now - CHANGE_WINDOW
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Get all monitored files
|
|
55
|
-
def monitored_files
|
|
56
|
-
view_files + controller_files + helper_files
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# Get all view files
|
|
60
|
-
def view_files
|
|
61
|
-
VIEW_EXTENSIONS.flat_map do |ext|
|
|
62
|
-
Dir.glob("app/views/**/*.#{ext}")
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# Get all controller files
|
|
67
|
-
def controller_files
|
|
68
|
-
Dir.glob('app/controllers/**/*.rb')
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# Get all helper files
|
|
72
|
-
def helper_files
|
|
73
|
-
Dir.glob('app/helpers/**/*.rb')
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
# Determine view file from Rails path
|
|
77
|
-
def determine_view_file_from_path(path)
|
|
78
|
-
return nil unless path
|
|
79
|
-
|
|
80
|
-
clean_path = path.split('?').first.split('#').first
|
|
81
|
-
return nil unless clean_path.start_with?('/')
|
|
82
|
-
|
|
83
|
-
parts = clean_path.sub(/\A\//, '').split('/')
|
|
84
|
-
return nil if parts.empty?
|
|
85
|
-
|
|
86
|
-
find_view_file(parts)
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
# Find view file based on path parts
|
|
90
|
-
def find_view_file(parts)
|
|
91
|
-
if parts.length >= 2
|
|
92
|
-
controller = parts[0..-2].join('/')
|
|
93
|
-
action = parts.last
|
|
94
|
-
find_view_for_action(controller, action)
|
|
95
|
-
elsif parts.length == 1
|
|
96
|
-
find_view_for_action(parts[0], 'index')
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
# Find view file for a specific controller and action
|
|
101
|
-
def find_view_for_action(controller, action)
|
|
102
|
-
view_paths = VIEW_EXTENSIONS.flat_map do |ext|
|
|
103
|
-
[
|
|
104
|
-
"app/views/#{controller}/#{action}.html.#{ext}",
|
|
105
|
-
"app/views/#{controller}/_#{action}.html.#{ext}"
|
|
106
|
-
]
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
view_paths.find { |vp| File.exist?(vp) }
|
|
7
|
+
# Convert route to path string for testing
|
|
8
|
+
# @param route [ActionDispatch::Journey::Route] The route object
|
|
9
|
+
# @return [String, nil] The path string or nil if route can't be converted
|
|
10
|
+
def route_to_path(route)
|
|
11
|
+
return nil unless route.respond_to?(:path)
|
|
12
|
+
return nil unless route.verb.to_s.include?('GET')
|
|
13
|
+
|
|
14
|
+
path_spec = route.path.spec.to_s
|
|
15
|
+
path = path_spec.dup
|
|
16
|
+
path.gsub!(/\(\.:format\)/, '')
|
|
17
|
+
path.gsub!(/\(:id\)/, '1')
|
|
18
|
+
path.gsub!(/\(:(\w+)\)/, '1')
|
|
19
|
+
path.gsub!(/\([^)]*\)/, '')
|
|
20
|
+
path = '/' if path.empty? || path == '/'
|
|
21
|
+
path = "/#{path}" unless path.start_with?('/')
|
|
22
|
+
path.include?(':') ? nil : path
|
|
110
23
|
end
|
|
111
24
|
end
|
|
112
25
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../accessibility_helper'
|
|
4
|
+
|
|
3
5
|
module RailsAccessibilityTesting
|
|
4
6
|
module Checks
|
|
5
7
|
# Base class for all accessibility checks
|
|
@@ -24,6 +26,9 @@ module RailsAccessibilityTesting
|
|
|
24
26
|
#
|
|
25
27
|
# @api private
|
|
26
28
|
class BaseCheck
|
|
29
|
+
# Include partial detection methods from AccessibilityHelper
|
|
30
|
+
include AccessibilityHelper::PartialDetection
|
|
31
|
+
|
|
27
32
|
attr_reader :page, :context
|
|
28
33
|
|
|
29
34
|
# Initialize the check
|
|
@@ -65,19 +70,20 @@ module RailsAccessibilityTesting
|
|
|
65
70
|
rule_name: self.class.rule_name,
|
|
66
71
|
message: message,
|
|
67
72
|
element_context: element_context,
|
|
68
|
-
page_context: page_context,
|
|
73
|
+
page_context: page_context(element_context),
|
|
69
74
|
wcag_reference: wcag_reference,
|
|
70
75
|
remediation: remediation
|
|
71
76
|
)
|
|
72
77
|
end
|
|
73
78
|
|
|
74
79
|
# Get page context
|
|
80
|
+
# @param element_context [Hash] Optional element context to help find partials
|
|
75
81
|
# @return [Hash]
|
|
76
|
-
def page_context
|
|
82
|
+
def page_context(element_context = nil)
|
|
77
83
|
{
|
|
78
84
|
url: safe_page_url,
|
|
79
85
|
path: safe_page_path,
|
|
80
|
-
view_file: determine_view_file
|
|
86
|
+
view_file: determine_view_file(element_context)
|
|
81
87
|
}
|
|
82
88
|
end
|
|
83
89
|
|
|
@@ -123,7 +129,8 @@ module RailsAccessibilityTesting
|
|
|
123
129
|
end
|
|
124
130
|
|
|
125
131
|
# Determine likely view file (simplified version)
|
|
126
|
-
|
|
132
|
+
# Also checks for partials that might contain the element
|
|
133
|
+
def determine_view_file(element_context = nil)
|
|
127
134
|
return nil unless safe_page_path
|
|
128
135
|
|
|
129
136
|
path = safe_page_path.split('?').first.split('#').first
|
|
@@ -134,7 +141,21 @@ module RailsAccessibilityTesting
|
|
|
134
141
|
controller = route[:controller]
|
|
135
142
|
action = route[:action]
|
|
136
143
|
|
|
137
|
-
find_view_file_for_controller_action(controller, action)
|
|
144
|
+
view_file = find_view_file_for_controller_action(controller, action)
|
|
145
|
+
|
|
146
|
+
# If we found the view file and have element context, check for partials
|
|
147
|
+
if view_file && element_context
|
|
148
|
+
# Scan the view file for rendered partials
|
|
149
|
+
partials_in_view = find_partials_in_view_file(view_file)
|
|
150
|
+
|
|
151
|
+
# Check if element matches any partial in the view
|
|
152
|
+
if partials_in_view.any?
|
|
153
|
+
partial_file = find_partial_for_element_in_list(controller, element_context, partials_in_view)
|
|
154
|
+
return partial_file if partial_file
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
view_file
|
|
138
159
|
rescue StandardError
|
|
139
160
|
nil
|
|
140
161
|
end
|
|
@@ -142,12 +163,40 @@ module RailsAccessibilityTesting
|
|
|
142
163
|
end
|
|
143
164
|
|
|
144
165
|
# Find view file for controller and action
|
|
166
|
+
# Handles cases where action name doesn't match view file name
|
|
145
167
|
def find_view_file_for_controller_action(controller, action)
|
|
146
168
|
extensions = %w[erb haml slim]
|
|
169
|
+
controller_path = "app/views/#{controller}"
|
|
170
|
+
|
|
171
|
+
# First, try exact matches
|
|
147
172
|
extensions.each do |ext|
|
|
148
|
-
|
|
149
|
-
|
|
173
|
+
view_paths = [
|
|
174
|
+
"#{controller_path}/#{action}.html.#{ext}",
|
|
175
|
+
"#{controller_path}/_#{action}.html.#{ext}",
|
|
176
|
+
"#{controller_path}/#{action}.#{ext}"
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
found = view_paths.find { |vp| File.exist?(vp) }
|
|
180
|
+
return found if found
|
|
150
181
|
end
|
|
182
|
+
|
|
183
|
+
# If exact match not found, scan all view files in the controller directory
|
|
184
|
+
# This handles cases like: search action -> search_result.html.erb
|
|
185
|
+
if File.directory?(controller_path)
|
|
186
|
+
extensions.each do |ext|
|
|
187
|
+
# Look for files that might match the action (e.g., search_result, search_results, etc.)
|
|
188
|
+
pattern = "#{controller_path}/*#{action}*.html.#{ext}"
|
|
189
|
+
matching_files = Dir.glob(pattern)
|
|
190
|
+
|
|
191
|
+
# Prefer files that start with the action name
|
|
192
|
+
preferred = matching_files.find { |f| File.basename(f).start_with?("#{action}_") || File.basename(f).start_with?("#{action}.") }
|
|
193
|
+
return preferred if preferred
|
|
194
|
+
|
|
195
|
+
# Return first match if any found
|
|
196
|
+
return matching_files.first if matching_files.any?
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
151
200
|
nil
|
|
152
201
|
end
|
|
153
202
|
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAccessibilityTesting
|
|
4
|
+
module Checks
|
|
5
|
+
# Comprehensive heading accessibility checks
|
|
6
|
+
#
|
|
7
|
+
# WCAG 2.1 AA requirements for headings:
|
|
8
|
+
# - 1.3.1 Info and Relationships (Level A): Proper heading hierarchy
|
|
9
|
+
# - 2.4.6 Headings and Labels (Level AA): Descriptive headings
|
|
10
|
+
# - 4.1.2 Name, Role, Value (Level A): Headings must have accessible names
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
class HeadingCheck < BaseCheck
|
|
14
|
+
def self.rule_name
|
|
15
|
+
:heading
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def check
|
|
19
|
+
violations = []
|
|
20
|
+
headings = page.all('h1, h2, h3, h4, h5, h6', visible: true)
|
|
21
|
+
|
|
22
|
+
if headings.empty?
|
|
23
|
+
return violations # Warning only, not error
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Check 1: Missing H1
|
|
27
|
+
h1_count = headings.count { |h| h.tag_name == 'h1' }
|
|
28
|
+
first_heading = headings.first
|
|
29
|
+
first_heading_level = first_heading ? first_heading.tag_name[1].to_i : nil
|
|
30
|
+
|
|
31
|
+
if h1_count == 0
|
|
32
|
+
# If the first heading is h2 or higher, provide a more specific message
|
|
33
|
+
if first_heading_level && first_heading_level >= 2
|
|
34
|
+
element_ctx = element_context(first_heading)
|
|
35
|
+
violations << violation(
|
|
36
|
+
message: "Page has h#{first_heading_level} but no h1 heading",
|
|
37
|
+
element_context: element_ctx,
|
|
38
|
+
wcag_reference: "1.3.1",
|
|
39
|
+
remediation: "Add an <h1> heading before the first h#{first_heading_level}:\n\n<h1>Main Page Title</h1>\n<h#{first_heading_level}>#{first_heading.text}</h#{first_heading_level}>"
|
|
40
|
+
)
|
|
41
|
+
else
|
|
42
|
+
violations << violation(
|
|
43
|
+
message: "Page missing H1 heading",
|
|
44
|
+
element_context: { tag: 'page', text: 'Page has no H1 heading' },
|
|
45
|
+
wcag_reference: "1.3.1",
|
|
46
|
+
remediation: "Add an <h1> heading to your page:\n\n<h1>Main Page Title</h1>"
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Check 2: Multiple H1s (WCAG 1.3.1)
|
|
52
|
+
if h1_count > 1
|
|
53
|
+
# Report error for each h1 after the first one
|
|
54
|
+
h1_elements = headings.select { |h| h.tag_name == 'h1' }
|
|
55
|
+
h1_elements[1..-1].each do |h1|
|
|
56
|
+
element_ctx = element_context(h1)
|
|
57
|
+
violations << violation(
|
|
58
|
+
message: "Page has multiple h1 headings (#{h1_count} total) - only one h1 should be used per page",
|
|
59
|
+
element_context: element_ctx,
|
|
60
|
+
wcag_reference: "1.3.1",
|
|
61
|
+
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>"
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check 3: Heading hierarchy skipped levels (WCAG 1.3.1)
|
|
67
|
+
previous_level = 0
|
|
68
|
+
headings.each do |heading|
|
|
69
|
+
current_level = heading.tag_name[1].to_i
|
|
70
|
+
if current_level > previous_level + 1
|
|
71
|
+
element_ctx = element_context(heading)
|
|
72
|
+
violations << violation(
|
|
73
|
+
message: "Heading hierarchy skipped (h#{previous_level} to h#{current_level})",
|
|
74
|
+
element_context: element_ctx,
|
|
75
|
+
wcag_reference: "1.3.1",
|
|
76
|
+
remediation: "Fix the heading hierarchy. Don't skip levels. Use h#{previous_level + 1} instead of h#{current_level}."
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
previous_level = current_level
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Check 4: Empty headings (WCAG 4.1.2)
|
|
83
|
+
headings.each do |heading|
|
|
84
|
+
heading_text = heading.text.strip
|
|
85
|
+
# Check if heading is empty or only contains whitespace/formatting
|
|
86
|
+
if heading_text.empty? || heading_text.match?(/^\s*$/)
|
|
87
|
+
element_ctx = element_context(heading)
|
|
88
|
+
violations << violation(
|
|
89
|
+
message: "Empty heading detected (<#{heading.tag_name}>) - headings must have accessible text",
|
|
90
|
+
element_context: element_ctx,
|
|
91
|
+
wcag_reference: "4.1.2",
|
|
92
|
+
remediation: "Add descriptive text to the heading:\n\n<#{heading.tag_name}>Descriptive Heading Text</#{heading.tag_name}>"
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Check 5: Headings with only images (no alt text or empty alt) (WCAG 4.1.2)
|
|
98
|
+
headings.each do |heading|
|
|
99
|
+
# Check if heading contains only images
|
|
100
|
+
images = heading.all('img', visible: true)
|
|
101
|
+
if images.any? && heading.text.strip.empty?
|
|
102
|
+
# Check if all images have alt text
|
|
103
|
+
images_without_alt = images.select { |img| img[:alt].nil? || img[:alt].strip.empty? }
|
|
104
|
+
if images_without_alt.any?
|
|
105
|
+
element_ctx = element_context(heading)
|
|
106
|
+
violations << violation(
|
|
107
|
+
message: "Heading contains images without alt text - headings must have accessible text",
|
|
108
|
+
element_context: element_ctx,
|
|
109
|
+
wcag_reference: "4.1.2",
|
|
110
|
+
remediation: "Add alt text to images in the heading, or add visible text alongside the images:\n\n<h1><img src=\"image.jpg\" alt=\"Descriptive text\">Heading Text</h1>"
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Check 6: Headings used only for styling (WCAG 2.4.6)
|
|
117
|
+
# This is harder to detect automatically, but we can check for very short or generic text
|
|
118
|
+
headings.each do |heading|
|
|
119
|
+
heading_text = heading.text.strip
|
|
120
|
+
# Very short headings (1-2 characters) might be styling-only
|
|
121
|
+
# Generic text like "•", "→", "..." are likely styling
|
|
122
|
+
if heading_text.length <= 2 && heading_text.match?(/^[•→…\s\-_=]+$/)
|
|
123
|
+
element_ctx = element_context(heading)
|
|
124
|
+
violations << violation(
|
|
125
|
+
message: "Heading appears to be used for styling only (text: '#{heading_text}') - headings should be descriptive",
|
|
126
|
+
element_context: element_ctx,
|
|
127
|
+
wcag_reference: "2.4.6",
|
|
128
|
+
remediation: "Use CSS for styling instead of headings. Replace with a <div> or <span> with appropriate CSS classes."
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
violations
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
@@ -15,14 +15,14 @@ module RailsAccessibilityTesting
|
|
|
15
15
|
def check
|
|
16
16
|
violations = []
|
|
17
17
|
|
|
18
|
+
# Use native attribute access instead of JavaScript evaluation for better performance
|
|
18
19
|
page.all('img', visible: :all).each do |img|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
alt_value_js = page.evaluate_script("arguments[0].getAttribute('alt')", img.native) || ""
|
|
20
|
+
# Get alt value directly from Capybara element (faster than JavaScript)
|
|
21
|
+
alt_value = img[:alt]
|
|
22
|
+
# Check if alt attribute exists (nil means missing, empty string means present but empty)
|
|
23
|
+
has_alt_attribute = img.native.attribute('alt') != nil rescue false
|
|
24
24
|
|
|
25
|
-
if has_alt_attribute
|
|
25
|
+
if !has_alt_attribute
|
|
26
26
|
element_ctx = element_context(img)
|
|
27
27
|
|
|
28
28
|
violations << violation(
|
|
@@ -31,7 +31,7 @@ module RailsAccessibilityTesting
|
|
|
31
31
|
wcag_reference: "1.1.1",
|
|
32
32
|
remediation: generate_remediation(element_ctx)
|
|
33
33
|
)
|
|
34
|
-
elsif
|
|
34
|
+
elsif alt_value.blank? && has_alt_attribute
|
|
35
35
|
# Image has alt attribute but it's empty - warn about this
|
|
36
36
|
# Empty alt is valid for decorative images, but we should check if it's actually decorative
|
|
37
37
|
element_ctx = element_context(img)
|
|
@@ -23,7 +23,17 @@ module RailsAccessibilityTesting
|
|
|
23
23
|
aria_labelledby = element[:"aria-labelledby"]
|
|
24
24
|
title = element[:title]
|
|
25
25
|
|
|
26
|
-
if text
|
|
26
|
+
# Check if element contains an image with alt text (common pattern for logo links)
|
|
27
|
+
has_image_with_alt = false
|
|
28
|
+
if text.blank?
|
|
29
|
+
images = element.all('img', visible: :all)
|
|
30
|
+
has_image_with_alt = images.any? do |img|
|
|
31
|
+
alt = img[:alt]
|
|
32
|
+
alt.present? && !alt.strip.empty?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if text.blank? && aria_label.blank? && aria_labelledby.blank? && title.blank? && !has_image_with_alt
|
|
27
37
|
element_ctx = element_context(element)
|
|
28
38
|
tag = element.tag_name
|
|
29
39
|
|
|
@@ -335,9 +335,11 @@ module RailsAccessibilityTesting
|
|
|
335
335
|
end
|
|
336
336
|
|
|
337
337
|
def generate_human_report(results)
|
|
338
|
+
timestamp = Time.now.strftime("%H:%M:%S")
|
|
339
|
+
|
|
338
340
|
output = []
|
|
339
341
|
output << "=" * 70
|
|
340
|
-
output << "Rails A11y Accessibility Report"
|
|
342
|
+
output << "Rails A11y Accessibility Report • #{timestamp}"
|
|
341
343
|
output << "=" * 70
|
|
342
344
|
output << ""
|
|
343
345
|
output << "Summary:"
|
|
@@ -116,7 +116,7 @@ module RailsAccessibilityTesting
|
|
|
116
116
|
'form_labels' => true,
|
|
117
117
|
'image_alt_text' => true,
|
|
118
118
|
'interactive_elements' => true,
|
|
119
|
-
'
|
|
119
|
+
'heading' => true,
|
|
120
120
|
'keyboard_accessibility' => true,
|
|
121
121
|
'aria_landmarks' => true,
|
|
122
122
|
'form_errors' => true,
|
|
@@ -25,22 +25,45 @@ module RailsAccessibilityTesting
|
|
|
25
25
|
# Run all enabled checks against a page
|
|
26
26
|
# @param page [Capybara::Session] The page to check
|
|
27
27
|
# @param context [Hash] Additional context (url, path, etc.)
|
|
28
|
+
# @param progress_callback [Proc] Optional callback for progress updates
|
|
28
29
|
# @return [Array<Violation>] Array of violations found
|
|
29
|
-
def check(page, context: {})
|
|
30
|
+
def check(page, context: {}, progress_callback: nil)
|
|
30
31
|
@violation_collector.reset
|
|
31
32
|
|
|
32
|
-
enabled_checks.
|
|
33
|
-
|
|
33
|
+
checks_to_run = enabled_checks.reject { |check_class| rule_ignored?(check_class.rule_name) }
|
|
34
|
+
total_checks = checks_to_run.length
|
|
35
|
+
|
|
36
|
+
checks_to_run.each_with_index do |check_class, index|
|
|
37
|
+
check_number = index + 1
|
|
38
|
+
check_name = humanize_check_name(check_class.rule_name)
|
|
39
|
+
|
|
40
|
+
# Report progress
|
|
41
|
+
if progress_callback
|
|
42
|
+
progress_callback.call(check_number, total_checks, check_name, :start)
|
|
43
|
+
end
|
|
34
44
|
|
|
35
45
|
begin
|
|
36
46
|
check_instance = check_class.new(page: page, context: context)
|
|
37
47
|
violations = check_instance.run
|
|
38
|
-
|
|
48
|
+
|
|
49
|
+
if violations.any?
|
|
50
|
+
@violation_collector.add(violations)
|
|
51
|
+
if progress_callback
|
|
52
|
+
progress_callback.call(check_number, total_checks, check_name, :found_issues, violations.length)
|
|
53
|
+
end
|
|
54
|
+
else
|
|
55
|
+
if progress_callback
|
|
56
|
+
progress_callback.call(check_number, total_checks, check_name, :passed)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
39
59
|
rescue StandardError => e
|
|
40
60
|
# Log but don't fail - one check error shouldn't stop others
|
|
41
61
|
if defined?(RailsAccessibilityTesting) && RailsAccessibilityTesting.config.respond_to?(:logger) && RailsAccessibilityTesting.config.logger
|
|
42
62
|
RailsAccessibilityTesting.config.logger.error("Check #{check_class.rule_name} failed: #{e.message}")
|
|
43
63
|
end
|
|
64
|
+
if progress_callback
|
|
65
|
+
progress_callback.call(check_number, total_checks, check_name, :error, e.message)
|
|
66
|
+
end
|
|
44
67
|
end
|
|
45
68
|
end
|
|
46
69
|
|
|
@@ -55,7 +78,7 @@ module RailsAccessibilityTesting
|
|
|
55
78
|
Checks::FormLabelsCheck,
|
|
56
79
|
Checks::ImageAltTextCheck,
|
|
57
80
|
Checks::InteractiveElementsCheck,
|
|
58
|
-
Checks::
|
|
81
|
+
Checks::HeadingCheck,
|
|
59
82
|
Checks::KeyboardAccessibilityCheck,
|
|
60
83
|
Checks::AriaLandmarksCheck,
|
|
61
84
|
Checks::FormErrorsCheck,
|
|
@@ -93,6 +116,27 @@ module RailsAccessibilityTesting
|
|
|
93
116
|
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
94
117
|
.downcase
|
|
95
118
|
end
|
|
119
|
+
|
|
120
|
+
# Convert rule name to human-readable check name
|
|
121
|
+
def humanize_check_name(rule_name)
|
|
122
|
+
# Map of rule names to friendly display names
|
|
123
|
+
friendly_names = {
|
|
124
|
+
'form_labels' => 'Form Labels',
|
|
125
|
+
'image_alt_text' => 'Image Alt Text',
|
|
126
|
+
'interactive_elements' => 'Interactive Elements',
|
|
127
|
+
'heading' => 'Heading Hierarchy',
|
|
128
|
+
'keyboard_accessibility' => 'Keyboard Accessibility',
|
|
129
|
+
'aria_landmarks' => 'ARIA Landmarks',
|
|
130
|
+
'form_errors' => 'Form Error Associations',
|
|
131
|
+
'table_structure' => 'Table Structure',
|
|
132
|
+
'duplicate_ids' => 'Duplicate IDs',
|
|
133
|
+
'skip_links' => 'Skip Links',
|
|
134
|
+
'color_contrast' => 'Color Contrast'
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
rule_str = rule_name.to_s
|
|
138
|
+
friendly_names[rule_str] || rule_str.split('_').map(&:capitalize).join(' ')
|
|
139
|
+
end
|
|
96
140
|
end
|
|
97
141
|
end
|
|
98
142
|
end
|
|
@@ -45,14 +45,18 @@ module RailsAccessibilityTesting
|
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
def page_info(page_context)
|
|
48
|
-
lines = [
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
" Path: #{page_context[:path] || '(unknown)'}"
|
|
52
|
-
]
|
|
53
|
-
|
|
48
|
+
lines = ['📄 Page Being Tested:']
|
|
49
|
+
|
|
50
|
+
# Show view file first and prominently if available
|
|
54
51
|
if page_context[:view_file]
|
|
55
|
-
lines << " 📝
|
|
52
|
+
lines << " 📝 View File: #{page_context[:view_file]}"
|
|
53
|
+
lines << " 🔗 Path: #{page_context[:path] || '(unknown)'}"
|
|
54
|
+
lines << " 🌐 URL: #{page_context[:url] || '(unknown)'}" if page_context[:url] && !page_context[:url].include?('127.0.0.1')
|
|
55
|
+
else
|
|
56
|
+
# Fallback to path/URL if view file not found
|
|
57
|
+
lines << " 🔗 Path: #{page_context[:path] || '(unknown)'}"
|
|
58
|
+
lines << " 🌐 URL: #{page_context[:url] || '(unknown)'}" if page_context[:url]
|
|
59
|
+
lines << " ⚠️ View file not detected - check path: #{page_context[:path]}"
|
|
56
60
|
end
|
|
57
61
|
|
|
58
62
|
lines.join("\n") + "\n"
|
|
@@ -109,8 +113,14 @@ module RailsAccessibilityTesting
|
|
|
109
113
|
interactive_element_remediation(error_type, element_context)
|
|
110
114
|
when /Page missing H1 heading/i
|
|
111
115
|
missing_h1_remediation
|
|
116
|
+
when /multiple h1|Multiple h1/i
|
|
117
|
+
multiple_h1_remediation(error_type, element_context)
|
|
112
118
|
when /Heading hierarchy skipped/i
|
|
113
119
|
heading_hierarchy_remediation(error_type, element_context)
|
|
120
|
+
when /Empty heading|heading.*empty/i
|
|
121
|
+
empty_heading_remediation(element_context)
|
|
122
|
+
when /heading.*styling|styling.*heading/i
|
|
123
|
+
heading_styling_remediation(element_context)
|
|
114
124
|
when /Modal dialog has no focusable elements/i
|
|
115
125
|
modal_remediation
|
|
116
126
|
when /Page missing MAIN landmark/i
|
|
@@ -134,8 +144,14 @@ module RailsAccessibilityTesting
|
|
|
134
144
|
interactive_element_remediation(error_type, element_context)
|
|
135
145
|
when /missing.*H1/i
|
|
136
146
|
missing_h1_remediation
|
|
147
|
+
when /multiple.*h1/i
|
|
148
|
+
multiple_h1_remediation(error_type, element_context)
|
|
137
149
|
when /hierarchy.*skipped/i
|
|
138
150
|
heading_hierarchy_remediation(error_type, element_context)
|
|
151
|
+
when /empty.*heading|heading.*empty/i
|
|
152
|
+
empty_heading_remediation(element_context)
|
|
153
|
+
when /heading.*styling|styling.*heading/i
|
|
154
|
+
heading_styling_remediation(element_context)
|
|
139
155
|
when /modal.*focusable/i
|
|
140
156
|
modal_remediation
|
|
141
157
|
when /missing.*MAIN/i
|
|
@@ -228,6 +244,46 @@ module RailsAccessibilityTesting
|
|
|
228
244
|
remediation
|
|
229
245
|
end
|
|
230
246
|
|
|
247
|
+
def multiple_h1_remediation(error_type, element_context)
|
|
248
|
+
# Extract h1 count from error message if available
|
|
249
|
+
match = error_type.match(/(\d+)\s+total/)
|
|
250
|
+
h1_count = match ? match[1] : "multiple"
|
|
251
|
+
|
|
252
|
+
remediation = " Fix multiple h1 headings:\n\n"
|
|
253
|
+
remediation += " Current: Page has #{h1_count} <h1> headings\n"
|
|
254
|
+
remediation += " Should be: Only one <h1> per page\n\n"
|
|
255
|
+
remediation += " Example fix:\n"
|
|
256
|
+
remediation += " <h1>Main Page Title</h1>\n"
|
|
257
|
+
remediation += " <h2>Section Title</h2> ← Convert additional h1s to h2\n\n"
|
|
258
|
+
remediation += " 💡 Best Practice: Use only one <h1> for the main page title.\n"
|
|
259
|
+
remediation += " Use <h2> for major sections, <h3> for subsections, etc.\n"
|
|
260
|
+
remediation
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def empty_heading_remediation(element_context)
|
|
264
|
+
tag = element_context[:tag] || "h1"
|
|
265
|
+
remediation = " Fix empty heading:\n\n"
|
|
266
|
+
remediation += " Current: <#{tag}></#{tag}>\n"
|
|
267
|
+
remediation += " Should be: <#{tag}>Descriptive Heading Text</#{tag}>\n\n"
|
|
268
|
+
remediation += " Example:\n"
|
|
269
|
+
remediation += " <#{tag}>Introduction to Our Services</#{tag}>\n\n"
|
|
270
|
+
remediation += " 💡 Best Practice: Headings must have accessible text.\n"
|
|
271
|
+
remediation += " Screen readers need text content to announce headings.\n"
|
|
272
|
+
remediation
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def heading_styling_remediation(element_context)
|
|
276
|
+
remediation = " Fix heading used for styling:\n\n"
|
|
277
|
+
remediation += " Current: Using <h1> or <h2> for visual styling only\n"
|
|
278
|
+
remediation += " Should be: Use CSS classes for styling\n\n"
|
|
279
|
+
remediation += " Example fix:\n"
|
|
280
|
+
remediation += " ❌ <h1>•</h1> (bad - heading for styling)\n"
|
|
281
|
+
remediation += " ✅ <div class=\"decorative-bullet\">•</div> (good - div with CSS)\n\n"
|
|
282
|
+
remediation += " 💡 Best Practice: Headings should be semantic, not decorative.\n"
|
|
283
|
+
remediation += " Use <div> or <span> with CSS classes for visual styling.\n"
|
|
284
|
+
remediation
|
|
285
|
+
end
|
|
286
|
+
|
|
231
287
|
def heading_hierarchy_remediation(error_type, element_context)
|
|
232
288
|
# Extract heading levels from error_type: "HEADING hierarchy skipped (h1 to h3)"
|
|
233
289
|
match = error_type.match(/h(\d+) to h(\d+)/)
|