@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/src/domUtils.js CHANGED
@@ -18,10 +18,8 @@ const domUtils = {
18
18
  * @returns {Array} - An array of elements that have at least one attribute starting with the specified prefix.
19
19
  */
20
20
  attrBegins(elements, prefix) {
21
- return Array.from(elements).filter((element) => {
22
- return Array.from(element.attributes).some((attr) =>
23
- attr.name.startsWith(prefix)
24
- );
21
+ return Array.from(elements).filter(element => {
22
+ return Array.from(element.attributes).some(attr => attr.name.startsWith(prefix));
25
23
  });
26
24
  },
27
25
 
@@ -43,7 +41,9 @@ const domUtils = {
43
41
  * @returns {Object} An object containing the element's attributes as key-value pairs.
44
42
  */
45
43
  getAttributes(element) {
46
- if (!element) return {};
44
+ if (!element) {
45
+ return {};
46
+ }
47
47
  return [...element.attributes].reduce((attrs, attr) => {
48
48
  attrs[attr.name] = attr.value;
49
49
  return attrs;
@@ -80,7 +80,7 @@ const domUtils = {
80
80
  * @returns {number} The length of the document's HTML content without whitespace.
81
81
  */
82
82
  getDocumentSize() {
83
- return document.documentElement.outerHTML.replace(/\s+/g, "").length;
83
+ return document.documentElement.outerHTML.replace(/\s+/g, '').length;
84
84
  },
85
85
 
86
86
  /**
@@ -89,17 +89,17 @@ const domUtils = {
89
89
  * @returns {Element[]} An array of elements that have duplicate IDs.
90
90
  */
91
91
  getElementsWithDuplicateIds() {
92
- const nodes = document.querySelectorAll("[id]");
92
+ const nodes = document.querySelectorAll('[id]');
93
93
  const ids = {};
94
94
  const duplicates = [];
95
- nodes.forEach((node) => {
95
+ nodes.forEach(node => {
96
96
  const id = node.id.trim();
97
97
  ids[id] = (ids[id] || 0) + 1;
98
98
  if (ids[id] > 1 && !duplicates.includes(id)) {
99
99
  duplicates.push(id);
100
100
  }
101
101
  });
102
- return duplicates.map((id) => document.querySelector(`#${id}`));
102
+ return duplicates.map(id => document.querySelector(`#${id}`));
103
103
  },
104
104
 
105
105
  /**
@@ -119,13 +119,17 @@ const domUtils = {
119
119
  * @returns {string} The XPath string representing the element's location in the DOM.
120
120
  */
121
121
  getXPath(element) {
122
- if (!element) return "";
123
- let path = "";
122
+ if (!element) {
123
+ return '';
124
+ }
125
+ let path = '';
124
126
  while (element && element.nodeType === Node.ELEMENT_NODE) {
125
127
  let index = 1;
126
128
  let sibling = element.previousElementSibling;
127
129
  while (sibling) {
128
- if (sibling.nodeName === element.nodeName) index++;
130
+ if (sibling.nodeName === element.nodeName) {
131
+ index++;
132
+ }
129
133
  sibling = sibling.previousElementSibling;
130
134
  }
131
135
  path = `/${element.nodeName.toLowerCase()}[${index}]` + path;
@@ -134,6 +138,258 @@ const domUtils = {
134
138
  return path;
135
139
  },
136
140
 
141
+ /**
142
+ * Checks if a given ID is referenced by other elements in the document.
143
+ * References include label[for], aria-labelledby, aria-describedby,
144
+ * aria-controls, aria-owns, aria-activedescendant, aria-flowto,
145
+ * aria-errormessage, href="#id", headers, and list attributes.
146
+ *
147
+ * @param {string} id - The ID value to check for references.
148
+ * @returns {boolean} True if the ID is referenced by another element.
149
+ */
150
+ isIdReferenced(id) {
151
+ if (!id) {
152
+ return false;
153
+ }
154
+
155
+ // CSS-escape the ID for use in attribute selectors
156
+ const escaped = CSS.escape(id);
157
+
158
+ // Direct attribute references
159
+ if (document.querySelector('[for="' + escaped + '"]')) {
160
+ return true;
161
+ }
162
+
163
+ // ARIA token-list attributes that reference IDs
164
+ const ariaTokenAttrs = [
165
+ 'aria-labelledby',
166
+ 'aria-describedby',
167
+ 'aria-controls',
168
+ 'aria-owns',
169
+ 'aria-flowto',
170
+ ];
171
+ for (const attr of ariaTokenAttrs) {
172
+ const elements = document.querySelectorAll('[' + attr + ']');
173
+ for (const el of elements) {
174
+ const ids = el.getAttribute(attr).trim().split(/\s+/);
175
+ if (ids.includes(id)) {
176
+ return true;
177
+ }
178
+ }
179
+ }
180
+
181
+ // Single-ID ARIA references
182
+ const ariaSingleAttrs = ['aria-activedescendant', 'aria-errormessage'];
183
+ for (const attr of ariaSingleAttrs) {
184
+ if (document.querySelector('[' + attr + '="' + escaped + '"]')) {
185
+ return true;
186
+ }
187
+ }
188
+
189
+ // Fragment references in href
190
+ if (document.querySelector('a[href="#' + escaped + '"]')) {
191
+ return true;
192
+ }
193
+
194
+ // Table headers attribute (space-separated list of IDs)
195
+ const headerElements = document.querySelectorAll('[headers]');
196
+ for (const el of headerElements) {
197
+ const ids = el.getAttribute('headers').trim().split(/\s+/);
198
+ if (ids.includes(id)) {
199
+ return true;
200
+ }
201
+ }
202
+
203
+ // list attribute on input elements
204
+ if (document.querySelector('input[list="' + escaped + '"]')) {
205
+ return true;
206
+ }
207
+
208
+ return false;
209
+ },
210
+
211
+ /**
212
+ * Check if an element is hidden from assistive technology.
213
+ * @param {HTMLElement} element - The element to check
214
+ * @returns {boolean} True if element or ancestor has aria-hidden="true"
215
+ */
216
+ isHiddenFromAT(element) {
217
+ if (!element) {
218
+ return false;
219
+ }
220
+ return (
221
+ element.getAttribute('aria-hidden') === 'true' ||
222
+ (element.closest && !!element.closest('[aria-hidden="true"]'))
223
+ );
224
+ },
225
+
226
+ /**
227
+ * Check if an element is effectively interactive (not disabled, not hidden from AT,
228
+ * not role=presentation/none).
229
+ * @param {HTMLElement} element - The element to check
230
+ * @returns {boolean} True if the element is interactive
231
+ */
232
+ isEffectivelyInteractive(element) {
233
+ if (element.disabled || element.getAttribute('aria-disabled') === 'true') {
234
+ return false;
235
+ }
236
+ if (element.getAttribute('aria-hidden') === 'true') {
237
+ return false;
238
+ }
239
+ const role = element.getAttribute('role');
240
+ if (role === 'presentation' || role === 'none') {
241
+ return false;
242
+ }
243
+ return true;
244
+ },
245
+
246
+ /**
247
+ * Check if a parent-child pair constitutes a valid ARIA composite widget nesting.
248
+ * @param {HTMLElement} parent - The parent interactive element
249
+ * @param {HTMLElement} child - The nested interactive element
250
+ * @returns {boolean} True if this is a valid ARIA nesting pattern
251
+ */
252
+ isValidAriaNesting(parent, child) {
253
+ const VALID_ARIA_NESTING = {
254
+ listbox: ['option'],
255
+ menu: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'menu'],
256
+ menubar: ['menuitem', 'menuitemcheckbox', 'menuitemradio', 'menu'],
257
+ menuitem: ['menu', 'menubar'],
258
+ tablist: ['tab'],
259
+ tree: ['treeitem', 'group'],
260
+ treeitem: ['group', 'tree'],
261
+ grid: ['gridcell', 'row', 'rowgroup'],
262
+ row: ['gridcell', 'columnheader', 'rowheader', 'cell'],
263
+ rowgroup: ['row'],
264
+ radiogroup: ['radio'],
265
+ combobox: ['listbox', 'textbox', 'tree', 'grid', 'dialog'],
266
+ };
267
+
268
+ const parentRole = (parent.getAttribute('role') || '').toLowerCase();
269
+ const childRole = (child.getAttribute('role') || '').toLowerCase();
270
+
271
+ if (parentRole && VALID_ARIA_NESTING[parentRole]) {
272
+ if (VALID_ARIA_NESTING[parentRole].includes(childRole)) {
273
+ return true;
274
+ }
275
+ }
276
+
277
+ if (parent.tagName === 'LABEL' && child.closest('label') === parent) {
278
+ return true;
279
+ }
280
+
281
+ return false;
282
+ },
283
+
284
+ /**
285
+ * Check if element has event handlers that suggest interactivity.
286
+ * @param {HTMLElement} element - The element to check
287
+ * @returns {boolean} True if element has interactive handlers
288
+ */
289
+ hasInteractiveHandler(element) {
290
+ return (
291
+ element.hasAttribute('onclick') ||
292
+ element.hasAttribute('onmousedown') ||
293
+ element.hasAttribute('onmouseup') ||
294
+ element.hasAttribute('ontouchstart') ||
295
+ element.hasAttribute('onkeydown') ||
296
+ element.hasAttribute('onkeyup')
297
+ );
298
+ },
299
+
300
+ /**
301
+ * Check if an element is within a navigation context (nav, menu, menubar).
302
+ * @param {HTMLElement} element - The element to check
303
+ * @returns {boolean} True if element is within a navigation context
304
+ */
305
+ isWithinNavContext(element) {
306
+ let ancestor = element.parentElement;
307
+ while (ancestor) {
308
+ const tag = ancestor.tagName;
309
+ const role = (ancestor.getAttribute('role') || '').toLowerCase();
310
+ if (tag === 'NAV' || role === 'navigation' || role === 'menu' || role === 'menubar') {
311
+ return true;
312
+ }
313
+ ancestor = ancestor.parentElement;
314
+ }
315
+ return false;
316
+ },
317
+
318
+ /**
319
+ * Check if an element is a landmark element.
320
+ * @param {HTMLElement} element - The element to check
321
+ * @returns {boolean} True if element is a landmark
322
+ */
323
+ isLandmark(element) {
324
+ const landmarkTags = ['NAV', 'MAIN', 'HEADER', 'FOOTER', 'ASIDE', 'SECTION'];
325
+ const landmarkRoles = [
326
+ 'navigation',
327
+ 'main',
328
+ 'banner',
329
+ 'contentinfo',
330
+ 'complementary',
331
+ 'region',
332
+ 'search',
333
+ 'form',
334
+ ];
335
+ if (landmarkTags.includes(element.tagName)) {
336
+ return true;
337
+ }
338
+ const role = (element.getAttribute('role') || '').toLowerCase();
339
+ return landmarkRoles.includes(role);
340
+ },
341
+
342
+ /**
343
+ * Get the nearest semantic container for an element.
344
+ * @param {HTMLElement} el - The element
345
+ * @returns {HTMLElement|null} The nearest semantic container
346
+ */
347
+ getSemanticContainer(el) {
348
+ const containerTags = ['ARTICLE', 'SECTION', 'LI', 'TD', 'TH', 'BLOCKQUOTE', 'FIGURE'];
349
+ let ancestor = el.parentElement;
350
+ while (ancestor && ancestor !== document.body) {
351
+ if (containerTags.includes(ancestor.tagName) || ancestor.hasAttribute('role')) {
352
+ return ancestor;
353
+ }
354
+ ancestor = ancestor.parentElement;
355
+ }
356
+ return null;
357
+ },
358
+
359
+ /**
360
+ * Gets the heading level from an element.
361
+ * Supports native heading elements (h1-h6) and ARIA headings (role="heading" with aria-level).
362
+ * @param {HTMLElement} element - The element to check
363
+ * @returns {number|null} The heading level (1-6+) or null if not a valid heading
364
+ */
365
+ getHeadingLevel(element) {
366
+ const tagName = element.tagName.toUpperCase();
367
+ const role = element.getAttribute('role');
368
+ const ariaLevel = element.getAttribute('aria-level');
369
+
370
+ if (role === 'heading') {
371
+ if (ariaLevel) {
372
+ const level = parseInt(ariaLevel, 10);
373
+ if (!isNaN(level) && level >= 1) {
374
+ return level;
375
+ }
376
+ }
377
+ return null;
378
+ }
379
+
380
+ if (/^H[1-6]$/.test(tagName)) {
381
+ if (ariaLevel) {
382
+ const lvl = parseInt(ariaLevel, 10);
383
+ if (!isNaN(lvl) && lvl >= 1) {
384
+ return lvl;
385
+ }
386
+ }
387
+ return parseInt(tagName.charAt(1), 10);
388
+ }
389
+
390
+ return null;
391
+ },
392
+
137
393
  /**
138
394
  * Checks if the given element has focus.
139
395
  *
@@ -0,0 +1,175 @@
1
+ /**
2
+ * @file Form-related accessibility utilities
3
+ * @module formUtils
4
+ */
5
+
6
+ const formUtils = {
7
+ /**
8
+ * Check if an element is a labellable form control per HTML spec.
9
+ * @param {Element} element - The element to check
10
+ * @returns {boolean} True if the element is labellable
11
+ */
12
+ isLabellable(element) {
13
+ if (!element) {
14
+ return false;
15
+ }
16
+ const labellable = ['input', 'select', 'textarea', 'button', 'meter', 'output', 'progress'];
17
+ const tagName = element.tagName.toLowerCase();
18
+ return labellable.includes(tagName);
19
+ },
20
+
21
+ /**
22
+ * Check if an element is a hidden input.
23
+ * @param {Element} element - The element to check
24
+ * @returns {boolean} True if the element is a hidden input
25
+ */
26
+ isHiddenInput(element) {
27
+ if (!element) {
28
+ return false;
29
+ }
30
+ return (
31
+ element.tagName.toLowerCase() === 'input' &&
32
+ (element.getAttribute('type') || '').toLowerCase() === 'hidden'
33
+ );
34
+ },
35
+
36
+ /**
37
+ * Checks if an element has an explicit accessible name via aria-labelledby, aria-label, or title.
38
+ * Text content is NOT checked (useful for containers like radiogroup/group where
39
+ * text content is not a valid accessible name source).
40
+ * @param {Element} element - The element to check
41
+ * @returns {boolean} True if element has an explicit accessible name
42
+ */
43
+ hasExplicitAccessibleName(element) {
44
+ if (element.hasAttribute('aria-labelledby')) {
45
+ const ids = element.getAttribute('aria-labelledby').trim().split(/\s+/);
46
+ for (let i = 0; i < ids.length; i++) {
47
+ const labelEl = document.getElementById(ids[i]);
48
+ if (labelEl && labelEl.textContent.trim()) {
49
+ return true;
50
+ }
51
+ }
52
+ }
53
+
54
+ if (element.hasAttribute('aria-label')) {
55
+ const ariaLabel = element.getAttribute('aria-label').trim();
56
+ if (ariaLabel) {
57
+ return true;
58
+ }
59
+ }
60
+
61
+ if (element.hasAttribute('title')) {
62
+ const title = element.getAttribute('title').trim();
63
+ if (title) {
64
+ return true;
65
+ }
66
+ }
67
+
68
+ return false;
69
+ },
70
+
71
+ /**
72
+ * Checks if a form control is properly grouped with an accessible label.
73
+ * Proper grouping means inside a fieldset with a legend, or inside an element
74
+ * with role="radiogroup" or role="group" that has an explicit accessible name.
75
+ * @param {HTMLElement} control - The form control to check
76
+ * @param {string} [groupRole='radiogroup'] - The ARIA group role to look for
77
+ * @returns {boolean} True if properly grouped
78
+ */
79
+ isProperlyGrouped(control, groupRole) {
80
+ groupRole = groupRole || 'radiogroup';
81
+
82
+ const fieldset = control.closest('fieldset');
83
+ if (fieldset) {
84
+ const legend = fieldset.querySelector('legend');
85
+ if (legend && legend.textContent.trim()) {
86
+ return true;
87
+ }
88
+ return false;
89
+ }
90
+
91
+ const group = control.closest('[role="' + groupRole + '"]');
92
+ if (group) {
93
+ if (formUtils.hasExplicitAccessibleName(group)) {
94
+ return true;
95
+ }
96
+ return false;
97
+ }
98
+
99
+ return false;
100
+ },
101
+
102
+ /**
103
+ * Finds the grouping ancestor for an element (fieldset, form, or ARIA group).
104
+ * @param {Element} element - The element to start from
105
+ * @returns {Element} The grouping ancestor or document.body if none found
106
+ */
107
+ findGroupingAncestor(element) {
108
+ let current = element.parentElement;
109
+
110
+ while (current && current !== document.body) {
111
+ const tagName = current.tagName;
112
+ const role = current.getAttribute('role');
113
+
114
+ if (
115
+ tagName === 'FIELDSET' ||
116
+ tagName === 'FORM' ||
117
+ role === 'radiogroup' ||
118
+ role === 'group'
119
+ ) {
120
+ return current;
121
+ }
122
+
123
+ current = current.parentElement;
124
+ }
125
+
126
+ return document.body;
127
+ },
128
+
129
+ /**
130
+ * Check if a native form element has an associated label.
131
+ * Checks label[for], wrapping label, aria-label, aria-labelledby, and title.
132
+ * @param {HTMLElement} element - The element to check
133
+ * @returns {boolean} True if the element has an associated label
134
+ */
135
+ hasAssociatedLabel(element) {
136
+ if (element.id) {
137
+ const label = document.querySelector('label[for="' + element.id + '"]');
138
+ if (label && label.textContent.trim()) {
139
+ return true;
140
+ }
141
+ }
142
+
143
+ const parentLabel = element.closest('label');
144
+ if (parentLabel && parentLabel.textContent.trim()) {
145
+ return true;
146
+ }
147
+
148
+ return false;
149
+ },
150
+
151
+ /**
152
+ * Get the text content of an element, excluding form control children.
153
+ * Useful for wrapped labels like <label><input type="checkbox"> Remember me</label>
154
+ * where the input element should not contribute to label text.
155
+ * @param {Element} element - The element to get text from
156
+ * @returns {string} The text content excluding form controls
157
+ */
158
+ getTextContentExcludingControls(element) {
159
+ let text = '';
160
+ for (let i = 0; i < element.childNodes.length; i++) {
161
+ const node = element.childNodes[i];
162
+ if (node.nodeType === Node.TEXT_NODE) {
163
+ text += node.textContent;
164
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
165
+ const tagName = node.tagName.toLowerCase();
166
+ if (!['input', 'select', 'textarea', 'button'].includes(tagName)) {
167
+ text += formUtils.getTextContentExcludingControls(node);
168
+ }
169
+ }
170
+ }
171
+ return text;
172
+ },
173
+ };
174
+
175
+ module.exports = formUtils;
@@ -1,44 +1,66 @@
1
+ /**
2
+ * Checks if a CSS content value is meaningful (non-empty, non-whitespace).
3
+ * Strips surrounding quotes and checks for actual visible content.
4
+ *
5
+ * @param {string} rawValue - The raw CSS content value from getComputedStyle
6
+ * @returns {string|false} The cleaned content string or false if empty/whitespace
7
+ */
8
+ function extractMeaningfulContent(rawValue) {
9
+ if (!rawValue || rawValue === 'none' || rawValue === 'normal') {
10
+ return false;
11
+ }
12
+
13
+ // Remove surrounding quotes (single or double)
14
+ let cleaned = rawValue.replace(/^["'](.*)["']$/, '$1');
15
+
16
+ // Trim whitespace - content that is only whitespace is not meaningful
17
+ cleaned = cleaned.trim();
18
+
19
+ return cleaned.length > 0 ? cleaned : false;
20
+ }
21
+
1
22
  /**
2
23
  * Gets the CSS generated content for an element's ::before or ::after pseudo-elements.
3
24
  * This function only checks for content added via the CSS `content` property,
4
- * not the element's own text content.
25
+ * not the element's own text content. Empty, whitespace-only, and blank content
26
+ * values are filtered out as they do not convey meaningful information.
5
27
  *
6
28
  * @param {Element} el - The DOM element to check
7
29
  * @param {string} [pseudoElement='both'] - Which pseudo-element to check ('before', 'after', or 'both')
8
30
  * @returns {string|boolean} The generated content as a string or false if none exists
9
31
  */
10
32
  function getCSSGeneratedContent(el, pseudoElement = 'both') {
11
- if (!el) return false;
33
+ if (!el) {
34
+ return false;
35
+ }
12
36
 
13
37
  let content = '';
14
38
 
15
- if (pseudoElement === 'before' || pseudoElement === 'both') {
16
- const style = window.getComputedStyle(el, '::before');
17
- const before = style.getPropertyValue('content');
18
- if (before && before !== 'none' && before !== 'normal' && before !== '""' && before !== "''") {
19
- // Remove surrounding quotes if present
20
- const cleanBefore = before.replace(/^["'](.*)["']$/, '$1');
39
+ try {
40
+ if (pseudoElement === 'before' || pseudoElement === 'both') {
41
+ const style = window.getComputedStyle(el, '::before');
42
+ const before = style.getPropertyValue('content');
43
+ const cleanBefore = extractMeaningfulContent(before);
21
44
  if (cleanBefore) {
22
45
  content += cleanBefore;
23
46
  }
24
47
  }
25
- }
26
48
 
27
- if (pseudoElement === 'after' || pseudoElement === 'both') {
28
- const style = window.getComputedStyle(el, '::after');
29
- const after = style.getPropertyValue('content');
30
- if (after && after !== 'none' && after !== 'normal' && after !== '""' && after !== "''") {
31
- // Remove surrounding quotes if present
32
- const cleanAfter = after.replace(/^["'](.*)["']$/, '$1');
49
+ if (pseudoElement === 'after' || pseudoElement === 'both') {
50
+ const style = window.getComputedStyle(el, '::after');
51
+ const after = style.getPropertyValue('content');
52
+ const cleanAfter = extractMeaningfulContent(after);
33
53
  if (cleanAfter) {
34
54
  content += (content ? ' ' : '') + cleanAfter;
35
55
  }
36
56
  }
57
+ } catch (_e) {
58
+ return false;
37
59
  }
38
60
 
39
61
  return content ? content.trim() : false;
40
62
  }
41
63
 
42
64
  module.exports = {
43
- getCSSGeneratedContent
44
- };
65
+ getCSSGeneratedContent,
66
+ };
package/src/index.js CHANGED
@@ -63,6 +63,18 @@ const isValidUrl = require('./isValidUrl.js');
63
63
  // String utilities
64
64
  const stringUtils = require('./stringUtils.js');
65
65
 
66
+ // Constants
67
+ const constants = require('./constants.js');
68
+
69
+ // CSS utilities
70
+ const cssUtils = require('./cssUtils.js');
71
+
72
+ // Form utilities
73
+ const formUtils = require('./formUtils.js');
74
+
75
+ // Table utilities
76
+ const tableUtils = require('./tableUtils.js');
77
+
66
78
  // Query cache utilities
67
79
  const queryCache = require('./queryCache.js');
68
80
 
@@ -102,6 +114,10 @@ module.exports = {
102
114
  ...testOrder,
103
115
  ...isValidUrl,
104
116
  ...stringUtils,
117
+ ...constants,
118
+ ...cssUtils,
119
+ ...formUtils,
120
+ ...tableUtils,
105
121
  ...queryCache,
106
- ...listEventListeners
107
- };
122
+ ...listEventListeners,
123
+ };