@reshotdev/screenshot 0.0.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +388 -0
  3. package/package.json +64 -0
  4. package/src/commands/auth.js +259 -0
  5. package/src/commands/chrome.js +140 -0
  6. package/src/commands/ci-run.js +123 -0
  7. package/src/commands/ci-setup.js +288 -0
  8. package/src/commands/drifts.js +423 -0
  9. package/src/commands/import-tests.js +309 -0
  10. package/src/commands/ingest.js +458 -0
  11. package/src/commands/init.js +633 -0
  12. package/src/commands/publish.js +1721 -0
  13. package/src/commands/pull.js +303 -0
  14. package/src/commands/record.js +94 -0
  15. package/src/commands/run.js +476 -0
  16. package/src/commands/setup-wizard.js +740 -0
  17. package/src/commands/setup.js +137 -0
  18. package/src/commands/status.js +275 -0
  19. package/src/commands/sync.js +621 -0
  20. package/src/commands/ui.js +248 -0
  21. package/src/commands/validate-docs.js +529 -0
  22. package/src/index.js +462 -0
  23. package/src/lib/api-client.js +815 -0
  24. package/src/lib/capture-engine.js +1623 -0
  25. package/src/lib/capture-script-runner.js +3120 -0
  26. package/src/lib/ci-detect.js +137 -0
  27. package/src/lib/config.js +1240 -0
  28. package/src/lib/diff-engine.js +642 -0
  29. package/src/lib/hash.js +74 -0
  30. package/src/lib/image-crop.js +396 -0
  31. package/src/lib/matrix.js +89 -0
  32. package/src/lib/output-path-template.js +318 -0
  33. package/src/lib/playwright-runner.js +252 -0
  34. package/src/lib/polished-clip.js +553 -0
  35. package/src/lib/privacy-engine.js +408 -0
  36. package/src/lib/progress-tracker.js +142 -0
  37. package/src/lib/record-browser-injection.js +654 -0
  38. package/src/lib/record-cdp.js +612 -0
  39. package/src/lib/record-clip.js +343 -0
  40. package/src/lib/record-config.js +623 -0
  41. package/src/lib/record-screenshot.js +360 -0
  42. package/src/lib/record-terminal.js +123 -0
  43. package/src/lib/recorder-service.js +781 -0
  44. package/src/lib/secrets.js +51 -0
  45. package/src/lib/selector-strategies.js +859 -0
  46. package/src/lib/standalone-mode.js +400 -0
  47. package/src/lib/storage-providers.js +569 -0
  48. package/src/lib/style-engine.js +684 -0
  49. package/src/lib/ui-api.js +4677 -0
  50. package/src/lib/ui-assets.js +373 -0
  51. package/src/lib/ui-executor.js +587 -0
  52. package/src/lib/variant-injector.js +591 -0
  53. package/src/lib/viewport-presets.js +454 -0
  54. package/src/lib/worker-pool.js +118 -0
  55. package/web/cropper/index.html +436 -0
  56. package/web/manager/dist/assets/index--ZgioErz.js +507 -0
  57. package/web/manager/dist/assets/index-n468W0Wr.css +1 -0
  58. package/web/manager/dist/index.html +27 -0
  59. package/web/subtitle-editor/index.html +295 -0
@@ -0,0 +1,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
+ };