@reshotdev/screenshot 0.0.1-beta.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 (59) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +388 -0
  3. package/package.json +64 -0
  4. package/src/commands/auth.js +259 -0
  5. package/src/commands/chrome.js +140 -0
  6. package/src/commands/ci-run.js +123 -0
  7. package/src/commands/ci-setup.js +288 -0
  8. package/src/commands/drifts.js +423 -0
  9. package/src/commands/import-tests.js +309 -0
  10. package/src/commands/ingest.js +458 -0
  11. package/src/commands/init.js +633 -0
  12. package/src/commands/publish.js +1721 -0
  13. package/src/commands/pull.js +303 -0
  14. package/src/commands/record.js +94 -0
  15. package/src/commands/run.js +476 -0
  16. package/src/commands/setup-wizard.js +740 -0
  17. package/src/commands/setup.js +137 -0
  18. package/src/commands/status.js +275 -0
  19. package/src/commands/sync.js +621 -0
  20. package/src/commands/ui.js +248 -0
  21. package/src/commands/validate-docs.js +529 -0
  22. package/src/index.js +462 -0
  23. package/src/lib/api-client.js +815 -0
  24. package/src/lib/capture-engine.js +1623 -0
  25. package/src/lib/capture-script-runner.js +3120 -0
  26. package/src/lib/ci-detect.js +137 -0
  27. package/src/lib/config.js +1240 -0
  28. package/src/lib/diff-engine.js +642 -0
  29. package/src/lib/hash.js +74 -0
  30. package/src/lib/image-crop.js +396 -0
  31. package/src/lib/matrix.js +89 -0
  32. package/src/lib/output-path-template.js +318 -0
  33. package/src/lib/playwright-runner.js +252 -0
  34. package/src/lib/polished-clip.js +553 -0
  35. package/src/lib/privacy-engine.js +408 -0
  36. package/src/lib/progress-tracker.js +142 -0
  37. package/src/lib/record-browser-injection.js +654 -0
  38. package/src/lib/record-cdp.js +612 -0
  39. package/src/lib/record-clip.js +343 -0
  40. package/src/lib/record-config.js +623 -0
  41. package/src/lib/record-screenshot.js +360 -0
  42. package/src/lib/record-terminal.js +123 -0
  43. package/src/lib/recorder-service.js +781 -0
  44. package/src/lib/secrets.js +51 -0
  45. package/src/lib/selector-strategies.js +859 -0
  46. package/src/lib/standalone-mode.js +400 -0
  47. package/src/lib/storage-providers.js +569 -0
  48. package/src/lib/style-engine.js +684 -0
  49. package/src/lib/ui-api.js +4677 -0
  50. package/src/lib/ui-assets.js +373 -0
  51. package/src/lib/ui-executor.js +587 -0
  52. package/src/lib/variant-injector.js +591 -0
  53. package/src/lib/viewport-presets.js +454 -0
  54. package/src/lib/worker-pool.js +118 -0
  55. package/web/cropper/index.html +436 -0
  56. package/web/manager/dist/assets/index--ZgioErz.js +507 -0
  57. package/web/manager/dist/assets/index-n468W0Wr.css +1 -0
  58. package/web/manager/dist/index.html +27 -0
  59. package/web/subtitle-editor/index.html +295 -0
@@ -0,0 +1,859 @@
1
+ // selector-strategies.js - Element identification following Playwright/Testing Library best practices
2
+ // Priority: role > label > placeholder > text > testId > attributes > CSS
3
+
4
+ /**
5
+ * Injected script for smart selector generation
6
+ * This follows W3C accessibility guidelines and modern testing best practices
7
+ */
8
+ const SELECTOR_STRATEGIES_SCRIPT = `
9
+ (function() {
10
+ if (window.__RESHOT_SELECTOR_STRATEGIES) return window.__RESHOT_SELECTOR_STRATEGIES;
11
+
12
+ // ============================================================================
13
+ // ARIA Role Mappings (W3C HTML-AAM specification)
14
+ // https://www.w3.org/TR/html-aria/#docconformance
15
+ // ============================================================================
16
+
17
+ const IMPLICIT_ROLES = {
18
+ 'A': (el) => el.hasAttribute('href') ? 'link' : null,
19
+ 'ARTICLE': () => 'article',
20
+ 'ASIDE': () => 'complementary',
21
+ 'BUTTON': () => 'button',
22
+ 'DATALIST': () => 'listbox',
23
+ 'DETAILS': () => 'group',
24
+ 'DIALOG': () => 'dialog',
25
+ 'FIELDSET': () => 'group',
26
+ 'FIGURE': () => 'figure',
27
+ 'FOOTER': (el) => {
28
+ // Footer within article/aside/main/nav/section = null, otherwise contentinfo
29
+ const parent = el.closest('article, aside, main, nav, section');
30
+ return parent ? null : 'contentinfo';
31
+ },
32
+ 'FORM': (el) => el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby') || el.hasAttribute('name') ? 'form' : null,
33
+ 'H1': () => 'heading',
34
+ 'H2': () => 'heading',
35
+ 'H3': () => 'heading',
36
+ 'H4': () => 'heading',
37
+ 'H5': () => 'heading',
38
+ 'H6': () => 'heading',
39
+ 'HEADER': (el) => {
40
+ const parent = el.closest('article, aside, main, nav, section');
41
+ return parent ? null : 'banner';
42
+ },
43
+ 'HR': () => 'separator',
44
+ 'IMG': (el) => {
45
+ const alt = el.getAttribute('alt');
46
+ if (alt === '') return 'presentation';
47
+ return 'img';
48
+ },
49
+ 'INPUT': (el) => {
50
+ const type = (el.getAttribute('type') || 'text').toLowerCase();
51
+ const typeRoles = {
52
+ 'button': 'button',
53
+ 'checkbox': 'checkbox',
54
+ 'email': 'textbox',
55
+ 'image': 'button',
56
+ 'number': 'spinbutton',
57
+ 'password': 'textbox',
58
+ 'radio': 'radio',
59
+ 'range': 'slider',
60
+ 'reset': 'button',
61
+ 'search': 'searchbox',
62
+ 'submit': 'button',
63
+ 'tel': 'textbox',
64
+ 'text': 'textbox',
65
+ 'url': 'textbox'
66
+ };
67
+ return typeRoles[type] || 'textbox';
68
+ },
69
+ 'LI': (el) => {
70
+ const parent = el.closest('ul, ol, menu');
71
+ return parent ? 'listitem' : null;
72
+ },
73
+ 'MAIN': () => 'main',
74
+ 'MENU': () => 'list',
75
+ 'NAV': () => 'navigation',
76
+ 'OL': () => 'list',
77
+ 'OPTGROUP': () => 'group',
78
+ 'OPTION': () => 'option',
79
+ 'OUTPUT': () => 'status',
80
+ 'PROGRESS': () => 'progressbar',
81
+ 'SECTION': (el) => el.hasAttribute('aria-label') || el.hasAttribute('aria-labelledby') ? 'region' : null,
82
+ 'SELECT': (el) => el.hasAttribute('multiple') || (el.hasAttribute('size') && parseInt(el.getAttribute('size')) > 1) ? 'listbox' : 'combobox',
83
+ 'SUMMARY': () => 'button',
84
+ 'TABLE': () => 'table',
85
+ 'TBODY': () => 'rowgroup',
86
+ 'TD': () => 'cell',
87
+ 'TEXTAREA': () => 'textbox',
88
+ 'TFOOT': () => 'rowgroup',
89
+ 'TH': (el) => el.getAttribute('scope') === 'row' ? 'rowheader' : 'columnheader',
90
+ 'THEAD': () => 'rowgroup',
91
+ 'TR': () => 'row',
92
+ 'UL': () => 'list'
93
+ };
94
+
95
+ /**
96
+ * Get the ARIA role for an element (explicit or implicit)
97
+ */
98
+ function getRole(element) {
99
+ // Explicit role takes precedence
100
+ const explicitRole = element.getAttribute('role');
101
+ if (explicitRole) return explicitRole.split(' ')[0]; // Take first role if multiple
102
+
103
+ // Check for implicit role based on tag name
104
+ const tagName = element.tagName;
105
+ const implicitRoleFn = IMPLICIT_ROLES[tagName];
106
+ if (implicitRoleFn) {
107
+ return implicitRoleFn(element);
108
+ }
109
+
110
+ return null;
111
+ }
112
+
113
+ // ============================================================================
114
+ // Accessible Name Computation (simplified W3C accname algorithm)
115
+ // https://www.w3.org/TR/accname-1.1/
116
+ // ============================================================================
117
+
118
+ /**
119
+ * Compute the accessible name for an element
120
+ * This follows a simplified version of the W3C Accessible Name Computation
121
+ */
122
+ function getAccessibleName(element, options = {}) {
123
+ const { maxLength = 50 } = options;
124
+ let name = null;
125
+
126
+ // Step 1: aria-labelledby
127
+ const labelledBy = element.getAttribute('aria-labelledby');
128
+ if (labelledBy) {
129
+ const ids = labelledBy.split(/\\s+/);
130
+ const names = ids
131
+ .map(id => document.getElementById(id))
132
+ .filter(Boolean)
133
+ .map(el => el.textContent?.trim())
134
+ .filter(Boolean);
135
+ if (names.length > 0) {
136
+ name = names.join(' ');
137
+ }
138
+ }
139
+
140
+ // Step 2: aria-label
141
+ if (!name) {
142
+ const ariaLabel = element.getAttribute('aria-label');
143
+ if (ariaLabel && ariaLabel.trim()) {
144
+ name = ariaLabel.trim();
145
+ }
146
+ }
147
+
148
+ // Step 3: Native label association (for form controls)
149
+ if (!name && (element.tagName === 'INPUT' || element.tagName === 'SELECT' || element.tagName === 'TEXTAREA')) {
150
+ // Check for associated label via 'for' attribute
151
+ if (element.id) {
152
+ const label = document.querySelector('label[for="' + CSS.escape(element.id) + '"]');
153
+ if (label) {
154
+ name = label.textContent?.trim();
155
+ }
156
+ }
157
+ // Check for wrapping label
158
+ if (!name) {
159
+ const parentLabel = element.closest('label');
160
+ if (parentLabel) {
161
+ // Get text content excluding the input itself
162
+ const clone = parentLabel.cloneNode(true);
163
+ const inputs = clone.querySelectorAll('input, select, textarea');
164
+ inputs.forEach(input => input.remove());
165
+ name = clone.textContent?.trim();
166
+ }
167
+ }
168
+ }
169
+
170
+ // Step 4: For buttons, links - use text content
171
+ if (!name) {
172
+ const role = getRole(element);
173
+ if (role === 'button' || role === 'link' || role === 'menuitem' || role === 'tab' || role === 'option') {
174
+ name = getTextContent(element);
175
+ }
176
+ }
177
+
178
+ // Step 5: For inputs - check value, placeholder
179
+ if (!name && element.tagName === 'INPUT') {
180
+ const type = (element.getAttribute('type') || 'text').toLowerCase();
181
+ if (type === 'submit' || type === 'reset' || type === 'button') {
182
+ name = element.value || (type === 'submit' ? 'Submit' : type === 'reset' ? 'Reset' : null);
183
+ } else if (element.placeholder) {
184
+ name = element.placeholder;
185
+ }
186
+ }
187
+
188
+ // Step 6: img alt text
189
+ if (!name && element.tagName === 'IMG') {
190
+ name = element.getAttribute('alt');
191
+ }
192
+
193
+ // Step 7: title attribute (last resort)
194
+ if (!name) {
195
+ name = element.getAttribute('title');
196
+ }
197
+
198
+ // Normalize and truncate
199
+ if (name) {
200
+ name = name.replace(/\\s+/g, ' ').trim();
201
+ if (name.length > maxLength) {
202
+ name = name.substring(0, maxLength);
203
+ }
204
+ }
205
+
206
+ return name;
207
+ }
208
+
209
+ /**
210
+ * Get visible text content of an element (excluding hidden elements)
211
+ */
212
+ function getTextContent(element) {
213
+ // Skip hidden elements
214
+ if (element.getAttribute('aria-hidden') === 'true') return '';
215
+ if (element.hidden) return '';
216
+
217
+ const style = window.getComputedStyle(element);
218
+ if (style.display === 'none' || style.visibility === 'hidden') return '';
219
+
220
+ // Get direct text and recurse into children
221
+ let text = '';
222
+ for (const node of element.childNodes) {
223
+ if (node.nodeType === Node.TEXT_NODE) {
224
+ text += node.textContent;
225
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
226
+ // Skip certain elements
227
+ const tagName = node.tagName;
228
+ if (tagName === 'SCRIPT' || tagName === 'STYLE' || tagName === 'SVG') continue;
229
+ text += getTextContent(node);
230
+ }
231
+ }
232
+
233
+ return text.replace(/\\s+/g, ' ').trim();
234
+ }
235
+
236
+ // ============================================================================
237
+ // Selector Generation Strategies
238
+ // ============================================================================
239
+
240
+ /**
241
+ * Generate a role-based selector (most resilient)
242
+ * Example: role=button[name="Submit"]
243
+ */
244
+ function generateRoleSelector(element) {
245
+ const role = getRole(element);
246
+ if (!role) return null;
247
+
248
+ const accessibleName = getAccessibleName(element);
249
+
250
+ if (accessibleName && accessibleName.length > 0 && accessibleName.length < 50) {
251
+ // Escape quotes in the name
252
+ const escapedName = accessibleName.replace(/"/g, '\\\\"');
253
+ return {
254
+ type: 'role',
255
+ selector: 'role=' + role + '[name="' + escapedName + '"]',
256
+ confidence: 0.95,
257
+ description: role + ' with name "' + accessibleName + '"'
258
+ };
259
+ }
260
+
261
+ // Role without name is less reliable but still useful for unique elements
262
+ return null;
263
+ }
264
+
265
+ /**
266
+ * Generate a label-based selector for form controls
267
+ * Example: input:near(label:has-text("Email"))
268
+ */
269
+ function generateLabelSelector(element) {
270
+ const tagName = element.tagName;
271
+ if (tagName !== 'INPUT' && tagName !== 'SELECT' && tagName !== 'TEXTAREA') {
272
+ return null;
273
+ }
274
+
275
+ // Check for associated label
276
+ let labelText = null;
277
+
278
+ // Via 'for' attribute
279
+ if (element.id) {
280
+ const label = document.querySelector('label[for="' + CSS.escape(element.id) + '"]');
281
+ if (label) {
282
+ labelText = label.textContent?.trim();
283
+ }
284
+ }
285
+
286
+ // Via wrapping label
287
+ if (!labelText) {
288
+ const parentLabel = element.closest('label');
289
+ if (parentLabel) {
290
+ const clone = parentLabel.cloneNode(true);
291
+ clone.querySelectorAll('input, select, textarea').forEach(el => el.remove());
292
+ labelText = clone.textContent?.trim();
293
+ }
294
+ }
295
+
296
+ if (labelText && labelText.length > 0 && labelText.length < 40) {
297
+ return {
298
+ type: 'label',
299
+ selector: 'label:has-text("' + labelText + '") >> ' + tagName.toLowerCase(),
300
+ // Alternative Playwright-style selector
301
+ playwrightSelector: 'getByLabel("' + labelText + '")',
302
+ confidence: 0.9,
303
+ description: tagName.toLowerCase() + ' with label "' + labelText + '"'
304
+ };
305
+ }
306
+
307
+ return null;
308
+ }
309
+
310
+ /**
311
+ * Generate a placeholder-based selector for inputs
312
+ * Example: input[placeholder="Enter your email"]
313
+ */
314
+ function generatePlaceholderSelector(element) {
315
+ const placeholder = element.getAttribute('placeholder');
316
+ if (!placeholder || placeholder.length > 50) return null;
317
+
318
+ const tagName = element.tagName.toLowerCase();
319
+ return {
320
+ type: 'placeholder',
321
+ selector: tagName + '[placeholder="' + placeholder + '"]',
322
+ playwrightSelector: 'getByPlaceholder("' + placeholder + '")',
323
+ confidence: 0.85,
324
+ description: tagName + ' with placeholder "' + placeholder + '"'
325
+ };
326
+ }
327
+
328
+ /**
329
+ * Generate a text-based selector
330
+ * Example: button:has-text("Submit")
331
+ */
332
+ function generateTextSelector(element) {
333
+ const role = getRole(element);
334
+ const textContent = getTextContent(element);
335
+
336
+ if (!textContent || textContent.length === 0 || textContent.length > 40) {
337
+ return null;
338
+ }
339
+
340
+ // Only for interactive or meaningful elements
341
+ const interactiveRoles = ['button', 'link', 'menuitem', 'option', 'tab', 'checkbox', 'radio'];
342
+ const interactiveTags = ['BUTTON', 'A', 'LABEL', 'SUMMARY'];
343
+
344
+ if (interactiveRoles.includes(role) || interactiveTags.includes(element.tagName)) {
345
+ const tagName = element.tagName.toLowerCase();
346
+ const escapedText = textContent.replace(/"/g, '\\\\"');
347
+
348
+ return {
349
+ type: 'text',
350
+ selector: tagName + ':has-text("' + escapedText + '")',
351
+ playwrightSelector: 'getByText("' + escapedText + '")',
352
+ confidence: 0.8,
353
+ description: tagName + ' containing text "' + textContent + '"'
354
+ };
355
+ }
356
+
357
+ return null;
358
+ }
359
+
360
+ /**
361
+ * Generate an alt text selector for images
362
+ * Example: img[alt="Company Logo"]
363
+ */
364
+ function generateAltTextSelector(element) {
365
+ if (element.tagName !== 'IMG' && element.tagName !== 'AREA') return null;
366
+
367
+ const alt = element.getAttribute('alt');
368
+ if (!alt || alt.length === 0 || alt.length > 50) return null;
369
+
370
+ return {
371
+ type: 'alt',
372
+ selector: element.tagName.toLowerCase() + '[alt="' + alt + '"]',
373
+ playwrightSelector: 'getByAltText("' + alt + '")',
374
+ confidence: 0.85,
375
+ description: element.tagName.toLowerCase() + ' with alt text "' + alt + '"'
376
+ };
377
+ }
378
+
379
+ /**
380
+ * Generate a title-based selector
381
+ * Example: [title="More information"]
382
+ */
383
+ function generateTitleSelector(element) {
384
+ const title = element.getAttribute('title');
385
+ if (!title || title.length > 50) return null;
386
+
387
+ return {
388
+ type: 'title',
389
+ selector: '[title="' + title + '"]',
390
+ playwrightSelector: 'getByTitle("' + title + '")',
391
+ confidence: 0.75,
392
+ description: 'element with title "' + title + '"'
393
+ };
394
+ }
395
+
396
+ /**
397
+ * Generate a test ID selector (most stable, but requires explicit contract)
398
+ * Example: [data-testid="submit-button"]
399
+ */
400
+ function generateTestIdSelector(element) {
401
+ // Check various test ID conventions
402
+ const testIdAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-pw'];
403
+
404
+ for (const attr of testIdAttrs) {
405
+ const value = element.getAttribute(attr);
406
+ if (value && value.length < 60) {
407
+ return {
408
+ type: 'testid',
409
+ selector: '[' + attr + '="' + value + '"]',
410
+ playwrightSelector: 'getByTestId("' + value + '")',
411
+ confidence: 0.99,
412
+ description: 'test ID "' + value + '"'
413
+ };
414
+ }
415
+ }
416
+
417
+ return null;
418
+ }
419
+
420
+ /**
421
+ * Generate an ARIA attribute selector
422
+ * Example: [aria-label="Close dialog"]
423
+ */
424
+ function generateAriaSelector(element) {
425
+ const ariaLabel = element.getAttribute('aria-label');
426
+ if (ariaLabel && ariaLabel.length < 50) {
427
+ return {
428
+ type: 'aria',
429
+ selector: '[aria-label="' + ariaLabel + '"]',
430
+ confidence: 0.9,
431
+ description: 'element with aria-label "' + ariaLabel + '"'
432
+ };
433
+ }
434
+
435
+ return null;
436
+ }
437
+
438
+ /**
439
+ * Generate an ID-based selector (if ID is stable)
440
+ * Example: #submit-form
441
+ */
442
+ function generateIdSelector(element) {
443
+ const id = element.id;
444
+ if (!id) return null;
445
+
446
+ // Skip dynamic/generated IDs
447
+ const dynamicPatterns = [
448
+ /^react-/i,
449
+ /^ember/i,
450
+ /^vue-/i,
451
+ /^radix-/i,
452
+ /^:r[0-9a-z]+:/i, // React 18+ useId
453
+ /^[a-f0-9]{8}-[a-f0-9]{4}/i, // UUID
454
+ /[_-][a-f0-9]{6,}$/i, // Hash suffix
455
+ /^[a-z]{1,3}[0-9]{4,}$/i, // Generated like a1234
456
+ /^\\d+$/, // Pure numbers
457
+ ];
458
+
459
+ for (const pattern of dynamicPatterns) {
460
+ if (pattern.test(id)) return null;
461
+ }
462
+
463
+ return {
464
+ type: 'id',
465
+ selector: '#' + CSS.escape(id),
466
+ confidence: 0.95,
467
+ description: 'ID "' + id + '"'
468
+ };
469
+ }
470
+
471
+ /**
472
+ * Generate a name attribute selector (for form elements)
473
+ * Example: input[name="email"]
474
+ */
475
+ function generateNameSelector(element) {
476
+ const name = element.getAttribute('name');
477
+ if (!name) return null;
478
+
479
+ const validTags = ['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON', 'FORM'];
480
+ if (!validTags.includes(element.tagName)) return null;
481
+
482
+ return {
483
+ type: 'name',
484
+ selector: element.tagName.toLowerCase() + '[name="' + name + '"]',
485
+ confidence: 0.85,
486
+ description: element.tagName.toLowerCase() + ' with name "' + name + '"'
487
+ };
488
+ }
489
+
490
+ /**
491
+ * Generate a data attribute selector
492
+ * Example: [data-value="option1"]
493
+ */
494
+ function generateDataAttributeSelector(element) {
495
+ // Check for meaningful data attributes
496
+ const meaningfulDataAttrs = [
497
+ 'data-value', 'data-id', 'data-name', 'data-key',
498
+ 'data-action', 'data-target', 'data-type',
499
+ 'data-radix-collection-item' // Common in Radix UI
500
+ ];
501
+
502
+ for (const attr of meaningfulDataAttrs) {
503
+ const value = element.getAttribute(attr);
504
+ if (value && value.length < 50) {
505
+ return {
506
+ type: 'data-attribute',
507
+ selector: '[' + attr + '="' + value + '"]',
508
+ confidence: 0.7,
509
+ description: attr + '="' + value + '"'
510
+ };
511
+ }
512
+ }
513
+
514
+ return null;
515
+ }
516
+
517
+ /**
518
+ * Generate a composite selector combining ancestor context with element
519
+ * Example: [data-testid="modal"] button:has-text("Confirm")
520
+ */
521
+ function generateCompositeSelector(element) {
522
+ // Find nearest identifiable ancestor
523
+ let ancestor = element.parentElement;
524
+ let depth = 0;
525
+ const maxDepth = 5;
526
+
527
+ while (ancestor && ancestor !== document.body && depth < maxDepth) {
528
+ // Check for test ID on ancestor
529
+ const ancestorTestId = generateTestIdSelector(ancestor);
530
+ if (ancestorTestId) {
531
+ // Now generate a simple selector for the element relative to ancestor
532
+ const elementRole = generateRoleSelector(element);
533
+ if (elementRole) {
534
+ return {
535
+ type: 'composite',
536
+ selector: ancestorTestId.selector + ' >> ' + elementRole.selector,
537
+ confidence: 0.85,
538
+ description: 'inside ' + ancestorTestId.description + ': ' + elementRole.description
539
+ };
540
+ }
541
+
542
+ const elementText = generateTextSelector(element);
543
+ if (elementText) {
544
+ return {
545
+ type: 'composite',
546
+ selector: ancestorTestId.selector + ' ' + elementText.selector,
547
+ confidence: 0.8,
548
+ description: 'inside ' + ancestorTestId.description + ': ' + elementText.description
549
+ };
550
+ }
551
+ }
552
+
553
+ // Check for stable ID on ancestor
554
+ const ancestorId = generateIdSelector(ancestor);
555
+ if (ancestorId) {
556
+ const elementRole = generateRoleSelector(element);
557
+ if (elementRole) {
558
+ return {
559
+ type: 'composite',
560
+ selector: ancestorId.selector + ' >> ' + elementRole.selector,
561
+ confidence: 0.8,
562
+ description: 'inside ' + ancestorId.description + ': ' + elementRole.description
563
+ };
564
+ }
565
+ }
566
+
567
+ // Check for ARIA landmark
568
+ const ancestorRole = getRole(ancestor);
569
+ if (['navigation', 'main', 'banner', 'contentinfo', 'dialog', 'form'].includes(ancestorRole)) {
570
+ const ancestorName = getAccessibleName(ancestor);
571
+ if (ancestorName) {
572
+ const elementSel = generateRoleSelector(element) || generateTextSelector(element);
573
+ if (elementSel) {
574
+ return {
575
+ type: 'composite',
576
+ selector: 'role=' + ancestorRole + '[name="' + ancestorName + '"] >> ' + elementSel.selector,
577
+ confidence: 0.75,
578
+ description: 'inside ' + ancestorRole + ' "' + ancestorName + '": ' + elementSel.description
579
+ };
580
+ }
581
+ }
582
+ }
583
+
584
+ ancestor = ancestor.parentElement;
585
+ depth++;
586
+ }
587
+
588
+ return null;
589
+ }
590
+
591
+ /**
592
+ * Generate a structural path selector (fallback)
593
+ * Example: main > section > button:nth-of-type(2)
594
+ */
595
+ function generateStructuralSelector(element) {
596
+ const parts = [];
597
+ let current = element;
598
+ let depth = 0;
599
+ const maxDepth = 4;
600
+
601
+ while (current && current !== document.body && depth < maxDepth) {
602
+ let part = current.tagName.toLowerCase();
603
+
604
+ // Add distinguishing info
605
+ if (current.id && !/[_-][a-f0-9]{6,}/.test(current.id)) {
606
+ return {
607
+ type: 'structural',
608
+ selector: parts.reverse().join(' > ') + (parts.length ? ' > ' : '') + '#' + CSS.escape(current.id),
609
+ confidence: 0.6,
610
+ description: 'structural path ending at #' + current.id
611
+ };
612
+ }
613
+
614
+ // Add nth-of-type if there are siblings of same type
615
+ const parent = current.parentElement;
616
+ if (parent) {
617
+ const siblings = Array.from(parent.children).filter(c => c.tagName === current.tagName);
618
+ if (siblings.length > 1) {
619
+ const index = siblings.indexOf(current) + 1;
620
+ part += ':nth-of-type(' + index + ')';
621
+ }
622
+ }
623
+
624
+ parts.push(part);
625
+ current = current.parentElement;
626
+ depth++;
627
+ }
628
+
629
+ return {
630
+ type: 'structural',
631
+ selector: parts.reverse().join(' > '),
632
+ confidence: 0.4,
633
+ description: 'structural path (least reliable)'
634
+ };
635
+ }
636
+
637
+ // ============================================================================
638
+ // Main API
639
+ // ============================================================================
640
+
641
+ /**
642
+ * Generate the best selector(s) for an element
643
+ * Returns an array of selectors sorted by confidence
644
+ */
645
+ function generateSelectors(element, options = {}) {
646
+ const { includeAll = false, maxResults = 3 } = options;
647
+ const selectors = [];
648
+
649
+ // Strategy priority order (industry best practices)
650
+ const strategies = [
651
+ generateTestIdSelector, // 1. Test ID (explicit contract)
652
+ generateRoleSelector, // 2. Role + accessible name
653
+ generateLabelSelector, // 3. Label association
654
+ generateAriaSelector, // 4. ARIA attributes
655
+ generatePlaceholderSelector, // 5. Placeholder
656
+ generateTextSelector, // 6. Text content
657
+ generateAltTextSelector, // 7. Alt text
658
+ generateTitleSelector, // 8. Title
659
+ generateIdSelector, // 9. Stable ID
660
+ generateNameSelector, // 10. Name attribute
661
+ generateDataAttributeSelector, // 11. Data attributes
662
+ generateCompositeSelector, // 12. Composite (ancestor + element)
663
+ generateStructuralSelector // 13. Structural path (fallback)
664
+ ];
665
+
666
+ for (const strategy of strategies) {
667
+ try {
668
+ const result = strategy(element);
669
+ if (result) {
670
+ selectors.push(result);
671
+ // For efficiency, stop early if we have a high-confidence selector
672
+ if (!includeAll && result.confidence >= 0.9) {
673
+ break;
674
+ }
675
+ }
676
+ } catch (e) {
677
+ // Strategy failed, continue to next
678
+ console.warn('[Reshot] Selector strategy failed:', e.message);
679
+ }
680
+ }
681
+
682
+ // Sort by confidence and return
683
+ selectors.sort((a, b) => b.confidence - a.confidence);
684
+ return includeAll ? selectors : selectors.slice(0, maxResults);
685
+ }
686
+
687
+ /**
688
+ * Get the best single selector for an element
689
+ */
690
+ function getBestSelector(element) {
691
+ const selectors = generateSelectors(element, { includeAll: false, maxResults: 1 });
692
+ return selectors[0]?.selector || null;
693
+ }
694
+
695
+ /**
696
+ * Validate that a selector uniquely identifies the target element
697
+ */
698
+ function validateSelector(selector, expectedElement) {
699
+ try {
700
+ // Handle Playwright-style role selectors
701
+ if (selector.startsWith('role=')) {
702
+ // Can't validate role selectors in browser - they need Playwright
703
+ return { valid: true, reason: 'role selector (Playwright-only)' };
704
+ }
705
+
706
+ const matches = document.querySelectorAll(selector);
707
+ if (matches.length === 0) {
708
+ return { valid: false, reason: 'no matches' };
709
+ }
710
+ if (matches.length > 1) {
711
+ return { valid: false, reason: 'multiple matches (' + matches.length + ')' };
712
+ }
713
+ if (matches[0] !== expectedElement) {
714
+ return { valid: false, reason: 'matches different element' };
715
+ }
716
+ return { valid: true, reason: 'unique match' };
717
+ } catch (e) {
718
+ return { valid: false, reason: 'invalid selector: ' + e.message };
719
+ }
720
+ }
721
+
722
+ // Export the API
723
+ const api = {
724
+ getRole,
725
+ getAccessibleName,
726
+ getTextContent,
727
+ generateSelectors,
728
+ getBestSelector,
729
+ validateSelector,
730
+ // Individual strategies for debugging
731
+ strategies: {
732
+ testId: generateTestIdSelector,
733
+ role: generateRoleSelector,
734
+ label: generateLabelSelector,
735
+ aria: generateAriaSelector,
736
+ placeholder: generatePlaceholderSelector,
737
+ text: generateTextSelector,
738
+ altText: generateAltTextSelector,
739
+ title: generateTitleSelector,
740
+ id: generateIdSelector,
741
+ name: generateNameSelector,
742
+ dataAttribute: generateDataAttributeSelector,
743
+ composite: generateCompositeSelector,
744
+ structural: generateStructuralSelector
745
+ }
746
+ };
747
+
748
+ window.__RESHOT_SELECTOR_STRATEGIES = api;
749
+ return api;
750
+ })();
751
+ `;
752
+
753
+ /**
754
+ * Node.js utilities for working with selectors
755
+ */
756
+
757
+ /**
758
+ * Convert a role-based selector to Playwright locator syntax
759
+ * @param {string} selector - Selector like 'role=button[name="Submit"]'
760
+ * @returns {Object} Playwright locator config
761
+ */
762
+ function parseRoleSelector(selector) {
763
+ if (!selector.startsWith('role=')) return null;
764
+
765
+ const match = selector.match(/^role=(\w+)(?:\[name="(.+)"\])?$/);
766
+ if (!match) return null;
767
+
768
+ return {
769
+ role: match[1],
770
+ name: match[2] || undefined
771
+ };
772
+ }
773
+
774
+ /**
775
+ * Generate a Playwright locator call from selector info
776
+ * @param {Object} selectorInfo - Result from generateSelectors
777
+ * @returns {string} Playwright code snippet
778
+ */
779
+ function toPlaywrightLocator(selectorInfo) {
780
+ if (!selectorInfo) return null;
781
+
782
+ switch (selectorInfo.type) {
783
+ case 'role': {
784
+ const parsed = parseRoleSelector(selectorInfo.selector);
785
+ if (parsed) {
786
+ if (parsed.name) {
787
+ return `page.getByRole('${parsed.role}', { name: '${parsed.name}' })`;
788
+ }
789
+ return `page.getByRole('${parsed.role}')`;
790
+ }
791
+ break;
792
+ }
793
+ case 'label':
794
+ return selectorInfo.playwrightSelector ? `page.${selectorInfo.playwrightSelector}` : null;
795
+ case 'placeholder':
796
+ return selectorInfo.playwrightSelector ? `page.${selectorInfo.playwrightSelector}` : null;
797
+ case 'text':
798
+ return selectorInfo.playwrightSelector ? `page.${selectorInfo.playwrightSelector}` : null;
799
+ case 'alt':
800
+ return selectorInfo.playwrightSelector ? `page.${selectorInfo.playwrightSelector}` : null;
801
+ case 'title':
802
+ return selectorInfo.playwrightSelector ? `page.${selectorInfo.playwrightSelector}` : null;
803
+ case 'testid':
804
+ return selectorInfo.playwrightSelector ? `page.${selectorInfo.playwrightSelector}` : null;
805
+ default:
806
+ return `page.locator('${selectorInfo.selector}')`;
807
+ }
808
+
809
+ return `page.locator('${selectorInfo.selector}')`;
810
+ }
811
+
812
+ /**
813
+ * Determine the best locator strategy to use in capture-engine
814
+ * @param {Array} selectors - Array of selector results
815
+ * @returns {Object} Recommended strategy with selector and method
816
+ */
817
+ function chooseBestStrategy(selectors) {
818
+ if (!selectors || selectors.length === 0) {
819
+ return null;
820
+ }
821
+
822
+ // Sort by confidence
823
+ const sorted = [...selectors].sort((a, b) => b.confidence - a.confidence);
824
+ const best = sorted[0];
825
+
826
+ // Prefer certain types for reliability
827
+ const preferredOrder = ['testid', 'role', 'label', 'aria', 'id'];
828
+
829
+ for (const type of preferredOrder) {
830
+ const match = sorted.find(s => s.type === type && s.confidence >= 0.8);
831
+ if (match) {
832
+ return {
833
+ selector: match.selector,
834
+ type: match.type,
835
+ confidence: match.confidence,
836
+ description: match.description,
837
+ playwrightLocator: toPlaywrightLocator(match),
838
+ fallbacks: sorted.filter(s => s !== match).map(s => s.selector)
839
+ };
840
+ }
841
+ }
842
+
843
+ // Return the highest confidence one
844
+ return {
845
+ selector: best.selector,
846
+ type: best.type,
847
+ confidence: best.confidence,
848
+ description: best.description,
849
+ playwrightLocator: toPlaywrightLocator(best),
850
+ fallbacks: sorted.slice(1).map(s => s.selector)
851
+ };
852
+ }
853
+
854
+ module.exports = {
855
+ SELECTOR_STRATEGIES_SCRIPT,
856
+ parseRoleSelector,
857
+ toPlaywrightLocator,
858
+ chooseBestStrategy
859
+ };