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.
Files changed (42) hide show
  1. package/README.md +446 -0
  2. package/dist/index.d.ts +13 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +14 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/mimic.d.ts +26 -0
  7. package/dist/mimic.d.ts.map +1 -0
  8. package/dist/mimic.js +71 -0
  9. package/dist/mimic.js.map +1 -0
  10. package/dist/mimicry/actionType.d.ts +6 -0
  11. package/dist/mimicry/actionType.d.ts.map +1 -0
  12. package/dist/mimicry/actionType.js +32 -0
  13. package/dist/mimicry/actionType.js.map +1 -0
  14. package/dist/mimicry/click.d.ts +20 -0
  15. package/dist/mimicry/click.d.ts.map +1 -0
  16. package/dist/mimicry/click.js +152 -0
  17. package/dist/mimicry/click.js.map +1 -0
  18. package/dist/mimicry/forms.d.ts +31 -0
  19. package/dist/mimicry/forms.d.ts.map +1 -0
  20. package/dist/mimicry/forms.js +154 -0
  21. package/dist/mimicry/forms.js.map +1 -0
  22. package/dist/mimicry/navigation.d.ts +6 -0
  23. package/dist/mimicry/navigation.d.ts.map +1 -0
  24. package/dist/mimicry/navigation.js +52 -0
  25. package/dist/mimicry/navigation.js.map +1 -0
  26. package/dist/mimicry/schema/action.d.ts +312 -0
  27. package/dist/mimicry/schema/action.d.ts.map +1 -0
  28. package/dist/mimicry/schema/action.js +194 -0
  29. package/dist/mimicry/schema/action.js.map +1 -0
  30. package/dist/mimicry/selector.d.ts +118 -0
  31. package/dist/mimicry/selector.d.ts.map +1 -0
  32. package/dist/mimicry/selector.js +682 -0
  33. package/dist/mimicry/selector.js.map +1 -0
  34. package/dist/mimicry.d.ts +24 -0
  35. package/dist/mimicry.d.ts.map +1 -0
  36. package/dist/mimicry.js +71 -0
  37. package/dist/mimicry.js.map +1 -0
  38. package/dist/utils/token-counter.d.ts +63 -0
  39. package/dist/utils/token-counter.d.ts.map +1 -0
  40. package/dist/utils/token-counter.js +171 -0
  41. package/dist/utils/token-counter.js.map +1 -0
  42. 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