rails_accessibility_testing 1.5.3 → 1.5.5

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