@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,781 @@
1
+ const { chromium } = require("playwright");
2
+ const chalk = require("chalk");
3
+ const path = require("path");
4
+ const fs = require("fs-extra");
5
+ const { connectToActivePage } = require("./record-cdp");
6
+ const { setupBrowserActionListener } = require("./record-browser-injection");
7
+ const { captureScreenshotInteractive } = require("./record-screenshot");
8
+ const {
9
+ finalizeScenarioAndWriteConfig,
10
+ saveScenarioProgress,
11
+ showVisualSelectionMenu,
12
+ } = require("./record-config");
13
+ const config = require("./config");
14
+
15
+ /**
16
+ * RecorderService - Manages recording sessions for Studio UI and CLI
17
+ * Replaces terminal-based recording with API-driven stateful service
18
+ * Enhanced with robust CDP connection and real-time event broadcasting
19
+ */
20
+ class RecorderService {
21
+ constructor(options = {}) {
22
+ this.io = options.io || null; // Socket.io server instance (optional, for Studio)
23
+ this.dispatcher = options.dispatcher || null; // Event dispatcher function (optional)
24
+ this.logger = options.logger || console.log; // Logger function (optional)
25
+ this.currentSession = null;
26
+ this.browser = null;
27
+ this.page = null;
28
+ this.context = null;
29
+ this._active = false;
30
+ this._lastError = null;
31
+ this._navigationListener = null; // Track navigation listener for cleanup
32
+ this._disconnectListener = null; // Track disconnect listener for cleanup
33
+ }
34
+
35
+ /**
36
+ * Emergency cleanup called on server shutdown or new session start
37
+ * Uses timeout to avoid blocking shutdown if browser is unresponsive
38
+ */
39
+ async forceCleanup() {
40
+ console.log(
41
+ chalk.yellow("[Recorder] Force cleaning up browser session...")
42
+ );
43
+
44
+ // Remove event listeners first (synchronous, won't block)
45
+ if (this.page && this._navigationListener) {
46
+ try {
47
+ this.page.off("framenavigated", this._navigationListener);
48
+ } catch (e) {}
49
+ this._navigationListener = null;
50
+ }
51
+
52
+ if (this.browser && this._disconnectListener) {
53
+ try {
54
+ this.browser.off("disconnected", this._disconnectListener);
55
+ } catch (e) {}
56
+ this._disconnectListener = null;
57
+ }
58
+
59
+ if (this.page) {
60
+ try {
61
+ // Remove listeners if possible to avoid side effects
62
+ // await this.page.evaluate(() => window.__RESHOT_ACTIVE = false).catch(() => {});
63
+ } catch (e) {}
64
+ this.page = null;
65
+ }
66
+
67
+ if (this.browser) {
68
+ try {
69
+ // Use disconnect for CDP connections to avoid killing user's Chrome
70
+ // Wrap in a timeout to avoid blocking shutdown indefinitely
71
+ if (this.browser.isConnected()) {
72
+ console.log(chalk.gray("[Recorder] Disconnecting from Chrome..."));
73
+ const disconnectPromise = this.browser.disconnect();
74
+ const timeoutPromise = new Promise((_, reject) =>
75
+ setTimeout(() => reject(new Error("Disconnect timeout")), 2000)
76
+ );
77
+ await Promise.race([disconnectPromise, timeoutPromise]).catch(() => {
78
+ console.log(
79
+ chalk.gray(
80
+ "[Recorder] Disconnect timed out, continuing shutdown..."
81
+ )
82
+ );
83
+ });
84
+ } else {
85
+ // If we launched it (headless), close it.
86
+ // NOTE: connectToActivePage usually connects to an existing Chrome.
87
+ // Closing it might close the user's window. Disconnect is safer.
88
+ const closePromise = this.browser.close();
89
+ const timeoutPromise = new Promise((_, reject) =>
90
+ setTimeout(() => reject(new Error("Close timeout")), 2000)
91
+ );
92
+ await Promise.race([closePromise, timeoutPromise]).catch(() => {
93
+ console.log(
94
+ chalk.gray("[Recorder] Close timed out, continuing shutdown...")
95
+ );
96
+ });
97
+ }
98
+ } catch (e) {
99
+ console.error("[Recorder] Error disconnecting browser:", e.message);
100
+ }
101
+ this.browser = null;
102
+ }
103
+ this.context = null;
104
+ this.currentSession = null;
105
+ }
106
+
107
+ /**
108
+ * Start a new recording session
109
+ * @param {Object} options - Recording options
110
+ * @param {string} options.visualKey - Visual key (optional, will prompt if not provided in CLI mode)
111
+ * @param {string} options.title - Title for new visual (optional)
112
+ * @param {boolean} options.uiMode - If true, skip prompts and use options directly
113
+ * @param {string} options.targetUrl - Specific URL to connect to (optional)
114
+ * @param {string} options.targetId - Specific tab ID to connect to (optional)
115
+ * @returns {Promise<{sessionId: string, visualKey: string}>}
116
+ */
117
+ async start(options = {}) {
118
+ const {
119
+ visualKey: providedVisualKey,
120
+ title,
121
+ uiMode = false,
122
+ targetUrl,
123
+ targetId,
124
+ scenarioUrl, // Custom URL to save with the scenario
125
+ } = options;
126
+
127
+ // 1. Strict Session Check & Cleanup
128
+ if (this._active) {
129
+ if (uiMode) {
130
+ // In Studio mode, auto-stop without saving
131
+ this.logger(
132
+ chalk.yellow(
133
+ "[Recorder] Session active. Auto-stopping previous session..."
134
+ )
135
+ );
136
+ await this.stop(false);
137
+ } else {
138
+ // In CLI mode, throw error
139
+ throw new Error(
140
+ "Recording session already active. Stop the current session before starting a new one."
141
+ );
142
+ }
143
+ }
144
+
145
+ try {
146
+ this._emitDiagnostic("info", "Connecting to Chrome via CDP...");
147
+
148
+ // 2. Connect to Chrome (with optional target selection)
149
+ const { browser, page, context } = await connectToActivePage({
150
+ autoLaunch: true,
151
+ uiMode: uiMode,
152
+ targetUrl,
153
+ targetId,
154
+ });
155
+
156
+ this.browser = browser;
157
+ this.page = page;
158
+ this.context = context;
159
+
160
+ // Check if we're on a valid page
161
+ const currentUrl = page.url();
162
+ if (
163
+ currentUrl.startsWith("chrome-error://") ||
164
+ currentUrl.startsWith("about:blank")
165
+ ) {
166
+ this._emitDiagnostic(
167
+ "warn",
168
+ `Connected to ${currentUrl}. Please navigate to your application in Chrome first.`
169
+ );
170
+ throw new Error(
171
+ "Chrome is not on a valid page. Please navigate to your application first, then start recording."
172
+ );
173
+ }
174
+
175
+ // Handle disconnection event (remove old one if exists)
176
+ if (this._disconnectListener) {
177
+ this.browser.off("disconnected", this._disconnectListener);
178
+ }
179
+
180
+ this._disconnectListener = () => {
181
+ this.logger(chalk.red("[Recorder] Browser disconnected"));
182
+ this.stop(false).catch(() => {});
183
+ };
184
+
185
+ this.browser.on("disconnected", this._disconnectListener);
186
+
187
+ this._emitDiagnostic("info", `Connected to Chrome at ${page.url()}`);
188
+
189
+ // 3. Inject "Highlighter" CSS
190
+ await this.page.addInitScript(() => {
191
+ if (document.getElementById("reshot-styles")) return;
192
+ const style = document.createElement("style");
193
+ style.id = "reshot-styles";
194
+ style.innerHTML = `
195
+ .reshot-highlight { outline: 2px solid #00ff00 !important; z-index: 2147483647; cursor: crosshair !important; }
196
+ .reshot-cursor { position: fixed; width: 20px; height: 20px; border: 2px solid red; border-radius: 50%; pointer-events: none; z-index: 100000; transition: all 0.1s; }
197
+ `;
198
+ document.head.appendChild(style);
199
+ });
200
+
201
+ // 4. Initialize Session State
202
+ let visualKey = providedVisualKey;
203
+ let existingScenario = null;
204
+
205
+ // In CLI mode, show visual selection menu if visualKey not provided
206
+ if (!visualKey && !uiMode) {
207
+ const selection = await showVisualSelectionMenu(page, title);
208
+ visualKey = selection.visualKey;
209
+ existingScenario = selection.existingScenario;
210
+ } else if (!visualKey && title) {
211
+ // Generate key from title
212
+ visualKey = title
213
+ .toLowerCase()
214
+ .replace(/[^a-z0-9]+/g, "-")
215
+ .replace(/(^-|-$)/g, "");
216
+ }
217
+
218
+ // Check existing config
219
+ const { readConfig, configExists } = require("./config");
220
+ if (!existingScenario && configExists()) {
221
+ try {
222
+ const cfg = readConfig();
223
+ existingScenario =
224
+ cfg.scenarios?.find((s) => s.key === visualKey) || null;
225
+ } catch (e) {
226
+ /* ignore */
227
+ }
228
+ }
229
+
230
+ const sessionState = {
231
+ visualKey: visualKey || "untitled",
232
+ scenarioUrl: scenarioUrl || null, // Custom URL to save (if not provided, uses page URL)
233
+ capturedSteps: existingScenario
234
+ ? [...(existingScenario.steps || [])]
235
+ : [],
236
+ existingScenario: existingScenario
237
+ ? JSON.parse(JSON.stringify(existingScenario))
238
+ : null,
239
+ savedStepCount: 0,
240
+ mode: "normal",
241
+ phase: uiMode ? "recording" : "idle",
242
+ pendingCapture: null,
243
+ quit: false,
244
+ saveOnQuit: true,
245
+ clipEvents: null,
246
+ recordingStart: null,
247
+ stopClipRecording: false,
248
+ onChange: null,
249
+ onElementSelected: null,
250
+ emitEvent: this.dispatcher
251
+ ? (type, payload) => {
252
+ this.dispatcher("recorder:event", {
253
+ type,
254
+ ...payload,
255
+ timestamp: new Date().toISOString(),
256
+ });
257
+ }
258
+ : null,
259
+ };
260
+
261
+ this.currentSession = {
262
+ id: `sess_${Date.now()}`,
263
+ visualKey: sessionState.visualKey,
264
+ state: sessionState,
265
+ startedAt: new Date().toISOString(),
266
+ };
267
+
268
+ this._active = true;
269
+
270
+ // 5. Expose Binding (The Data Bridge)
271
+ // This is the ONLY place we expose the binding - setupBrowserActionListener will skip it
272
+ let bindingRegistered = false;
273
+ try {
274
+ await this.page.exposeBinding("reshotReportAction", (source, data) => {
275
+ this.handleBrowserAction(data);
276
+ });
277
+ bindingRegistered = true;
278
+ } catch (error) {
279
+ if (
280
+ error.message.includes("already registered") ||
281
+ error.message.includes("already been registered")
282
+ ) {
283
+ // Binding exists from a previous session - this is okay
284
+ console.log(
285
+ chalk.yellow(
286
+ "[Recorder] Binding already exists from previous session, reusing"
287
+ )
288
+ );
289
+ bindingRegistered = true; // We can still use it
290
+ } else {
291
+ throw error;
292
+ }
293
+ }
294
+
295
+ // 6. Inject Listeners (skip binding since we just handled it above)
296
+ await setupBrowserActionListener(this.page, sessionState, {
297
+ skipBinding: true,
298
+ });
299
+
300
+ // 7. Navigation Listener (remove old one if exists)
301
+ if (this._navigationListener) {
302
+ try {
303
+ this.page.off("framenavigated", this._navigationListener);
304
+ } catch (e) {
305
+ // Ignore errors removing old listener
306
+ }
307
+ }
308
+
309
+ this._navigationListener = async () => {
310
+ // Re-inject listeners on nav (skip binding - it persists across navigations)
311
+ try {
312
+ await setupBrowserActionListener(this.page, sessionState, {
313
+ skipBinding: true,
314
+ });
315
+ this._emitEvent("recorder:event", {
316
+ type: "navigation",
317
+ sessionId: this.currentSession?.id,
318
+ url: this.page.url(),
319
+ });
320
+ } catch (e) {
321
+ console.log(
322
+ chalk.yellow(
323
+ "[Recorder] Error re-injecting on navigation:",
324
+ e.message
325
+ )
326
+ );
327
+ }
328
+ };
329
+
330
+ this.page.on("framenavigated", this._navigationListener);
331
+
332
+ // 8. Broadcast "Started"
333
+ this._broadcastStatus();
334
+ this._emitEvent("recorder:event", {
335
+ type: "session_started",
336
+ sessionId: this.currentSession.id,
337
+ visualKey: sessionState.visualKey,
338
+ });
339
+
340
+ // Send initial steps
341
+ if (sessionState.capturedSteps.length > 0) {
342
+ this._emitEvent("recorder:steps", {
343
+ steps: sessionState.capturedSteps,
344
+ });
345
+ }
346
+
347
+ this.logger(
348
+ chalk.green(`[Recorder] Session started for ${sessionState.visualKey}`)
349
+ );
350
+ return {
351
+ sessionId: this.currentSession.id,
352
+ visualKey: sessionState.visualKey,
353
+ };
354
+ } catch (error) {
355
+ // Emit error diagnostic before cleanup
356
+ this._emitDiagnostic(
357
+ "error",
358
+ `Failed to start recording: ${error.message}`
359
+ );
360
+ await this.forceCleanup();
361
+ throw error;
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Stop the current recording session
367
+ * @param {boolean} save - Whether to save the scenario
368
+ * @param {Object} options - Additional options
369
+ * @param {boolean} options.uiMode - If true, skip prompts
370
+ * @param {string} options.mergeMode - Merge mode ('replace' or 'append')
371
+ */
372
+ async stop(save = true, options = {}) {
373
+ if (!this._active || !this.currentSession) {
374
+ return { saved: false, message: "No active session" };
375
+ }
376
+
377
+ const { uiMode = false, mergeMode = "replace" } = options;
378
+
379
+ try {
380
+ if (save) {
381
+ // Finalize and write to disk
382
+ await finalizeScenarioAndWriteConfig(
383
+ this.currentSession.state,
384
+ this.page,
385
+ { uiMode, mergeMode }
386
+ );
387
+ }
388
+
389
+ this._emitEvent("recorder:event", {
390
+ type: "session_stopped",
391
+ sessionId: this.currentSession.id,
392
+ saved: save,
393
+ });
394
+
395
+ return { saved: save, sessionId: this.currentSession.id };
396
+ } finally {
397
+ this._cleanup();
398
+ this._broadcastStatus();
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Capture a screenshot
404
+ * @param {Object} options - Capture options
405
+ * @param {string} options.outputFilename - Output filename
406
+ * @param {string} options.areaType - 'full' or 'element'
407
+ * @param {string} options.selector - Element selector (if areaType is 'element')
408
+ * @param {boolean} options.uiMode - If true, bypass prompts
409
+ */
410
+ async capture(options = {}) {
411
+ if (!this._active || !this.currentSession) {
412
+ throw new Error("No active session");
413
+ }
414
+
415
+ const { uiMode = false } = options;
416
+
417
+ // Notify UI capture starting
418
+ this._emitEvent("recorder:event", { type: "capture_started" });
419
+
420
+ try {
421
+ // Reuse logic from record-screenshot.js but bypass inquirer
422
+ const screenshotStep = await captureScreenshotInteractive(
423
+ this.currentSession.state,
424
+ this.page,
425
+ { ...options, uiMode } // Pass uiMode flag to bypass prompts
426
+ );
427
+
428
+ // IMMEDIATELY save the screenshot file to output directory
429
+ await this._saveScreenshotFile(screenshotStep);
430
+
431
+ // Add step to session
432
+ this.currentSession.state.capturedSteps.push(screenshotStep);
433
+
434
+ // Auto-save progress
435
+ await saveScenarioProgress(this.currentSession.state, this.page, {
436
+ finalize: false,
437
+ uiMode,
438
+ });
439
+
440
+ this._emitEvent("recorder:event", {
441
+ type: "capture_completed",
442
+ step: screenshotStep,
443
+ });
444
+
445
+ // Send updated step list
446
+ this._emitEvent("recorder:steps", {
447
+ steps: this.currentSession.state.capturedSteps,
448
+ });
449
+
450
+ return screenshotStep;
451
+ } catch (error) {
452
+ this._emitEvent("recorder:event", {
453
+ type: "capture_error",
454
+ error: error.message,
455
+ });
456
+ throw error;
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Save screenshot file immediately during recording
462
+ */
463
+ async _saveScreenshotFile(screenshotStep) {
464
+ if (!this.page || !screenshotStep.path) {
465
+ return;
466
+ }
467
+
468
+ // Determine output directory based on config
469
+ const docSyncConfig = config.readConfig();
470
+ const outputBaseDir = path.join(
471
+ process.cwd(),
472
+ docSyncConfig.assetDir || ".reshot/output"
473
+ );
474
+ const scenarioKey = this.currentSession.visualKey;
475
+ const variationSlug = "default"; // For now, use default variation
476
+ const outputDir = path.join(outputBaseDir, scenarioKey, variationSlug);
477
+
478
+ fs.ensureDirSync(outputDir);
479
+
480
+ const outputPath = path.join(outputDir, screenshotStep.path);
481
+
482
+ try {
483
+ if (screenshotStep.selector) {
484
+ // Capture specific element
485
+ const element = await this.page
486
+ .locator(screenshotStep.selector)
487
+ .first();
488
+ await element.screenshot({
489
+ path: outputPath,
490
+ clip: screenshotStep.clip,
491
+ });
492
+ } else {
493
+ // Capture full page or clip
494
+ const screenshotOptions = {
495
+ path: outputPath,
496
+ fullPage: !screenshotStep.clip,
497
+ };
498
+
499
+ if (screenshotStep.clip) {
500
+ screenshotOptions.clip = screenshotStep.clip;
501
+ }
502
+
503
+ await this.page.screenshot(screenshotOptions);
504
+ }
505
+
506
+ console.log(chalk.green(` ✔ Screenshot saved to ${outputPath}`));
507
+
508
+ // Add saved path to step for reference
509
+ screenshotStep.savedPath = outputPath;
510
+ } catch (error) {
511
+ console.log(
512
+ chalk.yellow(` ⚠ Could not save screenshot: ${error.message}`)
513
+ );
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Get current session status
519
+ */
520
+ getStatus() {
521
+ if (!this._active || !this.currentSession) {
522
+ return { active: false };
523
+ }
524
+ return {
525
+ active: true,
526
+ sessionId: this.currentSession.id,
527
+ visualKey: this.currentSession.visualKey,
528
+ stepsCount: this.currentSession.state.capturedSteps.length,
529
+ url: this.page?.url(),
530
+ phase: this.currentSession.state.phase,
531
+ mode: this.currentSession.state.mode,
532
+ };
533
+ }
534
+
535
+ /**
536
+ * Get captured steps
537
+ */
538
+ getSteps() {
539
+ return this.currentSession ? this.currentSession.state.capturedSteps : [];
540
+ }
541
+
542
+ /**
543
+ * Remove a step at a specific index
544
+ * @param {number} index - The index of the step to remove
545
+ */
546
+ removeStep(index) {
547
+ if (!this.currentSession) {
548
+ throw new Error("No active recording session");
549
+ }
550
+
551
+ const steps = this.currentSession.state.capturedSteps;
552
+ if (index < 0 || index >= steps.length) {
553
+ throw new Error(
554
+ `Invalid step index: ${index}. Valid range: 0-${steps.length - 1}`
555
+ );
556
+ }
557
+
558
+ // Remove the step
559
+ const removed = steps.splice(index, 1)[0];
560
+
561
+ // Emit updated steps to UI
562
+ this._emitEvent("recorder:steps", {
563
+ steps: this.currentSession.state.capturedSteps,
564
+ });
565
+
566
+ return { removed, remaining: steps.length };
567
+ }
568
+
569
+ /**
570
+ * Check if a selector is unstable/garbage and should be rejected
571
+ */
572
+ _isUnstableSelector(selector) {
573
+ if (!selector) return true;
574
+
575
+ // Reject body/html selectors
576
+ if (selector === "body" || selector === "html") return true;
577
+ if (selector.startsWith("body.") || selector.startsWith("html."))
578
+ return true;
579
+
580
+ // Reject form container selectors
581
+ if (selector.startsWith("form.") || selector === "form") return true;
582
+ if (selector.includes("form.space-") || selector.includes("form.flex"))
583
+ return true;
584
+
585
+ // Reject main/section/article container selectors
586
+ if (/^(main|section|article|header|footer|nav|aside)(\.|$)/.test(selector))
587
+ return true;
588
+
589
+ // Reject generic div selectors without data-testid
590
+ if (selector.startsWith("div.") && !selector.includes("[data-testid"))
591
+ return true;
592
+
593
+ // Reject complex path selectors with generic class patterns
594
+ if (
595
+ selector.includes("> select") ||
596
+ selector.includes("> div.") ||
597
+ selector.includes("> form.")
598
+ )
599
+ return true;
600
+ if (selector.includes(".space-y-") || selector.includes(".space-x-"))
601
+ return true;
602
+
603
+ // Reject selectors that are purely Tailwind utility classes
604
+ const tailwindPattern =
605
+ /\.(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)-/;
606
+ if (
607
+ tailwindPattern.test(selector) &&
608
+ !selector.includes("[data-testid") &&
609
+ !selector.includes("#")
610
+ )
611
+ return true;
612
+
613
+ // Reject selectors with dynamic Radix IDs
614
+ if (/radix-[A-Za-z0-9_-]+/.test(selector)) return true;
615
+
616
+ // Reject selectors with CSS module hashes
617
+ if (/[a-z]+_[a-f0-9]{6,}/.test(selector)) return true;
618
+ if (/-module__/.test(selector)) return true;
619
+
620
+ // Reject hidden native select elements (Radix uses these)
621
+ if (selector.includes("select") && !selector.includes("[data-testid"))
622
+ return true;
623
+
624
+ return false;
625
+ }
626
+
627
+ handleBrowserAction(action) {
628
+ if (!this._active || !this.currentSession) return;
629
+
630
+ const { type, selector, value } = action;
631
+
632
+ // Validate selector - reject unstable ones
633
+ if (this._isUnstableSelector(selector)) {
634
+ this.logger(
635
+ chalk.yellow(`[Action REJECTED] Unstable selector: ${selector}`)
636
+ );
637
+ this._emitDiagnostic("warn", `Rejected unstable selector: ${selector}`);
638
+ return;
639
+ }
640
+
641
+ const steps = this.currentSession.state.capturedSteps;
642
+ const lastStep = steps[steps.length - 1];
643
+
644
+ // Deduplication: skip duplicate consecutive clicks
645
+ if (
646
+ type === "click" &&
647
+ lastStep &&
648
+ lastStep.action === "click" &&
649
+ lastStep.selector === selector
650
+ ) {
651
+ this.logger(
652
+ chalk.yellow(`[Action SKIPPED] Duplicate click: ${selector}`)
653
+ );
654
+ return;
655
+ }
656
+
657
+ // Skip click if we just typed into the same element
658
+ if (
659
+ type === "click" &&
660
+ lastStep &&
661
+ lastStep.action === "input" &&
662
+ lastStep.selector === selector
663
+ ) {
664
+ this.logger(
665
+ chalk.yellow(`[Action SKIPPED] Click after type: ${selector}`)
666
+ );
667
+ return;
668
+ }
669
+
670
+ // If this is an input, remove redundant preceding click on same element
671
+ if (
672
+ type === "input" &&
673
+ lastStep &&
674
+ lastStep.action === "click" &&
675
+ lastStep.selector === selector
676
+ ) {
677
+ steps.pop();
678
+ this.logger(
679
+ chalk.yellow(
680
+ `[Action REMOVED] Redundant click before type: ${selector}`
681
+ )
682
+ );
683
+ }
684
+
685
+ const step = {
686
+ action: type === "input" ? "input" : type, // Normalize action type
687
+ selector: selector,
688
+ };
689
+
690
+ if (value !== undefined) {
691
+ step.text = value;
692
+ }
693
+
694
+ steps.push(step);
695
+ this.logger(
696
+ chalk.green(
697
+ `[Action ✔] ${type} on ${selector}${value ? ` (${value})` : ""}`
698
+ )
699
+ );
700
+
701
+ // Emit normalized event
702
+ this._emitEvent("recorder:event", {
703
+ type: "action_captured",
704
+ sessionId: this.currentSession.id,
705
+ step,
706
+ timestamp: new Date().toISOString(),
707
+ });
708
+
709
+ // Also emit legacy 'recorder:action' for backward compatibility
710
+ this._emitEvent("recorder:action", {
711
+ sessionId: this.currentSession.id,
712
+ step,
713
+ });
714
+ }
715
+
716
+ async _cleanup() {
717
+ // Remove event listeners first
718
+ if (this.page && this._navigationListener) {
719
+ try {
720
+ this.page.off("framenavigated", this._navigationListener);
721
+ } catch (e) {
722
+ // Ignore errors during cleanup
723
+ }
724
+ this._navigationListener = null;
725
+ }
726
+
727
+ if (this.browser && this._disconnectListener) {
728
+ try {
729
+ this.browser.off("disconnected", this._disconnectListener);
730
+ } catch (e) {
731
+ // Ignore errors during cleanup
732
+ }
733
+ this._disconnectListener = null;
734
+ }
735
+
736
+ if (this.browser) {
737
+ try {
738
+ // Use disconnect for CDP connections to avoid killing user's Chrome
739
+ // Check if disconnect method exists (CDP connections have it)
740
+ if (typeof this.browser.disconnect === "function") {
741
+ await this.browser.disconnect();
742
+ } else if (typeof this.browser.close === "function") {
743
+ await this.browser.close();
744
+ }
745
+ } catch (e) {
746
+ // Ignore cleanup errors - browser may already be disconnected
747
+ }
748
+ }
749
+ this.browser = null;
750
+ this.page = null;
751
+ this.context = null;
752
+ this.currentSession = null;
753
+ this._active = false;
754
+ }
755
+
756
+ _broadcastStatus() {
757
+ const status = this.getStatus();
758
+ this._emitEvent("recorder:status", status);
759
+ return status; // Return for local use
760
+ }
761
+
762
+ _emitEvent(eventType, payload) {
763
+ if (this.io) {
764
+ this.io.emit(eventType, payload);
765
+ }
766
+ if (this.dispatcher) {
767
+ this.dispatcher(eventType, payload);
768
+ }
769
+ }
770
+
771
+ _emitDiagnostic(level, message) {
772
+ this._emitEvent("recorder:diagnostic", {
773
+ level,
774
+ message,
775
+ timestamp: new Date().toISOString(),
776
+ });
777
+ this.logger(`[Recorder] ${message}`);
778
+ }
779
+ }
780
+
781
+ module.exports = RecorderService;