@afixt/test-utils 1.2.3 → 2.0.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.
Files changed (39) hide show
  1. package/.claude/settings.local.json +1 -6
  2. package/BROWSER_TESTING.md +42 -22
  3. package/CHANGELOG.md +40 -0
  4. package/CLAUDE.md +10 -9
  5. package/package.json +1 -1
  6. package/src/constants.js +438 -1
  7. package/src/domUtils.js +17 -38
  8. package/src/formUtils.js +7 -24
  9. package/src/getAccessibleName.js +20 -56
  10. package/src/getCSSGeneratedContent.js +2 -0
  11. package/src/getFocusableElements.js +12 -21
  12. package/src/getGeneratedContent.js +18 -11
  13. package/src/getImageText.js +22 -7
  14. package/src/hasValidAriaRole.js +11 -19
  15. package/src/index.js +4 -4
  16. package/src/interactiveRoles.js +2 -19
  17. package/src/isA11yVisible.js +95 -0
  18. package/src/isAriaAttributesValid.js +5 -64
  19. package/src/isFocusable.js +30 -10
  20. package/src/isHidden.js +44 -8
  21. package/src/listEventListeners.js +115 -10
  22. package/src/stringUtils.js +19 -98
  23. package/src/tableUtils.js +4 -36
  24. package/src/testContrast.js +54 -0
  25. package/test/domUtils.test.js +156 -0
  26. package/test/formUtils.test.js +0 -47
  27. package/test/getAccessibleName.test.js +39 -0
  28. package/test/getGeneratedContent.test.js +305 -241
  29. package/test/getImageText.test.js +158 -99
  30. package/test/index.test.js +54 -17
  31. package/test/{isVisible.test.js → isA11yVisible.test.js} +39 -33
  32. package/test/isFocusable.test.js +265 -272
  33. package/test/isHidden.test.js +257 -153
  34. package/test/listEventListeners.test.js +163 -44
  35. package/test/playwright/css-pseudo-elements.spec.js +3 -13
  36. package/test/stringUtils.test.js +55 -228
  37. package/test/testContrast.test.js +104 -2
  38. package/todo.md +2 -2
  39. package/src/isVisible.js +0 -103
@@ -1,21 +1,29 @@
1
+ const isHidden = require('./isHidden.js');
2
+ const hasHiddenParent = require('./hasHiddenParent.js');
3
+
1
4
  /**
2
- * Determines if an element is focusable.
5
+ * Determines if an element is focusable via user interaction (tab order).
6
+ * Elements with tabindex="-1" are programmatically focusable via .focus()
7
+ * but are NOT considered focusable by this function, as they are not
8
+ * reachable through keyboard navigation.
3
9
  * @param {Element} element - The HTML element to check.
4
10
  * @returns {boolean} - Returns true if the element is focusable, otherwise false.
5
11
  */
6
12
  function isFocusable(element) {
7
- if (!element) return false;
13
+ if (!element) {
14
+ return false;
15
+ }
8
16
 
9
17
  const nodeName = element.nodeName.toLowerCase();
10
- const tabIndex = element.getAttribute("tabindex");
18
+ const tabIndex = element.getAttribute('tabindex');
11
19
 
12
20
  // The element and all of its ancestors must be visible
13
- if (element.closest(":hidden")) {
21
+ if (isHidden(element) || hasHiddenParent(element)) {
14
22
  return false;
15
23
  }
16
24
 
17
- // If tabindex is defined, its value must be greater than or equal to 0
18
- if (!isNaN(tabIndex) && tabIndex < 0) {
25
+ // If tabindex is defined, its value must be >= 0
26
+ if (tabIndex !== null && !isNaN(tabIndex) && parseInt(tabIndex, 10) < 0) {
19
27
  return false;
20
28
  }
21
29
 
@@ -25,14 +33,26 @@ function isFocusable(element) {
25
33
  }
26
34
 
27
35
  // If the element is a link, href must be defined
28
- if (nodeName === "a" || nodeName === "area") {
29
- return element.hasAttribute("href");
36
+ if (nodeName === 'a' || nodeName === 'area') {
37
+ return element.hasAttribute('href');
38
+ }
39
+
40
+ // contenteditable elements are focusable
41
+ if (
42
+ element.getAttribute('contenteditable') === 'true' ||
43
+ element.getAttribute('contenteditable') === ''
44
+ ) {
45
+ return true;
46
+ }
47
+
48
+ // Any element with a non-negative tabindex is focusable
49
+ if (tabIndex !== null && !isNaN(tabIndex) && parseInt(tabIndex, 10) >= 0) {
50
+ return true;
30
51
  }
31
52
 
32
- // This is some other page element that is not normally focusable
33
53
  return false;
34
54
  }
35
55
 
36
56
  module.exports = {
37
- isFocusable
57
+ isFocusable,
38
58
  };
package/src/isHidden.js CHANGED
@@ -1,17 +1,53 @@
1
-
2
1
  /**
3
2
  * Checks if a given DOM element is hidden.
4
3
  *
5
- * An element is considered hidden if its `display` style is set to "none"
6
- * or if it has the `hidden` attribute.
4
+ * By default checks: computed display:none, visibility:hidden, and the hidden attribute.
5
+ * Additional checks can be enabled via the options parameter.
7
6
  *
8
7
  * @param {HTMLElement} element - The DOM element to check.
9
- * @returns {boolean} - Returns `true` if the element is hidden, otherwise `false`.
8
+ * @param {Object} [options={}] - Optional configuration.
9
+ * @param {boolean} [options.checkAriaHidden=false] - Also check aria-hidden="true".
10
+ * @param {boolean} [options.checkOpacity=false] - Also treat opacity:0 as hidden.
11
+ * @param {boolean} [options.checkDimensions=false] - Also treat zero width+height as hidden.
12
+ * @returns {boolean} True if the element is hidden.
10
13
  */
11
- const isHidden = (element) => {
12
- return (
13
- element.style.display === "none" || element.hasAttribute("hidden")
14
- );
14
+ const isHidden = (element, options = {}) => {
15
+ if (!element || !(element instanceof Element)) {
16
+ return false;
17
+ }
18
+
19
+ const { checkAriaHidden = false, checkOpacity = false, checkDimensions = false } = options;
20
+
21
+ // Check the hidden attribute
22
+ if (element.hasAttribute('hidden')) {
23
+ return true;
24
+ }
25
+
26
+ // Use computed style to catch CSS class/stylesheet rules
27
+ const style = window.getComputedStyle(element);
28
+
29
+ if (style.display === 'none') {
30
+ return true;
31
+ }
32
+
33
+ if (style.visibility === 'hidden') {
34
+ return true;
35
+ }
36
+
37
+ // Optional checks
38
+ if (checkAriaHidden && element.getAttribute('aria-hidden') === 'true') {
39
+ return true;
40
+ }
41
+
42
+ if (checkOpacity && style.opacity === '0') {
43
+ return true;
44
+ }
45
+
46
+ if (checkDimensions && element.offsetWidth === 0 && element.offsetHeight === 0) {
47
+ return true;
48
+ }
49
+
50
+ return false;
15
51
  };
16
52
 
17
53
  module.exports = isHidden;
@@ -5,6 +5,33 @@ const eventListenersMap = new WeakMap();
5
5
  const originalAddEventListener = EventTarget.prototype.addEventListener;
6
6
  const originalRemoveEventListener = EventTarget.prototype.removeEventListener;
7
7
 
8
+ const KNOWN_EVENT_PROPERTIES = [
9
+ 'onclick',
10
+ 'ondblclick',
11
+ 'oncontextmenu',
12
+ 'onmousedown',
13
+ 'onmouseup',
14
+ 'onmouseover',
15
+ 'onmouseout',
16
+ 'onmouseenter',
17
+ 'onmouseleave',
18
+ 'onkeydown',
19
+ 'onkeyup',
20
+ 'onkeypress',
21
+ 'onfocus',
22
+ 'onblur',
23
+ 'onchange',
24
+ 'oninput',
25
+ 'onsubmit',
26
+ 'ontouchstart',
27
+ 'ontouchend',
28
+ 'ontouchmove',
29
+ 'onscroll',
30
+ 'onresize',
31
+ 'onload',
32
+ 'onerror',
33
+ ];
34
+
8
35
  // Override addEventListener to track event listeners
9
36
  EventTarget.prototype.addEventListener = function (type, listener, options) {
10
37
  if (!eventListenersMap.has(this)) {
@@ -25,14 +52,14 @@ EventTarget.prototype.removeEventListener = function (type, listener, options) {
25
52
  if (eventListenersMap.has(this)) {
26
53
  const listeners = eventListenersMap.get(this);
27
54
  if (listeners[type]) {
28
- listeners[type] = listeners[type].filter((l) => l.listener !== listener);
55
+ listeners[type] = listeners[type].filter(l => l.listener !== listener);
29
56
  }
30
57
  }
31
58
  return originalRemoveEventListener.call(this, type, listener, options);
32
59
  };
33
60
 
34
61
  // Function to get XPath of an element
35
- const getXPath = (element) => {
62
+ const getXPath = element => {
36
63
  if (element.id) {
37
64
  return `//*[@id="${element.id}"]`;
38
65
  }
@@ -52,9 +79,84 @@ const getXPath = (element) => {
52
79
  return path;
53
80
  };
54
81
 
55
- // Function to get all event listeners on an element
56
- const getEventListeners = (element) => {
57
- return eventListenersMap.get(element) || {};
82
+ /**
83
+ * Collect inline attribute event handlers from an element
84
+ * @param {Element} element - The element to inspect
85
+ * @returns {Object} Map of event names to listener entry arrays
86
+ */
87
+ const getAttributeHandlers = element => {
88
+ const handlers = {};
89
+ if (!element || !element.attributes) {
90
+ return handlers;
91
+ }
92
+ for (let i = 0; i < element.attributes.length; i++) {
93
+ const attr = element.attributes[i];
94
+ if (attr.name.startsWith('on')) {
95
+ const eventName = attr.name.slice(2);
96
+ handlers[eventName] = [{ listener: attr.value, bindingType: 'attribute' }];
97
+ }
98
+ }
99
+ return handlers;
100
+ };
101
+
102
+ /**
103
+ * Collect property-based event handlers from an element
104
+ * @param {Element} element - The element to inspect
105
+ * @param {Object} attributeHandlers - Already-detected attribute handlers to avoid duplicates
106
+ * @returns {Object} Map of event names to listener entry arrays
107
+ */
108
+ const getPropertyHandlers = (element, attributeHandlers) => {
109
+ const handlers = {};
110
+ if (!element) {
111
+ return handlers;
112
+ }
113
+ for (const prop of KNOWN_EVENT_PROPERTIES) {
114
+ if (typeof element[prop] === 'function') {
115
+ const eventName = prop.slice(2);
116
+ if (!attributeHandlers[eventName]) {
117
+ handlers[eventName] = [{ listener: element[prop], bindingType: 'property' }];
118
+ }
119
+ }
120
+ }
121
+ return handlers;
122
+ };
123
+
124
+ /**
125
+ * Get all event listeners on an element, including addEventListener, attribute, and property handlers
126
+ * @param {Element} element - The element to inspect
127
+ * @returns {Object} Map of event names to listener entry arrays
128
+ */
129
+ const getEventListeners = element => {
130
+ const tracked = eventListenersMap.get(element) || {};
131
+
132
+ // Add bindingType to tracked entries
133
+ const result = {};
134
+ for (const eventName of Object.keys(tracked)) {
135
+ result[eventName] = tracked[eventName].map(entry => ({
136
+ ...entry,
137
+ bindingType: 'addEventListener',
138
+ }));
139
+ }
140
+
141
+ // Merge attribute handlers
142
+ const attrHandlers = getAttributeHandlers(element);
143
+ for (const eventName of Object.keys(attrHandlers)) {
144
+ if (!result[eventName]) {
145
+ result[eventName] = [];
146
+ }
147
+ result[eventName].push(...attrHandlers[eventName]);
148
+ }
149
+
150
+ // Merge property handlers (excluding those already found as attributes)
151
+ const propHandlers = getPropertyHandlers(element, attrHandlers);
152
+ for (const eventName of Object.keys(propHandlers)) {
153
+ if (!result[eventName]) {
154
+ result[eventName] = [];
155
+ }
156
+ result[eventName].push(...propHandlers[eventName]);
157
+ }
158
+
159
+ return result;
58
160
  };
59
161
 
60
162
  /**
@@ -72,11 +174,14 @@ const listEventListeners = (rootElement = document) => {
72
174
  function processElement(el) {
73
175
  const listeners = getEventListeners(el);
74
176
  if (Object.keys(listeners).length > 0) {
75
- Object.keys(listeners).forEach((eventName) => {
76
- eventListeners.push({
77
- element: el.tagName.toLowerCase(),
78
- xpath: getXPath(el),
79
- event: eventName
177
+ Object.keys(listeners).forEach(eventName => {
178
+ listeners[eventName].forEach(entry => {
179
+ eventListeners.push({
180
+ element: el.tagName.toLowerCase(),
181
+ xpath: getXPath(el),
182
+ event: eventName,
183
+ bindingType: entry.bindingType,
184
+ });
80
185
  });
81
186
  });
82
187
  }
@@ -1,3 +1,5 @@
1
+ const { GENERIC_TITLES, GENERIC_LINK_TEXT } = require('./constants.js');
2
+
1
3
  const stringUtils = (function () {
2
4
  /**
3
5
  * Check if a string is empty or only contains whitespace.
@@ -83,45 +85,6 @@ const stringUtils = (function () {
83
85
  return str.pathname;
84
86
  }
85
87
 
86
- /**
87
- * Extracts and concatenates all text content from a given DOM element, including text from text nodes,
88
- * elements with aria-label attributes, and alt attributes of img elements.
89
- *
90
- * @param {Element} el - The DOM element from which to extract text.
91
- * @returns {string} A string containing all concatenated text content from the element.
92
- */
93
- function getAllText(el) {
94
- // Check for form element value (input, textarea, select)
95
- if (el.value !== undefined && el.value !== '') {
96
- return el.value;
97
- }
98
-
99
- const walker = document.createTreeWalker(el, NodeFilter.SHOW_ALL, null, false);
100
- const textNodes = [];
101
- let node;
102
- let text;
103
-
104
- while (walker.nextNode()) {
105
- node = walker.currentNode;
106
- if (node.nodeType === Node.TEXT_NODE) {
107
- text = node.nodeValue.trim();
108
- if (text) {
109
- textNodes.push(text);
110
- } else {
111
- textNodes.push(node.textContent.trim());
112
- }
113
- } else if (node.nodeType === Node.ELEMENT_NODE) {
114
- if (node.hasAttribute('aria-label')) {
115
- textNodes.push(node.getAttribute('aria-label'));
116
- } else if (node.tagName === 'IMG' && node.hasAttribute('alt')) {
117
- textNodes.push(node.getAttribute('alt'));
118
- }
119
- }
120
- }
121
-
122
- return textNodes.join(' ');
123
- }
124
-
125
88
  /**
126
89
  * Checks if the given element contains any text.
127
90
  *
@@ -129,7 +92,16 @@ const stringUtils = (function () {
129
92
  * @returns {boolean} True if the element contains text, false otherwise.
130
93
  */
131
94
  function hasText(element) {
132
- return getAllText(element).trim() !== '';
95
+ if (!element) {
96
+ return false;
97
+ }
98
+ // Check form element value (input, textarea, select)
99
+ if (element.value !== undefined && element.value !== '') {
100
+ return true;
101
+ }
102
+ // Lazy require to avoid circular dependency (getAccessibleText requires stringUtils)
103
+ const { getAccessibleText } = require('./getAccessibleText.js');
104
+ return getAccessibleText(element).trim() !== '';
133
105
  }
134
106
 
135
107
  /**
@@ -156,14 +128,17 @@ const stringUtils = (function () {
156
128
  if (!title) {
157
129
  return false;
158
130
  }
159
- const genericTitles = ['iframe', 'frame', 'untitled', 'title', 'content', 'main', 'page'];
160
131
  const normalized = title.toLowerCase().trim();
161
132
 
162
- if (genericTitles.includes(normalized)) {
133
+ if (GENERIC_TITLES.includes(normalized)) {
163
134
  return true;
164
135
  }
165
136
 
166
- if (/^(frame|iframe|untitled|title)\d*$/i.test(normalized)) {
137
+ if (
138
+ /^(frame|iframe|untitled|title|page|content|section|document|tab|slide|sheet|panel|window|screen|view|module|widget|region|form)\s?\d+$/i.test(
139
+ normalized
140
+ )
141
+ ) {
167
142
  return true;
168
143
  }
169
144
 
@@ -180,32 +155,7 @@ const stringUtils = (function () {
180
155
  if (!text) {
181
156
  return false;
182
157
  }
183
- const defaults = [
184
- 'click here',
185
- 'here',
186
- 'more',
187
- 'read more',
188
- 'learn more',
189
- 'click',
190
- 'link',
191
- 'this',
192
- 'page',
193
- 'article',
194
- 'continue',
195
- 'go',
196
- 'see more',
197
- 'view',
198
- 'download',
199
- 'pdf',
200
- 'document',
201
- 'form',
202
- 'submit',
203
- 'button',
204
- 'press',
205
- 'select',
206
- 'choose',
207
- ];
208
- const list = genericList || defaults;
158
+ const list = genericList || GENERIC_LINK_TEXT;
209
159
  const lowerText = text.toLowerCase().trim();
210
160
  return list.some(function (generic) {
211
161
  return lowerText === generic;
@@ -248,33 +198,6 @@ const stringUtils = (function () {
248
198
  });
249
199
  }
250
200
 
251
- /**
252
- * Extracts DOM text content from an element, including img alt text,
253
- * but excluding ARIA attributes (aria-label, aria-labelledby).
254
- * Useful for detecting actual visible/DOM text that may conflict with ARIA labels.
255
- *
256
- * @param {Element} root - The DOM element from which to extract text.
257
- * @returns {string} Concatenated text from text nodes and img alt attributes.
258
- */
259
- function textIncludingImgAlt(root) {
260
- let out = '';
261
- const walker = document.createTreeWalker(
262
- root,
263
- NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT // eslint-disable-line no-bitwise
264
- );
265
- let n = walker.currentNode;
266
- while (n) {
267
- if (n.nodeType === Node.TEXT_NODE) {
268
- out += n.nodeValue;
269
- }
270
- if (n.nodeType === Node.ELEMENT_NODE && n.tagName === 'IMG') {
271
- out += n.getAttribute('alt') || '';
272
- }
273
- n = walker.nextNode();
274
- }
275
- return out;
276
- }
277
-
278
201
  return {
279
202
  isEmpty,
280
203
  isString,
@@ -283,9 +206,7 @@ const stringUtils = (function () {
283
206
  isUpperCase,
284
207
  isAlphaNumeric,
285
208
  getPathFromUrl,
286
- getAllText,
287
209
  hasText,
288
- textIncludingImgAlt,
289
210
  isEmptyOrWhitespace,
290
211
  isGenericTitle,
291
212
  isGenericLinkText,
package/src/tableUtils.js CHANGED
@@ -3,6 +3,8 @@
3
3
  * @module tableUtils
4
4
  */
5
5
 
6
+ const { GENERIC_SUMMARIES } = require('./constants.js');
7
+
6
8
  const tableUtils = {
7
9
  /**
8
10
  * Check if table has multiple header rows (rows containing th elements).
@@ -137,43 +139,9 @@ const tableUtils = {
137
139
  * @returns {boolean} True if the summary is generic
138
140
  */
139
141
  isGenericSummary(summary, genericList) {
140
- const GENERIC_SUMMARIES = genericList || [
141
- 'table',
142
- 'data',
143
- 'data table',
144
- 'information',
145
- 'content',
146
- 'main content',
147
- 'layout',
148
- 'layout table',
149
- 'for layout',
150
- 'table for layout purposes',
151
- 'structural table',
152
- 'this table is used for page layout',
153
- 'header',
154
- 'footer',
155
- 'navigation',
156
- 'nav',
157
- 'body',
158
- 'combobox',
159
- 'links design table',
160
- 'title and navigation',
161
- 'main heading',
162
- 'spacer',
163
- 'spacer table',
164
- 'menu',
165
- 'n/a',
166
- 'na',
167
- 'none',
168
- 'null',
169
- 'empty',
170
- 'blank',
171
- 'undefined',
172
- 'table summary',
173
- 'summary',
174
- ];
142
+ const list = genericList || GENERIC_SUMMARIES;
175
143
  const normalized = summary.toLowerCase().trim();
176
- return GENERIC_SUMMARIES.includes(normalized);
144
+ return list.includes(normalized);
177
145
  },
178
146
  };
179
147
 
@@ -1,3 +1,50 @@
1
+ /**
2
+ * Check if an element or any of its ancestors has a background image set.
3
+ * Walks up the DOM tree until it finds a background image or an opaque background color.
4
+ * @param {Element} el - The element to check
5
+ * @returns {boolean} True if the element or a visible ancestor has a background image
6
+ */
7
+ function hasBackgroundImage(el) {
8
+ if (!el || el.nodeType === 9 || !window.getComputedStyle) {
9
+ return false;
10
+ }
11
+
12
+ let current = el;
13
+ while (current && current.nodeType !== 9) {
14
+ const styles = window.getComputedStyle(current);
15
+ if (!styles) {
16
+ return false;
17
+ }
18
+
19
+ const bgImage = styles.getPropertyValue('background-image');
20
+ if (bgImage && bgImage !== 'none') {
21
+ return true;
22
+ }
23
+
24
+ // If this element has an opaque background color, stop traversal
25
+ // since no ancestor background image would be visible through it
26
+ const bgColor = styles.getPropertyValue('background-color');
27
+ const parsed = parseRGB(bgColor);
28
+ if (parsed) {
29
+ const isTransparent =
30
+ bgColor === 'rgba(0, 0, 0, 0)' ||
31
+ bgColor === 'transparent' ||
32
+ (parsed[4] !== undefined && parseFloat(parsed[4]) === 0);
33
+ const isSemiTransparent =
34
+ parsed[4] !== undefined && parseFloat(parsed[4]) > 0 && parseFloat(parsed[4]) < 1;
35
+
36
+ if (!isTransparent && !isSemiTransparent) {
37
+ // Opaque background color, no ancestor background image visible
38
+ return false;
39
+ }
40
+ }
41
+
42
+ current = current.parentElement;
43
+ }
44
+
45
+ return false;
46
+ }
47
+
1
48
  /**
2
49
  * Get the computed background color for an element.
3
50
  * @param {Element} el - the element to be tested
@@ -240,6 +287,12 @@ function testContrast(el, options = { level: 'AA' }) {
240
287
  return false;
241
288
  }
242
289
 
290
+ // Skip elements with a background image on the element itself or a visible ancestor.
291
+ // Contrast cannot be reliably tested against background images.
292
+ if (hasBackgroundImage(el)) {
293
+ return true;
294
+ }
295
+
243
296
  const styles = window.getComputedStyle(el);
244
297
  const selfFG = styles.getPropertyValue('color');
245
298
  const selfBG = getComputedBackgroundColor(el);
@@ -346,6 +399,7 @@ function testContrast(el, options = { level: 'AA' }) {
346
399
 
347
400
  module.exports = {
348
401
  testContrast,
402
+ hasBackgroundImage,
349
403
  getComputedBackgroundColor,
350
404
  luminance,
351
405
  parseRGB,