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

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 (63) 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-release.js +7 -0
  7. package/src/commands/doctor-target.js +36 -4
  8. package/src/commands/drifts.js +13 -1
  9. package/src/commands/publish.js +183 -21
  10. package/src/commands/pull.js +9 -4
  11. package/src/commands/refresh.js +166 -0
  12. package/src/commands/setup-wizard.js +57 -3
  13. package/src/commands/status.js +22 -2
  14. package/src/commands/variation.js +194 -0
  15. package/src/index.js +190 -10
  16. package/src/lib/api-client.js +61 -35
  17. package/src/lib/auto-update/refresh.js +598 -0
  18. package/src/lib/auto-update/scene-runtime.compose.tsx +73 -0
  19. package/src/lib/auto-update/spec.js +89 -0
  20. package/src/lib/capture-engine.js +76 -2
  21. package/src/lib/capture-script-runner.js +289 -138
  22. package/src/lib/certification.js +23 -1
  23. package/src/lib/compose-context.js +156 -0
  24. package/src/lib/compose-pack.js +42 -0
  25. package/src/lib/compose-runtime.js +34 -0
  26. package/src/lib/compose-upload.js +142 -0
  27. package/src/lib/config.js +2 -2
  28. package/src/lib/dom-capture.js +64 -0
  29. package/src/lib/ensure-browser.js +147 -0
  30. package/src/lib/record-clip.js +83 -3
  31. package/src/lib/record-config.js +0 -4
  32. package/src/lib/release-doctor.js +11 -3
  33. package/src/lib/resolve-targets.js +60 -0
  34. package/src/lib/run-manifest.js +45 -0
  35. package/src/lib/ui-api-helpers.js +118 -0
  36. package/src/lib/ui-api.js +28 -820
  37. package/src/lib/ui-asset-cleanup.js +62 -0
  38. package/src/lib/ui-output-versions.js +165 -0
  39. package/src/lib/ui-recorder-routes.js +341 -0
  40. package/src/lib/ui-scenario-metadata.js +161 -0
  41. package/vendor/compose/dist/auto-update.cjs +5544 -0
  42. package/vendor/compose/dist/auto-update.mjs +5518 -0
  43. package/vendor/compose/dist/capture.cjs +1450 -0
  44. package/vendor/compose/dist/capture.mjs +1416 -0
  45. package/vendor/compose/dist/eligibility.cjs +5331 -0
  46. package/vendor/compose/dist/eligibility.mjs +5313 -0
  47. package/vendor/compose/dist/index.cjs +2046 -0
  48. package/vendor/compose/dist/index.mjs +1997 -0
  49. package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
  50. package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
  51. package/vendor/compose/dist/jsx-runtime.cjs +58 -0
  52. package/vendor/compose/dist/jsx-runtime.mjs +31 -0
  53. package/vendor/compose/dist/render.cjs +558 -0
  54. package/vendor/compose/dist/render.mjs +515 -0
  55. package/vendor/compose/dist/verify-cli.cjs +3806 -0
  56. package/vendor/compose/dist/verify-cli.mjs +3812 -0
  57. package/vendor/compose/dist/verify.cjs +3880 -0
  58. package/vendor/compose/dist/verify.mjs +3858 -0
  59. package/web/manager/dist/assets/{index-CvleJUur.js → index-D0S2otug.js} +56 -56
  60. package/web/manager/dist/index.html +1 -1
  61. package/src/commands/ingest.js +0 -458
  62. package/src/commands/setup.js +0 -165
  63. 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,8 +1095,21 @@ 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) {
969
- // 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.
970
1113
  const sessionStats = fs.statSync(sessionPath);
971
1114
  const sessionAgeHours =
972
1115
  (Date.now() - sessionStats.mtimeMs) / (1000 * 60 * 60);
@@ -1230,9 +1373,18 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
1230
1373
  await fs.writeFile(filePath, buffer);
1231
1374
  lastScreenshotHash = currentHash;
1232
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
+
1233
1383
  const asset = {
1234
1384
  name,
1235
1385
  path: filePath,
1386
+ domScenePath: domScene ? domScene.path : null,
1387
+ domSceneBytes: domScene ? domScene.bytes : null,
1236
1388
  description,
1237
1389
  captureIndex,
1238
1390
  type,
@@ -1391,6 +1543,13 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
1391
1543
 
1392
1544
  if (!elementExists) {
1393
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
+ }
1394
1553
  continue;
1395
1554
  }
1396
1555
 
@@ -1665,7 +1824,7 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
1665
1824
  }
1666
1825
 
1667
1826
  /**
1668
- * Capture screenshot with highlight box around element
1827
+ * Capture screenshot without interaction overlays.
1669
1828
  */
1670
1829
  async function captureWithHighlight(
1671
1830
  engine,
@@ -1673,63 +1832,12 @@ async function captureWithHighlight(
1673
1832
  outputPath,
1674
1833
  highlight = {}
1675
1834
  ) {
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
1835
  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
1836
  }
1729
1837
 
1730
1838
  /**
1731
1839
  * Run a scenario with video capture (summary-video format)
1732
- * 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
1733
1841
  * Supports graceful handling of permission-restricted steps
1734
1842
  * Supports cropping for sentinel frames (same config as step-by-step-images)
1735
1843
  */
@@ -1740,21 +1848,36 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1740
1848
  headless = true,
1741
1849
  viewport = { width: 1280, height: 720 },
1742
1850
  variantsConfig = {}, // Global variant configuration (new format with dimensions)
1851
+ globalQuality = null,
1743
1852
  } = options;
1744
1853
 
1745
1854
  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
1855
  const subtitles = outputConfig.subtitles || { enabled: false };
1856
+ const videoFrameRate = Number(outputConfig.frameRate || 24);
1857
+ const typeDelayMs = Number(outputConfig.typeDelayMs || 20);
1751
1858
 
1752
1859
  // Extract crop configuration from scenario output settings
1753
1860
  // This persists across all variations and applies to sentinel frames
1754
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);
1755
1869
 
1756
1870
  // Resolve variant configuration using new universal variant system
1757
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
+ }
1758
1881
 
1759
1882
  // Resolve privacy configuration for video (CSS masking persists through entire video)
1760
1883
  const videoPrivacyConfig = config.getPrivacyConfig(scenario.privacy);
@@ -1817,8 +1940,10 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1817
1940
  // Check for saved session state (auth cookies) - CRITICAL for authenticated scenarios
1818
1941
  const sessionPath = getDefaultSessionPath();
1819
1942
  const hasSession = fs.existsSync(sessionPath);
1820
- if (hasSession) {
1821
- // 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.
1822
1947
  const sessionStats = fs.statSync(sessionPath);
1823
1948
  const sessionAgeHours = (Date.now() - sessionStats.mtimeMs) / (1000 * 60 * 60);
1824
1949
  if (sessionAgeHours > 24) {
@@ -1831,6 +1956,7 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1831
1956
  }
1832
1957
 
1833
1958
  const { chromium } = require("playwright");
1959
+ const { launchChromium } = require("./ensure-browser");
1834
1960
  // Use a unique temp directory for this recording to avoid conflicts
1835
1961
  const recordingId = `recording-${Date.now()}-${Math.random()
1836
1962
  .toString(36)
@@ -1857,7 +1983,7 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1857
1983
  debug("Launching browser...");
1858
1984
 
1859
1985
  // Launch browser with video recording
1860
- browser = await chromium.launch(buildLaunchOptions({ headless }));
1986
+ browser = await launchChromium(chromium, buildLaunchOptions({ headless }));
1861
1987
  debug("Browser launched successfully");
1862
1988
 
1863
1989
  // Build context options with variant support using universal injector
@@ -1906,6 +2032,7 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1906
2032
  debug("Browser context created");
1907
2033
  page = await context.newPage();
1908
2034
  debug("Page created");
2035
+ await installVisibleCursor(page);
1909
2036
 
1910
2037
  // CRITICAL: Hide development overlays (Next.js devtools, Vercel toolbar, etc.)
1911
2038
  // This prevents dev tools from intercepting clicks during video capture
@@ -2025,10 +2152,11 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2025
2152
  storeState.activeWorkspace = { id: ws.id, name: ws.name, slug: ws.slug };
2026
2153
  }
2027
2154
 
2155
+ const storePrefixes = ["reshot-store-", "workspace-store-"];
2028
2156
  let found = false;
2029
2157
  for (let i = 0; i < localStorage.length; i++) {
2030
2158
  const key = localStorage.key(i);
2031
- if (key && key.startsWith("workspace-store-")) {
2159
+ if (key && storePrefixes.some((prefix) => key.startsWith(prefix))) {
2032
2160
  try {
2033
2161
  const data = JSON.parse(localStorage.getItem(key) || "{}");
2034
2162
  data.state = { ...data.state, ...storeState };
@@ -2040,7 +2168,7 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2040
2168
  }
2041
2169
  if (!found) {
2042
2170
  localStorage.setItem(
2043
- "workspace-store-1",
2171
+ "reshot-store-workspace",
2044
2172
  JSON.stringify({ state: storeState, version: 0 })
2045
2173
  );
2046
2174
  }
@@ -2064,6 +2192,14 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2064
2192
  let sentinelIndex = 0;
2065
2193
  let hasAppliedStorageReload = false; // Track if we've reloaded for localStorage
2066
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
+
2067
2203
  /**
2068
2204
  * Capture a sentinel frame (full page screenshot)
2069
2205
  * Applies scenario-level cropping if configured
@@ -2204,14 +2340,61 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2204
2340
  } catch (e) {
2205
2341
  // Okay if timeout
2206
2342
  }
2207
- 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);
2208
2389
 
2209
2390
  // Re-inject workspace store after navigation to handle Zustand hydration resets
2210
2391
  if (_activeProjectId) {
2211
2392
  await page.evaluate(({ pid, ws }) => {
2393
+ const storePrefixes = ["reshot-store-", "workspace-store-"];
2394
+ let foundKey = null;
2212
2395
  for (let i = 0; i < localStorage.length; i++) {
2213
2396
  const key = localStorage.key(i);
2214
- if (key && key.startsWith("workspace-store-")) {
2397
+ if (key && storePrefixes.some((prefix) => key.startsWith(prefix))) {
2215
2398
  try {
2216
2399
  const data = JSON.parse(localStorage.getItem(key) || "{}");
2217
2400
  if (data.state) {
@@ -2219,11 +2402,12 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2219
2402
  if (ws) data.state.activeWorkspace = data.state.activeWorkspace || { id: ws.id, name: ws.name, slug: ws.slug };
2220
2403
  data.version = data.version ?? 0;
2221
2404
  localStorage.setItem(key, JSON.stringify(data));
2405
+ foundKey = key;
2222
2406
  }
2223
2407
  } catch (e) {}
2224
2408
  }
2225
2409
  }
2226
- window.dispatchEvent(new StorageEvent("storage", { key: null }));
2410
+ window.dispatchEvent(new StorageEvent("storage", { key: foundKey || "reshot-store-workspace" }));
2227
2411
  }, { pid: _activeProjectId, ws: _activeWorkspace });
2228
2412
  }
2229
2413
 
@@ -2272,43 +2456,13 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2272
2456
  await element.waitFor({ state: "visible", timeout: clickTimeout });
2273
2457
  const box = await element.boundingBox();
2274
2458
 
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
-
2459
+ await moveMouseToBox(box);
2301
2460
  await element.click();
2302
2461
 
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
2462
  events.push({
2310
2463
  action: "click",
2311
2464
  timestamp,
2465
+ target,
2312
2466
  subtitle: subtitles.enabled ? `Click on ${target}` : "",
2313
2467
  elementBox: box,
2314
2468
  });
@@ -2318,6 +2472,11 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2318
2472
  // Capture sentinel after click
2319
2473
  await captureSentinel(`after-click-${stepIndex}`);
2320
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
+ }
2321
2480
  console.warn(
2322
2481
  chalk.yellow(` ⚠ Could not click ${target}: ${e.message}`)
2323
2482
  );
@@ -2341,41 +2500,14 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2341
2500
  await element.waitFor({ state: "visible", timeout: typeTimeout });
2342
2501
  const box = await element.boundingBox();
2343
2502
 
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
-
2503
+ await moveMouseToBox(box);
2367
2504
  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
- });
2505
+ await element.type(text, { delay: typeDelayMs }); // Visible typing effect
2375
2506
 
2376
2507
  events.push({
2377
2508
  action: "type",
2378
2509
  timestamp,
2510
+ target,
2379
2511
  subtitle: subtitles.enabled ? `Entering "${text}"` : "",
2380
2512
  elementBox: box,
2381
2513
  });
@@ -2385,6 +2517,11 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2385
2517
  // Capture sentinel after type
2386
2518
  await captureSentinel(`after-type-${stepIndex}`);
2387
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
+ }
2388
2525
  console.warn(
2389
2526
  chalk.yellow(` ⚠ Could not type into ${target}: ${e.message}`)
2390
2527
  );
@@ -2408,15 +2545,8 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2408
2545
  } catch (e) {
2409
2546
  if (!isOptional) {
2410
2547
  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
- )
2548
+ throw new Error(
2549
+ `Required selector not found in video capture: ${params.target} (URL: ${currentUrl}, timeout: ${waitTimeout}ms)`
2420
2550
  );
2421
2551
  }
2422
2552
  }
@@ -2434,11 +2564,18 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2434
2564
  try {
2435
2565
  const element = await page.locator(params.target).first();
2436
2566
  await element.waitFor({ state: "visible", timeout: hoverTimeout });
2567
+ const box = await element.boundingBox();
2568
+ await moveMouseToBox(box);
2437
2569
  await element.hover();
2438
2570
  await page.waitForTimeout(300);
2439
2571
  // Capture sentinel after hover (state may have changed with tooltips/dropdowns)
2440
2572
  await captureSentinel(`after-hover-${stepIndex}`);
2441
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
+ }
2442
2579
  console.warn(
2443
2580
  chalk.yellow(` ⚠ Could not hover ${params.target}: ${e.message}`)
2444
2581
  );
@@ -2530,7 +2667,7 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2530
2667
  // Convert to MP4 with ffmpeg, trimming blank loading frames from start
2531
2668
  // and excess frames from end
2532
2669
  const startOffset = Math.max(0, (firstSentinelTimestamp || 0) - 0.3);
2533
- const endTimestamp = finalTimestamp + 0.5;
2670
+ const endTimestamp = finalTimestamp + 0.25;
2534
2671
  const contentDuration = endTimestamp - startOffset;
2535
2672
  if (startOffset > 0) {
2536
2673
  console.log(
@@ -2559,6 +2696,8 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2559
2696
  "fast",
2560
2697
  "-pix_fmt",
2561
2698
  "yuv420p",
2699
+ "-r",
2700
+ String(videoFrameRate),
2562
2701
  "-movflags",
2563
2702
  "+faststart",
2564
2703
  "-y",
@@ -2601,6 +2740,14 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2601
2740
  );
2602
2741
  debug(`Sentinel manifest saved to: ${sentinelManifestPath}`);
2603
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
+
2604
2751
  // Cleanup temp directory (unique per recording)
2605
2752
  try {
2606
2753
  fs.removeSync(tempDir);
@@ -2618,6 +2765,7 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
2618
2765
  path: finalVideoPath,
2619
2766
  type: "video",
2620
2767
  duration: (Date.now() - startTime) / 1000,
2768
+ metadataPath,
2621
2769
  },
2622
2770
  ],
2623
2771
  sentinels: sentinelPaths.map((s) => ({
@@ -3398,6 +3546,7 @@ module.exports = {
3398
3546
  runScenarioWithEngine,
3399
3547
  runScenarioWithStepByStepCapture,
3400
3548
  runScenarioWithVideoCapture,
3549
+ buildVideoMetadata,
3401
3550
  captureWithHighlight,
3402
3551
  checkFFmpeg,
3403
3552
  runAllScenarios,
@@ -3418,4 +3567,6 @@ module.exports = {
3418
3567
  generateVersionTimestamp,
3419
3568
  // Concurrency
3420
3569
  detectOptimalConcurrency,
3570
+ installVisibleCursor,
3571
+ normalizeVideoTargetName,
3421
3572
  };