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.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +307 -0
  3. data/CHANGELOG.md +81 -0
  4. data/CODE_OF_CONDUCT.md +125 -0
  5. data/CONTRIBUTING.md +225 -0
  6. data/GUIDES/continuous_integration.md +326 -0
  7. data/GUIDES/getting_started.md +205 -0
  8. data/GUIDES/working_with_designers_and_content_authors.md +398 -0
  9. data/GUIDES/writing_accessible_views_in_rails.md +412 -0
  10. data/LICENSE +22 -0
  11. data/README.md +350 -0
  12. data/docs_site/404.html +11 -0
  13. data/docs_site/Gemfile +11 -0
  14. data/docs_site/Makefile +14 -0
  15. data/docs_site/_config.yml +41 -0
  16. data/docs_site/_includes/header.html +13 -0
  17. data/docs_site/_layouts/default.html +130 -0
  18. data/docs_site/assets/main.scss +4 -0
  19. data/docs_site/ci_integration.md +76 -0
  20. data/docs_site/configuration.md +114 -0
  21. data/docs_site/contributing.md +69 -0
  22. data/docs_site/getting_started.md +57 -0
  23. data/docs_site/index.md +57 -0
  24. data/exe/rails_a11y +12 -0
  25. data/exe/rails_server_safe +41 -0
  26. data/lib/generators/rails_a11y/install/generator.rb +51 -0
  27. data/lib/rails_accessibility_testing/accessibility_helper.rb +701 -0
  28. data/lib/rails_accessibility_testing/change_detector.rb +114 -0
  29. data/lib/rails_accessibility_testing/checks/aria_landmarks_check.rb +33 -0
  30. data/lib/rails_accessibility_testing/checks/base_check.rb +156 -0
  31. data/lib/rails_accessibility_testing/checks/color_contrast_check.rb +56 -0
  32. data/lib/rails_accessibility_testing/checks/duplicate_ids_check.rb +49 -0
  33. data/lib/rails_accessibility_testing/checks/form_errors_check.rb +40 -0
  34. data/lib/rails_accessibility_testing/checks/form_labels_check.rb +62 -0
  35. data/lib/rails_accessibility_testing/checks/heading_hierarchy_check.rb +53 -0
  36. data/lib/rails_accessibility_testing/checks/image_alt_text_check.rb +52 -0
  37. data/lib/rails_accessibility_testing/checks/interactive_elements_check.rb +66 -0
  38. data/lib/rails_accessibility_testing/checks/keyboard_accessibility_check.rb +36 -0
  39. data/lib/rails_accessibility_testing/checks/skip_links_check.rb +24 -0
  40. data/lib/rails_accessibility_testing/checks/table_structure_check.rb +36 -0
  41. data/lib/rails_accessibility_testing/cli/command.rb +259 -0
  42. data/lib/rails_accessibility_testing/config/yaml_loader.rb +131 -0
  43. data/lib/rails_accessibility_testing/configuration.rb +30 -0
  44. data/lib/rails_accessibility_testing/engine/rule_engine.rb +97 -0
  45. data/lib/rails_accessibility_testing/engine/violation.rb +58 -0
  46. data/lib/rails_accessibility_testing/engine/violation_collector.rb +59 -0
  47. data/lib/rails_accessibility_testing/error_message_builder.rb +354 -0
  48. data/lib/rails_accessibility_testing/integration/minitest_integration.rb +74 -0
  49. data/lib/rails_accessibility_testing/rspec_integration.rb +58 -0
  50. data/lib/rails_accessibility_testing/shared_examples.rb +93 -0
  51. data/lib/rails_accessibility_testing/version.rb +4 -0
  52. data/lib/rails_accessibility_testing.rb +83 -0
  53. data/lib/tasks/accessibility.rake +28 -0
  54. 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
+