@nodebug/browser-element-finder 1.1.9 → 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.
@@ -0,0 +1,1590 @@
1
+ /**
2
+ * Element Finder - Combined Module
3
+ *
4
+ * This module provides functionality to find elements by type and/or searchable attributes.
5
+ * Combined implementation supporting both findElementsByType and findElementsByAttribute.
6
+ */
7
+
8
+ import elementDefinitionsData from './element-definitions.json' with { type: 'json' };
9
+ import searchableAttributesData from './searchable-attributes.json' with { type: 'json' };
10
+
11
+ // Precompiled regex patterns for performance (avoid recompiling on every call)
12
+ const REGEX_PATTERNS = {
13
+ selfWithTag: /^self::([a-zA-Z0-9-]+)(?:\[([^\]]+)\])?$/,
14
+ contains: /contains\(@([a-zA-Z0-9-]+),\s*['"]([^'"]+)['"]\)/i,
15
+ attrEquals: /@([a-zA-Z0-9-]+)\s*=\s*['"]([^'"]*)['"]/,
16
+ attrExists: /^@([a-zA-Z0-9-]+)$/,
17
+ descendant: /descendant::([a-zA-Z0-9-]+)/i,
18
+ ancestor: /ancestor::\*\[([^\]]+)\]/i,
19
+ operatorOr: /^\s*\bor\b\s*/i,
20
+ operatorAnd: /^\s*\band\b\s*/i
21
+ };
22
+
23
+ // Maximum recursion depth for XPath parsing to prevent stack overflow
24
+ const MAX_RECURSION_DEPTH = 100;
25
+
26
+ // Maximum length for text/textContent fallback descriptors
27
+ const MAX_IDENTIFIABLE_TEXT_LENGTH = 25;
28
+
29
+ // Pre-compiled type matcher functions for faster type checking
30
+ const TYPE_MATCHERS = new Map();
31
+
32
+ // Compile all type definitions into matcher functions on module load
33
+ for (const [type, expr] of Object.entries(elementDefinitionsData)) {
34
+ if (expr === 'true()') {
35
+ TYPE_MATCHERS.set(type, () => true);
36
+ } else {
37
+ TYPE_MATCHERS.set(type, (el) => parseXPath(expr, el));
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Searchable attributes (in priority order) - internal state
43
+ */
44
+ let SEARCHABLE_ATTRIBUTES = searchableAttributesData;
45
+
46
+ /**
47
+ * Sets custom searchable attributes for attribute matching.
48
+ * @param {string[]} attributes - Array of attribute names to search (in priority order)
49
+ * @throws {TypeError} If attributes is not an array
50
+ */
51
+ export function setSearchableAttributes(attributes) {
52
+ if (!Array.isArray(attributes)) {
53
+ throw new TypeError('attributes must be an array');
54
+ }
55
+ SEARCHABLE_ATTRIBUTES = attributes;
56
+ }
57
+
58
+ /**
59
+ * Gets the current searchable attributes array.
60
+ * @returns {string[]} Copy of the current searchable attributes array
61
+ */
62
+ export function getSearchableAttributes() {
63
+ return [...SEARCHABLE_ATTRIBUTES];
64
+ }
65
+
66
+ /**
67
+ * Gets the current values of searchable attributes on an element.
68
+ * Only returns attributes that exist on the element and have non-empty values.
69
+ * @param {Element|null|undefined} el - The DOM element to inspect
70
+ * @returns {Object.<string, string>} Attribute name to value map
71
+ */
72
+ export function getSearchableAttributeValues(el) {
73
+ if (el == null || el.nodeType !== Node.ELEMENT_NODE) return {};
74
+
75
+ const values = {};
76
+ const attrs = SEARCHABLE_ATTRIBUTES;
77
+
78
+ for (let i = 0; i < attrs.length; i++) {
79
+ const attr = attrs[i];
80
+ let attrValue;
81
+ try {
82
+ attrValue = el.getAttribute(attr);
83
+ } catch {
84
+ continue;
85
+ }
86
+
87
+ if (attrValue !== null && attrValue !== undefined && attrValue !== '') {
88
+ values[attr] = attrValue;
89
+ }
90
+ }
91
+
92
+ return values;
93
+ }
94
+
95
+ /**
96
+ * Normalizes descriptor text for consistent matching.
97
+ * @param {string|null|undefined} text - Text to normalize
98
+ * @returns {string} Normalized text
99
+ */
100
+ function normalizeDescriptorText(text) {
101
+ if (text == null) return '';
102
+ return String(text).replace(/\s+/g, ' ').trim();
103
+ }
104
+
105
+ /**
106
+ * Shortens text fallback descriptors without cutting words.
107
+ * Uses only the first non-empty line so text after new lines is ignored.
108
+ * @param {string|null|undefined} text - Text to shorten
109
+ * @returns {string} Shortened normalized text
110
+ */
111
+ function shortenDescriptorText(text) {
112
+ if (text == null) return '';
113
+
114
+ const lines = String(text).split(/\r\n|\r|\n/);
115
+ let normalizedText = '';
116
+
117
+ for (let i = 0; i < lines.length; i++) {
118
+ normalizedText = normalizeDescriptorText(lines[i]);
119
+ if (normalizedText) break;
120
+ }
121
+
122
+ if (!normalizedText || normalizedText.length <= MAX_IDENTIFIABLE_TEXT_LENGTH) {
123
+ return normalizedText;
124
+ }
125
+
126
+ const shortened = normalizedText.slice(0, MAX_IDENTIFIABLE_TEXT_LENGTH);
127
+ const lastSpaceIndex = shortened.lastIndexOf(' ');
128
+
129
+ return lastSpaceIndex > 0 ? shortened.slice(0, lastSpaceIndex) : normalizedText;
130
+ }
131
+
132
+ /**
133
+ * Gets the filename portion of an image src without path or extension.
134
+ * @param {string|null|undefined} src - Image src value
135
+ * @returns {string} Filename without path or extension
136
+ */
137
+ function getImageFilenameWithoutExtension(src) {
138
+ const normalizedSrc = normalizeDescriptorText(src);
139
+ if (!normalizedSrc) return '';
140
+
141
+ const withoutQueryOrFragment = normalizedSrc.split(/[?#]/)[0];
142
+ const lastSlashIndex = Math.max(
143
+ withoutQueryOrFragment.lastIndexOf('/'),
144
+ withoutQueryOrFragment.lastIndexOf('\\')
145
+ );
146
+ const filenameWithExtension = lastSlashIndex >= 0
147
+ ? withoutQueryOrFragment.slice(lastSlashIndex + 1)
148
+ : withoutQueryOrFragment;
149
+
150
+ const lastDotIndex = filenameWithExtension.lastIndexOf('.');
151
+ return lastDotIndex > 0
152
+ ? filenameWithExtension.slice(0, lastDotIndex)
153
+ : filenameWithExtension;
154
+ }
155
+
156
+ /**
157
+ * Gets the text content from elements referenced by aria-labelledby.
158
+ * @param {Element} el - The DOM element to check
159
+ * @returns {string} Concatenated resolved text from referenced elements, or empty string
160
+ */
161
+ function getResolvedAriaLabelledByText(el) {
162
+ const labelledBy = el.getAttribute('aria-labelledby');
163
+ if (!labelledBy) return '';
164
+
165
+ const ids = labelledBy.split(/\s+/);
166
+ const ownerDocument = el.ownerDocument || document;
167
+ let text = '';
168
+
169
+ for (const id of ids) {
170
+ try {
171
+ const refEl = ownerDocument.getElementById(id);
172
+ if (!refEl) continue;
173
+
174
+ const refText = normalizeDescriptorText(refEl.textContent);
175
+ if (refText) {
176
+ text = text ? `${text} ${refText}` : refText;
177
+ }
178
+ } catch {
179
+ // Skip if element not found or access denied
180
+ }
181
+ }
182
+
183
+ return text;
184
+ }
185
+
186
+ /**
187
+ * Gets the first searchable attribute or text fallback identifiable text for an element.
188
+ * @param {Element} el - The DOM element to describe
189
+ * @returns {{attributeName: string|null, identifiableText: string}|null} Descriptor source and identifiable text
190
+ */
191
+ function getElementDescriptorText(el) {
192
+ const values = getSearchableAttributeValues(el);
193
+ const attrs = SEARCHABLE_ATTRIBUTES;
194
+
195
+ for (let i = 0; i < attrs.length; i++) {
196
+ const attr = attrs[i];
197
+ if (!Object.prototype.hasOwnProperty.call(values, attr)) continue;
198
+
199
+ const rawText = attr === 'aria-labelledby'
200
+ ? getResolvedAriaLabelledByText(el)
201
+ : attr === 'src'
202
+ ? getImageFilenameWithoutExtension(values[attr])
203
+ : values[attr];
204
+ const identifiableText = normalizeDescriptorText(rawText);
205
+
206
+ if (identifiableText) {
207
+ return { attributeName: attr, identifiableText };
208
+ }
209
+ }
210
+
211
+ const directText = shortenDescriptorText(getDirectText(el));
212
+ if (directText) {
213
+ return { attributeName: 'text', identifiableText: directText };
214
+ }
215
+
216
+ const fullText = shortenDescriptorText(el.textContent);
217
+ if (fullText) {
218
+ return { attributeName: 'text', identifiableText: fullText };
219
+ }
220
+
221
+ return null;
222
+ }
223
+
224
+ /**
225
+ * Gets the root document to use for descriptor uniqueness checks.
226
+ * @param {Element} el - The DOM element to describe
227
+ * @returns {Document|null} The element's frame document, if available
228
+ */
229
+ function getElementDescriptorFrame(el) {
230
+ if (!el || !el.ownerDocument) return null;
231
+
232
+ try {
233
+ const frames = getAllFrames(window);
234
+ for (let i = 0; i < frames.length; i++) {
235
+ if (frames[i].document === el.ownerDocument) {
236
+ return frames[i].document;
237
+ }
238
+ }
239
+ } catch {
240
+ // Fall back to the element's owner document below
241
+ }
242
+
243
+ return el.ownerDocument;
244
+ }
245
+
246
+ /**
247
+ * Gets occurrence index for descriptor text within the element's frame.
248
+ * @param {Element} el - The element to describe
249
+ * @param {string} text - Descriptor text to count
250
+ * @returns {{index: number}} 1-based occurrence index
251
+ */
252
+ function getElementDescriptorUniqueness(el, text) {
253
+ const root = getElementDescriptorFrame(el);
254
+ if (!root) {
255
+ return { index: 1 };
256
+ }
257
+
258
+ const elements = getAllElements(root);
259
+ let index = 1;
260
+ let count = 0;
261
+
262
+ for (let i = 0; i < elements.length; i++) {
263
+ const candidate = elements[i];
264
+
265
+ if (candidate !== el && (candidate === root.documentElement || candidate.tagName === 'BODY')) {
266
+ continue;
267
+ }
268
+
269
+ const candidateDescriptor = getElementDescriptorText(candidate);
270
+
271
+ if (!candidateDescriptor || candidateDescriptor.identifiableText !== text) continue;
272
+
273
+ count++;
274
+ if (candidate === el) {
275
+ index = count;
276
+ }
277
+ }
278
+
279
+ return { index };
280
+ }
281
+
282
+ /**
283
+ * Gets the first matching semantic type for an element.
284
+ * @param {Element} el - The DOM element to classify
285
+ * @returns {string|null} Matching type name, or null for non-elements
286
+ */
287
+ function getElementDescriptorType(el) {
288
+ if (el == null || el.nodeType !== Node.ELEMENT_NODE) return null;
289
+
290
+ const types = Object.keys(ELEMENT_DEFINITIONS);
291
+ for (let i = 0; i < types.length; i++) {
292
+ const type = types[i];
293
+ if (type === 'element') continue;
294
+ if (matchesType(el, type)) return type;
295
+ }
296
+
297
+ return 'element';
298
+ }
299
+
300
+ /**
301
+ * Gets a plain-text identifier for a DOM element.
302
+ * Uses the first non-empty searchable attribute value, falls back to element text,
303
+ * reports occurrence index within the current frame, and includes the semantic element type.
304
+ * @param {Element|null|undefined} el - The DOM element to describe
305
+ * @returns {{identifiableText: string|null, attributeName: string|null, index: number, type: string|null, tagName: string|null}} Element descriptor
306
+ */
307
+ export function getElementDescriptor(el) {
308
+ if (el == null || el.nodeType !== Node.ELEMENT_NODE) {
309
+ return {
310
+ identifiableText: null,
311
+ attributeName: null,
312
+ index: 1,
313
+ type: null,
314
+ tagName: null
315
+ };
316
+ }
317
+
318
+ const type = getElementDescriptorType(el);
319
+ const descriptorSource = getElementDescriptorText(el);
320
+
321
+ if (!descriptorSource) {
322
+ return {
323
+ identifiableText: null,
324
+ attributeName: null,
325
+ index: 1,
326
+ type,
327
+ tagName: el.tagName.toLowerCase()
328
+ };
329
+ }
330
+
331
+ const uniqueness = getElementDescriptorUniqueness(el, descriptorSource.identifiableText);
332
+
333
+ return {
334
+ identifiableText: descriptorSource.identifiableText,
335
+ attributeName: descriptorSource.attributeName,
336
+ index: uniqueness.index,
337
+ type,
338
+ tagName: el.tagName.toLowerCase()
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Parses an XPath-like expression for element type matching.
344
+ * Supports conditions like self::tag, @attr='value', contains(), descendant::, ancestor::*
345
+ * @param {string} expr - The XPath-like expression to parse
346
+ * @param {Element} el - The DOM element to test against
347
+ * @param {number} [depth=0] - Current recursion depth (internal use)
348
+ * @returns {boolean} True if the element matches the expression
349
+ */
350
+ export function parseXPath(expr, el, depth = 0) {
351
+ if (expr == null || el == null) return false;
352
+
353
+ if (depth > MAX_RECURSION_DEPTH) {
354
+ throw new Error('XPath expression exceeds maximum recursion depth');
355
+ }
356
+
357
+ expr = expr.trim();
358
+ if (expr === 'true()') return true;
359
+
360
+ // Handle outermost matching parentheses
361
+ if (expr[0] === '(' && expr[expr.length - 1] === ')') {
362
+ let parenDepth = 1;
363
+ let matchedAll = true;
364
+ for (let i = 1; i < expr.length - 1; i++) {
365
+ if (expr[i] === '(') parenDepth++;
366
+ else if (expr[i] === ')') parenDepth--;
367
+ if (parenDepth === 0) { matchedAll = false; break; }
368
+ }
369
+ if (matchedAll) return parseXPath(expr.slice(1, -1), el, depth + 1);
370
+ }
371
+
372
+ // Split by ' or ' for OR conditions
373
+ const orParts = splitByOperator(expr, 'or');
374
+ if (orParts.length > 1) {
375
+ for (const part of orParts) {
376
+ if (parseXPath(part, el, depth + 1)) return true;
377
+ }
378
+ return false;
379
+ }
380
+
381
+ // Split by ' and ' for AND conditions
382
+ const andParts = splitByOperator(expr, 'and');
383
+ if (andParts.length > 1) {
384
+ for (const part of andParts) {
385
+ if (!parseXPath(part, el, depth + 1)) return false;
386
+ }
387
+ return true;
388
+ }
389
+
390
+ return parseCondition(expr, el, depth);
391
+ }
392
+
393
+ /**
394
+ * Splits an XPath expression by the specified operator (and/or).
395
+ * Handles nested parentheses and quoted strings correctly.
396
+ * @param {string} expr - The XPath expression to split
397
+ * @param {string} op - The operator to split by ('and' or 'or')
398
+ * @returns {string[]} Array of expression parts
399
+ */
400
+ export function splitByOperator(expr, op) {
401
+ const parts = [];
402
+ let depth = 0;
403
+ let current = '';
404
+ let inQuotes = false;
405
+ let quoteChar = '';
406
+ const opPattern = op === 'or' ? REGEX_PATTERNS.operatorOr : REGEX_PATTERNS.operatorAnd;
407
+
408
+ for (let i = 0; i < expr.length; i++) {
409
+ const char = expr[i];
410
+
411
+ if ((char === "'" || char === '"') && (i === 0 || expr[i-1] !== '\\')) {
412
+ if (!inQuotes) {
413
+ inQuotes = true;
414
+ quoteChar = char;
415
+ } else if (char === quoteChar) {
416
+ inQuotes = false;
417
+ }
418
+ }
419
+
420
+ if (!inQuotes) {
421
+ if (char === '(') depth++;
422
+ else if (char === ')') depth--;
423
+
424
+ if (depth === 0) {
425
+ const remaining = expr.slice(i);
426
+ const match = remaining.match(opPattern);
427
+ if (match) {
428
+ parts.push(current.trim());
429
+ i += match[0].length - 1;
430
+ current = '';
431
+ continue;
432
+ }
433
+ }
434
+ }
435
+ current += char;
436
+ }
437
+ if (current.trim()) parts.push(current.trim());
438
+ return parts;
439
+ }
440
+
441
+ /**
442
+ * Parses a single condition within an XPath expression.
443
+ * Handles self::tag, @attr='value', @attr, contains(), descendant::, ancestor::*
444
+ * @param {string} expr - The condition expression to parse
445
+ * @param {Element} el - The DOM element to test against
446
+ * @param {number} [depth=0] - Current recursion depth (internal use)
447
+ * @returns {boolean} True if the element matches the condition
448
+ */
449
+ export function parseCondition(expr, el, depth = 0) {
450
+ if (expr == null || el == null) return false;
451
+
452
+ expr = expr.trim();
453
+
454
+ const selfMatch = expr.match(REGEX_PATTERNS.selfWithTag);
455
+ if (selfMatch) {
456
+ const tagName = selfMatch[1].toUpperCase();
457
+ if (el.tagName !== tagName) return false;
458
+ return selfMatch[2] ? parseXPath(selfMatch[2], el, depth + 1) : true;
459
+ }
460
+
461
+ const containsMatch = expr.match(REGEX_PATTERNS.contains);
462
+ if (containsMatch) {
463
+ const attr = el.getAttribute(containsMatch[1]) || '';
464
+ return attr.toLowerCase().includes(containsMatch[2].toLowerCase());
465
+ }
466
+
467
+ const attrEqualsMatch = expr.match(REGEX_PATTERNS.attrEquals);
468
+ if (attrEqualsMatch) {
469
+ return el.getAttribute(attrEqualsMatch[1]) === attrEqualsMatch[2];
470
+ }
471
+
472
+ const attrExistsMatch = expr.match(REGEX_PATTERNS.attrExists);
473
+ if (attrExistsMatch) {
474
+ return el.hasAttribute(attrExistsMatch[1]);
475
+ }
476
+
477
+ const descendantMatch = expr.match(REGEX_PATTERNS.descendant);
478
+ if (descendantMatch) {
479
+ return el.querySelector(descendantMatch[1]) !== null;
480
+ }
481
+
482
+ const ancestorMatch = expr.match(REGEX_PATTERNS.ancestor);
483
+ if (ancestorMatch) {
484
+ let parent = el.parentElement;
485
+ while (parent) {
486
+ if (parseXPath(ancestorMatch[1], parent, depth + 1)) return true;
487
+ parent = parent.parentElement;
488
+ }
489
+ return false;
490
+ }
491
+
492
+ return false;
493
+ }
494
+
495
+ /**
496
+ * Element type definitions as XPath-like strings.
497
+ * Keys are type names, values are XPath expressions.
498
+ * @type {Object.<string, string>}
499
+ */
500
+ export const ELEMENT_DEFINITIONS = Object.freeze(elementDefinitionsData);
501
+
502
+ /**
503
+ * Gets direct text content from an element's text nodes.
504
+ * More efficient than textContent for simple text matching.
505
+ * @param {Element} el - The DOM element
506
+ * @returns {string} Concatenated text from direct text nodes
507
+ */
508
+ function getDirectText(el) {
509
+ let text = '';
510
+ for (let i = 0; i < el.childNodes.length; i++) {
511
+ const node = el.childNodes[i];
512
+ if (node.nodeType === Node.TEXT_NODE) {
513
+ text += node.textContent;
514
+ }
515
+ }
516
+ return text.trim();
517
+ }
518
+
519
+ /**
520
+ * Checks if an element is inside a STYLE or SCRIPT tag, or contains STYLE/SCRIPT descendants.
521
+ * @param {Element} el - The DOM element to check
522
+ * @returns {boolean} True if the element is inside a STYLE or SCRIPT tag, or contains one
523
+ */
524
+ function isInsideStyleOrScript(el) {
525
+ // Check if element itself is a STYLE or SCRIPT tag
526
+ if (el.tagName === 'STYLE' || el.tagName === 'SCRIPT') {
527
+ return true;
528
+ }
529
+
530
+ // Check if element contains STYLE or SCRIPT descendants
531
+ if (el.querySelector('STYLE, SCRIPT')) {
532
+ return true;
533
+ }
534
+
535
+ // Check if element is inside a STYLE or SCRIPT tag
536
+ let parent = el.parentElement;
537
+ while (parent) {
538
+ if (parent.tagName === 'STYLE' || parent.tagName === 'SCRIPT') {
539
+ return true;
540
+ }
541
+ parent = parent.parentElement;
542
+ }
543
+ return false;
544
+ }
545
+
546
+ /**
547
+ * Gets the text content from elements referenced by aria-labelledby.
548
+ * @param {Element} el - The DOM element to check
549
+ * @returns {string} Concatenated text from referenced elements, or empty string
550
+ */
551
+ function getAriaLabelledByText(el) {
552
+ const labelledBy = el.getAttribute('aria-labelledby');
553
+ if (!labelledBy) return '';
554
+
555
+ const ids = labelledBy.split(/\s+/);
556
+ let text = '';
557
+ for (const id of ids) {
558
+ try {
559
+ const refEl = document.getElementById(id);
560
+ if (refEl) {
561
+ text += refEl.textContent;
562
+ }
563
+ } catch {
564
+ // Skip if element not found or access denied
565
+ }
566
+ }
567
+ return text;
568
+ }
569
+
570
+ /**
571
+ * Checks if an element matches the specified attribute value.
572
+ * Searches through all searchable attributes in priority order, then text content.
573
+ * Text matching is case-sensitive. Ignores elements inside STYLE or SCRIPT tags.
574
+ * @param {Element} el - The DOM element to check
575
+ * @param {string} value - The attribute value to search for
576
+ * @param {boolean} [exact=false] - Whether to match exactly or as substring
577
+ * @returns {boolean} True if the element has a matching attribute value or text content
578
+ */
579
+ export function matchesAttribute(el, value, exact = false) {
580
+ if (el == null) return false;
581
+ if (value === undefined || value === null || value === '') return true;
582
+
583
+ // Skip elements inside STYLE or SCRIPT tags
584
+ if (isInsideStyleOrScript(el)) return false;
585
+
586
+ const attrs = SEARCHABLE_ATTRIBUTES;
587
+
588
+ // Check prioritized attributes first
589
+ for (let i = 0; i < attrs.length; i++) {
590
+ const attr = attrs[i];
591
+ let attrValue;
592
+ try {
593
+ attrValue = el.getAttribute(attr);
594
+ } catch {
595
+ continue;
596
+ }
597
+ if (attrValue) {
598
+ // Special handling for aria-labelledby - check both raw value and resolved text
599
+ if (attr === 'aria-labelledby') {
600
+ // First check if the raw attribute value contains the search string
601
+ if (exact ? attrValue === value : attrValue.includes(value)) {
602
+ return true;
603
+ }
604
+ // Then check the resolved text from referenced elements
605
+ const resolvedText = getAriaLabelledByText(el);
606
+ if (resolvedText) {
607
+ if (exact ? resolvedText === value : resolvedText.includes(value)) {
608
+ return true;
609
+ }
610
+ }
611
+ } else if (exact ? attrValue === value : attrValue.includes(value)) {
612
+ return true;
613
+ }
614
+ }
615
+ }
616
+
617
+ // Check direct text nodes (case-sensitive)
618
+ const directText = getDirectText(el);
619
+ if (exact ? directText === value : directText.includes(value)) {
620
+ return true;
621
+ }
622
+
623
+ // Check full text content (includes nested elements, case-sensitive)
624
+ const textContent = el.textContent;
625
+ if (exact ? textContent.trim() === value : textContent.includes(value)) {
626
+ return true;
627
+ }
628
+
629
+ return false;
630
+ }
631
+
632
+ /**
633
+ * Checks if an element matches the specified type definition.
634
+ * Uses pre-compiled matcher functions for better performance.
635
+ * @param {Element} el - The DOM element to check
636
+ * @param {string} type - The element type name (e.g., 'button', 'textbox')
637
+ * @returns {boolean} True if the element matches the type definition
638
+ */
639
+ export function matchesType(el, type) {
640
+ if (el == null) return false;
641
+ const matcher = TYPE_MATCHERS.get(type);
642
+ return matcher ? matcher(el) : false;
643
+ }
644
+
645
+ /**
646
+ * Gets all elements including shadow DOM contents.
647
+ * @param {Document|Element} [root=document] - The root node to start traversal from
648
+ * @returns {Element[]} Array of all elements found
649
+ */
650
+ export function getAllElements(root = document) {
651
+ const elements = [];
652
+ if (root == null) return elements;
653
+ const rootNode = root.nodeType === Node.DOCUMENT_NODE ? root.documentElement : root;
654
+ if (!rootNode) return elements;
655
+
656
+ const stack = [rootNode];
657
+ while (stack.length > 0) {
658
+ const node = stack.pop();
659
+ if (node.nodeType !== Node.ELEMENT_NODE) continue;
660
+ if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') continue;
661
+
662
+ elements.push(node);
663
+
664
+ const children = node.children;
665
+ for (let i = children.length - 1; i >= 0; i--) {
666
+ stack.push(children[i]);
667
+ }
668
+
669
+ try {
670
+ if (node.shadowRoot) {
671
+ const shadowChildren = node.shadowRoot.children;
672
+ for (let i = shadowChildren.length - 1; i >= 0; i--) {
673
+ stack.push(shadowChildren[i]);
674
+ }
675
+ }
676
+ } catch {
677
+ // Restricted shadow root - skip
678
+ }
679
+ }
680
+ return elements;
681
+ }
682
+
683
+ /**
684
+ * Gets all frames/iframes in the window (same-origin only).
685
+ * @param {Window} [root=window] - The window object to search
686
+ * @returns {Array<{window: Window, document: Document, isMainFrame: boolean, frameIndex: number}>} Array of frame objects
687
+ */
688
+ export function getAllFrames(root = window) {
689
+ const frames = [];
690
+ try {
691
+ frames.push({ window: root, document: root.document, isMainFrame: true, frameIndex: -1 });
692
+
693
+ const iframes = root.document.querySelectorAll('iframe');
694
+ for (let i = 0; i < iframes.length; i++) {
695
+ const iframe = iframes[i];
696
+ try {
697
+ if (iframe.contentWindow && iframe.contentDocument) {
698
+ frames.push({
699
+ window: iframe.contentWindow,
700
+ document: iframe.contentDocument,
701
+ isMainFrame: false,
702
+ frameElement: iframe,
703
+ frameIndex: i
704
+ });
705
+ }
706
+ } catch (e) {
707
+ if (e.name === 'SecurityError') {
708
+ console.warn('Skipping cross-origin iframe:', e.message);
709
+ } else {
710
+ console.warn('Error accessing iframe:', e.message);
711
+ }
712
+ }
713
+ }
714
+ } catch (e) {
715
+ console.warn('Error getting frames:', e.message);
716
+ }
717
+ return frames;
718
+ }
719
+
720
+ /**
721
+ * Gets the bounding box for an element.
722
+ * @param {Element} el - The DOM element
723
+ * @returns {{x: number, y: number, width: number, height: number, top: number, bottom: number, left: number, right: number, midx: number, midy: number, tagName: string}} Bounding box data
724
+ */
725
+ export function getBoundingBox(el) {
726
+ const rect = el.getBoundingClientRect();
727
+ return {
728
+ x: rect.x,
729
+ y: rect.y,
730
+ width: rect.width,
731
+ height: rect.height,
732
+ top: rect.top,
733
+ bottom: rect.bottom,
734
+ left: rect.left,
735
+ right: rect.right,
736
+ midx: rect.x + rect.width / 2,
737
+ midy: rect.y + rect.height / 2,
738
+ tagName: el.tagName.toLowerCase()
739
+ };
740
+ }
741
+
742
+ /**
743
+ * Checks if an element is hidden (not visible on the page).
744
+ * Considers offset dimensions, CSS visibility, display, and hidden attribute.
745
+ * @param {Element} el - The DOM element to check
746
+ * @returns {boolean} True if the element is hidden
747
+ */
748
+ export function isHidden(el) {
749
+ if (el == null) return true;
750
+
751
+ // Check offset dimensions (element has no size)
752
+ if (el.offsetWidth === 0 && el.offsetHeight === 0) {
753
+ return true;
754
+ }
755
+
756
+ // Check computed styles for visibility and display
757
+ try {
758
+ const style = window.getComputedStyle(el);
759
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') {
760
+ return true;
761
+ }
762
+ if (style.display === 'none') {
763
+ return true;
764
+ }
765
+ } catch {
766
+ // Restricted access - continue with other checks
767
+ }
768
+
769
+ // Check hidden attribute
770
+ if (el.hasAttribute('hidden')) {
771
+ return true;
772
+ }
773
+
774
+ return false;
775
+ }
776
+
777
+ /**
778
+ * Finds elements matching the specified type.
779
+ * Searches all frames (main document + iframes) by default.
780
+ * @param {string} [type="element"] - Element type (see ELEMENT_DEFINITIONS for valid types)
781
+ * @param {Element|null} [parent=null] - Parent element to search within
782
+ * @param {{failOnUnknownType?: boolean}} [options=null] - Search options
783
+ * @returns {{elements: Array<{element: Element|undefined, boundingBox: Object, tagName: string, frameIndex: number}>}} Found elements with metadata
784
+ */
785
+ export function findElementsByType(type = "element", parent = null, options = null) {
786
+ if (type === null || type === undefined) {
787
+ type = "element";
788
+ }
789
+
790
+ if (typeof type !== 'string') {
791
+ throw new TypeError(`type must be a string, got ${typeof type}`);
792
+ }
793
+
794
+ const failOnUnknownType = options && options.failOnUnknownType === true;
795
+ if (type && !ELEMENT_DEFINITIONS[type]) {
796
+ const message = `Unknown element type: ${type}. Valid types: ${Object.keys(ELEMENT_DEFINITIONS).join(', ')}`;
797
+ if (failOnUnknownType) {
798
+ throw new TypeError(`Unknown element type: ${type}`);
799
+ }
800
+ console.warn(message);
801
+ return { elements: [] };
802
+ }
803
+
804
+ const matches = [];
805
+ const frames = getAllFrames(window);
806
+
807
+ for (const frame of frames) {
808
+ const allElements = getAllElements(parent || frame.document);
809
+
810
+ for (let i = 0; i < allElements.length; i++) {
811
+ const el = allElements[i];
812
+
813
+ if (type && !matchesType(el, type)) continue;
814
+
815
+ matches.push({ element: el, frame: frame });
816
+ }
817
+ }
818
+
819
+ // Get innermost matches (exclude parent elements that contain matched children)
820
+ const innermostMatches = [];
821
+ if (matches.length > 0) {
822
+ const matchedElements = new Set(matches.map(m => m.element));
823
+ const excludedElements = new Set();
824
+
825
+ for (let i = matches.length - 1; i >= 0; i--) {
826
+ const match = matches[i];
827
+ const el = match.element;
828
+
829
+ if (!excludedElements.has(el)) {
830
+ innermostMatches.unshift(match);
831
+ let parentEl = el.parentElement;
832
+ while (parentEl) {
833
+ if (matchedElements.has(parentEl)) {
834
+ excludedElements.add(parentEl);
835
+ }
836
+ parentEl = parentEl.parentElement;
837
+ }
838
+ }
839
+ }
840
+ }
841
+
842
+ const qualified = innermostMatches.map(item => {
843
+ const boundingBox = getBoundingBox(item.element);
844
+ const tagName = item.element.tagName.toLowerCase();
845
+ const hidden = isHidden(item.element);
846
+
847
+ if (!item.frame.isMainFrame) {
848
+ return {
849
+ boundingBox: boundingBox,
850
+ tagName: tagName,
851
+ frameIndex: item.frame.frameIndex,
852
+ isHidden: hidden
853
+ };
854
+ }
855
+
856
+ return {
857
+ element: item.element,
858
+ boundingBox: boundingBox,
859
+ tagName: tagName,
860
+ frameIndex: item.frame.frameIndex,
861
+ isHidden: hidden
862
+ };
863
+ });
864
+
865
+ return { elements: qualified };
866
+ }
867
+
868
+ /**
869
+ * Finds elements matching the specified attribute value.
870
+ * Searches all frames (main document + iframes) by default.
871
+ * @param {string} value - The attribute value to search for
872
+ * @param {boolean} [exact=false] - Exact match vs substring
873
+ * @param {Element|null} [parent=null] - Parent element to search within
874
+ * @returns {{elements: Array<{element: Element|undefined, boundingBox: Object, tagName: string, frameIndex: number}>}} Found elements with metadata
875
+ */
876
+ export function findElementsByAttribute(value, exact = false, parent = null) {
877
+ if (value === null || value === undefined) {
878
+ value = '';
879
+ }
880
+
881
+ if (typeof value !== 'string') {
882
+ throw new TypeError(`value must be a string, got ${typeof value}`);
883
+ }
884
+
885
+ const matches = [];
886
+ const frames = getAllFrames(window);
887
+
888
+ for (const frame of frames) {
889
+ const allElements = getAllElements(parent || frame.document);
890
+
891
+ for (let i = 0; i < allElements.length; i++) {
892
+ const el = allElements[i];
893
+
894
+ if (!matchesAttribute(el, value, exact)) continue;
895
+
896
+ matches.push({ element: el, frame: frame });
897
+ }
898
+ }
899
+
900
+ // Filter out parent elements that ONLY match because they contain matching children
901
+ // Keep elements that have their own independent match (attribute or direct text)
902
+ const filteredMatches = matches.filter(item => {
903
+ const el = item.element;
904
+ // Check if this element has its own direct match (not just via descendant)
905
+ const hasDirectMatch = hasOwnMatch(el, value, exact);
906
+ if (hasDirectMatch) return true; // Keep elements with their own match
907
+
908
+ // Check if any descendant also matches - if so, this parent is redundant
909
+ for (const other of matches) {
910
+ if (other.element !== el && el.contains(other.element)) {
911
+ return false; // This element only matches via descendant
912
+ }
913
+ }
914
+ return true;
915
+ });
916
+
917
+ const qualified = filteredMatches.map(item => {
918
+ const boundingBox = getBoundingBox(item.element);
919
+ const tagName = item.element.tagName.toLowerCase();
920
+ const hidden = isHidden(item.element);
921
+
922
+ if (!item.frame.isMainFrame) {
923
+ return {
924
+ boundingBox: boundingBox,
925
+ tagName: tagName,
926
+ frameIndex: item.frame.frameIndex,
927
+ isHidden: hidden
928
+ };
929
+ }
930
+
931
+ return {
932
+ element: item.element,
933
+ boundingBox: boundingBox,
934
+ tagName: tagName,
935
+ frameIndex: item.frame.frameIndex,
936
+ isHidden: hidden
937
+ };
938
+ });
939
+
940
+ return { elements: qualified };
941
+ }
942
+
943
+ /**
944
+ * Checks if an element has its own direct match (attribute or direct text),
945
+ * not just via descendant elements.
946
+ * @param {Element} el - The DOM element to check
947
+ * @param {string} value - The attribute value to search for
948
+ * @param {boolean} [exact=false] - Whether to match exactly or as substring
949
+ * @returns {boolean} True if the element has its own direct match
950
+ */
951
+ function hasOwnMatch(el, value, exact = false) {
952
+ if (value === undefined || value === null || value === '') return true;
953
+
954
+ const attrs = SEARCHABLE_ATTRIBUTES;
955
+
956
+ // Check if any attribute on this element matches
957
+ for (let i = 0; i < attrs.length; i++) {
958
+ const attr = attrs[i];
959
+ let attrValue;
960
+ try {
961
+ attrValue = el.getAttribute(attr);
962
+ } catch {
963
+ continue;
964
+ }
965
+ if (attrValue) {
966
+ // Special handling for aria-labelledby - check both raw value and resolved text
967
+ if (attr === 'aria-labelledby') {
968
+ // First check if the raw attribute value contains the search string
969
+ if (exact ? attrValue === value : attrValue.includes(value)) {
970
+ return true;
971
+ }
972
+ // Then check the resolved text from referenced elements
973
+ const resolvedText = getAriaLabelledByText(el);
974
+ if (resolvedText) {
975
+ if (exact ? resolvedText === value : resolvedText.includes(value)) {
976
+ return true;
977
+ }
978
+ }
979
+ } else if (exact ? attrValue === value : attrValue.includes(value)) {
980
+ return true;
981
+ }
982
+ }
983
+ }
984
+
985
+ // Check if direct text nodes match
986
+ const directText = getDirectText(el);
987
+ if (exact ? directText === value : directText.includes(value)) {
988
+ return true;
989
+ }
990
+
991
+ return false;
992
+ }
993
+
994
+ /**
995
+ * Gets counts of elements by semantic type and visibility on the current screen.
996
+ * Excludes the generic `element` type unless a specific type is requested.
997
+ * If no type is provided, returns counts for all defined non-generic types.
998
+ * Searches all frames (main document + iframes) by default.
999
+ * @param {string|null|undefined} [type=null] - Element type to count. If null/undefined, count all defined non-generic types.
1000
+ * @param {Element|null} [parent=null] - Parent element to count within
1001
+ * @param {{failOnUnknownType?: boolean}} [options=null] - Search options
1002
+ * @returns {Object.<string, {visible: number, hidden: number, total: number}>} Counts keyed by semantic element type, or `{ [type]: { visible, hidden, total } }` when type is provided
1003
+ */
1004
+ export function getElementCounts(type = null, parent = null, options = null) {
1005
+ const hasType = type !== null && type !== undefined;
1006
+
1007
+ if (hasType) {
1008
+ if (typeof type !== 'string') {
1009
+ throw new TypeError(`type must be a string, got ${typeof type}`);
1010
+ }
1011
+ if (!ELEMENT_DEFINITIONS[type]) {
1012
+ const message = `Unknown element type: ${type}. Valid types: ${Object.keys(ELEMENT_DEFINITIONS).join(', ')}`;
1013
+ if (options && options.failOnUnknownType === true) {
1014
+ throw new TypeError(`Unknown element type: ${type}`);
1015
+ }
1016
+ console.warn(message);
1017
+ return { [type]: { visible: 0, hidden: 0, total: 0 } };
1018
+ }
1019
+ }
1020
+
1021
+ const counts = {};
1022
+ const targetTypes = hasType ? [type] : Object.keys(ELEMENT_DEFINITIONS).filter((item) => item !== 'element');
1023
+
1024
+ for (let i = 0; i < targetTypes.length; i++) {
1025
+ counts[targetTypes[i]] = { visible: 0, hidden: 0, total: 0 };
1026
+ }
1027
+
1028
+ const frames = getAllFrames(window);
1029
+
1030
+ for (let i = 0; i < frames.length; i++) {
1031
+ const frame = frames[i];
1032
+ const allElements = getAllElements(parent || frame.document);
1033
+
1034
+ for (let j = 0; j < allElements.length; j++) {
1035
+ const el = allElements[j];
1036
+
1037
+ // Check against all target types and increment count for each match
1038
+ for (let k = 0; k < targetTypes.length; k++) {
1039
+ const targetType = targetTypes[k];
1040
+ if (matchesType(el, targetType)) {
1041
+ const bucket = isHidden(el) ? 'hidden' : 'visible';
1042
+ counts[targetType][bucket] += 1;
1043
+ counts[targetType].total += 1;
1044
+ }
1045
+ }
1046
+ }
1047
+ }
1048
+
1049
+ return counts;
1050
+ }
1051
+
1052
+ /**
1053
+ * Finds elements matching the specified type and/or attribute value.
1054
+ * Combines type and attribute matching in a single call.
1055
+ * @param {string|null} [type=null] - Element type (see ELEMENT_DEFINITIONS for valid types), or null for any type
1056
+ * @param {string|null} [text=null] - Text/attribute value to search for, or null/undefined/'' for any text
1057
+ * @param {boolean} [exact=false] - Exact match vs substring (only used when text is provided)
1058
+ * @param {Element|null} [parent=null] - Parent element to search within
1059
+ * @param {{failOnUnknownType?: boolean}} [options=null] - Search options
1060
+ * @returns {{elements: Array<{element: Element|undefined, boundingBox: Object, tagName: string, frameIndex: number}>}} Found elements with metadata
1061
+ */
1062
+ export function findElements(type = null, text = null, exact = false, parent = null, options = null) {
1063
+ // Normalize text parameter
1064
+ if (text === null || text === undefined) {
1065
+ text = '';
1066
+ }
1067
+
1068
+ // Validate type if provided
1069
+ if (type !== null && type !== undefined) {
1070
+ if (typeof type !== 'string') {
1071
+ throw new TypeError(`type must be a string, got ${typeof type}`);
1072
+ }
1073
+ if (!ELEMENT_DEFINITIONS[type]) {
1074
+ const message = `Unknown element type: ${type}. Valid types: ${Object.keys(ELEMENT_DEFINITIONS).join(', ')}`;
1075
+ if (options && options.failOnUnknownType === true) {
1076
+ throw new TypeError(`Unknown element type: ${type}`);
1077
+ }
1078
+ console.warn(message);
1079
+ return { elements: [] };
1080
+ }
1081
+ }
1082
+
1083
+ // Validate text if provided
1084
+ if (text !== '' && typeof text !== 'string') {
1085
+ throw new TypeError(`text must be a string, got ${typeof text}`);
1086
+ }
1087
+
1088
+ const matches = [];
1089
+ const frames = getAllFrames(window);
1090
+
1091
+ for (const frame of frames) {
1092
+ const allElements = getAllElements(parent || frame.document);
1093
+
1094
+ for (let i = 0; i < allElements.length; i++) {
1095
+ const el = allElements[i];
1096
+
1097
+ // Check type match if type is specified
1098
+ if (type !== null && type !== undefined && !matchesType(el, type)) continue;
1099
+
1100
+ // Check attribute/text match if text is specified (non-empty)
1101
+ if (text !== '' && !matchesAttribute(el, text, exact)) continue;
1102
+
1103
+ matches.push({ element: el, frame: frame });
1104
+ }
1105
+ }
1106
+
1107
+ // Filter out parent elements that ONLY match because they contain matching children
1108
+ // Keep elements that have their own independent match (attribute or direct text)
1109
+ // Only apply this filter when text is provided (not for type-only searches)
1110
+ const filteredMatches = text !== ''
1111
+ ? matches.filter(item => {
1112
+ const el = item.element;
1113
+ // Check if this element has its own direct match (not just via descendant)
1114
+ const hasDirectMatch = hasOwnMatch(el, text, exact);
1115
+ if (hasDirectMatch) return true; // Keep elements with their own match
1116
+
1117
+ // Check if any descendant also matches - if so, this parent is redundant
1118
+ for (const other of matches) {
1119
+ if (other.element !== el && el.contains(other.element)) {
1120
+ return false; // This element only matches via descendant
1121
+ }
1122
+ }
1123
+ return true;
1124
+ })
1125
+ : matches; // For type-only searches, keep all matches (original behavior)
1126
+
1127
+ const qualified = filteredMatches.map(item => {
1128
+ const boundingBox = getBoundingBox(item.element);
1129
+ const tagName = item.element.tagName.toLowerCase();
1130
+ const hidden = isHidden(item.element);
1131
+
1132
+ if (!item.frame.isMainFrame) {
1133
+ return {
1134
+ boundingBox: boundingBox,
1135
+ tagName: tagName,
1136
+ frameIndex: item.frame.frameIndex,
1137
+ isHidden: hidden
1138
+ };
1139
+ }
1140
+
1141
+ return {
1142
+ element: item.element,
1143
+ boundingBox: boundingBox,
1144
+ tagName: tagName,
1145
+ frameIndex: item.frame.frameIndex,
1146
+ isHidden: hidden
1147
+ };
1148
+ });
1149
+
1150
+ return { elements: qualified };
1151
+ }
1152
+
1153
+ /**
1154
+ * Gets the parent element, handling shadow DOM elements.
1155
+ * For elements inside shadow roots, returns the shadow root's host element.
1156
+ * @param {Element} el - The element to get parent for
1157
+ * @returns {Element|null} - The parent element or shadow host
1158
+ */
1159
+ function getParentElement(el) {
1160
+ // Try standard parentElement first
1161
+ if (el.parentElement) {
1162
+ return el.parentElement;
1163
+ }
1164
+
1165
+ // For elements inside shadow roots, getRootNode() returns the shadow root
1166
+ // The shadow root has a 'host' property pointing to the custom element
1167
+ try {
1168
+ const rootNode = el.getRootNode();
1169
+ if (rootNode && rootNode.host) {
1170
+ return rootNode.host;
1171
+ }
1172
+ } catch {
1173
+ // Restricted shadow root - return null
1174
+ }
1175
+
1176
+ return null;
1177
+ }
1178
+
1179
+ /**
1180
+ * Gets siblings of an element, handling shadow DOM elements.
1181
+ * @param {Element} el - The element to get siblings for
1182
+ * @returns {Element[]} - Array of sibling elements
1183
+ */
1184
+ function getSiblingElements(el) {
1185
+ const parent = getParentElement(el);
1186
+ if (!parent) return [];
1187
+
1188
+ // If parent is a shadow root host, get children from the shadow root
1189
+ if (parent.shadowRoot) {
1190
+ try {
1191
+ return Array.from(parent.shadowRoot.children);
1192
+ } catch {
1193
+ // Restricted shadow root
1194
+ return [];
1195
+ }
1196
+ }
1197
+
1198
+ return Array.from(parent.children);
1199
+ }
1200
+
1201
+ /**
1202
+ * Finds a nearby element of the specified type relative to the given element.
1203
+ * Searches parent, then children, then siblings.
1204
+ * Handles shadow DOM elements by traversing through shadow boundaries.
1205
+ * @param {Element} el - The reference element
1206
+ * @param {string} targetType - The element type to find
1207
+ * @returns {Element|null} - Nearby element of target type, or null
1208
+ */
1209
+ function findNearbyElementType(el, targetType) {
1210
+ // Check parent elements (including shadow host traversal)
1211
+ let parent = getParentElement(el);
1212
+ while (parent) {
1213
+ if (matchesType(parent, targetType)) {
1214
+ return parent;
1215
+ }
1216
+ parent = getParentElement(parent);
1217
+ }
1218
+
1219
+ // Check immediate children only (not all descendants)
1220
+ // This prevents matching elements that are far away in the DOM tree
1221
+ const immediateChildren = el.children || [];
1222
+ for (const child of immediateChildren) {
1223
+ if (matchesType(child, targetType)) {
1224
+ return child;
1225
+ }
1226
+ }
1227
+
1228
+ // Check siblings (including shadow DOM siblings)
1229
+ const siblings = getSiblingElements(el);
1230
+ for (const sibling of siblings) {
1231
+ if (sibling !== el && matchesType(sibling, targetType)) {
1232
+ return sibling;
1233
+ }
1234
+ }
1235
+
1236
+ // Check descendants of siblings (for cases where the target element is nested within a sibling)
1237
+ for (const sibling of siblings) {
1238
+ if (sibling === el) continue;
1239
+ const siblingElements = getAllElements(sibling);
1240
+ for (let i = 0; i < siblingElements.length; i++) {
1241
+ if (matchesType(siblingElements[i], targetType)) {
1242
+ return siblingElements[i];
1243
+ }
1244
+ }
1245
+ }
1246
+
1247
+ // Check siblings of ancestors (for cases where the target element is a sibling of the parent)
1248
+ // This handles structures like:
1249
+ // <div class="switch-row">
1250
+ // <div><label>Label text</label></div>
1251
+ // <label class="switch-cb"><input type="checkbox"></label>
1252
+ // </div>
1253
+ let ancestor = el.parentElement;
1254
+ while (ancestor) {
1255
+ const ancestorSiblings = getSiblingElements(ancestor);
1256
+ for (const sibling of ancestorSiblings) {
1257
+ if (sibling !== ancestor) {
1258
+ // Check the sibling itself
1259
+ if (matchesType(sibling, targetType)) {
1260
+ return sibling;
1261
+ }
1262
+ // Check descendants of the sibling
1263
+ const siblingElements = getAllElements(sibling);
1264
+ for (let i = 0; i < siblingElements.length; i++) {
1265
+ if (matchesType(siblingElements[i], targetType)) {
1266
+ return siblingElements[i];
1267
+ }
1268
+ }
1269
+ }
1270
+ }
1271
+ ancestor = ancestor.parentElement;
1272
+ }
1273
+
1274
+ return null;
1275
+ }
1276
+
1277
+ /**
1278
+ * Finds elements matching the specified type and/or attribute value.
1279
+ * When both type and text are provided but no element matches both,
1280
+ * finds elements matching the attribute/text and returns a nearby element of the specified type.
1281
+ * If only type is provided, delegates to findElementsByType.
1282
+ * If only text is provided, delegates to findElementsByAttribute.
1283
+ * @param {string|null|undefined} elementType - Element type (see ELEMENT_DEFINITIONS for valid types). If null/undefined/blank, matches any type.
1284
+ * @param {string|null|undefined} attributeText - Text/attribute value to search for. If null/undefined/blank, matches any text.
1285
+ * @param {boolean} [exact=false] - Exact match vs substring
1286
+ * @param {Element|null} [parent=null] - Parent element to search within
1287
+ * @param {{failOnUnknownType?: boolean}} [options=null] - Search options
1288
+ * @returns {{elements: Array<{element: Element|undefined, boundingBox: Object, tagName: string, frameIndex: number}>}} Found elements with metadata
1289
+ */
1290
+ export function findProbableElements(elementType, attributeText, exact = false, parent = null, options = null) {
1291
+ // Normalize parameters
1292
+ const hasType = elementType !== null && elementType !== undefined && elementType !== '';
1293
+ const hasText = attributeText !== null && attributeText !== undefined && attributeText !== '';
1294
+
1295
+ // If only type is provided, delegate to findElementsByType
1296
+ if (hasType && !hasText) {
1297
+ return findElementsByType(elementType, parent, options);
1298
+ }
1299
+
1300
+ // If only text is provided, delegate to findElementsByAttribute
1301
+ if (!hasType && hasText) {
1302
+ return findElementsByAttribute(attributeText, exact, parent);
1303
+ }
1304
+
1305
+ // Validate elementType if provided
1306
+ if (hasType) {
1307
+ if (typeof elementType !== 'string') {
1308
+ throw new TypeError(`elementType must be a string, got ${typeof elementType}`);
1309
+ }
1310
+ if (!ELEMENT_DEFINITIONS[elementType]) {
1311
+ const message = `Unknown element type: ${elementType}. Valid types: ${Object.keys(ELEMENT_DEFINITIONS).join(', ')}`;
1312
+ if (options && options.failOnUnknownType === true) {
1313
+ throw new TypeError(`Unknown element type: ${elementType}`);
1314
+ }
1315
+ console.warn(message);
1316
+ return { elements: [] };
1317
+ }
1318
+ }
1319
+
1320
+ // Validate attributeText if provided
1321
+ if (hasText) {
1322
+ if (typeof attributeText !== 'string') {
1323
+ throw new TypeError(`attributeText must be a string, got ${typeof attributeText}`);
1324
+ }
1325
+ }
1326
+
1327
+ const matches = [];
1328
+ const frames = getAllFrames(window);
1329
+
1330
+ // First, try to find elements matching both type and attribute text
1331
+ for (const frame of frames) {
1332
+ const allElements = getAllElements(parent || frame.document);
1333
+
1334
+ for (let i = 0; i < allElements.length; i++) {
1335
+ const el = allElements[i];
1336
+
1337
+ // Check type match if type is specified
1338
+ if (hasType && !matchesType(el, elementType)) continue;
1339
+
1340
+ // Check attribute/text match if text is specified
1341
+ if (hasText && !matchesAttribute(el, attributeText, exact)) continue;
1342
+
1343
+ matches.push({ element: el, frame: frame });
1344
+ }
1345
+ }
1346
+
1347
+ // If no matches found with both criteria, try fallback: find attribute matches and get nearby type elements
1348
+ if (matches.length === 0 && hasType && hasText) {
1349
+ const attributeMatches = [];
1350
+ for (const frame of frames) {
1351
+ const allElements = getAllElements(parent || frame.document);
1352
+ for (let i = 0; i < allElements.length; i++) {
1353
+ const el = allElements[i];
1354
+ // Check attribute match
1355
+ if (!matchesAttribute(el, attributeText, exact)) continue;
1356
+ // Only consider elements with their own direct match (not just via descendant)
1357
+ if (hasOwnMatch(el, attributeText, exact)) {
1358
+ attributeMatches.push({ element: el, frame: frame });
1359
+ }
1360
+ }
1361
+ }
1362
+
1363
+ // For each attribute match, find a nearby element of the specified type
1364
+ // Use a Set to track already-found elements to avoid duplicates
1365
+ const foundElements = new Set();
1366
+ for (const match of attributeMatches) {
1367
+ const nearbyElement = findNearbyElementType(match.element, elementType);
1368
+ if (nearbyElement && !foundElements.has(nearbyElement)) {
1369
+ foundElements.add(nearbyElement);
1370
+ matches.push({ element: nearbyElement, frame: match.frame });
1371
+ }
1372
+ }
1373
+ }
1374
+
1375
+ // Filter out parent elements that ONLY match because they contain matching children
1376
+ // Keep elements that have their own independent match (attribute or direct text)
1377
+ // Only apply this filter when attributeText is provided (not for type-only searches)
1378
+ const filteredMatches = hasText
1379
+ ? matches.filter(item => {
1380
+ const el = item.element;
1381
+ // Check if this element has its own direct match (not just via descendant)
1382
+ const hasDirectMatch = hasOwnMatch(el, attributeText, exact);
1383
+ if (hasDirectMatch) return true; // Keep elements with their own match
1384
+
1385
+ // Check if any descendant also matches - if so, this parent is redundant
1386
+ for (const other of matches) {
1387
+ if (other.element !== el && el.contains(other.element)) {
1388
+ return false; // This element only matches via descendant
1389
+ }
1390
+ }
1391
+ return true;
1392
+ })
1393
+ : matches; // For type-only searches, keep all matches (original behavior)
1394
+
1395
+ const qualified = filteredMatches.map(item => {
1396
+ const boundingBox = getBoundingBox(item.element);
1397
+ const tagName = item.element.tagName.toLowerCase();
1398
+ const hidden = isHidden(item.element);
1399
+
1400
+ if (!item.frame.isMainFrame) {
1401
+ return {
1402
+ boundingBox: boundingBox,
1403
+ tagName: tagName,
1404
+ frameIndex: item.frame.frameIndex,
1405
+ isHidden: hidden
1406
+ };
1407
+ }
1408
+
1409
+ return {
1410
+ element: item.element,
1411
+ boundingBox: boundingBox,
1412
+ tagName: tagName,
1413
+ frameIndex: item.frame.frameIndex,
1414
+ isHidden: hidden
1415
+ };
1416
+ });
1417
+
1418
+ return { elements: qualified };
1419
+ }
1420
+
1421
+ /**
1422
+ * Extract elements array from various input formats.
1423
+ */
1424
+ function extractElements(elements) {
1425
+ if (!elements) return [];
1426
+ if (elements && elements.elements && Array.isArray(elements.elements)) {
1427
+ return elements.elements;
1428
+ }
1429
+ return Array.isArray(elements) ? elements : [elements];
1430
+ }
1431
+
1432
+ /**
1433
+ * Highlights elements on the page with a colored outline.
1434
+ * @param {Array|Object} elements - Elements to highlight (from findElementsByType or findElementsByAttribute result or array)
1435
+ * @param {string} [color='red'] - Outline color
1436
+ * @param {number} [width=3] - Outline width in pixels
1437
+ */
1438
+ export function highlight(elements, color = 'red', width = 3) {
1439
+ const items = extractElements(elements);
1440
+
1441
+ for (let i = 0; i < items.length; i++) {
1442
+ const item = items[i];
1443
+ const el = item.element ? item.element : item;
1444
+ if (el && el.style) {
1445
+ el.style.outline = `${width}px solid ${color}`;
1446
+ el.style.outlineOffset = '2px';
1447
+ el.style.boxShadow = `0 0 0 2px rgba(255, 255, 255, 0.8)`;
1448
+ el.classList.add('elementfinder-highlighted');
1449
+ }
1450
+ }
1451
+ }
1452
+
1453
+ /**
1454
+ * Removes highlighting from elements.
1455
+ * @param {Array|Object} elements - Elements to unhighlight (from findElementsByType or findElementsByAttribute result or array)
1456
+ */
1457
+ export function unhighlight(elements) {
1458
+ const items = extractElements(elements);
1459
+
1460
+ for (let i = 0; i < items.length; i++) {
1461
+ const item = items[i];
1462
+ const el = item.element ? item.element : item;
1463
+ if (el && el.style) {
1464
+ el.style.outline = '';
1465
+ el.style.outlineOffset = '';
1466
+ el.style.boxShadow = '';
1467
+ el.classList.remove('elementfinder-highlighted');
1468
+ }
1469
+ }
1470
+ }
1471
+
1472
+ /**
1473
+ * Global state for animation pausing (supports nested pause/resume)
1474
+ */
1475
+ const animationPauseStack = [];
1476
+
1477
+ /**
1478
+ * Pauses all CSS animations and transitions on the page.
1479
+ * Stores original animation state for later restoration.
1480
+ * Supports nested calls - each pause() needs a corresponding resume().
1481
+ * @returns {Object} Object containing the restore function and state info
1482
+ */
1483
+ export function pauseAnimations() {
1484
+ // Store original styles for restoration
1485
+ const originalStyles = new Map();
1486
+ const elements = getAllElements();
1487
+
1488
+ for (const el of elements) {
1489
+ if (el && el.style) {
1490
+ // Only store and modify if not already paused
1491
+ if (el.style.animationPlayState !== 'paused') {
1492
+ originalStyles.set(el, {
1493
+ animationPlayState: el.style.animationPlayState,
1494
+ transitionProperty: el.style.transitionProperty,
1495
+ webkitAnimationPlayState: el.style.webkitAnimationPlayState,
1496
+ webkitTransitionProperty: el.style.webkitTransitionProperty,
1497
+ });
1498
+
1499
+ // Pause animations and disable transitions
1500
+ el.style.animationPlayState = 'paused';
1501
+ el.style.transitionProperty = 'none';
1502
+ el.style.webkitAnimationPlayState = 'paused';
1503
+ el.style.webkitTransitionProperty = 'none';
1504
+ }
1505
+ }
1506
+ }
1507
+
1508
+ // Also pause animations on document level via CSSOM
1509
+ // Only add stylesheet if not already present
1510
+ let styleSheet = document.getElementById('elementfinder-animation-pause');
1511
+ if (!styleSheet) {
1512
+ styleSheet = document.createElement('style');
1513
+ styleSheet.id = 'elementfinder-animation-pause';
1514
+ styleSheet.textContent = `
1515
+ *, *::before, *::after {
1516
+ animation-play-state: paused !important;
1517
+ transition-property: none !important;
1518
+ -webkit-animation-play-state: paused !important;
1519
+ -webkit-transition-property: none !important;
1520
+ }
1521
+ @media (prefers-reduced-motion: no-preference) {
1522
+ *, *::before, *::after {
1523
+ animation-duration: 0s !important;
1524
+ animation-iteration-count: 1 !important;
1525
+ transition-duration: 0s !important;
1526
+ }
1527
+ }
1528
+ `;
1529
+ document.head.appendChild(styleSheet);
1530
+ }
1531
+
1532
+ // Push to stack for nested support
1533
+ const pauseState = { originalStyles, pausedCount: originalStyles.size };
1534
+ animationPauseStack.push(pauseState);
1535
+
1536
+ return pauseState;
1537
+ }
1538
+
1539
+ /**
1540
+ * Resumes all CSS animations and transitions that were previously paused.
1541
+ * Supports nested calls - only removes stylesheet when stack is empty.
1542
+ * @param {Object} [pauseState] - Optional state object from pauseAnimations(). If omitted, pops the most recent pause.
1543
+ */
1544
+ export function resumeAnimations(pauseState) {
1545
+ // If no pauseState provided, pop the most recent from stack (for Selenium/browser use)
1546
+ if (!pauseState) {
1547
+ if (animationPauseStack.length === 0) return;
1548
+ pauseState = animationPauseStack.pop();
1549
+ } else {
1550
+ // If pauseState provided, remove it from stack
1551
+ const index = animationPauseStack.indexOf(pauseState);
1552
+ if (index === -1) return; // Not found in stack
1553
+ animationPauseStack.splice(index, 1);
1554
+ }
1555
+
1556
+ // Restore original styles
1557
+ const originalStyles = pauseState.originalStyles;
1558
+ if (originalStyles) {
1559
+ for (const [el, styles] of originalStyles) {
1560
+ if (el && el.style) {
1561
+ el.style.animationPlayState = styles.animationPlayState || '';
1562
+ el.style.transitionProperty = styles.transitionProperty || '';
1563
+ el.style.webkitAnimationPlayState = styles.webkitAnimationPlayState || '';
1564
+ el.style.webkitTransitionProperty = styles.webkitTransitionProperty || '';
1565
+ }
1566
+ }
1567
+ }
1568
+
1569
+ // Only remove stylesheet when stack is empty (all pauses resolved)
1570
+ if (animationPauseStack.length === 0) {
1571
+ const styleSheet = document.getElementById('elementfinder-animation-pause');
1572
+ if (styleSheet) {
1573
+ styleSheet.remove();
1574
+ }
1575
+ }
1576
+ }
1577
+
1578
+ /**
1579
+ * Returns an array of all valid element type names.
1580
+ */
1581
+ export function getValidTypes() {
1582
+ return Object.keys(ELEMENT_DEFINITIONS);
1583
+ }
1584
+
1585
+ /**
1586
+ * Returns an array of all valid searchable attribute names.
1587
+ */
1588
+ export function getValidAttributes() {
1589
+ return [...SEARCHABLE_ATTRIBUTES];
1590
+ }