@afixt/test-utils 2.1.1 → 2.3.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.
@@ -259,4 +259,37 @@ describe('isA11yVisible', () => {
259
259
  const element = document.getElementById('test');
260
260
  expect(isA11yVisible(element)).toBe(false);
261
261
  });
262
+
263
+ it('should treat <template> as inherently non-visible (AT-visible) even with display:none', () => {
264
+ // In real browsers, <template> has display:none by default via UA stylesheet.
265
+ // It should be treated as inherently non-visible (like script/style) and
266
+ // return true (exempt from accessibility checks), not false (hidden).
267
+ document.body.innerHTML =
268
+ '<template id="test" style="display:none"><div>Template content</div></template>';
269
+ const element = document.getElementById('test');
270
+ expect(isA11yVisible(element)).toBe(true);
271
+ });
272
+
273
+ describe('shadow DOM integration', () => {
274
+ it('should detect aria-labelledby reference from inside a shadow root', () => {
275
+ // Create a hidden element that provides an accessible name
276
+ const hidden = document.createElement('span');
277
+ hidden.id = 'shadow-ref-source';
278
+ hidden.style.display = 'none';
279
+ hidden.textContent = 'Label text';
280
+ document.body.appendChild(hidden);
281
+
282
+ // Create shadow host with an element that references the hidden element
283
+ const host = document.createElement('div');
284
+ document.body.appendChild(host);
285
+ const shadow = host.attachShadow({ mode: 'open' });
286
+ const button = document.createElement('button');
287
+ button.setAttribute('aria-labelledby', 'shadow-ref-source');
288
+ shadow.appendChild(button);
289
+
290
+ // The hidden element should still be considered AT-visible because
291
+ // it's referenced by an aria-labelledby from the shadow DOM
292
+ expect(isA11yVisible(hidden)).toBe(true);
293
+ });
294
+ });
262
295
  });
@@ -240,6 +240,24 @@ describe('isHidden', () => {
240
240
  expect(isHidden(element, { checkDimensions: true })).toBe(true);
241
241
  });
242
242
 
243
+ it('should return false for elements with hidden="until-found"', () => {
244
+ const element = document.createElement('div');
245
+ element.setAttribute('hidden', 'until-found');
246
+ document.body.appendChild(element);
247
+
248
+ // hidden="until-found" is NOT fully hidden — content is findable by
249
+ // browser Find-in-Page and may be accessible to AT
250
+ expect(isHidden(element)).toBe(false);
251
+ });
252
+
253
+ it('should return true for elements with plain hidden attribute (empty string)', () => {
254
+ const element = document.createElement('div');
255
+ element.setAttribute('hidden', '');
256
+ document.body.appendChild(element);
257
+
258
+ expect(isHidden(element)).toBe(true);
259
+ });
260
+
243
261
  it('should detect visibility:hidden applied via CSS class', () => {
244
262
  // Arrange
245
263
  const style = document.createElement('style');
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Playwright tests for isA11yVisible with colon-containing IDs
3
+ *
4
+ * Issue #61: CSS.escape is needed when building attribute selectors from
5
+ * element IDs. Colons in IDs (e.g. Radix UI "radix-:rj:-tab-account")
6
+ * break unescaped CSS selectors.
7
+ *
8
+ * Run with: npm run test:playwright:css
9
+ */
10
+
11
+ const { test, expect } = require('@playwright/test');
12
+ const path = require('path');
13
+
14
+ test.describe('isA11yVisible with colon-containing IDs', () => {
15
+ test.beforeEach(async ({ page }) => {
16
+ const fixturePath = path.join(__dirname, 'fixtures', 'colon-id-a11y-visible.html');
17
+ await page.goto(`file://${fixturePath}`);
18
+
19
+ // Inject browser-compatible isA11yVisible (mirrors src/isA11yVisible.js)
20
+ await page.addScriptTag({
21
+ content: `
22
+ const NON_VISIBLE_SELECTORS = [
23
+ 'base','head','meta','title','link','style','script','br','nobr',
24
+ 'col','embed','input[type="hidden"]','keygen','source','track',
25
+ ];
26
+
27
+ function isHidden(element, options) {
28
+ if (!element || !(element instanceof Element)) return false;
29
+ options = options || {};
30
+ if (element.hasAttribute('hidden')) return true;
31
+ const style = window.getComputedStyle(element);
32
+ if (style.display === 'none') return true;
33
+ if (style.visibility === 'hidden') return true;
34
+ if (options.checkOpacity && style.opacity === '0') return true;
35
+ return false;
36
+ }
37
+
38
+ function isA11yVisible(element, strict) {
39
+ if (strict === undefined) strict = false;
40
+ if (!element || !(element instanceof Element)) return false;
41
+ if (!element.isConnected) return false;
42
+
43
+ const id = element.id;
44
+ let visible = true;
45
+
46
+ if (NON_VISIBLE_SELECTORS.some(function(s) { return element.matches(s); })) {
47
+ return true;
48
+ }
49
+
50
+ function optionalAriaHidden(el, strictCheck) {
51
+ return strictCheck && el.getAttribute('aria-hidden') === 'true';
52
+ }
53
+
54
+ if (isHidden(element, { checkOpacity: true })) {
55
+ visible = false;
56
+ } else {
57
+ let parent = element.parentElement;
58
+ while (parent) {
59
+ const style = window.getComputedStyle(parent);
60
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
61
+ visible = false;
62
+ break;
63
+ }
64
+ parent = parent.parentElement;
65
+ }
66
+ }
67
+
68
+ if (visible && optionalAriaHidden(element, strict)) {
69
+ visible = false;
70
+ }
71
+
72
+ if (id) {
73
+ document
74
+ .querySelectorAll(
75
+ '*[aria-labelledby~="' + CSS.escape(id) + '"], *[aria-describedby~="' + CSS.escape(id) + '"]'
76
+ )
77
+ .forEach(function(referencingElement) {
78
+ if (window.getComputedStyle(referencingElement).display !== 'none') {
79
+ visible = true;
80
+ }
81
+ });
82
+ }
83
+
84
+ if (visible && strict) {
85
+ let parent = element.parentElement;
86
+ while (parent) {
87
+ if (optionalAriaHidden(parent, strict)) {
88
+ visible = false;
89
+ break;
90
+ }
91
+ parent = parent.parentElement;
92
+ }
93
+ }
94
+
95
+ return visible;
96
+ }
97
+
98
+ window.isA11yVisible = isA11yVisible;
99
+ `,
100
+ });
101
+ });
102
+
103
+ test('baseline: hidden element with simple ID referenced by aria-labelledby is AT-visible', async ({
104
+ page,
105
+ }) => {
106
+ const result = await page.evaluate(() => {
107
+ const el = document.getElementById('simple-label');
108
+ return window.isA11yVisible(el);
109
+ });
110
+ expect(result).toBe(true);
111
+ });
112
+
113
+ test('hidden element with colon ID referenced by aria-labelledby is AT-visible', async ({
114
+ page,
115
+ }) => {
116
+ const result = await page.evaluate(() => {
117
+ const el = document.getElementById('radix-:rj:-tab-account');
118
+ return window.isA11yVisible(el);
119
+ });
120
+ expect(result).toBe(true);
121
+ });
122
+
123
+ test('hidden element with colon ID referenced by aria-describedby is AT-visible', async ({
124
+ page,
125
+ }) => {
126
+ const result = await page.evaluate(() => {
127
+ const el = document.getElementById('desc-:r1:-help');
128
+ return window.isA11yVisible(el);
129
+ });
130
+ expect(result).toBe(true);
131
+ });
132
+
133
+ test('hidden element with multiple colons in ID referenced by aria-labelledby is AT-visible', async ({
134
+ page,
135
+ }) => {
136
+ const result = await page.evaluate(() => {
137
+ const el = document.getElementById('ns:component:instance:42');
138
+ return window.isA11yVisible(el);
139
+ });
140
+ expect(result).toBe(true);
141
+ });
142
+
143
+ test('visible element with colon ID (no aria reference) is AT-visible', async ({ page }) => {
144
+ const result = await page.evaluate(() => {
145
+ const el = document.getElementById('colon:visible');
146
+ return window.isA11yVisible(el);
147
+ });
148
+ expect(result).toBe(true);
149
+ });
150
+
151
+ test('hidden element with colon ID and no aria reference is NOT AT-visible', async ({
152
+ page,
153
+ }) => {
154
+ const result = await page.evaluate(() => {
155
+ const el = document.getElementById('colon:hidden');
156
+ return window.isA11yVisible(el);
157
+ });
158
+ expect(result).toBe(false);
159
+ });
160
+ });
@@ -0,0 +1,48 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>isA11yVisible - Colon ID Test Fixtures</title>
7
+ <style>
8
+ .sr-only {
9
+ position: absolute;
10
+ width: 1px;
11
+ height: 1px;
12
+ padding: 0;
13
+ margin: -1px;
14
+ overflow: hidden;
15
+ clip: rect(0, 0, 0, 0);
16
+ white-space: nowrap;
17
+ border: 0;
18
+ }
19
+ </style>
20
+ </head>
21
+ <body>
22
+ <!-- Standard ID: baseline reference -->
23
+ <span id="simple-label" class="sr-only" style="display:none">Account settings</span>
24
+ <button aria-labelledby="simple-label">Settings</button>
25
+
26
+ <!-- Colon-containing ID: Radix UI style -->
27
+ <span id="radix-:rj:-tab-account" class="sr-only" style="display:none">Account tab</span>
28
+ <button aria-labelledby="radix-:rj:-tab-account">Account</button>
29
+
30
+ <!-- Colon-containing ID: aria-describedby -->
31
+ <span id="desc-:r1:-help" class="sr-only" style="display:none">Help text for input</span>
32
+ <input aria-describedby="desc-:r1:-help" type="text" />
33
+
34
+ <!-- Multiple colons -->
35
+ <span id="ns:component:instance:42" class="sr-only" style="display:none">Namespaced label</span>
36
+ <div aria-labelledby="ns:component:instance:42">Namespaced widget</div>
37
+
38
+ <!-- Visible element with colon ID (no aria reference, just visible) -->
39
+ <div id="colon:visible">Visible colon ID</div>
40
+
41
+ <!-- Hidden element with colon ID, no aria reference (should be hidden) -->
42
+ <div id="colon:hidden" style="display:none">Hidden colon ID</div>
43
+
44
+ <script>
45
+ window.testReady = true;
46
+ </script>
47
+ </body>
48
+ </html>
@@ -0,0 +1,248 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import {
3
+ deepGetElementById,
4
+ deepQuerySelector,
5
+ deepQuerySelectorAll,
6
+ } from '../src/shadowDomUtils.js';
7
+
8
+ describe('shadowDomUtils', () => {
9
+ beforeEach(() => {
10
+ document.body.innerHTML = '';
11
+ });
12
+
13
+ // =========================================================================
14
+ // Batch 1: deepGetElementById — basic cases
15
+ // =========================================================================
16
+ describe('deepGetElementById', () => {
17
+ it('should return null when root is null', () => {
18
+ expect(deepGetElementById(null, 'foo')).toBeNull();
19
+ });
20
+
21
+ it('should return null when id is null or empty', () => {
22
+ expect(deepGetElementById(document, null)).toBeNull();
23
+ expect(deepGetElementById(document, '')).toBeNull();
24
+ });
25
+
26
+ it('should find an element in the light DOM', () => {
27
+ document.body.innerHTML = '<div id="target">Hello</div>';
28
+ const result = deepGetElementById(document, 'target');
29
+ expect(result).not.toBeNull();
30
+ expect(result.textContent).toBe('Hello');
31
+ });
32
+
33
+ it('should find an element inside an open shadow root', () => {
34
+ const host = document.createElement('div');
35
+ document.body.appendChild(host);
36
+ const shadow = host.attachShadow({ mode: 'open' });
37
+ const inner = document.createElement('span');
38
+ inner.id = 'shadow-target';
39
+ inner.textContent = 'Inside shadow';
40
+ shadow.appendChild(inner);
41
+
42
+ const result = deepGetElementById(document, 'shadow-target');
43
+ expect(result).not.toBeNull();
44
+ expect(result.textContent).toBe('Inside shadow');
45
+ });
46
+
47
+ it('should find an element in a nested shadow root', () => {
48
+ const host1 = document.createElement('div');
49
+ document.body.appendChild(host1);
50
+ const shadow1 = host1.attachShadow({ mode: 'open' });
51
+
52
+ const host2 = document.createElement('div');
53
+ shadow1.appendChild(host2);
54
+ const shadow2 = host2.attachShadow({ mode: 'open' });
55
+
56
+ const inner = document.createElement('span');
57
+ inner.id = 'nested-target';
58
+ inner.textContent = 'Nested';
59
+ shadow2.appendChild(inner);
60
+
61
+ const result = deepGetElementById(document, 'nested-target');
62
+ expect(result).not.toBeNull();
63
+ expect(result.textContent).toBe('Nested');
64
+ });
65
+
66
+ it('should return null when element is not found anywhere', () => {
67
+ document.body.innerHTML = '<div id="other">Other</div>';
68
+ expect(deepGetElementById(document, 'nonexistent')).toBeNull();
69
+ });
70
+
71
+ it('should prefer light DOM match (fast path)', () => {
72
+ // Light DOM element
73
+ document.body.innerHTML = '<div id="dup">Light</div>';
74
+
75
+ // Also put one in shadow DOM
76
+ const host = document.createElement('div');
77
+ document.body.appendChild(host);
78
+ const shadow = host.attachShadow({ mode: 'open' });
79
+ const inner = document.createElement('span');
80
+ inner.id = 'dup';
81
+ inner.textContent = 'Shadow';
82
+ shadow.appendChild(inner);
83
+
84
+ const result = deepGetElementById(document, 'dup');
85
+ expect(result).not.toBeNull();
86
+ expect(result.textContent).toBe('Light');
87
+ });
88
+
89
+ it('should work when root is a ShadowRoot', () => {
90
+ const host = document.createElement('div');
91
+ document.body.appendChild(host);
92
+ const shadow = host.attachShadow({ mode: 'open' });
93
+ const inner = document.createElement('span');
94
+ inner.id = 'sr-target';
95
+ inner.textContent = 'Direct';
96
+ shadow.appendChild(inner);
97
+
98
+ const result = deepGetElementById(shadow, 'sr-target');
99
+ expect(result).not.toBeNull();
100
+ expect(result.textContent).toBe('Direct');
101
+ });
102
+ });
103
+
104
+ // =========================================================================
105
+ // Batch 3-4: deepQuerySelector
106
+ // =========================================================================
107
+ describe('deepQuerySelector', () => {
108
+ it('should return null when root is null', () => {
109
+ expect(deepQuerySelector(null, 'div')).toBeNull();
110
+ });
111
+
112
+ it('should return null when selector is null or empty', () => {
113
+ expect(deepQuerySelector(document, null)).toBeNull();
114
+ expect(deepQuerySelector(document, '')).toBeNull();
115
+ });
116
+
117
+ it('should find an element in the light DOM', () => {
118
+ document.body.innerHTML = '<label for="name">Name</label>';
119
+ const result = deepQuerySelector(document, 'label[for="name"]');
120
+ expect(result).not.toBeNull();
121
+ expect(result.textContent).toBe('Name');
122
+ });
123
+
124
+ it('should find a label[for] inside a shadow root', () => {
125
+ const host = document.createElement('div');
126
+ document.body.appendChild(host);
127
+ const shadow = host.attachShadow({ mode: 'open' });
128
+ const label = document.createElement('label');
129
+ label.setAttribute('for', 'email');
130
+ label.textContent = 'Email';
131
+ shadow.appendChild(label);
132
+
133
+ const result = deepQuerySelector(document, 'label[for="email"]');
134
+ expect(result).not.toBeNull();
135
+ expect(result.textContent).toBe('Email');
136
+ });
137
+
138
+ it('should find an element in a nested shadow root', () => {
139
+ const host1 = document.createElement('div');
140
+ document.body.appendChild(host1);
141
+ const shadow1 = host1.attachShadow({ mode: 'open' });
142
+
143
+ const host2 = document.createElement('div');
144
+ shadow1.appendChild(host2);
145
+ const shadow2 = host2.attachShadow({ mode: 'open' });
146
+
147
+ const el = document.createElement('span');
148
+ el.className = 'deep-nested';
149
+ el.textContent = 'Found';
150
+ shadow2.appendChild(el);
151
+
152
+ const result = deepQuerySelector(document, '.deep-nested');
153
+ expect(result).not.toBeNull();
154
+ expect(result.textContent).toBe('Found');
155
+ });
156
+
157
+ it('should return null when no match is found', () => {
158
+ document.body.innerHTML = '<div>Nothing matching</div>';
159
+ expect(deepQuerySelector(document, '.nonexistent')).toBeNull();
160
+ });
161
+
162
+ it('should prefer light DOM match (fast path)', () => {
163
+ document.body.innerHTML = '<span class="item">Light</span>';
164
+
165
+ const host = document.createElement('div');
166
+ document.body.appendChild(host);
167
+ const shadow = host.attachShadow({ mode: 'open' });
168
+ const inner = document.createElement('span');
169
+ inner.className = 'item';
170
+ inner.textContent = 'Shadow';
171
+ shadow.appendChild(inner);
172
+
173
+ const result = deepQuerySelector(document, '.item');
174
+ expect(result.textContent).toBe('Light');
175
+ });
176
+ });
177
+
178
+ // =========================================================================
179
+ // Batch 5-6: deepQuerySelectorAll
180
+ // =========================================================================
181
+ describe('deepQuerySelectorAll', () => {
182
+ it('should return empty array when root is null', () => {
183
+ expect(deepQuerySelectorAll(null, 'div')).toEqual([]);
184
+ });
185
+
186
+ it('should return empty array when selector is null or empty', () => {
187
+ expect(deepQuerySelectorAll(document, null)).toEqual([]);
188
+ expect(deepQuerySelectorAll(document, '')).toEqual([]);
189
+ });
190
+
191
+ it('should find elements in the light DOM', () => {
192
+ document.body.innerHTML = '<div class="x">A</div><div class="x">B</div>';
193
+ const results = deepQuerySelectorAll(document, '.x');
194
+ expect(results).toHaveLength(2);
195
+ });
196
+
197
+ it('should collect elements from multiple shadow roots', () => {
198
+ document.body.innerHTML = '<span class="item">Light</span>';
199
+
200
+ const host1 = document.createElement('div');
201
+ document.body.appendChild(host1);
202
+ const shadow1 = host1.attachShadow({ mode: 'open' });
203
+ const inner1 = document.createElement('span');
204
+ inner1.className = 'item';
205
+ shadow1.appendChild(inner1);
206
+
207
+ const host2 = document.createElement('div');
208
+ document.body.appendChild(host2);
209
+ const shadow2 = host2.attachShadow({ mode: 'open' });
210
+ const inner2 = document.createElement('span');
211
+ inner2.className = 'item';
212
+ shadow2.appendChild(inner2);
213
+
214
+ const results = deepQuerySelectorAll(document, '.item');
215
+ expect(results).toHaveLength(3);
216
+ });
217
+
218
+ it('should collect from nested shadow roots', () => {
219
+ const host1 = document.createElement('div');
220
+ document.body.appendChild(host1);
221
+ const shadow1 = host1.attachShadow({ mode: 'open' });
222
+
223
+ const host2 = document.createElement('div');
224
+ shadow1.appendChild(host2);
225
+ const shadow2 = host2.attachShadow({ mode: 'open' });
226
+
227
+ const inner = document.createElement('span');
228
+ inner.className = 'nested-item';
229
+ shadow2.appendChild(inner);
230
+
231
+ const results = deepQuerySelectorAll(document, '.nested-item');
232
+ expect(results).toHaveLength(1);
233
+ expect(results[0].className).toBe('nested-item');
234
+ });
235
+
236
+ it('should return an array (not NodeList)', () => {
237
+ document.body.innerHTML = '<div class="y">A</div>';
238
+ const results = deepQuerySelectorAll(document, '.y');
239
+ expect(Array.isArray(results)).toBe(true);
240
+ });
241
+
242
+ it('should return empty array when nothing matches', () => {
243
+ document.body.innerHTML = '<div>Nothing</div>';
244
+ const results = deepQuerySelectorAll(document, '.missing');
245
+ expect(results).toHaveLength(0);
246
+ });
247
+ });
248
+ });
@@ -95,18 +95,18 @@ describe('testContrast', () => {
95
95
  expect(testContrast(div)).toBe(true);
96
96
  });
97
97
 
98
- it('should skip elements with a background image', () => {
98
+ it('should return null for elements with a background image (cannot determine)', () => {
99
99
  const div = document.createElement('div');
100
100
  div.textContent = 'Text with background image';
101
101
  div.style.color = 'rgb(0, 0, 0)';
102
102
  div.style.backgroundImage = 'url(image.jpg)';
103
103
  document.body.appendChild(div);
104
104
 
105
- // Should skip because contrast can't be reliably tested against background images
106
- expect(testContrast(div)).toBe(true);
105
+ // Should return null because contrast can't be reliably tested against background images
106
+ expect(testContrast(div)).toBe(null);
107
107
  });
108
108
 
109
- it('should skip elements whose ancestor has a background image', () => {
109
+ it('should return null for elements whose ancestor has a background image (cannot determine)', () => {
110
110
  const parent = document.createElement('div');
111
111
  parent.style.backgroundImage = 'url(bg.jpg)';
112
112
 
@@ -118,7 +118,7 @@ describe('testContrast', () => {
118
118
  parent.appendChild(child);
119
119
  document.body.appendChild(parent);
120
120
 
121
- expect(testContrast(child)).toBe(true);
121
+ expect(testContrast(child)).toBe(null);
122
122
  });
123
123
 
124
124
  it('should not skip elements with opaque background over ancestor background image', () => {
@@ -138,6 +138,43 @@ describe('testContrast', () => {
138
138
  expect(testContrast(child)).toBe(true);
139
139
  });
140
140
 
141
+ it('should return null for elements with CSS filter (cannot determine)', () => {
142
+ const div = document.createElement('div');
143
+ div.textContent = 'Filtered text';
144
+ div.style.color = 'rgb(0, 0, 0)';
145
+ div.style.backgroundColor = 'rgb(255, 255, 255)';
146
+ div.style.filter = 'brightness(0.5)';
147
+ document.body.appendChild(div);
148
+
149
+ expect(testContrast(div)).toBe(null);
150
+ });
151
+
152
+ it('should return null for elements with mix-blend-mode (cannot determine)', () => {
153
+ const div = document.createElement('div');
154
+ div.textContent = 'Blended text';
155
+ div.style.color = 'rgb(0, 0, 0)';
156
+ div.style.backgroundColor = 'rgb(255, 255, 255)';
157
+ div.style.mixBlendMode = 'multiply';
158
+ document.body.appendChild(div);
159
+
160
+ expect(testContrast(div)).toBe(null);
161
+ });
162
+
163
+ it('should return null when ancestor has CSS filter (cannot determine)', () => {
164
+ const parent = document.createElement('div');
165
+ parent.style.filter = 'invert(1)';
166
+
167
+ const child = document.createElement('div');
168
+ child.textContent = 'Child of filtered parent';
169
+ child.style.color = 'rgb(0, 0, 0)';
170
+ child.style.backgroundColor = 'rgb(255, 255, 255)';
171
+
172
+ parent.appendChild(child);
173
+ document.body.appendChild(parent);
174
+
175
+ expect(testContrast(child)).toBe(null);
176
+ });
177
+
141
178
  it('should handle invisible elements', () => {
142
179
  const div = document.createElement('div');
143
180
  div.textContent = 'Hidden text';
package/todo.md CHANGED
@@ -6,6 +6,7 @@ _No pending tasks_
6
6
 
7
7
  ## Completed
8
8
 
9
- - ✅ Test coverage improvements (87.69% line coverage, 82.11% branch coverage)
9
+ - ✅ Test coverage improvements (89.04% line coverage, 84.37% branch coverage)
10
10
  - ✅ Playwright integration for CSS pseudo-element tests
11
- - ✅ 963 total tests passing (943 JSDOM + 20 Playwright)
11
+ - ✅ 1121 total tests passing (1101 JSDOM + 20 Playwright)
12
+ - ✅ v2.2.0 release (issues #57, #62, #63, #64, #67, #68, #69, #70, #71, #72)