@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.
- package/README.md +67 -22
- package/package.json +18 -14
- package/src/commands/auth.js +37 -7
- package/src/commands/capture-dom.js +50 -0
- package/src/commands/compose.js +220 -0
- package/src/commands/doctor-release.js +7 -0
- package/src/commands/doctor-target.js +36 -4
- package/src/commands/drifts.js +13 -1
- package/src/commands/publish.js +183 -21
- package/src/commands/pull.js +9 -4
- package/src/commands/refresh.js +166 -0
- package/src/commands/setup-wizard.js +57 -3
- package/src/commands/status.js +22 -2
- package/src/commands/variation.js +194 -0
- package/src/index.js +190 -10
- package/src/lib/api-client.js +61 -35
- package/src/lib/auto-update/refresh.js +598 -0
- package/src/lib/auto-update/scene-runtime.compose.tsx +73 -0
- package/src/lib/auto-update/spec.js +89 -0
- package/src/lib/capture-engine.js +76 -2
- package/src/lib/capture-script-runner.js +289 -138
- package/src/lib/certification.js +23 -1
- package/src/lib/compose-context.js +156 -0
- package/src/lib/compose-pack.js +42 -0
- package/src/lib/compose-runtime.js +34 -0
- package/src/lib/compose-upload.js +142 -0
- package/src/lib/config.js +2 -2
- package/src/lib/dom-capture.js +64 -0
- package/src/lib/ensure-browser.js +147 -0
- package/src/lib/record-clip.js +83 -3
- package/src/lib/record-config.js +0 -4
- package/src/lib/release-doctor.js +11 -3
- package/src/lib/resolve-targets.js +60 -0
- package/src/lib/run-manifest.js +45 -0
- package/src/lib/ui-api-helpers.js +118 -0
- package/src/lib/ui-api.js +28 -820
- package/src/lib/ui-asset-cleanup.js +62 -0
- package/src/lib/ui-output-versions.js +165 -0
- package/src/lib/ui-recorder-routes.js +341 -0
- package/src/lib/ui-scenario-metadata.js +161 -0
- package/vendor/compose/dist/auto-update.cjs +5544 -0
- package/vendor/compose/dist/auto-update.mjs +5518 -0
- package/vendor/compose/dist/capture.cjs +1450 -0
- package/vendor/compose/dist/capture.mjs +1416 -0
- package/vendor/compose/dist/eligibility.cjs +5331 -0
- package/vendor/compose/dist/eligibility.mjs +5313 -0
- package/vendor/compose/dist/index.cjs +2046 -0
- package/vendor/compose/dist/index.mjs +1997 -0
- package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
- package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
- package/vendor/compose/dist/jsx-runtime.cjs +58 -0
- package/vendor/compose/dist/jsx-runtime.mjs +31 -0
- package/vendor/compose/dist/render.cjs +558 -0
- package/vendor/compose/dist/render.mjs +515 -0
- package/vendor/compose/dist/verify-cli.cjs +3806 -0
- package/vendor/compose/dist/verify-cli.mjs +3812 -0
- package/vendor/compose/dist/verify.cjs +3880 -0
- package/vendor/compose/dist/verify.mjs +3858 -0
- package/web/manager/dist/assets/{index-CvleJUur.js → index-D0S2otug.js} +56 -56
- package/web/manager/dist/index.html +1 -1
- package/src/commands/ingest.js +0 -458
- package/src/commands/setup.js +0 -165
- 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
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
"
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
2412
|
-
|
|
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.
|
|
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
|
};
|