@nodebug/browser-element-finder 1.1.8 → 1.2.0

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