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