rails_accessibility_testing 1.5.8 → 1.5.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -0
- data/README.md +9 -1
- data/docs_site/architecture.md +39 -1
- data/docs_site/getting_started.md +3 -3
- data/docs_site/index.md +1 -1
- data/exe/a11y_live_scanner +4 -3
- data/exe/a11y_static_scanner +4 -3
- data/lib/generators/rails_a11y/install/templates/accessibility.yml.erb +2 -2
- data/lib/rails_accessibility_testing/accessibility_helper.rb +48 -6
- data/lib/rails_accessibility_testing/checks/heading_check.rb +17 -10
- data/lib/rails_accessibility_testing/composed_page_scanner.rb +326 -0
- data/lib/rails_accessibility_testing/config/yaml_loader.rb +3 -2
- data/lib/rails_accessibility_testing/rspec_integration.rb +2 -1
- data/lib/rails_accessibility_testing/static_file_scanner.rb +47 -4
- data/lib/rails_accessibility_testing/version.rb +1 -1
- data/lib/rails_accessibility_testing/view_composition_builder.rb +313 -0
- data/lib/rails_accessibility_testing.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9f8824e08bc8db71c7a6598998b44c6b39826f367398c8c98a1ef85366a044e2
|
|
4
|
+
data.tar.gz: bd9337c4b6b89deae25b1adfc9dbec92645c934703e930d5f5048c71a59117b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cd9fdfced6342b861ae5d1db4ee852b4c566c9572d62dac463fdc0d7566d37ef1201201c36c3af4d45393d24af7f0a504f76f7eaf25322e7025a577eeb3d94dc
|
|
7
|
+
data.tar.gz: a2672850fd85a29fb3a304243f1367215c282a34e63df58f5878d274dd31176465afaad23734699124bd6649032b68ff578f0d6a1ae4b6ec853d2f4ffd7ef23c
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,72 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.5.10] - 2024-12-01
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- **Configuration flag renamed**: The `enabled` flag has been renamed to `accessibility_enabled` for better clarity. The old `enabled` key is still supported for backward compatibility.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- **Composed Page Scanning**: The static scanner now analyzes complete page compositions (layout + view + partials) for page-level accessibility checks, eliminating false positives for heading hierarchy, ARIA landmarks, duplicate IDs, and heading issues.
|
|
15
|
+
- **View Composition Builder**: New comprehensive system that traces the complete page structure by finding all partials recursively across all directories in `app/views`.
|
|
16
|
+
- **Exhaustive Partial Detection**: Enhanced partial detection that finds partials in any subdirectory, not just specific folders. Works for deeply nested namespaces (e.g., `collections/collection_questions`).
|
|
17
|
+
- **ERB Content Detection**: Improved handling of ERB expressions (`<%= ... %>`) to prevent false positives for empty headings and missing accessible names on buttons.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- **Heading Hierarchy Checks**: Now performed on the complete composed page instead of individual files, preventing false positives when H1 is in layout or partials.
|
|
21
|
+
- **ARIA Landmarks Checks**: Now checks for `<main>` landmark across the entire composed page.
|
|
22
|
+
- **Duplicate ID Checks**: Now checks for duplicate IDs across the complete page composition.
|
|
23
|
+
- **Empty Heading Checks**: Now checks for empty headings across the complete composed page.
|
|
24
|
+
- **Partial Search**: Enhanced to traverse ALL folders in `app/views` recursively using `Dir.glob`, making it a general solution that works for any folder structure.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- Fixed false positive for "Page missing MAIN landmark" when `<main>` is in the layout file.
|
|
28
|
+
- Fixed false positive for "Page has h2 but no h1 heading" when H1 is in a partial rendered via `render @model`.
|
|
29
|
+
- Fixed false positive for "Heading hierarchy skipped (h0 to h2)" when first heading is h2.
|
|
30
|
+
- Fixed false positive for "Duplicate ID 'ERB_CONTENT'" by filtering out ERB placeholder strings.
|
|
31
|
+
- Fixed partial detection for Rails shorthand patterns (`render @model`, `render collection: @models`).
|
|
32
|
+
- Fixed namespaced partial path resolution (e.g., `layouts/_advance_search`).
|
|
33
|
+
- Fixed path normalization to handle both relative and absolute paths consistently.
|
|
34
|
+
|
|
35
|
+
### Performance
|
|
36
|
+
- Optimized exhaustive directory search to return first match found instead of checking all matches.
|
|
37
|
+
|
|
38
|
+
## [Unreleased]
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
- **Composed Page Scanning**: The static scanner now analyzes complete page compositions (layout + view + partials) for page-level accessibility checks, eliminating false positives for heading hierarchy, ARIA landmarks, duplicate IDs, and heading issues.
|
|
42
|
+
- **View Composition Builder**: New comprehensive system that traces the complete page structure by finding all partials recursively across all directories in `app/views`.
|
|
43
|
+
- **Exhaustive Partial Detection**: Enhanced partial detection that finds partials in any subdirectory, not just specific folders. Works for deeply nested namespaces (e.g., `collections/collection_questions`).
|
|
44
|
+
- **ERB Content Detection**: Improved handling of ERB expressions (`<%= ... %>`) to prevent false positives for empty headings and missing accessible names on buttons.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
- **Heading Hierarchy Checks**: Now performed on the complete composed page instead of individual files, preventing false positives when H1 is in layout or partials.
|
|
48
|
+
- **ARIA Landmarks Checks**: Now checks for `<main>` landmark across the entire composed page.
|
|
49
|
+
- **Duplicate ID Checks**: Now checks for duplicate IDs across the complete page composition.
|
|
50
|
+
- **Empty Heading Checks**: Now checks for empty headings across the complete composed page.
|
|
51
|
+
- **Partial Search**: Enhanced to traverse ALL folders in `app/views` recursively using `Dir.glob`, making it a general solution that works for any folder structure.
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
- Fixed false positive for "Page missing MAIN landmark" when `<main>` is in the layout file.
|
|
55
|
+
- Fixed false positive for "Page has h2 but no h1 heading" when H1 is in a partial rendered via `render @model`.
|
|
56
|
+
- Fixed false positive for "Heading hierarchy skipped (h0 to h2)" when first heading is h2.
|
|
57
|
+
- Fixed false positive for "Duplicate ID 'ERB_CONTENT'" by filtering out ERB placeholder strings.
|
|
58
|
+
- Fixed partial detection for Rails shorthand patterns (`render @model`, `render collection: @models`).
|
|
59
|
+
- Fixed namespaced partial path resolution (e.g., `layouts/_advance_search`).
|
|
60
|
+
- Fixed path normalization to handle both relative and absolute paths consistently.
|
|
61
|
+
|
|
62
|
+
### Performance
|
|
63
|
+
- Optimized exhaustive directory search to return first match found instead of checking all matches.
|
|
64
|
+
|
|
65
|
+
## [1.5.9] - 2024-12-01
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
- **Force option for manual checks**: Added `force: true` parameter to `check_comprehensive_accessibility` and `check_basic_accessibility` to bypass `enabled: false` setting
|
|
69
|
+
- **Manual check flexibility**: You can now force manual checks to run even when accessibility checks are globally disabled
|
|
70
|
+
|
|
71
|
+
### Improved
|
|
72
|
+
- **Manual check documentation**: Clarified that manual checks can be run separately in RSpec specs regardless of configuration
|
|
73
|
+
|
|
8
74
|
## [1.5.8] - 2024-12-01
|
|
9
75
|
|
|
10
76
|
### Fixed
|
data/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
**The RSpec + RuboCop of accessibility for Rails. Catch WCAG violations before they reach production.**
|
|
9
9
|
|
|
10
|
-
**Current Version:** 1.5.
|
|
10
|
+
**Current Version:** 1.5.9
|
|
11
11
|
|
|
12
12
|
📖 **[📚 Full Documentation](https://rayraycodes.github.io/rails-accessibility-testing/)** | [💻 GitHub](https://github.com/rayraycodes/rails-accessibility-testing) | [💎 RubyGems](https://rubygems.org/gems/rails_accessibility_testing)
|
|
13
13
|
|
|
@@ -51,6 +51,13 @@ Rails Accessibility Testing fills a critical gap in the Rails testing ecosystem.
|
|
|
51
51
|
- **Accurate error counting**: Properly tracks and displays error/warning counts
|
|
52
52
|
- **Persistent output**: Errors stay visible in terminal (no clearing)
|
|
53
53
|
|
|
54
|
+
#### 🔍 Composed Page Scanning (NEW in 1.5.9+)
|
|
55
|
+
- **Complete page analysis**: Analyzes full page composition (layout + view + partials) for page-level checks
|
|
56
|
+
- **Eliminates false positives**: No more false positives when H1 is in layout or partials
|
|
57
|
+
- **Exhaustive partial finding**: Traverses ALL folders recursively to find all partials
|
|
58
|
+
- **Works for any structure**: General solution that works for any Rails folder structure (collections, items, profiles, loan_requests, etc.)
|
|
59
|
+
- **Page-level checks**: Heading hierarchy, ARIA landmarks, duplicate IDs, and empty headings checked across complete page
|
|
60
|
+
|
|
54
61
|
#### 🔍 Smart View File Detection
|
|
55
62
|
- **Intelligent matching**: Automatically finds view files even when action names don't match
|
|
56
63
|
- **Controller directory scanning**: Searches all view files to find the correct template
|
|
@@ -61,6 +68,7 @@ Rails Accessibility Testing fills a critical gap in the Rails testing ecosystem.
|
|
|
61
68
|
- **Optimized DOM queries**: Faster image alt checks without JavaScript evaluation
|
|
62
69
|
- **Removed delays**: Eliminated unnecessary sleep calls in live scanner
|
|
63
70
|
- **Efficient scanning**: ~25-30% faster page scans
|
|
71
|
+
- **Optimized directory search**: Returns first match found instead of checking all matches
|
|
64
72
|
|
|
65
73
|
#### 🎨 Enhanced Developer Experience
|
|
66
74
|
- **Real-time progress**: Step-by-step feedback during accessibility checks
|
data/docs_site/architecture.md
CHANGED
|
@@ -3,7 +3,45 @@ layout: default
|
|
|
3
3
|
title: Architecture
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Architecture
|
|
6
|
+
# Architecture
|
|
7
|
+
|
|
8
|
+
## Composed Page Scanning
|
|
9
|
+
|
|
10
|
+
The gem now uses **composed page scanning** for page-level accessibility checks. This ensures that checks like heading hierarchy, ARIA landmarks, and duplicate IDs are evaluated against the complete rendered page (layout + view + partials), not individual files.
|
|
11
|
+
|
|
12
|
+
### View Composition Builder
|
|
13
|
+
|
|
14
|
+
The `ViewCompositionBuilder` class traces the complete page structure:
|
|
15
|
+
|
|
16
|
+
1. **Finds Layout File**: Identifies the layout file (defaults to `application.html.erb`)
|
|
17
|
+
2. **Finds View File**: The main view file being rendered
|
|
18
|
+
3. **Recursively Finds Partials**: Discovers all partials rendered in the view, including:
|
|
19
|
+
- Partials in the same directory
|
|
20
|
+
- Partials in `layouts/`, `shared/`, `application/`
|
|
21
|
+
- Partials in any subdirectory (exhaustive search)
|
|
22
|
+
- Nested partials (partials within partials)
|
|
23
|
+
|
|
24
|
+
### Partial Detection
|
|
25
|
+
|
|
26
|
+
The gem detects all Rails render patterns:
|
|
27
|
+
- `render 'partial'`
|
|
28
|
+
- `render partial: 'partial'`
|
|
29
|
+
- `render @model` (Rails shorthand)
|
|
30
|
+
- `render collection: @models`
|
|
31
|
+
- `render partial: 'item', collection: @items`
|
|
32
|
+
- `render partial: 'form', locals: {...}`
|
|
33
|
+
|
|
34
|
+
### Exhaustive Folder Traversal
|
|
35
|
+
|
|
36
|
+
The partial search traverses ALL folders in `app/views` recursively using `Dir.glob`, ensuring partials are found regardless of their location:
|
|
37
|
+
- `app/views/collections/`
|
|
38
|
+
- `app/views/collections/collection_questions/`
|
|
39
|
+
- `app/views/items/`
|
|
40
|
+
- `app/views/profiles/`
|
|
41
|
+
- `app/views/loan_requests/`
|
|
42
|
+
- Any other nested structure
|
|
43
|
+
|
|
44
|
+
This makes it a general solution that works for any Rails application structure. Overview
|
|
7
45
|
|
|
8
46
|
This guide explains how Rails Accessibility Testing works under the hood in simple terms.
|
|
9
47
|
|
|
@@ -144,12 +144,12 @@ Rails Accessibility Testing runs **11 comprehensive checks** automatically. Thes
|
|
|
144
144
|
1. **Form Labels** - All form inputs have associated labels
|
|
145
145
|
2. **Image Alt Text** - All images have descriptive alt attributes
|
|
146
146
|
3. **Interactive Elements** - Buttons, links have accessible names
|
|
147
|
-
4. **Heading Hierarchy** - Proper h1-h6 structure without skipping levels
|
|
147
|
+
4. **Heading Hierarchy** - Proper h1-h6 structure without skipping levels (checked across complete page: layout + view + partials)
|
|
148
148
|
5. **Keyboard Accessibility** - All interactive elements are keyboard accessible
|
|
149
|
-
6. **ARIA Landmarks** - Proper use of ARIA landmark roles
|
|
149
|
+
6. **ARIA Landmarks** - Proper use of ARIA landmark roles (checked across complete page: layout + view + partials)
|
|
150
150
|
7. **Form Error Associations** - Form errors are properly linked to form fields
|
|
151
151
|
8. **Table Structure** - Tables have proper headers
|
|
152
|
-
9. **Duplicate IDs** - No duplicate ID attributes
|
|
152
|
+
9. **Duplicate IDs** - No duplicate ID attributes (checked across complete page: layout + view + partials)
|
|
153
153
|
10. **Skip Links** - Skip navigation links present
|
|
154
154
|
11. **Color Contrast** - Text meets WCAG contrast requirements (optional, disabled by default)
|
|
155
155
|
|
data/docs_site/index.md
CHANGED
|
@@ -7,7 +7,7 @@ title: Home
|
|
|
7
7
|
|
|
8
8
|
**The RSpec + RuboCop of accessibility for Rails. Catch WCAG violations before they reach production.**
|
|
9
9
|
|
|
10
|
-
**Version:** 1.5.
|
|
10
|
+
**Version:** 1.5.9
|
|
11
11
|
|
|
12
12
|
Rails Accessibility Testing is a comprehensive, opinionated but configurable gem that makes accessibility testing as natural as unit testing. It integrates seamlessly into your Rails workflow, catching accessibility issues as you code—not after deployment.
|
|
13
13
|
|
data/exe/a11y_live_scanner
CHANGED
|
@@ -35,10 +35,11 @@ begin
|
|
|
35
35
|
require 'rails_accessibility_testing/config/yaml_loader'
|
|
36
36
|
profile = defined?(Rails) && Rails.env.test? ? :test : :development
|
|
37
37
|
config = RailsAccessibilityTesting::Config::YamlLoader.load(profile: profile)
|
|
38
|
-
|
|
38
|
+
# Support both 'accessibility_enabled' (new) and 'enabled' (legacy) for backward compatibility
|
|
39
|
+
enabled = config.fetch('accessibility_enabled', config.fetch('enabled', true))
|
|
39
40
|
unless enabled
|
|
40
|
-
puts "⏸️ Accessibility checks are disabled (
|
|
41
|
-
puts " Set
|
|
41
|
+
puts "⏸️ Accessibility checks are disabled (accessibility_enabled: false in config/accessibility.yml)"
|
|
42
|
+
puts " Set accessibility_enabled: true to enable accessibility scanning"
|
|
42
43
|
puts " Scanner process will remain running but will not scan files"
|
|
43
44
|
puts ""
|
|
44
45
|
# Keep process alive so Foreman doesn't kill other processes
|
data/exe/a11y_static_scanner
CHANGED
|
@@ -29,10 +29,11 @@ begin
|
|
|
29
29
|
require 'rails_accessibility_testing/config/yaml_loader'
|
|
30
30
|
profile = defined?(Rails) && Rails.env.test? ? :test : :development
|
|
31
31
|
config = RailsAccessibilityTesting::Config::YamlLoader.load(profile: profile)
|
|
32
|
-
|
|
32
|
+
# Support both 'accessibility_enabled' (new) and 'enabled' (legacy) for backward compatibility
|
|
33
|
+
enabled = config.fetch('accessibility_enabled', config.fetch('enabled', true))
|
|
33
34
|
unless enabled
|
|
34
|
-
puts "⏸️ Accessibility checks are disabled (
|
|
35
|
-
puts " Set
|
|
35
|
+
puts "⏸️ Accessibility checks are disabled (accessibility_enabled: false in config/accessibility.yml)"
|
|
36
|
+
puts " Set accessibility_enabled: true to enable accessibility scanning"
|
|
36
37
|
puts " Scanner process will remain running but will not scan files"
|
|
37
38
|
puts ""
|
|
38
39
|
# Keep process alive so Foreman doesn't kill other processes
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
# This file configures accessibility checks for your Rails application.
|
|
4
4
|
# See https://github.com/your-org/rails-a11y for full documentation.
|
|
5
5
|
|
|
6
|
-
# Global enable/disable flag
|
|
6
|
+
# Global enable/disable flag for all accessibility checks
|
|
7
7
|
# Set to false to completely disable all accessibility checks (manual and automatic)
|
|
8
8
|
# When false, check_comprehensive_accessibility and automatic checks will be skipped
|
|
9
9
|
# Default: true
|
|
10
|
-
|
|
10
|
+
accessibility_enabled: true
|
|
11
11
|
|
|
12
12
|
# WCAG compliance level (A, AA, AAA)
|
|
13
13
|
wcag_level: AA
|
|
@@ -46,14 +46,34 @@ module RailsAccessibilityTesting
|
|
|
46
46
|
# render partial: "partial_name"
|
|
47
47
|
# render 'path/to/partial'
|
|
48
48
|
# render partial: 'path/to/partial'
|
|
49
|
+
# render @model (Rails shorthand - renders _model.html.erb)
|
|
50
|
+
# render collection: @models (renders _model.html.erb for each)
|
|
51
|
+
# render partial: 'partial', locals: {...}
|
|
52
|
+
# render partial: 'partial', collection: @items
|
|
49
53
|
# <%= render 'partial_name' %>
|
|
50
54
|
# <%= render partial: 'partial_name' %>
|
|
55
|
+
# <%= render @model %>
|
|
56
|
+
# <%= render collection: @models %>
|
|
51
57
|
|
|
52
58
|
patterns = [
|
|
59
|
+
# Standard partial renders (with or without partial: keyword)
|
|
53
60
|
/render\s+(?:partial:\s*)?['"]([^'"]+)['"]/,
|
|
54
61
|
/render\s+(?:partial:\s*)?:(\w+)/,
|
|
55
62
|
/<%=?\s*render\s+(?:partial:\s*)?['"]([^'"]+)['"]/,
|
|
56
|
-
/<%=?\s*render\s+(?:partial:\s*)?:(\w+)
|
|
63
|
+
/<%=?\s*render\s+(?:partial:\s*)?:(\w+)/,
|
|
64
|
+
# Rails shorthand: render @model (renders _model.html.erb)
|
|
65
|
+
/render\s+@(\w+)/,
|
|
66
|
+
/<%=?\s*render\s+@(\w+)/,
|
|
67
|
+
# Collection renders: render collection: @models (renders _model.html.erb)
|
|
68
|
+
/render\s+collection:\s*@(\w+)/,
|
|
69
|
+
/<%=?\s*render\s+collection:\s*@(\w+)/,
|
|
70
|
+
# Partial with collection: render partial: 'item', collection: @items
|
|
71
|
+
/render\s+partial:\s*['"]([^'"]+)['"]\s*,\s*collection:/,
|
|
72
|
+
/<%=?\s*render\s+partial:\s*['"]([^'"]+)['"]\s*,\s*collection:/,
|
|
73
|
+
# Partial with locals: render partial: 'form', locals: {...}
|
|
74
|
+
# (extract just the partial name, ignore locals)
|
|
75
|
+
/render\s+partial:\s*['"]([^'"]+)['"]\s*,\s*locals:/,
|
|
76
|
+
/<%=?\s*render\s+partial:\s*['"]([^'"]+)['"]\s*,\s*locals:/
|
|
57
77
|
]
|
|
58
78
|
|
|
59
79
|
patterns.each do |pattern|
|
|
@@ -77,6 +97,23 @@ module RailsAccessibilityTesting
|
|
|
77
97
|
end
|
|
78
98
|
end
|
|
79
99
|
|
|
100
|
+
# Also check for Rails shorthand: render @model (renders _model.html.erb)
|
|
101
|
+
# This is separate because it needs special handling
|
|
102
|
+
content.scan(/render\s+@(\w+)/) do |match|
|
|
103
|
+
model_name = match[0]
|
|
104
|
+
partial_name = model_name.underscore
|
|
105
|
+
partial_name = "_#{partial_name}" unless partial_name.start_with?('_')
|
|
106
|
+
partials << partial_name unless partials.include?(partial_name)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Check for collection renders: render collection: @models
|
|
110
|
+
content.scan(/render\s+collection:\s*@(\w+)/) do |match|
|
|
111
|
+
model_name = match[0]
|
|
112
|
+
partial_name = model_name.underscore.singularize # collection uses singular
|
|
113
|
+
partial_name = "_#{partial_name}" unless partial_name.start_with?('_')
|
|
114
|
+
partials << partial_name unless partials.include?(partial_name)
|
|
115
|
+
end
|
|
116
|
+
|
|
80
117
|
partials
|
|
81
118
|
end
|
|
82
119
|
|
|
@@ -210,9 +247,11 @@ module RailsAccessibilityTesting
|
|
|
210
247
|
end
|
|
211
248
|
|
|
212
249
|
# Basic accessibility check - runs 5 basic checks
|
|
213
|
-
|
|
250
|
+
# @param force [Boolean] If true, bypasses the accessibility_enabled: false setting (default: false)
|
|
251
|
+
def check_basic_accessibility(force: false)
|
|
214
252
|
# Check if accessibility checks are globally disabled
|
|
215
|
-
|
|
253
|
+
# Manual calls can use force: true to bypass the accessibility_enabled: false setting
|
|
254
|
+
return if !force && accessibility_disabled?
|
|
216
255
|
|
|
217
256
|
@accessibility_errors ||= []
|
|
218
257
|
@accessibility_warnings ||= []
|
|
@@ -244,10 +283,12 @@ module RailsAccessibilityTesting
|
|
|
244
283
|
|
|
245
284
|
# Full comprehensive check - runs all 11 checks including advanced
|
|
246
285
|
# Uses the RuleEngine and checks from the checks/ folder for consistency
|
|
286
|
+
# @param force [Boolean] If true, bypasses the accessibility_enabled: false setting (default: false)
|
|
247
287
|
# @return [Hash] Hash with :errors and :warnings counts
|
|
248
|
-
def check_comprehensive_accessibility
|
|
288
|
+
def check_comprehensive_accessibility(force: false)
|
|
249
289
|
# Check if accessibility checks are globally disabled - do this FIRST before any output
|
|
250
|
-
|
|
290
|
+
# Manual calls can use force: true to bypass the accessibility_enabled: false setting
|
|
291
|
+
if !force && accessibility_disabled?
|
|
251
292
|
return { errors: 0, warnings: 0, page_context: {} }
|
|
252
293
|
end
|
|
253
294
|
|
|
@@ -447,7 +488,8 @@ module RailsAccessibilityTesting
|
|
|
447
488
|
require 'rails_accessibility_testing/config/yaml_loader'
|
|
448
489
|
profile = defined?(Rails) && Rails.env.test? ? :test : :development
|
|
449
490
|
config = RailsAccessibilityTesting::Config::YamlLoader.load(profile: profile)
|
|
450
|
-
|
|
491
|
+
# Support both 'accessibility_enabled' (new) and 'enabled' (legacy) for backward compatibility
|
|
492
|
+
enabled = config.fetch('accessibility_enabled', config.fetch('enabled', true)) # Default to enabled if not specified
|
|
451
493
|
disabled = !enabled # Return true if disabled
|
|
452
494
|
disabled
|
|
453
495
|
rescue StandardError => e
|
|
@@ -23,7 +23,7 @@ module RailsAccessibilityTesting
|
|
|
23
23
|
return violations # Warning only, not error
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
# Check 1: Missing H1
|
|
26
|
+
# Check 1: Missing H1 (page-level check - checks complete composed page)
|
|
27
27
|
h1_count = headings.count { |h| h.tag_name == 'h1' }
|
|
28
28
|
first_heading = headings.first
|
|
29
29
|
first_heading_level = first_heading ? first_heading.tag_name[1].to_i : nil
|
|
@@ -32,30 +32,35 @@ module RailsAccessibilityTesting
|
|
|
32
32
|
# If the first heading is h2 or higher, provide a more specific message
|
|
33
33
|
if first_heading_level && first_heading_level >= 2
|
|
34
34
|
element_ctx = element_context(first_heading)
|
|
35
|
+
# Mark as page-level check - H1 might be in layout or partial
|
|
36
|
+
element_ctx[:page_level_check] = true
|
|
37
|
+
element_ctx[:check_type] = 'heading_hierarchy'
|
|
35
38
|
violations << violation(
|
|
36
|
-
message: "Page has h#{first_heading_level} but no h1 heading",
|
|
39
|
+
message: "Page has h#{first_heading_level} but no h1 heading (checked complete page: layout + view + partials)",
|
|
37
40
|
element_context: element_ctx,
|
|
38
41
|
wcag_reference: "1.3.1",
|
|
39
|
-
remediation: "Add an <h1> heading
|
|
42
|
+
remediation: "Add an <h1> heading to your page. The H1 can be in the layout, view, or a partial:\n\n<h1>Main Page Title</h1>\n<h#{first_heading_level}>#{first_heading.text}</h#{first_heading_level}>"
|
|
40
43
|
)
|
|
41
44
|
else
|
|
42
45
|
violations << violation(
|
|
43
|
-
message: "Page missing H1 heading",
|
|
44
|
-
element_context: { tag: 'page', text: 'Page has no H1 heading' },
|
|
46
|
+
message: "Page missing H1 heading (checked complete page: layout + view + partials)",
|
|
47
|
+
element_context: { tag: 'page', text: 'Page has no H1 heading', page_level_check: true, check_type: 'heading_hierarchy' },
|
|
45
48
|
wcag_reference: "1.3.1",
|
|
46
|
-
remediation: "Add an <h1> heading to your page:\n\n<h1>Main Page Title</h1>"
|
|
49
|
+
remediation: "Add an <h1> heading to your page. The H1 can be in the layout, view, or a partial:\n\n<h1>Main Page Title</h1>"
|
|
47
50
|
)
|
|
48
51
|
end
|
|
49
52
|
end
|
|
50
53
|
|
|
51
|
-
# Check 2: Multiple H1s (WCAG 1.3.1)
|
|
54
|
+
# Check 2: Multiple H1s (WCAG 1.3.1) - page-level check
|
|
52
55
|
if h1_count > 1
|
|
53
56
|
# Report error for each h1 after the first one
|
|
54
57
|
h1_elements = headings.select { |h| h.tag_name == 'h1' }
|
|
55
58
|
h1_elements[1..-1].each do |h1|
|
|
56
59
|
element_ctx = element_context(h1)
|
|
60
|
+
element_ctx[:page_level_check] = true
|
|
61
|
+
element_ctx[:check_type] = 'heading_hierarchy'
|
|
57
62
|
violations << violation(
|
|
58
|
-
message: "Page has multiple h1 headings (#{h1_count} total) - only one h1 should be used per page",
|
|
63
|
+
message: "Page has multiple h1 headings (#{h1_count} total) - only one h1 should be used per page (checked complete page: layout + view + partials)",
|
|
59
64
|
element_context: element_ctx,
|
|
60
65
|
wcag_reference: "1.3.1",
|
|
61
66
|
remediation: "Use only one <h1> per page. Convert additional h1s to h2 or lower:\n\n<h1>Main Title</h1>\n<h2>Section Title</h2>"
|
|
@@ -63,14 +68,16 @@ module RailsAccessibilityTesting
|
|
|
63
68
|
end
|
|
64
69
|
end
|
|
65
70
|
|
|
66
|
-
# Check 3: Heading hierarchy skipped levels (WCAG 1.3.1)
|
|
71
|
+
# Check 3: Heading hierarchy skipped levels (WCAG 1.3.1) - page-level check
|
|
67
72
|
previous_level = 0
|
|
68
73
|
headings.each do |heading|
|
|
69
74
|
current_level = heading.tag_name[1].to_i
|
|
70
75
|
if current_level > previous_level + 1
|
|
71
76
|
element_ctx = element_context(heading)
|
|
77
|
+
element_ctx[:page_level_check] = true
|
|
78
|
+
element_ctx[:check_type] = 'heading_hierarchy'
|
|
72
79
|
violations << violation(
|
|
73
|
-
message: "Heading hierarchy skipped (h#{previous_level} to h#{current_level})",
|
|
80
|
+
message: "Heading hierarchy skipped (h#{previous_level} to h#{current_level}) - checked complete page: layout + view + partials",
|
|
74
81
|
element_context: element_ctx,
|
|
75
82
|
wcag_reference: "1.3.1",
|
|
76
83
|
remediation: "Fix the heading hierarchy. Don't skip levels. Use h#{previous_level + 1} instead of h#{current_level}."
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
require_relative 'view_composition_builder'
|
|
5
|
+
require_relative 'erb_extractor'
|
|
6
|
+
|
|
7
|
+
module RailsAccessibilityTesting
|
|
8
|
+
# Scans a complete composed page (layout + view + partials) for heading hierarchy
|
|
9
|
+
# This ensures heading hierarchy is checked across the entire page, not just individual files
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
class ComposedPageScanner
|
|
13
|
+
def initialize(view_file)
|
|
14
|
+
@view_file = view_file
|
|
15
|
+
@builder = ViewCompositionBuilder.new(view_file)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Scan the complete composed page for heading hierarchy violations
|
|
19
|
+
# @return [Hash] Hash with :errors and :warnings arrays
|
|
20
|
+
def scan_heading_hierarchy
|
|
21
|
+
return { errors: [], warnings: [] } unless @view_file && File.exist?(@view_file)
|
|
22
|
+
|
|
23
|
+
# Build composition
|
|
24
|
+
all_files = @builder.build
|
|
25
|
+
return { errors: [], warnings: [] } if all_files.empty?
|
|
26
|
+
|
|
27
|
+
# Get all headings from complete composition
|
|
28
|
+
headings = @builder.all_headings
|
|
29
|
+
return { errors: [], warnings: [] } if headings.empty?
|
|
30
|
+
|
|
31
|
+
violations = []
|
|
32
|
+
|
|
33
|
+
# Check 1: Missing H1
|
|
34
|
+
h1_count = headings.count { |h| h[:level] == 1 }
|
|
35
|
+
first_heading = headings.first
|
|
36
|
+
|
|
37
|
+
if h1_count == 0
|
|
38
|
+
if first_heading
|
|
39
|
+
violations << create_violation(
|
|
40
|
+
message: "Page has h#{first_heading[:level]} but no h1 heading (checked complete page: layout + view + partials)",
|
|
41
|
+
heading: first_heading,
|
|
42
|
+
wcag_reference: "1.3.1",
|
|
43
|
+
remediation: "Add an <h1> heading to your page. The H1 can be in the layout, view, or a partial:\n\n<h1>Main Page Title</h1>"
|
|
44
|
+
)
|
|
45
|
+
else
|
|
46
|
+
violations << create_violation(
|
|
47
|
+
message: "Page missing H1 heading (checked complete page: layout + view + partials)",
|
|
48
|
+
heading: nil,
|
|
49
|
+
wcag_reference: "1.3.1",
|
|
50
|
+
remediation: "Add an <h1> heading to your page. The H1 can be in the layout, view, or a partial:\n\n<h1>Main Page Title</h1>"
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Check 2: Multiple H1s
|
|
56
|
+
if h1_count > 1
|
|
57
|
+
h1_headings = headings.select { |h| h[:level] == 1 }
|
|
58
|
+
h1_headings[1..-1].each do |h1|
|
|
59
|
+
violations << create_violation(
|
|
60
|
+
message: "Page has multiple h1 headings (#{h1_count} total) - only one h1 should be used per page (checked complete page: layout + view + partials)",
|
|
61
|
+
heading: h1,
|
|
62
|
+
wcag_reference: "1.3.1",
|
|
63
|
+
remediation: "Use only one <h1> per page. Convert additional h1s to h2 or lower:\n\n<h1>Main Title</h1>\n<h2>Section Title</h2>"
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Check 3: Heading hierarchy skipped levels
|
|
69
|
+
# Only flag if we're skipping from a real heading (not from h0)
|
|
70
|
+
# If the first heading is h2, that's fine - it just means no h1 exists (handled by Check 1)
|
|
71
|
+
previous_level = nil
|
|
72
|
+
headings.each do |heading|
|
|
73
|
+
current_level = heading[:level]
|
|
74
|
+
if previous_level && current_level > previous_level + 1
|
|
75
|
+
# Only flag if we're skipping from a real heading level
|
|
76
|
+
violations << create_violation(
|
|
77
|
+
message: "Heading hierarchy skipped (h#{previous_level} to h#{current_level}) - checked complete page: layout + view + partials",
|
|
78
|
+
heading: heading,
|
|
79
|
+
wcag_reference: "1.3.1",
|
|
80
|
+
remediation: "Fix the heading hierarchy. Don't skip levels. Use h#{previous_level + 1} instead of h#{current_level}."
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
previous_level = current_level
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Convert to errors/warnings format (matching ViolationConverter format)
|
|
87
|
+
errors = violations.map do |v|
|
|
88
|
+
{
|
|
89
|
+
type: v[:message],
|
|
90
|
+
element: {
|
|
91
|
+
tag: v[:heading] ? "h#{v[:heading][:level]}" : 'page',
|
|
92
|
+
text: v[:heading] ? v[:heading][:text] : 'Page-level heading hierarchy check',
|
|
93
|
+
file: v[:file] || @view_file
|
|
94
|
+
},
|
|
95
|
+
file: v[:file] || @view_file,
|
|
96
|
+
line: v[:line] || 1,
|
|
97
|
+
wcag: v[:wcag_reference],
|
|
98
|
+
remediation: v[:remediation],
|
|
99
|
+
page_level_check: true,
|
|
100
|
+
check_type: 'heading_hierarchy'
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
{ errors: errors, warnings: [] }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Scan the complete composed page for all heading issues (not just hierarchy)
|
|
108
|
+
# This includes: empty headings, styling-only headings, etc.
|
|
109
|
+
# @return [Hash] Hash with :errors and :warnings arrays
|
|
110
|
+
def scan_all_headings
|
|
111
|
+
return { errors: [], warnings: [] } unless @view_file && File.exist?(@view_file)
|
|
112
|
+
|
|
113
|
+
# Build composition
|
|
114
|
+
all_files = @builder.build
|
|
115
|
+
return { errors: [], warnings: [] } if all_files.empty?
|
|
116
|
+
|
|
117
|
+
# Get all headings from complete composition
|
|
118
|
+
headings = @builder.all_headings
|
|
119
|
+
return { errors: [], warnings: [] } if headings.empty?
|
|
120
|
+
|
|
121
|
+
errors = []
|
|
122
|
+
warnings = []
|
|
123
|
+
|
|
124
|
+
# Check for empty headings (across complete page)
|
|
125
|
+
headings.each do |heading|
|
|
126
|
+
heading_text = heading[:text]
|
|
127
|
+
|
|
128
|
+
# Check if heading contains ERB placeholder
|
|
129
|
+
has_erb_content = heading_text.include?('ERB_CONTENT')
|
|
130
|
+
|
|
131
|
+
# Check if heading is empty or only contains whitespace
|
|
132
|
+
if (heading_text.empty? || heading_text.match?(/^\s*$/)) && !has_erb_content
|
|
133
|
+
errors << {
|
|
134
|
+
type: "Empty heading detected (<h#{heading[:level]}>) - headings must have accessible text (checked complete page: layout + view + partials)",
|
|
135
|
+
element: {
|
|
136
|
+
tag: "h#{heading[:level]}",
|
|
137
|
+
text: 'Empty heading',
|
|
138
|
+
file: heading[:file]
|
|
139
|
+
},
|
|
140
|
+
file: heading[:file],
|
|
141
|
+
line: heading[:line],
|
|
142
|
+
wcag: "4.1.2",
|
|
143
|
+
remediation: "Add descriptive text to the heading:\n\n<h#{heading[:level]}>Descriptive Heading Text</h#{heading[:level]}>",
|
|
144
|
+
page_level_check: true,
|
|
145
|
+
check_type: 'heading_empty'
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Check for styling-only headings (very short or generic text)
|
|
150
|
+
if heading_text.length <= 2 && heading_text.match?(/^[•→…\s\-_=]+$/)
|
|
151
|
+
warnings << {
|
|
152
|
+
type: "Heading appears to be used for styling only (text: '#{heading_text}') - headings should be descriptive (checked complete page: layout + view + partials)",
|
|
153
|
+
element: {
|
|
154
|
+
tag: "h#{heading[:level]}",
|
|
155
|
+
text: heading_text,
|
|
156
|
+
file: heading[:file]
|
|
157
|
+
},
|
|
158
|
+
file: heading[:file],
|
|
159
|
+
line: heading[:line],
|
|
160
|
+
wcag: "2.4.6",
|
|
161
|
+
remediation: "Use CSS for styling instead of headings. Replace with a <div> or <span> with appropriate CSS classes.",
|
|
162
|
+
page_level_check: true,
|
|
163
|
+
check_type: 'heading_styling'
|
|
164
|
+
}
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
{ errors: errors, warnings: warnings }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Scan the complete composed page for duplicate IDs
|
|
172
|
+
# IDs must be unique across the entire page (layout + view + partials)
|
|
173
|
+
# @return [Hash] Hash with :errors and :warnings arrays
|
|
174
|
+
def scan_duplicate_ids
|
|
175
|
+
return { errors: [], warnings: [] } unless @view_file && File.exist?(@view_file)
|
|
176
|
+
|
|
177
|
+
# Build composition
|
|
178
|
+
all_files = @builder.build
|
|
179
|
+
return { errors: [], warnings: [] } if all_files.empty?
|
|
180
|
+
|
|
181
|
+
# Collect all IDs from complete composition
|
|
182
|
+
id_map = {} # id => [{ file: String, line: Integer, element: String }]
|
|
183
|
+
|
|
184
|
+
all_files.each do |file|
|
|
185
|
+
# Handle both relative and absolute paths
|
|
186
|
+
file_path = if File.exist?(file)
|
|
187
|
+
file
|
|
188
|
+
elsif defined?(Rails) && Rails.root
|
|
189
|
+
rails_path = Rails.root.join(file)
|
|
190
|
+
rails_path.exist? ? rails_path.to_s : nil
|
|
191
|
+
else
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
next unless file_path && File.exist?(file_path)
|
|
195
|
+
|
|
196
|
+
content = File.read(file_path)
|
|
197
|
+
html_content = ErbExtractor.extract_html(content)
|
|
198
|
+
doc = Nokogiri::HTML::DocumentFragment.parse(html_content)
|
|
199
|
+
|
|
200
|
+
# Find all elements with IDs
|
|
201
|
+
doc.css('[id]').each do |element|
|
|
202
|
+
id = element[:id]
|
|
203
|
+
next if id.nil? || id.to_s.strip.empty?
|
|
204
|
+
# Skip ERB_CONTENT placeholder - it's not a real ID
|
|
205
|
+
next if id == 'ERB_CONTENT'
|
|
206
|
+
|
|
207
|
+
id_map[id] ||= []
|
|
208
|
+
line = find_line_number_for_element(content, element)
|
|
209
|
+
id_map[id] << {
|
|
210
|
+
file: file_path, # Use resolved path
|
|
211
|
+
line: line,
|
|
212
|
+
element: element.name
|
|
213
|
+
}
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
errors = []
|
|
218
|
+
|
|
219
|
+
# Find duplicate IDs
|
|
220
|
+
id_map.each do |id, occurrences|
|
|
221
|
+
if occurrences.length > 1
|
|
222
|
+
# Report all occurrences after the first one
|
|
223
|
+
occurrences[1..-1].each do |occurrence|
|
|
224
|
+
errors << {
|
|
225
|
+
type: "Duplicate ID '#{id}' found (checked complete page: layout + view + partials) - IDs must be unique across the entire page",
|
|
226
|
+
element: {
|
|
227
|
+
tag: occurrence[:element],
|
|
228
|
+
id: id,
|
|
229
|
+
file: occurrence[:file]
|
|
230
|
+
},
|
|
231
|
+
file: occurrence[:file],
|
|
232
|
+
line: occurrence[:line],
|
|
233
|
+
wcag: "4.1.1",
|
|
234
|
+
remediation: "Remove or rename the duplicate ID. Each ID must be unique on the page:\n\n<!-- Change one of these -->\n<div id=\"#{id}\">...</div>\n<div id=\"#{id}\">...</div>\n\n<!-- To -->\n<div id=\"#{id}\">...</div>\n<div id=\"#{id}-2\">...</div>",
|
|
235
|
+
page_level_check: true,
|
|
236
|
+
check_type: 'duplicate_ids'
|
|
237
|
+
}
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
{ errors: errors, warnings: [] }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Helper to find line number for an element
|
|
246
|
+
def find_line_number_for_element(content, element)
|
|
247
|
+
tag_name = element.name
|
|
248
|
+
id = element[:id]
|
|
249
|
+
|
|
250
|
+
lines = content.split("\n")
|
|
251
|
+
lines.each_with_index do |line, index|
|
|
252
|
+
if line.include?("<#{tag_name}") && (id.nil? || line.include?("id=\"#{id}\"") || line.include?("id='#{id}'"))
|
|
253
|
+
return index + 1
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
1
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Scan the complete composed page for ARIA landmarks
|
|
261
|
+
# This ensures landmarks in layout are detected when scanning view files
|
|
262
|
+
# @return [Hash] Hash with :errors and :warnings arrays
|
|
263
|
+
def scan_aria_landmarks
|
|
264
|
+
return { errors: [], warnings: [] } unless @view_file && File.exist?(@view_file)
|
|
265
|
+
|
|
266
|
+
# Build composition
|
|
267
|
+
all_files = @builder.build
|
|
268
|
+
return { errors: [], warnings: [] } if all_files.empty?
|
|
269
|
+
|
|
270
|
+
# Check for main landmark across all files
|
|
271
|
+
has_main = false
|
|
272
|
+
main_location = nil
|
|
273
|
+
|
|
274
|
+
all_files.each do |file|
|
|
275
|
+
next unless File.exist?(file)
|
|
276
|
+
|
|
277
|
+
content = File.read(file)
|
|
278
|
+
html_content = ErbExtractor.extract_html(content)
|
|
279
|
+
doc = Nokogiri::HTML::DocumentFragment.parse(html_content)
|
|
280
|
+
|
|
281
|
+
# Check for <main> tag or [role="main"]
|
|
282
|
+
main_elements = doc.css('main, [role="main"]')
|
|
283
|
+
if main_elements.any?
|
|
284
|
+
has_main = true
|
|
285
|
+
main_location = file
|
|
286
|
+
break
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
warnings = []
|
|
291
|
+
|
|
292
|
+
# Only report missing main if it's truly missing from the complete page
|
|
293
|
+
unless has_main
|
|
294
|
+
warnings << {
|
|
295
|
+
type: "Page missing MAIN landmark (checked complete page: layout + view + partials)",
|
|
296
|
+
element: {
|
|
297
|
+
tag: 'page',
|
|
298
|
+
text: 'Page-level ARIA landmark check'
|
|
299
|
+
},
|
|
300
|
+
file: @view_file,
|
|
301
|
+
line: 1,
|
|
302
|
+
wcag: "1.3.1",
|
|
303
|
+
remediation: "Add a <main> landmark to your page. It can be in the layout, view, or a partial:\n\n<main id=\"maincontent\">\n <!-- Page content -->\n</main>",
|
|
304
|
+
page_level_check: true,
|
|
305
|
+
check_type: 'aria_landmarks'
|
|
306
|
+
}
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
{ errors: [], warnings: warnings }
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
private
|
|
313
|
+
|
|
314
|
+
def create_violation(message:, heading:, wcag_reference:, remediation:)
|
|
315
|
+
{
|
|
316
|
+
message: message,
|
|
317
|
+
file: heading ? heading[:file] : @view_file,
|
|
318
|
+
line: heading ? heading[:line] : 1,
|
|
319
|
+
wcag_reference: wcag_reference,
|
|
320
|
+
remediation: remediation,
|
|
321
|
+
heading: heading # Keep heading info for element context
|
|
322
|
+
}
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
@@ -84,7 +84,8 @@ module RailsAccessibilityTesting
|
|
|
84
84
|
merged_system_specs = base_system_specs.merge(profile_system_specs)
|
|
85
85
|
|
|
86
86
|
base_config.merge(
|
|
87
|
-
|
|
87
|
+
# Support both 'accessibility_enabled' (new) and 'enabled' (legacy) for backward compatibility
|
|
88
|
+
'accessibility_enabled' => profile_config.fetch('accessibility_enabled', profile_config.fetch('enabled', base_config.fetch('accessibility_enabled', base_config.fetch('enabled', true)))), # Profile can override, supports legacy 'enabled' key
|
|
88
89
|
'checks' => merged_checks,
|
|
89
90
|
'summary' => merged_summary,
|
|
90
91
|
'scan_strategy' => profile_config['scan_strategy'] || base_config['scan_strategy'] || 'paths',
|
|
@@ -123,7 +124,7 @@ module RailsAccessibilityTesting
|
|
|
123
124
|
# Default configuration when no file exists
|
|
124
125
|
def default_config
|
|
125
126
|
{
|
|
126
|
-
'
|
|
127
|
+
'accessibility_enabled' => true, # Global enable/disable flag for all accessibility checks
|
|
127
128
|
'wcag_level' => 'AA',
|
|
128
129
|
'checks' => default_checks,
|
|
129
130
|
'summary' => {
|
|
@@ -52,7 +52,8 @@ module RailsAccessibilityTesting
|
|
|
52
52
|
require 'rails_accessibility_testing/config/yaml_loader'
|
|
53
53
|
profile = defined?(Rails) && Rails.env.test? ? :test : :development
|
|
54
54
|
config = Config::YamlLoader.load(profile: profile)
|
|
55
|
-
|
|
55
|
+
# Support both 'accessibility_enabled' (new) and 'enabled' (legacy) for backward compatibility
|
|
56
|
+
enabled = config.fetch('accessibility_enabled', config.fetch('enabled', true)) # Default to enabled if not specified
|
|
56
57
|
!enabled # Return true if disabled
|
|
57
58
|
rescue StandardError
|
|
58
59
|
false # If config can't be loaded, assume enabled
|
|
@@ -36,7 +36,8 @@ module RailsAccessibilityTesting
|
|
|
36
36
|
# Check if accessibility checks are globally disabled
|
|
37
37
|
begin
|
|
38
38
|
config = Config::YamlLoader.load(profile: :test)
|
|
39
|
-
|
|
39
|
+
# Support both 'accessibility_enabled' (new) and 'enabled' (legacy) for backward compatibility
|
|
40
|
+
enabled = config.fetch('accessibility_enabled', config.fetch('enabled', true))
|
|
40
41
|
return { errors: [], warnings: [] } unless enabled
|
|
41
42
|
rescue StandardError
|
|
42
43
|
# If config can't be loaded, continue (assume enabled)
|
|
@@ -70,14 +71,56 @@ module RailsAccessibilityTesting
|
|
|
70
71
|
# Run all enabled checks using existing RuleEngine
|
|
71
72
|
violations = engine.check(static_page, context: context)
|
|
72
73
|
|
|
73
|
-
#
|
|
74
|
+
# For page-level checks, use composed page scanner
|
|
75
|
+
# This checks the complete page (layout + view + partials) for:
|
|
76
|
+
# - All heading checks (hierarchy, empty, styling-only)
|
|
77
|
+
# - ARIA landmarks (main, etc.)
|
|
78
|
+
# - Duplicate IDs (must be unique across entire page)
|
|
79
|
+
require_relative 'composed_page_scanner'
|
|
80
|
+
composed_scanner = ComposedPageScanner.new(@view_file)
|
|
81
|
+
hierarchy_result = composed_scanner.scan_heading_hierarchy
|
|
82
|
+
all_headings_result = composed_scanner.scan_all_headings
|
|
83
|
+
landmarks_result = composed_scanner.scan_aria_landmarks
|
|
84
|
+
duplicate_ids_result = composed_scanner.scan_duplicate_ids
|
|
85
|
+
|
|
86
|
+
# Filter out page-level violations from regular violations
|
|
87
|
+
# (we'll use the composed page scanner results instead)
|
|
88
|
+
heading_violations = violations.select { |v|
|
|
89
|
+
v.rule_name.to_s == 'heading'
|
|
90
|
+
}
|
|
91
|
+
landmarks_violations = violations.select { |v|
|
|
92
|
+
v.rule_name.to_s == 'aria_landmarks' &&
|
|
93
|
+
(v.message.include?('missing MAIN') || v.message.include?('missing main') ||
|
|
94
|
+
v.message.include?('MAIN landmark'))
|
|
95
|
+
}
|
|
96
|
+
duplicate_ids_violations = violations.select { |v|
|
|
97
|
+
v.rule_name.to_s == 'duplicate_ids'
|
|
98
|
+
}
|
|
99
|
+
other_violations = violations.reject { |v|
|
|
100
|
+
heading_violations.include?(v) || landmarks_violations.include?(v) || duplicate_ids_violations.include?(v)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Convert non-page-level violations to errors/warnings format
|
|
74
104
|
line_number_finder = LineNumberFinder.new(@file_content)
|
|
75
|
-
ViolationConverter.convert(
|
|
76
|
-
|
|
105
|
+
result = ViolationConverter.convert(
|
|
106
|
+
other_violations,
|
|
77
107
|
view_file: @view_file,
|
|
78
108
|
line_number_finder: line_number_finder,
|
|
79
109
|
config: config
|
|
80
110
|
)
|
|
111
|
+
|
|
112
|
+
# Add composed page results (all heading checks + landmarks + duplicate IDs)
|
|
113
|
+
result[:errors] = (result[:errors] || []) +
|
|
114
|
+
hierarchy_result[:errors] +
|
|
115
|
+
all_headings_result[:errors] +
|
|
116
|
+
duplicate_ids_result[:errors]
|
|
117
|
+
result[:warnings] = (result[:warnings] || []) +
|
|
118
|
+
hierarchy_result[:warnings] +
|
|
119
|
+
all_headings_result[:warnings] +
|
|
120
|
+
landmarks_result[:warnings] +
|
|
121
|
+
duplicate_ids_result[:warnings]
|
|
122
|
+
|
|
123
|
+
result
|
|
81
124
|
rescue StandardError => e
|
|
82
125
|
# If engine fails, log error and return empty results
|
|
83
126
|
if defined?(Rails) && Rails.env.development?
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
require 'nokogiri'
|
|
5
|
+
require_relative 'accessibility_helper'
|
|
6
|
+
require_relative 'erb_extractor'
|
|
7
|
+
|
|
8
|
+
# Builds a composition graph of a Rails page (layout + view + partials)
|
|
9
|
+
# This allows us to check heading hierarchy across the complete composed page
|
|
10
|
+
# rather than individual files
|
|
11
|
+
module RailsAccessibilityTesting
|
|
12
|
+
# Builds the composition of a Rails page by tracing:
|
|
13
|
+
# - Layout file (application.html.erb)
|
|
14
|
+
# - View file (yield content)
|
|
15
|
+
# - All partials rendered (recursively)
|
|
16
|
+
#
|
|
17
|
+
# @api private
|
|
18
|
+
class ViewCompositionBuilder
|
|
19
|
+
include AccessibilityHelper::PartialDetection
|
|
20
|
+
|
|
21
|
+
attr_reader :view_file, :layout_file, :all_files
|
|
22
|
+
|
|
23
|
+
def initialize(view_file)
|
|
24
|
+
@view_file = view_file
|
|
25
|
+
@layout_file = nil
|
|
26
|
+
@all_files = []
|
|
27
|
+
@visited_files = Set.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Build the complete composition
|
|
31
|
+
# @return [Array<String>] Array of all file paths in the composition
|
|
32
|
+
def build
|
|
33
|
+
# Resolve view file path (handle relative/absolute)
|
|
34
|
+
view_file_path = normalize_path(@view_file)
|
|
35
|
+
return [] unless view_file_path && File.exist?(view_file_path)
|
|
36
|
+
|
|
37
|
+
@all_files = []
|
|
38
|
+
@visited_files = Set.new
|
|
39
|
+
|
|
40
|
+
# Find layout file
|
|
41
|
+
@layout_file = find_layout_file_for_view(view_file_path)
|
|
42
|
+
if @layout_file
|
|
43
|
+
layout_path = normalize_path(@layout_file)
|
|
44
|
+
@all_files << layout_path if layout_path && File.exist?(layout_path)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Recursively find all partials (handles nested partials, collections, etc.)
|
|
48
|
+
# Note: find_all_partials_recursive adds files to @all_files and @visited_files
|
|
49
|
+
# We call it BEFORE manually adding to ensure it processes the file
|
|
50
|
+
find_all_partials_recursive(view_file_path)
|
|
51
|
+
if @layout_file
|
|
52
|
+
layout_path = normalize_path(@layout_file)
|
|
53
|
+
find_all_partials_recursive(layout_path) if layout_path
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Ensure view file is in @all_files (it should be added by find_all_partials_recursive)
|
|
57
|
+
@all_files << view_file_path unless @all_files.include?(view_file_path)
|
|
58
|
+
|
|
59
|
+
@all_files.uniq
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Normalize file path to consistent format (absolute if possible)
|
|
63
|
+
def normalize_path(file_path)
|
|
64
|
+
return nil unless file_path
|
|
65
|
+
|
|
66
|
+
# Try with Rails.root first (most reliable for Rails apps)
|
|
67
|
+
if defined?(Rails) && Rails.root
|
|
68
|
+
rails_path = Rails.root.join(file_path)
|
|
69
|
+
return rails_path.to_s if File.exist?(rails_path)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# If file exists as-is (relative or absolute), return it
|
|
73
|
+
return file_path if File.exist?(file_path)
|
|
74
|
+
|
|
75
|
+
# Try making it absolute if it's relative
|
|
76
|
+
if !file_path.start_with?('/')
|
|
77
|
+
expanded = File.expand_path(file_path)
|
|
78
|
+
return expanded if File.exist?(expanded)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Return original if nothing works (will be checked later)
|
|
82
|
+
file_path
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Get all headings from the complete composition
|
|
86
|
+
# @return [Array<Hash>] Array of heading info: { level: 1-6, text: String, file: String, line: Integer }
|
|
87
|
+
def all_headings
|
|
88
|
+
headings = []
|
|
89
|
+
|
|
90
|
+
@all_files.each do |file|
|
|
91
|
+
# Handle both relative and absolute paths
|
|
92
|
+
file_path = if File.exist?(file)
|
|
93
|
+
file
|
|
94
|
+
elsif defined?(Rails) && Rails.root
|
|
95
|
+
rails_path = Rails.root.join(file)
|
|
96
|
+
rails_path.exist? ? rails_path.to_s : nil
|
|
97
|
+
else
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
next unless file_path && File.exist?(file_path)
|
|
102
|
+
|
|
103
|
+
content = File.read(file_path)
|
|
104
|
+
html_content = ErbExtractor.extract_html(content)
|
|
105
|
+
doc = Nokogiri::HTML::DocumentFragment.parse(html_content)
|
|
106
|
+
|
|
107
|
+
doc.css('h1, h2, h3, h4, h5, h6').each do |heading|
|
|
108
|
+
level = heading.name[1].to_i
|
|
109
|
+
text = heading.text.strip
|
|
110
|
+
line = find_line_number(content, heading)
|
|
111
|
+
|
|
112
|
+
headings << {
|
|
113
|
+
level: level,
|
|
114
|
+
text: text,
|
|
115
|
+
file: file_path, # Use resolved path
|
|
116
|
+
line: line
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Sort by file order (layout first, then view, then partials)
|
|
122
|
+
# This preserves the DOM order
|
|
123
|
+
headings.sort_by do |h|
|
|
124
|
+
file_index = @all_files.index(h[:file]) || 999
|
|
125
|
+
[file_index, h[:line]]
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# Find layout file for a view
|
|
132
|
+
# Handles:
|
|
133
|
+
# - Explicit layout declaration in view: layout 'custom_layout'
|
|
134
|
+
# - Controller-level layout (via ApplicationController)
|
|
135
|
+
# - Default application layout
|
|
136
|
+
def find_layout_file_for_view(view_file)
|
|
137
|
+
return nil unless view_file && File.exist?(view_file)
|
|
138
|
+
|
|
139
|
+
# Check for layout declaration in view file
|
|
140
|
+
content = File.read(view_file)
|
|
141
|
+
layout_match = content.match(/layout\s+['"]([^'"]+)['"]/)
|
|
142
|
+
layout_name = layout_match ? layout_match[1] : 'application'
|
|
143
|
+
|
|
144
|
+
# Try to find layout file
|
|
145
|
+
extensions = %w[erb haml slim]
|
|
146
|
+
extensions.each do |ext|
|
|
147
|
+
layout_path = "app/views/layouts/#{layout_name}.html.#{ext}"
|
|
148
|
+
if File.exist?(layout_path)
|
|
149
|
+
return layout_path
|
|
150
|
+
elsif defined?(Rails) && Rails.root
|
|
151
|
+
rails_path = Rails.root.join(layout_path)
|
|
152
|
+
return rails_path.to_s if File.exist?(rails_path)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Default to application layout
|
|
157
|
+
extensions.each do |ext|
|
|
158
|
+
layout_path = "app/views/layouts/application.html.#{ext}"
|
|
159
|
+
if File.exist?(layout_path)
|
|
160
|
+
return layout_path
|
|
161
|
+
elsif defined?(Rails) && Rails.root
|
|
162
|
+
rails_path = Rails.root.join(layout_path)
|
|
163
|
+
return rails_path.to_s if File.exist?(rails_path)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Recursively find all partials rendered in a file
|
|
171
|
+
# Handles nested partials, collections, and all Rails render patterns
|
|
172
|
+
def find_all_partials_recursive(file)
|
|
173
|
+
# Normalize file path to consistent format
|
|
174
|
+
file_path = normalize_path(file)
|
|
175
|
+
return unless file_path && File.exist?(file_path)
|
|
176
|
+
return if @visited_files.include?(file_path)
|
|
177
|
+
|
|
178
|
+
@visited_files.add(file_path)
|
|
179
|
+
content = File.read(file_path)
|
|
180
|
+
|
|
181
|
+
# Find all partials rendered in this file
|
|
182
|
+
# Use the PartialDetection module method (handles all Rails patterns)
|
|
183
|
+
partials = find_partials_in_view_file(file_path)
|
|
184
|
+
|
|
185
|
+
# Also check for Rails shorthand: render @model (renders _model.html.erb)
|
|
186
|
+
content.scan(/render\s+@(\w+)/) do |match|
|
|
187
|
+
model_name = match[0]
|
|
188
|
+
partial_name = model_name.underscore
|
|
189
|
+
partials << partial_name unless partials.include?(partial_name)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
partials.each do |partial_name|
|
|
193
|
+
partial_file = find_partial_file(partial_name)
|
|
194
|
+
next unless partial_file
|
|
195
|
+
|
|
196
|
+
# Normalize partial file path to consistent format
|
|
197
|
+
full_partial_path = normalize_path(partial_file)
|
|
198
|
+
next unless full_partial_path && File.exist?(full_partial_path)
|
|
199
|
+
next if @all_files.include?(full_partial_path)
|
|
200
|
+
|
|
201
|
+
@all_files << full_partial_path
|
|
202
|
+
|
|
203
|
+
# Recursively find partials within this partial (handles nested partials)
|
|
204
|
+
find_all_partials_recursive(full_partial_path)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Find the actual file path for a partial name
|
|
209
|
+
def find_partial_file(partial_name)
|
|
210
|
+
extensions = %w[erb haml slim]
|
|
211
|
+
|
|
212
|
+
# Handle namespaced partials (e.g., 'layouts/_navbar', 'layouts/navbar')
|
|
213
|
+
if partial_name.include?('/')
|
|
214
|
+
parts = partial_name.split('/')
|
|
215
|
+
dir = parts[0..-2].join('/')
|
|
216
|
+
name = parts.last
|
|
217
|
+
# Remove leading underscore from name if present
|
|
218
|
+
name = name.start_with?('_') ? name[1..-1] : name
|
|
219
|
+
partial_path = "app/views/#{dir}/_#{name}"
|
|
220
|
+
|
|
221
|
+
extensions.each do |ext|
|
|
222
|
+
full_path = "#{partial_path}.html.#{ext}"
|
|
223
|
+
# Try with Rails.root first (most reliable)
|
|
224
|
+
if defined?(Rails) && Rails.root
|
|
225
|
+
rails_path = Rails.root.join(full_path)
|
|
226
|
+
return rails_path.to_s if File.exist?(rails_path)
|
|
227
|
+
end
|
|
228
|
+
# Fallback to relative path
|
|
229
|
+
if File.exist?(full_path)
|
|
230
|
+
return full_path
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
else
|
|
234
|
+
# Non-namespaced partial - remove leading underscore if present
|
|
235
|
+
clean_name = partial_name.start_with?('_') ? partial_name[1..-1] : partial_name
|
|
236
|
+
|
|
237
|
+
# Try view directory first (same directory as view file)
|
|
238
|
+
# Handle both absolute and relative paths
|
|
239
|
+
view_dir_path = File.dirname(@view_file)
|
|
240
|
+
if view_dir_path.include?('app/views/')
|
|
241
|
+
# Extract directory relative to app/views
|
|
242
|
+
view_dir = view_dir_path.sub(/^.*\/app\/views\//, '')
|
|
243
|
+
else
|
|
244
|
+
view_dir = view_dir_path.sub('app/views/', '')
|
|
245
|
+
end
|
|
246
|
+
partial_path = "app/views/#{view_dir}/_#{clean_name}"
|
|
247
|
+
|
|
248
|
+
extensions.each do |ext|
|
|
249
|
+
full_path = "#{partial_path}.html.#{ext}"
|
|
250
|
+
# Try with Rails.root if file doesn't exist (for absolute paths)
|
|
251
|
+
if File.exist?(full_path)
|
|
252
|
+
return full_path
|
|
253
|
+
elsif defined?(Rails) && Rails.root
|
|
254
|
+
rails_path = Rails.root.join(full_path)
|
|
255
|
+
return rails_path.to_s if File.exist?(rails_path)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Try standard directories first (most common locations)
|
|
260
|
+
standard_dirs = ['layouts', 'shared', 'application']
|
|
261
|
+
standard_dirs.each do |dir|
|
|
262
|
+
partial_path = "app/views/#{dir}/_#{clean_name}"
|
|
263
|
+
extensions.each do |ext|
|
|
264
|
+
full_path = "#{partial_path}.html.#{ext}"
|
|
265
|
+
# Try with Rails.root first
|
|
266
|
+
if defined?(Rails) && Rails.root
|
|
267
|
+
rails_path = Rails.root.join(full_path)
|
|
268
|
+
return rails_path.to_s if File.exist?(rails_path)
|
|
269
|
+
end
|
|
270
|
+
# Fallback to relative path
|
|
271
|
+
if File.exist?(full_path)
|
|
272
|
+
return full_path
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Exhaustive search: traverse ALL folders in app/views recursively
|
|
278
|
+
# This ensures we find partials in any subdirectory (collections, items, profiles, loan_requests, etc.)
|
|
279
|
+
# Only do this if partial wasn't found in standard locations (performance optimization)
|
|
280
|
+
if defined?(Rails) && Rails.root
|
|
281
|
+
views_dir = Rails.root.join('app', 'views')
|
|
282
|
+
if File.exist?(views_dir)
|
|
283
|
+
# Search for partial in all subdirectories (use first match found)
|
|
284
|
+
extensions.each do |ext|
|
|
285
|
+
pattern = views_dir.join('**', "_#{clean_name}.html.#{ext}")
|
|
286
|
+
found_path = Dir.glob(pattern).first
|
|
287
|
+
return found_path if found_path && File.exist?(found_path)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
nil
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Find line number for an element in the original ERB file
|
|
297
|
+
def find_line_number(content, element)
|
|
298
|
+
# Simple approach: find the line containing the tag
|
|
299
|
+
tag_name = element.name
|
|
300
|
+
text = element.text.strip[0..50] # First 50 chars for matching
|
|
301
|
+
|
|
302
|
+
lines = content.split("\n")
|
|
303
|
+
lines.each_with_index do |line, index|
|
|
304
|
+
if line.include?("<#{tag_name}") && (text.empty? || line.include?(text[0..20]))
|
|
305
|
+
return index + 1
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
1 # Default to line 1 if not found
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
@@ -55,6 +55,8 @@ require_relative 'rails_accessibility_testing/line_number_finder'
|
|
|
55
55
|
require_relative 'rails_accessibility_testing/violation_converter'
|
|
56
56
|
require_relative 'rails_accessibility_testing/static_file_scanner'
|
|
57
57
|
require_relative 'rails_accessibility_testing/static_scanning'
|
|
58
|
+
require_relative 'rails_accessibility_testing/view_composition_builder'
|
|
59
|
+
require_relative 'rails_accessibility_testing/composed_page_scanner'
|
|
58
60
|
# Only load RSpec-specific components when RSpec is available
|
|
59
61
|
if defined?(RSpec)
|
|
60
62
|
require_relative 'rails_accessibility_testing/shared_examples'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_accessibility_testing
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.5.
|
|
4
|
+
version: 1.5.10
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Regan Maharjan
|
|
@@ -143,6 +143,7 @@ files:
|
|
|
143
143
|
- lib/rails_accessibility_testing/checks/skip_links_check.rb
|
|
144
144
|
- lib/rails_accessibility_testing/checks/table_structure_check.rb
|
|
145
145
|
- lib/rails_accessibility_testing/cli/command.rb
|
|
146
|
+
- lib/rails_accessibility_testing/composed_page_scanner.rb
|
|
146
147
|
- lib/rails_accessibility_testing/config/yaml_loader.rb
|
|
147
148
|
- lib/rails_accessibility_testing/configuration.rb
|
|
148
149
|
- lib/rails_accessibility_testing/engine/rule_engine.rb
|
|
@@ -161,6 +162,7 @@ files:
|
|
|
161
162
|
- lib/rails_accessibility_testing/static_page_adapter.rb
|
|
162
163
|
- lib/rails_accessibility_testing/static_scanning.rb
|
|
163
164
|
- lib/rails_accessibility_testing/version.rb
|
|
165
|
+
- lib/rails_accessibility_testing/view_composition_builder.rb
|
|
164
166
|
- lib/rails_accessibility_testing/violation_converter.rb
|
|
165
167
|
- lib/tasks/accessibility.rake
|
|
166
168
|
homepage: https://rayraycodes.github.io/rails-accessibility-testing/
|