@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,3120 @@
|
|
|
1
|
+
// capture-script-runner.js - Run capture scripts with the new robust engine
|
|
2
|
+
const { CaptureEngine, isAuthRedirectUrl } = require("./capture-engine");
|
|
3
|
+
const { buildLaunchOptions } = require("./ci-detect");
|
|
4
|
+
const {
|
|
5
|
+
resolveVariantConfig,
|
|
6
|
+
applyVariantToPage,
|
|
7
|
+
applyStorageAndReload,
|
|
8
|
+
setupHeaderInterception,
|
|
9
|
+
applyUrlParams,
|
|
10
|
+
getBrowserOptions,
|
|
11
|
+
logVariantSummary,
|
|
12
|
+
} = require("./variant-injector");
|
|
13
|
+
const {
|
|
14
|
+
cropImageBuffer,
|
|
15
|
+
mergeCropConfigs,
|
|
16
|
+
isSharpAvailable,
|
|
17
|
+
} = require("./image-crop");
|
|
18
|
+
const {
|
|
19
|
+
resolveOutputPath,
|
|
20
|
+
buildTemplateContext,
|
|
21
|
+
ensureOutputDirectory,
|
|
22
|
+
DEFAULT_OUTPUT_TEMPLATE,
|
|
23
|
+
} = require("./output-path-template");
|
|
24
|
+
const {
|
|
25
|
+
resolveViewport,
|
|
26
|
+
parseViewportMatrix,
|
|
27
|
+
resolveCropRegion,
|
|
28
|
+
} = require("./viewport-presets");
|
|
29
|
+
const {
|
|
30
|
+
getDefaultSessionPath,
|
|
31
|
+
autoSyncSessionFromCDP,
|
|
32
|
+
sanitizeStorageState,
|
|
33
|
+
} = require("./record-cdp");
|
|
34
|
+
const config = require("./config");
|
|
35
|
+
const {
|
|
36
|
+
injectPrivacyMasking,
|
|
37
|
+
removePrivacyMasking,
|
|
38
|
+
mergePrivacyConfig,
|
|
39
|
+
generatePrivacyInitScript,
|
|
40
|
+
generatePrivacyCSS,
|
|
41
|
+
pausePrivacyReinjection,
|
|
42
|
+
resumePrivacyReinjection,
|
|
43
|
+
} = require("./privacy-engine");
|
|
44
|
+
const { applyStyle, isStyleAvailable, mergeStyleConfig } = require("./style-engine");
|
|
45
|
+
const { WorkerPool } = require("./worker-pool");
|
|
46
|
+
const { ProgressTracker, formatDuration } = require("./progress-tracker");
|
|
47
|
+
const chalk = require("chalk");
|
|
48
|
+
const path = require("path");
|
|
49
|
+
const fs = require("fs-extra");
|
|
50
|
+
const crypto = require("crypto");
|
|
51
|
+
const os = require("os");
|
|
52
|
+
|
|
53
|
+
// Debug mode - set RESHOT_DEBUG=1 or RESHOT_DEBUG=video to enable verbose logging
|
|
54
|
+
const DEBUG =
|
|
55
|
+
process.env.RESHOT_DEBUG === "1" || process.env.RESHOT_DEBUG === "video";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Substitute URL variables using user-configured mappings from settings
|
|
59
|
+
*
|
|
60
|
+
* Users can configure custom variable mappings in .reshot/settings.json:
|
|
61
|
+
* {
|
|
62
|
+
* "urlVariables": {
|
|
63
|
+
* "PROJECT_ID": "cmj5eoyxr...",
|
|
64
|
+
* "API_HOST": "https://api.example.com",
|
|
65
|
+
* "CUSTOM_VAR": "my-value"
|
|
66
|
+
* }
|
|
67
|
+
* }
|
|
68
|
+
*
|
|
69
|
+
* Supports formats:
|
|
70
|
+
* - {{VAR_NAME}} - Mustache-style (recommended)
|
|
71
|
+
* - ${VAR_NAME} - Shell-style
|
|
72
|
+
* - Bare VAR_NAME - Direct token replacement
|
|
73
|
+
*
|
|
74
|
+
* Falls back to environment variables if not found in settings.
|
|
75
|
+
*/
|
|
76
|
+
function substituteUrlVariables(url) {
|
|
77
|
+
if (!url) return url;
|
|
78
|
+
|
|
79
|
+
// Get user-configured variables from settings
|
|
80
|
+
let settings = {};
|
|
81
|
+
try {
|
|
82
|
+
settings = config.readSettings() || {};
|
|
83
|
+
} catch (e) {
|
|
84
|
+
// Settings may not exist, continue with empty
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// User-defined variable mappings take priority
|
|
88
|
+
const userVariables = settings.urlVariables || {};
|
|
89
|
+
|
|
90
|
+
// Build substitution map: user variables + env variables
|
|
91
|
+
// User settings override environment variables
|
|
92
|
+
const substitutions = { ...userVariables };
|
|
93
|
+
|
|
94
|
+
// Auto-populate PROJECT_ID from settings.projectId (set during `reshot link`)
|
|
95
|
+
// This mirrors the fallback chain in capture-engine.js _injectActiveProjectId()
|
|
96
|
+
if (!substitutions.PROJECT_ID && settings.projectId) {
|
|
97
|
+
substitutions.PROJECT_ID = settings.projectId;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let result = url;
|
|
101
|
+
|
|
102
|
+
// Replace {{VAR_NAME}} format (mustache-style, recommended)
|
|
103
|
+
result = result.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
|
|
104
|
+
return substitutions[varName] || process.env[varName] || match;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Replace ${VAR_NAME} format (shell-style)
|
|
108
|
+
result = result.replace(/\$\{(\w+)\}/g, (match, varName) => {
|
|
109
|
+
return substitutions[varName] || process.env[varName] || match;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Replace bare tokens (only if explicitly defined in user settings)
|
|
113
|
+
// This avoids accidentally replacing common words
|
|
114
|
+
for (const [token, value] of Object.entries(userVariables)) {
|
|
115
|
+
if (value && result.includes(token)) {
|
|
116
|
+
result = result.replace(new RegExp(token, "g"), value);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Detect unresolved {{...}} tokens — hard error to prevent useless captures
|
|
121
|
+
const unresolvedMatches = result.match(/\{\{(\w+)\}\}/g);
|
|
122
|
+
if (unresolvedMatches) {
|
|
123
|
+
const unresolvedVars = unresolvedMatches.map(m => m.replace(/[{}]/g, ''));
|
|
124
|
+
throw new Error(`Unresolved URL variables: ${unresolvedVars.join(', ')}. Set these in .reshot/settings.json urlVariables or as environment variables.`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const { getCaptureConfig } = require("./config");
|
|
131
|
+
|
|
132
|
+
function debug(...args) {
|
|
133
|
+
if (DEBUG) {
|
|
134
|
+
console.log(chalk.gray("[DEBUG]"), ...args);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Execute a page load with retry logic on error/timeout
|
|
140
|
+
* Uses the capture engine's error detection to identify failures and retry
|
|
141
|
+
*
|
|
142
|
+
* @param {Object} engine - CaptureEngine instance
|
|
143
|
+
* @param {string} readySelector - Selector indicating page is ready
|
|
144
|
+
* @param {Object} options - Retry options
|
|
145
|
+
* @param {number} options.retryOnError - Number of retries (default: 2)
|
|
146
|
+
* @param {number} options.retryDelay - Base delay between retries in ms (default: 1000)
|
|
147
|
+
* @param {number} options.readyTimeout - Timeout for ready check (default: 15000)
|
|
148
|
+
* @param {string[]} options.errorSelectors - Custom error selectors
|
|
149
|
+
* @param {boolean} options.errorHeuristics - Enable heuristic detection
|
|
150
|
+
* @returns {Promise<{status: string, attempts: number, errorDetails?: Object}>}
|
|
151
|
+
*/
|
|
152
|
+
async function executeWithRetry(engine, readySelector, options = {}) {
|
|
153
|
+
const captureConfig = getCaptureConfig(options);
|
|
154
|
+
const {
|
|
155
|
+
retryOnError = captureConfig.retryOnError,
|
|
156
|
+
retryDelay = captureConfig.retryDelay,
|
|
157
|
+
readyTimeout = captureConfig.readyTimeout,
|
|
158
|
+
errorSelectors = captureConfig.errorSelectors,
|
|
159
|
+
errorHeuristics = captureConfig.errorHeuristics,
|
|
160
|
+
} = options;
|
|
161
|
+
|
|
162
|
+
let lastResult = null;
|
|
163
|
+
const maxAttempts = 1 + retryOnError;
|
|
164
|
+
|
|
165
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
166
|
+
// Check ready/error state
|
|
167
|
+
const result = await engine.waitForReadyOrError(readySelector, {
|
|
168
|
+
timeout: readyTimeout,
|
|
169
|
+
errorSelectors,
|
|
170
|
+
errorHeuristics,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
lastResult = result;
|
|
174
|
+
|
|
175
|
+
if (result.status === "ready") {
|
|
176
|
+
return { status: "ready", attempts: attempt };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// If this is the last attempt, don't retry
|
|
180
|
+
if (attempt === maxAttempts) {
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Determine backoff delay
|
|
185
|
+
let delay = retryDelay * Math.pow(2, attempt - 1); // Exponential: 1s, 2s, 4s
|
|
186
|
+
|
|
187
|
+
// Rate-limit detection: use longer backoff
|
|
188
|
+
if (result.status === "error" && result.errorDetails) {
|
|
189
|
+
const msg = (result.errorDetails.errorMessage || "").toLowerCase();
|
|
190
|
+
if (
|
|
191
|
+
msg.includes("too many requests") ||
|
|
192
|
+
msg.includes("rate limit") ||
|
|
193
|
+
msg.includes("429")
|
|
194
|
+
) {
|
|
195
|
+
delay = Math.max(delay, 5000);
|
|
196
|
+
console.log(
|
|
197
|
+
chalk.yellow(
|
|
198
|
+
` ⚠ Rate limit detected, using longer backoff (${delay}ms)`
|
|
199
|
+
)
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const statusLabel =
|
|
205
|
+
result.status === "error"
|
|
206
|
+
? `error: ${result.errorDetails?.errorMessage?.slice(0, 80) || "unknown"}`
|
|
207
|
+
: "timeout";
|
|
208
|
+
console.log(
|
|
209
|
+
chalk.yellow(
|
|
210
|
+
` ⚠ Attempt ${attempt}/${maxAttempts} failed (${statusLabel}). Retrying in ${delay}ms...`
|
|
211
|
+
)
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// Wait and reload
|
|
215
|
+
await engine.page.waitForTimeout(delay);
|
|
216
|
+
await engine.page.reload({ waitUntil: "domcontentloaded" });
|
|
217
|
+
await engine._waitForStability();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// All attempts exhausted
|
|
221
|
+
if (lastResult?.status === "error") {
|
|
222
|
+
// Capture debug screenshot
|
|
223
|
+
try {
|
|
224
|
+
const debugPath = path.join(
|
|
225
|
+
engine.outputDir,
|
|
226
|
+
"debug-error-state.png"
|
|
227
|
+
);
|
|
228
|
+
fs.ensureDirSync(path.dirname(debugPath));
|
|
229
|
+
await engine.page.screenshot({ path: debugPath, fullPage: true });
|
|
230
|
+
console.log(chalk.yellow(` → Debug screenshot: ${debugPath}`));
|
|
231
|
+
} catch (e) {
|
|
232
|
+
// Ignore screenshot errors
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
status: lastResult?.status || "timeout",
|
|
238
|
+
attempts: maxAttempts,
|
|
239
|
+
errorDetails: lastResult?.errorDetails,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Run an auth pre-flight check before executing scenarios
|
|
245
|
+
* Navigates to a known page and verifies auth + data loading work
|
|
246
|
+
*
|
|
247
|
+
* @param {string} baseUrl - Base URL of the application
|
|
248
|
+
* @param {Object} options - Pre-flight options
|
|
249
|
+
* @param {string} options.storageStatePath - Path to auth state file
|
|
250
|
+
* @param {Object} options.viewport - Viewport configuration
|
|
251
|
+
* @returns {Promise<{ok: boolean, message?: string}>}
|
|
252
|
+
*/
|
|
253
|
+
async function preflightAuthCheck(baseUrl, options = {}) {
|
|
254
|
+
const { storageStatePath, viewport = { width: 1280, height: 720 } } = options;
|
|
255
|
+
|
|
256
|
+
if (!storageStatePath || !fs.existsSync(storageStatePath)) {
|
|
257
|
+
return { ok: true }; // No session to verify
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
console.log(chalk.gray(" → Running auth pre-flight check..."));
|
|
261
|
+
|
|
262
|
+
const engine = new CaptureEngine({
|
|
263
|
+
baseUrl,
|
|
264
|
+
viewport,
|
|
265
|
+
headless: true,
|
|
266
|
+
storageStatePath,
|
|
267
|
+
hideDevtools: true,
|
|
268
|
+
outputDir: path.join(".reshot", "tmp", "preflight"),
|
|
269
|
+
logger: () => {}, // Silent
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
await engine.init();
|
|
274
|
+
|
|
275
|
+
// Navigate to projects page (a page that requires auth + data)
|
|
276
|
+
await engine.page.goto(`${baseUrl}/app/projects`, {
|
|
277
|
+
waitUntil: "domcontentloaded",
|
|
278
|
+
timeout: 15000,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Check for auth redirect using shared utility
|
|
282
|
+
const currentUrl = engine.page.url();
|
|
283
|
+
const isAuthRedirect = isAuthRedirectUrl(currentUrl);
|
|
284
|
+
|
|
285
|
+
if (isAuthRedirect) {
|
|
286
|
+
return {
|
|
287
|
+
ok: false,
|
|
288
|
+
message:
|
|
289
|
+
"Auth session expired. Run `reshot record` to capture a fresh session.",
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Wait for data to settle
|
|
294
|
+
await engine.page.waitForTimeout(3000);
|
|
295
|
+
await engine._waitForStability();
|
|
296
|
+
|
|
297
|
+
// Check for error state
|
|
298
|
+
const errorState = await engine._detectErrorState();
|
|
299
|
+
if (errorState.hasError) {
|
|
300
|
+
return {
|
|
301
|
+
ok: false,
|
|
302
|
+
message: `Auth session appears valid but data fetching failed (${errorState.errorType}). This usually means your JWT has expired. Run \`reshot record\` to refresh.`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
console.log(chalk.green(" ✔ Auth pre-flight check passed"));
|
|
307
|
+
return { ok: true };
|
|
308
|
+
} catch (e) {
|
|
309
|
+
// If the error is an auth redirect thrown by the engine, handle gracefully
|
|
310
|
+
if (e.message?.includes("Auth redirect")) {
|
|
311
|
+
return {
|
|
312
|
+
ok: false,
|
|
313
|
+
message:
|
|
314
|
+
"Auth session expired. Run `reshot record` to capture a fresh session.",
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
// Other errors - don't block, just warn
|
|
318
|
+
console.log(
|
|
319
|
+
chalk.yellow(` ⚠ Pre-flight check error: ${e.message}. Continuing...`)
|
|
320
|
+
);
|
|
321
|
+
return { ok: true };
|
|
322
|
+
} finally {
|
|
323
|
+
await engine.close();
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Retry a single interactive step (click/type/hover) with page reload recovery.
|
|
329
|
+
*
|
|
330
|
+
* 1. Attempts the step once.
|
|
331
|
+
* 2. On failure of a non-optional step: reloads the page, re-navigates to
|
|
332
|
+
* lastGotoUrl (which triggers auth detection + stability checks), and retries.
|
|
333
|
+
* 3. On second failure: returns { success: false } — caller continues to next step.
|
|
334
|
+
*
|
|
335
|
+
* @param {CaptureEngine} engine
|
|
336
|
+
* @param {string} action - "click" | "type" | "hover"
|
|
337
|
+
* @param {Object} params - Step params (target, text, etc.)
|
|
338
|
+
* @param {Object} context
|
|
339
|
+
* @param {string|null} context.lastGotoUrl - Last goto URL for page restoration
|
|
340
|
+
* @param {Object|null} context.variantConfig - Variant config for URL params
|
|
341
|
+
* @param {Function} context.logger - Logging function
|
|
342
|
+
* @returns {Promise<{success: boolean, retried: boolean, error?: string}>}
|
|
343
|
+
*/
|
|
344
|
+
async function retryInteractiveStep(engine, action, params, context) {
|
|
345
|
+
const { lastGotoUrl, variantConfig, logger } = context;
|
|
346
|
+
|
|
347
|
+
async function attemptStep() {
|
|
348
|
+
// Check element visibility (5s timeout)
|
|
349
|
+
const element = await engine.page.locator(params.target).first();
|
|
350
|
+
const visible = await element
|
|
351
|
+
.isVisible({ timeout: 5000 })
|
|
352
|
+
.catch(() => false);
|
|
353
|
+
if (!visible) {
|
|
354
|
+
throw new Error(`Element not visible: ${params.target}`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Execute the action
|
|
358
|
+
switch (action) {
|
|
359
|
+
case "click":
|
|
360
|
+
await engine.click(params.target, params);
|
|
361
|
+
break;
|
|
362
|
+
case "type":
|
|
363
|
+
await engine.type(params.target, params.text, params);
|
|
364
|
+
break;
|
|
365
|
+
case "hover":
|
|
366
|
+
await engine.hover(params.target, params);
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// First attempt
|
|
372
|
+
try {
|
|
373
|
+
await attemptStep();
|
|
374
|
+
return { success: true, retried: false };
|
|
375
|
+
} catch (firstError) {
|
|
376
|
+
logger(
|
|
377
|
+
chalk.yellow(
|
|
378
|
+
` ⚠ Step failed: ${firstError.message}. Retrying after reload...`
|
|
379
|
+
)
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Retry: reload + re-navigate to last goto URL
|
|
384
|
+
if (!lastGotoUrl) {
|
|
385
|
+
return {
|
|
386
|
+
success: false,
|
|
387
|
+
retried: true,
|
|
388
|
+
error: "No goto URL available for page restoration",
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
await engine.page.reload({ waitUntil: "domcontentloaded" });
|
|
394
|
+
await engine._waitForStability();
|
|
395
|
+
|
|
396
|
+
// Re-navigate via engine.goto() so auth detection + stability run
|
|
397
|
+
let url = lastGotoUrl;
|
|
398
|
+
if (variantConfig?.urlParams) {
|
|
399
|
+
url = applyUrlParams(url, variantConfig.urlParams);
|
|
400
|
+
}
|
|
401
|
+
await engine.goto(url);
|
|
402
|
+
|
|
403
|
+
await attemptStep();
|
|
404
|
+
return { success: true, retried: true };
|
|
405
|
+
} catch (retryError) {
|
|
406
|
+
return { success: false, retried: true, error: retryError.message };
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Calculate a perceptual hash for an image buffer
|
|
412
|
+
* This is a simple hash based on resizing the image to a small grid
|
|
413
|
+
* For now we use a simple pixel-based comparison via buffer hash
|
|
414
|
+
*/
|
|
415
|
+
function calculateImageHash(buffer) {
|
|
416
|
+
return crypto.createHash("md5").update(buffer).digest("hex");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Check if two image buffers are visually similar
|
|
421
|
+
* Uses hash comparison - if hashes match, images are identical
|
|
422
|
+
*/
|
|
423
|
+
function imagesAreIdentical(buffer1, buffer2) {
|
|
424
|
+
if (!buffer1 || !buffer2) return false;
|
|
425
|
+
return calculateImageHash(buffer1) === calculateImageHash(buffer2);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Convert old-style steps to new capture script format
|
|
430
|
+
* This provides backward compatibility
|
|
431
|
+
* Also passes through crop configuration for individual steps
|
|
432
|
+
*/
|
|
433
|
+
function convertLegacySteps(scenario) {
|
|
434
|
+
const script = [];
|
|
435
|
+
|
|
436
|
+
// Start with navigation - apply URL variable substitution
|
|
437
|
+
if (scenario.url) {
|
|
438
|
+
script.push({ action: "goto", url: substituteUrlVariables(scenario.url) });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
for (const step of scenario.steps || []) {
|
|
442
|
+
switch (step.action) {
|
|
443
|
+
case "click":
|
|
444
|
+
script.push({
|
|
445
|
+
action: "click",
|
|
446
|
+
target: step.selector,
|
|
447
|
+
// Add description if available
|
|
448
|
+
description: step.description,
|
|
449
|
+
// Preserve optional flag for shorter timeouts
|
|
450
|
+
optional: step.optional,
|
|
451
|
+
});
|
|
452
|
+
break;
|
|
453
|
+
|
|
454
|
+
case "type":
|
|
455
|
+
case "input":
|
|
456
|
+
script.push({
|
|
457
|
+
action: "type",
|
|
458
|
+
target: step.selector,
|
|
459
|
+
text: step.text || "",
|
|
460
|
+
description: step.description,
|
|
461
|
+
optional: step.optional,
|
|
462
|
+
});
|
|
463
|
+
break;
|
|
464
|
+
|
|
465
|
+
case "hover":
|
|
466
|
+
script.push({
|
|
467
|
+
action: "hover",
|
|
468
|
+
target: step.selector,
|
|
469
|
+
description: step.description,
|
|
470
|
+
optional: step.optional,
|
|
471
|
+
});
|
|
472
|
+
break;
|
|
473
|
+
|
|
474
|
+
case "wait":
|
|
475
|
+
script.push({ action: "wait", ms: step.ms || step.duration || 1000 });
|
|
476
|
+
break;
|
|
477
|
+
|
|
478
|
+
case "waitForSelector":
|
|
479
|
+
script.push({
|
|
480
|
+
action: "waitFor",
|
|
481
|
+
target: step.selector,
|
|
482
|
+
optional: step.optional,
|
|
483
|
+
timeout: step.timeout,
|
|
484
|
+
});
|
|
485
|
+
break;
|
|
486
|
+
|
|
487
|
+
case "screenshot":
|
|
488
|
+
script.push({
|
|
489
|
+
action: "capture",
|
|
490
|
+
name:
|
|
491
|
+
step.key ||
|
|
492
|
+
step.path?.replace(".png", "") ||
|
|
493
|
+
`screenshot-${Date.now()}`,
|
|
494
|
+
selector: step.selector,
|
|
495
|
+
fullPage: step.fullPage,
|
|
496
|
+
clip: step.clip,
|
|
497
|
+
description: step.description,
|
|
498
|
+
// Pass through step-level crop configuration
|
|
499
|
+
cropConfig: step.crop || step.cropConfig,
|
|
500
|
+
// Pass through step-level privacy and style overrides
|
|
501
|
+
privacy: step.privacy,
|
|
502
|
+
style: step.style,
|
|
503
|
+
});
|
|
504
|
+
break;
|
|
505
|
+
|
|
506
|
+
case "keyboard":
|
|
507
|
+
script.push({
|
|
508
|
+
action: "keyboard",
|
|
509
|
+
key: step.key,
|
|
510
|
+
description: step.description,
|
|
511
|
+
});
|
|
512
|
+
break;
|
|
513
|
+
|
|
514
|
+
case "goto":
|
|
515
|
+
script.push({ action: "goto", url: substituteUrlVariables(step.url) });
|
|
516
|
+
break;
|
|
517
|
+
|
|
518
|
+
default:
|
|
519
|
+
console.warn(chalk.yellow(` ⚠ Unknown legacy action: ${step.action}`));
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return script;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Wait for loading skeletons and spinners to disappear
|
|
528
|
+
* Looks for common skeleton/loading patterns and waits until they're gone
|
|
529
|
+
* Increased maxWait to handle slower data fetches in SaaS apps
|
|
530
|
+
*/
|
|
531
|
+
async function waitForLoadingComplete(page, maxWait = 10000) {
|
|
532
|
+
// Strict loading selectors - these are definitely loading states
|
|
533
|
+
const strictLoadingSelectors = [
|
|
534
|
+
// Common skeleton classes
|
|
535
|
+
'[class*="skeleton"]',
|
|
536
|
+
'[class*="Skeleton"]',
|
|
537
|
+
'[class*="shimmer"]',
|
|
538
|
+
// Explicit loading states
|
|
539
|
+
'[class*="loading"]',
|
|
540
|
+
'[class*="Loading"]',
|
|
541
|
+
// Spinner/loader elements
|
|
542
|
+
'[class*="spinner"]',
|
|
543
|
+
'[class*="Spinner"]',
|
|
544
|
+
'[class*="loader"]',
|
|
545
|
+
'[class*="Loader"]',
|
|
546
|
+
// Role-based loading indicators
|
|
547
|
+
'[role="progressbar"]',
|
|
548
|
+
'[aria-busy="true"]',
|
|
549
|
+
// Next.js/React specific
|
|
550
|
+
'[data-loading="true"]',
|
|
551
|
+
"[data-skeleton]",
|
|
552
|
+
// Bootstrap placeholders
|
|
553
|
+
".placeholder-glow",
|
|
554
|
+
".placeholder-wave",
|
|
555
|
+
// Suspense fallbacks
|
|
556
|
+
".suspense-fallback",
|
|
557
|
+
".lazy-loading",
|
|
558
|
+
// Data testids for loading
|
|
559
|
+
'[data-testid*="loading"]',
|
|
560
|
+
'[data-testid*="skeleton"]',
|
|
561
|
+
];
|
|
562
|
+
|
|
563
|
+
// These selectors might be decorative (like animated icons) - check size
|
|
564
|
+
const decorativeSelectors = ['[class*="pulse"]', '[class*="animate-pulse"]'];
|
|
565
|
+
|
|
566
|
+
const startTime = Date.now();
|
|
567
|
+
let consecutiveNoLoading = 0;
|
|
568
|
+
|
|
569
|
+
while (Date.now() - startTime < maxWait) {
|
|
570
|
+
try {
|
|
571
|
+
// Check if any strict loading elements are visible
|
|
572
|
+
const hasStrictLoading = await page.evaluate((selectors) => {
|
|
573
|
+
for (const selector of selectors) {
|
|
574
|
+
try {
|
|
575
|
+
const elements = document.querySelectorAll(selector);
|
|
576
|
+
for (const el of elements) {
|
|
577
|
+
// Check if element is visible and reasonably sized
|
|
578
|
+
const rect = el.getBoundingClientRect();
|
|
579
|
+
const style = window.getComputedStyle(el);
|
|
580
|
+
if (
|
|
581
|
+
rect.width > 10 &&
|
|
582
|
+
rect.height > 10 &&
|
|
583
|
+
style.display !== "none" &&
|
|
584
|
+
style.visibility !== "hidden" &&
|
|
585
|
+
parseFloat(style.opacity) > 0
|
|
586
|
+
) {
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
} catch (e) {
|
|
591
|
+
// Invalid selector, skip
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return false;
|
|
595
|
+
}, strictLoadingSelectors);
|
|
596
|
+
|
|
597
|
+
// Check decorative selectors only if they're large (skeleton-like)
|
|
598
|
+
let hasDecorativeLoading = false;
|
|
599
|
+
if (!hasStrictLoading) {
|
|
600
|
+
hasDecorativeLoading = await page.evaluate((selectors) => {
|
|
601
|
+
for (const selector of selectors) {
|
|
602
|
+
try {
|
|
603
|
+
const elements = document.querySelectorAll(selector);
|
|
604
|
+
for (const el of elements) {
|
|
605
|
+
const rect = el.getBoundingClientRect();
|
|
606
|
+
const style = window.getComputedStyle(el);
|
|
607
|
+
// Only consider large pulse elements (actual skeletons, not decorative)
|
|
608
|
+
if (
|
|
609
|
+
rect.width > 50 &&
|
|
610
|
+
rect.height > 20 &&
|
|
611
|
+
style.display !== "none" &&
|
|
612
|
+
style.visibility !== "hidden" &&
|
|
613
|
+
parseFloat(style.opacity) > 0
|
|
614
|
+
) {
|
|
615
|
+
return true;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
} catch (e) {
|
|
619
|
+
// Invalid selector, skip
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return false;
|
|
623
|
+
}, decorativeSelectors);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (!hasStrictLoading && !hasDecorativeLoading) {
|
|
627
|
+
consecutiveNoLoading++;
|
|
628
|
+
// Require 3 consecutive checks with no loading to ensure stability
|
|
629
|
+
if (consecutiveNoLoading >= 3) {
|
|
630
|
+
return true;
|
|
631
|
+
}
|
|
632
|
+
} else {
|
|
633
|
+
consecutiveNoLoading = 0;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Wait a bit and check again
|
|
637
|
+
await page.waitForTimeout(150);
|
|
638
|
+
} catch {
|
|
639
|
+
// Page might be navigating, wait and retry
|
|
640
|
+
await page.waitForTimeout(150);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Timed out, but continue anyway
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Wait for visual stability - detect when the page stops changing
|
|
650
|
+
* Returns true if stable, false if timed out
|
|
651
|
+
*/
|
|
652
|
+
async function waitForVisualStability(page, maxWait = 1500) {
|
|
653
|
+
// First wait for loading elements to disappear
|
|
654
|
+
await waitForLoadingComplete(page, Math.min(maxWait, 3000));
|
|
655
|
+
|
|
656
|
+
let previousHash = null;
|
|
657
|
+
let stableCount = 0;
|
|
658
|
+
const checkInterval = 100;
|
|
659
|
+
let elapsed = 0;
|
|
660
|
+
|
|
661
|
+
while (elapsed < maxWait && stableCount < 2) {
|
|
662
|
+
const buffer = await page.screenshot();
|
|
663
|
+
const currentHash = calculateImageHash(buffer);
|
|
664
|
+
|
|
665
|
+
if (currentHash === previousHash) {
|
|
666
|
+
stableCount++;
|
|
667
|
+
} else {
|
|
668
|
+
stableCount = 0;
|
|
669
|
+
previousHash = currentHash;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (stableCount < 2) {
|
|
673
|
+
await page.waitForTimeout(checkInterval);
|
|
674
|
+
elapsed += checkInterval;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return stableCount >= 2;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Run scenario with deduplication and step-by-step image capture.
|
|
683
|
+
*/
|
|
684
|
+
async function runScenarioWithStepByStepCapture(scenario, options = {}) {
|
|
685
|
+
const {
|
|
686
|
+
outputDir,
|
|
687
|
+
baseUrl,
|
|
688
|
+
headless = true,
|
|
689
|
+
viewport = { width: 1280, height: 720 },
|
|
690
|
+
variantsConfig = {},
|
|
691
|
+
storageStateData = null,
|
|
692
|
+
quiet = false,
|
|
693
|
+
} = options;
|
|
694
|
+
|
|
695
|
+
const outputConfig = scenario.output || {};
|
|
696
|
+
|
|
697
|
+
// Extract crop configuration from scenario output settings
|
|
698
|
+
// This persists across all variations of the scenario
|
|
699
|
+
const scenarioCropConfig = outputConfig.crop || null;
|
|
700
|
+
|
|
701
|
+
// Resolve variant configuration
|
|
702
|
+
const variantConfig = resolveVariantConfig(scenario, variantsConfig);
|
|
703
|
+
|
|
704
|
+
// Resolve privacy configuration (global + scenario-level overrides)
|
|
705
|
+
const scenarioPrivacyConfig = config.getPrivacyConfig(scenario.privacy);
|
|
706
|
+
// Respect --no-privacy CLI flag
|
|
707
|
+
if (options.noPrivacy) {
|
|
708
|
+
scenarioPrivacyConfig.enabled = false;
|
|
709
|
+
}
|
|
710
|
+
const hasPrivacy = scenarioPrivacyConfig.enabled && scenarioPrivacyConfig.selectors.length > 0;
|
|
711
|
+
|
|
712
|
+
// Resolve style configuration (global + scenario-level overrides)
|
|
713
|
+
const scenarioStyleConfig = config.getStyleConfig(scenario.style);
|
|
714
|
+
// Respect --no-style CLI flag
|
|
715
|
+
if (options.noStyle) {
|
|
716
|
+
scenarioStyleConfig.enabled = false;
|
|
717
|
+
}
|
|
718
|
+
// Smart default: if scenario uses element capture (selector), default frame to "none"
|
|
719
|
+
const hasElementCapture = (scenario.steps || []).some(
|
|
720
|
+
(s) => s.action === "screenshot" && s.selector
|
|
721
|
+
);
|
|
722
|
+
if (hasElementCapture && scenarioStyleConfig.frame === undefined) {
|
|
723
|
+
scenarioStyleConfig.frame = "none";
|
|
724
|
+
}
|
|
725
|
+
const hasStyle = scenarioStyleConfig.enabled;
|
|
726
|
+
|
|
727
|
+
if (!quiet) {
|
|
728
|
+
console.log(chalk.bold(`\n📋 Scenario: ${scenario.name}`));
|
|
729
|
+
console.log(chalk.gray(` Key: ${scenario.key}`));
|
|
730
|
+
|
|
731
|
+
if (variantConfig?.summary?.length) {
|
|
732
|
+
for (const item of variantConfig.summary) {
|
|
733
|
+
console.log(chalk.gray(` ${item}`));
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Log crop config if enabled
|
|
738
|
+
if (scenarioCropConfig && scenarioCropConfig.enabled) {
|
|
739
|
+
console.log(
|
|
740
|
+
chalk.gray(` Crop: ${JSON.stringify(scenarioCropConfig.region)}`)
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Log privacy config if enabled
|
|
745
|
+
if (hasPrivacy) {
|
|
746
|
+
console.log(
|
|
747
|
+
chalk.gray(` Privacy: ${scenarioPrivacyConfig.selectors.length} selector(s), method=${scenarioPrivacyConfig.method}`)
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Log style config if enabled
|
|
752
|
+
if (hasStyle) {
|
|
753
|
+
const styleDesc = [];
|
|
754
|
+
if (scenarioStyleConfig.frame !== "none") styleDesc.push(`frame=${scenarioStyleConfig.frame}`);
|
|
755
|
+
if (scenarioStyleConfig.shadow !== "none") styleDesc.push(`shadow=${scenarioStyleConfig.shadow}`);
|
|
756
|
+
if (scenarioStyleConfig.padding > 0) styleDesc.push(`padding=${scenarioStyleConfig.padding}`);
|
|
757
|
+
if (scenarioStyleConfig.borderRadius > 0) styleDesc.push(`radius=${scenarioStyleConfig.borderRadius}`);
|
|
758
|
+
if (styleDesc.length > 0) {
|
|
759
|
+
console.log(chalk.gray(` Style: ${styleDesc.join(", ")}`));
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Resolve capture config for this scenario
|
|
765
|
+
const scenarioCaptureConfig = getCaptureConfig({
|
|
766
|
+
retryOnError: scenario.retryOnError,
|
|
767
|
+
readyTimeout: scenario.readyTimeout,
|
|
768
|
+
scenarioTimeout: scenario.scenarioTimeout,
|
|
769
|
+
errorSelectors: scenario.errorSelectors,
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
// Extract readySelector: prefer scenario-level, fall back to first waitForSelector step
|
|
773
|
+
let readySelector = scenario.readySelector || null;
|
|
774
|
+
if (!readySelector && scenario.steps) {
|
|
775
|
+
const firstWaitFor = scenario.steps.find(
|
|
776
|
+
(s) => s.action === "waitForSelector"
|
|
777
|
+
);
|
|
778
|
+
if (firstWaitFor) {
|
|
779
|
+
readySelector = firstWaitFor.selector;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const script = convertLegacySteps(scenario);
|
|
784
|
+
|
|
785
|
+
if (script.length === 0) {
|
|
786
|
+
if (!quiet) console.log(chalk.yellow(" ⚠ No steps to execute"));
|
|
787
|
+
return { success: true, assets: [] };
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (!quiet) console.log(chalk.gray(` Steps: ${script.length}`));
|
|
791
|
+
|
|
792
|
+
// Check for saved session state (auth cookies)
|
|
793
|
+
const sessionPath = getDefaultSessionPath();
|
|
794
|
+
const hasSession = fs.existsSync(sessionPath);
|
|
795
|
+
if (!quiet) {
|
|
796
|
+
if (hasSession) {
|
|
797
|
+
// Validate session freshness with graduated warnings
|
|
798
|
+
const sessionStats = fs.statSync(sessionPath);
|
|
799
|
+
const sessionAgeHours =
|
|
800
|
+
(Date.now() - sessionStats.mtimeMs) / (1000 * 60 * 60);
|
|
801
|
+
if (sessionAgeHours > 48) {
|
|
802
|
+
console.log(
|
|
803
|
+
chalk.red(
|
|
804
|
+
` ⚠ Auth session is ${Math.round(sessionAgeHours)}h old. Strongly recommend refreshing with \`reshot record\`.`
|
|
805
|
+
)
|
|
806
|
+
);
|
|
807
|
+
} else if (sessionAgeHours > 24) {
|
|
808
|
+
console.log(
|
|
809
|
+
chalk.yellow(
|
|
810
|
+
` ⚠ Auth session is ${Math.round(sessionAgeHours)}h old. Consider refreshing with \`reshot record\`.`
|
|
811
|
+
)
|
|
812
|
+
);
|
|
813
|
+
} else if (sessionAgeHours > 12) {
|
|
814
|
+
console.log(
|
|
815
|
+
chalk.gray(
|
|
816
|
+
` Auth session is ${Math.round(sessionAgeHours)}h old`
|
|
817
|
+
)
|
|
818
|
+
);
|
|
819
|
+
} else {
|
|
820
|
+
console.log(chalk.gray(` Using saved auth session`));
|
|
821
|
+
}
|
|
822
|
+
} else if (scenario.requiresAuth) {
|
|
823
|
+
console.log(
|
|
824
|
+
chalk.yellow(
|
|
825
|
+
` ⚠ Scenario requires auth but no session found at ${sessionPath}. Run \`reshot record\` to capture a session.`
|
|
826
|
+
)
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const engine = new CaptureEngine({
|
|
832
|
+
outputDir:
|
|
833
|
+
outputDir || path.join(".reshot/output", scenario.key, "default"),
|
|
834
|
+
baseUrl: baseUrl || "",
|
|
835
|
+
viewport,
|
|
836
|
+
headless,
|
|
837
|
+
variantConfig,
|
|
838
|
+
cropConfig: scenarioCropConfig, // Pass scenario-level crop config to engine
|
|
839
|
+
storageStatePath: hasSession ? sessionPath : null, // Use saved session if available
|
|
840
|
+
storageStateData, // Pre-loaded auth state (avoids redundant file reads)
|
|
841
|
+
hideDevtools: true, // Always hide dev overlays in captures
|
|
842
|
+
authPatterns: scenarioCaptureConfig.authPatterns, // Custom auth redirect patterns
|
|
843
|
+
waitForReady: scenario.waitForReady || null, // Custom loading-state hook
|
|
844
|
+
privacyConfig: hasPrivacy ? scenarioPrivacyConfig : null, // Privacy masking
|
|
845
|
+
styleConfig: hasStyle ? scenarioStyleConfig : null, // Image beautification
|
|
846
|
+
logger: quiet ? () => {} : (msg) => console.log(msg),
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
const assets = [];
|
|
850
|
+
let skippedSteps = 0;
|
|
851
|
+
let duplicatesSkipped = 0;
|
|
852
|
+
let failedSteps = [];
|
|
853
|
+
let retriedSteps = 0;
|
|
854
|
+
let lastGotoUrl = null;
|
|
855
|
+
let lastScreenshotHash = null;
|
|
856
|
+
let captureIndex = 0;
|
|
857
|
+
|
|
858
|
+
try {
|
|
859
|
+
await engine.init();
|
|
860
|
+
|
|
861
|
+
// Wrap scenario execution in a timeout to prevent hanging
|
|
862
|
+
const scenarioTimeoutMs = scenarioCaptureConfig.scenarioTimeout;
|
|
863
|
+
const scenarioTimeoutPromise = new Promise((_, reject) => {
|
|
864
|
+
setTimeout(
|
|
865
|
+
() =>
|
|
866
|
+
reject(
|
|
867
|
+
new Error(
|
|
868
|
+
`Scenario timed out after ${scenarioTimeoutMs / 1000}s`
|
|
869
|
+
)
|
|
870
|
+
),
|
|
871
|
+
scenarioTimeoutMs
|
|
872
|
+
);
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
// Execute the scenario steps (will race against timeout)
|
|
876
|
+
const scenarioExecution = (async () => {
|
|
877
|
+
const outDir =
|
|
878
|
+
outputDir || path.join(".reshot/output", scenario.key, "default");
|
|
879
|
+
fs.ensureDirSync(outDir);
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Capture a screenshot only if it's visually different from the last one
|
|
883
|
+
* Applies scenario-level cropping and style processing if configured
|
|
884
|
+
* @param {string} name - Capture name
|
|
885
|
+
* @param {string} description - Human-readable description
|
|
886
|
+
* @param {string} type - Capture type (state, initial, action, final)
|
|
887
|
+
* @param {Object} [stepOverrides] - Optional step-level overrides
|
|
888
|
+
* @param {Object} [stepOverrides.cropConfig] - Step-level crop override
|
|
889
|
+
* @param {Object} [stepOverrides.privacy] - Step-level privacy override
|
|
890
|
+
* @param {Object} [stepOverrides.style] - Step-level style override
|
|
891
|
+
*/
|
|
892
|
+
async function captureIfChanged(
|
|
893
|
+
name,
|
|
894
|
+
description,
|
|
895
|
+
type = "state",
|
|
896
|
+
stepOverrides = {}
|
|
897
|
+
) {
|
|
898
|
+
const { cropConfig: stepCropConfig = null, privacy: stepPrivacy = null, style: stepStyle = null } = stepOverrides || {};
|
|
899
|
+
|
|
900
|
+
// CRITICAL: If privacy masking was configured but injection failed, skip capture
|
|
901
|
+
if (hasPrivacy && !engine._privacyInjectionOk) {
|
|
902
|
+
console.error(chalk.red(` ✖ PRIVACY: Skipping capture "${name}" — privacy masking injection failed. Fix the issue or use --no-privacy.`));
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Handle step-level privacy override (remove + re-inject merged config)
|
|
907
|
+
// Uses pause/resume to prevent framenavigated handler from re-injecting stale CSS
|
|
908
|
+
let privacyWasOverridden = false;
|
|
909
|
+
if (stepPrivacy && hasPrivacy) {
|
|
910
|
+
pausePrivacyReinjection(engine.page);
|
|
911
|
+
try {
|
|
912
|
+
const mergedStepPrivacy = mergePrivacyConfig(scenarioPrivacyConfig, stepPrivacy);
|
|
913
|
+
await removePrivacyMasking(engine.page);
|
|
914
|
+
const stepResult = await injectPrivacyMasking(engine.page, mergedStepPrivacy, quiet ? () => {} : (msg) => console.log(msg));
|
|
915
|
+
if (!stepResult.success) {
|
|
916
|
+
// Fallback: re-inject scenario-level privacy
|
|
917
|
+
console.error(chalk.red(` ✖ PRIVACY: Step override injection failed, re-injecting scenario-level masking`));
|
|
918
|
+
await injectPrivacyMasking(engine.page, scenarioPrivacyConfig, quiet ? () => {} : (msg) => console.log(msg));
|
|
919
|
+
}
|
|
920
|
+
privacyWasOverridden = true;
|
|
921
|
+
} catch (privacyError) {
|
|
922
|
+
// Fallback: try to re-inject scenario-level privacy
|
|
923
|
+
console.error(chalk.red(` ✖ PRIVACY: Step override error: ${privacyError.message}. Re-injecting scenario-level masking.`));
|
|
924
|
+
try {
|
|
925
|
+
await injectPrivacyMasking(engine.page, scenarioPrivacyConfig, quiet ? () => {} : (msg) => console.log(msg));
|
|
926
|
+
} catch (_e) {
|
|
927
|
+
// Last resort — scenario privacy is broken
|
|
928
|
+
}
|
|
929
|
+
} finally {
|
|
930
|
+
resumePrivacyReinjection(engine.page);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
// Wait for visual stability
|
|
934
|
+
await waitForVisualStability(engine.page, 1000);
|
|
935
|
+
|
|
936
|
+
// CRITICAL: Final theme enforcement right before capture
|
|
937
|
+
// This ensures theme classes haven't been reset by React/framework re-renders
|
|
938
|
+
await engine.page.evaluate(() => {
|
|
939
|
+
if (window.__RESHOT_THEME_OVERRIDE__) {
|
|
940
|
+
const wanted = window.__RESHOT_THEME_OVERRIDE__;
|
|
941
|
+
document.documentElement.classList.remove("dark", "light");
|
|
942
|
+
document.documentElement.classList.add(wanted);
|
|
943
|
+
document.documentElement.style.colorScheme = wanted;
|
|
944
|
+
document.documentElement.setAttribute("data-theme", wanted);
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
// Brief wait for CSS to apply
|
|
948
|
+
await engine.page.waitForTimeout(50);
|
|
949
|
+
|
|
950
|
+
let buffer = await engine.page.screenshot();
|
|
951
|
+
|
|
952
|
+
// Apply cropping if configured (scenario-level or step-level)
|
|
953
|
+
const effectiveCropConfig = mergeCropConfigs(
|
|
954
|
+
scenarioCropConfig,
|
|
955
|
+
stepCropConfig
|
|
956
|
+
);
|
|
957
|
+
let wasCropped = false;
|
|
958
|
+
|
|
959
|
+
// Resolve selector-based crop to a bounding box region
|
|
960
|
+
if (effectiveCropConfig && effectiveCropConfig.enabled && effectiveCropConfig.selector && !effectiveCropConfig.region) {
|
|
961
|
+
try {
|
|
962
|
+
const box = await engine.page.evaluate((sel) => {
|
|
963
|
+
const selectors = sel.split(',').map(s => s.trim());
|
|
964
|
+
for (const s of selectors) {
|
|
965
|
+
const el = document.querySelector(s);
|
|
966
|
+
if (el) {
|
|
967
|
+
const rect = el.getBoundingClientRect();
|
|
968
|
+
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return null;
|
|
972
|
+
}, effectiveCropConfig.selector);
|
|
973
|
+
if (box) {
|
|
974
|
+
effectiveCropConfig.region = box;
|
|
975
|
+
} else {
|
|
976
|
+
debug(`Crop selector not found: ${effectiveCropConfig.selector}`);
|
|
977
|
+
}
|
|
978
|
+
} catch (e) {
|
|
979
|
+
debug(`Failed to resolve crop selector: ${e.message}`);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
if (
|
|
984
|
+
effectiveCropConfig &&
|
|
985
|
+
effectiveCropConfig.enabled &&
|
|
986
|
+
isSharpAvailable()
|
|
987
|
+
) {
|
|
988
|
+
try {
|
|
989
|
+
// Get device scale factor for coordinate scaling
|
|
990
|
+
const deviceScaleFactor = await engine.page.evaluate(
|
|
991
|
+
() => window.devicePixelRatio || 1
|
|
992
|
+
);
|
|
993
|
+
|
|
994
|
+
buffer = await cropImageBuffer(buffer, effectiveCropConfig, {
|
|
995
|
+
deviceScaleFactor,
|
|
996
|
+
});
|
|
997
|
+
wasCropped = true;
|
|
998
|
+
debug(
|
|
999
|
+
`Cropped ${name} to region: ${JSON.stringify(
|
|
1000
|
+
effectiveCropConfig.region
|
|
1001
|
+
)}`
|
|
1002
|
+
);
|
|
1003
|
+
} catch (cropError) {
|
|
1004
|
+
console.log(
|
|
1005
|
+
chalk.yellow(` ⚠ Crop failed for ${name}: ${cropError.message}`)
|
|
1006
|
+
);
|
|
1007
|
+
// Continue with uncropped buffer
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Apply style processing (frames, shadow, padding, etc.)
|
|
1012
|
+
let wasStyled = false;
|
|
1013
|
+
if (hasStyle && isStyleAvailable()) {
|
|
1014
|
+
const effectiveStyleConfig = stepStyle
|
|
1015
|
+
? mergeStyleConfig(scenarioStyleConfig, stepStyle)
|
|
1016
|
+
: { ...scenarioStyleConfig };
|
|
1017
|
+
|
|
1018
|
+
// Detect dark mode from variant config
|
|
1019
|
+
if (variantConfig?.browserOptions?.colorScheme === "dark") {
|
|
1020
|
+
effectiveStyleConfig._darkMode = true;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
try {
|
|
1024
|
+
// Get DPR for accurate scaling
|
|
1025
|
+
const captureDpr = await engine.page.evaluate(() => window.devicePixelRatio || 1);
|
|
1026
|
+
buffer = await applyStyle(buffer, effectiveStyleConfig, quiet ? () => {} : (msg) => console.log(msg), captureDpr);
|
|
1027
|
+
wasStyled = true;
|
|
1028
|
+
} catch (styleError) {
|
|
1029
|
+
console.log(
|
|
1030
|
+
chalk.yellow(` ⚠ Style failed for ${name}: ${styleError.message}`)
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const currentHash = calculateImageHash(buffer);
|
|
1036
|
+
|
|
1037
|
+
// Check for duplicate — but always save explicit captures (docs reference these keys)
|
|
1038
|
+
if (lastScreenshotHash && currentHash === lastScreenshotHash && type !== "explicit") {
|
|
1039
|
+
console.log(chalk.gray(` → Skipped (no change): ${name}`));
|
|
1040
|
+
duplicatesSkipped++;
|
|
1041
|
+
// Restore scenario-level privacy if step override was used
|
|
1042
|
+
if (privacyWasOverridden) {
|
|
1043
|
+
pausePrivacyReinjection(engine.page);
|
|
1044
|
+
try {
|
|
1045
|
+
await removePrivacyMasking(engine.page);
|
|
1046
|
+
await injectPrivacyMasking(engine.page, scenarioPrivacyConfig, quiet ? () => {} : (msg) => console.log(msg));
|
|
1047
|
+
} finally {
|
|
1048
|
+
resumePrivacyReinjection(engine.page);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Save the screenshot
|
|
1055
|
+
const filePath = path.join(outDir, `${name}.png`);
|
|
1056
|
+
await fs.writeFile(filePath, buffer);
|
|
1057
|
+
lastScreenshotHash = currentHash;
|
|
1058
|
+
|
|
1059
|
+
const asset = {
|
|
1060
|
+
name,
|
|
1061
|
+
path: filePath,
|
|
1062
|
+
description,
|
|
1063
|
+
captureIndex,
|
|
1064
|
+
type,
|
|
1065
|
+
cropped: wasCropped,
|
|
1066
|
+
cropConfig: wasCropped ? effectiveCropConfig : undefined,
|
|
1067
|
+
styled: wasStyled,
|
|
1068
|
+
};
|
|
1069
|
+
assets.push(asset);
|
|
1070
|
+
captureIndex++;
|
|
1071
|
+
const cropIndicator = wasCropped ? " ✂" : "";
|
|
1072
|
+
const styleIndicator = wasStyled ? " ✨" : "";
|
|
1073
|
+
console.log(chalk.green(` 📸 ${name}.png${cropIndicator}${styleIndicator}`));
|
|
1074
|
+
|
|
1075
|
+
// Restore scenario-level privacy if step override was used
|
|
1076
|
+
if (privacyWasOverridden) {
|
|
1077
|
+
pausePrivacyReinjection(engine.page);
|
|
1078
|
+
try {
|
|
1079
|
+
await removePrivacyMasking(engine.page);
|
|
1080
|
+
await injectPrivacyMasking(engine.page, scenarioPrivacyConfig, quiet ? () => {} : (msg) => console.log(msg));
|
|
1081
|
+
} finally {
|
|
1082
|
+
resumePrivacyReinjection(engine.page);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
return asset;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Execute steps
|
|
1090
|
+
for (let stepIndex = 0; stepIndex < script.length; stepIndex++) {
|
|
1091
|
+
const step = script[stepIndex];
|
|
1092
|
+
const { action, ...params } = step;
|
|
1093
|
+
const onNotFound = step.onNotFound || "skip";
|
|
1094
|
+
|
|
1095
|
+
// Handle goto - capture initial state with error detection
|
|
1096
|
+
if (action === "goto") {
|
|
1097
|
+
let url = params.url;
|
|
1098
|
+
if (variantConfig?.urlParams) {
|
|
1099
|
+
url = applyUrlParams(url, variantConfig.urlParams);
|
|
1100
|
+
}
|
|
1101
|
+
lastGotoUrl = url; // Track for per-step retry restoration
|
|
1102
|
+
await engine.goto(url, params);
|
|
1103
|
+
|
|
1104
|
+
// Wait for page to fully load
|
|
1105
|
+
try {
|
|
1106
|
+
await engine.page.waitForLoadState("networkidle", { timeout: 5000 });
|
|
1107
|
+
} catch (e) {
|
|
1108
|
+
// Continue even if timeout
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// Extra wait for i18n/dynamic content
|
|
1112
|
+
await engine.page.waitForTimeout(300);
|
|
1113
|
+
|
|
1114
|
+
// If we have a readySelector, use error-aware waiting with retries
|
|
1115
|
+
if (readySelector) {
|
|
1116
|
+
const retryResult = await executeWithRetry(engine, readySelector, {
|
|
1117
|
+
retryOnError: scenarioCaptureConfig.retryOnError,
|
|
1118
|
+
retryDelay: scenarioCaptureConfig.retryDelay,
|
|
1119
|
+
readyTimeout: scenarioCaptureConfig.readyTimeout,
|
|
1120
|
+
errorSelectors: scenarioCaptureConfig.errorSelectors,
|
|
1121
|
+
errorHeuristics: scenarioCaptureConfig.errorHeuristics,
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
if (retryResult.status === "error") {
|
|
1125
|
+
const errMsg =
|
|
1126
|
+
retryResult.errorDetails?.errorMessage || "Unknown error";
|
|
1127
|
+
console.log(
|
|
1128
|
+
chalk.red(
|
|
1129
|
+
` ✖ Page loaded with error after ${retryResult.attempts} attempt(s): ${errMsg}`
|
|
1130
|
+
)
|
|
1131
|
+
);
|
|
1132
|
+
throw new Error(
|
|
1133
|
+
`Page error detected: ${errMsg}. The page rendered an error UI instead of expected content.`
|
|
1134
|
+
);
|
|
1135
|
+
} else if (retryResult.status === "timeout") {
|
|
1136
|
+
console.log(
|
|
1137
|
+
chalk.yellow(
|
|
1138
|
+
` ⚠ Ready selector not found after ${retryResult.attempts} attempt(s), proceeding with current state`
|
|
1139
|
+
)
|
|
1140
|
+
);
|
|
1141
|
+
} else if (retryResult.attempts > 1) {
|
|
1142
|
+
console.log(
|
|
1143
|
+
chalk.green(
|
|
1144
|
+
` ✔ Page loaded successfully after ${retryResult.attempts} attempt(s)`
|
|
1145
|
+
)
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Content verification (if enabled)
|
|
1151
|
+
if (scenarioCaptureConfig.contentVerification) {
|
|
1152
|
+
const contentResult = await engine._verifyContent({
|
|
1153
|
+
minContentLength: 100,
|
|
1154
|
+
rejectSelectors: scenarioCaptureConfig.errorSelectors,
|
|
1155
|
+
});
|
|
1156
|
+
if (!contentResult.valid) {
|
|
1157
|
+
console.log(
|
|
1158
|
+
chalk.yellow(
|
|
1159
|
+
` ⚠ Content verification warning: ${contentResult.reason}`
|
|
1160
|
+
)
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Capture initial state
|
|
1166
|
+
await captureIfChanged(
|
|
1167
|
+
`step-${stepIndex}-initial`,
|
|
1168
|
+
"Initial page state",
|
|
1169
|
+
"initial"
|
|
1170
|
+
);
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// Handle keyboard actions
|
|
1175
|
+
if (action === "keyboard") {
|
|
1176
|
+
await engine.page.keyboard.press(params.key);
|
|
1177
|
+
await engine.page.waitForTimeout(300);
|
|
1178
|
+
await captureIfChanged(
|
|
1179
|
+
`step-${stepIndex}-keyboard`,
|
|
1180
|
+
params.description || `After pressing ${params.key}`,
|
|
1181
|
+
"keyboard"
|
|
1182
|
+
);
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Handle interactive actions (click / type / hover)
|
|
1187
|
+
if (["click", "type", "hover"].includes(action)) {
|
|
1188
|
+
const target = params.target;
|
|
1189
|
+
const isOptional = step.optional === true;
|
|
1190
|
+
|
|
1191
|
+
if (isOptional) {
|
|
1192
|
+
// Optional steps: attempt once, skip silently on failure (no retry)
|
|
1193
|
+
const visibilityTimeout = 3000;
|
|
1194
|
+
let elementExists = false;
|
|
1195
|
+
try {
|
|
1196
|
+
const element = await engine.page.locator(target).first();
|
|
1197
|
+
elementExists = await element
|
|
1198
|
+
.isVisible({ timeout: visibilityTimeout })
|
|
1199
|
+
.catch(() => false);
|
|
1200
|
+
} catch (_e) {
|
|
1201
|
+
elementExists = false;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
if (!elementExists) {
|
|
1205
|
+
skippedSteps++;
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
try {
|
|
1210
|
+
switch (action) {
|
|
1211
|
+
case "click":
|
|
1212
|
+
await engine.click(target, params);
|
|
1213
|
+
break;
|
|
1214
|
+
case "type":
|
|
1215
|
+
await engine.type(target, params.text, params);
|
|
1216
|
+
break;
|
|
1217
|
+
case "hover":
|
|
1218
|
+
await engine.hover(target, params);
|
|
1219
|
+
break;
|
|
1220
|
+
}
|
|
1221
|
+
} catch (_actionError) {
|
|
1222
|
+
skippedSteps++;
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
} else {
|
|
1226
|
+
// Non-optional steps: use retry with page reload recovery
|
|
1227
|
+
const result = await retryInteractiveStep(engine, action, params, {
|
|
1228
|
+
lastGotoUrl,
|
|
1229
|
+
variantConfig,
|
|
1230
|
+
logger: quiet ? () => {} : (msg) => console.log(msg),
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
if (result.retried) retriedSteps++;
|
|
1234
|
+
|
|
1235
|
+
if (!result.success) {
|
|
1236
|
+
if (onNotFound === "fail") {
|
|
1237
|
+
throw new Error(
|
|
1238
|
+
`Step ${stepIndex + 1} (${action} "${target}") failed after retry: ${result.error}`
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
console.log(
|
|
1242
|
+
chalk.red(
|
|
1243
|
+
` ✖ Step ${stepIndex + 1} (${action} "${target}") failed after retry: ${result.error}`
|
|
1244
|
+
)
|
|
1245
|
+
);
|
|
1246
|
+
failedSteps.push({
|
|
1247
|
+
stepIndex: stepIndex + 1,
|
|
1248
|
+
action,
|
|
1249
|
+
target,
|
|
1250
|
+
error: result.error,
|
|
1251
|
+
});
|
|
1252
|
+
continue;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// Wait for animations/transitions - longer wait for multi-step flows
|
|
1257
|
+
const isMultiStep = script.length > 3;
|
|
1258
|
+
await engine.page.waitForTimeout(isMultiStep ? 500 : 150);
|
|
1259
|
+
|
|
1260
|
+
// Capture the result (only if visually different)
|
|
1261
|
+
const stepDesc = step.description || `After ${action}`;
|
|
1262
|
+
await captureIfChanged(`step-${stepIndex}-${action}`, stepDesc, action);
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Handle wait actions (no capture)
|
|
1267
|
+
if (action === "wait") {
|
|
1268
|
+
await engine.wait(params.ms || params.duration || 1000);
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
if (action === "waitFor") {
|
|
1273
|
+
const isOptional = step.optional === true;
|
|
1274
|
+
const waitTimeout = params.timeout || (isOptional ? 3000 : 10000);
|
|
1275
|
+
|
|
1276
|
+
// Use error-aware waiting for waitFor steps
|
|
1277
|
+
const waitResult = await engine.waitForReadyOrError(params.target, {
|
|
1278
|
+
timeout: waitTimeout,
|
|
1279
|
+
errorSelectors: scenarioCaptureConfig.errorSelectors,
|
|
1280
|
+
errorHeuristics: scenarioCaptureConfig.errorHeuristics,
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
if (waitResult.status === "error") {
|
|
1284
|
+
const errMsg =
|
|
1285
|
+
waitResult.errorDetails?.errorMessage?.slice(0, 100) ||
|
|
1286
|
+
"Unknown error";
|
|
1287
|
+
if (!isOptional) {
|
|
1288
|
+
console.warn(
|
|
1289
|
+
chalk.yellow(
|
|
1290
|
+
` ⚠ Page error detected while waiting for: ${params.target}`
|
|
1291
|
+
)
|
|
1292
|
+
);
|
|
1293
|
+
console.warn(chalk.gray(` Error: ${errMsg}`));
|
|
1294
|
+
console.warn(
|
|
1295
|
+
chalk.gray(
|
|
1296
|
+
` Hint: If data isn't loading, run 'reshot record' to refresh your session`
|
|
1297
|
+
)
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
} else if (waitResult.status === "timeout") {
|
|
1301
|
+
if (!isOptional) {
|
|
1302
|
+
const currentUrl = engine.page.url();
|
|
1303
|
+
console.warn(
|
|
1304
|
+
chalk.yellow(` ⚠ Element not found: ${params.target}`)
|
|
1305
|
+
);
|
|
1306
|
+
console.warn(chalk.gray(` URL: ${currentUrl}`));
|
|
1307
|
+
console.warn(
|
|
1308
|
+
chalk.gray(
|
|
1309
|
+
` Hint: If content isn't loading, run 'reshot record' to refresh your session`
|
|
1310
|
+
)
|
|
1311
|
+
);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
// Continue with next steps - the scenario may still capture partial state
|
|
1315
|
+
continue;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// Handle explicit capture actions
|
|
1319
|
+
if (action === "capture") {
|
|
1320
|
+
await captureIfChanged(
|
|
1321
|
+
params.name || `step-${stepIndex}`,
|
|
1322
|
+
params.description,
|
|
1323
|
+
"explicit",
|
|
1324
|
+
{
|
|
1325
|
+
cropConfig: params.cropConfig,
|
|
1326
|
+
privacy: params.privacy,
|
|
1327
|
+
style: params.style,
|
|
1328
|
+
}
|
|
1329
|
+
);
|
|
1330
|
+
continue;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// Wait for the final state to settle after all actions
|
|
1335
|
+
// This is important for actions like form submissions that trigger page changes
|
|
1336
|
+
if (!quiet) console.log(chalk.gray(` → Waiting for final state to settle...`));
|
|
1337
|
+
await engine.page.waitForTimeout(1000);
|
|
1338
|
+
try {
|
|
1339
|
+
await engine.page.waitForLoadState("networkidle", { timeout: 3000 });
|
|
1340
|
+
} catch (e) {
|
|
1341
|
+
// Continue even if timeout
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// Capture final state (only if different from last)
|
|
1345
|
+
await captureIfChanged(`final`, "Final state", "final");
|
|
1346
|
+
|
|
1347
|
+
// Summary
|
|
1348
|
+
const captured = assets.length;
|
|
1349
|
+
|
|
1350
|
+
if (!quiet) {
|
|
1351
|
+
console.log(chalk.green(`\n ✔ Scenario completed: ${captured} captures`));
|
|
1352
|
+
if (duplicatesSkipped > 0) {
|
|
1353
|
+
console.log(
|
|
1354
|
+
chalk.gray(` ${duplicatesSkipped} unchanged states skipped`)
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1357
|
+
if (skippedSteps > 0) {
|
|
1358
|
+
console.log(
|
|
1359
|
+
chalk.yellow(` ${skippedSteps} optional steps skipped`)
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
if (retriedSteps > 0) {
|
|
1363
|
+
console.log(
|
|
1364
|
+
chalk.cyan(` ↻ ${retriedSteps} step(s) recovered after retry`)
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
if (failedSteps.length > 0) {
|
|
1368
|
+
console.log(
|
|
1369
|
+
chalk.red(` ✖ ${failedSteps.length} step(s) failed after retry:`)
|
|
1370
|
+
);
|
|
1371
|
+
for (const f of failedSteps) {
|
|
1372
|
+
console.log(
|
|
1373
|
+
chalk.red(` Step ${f.stepIndex} (${f.action} "${f.target}"): ${f.error}`)
|
|
1374
|
+
);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// Build privacy/style metadata for the manifest
|
|
1380
|
+
const privacyMeta = hasPrivacy ? {
|
|
1381
|
+
enabled: true,
|
|
1382
|
+
method: scenarioPrivacyConfig.method,
|
|
1383
|
+
selectorCount: scenarioPrivacyConfig.selectors.length,
|
|
1384
|
+
} : { enabled: false };
|
|
1385
|
+
|
|
1386
|
+
const styleMeta = hasStyle ? {
|
|
1387
|
+
enabled: true,
|
|
1388
|
+
frame: scenarioStyleConfig.frame || "none",
|
|
1389
|
+
shadow: scenarioStyleConfig.shadow || "none",
|
|
1390
|
+
padding: scenarioStyleConfig.padding || 0,
|
|
1391
|
+
borderRadius: scenarioStyleConfig.borderRadius || 0,
|
|
1392
|
+
background: scenarioStyleConfig.background || "transparent",
|
|
1393
|
+
} : { enabled: false };
|
|
1394
|
+
|
|
1395
|
+
// Write manifest with privacy/style metadata
|
|
1396
|
+
const manifestPath = path.join(outDir, "manifest.json");
|
|
1397
|
+
const manifest = {
|
|
1398
|
+
generatedAt: new Date().toISOString(),
|
|
1399
|
+
scenario: scenario.key,
|
|
1400
|
+
assetCount: assets.length,
|
|
1401
|
+
privacy: privacyMeta,
|
|
1402
|
+
style: styleMeta,
|
|
1403
|
+
};
|
|
1404
|
+
try {
|
|
1405
|
+
fs.writeJSONSync(manifestPath, manifest, { spaces: 2 });
|
|
1406
|
+
} catch (_e) {
|
|
1407
|
+
// Non-critical — don't fail the capture
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
return { success: failedSteps.length === 0, assets, skippedSteps, duplicatesSkipped, failedSteps, retriedSteps, privacy: privacyMeta, style: styleMeta };
|
|
1411
|
+
})(); // End of scenarioExecution async IIFE
|
|
1412
|
+
|
|
1413
|
+
// Race scenario execution against timeout
|
|
1414
|
+
return await Promise.race([scenarioExecution, scenarioTimeoutPromise]);
|
|
1415
|
+
} catch (error) {
|
|
1416
|
+
console.error(
|
|
1417
|
+
chalk.red(
|
|
1418
|
+
`\n ❌ Scenario '${scenario.name || scenario.key}' failed: ${
|
|
1419
|
+
error.message
|
|
1420
|
+
}`
|
|
1421
|
+
)
|
|
1422
|
+
);
|
|
1423
|
+
|
|
1424
|
+
try {
|
|
1425
|
+
if (engine.page) {
|
|
1426
|
+
const debugPath = path.join(
|
|
1427
|
+
outputDir || ".reshot/output",
|
|
1428
|
+
scenario.key,
|
|
1429
|
+
"debug-failure.png"
|
|
1430
|
+
);
|
|
1431
|
+
fs.ensureDirSync(path.dirname(debugPath));
|
|
1432
|
+
await engine.page.screenshot({ path: debugPath, fullPage: true });
|
|
1433
|
+
console.error(chalk.yellow(` Debug screenshot: ${debugPath}`));
|
|
1434
|
+
}
|
|
1435
|
+
} catch (e) {
|
|
1436
|
+
// Ignore
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
return { success: false, error: error.message, assets, skippedSteps, failedSteps, retriedSteps };
|
|
1440
|
+
} finally {
|
|
1441
|
+
await engine.close();
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/**
|
|
1446
|
+
* Capture screenshot with highlight box around element
|
|
1447
|
+
*/
|
|
1448
|
+
async function captureWithHighlight(
|
|
1449
|
+
engine,
|
|
1450
|
+
target,
|
|
1451
|
+
outputPath,
|
|
1452
|
+
highlight = {}
|
|
1453
|
+
) {
|
|
1454
|
+
const { color = "rgba(255, 255, 0, 0.5)", style = "box" } = highlight;
|
|
1455
|
+
|
|
1456
|
+
// Try to find the element
|
|
1457
|
+
const element = await engine._findElement(target, {
|
|
1458
|
+
mustBeVisible: false,
|
|
1459
|
+
timeout: 2000,
|
|
1460
|
+
});
|
|
1461
|
+
const box = await element.boundingBox();
|
|
1462
|
+
|
|
1463
|
+
if (box) {
|
|
1464
|
+
// Inject highlight overlay
|
|
1465
|
+
await engine.page.evaluate(
|
|
1466
|
+
({ box, color, style }) => {
|
|
1467
|
+
const existingHighlight = document.getElementById("reshot-highlight");
|
|
1468
|
+
if (existingHighlight) existingHighlight.remove();
|
|
1469
|
+
|
|
1470
|
+
const div = document.createElement("div");
|
|
1471
|
+
div.id = "reshot-highlight";
|
|
1472
|
+
div.style.cssText = `
|
|
1473
|
+
position: fixed;
|
|
1474
|
+
left: ${box.x}px;
|
|
1475
|
+
top: ${box.y}px;
|
|
1476
|
+
width: ${box.width}px;
|
|
1477
|
+
height: ${box.height}px;
|
|
1478
|
+
background: ${style === "box" ? color : "transparent"};
|
|
1479
|
+
border: ${
|
|
1480
|
+
style === "outline"
|
|
1481
|
+
? `3px solid ${color.replace("0.5", "1")}`
|
|
1482
|
+
: "none"
|
|
1483
|
+
};
|
|
1484
|
+
pointer-events: none;
|
|
1485
|
+
z-index: 999999;
|
|
1486
|
+
box-sizing: border-box;
|
|
1487
|
+
border-radius: 4px;
|
|
1488
|
+
`;
|
|
1489
|
+
document.body.appendChild(div);
|
|
1490
|
+
},
|
|
1491
|
+
{ box, color, style }
|
|
1492
|
+
);
|
|
1493
|
+
|
|
1494
|
+
// Wait for highlight to render
|
|
1495
|
+
await engine.page.waitForTimeout(50);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// Capture screenshot
|
|
1499
|
+
await engine.page.screenshot({ path: outputPath });
|
|
1500
|
+
|
|
1501
|
+
// Remove highlight overlay
|
|
1502
|
+
await engine.page.evaluate(() => {
|
|
1503
|
+
const highlight = document.getElementById("reshot-highlight");
|
|
1504
|
+
if (highlight) highlight.remove();
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* Run a scenario with video capture (summary-video format)
|
|
1510
|
+
* Records the entire flow as a single video with optional highlights and subtitles
|
|
1511
|
+
* Supports graceful handling of permission-restricted steps
|
|
1512
|
+
* Supports cropping for sentinel frames (same config as step-by-step-images)
|
|
1513
|
+
*/
|
|
1514
|
+
async function runScenarioWithVideoCapture(scenario, options = {}) {
|
|
1515
|
+
const {
|
|
1516
|
+
outputDir,
|
|
1517
|
+
baseUrl,
|
|
1518
|
+
headless = true,
|
|
1519
|
+
viewport = { width: 1280, height: 720 },
|
|
1520
|
+
variantsConfig = {}, // Global variant configuration (new format with dimensions)
|
|
1521
|
+
} = options;
|
|
1522
|
+
|
|
1523
|
+
const outputConfig = scenario.output || { format: "summary-video" };
|
|
1524
|
+
const highlight = outputConfig.highlight || {
|
|
1525
|
+
color: "rgba(255, 255, 0, 0.5)",
|
|
1526
|
+
style: "box",
|
|
1527
|
+
};
|
|
1528
|
+
const subtitles = outputConfig.subtitles || { enabled: false };
|
|
1529
|
+
|
|
1530
|
+
// Extract crop configuration from scenario output settings
|
|
1531
|
+
// This persists across all variations and applies to sentinel frames
|
|
1532
|
+
const scenarioCropConfig = outputConfig.crop || null;
|
|
1533
|
+
|
|
1534
|
+
// Resolve variant configuration using new universal variant system
|
|
1535
|
+
const variantConfig = resolveVariantConfig(scenario, variantsConfig);
|
|
1536
|
+
|
|
1537
|
+
// Resolve privacy configuration for video (CSS masking persists through entire video)
|
|
1538
|
+
const videoPrivacyConfig = config.getPrivacyConfig(scenario.privacy);
|
|
1539
|
+
const hasVideoPrivacy = videoPrivacyConfig.enabled && videoPrivacyConfig.selectors.length > 0;
|
|
1540
|
+
|
|
1541
|
+
console.log(chalk.bold(`\n📋 Scenario: ${scenario.name}`));
|
|
1542
|
+
console.log(chalk.gray(` Key: ${scenario.key}`));
|
|
1543
|
+
console.log(chalk.gray(` Output format: summary-video`));
|
|
1544
|
+
|
|
1545
|
+
// Log variant summary
|
|
1546
|
+
if (variantConfig?.summary?.length) {
|
|
1547
|
+
for (const item of variantConfig.summary) {
|
|
1548
|
+
console.log(chalk.gray(` ${item}`));
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// Log privacy config for video
|
|
1553
|
+
if (hasVideoPrivacy) {
|
|
1554
|
+
console.log(
|
|
1555
|
+
chalk.gray(` Privacy: ${videoPrivacyConfig.selectors.length} selector(s), method=${videoPrivacyConfig.method}`)
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// Resolve style configuration for sentinel frames
|
|
1560
|
+
const sentinelStyleConfig = config.getStyleConfig(scenario.style);
|
|
1561
|
+
const hasSentinelStyle = sentinelStyleConfig.enabled;
|
|
1562
|
+
|
|
1563
|
+
// Log crop config if enabled
|
|
1564
|
+
if (scenarioCropConfig && scenarioCropConfig.enabled) {
|
|
1565
|
+
console.log(
|
|
1566
|
+
chalk.gray(` Crop: ${JSON.stringify(scenarioCropConfig.region)}`)
|
|
1567
|
+
);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// Check for ffmpeg
|
|
1571
|
+
debug("Checking for ffmpeg...");
|
|
1572
|
+
const hasFFmpeg = await checkFFmpeg();
|
|
1573
|
+
if (!hasFFmpeg) {
|
|
1574
|
+
console.error(
|
|
1575
|
+
chalk.red(
|
|
1576
|
+
" ❌ ffmpeg is not installed. Please install it for video generation."
|
|
1577
|
+
)
|
|
1578
|
+
);
|
|
1579
|
+
console.log(chalk.yellow(" Install with: brew install ffmpeg"));
|
|
1580
|
+
return { success: false, error: "ffmpeg not installed", assets: [] };
|
|
1581
|
+
}
|
|
1582
|
+
debug("ffmpeg found");
|
|
1583
|
+
|
|
1584
|
+
// Convert steps
|
|
1585
|
+
const script = convertLegacySteps(scenario);
|
|
1586
|
+
debug(`Converted ${script.length} steps from scenario`);
|
|
1587
|
+
|
|
1588
|
+
if (script.length === 0) {
|
|
1589
|
+
console.log(chalk.yellow(" ⚠ No steps to execute"));
|
|
1590
|
+
return { success: true, assets: [] };
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
console.log(chalk.gray(` Steps: ${script.length}`));
|
|
1594
|
+
|
|
1595
|
+
// Check for saved session state (auth cookies) - CRITICAL for authenticated scenarios
|
|
1596
|
+
const sessionPath = getDefaultSessionPath();
|
|
1597
|
+
const hasSession = fs.existsSync(sessionPath);
|
|
1598
|
+
if (hasSession) {
|
|
1599
|
+
// Validate session freshness
|
|
1600
|
+
const sessionStats = fs.statSync(sessionPath);
|
|
1601
|
+
const sessionAgeHours = (Date.now() - sessionStats.mtimeMs) / (1000 * 60 * 60);
|
|
1602
|
+
if (sessionAgeHours > 24) {
|
|
1603
|
+
console.log(chalk.yellow(` ⚠ Auth session is ${Math.round(sessionAgeHours)}h old. Consider refreshing with \`reshot record\`.`));
|
|
1604
|
+
} else {
|
|
1605
|
+
console.log(chalk.gray(` Using saved auth session`));
|
|
1606
|
+
}
|
|
1607
|
+
} else if (scenario.requiresAuth) {
|
|
1608
|
+
console.log(chalk.yellow(` ⚠ Scenario requires auth but no session found at ${sessionPath}. Run \`reshot record\` to capture a session.`));
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
const { chromium } = require("playwright");
|
|
1612
|
+
// Use a unique temp directory for this recording to avoid conflicts
|
|
1613
|
+
const recordingId = `recording-${Date.now()}-${Math.random()
|
|
1614
|
+
.toString(36)
|
|
1615
|
+
.slice(2, 8)}`;
|
|
1616
|
+
const tempDir = path.join(process.cwd(), ".reshot", "tmp", recordingId);
|
|
1617
|
+
debug(`Using temp directory: ${tempDir}`);
|
|
1618
|
+
fs.ensureDirSync(tempDir);
|
|
1619
|
+
fs.ensureDirSync(
|
|
1620
|
+
outputDir || path.join(".reshot/output", scenario.key, "default")
|
|
1621
|
+
);
|
|
1622
|
+
|
|
1623
|
+
const finalVideoPath = path.join(
|
|
1624
|
+
outputDir || path.join(".reshot/output", scenario.key, "default"),
|
|
1625
|
+
"summary-video.mp4"
|
|
1626
|
+
);
|
|
1627
|
+
debug(`Final video path: ${finalVideoPath}`);
|
|
1628
|
+
|
|
1629
|
+
let browser = null;
|
|
1630
|
+
let page = null;
|
|
1631
|
+
const events = [];
|
|
1632
|
+
|
|
1633
|
+
try {
|
|
1634
|
+
console.log(chalk.cyan("🎬 Recording video..."));
|
|
1635
|
+
debug("Launching browser...");
|
|
1636
|
+
|
|
1637
|
+
// Launch browser with video recording
|
|
1638
|
+
browser = await chromium.launch(buildLaunchOptions({ headless }));
|
|
1639
|
+
debug("Browser launched successfully");
|
|
1640
|
+
|
|
1641
|
+
// Build context options with variant support using universal injector
|
|
1642
|
+
const defaultContextOptions = {
|
|
1643
|
+
viewport,
|
|
1644
|
+
recordVideo: { dir: tempDir, size: viewport },
|
|
1645
|
+
locale: "en-US",
|
|
1646
|
+
timezoneId: "America/New_York",
|
|
1647
|
+
};
|
|
1648
|
+
|
|
1649
|
+
const contextOptions = getBrowserOptions(
|
|
1650
|
+
variantConfig,
|
|
1651
|
+
defaultContextOptions
|
|
1652
|
+
);
|
|
1653
|
+
// Always include video recording
|
|
1654
|
+
contextOptions.recordVideo = { dir: tempDir, size: viewport };
|
|
1655
|
+
|
|
1656
|
+
// CRITICAL FIX: Load auth session for video capture (same as step-by-step)
|
|
1657
|
+
// This enables capturing authenticated platform pages in videos
|
|
1658
|
+
if (hasSession) {
|
|
1659
|
+
try {
|
|
1660
|
+
const rawState = JSON.parse(fs.readFileSync(sessionPath, "utf-8"));
|
|
1661
|
+
const { sanitized, stats } = sanitizeStorageState(rawState);
|
|
1662
|
+
contextOptions.storageState = sanitized;
|
|
1663
|
+
if (stats.fixed > 0 || stats.removed > 0 || stats.stripped > 0) {
|
|
1664
|
+
debug(`Sanitized cookies: ${stats.fixed} fixed, ${stats.removed} removed, ${stats.stripped} stripped`);
|
|
1665
|
+
}
|
|
1666
|
+
} catch (_e) {
|
|
1667
|
+
contextOptions.storageState = sessionPath;
|
|
1668
|
+
}
|
|
1669
|
+
debug("Loaded storageState from session file for video capture");
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
debug("Context options:", JSON.stringify(contextOptions, null, 2));
|
|
1673
|
+
|
|
1674
|
+
// Log colorScheme for debugging
|
|
1675
|
+
if (contextOptions.colorScheme) {
|
|
1676
|
+
console.log(
|
|
1677
|
+
chalk.magenta(` → colorScheme: ${contextOptions.colorScheme}`)
|
|
1678
|
+
);
|
|
1679
|
+
} else if (variantConfig) {
|
|
1680
|
+
console.log(chalk.yellow(` ⚠ No colorScheme set for video capture`));
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
const context = await browser.newContext(contextOptions);
|
|
1684
|
+
debug("Browser context created");
|
|
1685
|
+
page = await context.newPage();
|
|
1686
|
+
debug("Page created");
|
|
1687
|
+
|
|
1688
|
+
// CRITICAL: Hide development overlays (Next.js devtools, Vercel toolbar, etc.)
|
|
1689
|
+
// This prevents dev tools from intercepting clicks during video capture
|
|
1690
|
+
const hideDevtoolsCSS = `
|
|
1691
|
+
/* Next.js Development Overlay */
|
|
1692
|
+
[data-nextjs-dialog],
|
|
1693
|
+
[data-nextjs-dialog-overlay],
|
|
1694
|
+
[data-nextjs-toast],
|
|
1695
|
+
#__next-build-watcher,
|
|
1696
|
+
nextjs-portal,
|
|
1697
|
+
|
|
1698
|
+
/* Vercel Toolbar */
|
|
1699
|
+
[data-vercel-toolbar],
|
|
1700
|
+
#vercel-live-feedback,
|
|
1701
|
+
|
|
1702
|
+
/* React DevTools */
|
|
1703
|
+
#__REACT_DEVTOOLS_GLOBAL_HOOK__,
|
|
1704
|
+
|
|
1705
|
+
/* Common hot reload indicators */
|
|
1706
|
+
[data-hot-reload],
|
|
1707
|
+
.webpack-hot-middleware-clientOverlay {
|
|
1708
|
+
display: none !important;
|
|
1709
|
+
visibility: hidden !important;
|
|
1710
|
+
opacity: 0 !important;
|
|
1711
|
+
pointer-events: none !important;
|
|
1712
|
+
}
|
|
1713
|
+
`;
|
|
1714
|
+
|
|
1715
|
+
// Inject CSS early via addInitScript so it runs before page loads
|
|
1716
|
+
await page.addInitScript((css) => {
|
|
1717
|
+
const style = document.createElement("style");
|
|
1718
|
+
style.setAttribute("data-reshot-devtools-hide", "true");
|
|
1719
|
+
style.textContent = css;
|
|
1720
|
+
|
|
1721
|
+
// Try to add immediately, or wait for head/body
|
|
1722
|
+
const addStyle = () => {
|
|
1723
|
+
if (document.head) {
|
|
1724
|
+
document.head.appendChild(style);
|
|
1725
|
+
} else if (document.body) {
|
|
1726
|
+
document.body.appendChild(style);
|
|
1727
|
+
} else {
|
|
1728
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
1729
|
+
(document.head || document.body).appendChild(style);
|
|
1730
|
+
});
|
|
1731
|
+
}
|
|
1732
|
+
};
|
|
1733
|
+
|
|
1734
|
+
if (document.readyState === "loading") {
|
|
1735
|
+
document.addEventListener("DOMContentLoaded", addStyle);
|
|
1736
|
+
} else {
|
|
1737
|
+
addStyle();
|
|
1738
|
+
}
|
|
1739
|
+
}, hideDevtoolsCSS);
|
|
1740
|
+
debug("Dev overlays CSS injected via addInitScript");
|
|
1741
|
+
|
|
1742
|
+
// Inject privacy masking CSS for video capture (persists through entire recording)
|
|
1743
|
+
if (hasVideoPrivacy) {
|
|
1744
|
+
const privacyCss = generatePrivacyCSS(videoPrivacyConfig);
|
|
1745
|
+
if (privacyCss) {
|
|
1746
|
+
await page.addInitScript((css) => {
|
|
1747
|
+
const style = document.createElement("style");
|
|
1748
|
+
style.setAttribute("data-reshot-privacy", "true");
|
|
1749
|
+
style.textContent = css;
|
|
1750
|
+
const addStyle = () => {
|
|
1751
|
+
if (document.head) {
|
|
1752
|
+
document.head.appendChild(style);
|
|
1753
|
+
} else if (document.body) {
|
|
1754
|
+
document.body.appendChild(style);
|
|
1755
|
+
} else {
|
|
1756
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
1757
|
+
(document.head || document.body).appendChild(style);
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
};
|
|
1761
|
+
if (document.readyState === "loading") {
|
|
1762
|
+
document.addEventListener("DOMContentLoaded", addStyle);
|
|
1763
|
+
} else {
|
|
1764
|
+
addStyle();
|
|
1765
|
+
}
|
|
1766
|
+
}, privacyCss);
|
|
1767
|
+
debug("Privacy CSS injected via addInitScript for video capture");
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// Apply all variant injections (localStorage, sessionStorage, cookies, scripts)
|
|
1772
|
+
if (variantConfig) {
|
|
1773
|
+
debug("Applying variant config...");
|
|
1774
|
+
await applyVariantToPage(page, variantConfig, (msg) => debug(msg));
|
|
1775
|
+
|
|
1776
|
+
// Set up header interception if needed
|
|
1777
|
+
if (
|
|
1778
|
+
variantConfig.headers &&
|
|
1779
|
+
Object.keys(variantConfig.headers).length > 0
|
|
1780
|
+
) {
|
|
1781
|
+
await setupHeaderInterception(page, variantConfig.headers);
|
|
1782
|
+
debug("Header interception set up");
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
// CRITICAL: Auto-inject workspace store data (projectId + workspace) into Zustand store
|
|
1787
|
+
// Without both fields, the app shows "Failed to load project"
|
|
1788
|
+
let _activeProjectId = null;
|
|
1789
|
+
let _activeWorkspace = null;
|
|
1790
|
+
try {
|
|
1791
|
+
const settings = config.readSettings() || {};
|
|
1792
|
+
const projectId = settings.urlVariables?.PROJECT_ID || settings.projectId;
|
|
1793
|
+
const workspace = settings.workspace || null;
|
|
1794
|
+
if (projectId) {
|
|
1795
|
+
_activeProjectId = projectId;
|
|
1796
|
+
_activeWorkspace = workspace;
|
|
1797
|
+
await page.addInitScript(({ pid, ws }) => {
|
|
1798
|
+
const storeState = {
|
|
1799
|
+
activeProjectId: pid,
|
|
1800
|
+
sidebarMinimized: true,
|
|
1801
|
+
};
|
|
1802
|
+
if (ws) {
|
|
1803
|
+
storeState.activeWorkspace = { id: ws.id, name: ws.name, slug: ws.slug };
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
let found = false;
|
|
1807
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
1808
|
+
const key = localStorage.key(i);
|
|
1809
|
+
if (key && key.startsWith("workspace-store-")) {
|
|
1810
|
+
try {
|
|
1811
|
+
const data = JSON.parse(localStorage.getItem(key) || "{}");
|
|
1812
|
+
data.state = { ...data.state, ...storeState };
|
|
1813
|
+
data.version = data.version ?? 0;
|
|
1814
|
+
localStorage.setItem(key, JSON.stringify(data));
|
|
1815
|
+
found = true;
|
|
1816
|
+
} catch (e) {}
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
if (!found) {
|
|
1820
|
+
localStorage.setItem(
|
|
1821
|
+
"workspace-store-1",
|
|
1822
|
+
JSON.stringify({ state: storeState, version: 0 })
|
|
1823
|
+
);
|
|
1824
|
+
}
|
|
1825
|
+
}, { pid: projectId, ws: workspace });
|
|
1826
|
+
debug(`Injected workspace store: projectId=${projectId.slice(0, 12)}...${workspace ? `, workspace=${workspace.slug}` : ""}`);
|
|
1827
|
+
}
|
|
1828
|
+
} catch (e) {
|
|
1829
|
+
// Settings not available, continue without injection
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
const startTime = Date.now();
|
|
1833
|
+
|
|
1834
|
+
// ============================================
|
|
1835
|
+
// SENTINEL CAPTURE SETUP
|
|
1836
|
+
// ============================================
|
|
1837
|
+
const actualOutputDir =
|
|
1838
|
+
outputDir || path.join(".reshot/output", scenario.key, "default");
|
|
1839
|
+
const sentinelDir = path.join(actualOutputDir, "sentinels");
|
|
1840
|
+
fs.ensureDirSync(sentinelDir);
|
|
1841
|
+
const sentinelPaths = [];
|
|
1842
|
+
let sentinelIndex = 0;
|
|
1843
|
+
let hasAppliedStorageReload = false; // Track if we've reloaded for localStorage
|
|
1844
|
+
|
|
1845
|
+
/**
|
|
1846
|
+
* Capture a sentinel frame (full page screenshot)
|
|
1847
|
+
* Applies scenario-level cropping if configured
|
|
1848
|
+
* @param {string} label - Label for the sentinel (e.g., "initial", "after-click-1")
|
|
1849
|
+
*/
|
|
1850
|
+
async function captureSentinel(label) {
|
|
1851
|
+
const sentinelPath = path.join(
|
|
1852
|
+
sentinelDir,
|
|
1853
|
+
`step-${sentinelIndex}-${label}.png`
|
|
1854
|
+
);
|
|
1855
|
+
|
|
1856
|
+
// CRITICAL: Final theme enforcement right before capture
|
|
1857
|
+
await page.evaluate(() => {
|
|
1858
|
+
if (window.__RESHOT_THEME_OVERRIDE__) {
|
|
1859
|
+
const wanted = window.__RESHOT_THEME_OVERRIDE__;
|
|
1860
|
+
document.documentElement.classList.remove("dark", "light");
|
|
1861
|
+
document.documentElement.classList.add(wanted);
|
|
1862
|
+
document.documentElement.style.colorScheme = wanted;
|
|
1863
|
+
document.documentElement.setAttribute("data-theme", wanted);
|
|
1864
|
+
}
|
|
1865
|
+
});
|
|
1866
|
+
await page.waitForTimeout(50);
|
|
1867
|
+
|
|
1868
|
+
let buffer = await page.screenshot({ fullPage: false });
|
|
1869
|
+
|
|
1870
|
+
// Resolve selector-based crop to a bounding box region (sentinel)
|
|
1871
|
+
if (scenarioCropConfig && scenarioCropConfig.enabled && scenarioCropConfig.selector && !scenarioCropConfig.region) {
|
|
1872
|
+
try {
|
|
1873
|
+
const box = await page.evaluate((sel) => {
|
|
1874
|
+
const selectors = sel.split(',').map(s => s.trim());
|
|
1875
|
+
for (const s of selectors) {
|
|
1876
|
+
const el = document.querySelector(s);
|
|
1877
|
+
if (el) {
|
|
1878
|
+
const rect = el.getBoundingClientRect();
|
|
1879
|
+
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
return null;
|
|
1883
|
+
}, scenarioCropConfig.selector);
|
|
1884
|
+
if (box) {
|
|
1885
|
+
scenarioCropConfig.region = box;
|
|
1886
|
+
} else {
|
|
1887
|
+
debug(`Crop selector not found (sentinel): ${scenarioCropConfig.selector}`);
|
|
1888
|
+
}
|
|
1889
|
+
} catch (e) {
|
|
1890
|
+
debug(`Failed to resolve crop selector (sentinel): ${e.message}`);
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
// Apply cropping if configured at scenario level
|
|
1895
|
+
if (
|
|
1896
|
+
scenarioCropConfig &&
|
|
1897
|
+
scenarioCropConfig.enabled &&
|
|
1898
|
+
isSharpAvailable()
|
|
1899
|
+
) {
|
|
1900
|
+
try {
|
|
1901
|
+
const deviceScaleFactor = await page.evaluate(
|
|
1902
|
+
() => window.devicePixelRatio || 1
|
|
1903
|
+
);
|
|
1904
|
+
buffer = await cropImageBuffer(buffer, scenarioCropConfig, {
|
|
1905
|
+
deviceScaleFactor,
|
|
1906
|
+
});
|
|
1907
|
+
debug(
|
|
1908
|
+
`Cropped sentinel ${label} to region: ${JSON.stringify(
|
|
1909
|
+
scenarioCropConfig.region
|
|
1910
|
+
)}`
|
|
1911
|
+
);
|
|
1912
|
+
} catch (cropError) {
|
|
1913
|
+
debug(`Crop failed for sentinel ${label}: ${cropError.message}`);
|
|
1914
|
+
// Continue with uncropped buffer
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
// Apply style processing to sentinel frames (same as step-by-step captures)
|
|
1919
|
+
if (hasSentinelStyle && isStyleAvailable()) {
|
|
1920
|
+
try {
|
|
1921
|
+
const effectiveStyleConfig = { ...sentinelStyleConfig };
|
|
1922
|
+
if (variantConfig?.browserOptions?.colorScheme === "dark") {
|
|
1923
|
+
effectiveStyleConfig._darkMode = true;
|
|
1924
|
+
}
|
|
1925
|
+
const sentinelDpr = await page.evaluate(() => window.devicePixelRatio || 1);
|
|
1926
|
+
buffer = await applyStyle(buffer, effectiveStyleConfig, (msg) => debug(msg), sentinelDpr);
|
|
1927
|
+
} catch (styleError) {
|
|
1928
|
+
debug(`Style failed for sentinel ${label}: ${styleError.message}`);
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
await fs.writeFile(sentinelPath, buffer);
|
|
1933
|
+
sentinelPaths.push({ index: sentinelIndex, label, path: sentinelPath });
|
|
1934
|
+
sentinelIndex++;
|
|
1935
|
+
return sentinelPath;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
// Capture initial state BEFORE first navigation (placeholder - actual capture after goto)
|
|
1939
|
+
let hasNavigated = false;
|
|
1940
|
+
|
|
1941
|
+
// Execute all steps and capture timeline
|
|
1942
|
+
for (let stepIndex = 0; stepIndex < script.length; stepIndex++) {
|
|
1943
|
+
const step = script[stepIndex];
|
|
1944
|
+
const { action, ...params } = step;
|
|
1945
|
+
const timestamp = (Date.now() - startTime) / 1000;
|
|
1946
|
+
debug(`Executing step ${stepIndex + 1}/${script.length}: ${action}`);
|
|
1947
|
+
|
|
1948
|
+
if (action === "goto") {
|
|
1949
|
+
// Apply URL params from variant if any
|
|
1950
|
+
let url = params.url;
|
|
1951
|
+
if (variantConfig?.urlParams) {
|
|
1952
|
+
url = applyUrlParams(url, variantConfig.urlParams);
|
|
1953
|
+
}
|
|
1954
|
+
// Handle relative URLs by prepending baseUrl
|
|
1955
|
+
const fullUrl = url.startsWith("http") ? url : `${baseUrl || ""}${url}`;
|
|
1956
|
+
console.log(chalk.gray(` → Navigate to ${fullUrl}`));
|
|
1957
|
+
await page.goto(fullUrl, { waitUntil: "domcontentloaded" });
|
|
1958
|
+
|
|
1959
|
+
// CRITICAL: For SSR apps with inline <script> tags that read localStorage
|
|
1960
|
+
// during HTML parsing, we must reload after navigation so the localStorage
|
|
1961
|
+
// values (set by addInitScript) are available to inline scripts
|
|
1962
|
+
if (variantConfig && !hasAppliedStorageReload) {
|
|
1963
|
+
hasAppliedStorageReload = true;
|
|
1964
|
+
const didReload = await applyStorageAndReload(
|
|
1965
|
+
page,
|
|
1966
|
+
variantConfig,
|
|
1967
|
+
(msg) => debug(msg)
|
|
1968
|
+
);
|
|
1969
|
+
if (didReload) {
|
|
1970
|
+
debug("Page reloaded with localStorage applied for video capture");
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
// Wait for network to settle and i18n to render
|
|
1975
|
+
try {
|
|
1976
|
+
await page.waitForLoadState("networkidle", { timeout: 5000 });
|
|
1977
|
+
} catch (e) {
|
|
1978
|
+
// Okay if timeout
|
|
1979
|
+
}
|
|
1980
|
+
await page.waitForTimeout(800); // Extra time for i18n/translations to render
|
|
1981
|
+
|
|
1982
|
+
// Re-inject workspace store after navigation to handle Zustand hydration resets
|
|
1983
|
+
if (_activeProjectId) {
|
|
1984
|
+
await page.evaluate(({ pid, ws }) => {
|
|
1985
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
1986
|
+
const key = localStorage.key(i);
|
|
1987
|
+
if (key && key.startsWith("workspace-store-")) {
|
|
1988
|
+
try {
|
|
1989
|
+
const data = JSON.parse(localStorage.getItem(key) || "{}");
|
|
1990
|
+
if (data.state) {
|
|
1991
|
+
data.state.activeProjectId = pid;
|
|
1992
|
+
if (ws) data.state.activeWorkspace = data.state.activeWorkspace || { id: ws.id, name: ws.name, slug: ws.slug };
|
|
1993
|
+
data.version = data.version ?? 0;
|
|
1994
|
+
localStorage.setItem(key, JSON.stringify(data));
|
|
1995
|
+
}
|
|
1996
|
+
} catch (e) {}
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
window.dispatchEvent(new StorageEvent("storage", { key: null }));
|
|
2000
|
+
}, { pid: _activeProjectId, ws: _activeWorkspace });
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
// Capture sentinel after navigation (initial state)
|
|
2004
|
+
if (!hasNavigated) {
|
|
2005
|
+
await captureSentinel("initial");
|
|
2006
|
+
hasNavigated = true;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
events.push({
|
|
2010
|
+
action: "goto",
|
|
2011
|
+
timestamp,
|
|
2012
|
+
subtitle: `Navigating to ${url}`,
|
|
2013
|
+
elementBox: null,
|
|
2014
|
+
});
|
|
2015
|
+
continue;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
if (action === "keyboard") {
|
|
2019
|
+
console.log(chalk.gray(` → Keyboard: ${params.key}`));
|
|
2020
|
+
await page.keyboard.press(params.key);
|
|
2021
|
+
await page.waitForTimeout(300);
|
|
2022
|
+
|
|
2023
|
+
events.push({
|
|
2024
|
+
action: "keyboard",
|
|
2025
|
+
timestamp,
|
|
2026
|
+
subtitle: subtitles.enabled ? `Press ${params.key}` : "",
|
|
2027
|
+
elementBox: null,
|
|
2028
|
+
});
|
|
2029
|
+
|
|
2030
|
+
// Capture sentinel after keyboard action
|
|
2031
|
+
await captureSentinel(`after-keyboard-${stepIndex}`);
|
|
2032
|
+
continue;
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
if (action === "click") {
|
|
2036
|
+
const target = params.target;
|
|
2037
|
+
const isOptional = params.optional === true;
|
|
2038
|
+
const clickTimeout = isOptional ? 3000 : 10000; // Shorter timeout for optional clicks
|
|
2039
|
+
console.log(
|
|
2040
|
+
chalk.gray(` → Click: ${target}${isOptional ? " (optional)" : ""}`)
|
|
2041
|
+
);
|
|
2042
|
+
|
|
2043
|
+
try {
|
|
2044
|
+
const element = await page.locator(target).first();
|
|
2045
|
+
await element.waitFor({ state: "visible", timeout: clickTimeout });
|
|
2046
|
+
const box = await element.boundingBox();
|
|
2047
|
+
|
|
2048
|
+
// Add highlight before click
|
|
2049
|
+
if (box) {
|
|
2050
|
+
await page.evaluate(
|
|
2051
|
+
({ box, color }) => {
|
|
2052
|
+
const div = document.createElement("div");
|
|
2053
|
+
div.id = "reshot-video-highlight";
|
|
2054
|
+
div.style.cssText = `
|
|
2055
|
+
position: fixed;
|
|
2056
|
+
left: ${box.x}px;
|
|
2057
|
+
top: ${box.y}px;
|
|
2058
|
+
width: ${box.width}px;
|
|
2059
|
+
height: ${box.height}px;
|
|
2060
|
+
background: ${color};
|
|
2061
|
+
pointer-events: none;
|
|
2062
|
+
z-index: 999999;
|
|
2063
|
+
border-radius: 4px;
|
|
2064
|
+
transition: opacity 0.3s;
|
|
2065
|
+
`;
|
|
2066
|
+
document.body.appendChild(div);
|
|
2067
|
+
},
|
|
2068
|
+
{ box, color: highlight.color }
|
|
2069
|
+
);
|
|
2070
|
+
|
|
2071
|
+
await page.waitForTimeout(300);
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
await element.click();
|
|
2075
|
+
|
|
2076
|
+
// Remove highlight after click
|
|
2077
|
+
await page.evaluate(() => {
|
|
2078
|
+
const h = document.getElementById("reshot-video-highlight");
|
|
2079
|
+
if (h) h.remove();
|
|
2080
|
+
});
|
|
2081
|
+
|
|
2082
|
+
events.push({
|
|
2083
|
+
action: "click",
|
|
2084
|
+
timestamp,
|
|
2085
|
+
subtitle: subtitles.enabled ? `Click on ${target}` : "",
|
|
2086
|
+
elementBox: box,
|
|
2087
|
+
});
|
|
2088
|
+
|
|
2089
|
+
await page.waitForTimeout(500);
|
|
2090
|
+
|
|
2091
|
+
// Capture sentinel after click
|
|
2092
|
+
await captureSentinel(`after-click-${stepIndex}`);
|
|
2093
|
+
} catch (e) {
|
|
2094
|
+
console.warn(
|
|
2095
|
+
chalk.yellow(` ⚠ Could not click ${target}: ${e.message}`)
|
|
2096
|
+
);
|
|
2097
|
+
}
|
|
2098
|
+
continue;
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
if (action === "type") {
|
|
2102
|
+
const target = params.target;
|
|
2103
|
+
const text = params.text;
|
|
2104
|
+
const isOptional = params.optional === true;
|
|
2105
|
+
const typeTimeout = isOptional ? 3000 : 10000; // Shorter timeout for optional type actions
|
|
2106
|
+
console.log(
|
|
2107
|
+
chalk.gray(
|
|
2108
|
+
` → Type into: ${target}${isOptional ? " (optional)" : ""}`
|
|
2109
|
+
)
|
|
2110
|
+
);
|
|
2111
|
+
|
|
2112
|
+
try {
|
|
2113
|
+
const element = await page.locator(target).first();
|
|
2114
|
+
await element.waitFor({ state: "visible", timeout: typeTimeout });
|
|
2115
|
+
const box = await element.boundingBox();
|
|
2116
|
+
|
|
2117
|
+
// Add highlight before typing
|
|
2118
|
+
if (box) {
|
|
2119
|
+
await page.evaluate(
|
|
2120
|
+
({ box, color }) => {
|
|
2121
|
+
const div = document.createElement("div");
|
|
2122
|
+
div.id = "reshot-video-highlight";
|
|
2123
|
+
div.style.cssText = `
|
|
2124
|
+
position: fixed;
|
|
2125
|
+
left: ${box.x}px;
|
|
2126
|
+
top: ${box.y}px;
|
|
2127
|
+
width: ${box.width}px;
|
|
2128
|
+
height: ${box.height}px;
|
|
2129
|
+
background: ${color};
|
|
2130
|
+
pointer-events: none;
|
|
2131
|
+
z-index: 999999;
|
|
2132
|
+
border-radius: 4px;
|
|
2133
|
+
`;
|
|
2134
|
+
document.body.appendChild(div);
|
|
2135
|
+
},
|
|
2136
|
+
{ box, color: highlight.color }
|
|
2137
|
+
);
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
await element.fill("");
|
|
2141
|
+
await element.type(text, { delay: 50 }); // Visible typing effect
|
|
2142
|
+
|
|
2143
|
+
// Remove highlight
|
|
2144
|
+
await page.evaluate(() => {
|
|
2145
|
+
const h = document.getElementById("reshot-video-highlight");
|
|
2146
|
+
if (h) h.remove();
|
|
2147
|
+
});
|
|
2148
|
+
|
|
2149
|
+
events.push({
|
|
2150
|
+
action: "type",
|
|
2151
|
+
timestamp,
|
|
2152
|
+
subtitle: subtitles.enabled ? `Entering "${text}"` : "",
|
|
2153
|
+
elementBox: box,
|
|
2154
|
+
});
|
|
2155
|
+
|
|
2156
|
+
await page.waitForTimeout(300);
|
|
2157
|
+
|
|
2158
|
+
// Capture sentinel after type
|
|
2159
|
+
await captureSentinel(`after-type-${stepIndex}`);
|
|
2160
|
+
} catch (e) {
|
|
2161
|
+
console.warn(
|
|
2162
|
+
chalk.yellow(` ⚠ Could not type into ${target}: ${e.message}`)
|
|
2163
|
+
);
|
|
2164
|
+
}
|
|
2165
|
+
continue;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
if (action === "wait") {
|
|
2169
|
+
await page.waitForTimeout(params.ms || 1000);
|
|
2170
|
+
continue;
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
if (action === "waitFor") {
|
|
2174
|
+
const isOptional = params.optional === true;
|
|
2175
|
+
const waitTimeout = params.timeout || (isOptional ? 3000 : 10000);
|
|
2176
|
+
try {
|
|
2177
|
+
await page.locator(params.target).first().waitFor({
|
|
2178
|
+
state: "visible",
|
|
2179
|
+
timeout: waitTimeout,
|
|
2180
|
+
});
|
|
2181
|
+
} catch (e) {
|
|
2182
|
+
if (!isOptional) {
|
|
2183
|
+
console.warn(
|
|
2184
|
+
chalk.yellow(` ⚠ Wait for ${params.target} timed out`)
|
|
2185
|
+
);
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
continue;
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
if (action === "hover") {
|
|
2192
|
+
const isOptional = params.optional === true;
|
|
2193
|
+
const hoverTimeout = isOptional ? 3000 : 10000;
|
|
2194
|
+
console.log(
|
|
2195
|
+
chalk.gray(
|
|
2196
|
+
` → Hover: ${params.target}${isOptional ? " (optional)" : ""}`
|
|
2197
|
+
)
|
|
2198
|
+
);
|
|
2199
|
+
try {
|
|
2200
|
+
const element = await page.locator(params.target).first();
|
|
2201
|
+
await element.waitFor({ state: "visible", timeout: hoverTimeout });
|
|
2202
|
+
await element.hover();
|
|
2203
|
+
await page.waitForTimeout(300);
|
|
2204
|
+
// Capture sentinel after hover (state may have changed with tooltips/dropdowns)
|
|
2205
|
+
await captureSentinel(`after-hover-${stepIndex}`);
|
|
2206
|
+
} catch (e) {
|
|
2207
|
+
console.warn(
|
|
2208
|
+
chalk.yellow(` ⚠ Could not hover ${params.target}: ${e.message}`)
|
|
2209
|
+
);
|
|
2210
|
+
}
|
|
2211
|
+
continue;
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
// Capture final sentinel
|
|
2216
|
+
await captureSentinel("final");
|
|
2217
|
+
console.log(
|
|
2218
|
+
chalk.green(` ✔ Captured ${sentinelPaths.length} sentinel frames`)
|
|
2219
|
+
);
|
|
2220
|
+
|
|
2221
|
+
// Record final timestamp for trimming
|
|
2222
|
+
const finalTimestamp = (Date.now() - startTime) / 1000;
|
|
2223
|
+
debug(`Final action timestamp: ${finalTimestamp}s`);
|
|
2224
|
+
|
|
2225
|
+
// Brief wait to let the final state render
|
|
2226
|
+
debug("All steps executed, waiting before finalizing video...");
|
|
2227
|
+
await page.waitForTimeout(500);
|
|
2228
|
+
|
|
2229
|
+
// Get the video path from Playwright BEFORE closing the context
|
|
2230
|
+
const video = page.video();
|
|
2231
|
+
debug(`Video object exists: ${!!video}`);
|
|
2232
|
+
let recordedVideoPath = null;
|
|
2233
|
+
|
|
2234
|
+
if (video) {
|
|
2235
|
+
// Close context to finalize video
|
|
2236
|
+
debug("Closing context to finalize video...");
|
|
2237
|
+
await context.close();
|
|
2238
|
+
console.log(chalk.green(" ✔ Video recorded"));
|
|
2239
|
+
|
|
2240
|
+
// Get the path after closing (this ensures video is written)
|
|
2241
|
+
recordedVideoPath = await video.path();
|
|
2242
|
+
debug(`Recorded video path from Playwright: ${recordedVideoPath}`);
|
|
2243
|
+
} else {
|
|
2244
|
+
// Fallback: close and scan directory
|
|
2245
|
+
debug("No video object, using fallback directory scan...");
|
|
2246
|
+
await context.close();
|
|
2247
|
+
console.log(chalk.green(" ✔ Video recorded"));
|
|
2248
|
+
|
|
2249
|
+
// Wait for video file to be written
|
|
2250
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
2251
|
+
|
|
2252
|
+
// Find the recorded video in the unique temp directory
|
|
2253
|
+
const videoFiles = fs
|
|
2254
|
+
.readdirSync(tempDir)
|
|
2255
|
+
.filter((f) => f.endsWith(".webm"));
|
|
2256
|
+
debug(
|
|
2257
|
+
`Found ${videoFiles.length} video files in temp dir: ${videoFiles.join(
|
|
2258
|
+
", "
|
|
2259
|
+
)}`
|
|
2260
|
+
);
|
|
2261
|
+
if (videoFiles.length === 0) {
|
|
2262
|
+
throw new Error("No video file was created");
|
|
2263
|
+
}
|
|
2264
|
+
// Sort by modification time to get the newest
|
|
2265
|
+
const sortedFiles = videoFiles
|
|
2266
|
+
.map((f) => ({
|
|
2267
|
+
name: f,
|
|
2268
|
+
mtime: fs.statSync(path.join(tempDir, f)).mtime,
|
|
2269
|
+
}))
|
|
2270
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
2271
|
+
recordedVideoPath = path.join(tempDir, sortedFiles[0].name);
|
|
2272
|
+
debug(`Using video file: ${recordedVideoPath}`);
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
if (!recordedVideoPath || !fs.existsSync(recordedVideoPath)) {
|
|
2276
|
+
const existingFiles = fs.existsSync(tempDir)
|
|
2277
|
+
? fs.readdirSync(tempDir)
|
|
2278
|
+
: [];
|
|
2279
|
+
debug(`Temp dir contents: ${existingFiles.join(", ") || "empty"}`);
|
|
2280
|
+
throw new Error(
|
|
2281
|
+
`Video file not found after recording. Expected: ${recordedVideoPath}`
|
|
2282
|
+
);
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
const videoSize = fs.statSync(recordedVideoPath).size;
|
|
2286
|
+
debug(`Video file size: ${videoSize} bytes`);
|
|
2287
|
+
console.log(
|
|
2288
|
+
chalk.gray(
|
|
2289
|
+
` → Source video: ${recordedVideoPath} (${(videoSize / 1024).toFixed(
|
|
2290
|
+
1
|
|
2291
|
+
)} KB)`
|
|
2292
|
+
)
|
|
2293
|
+
);
|
|
2294
|
+
|
|
2295
|
+
// Convert to MP4 with ffmpeg, trimming to actual content duration
|
|
2296
|
+
// Add a small buffer (0.5s) after the final action
|
|
2297
|
+
const trimDuration = finalTimestamp + 0.5;
|
|
2298
|
+
console.log(
|
|
2299
|
+
chalk.cyan(
|
|
2300
|
+
` 📹 Converting to MP4 (trimmed to ${trimDuration.toFixed(1)}s)...`
|
|
2301
|
+
)
|
|
2302
|
+
);
|
|
2303
|
+
debug(`Running ffmpeg conversion with trim to ${trimDuration}s...`);
|
|
2304
|
+
await runFFmpegConvert([
|
|
2305
|
+
"-i",
|
|
2306
|
+
recordedVideoPath,
|
|
2307
|
+
"-t",
|
|
2308
|
+
trimDuration.toFixed(2),
|
|
2309
|
+
"-c:v",
|
|
2310
|
+
"libx264",
|
|
2311
|
+
"-preset",
|
|
2312
|
+
"fast",
|
|
2313
|
+
"-pix_fmt",
|
|
2314
|
+
"yuv420p",
|
|
2315
|
+
"-movflags",
|
|
2316
|
+
"+faststart",
|
|
2317
|
+
"-y",
|
|
2318
|
+
finalVideoPath,
|
|
2319
|
+
]);
|
|
2320
|
+
|
|
2321
|
+
const finalSize = fs.existsSync(finalVideoPath)
|
|
2322
|
+
? fs.statSync(finalVideoPath).size
|
|
2323
|
+
: 0;
|
|
2324
|
+
debug(`Final video size: ${finalSize} bytes`);
|
|
2325
|
+
console.log(
|
|
2326
|
+
chalk.green(
|
|
2327
|
+
` ✔ Video saved: ${finalVideoPath} (${(finalSize / 1024).toFixed(
|
|
2328
|
+
1
|
|
2329
|
+
)} KB)`
|
|
2330
|
+
)
|
|
2331
|
+
);
|
|
2332
|
+
|
|
2333
|
+
// Save timeline for reference
|
|
2334
|
+
const timelinePath = path.join(
|
|
2335
|
+
outputDir || path.join(".reshot/output", scenario.key, "default"),
|
|
2336
|
+
"timeline.json"
|
|
2337
|
+
);
|
|
2338
|
+
fs.writeFileSync(timelinePath, JSON.stringify(events, null, 2));
|
|
2339
|
+
debug(`Timeline saved to: ${timelinePath}`);
|
|
2340
|
+
|
|
2341
|
+
// Save sentinel manifest for the asset bundle
|
|
2342
|
+
const sentinelManifestPath = path.join(actualOutputDir, "sentinels.json");
|
|
2343
|
+
fs.writeJSONSync(
|
|
2344
|
+
sentinelManifestPath,
|
|
2345
|
+
{
|
|
2346
|
+
generatedAt: new Date().toISOString(),
|
|
2347
|
+
sentinels: sentinelPaths.map((s) => ({
|
|
2348
|
+
index: s.index,
|
|
2349
|
+
label: s.label,
|
|
2350
|
+
filename: path.basename(s.path),
|
|
2351
|
+
})),
|
|
2352
|
+
},
|
|
2353
|
+
{ spaces: 2 }
|
|
2354
|
+
);
|
|
2355
|
+
debug(`Sentinel manifest saved to: ${sentinelManifestPath}`);
|
|
2356
|
+
|
|
2357
|
+
// Cleanup temp directory (unique per recording)
|
|
2358
|
+
try {
|
|
2359
|
+
fs.removeSync(tempDir);
|
|
2360
|
+
debug("Temp directory cleaned up");
|
|
2361
|
+
} catch (e) {
|
|
2362
|
+
debug(`Cleanup error: ${e.message}`);
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
// Return asset bundle info including sentinels
|
|
2366
|
+
return {
|
|
2367
|
+
success: true,
|
|
2368
|
+
assets: [
|
|
2369
|
+
{
|
|
2370
|
+
name: "summary-video",
|
|
2371
|
+
path: finalVideoPath,
|
|
2372
|
+
type: "video",
|
|
2373
|
+
duration: (Date.now() - startTime) / 1000,
|
|
2374
|
+
},
|
|
2375
|
+
],
|
|
2376
|
+
sentinels: sentinelPaths.map((s) => ({
|
|
2377
|
+
index: s.index,
|
|
2378
|
+
label: s.label,
|
|
2379
|
+
path: s.path,
|
|
2380
|
+
})),
|
|
2381
|
+
};
|
|
2382
|
+
} catch (error) {
|
|
2383
|
+
console.error(
|
|
2384
|
+
chalk.red(
|
|
2385
|
+
`\n ❌ Video capture for '${scenario.name || scenario.key}' failed: ${
|
|
2386
|
+
error.message
|
|
2387
|
+
}`
|
|
2388
|
+
)
|
|
2389
|
+
);
|
|
2390
|
+
if (DEBUG) {
|
|
2391
|
+
console.error(chalk.red(" Stack trace:"));
|
|
2392
|
+
console.error(chalk.gray(error.stack));
|
|
2393
|
+
}
|
|
2394
|
+
// Cleanup temp directory on error too
|
|
2395
|
+
try {
|
|
2396
|
+
fs.removeSync(tempDir);
|
|
2397
|
+
} catch (e) {
|
|
2398
|
+
debug(`Cleanup error: ${e.message}`);
|
|
2399
|
+
}
|
|
2400
|
+
return { success: false, error: error.message, assets: [] };
|
|
2401
|
+
} finally {
|
|
2402
|
+
if (browser) {
|
|
2403
|
+
debug("Closing browser...");
|
|
2404
|
+
await browser.close();
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
/**
|
|
2410
|
+
* Check if ffmpeg is installed
|
|
2411
|
+
*/
|
|
2412
|
+
function checkFFmpeg() {
|
|
2413
|
+
const { spawn } = require("child_process");
|
|
2414
|
+
return new Promise((resolve) => {
|
|
2415
|
+
try {
|
|
2416
|
+
const proc = spawn("ffmpeg", ["-version"], { stdio: "ignore" });
|
|
2417
|
+
proc.on("close", (code) => resolve(code === 0));
|
|
2418
|
+
proc.on("error", () => resolve(false));
|
|
2419
|
+
} catch (e) {
|
|
2420
|
+
resolve(false);
|
|
2421
|
+
}
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
/**
|
|
2426
|
+
* Run ffmpeg conversion
|
|
2427
|
+
*/
|
|
2428
|
+
function runFFmpegConvert(args) {
|
|
2429
|
+
const { spawn } = require("child_process");
|
|
2430
|
+
return new Promise((resolve, reject) => {
|
|
2431
|
+
const proc = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
2432
|
+
|
|
2433
|
+
let stderr = "";
|
|
2434
|
+
proc.stderr.on("data", (data) => {
|
|
2435
|
+
stderr += data.toString();
|
|
2436
|
+
});
|
|
2437
|
+
|
|
2438
|
+
proc.on("close", (code) => {
|
|
2439
|
+
if (code === 0) {
|
|
2440
|
+
resolve();
|
|
2441
|
+
} else {
|
|
2442
|
+
reject(
|
|
2443
|
+
new Error(`ffmpeg failed with code ${code}: ${stderr.slice(-200)}`)
|
|
2444
|
+
);
|
|
2445
|
+
}
|
|
2446
|
+
});
|
|
2447
|
+
|
|
2448
|
+
proc.on("error", (err) => {
|
|
2449
|
+
reject(err);
|
|
2450
|
+
});
|
|
2451
|
+
});
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
/**
|
|
2455
|
+
* Run a scenario using the new capture engine
|
|
2456
|
+
* Routes to appropriate runner based on output.format
|
|
2457
|
+
*
|
|
2458
|
+
* Supported formats:
|
|
2459
|
+
* - "step-by-step-images" (default): Captures after each step with deduplication
|
|
2460
|
+
* - "summary-video": Records a video of the entire flow
|
|
2461
|
+
* - "legacy": Only captures explicit screenshot steps
|
|
2462
|
+
*/
|
|
2463
|
+
async function runScenarioWithEngine(scenario, options = {}) {
|
|
2464
|
+
const {
|
|
2465
|
+
outputDir,
|
|
2466
|
+
baseUrl,
|
|
2467
|
+
headless = true,
|
|
2468
|
+
viewport = { width: 1280, height: 720 },
|
|
2469
|
+
timeout = 30000,
|
|
2470
|
+
variantsConfig = {}, // Universal variant configuration
|
|
2471
|
+
storageStateData = null,
|
|
2472
|
+
quiet = false,
|
|
2473
|
+
} = options;
|
|
2474
|
+
|
|
2475
|
+
const outputFormat = scenario.output?.format || "step-by-step-images";
|
|
2476
|
+
|
|
2477
|
+
// Route to step-by-step capture (default - now with deduplication built-in)
|
|
2478
|
+
if (outputFormat === "step-by-step-images" || outputFormat === "smart") {
|
|
2479
|
+
return runScenarioWithStepByStepCapture(scenario, {
|
|
2480
|
+
...options,
|
|
2481
|
+
variantsConfig,
|
|
2482
|
+
});
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
// Route to summary video generation
|
|
2486
|
+
if (outputFormat === "summary-video") {
|
|
2487
|
+
return runScenarioWithVideoCapture(scenario, {
|
|
2488
|
+
...options,
|
|
2489
|
+
variantsConfig,
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
// Legacy behavior: only capture explicit screenshot steps
|
|
2494
|
+
// Resolve variant configuration for this scenario
|
|
2495
|
+
const variantConfig = resolveVariantConfig(scenario, variantsConfig);
|
|
2496
|
+
|
|
2497
|
+
// Extract crop configuration from scenario output settings
|
|
2498
|
+
const outputConfig = scenario.output || {};
|
|
2499
|
+
const scenarioCropConfig = outputConfig.crop || null;
|
|
2500
|
+
|
|
2501
|
+
if (!quiet) {
|
|
2502
|
+
console.log(chalk.bold(`\n📋 Scenario: ${scenario.name}`));
|
|
2503
|
+
console.log(chalk.gray(` Key: ${scenario.key}`));
|
|
2504
|
+
|
|
2505
|
+
// Log variant summary
|
|
2506
|
+
if (variantConfig?.summary?.length) {
|
|
2507
|
+
for (const item of variantConfig.summary) {
|
|
2508
|
+
console.log(chalk.gray(` ${item}`));
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
// Log crop config if enabled
|
|
2513
|
+
if (scenarioCropConfig && scenarioCropConfig.enabled) {
|
|
2514
|
+
console.log(
|
|
2515
|
+
chalk.gray(` Crop: ${JSON.stringify(scenarioCropConfig.region)}`)
|
|
2516
|
+
);
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
// Convert legacy steps to new format
|
|
2521
|
+
const script = convertLegacySteps(scenario);
|
|
2522
|
+
|
|
2523
|
+
if (script.length === 0) {
|
|
2524
|
+
if (!quiet) console.log(chalk.yellow(" ⚠ No steps to execute"));
|
|
2525
|
+
return { success: true, assets: [] };
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
if (!quiet) console.log(chalk.gray(` Steps: ${script.length}`));
|
|
2529
|
+
|
|
2530
|
+
// Check for saved session state (auth cookies)
|
|
2531
|
+
const sessionPath = getDefaultSessionPath();
|
|
2532
|
+
const hasSession = fs.existsSync(sessionPath);
|
|
2533
|
+
if (!quiet && hasSession) {
|
|
2534
|
+
console.log(chalk.gray(` Using saved auth session`));
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
const engine = new CaptureEngine({
|
|
2538
|
+
outputDir:
|
|
2539
|
+
outputDir || path.join(".reshot/output", scenario.key, "default"),
|
|
2540
|
+
baseUrl: baseUrl || "",
|
|
2541
|
+
viewport,
|
|
2542
|
+
headless,
|
|
2543
|
+
variantConfig, // Pass resolved variant config
|
|
2544
|
+
cropConfig: scenarioCropConfig, // Pass scenario-level crop config
|
|
2545
|
+
storageStatePath: hasSession ? sessionPath : null, // Use saved session if available
|
|
2546
|
+
storageStateData, // Pre-loaded auth state
|
|
2547
|
+
hideDevtools: true, // Always hide dev overlays in captures
|
|
2548
|
+
logger: quiet ? () => {} : (msg) => console.log(msg),
|
|
2549
|
+
});
|
|
2550
|
+
|
|
2551
|
+
try {
|
|
2552
|
+
await engine.init();
|
|
2553
|
+
const assets = await engine.runScript(script);
|
|
2554
|
+
|
|
2555
|
+
if (!quiet) console.log(
|
|
2556
|
+
chalk.green(`\n ✔ Scenario completed: ${assets.length} assets captured`)
|
|
2557
|
+
);
|
|
2558
|
+
|
|
2559
|
+
return { success: true, assets };
|
|
2560
|
+
} catch (error) {
|
|
2561
|
+
console.error(chalk.red(`\n ❌ Scenario failed: ${error.message}`));
|
|
2562
|
+
|
|
2563
|
+
// Try to capture debug screenshot
|
|
2564
|
+
try {
|
|
2565
|
+
if (engine.page) {
|
|
2566
|
+
const debugPath = path.join(
|
|
2567
|
+
outputDir || ".reshot/output",
|
|
2568
|
+
scenario.key,
|
|
2569
|
+
"debug-failure.png"
|
|
2570
|
+
);
|
|
2571
|
+
fs.ensureDirSync(path.dirname(debugPath));
|
|
2572
|
+
await engine.page.screenshot({ path: debugPath, fullPage: true });
|
|
2573
|
+
console.error(chalk.yellow(` Debug screenshot: ${debugPath}`));
|
|
2574
|
+
}
|
|
2575
|
+
} catch (e) {
|
|
2576
|
+
// Ignore screenshot errors
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
return { success: false, error: error.message };
|
|
2580
|
+
} finally {
|
|
2581
|
+
await engine.close();
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
/**
|
|
2586
|
+
* Generate a timestamp string for versioned output
|
|
2587
|
+
*/
|
|
2588
|
+
function generateVersionTimestamp() {
|
|
2589
|
+
const now = new Date();
|
|
2590
|
+
return now.toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19); // YYYY-MM-DD_HH-MM-SS
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
/**
|
|
2594
|
+
* Resolve output directory for a scenario capture
|
|
2595
|
+
* Supports output path templating with {{variables}}
|
|
2596
|
+
*
|
|
2597
|
+
* @param {Object} config - Global config
|
|
2598
|
+
* @param {Object} scenario - Scenario being captured
|
|
2599
|
+
* @param {Object} options - Additional options
|
|
2600
|
+
* @returns {Object} { outputDir, outputTemplate, useTemplating }
|
|
2601
|
+
*/
|
|
2602
|
+
function resolveScenarioOutputDir(config, scenario, options = {}) {
|
|
2603
|
+
const { variantOverride, timestamp, versioned = true } = options;
|
|
2604
|
+
|
|
2605
|
+
// Check if output templating is configured
|
|
2606
|
+
// Use DEFAULT_OUTPUT_TEMPLATE if no template is specified in config or scenario
|
|
2607
|
+
const outputTemplate =
|
|
2608
|
+
config.output?.template ||
|
|
2609
|
+
scenario.output?.template ||
|
|
2610
|
+
DEFAULT_OUTPUT_TEMPLATE;
|
|
2611
|
+
|
|
2612
|
+
if (outputTemplate) {
|
|
2613
|
+
// Use new output path templating system
|
|
2614
|
+
// Build directory template (remove filename portion)
|
|
2615
|
+
let dirTemplate = path.dirname(outputTemplate);
|
|
2616
|
+
|
|
2617
|
+
// IMPORTANT: If versioned mode is enabled and template doesn't include timestamp/date/time,
|
|
2618
|
+
// automatically inject timestamp folder after scenario for proper versioning
|
|
2619
|
+
const hasTimestampVar = /\{\{(timestamp|date|time)\}\}/.test(
|
|
2620
|
+
outputTemplate
|
|
2621
|
+
);
|
|
2622
|
+
if (versioned && !hasTimestampVar && timestamp) {
|
|
2623
|
+
// Insert timestamp after {{scenario}} or at the start of the path after base dir
|
|
2624
|
+
const scenarioMatch = dirTemplate.match(
|
|
2625
|
+
/^(.*)(\{\{scenario(Key)?\}\})(.*?)$/
|
|
2626
|
+
);
|
|
2627
|
+
if (scenarioMatch) {
|
|
2628
|
+
// Insert timestamp right after scenario
|
|
2629
|
+
dirTemplate = `${scenarioMatch[1]}{{scenario}}/{{timestamp}}${scenarioMatch[4]}`;
|
|
2630
|
+
} else {
|
|
2631
|
+
// No scenario in template, add timestamp as first folder after base
|
|
2632
|
+
const parts = dirTemplate.split("/");
|
|
2633
|
+
if (parts.length > 1) {
|
|
2634
|
+
// Insert timestamp after first path segment
|
|
2635
|
+
parts.splice(1, 0, "{{timestamp}}");
|
|
2636
|
+
dirTemplate = parts.join("/");
|
|
2637
|
+
} else {
|
|
2638
|
+
dirTemplate = `${dirTemplate}/{{timestamp}}`;
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
// Build context for this capture
|
|
2644
|
+
const variant = variantOverride || scenario.variant || {};
|
|
2645
|
+
const resolvedViewport = resolveViewport(config.viewport);
|
|
2646
|
+
|
|
2647
|
+
const context = buildTemplateContext({
|
|
2648
|
+
scenario,
|
|
2649
|
+
assetName: "placeholder", // Will be replaced per-asset
|
|
2650
|
+
stepIndex: 0,
|
|
2651
|
+
variant,
|
|
2652
|
+
timestamp,
|
|
2653
|
+
viewport: resolvedViewport,
|
|
2654
|
+
viewportPresetName: resolvedViewport.presetName,
|
|
2655
|
+
});
|
|
2656
|
+
|
|
2657
|
+
// Resolve directory path
|
|
2658
|
+
const outputDir = resolveOutputPath(dirTemplate + "/{{name}}.{{ext}}", {
|
|
2659
|
+
...options,
|
|
2660
|
+
scenario,
|
|
2661
|
+
assetName: "placeholder",
|
|
2662
|
+
variant,
|
|
2663
|
+
timestamp,
|
|
2664
|
+
viewport: resolvedViewport,
|
|
2665
|
+
}).replace(/\/placeholder\.png$/, "");
|
|
2666
|
+
|
|
2667
|
+
// For templating mode, versionFolder is the timestamp
|
|
2668
|
+
const versionFolder = timestamp || "latest";
|
|
2669
|
+
|
|
2670
|
+
return {
|
|
2671
|
+
outputDir,
|
|
2672
|
+
outputTemplate,
|
|
2673
|
+
useTemplating: true,
|
|
2674
|
+
context,
|
|
2675
|
+
versionFolder,
|
|
2676
|
+
};
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
// Legacy output directory logic
|
|
2680
|
+
let versionFolder = versioned ? timestamp : "latest";
|
|
2681
|
+
if (variantOverride) {
|
|
2682
|
+
const variantSlug = Object.entries(variantOverride)
|
|
2683
|
+
.map(([k, v]) => `${k}-${v}`)
|
|
2684
|
+
.join("_");
|
|
2685
|
+
versionFolder = path.join(versionFolder, variantSlug);
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
const outputDir = path.join(
|
|
2689
|
+
config.assetDir || ".reshot/output",
|
|
2690
|
+
scenario.key,
|
|
2691
|
+
versionFolder
|
|
2692
|
+
);
|
|
2693
|
+
|
|
2694
|
+
return {
|
|
2695
|
+
outputDir,
|
|
2696
|
+
outputTemplate: null,
|
|
2697
|
+
useTemplating: false,
|
|
2698
|
+
context: null,
|
|
2699
|
+
versionFolder,
|
|
2700
|
+
};
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
/**
|
|
2704
|
+
* Helper to generate variant combinations for a specific scenario
|
|
2705
|
+
* Uses scenario.variants.dimensions to filter which dimensions to expand
|
|
2706
|
+
*/
|
|
2707
|
+
function generateScenarioVariantCombinations(scenario, variantsConfig) {
|
|
2708
|
+
const scenarioVariants = scenario.variants || {};
|
|
2709
|
+
const dimensionKeys = scenarioVariants.dimensions || [];
|
|
2710
|
+
|
|
2711
|
+
if (dimensionKeys.length === 0) {
|
|
2712
|
+
return []; // No variants for this scenario
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
const dimensions = variantsConfig.dimensions || {};
|
|
2716
|
+
const validKeys = dimensionKeys.filter((key) => {
|
|
2717
|
+
const dim = dimensions[key];
|
|
2718
|
+
return dim?.options && Object.keys(dim.options).length > 0;
|
|
2719
|
+
});
|
|
2720
|
+
|
|
2721
|
+
if (validKeys.length === 0) {
|
|
2722
|
+
return [];
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
// Get options for each dimension
|
|
2726
|
+
const dimensionOptions = validKeys.map((key) => {
|
|
2727
|
+
const dim = dimensions[key];
|
|
2728
|
+
return Object.keys(dim.options).map((optKey) => ({
|
|
2729
|
+
dimension: key,
|
|
2730
|
+
option: optKey,
|
|
2731
|
+
}));
|
|
2732
|
+
});
|
|
2733
|
+
|
|
2734
|
+
// Generate cartesian product
|
|
2735
|
+
const cartesian = (...arrays) => {
|
|
2736
|
+
return arrays.reduce(
|
|
2737
|
+
(acc, arr) => acc.flatMap((combo) => arr.map((item) => [...combo, item])),
|
|
2738
|
+
[[]]
|
|
2739
|
+
);
|
|
2740
|
+
};
|
|
2741
|
+
|
|
2742
|
+
const combinations = cartesian(...dimensionOptions);
|
|
2743
|
+
|
|
2744
|
+
// Convert to variant objects
|
|
2745
|
+
return combinations.map((combo) => {
|
|
2746
|
+
const variant = {};
|
|
2747
|
+
for (const { dimension, option } of combo) {
|
|
2748
|
+
variant[dimension] = option;
|
|
2749
|
+
}
|
|
2750
|
+
return variant;
|
|
2751
|
+
});
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
/**
|
|
2755
|
+
* Detect optimal concurrency based on system resources.
|
|
2756
|
+
* Each browser context uses ~250MB of memory.
|
|
2757
|
+
* @returns {number}
|
|
2758
|
+
*/
|
|
2759
|
+
function detectOptimalConcurrency() {
|
|
2760
|
+
const cpuCount = Math.max(1, os.cpus().length - 1); // Leave one for system
|
|
2761
|
+
const freeMem = os.freemem();
|
|
2762
|
+
const memSlots = Math.max(1, Math.floor(freeMem / (250 * 1024 * 1024))); // 250MB per context
|
|
2763
|
+
const optimal = Math.min(cpuCount, memSlots, 8); // Cap at 8
|
|
2764
|
+
return Math.max(1, optimal);
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
/**
|
|
2768
|
+
* Run all scenarios from config
|
|
2769
|
+
*/
|
|
2770
|
+
async function runAllScenarios(config, options = {}) {
|
|
2771
|
+
const {
|
|
2772
|
+
scenarioKeys,
|
|
2773
|
+
headless = true,
|
|
2774
|
+
versioned = true,
|
|
2775
|
+
variantOverride,
|
|
2776
|
+
concurrency = 1,
|
|
2777
|
+
sharedTimestamp, // Optional shared timestamp for variant expansion
|
|
2778
|
+
} = options;
|
|
2779
|
+
|
|
2780
|
+
console.log(chalk.cyan("🎬 Running capture scenarios...\n"));
|
|
2781
|
+
|
|
2782
|
+
// Auto-sync session from CDP browser if available
|
|
2783
|
+
// This allows captures to use the authenticated session from a running Chrome instance
|
|
2784
|
+
try {
|
|
2785
|
+
const sessionPath = getDefaultSessionPath();
|
|
2786
|
+
const syncResult = await autoSyncSessionFromCDP(sessionPath, (msg) =>
|
|
2787
|
+
console.log(msg)
|
|
2788
|
+
);
|
|
2789
|
+
if (syncResult.synced) {
|
|
2790
|
+
console.log(
|
|
2791
|
+
chalk.gray(` → Using authenticated session from CDP browser\n`)
|
|
2792
|
+
);
|
|
2793
|
+
} else if (syncResult.reason === "no_cdp") {
|
|
2794
|
+
const hasExistingSession = fs.existsSync(sessionPath);
|
|
2795
|
+
if (hasExistingSession) {
|
|
2796
|
+
const sessionAge = Date.now() - fs.statSync(sessionPath).mtimeMs;
|
|
2797
|
+
const ageMinutes = Math.round(sessionAge / 60000);
|
|
2798
|
+
const ageLabel = ageMinutes < 60 ? `${ageMinutes}m ago` : `${Math.round(ageMinutes / 60)}h ago`;
|
|
2799
|
+
console.log(
|
|
2800
|
+
chalk.gray(` → CDP browser not found — using cached session (saved ${ageLabel})\n`)
|
|
2801
|
+
);
|
|
2802
|
+
} else {
|
|
2803
|
+
console.log(
|
|
2804
|
+
chalk.yellow(` ⚠ No CDP browser detected and no cached session found.`)
|
|
2805
|
+
);
|
|
2806
|
+
console.log(
|
|
2807
|
+
chalk.yellow(` Scenarios requiring auth will fail.\n`)
|
|
2808
|
+
);
|
|
2809
|
+
console.log(
|
|
2810
|
+
chalk.gray(` To fix: launch Chrome with remote debugging enabled:`)
|
|
2811
|
+
);
|
|
2812
|
+
console.log(
|
|
2813
|
+
chalk.gray(` google-chrome --remote-debugging-port=9222\n`)
|
|
2814
|
+
);
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
} catch (e) {
|
|
2818
|
+
// Silently continue - session sync is optional
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
// Run auth pre-flight check if any scenario requires auth
|
|
2822
|
+
const captureConfig = getCaptureConfig(config.capture || {});
|
|
2823
|
+
const scenarios = config.scenarios || [];
|
|
2824
|
+
const hasAuthScenarios = scenarios.some((s) => s.requiresAuth);
|
|
2825
|
+
|
|
2826
|
+
if (captureConfig.preflightCheck && hasAuthScenarios) {
|
|
2827
|
+
const sessionPath = getDefaultSessionPath();
|
|
2828
|
+
const hasSession = fs.existsSync(sessionPath);
|
|
2829
|
+
if (hasSession) {
|
|
2830
|
+
const preflightResult = await preflightAuthCheck(
|
|
2831
|
+
config.baseUrl || "",
|
|
2832
|
+
{
|
|
2833
|
+
storageStatePath: sessionPath,
|
|
2834
|
+
viewport: config.viewport || { width: 1280, height: 720 },
|
|
2835
|
+
}
|
|
2836
|
+
);
|
|
2837
|
+
if (!preflightResult.ok) {
|
|
2838
|
+
console.log(chalk.red(`\n ✖ ${preflightResult.message}\n`));
|
|
2839
|
+
return { success: false, results: [], error: preflightResult.message };
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
// Filter scenarios if keys provided
|
|
2845
|
+
const toRun =
|
|
2846
|
+
scenarioKeys?.length > 0
|
|
2847
|
+
? scenarios.filter((s) => scenarioKeys.includes(s.key))
|
|
2848
|
+
: scenarios;
|
|
2849
|
+
|
|
2850
|
+
if (toRun.length === 0) {
|
|
2851
|
+
console.log(chalk.yellow("No scenarios to run"));
|
|
2852
|
+
return { success: true, results: [] };
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
// Use shared timestamp if provided (for variant expansion), otherwise generate new one
|
|
2856
|
+
const runTimestamp = sharedTimestamp || generateVersionTimestamp();
|
|
2857
|
+
|
|
2858
|
+
// Get variant configuration from config (new universal format)
|
|
2859
|
+
const variantsConfig = config.variants || {};
|
|
2860
|
+
|
|
2861
|
+
// CRITICAL FIX: Expand scenarios based on their individual variant requirements
|
|
2862
|
+
// Each scenario can declare which variant dimensions it wants to expand across
|
|
2863
|
+
const expandedScenarios = [];
|
|
2864
|
+
|
|
2865
|
+
for (const scenario of toRun) {
|
|
2866
|
+
// If there's a global variant override, use it for all scenarios (CLI flag takes precedence)
|
|
2867
|
+
if (variantOverride) {
|
|
2868
|
+
expandedScenarios.push({ scenario, variantOverride });
|
|
2869
|
+
continue;
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
// Check if this scenario needs variant expansion
|
|
2873
|
+
const scenarioVariantCombos = generateScenarioVariantCombinations(
|
|
2874
|
+
scenario,
|
|
2875
|
+
variantsConfig
|
|
2876
|
+
);
|
|
2877
|
+
|
|
2878
|
+
if (scenarioVariantCombos.length > 0) {
|
|
2879
|
+
// Expand this scenario across all its variant combinations
|
|
2880
|
+
for (const variantCombo of scenarioVariantCombos) {
|
|
2881
|
+
expandedScenarios.push({ scenario, variantOverride: variantCombo });
|
|
2882
|
+
}
|
|
2883
|
+
} else {
|
|
2884
|
+
// No variants for this scenario, run it once with no variant override
|
|
2885
|
+
expandedScenarios.push({ scenario, variantOverride: null });
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
const totalRuns = expandedScenarios.length;
|
|
2890
|
+
const effectiveConcurrency = Math.max(1, Math.min(concurrency, totalRuns));
|
|
2891
|
+
console.log(
|
|
2892
|
+
chalk.gray(
|
|
2893
|
+
`Running ${totalRuns} scenario variation(s) with ${effectiveConcurrency} worker(s)...\n`
|
|
2894
|
+
)
|
|
2895
|
+
);
|
|
2896
|
+
|
|
2897
|
+
/**
|
|
2898
|
+
* Execute a single scenario variation (scenario + variant combination)
|
|
2899
|
+
* @param {Object} scenarioVariation - { scenario, variantOverride }
|
|
2900
|
+
* @param {Object} poolOptions - { storageStateData }
|
|
2901
|
+
*/
|
|
2902
|
+
async function executeScenarioVariation(scenarioVariation, poolOptions = {}) {
|
|
2903
|
+
const { scenario, variantOverride: variantCombo } = scenarioVariation;
|
|
2904
|
+
const { storageStateData: ssData = null, quiet = false } = poolOptions;
|
|
2905
|
+
|
|
2906
|
+
// Apply variant to the scenario
|
|
2907
|
+
let scenarioToRun = scenario;
|
|
2908
|
+
if (variantCombo && typeof variantCombo === "object") {
|
|
2909
|
+
// CRITICAL FIX: Merge the expanded variant with the scenario's base variant
|
|
2910
|
+
// This allows scenarios to declare a fixed role while varying theme, for example
|
|
2911
|
+
const baseVariant = scenario.variant || {};
|
|
2912
|
+
scenarioToRun = {
|
|
2913
|
+
...scenario,
|
|
2914
|
+
variant: { ...baseVariant, ...variantCombo },
|
|
2915
|
+
};
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
// Resolve output directory using new templating system or legacy logic
|
|
2919
|
+
const outputResolution = resolveScenarioOutputDir(config, scenario, {
|
|
2920
|
+
variantOverride: variantCombo,
|
|
2921
|
+
timestamp: runTimestamp,
|
|
2922
|
+
versioned,
|
|
2923
|
+
});
|
|
2924
|
+
|
|
2925
|
+
const { outputDir, outputTemplate, useTemplating, versionFolder } =
|
|
2926
|
+
outputResolution;
|
|
2927
|
+
|
|
2928
|
+
// Also create/update 'latest' symlink or copy (for legacy mode)
|
|
2929
|
+
const latestDir = path.join(
|
|
2930
|
+
config.assetDir || ".reshot/output",
|
|
2931
|
+
scenario.key,
|
|
2932
|
+
"latest"
|
|
2933
|
+
);
|
|
2934
|
+
|
|
2935
|
+
// Resolve viewport - support preset names and custom sizes
|
|
2936
|
+
const resolvedViewport = resolveViewport(config.viewport);
|
|
2937
|
+
|
|
2938
|
+
const result = await runScenarioWithEngine(scenarioToRun, {
|
|
2939
|
+
outputDir,
|
|
2940
|
+
outputTemplate, // Pass template for per-asset path resolution
|
|
2941
|
+
useTemplating,
|
|
2942
|
+
baseUrl: config.baseUrl,
|
|
2943
|
+
viewport: resolvedViewport,
|
|
2944
|
+
timeout: config.timeout,
|
|
2945
|
+
headless,
|
|
2946
|
+
variantsConfig, // Pass universal variant config
|
|
2947
|
+
runTimestamp, // Pass timestamp for templating
|
|
2948
|
+
storageStateData: ssData,
|
|
2949
|
+
quiet,
|
|
2950
|
+
noPrivacy: options.noPrivacy,
|
|
2951
|
+
noStyle: options.noStyle,
|
|
2952
|
+
});
|
|
2953
|
+
|
|
2954
|
+
// After successful run, update 'latest' to point to this version (legacy mode only)
|
|
2955
|
+
if (result.success && versioned && !useTemplating) {
|
|
2956
|
+
try {
|
|
2957
|
+
// Remove existing latest folder/symlink
|
|
2958
|
+
if (fs.existsSync(latestDir)) {
|
|
2959
|
+
fs.removeSync(latestDir);
|
|
2960
|
+
}
|
|
2961
|
+
// Copy the versioned output to latest
|
|
2962
|
+
fs.copySync(outputDir, latestDir);
|
|
2963
|
+
console.log(chalk.gray(` → Updated 'latest' symlink`));
|
|
2964
|
+
} catch (e) {
|
|
2965
|
+
// Ignore symlink errors
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
return {
|
|
2970
|
+
scenario: scenario.key,
|
|
2971
|
+
key: scenario.key,
|
|
2972
|
+
version: versionFolder,
|
|
2973
|
+
timestamp: versioned ? runTimestamp : null,
|
|
2974
|
+
outputDir,
|
|
2975
|
+
variant: variantCombo,
|
|
2976
|
+
...result,
|
|
2977
|
+
};
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
// Pre-load auth state once to avoid redundant file reads across parallel workers
|
|
2981
|
+
let storageStateData = null;
|
|
2982
|
+
const sessionPath = getDefaultSessionPath();
|
|
2983
|
+
if (fs.existsSync(sessionPath)) {
|
|
2984
|
+
try {
|
|
2985
|
+
const rawState = JSON.parse(fs.readFileSync(sessionPath, "utf-8"));
|
|
2986
|
+
const { sanitized, stats } = sanitizeStorageState(rawState);
|
|
2987
|
+
storageStateData = sanitized;
|
|
2988
|
+
if (stats.fixed > 0 || stats.removed > 0 || stats.stripped > 0) {
|
|
2989
|
+
console.log(chalk.gray(` → Sanitized cookies: ${stats.fixed} fixed, ${stats.removed} removed, ${stats.stripped} stripped`));
|
|
2990
|
+
}
|
|
2991
|
+
console.log(chalk.gray(` → Pre-loaded auth state for ${totalRuns} workers`));
|
|
2992
|
+
} catch (_e) {
|
|
2993
|
+
// Fall back to file path per-engine
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
// Helper to get a readable label for a scenario variation
|
|
2998
|
+
function getVariationLabel(scenarioVariation) {
|
|
2999
|
+
const { scenario, variantOverride: variantCombo } = scenarioVariation;
|
|
3000
|
+
if (variantCombo) {
|
|
3001
|
+
const variantLabel = Object.entries(variantCombo)
|
|
3002
|
+
.map(([dim, opt]) => {
|
|
3003
|
+
const dimension = variantsConfig.dimensions?.[dim];
|
|
3004
|
+
const option = dimension?.options?.[opt];
|
|
3005
|
+
return option?.name || opt;
|
|
3006
|
+
})
|
|
3007
|
+
.join(" \u2022 ");
|
|
3008
|
+
return `${scenario.name} (${variantLabel})`;
|
|
3009
|
+
}
|
|
3010
|
+
return scenario.name;
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
// Execute scenario variations with concurrency
|
|
3014
|
+
const results = [];
|
|
3015
|
+
let allSuccess = true;
|
|
3016
|
+
const tracker = new ProgressTracker(totalRuns, { concurrency: effectiveConcurrency });
|
|
3017
|
+
|
|
3018
|
+
if (effectiveConcurrency > 1) {
|
|
3019
|
+
// Parallel execution with streaming worker pool (each worker launches its own browser)
|
|
3020
|
+
console.log(chalk.gray(` → ${effectiveConcurrency} concurrent workers, each with isolated browser\n`));
|
|
3021
|
+
|
|
3022
|
+
const pool = new WorkerPool(effectiveConcurrency, {
|
|
3023
|
+
onProgress: ({ completed, total, active, durationMs, result, error, task }) => {
|
|
3024
|
+
tracker.recordCompletion(durationMs);
|
|
3025
|
+
|
|
3026
|
+
// Per-scenario completion line
|
|
3027
|
+
const label = task ? getVariationLabel(task) : `Scenario ${completed}`;
|
|
3028
|
+
const success = result && result.success !== false;
|
|
3029
|
+
console.log(
|
|
3030
|
+
success
|
|
3031
|
+
? chalk.green(` ${tracker.formatCompletionLine(label, durationMs, true)}`)
|
|
3032
|
+
: chalk.red(` ${tracker.formatCompletionLine(label, durationMs, false, error?.message)}`)
|
|
3033
|
+
);
|
|
3034
|
+
|
|
3035
|
+
// Structured progress line (parseable by Studio UI)
|
|
3036
|
+
console.log(chalk.cyan(` ${tracker.formatProgressLine(active, durationMs)}`));
|
|
3037
|
+
},
|
|
3038
|
+
});
|
|
3039
|
+
|
|
3040
|
+
const poolResults = await pool.runAll(expandedScenarios, (sv) =>
|
|
3041
|
+
executeScenarioVariation(sv, { storageStateData, quiet: true })
|
|
3042
|
+
);
|
|
3043
|
+
|
|
3044
|
+
for (const result of poolResults) {
|
|
3045
|
+
results.push(result);
|
|
3046
|
+
if (!result.success) {
|
|
3047
|
+
allSuccess = false;
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
} else {
|
|
3051
|
+
// Sequential execution (no pool needed)
|
|
3052
|
+
for (const scenarioVariation of expandedScenarios) {
|
|
3053
|
+
const label = getVariationLabel(scenarioVariation);
|
|
3054
|
+
console.log(chalk.gray(`\n Starting: ${label}`));
|
|
3055
|
+
|
|
3056
|
+
const taskStart = Date.now();
|
|
3057
|
+
const result = await executeScenarioVariation(scenarioVariation, { storageStateData });
|
|
3058
|
+
const durationMs = Date.now() - taskStart;
|
|
3059
|
+
tracker.recordCompletion(durationMs);
|
|
3060
|
+
|
|
3061
|
+
results.push(result);
|
|
3062
|
+
if (!result.success) {
|
|
3063
|
+
allSuccess = false;
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
// Per-scenario completion line
|
|
3067
|
+
console.log(
|
|
3068
|
+
result.success
|
|
3069
|
+
? chalk.green(` ${tracker.formatCompletionLine(label, durationMs, true)}`)
|
|
3070
|
+
: chalk.red(` ${tracker.formatCompletionLine(label, durationMs, false, result.error)}`)
|
|
3071
|
+
);
|
|
3072
|
+
|
|
3073
|
+
// Structured progress line
|
|
3074
|
+
console.log(chalk.cyan(` ${tracker.formatProgressLine(0, durationMs)}`));
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
// Summary
|
|
3079
|
+
const summary = tracker.getSummary();
|
|
3080
|
+
console.log(chalk.bold("\n\uD83D\uDCCA Summary"));
|
|
3081
|
+
const successful = results.filter((r) => r.success).length;
|
|
3082
|
+
const failed = results.filter((r) => !r.success).length;
|
|
3083
|
+
|
|
3084
|
+
console.log(chalk.gray(` Total: ${results.length} in ${summary.elapsed}`));
|
|
3085
|
+
console.log(chalk.green(` Successful: ${successful}`));
|
|
3086
|
+
if (failed > 0) {
|
|
3087
|
+
console.log(chalk.red(` Failed: ${failed}`));
|
|
3088
|
+
}
|
|
3089
|
+
console.log(chalk.gray(` Avg: ${summary.avgDuration}/scenario | Throughput: ${summary.throughput}/min`));
|
|
3090
|
+
if (effectiveConcurrency > 1) {
|
|
3091
|
+
console.log(chalk.gray(` Workers: ${effectiveConcurrency} parallel`));
|
|
3092
|
+
}
|
|
3093
|
+
if (versioned) {
|
|
3094
|
+
console.log(chalk.gray(` Version: ${runTimestamp}`));
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
return { success: allSuccess, results, version: runTimestamp };
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
module.exports = {
|
|
3101
|
+
convertLegacySteps,
|
|
3102
|
+
runScenarioWithEngine,
|
|
3103
|
+
runScenarioWithStepByStepCapture,
|
|
3104
|
+
runScenarioWithVideoCapture,
|
|
3105
|
+
captureWithHighlight,
|
|
3106
|
+
checkFFmpeg,
|
|
3107
|
+
runAllScenarios,
|
|
3108
|
+
calculateImageHash,
|
|
3109
|
+
imagesAreIdentical,
|
|
3110
|
+
waitForVisualStability,
|
|
3111
|
+
// Error detection & retry
|
|
3112
|
+
retryInteractiveStep,
|
|
3113
|
+
executeWithRetry,
|
|
3114
|
+
preflightAuthCheck,
|
|
3115
|
+
// New exports for output templating
|
|
3116
|
+
resolveScenarioOutputDir,
|
|
3117
|
+
generateVersionTimestamp,
|
|
3118
|
+
// Concurrency
|
|
3119
|
+
detectOptimalConcurrency,
|
|
3120
|
+
};
|