@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,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
+ };