@double-great/stylelint-a11y 3.1.0 → 3.2.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,40 @@ 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
+ ## [3.2.0] - 2025-08-16
9
+
10
+ ### Added
11
+
12
+ - Comprehensive E2E testing infrastructure with real-world projects
13
+ - Integration testing suite for plugin functionality
14
+ - Performance benchmarking with regression detection
15
+ - Test helper utilities for simplified testing
16
+ - Support for SCSS/Sass project testing
17
+ - Large codebase performance testing
18
+ - CLI integration tests with full stylelint compatibility
19
+ - Development documentation in README
20
+ - Pull request template for consistent PR descriptions
21
+
22
+ ### Changed
23
+
24
+ - Standardized import patterns across all rules for better consistency
25
+ - Fixed rule order in exports object to maintain alphabetical sorting
26
+ - Improved Jest configuration for multiple test types
27
+ - Enhanced ESLint configuration for E2E tests
28
+ - Updated actions/checkout from v4 to v5 (#77)
29
+
30
+ ### Fixed
31
+
32
+ - Fixed `media-prefers-reduced-motion` rule incorrectly adding duplicate media queries when `prefers-reduced-motion` is already nested inside another media query (#66)
33
+
34
+ ### Developer Experience
35
+
36
+ - Added npm scripts for granular test execution (`test:unit`, `test:integration`, `test:e2e`, `test:all`)
37
+ - Simplified test utilities for faster development
38
+ - Cross-platform compatibility testing
39
+ - Performance metrics and scaling tests
40
+ - Individual rule benchmarks included
41
+
8
42
  ## [3.1.0] - 2025-08-16
9
43
 
10
44
  ### Added
package/README.md CHANGED
@@ -52,19 +52,31 @@ This shareable config contains the following:
52
52
 
53
53
  Since it adds stylelint-a11y to `plugins`, you don't have to do this yourself when extending this config.
54
54
 
55
- ## Help out
55
+ ## Development
56
56
 
57
- There work on the plugin's rules is still in progress, so if you feel like it, you're welcome to help out in any of these (the plugin follows stylelint guidelines so most part of this is based on its docs):
57
+ ### Testing
58
58
 
59
- - Create, enhance, and debug rules (see stylelint's guide to "[Working on rules](https://github.com/stylelint/stylelint/blob/main/docs/developer-guide/rules.md)").
60
- - Improve documentation.
61
- - Chime in on any open issue or pull request.
62
- - Open new issues about your ideas on new rules, or for how to improve the existing ones, and pull requests to show us how your idea works.
63
- - Add new tests to absolutely anything.
64
- - Work on improving performance of rules.
65
- - Contribute to [stylelint](https://github.com/stylelint/stylelint)
66
- - Spread the word.
59
+ Run tests with the following commands:
67
60
 
68
- We communicate via [issues](https://github.com/double-great/stylelint-a11y/issues) and [pull requests](https://github.com/double-great/stylelint-a11y/pulls).
61
+ - `npm run test` - Run unit tests for all rules
62
+ - `npm run test:unit` - Run unit tests only
63
+ - `npm run test:integration` - Run integration tests
64
+ - `npm run test:e2e` - Run end-to-end tests with real projects
65
+ - `npm run test:performance` - Run performance benchmarks
66
+ - `npm run test:all` - Run complete test suite
69
67
 
70
- There is also [stackoverflow](https://stackoverflow.com/questions/tagged/stylelint), which would be the preferred QA forum.
68
+ ### Testing Infrastructure
69
+
70
+ This project includes testing at a few levels:
71
+
72
+ - **Unit tests** - Individual rule functionality
73
+ - **Integration tests** - Plugin integration with stylelint
74
+ - **E2E tests** - Real-world project testing with intentional violations
75
+ - **Performance tests** - Benchmark testing for large codebases
76
+
77
+ ### Other Commands
78
+
79
+ - `npm run lint` - Run ESLint
80
+ - `npm run format:check` - Check code formatting
81
+ - `npm run format:fix` - Fix code formatting
82
+ - `npm run coverage` - Run tests with coverage report
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@double-great/stylelint-a11y",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Plugin for stylelint with a11y rules",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -28,8 +28,14 @@
28
28
  "pretest": "npm run lint && npm run format:check",
29
29
  "format:check": "prettier --check .",
30
30
  "format:fix": "prettier --write .",
31
- "test": "jest",
32
- "coverage": "jest --coverage"
31
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
32
+ "test:unit": "node --experimental-vm-modules node_modules/jest/bin/jest.js src",
33
+ "test:integration": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/integration",
34
+ "test:e2e": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/e2e",
35
+ "test:performance": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/e2e/performance.test.js",
36
+ "test:all": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
37
+ "coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
38
+ "coverage:all": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage"
33
39
  },
34
40
  "prettier": {
35
41
  "printWidth": 100,
@@ -48,6 +54,7 @@
48
54
  "jest": "^30.0.5",
49
55
  "jest-light-runner": "^0.7.9",
50
56
  "jest-preset-stylelint": "^8.0.0",
57
+ "postcss-scss": "^4.0.9",
51
58
  "prettier": "^3.6.2",
52
59
  "stylelint": "^16.23.1"
53
60
  },
@@ -15,6 +15,7 @@ export default {
15
15
  'content-property-no-static-value': contentPropertyNoStaticValue,
16
16
  'font-size-is-readable': fontSizeIsReadable,
17
17
  'line-height-is-vertical-rhythmed': lineHeightIsVerticalRhythmed,
18
+ 'media-prefers-color-scheme': mediaPrefersColorScheme,
18
19
  'media-prefers-reduced-motion': mediaPrefersReducedMotion,
19
20
  'no-display-none': noDisplayNone,
20
21
  'no-obsolete-attribute': noObsoleteAttribute,
@@ -23,5 +24,4 @@ export default {
23
24
  'no-spread-text': noSpreadText,
24
25
  'no-text-align-justify': noTextAlignJustify,
25
26
  'selector-pseudo-class-focus': selectorPseudoClassFocus,
26
- 'media-prefers-color-scheme': mediaPrefersColorScheme,
27
27
  };
@@ -2,7 +2,9 @@ import isCustomSelector from 'stylelint/lib/utils/isCustomSelector.mjs';
2
2
  import isStandardSyntaxAtRule from 'stylelint/lib/utils/isStandardSyntaxAtRule.mjs';
3
3
  import isStandardSyntaxRule from 'stylelint/lib/utils/isStandardSyntaxRule.mjs';
4
4
  import isStandardSyntaxSelector from 'stylelint/lib/utils/isStandardSyntaxSelector.mjs';
5
+
5
6
  import { parse } from 'postcss';
7
+
6
8
  import stylelint from 'stylelint';
7
9
  const {
8
10
  utils: { report, ruleMessages, validateOptions },
@@ -62,6 +64,30 @@ function check(selector, node) {
62
64
 
63
65
  if (!declarationsIsMatched) return true;
64
66
 
67
+ // Check if there's a nested media query with prefers-reduced-motion inside the current rule
68
+ const hasNestedPrefersReducedMotion = declarations.some((childNode) => {
69
+ if (childNode.type === 'atrule' && childNode.name === 'media') {
70
+ return childNode.params && childNode.params.indexOf('prefers-reduced-motion') >= 0;
71
+ }
72
+
73
+ return false;
74
+ });
75
+
76
+ if (hasNestedPrefersReducedMotion) return true;
77
+
78
+ // Check if there's a sibling media query with prefers-reduced-motion at the same level
79
+ if (node.parent && node.parent.type === 'atrule' && node.parent.name === 'media') {
80
+ const siblingHasPrefersReducedMotion = node.parent.nodes.some((siblingNode) => {
81
+ if (siblingNode.type === 'atrule' && siblingNode.name === 'media') {
82
+ return siblingNode.params && siblingNode.params.indexOf('prefers-reduced-motion') >= 0;
83
+ }
84
+
85
+ return false;
86
+ });
87
+
88
+ if (siblingHasPrefersReducedMotion) return true;
89
+ }
90
+
65
91
  if (declarationsIsMatched) {
66
92
  const parentMatchedNode = parentNodes.some((parentNode) => {
67
93
  if (!parentNode || !parentNode.nodes) return false;
@@ -143,25 +169,62 @@ export default function mediaPrefersReducedMotion(actual, _, context) {
143
169
  media.nodes.forEach((o) => {
144
170
  o.raws.after = '\n';
145
171
  });
146
- const cloneRule = node.clone();
147
-
148
- cloneRule.raws = {
149
- ...cloneRule.raws,
150
- before: '\n',
151
- after: '\n',
152
- semicolon: true,
153
- };
154
- cloneRule.nodes.forEach((o) => {
155
- if (o.prop === 'animation-name') {
156
- o.prop = 'animation';
157
- }
158
-
159
- if (targetProperties.indexOf(o.prop) >= 0) {
160
- o.value = 'none';
161
- }
162
- });
163
- media.first.append(cloneRule);
164
- node.before(media);
172
+
173
+ // Check if we're already inside a media query
174
+ if (node.parent && node.parent.type === 'atrule' && node.parent.name === 'media') {
175
+ // Create a clone with only the animation/transition properties set to none
176
+ const cloneRule = node.clone();
177
+
178
+ cloneRule.nodes = cloneRule.nodes.filter((o) => {
179
+ if (o.prop === 'animation-name') {
180
+ o.prop = 'animation';
181
+ o.value = 'none';
182
+
183
+ return true;
184
+ }
185
+
186
+ if (targetProperties.indexOf(o.prop) >= 0) {
187
+ o.value = 'none';
188
+
189
+ return true;
190
+ }
191
+
192
+ return false;
193
+ });
194
+
195
+ cloneRule.raws = {
196
+ ...cloneRule.raws,
197
+ before: '\n',
198
+ after: '\n',
199
+ semicolon: true,
200
+ };
201
+
202
+ media.first.append(cloneRule);
203
+ // Insert the nested media query inside the current rule
204
+ node.append(media.first);
205
+ } else {
206
+ // Original logic for non-nested case
207
+ const cloneRule = node.clone();
208
+
209
+ cloneRule.raws = {
210
+ ...cloneRule.raws,
211
+ before: '\n',
212
+ after: '\n',
213
+ semicolon: true,
214
+ };
215
+ cloneRule.nodes.forEach((o) => {
216
+ if (o.prop === 'animation-name') {
217
+ o.prop = 'animation';
218
+ }
219
+
220
+ if (targetProperties.indexOf(o.prop) >= 0) {
221
+ o.value = 'none';
222
+ }
223
+ });
224
+ media.first.append(cloneRule);
225
+ // Insert the media query before the current node
226
+ node.before(media);
227
+ }
165
228
 
166
229
  return;
167
230
  }
@@ -1,5 +1,7 @@
1
1
  import isStandardSyntaxRule from 'stylelint/lib/utils/isStandardSyntaxRule.mjs';
2
+
2
3
  import { obsoleteAttributes } from './obsoleteAttributes.js';
4
+
3
5
  import stylelint from 'stylelint';
4
6
  const {
5
7
  utils: { report, ruleMessages, validateOptions },
@@ -1,5 +1,7 @@
1
1
  import isStandardSyntaxRule from 'stylelint/lib/utils/isStandardSyntaxRule.mjs';
2
+
2
3
  import { obsoleteElements } from './obsoleteElements.js';
4
+
3
5
  import stylelint from 'stylelint';
4
6
  const {
5
7
  utils: { report, ruleMessages, validateOptions },
@@ -0,0 +1,37 @@
1
+ # E2E Test Information
2
+
3
+ ## About E2E Test "Failures"
4
+
5
+ The E2E tests in this directory are designed to test stylelint-a11y against real CSS files that **intentionally contain rule violations**.
6
+
7
+ ### Expected Behavior
8
+
9
+ - Tests **expect** stylelint to exit with error codes when violations are found
10
+ - The tests catch these "failures" and parse the JSON output to verify:
11
+ - Correct number of violations detected
12
+ - Specific rules are triggered
13
+ - Line numbers and error messages are accurate
14
+
15
+ ### Test Output
16
+
17
+ The console output may show:
18
+
19
+ - JSON arrays of violations (this is expected)
20
+ - Some error messages during test runs (also expected)
21
+ - Performance metrics from the performance tests
22
+
23
+ ### In CI/GitHub Actions
24
+
25
+ These tests will pass in CI because:
26
+
27
+ - Jest catches the expected exceptions
28
+ - Tests verify the violations are correctly detected
29
+ - The test suite itself passes even though stylelint "fails" on the test CSS
30
+
31
+ ## Test Projects
32
+
33
+ - `basic-css/`: Tests core CSS functionality
34
+ - `scss-project/`: Tests SCSS/Sass compatibility
35
+ - `large-codebase/`: Tests performance with larger files
36
+
37
+ All test projects contain intentional accessibility violations for testing purposes.
@@ -0,0 +1,71 @@
1
+ import { execSync } from 'child_process';
2
+ import { dirname, join } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import fs from 'fs/promises';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = dirname(__filename);
8
+
9
+ describe('CLI Integration Tests', () => {
10
+ const projectRoot = join(__dirname, '../..');
11
+
12
+ it('should work with recommended config via CLI', async () => {
13
+ // Create a minimal test to verify CLI works with the plugin
14
+ const testCssFile = join(projectRoot, 'test-cli-temp.css');
15
+ const testConfigFile = join(projectRoot, '.stylelintrc-cli-temp.json');
16
+
17
+ try {
18
+ await fs.writeFile(testCssFile, '.test:focus { outline: none; }');
19
+ await fs.writeFile(
20
+ testConfigFile,
21
+ JSON.stringify({
22
+ plugins: ['./src/index.js'],
23
+ rules: { 'a11y/no-outline-none': true },
24
+ })
25
+ );
26
+
27
+ try {
28
+ execSync(`npx stylelint "${testCssFile}" --config "${testConfigFile}"`, {
29
+ cwd: projectRoot,
30
+ encoding: 'utf8',
31
+ });
32
+
33
+ throw new Error('Expected stylelint to exit with non-zero code');
34
+ } catch (error) {
35
+ expect(error.status).toBeGreaterThan(0);
36
+ }
37
+ } finally {
38
+ // Cleanup
39
+ await fs.unlink(testCssFile).catch(() => {});
40
+ await fs.unlink(testConfigFile).catch(() => {});
41
+ }
42
+ });
43
+
44
+ it('should handle valid CSS without CLI errors', async () => {
45
+ const testCssFile = join(projectRoot, 'test-cli-valid-temp.css');
46
+ const testConfigFile = join(projectRoot, '.stylelintrc-valid-temp.json');
47
+
48
+ try {
49
+ await fs.writeFile(testCssFile, '.test:hover:focus { color: blue; }');
50
+ await fs.writeFile(
51
+ testConfigFile,
52
+ JSON.stringify({
53
+ plugins: ['./src/index.js'],
54
+ rules: { 'a11y/selector-pseudo-class-focus': true },
55
+ })
56
+ );
57
+
58
+ const result = execSync(`npx stylelint "${testCssFile}" --config "${testConfigFile}"`, {
59
+ cwd: projectRoot,
60
+ encoding: 'utf8',
61
+ });
62
+
63
+ // Should succeed without errors
64
+ expect(typeof result).toBe('string');
65
+ } finally {
66
+ // Cleanup
67
+ await fs.unlink(testCssFile).catch(() => {});
68
+ await fs.unlink(testConfigFile).catch(() => {});
69
+ }
70
+ });
71
+ });
@@ -0,0 +1,98 @@
1
+ import { testCSS, getWarnings } from '../helpers/simple-test-utils.js';
2
+
3
+ describe('Performance Tests', () => {
4
+ it('should process rules efficiently', async () => {
5
+ const testCss = `
6
+ .test1:focus { outline: none; }
7
+ .test2:hover { color: blue; }
8
+ .test3 { font-size: 10px; }
9
+ .test4::before { content: "text"; }
10
+ .test5 { line-height: 1.0; }
11
+ .test6 { text-align: justify; }
12
+ .test7 { animation: slide 1s; }
13
+ .test8 { display: none; }
14
+ `.repeat(50); // Create larger test case
15
+
16
+ const times = [];
17
+
18
+ // Run multiple iterations to get average performance
19
+ for (let i = 0; i < 3; i++) {
20
+ const startTime = process.hrtime.bigint();
21
+
22
+ await testCSS(testCss, {
23
+ 'no-outline-none': true,
24
+ 'selector-pseudo-class-focus': true,
25
+ 'font-size-is-readable': true,
26
+ 'content-property-no-static-value': true,
27
+ 'line-height-is-vertical-rhythmed': true,
28
+ 'no-text-align-justify': true,
29
+ 'media-prefers-reduced-motion': true,
30
+ 'no-display-none': true,
31
+ });
32
+
33
+ const endTime = process.hrtime.bigint();
34
+ const duration = Number(endTime - startTime) / 1000000; // Convert to milliseconds
35
+
36
+ times.push(duration);
37
+ }
38
+
39
+ const averageTime = times.reduce((sum, time) => sum + time, 0) / times.length;
40
+
41
+ // Performance assertions
42
+ expect(averageTime).toBeLessThan(1000); // Average should be under 1 second
43
+ });
44
+
45
+ it('should scale reasonably with CSS size', async () => {
46
+ const baseCss = '.test:focus { outline: none; } .test:hover { color: blue; }';
47
+ const sizes = [100, 500]; // Different repetition counts
48
+ const results = [];
49
+
50
+ for (const size of sizes) {
51
+ const testCss = baseCss.repeat(size);
52
+
53
+ const startTime = process.hrtime.bigint();
54
+
55
+ await testCSS(testCss, {
56
+ 'no-outline-none': true,
57
+ 'selector-pseudo-class-focus': true,
58
+ });
59
+
60
+ const endTime = process.hrtime.bigint();
61
+ const duration = Number(endTime - startTime) / 1000000;
62
+
63
+ results.push({ size, duration });
64
+ }
65
+
66
+ // Check that performance scales reasonably
67
+ const scalingFactor = results[1].duration / results[0].duration; // 500 vs 100 rules
68
+
69
+ expect(scalingFactor).toBeLessThan(25); // Should not be more than 25x slower for 5x content
70
+ });
71
+
72
+ it('should handle large stylesheets without memory issues', async () => {
73
+ let largeCss = '';
74
+
75
+ for (let i = 0; i < 100; i++) {
76
+ largeCss += `.component-${i} { font-size: 10px; color: red; }\n`;
77
+ largeCss += `.component-${i}:hover { color: blue; }\n`;
78
+ largeCss += `.component-${i}:focus { outline: none; }\n`;
79
+ }
80
+
81
+ const memoryBefore = process.memoryUsage().heapUsed;
82
+
83
+ const warnings = await getWarnings(largeCss, {
84
+ 'no-outline-none': true,
85
+ 'font-size-is-readable': true,
86
+ 'selector-pseudo-class-focus': true,
87
+ });
88
+
89
+ const memoryAfter = process.memoryUsage().heapUsed;
90
+ const memoryDiff = memoryAfter - memoryBefore;
91
+
92
+ // Should detect violations
93
+ expect(warnings.length).toBeGreaterThan(0);
94
+
95
+ // Memory usage should be reasonable (less than 50MB increase)
96
+ expect(memoryDiff).toBeLessThan(50 * 1024 * 1024);
97
+ });
98
+ });
@@ -0,0 +1,17 @@
1
+ {
2
+ "plugins": ["../../../../src/index.js"],
3
+ "rules": {
4
+ "a11y/content-property-no-static-value": true,
5
+ "a11y/font-size-is-readable": true,
6
+ "a11y/line-height-is-vertical-rhythmed": true,
7
+ "a11y/media-prefers-color-scheme": true,
8
+ "a11y/media-prefers-reduced-motion": true,
9
+ "a11y/no-display-none": true,
10
+ "a11y/no-obsolete-attribute": true,
11
+ "a11y/no-obsolete-element": true,
12
+ "a11y/no-outline-none": true,
13
+ "a11y/no-spread-text": true,
14
+ "a11y/no-text-align-justify": true,
15
+ "a11y/selector-pseudo-class-focus": true
16
+ }
17
+ }
@@ -0,0 +1,81 @@
1
+ /* Basic CSS project for E2E testing */
2
+
3
+ /* Navigation styles */
4
+ .nav {
5
+ background: #333;
6
+ padding: 1rem;
7
+ }
8
+
9
+ .nav a:hover {
10
+ color: #fff;
11
+ text-decoration: none;
12
+ }
13
+
14
+ .nav .active {
15
+ font-weight: bold;
16
+ }
17
+
18
+ /* Button styles */
19
+ .btn:focus {
20
+ outline: none; /* Should trigger a11y/no-outline-none */
21
+ background: #007bff;
22
+ }
23
+
24
+ .btn-primary {
25
+ background: #007bff;
26
+ color: white;
27
+ padding: 0.5rem 1rem;
28
+ border: none;
29
+ font-size: 12px; /* Should trigger a11y/font-size-is-readable */
30
+ }
31
+
32
+ .btn-secondary:hover {
33
+ background: #6c757d; /* Should trigger a11y/selector-pseudo-class-focus */
34
+ }
35
+
36
+ /* Content styles */
37
+ .content::before {
38
+ content: '★'; /* Should trigger a11y/content-property-no-static-value */
39
+ color: gold;
40
+ }
41
+
42
+ .article {
43
+ line-height: 1.2; /* Should trigger a11y/line-height-is-vertical-rhythmed */
44
+ text-align: justify; /* Should trigger a11y/no-text-align-justify */
45
+ }
46
+
47
+ /* Animations */
48
+ .slide-in {
49
+ animation: slideIn 0.5s ease; /* Should trigger a11y/media-prefers-reduced-motion */
50
+ }
51
+
52
+ @keyframes slideIn {
53
+ from {
54
+ transform: translateX(-100%);
55
+ }
56
+ to {
57
+ transform: translateX(0);
58
+ }
59
+ }
60
+
61
+ /* Hidden elements */
62
+ .sr-only {
63
+ display: none; /* Should trigger a11y/no-display-none */
64
+ }
65
+
66
+ /* Obsolete elements and attributes */
67
+ center {
68
+ margin: 0 auto; /* Should trigger a11y/no-obsolete-element */
69
+ }
70
+
71
+ .old-style {
72
+ text-decoration: blink; /* Should trigger a11y/no-obsolete-attribute */
73
+ }
74
+
75
+ /* Media queries */
76
+ @media screen and (max-width: 768px) {
77
+ .mobile-nav a:hover {
78
+ color: blue; /* Should trigger a11y/selector-pseudo-class-focus */
79
+ font-size: 10px; /* Should trigger a11y/font-size-is-readable */
80
+ }
81
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "plugins": ["../../../../src/index.js"],
3
+ "rules": {
4
+ "a11y/content-property-no-static-value": true,
5
+ "a11y/font-size-is-readable": [true, { "minSize": 12 }],
6
+ "a11y/line-height-is-vertical-rhythmed": true,
7
+ "a11y/media-prefers-color-scheme": true,
8
+ "a11y/media-prefers-reduced-motion": true,
9
+ "a11y/no-display-none": true,
10
+ "a11y/no-obsolete-attribute": true,
11
+ "a11y/no-obsolete-element": true,
12
+ "a11y/no-outline-none": true,
13
+ "a11y/no-spread-text": true,
14
+ "a11y/no-text-align-justify": true,
15
+ "a11y/selector-pseudo-class-focus": true
16
+ }
17
+ }