@reshotdev/screenshot 0.0.1-beta.11 → 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.
- package/LICENSE +1 -1
- package/README.md +84 -51
- package/package.json +20 -16
- package/src/commands/auth.js +38 -8
- package/src/commands/capture-dom.js +50 -0
- package/src/commands/compose.js +220 -0
- package/src/commands/doctor-target.js +36 -4
- package/src/commands/drifts.js +13 -1
- package/src/commands/publish.js +137 -12
- package/src/commands/pull.js +13 -8
- package/src/commands/refresh.js +166 -0
- package/src/commands/setup-wizard.js +35 -2
- package/src/commands/status.js +22 -2
- package/src/commands/variation.js +194 -0
- package/src/index.js +189 -47
- 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 +73 -0
- package/src/lib/capture-script-runner.js +280 -134
- 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 +5 -5
- package/src/lib/dom-capture.js +64 -0
- package/src/lib/output-path-template.js +3 -3
- package/src/lib/record-clip.js +83 -3
- package/src/lib/record-config.js +0 -4
- package/src/lib/resolve-targets.js +60 -0
- package/src/lib/run-manifest.js +45 -0
- package/src/lib/storage-providers.js +1 -1
- package/src/lib/style-engine.js +5 -5
- 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-D2qqcFNN.js → index-D0S2otug.js} +56 -56
- package/web/manager/dist/index.html +1 -1
- package/src/commands/ci-run.js +0 -178
- package/src/commands/ci-setup.js +0 -288
- 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,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
|
|
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
|
|
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(
|
|
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
|
-
"
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
)
|
|
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.
|
|
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
|
};
|
package/src/lib/certification.js
CHANGED
|
@@ -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 ||
|
|
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 &&
|