@reshotdev/screenshot 0.0.1-beta.12 → 0.0.1-beta.13

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 (60) hide show
  1. package/README.md +67 -22
  2. package/package.json +18 -14
  3. package/src/commands/auth.js +37 -7
  4. package/src/commands/capture-dom.js +50 -0
  5. package/src/commands/compose.js +220 -0
  6. package/src/commands/doctor-target.js +36 -4
  7. package/src/commands/drifts.js +13 -1
  8. package/src/commands/publish.js +137 -12
  9. package/src/commands/pull.js +9 -4
  10. package/src/commands/refresh.js +166 -0
  11. package/src/commands/setup-wizard.js +35 -2
  12. package/src/commands/status.js +22 -2
  13. package/src/commands/variation.js +194 -0
  14. package/src/index.js +187 -9
  15. package/src/lib/api-client.js +61 -35
  16. package/src/lib/auto-update/refresh.js +598 -0
  17. package/src/lib/auto-update/scene-runtime.compose.tsx +73 -0
  18. package/src/lib/auto-update/spec.js +89 -0
  19. package/src/lib/capture-engine.js +73 -0
  20. package/src/lib/capture-script-runner.js +280 -134
  21. package/src/lib/certification.js +23 -1
  22. package/src/lib/compose-context.js +156 -0
  23. package/src/lib/compose-pack.js +42 -0
  24. package/src/lib/compose-runtime.js +34 -0
  25. package/src/lib/compose-upload.js +142 -0
  26. package/src/lib/config.js +2 -2
  27. package/src/lib/dom-capture.js +64 -0
  28. package/src/lib/record-clip.js +83 -3
  29. package/src/lib/record-config.js +0 -4
  30. package/src/lib/resolve-targets.js +60 -0
  31. package/src/lib/run-manifest.js +45 -0
  32. package/src/lib/ui-api-helpers.js +118 -0
  33. package/src/lib/ui-api.js +28 -820
  34. package/src/lib/ui-asset-cleanup.js +62 -0
  35. package/src/lib/ui-output-versions.js +165 -0
  36. package/src/lib/ui-recorder-routes.js +341 -0
  37. package/src/lib/ui-scenario-metadata.js +161 -0
  38. package/vendor/compose/dist/auto-update.cjs +5544 -0
  39. package/vendor/compose/dist/auto-update.mjs +5518 -0
  40. package/vendor/compose/dist/capture.cjs +1450 -0
  41. package/vendor/compose/dist/capture.mjs +1416 -0
  42. package/vendor/compose/dist/eligibility.cjs +5331 -0
  43. package/vendor/compose/dist/eligibility.mjs +5313 -0
  44. package/vendor/compose/dist/index.cjs +2046 -0
  45. package/vendor/compose/dist/index.mjs +1997 -0
  46. package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
  47. package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
  48. package/vendor/compose/dist/jsx-runtime.cjs +58 -0
  49. package/vendor/compose/dist/jsx-runtime.mjs +31 -0
  50. package/vendor/compose/dist/render.cjs +558 -0
  51. package/vendor/compose/dist/render.mjs +515 -0
  52. package/vendor/compose/dist/verify-cli.cjs +3806 -0
  53. package/vendor/compose/dist/verify-cli.mjs +3812 -0
  54. package/vendor/compose/dist/verify.cjs +3880 -0
  55. package/vendor/compose/dist/verify.mjs +3858 -0
  56. package/web/manager/dist/assets/{index-CvleJUur.js → index-D0S2otug.js} +56 -56
  57. package/web/manager/dist/index.html +1 -1
  58. package/src/commands/ingest.js +0 -458
  59. package/src/commands/setup.js +0 -165
  60. package/src/lib/playwright-runner.js +0 -252
@@ -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,
@@ -137,6 +168,83 @@ function debug(...args) {
137
168
  }
138
169
  }
139
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
+
140
248
  function normalizeVisibleText(value) {
141
249
  return String(value || "")
142
250
  .replace(/\s+/g, " ")
@@ -442,6 +550,28 @@ async function preflightAuthCheck(baseUrl, options = {}) {
442
550
  await engine.page.waitForTimeout(3000);
443
551
  await engine._waitForStability();
444
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
+
445
575
  // Check for error state
446
576
  const errorState = await engine._detectErrorState();
447
577
  if (errorState.hasError) {
@@ -965,7 +1095,18 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
965
1095
  const sessionPath = getDefaultSessionPath();
966
1096
  const hasSession = fs.existsSync(sessionPath);
967
1097
  if (!quiet) {
1098
+ let sessionIsEmpty = false;
968
1099
  if (hasSession) {
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) {
969
1110
  // Validate session freshness with graduated warnings
970
1111
  const sessionStats = fs.statSync(sessionPath);
971
1112
  const sessionAgeHours =
@@ -1230,9 +1371,18 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
1230
1371
  await fs.writeFile(filePath, buffer);
1231
1372
  lastScreenshotHash = currentHash;
1232
1373
 
1374
+ // Capture sidecar MHTML so variations can be rendered from the
1375
+ // captured DOM (one CDP call; failures are non-fatal).
1376
+ const domScene = await captureDomScene(engine.page, filePath, {
1377
+ enabled: scenario.domScene !== false && options.domScene !== false,
1378
+ logger: quiet ? () => {} : (msg) => console.log(chalk.gray(msg)),
1379
+ });
1380
+
1233
1381
  const asset = {
1234
1382
  name,
1235
1383
  path: filePath,
1384
+ domScenePath: domScene ? domScene.path : null,
1385
+ domSceneBytes: domScene ? domScene.bytes : null,
1236
1386
  description,
1237
1387
  captureIndex,
1238
1388
  type,
@@ -1391,6 +1541,13 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
1391
1541
 
1392
1542
  if (!elementExists) {
1393
1543
  skippedSteps++;
1544
+ if (!quiet) {
1545
+ console.log(
1546
+ chalk.dim(
1547
+ ` → ${action}(selector=${JSON.stringify(target)}) matched 0 elements (optional, skipped)`
1548
+ )
1549
+ );
1550
+ }
1394
1551
  continue;
1395
1552
  }
1396
1553
 
@@ -1665,7 +1822,7 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
1665
1822
  }
1666
1823
 
1667
1824
  /**
1668
- * Capture screenshot with highlight box around element
1825
+ * Capture screenshot without interaction overlays.
1669
1826
  */
1670
1827
  async function captureWithHighlight(
1671
1828
  engine,
@@ -1673,63 +1830,12 @@ async function captureWithHighlight(
1673
1830
  outputPath,
1674
1831
  highlight = {}
1675
1832
  ) {
1676
- const { color = "rgba(255, 255, 0, 0.5)", style = "box" } = highlight;
1677
-
1678
- // Try to find the element
1679
- const element = await engine._findElement(target, {
1680
- mustBeVisible: false,
1681
- timeout: 2000,
1682
- });
1683
- const box = await element.boundingBox();
1684
-
1685
- if (box) {
1686
- // Inject highlight overlay
1687
- await engine.page.evaluate(
1688
- ({ box, color, style }) => {
1689
- const existingHighlight = document.getElementById("reshot-highlight");
1690
- if (existingHighlight) existingHighlight.remove();
1691
-
1692
- const div = document.createElement("div");
1693
- div.id = "reshot-highlight";
1694
- div.style.cssText = `
1695
- position: fixed;
1696
- left: ${box.x}px;
1697
- top: ${box.y}px;
1698
- width: ${box.width}px;
1699
- height: ${box.height}px;
1700
- background: ${style === "box" ? color : "transparent"};
1701
- border: ${
1702
- style === "outline"
1703
- ? `3px solid ${color.replace("0.5", "1")}`
1704
- : "none"
1705
- };
1706
- pointer-events: none;
1707
- z-index: 999999;
1708
- box-sizing: border-box;
1709
- border-radius: 4px;
1710
- `;
1711
- document.body.appendChild(div);
1712
- },
1713
- { box, color, style }
1714
- );
1715
-
1716
- // Wait for highlight to render
1717
- await engine.page.waitForTimeout(50);
1718
- }
1719
-
1720
- // Capture screenshot
1721
1833
  await engine.page.screenshot({ path: outputPath });
1722
-
1723
- // Remove highlight overlay
1724
- await engine.page.evaluate(() => {
1725
- const highlight = document.getElementById("reshot-highlight");
1726
- if (highlight) highlight.remove();
1727
- });
1728
1834
  }
1729
1835
 
1730
1836
  /**
1731
1837
  * Run a scenario with video capture (summary-video format)
1732
- * Records the entire flow as a single video with optional highlights and subtitles
1838
+ * Records the entire flow as a single video without interaction overlays
1733
1839
  * Supports graceful handling of permission-restricted steps
1734
1840
  * Supports cropping for sentinel frames (same config as step-by-step-images)
1735
1841
  */
@@ -1740,21 +1846,36 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1740
1846
  headless = true,
1741
1847
  viewport = { width: 1280, height: 720 },
1742
1848
  variantsConfig = {}, // Global variant configuration (new format with dimensions)
1849
+ globalQuality = null,
1743
1850
  } = options;
1744
1851
 
1745
1852
  const outputConfig = scenario.output || { format: "summary-video" };
1746
- const highlight = outputConfig.highlight || {
1747
- color: "rgba(255, 255, 0, 0.5)",
1748
- style: "box",
1749
- };
1750
1853
  const subtitles = outputConfig.subtitles || { enabled: false };
1854
+ const videoFrameRate = Number(outputConfig.frameRate || 24);
1855
+ const typeDelayMs = Number(outputConfig.typeDelayMs || 20);
1751
1856
 
1752
1857
  // Extract crop configuration from scenario output settings
1753
1858
  // This persists across all variations and applies to sentinel frames
1754
1859
  const scenarioCropConfig = outputConfig.crop || null;
1860
+ const scenarioCaptureConfig = getCaptureConfig({
1861
+ retryOnError: scenario.retryOnError,
1862
+ readyTimeout: scenario.readyTimeout,
1863
+ scenarioTimeout: scenario.scenarioTimeout,
1864
+ errorSelectors: scenario.errorSelectors,
1865
+ });
1866
+ const forbidText = collectForbiddenText(globalQuality, scenario);
1755
1867
 
1756
1868
  // Resolve variant configuration using new universal variant system
1757
1869
  const variantConfig = resolveVariantConfig(scenario, variantsConfig);
1870
+ let readySelector = scenario.readySelector || null;
1871
+ if (!readySelector && scenario.steps) {
1872
+ const firstWaitFor = scenario.steps.find(
1873
+ (s) => s.action === "waitForSelector"
1874
+ );
1875
+ if (firstWaitFor) {
1876
+ readySelector = firstWaitFor.selector;
1877
+ }
1878
+ }
1758
1879
 
1759
1880
  // Resolve privacy configuration for video (CSS masking persists through entire video)
1760
1881
  const videoPrivacyConfig = config.getPrivacyConfig(scenario.privacy);
@@ -1906,6 +2027,7 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1906
2027
  debug("Browser context created");
1907
2028
  page = await context.newPage();
1908
2029
  debug("Page created");
2030
+ await installVisibleCursor(page);
1909
2031
 
1910
2032
  // CRITICAL: Hide development overlays (Next.js devtools, Vercel toolbar, etc.)
1911
2033
  // This prevents dev tools from intercepting clicks during video capture
@@ -2025,10 +2147,11 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2025
2147
  storeState.activeWorkspace = { id: ws.id, name: ws.name, slug: ws.slug };
2026
2148
  }
2027
2149
 
2150
+ const storePrefixes = ["reshot-store-", "workspace-store-"];
2028
2151
  let found = false;
2029
2152
  for (let i = 0; i < localStorage.length; i++) {
2030
2153
  const key = localStorage.key(i);
2031
- if (key && key.startsWith("workspace-store-")) {
2154
+ if (key && storePrefixes.some((prefix) => key.startsWith(prefix))) {
2032
2155
  try {
2033
2156
  const data = JSON.parse(localStorage.getItem(key) || "{}");
2034
2157
  data.state = { ...data.state, ...storeState };
@@ -2040,7 +2163,7 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2040
2163
  }
2041
2164
  if (!found) {
2042
2165
  localStorage.setItem(
2043
- "workspace-store-1",
2166
+ "reshot-store-workspace",
2044
2167
  JSON.stringify({ state: storeState, version: 0 })
2045
2168
  );
2046
2169
  }
@@ -2064,6 +2187,14 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2064
2187
  let sentinelIndex = 0;
2065
2188
  let hasAppliedStorageReload = false; // Track if we've reloaded for localStorage
2066
2189
 
2190
+ async function moveMouseToBox(box) {
2191
+ if (!box) return;
2192
+ const x = Math.round(box.x + box.width / 2);
2193
+ const y = Math.round(box.y + box.height / 2);
2194
+ await page.mouse.move(x, y, { steps: 18 });
2195
+ await page.waitForTimeout(120);
2196
+ }
2197
+
2067
2198
  /**
2068
2199
  * Capture a sentinel frame (full page screenshot)
2069
2200
  * Applies scenario-level cropping if configured
@@ -2204,14 +2335,61 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2204
2335
  } catch (e) {
2205
2336
  // Okay if timeout
2206
2337
  }
2207
- await page.waitForTimeout(800); // Extra time for i18n/translations to render
2338
+ await page.waitForTimeout(300); // Extra time for i18n/translations to render
2339
+ await waitForLoadingComplete(page, 5000);
2340
+
2341
+ if (readySelector) {
2342
+ let readyError = null;
2343
+ const maxAttempts = 1 + scenarioCaptureConfig.retryOnError;
2344
+
2345
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
2346
+ try {
2347
+ await page.locator(readySelector).first().waitFor({
2348
+ state: "visible",
2349
+ timeout: scenarioCaptureConfig.readyTimeout,
2350
+ });
2351
+ readyError = null;
2352
+ break;
2353
+ } catch (error) {
2354
+ readyError = error;
2355
+ if (attempt === maxAttempts) {
2356
+ break;
2357
+ }
2358
+
2359
+ const delay =
2360
+ scenarioCaptureConfig.retryDelay * Math.pow(2, attempt - 1);
2361
+ console.log(
2362
+ chalk.yellow(
2363
+ ` ⚠ Attempt ${attempt}/${maxAttempts} failed (ready selector timeout). Retrying in ${delay}ms...`
2364
+ )
2365
+ );
2366
+ await page.waitForTimeout(delay);
2367
+ await page.reload({ waitUntil: "domcontentloaded" });
2368
+ await page.waitForTimeout(300);
2369
+ await waitForLoadingComplete(page, 5000);
2370
+ }
2371
+ }
2372
+
2373
+ if (readyError) {
2374
+ const currentUrl = page.url();
2375
+ throw new Error(
2376
+ `Scenario readySelector "${readySelector}" not found in video capture mode at ${currentUrl}. ${
2377
+ readyError instanceof Error ? readyError.message : String(readyError)
2378
+ }`
2379
+ );
2380
+ }
2381
+ }
2382
+
2383
+ await assertForbiddenTextAbsent(page, forbidText);
2208
2384
 
2209
2385
  // Re-inject workspace store after navigation to handle Zustand hydration resets
2210
2386
  if (_activeProjectId) {
2211
2387
  await page.evaluate(({ pid, ws }) => {
2388
+ const storePrefixes = ["reshot-store-", "workspace-store-"];
2389
+ let foundKey = null;
2212
2390
  for (let i = 0; i < localStorage.length; i++) {
2213
2391
  const key = localStorage.key(i);
2214
- if (key && key.startsWith("workspace-store-")) {
2392
+ if (key && storePrefixes.some((prefix) => key.startsWith(prefix))) {
2215
2393
  try {
2216
2394
  const data = JSON.parse(localStorage.getItem(key) || "{}");
2217
2395
  if (data.state) {
@@ -2219,11 +2397,12 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2219
2397
  if (ws) data.state.activeWorkspace = data.state.activeWorkspace || { id: ws.id, name: ws.name, slug: ws.slug };
2220
2398
  data.version = data.version ?? 0;
2221
2399
  localStorage.setItem(key, JSON.stringify(data));
2400
+ foundKey = key;
2222
2401
  }
2223
2402
  } catch (e) {}
2224
2403
  }
2225
2404
  }
2226
- window.dispatchEvent(new StorageEvent("storage", { key: null }));
2405
+ window.dispatchEvent(new StorageEvent("storage", { key: foundKey || "reshot-store-workspace" }));
2227
2406
  }, { pid: _activeProjectId, ws: _activeWorkspace });
2228
2407
  }
2229
2408
 
@@ -2272,43 +2451,13 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2272
2451
  await element.waitFor({ state: "visible", timeout: clickTimeout });
2273
2452
  const box = await element.boundingBox();
2274
2453
 
2275
- // Add highlight before click
2276
- if (box) {
2277
- await page.evaluate(
2278
- ({ box, color }) => {
2279
- const div = document.createElement("div");
2280
- div.id = "reshot-video-highlight";
2281
- div.style.cssText = `
2282
- position: fixed;
2283
- left: ${box.x}px;
2284
- top: ${box.y}px;
2285
- width: ${box.width}px;
2286
- height: ${box.height}px;
2287
- background: ${color};
2288
- pointer-events: none;
2289
- z-index: 999999;
2290
- border-radius: 4px;
2291
- transition: opacity 0.3s;
2292
- `;
2293
- document.body.appendChild(div);
2294
- },
2295
- { box, color: highlight.color }
2296
- );
2297
-
2298
- await page.waitForTimeout(300);
2299
- }
2300
-
2454
+ await moveMouseToBox(box);
2301
2455
  await element.click();
2302
2456
 
2303
- // Remove highlight after click
2304
- await page.evaluate(() => {
2305
- const h = document.getElementById("reshot-video-highlight");
2306
- if (h) h.remove();
2307
- });
2308
-
2309
2457
  events.push({
2310
2458
  action: "click",
2311
2459
  timestamp,
2460
+ target,
2312
2461
  subtitle: subtitles.enabled ? `Click on ${target}` : "",
2313
2462
  elementBox: box,
2314
2463
  });
@@ -2318,6 +2467,11 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2318
2467
  // Capture sentinel after click
2319
2468
  await captureSentinel(`after-click-${stepIndex}`);
2320
2469
  } catch (e) {
2470
+ if (!isOptional) {
2471
+ throw new Error(
2472
+ `Required click target not found: ${target}. ${e instanceof Error ? e.message : String(e)}`
2473
+ );
2474
+ }
2321
2475
  console.warn(
2322
2476
  chalk.yellow(` ⚠ Could not click ${target}: ${e.message}`)
2323
2477
  );
@@ -2341,41 +2495,14 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2341
2495
  await element.waitFor({ state: "visible", timeout: typeTimeout });
2342
2496
  const box = await element.boundingBox();
2343
2497
 
2344
- // Add highlight before typing
2345
- if (box) {
2346
- await page.evaluate(
2347
- ({ box, color }) => {
2348
- const div = document.createElement("div");
2349
- div.id = "reshot-video-highlight";
2350
- div.style.cssText = `
2351
- position: fixed;
2352
- left: ${box.x}px;
2353
- top: ${box.y}px;
2354
- width: ${box.width}px;
2355
- height: ${box.height}px;
2356
- background: ${color};
2357
- pointer-events: none;
2358
- z-index: 999999;
2359
- border-radius: 4px;
2360
- `;
2361
- document.body.appendChild(div);
2362
- },
2363
- { box, color: highlight.color }
2364
- );
2365
- }
2366
-
2498
+ await moveMouseToBox(box);
2367
2499
  await element.fill("");
2368
- await element.type(text, { delay: 50 }); // Visible typing effect
2369
-
2370
- // Remove highlight
2371
- await page.evaluate(() => {
2372
- const h = document.getElementById("reshot-video-highlight");
2373
- if (h) h.remove();
2374
- });
2500
+ await element.type(text, { delay: typeDelayMs }); // Visible typing effect
2375
2501
 
2376
2502
  events.push({
2377
2503
  action: "type",
2378
2504
  timestamp,
2505
+ target,
2379
2506
  subtitle: subtitles.enabled ? `Entering "${text}"` : "",
2380
2507
  elementBox: box,
2381
2508
  });
@@ -2385,6 +2512,11 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2385
2512
  // Capture sentinel after type
2386
2513
  await captureSentinel(`after-type-${stepIndex}`);
2387
2514
  } catch (e) {
2515
+ if (!isOptional) {
2516
+ throw new Error(
2517
+ `Required input target not found: ${target}. ${e instanceof Error ? e.message : String(e)}`
2518
+ );
2519
+ }
2388
2520
  console.warn(
2389
2521
  chalk.yellow(` ⚠ Could not type into ${target}: ${e.message}`)
2390
2522
  );
@@ -2408,15 +2540,8 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2408
2540
  } catch (e) {
2409
2541
  if (!isOptional) {
2410
2542
  const currentUrl = page.url();
2411
- console.warn(
2412
- chalk.yellow(` Element not found: ${params.target}`)
2413
- );
2414
- console.warn(chalk.gray(` URL: ${currentUrl}`));
2415
- console.warn(chalk.gray(` Timeout: ${waitTimeout}ms`));
2416
- console.warn(
2417
- chalk.gray(
2418
- ` Hint: Verify the selector exists on the page. Run 'reshot record' to inspect.`
2419
- )
2543
+ throw new Error(
2544
+ `Required selector not found in video capture: ${params.target} (URL: ${currentUrl}, timeout: ${waitTimeout}ms)`
2420
2545
  );
2421
2546
  }
2422
2547
  }
@@ -2434,11 +2559,18 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2434
2559
  try {
2435
2560
  const element = await page.locator(params.target).first();
2436
2561
  await element.waitFor({ state: "visible", timeout: hoverTimeout });
2562
+ const box = await element.boundingBox();
2563
+ await moveMouseToBox(box);
2437
2564
  await element.hover();
2438
2565
  await page.waitForTimeout(300);
2439
2566
  // Capture sentinel after hover (state may have changed with tooltips/dropdowns)
2440
2567
  await captureSentinel(`after-hover-${stepIndex}`);
2441
2568
  } catch (e) {
2569
+ if (!isOptional) {
2570
+ throw new Error(
2571
+ `Required hover target not found: ${params.target}. ${e instanceof Error ? e.message : String(e)}`
2572
+ );
2573
+ }
2442
2574
  console.warn(
2443
2575
  chalk.yellow(` ⚠ Could not hover ${params.target}: ${e.message}`)
2444
2576
  );
@@ -2530,7 +2662,7 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2530
2662
  // Convert to MP4 with ffmpeg, trimming blank loading frames from start
2531
2663
  // and excess frames from end
2532
2664
  const startOffset = Math.max(0, (firstSentinelTimestamp || 0) - 0.3);
2533
- const endTimestamp = finalTimestamp + 0.5;
2665
+ const endTimestamp = finalTimestamp + 0.25;
2534
2666
  const contentDuration = endTimestamp - startOffset;
2535
2667
  if (startOffset > 0) {
2536
2668
  console.log(
@@ -2559,6 +2691,8 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2559
2691
  "fast",
2560
2692
  "-pix_fmt",
2561
2693
  "yuv420p",
2694
+ "-r",
2695
+ String(videoFrameRate),
2562
2696
  "-movflags",
2563
2697
  "+faststart",
2564
2698
  "-y",
@@ -2601,6 +2735,14 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2601
2735
  );
2602
2736
  debug(`Sentinel manifest saved to: ${sentinelManifestPath}`);
2603
2737
 
2738
+ const metadataPath = path.join(actualOutputDir, "summary-video.metadata.json");
2739
+ fs.writeJSONSync(
2740
+ metadataPath,
2741
+ buildVideoMetadata(events, sentinelPaths, viewport, videoFrameRate),
2742
+ { spaces: 2 },
2743
+ );
2744
+ debug(`Video metadata saved to: ${metadataPath}`);
2745
+
2604
2746
  // Cleanup temp directory (unique per recording)
2605
2747
  try {
2606
2748
  fs.removeSync(tempDir);
@@ -2618,6 +2760,7 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2618
2760
  path: finalVideoPath,
2619
2761
  type: "video",
2620
2762
  duration: (Date.now() - startTime) / 1000,
2763
+ metadataPath,
2621
2764
  },
2622
2765
  ],
2623
2766
  sentinels: sentinelPaths.map((s) => ({
@@ -3398,6 +3541,7 @@ module.exports = {
3398
3541
  runScenarioWithEngine,
3399
3542
  runScenarioWithStepByStepCapture,
3400
3543
  runScenarioWithVideoCapture,
3544
+ buildVideoMetadata,
3401
3545
  captureWithHighlight,
3402
3546
  checkFFmpeg,
3403
3547
  runAllScenarios,
@@ -3418,4 +3562,6 @@ module.exports = {
3418
3562
  generateVersionTimestamp,
3419
3563
  // Concurrency
3420
3564
  detectOptimalConcurrency,
3565
+ installVisibleCursor,
3566
+ normalizeVideoTargetName,
3421
3567
  };
@@ -372,7 +372,12 @@ async function runDoctorTarget(options = {}) {
372
372
  const docSyncConfig = config.readConfig();
373
373
  const target = docSyncConfig.target;
374
374
  const scenarios = getSelectedScenarios(docSyncConfig, options.scenarioKeys);
375
- const timeoutMs = options.timeoutMs || 30_000;
375
+ const timeoutMs = options.timeoutMs || 15_000;
376
+ // Overall budget so the command fails fast instead of grinding through every
377
+ // scenario at the full per-step timeout (which read as an indefinite hang).
378
+ const overallTimeoutMs = options.overallTimeoutMs || Math.max(timeoutMs * 4, 60_000);
379
+ const startedAt = Date.now();
380
+ const overBudget = () => Date.now() - startedAt > overallTimeoutMs;
376
381
  const onProgress =
377
382
  typeof options.onProgress === "function" ? options.onProgress : null;
378
383
 
@@ -402,7 +407,23 @@ async function runDoctorTarget(options = {}) {
402
407
  const advisories = [];
403
408
  const info = [];
404
409
 
410
+ let budgetExceeded = false;
405
411
  for (const scenario of scenarios) {
412
+ if (overBudget()) {
413
+ budgetExceeded = true;
414
+ onProgress?.(
415
+ `Overall doctor budget (${overallTimeoutMs}ms) exceeded — stopping before "${scenario.key}".`,
416
+ );
417
+ blockingIssues.push(
418
+ createIssue(
419
+ "blocking",
420
+ "doctor_timeout",
421
+ `Target doctor exceeded its overall time budget of ${overallTimeoutMs}ms. Remaining scenarios were not audited.`,
422
+ { auditedScenarios: readinessAudits.length, totalScenarios: scenarios.length },
423
+ ),
424
+ );
425
+ break;
426
+ }
406
427
  onProgress?.(`Auditing routes for ${scenario.key}...`);
407
428
  const routeResults = [];
408
429
  for (const route of scenario.requiredRoutes || []) {
@@ -544,6 +565,7 @@ async function runDoctorTarget(options = {}) {
544
565
  }
545
566
 
546
567
  const ok =
568
+ !budgetExceeded &&
547
569
  requiredEnv.every((item) => item.present) &&
548
570
  (fixture.skipped || fixture.ok) &&
549
571
  captureSafe.ok &&