@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.
- package/README.md +8 -8
- package/index.js +23 -5
- package/index.min.js +2 -2
- package/package.json +2 -1
- package/src/element-finder.js +1590 -0
|
@@ -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
|
+
}
|