playwright-mimic 0.1.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.
- package/README.md +446 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/mimic.d.ts +26 -0
- package/dist/mimic.d.ts.map +1 -0
- package/dist/mimic.js +71 -0
- package/dist/mimic.js.map +1 -0
- package/dist/mimicry/actionType.d.ts +6 -0
- package/dist/mimicry/actionType.d.ts.map +1 -0
- package/dist/mimicry/actionType.js +32 -0
- package/dist/mimicry/actionType.js.map +1 -0
- package/dist/mimicry/click.d.ts +20 -0
- package/dist/mimicry/click.d.ts.map +1 -0
- package/dist/mimicry/click.js +152 -0
- package/dist/mimicry/click.js.map +1 -0
- package/dist/mimicry/forms.d.ts +31 -0
- package/dist/mimicry/forms.d.ts.map +1 -0
- package/dist/mimicry/forms.js +154 -0
- package/dist/mimicry/forms.js.map +1 -0
- package/dist/mimicry/navigation.d.ts +6 -0
- package/dist/mimicry/navigation.d.ts.map +1 -0
- package/dist/mimicry/navigation.js +52 -0
- package/dist/mimicry/navigation.js.map +1 -0
- package/dist/mimicry/schema/action.d.ts +312 -0
- package/dist/mimicry/schema/action.d.ts.map +1 -0
- package/dist/mimicry/schema/action.js +194 -0
- package/dist/mimicry/schema/action.js.map +1 -0
- package/dist/mimicry/selector.d.ts +118 -0
- package/dist/mimicry/selector.d.ts.map +1 -0
- package/dist/mimicry/selector.js +682 -0
- package/dist/mimicry/selector.js.map +1 -0
- package/dist/mimicry.d.ts +24 -0
- package/dist/mimicry.d.ts.map +1 -0
- package/dist/mimicry.js +71 -0
- package/dist/mimicry.js.map +1 -0
- package/dist/utils/token-counter.d.ts +63 -0
- package/dist/utils/token-counter.d.ts.map +1 -0
- package/dist/utils/token-counter.js +171 -0
- package/dist/utils/token-counter.js.map +1 -0
- package/package.json +43 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
;
|
|
2
|
+
/**
|
|
3
|
+
* Capture target elements from the page
|
|
4
|
+
*
|
|
5
|
+
* @param page - Playwright Page object
|
|
6
|
+
* @param options - Optional configuration for capturing targets
|
|
7
|
+
* @returns Promise resolving to array of TargetInfo objects
|
|
8
|
+
*/
|
|
9
|
+
export async function captureTargets(page, options = {}) {
|
|
10
|
+
const { interactableOnly = false } = options;
|
|
11
|
+
return await page.evaluate((interactableOnlyFlag) => {
|
|
12
|
+
const targets = [];
|
|
13
|
+
const interactiveTags = ['button', 'a', 'input', 'select', 'textarea', 'details', 'summary'];
|
|
14
|
+
const interactiveRoles = [
|
|
15
|
+
'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
|
|
16
|
+
'menuitem', 'tab', 'option', 'switch'
|
|
17
|
+
];
|
|
18
|
+
const interactiveSelectors = [
|
|
19
|
+
'button',
|
|
20
|
+
'a[href]',
|
|
21
|
+
'input:not([type="hidden"])',
|
|
22
|
+
'select',
|
|
23
|
+
'textarea',
|
|
24
|
+
'[role="button"]',
|
|
25
|
+
'[role="link"]',
|
|
26
|
+
'[role="textbox"]',
|
|
27
|
+
'[role="checkbox"]',
|
|
28
|
+
'[role="radio"]',
|
|
29
|
+
'[role="combobox"]',
|
|
30
|
+
'[role="menuitem"]',
|
|
31
|
+
'[role="tab"]',
|
|
32
|
+
'[role="option"]',
|
|
33
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
34
|
+
];
|
|
35
|
+
// @ts-ignore - document is not defined in the browser context
|
|
36
|
+
const doc = document;
|
|
37
|
+
// @ts-ignore - window is not defined in the browser context
|
|
38
|
+
const win = window;
|
|
39
|
+
/**
|
|
40
|
+
* Normalize text content by trimming and collapsing whitespace
|
|
41
|
+
*/
|
|
42
|
+
function normalizeText(element) {
|
|
43
|
+
const text = element.textContent || '';
|
|
44
|
+
return text.trim().replace(/\s+/g, ' ');
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Get visible text (excludes hidden elements)
|
|
48
|
+
*/
|
|
49
|
+
function getVisibleText(element) {
|
|
50
|
+
const style = win.getComputedStyle(element);
|
|
51
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
return normalizeText(element);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Check if element is interactive
|
|
58
|
+
*/
|
|
59
|
+
function isInteractive(element) {
|
|
60
|
+
const tag = element.tagName.toLowerCase();
|
|
61
|
+
if (interactiveTags.includes(tag)) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
// Check for interactive ARIA roles
|
|
65
|
+
const role = element.getAttribute('role');
|
|
66
|
+
if (role && interactiveRoles.includes(role)) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
// Check for tabindex (focusable)
|
|
70
|
+
const tabIndex = element.getAttribute('tabindex');
|
|
71
|
+
if (tabIndex !== null && tabIndex !== '-1') {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
// Check for click handlers (heuristic)
|
|
75
|
+
const hasOnClick = element.hasAttribute('onclick') ||
|
|
76
|
+
element.onclick !== null;
|
|
77
|
+
if (hasOnClick) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Check if element has interactive children
|
|
84
|
+
*/
|
|
85
|
+
function hasInteractiveChildren(element) {
|
|
86
|
+
const interactive = element.querySelector('button, a, input, select, textarea, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])');
|
|
87
|
+
return interactive !== null;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Check if element is nested inside an interactive element
|
|
91
|
+
*/
|
|
92
|
+
function isNestedInInteractive(element) {
|
|
93
|
+
let parent = element.parentElement;
|
|
94
|
+
while (parent && parent !== doc.body) {
|
|
95
|
+
if (isInteractive(parent)) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
parent = parent.parentElement;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Infer ARIA role from element
|
|
104
|
+
*/
|
|
105
|
+
function inferRole(element) {
|
|
106
|
+
// Explicit role attribute
|
|
107
|
+
const explicitRole = element.getAttribute('role');
|
|
108
|
+
if (explicitRole) {
|
|
109
|
+
return explicitRole;
|
|
110
|
+
}
|
|
111
|
+
// Infer from tag
|
|
112
|
+
const tag = element.tagName.toLowerCase();
|
|
113
|
+
const roleMap = {
|
|
114
|
+
'button': 'button',
|
|
115
|
+
'a': 'link',
|
|
116
|
+
'input': inferInputRole(element),
|
|
117
|
+
'select': 'combobox',
|
|
118
|
+
'textarea': 'textbox',
|
|
119
|
+
'img': 'img',
|
|
120
|
+
'h1': 'heading',
|
|
121
|
+
'h2': 'heading',
|
|
122
|
+
'h3': 'heading',
|
|
123
|
+
'h4': 'heading',
|
|
124
|
+
'h5': 'heading',
|
|
125
|
+
'h6': 'heading',
|
|
126
|
+
'article': 'article',
|
|
127
|
+
'nav': 'navigation',
|
|
128
|
+
'form': 'form',
|
|
129
|
+
'ul': 'list',
|
|
130
|
+
'ol': 'list',
|
|
131
|
+
'li': 'listitem',
|
|
132
|
+
'table': 'table',
|
|
133
|
+
'tr': 'row',
|
|
134
|
+
'td': 'cell',
|
|
135
|
+
'th': 'cell',
|
|
136
|
+
};
|
|
137
|
+
return roleMap[tag] || null;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Infer input role based on type
|
|
141
|
+
*/
|
|
142
|
+
function inferInputRole(input) {
|
|
143
|
+
const type = input.type?.toLowerCase() || 'text';
|
|
144
|
+
switch (type) {
|
|
145
|
+
case 'button':
|
|
146
|
+
case 'submit':
|
|
147
|
+
case 'reset':
|
|
148
|
+
return 'button';
|
|
149
|
+
case 'checkbox':
|
|
150
|
+
return 'checkbox';
|
|
151
|
+
case 'radio':
|
|
152
|
+
return 'radio';
|
|
153
|
+
case 'email':
|
|
154
|
+
case 'password':
|
|
155
|
+
case 'search':
|
|
156
|
+
case 'tel':
|
|
157
|
+
case 'text':
|
|
158
|
+
case 'url':
|
|
159
|
+
return 'textbox';
|
|
160
|
+
default:
|
|
161
|
+
console.log(`Unknown input type: ${type}`);
|
|
162
|
+
return 'unknown';
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Get associated label text
|
|
167
|
+
*/
|
|
168
|
+
function getLabel(element) {
|
|
169
|
+
// aria-label
|
|
170
|
+
const ariaLabel = element.getAttribute('aria-label');
|
|
171
|
+
if (ariaLabel) {
|
|
172
|
+
return ariaLabel.trim();
|
|
173
|
+
}
|
|
174
|
+
// aria-labelledby
|
|
175
|
+
const labelledBy = element.getAttribute('aria-labelledby');
|
|
176
|
+
if (labelledBy) {
|
|
177
|
+
const labelElement = doc.getElementById(labelledBy);
|
|
178
|
+
if (labelElement) {
|
|
179
|
+
return normalizeText(labelElement);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// label[for] association
|
|
183
|
+
if (element.id) {
|
|
184
|
+
const label = doc.querySelector(`label[for="${element.id}"]`);
|
|
185
|
+
if (label) {
|
|
186
|
+
return normalizeText(label);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Wrapping label
|
|
190
|
+
const parentLabel = element.closest('label');
|
|
191
|
+
if (parentLabel) {
|
|
192
|
+
return normalizeText(parentLabel);
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Get all data-* attributes
|
|
198
|
+
*/
|
|
199
|
+
function getDataset(element) {
|
|
200
|
+
const dataset = {};
|
|
201
|
+
for (const attr of element.attributes) {
|
|
202
|
+
if (attr.name.startsWith('data-')) {
|
|
203
|
+
const key = attr.name.replace(/^data-/, '').replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
204
|
+
dataset[key] = attr.value;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return dataset;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Get nth-of-type index (1-based)
|
|
211
|
+
*/
|
|
212
|
+
function getNthOfType(element) {
|
|
213
|
+
const tag = element.tagName;
|
|
214
|
+
let index = 1;
|
|
215
|
+
let sibling = element.previousElementSibling;
|
|
216
|
+
while (sibling) {
|
|
217
|
+
if (sibling.tagName === tag) {
|
|
218
|
+
index++;
|
|
219
|
+
}
|
|
220
|
+
sibling = sibling.previousElementSibling;
|
|
221
|
+
}
|
|
222
|
+
return index;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Collect interactive elements
|
|
226
|
+
*/
|
|
227
|
+
function collectInteractive() {
|
|
228
|
+
const seen = new Set();
|
|
229
|
+
const elements = doc.querySelectorAll(interactiveSelectors.join(','));
|
|
230
|
+
for (const el of elements) {
|
|
231
|
+
if (seen.has(el) || !isInteractive(el)) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
seen.add(el);
|
|
235
|
+
const text = getVisibleText(el);
|
|
236
|
+
const target = {
|
|
237
|
+
tag: el.tagName.toLowerCase(),
|
|
238
|
+
text,
|
|
239
|
+
id: el.id || null,
|
|
240
|
+
role: inferRole(el),
|
|
241
|
+
label: getLabel(el),
|
|
242
|
+
ariaLabel: el.getAttribute('aria-label') || null,
|
|
243
|
+
typeAttr: el.type || null,
|
|
244
|
+
nameAttr: el.getAttribute('name') || null,
|
|
245
|
+
href: el.href || null,
|
|
246
|
+
dataset: getDataset(el),
|
|
247
|
+
nthOfType: getNthOfType(el),
|
|
248
|
+
};
|
|
249
|
+
targets.push(target);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Check if an element is a valid content candidate
|
|
254
|
+
* (helper function to avoid duplication)
|
|
255
|
+
*/
|
|
256
|
+
function isValidContentCandidate(element) {
|
|
257
|
+
const tag = element.tagName.toLowerCase();
|
|
258
|
+
// Skip structural elements
|
|
259
|
+
if (['body', 'html', 'head', 'script', 'style', 'meta', 'link'].includes(tag)) {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
// Skip if interactive
|
|
263
|
+
if (isInteractive(element)) {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
// Skip if has interactive children
|
|
267
|
+
if (hasInteractiveChildren(element)) {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
// Skip if nested in interactive
|
|
271
|
+
if (isNestedInInteractive(element)) {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
// Must have meaningful text
|
|
275
|
+
const text = getVisibleText(element);
|
|
276
|
+
return text.length > 0 && text.length < 500;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Collect non-interactive content elements
|
|
280
|
+
* (text-bearing elements that are not interactive and not nested)
|
|
281
|
+
* Uses querySelectorAll('*') and filters out elements with interactive children
|
|
282
|
+
* Returns the most parental node to avoid duplication
|
|
283
|
+
*/
|
|
284
|
+
function collectContent() {
|
|
285
|
+
// Query for all elements
|
|
286
|
+
const allElements = doc.querySelectorAll('*');
|
|
287
|
+
const candidates = [];
|
|
288
|
+
// First pass: collect all valid candidates
|
|
289
|
+
for (const element of allElements) {
|
|
290
|
+
if (isValidContentCandidate(element)) {
|
|
291
|
+
candidates.push(element);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// Second pass: filter to keep only the most parental nodes
|
|
295
|
+
// Track which elements are excluded (because a parent was included)
|
|
296
|
+
const excludedElements = new Set();
|
|
297
|
+
// Process candidates from shallowest to deepest (parents before children)
|
|
298
|
+
// This way, when we include a parent, we can mark its descendant candidates as excluded
|
|
299
|
+
const candidatesByDepth = candidates.slice().sort((a, b) => {
|
|
300
|
+
let depthA = 0;
|
|
301
|
+
let depthB = 0;
|
|
302
|
+
let parentA = a.parentElement;
|
|
303
|
+
let parentB = b.parentElement;
|
|
304
|
+
while (parentA && parentA !== doc.body) {
|
|
305
|
+
depthA++;
|
|
306
|
+
parentA = parentA.parentElement;
|
|
307
|
+
}
|
|
308
|
+
while (parentB && parentB !== doc.body) {
|
|
309
|
+
depthB++;
|
|
310
|
+
parentB = parentB.parentElement;
|
|
311
|
+
}
|
|
312
|
+
return depthA - depthB; // Shallower elements first (parents before children)
|
|
313
|
+
});
|
|
314
|
+
for (const element of candidatesByDepth) {
|
|
315
|
+
// Skip if already excluded by a parent
|
|
316
|
+
if (excludedElements.has(element)) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const elementText = getVisibleText(element);
|
|
320
|
+
// Find all descendant candidates that aren't excluded
|
|
321
|
+
const descendantCandidates = [];
|
|
322
|
+
const descendants = element.querySelectorAll('*');
|
|
323
|
+
for (const descendant of descendants) {
|
|
324
|
+
if (candidates.includes(descendant) && !excludedElements.has(descendant)) {
|
|
325
|
+
descendantCandidates.push(descendant);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// If no descendant candidates, this is a leaf node - include it
|
|
329
|
+
if (descendantCandidates.length === 0) {
|
|
330
|
+
const tag = element.tagName.toLowerCase();
|
|
331
|
+
const target = {
|
|
332
|
+
tag,
|
|
333
|
+
text: elementText,
|
|
334
|
+
id: element.id || null,
|
|
335
|
+
role: inferRole(element),
|
|
336
|
+
label: getLabel(element),
|
|
337
|
+
ariaLabel: element.getAttribute('aria-label') || null,
|
|
338
|
+
typeAttr: null,
|
|
339
|
+
nameAttr: element.getAttribute('name') || null,
|
|
340
|
+
href: null,
|
|
341
|
+
dataset: getDataset(element),
|
|
342
|
+
nthOfType: getNthOfType(element),
|
|
343
|
+
};
|
|
344
|
+
targets.push(target);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
// If we have descendant candidates, check if this element adds value
|
|
348
|
+
// Calculate the combined text from all descendant candidates
|
|
349
|
+
const descendantTexts = descendantCandidates.map(desc => getVisibleText(desc));
|
|
350
|
+
const combinedDescendantText = descendantTexts.join(' ').trim();
|
|
351
|
+
// Normalize both texts for comparison (remove extra whitespace)
|
|
352
|
+
const normalizedElementText = elementText.replace(/\s+/g, ' ').trim();
|
|
353
|
+
const normalizedDescendantText = combinedDescendantText.replace(/\s+/g, ' ').trim();
|
|
354
|
+
// If the element's text is exactly the same as the combined descendant text,
|
|
355
|
+
// skip this element (descendants represent the content)
|
|
356
|
+
// Otherwise, keep it (it has additional content beyond descendants)
|
|
357
|
+
if (normalizedElementText !== normalizedDescendantText) {
|
|
358
|
+
// Include this parent element and exclude all its descendant candidates
|
|
359
|
+
const tag = element.tagName.toLowerCase();
|
|
360
|
+
const target = {
|
|
361
|
+
tag,
|
|
362
|
+
text: elementText,
|
|
363
|
+
id: element.id || null,
|
|
364
|
+
role: inferRole(element),
|
|
365
|
+
label: getLabel(element),
|
|
366
|
+
ariaLabel: element.getAttribute('aria-label') || null,
|
|
367
|
+
typeAttr: null,
|
|
368
|
+
nameAttr: element.getAttribute('name') || null,
|
|
369
|
+
href: null,
|
|
370
|
+
dataset: getDataset(element),
|
|
371
|
+
nthOfType: getNthOfType(element),
|
|
372
|
+
};
|
|
373
|
+
targets.push(target);
|
|
374
|
+
// Mark all descendant candidates as excluded (parent contains them)
|
|
375
|
+
for (const descendant of descendantCandidates) {
|
|
376
|
+
excludedElements.add(descendant);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// If text matches, we skip this element and will get the descendants instead
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// Collect interactive elements (always collected)
|
|
383
|
+
collectInteractive();
|
|
384
|
+
// Collect content elements only if interactableOnly is false
|
|
385
|
+
if (!interactableOnlyFlag) {
|
|
386
|
+
// console.log('Collecting content elements ------------------------------');
|
|
387
|
+
collectContent();
|
|
388
|
+
}
|
|
389
|
+
// Deduplicate by element identity (if same id/text/role combo)
|
|
390
|
+
const unique = new Map();
|
|
391
|
+
for (const target of targets) {
|
|
392
|
+
const key = `${target.tag}:${target.id || ''}:${target.text.substring(0, 50)}:${target.role || ''}`;
|
|
393
|
+
if (!unique.has(key)) {
|
|
394
|
+
unique.set(key, target);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return Array.from(unique.values());
|
|
398
|
+
}, interactableOnly);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Escape special characters in CSS selector attribute values
|
|
402
|
+
*
|
|
403
|
+
* @param value - The attribute value to escape
|
|
404
|
+
* @returns Escaped value safe for use in CSS selectors
|
|
405
|
+
*/
|
|
406
|
+
function escapeSelectorValue(value) {
|
|
407
|
+
// Escape quotes and backslashes for CSS attribute selectors
|
|
408
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Build the best Playwright selector for a given target element
|
|
412
|
+
*
|
|
413
|
+
* Selectors are prioritized by stability:
|
|
414
|
+
* 1. ID selector (most stable)
|
|
415
|
+
* 2. Role + aria-label or label
|
|
416
|
+
* 3. Data attributes (data-testid, data-id, etc.)
|
|
417
|
+
* 4. Name attribute (for form elements)
|
|
418
|
+
* 5. Role + text content
|
|
419
|
+
* 6. Tag + nth-of-type (least stable, fallback)
|
|
420
|
+
*
|
|
421
|
+
* @param target - TargetInfo object containing element metadata
|
|
422
|
+
* @returns Playwright selector string optimized for stability and reliability
|
|
423
|
+
*/
|
|
424
|
+
/**
|
|
425
|
+
* Score how well an element matches the target information
|
|
426
|
+
* Higher score = better match
|
|
427
|
+
*
|
|
428
|
+
* @param elementIndex - Index of the element in the locator's matches
|
|
429
|
+
* @param locator - Playwright Locator that matches multiple elements
|
|
430
|
+
* @param target - TargetInfo to match against
|
|
431
|
+
* @returns Score indicating match quality (0-100)
|
|
432
|
+
*/
|
|
433
|
+
async function scoreElementMatch(elementIndex, locator, target) {
|
|
434
|
+
// Get element properties by evaluating on the specific element
|
|
435
|
+
// Note: Inside evaluate(), we're in the browser context where DOM APIs are available
|
|
436
|
+
const elementInfo = await locator.nth(elementIndex).evaluate((el) => {
|
|
437
|
+
const getVisibleText = (element) => {
|
|
438
|
+
// @ts-ignore - window is available in browser context
|
|
439
|
+
const style = window.getComputedStyle(element);
|
|
440
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
|
441
|
+
return '';
|
|
442
|
+
}
|
|
443
|
+
return (element.textContent || '').trim().replace(/\s+/g, ' ');
|
|
444
|
+
};
|
|
445
|
+
const getLabel = (element) => {
|
|
446
|
+
const ariaLabel = element.getAttribute('aria-label');
|
|
447
|
+
if (ariaLabel)
|
|
448
|
+
return ariaLabel.trim();
|
|
449
|
+
const labelledBy = element.getAttribute('aria-labelledby');
|
|
450
|
+
if (labelledBy) {
|
|
451
|
+
// @ts-ignore - document is available in browser context
|
|
452
|
+
const labelEl = document.getElementById(labelledBy);
|
|
453
|
+
if (labelEl)
|
|
454
|
+
return (labelEl.textContent || '').trim();
|
|
455
|
+
}
|
|
456
|
+
if (element.id) {
|
|
457
|
+
// @ts-ignore - document is available in browser context
|
|
458
|
+
const label = document.querySelector(`label[for="${element.id}"]`);
|
|
459
|
+
if (label)
|
|
460
|
+
return (label.textContent || '').trim();
|
|
461
|
+
}
|
|
462
|
+
const parentLabel = element.closest('label');
|
|
463
|
+
if (parentLabel)
|
|
464
|
+
return (parentLabel.textContent || '').trim();
|
|
465
|
+
return null;
|
|
466
|
+
};
|
|
467
|
+
const dataset = {};
|
|
468
|
+
for (const attr of el.attributes) {
|
|
469
|
+
if (attr.name.startsWith('data-')) {
|
|
470
|
+
const key = attr.name.replace(/^data-/, '').replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
471
|
+
dataset[key] = attr.value;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
tag: el.tagName.toLowerCase(),
|
|
476
|
+
text: getVisibleText(el),
|
|
477
|
+
id: el.id || null,
|
|
478
|
+
role: el.getAttribute('role') || null,
|
|
479
|
+
label: getLabel(el),
|
|
480
|
+
ariaLabel: el.getAttribute('aria-label') || null,
|
|
481
|
+
typeAttr: el.type || null,
|
|
482
|
+
nameAttr: el.getAttribute('name') || null,
|
|
483
|
+
dataset,
|
|
484
|
+
};
|
|
485
|
+
});
|
|
486
|
+
if (!elementInfo)
|
|
487
|
+
return 0;
|
|
488
|
+
let score = 0;
|
|
489
|
+
// Tag match (10 points)
|
|
490
|
+
if (elementInfo.tag === target.tag) {
|
|
491
|
+
score += 10;
|
|
492
|
+
}
|
|
493
|
+
// ID match (30 points - very specific)
|
|
494
|
+
if (target.id && elementInfo.id === target.id) {
|
|
495
|
+
score += 30;
|
|
496
|
+
}
|
|
497
|
+
// Role match (15 points)
|
|
498
|
+
if (target.role && elementInfo.role === target.role) {
|
|
499
|
+
score += 15;
|
|
500
|
+
}
|
|
501
|
+
// Text match (20 points)
|
|
502
|
+
if (target.text && elementInfo.text) {
|
|
503
|
+
const targetText = target.text.trim().toLowerCase();
|
|
504
|
+
const elementText = elementInfo.text.trim().toLowerCase();
|
|
505
|
+
if (targetText === elementText) {
|
|
506
|
+
score += 20; // Exact match
|
|
507
|
+
}
|
|
508
|
+
else if (elementText.includes(targetText) || targetText.includes(elementText)) {
|
|
509
|
+
score += 10; // Partial match
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// Aria-label match (15 points)
|
|
513
|
+
if (target.ariaLabel && elementInfo.ariaLabel) {
|
|
514
|
+
if (target.ariaLabel.trim().toLowerCase() === elementInfo.ariaLabel.trim().toLowerCase()) {
|
|
515
|
+
score += 15;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
// Label match (15 points)
|
|
519
|
+
if (target.label && elementInfo.label) {
|
|
520
|
+
if (target.label.trim().toLowerCase() === elementInfo.label.trim().toLowerCase()) {
|
|
521
|
+
score += 15;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
// Type attribute match (10 points)
|
|
525
|
+
if (target.typeAttr && elementInfo.typeAttr === target.typeAttr) {
|
|
526
|
+
score += 10;
|
|
527
|
+
}
|
|
528
|
+
// Name attribute match (15 points)
|
|
529
|
+
if (target.nameAttr && elementInfo.nameAttr === target.nameAttr) {
|
|
530
|
+
score += 15;
|
|
531
|
+
}
|
|
532
|
+
// Dataset match (10 points for testid, 5 for others)
|
|
533
|
+
if (target.dataset && elementInfo.dataset) {
|
|
534
|
+
if (target.dataset.testid && elementInfo.dataset.testid === target.dataset.testid) {
|
|
535
|
+
score += 10;
|
|
536
|
+
}
|
|
537
|
+
// Check other dataset keys
|
|
538
|
+
for (const key in target.dataset) {
|
|
539
|
+
if (target.dataset[key] && elementInfo.dataset[key] === target.dataset[key]) {
|
|
540
|
+
score += 5;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return score;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Build the best Playwright locator for a given target element
|
|
548
|
+
*
|
|
549
|
+
* Follows Playwright's recommended selector priority:
|
|
550
|
+
* 1. data-testid (use page.getByTestId()) - #1 recommendation, most stable
|
|
551
|
+
* 2. Role-based (use page.getByRole()) - #2 recommendation, accessibility-based
|
|
552
|
+
* 3. Text-based (use page.getByText()) - #3 recommendation, good for visible text
|
|
553
|
+
* 4. CSS selectors as fallback (ID, data attributes, name, tag selectors)
|
|
554
|
+
*
|
|
555
|
+
* If a locator matches multiple elements, this function will evaluate each
|
|
556
|
+
* and return the one that best matches the target information.
|
|
557
|
+
*
|
|
558
|
+
* @param page - Playwright Page object
|
|
559
|
+
* @param target - TargetInfo object containing element metadata
|
|
560
|
+
* @returns Playwright Locator for the target element, prioritized by Playwright's best practices
|
|
561
|
+
*/
|
|
562
|
+
export async function buildSelectorForTarget(page, target) {
|
|
563
|
+
if (!target) {
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Helper function to check if locator matches multiple elements and pick the best one
|
|
568
|
+
*/
|
|
569
|
+
const resolveBestLocator = async (locator) => {
|
|
570
|
+
const count = await locator.count();
|
|
571
|
+
// If only one match, return it directly
|
|
572
|
+
if (count <= 1) {
|
|
573
|
+
return locator;
|
|
574
|
+
}
|
|
575
|
+
// If multiple matches, score each one and pick the best
|
|
576
|
+
const scores = [];
|
|
577
|
+
for (let i = 0; i < count; i++) {
|
|
578
|
+
const score = await scoreElementMatch(i, locator, target);
|
|
579
|
+
scores.push({ index: i, score });
|
|
580
|
+
}
|
|
581
|
+
// Sort by score (highest first)
|
|
582
|
+
scores.sort((a, b) => b.score - a.score);
|
|
583
|
+
// Return the best matching element using .nth()
|
|
584
|
+
// We know scores has at least one element since count > 1
|
|
585
|
+
const bestMatch = scores[0];
|
|
586
|
+
if (!bestMatch) {
|
|
587
|
+
// Fallback to first element if somehow scores is empty
|
|
588
|
+
return locator.first();
|
|
589
|
+
}
|
|
590
|
+
return locator.nth(bestMatch.index);
|
|
591
|
+
};
|
|
592
|
+
// Priority 1: data-testid (Playwright's #1 recommendation)
|
|
593
|
+
// Use page.getByTestId() - most stable and recommended
|
|
594
|
+
if (target.dataset && target.dataset.testid) {
|
|
595
|
+
const locator = page.getByTestId(target.dataset.testid);
|
|
596
|
+
return await resolveBestLocator(locator);
|
|
597
|
+
}
|
|
598
|
+
// Priority 2: Role-based selector (Playwright's #2 recommendation)
|
|
599
|
+
// Use page.getByRole() - accessibility-based, very stable
|
|
600
|
+
if (target.role) {
|
|
601
|
+
let locator;
|
|
602
|
+
// If we have aria-label, use it as the name parameter for getByRole
|
|
603
|
+
if (target.ariaLabel) {
|
|
604
|
+
locator = page.getByRole(target.role, { name: target.ariaLabel });
|
|
605
|
+
}
|
|
606
|
+
// If we have a label, use it as the name parameter
|
|
607
|
+
else if (target.label) {
|
|
608
|
+
locator = page.getByRole(target.role, { name: target.label });
|
|
609
|
+
}
|
|
610
|
+
// If we have text content, use it as the name parameter
|
|
611
|
+
else if (target.text && target.text.trim().length > 0) {
|
|
612
|
+
locator = page.getByRole(target.role, { name: target.text.trim() });
|
|
613
|
+
}
|
|
614
|
+
// Just role without name
|
|
615
|
+
else {
|
|
616
|
+
locator = page.getByRole(target.role);
|
|
617
|
+
}
|
|
618
|
+
return await resolveBestLocator(locator);
|
|
619
|
+
}
|
|
620
|
+
// Priority 3: Text-based selector (Playwright's #3 recommendation)
|
|
621
|
+
// Use page.getByText() - good for elements with visible text
|
|
622
|
+
if (target.text && target.text.trim().length > 0) {
|
|
623
|
+
const trimmedText = target.text.trim();
|
|
624
|
+
// For short, specific text, use exact match
|
|
625
|
+
// For longer text, use partial match
|
|
626
|
+
const useExact = trimmedText.length < 50 && !trimmedText.includes('\n');
|
|
627
|
+
const locator = page.getByText(trimmedText, { exact: useExact });
|
|
628
|
+
return await resolveBestLocator(locator);
|
|
629
|
+
}
|
|
630
|
+
// Priority 4: ID selector (CSS fallback)
|
|
631
|
+
// Still stable but not Playwright's preferred method
|
|
632
|
+
if (target.id) {
|
|
633
|
+
const locator = page.locator(`#${target.id}`);
|
|
634
|
+
return await resolveBestLocator(locator);
|
|
635
|
+
}
|
|
636
|
+
// Priority 5: Other data attributes (CSS fallback)
|
|
637
|
+
if (target.dataset && Object.keys(target.dataset).length > 0) {
|
|
638
|
+
let locator;
|
|
639
|
+
// Prefer data-id if available
|
|
640
|
+
if (target.dataset.id) {
|
|
641
|
+
const escapedValue = escapeSelectorValue(target.dataset.id);
|
|
642
|
+
locator = page.locator(`[data-id="${escapedValue}"]`);
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
// Use first data attribute as fallback
|
|
646
|
+
const dataKeys = Object.keys(target.dataset);
|
|
647
|
+
if (dataKeys.length > 0) {
|
|
648
|
+
const firstKey = dataKeys[0];
|
|
649
|
+
if (firstKey) {
|
|
650
|
+
// Convert camelCase to kebab-case: testId -> test-id
|
|
651
|
+
const dataKey = firstKey
|
|
652
|
+
.replace(/([A-Z])/g, '-$1')
|
|
653
|
+
.toLowerCase();
|
|
654
|
+
const value = target.dataset[firstKey];
|
|
655
|
+
if (value) {
|
|
656
|
+
const escapedValue = escapeSelectorValue(value);
|
|
657
|
+
locator = page.locator(`[data-${dataKey}="${escapedValue}"]`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
if (locator) {
|
|
663
|
+
return await resolveBestLocator(locator);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
// Priority 6: Name attribute (CSS fallback, useful for form elements)
|
|
667
|
+
if (target.nameAttr) {
|
|
668
|
+
const escapedName = escapeSelectorValue(target.nameAttr);
|
|
669
|
+
const locator = page.locator(`[name="${escapedName}"]`);
|
|
670
|
+
return await resolveBestLocator(locator);
|
|
671
|
+
}
|
|
672
|
+
// Priority 7: Tag + type attribute (CSS fallback, for inputs)
|
|
673
|
+
if (target.tag === 'input' && target.typeAttr) {
|
|
674
|
+
const locator = page.locator(`input[type="${target.typeAttr}"]`);
|
|
675
|
+
return await resolveBestLocator(locator);
|
|
676
|
+
}
|
|
677
|
+
// Priority 8: Tag + nth-of-type (CSS fallback, least stable)
|
|
678
|
+
// This is the most fragile selector but ensures we can always find something
|
|
679
|
+
// No need to check for multiple matches here since nth-of-type is already specific
|
|
680
|
+
return page.locator(`${target.tag}:nth-of-type(${target.nthOfType})`);
|
|
681
|
+
}
|
|
682
|
+
//# sourceMappingURL=selector.js.map
|