@reshotdev/screenshot 0.0.1-beta.2 → 0.0.1-beta.21

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 (81) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +138 -47
  3. package/package.json +27 -16
  4. package/src/commands/auth.js +159 -30
  5. package/src/commands/capture-dom.js +50 -0
  6. package/src/commands/certify.js +62 -0
  7. package/src/commands/compose.js +220 -0
  8. package/src/commands/doctor-release.js +74 -0
  9. package/src/commands/doctor-target.js +108 -0
  10. package/src/commands/drifts.js +16 -69
  11. package/src/commands/import-tests.js +13 -13
  12. package/src/commands/init.js +16 -277
  13. package/src/commands/publish.js +484 -257
  14. package/src/commands/pull.js +302 -35
  15. package/src/commands/refresh.js +166 -0
  16. package/src/commands/run.js +292 -12
  17. package/src/commands/setup-wizard.js +348 -496
  18. package/src/commands/status.js +334 -126
  19. package/src/commands/sync.js +28 -236
  20. package/src/commands/ui.js +1 -1
  21. package/src/commands/variation.js +194 -0
  22. package/src/commands/verify-publish.js +46 -0
  23. package/src/index.js +383 -118
  24. package/src/lib/api-client.js +172 -60
  25. package/src/lib/auto-update/refresh.js +598 -0
  26. package/src/lib/auto-update/scene-runtime.compose.tsx +73 -0
  27. package/src/lib/auto-update/spec.js +89 -0
  28. package/src/lib/capture-engine.js +179 -9
  29. package/src/lib/capture-script-runner.js +639 -214
  30. package/src/lib/certification.js +887 -0
  31. package/src/lib/compose-context.js +156 -0
  32. package/src/lib/compose-pack.js +42 -0
  33. package/src/lib/compose-runtime.js +34 -0
  34. package/src/lib/compose-upload.js +142 -0
  35. package/src/lib/config.js +186 -81
  36. package/src/lib/dom-capture.js +64 -0
  37. package/src/lib/ensure-browser.js +147 -0
  38. package/src/lib/output-path-template.js +3 -3
  39. package/src/lib/record-cdp.js +288 -16
  40. package/src/lib/record-clip.js +83 -3
  41. package/src/lib/record-config.js +1 -5
  42. package/src/lib/release-doctor.js +321 -0
  43. package/src/lib/resolve-targets.js +60 -0
  44. package/src/lib/run-manifest.js +148 -0
  45. package/src/lib/standalone-mode.js +1 -1
  46. package/src/lib/storage-providers.js +5 -5
  47. package/src/lib/style-engine.js +5 -5
  48. package/src/lib/target-contract.js +292 -0
  49. package/src/lib/ui-api-helpers.js +118 -0
  50. package/src/lib/ui-api.js +31 -824
  51. package/src/lib/ui-asset-cleanup.js +62 -0
  52. package/src/lib/ui-output-versions.js +165 -0
  53. package/src/lib/ui-recorder-routes.js +341 -0
  54. package/src/lib/ui-scenario-metadata.js +161 -0
  55. package/vendor/compose/dist/auto-update.cjs +5544 -0
  56. package/vendor/compose/dist/auto-update.mjs +5518 -0
  57. package/vendor/compose/dist/capture.cjs +1450 -0
  58. package/vendor/compose/dist/capture.mjs +1416 -0
  59. package/vendor/compose/dist/eligibility.cjs +5331 -0
  60. package/vendor/compose/dist/eligibility.mjs +5313 -0
  61. package/vendor/compose/dist/index.cjs +2046 -0
  62. package/vendor/compose/dist/index.mjs +1997 -0
  63. package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
  64. package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
  65. package/vendor/compose/dist/jsx-runtime.cjs +58 -0
  66. package/vendor/compose/dist/jsx-runtime.mjs +31 -0
  67. package/vendor/compose/dist/render.cjs +558 -0
  68. package/vendor/compose/dist/render.mjs +515 -0
  69. package/vendor/compose/dist/verify-cli.cjs +3806 -0
  70. package/vendor/compose/dist/verify-cli.mjs +3812 -0
  71. package/vendor/compose/dist/verify.cjs +3880 -0
  72. package/vendor/compose/dist/verify.mjs +3858 -0
  73. package/web/manager/dist/assets/index-D0S2otug.js +507 -0
  74. package/web/manager/dist/index.html +1 -1
  75. package/src/commands/ci-run.js +0 -123
  76. package/src/commands/ci-setup.js +0 -288
  77. package/src/commands/ingest.js +0 -458
  78. package/src/commands/setup.js +0 -137
  79. package/src/commands/validate-docs.js +0 -529
  80. package/src/lib/playwright-runner.js +0 -252
  81. package/web/manager/dist/assets/index--ZgioErz.js +0 -507
@@ -1,5 +1,36 @@
1
1
  // capture-script-runner.js - Run capture scripts with the new robust engine
2
2
  const { CaptureEngine, isAuthRedirectUrl } = require("./capture-engine");
3
+
4
+ /**
5
+ * Capture a self-contained MHTML bundle of the page at the same moment as
6
+ * a screenshot. The bundle re-renders in any Chromium browser without
7
+ * network access and is the source of truth for variations — marketing
8
+ * can mutate the captured DOM (swap copy, hide chrome, recrop, rebrand)
9
+ * and render new outputs without re-running Playwright against the live
10
+ * app.
11
+ *
12
+ * Best-effort: failures are logged via `logger` but never bubble up.
13
+ * Returns `{ path, bytes }` on success, `null` otherwise.
14
+ *
15
+ * Opt out per-call via `enabled=false` (typically driven from scenario
16
+ * config). Defaults ON.
17
+ */
18
+ async function captureDomScene(page, pngOutputPath, { enabled = true, logger = () => {} } = {}) {
19
+ if (!enabled) return null;
20
+ try {
21
+ const cdp = await page.context().newCDPSession(page);
22
+ const { data: mhtml } = await cdp.send("Page.captureSnapshot", {
23
+ format: "mhtml",
24
+ });
25
+ const mhtmlPath = pngOutputPath.replace(/\.png$/i, ".mhtml");
26
+ const fsmod = require("fs-extra");
27
+ await fsmod.writeFile(mhtmlPath, mhtml);
28
+ return { path: mhtmlPath, bytes: Buffer.byteLength(mhtml, "utf8") };
29
+ } catch (err) {
30
+ logger(` ⚠ DOM scene capture skipped: ${err && err.message ? err.message : err}`);
31
+ return null;
32
+ }
33
+ }
3
34
  const { buildLaunchOptions } = require("./ci-detect");
4
35
  const {
5
36
  resolveVariantConfig,
@@ -30,6 +61,8 @@ const {
30
61
  getDefaultSessionPath,
31
62
  autoSyncSessionFromCDP,
32
63
  sanitizeStorageState,
64
+ assessSessionHealth,
65
+ writeSessionArtifacts,
33
66
  } = require("./record-cdp");
34
67
  const config = require("./config");
35
68
  const {
@@ -135,6 +168,178 @@ function debug(...args) {
135
168
  }
136
169
  }
137
170
 
171
+ function normalizeVideoTargetName(selector) {
172
+ return String(selector || "")
173
+ .replace(/^\[data-testid=['"]?([^'"\]]+)['"]?\]$/, "$1")
174
+ .replace(/[^a-z0-9]+/gi, "_")
175
+ .replace(/^_+|_+$/g, "")
176
+ .toLowerCase();
177
+ }
178
+
179
+ function buildVideoMetadata(events, sentinels, viewport, frameRate) {
180
+ const targets = {};
181
+ const timeline = events.map((event, index) => {
182
+ const targetName = event.target ? normalizeVideoTargetName(event.target) : null;
183
+ if (targetName && event.elementBox) {
184
+ targets[targetName] = event.elementBox;
185
+ }
186
+ return {
187
+ type: event.action,
188
+ tMs: Math.round(event.timestamp * 1000),
189
+ label: event.subtitle || event.action,
190
+ target: targetName || undefined,
191
+ elementBox: event.elementBox || undefined,
192
+ index,
193
+ };
194
+ });
195
+
196
+ return {
197
+ version: 1,
198
+ generatedAt: new Date().toISOString(),
199
+ frameRate,
200
+ viewport,
201
+ timeline,
202
+ targets,
203
+ sentinels: sentinels.map((sentinel) => ({
204
+ index: sentinel.index,
205
+ label: sentinel.label,
206
+ filename: path.basename(sentinel.path),
207
+ })),
208
+ };
209
+ }
210
+
211
+ async function installVisibleCursor(page) {
212
+ await page.addInitScript(() => {
213
+ const install = () => {
214
+ if (document.querySelector("[data-reshot-cursor]")) return;
215
+ const cursor = document.createElement("div");
216
+ cursor.setAttribute("data-reshot-cursor", "true");
217
+ cursor.style.cssText = [
218
+ "position:fixed",
219
+ "left:0",
220
+ "top:0",
221
+ "width:18px",
222
+ "height:18px",
223
+ "z-index:2147483647",
224
+ "pointer-events:none",
225
+ "transform:translate(-100px,-100px)",
226
+ "filter:drop-shadow(0 2px 4px rgba(0,0,0,.35))",
227
+ ].join(";");
228
+ cursor.innerHTML = '<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M4 3.5 19.5 14l-7.2 1.1 4.2 5.3-2.7 2.1-4.1-5.3-3.2 6.8L4 3.5Z" fill="white" stroke="rgba(15,23,42,.9)" stroke-width="1.4"/></svg>';
229
+ document.documentElement.appendChild(cursor);
230
+ window.addEventListener("mousemove", (event) => {
231
+ cursor.style.transform = `translate(${event.clientX}px, ${event.clientY}px)`;
232
+ }, { passive: true });
233
+ window.addEventListener("mousedown", () => {
234
+ cursor.style.scale = "0.82";
235
+ }, { passive: true });
236
+ window.addEventListener("mouseup", () => {
237
+ cursor.style.scale = "1";
238
+ }, { passive: true });
239
+ };
240
+ if (document.readyState === "loading") {
241
+ document.addEventListener("DOMContentLoaded", install, { once: true });
242
+ } else {
243
+ install();
244
+ }
245
+ });
246
+ }
247
+
248
+ function normalizeVisibleText(value) {
249
+ return String(value || "")
250
+ .replace(/\s+/g, " ")
251
+ .trim()
252
+ .toLowerCase();
253
+ }
254
+
255
+ function collectForbiddenText(globalQuality = null, scenario = {}) {
256
+ const merged = [
257
+ ...(globalQuality?.forbidText || []),
258
+ ...(scenario.quality?.forbidText || []),
259
+ ]
260
+ .map((value) => String(value || "").trim())
261
+ .filter(Boolean);
262
+
263
+ return [
264
+ ...new Map(
265
+ merged.map((value) => [normalizeVisibleText(value), value]),
266
+ ).values(),
267
+ ];
268
+ }
269
+
270
+ async function assertForbiddenTextAbsent(page, forbidText = []) {
271
+ if (!Array.isArray(forbidText) || forbidText.length === 0) {
272
+ return null;
273
+ }
274
+
275
+ const visibleText = normalizeVisibleText(
276
+ await page.evaluate(() => {
277
+ const isElementVisibleInViewport = (element) => {
278
+ if (!(element instanceof Element)) {
279
+ return false;
280
+ }
281
+
282
+ const style = window.getComputedStyle(element);
283
+ if (
284
+ style.display === "none" ||
285
+ style.visibility === "hidden" ||
286
+ Number(style.opacity || "1") === 0
287
+ ) {
288
+ return false;
289
+ }
290
+
291
+ const rect = element.getBoundingClientRect();
292
+ if (rect.width <= 0 || rect.height <= 0) {
293
+ return false;
294
+ }
295
+
296
+ return (
297
+ rect.bottom > 0 &&
298
+ rect.right > 0 &&
299
+ rect.top < window.innerHeight &&
300
+ rect.left < window.innerWidth
301
+ );
302
+ };
303
+
304
+ const walker = document.createTreeWalker(
305
+ document.body,
306
+ NodeFilter.SHOW_TEXT,
307
+ {
308
+ acceptNode(node) {
309
+ const text = node.textContent?.replace(/\s+/g, " ").trim();
310
+ if (!text) {
311
+ return NodeFilter.FILTER_REJECT;
312
+ }
313
+
314
+ const parent = node.parentElement;
315
+ if (!parent || !isElementVisibleInViewport(parent)) {
316
+ return NodeFilter.FILTER_REJECT;
317
+ }
318
+
319
+ return NodeFilter.FILTER_ACCEPT;
320
+ },
321
+ },
322
+ );
323
+
324
+ const textParts = [];
325
+ while (walker.nextNode()) {
326
+ textParts.push(walker.currentNode.textContent || "");
327
+ }
328
+
329
+ return textParts.join(" ");
330
+ }),
331
+ );
332
+ const matched = forbidText.find((candidate) =>
333
+ visibleText.includes(normalizeVisibleText(candidate)),
334
+ );
335
+
336
+ if (!matched) {
337
+ return null;
338
+ }
339
+
340
+ throw new Error(`Forbidden visible text detected during capture: "${matched}"`);
341
+ }
342
+
138
343
  /**
139
344
  * Execute a page load with retry logic on error/timeout
140
345
  * Uses the capture engine's error detection to identify failures and retry
@@ -251,13 +456,42 @@ async function executeWithRetry(engine, readySelector, options = {}) {
251
456
  * @returns {Promise<{ok: boolean, message?: string}>}
252
457
  */
253
458
  async function preflightAuthCheck(baseUrl, options = {}) {
254
- const { storageStatePath, viewport = { width: 1280, height: 720 } } = options;
459
+ const {
460
+ storageStatePath,
461
+ viewport = { width: 1280, height: 720 },
462
+ authCheckUrl = "/app/projects",
463
+ } = options;
255
464
 
256
465
  if (!storageStatePath || !fs.existsSync(storageStatePath)) {
257
466
  return { ok: true }; // No session to verify
258
467
  }
259
468
 
469
+ const sessionHealth = assessSessionHealth(storageStatePath, baseUrl);
470
+ if (!sessionHealth.compatible) {
471
+ const mismatchSummary =
472
+ sessionHealth.evidence.sourceOrigin ||
473
+ sessionHealth.evidence.storageOrigins[0] ||
474
+ sessionHealth.evidence.cookieDomains[0] ||
475
+ "another environment";
476
+ return {
477
+ ok: false,
478
+ message:
479
+ `Cached auth session does not match this environment (${mismatchSummary} -> ${sessionHealth.expectedOrigin || baseUrl}). ` +
480
+ "Run `reshot record` against this target to capture a fresh session.",
481
+ };
482
+ }
483
+
260
484
  console.log(chalk.gray(" → Running auth pre-flight check..."));
485
+ if (sessionHealth.stale) {
486
+ console.log(
487
+ chalk.yellow(
488
+ ` ⚠ Cached auth session is ${sessionHealth.ageMinutes}m old; verifying it before capture...`,
489
+ ),
490
+ );
491
+ }
492
+ for (const warning of sessionHealth.warnings) {
493
+ console.log(chalk.gray(` ${warning}`));
494
+ }
261
495
 
262
496
  const engine = new CaptureEngine({
263
497
  baseUrl,
@@ -272,38 +506,97 @@ async function preflightAuthCheck(baseUrl, options = {}) {
272
506
  try {
273
507
  await engine.init();
274
508
 
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
- });
509
+ const authCheckTargets = Array.isArray(authCheckUrl)
510
+ ? authCheckUrl
511
+ : [authCheckUrl];
280
512
 
281
- // Check for auth redirect using shared utility
282
- const currentUrl = engine.page.url();
283
- const isAuthRedirect = isAuthRedirectUrl(currentUrl);
513
+ for (const authTarget of authCheckTargets) {
514
+ const preflightPath = authTarget.startsWith("http")
515
+ ? authTarget
516
+ : `${baseUrl}${authTarget}`;
284
517
 
285
- if (isAuthRedirect) {
286
- return {
287
- ok: false,
288
- message:
289
- "Auth session expired. Run `reshot record` to capture a fresh session.",
290
- };
291
- }
518
+ // Navigate to a known authenticated page and validate session/data loading.
519
+ await engine.page.goto(preflightPath, {
520
+ waitUntil: "domcontentloaded",
521
+ timeout: 15000,
522
+ });
292
523
 
293
- // Wait for data to settle
294
- await engine.page.waitForTimeout(3000);
295
- await engine._waitForStability();
524
+ // Check for auth redirect using shared utility
525
+ const currentUrl = engine.page.url();
526
+ const isAuthRedirect = isAuthRedirectUrl(currentUrl);
296
527
 
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
- };
528
+ if (isAuthRedirect) {
529
+ return {
530
+ ok: false,
531
+ message:
532
+ "Auth session expired. Run `reshot record` to capture a fresh session.",
533
+ };
534
+ }
535
+
536
+ // Also detect login page via DOM (catches SPA redirects where URL hasn't changed)
537
+ const hasLoginForm = await engine.page.evaluate(() => {
538
+ const h = document.querySelector("h1, h2");
539
+ return h && /sign\s*in|log\s*in/i.test(h.textContent);
540
+ }).catch(() => false);
541
+ if (hasLoginForm) {
542
+ return {
543
+ ok: false,
544
+ message:
545
+ "Auth session expired (login form detected). Run `reshot record` to refresh.",
546
+ };
547
+ }
548
+
549
+ // Wait for data to settle
550
+ await engine.page.waitForTimeout(3000);
551
+ await engine._waitForStability();
552
+
553
+ // Post-stability auth redirect check: catches SPA redirects that
554
+ // complete after JS has finished executing (client-side routing)
555
+ const postStabilityUrl = engine.page.url();
556
+ if (isAuthRedirectUrl(postStabilityUrl)) {
557
+ return {
558
+ ok: false,
559
+ message:
560
+ "Auth session expired (redirect detected after page load). Run `reshot record` to capture a fresh session.",
561
+ };
562
+ }
563
+ const hasLoginFormPostStability = await engine.page.evaluate(() => {
564
+ const h = document.querySelector("h1, h2");
565
+ return h && /sign\s*in|log\s*in/i.test(h.textContent);
566
+ }).catch(() => false);
567
+ if (hasLoginFormPostStability) {
568
+ return {
569
+ ok: false,
570
+ message:
571
+ "Auth session expired (login form detected after page load). Run `reshot record` to refresh.",
572
+ };
573
+ }
574
+
575
+ // Check for error state
576
+ const errorState = await engine._detectErrorState();
577
+ if (errorState.hasError) {
578
+ return {
579
+ ok: false,
580
+ message: `Auth session appears valid but data fetching failed (${errorState.errorType}). This usually means your JWT has expired. Run \`reshot record\` to refresh.`,
581
+ };
582
+ }
304
583
  }
305
584
 
306
- console.log(chalk.green(" ✔ Auth pre-flight check passed"));
585
+ // Save refreshed session back so scenarios use fresh cookies
586
+ if (storageStatePath && engine.context) {
587
+ try {
588
+ const refreshedState = await engine.context.storageState();
589
+ writeSessionArtifacts(storageStatePath, refreshedState, {
590
+ baseUrl,
591
+ pageUrl: engine.page?.url?.() || baseUrl,
592
+ });
593
+ console.log(chalk.green(" ✔ Auth pre-flight check passed (session refreshed)"));
594
+ } catch (_saveErr) {
595
+ console.log(chalk.green(" ✔ Auth pre-flight check passed"));
596
+ }
597
+ } else {
598
+ console.log(chalk.green(" ✔ Auth pre-flight check passed"));
599
+ }
307
600
  return { ok: true };
308
601
  } catch (e) {
309
602
  // If the error is an auth redirect thrown by the engine, handle gracefully
@@ -407,6 +700,14 @@ async function retryInteractiveStep(engine, action, params, context) {
407
700
  }
408
701
  }
409
702
 
703
+ function promoteLastGotoUrl(lastGotoUrl, currentUrl) {
704
+ if (!currentUrl || currentUrl === "about:blank") {
705
+ return lastGotoUrl;
706
+ }
707
+
708
+ return currentUrl !== lastGotoUrl ? currentUrl : lastGotoUrl;
709
+ }
710
+
410
711
  /**
411
712
  * Calculate a perceptual hash for an image buffer
412
713
  * This is a simple hash based on resizing the image to a small grid
@@ -768,6 +1069,7 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
768
1069
  scenarioTimeout: scenario.scenarioTimeout,
769
1070
  errorSelectors: scenario.errorSelectors,
770
1071
  });
1072
+ const forbidText = collectForbiddenText(options.globalQuality, scenario);
771
1073
 
772
1074
  // Extract readySelector: prefer scenario-level, fall back to first waitForSelector step
773
1075
  let readySelector = scenario.readySelector || null;
@@ -793,8 +1095,21 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
793
1095
  const sessionPath = getDefaultSessionPath();
794
1096
  const hasSession = fs.existsSync(sessionPath);
795
1097
  if (!quiet) {
1098
+ let sessionIsEmpty = false;
796
1099
  if (hasSession) {
797
- // Validate session freshness with graduated warnings
1100
+ try {
1101
+ const sessionContents = JSON.parse(fs.readFileSync(sessionPath, "utf8"));
1102
+ const noCookies = !Array.isArray(sessionContents.cookies) || sessionContents.cookies.length === 0;
1103
+ const noOrigins = !Array.isArray(sessionContents.origins) || sessionContents.origins.length === 0;
1104
+ sessionIsEmpty = noCookies && noOrigins;
1105
+ } catch {
1106
+ // Malformed JSON — fall through and treat as non-empty so the warning still fires.
1107
+ }
1108
+ }
1109
+ if (hasSession && !sessionIsEmpty && scenario.requiresAuth) {
1110
+ // Validate session freshness with graduated warnings.
1111
+ // Only relevant when this scenario actually requires auth — a leftover
1112
+ // session file should not trigger staleness warnings for public scenarios.
798
1113
  const sessionStats = fs.statSync(sessionPath);
799
1114
  const sessionAgeHours =
800
1115
  (Date.now() - sessionStats.mtimeMs) / (1000 * 60 * 60);
@@ -843,6 +1158,7 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
843
1158
  waitForReady: scenario.waitForReady || null, // Custom loading-state hook
844
1159
  privacyConfig: hasPrivacy ? scenarioPrivacyConfig : null, // Privacy masking
845
1160
  styleConfig: hasStyle ? scenarioStyleConfig : null, // Image beautification
1161
+ injectWorkspaceStore: scenario.needsWorkspaceInjection !== false,
846
1162
  logger: quiet ? () => {} : (msg) => console.log(msg),
847
1163
  });
848
1164
 
@@ -946,6 +1262,7 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
946
1262
  });
947
1263
  // Brief wait for CSS to apply
948
1264
  await engine.page.waitForTimeout(50);
1265
+ await assertForbiddenTextAbsent(engine.page, forbidText);
949
1266
 
950
1267
  let buffer = await engine.page.screenshot();
951
1268
 
@@ -1056,9 +1373,18 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
1056
1373
  await fs.writeFile(filePath, buffer);
1057
1374
  lastScreenshotHash = currentHash;
1058
1375
 
1376
+ // Capture sidecar MHTML so variations can be rendered from the
1377
+ // captured DOM (one CDP call; failures are non-fatal).
1378
+ const domScene = await captureDomScene(engine.page, filePath, {
1379
+ enabled: scenario.domScene !== false && options.domScene !== false,
1380
+ logger: quiet ? () => {} : (msg) => console.log(chalk.gray(msg)),
1381
+ });
1382
+
1059
1383
  const asset = {
1060
1384
  name,
1061
1385
  path: filePath,
1386
+ domScenePath: domScene ? domScene.path : null,
1387
+ domSceneBytes: domScene ? domScene.bytes : null,
1062
1388
  description,
1063
1389
  captureIndex,
1064
1390
  type,
@@ -1133,11 +1459,25 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
1133
1459
  `Page error detected: ${errMsg}. The page rendered an error UI instead of expected content.`
1134
1460
  );
1135
1461
  } else if (retryResult.status === "timeout") {
1462
+ const currentUrl = engine.page.url();
1136
1463
  console.log(
1137
1464
  chalk.yellow(
1138
- ` ⚠ Ready selector not found after ${retryResult.attempts} attempt(s), proceeding with current state`
1465
+ ` ⚠ Ready selector not found after ${retryResult.attempts} attempt(s): ${readySelector}`
1139
1466
  )
1140
1467
  );
1468
+ console.log(
1469
+ chalk.gray(` URL: ${currentUrl}`)
1470
+ );
1471
+ console.log(
1472
+ chalk.gray(
1473
+ ` Hint: The page loaded but this selector does not exist. Check your readySelector in reshot.config.json.`
1474
+ )
1475
+ );
1476
+ throw new Error(
1477
+ `Scenario readySelector "${readySelector}" not found after ${retryResult.attempts} attempt(s). ` +
1478
+ `The page loaded at ${currentUrl} but the selector does not exist. ` +
1479
+ `Update readySelector in reshot.config.json or remove it to skip this check.`
1480
+ );
1141
1481
  } else if (retryResult.attempts > 1) {
1142
1482
  console.log(
1143
1483
  chalk.green(
@@ -1203,6 +1543,13 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
1203
1543
 
1204
1544
  if (!elementExists) {
1205
1545
  skippedSteps++;
1546
+ if (!quiet) {
1547
+ console.log(
1548
+ chalk.dim(
1549
+ ` → ${action}(selector=${JSON.stringify(target)}) matched 0 elements (optional, skipped)`
1550
+ )
1551
+ );
1552
+ }
1206
1553
  continue;
1207
1554
  }
1208
1555
 
@@ -1253,6 +1600,11 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
1253
1600
  }
1254
1601
  }
1255
1602
 
1603
+ // Promote the current page URL after successful interactive steps so
1604
+ // retries restore the page we actually navigated to, not the last
1605
+ // explicit goto target.
1606
+ lastGotoUrl = promoteLastGotoUrl(lastGotoUrl, engine.page.url());
1607
+
1256
1608
  // Wait for animations/transitions - longer wait for multi-step flows
1257
1609
  const isMultiStep = script.length > 3;
1258
1610
  await engine.page.waitForTimeout(isMultiStep ? 500 : 150);
@@ -1296,6 +1648,12 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
1296
1648
  ` Hint: If data isn't loading, run 'reshot record' to refresh your session`
1297
1649
  )
1298
1650
  );
1651
+ failedSteps.push({
1652
+ stepIndex: stepIndex + 1,
1653
+ action: "waitFor",
1654
+ target: params.target,
1655
+ error: errMsg,
1656
+ });
1299
1657
  }
1300
1658
  } else if (waitResult.status === "timeout") {
1301
1659
  if (!isOptional) {
@@ -1309,9 +1667,14 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
1309
1667
  ` Hint: If content isn't loading, run 'reshot record' to refresh your session`
1310
1668
  )
1311
1669
  );
1670
+ failedSteps.push({
1671
+ stepIndex: stepIndex + 1,
1672
+ action: "waitFor",
1673
+ target: params.target,
1674
+ error: `Element not found within ${waitTimeout}ms`,
1675
+ });
1312
1676
  }
1313
1677
  }
1314
- // Continue with next steps - the scenario may still capture partial state
1315
1678
  continue;
1316
1679
  }
1317
1680
 
@@ -1407,7 +1770,17 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
1407
1770
  // Non-critical — don't fail the capture
1408
1771
  }
1409
1772
 
1410
- return { success: failedSteps.length === 0, assets, skippedSteps, duplicatesSkipped, failedSteps, retriedSteps, privacy: privacyMeta, style: styleMeta };
1773
+ return {
1774
+ success: failedSteps.length === 0,
1775
+ assets,
1776
+ skippedSteps,
1777
+ duplicatesSkipped,
1778
+ failedSteps,
1779
+ retriedSteps,
1780
+ privacy: privacyMeta,
1781
+ style: styleMeta,
1782
+ diagnostics: engine.getDiagnostics(),
1783
+ };
1411
1784
  })(); // End of scenarioExecution async IIFE
1412
1785
 
1413
1786
  // Race scenario execution against timeout
@@ -1436,14 +1809,22 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
1436
1809
  // Ignore
1437
1810
  }
1438
1811
 
1439
- return { success: false, error: error.message, assets, skippedSteps, failedSteps, retriedSteps };
1812
+ return {
1813
+ success: false,
1814
+ error: error.message,
1815
+ assets,
1816
+ skippedSteps,
1817
+ failedSteps,
1818
+ retriedSteps,
1819
+ diagnostics: engine.getDiagnostics(),
1820
+ };
1440
1821
  } finally {
1441
1822
  await engine.close();
1442
1823
  }
1443
1824
  }
1444
1825
 
1445
1826
  /**
1446
- * Capture screenshot with highlight box around element
1827
+ * Capture screenshot without interaction overlays.
1447
1828
  */
1448
1829
  async function captureWithHighlight(
1449
1830
  engine,
@@ -1451,63 +1832,12 @@ async function captureWithHighlight(
1451
1832
  outputPath,
1452
1833
  highlight = {}
1453
1834
  ) {
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
1835
  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
1836
  }
1507
1837
 
1508
1838
  /**
1509
1839
  * Run a scenario with video capture (summary-video format)
1510
- * Records the entire flow as a single video with optional highlights and subtitles
1840
+ * Records the entire flow as a single video without interaction overlays
1511
1841
  * Supports graceful handling of permission-restricted steps
1512
1842
  * Supports cropping for sentinel frames (same config as step-by-step-images)
1513
1843
  */
@@ -1518,21 +1848,36 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1518
1848
  headless = true,
1519
1849
  viewport = { width: 1280, height: 720 },
1520
1850
  variantsConfig = {}, // Global variant configuration (new format with dimensions)
1851
+ globalQuality = null,
1521
1852
  } = options;
1522
1853
 
1523
1854
  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
1855
  const subtitles = outputConfig.subtitles || { enabled: false };
1856
+ const videoFrameRate = Number(outputConfig.frameRate || 24);
1857
+ const typeDelayMs = Number(outputConfig.typeDelayMs || 20);
1529
1858
 
1530
1859
  // Extract crop configuration from scenario output settings
1531
1860
  // This persists across all variations and applies to sentinel frames
1532
1861
  const scenarioCropConfig = outputConfig.crop || null;
1862
+ const scenarioCaptureConfig = getCaptureConfig({
1863
+ retryOnError: scenario.retryOnError,
1864
+ readyTimeout: scenario.readyTimeout,
1865
+ scenarioTimeout: scenario.scenarioTimeout,
1866
+ errorSelectors: scenario.errorSelectors,
1867
+ });
1868
+ const forbidText = collectForbiddenText(globalQuality, scenario);
1533
1869
 
1534
1870
  // Resolve variant configuration using new universal variant system
1535
1871
  const variantConfig = resolveVariantConfig(scenario, variantsConfig);
1872
+ let readySelector = scenario.readySelector || null;
1873
+ if (!readySelector && scenario.steps) {
1874
+ const firstWaitFor = scenario.steps.find(
1875
+ (s) => s.action === "waitForSelector"
1876
+ );
1877
+ if (firstWaitFor) {
1878
+ readySelector = firstWaitFor.selector;
1879
+ }
1880
+ }
1536
1881
 
1537
1882
  // Resolve privacy configuration for video (CSS masking persists through entire video)
1538
1883
  const videoPrivacyConfig = config.getPrivacyConfig(scenario.privacy);
@@ -1595,8 +1940,10 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1595
1940
  // Check for saved session state (auth cookies) - CRITICAL for authenticated scenarios
1596
1941
  const sessionPath = getDefaultSessionPath();
1597
1942
  const hasSession = fs.existsSync(sessionPath);
1598
- if (hasSession) {
1599
- // Validate session freshness
1943
+ if (hasSession && scenario.requiresAuth) {
1944
+ // Validate session freshness. Only relevant when this scenario actually
1945
+ // requires auth — a leftover session file should not trigger staleness
1946
+ // warnings for public scenarios.
1600
1947
  const sessionStats = fs.statSync(sessionPath);
1601
1948
  const sessionAgeHours = (Date.now() - sessionStats.mtimeMs) / (1000 * 60 * 60);
1602
1949
  if (sessionAgeHours > 24) {
@@ -1609,6 +1956,7 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1609
1956
  }
1610
1957
 
1611
1958
  const { chromium } = require("playwright");
1959
+ const { launchChromium } = require("./ensure-browser");
1612
1960
  // Use a unique temp directory for this recording to avoid conflicts
1613
1961
  const recordingId = `recording-${Date.now()}-${Math.random()
1614
1962
  .toString(36)
@@ -1635,7 +1983,7 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1635
1983
  debug("Launching browser...");
1636
1984
 
1637
1985
  // Launch browser with video recording
1638
- browser = await chromium.launch(buildLaunchOptions({ headless }));
1986
+ browser = await launchChromium(chromium, buildLaunchOptions({ headless }));
1639
1987
  debug("Browser launched successfully");
1640
1988
 
1641
1989
  // Build context options with variant support using universal injector
@@ -1684,6 +2032,7 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1684
2032
  debug("Browser context created");
1685
2033
  page = await context.newPage();
1686
2034
  debug("Page created");
2035
+ await installVisibleCursor(page);
1687
2036
 
1688
2037
  // CRITICAL: Hide development overlays (Next.js devtools, Vercel toolbar, etc.)
1689
2038
  // This prevents dev tools from intercepting clicks during video capture
@@ -1803,10 +2152,11 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1803
2152
  storeState.activeWorkspace = { id: ws.id, name: ws.name, slug: ws.slug };
1804
2153
  }
1805
2154
 
2155
+ const storePrefixes = ["reshot-store-", "workspace-store-"];
1806
2156
  let found = false;
1807
2157
  for (let i = 0; i < localStorage.length; i++) {
1808
2158
  const key = localStorage.key(i);
1809
- if (key && key.startsWith("workspace-store-")) {
2159
+ if (key && storePrefixes.some((prefix) => key.startsWith(prefix))) {
1810
2160
  try {
1811
2161
  const data = JSON.parse(localStorage.getItem(key) || "{}");
1812
2162
  data.state = { ...data.state, ...storeState };
@@ -1818,7 +2168,7 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1818
2168
  }
1819
2169
  if (!found) {
1820
2170
  localStorage.setItem(
1821
- "workspace-store-1",
2171
+ "reshot-store-workspace",
1822
2172
  JSON.stringify({ state: storeState, version: 0 })
1823
2173
  );
1824
2174
  }
@@ -1842,6 +2192,14 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1842
2192
  let sentinelIndex = 0;
1843
2193
  let hasAppliedStorageReload = false; // Track if we've reloaded for localStorage
1844
2194
 
2195
+ async function moveMouseToBox(box) {
2196
+ if (!box) return;
2197
+ const x = Math.round(box.x + box.width / 2);
2198
+ const y = Math.round(box.y + box.height / 2);
2199
+ await page.mouse.move(x, y, { steps: 18 });
2200
+ await page.waitForTimeout(120);
2201
+ }
2202
+
1845
2203
  /**
1846
2204
  * Capture a sentinel frame (full page screenshot)
1847
2205
  * Applies scenario-level cropping if configured
@@ -1931,12 +2289,17 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1931
2289
 
1932
2290
  await fs.writeFile(sentinelPath, buffer);
1933
2291
  sentinelPaths.push({ index: sentinelIndex, label, path: sentinelPath });
2292
+ if (firstSentinelTimestamp === null) {
2293
+ firstSentinelTimestamp = (Date.now() - startTime) / 1000;
2294
+ debug(`First sentinel captured at ${firstSentinelTimestamp.toFixed(2)}s`);
2295
+ }
1934
2296
  sentinelIndex++;
1935
2297
  return sentinelPath;
1936
2298
  }
1937
2299
 
1938
2300
  // Capture initial state BEFORE first navigation (placeholder - actual capture after goto)
1939
2301
  let hasNavigated = false;
2302
+ let firstSentinelTimestamp = null;
1940
2303
 
1941
2304
  // Execute all steps and capture timeline
1942
2305
  for (let stepIndex = 0; stepIndex < script.length; stepIndex++) {
@@ -1977,14 +2340,61 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1977
2340
  } catch (e) {
1978
2341
  // Okay if timeout
1979
2342
  }
1980
- await page.waitForTimeout(800); // Extra time for i18n/translations to render
2343
+ await page.waitForTimeout(300); // Extra time for i18n/translations to render
2344
+ await waitForLoadingComplete(page, 5000);
2345
+
2346
+ if (readySelector) {
2347
+ let readyError = null;
2348
+ const maxAttempts = 1 + scenarioCaptureConfig.retryOnError;
2349
+
2350
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
2351
+ try {
2352
+ await page.locator(readySelector).first().waitFor({
2353
+ state: "visible",
2354
+ timeout: scenarioCaptureConfig.readyTimeout,
2355
+ });
2356
+ readyError = null;
2357
+ break;
2358
+ } catch (error) {
2359
+ readyError = error;
2360
+ if (attempt === maxAttempts) {
2361
+ break;
2362
+ }
2363
+
2364
+ const delay =
2365
+ scenarioCaptureConfig.retryDelay * Math.pow(2, attempt - 1);
2366
+ console.log(
2367
+ chalk.yellow(
2368
+ ` ⚠ Attempt ${attempt}/${maxAttempts} failed (ready selector timeout). Retrying in ${delay}ms...`
2369
+ )
2370
+ );
2371
+ await page.waitForTimeout(delay);
2372
+ await page.reload({ waitUntil: "domcontentloaded" });
2373
+ await page.waitForTimeout(300);
2374
+ await waitForLoadingComplete(page, 5000);
2375
+ }
2376
+ }
2377
+
2378
+ if (readyError) {
2379
+ const currentUrl = page.url();
2380
+ throw new Error(
2381
+ `Scenario readySelector "${readySelector}" not found in video capture mode at ${currentUrl}. ${
2382
+ readyError instanceof Error ? readyError.message : String(readyError)
2383
+ }`
2384
+ );
2385
+ }
2386
+ }
2387
+
2388
+ await assertForbiddenTextAbsent(page, forbidText);
1981
2389
 
1982
2390
  // Re-inject workspace store after navigation to handle Zustand hydration resets
1983
2391
  if (_activeProjectId) {
1984
2392
  await page.evaluate(({ pid, ws }) => {
2393
+ const storePrefixes = ["reshot-store-", "workspace-store-"];
2394
+ let foundKey = null;
1985
2395
  for (let i = 0; i < localStorage.length; i++) {
1986
2396
  const key = localStorage.key(i);
1987
- if (key && key.startsWith("workspace-store-")) {
2397
+ if (key && storePrefixes.some((prefix) => key.startsWith(prefix))) {
1988
2398
  try {
1989
2399
  const data = JSON.parse(localStorage.getItem(key) || "{}");
1990
2400
  if (data.state) {
@@ -1992,11 +2402,12 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1992
2402
  if (ws) data.state.activeWorkspace = data.state.activeWorkspace || { id: ws.id, name: ws.name, slug: ws.slug };
1993
2403
  data.version = data.version ?? 0;
1994
2404
  localStorage.setItem(key, JSON.stringify(data));
2405
+ foundKey = key;
1995
2406
  }
1996
2407
  } catch (e) {}
1997
2408
  }
1998
2409
  }
1999
- window.dispatchEvent(new StorageEvent("storage", { key: null }));
2410
+ window.dispatchEvent(new StorageEvent("storage", { key: foundKey || "reshot-store-workspace" }));
2000
2411
  }, { pid: _activeProjectId, ws: _activeWorkspace });
2001
2412
  }
2002
2413
 
@@ -2045,43 +2456,13 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2045
2456
  await element.waitFor({ state: "visible", timeout: clickTimeout });
2046
2457
  const box = await element.boundingBox();
2047
2458
 
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
-
2459
+ await moveMouseToBox(box);
2074
2460
  await element.click();
2075
2461
 
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
2462
  events.push({
2083
2463
  action: "click",
2084
2464
  timestamp,
2465
+ target,
2085
2466
  subtitle: subtitles.enabled ? `Click on ${target}` : "",
2086
2467
  elementBox: box,
2087
2468
  });
@@ -2091,6 +2472,11 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2091
2472
  // Capture sentinel after click
2092
2473
  await captureSentinel(`after-click-${stepIndex}`);
2093
2474
  } catch (e) {
2475
+ if (!isOptional) {
2476
+ throw new Error(
2477
+ `Required click target not found: ${target}. ${e instanceof Error ? e.message : String(e)}`
2478
+ );
2479
+ }
2094
2480
  console.warn(
2095
2481
  chalk.yellow(` ⚠ Could not click ${target}: ${e.message}`)
2096
2482
  );
@@ -2114,41 +2500,14 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2114
2500
  await element.waitFor({ state: "visible", timeout: typeTimeout });
2115
2501
  const box = await element.boundingBox();
2116
2502
 
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
-
2503
+ await moveMouseToBox(box);
2140
2504
  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
- });
2505
+ await element.type(text, { delay: typeDelayMs }); // Visible typing effect
2148
2506
 
2149
2507
  events.push({
2150
2508
  action: "type",
2151
2509
  timestamp,
2510
+ target,
2152
2511
  subtitle: subtitles.enabled ? `Entering "${text}"` : "",
2153
2512
  elementBox: box,
2154
2513
  });
@@ -2158,6 +2517,11 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2158
2517
  // Capture sentinel after type
2159
2518
  await captureSentinel(`after-type-${stepIndex}`);
2160
2519
  } catch (e) {
2520
+ if (!isOptional) {
2521
+ throw new Error(
2522
+ `Required input target not found: ${target}. ${e instanceof Error ? e.message : String(e)}`
2523
+ );
2524
+ }
2161
2525
  console.warn(
2162
2526
  chalk.yellow(` ⚠ Could not type into ${target}: ${e.message}`)
2163
2527
  );
@@ -2180,8 +2544,9 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2180
2544
  });
2181
2545
  } catch (e) {
2182
2546
  if (!isOptional) {
2183
- console.warn(
2184
- chalk.yellow(` ⚠ Wait for ${params.target} timed out`)
2547
+ const currentUrl = page.url();
2548
+ throw new Error(
2549
+ `Required selector not found in video capture: ${params.target} (URL: ${currentUrl}, timeout: ${waitTimeout}ms)`
2185
2550
  );
2186
2551
  }
2187
2552
  }
@@ -2199,11 +2564,18 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2199
2564
  try {
2200
2565
  const element = await page.locator(params.target).first();
2201
2566
  await element.waitFor({ state: "visible", timeout: hoverTimeout });
2567
+ const box = await element.boundingBox();
2568
+ await moveMouseToBox(box);
2202
2569
  await element.hover();
2203
2570
  await page.waitForTimeout(300);
2204
2571
  // Capture sentinel after hover (state may have changed with tooltips/dropdowns)
2205
2572
  await captureSentinel(`after-hover-${stepIndex}`);
2206
2573
  } catch (e) {
2574
+ if (!isOptional) {
2575
+ throw new Error(
2576
+ `Required hover target not found: ${params.target}. ${e instanceof Error ? e.message : String(e)}`
2577
+ );
2578
+ }
2207
2579
  console.warn(
2208
2580
  chalk.yellow(` ⚠ Could not hover ${params.target}: ${e.message}`)
2209
2581
  );
@@ -2292,26 +2664,40 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2292
2664
  )
2293
2665
  );
2294
2666
 
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...`);
2667
+ // Convert to MP4 with ffmpeg, trimming blank loading frames from start
2668
+ // and excess frames from end
2669
+ const startOffset = Math.max(0, (firstSentinelTimestamp || 0) - 0.3);
2670
+ const endTimestamp = finalTimestamp + 0.25;
2671
+ const contentDuration = endTimestamp - startOffset;
2672
+ if (startOffset > 0) {
2673
+ console.log(
2674
+ chalk.cyan(
2675
+ ` 📹 Converting to MP4 (${startOffset.toFixed(1)}s–${endTimestamp.toFixed(1)}s, ${contentDuration.toFixed(1)}s content)...`
2676
+ )
2677
+ );
2678
+ } else {
2679
+ console.log(
2680
+ chalk.cyan(
2681
+ ` 📹 Converting to MP4 (trimmed to ${contentDuration.toFixed(1)}s)...`
2682
+ )
2683
+ );
2684
+ }
2685
+ debug(`Running ffmpeg: start=${startOffset}s, duration=${contentDuration}s`);
2304
2686
  await runFFmpegConvert([
2687
+ "-ss",
2688
+ startOffset.toFixed(2),
2305
2689
  "-i",
2306
2690
  recordedVideoPath,
2307
2691
  "-t",
2308
- trimDuration.toFixed(2),
2692
+ contentDuration.toFixed(2),
2309
2693
  "-c:v",
2310
2694
  "libx264",
2311
2695
  "-preset",
2312
2696
  "fast",
2313
2697
  "-pix_fmt",
2314
2698
  "yuv420p",
2699
+ "-r",
2700
+ String(videoFrameRate),
2315
2701
  "-movflags",
2316
2702
  "+faststart",
2317
2703
  "-y",
@@ -2330,33 +2716,6 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2330
2716
  )
2331
2717
  );
2332
2718
 
2333
- // Extract poster frame from the video (first frame at 0.5s for non-blank content)
2334
- const posterPath = finalVideoPath.replace(/\.mp4$/, "-poster.png");
2335
- try {
2336
- await runFFmpegConvert([
2337
- "-i",
2338
- finalVideoPath,
2339
- "-ss",
2340
- "0.5",
2341
- "-frames:v",
2342
- "1",
2343
- "-q:v",
2344
- "2",
2345
- "-y",
2346
- posterPath,
2347
- ]);
2348
- if (fs.existsSync(posterPath)) {
2349
- const posterSize = fs.statSync(posterPath).size;
2350
- console.log(
2351
- chalk.green(
2352
- ` ✔ Poster frame: ${posterPath} (${(posterSize / 1024).toFixed(1)} KB)`
2353
- )
2354
- );
2355
- }
2356
- } catch (e) {
2357
- debug(`Poster frame extraction failed: ${e.message}`);
2358
- }
2359
-
2360
2719
  // Save timeline for reference
2361
2720
  const timelinePath = path.join(
2362
2721
  outputDir || path.join(".reshot/output", scenario.key, "default"),
@@ -2381,6 +2740,14 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2381
2740
  );
2382
2741
  debug(`Sentinel manifest saved to: ${sentinelManifestPath}`);
2383
2742
 
2743
+ const metadataPath = path.join(actualOutputDir, "summary-video.metadata.json");
2744
+ fs.writeJSONSync(
2745
+ metadataPath,
2746
+ buildVideoMetadata(events, sentinelPaths, viewport, videoFrameRate),
2747
+ { spaces: 2 },
2748
+ );
2749
+ debug(`Video metadata saved to: ${metadataPath}`);
2750
+
2384
2751
  // Cleanup temp directory (unique per recording)
2385
2752
  try {
2386
2753
  fs.removeSync(tempDir);
@@ -2398,6 +2765,7 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2398
2765
  path: finalVideoPath,
2399
2766
  type: "video",
2400
2767
  duration: (Date.now() - startTime) / 1000,
2768
+ metadataPath,
2401
2769
  },
2402
2770
  ],
2403
2771
  sentinels: sentinelPaths.map((s) => ({
@@ -2496,6 +2864,7 @@ async function runScenarioWithEngine(scenario, options = {}) {
2496
2864
  timeout = 30000,
2497
2865
  variantsConfig = {}, // Universal variant configuration
2498
2866
  storageStateData = null,
2867
+ globalQuality = null,
2499
2868
  quiet = false,
2500
2869
  } = options;
2501
2870
 
@@ -2506,6 +2875,7 @@ async function runScenarioWithEngine(scenario, options = {}) {
2506
2875
  return runScenarioWithStepByStepCapture(scenario, {
2507
2876
  ...options,
2508
2877
  variantsConfig,
2878
+ globalQuality,
2509
2879
  });
2510
2880
  }
2511
2881
 
@@ -2520,6 +2890,7 @@ async function runScenarioWithEngine(scenario, options = {}) {
2520
2890
  // Legacy behavior: only capture explicit screenshot steps
2521
2891
  // Resolve variant configuration for this scenario
2522
2892
  const variantConfig = resolveVariantConfig(scenario, variantsConfig);
2893
+ const forbidText = collectForbiddenText(globalQuality, scenario);
2523
2894
 
2524
2895
  // Extract crop configuration from scenario output settings
2525
2896
  const outputConfig = scenario.output || {};
@@ -2572,18 +2943,20 @@ async function runScenarioWithEngine(scenario, options = {}) {
2572
2943
  storageStatePath: hasSession ? sessionPath : null, // Use saved session if available
2573
2944
  storageStateData, // Pre-loaded auth state
2574
2945
  hideDevtools: true, // Always hide dev overlays in captures
2946
+ injectWorkspaceStore: scenario.needsWorkspaceInjection !== false,
2575
2947
  logger: quiet ? () => {} : (msg) => console.log(msg),
2576
2948
  });
2577
2949
 
2578
2950
  try {
2579
2951
  await engine.init();
2952
+ await assertForbiddenTextAbsent(engine.page, forbidText);
2580
2953
  const assets = await engine.runScript(script);
2581
2954
 
2582
2955
  if (!quiet) console.log(
2583
2956
  chalk.green(`\n ✔ Scenario completed: ${assets.length} assets captured`)
2584
2957
  );
2585
2958
 
2586
- return { success: true, assets };
2959
+ return { success: true, assets, diagnostics: engine.getDiagnostics() };
2587
2960
  } catch (error) {
2588
2961
  console.error(chalk.red(`\n ❌ Scenario failed: ${error.message}`));
2589
2962
 
@@ -2603,7 +2976,7 @@ async function runScenarioWithEngine(scenario, options = {}) {
2603
2976
  // Ignore screenshot errors
2604
2977
  }
2605
2978
 
2606
- return { success: false, error: error.message };
2979
+ return { success: false, error: error.message, diagnostics: engine.getDiagnostics() };
2607
2980
  } finally {
2608
2981
  await engine.close();
2609
2982
  }
@@ -2791,6 +3164,47 @@ function detectOptimalConcurrency() {
2791
3164
  return Math.max(1, optimal);
2792
3165
  }
2793
3166
 
3167
+ function normalizeAuthPreflightTargets(value) {
3168
+ if (Array.isArray(value)) {
3169
+ return value.map((item) => String(item || "").trim()).filter(Boolean);
3170
+ }
3171
+
3172
+ const normalized = String(value || "").trim();
3173
+ return normalized ? [normalized] : [];
3174
+ }
3175
+
3176
+ function resolveAuthPreflightTargets(config, options = {}) {
3177
+ const { scenarioKeys = null } = options;
3178
+ const scenarios = config.scenarios || [];
3179
+ const selectedScenarios =
3180
+ Array.isArray(scenarioKeys) && scenarioKeys.length > 0
3181
+ ? scenarios.filter((scenario) => scenarioKeys.includes(scenario.key))
3182
+ : scenarios;
3183
+ const liveAuthScenarios = selectedScenarios.filter(
3184
+ (scenario) => scenario.captureClass === "live-auth",
3185
+ );
3186
+ const configuredTargets = normalizeAuthPreflightTargets(
3187
+ config.target?.authPreflightUrls || config.target?.authPreflightUrl,
3188
+ );
3189
+ const scenarioTargets = liveAuthScenarios
3190
+ .map((scenario) => scenario.authPreflightUrl || scenario.url)
3191
+ .filter(Boolean);
3192
+ const targets = Array.from(
3193
+ new Set([
3194
+ ...configuredTargets,
3195
+ ...scenarioTargets,
3196
+ ]),
3197
+ );
3198
+
3199
+ return {
3200
+ selectedScenarioKeys: selectedScenarios.map((scenario) => scenario.key),
3201
+ liveAuthScenarioKeys: liveAuthScenarios.map((scenario) => scenario.key),
3202
+ targets: targets.length > 0 || liveAuthScenarios.length === 0
3203
+ ? targets
3204
+ : ["/app/projects"],
3205
+ };
3206
+ }
3207
+
2794
3208
  /**
2795
3209
  * Run all scenarios from config
2796
3210
  */
@@ -2806,6 +3220,17 @@ async function runAllScenarios(config, options = {}) {
2806
3220
 
2807
3221
  console.log(chalk.cyan("🎬 Running capture scenarios...\n"));
2808
3222
 
3223
+ const scenarios = config.scenarios || [];
3224
+ const toRun =
3225
+ scenarioKeys?.length > 0
3226
+ ? scenarios.filter((scenario) => scenarioKeys.includes(scenario.key))
3227
+ : scenarios;
3228
+
3229
+ if (toRun.length === 0) {
3230
+ console.log(chalk.yellow("No scenarios to run"));
3231
+ return { success: true, results: [] };
3232
+ }
3233
+
2809
3234
  // Auto-sync session from CDP browser if available
2810
3235
  // This allows captures to use the authenticated session from a running Chrome instance
2811
3236
  try {
@@ -2847,10 +3272,10 @@ async function runAllScenarios(config, options = {}) {
2847
3272
 
2848
3273
  // Run auth pre-flight check if any scenario requires auth
2849
3274
  const captureConfig = getCaptureConfig(config.capture || {});
2850
- const scenarios = config.scenarios || [];
2851
- const hasAuthScenarios = scenarios.some((s) => s.requiresAuth);
3275
+ const authPreflight = resolveAuthPreflightTargets(config, { scenarioKeys });
3276
+ const hasLiveAuthScenarios = authPreflight.liveAuthScenarioKeys.length > 0;
2852
3277
 
2853
- if (captureConfig.preflightCheck && hasAuthScenarios) {
3278
+ if (captureConfig.preflightCheck && hasLiveAuthScenarios) {
2854
3279
  const sessionPath = getDefaultSessionPath();
2855
3280
  const hasSession = fs.existsSync(sessionPath);
2856
3281
  if (hasSession) {
@@ -2859,6 +3284,7 @@ async function runAllScenarios(config, options = {}) {
2859
3284
  {
2860
3285
  storageStatePath: sessionPath,
2861
3286
  viewport: config.viewport || { width: 1280, height: 720 },
3287
+ authCheckUrl: authPreflight.targets,
2862
3288
  }
2863
3289
  );
2864
3290
  if (!preflightResult.ok) {
@@ -2868,17 +3294,6 @@ async function runAllScenarios(config, options = {}) {
2868
3294
  }
2869
3295
  }
2870
3296
 
2871
- // Filter scenarios if keys provided
2872
- const toRun =
2873
- scenarioKeys?.length > 0
2874
- ? scenarios.filter((s) => scenarioKeys.includes(s.key))
2875
- : scenarios;
2876
-
2877
- if (toRun.length === 0) {
2878
- console.log(chalk.yellow("No scenarios to run"));
2879
- return { success: true, results: [] };
2880
- }
2881
-
2882
3297
  // Use shared timestamp if provided (for variant expansion), otherwise generate new one
2883
3298
  const runTimestamp = sharedTimestamp || generateVersionTimestamp();
2884
3299
 
@@ -2974,6 +3389,7 @@ async function runAllScenarios(config, options = {}) {
2974
3389
  runTimestamp, // Pass timestamp for templating
2975
3390
  storageStateData: ssData,
2976
3391
  quiet,
3392
+ globalQuality: config.quality || null,
2977
3393
  noPrivacy: options.noPrivacy,
2978
3394
  noStyle: options.noStyle,
2979
3395
  });
@@ -3126,9 +3542,11 @@ async function runAllScenarios(config, options = {}) {
3126
3542
 
3127
3543
  module.exports = {
3128
3544
  convertLegacySteps,
3545
+ substituteUrlVariables,
3129
3546
  runScenarioWithEngine,
3130
3547
  runScenarioWithStepByStepCapture,
3131
3548
  runScenarioWithVideoCapture,
3549
+ buildVideoMetadata,
3132
3550
  captureWithHighlight,
3133
3551
  checkFFmpeg,
3134
3552
  runAllScenarios,
@@ -3137,11 +3555,18 @@ module.exports = {
3137
3555
  waitForVisualStability,
3138
3556
  // Error detection & retry
3139
3557
  retryInteractiveStep,
3558
+ promoteLastGotoUrl,
3140
3559
  executeWithRetry,
3141
3560
  preflightAuthCheck,
3561
+ resolveAuthPreflightTargets,
3562
+ collectForbiddenText,
3563
+ assertForbiddenTextAbsent,
3564
+ normalizeVisibleText,
3142
3565
  // New exports for output templating
3143
3566
  resolveScenarioOutputDir,
3144
3567
  generateVersionTimestamp,
3145
3568
  // Concurrency
3146
3569
  detectOptimalConcurrency,
3570
+ installVisibleCursor,
3571
+ normalizeVideoTargetName,
3147
3572
  };