@afixt/test-utils 1.3.0 → 2.0.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.
@@ -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;
@@ -224,6 +174,40 @@ const stringUtils = (function () {
224
174
  return (element.textContent || '').trim();
225
175
  }
226
176
 
177
+ /**
178
+ * Checks if an accessible name contains the visible text as a whole-word phrase.
179
+ * Prevents false positives where aria-label is a superset of visible text
180
+ * (e.g. "Report a Concern Opens in new window" contains "Report a Concern").
181
+ * Uses word-boundary matching to avoid partial matches
182
+ * (e.g. "Homepage" does NOT contain "Home" as a whole word).
183
+ * @param {string} accessibleName - The accessible name (e.g. aria-label value)
184
+ * @param {string} visibleText - The visible text content of the element
185
+ * @returns {boolean} True if the accessible name contains the visible text at word boundaries
186
+ */
187
+ function containsVisibleText(accessibleName, visibleText) {
188
+ if (!accessibleName || !visibleText) {
189
+ return false;
190
+ }
191
+ const haystack = accessibleName.toLowerCase().replace(/\s+/g, ' ').trim();
192
+ const needle = visibleText.toLowerCase().replace(/\s+/g, ' ').trim();
193
+ if (!needle) {
194
+ return false;
195
+ }
196
+ const index = haystack.indexOf(needle);
197
+ if (index === -1) {
198
+ return false;
199
+ }
200
+ // Check word boundaries
201
+ if (index > 0 && /\w/.test(haystack.charAt(index - 1))) {
202
+ return false;
203
+ }
204
+ const endIndex = index + needle.length;
205
+ if (endIndex < haystack.length && /\w/.test(haystack.charAt(endIndex))) {
206
+ return false;
207
+ }
208
+ return true;
209
+ }
210
+
227
211
  /**
228
212
  * Checks if text contains a warning about new window/tab behavior.
229
213
  * @param {string} text - The text to check
@@ -248,33 +232,6 @@ const stringUtils = (function () {
248
232
  });
249
233
  }
250
234
 
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
235
  return {
279
236
  isEmpty,
280
237
  isString,
@@ -283,13 +240,12 @@ const stringUtils = (function () {
283
240
  isUpperCase,
284
241
  isAlphaNumeric,
285
242
  getPathFromUrl,
286
- getAllText,
287
243
  hasText,
288
- textIncludingImgAlt,
289
244
  isEmptyOrWhitespace,
290
245
  isGenericTitle,
291
246
  isGenericLinkText,
292
247
  getActualVisibleText,
248
+ containsVisibleText,
293
249
  hasNewWindowWarning,
294
250
  };
295
251
  })();
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