@afixt/test-utils 2.3.0 → 2.4.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.
@@ -18,7 +18,8 @@
18
18
  "Bash(node:*)",
19
19
  "Bash(npx vitest run:*)",
20
20
  "Bash(1)",
21
- "Bash(npm run test:playwright:css:*)"
21
+ "Bash(npm run test:playwright:css:*)",
22
+ "Bash(npm run:*)"
22
23
  ]
23
24
  },
24
25
  "enableAllProjectMcpServers": false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afixt/test-utils",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Various utilities for accessibility testing",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
package/src/cssUtils.js CHANGED
@@ -3,6 +3,53 @@
3
3
  * @module cssUtils
4
4
  */
5
5
 
6
+ const { hasCSSGeneratedContent } = require('./hasCSSGeneratedContent.js');
7
+
8
+ const ICON_FONT_CLASS_PREFIXES = ['icon-', 'i-'];
9
+ const ICON_FONT_CLASSES = [
10
+ 'fa',
11
+ 'fas',
12
+ 'far',
13
+ 'fal',
14
+ 'fad',
15
+ 'fab',
16
+ 'glyphicon',
17
+ 'material-icons',
18
+ 'material-symbols-outlined',
19
+ 'material-symbols-rounded',
20
+ 'dashicons',
21
+ 'bi',
22
+ 'octicon',
23
+ 'feather',
24
+ 'fi',
25
+ 'typcn',
26
+ 'wi',
27
+ 'zmdi',
28
+ 'icofont',
29
+ ];
30
+
31
+ /**
32
+ * Check if a single class name matches known icon font patterns.
33
+ * @param {string} cls - The class name to check
34
+ * @returns {boolean} True if the class matches an icon font pattern
35
+ */
36
+ function matchesIconFontPattern(cls) {
37
+ if (ICON_FONT_CLASSES.includes(cls)) {
38
+ return true;
39
+ }
40
+ for (const prefix of ICON_FONT_CLASS_PREFIXES) {
41
+ if (cls.startsWith(prefix)) {
42
+ return true;
43
+ }
44
+ }
45
+ for (const iconClass of ICON_FONT_CLASSES) {
46
+ if (cls.startsWith(iconClass + '-')) {
47
+ return true;
48
+ }
49
+ }
50
+ return false;
51
+ }
52
+
6
53
  const cssUtils = {
7
54
  /**
8
55
  * Check if an element's background is transparent/none.
@@ -29,12 +76,81 @@ const cssUtils = {
29
76
  return style.borderStyle === 'none' || parseFloat(style.borderWidth) === 0;
30
77
  },
31
78
 
79
+ /**
80
+ * Check if the element or any descendant has a class matching common icon font patterns.
81
+ * @param {HTMLElement} element - The element to check
82
+ * @returns {boolean} True if an icon font class is found
83
+ */
84
+ hasIconFontClass(element) {
85
+ if (!element) {
86
+ return false;
87
+ }
88
+
89
+ const elements = [element, ...element.querySelectorAll('*')];
90
+ for (const el of elements) {
91
+ if (el.classList) {
92
+ for (const cls of el.classList) {
93
+ if (matchesIconFontPattern(cls)) {
94
+ return true;
95
+ }
96
+ }
97
+ }
98
+ }
99
+ return false;
100
+ },
101
+
102
+ /**
103
+ * Check if the element has any visual indicator beyond text styling,
104
+ * such as CSS-generated content, icon font classes, background images on children,
105
+ * or inline img/svg elements.
106
+ * @param {HTMLElement} element - The element to check
107
+ * @returns {boolean} True if the element has a visual indicator
108
+ */
109
+ hasVisualIndicator(element) {
110
+ if (!element) {
111
+ return false;
112
+ }
113
+
114
+ // Check for CSS-generated content on the element itself
115
+ if (hasCSSGeneratedContent(element)) {
116
+ return true;
117
+ }
118
+
119
+ // Check for icon font classes on element or descendants
120
+ if (cssUtils.hasIconFontClass(element)) {
121
+ return true;
122
+ }
123
+
124
+ // Check descendants for background-image, <img>, or <svg>
125
+ const descendants = element.querySelectorAll('*');
126
+ for (const child of descendants) {
127
+ const tagName = child.tagName ? child.tagName.toLowerCase() : '';
128
+ if (tagName === 'img' || tagName === 'svg') {
129
+ return true;
130
+ }
131
+ try {
132
+ const childStyle = window.getComputedStyle(child);
133
+ if (childStyle.backgroundImage && childStyle.backgroundImage !== 'none') {
134
+ return true;
135
+ }
136
+ } catch {
137
+ // skip if getComputedStyle fails
138
+ }
139
+ }
140
+
141
+ return false;
142
+ },
143
+
32
144
  /**
33
145
  * Check if an element looks like regular text (no underline, background, or border).
34
146
  * @param {HTMLElement} element - The element to check
35
147
  * @returns {boolean} True if element looks like regular text
36
148
  */
37
149
  looksLikeText(element) {
150
+ if (cssUtils.hasVisualIndicator(element)) {
151
+ return false;
152
+ }
153
+
38
154
  const style = window.getComputedStyle(element);
39
155
 
40
156
  const noUnderline = !style.textDecoration.toLowerCase().includes('underline');
@@ -245,4 +245,256 @@ describe('cssUtils', () => {
245
245
  vi.restoreAllMocks();
246
246
  });
247
247
  });
248
+
249
+ describe('hasIconFontClass', () => {
250
+ it('should return false for element with no classes', () => {
251
+ const el = document.createElement('span');
252
+ document.body.appendChild(el);
253
+ expect(cssUtils.hasIconFontClass(el)).toBe(false);
254
+ });
255
+
256
+ it('should return true for element with "fa" class', () => {
257
+ const el = document.createElement('i');
258
+ el.className = 'fa';
259
+ document.body.appendChild(el);
260
+ expect(cssUtils.hasIconFontClass(el)).toBe(true);
261
+ });
262
+
263
+ it('should return true for element with "fa-circle" class (hyphenated prefix)', () => {
264
+ const el = document.createElement('i');
265
+ el.className = 'fa-circle';
266
+ document.body.appendChild(el);
267
+ expect(cssUtils.hasIconFontClass(el)).toBe(true);
268
+ });
269
+
270
+ it('should return true for child element with "material-icons" class', () => {
271
+ const el = document.createElement('button');
272
+ const child = document.createElement('span');
273
+ child.className = 'material-icons';
274
+ el.appendChild(child);
275
+ document.body.appendChild(el);
276
+ expect(cssUtils.hasIconFontClass(el)).toBe(true);
277
+ });
278
+
279
+ it('should return true for child with "icon-" prefixed class', () => {
280
+ const el = document.createElement('a');
281
+ const child = document.createElement('span');
282
+ child.className = 'icon-home';
283
+ el.appendChild(child);
284
+ document.body.appendChild(el);
285
+ expect(cssUtils.hasIconFontClass(el)).toBe(true);
286
+ });
287
+
288
+ it('should return false for element with unrelated class', () => {
289
+ const el = document.createElement('div');
290
+ el.className = 'container main-wrapper';
291
+ document.body.appendChild(el);
292
+ expect(cssUtils.hasIconFontClass(el)).toBe(false);
293
+ });
294
+ });
295
+
296
+ describe('hasVisualIndicator', () => {
297
+ it('should return false for plain text element', () => {
298
+ const el = document.createElement('span');
299
+ el.textContent = 'Click here';
300
+ document.body.appendChild(el);
301
+
302
+ vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({
303
+ getPropertyValue: () => 'none',
304
+ }));
305
+
306
+ expect(cssUtils.hasVisualIndicator(el)).toBe(false);
307
+ vi.restoreAllMocks();
308
+ });
309
+
310
+ it('should return true when element has CSS-generated content', () => {
311
+ const el = document.createElement('span');
312
+ document.body.appendChild(el);
313
+
314
+ vi.spyOn(window, 'getComputedStyle').mockImplementation((_el, pseudo) => {
315
+ if (pseudo === '::before') {
316
+ return { getPropertyValue: () => '"\\f00c"' };
317
+ }
318
+ return { getPropertyValue: () => 'none' };
319
+ });
320
+
321
+ expect(cssUtils.hasVisualIndicator(el)).toBe(true);
322
+ vi.restoreAllMocks();
323
+ });
324
+
325
+ it('should return true when child has icon font class', () => {
326
+ const el = document.createElement('button');
327
+ const icon = document.createElement('i');
328
+ icon.className = 'fas fa-check';
329
+ el.appendChild(icon);
330
+ document.body.appendChild(el);
331
+
332
+ vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({
333
+ getPropertyValue: () => 'none',
334
+ backgroundImage: 'none',
335
+ }));
336
+
337
+ expect(cssUtils.hasVisualIndicator(el)).toBe(true);
338
+ vi.restoreAllMocks();
339
+ });
340
+
341
+ it('should return true when child has background-image', () => {
342
+ const el = document.createElement('a');
343
+ const child = document.createElement('span');
344
+ el.appendChild(child);
345
+ document.body.appendChild(el);
346
+
347
+ vi.spyOn(window, 'getComputedStyle').mockImplementation((_el, pseudo) => {
348
+ if (pseudo) {
349
+ return { getPropertyValue: () => 'none' };
350
+ }
351
+ if (_el === child) {
352
+ return {
353
+ getPropertyValue: () => 'none',
354
+ backgroundImage: 'url(icon.png)',
355
+ };
356
+ }
357
+ return {
358
+ getPropertyValue: () => 'none',
359
+ backgroundImage: 'none',
360
+ };
361
+ });
362
+
363
+ expect(cssUtils.hasVisualIndicator(el)).toBe(true);
364
+ vi.restoreAllMocks();
365
+ });
366
+
367
+ it('should return true when child is an <img> element', () => {
368
+ const el = document.createElement('a');
369
+ const img = document.createElement('img');
370
+ img.src = 'icon.png';
371
+ el.appendChild(img);
372
+ document.body.appendChild(el);
373
+
374
+ vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({
375
+ getPropertyValue: () => 'none',
376
+ backgroundImage: 'none',
377
+ }));
378
+
379
+ expect(cssUtils.hasVisualIndicator(el)).toBe(true);
380
+ vi.restoreAllMocks();
381
+ });
382
+
383
+ it('should return true when child is an <svg> element', () => {
384
+ const el = document.createElement('button');
385
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
386
+ el.appendChild(svg);
387
+ document.body.appendChild(el);
388
+
389
+ vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({
390
+ getPropertyValue: () => 'none',
391
+ backgroundImage: 'none',
392
+ }));
393
+
394
+ expect(cssUtils.hasVisualIndicator(el)).toBe(true);
395
+ vi.restoreAllMocks();
396
+ });
397
+ });
398
+
399
+ describe('looksLikeText - visual indicator integration', () => {
400
+ it('should return false for element with Font Awesome icon child', () => {
401
+ const el = document.createElement('a');
402
+ const icon = document.createElement('i');
403
+ icon.className = 'fas fa-arrow-right';
404
+ el.appendChild(icon);
405
+ el.appendChild(document.createTextNode(' Next'));
406
+ document.body.appendChild(el);
407
+
408
+ vi.spyOn(window, 'getComputedStyle').mockImplementation((_el, pseudo) => {
409
+ if (pseudo) {
410
+ return { getPropertyValue: () => 'none' };
411
+ }
412
+ return {
413
+ textDecoration: 'none',
414
+ backgroundColor: 'rgba(0, 0, 0, 0)',
415
+ backgroundImage: 'none',
416
+ borderStyle: 'none',
417
+ borderWidth: '0px',
418
+ getPropertyValue: () => 'none',
419
+ };
420
+ });
421
+
422
+ expect(cssUtils.looksLikeText(el)).toBe(false);
423
+ vi.restoreAllMocks();
424
+ });
425
+
426
+ it('should return false for element with CSS pseudo-element content', () => {
427
+ const el = document.createElement('a');
428
+ el.textContent = 'Submit';
429
+ document.body.appendChild(el);
430
+
431
+ vi.spyOn(window, 'getComputedStyle').mockImplementation((_el, pseudo) => {
432
+ if (pseudo === '::before') {
433
+ return { getPropertyValue: () => '"\\f00c"' };
434
+ }
435
+ if (pseudo === '::after') {
436
+ return { getPropertyValue: () => 'none' };
437
+ }
438
+ return {
439
+ textDecoration: 'none',
440
+ backgroundColor: 'rgba(0, 0, 0, 0)',
441
+ backgroundImage: 'none',
442
+ borderStyle: 'none',
443
+ borderWidth: '0px',
444
+ getPropertyValue: () => 'none',
445
+ };
446
+ });
447
+
448
+ expect(cssUtils.looksLikeText(el)).toBe(false);
449
+ vi.restoreAllMocks();
450
+ });
451
+
452
+ it('should return false for element with inline <svg> icon child', () => {
453
+ const el = document.createElement('button');
454
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
455
+ el.appendChild(svg);
456
+ el.appendChild(document.createTextNode('Save'));
457
+ document.body.appendChild(el);
458
+
459
+ vi.spyOn(window, 'getComputedStyle').mockImplementation((_el, pseudo) => {
460
+ if (pseudo) {
461
+ return { getPropertyValue: () => 'none' };
462
+ }
463
+ return {
464
+ textDecoration: 'none',
465
+ backgroundColor: 'rgba(0, 0, 0, 0)',
466
+ backgroundImage: 'none',
467
+ borderStyle: 'none',
468
+ borderWidth: '0px',
469
+ getPropertyValue: () => 'none',
470
+ };
471
+ });
472
+
473
+ expect(cssUtils.looksLikeText(el)).toBe(false);
474
+ vi.restoreAllMocks();
475
+ });
476
+
477
+ it('should still return true for plain text element with no visual indicators', () => {
478
+ const el = document.createElement('span');
479
+ el.textContent = 'Just text';
480
+ document.body.appendChild(el);
481
+
482
+ vi.spyOn(window, 'getComputedStyle').mockImplementation((_el, pseudo) => {
483
+ if (pseudo) {
484
+ return { getPropertyValue: () => 'none' };
485
+ }
486
+ return {
487
+ textDecoration: 'none',
488
+ backgroundColor: 'rgba(0, 0, 0, 0)',
489
+ backgroundImage: 'none',
490
+ borderStyle: 'none',
491
+ borderWidth: '0px',
492
+ getPropertyValue: () => 'none',
493
+ };
494
+ });
495
+
496
+ expect(cssUtils.looksLikeText(el)).toBe(true);
497
+ vi.restoreAllMocks();
498
+ });
499
+ });
248
500
  });