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