@reshotdev/screenshot 0.0.1-beta.1 → 0.0.1-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -7
- package/package.json +9 -2
- package/src/commands/auth.js +108 -26
- package/src/commands/certify.js +62 -0
- package/src/commands/ci-run.js +57 -2
- package/src/commands/ci-setup.js +5 -5
- package/src/commands/doctor-release.js +67 -0
- package/src/commands/doctor-target.js +49 -0
- package/src/commands/drifts.js +5 -70
- package/src/commands/import-tests.js +13 -13
- package/src/commands/ingest.js +10 -10
- package/src/commands/init.js +16 -277
- package/src/commands/publish.js +204 -237
- package/src/commands/pull.js +253 -23
- package/src/commands/run.js +292 -12
- package/src/commands/setup-wizard.js +277 -499
- package/src/commands/setup.js +41 -13
- package/src/commands/status.js +313 -125
- package/src/commands/sync.js +28 -236
- package/src/commands/ui.js +1 -1
- package/src/commands/verify-publish.js +46 -0
- package/src/index.js +194 -94
- package/src/lib/api-client.js +121 -35
- package/src/lib/capture-engine.js +103 -7
- package/src/lib/capture-script-runner.js +359 -58
- package/src/lib/certification.js +865 -0
- package/src/lib/config.js +181 -76
- package/src/lib/record-cdp.js +288 -16
- package/src/lib/record-config.js +1 -1
- package/src/lib/release-doctor.js +313 -0
- package/src/lib/run-manifest.js +103 -0
- package/src/lib/standalone-mode.js +1 -1
- package/src/lib/storage-providers.js +4 -4
- package/src/lib/target-contract.js +292 -0
- package/src/lib/ui-api.js +6 -7
- package/web/manager/dist/assets/{index--ZgioErz.js → index-D2qqcFNN.js} +1 -1
- package/web/manager/dist/index.html +1 -1
- package/src/commands/validate-docs.js +0 -529
|
@@ -30,6 +30,8 @@ const {
|
|
|
30
30
|
getDefaultSessionPath,
|
|
31
31
|
autoSyncSessionFromCDP,
|
|
32
32
|
sanitizeStorageState,
|
|
33
|
+
assessSessionHealth,
|
|
34
|
+
writeSessionArtifacts,
|
|
33
35
|
} = require("./record-cdp");
|
|
34
36
|
const config = require("./config");
|
|
35
37
|
const {
|
|
@@ -135,6 +137,101 @@ function debug(...args) {
|
|
|
135
137
|
}
|
|
136
138
|
}
|
|
137
139
|
|
|
140
|
+
function normalizeVisibleText(value) {
|
|
141
|
+
return String(value || "")
|
|
142
|
+
.replace(/\s+/g, " ")
|
|
143
|
+
.trim()
|
|
144
|
+
.toLowerCase();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function collectForbiddenText(globalQuality = null, scenario = {}) {
|
|
148
|
+
const merged = [
|
|
149
|
+
...(globalQuality?.forbidText || []),
|
|
150
|
+
...(scenario.quality?.forbidText || []),
|
|
151
|
+
]
|
|
152
|
+
.map((value) => String(value || "").trim())
|
|
153
|
+
.filter(Boolean);
|
|
154
|
+
|
|
155
|
+
return [
|
|
156
|
+
...new Map(
|
|
157
|
+
merged.map((value) => [normalizeVisibleText(value), value]),
|
|
158
|
+
).values(),
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function assertForbiddenTextAbsent(page, forbidText = []) {
|
|
163
|
+
if (!Array.isArray(forbidText) || forbidText.length === 0) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const visibleText = normalizeVisibleText(
|
|
168
|
+
await page.evaluate(() => {
|
|
169
|
+
const isElementVisibleInViewport = (element) => {
|
|
170
|
+
if (!(element instanceof Element)) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const style = window.getComputedStyle(element);
|
|
175
|
+
if (
|
|
176
|
+
style.display === "none" ||
|
|
177
|
+
style.visibility === "hidden" ||
|
|
178
|
+
Number(style.opacity || "1") === 0
|
|
179
|
+
) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const rect = element.getBoundingClientRect();
|
|
184
|
+
if (rect.width <= 0 || rect.height <= 0) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
rect.bottom > 0 &&
|
|
190
|
+
rect.right > 0 &&
|
|
191
|
+
rect.top < window.innerHeight &&
|
|
192
|
+
rect.left < window.innerWidth
|
|
193
|
+
);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const walker = document.createTreeWalker(
|
|
197
|
+
document.body,
|
|
198
|
+
NodeFilter.SHOW_TEXT,
|
|
199
|
+
{
|
|
200
|
+
acceptNode(node) {
|
|
201
|
+
const text = node.textContent?.replace(/\s+/g, " ").trim();
|
|
202
|
+
if (!text) {
|
|
203
|
+
return NodeFilter.FILTER_REJECT;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const parent = node.parentElement;
|
|
207
|
+
if (!parent || !isElementVisibleInViewport(parent)) {
|
|
208
|
+
return NodeFilter.FILTER_REJECT;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const textParts = [];
|
|
217
|
+
while (walker.nextNode()) {
|
|
218
|
+
textParts.push(walker.currentNode.textContent || "");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return textParts.join(" ");
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
const matched = forbidText.find((candidate) =>
|
|
225
|
+
visibleText.includes(normalizeVisibleText(candidate)),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
if (!matched) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
throw new Error(`Forbidden visible text detected during capture: "${matched}"`);
|
|
233
|
+
}
|
|
234
|
+
|
|
138
235
|
/**
|
|
139
236
|
* Execute a page load with retry logic on error/timeout
|
|
140
237
|
* Uses the capture engine's error detection to identify failures and retry
|
|
@@ -251,13 +348,42 @@ async function executeWithRetry(engine, readySelector, options = {}) {
|
|
|
251
348
|
* @returns {Promise<{ok: boolean, message?: string}>}
|
|
252
349
|
*/
|
|
253
350
|
async function preflightAuthCheck(baseUrl, options = {}) {
|
|
254
|
-
const {
|
|
351
|
+
const {
|
|
352
|
+
storageStatePath,
|
|
353
|
+
viewport = { width: 1280, height: 720 },
|
|
354
|
+
authCheckUrl = "/app/projects",
|
|
355
|
+
} = options;
|
|
255
356
|
|
|
256
357
|
if (!storageStatePath || !fs.existsSync(storageStatePath)) {
|
|
257
358
|
return { ok: true }; // No session to verify
|
|
258
359
|
}
|
|
259
360
|
|
|
361
|
+
const sessionHealth = assessSessionHealth(storageStatePath, baseUrl);
|
|
362
|
+
if (!sessionHealth.compatible) {
|
|
363
|
+
const mismatchSummary =
|
|
364
|
+
sessionHealth.evidence.sourceOrigin ||
|
|
365
|
+
sessionHealth.evidence.storageOrigins[0] ||
|
|
366
|
+
sessionHealth.evidence.cookieDomains[0] ||
|
|
367
|
+
"another environment";
|
|
368
|
+
return {
|
|
369
|
+
ok: false,
|
|
370
|
+
message:
|
|
371
|
+
`Cached auth session does not match this environment (${mismatchSummary} -> ${sessionHealth.expectedOrigin || baseUrl}). ` +
|
|
372
|
+
"Run `reshot record` against this target to capture a fresh session.",
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
260
376
|
console.log(chalk.gray(" → Running auth pre-flight check..."));
|
|
377
|
+
if (sessionHealth.stale) {
|
|
378
|
+
console.log(
|
|
379
|
+
chalk.yellow(
|
|
380
|
+
` ⚠ Cached auth session is ${sessionHealth.ageMinutes}m old; verifying it before capture...`,
|
|
381
|
+
),
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
for (const warning of sessionHealth.warnings) {
|
|
385
|
+
console.log(chalk.gray(` ${warning}`));
|
|
386
|
+
}
|
|
261
387
|
|
|
262
388
|
const engine = new CaptureEngine({
|
|
263
389
|
baseUrl,
|
|
@@ -272,38 +398,75 @@ async function preflightAuthCheck(baseUrl, options = {}) {
|
|
|
272
398
|
try {
|
|
273
399
|
await engine.init();
|
|
274
400
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
timeout: 15000,
|
|
279
|
-
});
|
|
401
|
+
const authCheckTargets = Array.isArray(authCheckUrl)
|
|
402
|
+
? authCheckUrl
|
|
403
|
+
: [authCheckUrl];
|
|
280
404
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
405
|
+
for (const authTarget of authCheckTargets) {
|
|
406
|
+
const preflightPath = authTarget.startsWith("http")
|
|
407
|
+
? authTarget
|
|
408
|
+
: `${baseUrl}${authTarget}`;
|
|
284
409
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
};
|
|
291
|
-
}
|
|
410
|
+
// Navigate to a known authenticated page and validate session/data loading.
|
|
411
|
+
await engine.page.goto(preflightPath, {
|
|
412
|
+
waitUntil: "domcontentloaded",
|
|
413
|
+
timeout: 15000,
|
|
414
|
+
});
|
|
292
415
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
416
|
+
// Check for auth redirect using shared utility
|
|
417
|
+
const currentUrl = engine.page.url();
|
|
418
|
+
const isAuthRedirect = isAuthRedirectUrl(currentUrl);
|
|
296
419
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
420
|
+
if (isAuthRedirect) {
|
|
421
|
+
return {
|
|
422
|
+
ok: false,
|
|
423
|
+
message:
|
|
424
|
+
"Auth session expired. Run `reshot record` to capture a fresh session.",
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Also detect login page via DOM (catches SPA redirects where URL hasn't changed)
|
|
429
|
+
const hasLoginForm = await engine.page.evaluate(() => {
|
|
430
|
+
const h = document.querySelector("h1, h2");
|
|
431
|
+
return h && /sign\s*in|log\s*in/i.test(h.textContent);
|
|
432
|
+
}).catch(() => false);
|
|
433
|
+
if (hasLoginForm) {
|
|
434
|
+
return {
|
|
435
|
+
ok: false,
|
|
436
|
+
message:
|
|
437
|
+
"Auth session expired (login form detected). Run `reshot record` to refresh.",
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Wait for data to settle
|
|
442
|
+
await engine.page.waitForTimeout(3000);
|
|
443
|
+
await engine._waitForStability();
|
|
444
|
+
|
|
445
|
+
// Check for error state
|
|
446
|
+
const errorState = await engine._detectErrorState();
|
|
447
|
+
if (errorState.hasError) {
|
|
448
|
+
return {
|
|
449
|
+
ok: false,
|
|
450
|
+
message: `Auth session appears valid but data fetching failed (${errorState.errorType}). This usually means your JWT has expired. Run \`reshot record\` to refresh.`,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
304
453
|
}
|
|
305
454
|
|
|
306
|
-
|
|
455
|
+
// Save refreshed session back so scenarios use fresh cookies
|
|
456
|
+
if (storageStatePath && engine.context) {
|
|
457
|
+
try {
|
|
458
|
+
const refreshedState = await engine.context.storageState();
|
|
459
|
+
writeSessionArtifacts(storageStatePath, refreshedState, {
|
|
460
|
+
baseUrl,
|
|
461
|
+
pageUrl: engine.page?.url?.() || baseUrl,
|
|
462
|
+
});
|
|
463
|
+
console.log(chalk.green(" ✔ Auth pre-flight check passed (session refreshed)"));
|
|
464
|
+
} catch (_saveErr) {
|
|
465
|
+
console.log(chalk.green(" ✔ Auth pre-flight check passed"));
|
|
466
|
+
}
|
|
467
|
+
} else {
|
|
468
|
+
console.log(chalk.green(" ✔ Auth pre-flight check passed"));
|
|
469
|
+
}
|
|
307
470
|
return { ok: true };
|
|
308
471
|
} catch (e) {
|
|
309
472
|
// If the error is an auth redirect thrown by the engine, handle gracefully
|
|
@@ -407,6 +570,14 @@ async function retryInteractiveStep(engine, action, params, context) {
|
|
|
407
570
|
}
|
|
408
571
|
}
|
|
409
572
|
|
|
573
|
+
function promoteLastGotoUrl(lastGotoUrl, currentUrl) {
|
|
574
|
+
if (!currentUrl || currentUrl === "about:blank") {
|
|
575
|
+
return lastGotoUrl;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return currentUrl !== lastGotoUrl ? currentUrl : lastGotoUrl;
|
|
579
|
+
}
|
|
580
|
+
|
|
410
581
|
/**
|
|
411
582
|
* Calculate a perceptual hash for an image buffer
|
|
412
583
|
* This is a simple hash based on resizing the image to a small grid
|
|
@@ -768,6 +939,7 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
|
|
|
768
939
|
scenarioTimeout: scenario.scenarioTimeout,
|
|
769
940
|
errorSelectors: scenario.errorSelectors,
|
|
770
941
|
});
|
|
942
|
+
const forbidText = collectForbiddenText(options.globalQuality, scenario);
|
|
771
943
|
|
|
772
944
|
// Extract readySelector: prefer scenario-level, fall back to first waitForSelector step
|
|
773
945
|
let readySelector = scenario.readySelector || null;
|
|
@@ -843,6 +1015,7 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
|
|
|
843
1015
|
waitForReady: scenario.waitForReady || null, // Custom loading-state hook
|
|
844
1016
|
privacyConfig: hasPrivacy ? scenarioPrivacyConfig : null, // Privacy masking
|
|
845
1017
|
styleConfig: hasStyle ? scenarioStyleConfig : null, // Image beautification
|
|
1018
|
+
injectWorkspaceStore: scenario.needsWorkspaceInjection !== false,
|
|
846
1019
|
logger: quiet ? () => {} : (msg) => console.log(msg),
|
|
847
1020
|
});
|
|
848
1021
|
|
|
@@ -946,6 +1119,7 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
|
|
|
946
1119
|
});
|
|
947
1120
|
// Brief wait for CSS to apply
|
|
948
1121
|
await engine.page.waitForTimeout(50);
|
|
1122
|
+
await assertForbiddenTextAbsent(engine.page, forbidText);
|
|
949
1123
|
|
|
950
1124
|
let buffer = await engine.page.screenshot();
|
|
951
1125
|
|
|
@@ -1133,11 +1307,25 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
|
|
|
1133
1307
|
`Page error detected: ${errMsg}. The page rendered an error UI instead of expected content.`
|
|
1134
1308
|
);
|
|
1135
1309
|
} else if (retryResult.status === "timeout") {
|
|
1310
|
+
const currentUrl = engine.page.url();
|
|
1136
1311
|
console.log(
|
|
1137
1312
|
chalk.yellow(
|
|
1138
|
-
` ⚠ Ready selector not found after ${retryResult.attempts} attempt(s)
|
|
1313
|
+
` ⚠ Ready selector not found after ${retryResult.attempts} attempt(s): ${readySelector}`
|
|
1314
|
+
)
|
|
1315
|
+
);
|
|
1316
|
+
console.log(
|
|
1317
|
+
chalk.gray(` URL: ${currentUrl}`)
|
|
1318
|
+
);
|
|
1319
|
+
console.log(
|
|
1320
|
+
chalk.gray(
|
|
1321
|
+
` Hint: The page loaded but this selector does not exist. Check your readySelector in reshot.config.json.`
|
|
1139
1322
|
)
|
|
1140
1323
|
);
|
|
1324
|
+
throw new Error(
|
|
1325
|
+
`Scenario readySelector "${readySelector}" not found after ${retryResult.attempts} attempt(s). ` +
|
|
1326
|
+
`The page loaded at ${currentUrl} but the selector does not exist. ` +
|
|
1327
|
+
`Update readySelector in reshot.config.json or remove it to skip this check.`
|
|
1328
|
+
);
|
|
1141
1329
|
} else if (retryResult.attempts > 1) {
|
|
1142
1330
|
console.log(
|
|
1143
1331
|
chalk.green(
|
|
@@ -1253,6 +1441,11 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
|
|
|
1253
1441
|
}
|
|
1254
1442
|
}
|
|
1255
1443
|
|
|
1444
|
+
// Promote the current page URL after successful interactive steps so
|
|
1445
|
+
// retries restore the page we actually navigated to, not the last
|
|
1446
|
+
// explicit goto target.
|
|
1447
|
+
lastGotoUrl = promoteLastGotoUrl(lastGotoUrl, engine.page.url());
|
|
1448
|
+
|
|
1256
1449
|
// Wait for animations/transitions - longer wait for multi-step flows
|
|
1257
1450
|
const isMultiStep = script.length > 3;
|
|
1258
1451
|
await engine.page.waitForTimeout(isMultiStep ? 500 : 150);
|
|
@@ -1296,6 +1489,12 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
|
|
|
1296
1489
|
` Hint: If data isn't loading, run 'reshot record' to refresh your session`
|
|
1297
1490
|
)
|
|
1298
1491
|
);
|
|
1492
|
+
failedSteps.push({
|
|
1493
|
+
stepIndex: stepIndex + 1,
|
|
1494
|
+
action: "waitFor",
|
|
1495
|
+
target: params.target,
|
|
1496
|
+
error: errMsg,
|
|
1497
|
+
});
|
|
1299
1498
|
}
|
|
1300
1499
|
} else if (waitResult.status === "timeout") {
|
|
1301
1500
|
if (!isOptional) {
|
|
@@ -1309,9 +1508,14 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
|
|
|
1309
1508
|
` Hint: If content isn't loading, run 'reshot record' to refresh your session`
|
|
1310
1509
|
)
|
|
1311
1510
|
);
|
|
1511
|
+
failedSteps.push({
|
|
1512
|
+
stepIndex: stepIndex + 1,
|
|
1513
|
+
action: "waitFor",
|
|
1514
|
+
target: params.target,
|
|
1515
|
+
error: `Element not found within ${waitTimeout}ms`,
|
|
1516
|
+
});
|
|
1312
1517
|
}
|
|
1313
1518
|
}
|
|
1314
|
-
// Continue with next steps - the scenario may still capture partial state
|
|
1315
1519
|
continue;
|
|
1316
1520
|
}
|
|
1317
1521
|
|
|
@@ -1407,7 +1611,17 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
|
|
|
1407
1611
|
// Non-critical — don't fail the capture
|
|
1408
1612
|
}
|
|
1409
1613
|
|
|
1410
|
-
return {
|
|
1614
|
+
return {
|
|
1615
|
+
success: failedSteps.length === 0,
|
|
1616
|
+
assets,
|
|
1617
|
+
skippedSteps,
|
|
1618
|
+
duplicatesSkipped,
|
|
1619
|
+
failedSteps,
|
|
1620
|
+
retriedSteps,
|
|
1621
|
+
privacy: privacyMeta,
|
|
1622
|
+
style: styleMeta,
|
|
1623
|
+
diagnostics: engine.getDiagnostics(),
|
|
1624
|
+
};
|
|
1411
1625
|
})(); // End of scenarioExecution async IIFE
|
|
1412
1626
|
|
|
1413
1627
|
// Race scenario execution against timeout
|
|
@@ -1436,7 +1650,15 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
|
|
|
1436
1650
|
// Ignore
|
|
1437
1651
|
}
|
|
1438
1652
|
|
|
1439
|
-
return {
|
|
1653
|
+
return {
|
|
1654
|
+
success: false,
|
|
1655
|
+
error: error.message,
|
|
1656
|
+
assets,
|
|
1657
|
+
skippedSteps,
|
|
1658
|
+
failedSteps,
|
|
1659
|
+
retriedSteps,
|
|
1660
|
+
diagnostics: engine.getDiagnostics(),
|
|
1661
|
+
};
|
|
1440
1662
|
} finally {
|
|
1441
1663
|
await engine.close();
|
|
1442
1664
|
}
|
|
@@ -1931,12 +2153,17 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
|
|
|
1931
2153
|
|
|
1932
2154
|
await fs.writeFile(sentinelPath, buffer);
|
|
1933
2155
|
sentinelPaths.push({ index: sentinelIndex, label, path: sentinelPath });
|
|
2156
|
+
if (firstSentinelTimestamp === null) {
|
|
2157
|
+
firstSentinelTimestamp = (Date.now() - startTime) / 1000;
|
|
2158
|
+
debug(`First sentinel captured at ${firstSentinelTimestamp.toFixed(2)}s`);
|
|
2159
|
+
}
|
|
1934
2160
|
sentinelIndex++;
|
|
1935
2161
|
return sentinelPath;
|
|
1936
2162
|
}
|
|
1937
2163
|
|
|
1938
2164
|
// Capture initial state BEFORE first navigation (placeholder - actual capture after goto)
|
|
1939
2165
|
let hasNavigated = false;
|
|
2166
|
+
let firstSentinelTimestamp = null;
|
|
1940
2167
|
|
|
1941
2168
|
// Execute all steps and capture timeline
|
|
1942
2169
|
for (let stepIndex = 0; stepIndex < script.length; stepIndex++) {
|
|
@@ -2180,8 +2407,16 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
|
|
|
2180
2407
|
});
|
|
2181
2408
|
} catch (e) {
|
|
2182
2409
|
if (!isOptional) {
|
|
2410
|
+
const currentUrl = page.url();
|
|
2183
2411
|
console.warn(
|
|
2184
|
-
chalk.yellow(` ⚠
|
|
2412
|
+
chalk.yellow(` ⚠ Element not found: ${params.target}`)
|
|
2413
|
+
);
|
|
2414
|
+
console.warn(chalk.gray(` URL: ${currentUrl}`));
|
|
2415
|
+
console.warn(chalk.gray(` Timeout: ${waitTimeout}ms`));
|
|
2416
|
+
console.warn(
|
|
2417
|
+
chalk.gray(
|
|
2418
|
+
` Hint: Verify the selector exists on the page. Run 'reshot record' to inspect.`
|
|
2419
|
+
)
|
|
2185
2420
|
);
|
|
2186
2421
|
}
|
|
2187
2422
|
}
|
|
@@ -2292,20 +2527,32 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
|
|
|
2292
2527
|
)
|
|
2293
2528
|
);
|
|
2294
2529
|
|
|
2295
|
-
// Convert to MP4 with ffmpeg, trimming
|
|
2296
|
-
//
|
|
2297
|
-
const
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2530
|
+
// Convert to MP4 with ffmpeg, trimming blank loading frames from start
|
|
2531
|
+
// and excess frames from end
|
|
2532
|
+
const startOffset = Math.max(0, (firstSentinelTimestamp || 0) - 0.3);
|
|
2533
|
+
const endTimestamp = finalTimestamp + 0.5;
|
|
2534
|
+
const contentDuration = endTimestamp - startOffset;
|
|
2535
|
+
if (startOffset > 0) {
|
|
2536
|
+
console.log(
|
|
2537
|
+
chalk.cyan(
|
|
2538
|
+
` 📹 Converting to MP4 (${startOffset.toFixed(1)}s–${endTimestamp.toFixed(1)}s, ${contentDuration.toFixed(1)}s content)...`
|
|
2539
|
+
)
|
|
2540
|
+
);
|
|
2541
|
+
} else {
|
|
2542
|
+
console.log(
|
|
2543
|
+
chalk.cyan(
|
|
2544
|
+
` 📹 Converting to MP4 (trimmed to ${contentDuration.toFixed(1)}s)...`
|
|
2545
|
+
)
|
|
2546
|
+
);
|
|
2547
|
+
}
|
|
2548
|
+
debug(`Running ffmpeg: start=${startOffset}s, duration=${contentDuration}s`);
|
|
2304
2549
|
await runFFmpegConvert([
|
|
2550
|
+
"-ss",
|
|
2551
|
+
startOffset.toFixed(2),
|
|
2305
2552
|
"-i",
|
|
2306
2553
|
recordedVideoPath,
|
|
2307
2554
|
"-t",
|
|
2308
|
-
|
|
2555
|
+
contentDuration.toFixed(2),
|
|
2309
2556
|
"-c:v",
|
|
2310
2557
|
"libx264",
|
|
2311
2558
|
"-preset",
|
|
@@ -2469,6 +2716,7 @@ async function runScenarioWithEngine(scenario, options = {}) {
|
|
|
2469
2716
|
timeout = 30000,
|
|
2470
2717
|
variantsConfig = {}, // Universal variant configuration
|
|
2471
2718
|
storageStateData = null,
|
|
2719
|
+
globalQuality = null,
|
|
2472
2720
|
quiet = false,
|
|
2473
2721
|
} = options;
|
|
2474
2722
|
|
|
@@ -2479,6 +2727,7 @@ async function runScenarioWithEngine(scenario, options = {}) {
|
|
|
2479
2727
|
return runScenarioWithStepByStepCapture(scenario, {
|
|
2480
2728
|
...options,
|
|
2481
2729
|
variantsConfig,
|
|
2730
|
+
globalQuality,
|
|
2482
2731
|
});
|
|
2483
2732
|
}
|
|
2484
2733
|
|
|
@@ -2493,6 +2742,7 @@ async function runScenarioWithEngine(scenario, options = {}) {
|
|
|
2493
2742
|
// Legacy behavior: only capture explicit screenshot steps
|
|
2494
2743
|
// Resolve variant configuration for this scenario
|
|
2495
2744
|
const variantConfig = resolveVariantConfig(scenario, variantsConfig);
|
|
2745
|
+
const forbidText = collectForbiddenText(globalQuality, scenario);
|
|
2496
2746
|
|
|
2497
2747
|
// Extract crop configuration from scenario output settings
|
|
2498
2748
|
const outputConfig = scenario.output || {};
|
|
@@ -2545,18 +2795,20 @@ async function runScenarioWithEngine(scenario, options = {}) {
|
|
|
2545
2795
|
storageStatePath: hasSession ? sessionPath : null, // Use saved session if available
|
|
2546
2796
|
storageStateData, // Pre-loaded auth state
|
|
2547
2797
|
hideDevtools: true, // Always hide dev overlays in captures
|
|
2798
|
+
injectWorkspaceStore: scenario.needsWorkspaceInjection !== false,
|
|
2548
2799
|
logger: quiet ? () => {} : (msg) => console.log(msg),
|
|
2549
2800
|
});
|
|
2550
2801
|
|
|
2551
2802
|
try {
|
|
2552
2803
|
await engine.init();
|
|
2804
|
+
await assertForbiddenTextAbsent(engine.page, forbidText);
|
|
2553
2805
|
const assets = await engine.runScript(script);
|
|
2554
2806
|
|
|
2555
2807
|
if (!quiet) console.log(
|
|
2556
2808
|
chalk.green(`\n ✔ Scenario completed: ${assets.length} assets captured`)
|
|
2557
2809
|
);
|
|
2558
2810
|
|
|
2559
|
-
return { success: true, assets };
|
|
2811
|
+
return { success: true, assets, diagnostics: engine.getDiagnostics() };
|
|
2560
2812
|
} catch (error) {
|
|
2561
2813
|
console.error(chalk.red(`\n ❌ Scenario failed: ${error.message}`));
|
|
2562
2814
|
|
|
@@ -2576,7 +2828,7 @@ async function runScenarioWithEngine(scenario, options = {}) {
|
|
|
2576
2828
|
// Ignore screenshot errors
|
|
2577
2829
|
}
|
|
2578
2830
|
|
|
2579
|
-
return { success: false, error: error.message };
|
|
2831
|
+
return { success: false, error: error.message, diagnostics: engine.getDiagnostics() };
|
|
2580
2832
|
} finally {
|
|
2581
2833
|
await engine.close();
|
|
2582
2834
|
}
|
|
@@ -2764,6 +3016,47 @@ function detectOptimalConcurrency() {
|
|
|
2764
3016
|
return Math.max(1, optimal);
|
|
2765
3017
|
}
|
|
2766
3018
|
|
|
3019
|
+
function normalizeAuthPreflightTargets(value) {
|
|
3020
|
+
if (Array.isArray(value)) {
|
|
3021
|
+
return value.map((item) => String(item || "").trim()).filter(Boolean);
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
const normalized = String(value || "").trim();
|
|
3025
|
+
return normalized ? [normalized] : [];
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
function resolveAuthPreflightTargets(config, options = {}) {
|
|
3029
|
+
const { scenarioKeys = null } = options;
|
|
3030
|
+
const scenarios = config.scenarios || [];
|
|
3031
|
+
const selectedScenarios =
|
|
3032
|
+
Array.isArray(scenarioKeys) && scenarioKeys.length > 0
|
|
3033
|
+
? scenarios.filter((scenario) => scenarioKeys.includes(scenario.key))
|
|
3034
|
+
: scenarios;
|
|
3035
|
+
const liveAuthScenarios = selectedScenarios.filter(
|
|
3036
|
+
(scenario) => scenario.captureClass === "live-auth",
|
|
3037
|
+
);
|
|
3038
|
+
const configuredTargets = normalizeAuthPreflightTargets(
|
|
3039
|
+
config.target?.authPreflightUrls || config.target?.authPreflightUrl,
|
|
3040
|
+
);
|
|
3041
|
+
const scenarioTargets = liveAuthScenarios
|
|
3042
|
+
.map((scenario) => scenario.authPreflightUrl || scenario.url)
|
|
3043
|
+
.filter(Boolean);
|
|
3044
|
+
const targets = Array.from(
|
|
3045
|
+
new Set([
|
|
3046
|
+
...configuredTargets,
|
|
3047
|
+
...scenarioTargets,
|
|
3048
|
+
]),
|
|
3049
|
+
);
|
|
3050
|
+
|
|
3051
|
+
return {
|
|
3052
|
+
selectedScenarioKeys: selectedScenarios.map((scenario) => scenario.key),
|
|
3053
|
+
liveAuthScenarioKeys: liveAuthScenarios.map((scenario) => scenario.key),
|
|
3054
|
+
targets: targets.length > 0 || liveAuthScenarios.length === 0
|
|
3055
|
+
? targets
|
|
3056
|
+
: ["/app/projects"],
|
|
3057
|
+
};
|
|
3058
|
+
}
|
|
3059
|
+
|
|
2767
3060
|
/**
|
|
2768
3061
|
* Run all scenarios from config
|
|
2769
3062
|
*/
|
|
@@ -2779,6 +3072,17 @@ async function runAllScenarios(config, options = {}) {
|
|
|
2779
3072
|
|
|
2780
3073
|
console.log(chalk.cyan("🎬 Running capture scenarios...\n"));
|
|
2781
3074
|
|
|
3075
|
+
const scenarios = config.scenarios || [];
|
|
3076
|
+
const toRun =
|
|
3077
|
+
scenarioKeys?.length > 0
|
|
3078
|
+
? scenarios.filter((scenario) => scenarioKeys.includes(scenario.key))
|
|
3079
|
+
: scenarios;
|
|
3080
|
+
|
|
3081
|
+
if (toRun.length === 0) {
|
|
3082
|
+
console.log(chalk.yellow("No scenarios to run"));
|
|
3083
|
+
return { success: true, results: [] };
|
|
3084
|
+
}
|
|
3085
|
+
|
|
2782
3086
|
// Auto-sync session from CDP browser if available
|
|
2783
3087
|
// This allows captures to use the authenticated session from a running Chrome instance
|
|
2784
3088
|
try {
|
|
@@ -2820,10 +3124,10 @@ async function runAllScenarios(config, options = {}) {
|
|
|
2820
3124
|
|
|
2821
3125
|
// Run auth pre-flight check if any scenario requires auth
|
|
2822
3126
|
const captureConfig = getCaptureConfig(config.capture || {});
|
|
2823
|
-
const
|
|
2824
|
-
const
|
|
3127
|
+
const authPreflight = resolveAuthPreflightTargets(config, { scenarioKeys });
|
|
3128
|
+
const hasLiveAuthScenarios = authPreflight.liveAuthScenarioKeys.length > 0;
|
|
2825
3129
|
|
|
2826
|
-
if (captureConfig.preflightCheck &&
|
|
3130
|
+
if (captureConfig.preflightCheck && hasLiveAuthScenarios) {
|
|
2827
3131
|
const sessionPath = getDefaultSessionPath();
|
|
2828
3132
|
const hasSession = fs.existsSync(sessionPath);
|
|
2829
3133
|
if (hasSession) {
|
|
@@ -2832,6 +3136,7 @@ async function runAllScenarios(config, options = {}) {
|
|
|
2832
3136
|
{
|
|
2833
3137
|
storageStatePath: sessionPath,
|
|
2834
3138
|
viewport: config.viewport || { width: 1280, height: 720 },
|
|
3139
|
+
authCheckUrl: authPreflight.targets,
|
|
2835
3140
|
}
|
|
2836
3141
|
);
|
|
2837
3142
|
if (!preflightResult.ok) {
|
|
@@ -2841,17 +3146,6 @@ async function runAllScenarios(config, options = {}) {
|
|
|
2841
3146
|
}
|
|
2842
3147
|
}
|
|
2843
3148
|
|
|
2844
|
-
// Filter scenarios if keys provided
|
|
2845
|
-
const toRun =
|
|
2846
|
-
scenarioKeys?.length > 0
|
|
2847
|
-
? scenarios.filter((s) => scenarioKeys.includes(s.key))
|
|
2848
|
-
: scenarios;
|
|
2849
|
-
|
|
2850
|
-
if (toRun.length === 0) {
|
|
2851
|
-
console.log(chalk.yellow("No scenarios to run"));
|
|
2852
|
-
return { success: true, results: [] };
|
|
2853
|
-
}
|
|
2854
|
-
|
|
2855
3149
|
// Use shared timestamp if provided (for variant expansion), otherwise generate new one
|
|
2856
3150
|
const runTimestamp = sharedTimestamp || generateVersionTimestamp();
|
|
2857
3151
|
|
|
@@ -2947,6 +3241,7 @@ async function runAllScenarios(config, options = {}) {
|
|
|
2947
3241
|
runTimestamp, // Pass timestamp for templating
|
|
2948
3242
|
storageStateData: ssData,
|
|
2949
3243
|
quiet,
|
|
3244
|
+
globalQuality: config.quality || null,
|
|
2950
3245
|
noPrivacy: options.noPrivacy,
|
|
2951
3246
|
noStyle: options.noStyle,
|
|
2952
3247
|
});
|
|
@@ -3099,6 +3394,7 @@ async function runAllScenarios(config, options = {}) {
|
|
|
3099
3394
|
|
|
3100
3395
|
module.exports = {
|
|
3101
3396
|
convertLegacySteps,
|
|
3397
|
+
substituteUrlVariables,
|
|
3102
3398
|
runScenarioWithEngine,
|
|
3103
3399
|
runScenarioWithStepByStepCapture,
|
|
3104
3400
|
runScenarioWithVideoCapture,
|
|
@@ -3110,8 +3406,13 @@ module.exports = {
|
|
|
3110
3406
|
waitForVisualStability,
|
|
3111
3407
|
// Error detection & retry
|
|
3112
3408
|
retryInteractiveStep,
|
|
3409
|
+
promoteLastGotoUrl,
|
|
3113
3410
|
executeWithRetry,
|
|
3114
3411
|
preflightAuthCheck,
|
|
3412
|
+
resolveAuthPreflightTargets,
|
|
3413
|
+
collectForbiddenText,
|
|
3414
|
+
assertForbiddenTextAbsent,
|
|
3415
|
+
normalizeVisibleText,
|
|
3115
3416
|
// New exports for output templating
|
|
3116
3417
|
resolveScenarioOutputDir,
|
|
3117
3418
|
generateVersionTimestamp,
|