@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,654 @@
|
|
|
1
|
+
// record-browser-injection.js - Browser event listener injection for recording
|
|
2
|
+
const chalk = require("chalk");
|
|
3
|
+
const { SELECTOR_STRATEGIES_SCRIPT } = require("./selector-strategies");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Injected script that captures browser events and generates selectors.
|
|
7
|
+
*/
|
|
8
|
+
const INJECTED_LISTENER_SCRIPT = `
|
|
9
|
+
// First, inject the selector strategies library
|
|
10
|
+
${SELECTOR_STRATEGIES_SCRIPT}
|
|
11
|
+
|
|
12
|
+
(function() {
|
|
13
|
+
if (window.__RESHOT_INJECTED) return;
|
|
14
|
+
window.__RESHOT_INJECTED = true;
|
|
15
|
+
|
|
16
|
+
// Check if an element is "noise" - something we shouldn't record interactions on
|
|
17
|
+
function isNoiseElement(element) {
|
|
18
|
+
if (!element) return true;
|
|
19
|
+
const tagName = element.tagName;
|
|
20
|
+
|
|
21
|
+
// Skip body/html/document level clicks - usually dismiss actions
|
|
22
|
+
if (tagName === 'BODY' || tagName === 'HTML') return true;
|
|
23
|
+
|
|
24
|
+
// Skip form/container elements - clicks on these are usually accidental
|
|
25
|
+
if (tagName === 'FORM' || tagName === 'MAIN' || tagName === 'SECTION' || tagName === 'ARTICLE' || tagName === 'HEADER' || tagName === 'FOOTER' || tagName === 'NAV' || tagName === 'ASIDE') return true;
|
|
26
|
+
|
|
27
|
+
// Skip hidden elements (aria-hidden, hidden attribute, display:none)
|
|
28
|
+
if (element.getAttribute('aria-hidden') === 'true') return true;
|
|
29
|
+
if (element.hidden) return true;
|
|
30
|
+
if (element.getAttribute('tabindex') === '-1' && tagName === 'SELECT') return true; // Radix hidden native select
|
|
31
|
+
|
|
32
|
+
// Skip native SELECT elements inside Radix components (they're hidden placeholders)
|
|
33
|
+
if (tagName === 'SELECT') {
|
|
34
|
+
// If it's inside a Radix select root, it's the hidden native select
|
|
35
|
+
const radixRoot = element.closest('[data-radix-select-viewport], [role="combobox"]');
|
|
36
|
+
if (radixRoot || element.getAttribute('tabindex') === '-1') {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Skip SVG elements (usually icons inside buttons - we want the button, not the SVG)
|
|
42
|
+
if (tagName === 'SVG' || tagName === 'PATH' || tagName === 'CIRCLE' || tagName === 'RECT' || tagName === 'LINE' || tagName === 'POLYLINE' || tagName === 'POLYGON') {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Skip generic divs that don't have meaningful attributes
|
|
47
|
+
if (tagName === 'DIV') {
|
|
48
|
+
const hasTestId = element.hasAttribute('data-testid') || element.hasAttribute('data-test') || element.hasAttribute('data-cy');
|
|
49
|
+
const hasRole = element.hasAttribute('role') && element.getAttribute('role') !== 'presentation';
|
|
50
|
+
const hasAriaLabel = element.hasAttribute('aria-label');
|
|
51
|
+
const hasOnClick = element.hasAttribute('onclick') || element.onclick;
|
|
52
|
+
// If div has no meaningful attributes, it's probably a layout container
|
|
53
|
+
if (!hasTestId && !hasRole && !hasAriaLabel && !hasOnClick) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Skip backdrop/overlay divs
|
|
59
|
+
if (element.getAttribute('data-radix-portal') !== null) return true;
|
|
60
|
+
if (element.getAttribute('data-radix-popper-content-wrapper') !== null) return true;
|
|
61
|
+
if (element.className && typeof element.className === 'string') {
|
|
62
|
+
if (element.className.includes('backdrop') || element.className.includes('overlay')) return true;
|
|
63
|
+
// Skip elements that only have layout/spacing classes
|
|
64
|
+
const classes = element.className.trim().split(/\\s+/);
|
|
65
|
+
const meaningfulClasses = classes.filter(c => !isLayoutClass(c));
|
|
66
|
+
if (meaningfulClasses.length === 0) return true;
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Find the best clickable parent (for SVGs and other nested elements)
|
|
72
|
+
function findBestClickableParent(element) {
|
|
73
|
+
let current = element;
|
|
74
|
+
let depth = 0;
|
|
75
|
+
|
|
76
|
+
while (current && current !== document.body && depth < 5) {
|
|
77
|
+
// If this element has data-testid, use it
|
|
78
|
+
if (current.hasAttribute('data-testid')) return current;
|
|
79
|
+
|
|
80
|
+
// If it's a button, link, or has role=button, use it
|
|
81
|
+
const tagName = current.tagName;
|
|
82
|
+
if (tagName === 'BUTTON' || tagName === 'A' || current.getAttribute('role') === 'button') {
|
|
83
|
+
return current;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// If it's an input-like element, use it
|
|
87
|
+
if (tagName === 'INPUT' || tagName === 'TEXTAREA') {
|
|
88
|
+
return current;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
current = current.parentElement;
|
|
92
|
+
depth++;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return element; // Return original if no better parent found
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Check if a class is a layout/utility class (not meaningful for identification)
|
|
99
|
+
function isLayoutClass(className) {
|
|
100
|
+
if (!className) return true;
|
|
101
|
+
// Tailwind spacing/layout patterns
|
|
102
|
+
if (/^(p|m|px|py|pt|pb|pl|pr|mx|my|mt|mb|ml|mr)-/.test(className)) return true;
|
|
103
|
+
if (/^(w-|h-|min-|max-|flex|grid|gap-|space-)/.test(className)) return true;
|
|
104
|
+
if (/^(items-|justify-|self-|place-)/.test(className)) return true;
|
|
105
|
+
if (/^(col-|row-|order-)/.test(className)) return true;
|
|
106
|
+
if (/^(overflow|z-|inset|top-|right-|bottom-|left-)/.test(className)) return true;
|
|
107
|
+
if (/^(block|inline|hidden|visible|absolute|relative|fixed|sticky)$/.test(className)) return true;
|
|
108
|
+
if (/^(rounded|border|shadow|bg-|text-|font-)/.test(className)) return true;
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check if a class name is dynamic/generated (should be skipped)
|
|
113
|
+
function isDynamicClass(className) {
|
|
114
|
+
if (!className || className.length < 2) return true;
|
|
115
|
+
// Skip state classes
|
|
116
|
+
if (/^(is-|has-|active|disabled|hover|focus|selected|open|closed|hidden|visible)/.test(className)) return true;
|
|
117
|
+
// Skip framework prefixes
|
|
118
|
+
if (/^(js-|ng-|v-|react-|ember-)/.test(className)) return true;
|
|
119
|
+
// Skip hash-based classes (Tailwind/CSS Modules)
|
|
120
|
+
if (/^[a-z]+_[a-f0-9]{6,}/.test(className)) return true; // module__hash pattern
|
|
121
|
+
if (/[_-][a-f0-9]{6,}[_-]/.test(className)) return true; // hash in middle
|
|
122
|
+
if (/^[a-z]{1,3}[0-9]{4,}/.test(className)) return true; // a1234 pattern
|
|
123
|
+
if (/-module__/.test(className)) return true; // CSS module pattern
|
|
124
|
+
if (/^__/.test(className)) return true; // CSS module output like __className
|
|
125
|
+
// Skip Radix UI dynamic IDs in classes
|
|
126
|
+
if (/radix/.test(className.toLowerCase())) return true;
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Filter classes to only meaningful ones
|
|
131
|
+
function getMeaningfulClasses(element) {
|
|
132
|
+
if (!element.className || typeof element.className !== 'string') return [];
|
|
133
|
+
return element.className.trim().split(/\\s+/).filter(c => !isDynamicClass(c));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ============================================================================
|
|
137
|
+
// ENHANCED SMART SELECTOR GENERATION
|
|
138
|
+
// Uses the new selector-strategies library for industry-standard identification
|
|
139
|
+
// Following Playwright/Testing Library best practices
|
|
140
|
+
// ============================================================================
|
|
141
|
+
|
|
142
|
+
function generateSmartSelector(element) {
|
|
143
|
+
// Use the enhanced selector strategies if available
|
|
144
|
+
if (window.__RESHOT_SELECTOR_STRATEGIES) {
|
|
145
|
+
const strategies = window.__RESHOT_SELECTOR_STRATEGIES;
|
|
146
|
+
const selectors = strategies.generateSelectors(element, { includeAll: false, maxResults: 3 });
|
|
147
|
+
|
|
148
|
+
if (selectors && selectors.length > 0) {
|
|
149
|
+
const best = selectors[0];
|
|
150
|
+
|
|
151
|
+
// Log selector info for debugging (only in dev mode)
|
|
152
|
+
if (window.__RESHOT_DEBUG) {
|
|
153
|
+
console.log('[Reshot] Selector generated:', {
|
|
154
|
+
type: best.type,
|
|
155
|
+
selector: best.selector,
|
|
156
|
+
confidence: best.confidence,
|
|
157
|
+
description: best.description,
|
|
158
|
+
alternates: selectors.slice(1).map(s => s.selector)
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Store alternate selectors for fallback during playback
|
|
163
|
+
if (selectors.length > 1) {
|
|
164
|
+
window.__RESHOT_LAST_ALTERNATES = selectors.slice(1).map(s => s.selector);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return best.selector;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Fallback to legacy selector generation if strategies not available
|
|
172
|
+
return generateLegacySelector(element);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Legacy selector generation (kept for backward compatibility)
|
|
176
|
+
function generateLegacySelector(element) {
|
|
177
|
+
// Priority 1: data-testid (most reliable)
|
|
178
|
+
if (element.hasAttribute('data-testid')) {
|
|
179
|
+
return '[data-testid="' + element.getAttribute('data-testid') + '"]';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Priority 2: data-test, data-cy (common test attributes)
|
|
183
|
+
if (element.hasAttribute('data-test')) {
|
|
184
|
+
return '[data-test="' + element.getAttribute('data-test') + '"]';
|
|
185
|
+
}
|
|
186
|
+
if (element.hasAttribute('data-cy')) {
|
|
187
|
+
return '[data-cy="' + element.getAttribute('data-cy') + '"]';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Priority 3: ID (if not dynamic/generated)
|
|
191
|
+
if (element.id && !element.id.match(/^(:|react|ember|vue|radix)/i) && !element.id.match(/[_-][a-f0-9]{6,}/)) {
|
|
192
|
+
return '#' + CSS.escape(element.id);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Priority 4: ARIA label
|
|
196
|
+
if (element.hasAttribute('aria-label')) {
|
|
197
|
+
const label = element.getAttribute('aria-label');
|
|
198
|
+
if (label && label.length < 50) {
|
|
199
|
+
return '[aria-label="' + label + '"]';
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Priority 5: Name attribute (for form elements)
|
|
204
|
+
if (element.name && (element.tagName === 'INPUT' || element.tagName === 'SELECT' || element.tagName === 'TEXTAREA')) {
|
|
205
|
+
return element.tagName.toLowerCase() + '[name="' + element.name + '"]';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Priority 6: For attribute (for labels)
|
|
209
|
+
if (element.tagName === 'LABEL' && element.htmlFor) {
|
|
210
|
+
return 'label[for="' + element.htmlFor + '"]';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Priority 7: Button/link text content
|
|
214
|
+
if (element.tagName === 'BUTTON' || element.tagName === 'A' || element.getAttribute('role') === 'button') {
|
|
215
|
+
const text = element.textContent.trim().replace(/\\s+/g, ' ');
|
|
216
|
+
if (text && text.length > 0 && text.length < 50 && !text.includes('\\n')) {
|
|
217
|
+
const tagOrRole = element.tagName === 'BUTTON' ? 'button' : (element.tagName === 'A' ? 'a' : '[role="button"]');
|
|
218
|
+
return tagOrRole + ':has-text("' + text.substring(0, 30) + '")';
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Priority 8: Input by placeholder
|
|
223
|
+
if (element.tagName === 'INPUT' && element.placeholder) {
|
|
224
|
+
return 'input[placeholder="' + element.placeholder + '"]';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Priority 9: Input/select by label association
|
|
228
|
+
if (element.tagName === 'INPUT' || element.tagName === 'SELECT' || element.tagName === 'TEXTAREA') {
|
|
229
|
+
const id = element.id;
|
|
230
|
+
if (id) {
|
|
231
|
+
const label = document.querySelector('label[for="' + id + '"]');
|
|
232
|
+
if (label && label.textContent) {
|
|
233
|
+
const labelText = label.textContent.trim();
|
|
234
|
+
if (labelText.length < 30) {
|
|
235
|
+
return element.tagName.toLowerCase() + ':near(label:has-text("' + labelText + '"))';
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Priority 10: Radix UI data attributes (stable for select/dropdown items)
|
|
242
|
+
if (element.hasAttribute('data-radix-collection-item')) {
|
|
243
|
+
const value = element.getAttribute('data-value') || element.textContent?.trim();
|
|
244
|
+
if (value && value.length < 30) {
|
|
245
|
+
return '[data-radix-collection-item]:has-text("' + value + '")';
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Priority 11: Unique class combinations (filtered)
|
|
250
|
+
const meaningfulClasses = getMeaningfulClasses(element);
|
|
251
|
+
if (meaningfulClasses.length > 0) {
|
|
252
|
+
const classCombo = meaningfulClasses.slice(0, 2).join('.');
|
|
253
|
+
const selector = element.tagName.toLowerCase() + '.' + classCombo;
|
|
254
|
+
try {
|
|
255
|
+
const matches = document.querySelectorAll(selector);
|
|
256
|
+
if (matches.length === 1) {
|
|
257
|
+
return selector;
|
|
258
|
+
}
|
|
259
|
+
} catch (e) {
|
|
260
|
+
// Invalid selector, skip
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Priority 12: Build path from nearest identifiable ancestor
|
|
265
|
+
let current = element;
|
|
266
|
+
let pathParts = [];
|
|
267
|
+
let depth = 0;
|
|
268
|
+
|
|
269
|
+
while (current && current !== document.body && depth < 5) {
|
|
270
|
+
// Check for data-testid on ancestor
|
|
271
|
+
if (current.hasAttribute('data-testid')) {
|
|
272
|
+
const ancestorSelector = '[data-testid="' + current.getAttribute('data-testid') + '"]';
|
|
273
|
+
if (pathParts.length > 0) {
|
|
274
|
+
return ancestorSelector + ' ' + pathParts.reverse().join(' > ');
|
|
275
|
+
}
|
|
276
|
+
return ancestorSelector;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Check for stable ID on ancestor
|
|
280
|
+
if (current.id && !current.id.match(/^(:|react|ember|vue|radix)/i) && !current.id.match(/[_-][a-f0-9]{6,}/)) {
|
|
281
|
+
const ancestorSelector = '#' + CSS.escape(current.id);
|
|
282
|
+
if (pathParts.length > 0) {
|
|
283
|
+
return ancestorSelector + ' ' + pathParts.reverse().join(' > ');
|
|
284
|
+
}
|
|
285
|
+
return ancestorSelector;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let part = current.tagName.toLowerCase();
|
|
289
|
+
|
|
290
|
+
// Add meaningful class if available
|
|
291
|
+
const classes = getMeaningfulClasses(current);
|
|
292
|
+
if (classes.length > 0) {
|
|
293
|
+
part += '.' + classes[0];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
pathParts.push(part);
|
|
297
|
+
current = current.parentElement;
|
|
298
|
+
depth++;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Fallback: return the path we built
|
|
302
|
+
if (pathParts.length > 0) {
|
|
303
|
+
return pathParts.reverse().join(' > ');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Last resort: tag with nth-child (least stable)
|
|
307
|
+
let selector = element.tagName.toLowerCase();
|
|
308
|
+
const parent = element.parentElement;
|
|
309
|
+
if (parent) {
|
|
310
|
+
const siblings = Array.from(parent.children).filter(el => el.tagName === element.tagName);
|
|
311
|
+
if (siblings.length > 1) {
|
|
312
|
+
const index = siblings.indexOf(element) + 1;
|
|
313
|
+
selector += ':nth-of-type(' + index + ')';
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return selector;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Click event handler
|
|
320
|
+
function handleClick(event) {
|
|
321
|
+
const mode = window.__RESHOT_MODE || 'normal';
|
|
322
|
+
const debug = window.__RESHOT_DEBUG;
|
|
323
|
+
|
|
324
|
+
// Find best target - go up the tree if clicking on SVG, SPAN, etc.
|
|
325
|
+
let target = event.target;
|
|
326
|
+
if (isNoiseElement(target)) {
|
|
327
|
+
target = findBestClickableParent(target);
|
|
328
|
+
if (isNoiseElement(target)) {
|
|
329
|
+
if (debug) console.log('[Reshot] Skipping noise click on:', event.target.tagName);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const selector = generateSmartSelector(target);
|
|
335
|
+
|
|
336
|
+
// Skip if we couldn't generate a meaningful selector
|
|
337
|
+
if (!selector || selector === 'body' || selector === 'html' || selector.startsWith('body.') || selector.startsWith('html.')) {
|
|
338
|
+
if (debug) console.log('[Reshot] Skipping click with unstable selector:', selector);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Skip complex selectors with generic class patterns (these are unstable)
|
|
343
|
+
if (selector.includes('form.') || selector.includes('> select') || selector.includes('div.space-')) {
|
|
344
|
+
if (debug) console.log('[Reshot] Skipping complex unstable selector:', selector);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (mode === 'select-element-for-screenshot' || mode === 'select-element-for-clip') {
|
|
349
|
+
// In selection mode, prevent default behavior and report selection
|
|
350
|
+
event.preventDefault();
|
|
351
|
+
event.stopPropagation();
|
|
352
|
+
|
|
353
|
+
window.reshotReportAction({
|
|
354
|
+
type: 'selection',
|
|
355
|
+
selector: selector
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
return false;
|
|
359
|
+
} else if (mode === 'normal' || mode === 'recording-clip') {
|
|
360
|
+
// Normal recording mode - capture click
|
|
361
|
+
window.reshotReportAction({
|
|
362
|
+
type: 'click',
|
|
363
|
+
selector: selector
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Input change handler
|
|
369
|
+
function handleChange(event) {
|
|
370
|
+
const mode = window.__RESHOT_MODE || 'normal';
|
|
371
|
+
const debug = window.__RESHOT_DEBUG;
|
|
372
|
+
|
|
373
|
+
// Skip hidden/aria-hidden elements (like Radix hidden selects)
|
|
374
|
+
if (event.target.getAttribute('aria-hidden') === 'true') {
|
|
375
|
+
if (debug) console.log('[Reshot] Skipping change on aria-hidden element');
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (event.target.getAttribute('tabindex') === '-1' && event.target.tagName === 'SELECT') {
|
|
379
|
+
if (debug) console.log('[Reshot] Skipping change on hidden Radix select');
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (mode === 'normal' || mode === 'recording-clip') {
|
|
384
|
+
const selector = generateSmartSelector(event.target);
|
|
385
|
+
|
|
386
|
+
// Skip complex selectors
|
|
387
|
+
if (selector.includes('form.') || selector.includes('> select') || selector.includes('div.space-')) {
|
|
388
|
+
if (debug) console.log('[Reshot] Skipping input with complex unstable selector:', selector);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
window.reshotReportAction({
|
|
393
|
+
type: 'input',
|
|
394
|
+
selector: selector,
|
|
395
|
+
value: event.target.value
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Attach listeners
|
|
401
|
+
document.addEventListener('click', handleClick, true);
|
|
402
|
+
document.addEventListener('change', handleChange, true);
|
|
403
|
+
|
|
404
|
+
if (window.__RESHOT_DEBUG) console.log('[Reshot] Event listeners attached');
|
|
405
|
+
})();
|
|
406
|
+
`;
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Set up browser action listener via exposeBinding
|
|
410
|
+
* @param {Page} page - Playwright page object
|
|
411
|
+
* @param {Object} sessionState - Recording session state
|
|
412
|
+
* @param {Object} options - Options
|
|
413
|
+
* @param {boolean} options.skipBinding - Skip exposeBinding if already done
|
|
414
|
+
*/
|
|
415
|
+
async function setupBrowserActionListener(page, sessionState, options = {}) {
|
|
416
|
+
const { skipBinding = false } = options;
|
|
417
|
+
|
|
418
|
+
// Expose binding for browser to report actions back to Node
|
|
419
|
+
// Only if not already done (e.g., by RecorderService)
|
|
420
|
+
if (!skipBinding) {
|
|
421
|
+
try {
|
|
422
|
+
// Check if binding already exists
|
|
423
|
+
const bindingExists = await page
|
|
424
|
+
.evaluate(() => typeof window.reshotReportAction === "function")
|
|
425
|
+
.catch(() => false);
|
|
426
|
+
|
|
427
|
+
if (!bindingExists) {
|
|
428
|
+
await page.exposeBinding(
|
|
429
|
+
"reshotReportAction",
|
|
430
|
+
async (source, payload) => {
|
|
431
|
+
await onBrowserAction(payload, sessionState, page);
|
|
432
|
+
}
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
} catch (error) {
|
|
436
|
+
// If binding already registered, that's fine - it means we're reconnecting
|
|
437
|
+
if (!error.message.includes("already registered")) {
|
|
438
|
+
throw error;
|
|
439
|
+
}
|
|
440
|
+
console.log(chalk.yellow("[Recorder] Binding already exists, reusing"));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Inject the listener script as init script (for future navigations)
|
|
445
|
+
try {
|
|
446
|
+
await page.addInitScript(INJECTED_LISTENER_SCRIPT);
|
|
447
|
+
} catch (error) {
|
|
448
|
+
// Init script may already be added
|
|
449
|
+
if (!error.message.includes("already")) {
|
|
450
|
+
console.log(chalk.yellow("[Recorder] Init script may already be added"));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Inject into current page
|
|
455
|
+
try {
|
|
456
|
+
await page.evaluate(INJECTED_LISTENER_SCRIPT);
|
|
457
|
+
} catch (error) {
|
|
458
|
+
console.log(
|
|
459
|
+
chalk.yellow("[Recorder] Could not inject script:", error.message)
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
console.log(chalk.green("✔ Browser event listeners injected\n"));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Update the mode in the browser
|
|
468
|
+
* @param {Page} page - Playwright page object
|
|
469
|
+
* @param {string} mode - New mode to set
|
|
470
|
+
*/
|
|
471
|
+
async function updateBrowserMode(page, mode) {
|
|
472
|
+
await page.evaluate((newMode) => {
|
|
473
|
+
window.__RESHOT_MODE = newMode;
|
|
474
|
+
}, mode);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Check if a selector is unstable/garbage and should be rejected
|
|
479
|
+
* @param {string} selector - The CSS selector to validate
|
|
480
|
+
* @returns {boolean} true if the selector is bad and should be rejected
|
|
481
|
+
*/
|
|
482
|
+
function isUnstableSelector(selector) {
|
|
483
|
+
if (!selector) return true;
|
|
484
|
+
|
|
485
|
+
// Reject body/html selectors
|
|
486
|
+
if (selector === "body" || selector === "html") return true;
|
|
487
|
+
if (selector.startsWith("body.") || selector.startsWith("html.")) return true;
|
|
488
|
+
|
|
489
|
+
// Reject form container selectors (form.something)
|
|
490
|
+
if (selector.startsWith("form.") || selector === "form") return true;
|
|
491
|
+
|
|
492
|
+
// Reject main/section/article container selectors
|
|
493
|
+
if (/^(main|section|article|header|footer|nav|aside)(\.|$)/.test(selector))
|
|
494
|
+
return true;
|
|
495
|
+
|
|
496
|
+
// Reject generic div selectors without data-testid
|
|
497
|
+
if (selector.startsWith("div.") && !selector.includes("[data-testid"))
|
|
498
|
+
return true;
|
|
499
|
+
|
|
500
|
+
// Reject selectors that are purely Tailwind utility classes
|
|
501
|
+
const tailwindPattern =
|
|
502
|
+
/\.(p|m|px|py|pt|pb|pl|pr|mx|my|mt|mb|ml|mr|w|h|flex|grid|gap|space|items|justify|rounded|border|shadow|bg|text|font)-/;
|
|
503
|
+
if (
|
|
504
|
+
tailwindPattern.test(selector) &&
|
|
505
|
+
!selector.includes("[data-testid") &&
|
|
506
|
+
!selector.includes("#")
|
|
507
|
+
)
|
|
508
|
+
return true;
|
|
509
|
+
|
|
510
|
+
// Reject selectors with dynamic Radix IDs
|
|
511
|
+
if (/radix-[A-Za-z0-9_-]+/.test(selector)) return true;
|
|
512
|
+
|
|
513
|
+
// Reject selectors with CSS module hashes
|
|
514
|
+
if (/[a-z]+_[a-f0-9]{6,}/.test(selector)) return true;
|
|
515
|
+
if (/-module__/.test(selector)) return true;
|
|
516
|
+
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Handle action reported from browser
|
|
522
|
+
* @param {Object} payload - Action payload from browser
|
|
523
|
+
* @param {Object} sessionState - Recording session state
|
|
524
|
+
* @param {Page} page - Playwright page object
|
|
525
|
+
*/
|
|
526
|
+
async function onBrowserAction(payload, sessionState, page) {
|
|
527
|
+
const { type, selector, value } = payload;
|
|
528
|
+
|
|
529
|
+
// Handle element selection modes
|
|
530
|
+
if (sessionState.mode === "select-element-for-screenshot") {
|
|
531
|
+
console.log(chalk.cyan(` SELECTED ELEMENT: ${selector}\n`));
|
|
532
|
+
sessionState.pendingCapture.selector = selector;
|
|
533
|
+
sessionState.mode = "normal";
|
|
534
|
+
|
|
535
|
+
// Trigger continuation of screenshot flow
|
|
536
|
+
if (sessionState.onElementSelected) {
|
|
537
|
+
sessionState.onElementSelected();
|
|
538
|
+
}
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (sessionState.mode === "select-element-for-clip") {
|
|
543
|
+
console.log(chalk.cyan(` SELECTED ELEMENT FOR CLIP: ${selector}\n`));
|
|
544
|
+
sessionState.pendingCapture.selector = selector;
|
|
545
|
+
sessionState.mode = "normal";
|
|
546
|
+
|
|
547
|
+
// Trigger continuation of clip flow
|
|
548
|
+
if (sessionState.onElementSelected) {
|
|
549
|
+
sessionState.onElementSelected();
|
|
550
|
+
}
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Normal recording mode
|
|
555
|
+
if (
|
|
556
|
+
sessionState.mode === "normal" ||
|
|
557
|
+
sessionState.mode === "recording-clip"
|
|
558
|
+
) {
|
|
559
|
+
if (sessionState.phase !== "capturing") {
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Server-side validation: reject unstable selectors
|
|
564
|
+
if (isUnstableSelector(selector)) {
|
|
565
|
+
console.log(chalk.yellow(` REJECTED (unstable selector): ${selector}`));
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Deduplication: skip duplicate consecutive clicks on the same element
|
|
570
|
+
const lastStep =
|
|
571
|
+
sessionState.capturedSteps[sessionState.capturedSteps.length - 1];
|
|
572
|
+
if (
|
|
573
|
+
lastStep &&
|
|
574
|
+
type === "click" &&
|
|
575
|
+
lastStep.action === "click" &&
|
|
576
|
+
lastStep.selector === selector
|
|
577
|
+
) {
|
|
578
|
+
console.log(chalk.yellow(` SKIPPED (duplicate click): ${selector}`));
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Also skip click if we just typed into the same element (click before type is redundant)
|
|
583
|
+
if (
|
|
584
|
+
type === "click" &&
|
|
585
|
+
lastStep &&
|
|
586
|
+
lastStep.action === "type" &&
|
|
587
|
+
lastStep.selector === selector
|
|
588
|
+
) {
|
|
589
|
+
console.log(chalk.yellow(` SKIPPED (click after type): ${selector}`));
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
let step;
|
|
594
|
+
|
|
595
|
+
if (type === "click") {
|
|
596
|
+
step = {
|
|
597
|
+
action: "click",
|
|
598
|
+
selector: selector,
|
|
599
|
+
};
|
|
600
|
+
console.log(chalk.green(` ✔ ACTION CAPTURED: click on ${selector}`));
|
|
601
|
+
} else if (type === "input") {
|
|
602
|
+
// If we have a pending click on the same element, remove it (click before type is redundant)
|
|
603
|
+
if (
|
|
604
|
+
lastStep &&
|
|
605
|
+
lastStep.action === "click" &&
|
|
606
|
+
lastStep.selector === selector
|
|
607
|
+
) {
|
|
608
|
+
sessionState.capturedSteps.pop();
|
|
609
|
+
console.log(
|
|
610
|
+
chalk.yellow(` REMOVED redundant click before type: ${selector}`)
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
step = {
|
|
615
|
+
action: "input",
|
|
616
|
+
selector: selector,
|
|
617
|
+
text: value,
|
|
618
|
+
};
|
|
619
|
+
console.log(
|
|
620
|
+
chalk.green(` ✔ ACTION CAPTURED: type "${value}" into ${selector}`)
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (step) {
|
|
625
|
+
sessionState.capturedSteps.push(step);
|
|
626
|
+
|
|
627
|
+
// If recording clip, also add to clip events with timestamp and replay to recording context
|
|
628
|
+
if (sessionState.mode === "recording-clip" && sessionState.clipEvents) {
|
|
629
|
+
const timestamp = (Date.now() - sessionState.recordingStart) / 1000;
|
|
630
|
+
const clipEvent = {
|
|
631
|
+
...step,
|
|
632
|
+
timestamp,
|
|
633
|
+
selector: selector,
|
|
634
|
+
};
|
|
635
|
+
sessionState.clipEvents.push(clipEvent);
|
|
636
|
+
|
|
637
|
+
// Replay action to recording context to sync video with timeline
|
|
638
|
+
if (sessionState.replayActionToRecording) {
|
|
639
|
+
// Fire and forget - don't block on replay
|
|
640
|
+
sessionState
|
|
641
|
+
.replayActionToRecording(step.action, selector, step.text)
|
|
642
|
+
.catch(() => {
|
|
643
|
+
// Silently handle replay errors
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
module.exports = {
|
|
652
|
+
setupBrowserActionListener,
|
|
653
|
+
updateBrowserMode,
|
|
654
|
+
};
|