@afixt/test-utils 1.1.8 → 1.2.1
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/.claude/settings.local.json +5 -1
- package/.github/workflows/pr-check.yml +88 -0
- package/.github/workflows/security.yml +0 -3
- package/eslint.config.mjs +1 -1
- package/package.json +2 -1
- package/src/constants.js +231 -0
- package/src/cssUtils.js +77 -0
- package/src/domUtils.js +268 -12
- package/src/formUtils.js +175 -0
- package/src/getCSSGeneratedContent.js +39 -17
- package/src/index.js +18 -2
- package/src/stringUtils.js +168 -21
- package/src/tableUtils.js +180 -0
- package/src/testContrast.js +137 -22
- package/src/testLang.js +514 -444
- package/test/cssUtils.test.js +248 -0
- package/test/domUtils.test.js +815 -297
- package/test/formUtils.test.js +389 -0
- package/test/getCSSGeneratedContent.test.js +187 -232
- package/test/hasCSSGeneratedContent.test.js +37 -147
- package/test/playwright/css-pseudo-elements.spec.js +224 -91
- package/test/playwright/fixtures/css-pseudo-elements.html +6 -0
- package/test/stringUtils.test.js +609 -343
- package/test/tableUtils.test.js +340 -0
- package/test/testContrast.test.js +801 -651
- package/vitest.config.js +28 -28
- package/.github/dependabot.yml +0 -36
- package/test/getCSSGeneratedContent.browser.test.js +0 -125
|
@@ -19,7 +19,11 @@
|
|
|
19
19
|
"Bash(npx vitest run:*)",
|
|
20
20
|
"Bash(1)",
|
|
21
21
|
"Bash(npm run test:playwright:css:*)",
|
|
22
|
-
"Bash(gh run list:*)"
|
|
22
|
+
"Bash(gh run list:*)",
|
|
23
|
+
"Bash(gh pr create:*)",
|
|
24
|
+
"Bash(git checkout:*)",
|
|
25
|
+
"Bash(git pull:*)",
|
|
26
|
+
"Bash(git merge:*)"
|
|
23
27
|
]
|
|
24
28
|
},
|
|
25
29
|
"enableAllProjectMcpServers": false
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
name: PR Check
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches: [main, develop]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
validate-workflows:
|
|
12
|
+
name: Validate GitHub Actions Workflows
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- name: Checkout code
|
|
16
|
+
uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- name: Validate workflow syntax
|
|
19
|
+
run: |
|
|
20
|
+
echo "Validating GitHub Actions workflow files..."
|
|
21
|
+
errors=0
|
|
22
|
+
|
|
23
|
+
for f in .github/workflows/*.yml .github/workflows/*.yaml; do
|
|
24
|
+
[ -f "$f" ] || continue
|
|
25
|
+
echo "Checking $f..."
|
|
26
|
+
|
|
27
|
+
# Basic YAML syntax check
|
|
28
|
+
if ! python3 -c "import yaml; yaml.safe_load(open('$f'))" 2>/dev/null; then
|
|
29
|
+
echo "ERROR: Invalid YAML in $f"
|
|
30
|
+
errors=$((errors + 1))
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# Check for required 'on' and 'jobs' keys
|
|
34
|
+
if ! grep -q '^on:' "$f" && ! grep -q "^'on':" "$f" && ! grep -q '^"on":' "$f"; then
|
|
35
|
+
echo "WARNING: Missing 'on' trigger in $f"
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
if ! grep -q '^jobs:' "$f"; then
|
|
39
|
+
echo "WARNING: Missing 'jobs' section in $f"
|
|
40
|
+
fi
|
|
41
|
+
done
|
|
42
|
+
|
|
43
|
+
if [ $errors -gt 0 ]; then
|
|
44
|
+
echo "Found $errors workflow file(s) with errors"
|
|
45
|
+
exit 1
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
echo "All workflow files are valid"
|
|
49
|
+
|
|
50
|
+
ci-check:
|
|
51
|
+
name: CI Readiness Check
|
|
52
|
+
runs-on: ubuntu-latest
|
|
53
|
+
steps:
|
|
54
|
+
- name: Checkout code
|
|
55
|
+
uses: actions/checkout@v4
|
|
56
|
+
|
|
57
|
+
- name: Detect project type
|
|
58
|
+
id: detect
|
|
59
|
+
run: |
|
|
60
|
+
if [ -f "package.json" ]; then
|
|
61
|
+
echo "type=node" >> $GITHUB_OUTPUT
|
|
62
|
+
elif [ -f "requirements.txt" ] || [ -f "pyproject.toml" ]; then
|
|
63
|
+
echo "type=python" >> $GITHUB_OUTPUT
|
|
64
|
+
else
|
|
65
|
+
echo "type=other" >> $GITHUB_OUTPUT
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
- name: Setup Node.js
|
|
69
|
+
if: steps.detect.outputs.type == 'node'
|
|
70
|
+
uses: actions/setup-node@v4
|
|
71
|
+
with:
|
|
72
|
+
node-version: 'lts/*'
|
|
73
|
+
cache: 'npm'
|
|
74
|
+
|
|
75
|
+
- name: Install dependencies
|
|
76
|
+
if: steps.detect.outputs.type == 'node'
|
|
77
|
+
run: npm ci --ignore-scripts
|
|
78
|
+
continue-on-error: true
|
|
79
|
+
|
|
80
|
+
- name: Run lint check
|
|
81
|
+
if: steps.detect.outputs.type == 'node'
|
|
82
|
+
run: npm run lint --if-present
|
|
83
|
+
continue-on-error: true
|
|
84
|
+
|
|
85
|
+
- name: Run tests
|
|
86
|
+
if: steps.detect.outputs.type == 'node'
|
|
87
|
+
run: npm test --if-present
|
|
88
|
+
continue-on-error: true
|
package/eslint.config.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@afixt/test-utils",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Various utilities for accessibility testing",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@eslint/js": "^9.39.2",
|
|
55
55
|
"@playwright/test": "^1.57.0",
|
|
56
|
+
"@vitest/coverage-istanbul": "^3.2.4",
|
|
56
57
|
"@vitest/coverage-v8": "^3.0.9",
|
|
57
58
|
"clean-jsdoc-theme": "^4.3.0",
|
|
58
59
|
"eslint": "^9.39.2",
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Shared constants for accessibility testing
|
|
3
|
+
* @module constants
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* CSS selector string for interactive elements.
|
|
8
|
+
* Matches native HTML interactive elements and ARIA widget roles.
|
|
9
|
+
* @type {string}
|
|
10
|
+
*/
|
|
11
|
+
const INTERACTIVE_SELECTOR = [
|
|
12
|
+
'a[href]',
|
|
13
|
+
'button',
|
|
14
|
+
'input:not([type="hidden"])',
|
|
15
|
+
'select',
|
|
16
|
+
'textarea',
|
|
17
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
18
|
+
'[role="button"]',
|
|
19
|
+
'[role="link"]',
|
|
20
|
+
'[role="checkbox"]',
|
|
21
|
+
'[role="menuitem"]',
|
|
22
|
+
'[role="menuitemcheckbox"]',
|
|
23
|
+
'[role="menuitemradio"]',
|
|
24
|
+
'[role="tab"]',
|
|
25
|
+
'[role="switch"]',
|
|
26
|
+
'[role="option"]',
|
|
27
|
+
'[role="radio"]',
|
|
28
|
+
'[role="slider"]',
|
|
29
|
+
'[role="spinbutton"]',
|
|
30
|
+
'[role="textbox"]',
|
|
31
|
+
'[role="combobox"]',
|
|
32
|
+
'[role="listbox"]',
|
|
33
|
+
'[role="searchbox"]',
|
|
34
|
+
'[role="gridcell"]',
|
|
35
|
+
'[role="treeitem"]',
|
|
36
|
+
].join(', ');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* CSS selector string for naturally focusable elements.
|
|
40
|
+
* These elements are focusable by default (unless disabled).
|
|
41
|
+
* @type {string}
|
|
42
|
+
*/
|
|
43
|
+
const FOCUSABLE_SELECTOR = [
|
|
44
|
+
'a[href]',
|
|
45
|
+
'button:not([disabled])',
|
|
46
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
47
|
+
'select:not([disabled])',
|
|
48
|
+
'textarea:not([disabled])',
|
|
49
|
+
'[tabindex]',
|
|
50
|
+
'audio[controls]',
|
|
51
|
+
'video[controls]',
|
|
52
|
+
'embed',
|
|
53
|
+
'iframe',
|
|
54
|
+
'object',
|
|
55
|
+
'[contenteditable]:not([contenteditable="false"])',
|
|
56
|
+
].join(', ');
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Labellable elements per HTML spec that can be associated with a <label>.
|
|
60
|
+
* @type {string[]}
|
|
61
|
+
*/
|
|
62
|
+
const LABELLABLE_ELEMENTS = [
|
|
63
|
+
'input',
|
|
64
|
+
'select',
|
|
65
|
+
'textarea',
|
|
66
|
+
'button',
|
|
67
|
+
'meter',
|
|
68
|
+
'output',
|
|
69
|
+
'progress',
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Elements that validly support the alt attribute per HTML spec.
|
|
74
|
+
* @type {string[]}
|
|
75
|
+
*/
|
|
76
|
+
const VALID_ALT_ELEMENTS = ['IMG', 'AREA', 'INPUT', 'APPLET'];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* ISO 639-1 codes for right-to-left languages.
|
|
80
|
+
* @type {string[]}
|
|
81
|
+
*/
|
|
82
|
+
const RTL_LANGUAGES = [
|
|
83
|
+
'ar', // Arabic
|
|
84
|
+
'he', // Hebrew
|
|
85
|
+
'fa', // Persian/Farsi
|
|
86
|
+
'ur', // Urdu
|
|
87
|
+
'sd', // Sindhi
|
|
88
|
+
'yi', // Yiddish
|
|
89
|
+
'ps', // Pashto
|
|
90
|
+
'ku', // Kurdish (some dialects)
|
|
91
|
+
'ug', // Uyghur
|
|
92
|
+
'dv', // Divehi/Maldivian
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Generic, non-descriptive, or meaningless frame/iframe titles.
|
|
97
|
+
* @type {string[]}
|
|
98
|
+
*/
|
|
99
|
+
const GENERIC_TITLES = ['iframe', 'frame', 'untitled', 'title', 'content', 'main', 'page'];
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Generic or useless table summaries commonly found in the wild.
|
|
103
|
+
* Merged from multiple detection contexts.
|
|
104
|
+
* @type {string[]}
|
|
105
|
+
*/
|
|
106
|
+
const GENERIC_SUMMARIES = [
|
|
107
|
+
'table',
|
|
108
|
+
'data',
|
|
109
|
+
'data table',
|
|
110
|
+
'information',
|
|
111
|
+
'content',
|
|
112
|
+
'main content',
|
|
113
|
+
'layout',
|
|
114
|
+
'layout table',
|
|
115
|
+
'for layout',
|
|
116
|
+
'table for layout purposes',
|
|
117
|
+
'structural table',
|
|
118
|
+
'this table is used for page layout',
|
|
119
|
+
'header',
|
|
120
|
+
'footer',
|
|
121
|
+
'navigation',
|
|
122
|
+
'nav',
|
|
123
|
+
'body',
|
|
124
|
+
'combobox',
|
|
125
|
+
'links design table',
|
|
126
|
+
'title and navigation',
|
|
127
|
+
'main heading',
|
|
128
|
+
'spacer',
|
|
129
|
+
'spacer table',
|
|
130
|
+
'menu',
|
|
131
|
+
'n/a',
|
|
132
|
+
'na',
|
|
133
|
+
'none',
|
|
134
|
+
'null',
|
|
135
|
+
'empty',
|
|
136
|
+
'blank',
|
|
137
|
+
'undefined',
|
|
138
|
+
'table summary',
|
|
139
|
+
'summary',
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Phrases indicating structure-focused rather than content-focused table summary.
|
|
144
|
+
* @type {string[]}
|
|
145
|
+
*/
|
|
146
|
+
const STRUCTURE_PHRASES = [
|
|
147
|
+
'rows and columns',
|
|
148
|
+
'grid format',
|
|
149
|
+
'for display purposes',
|
|
150
|
+
'arranged in a grid',
|
|
151
|
+
'the following table shows',
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Phrases indicating vague/rambling table summary content.
|
|
156
|
+
* @type {string[]}
|
|
157
|
+
*/
|
|
158
|
+
const VAGUE_PHRASES = [
|
|
159
|
+
'might find interesting',
|
|
160
|
+
'depending on their needs',
|
|
161
|
+
'users might find',
|
|
162
|
+
'may be useful',
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Valid ARIA parent-child interactive role combinations per the WAI-ARIA spec.
|
|
167
|
+
* These composite widget patterns require interactive children by design.
|
|
168
|
+
* Key = parent role, Value = array of valid child roles.
|
|
169
|
+
* @type {Object.<string, string[]>}
|
|
170
|
+
*/
|
|
171
|
+
const VALID_ARIA_NESTING = {
|
|
172
|
+
listbox: ['option'],
|
|
173
|
+
menu: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'menu'],
|
|
174
|
+
menubar: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'menu'],
|
|
175
|
+
menuitem: ['menu', 'menubar'],
|
|
176
|
+
tablist: ['tab'],
|
|
177
|
+
tree: ['treeitem', 'group'],
|
|
178
|
+
treeitem: ['group', 'tree'],
|
|
179
|
+
grid: ['gridcell', 'row', 'rowgroup'],
|
|
180
|
+
row: ['gridcell', 'columnheader', 'rowheader', 'cell'],
|
|
181
|
+
rowgroup: ['row'],
|
|
182
|
+
radiogroup: ['radio'],
|
|
183
|
+
combobox: ['listbox', 'textbox', 'tree', 'grid', 'dialog'],
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Thresholds for determining table complexity.
|
|
188
|
+
* @type {Object}
|
|
189
|
+
*/
|
|
190
|
+
const COMPLEXITY_THRESHOLDS = {
|
|
191
|
+
MAX_COLUMNS: 10,
|
|
192
|
+
MAX_HEADER_COLSPAN: 4,
|
|
193
|
+
MAX_ROWSPAN: 3,
|
|
194
|
+
MIN_HEADER_ROWS_FOR_COMPLEX: 3,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* WCAG 2.5.5 minimum target size in CSS pixels.
|
|
199
|
+
* @type {number}
|
|
200
|
+
*/
|
|
201
|
+
const MIN_TARGET_SIZE = 44;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Phrases that indicate a warning about new window/tab behavior.
|
|
205
|
+
* @type {string[]}
|
|
206
|
+
*/
|
|
207
|
+
const NEW_WINDOW_WARNINGS = [
|
|
208
|
+
'new window',
|
|
209
|
+
'new tab',
|
|
210
|
+
'opens in new',
|
|
211
|
+
'opens in a new',
|
|
212
|
+
'opens new',
|
|
213
|
+
'external link',
|
|
214
|
+
'external site',
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
module.exports = {
|
|
218
|
+
INTERACTIVE_SELECTOR,
|
|
219
|
+
FOCUSABLE_SELECTOR,
|
|
220
|
+
LABELLABLE_ELEMENTS,
|
|
221
|
+
VALID_ALT_ELEMENTS,
|
|
222
|
+
RTL_LANGUAGES,
|
|
223
|
+
GENERIC_TITLES,
|
|
224
|
+
GENERIC_SUMMARIES,
|
|
225
|
+
STRUCTURE_PHRASES,
|
|
226
|
+
VAGUE_PHRASES,
|
|
227
|
+
VALID_ARIA_NESTING,
|
|
228
|
+
COMPLEXITY_THRESHOLDS,
|
|
229
|
+
MIN_TARGET_SIZE,
|
|
230
|
+
NEW_WINDOW_WARNINGS,
|
|
231
|
+
};
|
package/src/cssUtils.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file CSS styling inspection utilities
|
|
3
|
+
* @module cssUtils
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const cssUtils = {
|
|
7
|
+
/**
|
|
8
|
+
* Check if an element's background is transparent/none.
|
|
9
|
+
* @param {CSSStyleDeclaration} style - The computed style
|
|
10
|
+
* @returns {boolean} True if background is transparent
|
|
11
|
+
*/
|
|
12
|
+
hasTransparentBackground(style) {
|
|
13
|
+
const bg = style.backgroundColor;
|
|
14
|
+
const bgImage = style.backgroundImage;
|
|
15
|
+
|
|
16
|
+
if (bgImage && bgImage !== 'none') {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent' || bg === '';
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check if an element has no visible border.
|
|
25
|
+
* @param {CSSStyleDeclaration} style - The computed style
|
|
26
|
+
* @returns {boolean} True if no border
|
|
27
|
+
*/
|
|
28
|
+
hasNoBorder(style) {
|
|
29
|
+
return style.borderStyle === 'none' || parseFloat(style.borderWidth) === 0;
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if an element looks like regular text (no underline, background, or border).
|
|
34
|
+
* @param {HTMLElement} element - The element to check
|
|
35
|
+
* @returns {boolean} True if element looks like regular text
|
|
36
|
+
*/
|
|
37
|
+
looksLikeText(element) {
|
|
38
|
+
const style = window.getComputedStyle(element);
|
|
39
|
+
|
|
40
|
+
const noUnderline = !style.textDecoration.toLowerCase().includes('underline');
|
|
41
|
+
const noBackground = cssUtils.hasTransparentBackground(style);
|
|
42
|
+
const noBorder = cssUtils.hasNoBorder(style);
|
|
43
|
+
|
|
44
|
+
return noUnderline && noBackground && noBorder;
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if an element has link-like styling (underline).
|
|
49
|
+
* @param {HTMLElement} element - The element to check
|
|
50
|
+
* @returns {boolean} True if element has link-like styling
|
|
51
|
+
*/
|
|
52
|
+
hasLinkStyling(element) {
|
|
53
|
+
const style = window.getComputedStyle(element);
|
|
54
|
+
return style.textDecoration.toLowerCase().includes('underline');
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check if an element has button-like styling (background/border with padding).
|
|
59
|
+
* @param {HTMLElement} element - The element to check
|
|
60
|
+
* @returns {boolean} True if element has button-like styling
|
|
61
|
+
*/
|
|
62
|
+
hasButtonStyling(element) {
|
|
63
|
+
const style = window.getComputedStyle(element);
|
|
64
|
+
|
|
65
|
+
const hasBackground = !cssUtils.hasTransparentBackground(style);
|
|
66
|
+
const hasBorder = !cssUtils.hasNoBorder(style);
|
|
67
|
+
const hasPadding =
|
|
68
|
+
parseFloat(style.paddingTop) > 2 ||
|
|
69
|
+
parseFloat(style.paddingBottom) > 2 ||
|
|
70
|
+
parseFloat(style.paddingLeft) > 4 ||
|
|
71
|
+
parseFloat(style.paddingRight) > 4;
|
|
72
|
+
|
|
73
|
+
return (hasBackground || hasBorder) && hasPadding;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
module.exports = cssUtils;
|