playwright-mimic 0.1.0 → 0.1.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.
Files changed (110) hide show
  1. package/README.md +633 -446
  2. package/dist/agentic/agent.d.ts +106 -0
  3. package/dist/agentic/agent.d.ts.map +1 -0
  4. package/dist/agentic/agent.js +528 -0
  5. package/dist/agentic/agent.js.map +1 -0
  6. package/dist/agentic/index.d.ts +13 -0
  7. package/dist/agentic/index.d.ts.map +1 -0
  8. package/dist/agentic/index.js +12 -0
  9. package/dist/agentic/index.js.map +1 -0
  10. package/dist/agentic/planner.d.ts +41 -0
  11. package/dist/agentic/planner.d.ts.map +1 -0
  12. package/dist/agentic/planner.js +136 -0
  13. package/dist/agentic/planner.js.map +1 -0
  14. package/dist/agentic/react.d.ts +35 -0
  15. package/dist/agentic/react.d.ts.map +1 -0
  16. package/dist/agentic/react.js +170 -0
  17. package/dist/agentic/react.js.map +1 -0
  18. package/dist/agentic/recovery.d.ts +55 -0
  19. package/dist/agentic/recovery.d.ts.map +1 -0
  20. package/dist/agentic/recovery.js +200 -0
  21. package/dist/agentic/recovery.js.map +1 -0
  22. package/dist/agentic/reflection.d.ts +40 -0
  23. package/dist/agentic/reflection.d.ts.map +1 -0
  24. package/dist/agentic/reflection.js +142 -0
  25. package/dist/agentic/reflection.js.map +1 -0
  26. package/dist/agentic/types.d.ts +177 -0
  27. package/dist/agentic/types.d.ts.map +1 -0
  28. package/dist/agentic/types.js +8 -0
  29. package/dist/agentic/types.js.map +1 -0
  30. package/dist/agentic/wait.d.ts +50 -0
  31. package/dist/agentic/wait.d.ts.map +1 -0
  32. package/dist/agentic/wait.js +140 -0
  33. package/dist/agentic/wait.js.map +1 -0
  34. package/dist/agentic-mimic.d.ts +56 -0
  35. package/dist/agentic-mimic.d.ts.map +1 -0
  36. package/dist/agentic-mimic.js +98 -0
  37. package/dist/agentic-mimic.js.map +1 -0
  38. package/dist/index.d.ts +11 -8
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +12 -9
  41. package/dist/index.js.map +1 -1
  42. package/dist/mimic/actionType.d.ts +7 -0
  43. package/dist/mimic/actionType.d.ts.map +1 -0
  44. package/dist/mimic/actionType.js +44 -0
  45. package/dist/mimic/actionType.js.map +1 -0
  46. package/dist/mimic/annotations.d.ts +20 -0
  47. package/dist/mimic/annotations.d.ts.map +1 -0
  48. package/dist/mimic/annotations.js +30 -0
  49. package/dist/mimic/annotations.js.map +1 -0
  50. package/dist/mimic/cli.d.ts +15 -0
  51. package/dist/mimic/cli.d.ts.map +1 -0
  52. package/dist/mimic/cli.js +17 -0
  53. package/dist/mimic/cli.js.map +1 -0
  54. package/dist/mimic/click.d.ts +39 -0
  55. package/dist/mimic/click.d.ts.map +1 -0
  56. package/dist/mimic/click.js +233 -0
  57. package/dist/mimic/click.js.map +1 -0
  58. package/dist/mimic/forms.d.ts +47 -0
  59. package/dist/mimic/forms.d.ts.map +1 -0
  60. package/dist/mimic/forms.js +264 -0
  61. package/dist/mimic/forms.js.map +1 -0
  62. package/dist/mimic/navigation.d.ts +19 -0
  63. package/dist/mimic/navigation.d.ts.map +1 -0
  64. package/dist/mimic/navigation.js +117 -0
  65. package/dist/mimic/navigation.js.map +1 -0
  66. package/dist/mimic/replay.d.ts +21 -0
  67. package/dist/mimic/replay.d.ts.map +1 -0
  68. package/dist/mimic/replay.js +133 -0
  69. package/dist/mimic/replay.js.map +1 -0
  70. package/dist/mimic/schema/action.d.ts +315 -0
  71. package/dist/mimic/schema/action.d.ts.map +1 -0
  72. package/dist/mimic/schema/action.js +204 -0
  73. package/dist/mimic/schema/action.js.map +1 -0
  74. package/dist/mimic/selector.d.ts +139 -0
  75. package/dist/mimic/selector.d.ts.map +1 -0
  76. package/dist/mimic/selector.js +1103 -0
  77. package/dist/mimic/selector.js.map +1 -0
  78. package/dist/mimic/selectorDescriptor.d.ts +13 -0
  79. package/dist/mimic/selectorDescriptor.d.ts.map +1 -0
  80. package/dist/mimic/selectorDescriptor.js +9 -0
  81. package/dist/mimic/selectorDescriptor.js.map +1 -0
  82. package/dist/mimic/selectorSerialization.d.ts +30 -0
  83. package/dist/mimic/selectorSerialization.d.ts.map +1 -0
  84. package/dist/mimic/selectorSerialization.js +170 -0
  85. package/dist/mimic/selectorSerialization.js.map +1 -0
  86. package/dist/mimic/selectorTypes.d.ts +200 -0
  87. package/dist/mimic/selectorTypes.d.ts.map +1 -0
  88. package/dist/mimic/selectorTypes.js +2 -0
  89. package/dist/mimic/selectorTypes.js.map +1 -0
  90. package/dist/mimic/selectorUtils.d.ts +26 -0
  91. package/dist/mimic/selectorUtils.d.ts.map +1 -0
  92. package/dist/mimic/selectorUtils.js +144 -0
  93. package/dist/mimic/selectorUtils.js.map +1 -0
  94. package/dist/mimic/storage.d.ts +75 -0
  95. package/dist/mimic/storage.d.ts.map +1 -0
  96. package/dist/mimic/storage.js +197 -0
  97. package/dist/mimic/storage.js.map +1 -0
  98. package/dist/mimic/types.d.ts +63 -0
  99. package/dist/mimic/types.d.ts.map +1 -0
  100. package/dist/mimic/types.js +7 -0
  101. package/dist/mimic/types.js.map +1 -0
  102. package/dist/mimic.d.ts +28 -4
  103. package/dist/mimic.d.ts.map +1 -1
  104. package/dist/mimic.js +374 -32
  105. package/dist/mimic.js.map +1 -1
  106. package/dist/mimicry.d.ts +4 -4
  107. package/dist/mimicry.d.ts.map +1 -1
  108. package/dist/mimicry.js +22 -13
  109. package/dist/mimicry.js.map +1 -1
  110. package/package.json +5 -2
@@ -0,0 +1,1103 @@
1
+ import { verifySelectorUniqueness } from './selectorDescriptor.js';
2
+ ;
3
+ /**
4
+ * Capture target elements from the page
5
+ *
6
+ * @param page - Playwright Page object
7
+ * @param options - Optional configuration for capturing targets
8
+ * @returns Promise resolving to array of TargetInfo objects
9
+ */
10
+ export async function captureTargets(page, options = {}) {
11
+ const { interactableOnly = false } = options;
12
+ return await page.evaluate((interactableOnlyFlag) => {
13
+ const targets = [];
14
+ const interactiveTags = ['button', 'a', 'input', 'select', 'textarea', 'details', 'summary'];
15
+ const interactiveRoles = [
16
+ 'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
17
+ 'menuitem', 'tab', 'option', 'switch'
18
+ ];
19
+ const interactiveSelectors = [
20
+ 'button',
21
+ 'a[href]',
22
+ 'input:not([type="hidden"])',
23
+ 'select',
24
+ 'textarea',
25
+ '[role="button"]',
26
+ '[role="link"]',
27
+ '[role="textbox"]',
28
+ '[role="checkbox"]',
29
+ '[role="radio"]',
30
+ '[role="combobox"]',
31
+ '[role="menuitem"]',
32
+ '[role="tab"]',
33
+ '[role="option"]',
34
+ '[tabindex]:not([tabindex="-1"])',
35
+ ];
36
+ // @ts-ignore - document is not defined in the browser context
37
+ const doc = document;
38
+ // @ts-ignore - window is not defined in the browser context
39
+ const win = window;
40
+ /**
41
+ * Normalize text content by trimming and collapsing whitespace
42
+ */
43
+ function normalizeText(element) {
44
+ const text = element.textContent || '';
45
+ return text.trim().replace(/\s+/g, ' ');
46
+ }
47
+ /**
48
+ * Get visible text (excludes hidden elements)
49
+ */
50
+ function getVisibleText(element) {
51
+ const style = win.getComputedStyle(element);
52
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
53
+ return '';
54
+ }
55
+ return normalizeText(element);
56
+ }
57
+ /**
58
+ * Check if element is interactive
59
+ */
60
+ function isInteractive(element) {
61
+ const tag = element.tagName.toLowerCase();
62
+ if (interactiveTags.includes(tag)) {
63
+ return true;
64
+ }
65
+ // Check for interactive ARIA roles
66
+ const role = element.getAttribute('role');
67
+ if (role && interactiveRoles.includes(role)) {
68
+ return true;
69
+ }
70
+ // Check for tabindex (focusable)
71
+ const tabIndex = element.getAttribute('tabindex');
72
+ if (tabIndex !== null && tabIndex !== '-1') {
73
+ return true;
74
+ }
75
+ // Check for click handlers (heuristic)
76
+ const hasOnClick = element.hasAttribute('onclick') ||
77
+ element.onclick !== null;
78
+ if (hasOnClick) {
79
+ return true;
80
+ }
81
+ return false;
82
+ }
83
+ /**
84
+ * Check if element has interactive children
85
+ */
86
+ function hasInteractiveChildren(element) {
87
+ const interactive = element.querySelector('button, a, input, select, textarea, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])');
88
+ return interactive !== null;
89
+ }
90
+ /**
91
+ * Check if element is nested inside an interactive element
92
+ */
93
+ function isNestedInInteractive(element) {
94
+ let parent = element.parentElement;
95
+ while (parent && parent !== doc.body) {
96
+ if (isInteractive(parent)) {
97
+ return true;
98
+ }
99
+ parent = parent.parentElement;
100
+ }
101
+ return false;
102
+ }
103
+ /**
104
+ * Infer ARIA role from element
105
+ */
106
+ function inferRole(element) {
107
+ // Explicit role attribute
108
+ const explicitRole = element.getAttribute('role');
109
+ if (explicitRole) {
110
+ return explicitRole;
111
+ }
112
+ // Infer from tag
113
+ const tag = element.tagName.toLowerCase();
114
+ const roleMap = {
115
+ 'button': 'button',
116
+ 'a': 'link',
117
+ 'input': inferInputRole(element),
118
+ 'select': 'combobox',
119
+ 'textarea': 'textbox',
120
+ 'img': 'img',
121
+ 'h1': 'heading',
122
+ 'h2': 'heading',
123
+ 'h3': 'heading',
124
+ 'h4': 'heading',
125
+ 'h5': 'heading',
126
+ 'h6': 'heading',
127
+ 'article': 'article',
128
+ 'nav': 'navigation',
129
+ 'form': 'form',
130
+ 'ul': 'list',
131
+ 'ol': 'list',
132
+ 'li': 'listitem',
133
+ 'table': 'table',
134
+ 'tr': 'row',
135
+ 'td': 'cell',
136
+ 'th': 'cell',
137
+ };
138
+ return roleMap[tag] || null;
139
+ }
140
+ /**
141
+ * Infer input role based on type
142
+ */
143
+ function inferInputRole(input) {
144
+ const type = input.type?.toLowerCase() || 'text';
145
+ switch (type) {
146
+ case 'button':
147
+ case 'submit':
148
+ case 'reset':
149
+ return 'button';
150
+ case 'checkbox':
151
+ return 'checkbox';
152
+ case 'radio':
153
+ return 'radio';
154
+ case 'email':
155
+ case 'password':
156
+ case 'search':
157
+ case 'tel':
158
+ case 'text':
159
+ case 'url':
160
+ return 'textbox';
161
+ default:
162
+ console.log(`Unknown input type: ${type}`);
163
+ return 'unknown';
164
+ }
165
+ }
166
+ /**
167
+ * Get associated label text
168
+ */
169
+ function getLabel(element) {
170
+ // aria-label
171
+ const ariaLabel = element.getAttribute('aria-label');
172
+ if (ariaLabel) {
173
+ return ariaLabel.trim();
174
+ }
175
+ // aria-labelledby
176
+ const labelledBy = element.getAttribute('aria-labelledby');
177
+ if (labelledBy) {
178
+ const labelElement = doc.getElementById(labelledBy);
179
+ if (labelElement) {
180
+ return normalizeText(labelElement);
181
+ }
182
+ }
183
+ // label[for] association
184
+ if (element.id) {
185
+ const label = doc.querySelector(`label[for="${element.id}"]`);
186
+ if (label) {
187
+ return normalizeText(label);
188
+ }
189
+ }
190
+ // Wrapping label
191
+ const parentLabel = element.closest('label');
192
+ if (parentLabel) {
193
+ return normalizeText(parentLabel);
194
+ }
195
+ return null;
196
+ }
197
+ /**
198
+ * Get all data-* attributes
199
+ */
200
+ function getDataset(element) {
201
+ const dataset = {};
202
+ for (const attr of element.attributes) {
203
+ if (attr.name.startsWith('data-')) {
204
+ const key = attr.name.replace(/^data-/, '').replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
205
+ dataset[key] = attr.value;
206
+ }
207
+ }
208
+ return dataset;
209
+ }
210
+ /**
211
+ * Get nth-of-type index (1-based)
212
+ */
213
+ function getNthOfType(element) {
214
+ const tag = element.tagName;
215
+ let index = 1;
216
+ let sibling = element.previousElementSibling;
217
+ while (sibling) {
218
+ if (sibling.tagName === tag) {
219
+ index++;
220
+ }
221
+ sibling = sibling.previousElementSibling;
222
+ }
223
+ return index;
224
+ }
225
+ /**
226
+ * Collect interactive elements
227
+ */
228
+ function collectInteractive() {
229
+ const seen = new Set();
230
+ const elements = doc.querySelectorAll(interactiveSelectors.join(','));
231
+ for (const el of elements) {
232
+ if (seen.has(el) || !isInteractive(el)) {
233
+ continue;
234
+ }
235
+ seen.add(el);
236
+ const text = getVisibleText(el);
237
+ const target = {
238
+ tag: el.tagName.toLowerCase(),
239
+ text,
240
+ id: el.id || null,
241
+ role: inferRole(el),
242
+ label: getLabel(el),
243
+ ariaLabel: el.getAttribute('aria-label') || null,
244
+ typeAttr: el.type || null,
245
+ nameAttr: el.getAttribute('name') || null,
246
+ href: el.href || null,
247
+ dataset: getDataset(el),
248
+ nthOfType: getNthOfType(el),
249
+ };
250
+ targets.push(target);
251
+ }
252
+ }
253
+ /**
254
+ * Check if an element is a valid content candidate
255
+ * (helper function to avoid duplication)
256
+ */
257
+ function isValidContentCandidate(element) {
258
+ const tag = element.tagName.toLowerCase();
259
+ // Skip structural elements
260
+ if (['body', 'html', 'head', 'script', 'style', 'meta', 'link'].includes(tag)) {
261
+ return false;
262
+ }
263
+ // Skip if interactive
264
+ if (isInteractive(element)) {
265
+ return false;
266
+ }
267
+ // Skip if has interactive children
268
+ if (hasInteractiveChildren(element)) {
269
+ return false;
270
+ }
271
+ // Skip if nested in interactive
272
+ if (isNestedInInteractive(element)) {
273
+ return false;
274
+ }
275
+ // Must have meaningful text
276
+ const text = getVisibleText(element);
277
+ return text.length > 0 && text.length < 500;
278
+ }
279
+ /**
280
+ * Collect non-interactive content elements
281
+ * (text-bearing elements that are not interactive and not nested)
282
+ * Uses querySelectorAll('*') and filters out elements with interactive children
283
+ * Returns the most parental node to avoid duplication
284
+ */
285
+ function collectContent() {
286
+ // Query for all elements
287
+ const allElements = doc.querySelectorAll('*');
288
+ const candidates = [];
289
+ // First pass: collect all valid candidates
290
+ for (const element of allElements) {
291
+ if (isValidContentCandidate(element)) {
292
+ candidates.push(element);
293
+ }
294
+ }
295
+ // Second pass: filter to keep only the most parental nodes
296
+ // Track which elements are excluded (because a parent was included)
297
+ const excludedElements = new Set();
298
+ // Process candidates from shallowest to deepest (parents before children)
299
+ // This way, when we include a parent, we can mark its descendant candidates as excluded
300
+ const candidatesByDepth = candidates.slice().sort((a, b) => {
301
+ let depthA = 0;
302
+ let depthB = 0;
303
+ let parentA = a.parentElement;
304
+ let parentB = b.parentElement;
305
+ while (parentA && parentA !== doc.body) {
306
+ depthA++;
307
+ parentA = parentA.parentElement;
308
+ }
309
+ while (parentB && parentB !== doc.body) {
310
+ depthB++;
311
+ parentB = parentB.parentElement;
312
+ }
313
+ return depthA - depthB; // Shallower elements first (parents before children)
314
+ });
315
+ for (const element of candidatesByDepth) {
316
+ // Skip if already excluded by a parent
317
+ if (excludedElements.has(element)) {
318
+ continue;
319
+ }
320
+ const elementText = getVisibleText(element);
321
+ // Find all descendant candidates that aren't excluded
322
+ const descendantCandidates = [];
323
+ const descendants = element.querySelectorAll('*');
324
+ for (const descendant of descendants) {
325
+ if (candidates.includes(descendant) && !excludedElements.has(descendant)) {
326
+ descendantCandidates.push(descendant);
327
+ }
328
+ }
329
+ // If no descendant candidates, this is a leaf node - include it
330
+ if (descendantCandidates.length === 0) {
331
+ const tag = element.tagName.toLowerCase();
332
+ const target = {
333
+ tag,
334
+ text: elementText,
335
+ id: element.id || null,
336
+ role: inferRole(element),
337
+ label: getLabel(element),
338
+ ariaLabel: element.getAttribute('aria-label') || null,
339
+ typeAttr: null,
340
+ nameAttr: element.getAttribute('name') || null,
341
+ href: null,
342
+ dataset: getDataset(element),
343
+ nthOfType: getNthOfType(element),
344
+ };
345
+ targets.push(target);
346
+ continue;
347
+ }
348
+ // If we have descendant candidates, check if this element adds value
349
+ // Calculate the combined text from all descendant candidates
350
+ const descendantTexts = descendantCandidates.map(desc => getVisibleText(desc));
351
+ const combinedDescendantText = descendantTexts.join(' ').trim();
352
+ // Normalize both texts for comparison (remove extra whitespace)
353
+ const normalizedElementText = elementText.replace(/\s+/g, ' ').trim();
354
+ const normalizedDescendantText = combinedDescendantText.replace(/\s+/g, ' ').trim();
355
+ // If the element's text is exactly the same as the combined descendant text,
356
+ // skip this element (descendants represent the content)
357
+ // Otherwise, keep it (it has additional content beyond descendants)
358
+ if (normalizedElementText !== normalizedDescendantText) {
359
+ // Include this parent element and exclude all its descendant candidates
360
+ const tag = element.tagName.toLowerCase();
361
+ const target = {
362
+ tag,
363
+ text: elementText,
364
+ id: element.id || null,
365
+ role: inferRole(element),
366
+ label: getLabel(element),
367
+ ariaLabel: element.getAttribute('aria-label') || null,
368
+ typeAttr: null,
369
+ nameAttr: element.getAttribute('name') || null,
370
+ href: null,
371
+ dataset: getDataset(element),
372
+ nthOfType: getNthOfType(element),
373
+ };
374
+ targets.push(target);
375
+ // Mark all descendant candidates as excluded (parent contains them)
376
+ for (const descendant of descendantCandidates) {
377
+ excludedElements.add(descendant);
378
+ }
379
+ }
380
+ // If text matches, we skip this element and will get the descendants instead
381
+ }
382
+ }
383
+ // Collect interactive elements (always collected)
384
+ collectInteractive();
385
+ // Collect content elements only if interactableOnly is false
386
+ if (!interactableOnlyFlag) {
387
+ // console.log('Collecting content elements ------------------------------');
388
+ collectContent();
389
+ }
390
+ // Deduplicate by element identity (if same id/text/role combo)
391
+ const unique = new Map();
392
+ for (const target of targets) {
393
+ const key = `${target.tag}:${target.id || ''}:${target.text.substring(0, 50)}:${target.role || ''}`;
394
+ if (!unique.has(key)) {
395
+ unique.set(key, target);
396
+ }
397
+ }
398
+ return Array.from(unique.values());
399
+ }, interactableOnly);
400
+ }
401
+ /**
402
+ * Escape special characters in CSS selector attribute values
403
+ *
404
+ * @param value - The attribute value to escape
405
+ * @returns Escaped value safe for use in CSS selectors
406
+ */
407
+ function escapeSelectorValue(value) {
408
+ // Escape quotes and backslashes for CSS attribute selectors
409
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
410
+ }
411
+ /**
412
+ * Build the best Playwright selector for a given target element
413
+ *
414
+ * Selectors are prioritized by stability:
415
+ * 1. ID selector (most stable)
416
+ * 2. Role + aria-label or label
417
+ * 3. Data attributes (data-testid, data-id, etc.)
418
+ * 4. Name attribute (for form elements)
419
+ * 5. Role + text content
420
+ * 6. Tag + nth-of-type (least stable, fallback)
421
+ *
422
+ * @param target - TargetInfo object containing element metadata
423
+ * @returns Playwright selector string optimized for stability and reliability
424
+ */
425
+ /**
426
+ * Score how well an element matches the target information
427
+ * Higher score = better match
428
+ *
429
+ * @param elementIndex - Index of the element in the locator's matches
430
+ * @param locator - Playwright Locator that matches multiple elements
431
+ * @param target - TargetInfo to match against
432
+ * @returns Score indicating match quality (0-100)
433
+ */
434
+ async function scoreElementMatch(elementIndex, locator, target) {
435
+ // Get element properties by evaluating on the specific element
436
+ // Note: Inside evaluate(), we're in the browser context where DOM APIs are available
437
+ const elementInfo = await locator.nth(elementIndex).evaluate((el) => {
438
+ const getVisibleText = (element) => {
439
+ // @ts-ignore - window is available in browser context
440
+ const style = window.getComputedStyle(element);
441
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
442
+ return '';
443
+ }
444
+ return (element.textContent || '').trim().replace(/\s+/g, ' ');
445
+ };
446
+ const getLabel = (element) => {
447
+ const ariaLabel = element.getAttribute('aria-label');
448
+ if (ariaLabel)
449
+ return ariaLabel.trim();
450
+ const labelledBy = element.getAttribute('aria-labelledby');
451
+ if (labelledBy) {
452
+ // @ts-ignore - document is available in browser context
453
+ const labelEl = document.getElementById(labelledBy);
454
+ if (labelEl)
455
+ return (labelEl.textContent || '').trim();
456
+ }
457
+ if (element.id) {
458
+ // @ts-ignore - document is available in browser context
459
+ const label = document.querySelector(`label[for="${element.id}"]`);
460
+ if (label)
461
+ return (label.textContent || '').trim();
462
+ }
463
+ const parentLabel = element.closest('label');
464
+ if (parentLabel)
465
+ return (parentLabel.textContent || '').trim();
466
+ return null;
467
+ };
468
+ const dataset = {};
469
+ for (const attr of el.attributes) {
470
+ if (attr.name.startsWith('data-')) {
471
+ const key = attr.name.replace(/^data-/, '').replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
472
+ dataset[key] = attr.value;
473
+ }
474
+ }
475
+ return {
476
+ tag: el.tagName.toLowerCase(),
477
+ text: getVisibleText(el),
478
+ id: el.id || null,
479
+ role: el.getAttribute('role') || null,
480
+ label: getLabel(el),
481
+ ariaLabel: el.getAttribute('aria-label') || null,
482
+ typeAttr: el.type || null,
483
+ nameAttr: el.getAttribute('name') || null,
484
+ dataset,
485
+ };
486
+ });
487
+ if (!elementInfo)
488
+ return 0;
489
+ let score = 0;
490
+ // Tag match (10 points)
491
+ if (elementInfo.tag === target.tag) {
492
+ score += 10;
493
+ }
494
+ // ID match (30 points - very specific)
495
+ if (target.id && elementInfo.id === target.id) {
496
+ score += 30;
497
+ }
498
+ // Role match (15 points)
499
+ if (target.role && elementInfo.role === target.role) {
500
+ score += 15;
501
+ }
502
+ // Text match (20 points)
503
+ if (target.text && elementInfo.text) {
504
+ const targetText = target.text.trim().toLowerCase();
505
+ const elementText = elementInfo.text.trim().toLowerCase();
506
+ if (targetText === elementText) {
507
+ score += 20; // Exact match
508
+ }
509
+ else if (elementText.includes(targetText) || targetText.includes(elementText)) {
510
+ score += 10; // Partial match
511
+ }
512
+ }
513
+ // Aria-label match (15 points)
514
+ if (target.ariaLabel && elementInfo.ariaLabel) {
515
+ if (target.ariaLabel.trim().toLowerCase() === elementInfo.ariaLabel.trim().toLowerCase()) {
516
+ score += 15;
517
+ }
518
+ }
519
+ // Label match (15 points)
520
+ if (target.label && elementInfo.label) {
521
+ if (target.label.trim().toLowerCase() === elementInfo.label.trim().toLowerCase()) {
522
+ score += 15;
523
+ }
524
+ }
525
+ // Type attribute match (10 points)
526
+ if (target.typeAttr && elementInfo.typeAttr === target.typeAttr) {
527
+ score += 10;
528
+ }
529
+ // Name attribute match (15 points)
530
+ if (target.nameAttr && elementInfo.nameAttr === target.nameAttr) {
531
+ score += 15;
532
+ }
533
+ // Dataset match (10 points for testid, 5 for others)
534
+ if (target.dataset && elementInfo.dataset) {
535
+ if (target.dataset.testid && elementInfo.dataset.testid === target.dataset.testid) {
536
+ score += 10;
537
+ }
538
+ // Check other dataset keys
539
+ for (const key in target.dataset) {
540
+ if (target.dataset[key] && elementInfo.dataset[key] === target.dataset[key]) {
541
+ score += 5;
542
+ }
543
+ }
544
+ }
545
+ return score;
546
+ }
547
+ /**
548
+ * Build the best Playwright locator for a given target element
549
+ *
550
+ * Follows Playwright's recommended selector priority:
551
+ * 1. data-testid (use page.getByTestId()) - #1 recommendation, most stable
552
+ * 2. Role-based (use page.getByRole()) - #2 recommendation, accessibility-based
553
+ * 3. Text-based (use page.getByText()) - #3 recommendation, good for visible text
554
+ * 4. CSS selectors as fallback (ID, data attributes, name, tag selectors)
555
+ *
556
+ * If a locator matches multiple elements, this function will evaluate each
557
+ * and return the one that best matches the target information.
558
+ *
559
+ * @param page - Playwright Page object
560
+ * @param target - TargetInfo object containing element metadata
561
+ * @returns Playwright Locator for the target element, prioritized by Playwright's best practices
562
+ */
563
+ export async function buildSelectorForTarget(page, target) {
564
+ if (!target) {
565
+ return null;
566
+ }
567
+ /**
568
+ * Helper function to check if locator matches multiple elements and pick the best one
569
+ */
570
+ const resolveBestLocator = async (locator) => {
571
+ let count;
572
+ try {
573
+ count = await locator.count();
574
+ }
575
+ catch (error) {
576
+ // If page is closed, throw a more descriptive error
577
+ if (error instanceof Error && error.message.includes('closed')) {
578
+ throw new Error('Cannot resolve locator: page, context or browser has been closed. This may happen if a previous action closed the page unexpectedly.');
579
+ }
580
+ throw error;
581
+ }
582
+ // If only one match, return it directly
583
+ if (count <= 1) {
584
+ return locator;
585
+ }
586
+ // If multiple matches, score each one and pick the best
587
+ const scores = [];
588
+ for (let i = 0; i < count; i++) {
589
+ const score = await scoreElementMatch(i, locator, target);
590
+ scores.push({ index: i, score });
591
+ }
592
+ // Sort by score (highest first)
593
+ scores.sort((a, b) => b.score - a.score);
594
+ // Return the best matching element using .nth()
595
+ // We know scores has at least one element since count > 1
596
+ const bestMatch = scores[0];
597
+ if (!bestMatch) {
598
+ // Fallback to first element if somehow scores is empty
599
+ return locator.first();
600
+ }
601
+ return locator.nth(bestMatch.index);
602
+ };
603
+ // Priority 1: data-testid (Playwright's #1 recommendation)
604
+ // Use page.getByTestId() - most stable and recommended
605
+ if (target.dataset && target.dataset.testid) {
606
+ const locator = page.getByTestId(target.dataset.testid);
607
+ return await resolveBestLocator(locator);
608
+ }
609
+ // Priority 2: Role-based selector (Playwright's #2 recommendation)
610
+ // Use page.getByRole() - accessibility-based, very stable
611
+ if (target.role) {
612
+ let locator;
613
+ // If we have aria-label, use it as the name parameter for getByRole
614
+ if (target.ariaLabel) {
615
+ locator = page.getByRole(target.role, { name: target.ariaLabel });
616
+ }
617
+ // If we have a label, use it as the name parameter
618
+ else if (target.label) {
619
+ locator = page.getByRole(target.role, { name: target.label });
620
+ }
621
+ // If we have text content, use it as the name parameter
622
+ else if (target.text && target.text.trim().length > 0) {
623
+ locator = page.getByRole(target.role, { name: target.text.trim() });
624
+ }
625
+ // Just role without name
626
+ else {
627
+ locator = page.getByRole(target.role);
628
+ }
629
+ return await resolveBestLocator(locator);
630
+ }
631
+ // Priority 3: Text-based selector (Playwright's #3 recommendation)
632
+ // Use page.getByText() - good for elements with visible text
633
+ if (target.text && target.text.trim().length > 0) {
634
+ const trimmedText = target.text.trim();
635
+ // For short, specific text, use exact match
636
+ // For longer text, use partial match
637
+ const useExact = trimmedText.length < 50 && !trimmedText.includes('\n');
638
+ const locator = page.getByText(trimmedText, { exact: useExact });
639
+ return await resolveBestLocator(locator);
640
+ }
641
+ // Priority 4: ID selector (CSS fallback)
642
+ // Still stable but not Playwright's preferred method
643
+ if (target.id) {
644
+ const locator = page.locator(`#${target.id}`);
645
+ return await resolveBestLocator(locator);
646
+ }
647
+ // Priority 5: Other data attributes (CSS fallback)
648
+ if (target.dataset && Object.keys(target.dataset).length > 0) {
649
+ let locator;
650
+ // Prefer data-id if available
651
+ if (target.dataset.id) {
652
+ const escapedValue = escapeSelectorValue(target.dataset.id);
653
+ locator = page.locator(`[data-id="${escapedValue}"]`);
654
+ }
655
+ else {
656
+ // Use first data attribute as fallback
657
+ const dataKeys = Object.keys(target.dataset);
658
+ if (dataKeys.length > 0) {
659
+ const firstKey = dataKeys[0];
660
+ if (firstKey) {
661
+ // Convert camelCase to kebab-case: testId -> test-id
662
+ const dataKey = firstKey
663
+ .replace(/([A-Z])/g, '-$1')
664
+ .toLowerCase();
665
+ const value = target.dataset[firstKey];
666
+ if (value) {
667
+ const escapedValue = escapeSelectorValue(value);
668
+ locator = page.locator(`[data-${dataKey}="${escapedValue}"]`);
669
+ }
670
+ }
671
+ }
672
+ }
673
+ if (locator) {
674
+ return await resolveBestLocator(locator);
675
+ }
676
+ }
677
+ // Priority 6: Name attribute (CSS fallback, useful for form elements)
678
+ if (target.nameAttr) {
679
+ const escapedName = escapeSelectorValue(target.nameAttr);
680
+ const locator = page.locator(`[name="${escapedName}"]`);
681
+ return await resolveBestLocator(locator);
682
+ }
683
+ // Priority 7: Tag + type attribute (CSS fallback, for inputs)
684
+ if (target.tag === 'input' && target.typeAttr) {
685
+ const locator = page.locator(`input[type="${target.typeAttr}"]`);
686
+ return await resolveBestLocator(locator);
687
+ }
688
+ // Priority 8: Tag + nth-of-type (CSS fallback, least stable)
689
+ // This is the most fragile selector but ensures we can always find something
690
+ // No need to check for multiple matches here since nth-of-type is already specific
691
+ return page.locator(`${target.tag}:nth-of-type(${target.nthOfType})`);
692
+ }
693
+ /**
694
+ * Generate the best Playwright selector for a given element
695
+ *
696
+ * Analyzes an element and its parent hierarchy to find the best selector
697
+ * following Playwright's recommended practices. Verifies uniqueness at each step.
698
+ *
699
+ * Priority order:
700
+ * 1. data-testid → getByTestId()
701
+ * 2. role + name (aria-label/label) → getByRole()
702
+ * 3. label → getByLabel()
703
+ * 4. placeholder → getByPlaceholder()
704
+ * 5. alt → getByAltText()
705
+ * 6. title → getByTitle()
706
+ * 7. text → getByText()
707
+ * 8. CSS fallback (ID, name, tag + nth-of-type)
708
+ *
709
+ * @param locator - Playwright Locator pointing to the target element
710
+ * @returns Promise resolving to SelectorDescriptor that uniquely identifies the element
711
+ */
712
+ export async function generateBestSelectorForElement(locator) {
713
+ const page = locator.page();
714
+ const targetElementHandle = await locator.elementHandle();
715
+ if (!targetElementHandle) {
716
+ throw new Error('Cannot get element handle from locator');
717
+ }
718
+ // Get element information in browser context
719
+ const elementInfo = await page.evaluate((element) => {
720
+ // @ts-ignore - window is available in browser context
721
+ const win = window;
722
+ // @ts-ignore - document is available in browser context
723
+ const doc = document;
724
+ /**
725
+ * Get visible text content
726
+ */
727
+ function getVisibleText(el) {
728
+ const style = win.getComputedStyle(el);
729
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
730
+ return '';
731
+ }
732
+ return (el.textContent || '').trim().replace(/\s+/g, ' ');
733
+ }
734
+ /**
735
+ * Get label text via various methods
736
+ */
737
+ function getLabel(el) {
738
+ const ariaLabel = el.getAttribute('aria-label');
739
+ if (ariaLabel)
740
+ return ariaLabel.trim();
741
+ const labelledBy = el.getAttribute('aria-labelledby');
742
+ if (labelledBy) {
743
+ const labelEl = doc.getElementById(labelledBy);
744
+ if (labelEl)
745
+ return (labelEl.textContent || '').trim();
746
+ }
747
+ if (el.id) {
748
+ const label = doc.querySelector(`label[for="${el.id}"]`);
749
+ if (label)
750
+ return (label.textContent || '').trim();
751
+ }
752
+ const parentLabel = el.closest('label');
753
+ if (parentLabel)
754
+ return (parentLabel.textContent || '').trim();
755
+ return null;
756
+ }
757
+ /**
758
+ * Infer ARIA role from element
759
+ */
760
+ function inferRole(el) {
761
+ const explicitRole = el.getAttribute('role');
762
+ if (explicitRole)
763
+ return explicitRole;
764
+ const tag = el.tagName.toLowerCase();
765
+ const roleMap = {
766
+ 'button': 'button',
767
+ 'a': 'link',
768
+ 'input': el.type === 'button' || el.type === 'submit' || el.type === 'reset' ? 'button' :
769
+ el.type === 'checkbox' ? 'checkbox' :
770
+ el.type === 'radio' ? 'radio' : 'textbox',
771
+ 'select': 'combobox',
772
+ 'textarea': 'textbox',
773
+ 'img': 'img',
774
+ };
775
+ return roleMap[tag] || null;
776
+ }
777
+ /**
778
+ * Get dataset (data-* attributes)
779
+ */
780
+ function getDataset(el) {
781
+ const dataset = {};
782
+ for (const attr of el.attributes) {
783
+ if (attr.name.startsWith('data-')) {
784
+ const key = attr.name.replace(/^data-/, '').replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
785
+ dataset[key] = attr.value;
786
+ }
787
+ }
788
+ return dataset;
789
+ }
790
+ /**
791
+ * Get nth-of-type index
792
+ */
793
+ function getNthOfType(el) {
794
+ const tag = el.tagName;
795
+ let index = 1;
796
+ let sibling = el.previousElementSibling;
797
+ while (sibling) {
798
+ if (sibling.tagName === tag) {
799
+ index++;
800
+ }
801
+ sibling = sibling.previousElementSibling;
802
+ }
803
+ return index;
804
+ }
805
+ return {
806
+ tag: element.tagName.toLowerCase(),
807
+ text: getVisibleText(element),
808
+ id: element.id || null,
809
+ role: inferRole(element),
810
+ label: getLabel(element),
811
+ ariaLabel: element.getAttribute('aria-label') || null,
812
+ placeholder: element.getAttribute('placeholder') || null,
813
+ alt: element.getAttribute('alt') || null,
814
+ title: element.getAttribute('title') || null,
815
+ typeAttr: element.type || null,
816
+ nameAttr: element.getAttribute('name') || null,
817
+ dataset: getDataset(element),
818
+ nthOfType: getNthOfType(element),
819
+ };
820
+ }, targetElementHandle);
821
+ // Try to find selector on element itself first
822
+ const tryElementSelector = async () => {
823
+ // Priority 1: data-testid
824
+ if (elementInfo.dataset.testid) {
825
+ const descriptor = {
826
+ type: 'testid',
827
+ value: elementInfo.dataset.testid,
828
+ };
829
+ if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
830
+ return descriptor;
831
+ }
832
+ }
833
+ // Priority 2: role + name
834
+ if (elementInfo.role) {
835
+ const name = elementInfo.ariaLabel || elementInfo.label || elementInfo.text;
836
+ if (name && name.trim()) {
837
+ // Try exact match first
838
+ const descriptorExact = {
839
+ type: 'role',
840
+ role: elementInfo.role,
841
+ name: name.trim(),
842
+ exact: true,
843
+ };
844
+ if (await verifySelectorUniqueness(page, descriptorExact, targetElementHandle)) {
845
+ return descriptorExact;
846
+ }
847
+ // Try non-exact match
848
+ const descriptor = {
849
+ type: 'role',
850
+ role: elementInfo.role,
851
+ name: name.trim(),
852
+ exact: false,
853
+ };
854
+ if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
855
+ return descriptor;
856
+ }
857
+ }
858
+ // Role without name
859
+ const descriptor = {
860
+ type: 'role',
861
+ role: elementInfo.role,
862
+ };
863
+ if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
864
+ return descriptor;
865
+ }
866
+ }
867
+ // Priority 3: label
868
+ if (elementInfo.label) {
869
+ const descriptorExact = {
870
+ type: 'label',
871
+ value: elementInfo.label.trim(),
872
+ exact: true,
873
+ };
874
+ if (await verifySelectorUniqueness(page, descriptorExact, targetElementHandle)) {
875
+ return descriptorExact;
876
+ }
877
+ const descriptor = {
878
+ type: 'label',
879
+ value: elementInfo.label.trim(),
880
+ exact: false,
881
+ };
882
+ if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
883
+ return descriptor;
884
+ }
885
+ }
886
+ // Priority 4: placeholder
887
+ if (elementInfo.placeholder) {
888
+ const descriptor = {
889
+ type: 'placeholder',
890
+ value: elementInfo.placeholder.trim(),
891
+ exact: false,
892
+ };
893
+ if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
894
+ return descriptor;
895
+ }
896
+ }
897
+ // Priority 5: alt
898
+ if (elementInfo.alt) {
899
+ const descriptor = {
900
+ type: 'alt',
901
+ value: elementInfo.alt.trim(),
902
+ exact: false,
903
+ };
904
+ if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
905
+ return descriptor;
906
+ }
907
+ }
908
+ // Priority 6: title
909
+ if (elementInfo.title) {
910
+ const descriptor = {
911
+ type: 'title',
912
+ value: elementInfo.title.trim(),
913
+ exact: false,
914
+ };
915
+ if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
916
+ return descriptor;
917
+ }
918
+ }
919
+ // Priority 7: text
920
+ if (elementInfo.text && elementInfo.text.trim()) {
921
+ const trimmedText = elementInfo.text.trim();
922
+ // For short text, try exact match
923
+ if (trimmedText.length < 50) {
924
+ const descriptorExact = {
925
+ type: 'text',
926
+ value: trimmedText,
927
+ exact: true,
928
+ };
929
+ if (await verifySelectorUniqueness(page, descriptorExact, targetElementHandle)) {
930
+ return descriptorExact;
931
+ }
932
+ }
933
+ const descriptor = {
934
+ type: 'text',
935
+ value: trimmedText,
936
+ exact: false,
937
+ };
938
+ if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
939
+ return descriptor;
940
+ }
941
+ }
942
+ // Priority 8: CSS fallback
943
+ if (elementInfo.id) {
944
+ const descriptor = {
945
+ type: 'css',
946
+ selector: `#${elementInfo.id}`,
947
+ };
948
+ if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
949
+ return descriptor;
950
+ }
951
+ }
952
+ if (elementInfo.nameAttr) {
953
+ const descriptor = {
954
+ type: 'css',
955
+ selector: `[name="${elementInfo.nameAttr}"]`,
956
+ };
957
+ if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
958
+ return descriptor;
959
+ }
960
+ }
961
+ // Last resort: tag + nth-of-type
962
+ const descriptor = {
963
+ type: 'css',
964
+ selector: `${elementInfo.tag}:nth-of-type(${elementInfo.nthOfType})`,
965
+ };
966
+ if (await verifySelectorUniqueness(page, descriptor, targetElementHandle)) {
967
+ return descriptor;
968
+ }
969
+ return null;
970
+ };
971
+ // Try element itself first
972
+ const elementSelector = await tryElementSelector();
973
+ if (elementSelector) {
974
+ return elementSelector;
975
+ }
976
+ // If nothing found on element, try parent
977
+ const parentInfo = await page.evaluate((element) => {
978
+ const parent = element.parentElement;
979
+ if (!parent || parent.tagName === 'BODY' || parent.tagName === 'HTML') {
980
+ return null;
981
+ }
982
+ // @ts-ignore - window is available in browser context
983
+ const win = window;
984
+ // @ts-ignore - document is available in browser context
985
+ const doc = document;
986
+ function getVisibleText(el) {
987
+ const style = win.getComputedStyle(el);
988
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
989
+ return '';
990
+ }
991
+ return (el.textContent || '').trim().replace(/\s+/g, ' ');
992
+ }
993
+ function getLabel(el) {
994
+ const ariaLabel = el.getAttribute('aria-label');
995
+ if (ariaLabel)
996
+ return ariaLabel.trim();
997
+ const labelledBy = el.getAttribute('aria-labelledby');
998
+ if (labelledBy) {
999
+ const labelEl = doc.getElementById(labelledBy);
1000
+ if (labelEl)
1001
+ return (labelEl.textContent || '').trim();
1002
+ }
1003
+ if (el.id) {
1004
+ const label = doc.querySelector(`label[for="${el.id}"]`);
1005
+ if (label)
1006
+ return (label.textContent || '').trim();
1007
+ }
1008
+ const parentLabel = el.closest('label');
1009
+ if (parentLabel)
1010
+ return (parentLabel.textContent || '').trim();
1011
+ return null;
1012
+ }
1013
+ function inferRole(el) {
1014
+ const explicitRole = el.getAttribute('role');
1015
+ if (explicitRole)
1016
+ return explicitRole;
1017
+ const tag = el.tagName.toLowerCase();
1018
+ const roleMap = {
1019
+ 'button': 'button',
1020
+ 'a': 'link',
1021
+ 'input': el.type === 'button' || el.type === 'submit' || el.type === 'reset' ? 'button' :
1022
+ el.type === 'checkbox' ? 'checkbox' :
1023
+ el.type === 'radio' ? 'radio' : 'textbox',
1024
+ 'select': 'combobox',
1025
+ 'textarea': 'textbox',
1026
+ 'img': 'img',
1027
+ };
1028
+ return roleMap[tag] || null;
1029
+ }
1030
+ function getDataset(el) {
1031
+ const dataset = {};
1032
+ for (const attr of el.attributes) {
1033
+ if (attr.name.startsWith('data-')) {
1034
+ const key = attr.name.replace(/^data-/, '').replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
1035
+ dataset[key] = attr.value;
1036
+ }
1037
+ }
1038
+ return dataset;
1039
+ }
1040
+ return {
1041
+ tag: parent.tagName.toLowerCase(),
1042
+ text: getVisibleText(parent),
1043
+ id: parent.id || null,
1044
+ role: inferRole(parent),
1045
+ label: getLabel(parent),
1046
+ ariaLabel: parent.getAttribute('aria-label') || null,
1047
+ dataset: getDataset(parent),
1048
+ };
1049
+ }, targetElementHandle);
1050
+ if (parentInfo) {
1051
+ // Try to find selector for parent
1052
+ const tryParentSelector = async () => {
1053
+ // Check parent for testid
1054
+ if (parentInfo.dataset.testid) {
1055
+ const parentDescriptor = {
1056
+ type: 'testid',
1057
+ value: parentInfo.dataset.testid,
1058
+ };
1059
+ // Try to find child selector
1060
+ const childSelector = await tryElementSelector();
1061
+ if (childSelector) {
1062
+ parentDescriptor.child = childSelector;
1063
+ if (await verifySelectorUniqueness(page, parentDescriptor, targetElementHandle)) {
1064
+ return parentDescriptor;
1065
+ }
1066
+ }
1067
+ // Parent testid alone might be unique enough
1068
+ if (await verifySelectorUniqueness(page, parentDescriptor, targetElementHandle)) {
1069
+ return parentDescriptor;
1070
+ }
1071
+ }
1072
+ // Check parent for role
1073
+ if (parentInfo.role) {
1074
+ const name = parentInfo.ariaLabel || parentInfo.label || parentInfo.text;
1075
+ const parentDescriptor = {
1076
+ type: 'role',
1077
+ role: parentInfo.role,
1078
+ name: name?.trim(),
1079
+ exact: false,
1080
+ };
1081
+ const childSelector = await tryElementSelector();
1082
+ if (childSelector) {
1083
+ parentDescriptor.child = childSelector;
1084
+ if (await verifySelectorUniqueness(page, parentDescriptor, targetElementHandle)) {
1085
+ return parentDescriptor;
1086
+ }
1087
+ }
1088
+ }
1089
+ return null;
1090
+ };
1091
+ const parentSelector = await tryParentSelector();
1092
+ if (parentSelector) {
1093
+ return parentSelector;
1094
+ }
1095
+ }
1096
+ // Final fallback: CSS selector (should always work)
1097
+ const fallback = {
1098
+ type: 'css',
1099
+ selector: `${elementInfo.tag}:nth-of-type(${elementInfo.nthOfType})`,
1100
+ };
1101
+ return fallback;
1102
+ }
1103
+ //# sourceMappingURL=selector.js.map