@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.
- package/LICENSE +190 -0
- package/README.md +388 -0
- package/package.json +64 -0
- package/src/commands/auth.js +259 -0
- package/src/commands/chrome.js +140 -0
- package/src/commands/ci-run.js +123 -0
- package/src/commands/ci-setup.js +288 -0
- package/src/commands/drifts.js +423 -0
- package/src/commands/import-tests.js +309 -0
- package/src/commands/ingest.js +458 -0
- package/src/commands/init.js +633 -0
- package/src/commands/publish.js +1721 -0
- package/src/commands/pull.js +303 -0
- package/src/commands/record.js +94 -0
- package/src/commands/run.js +476 -0
- package/src/commands/setup-wizard.js +740 -0
- package/src/commands/setup.js +137 -0
- package/src/commands/status.js +275 -0
- package/src/commands/sync.js +621 -0
- package/src/commands/ui.js +248 -0
- package/src/commands/validate-docs.js +529 -0
- package/src/index.js +462 -0
- package/src/lib/api-client.js +815 -0
- package/src/lib/capture-engine.js +1623 -0
- package/src/lib/capture-script-runner.js +3120 -0
- package/src/lib/ci-detect.js +137 -0
- package/src/lib/config.js +1240 -0
- package/src/lib/diff-engine.js +642 -0
- package/src/lib/hash.js +74 -0
- package/src/lib/image-crop.js +396 -0
- package/src/lib/matrix.js +89 -0
- package/src/lib/output-path-template.js +318 -0
- package/src/lib/playwright-runner.js +252 -0
- package/src/lib/polished-clip.js +553 -0
- package/src/lib/privacy-engine.js +408 -0
- package/src/lib/progress-tracker.js +142 -0
- package/src/lib/record-browser-injection.js +654 -0
- package/src/lib/record-cdp.js +612 -0
- package/src/lib/record-clip.js +343 -0
- package/src/lib/record-config.js +623 -0
- package/src/lib/record-screenshot.js +360 -0
- package/src/lib/record-terminal.js +123 -0
- package/src/lib/recorder-service.js +781 -0
- package/src/lib/secrets.js +51 -0
- package/src/lib/selector-strategies.js +859 -0
- package/src/lib/standalone-mode.js +400 -0
- package/src/lib/storage-providers.js +569 -0
- package/src/lib/style-engine.js +684 -0
- package/src/lib/ui-api.js +4677 -0
- package/src/lib/ui-assets.js +373 -0
- package/src/lib/ui-executor.js +587 -0
- package/src/lib/variant-injector.js +591 -0
- package/src/lib/viewport-presets.js +454 -0
- package/src/lib/worker-pool.js +118 -0
- package/web/cropper/index.html +436 -0
- package/web/manager/dist/assets/index--ZgioErz.js +507 -0
- package/web/manager/dist/assets/index-n468W0Wr.css +1 -0
- package/web/manager/dist/index.html +27 -0
- 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
|
+
};
|