rails_accessibility_testing 1.1.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 +7 -0
- data/ARCHITECTURE.md +307 -0
- data/CHANGELOG.md +81 -0
- data/CODE_OF_CONDUCT.md +125 -0
- data/CONTRIBUTING.md +225 -0
- data/GUIDES/continuous_integration.md +326 -0
- data/GUIDES/getting_started.md +205 -0
- data/GUIDES/working_with_designers_and_content_authors.md +398 -0
- data/GUIDES/writing_accessible_views_in_rails.md +412 -0
- data/LICENSE +22 -0
- data/README.md +350 -0
- data/docs_site/404.html +11 -0
- data/docs_site/Gemfile +11 -0
- data/docs_site/Makefile +14 -0
- data/docs_site/_config.yml +41 -0
- data/docs_site/_includes/header.html +13 -0
- data/docs_site/_layouts/default.html +130 -0
- data/docs_site/assets/main.scss +4 -0
- data/docs_site/ci_integration.md +76 -0
- data/docs_site/configuration.md +114 -0
- data/docs_site/contributing.md +69 -0
- data/docs_site/getting_started.md +57 -0
- data/docs_site/index.md +57 -0
- data/exe/rails_a11y +12 -0
- data/exe/rails_server_safe +41 -0
- data/lib/generators/rails_a11y/install/generator.rb +51 -0
- data/lib/rails_accessibility_testing/accessibility_helper.rb +701 -0
- data/lib/rails_accessibility_testing/change_detector.rb +114 -0
- data/lib/rails_accessibility_testing/checks/aria_landmarks_check.rb +33 -0
- data/lib/rails_accessibility_testing/checks/base_check.rb +156 -0
- data/lib/rails_accessibility_testing/checks/color_contrast_check.rb +56 -0
- data/lib/rails_accessibility_testing/checks/duplicate_ids_check.rb +49 -0
- data/lib/rails_accessibility_testing/checks/form_errors_check.rb +40 -0
- data/lib/rails_accessibility_testing/checks/form_labels_check.rb +62 -0
- data/lib/rails_accessibility_testing/checks/heading_hierarchy_check.rb +53 -0
- data/lib/rails_accessibility_testing/checks/image_alt_text_check.rb +52 -0
- data/lib/rails_accessibility_testing/checks/interactive_elements_check.rb +66 -0
- data/lib/rails_accessibility_testing/checks/keyboard_accessibility_check.rb +36 -0
- data/lib/rails_accessibility_testing/checks/skip_links_check.rb +24 -0
- data/lib/rails_accessibility_testing/checks/table_structure_check.rb +36 -0
- data/lib/rails_accessibility_testing/cli/command.rb +259 -0
- data/lib/rails_accessibility_testing/config/yaml_loader.rb +131 -0
- data/lib/rails_accessibility_testing/configuration.rb +30 -0
- data/lib/rails_accessibility_testing/engine/rule_engine.rb +97 -0
- data/lib/rails_accessibility_testing/engine/violation.rb +58 -0
- data/lib/rails_accessibility_testing/engine/violation_collector.rb +59 -0
- data/lib/rails_accessibility_testing/error_message_builder.rb +354 -0
- data/lib/rails_accessibility_testing/integration/minitest_integration.rb +74 -0
- data/lib/rails_accessibility_testing/rspec_integration.rb +58 -0
- data/lib/rails_accessibility_testing/shared_examples.rb +93 -0
- data/lib/rails_accessibility_testing/version.rb +4 -0
- data/lib/rails_accessibility_testing.rb +83 -0
- data/lib/tasks/accessibility.rake +28 -0
- metadata +218 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAccessibilityTesting
|
|
4
|
+
# Detects if relevant files have changed to determine if accessibility checks should run
|
|
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
|
+
class << self
|
|
16
|
+
# Check if relevant files have changed
|
|
17
|
+
# @param current_path [String] The current Rails path being tested
|
|
18
|
+
# @return [Boolean] true if files have changed, false otherwise
|
|
19
|
+
def files_changed?(current_path)
|
|
20
|
+
return false unless current_path
|
|
21
|
+
|
|
22
|
+
view_file = determine_view_file_from_path(current_path)
|
|
23
|
+
return true if view_file_recently_modified?(view_file)
|
|
24
|
+
return true if git_has_uncommitted_changes?
|
|
25
|
+
return true if any_monitored_files_recently_modified?
|
|
26
|
+
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
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) }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAccessibilityTesting
|
|
4
|
+
module Checks
|
|
5
|
+
# Checks for proper ARIA landmarks
|
|
6
|
+
#
|
|
7
|
+
# WCAG 2.1 AA: 1.3.1 Info and Relationships (Level A)
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
class AriaLandmarksCheck < BaseCheck
|
|
11
|
+
def self.rule_name
|
|
12
|
+
:aria_landmarks
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def check
|
|
16
|
+
violations = []
|
|
17
|
+
|
|
18
|
+
main_landmarks = page.all('main, [role="main"]', visible: true)
|
|
19
|
+
if main_landmarks.empty?
|
|
20
|
+
violations << violation(
|
|
21
|
+
message: "Page missing MAIN landmark",
|
|
22
|
+
element_context: { tag: 'page', text: 'Page has no MAIN landmark' },
|
|
23
|
+
wcag_reference: "1.3.1",
|
|
24
|
+
remediation: "Wrap main content in <main> tag:\n\n<main>\n <%= yield %>\n</main>"
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
violations
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAccessibilityTesting
|
|
4
|
+
module Checks
|
|
5
|
+
# Base class for all accessibility checks
|
|
6
|
+
#
|
|
7
|
+
# Provides common functionality and defines the interface
|
|
8
|
+
# that all checks must implement.
|
|
9
|
+
#
|
|
10
|
+
# @abstract Subclass and implement {#check} to create a new check
|
|
11
|
+
#
|
|
12
|
+
# @example Creating a custom check
|
|
13
|
+
# class MyCustomCheck < BaseCheck
|
|
14
|
+
# def self.rule_name
|
|
15
|
+
# :my_custom_check
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# def check
|
|
19
|
+
# violations = []
|
|
20
|
+
# # Check logic here
|
|
21
|
+
# violations
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# @api private
|
|
26
|
+
class BaseCheck
|
|
27
|
+
attr_reader :page, :context
|
|
28
|
+
|
|
29
|
+
# Initialize the check
|
|
30
|
+
# @param page [Capybara::Session] The page to check
|
|
31
|
+
# @param context [Hash] Additional context (url, path, etc.)
|
|
32
|
+
def initialize(page:, context: {})
|
|
33
|
+
@page = page
|
|
34
|
+
@context = context
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Run the check and return violations
|
|
38
|
+
# @return [Array<Engine::Violation>]
|
|
39
|
+
def run
|
|
40
|
+
check
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The check implementation (must be overridden)
|
|
44
|
+
# @return [Array<Engine::Violation>]
|
|
45
|
+
def check
|
|
46
|
+
raise NotImplementedError, "Subclass must implement #check"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Rule name for this check (must be overridden)
|
|
50
|
+
# @return [Symbol]
|
|
51
|
+
def self.rule_name
|
|
52
|
+
raise NotImplementedError, "Subclass must implement .rule_name"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
protected
|
|
56
|
+
|
|
57
|
+
# Create a violation
|
|
58
|
+
# @param message [String] Error message
|
|
59
|
+
# @param element_context [Hash] Element context
|
|
60
|
+
# @param wcag_reference [String] WCAG reference
|
|
61
|
+
# @param remediation [String] Suggested fix
|
|
62
|
+
# @return [Engine::Violation]
|
|
63
|
+
def violation(message:, element_context: {}, wcag_reference: nil, remediation: nil)
|
|
64
|
+
Engine::Violation.new(
|
|
65
|
+
rule_name: self.class.rule_name,
|
|
66
|
+
message: message,
|
|
67
|
+
element_context: element_context,
|
|
68
|
+
page_context: page_context,
|
|
69
|
+
wcag_reference: wcag_reference,
|
|
70
|
+
remediation: remediation
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Get page context
|
|
75
|
+
# @return [Hash]
|
|
76
|
+
def page_context
|
|
77
|
+
{
|
|
78
|
+
url: safe_page_url,
|
|
79
|
+
path: safe_page_path,
|
|
80
|
+
view_file: determine_view_file
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Get element context from Capybara element
|
|
85
|
+
# @param element [Capybara::Node::Element] The element
|
|
86
|
+
# @return [Hash]
|
|
87
|
+
def element_context(element)
|
|
88
|
+
{
|
|
89
|
+
tag: element.tag_name,
|
|
90
|
+
id: element[:id],
|
|
91
|
+
classes: element[:class],
|
|
92
|
+
href: element[:href],
|
|
93
|
+
src: element[:src],
|
|
94
|
+
text: element.text.strip,
|
|
95
|
+
parent: safe_parent_info(element)
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Safely get page URL
|
|
100
|
+
def safe_page_url
|
|
101
|
+
page.current_url
|
|
102
|
+
rescue StandardError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Safely get page path
|
|
107
|
+
def safe_page_path
|
|
108
|
+
page.current_path
|
|
109
|
+
rescue StandardError
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Safely get parent element info
|
|
114
|
+
def safe_parent_info(element)
|
|
115
|
+
parent = element.find(:xpath, '..')
|
|
116
|
+
{
|
|
117
|
+
tag: parent.tag_name,
|
|
118
|
+
id: parent[:id],
|
|
119
|
+
classes: parent[:class]
|
|
120
|
+
}
|
|
121
|
+
rescue StandardError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Determine likely view file (simplified version)
|
|
126
|
+
def determine_view_file
|
|
127
|
+
return nil unless safe_page_path
|
|
128
|
+
|
|
129
|
+
path = safe_page_path.split('?').first.split('#').first
|
|
130
|
+
|
|
131
|
+
if defined?(Rails) && Rails.application
|
|
132
|
+
begin
|
|
133
|
+
route = Rails.application.routes.recognize_path(path)
|
|
134
|
+
controller = route[:controller]
|
|
135
|
+
action = route[:action]
|
|
136
|
+
|
|
137
|
+
find_view_file_for_controller_action(controller, action)
|
|
138
|
+
rescue StandardError
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Find view file for controller and action
|
|
145
|
+
def find_view_file_for_controller_action(controller, action)
|
|
146
|
+
extensions = %w[erb haml slim]
|
|
147
|
+
extensions.each do |ext|
|
|
148
|
+
view_path = "app/views/#{controller}/#{action}.html.#{ext}"
|
|
149
|
+
return view_path if File.exist?(view_path)
|
|
150
|
+
end
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAccessibilityTesting
|
|
4
|
+
module Checks
|
|
5
|
+
# Checks color contrast ratios for text elements
|
|
6
|
+
#
|
|
7
|
+
# Validates that text meets WCAG 2.1 AA contrast requirements:
|
|
8
|
+
# - Normal text: 4.5:1
|
|
9
|
+
# - Large text (18pt+ or 14pt+ bold): 3:1
|
|
10
|
+
#
|
|
11
|
+
# Note: This is a simplified check. Full contrast checking requires
|
|
12
|
+
# JavaScript evaluation of computed styles.
|
|
13
|
+
#
|
|
14
|
+
# @api private
|
|
15
|
+
class ColorContrastCheck < BaseCheck
|
|
16
|
+
def self.rule_name
|
|
17
|
+
:color_contrast
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def check
|
|
21
|
+
violations = []
|
|
22
|
+
|
|
23
|
+
# This is a placeholder implementation
|
|
24
|
+
# Full contrast checking requires JavaScript to compute
|
|
25
|
+
# actual foreground/background colors from CSS
|
|
26
|
+
|
|
27
|
+
# For now, we'll check for common contrast issues:
|
|
28
|
+
# - Text with low contrast classes
|
|
29
|
+
# - Inline styles with poor contrast
|
|
30
|
+
# - Elements that might have contrast issues
|
|
31
|
+
|
|
32
|
+
page.all('*[style*="color"], *[class*="text-"], p, span, div, h1, h2, h3, h4, h5, h6', visible: true).each do |element|
|
|
33
|
+
# Check for inline styles that might indicate contrast issues
|
|
34
|
+
style = element[:style]
|
|
35
|
+
if style && style.match?(/color:\s*(?:#(?:fff|ffffff|000|000000)|rgb\(255,\s*255,\s*255\)|rgb\(0,\s*0,\s*0\))/i)
|
|
36
|
+
# This is a simplified check - real contrast checking needs computed styles
|
|
37
|
+
# For now, we'll just warn about potential issues
|
|
38
|
+
next # Skip for now - requires JS evaluation
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
violations
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Calculate contrast ratio (simplified - would need actual color values)
|
|
48
|
+
def contrast_ratio(foreground, background)
|
|
49
|
+
# Placeholder - would need to convert colors to relative luminance
|
|
50
|
+
# and calculate: (L1 + 0.05) / (L2 + 0.05)
|
|
51
|
+
4.5 # Default to passing
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAccessibilityTesting
|
|
4
|
+
module Checks
|
|
5
|
+
# Checks for duplicate IDs
|
|
6
|
+
#
|
|
7
|
+
# WCAG 2.1 AA: 4.1.1 Parsing (Level A)
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
class DuplicateIdsCheck < BaseCheck
|
|
11
|
+
def self.rule_name
|
|
12
|
+
:duplicate_ids
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def check
|
|
16
|
+
violations = []
|
|
17
|
+
all_ids = page.all('[id]').map { |el| el[:id] }.compact
|
|
18
|
+
duplicates = all_ids.group_by(&:itself).select { |_k, v| v.length > 1 }.keys
|
|
19
|
+
|
|
20
|
+
if duplicates.any?
|
|
21
|
+
first_duplicate_id = duplicates.first
|
|
22
|
+
first_element = page.first("[id='#{first_duplicate_id}']", wait: false)
|
|
23
|
+
|
|
24
|
+
element_ctx = if first_element
|
|
25
|
+
ctx = element_context(first_element)
|
|
26
|
+
ctx[:duplicate_ids] = duplicates
|
|
27
|
+
ctx
|
|
28
|
+
else
|
|
29
|
+
{
|
|
30
|
+
tag: 'multiple',
|
|
31
|
+
id: first_duplicate_id,
|
|
32
|
+
duplicate_ids: duplicates
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
violations << violation(
|
|
37
|
+
message: "Duplicate IDs found: #{duplicates.join(', ')}",
|
|
38
|
+
element_context: element_ctx,
|
|
39
|
+
wcag_reference: "4.1.1",
|
|
40
|
+
remediation: "Ensure each ID is unique on the page"
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
violations
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAccessibilityTesting
|
|
4
|
+
module Checks
|
|
5
|
+
# Checks that form errors are associated with inputs
|
|
6
|
+
#
|
|
7
|
+
# WCAG 2.1 AA: 3.3.1 Error Identification (Level A)
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
class FormErrorsCheck < BaseCheck
|
|
11
|
+
def self.rule_name
|
|
12
|
+
:form_errors
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def check
|
|
16
|
+
violations = []
|
|
17
|
+
|
|
18
|
+
page.all('.field_with_errors input, .field_with_errors textarea, .field_with_errors select, .is-invalid, [aria-invalid="true"]').each do |input|
|
|
19
|
+
id = input[:id]
|
|
20
|
+
next if id.blank?
|
|
21
|
+
|
|
22
|
+
has_error_message = page.has_css?("[aria-describedby*='#{id}'], .field_with_errors label[for='#{id}'] + .error, .field_with_errors label[for='#{id}'] + .invalid-feedback", wait: false)
|
|
23
|
+
|
|
24
|
+
unless has_error_message
|
|
25
|
+
element_ctx = element_context(input)
|
|
26
|
+
violations << violation(
|
|
27
|
+
message: "Form input error message not associated",
|
|
28
|
+
element_context: element_ctx,
|
|
29
|
+
wcag_reference: "3.3.1",
|
|
30
|
+
remediation: "Associate error message with input using aria-describedby"
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
violations
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAccessibilityTesting
|
|
4
|
+
module Checks
|
|
5
|
+
# Checks that form inputs have associated labels
|
|
6
|
+
#
|
|
7
|
+
# WCAG 2.1 AA: 1.3.1 Info and Relationships (Level A)
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
class FormLabelsCheck < BaseCheck
|
|
11
|
+
def self.rule_name
|
|
12
|
+
:form_labels
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def check
|
|
16
|
+
violations = []
|
|
17
|
+
page_context = self.page_context
|
|
18
|
+
|
|
19
|
+
page.all('input[type="text"], input[type="email"], input[type="password"], input[type="number"], input[type="tel"], input[type="url"], input[type="search"], input[type="date"], input[type="time"], input[type="datetime-local"], textarea, select').each do |input|
|
|
20
|
+
id = input[:id]
|
|
21
|
+
next if id.blank?
|
|
22
|
+
|
|
23
|
+
has_label = page.has_css?("label[for='#{id}']", wait: false)
|
|
24
|
+
aria_label = input[:"aria-label"].present?
|
|
25
|
+
aria_labelledby = input[:"aria-labelledby"].present?
|
|
26
|
+
|
|
27
|
+
unless has_label || aria_label || aria_labelledby
|
|
28
|
+
element_ctx = element_context(input)
|
|
29
|
+
element_ctx[:input_type] = input[:type] || input.tag_name
|
|
30
|
+
|
|
31
|
+
violations << violation(
|
|
32
|
+
message: "Form input missing label",
|
|
33
|
+
element_context: element_ctx,
|
|
34
|
+
wcag_reference: "1.3.1",
|
|
35
|
+
remediation: generate_remediation(element_ctx)
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
violations
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def generate_remediation(element_context)
|
|
46
|
+
id = element_context[:id]
|
|
47
|
+
input_type = element_context[:input_type] || 'text'
|
|
48
|
+
|
|
49
|
+
"Choose ONE of these solutions:\n\n" \
|
|
50
|
+
"1. Add a <label> element:\n" \
|
|
51
|
+
" <label for=\"#{id}\">Field Label</label>\n" \
|
|
52
|
+
" <input type=\"#{input_type}\" id=\"#{id}\" name=\"field_name\">\n\n" \
|
|
53
|
+
"2. Add aria-label attribute:\n" \
|
|
54
|
+
" <input type=\"#{input_type}\" id=\"#{id}\" aria-label=\"Field Label\">\n\n" \
|
|
55
|
+
"3. Use Rails helper:\n" \
|
|
56
|
+
" <%= form.label :field_name, 'Field Label' %>\n" \
|
|
57
|
+
" <%= form.text_field :field_name, id: '#{id}' %>"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAccessibilityTesting
|
|
4
|
+
module Checks
|
|
5
|
+
# Checks for proper heading hierarchy
|
|
6
|
+
#
|
|
7
|
+
# WCAG 2.1 AA: 1.3.1 Info and Relationships (Level A)
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
class HeadingHierarchyCheck < BaseCheck
|
|
11
|
+
def self.rule_name
|
|
12
|
+
:heading_hierarchy
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def check
|
|
16
|
+
violations = []
|
|
17
|
+
headings = page.all('h1, h2, h3, h4, h5, h6', visible: true)
|
|
18
|
+
|
|
19
|
+
if headings.empty?
|
|
20
|
+
return violations # Warning only, not error
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
h1_count = headings.count { |h| h.tag_name == 'h1' }
|
|
24
|
+
if h1_count == 0
|
|
25
|
+
violations << violation(
|
|
26
|
+
message: "Page missing H1 heading",
|
|
27
|
+
element_context: { tag: 'page', text: 'Page has no H1 heading' },
|
|
28
|
+
wcag_reference: "1.3.1",
|
|
29
|
+
remediation: "Add an <h1> heading to your page:\n\n<h1>Main Page Title</h1>"
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
previous_level = 0
|
|
34
|
+
headings.each do |heading|
|
|
35
|
+
current_level = heading.tag_name[1].to_i
|
|
36
|
+
if current_level > previous_level + 1
|
|
37
|
+
element_ctx = element_context(heading)
|
|
38
|
+
violations << violation(
|
|
39
|
+
message: "Heading hierarchy skipped (h#{previous_level} to h#{current_level})",
|
|
40
|
+
element_context: element_ctx,
|
|
41
|
+
wcag_reference: "1.3.1",
|
|
42
|
+
remediation: "Fix the heading hierarchy. Don't skip levels."
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
previous_level = current_level
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
violations
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAccessibilityTesting
|
|
4
|
+
module Checks
|
|
5
|
+
# Checks that images have alt attributes
|
|
6
|
+
#
|
|
7
|
+
# WCAG 2.1 AA: 1.1.1 Non-text Content (Level A)
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
class ImageAltTextCheck < BaseCheck
|
|
11
|
+
def self.rule_name
|
|
12
|
+
:image_alt_text
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def check
|
|
16
|
+
violations = []
|
|
17
|
+
|
|
18
|
+
page.all('img', visible: :all).each do |img|
|
|
19
|
+
has_alt_attribute = page.evaluate_script("arguments[0].hasAttribute('alt')", img.native)
|
|
20
|
+
|
|
21
|
+
if has_alt_attribute == false
|
|
22
|
+
element_ctx = element_context(img)
|
|
23
|
+
|
|
24
|
+
violations << violation(
|
|
25
|
+
message: "Image missing alt attribute",
|
|
26
|
+
element_context: element_ctx,
|
|
27
|
+
wcag_reference: "1.1.1",
|
|
28
|
+
remediation: generate_remediation(element_ctx)
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
violations
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def generate_remediation(element_context)
|
|
39
|
+
src = element_context[:src] || 'image.png'
|
|
40
|
+
|
|
41
|
+
"Choose ONE of these solutions:\n\n" \
|
|
42
|
+
"1. Add alt text for informative images:\n" \
|
|
43
|
+
" <img src=\"#{src}\" alt=\"Description of image\">\n\n" \
|
|
44
|
+
"2. Add empty alt for decorative images:\n" \
|
|
45
|
+
" <img src=\"#{src}\" alt=\"\">\n\n" \
|
|
46
|
+
"3. Use Rails image_tag helper:\n" \
|
|
47
|
+
" <%= image_tag 'image.png', alt: 'Description' %>"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAccessibilityTesting
|
|
4
|
+
module Checks
|
|
5
|
+
# Checks that interactive elements have accessible names
|
|
6
|
+
#
|
|
7
|
+
# WCAG 2.1 AA: 2.4.4 Link Purpose (Level A), 4.1.2 Name, Role, Value (Level A)
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
class InteractiveElementsCheck < BaseCheck
|
|
11
|
+
def self.rule_name
|
|
12
|
+
:interactive_elements
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def check
|
|
16
|
+
violations = []
|
|
17
|
+
|
|
18
|
+
page.all('button, a[href], [role="button"], [role="link"]').each do |element|
|
|
19
|
+
next unless element.visible?
|
|
20
|
+
|
|
21
|
+
text = element.text.strip
|
|
22
|
+
aria_label = element[:"aria-label"]
|
|
23
|
+
aria_labelledby = element[:"aria-labelledby"]
|
|
24
|
+
title = element[:title]
|
|
25
|
+
|
|
26
|
+
if text.blank? && aria_label.blank? && aria_labelledby.blank? && title.blank?
|
|
27
|
+
element_ctx = element_context(element)
|
|
28
|
+
tag = element.tag_name
|
|
29
|
+
|
|
30
|
+
violations << violation(
|
|
31
|
+
message: "#{tag.capitalize} missing accessible name",
|
|
32
|
+
element_context: element_ctx,
|
|
33
|
+
wcag_reference: tag == 'a' ? "2.4.4" : "4.1.2",
|
|
34
|
+
remediation: generate_remediation(tag, element_ctx)
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
violations
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def generate_remediation(tag, element_context)
|
|
45
|
+
if tag == 'a'
|
|
46
|
+
"Choose ONE of these solutions:\n\n" \
|
|
47
|
+
"1. Add visible link text:\n" \
|
|
48
|
+
" <%= link_to 'Descriptive Link Text', path %>\n\n" \
|
|
49
|
+
"2. Add aria-label (for icon-only links):\n" \
|
|
50
|
+
" <%= link_to path, aria: { label: 'Descriptive action' } do %>\n" \
|
|
51
|
+
" <i class='icon'></i>\n" \
|
|
52
|
+
" <% end %>"
|
|
53
|
+
else
|
|
54
|
+
"Choose ONE of these solutions:\n\n" \
|
|
55
|
+
"1. Add visible button text:\n" \
|
|
56
|
+
" <button>Descriptive Button Text</button>\n\n" \
|
|
57
|
+
"2. Add aria-label (for icon-only buttons):\n" \
|
|
58
|
+
" <button aria-label='Descriptive action'>\n" \
|
|
59
|
+
" <i class='icon'></i>\n" \
|
|
60
|
+
" </button>"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|