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,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAccessibilityTesting
|
|
4
|
+
module Checks
|
|
5
|
+
# Checks keyboard accessibility for modals
|
|
6
|
+
#
|
|
7
|
+
# WCAG 2.1 AA: 2.1.1 Keyboard (Level A)
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
class KeyboardAccessibilityCheck < BaseCheck
|
|
11
|
+
def self.rule_name
|
|
12
|
+
:keyboard_accessibility
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def check
|
|
16
|
+
violations = []
|
|
17
|
+
|
|
18
|
+
page.all('[role="dialog"], [role="alertdialog"]', visible: true).each do |modal|
|
|
19
|
+
focusable = modal.all('button, a, input, textarea, select, [tabindex]:not([tabindex="-1"])', visible: true)
|
|
20
|
+
if focusable.empty?
|
|
21
|
+
element_ctx = element_context(modal)
|
|
22
|
+
violations << violation(
|
|
23
|
+
message: "Modal dialog has no focusable elements",
|
|
24
|
+
element_context: element_ctx,
|
|
25
|
+
wcag_reference: "2.1.1",
|
|
26
|
+
remediation: "Add focusable elements to the modal (buttons, links, inputs)"
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
violations
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAccessibilityTesting
|
|
4
|
+
module Checks
|
|
5
|
+
# Checks for skip links
|
|
6
|
+
#
|
|
7
|
+
# WCAG 2.1 AA: 2.4.1 Bypass Blocks (Level A)
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
class SkipLinksCheck < BaseCheck
|
|
11
|
+
def self.rule_name
|
|
12
|
+
:skip_links
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def check
|
|
16
|
+
violations = []
|
|
17
|
+
# This is a warning-only check, so we return empty violations
|
|
18
|
+
# but could add a warning mechanism if needed
|
|
19
|
+
violations
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAccessibilityTesting
|
|
4
|
+
module Checks
|
|
5
|
+
# Checks for proper table structure
|
|
6
|
+
#
|
|
7
|
+
# WCAG 2.1 AA: 1.3.1 Info and Relationships (Level A)
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
class TableStructureCheck < BaseCheck
|
|
11
|
+
def self.rule_name
|
|
12
|
+
:table_structure
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def check
|
|
16
|
+
violations = []
|
|
17
|
+
|
|
18
|
+
page.all('table').each do |table|
|
|
19
|
+
has_headers = table.all('th').any?
|
|
20
|
+
unless has_headers
|
|
21
|
+
element_ctx = element_context(table)
|
|
22
|
+
violations << violation(
|
|
23
|
+
message: "Table missing headers",
|
|
24
|
+
element_context: element_ctx,
|
|
25
|
+
wcag_reference: "1.3.1",
|
|
26
|
+
remediation: "Add <th> headers to your table:\n\n<table>\n <thead>\n <tr><th>Column 1</th></tr>\n </thead>\n</table>"
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
violations
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module RailsAccessibilityTesting
|
|
7
|
+
module CLI
|
|
8
|
+
# Main CLI command for running accessibility checks
|
|
9
|
+
#
|
|
10
|
+
# Provides a command-line interface to run checks against
|
|
11
|
+
# URLs or Rails routes.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# rails_a11y check /home /about
|
|
15
|
+
# rails_a11y check --urls https://example.com
|
|
16
|
+
# rails_a11y check --routes home_path about_path
|
|
17
|
+
#
|
|
18
|
+
class Command
|
|
19
|
+
def self.run(argv)
|
|
20
|
+
new.run(argv)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run(argv)
|
|
24
|
+
options = parse_options(argv)
|
|
25
|
+
|
|
26
|
+
if options[:help]
|
|
27
|
+
print_help
|
|
28
|
+
return 0
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if options[:version]
|
|
32
|
+
print_version
|
|
33
|
+
return 0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Load configuration
|
|
37
|
+
config = load_config(options[:profile])
|
|
38
|
+
|
|
39
|
+
# Run checks
|
|
40
|
+
results = run_checks(options, config)
|
|
41
|
+
|
|
42
|
+
# Generate report
|
|
43
|
+
generate_report(results, options)
|
|
44
|
+
|
|
45
|
+
results[:violations].any? ? 1 : 0
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
$stderr.puts "Error: #{e.message}"
|
|
48
|
+
$stderr.puts e.backtrace if options[:debug]
|
|
49
|
+
1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def parse_options(argv)
|
|
55
|
+
options = {
|
|
56
|
+
profile: :test,
|
|
57
|
+
format: :human,
|
|
58
|
+
output: nil,
|
|
59
|
+
debug: false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
OptionParser.new do |opts|
|
|
63
|
+
opts.banner = "Usage: rails_a11y [options] [paths...]"
|
|
64
|
+
|
|
65
|
+
opts.on('-u', '--urls URL1,URL2', Array, 'Check specific URLs') do |urls|
|
|
66
|
+
options[:urls] = urls
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
opts.on('-r', '--routes ROUTE1,ROUTE2', Array, 'Check Rails routes') do |routes|
|
|
70
|
+
options[:routes] = routes
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
opts.on('-p', '--profile PROFILE', 'Configuration profile (development, test, ci)') do |profile|
|
|
74
|
+
options[:profile] = profile.to_sym
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
opts.on('-f', '--format FORMAT', 'Output format (human, json)') do |format|
|
|
78
|
+
options[:format] = format.to_sym
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
opts.on('-o', '--output FILE', 'Output file path') do |file|
|
|
82
|
+
options[:output] = file
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
opts.on('--debug', 'Enable debug output') do
|
|
86
|
+
options[:debug] = true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
opts.on('-h', '--help', 'Show this help') do
|
|
90
|
+
options[:help] = true
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
opts.on('-v', '--version', 'Show version') do
|
|
94
|
+
options[:version] = true
|
|
95
|
+
end
|
|
96
|
+
end.parse!(argv)
|
|
97
|
+
|
|
98
|
+
# Remaining args are paths
|
|
99
|
+
options[:paths] = argv if argv.any?
|
|
100
|
+
|
|
101
|
+
options
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def load_config(profile)
|
|
105
|
+
Config::YamlLoader.load(profile: profile)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def run_checks(options, config)
|
|
109
|
+
require 'capybara'
|
|
110
|
+
require 'capybara/dsl'
|
|
111
|
+
require 'selenium-webdriver'
|
|
112
|
+
|
|
113
|
+
# Setup Capybara
|
|
114
|
+
Capybara.default_driver = :selenium_chrome_headless
|
|
115
|
+
Capybara.app = Rails.application if defined?(Rails)
|
|
116
|
+
|
|
117
|
+
engine = Engine::RuleEngine.new(config: config)
|
|
118
|
+
all_violations = []
|
|
119
|
+
checked_urls = []
|
|
120
|
+
|
|
121
|
+
# Determine what to check
|
|
122
|
+
targets = determine_targets(options)
|
|
123
|
+
|
|
124
|
+
targets.each do |target|
|
|
125
|
+
begin
|
|
126
|
+
Capybara.visit(target)
|
|
127
|
+
violations = engine.check(Capybara.current_session, context: { url: target })
|
|
128
|
+
all_violations.concat(violations)
|
|
129
|
+
checked_urls << { url: target, violations: violations.count }
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
$stderr.puts "Error checking #{target}: #{e.message}"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
{
|
|
136
|
+
violations: all_violations,
|
|
137
|
+
checked_urls: checked_urls,
|
|
138
|
+
summary: {
|
|
139
|
+
total_violations: all_violations.count,
|
|
140
|
+
urls_checked: checked_urls.count,
|
|
141
|
+
urls_with_violations: checked_urls.count { |u| u[:violations] > 0 }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def determine_targets(options)
|
|
147
|
+
targets = []
|
|
148
|
+
|
|
149
|
+
if options[:urls]
|
|
150
|
+
targets.concat(options[:urls])
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
if options[:routes]
|
|
154
|
+
targets.concat(resolve_routes(options[:routes]))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
if options[:paths]
|
|
158
|
+
targets.concat(options[:paths])
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
targets.uniq
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def resolve_routes(routes)
|
|
165
|
+
return [] unless defined?(Rails) && Rails.application
|
|
166
|
+
|
|
167
|
+
routes.map do |route_name|
|
|
168
|
+
begin
|
|
169
|
+
Rails.application.routes.url_helpers.send(route_name)
|
|
170
|
+
rescue StandardError
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
end.compact
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def generate_report(results, options)
|
|
177
|
+
output = case options[:format]
|
|
178
|
+
when :json
|
|
179
|
+
generate_json_report(results)
|
|
180
|
+
else
|
|
181
|
+
generate_human_report(results)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
if options[:output]
|
|
185
|
+
File.write(options[:output], output)
|
|
186
|
+
puts "Report written to #{options[:output]}"
|
|
187
|
+
else
|
|
188
|
+
puts output
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def generate_human_report(results)
|
|
193
|
+
output = []
|
|
194
|
+
output << "=" * 70
|
|
195
|
+
output << "Rails A11y Accessibility Report"
|
|
196
|
+
output << "=" * 70
|
|
197
|
+
output << ""
|
|
198
|
+
output << "Summary:"
|
|
199
|
+
output << " Total Violations: #{results[:summary][:total_violations]}"
|
|
200
|
+
output << " URLs Checked: #{results[:summary][:urls_checked]}"
|
|
201
|
+
output << " URLs with Issues: #{results[:summary][:urls_with_violations]}"
|
|
202
|
+
output << ""
|
|
203
|
+
|
|
204
|
+
if results[:violations].any?
|
|
205
|
+
output << "Violations:"
|
|
206
|
+
output << ""
|
|
207
|
+
|
|
208
|
+
results[:violations].each_with_index do |violation, index|
|
|
209
|
+
output << "#{index + 1}. #{violation.message}"
|
|
210
|
+
output << " Rule: #{violation.rule_name}"
|
|
211
|
+
output << " URL: #{violation.page_context[:url]}"
|
|
212
|
+
output << " View: #{violation.page_context[:view_file]}" if violation.page_context[:view_file]
|
|
213
|
+
output << ""
|
|
214
|
+
end
|
|
215
|
+
else
|
|
216
|
+
output << "✅ No accessibility violations found!"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
output.join("\n")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def generate_json_report(results)
|
|
223
|
+
{
|
|
224
|
+
summary: results[:summary],
|
|
225
|
+
violations: results[:violations].map(&:to_h),
|
|
226
|
+
checked_urls: results[:checked_urls]
|
|
227
|
+
}.to_json
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def print_help
|
|
231
|
+
puts <<~HELP
|
|
232
|
+
Rails A11y - Accessibility Testing for Rails
|
|
233
|
+
|
|
234
|
+
Usage: rails_a11y [options] [paths...]
|
|
235
|
+
|
|
236
|
+
Options:
|
|
237
|
+
-u, --urls URL1,URL2 Check specific URLs
|
|
238
|
+
-r, --routes ROUTE1,ROUTE2 Check Rails routes
|
|
239
|
+
-p, --profile PROFILE Configuration profile (development, test, ci)
|
|
240
|
+
-f, --format FORMAT Output format (human, json)
|
|
241
|
+
-o, --output FILE Output file path
|
|
242
|
+
--debug Enable debug output
|
|
243
|
+
-h, --help Show this help
|
|
244
|
+
-v, --version Show version
|
|
245
|
+
|
|
246
|
+
Examples:
|
|
247
|
+
rails_a11y check /home /about
|
|
248
|
+
rails_a11y --urls https://example.com
|
|
249
|
+
rails_a11y --routes home_path about_path --format json --output report.json
|
|
250
|
+
HELP
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def print_version
|
|
254
|
+
puts "Rails A11y #{RailsAccessibilityTesting::VERSION}"
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module RailsAccessibilityTesting
|
|
6
|
+
module Config
|
|
7
|
+
# Loads and parses the accessibility.yml configuration file
|
|
8
|
+
#
|
|
9
|
+
# Supports profiles (development, test, ci) and rule overrides.
|
|
10
|
+
#
|
|
11
|
+
# @example Configuration file structure
|
|
12
|
+
# # config/accessibility.yml
|
|
13
|
+
# wcag_level: AA
|
|
14
|
+
#
|
|
15
|
+
# development:
|
|
16
|
+
# checks:
|
|
17
|
+
# color_contrast: false # Skip in dev for speed
|
|
18
|
+
#
|
|
19
|
+
# ci:
|
|
20
|
+
# checks:
|
|
21
|
+
# color_contrast: true # Full checks in CI
|
|
22
|
+
#
|
|
23
|
+
# @api private
|
|
24
|
+
class YamlLoader
|
|
25
|
+
DEFAULT_CONFIG_PATH = 'config/accessibility.yml'
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Load configuration from YAML file
|
|
29
|
+
# @param path [String] Path to config file
|
|
30
|
+
# @param profile [Symbol] Profile to use (:development, :test, :ci)
|
|
31
|
+
# @return [Hash] Configuration hash
|
|
32
|
+
def load(path: DEFAULT_CONFIG_PATH, profile: :test)
|
|
33
|
+
config_path = resolve_config_path(path)
|
|
34
|
+
return default_config unless config_path && File.exist?(config_path)
|
|
35
|
+
|
|
36
|
+
yaml_content = File.read(config_path)
|
|
37
|
+
parsed = YAML.safe_load(yaml_content, permitted_classes: [Symbol], aliases: true) || {}
|
|
38
|
+
|
|
39
|
+
merge_profile_config(parsed, profile)
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
RailsAccessibilityTesting.config.logger&.warn("Failed to load config: #{e.message}") if defined?(RailsAccessibilityTesting)
|
|
42
|
+
default_config
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Resolve config path relative to Rails root
|
|
48
|
+
def resolve_config_path(path)
|
|
49
|
+
return path if Pathname.new(path).absolute?
|
|
50
|
+
|
|
51
|
+
if defined?(Rails) && Rails.root
|
|
52
|
+
Rails.root.join(path).to_s
|
|
53
|
+
else
|
|
54
|
+
path
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Merge profile-specific config with base config
|
|
59
|
+
def merge_profile_config(parsed, profile)
|
|
60
|
+
base_config = parsed.reject { |k, _| k.to_s.match?(/^(development|test|ci)$/) }
|
|
61
|
+
profile_config = parsed[profile.to_s] || parsed[profile] || {}
|
|
62
|
+
|
|
63
|
+
# Deep merge checks configuration
|
|
64
|
+
checks = base_config['checks'] || {}
|
|
65
|
+
profile_checks = profile_config['checks'] || {}
|
|
66
|
+
|
|
67
|
+
merged_checks = checks.merge(profile_checks)
|
|
68
|
+
|
|
69
|
+
base_config.merge(
|
|
70
|
+
'checks' => merged_checks,
|
|
71
|
+
'profile' => profile.to_s,
|
|
72
|
+
'ignored_rules' => parse_ignored_rules(parsed, profile)
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Parse ignored rules with comments
|
|
77
|
+
def parse_ignored_rules(parsed, profile)
|
|
78
|
+
ignored = []
|
|
79
|
+
|
|
80
|
+
# Check base config
|
|
81
|
+
ignored.concat(parse_rule_overrides(parsed['ignored_rules'] || []))
|
|
82
|
+
|
|
83
|
+
# Check profile-specific ignored rules
|
|
84
|
+
profile_config = parsed[profile.to_s] || parsed[profile] || {}
|
|
85
|
+
ignored.concat(parse_rule_overrides(profile_config['ignored_rules'] || []))
|
|
86
|
+
|
|
87
|
+
ignored.uniq
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Parse rule override entries
|
|
91
|
+
def parse_rule_overrides(overrides)
|
|
92
|
+
overrides.map do |override|
|
|
93
|
+
{
|
|
94
|
+
rule: override['rule'] || override[:rule],
|
|
95
|
+
reason: override['reason'] || override[:reason] || 'No reason provided',
|
|
96
|
+
comment: override['comment'] || override[:comment]
|
|
97
|
+
}
|
|
98
|
+
end.compact
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Default configuration when no file exists
|
|
102
|
+
def default_config
|
|
103
|
+
{
|
|
104
|
+
'wcag_level' => 'AA',
|
|
105
|
+
'checks' => default_checks,
|
|
106
|
+
'ignored_rules' => [],
|
|
107
|
+
'profile' => 'test'
|
|
108
|
+
}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Default check configuration (all enabled)
|
|
112
|
+
def default_checks
|
|
113
|
+
{
|
|
114
|
+
'form_labels' => true,
|
|
115
|
+
'image_alt_text' => true,
|
|
116
|
+
'interactive_elements' => true,
|
|
117
|
+
'heading_hierarchy' => true,
|
|
118
|
+
'keyboard_accessibility' => true,
|
|
119
|
+
'aria_landmarks' => true,
|
|
120
|
+
'form_errors' => true,
|
|
121
|
+
'table_structure' => true,
|
|
122
|
+
'duplicate_ids' => true,
|
|
123
|
+
'skip_links' => true,
|
|
124
|
+
'color_contrast' => false # Disabled by default (expensive)
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAccessibilityTesting
|
|
4
|
+
# Configuration for the accessibility testing gem
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# RailsAccessibilityTesting.configure do |config|
|
|
8
|
+
# config.auto_run_checks = true
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# @attr [Boolean] auto_run_checks Whether to automatically run checks after system specs
|
|
12
|
+
class Configuration
|
|
13
|
+
attr_accessor :auto_run_checks
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@auto_run_checks = true
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Global configuration instance
|
|
21
|
+
def self.config
|
|
22
|
+
@config ||= Configuration.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Configure the gem
|
|
26
|
+
def self.configure
|
|
27
|
+
yield config if block_given?
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAccessibilityTesting
|
|
4
|
+
module Engine
|
|
5
|
+
# Core rule engine that evaluates accessibility checks
|
|
6
|
+
#
|
|
7
|
+
# Coordinates check execution, applies configuration, and collects violations.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# engine = RuleEngine.new(config: config)
|
|
11
|
+
# violations = engine.check(page)
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
class RuleEngine
|
|
15
|
+
attr_reader :config, :violation_collector
|
|
16
|
+
|
|
17
|
+
# Initialize the rule engine
|
|
18
|
+
# @param config [Hash] Configuration hash from YamlLoader
|
|
19
|
+
def initialize(config:)
|
|
20
|
+
@config = config
|
|
21
|
+
@violation_collector = ViolationCollector.new
|
|
22
|
+
@checks = load_checks
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Run all enabled checks against a page
|
|
26
|
+
# @param page [Capybara::Session] The page to check
|
|
27
|
+
# @param context [Hash] Additional context (url, path, etc.)
|
|
28
|
+
# @return [Array<Violation>] Array of violations found
|
|
29
|
+
def check(page, context: {})
|
|
30
|
+
@violation_collector.reset
|
|
31
|
+
|
|
32
|
+
enabled_checks.each do |check_class|
|
|
33
|
+
next if rule_ignored?(check_class.rule_name)
|
|
34
|
+
|
|
35
|
+
begin
|
|
36
|
+
check_instance = check_class.new(page: page, context: context)
|
|
37
|
+
violations = check_instance.run
|
|
38
|
+
@violation_collector.add(violations) if violations.any?
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
# Log but don't fail - one check error shouldn't stop others
|
|
41
|
+
RailsAccessibilityTesting.config.logger&.error("Check #{check_class.rule_name} failed: #{e.message}") if defined?(RailsAccessibilityTesting)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@violation_collector.violations
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Load all check classes
|
|
51
|
+
def load_checks
|
|
52
|
+
[
|
|
53
|
+
Checks::FormLabelsCheck,
|
|
54
|
+
Checks::ImageAltTextCheck,
|
|
55
|
+
Checks::InteractiveElementsCheck,
|
|
56
|
+
Checks::HeadingHierarchyCheck,
|
|
57
|
+
Checks::KeyboardAccessibilityCheck,
|
|
58
|
+
Checks::AriaLandmarksCheck,
|
|
59
|
+
Checks::FormErrorsCheck,
|
|
60
|
+
Checks::TableStructureCheck,
|
|
61
|
+
Checks::DuplicateIdsCheck,
|
|
62
|
+
Checks::SkipLinksCheck,
|
|
63
|
+
Checks::ColorContrastCheck
|
|
64
|
+
]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Get enabled checks based on configuration
|
|
68
|
+
def enabled_checks
|
|
69
|
+
@checks.select do |check_class|
|
|
70
|
+
check_enabled?(check_class.rule_name)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Check if a specific check is enabled
|
|
75
|
+
def check_enabled?(rule_name)
|
|
76
|
+
checks_config = @config['checks'] || {}
|
|
77
|
+
check_key = rule_name_to_config_key(rule_name)
|
|
78
|
+
checks_config.fetch(check_key, true) # Default to enabled
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Check if a rule is in the ignored list
|
|
82
|
+
def rule_ignored?(rule_name)
|
|
83
|
+
ignored_rules = @config['ignored_rules'] || []
|
|
84
|
+
ignored_rules.any? { |override| override[:rule] == rule_name || override['rule'] == rule_name }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Convert rule name to config key
|
|
88
|
+
def rule_name_to_config_key(rule_name)
|
|
89
|
+
rule_name.to_s
|
|
90
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
91
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
92
|
+
.downcase
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAccessibilityTesting
|
|
4
|
+
module Engine
|
|
5
|
+
# Represents a single accessibility violation
|
|
6
|
+
#
|
|
7
|
+
# Contains all information needed to understand and fix the issue.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# violation = Violation.new(
|
|
11
|
+
# rule_name: 'form_labels',
|
|
12
|
+
# message: 'Form input missing label',
|
|
13
|
+
# element: element,
|
|
14
|
+
# page_context: { url: '/', path: '/' }
|
|
15
|
+
# )
|
|
16
|
+
#
|
|
17
|
+
# @api private
|
|
18
|
+
class Violation
|
|
19
|
+
attr_reader :rule_name, :message, :element_context, :page_context, :wcag_reference, :remediation
|
|
20
|
+
|
|
21
|
+
# Initialize a violation
|
|
22
|
+
# @param rule_name [String, Symbol] Name of the rule that was violated
|
|
23
|
+
# @param message [String] Human-readable error message
|
|
24
|
+
# @param element_context [Hash] Context about the element with the issue
|
|
25
|
+
# @param page_context [Hash] Context about the page being tested
|
|
26
|
+
# @param wcag_reference [String] WCAG reference (e.g., "1.1.1")
|
|
27
|
+
# @param remediation [String] Suggested fix
|
|
28
|
+
def initialize(rule_name:, message:, element_context: {}, page_context: {}, wcag_reference: nil, remediation: nil)
|
|
29
|
+
@rule_name = rule_name.to_s
|
|
30
|
+
@message = message
|
|
31
|
+
@element_context = element_context
|
|
32
|
+
@page_context = page_context
|
|
33
|
+
@wcag_reference = wcag_reference
|
|
34
|
+
@remediation = remediation
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Convert to hash for JSON serialization
|
|
38
|
+
# @return [Hash]
|
|
39
|
+
def to_h
|
|
40
|
+
{
|
|
41
|
+
rule_name: @rule_name,
|
|
42
|
+
message: @message,
|
|
43
|
+
element_context: @element_context,
|
|
44
|
+
page_context: @page_context,
|
|
45
|
+
wcag_reference: @wcag_reference,
|
|
46
|
+
remediation: @remediation
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Convert to JSON string
|
|
51
|
+
# @return [String]
|
|
52
|
+
def to_json(*args)
|
|
53
|
+
to_h.to_json(*args)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|