@reshotdev/screenshot 0.0.1-beta.1 → 0.0.1-beta.10
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 +305 -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,47 @@ 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(() => document.body?.innerText || ""),
|
|
169
|
+
);
|
|
170
|
+
const matched = forbidText.find((candidate) =>
|
|
171
|
+
visibleText.includes(normalizeVisibleText(candidate)),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
if (!matched) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
throw new Error(`Forbidden visible text detected during capture: "${matched}"`);
|
|
179
|
+
}
|
|
180
|
+
|
|
138
181
|
/**
|
|
139
182
|
* Execute a page load with retry logic on error/timeout
|
|
140
183
|
* Uses the capture engine's error detection to identify failures and retry
|
|
@@ -251,13 +294,42 @@ async function executeWithRetry(engine, readySelector, options = {}) {
|
|
|
251
294
|
* @returns {Promise<{ok: boolean, message?: string}>}
|
|
252
295
|
*/
|
|
253
296
|
async function preflightAuthCheck(baseUrl, options = {}) {
|
|
254
|
-
const {
|
|
297
|
+
const {
|
|
298
|
+
storageStatePath,
|
|
299
|
+
viewport = { width: 1280, height: 720 },
|
|
300
|
+
authCheckUrl = "/app/projects",
|
|
301
|
+
} = options;
|
|
255
302
|
|
|
256
303
|
if (!storageStatePath || !fs.existsSync(storageStatePath)) {
|
|
257
304
|
return { ok: true }; // No session to verify
|
|
258
305
|
}
|
|
259
306
|
|
|
307
|
+
const sessionHealth = assessSessionHealth(storageStatePath, baseUrl);
|
|
308
|
+
if (!sessionHealth.compatible) {
|
|
309
|
+
const mismatchSummary =
|
|
310
|
+
sessionHealth.evidence.sourceOrigin ||
|
|
311
|
+
sessionHealth.evidence.storageOrigins[0] ||
|
|
312
|
+
sessionHealth.evidence.cookieDomains[0] ||
|
|
313
|
+
"another environment";
|
|
314
|
+
return {
|
|
315
|
+
ok: false,
|
|
316
|
+
message:
|
|
317
|
+
`Cached auth session does not match this environment (${mismatchSummary} -> ${sessionHealth.expectedOrigin || baseUrl}). ` +
|
|
318
|
+
"Run `reshot record` against this target to capture a fresh session.",
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
260
322
|
console.log(chalk.gray(" → Running auth pre-flight check..."));
|
|
323
|
+
if (sessionHealth.stale) {
|
|
324
|
+
console.log(
|
|
325
|
+
chalk.yellow(
|
|
326
|
+
` ⚠ Cached auth session is ${sessionHealth.ageMinutes}m old; verifying it before capture...`,
|
|
327
|
+
),
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
for (const warning of sessionHealth.warnings) {
|
|
331
|
+
console.log(chalk.gray(` ${warning}`));
|
|
332
|
+
}
|
|
261
333
|
|
|
262
334
|
const engine = new CaptureEngine({
|
|
263
335
|
baseUrl,
|
|
@@ -272,38 +344,75 @@ async function preflightAuthCheck(baseUrl, options = {}) {
|
|
|
272
344
|
try {
|
|
273
345
|
await engine.init();
|
|
274
346
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
timeout: 15000,
|
|
279
|
-
});
|
|
347
|
+
const authCheckTargets = Array.isArray(authCheckUrl)
|
|
348
|
+
? authCheckUrl
|
|
349
|
+
: [authCheckUrl];
|
|
280
350
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
351
|
+
for (const authTarget of authCheckTargets) {
|
|
352
|
+
const preflightPath = authTarget.startsWith("http")
|
|
353
|
+
? authTarget
|
|
354
|
+
: `${baseUrl}${authTarget}`;
|
|
284
355
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
};
|
|
291
|
-
}
|
|
356
|
+
// Navigate to a known authenticated page and validate session/data loading.
|
|
357
|
+
await engine.page.goto(preflightPath, {
|
|
358
|
+
waitUntil: "domcontentloaded",
|
|
359
|
+
timeout: 15000,
|
|
360
|
+
});
|
|
292
361
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
362
|
+
// Check for auth redirect using shared utility
|
|
363
|
+
const currentUrl = engine.page.url();
|
|
364
|
+
const isAuthRedirect = isAuthRedirectUrl(currentUrl);
|
|
296
365
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
366
|
+
if (isAuthRedirect) {
|
|
367
|
+
return {
|
|
368
|
+
ok: false,
|
|
369
|
+
message:
|
|
370
|
+
"Auth session expired. Run `reshot record` to capture a fresh session.",
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Also detect login page via DOM (catches SPA redirects where URL hasn't changed)
|
|
375
|
+
const hasLoginForm = await engine.page.evaluate(() => {
|
|
376
|
+
const h = document.querySelector("h1, h2");
|
|
377
|
+
return h && /sign\s*in|log\s*in/i.test(h.textContent);
|
|
378
|
+
}).catch(() => false);
|
|
379
|
+
if (hasLoginForm) {
|
|
380
|
+
return {
|
|
381
|
+
ok: false,
|
|
382
|
+
message:
|
|
383
|
+
"Auth session expired (login form detected). Run `reshot record` to refresh.",
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Wait for data to settle
|
|
388
|
+
await engine.page.waitForTimeout(3000);
|
|
389
|
+
await engine._waitForStability();
|
|
390
|
+
|
|
391
|
+
// Check for error state
|
|
392
|
+
const errorState = await engine._detectErrorState();
|
|
393
|
+
if (errorState.hasError) {
|
|
394
|
+
return {
|
|
395
|
+
ok: false,
|
|
396
|
+
message: `Auth session appears valid but data fetching failed (${errorState.errorType}). This usually means your JWT has expired. Run \`reshot record\` to refresh.`,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
304
399
|
}
|
|
305
400
|
|
|
306
|
-
|
|
401
|
+
// Save refreshed session back so scenarios use fresh cookies
|
|
402
|
+
if (storageStatePath && engine.context) {
|
|
403
|
+
try {
|
|
404
|
+
const refreshedState = await engine.context.storageState();
|
|
405
|
+
writeSessionArtifacts(storageStatePath, refreshedState, {
|
|
406
|
+
baseUrl,
|
|
407
|
+
pageUrl: engine.page?.url?.() || baseUrl,
|
|
408
|
+
});
|
|
409
|
+
console.log(chalk.green(" ✔ Auth pre-flight check passed (session refreshed)"));
|
|
410
|
+
} catch (_saveErr) {
|
|
411
|
+
console.log(chalk.green(" ✔ Auth pre-flight check passed"));
|
|
412
|
+
}
|
|
413
|
+
} else {
|
|
414
|
+
console.log(chalk.green(" ✔ Auth pre-flight check passed"));
|
|
415
|
+
}
|
|
307
416
|
return { ok: true };
|
|
308
417
|
} catch (e) {
|
|
309
418
|
// If the error is an auth redirect thrown by the engine, handle gracefully
|
|
@@ -407,6 +516,14 @@ async function retryInteractiveStep(engine, action, params, context) {
|
|
|
407
516
|
}
|
|
408
517
|
}
|
|
409
518
|
|
|
519
|
+
function promoteLastGotoUrl(lastGotoUrl, currentUrl) {
|
|
520
|
+
if (!currentUrl || currentUrl === "about:blank") {
|
|
521
|
+
return lastGotoUrl;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return currentUrl !== lastGotoUrl ? currentUrl : lastGotoUrl;
|
|
525
|
+
}
|
|
526
|
+
|
|
410
527
|
/**
|
|
411
528
|
* Calculate a perceptual hash for an image buffer
|
|
412
529
|
* This is a simple hash based on resizing the image to a small grid
|
|
@@ -768,6 +885,7 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
|
|
|
768
885
|
scenarioTimeout: scenario.scenarioTimeout,
|
|
769
886
|
errorSelectors: scenario.errorSelectors,
|
|
770
887
|
});
|
|
888
|
+
const forbidText = collectForbiddenText(options.globalQuality, scenario);
|
|
771
889
|
|
|
772
890
|
// Extract readySelector: prefer scenario-level, fall back to first waitForSelector step
|
|
773
891
|
let readySelector = scenario.readySelector || null;
|
|
@@ -843,6 +961,7 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
|
|
|
843
961
|
waitForReady: scenario.waitForReady || null, // Custom loading-state hook
|
|
844
962
|
privacyConfig: hasPrivacy ? scenarioPrivacyConfig : null, // Privacy masking
|
|
845
963
|
styleConfig: hasStyle ? scenarioStyleConfig : null, // Image beautification
|
|
964
|
+
injectWorkspaceStore: scenario.needsWorkspaceInjection !== false,
|
|
846
965
|
logger: quiet ? () => {} : (msg) => console.log(msg),
|
|
847
966
|
});
|
|
848
967
|
|
|
@@ -946,6 +1065,7 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
|
|
|
946
1065
|
});
|
|
947
1066
|
// Brief wait for CSS to apply
|
|
948
1067
|
await engine.page.waitForTimeout(50);
|
|
1068
|
+
await assertForbiddenTextAbsent(engine.page, forbidText);
|
|
949
1069
|
|
|
950
1070
|
let buffer = await engine.page.screenshot();
|
|
951
1071
|
|
|
@@ -1133,11 +1253,25 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
|
|
|
1133
1253
|
`Page error detected: ${errMsg}. The page rendered an error UI instead of expected content.`
|
|
1134
1254
|
);
|
|
1135
1255
|
} else if (retryResult.status === "timeout") {
|
|
1256
|
+
const currentUrl = engine.page.url();
|
|
1136
1257
|
console.log(
|
|
1137
1258
|
chalk.yellow(
|
|
1138
|
-
` ⚠ Ready selector not found after ${retryResult.attempts} attempt(s)
|
|
1259
|
+
` ⚠ Ready selector not found after ${retryResult.attempts} attempt(s): ${readySelector}`
|
|
1260
|
+
)
|
|
1261
|
+
);
|
|
1262
|
+
console.log(
|
|
1263
|
+
chalk.gray(` URL: ${currentUrl}`)
|
|
1264
|
+
);
|
|
1265
|
+
console.log(
|
|
1266
|
+
chalk.gray(
|
|
1267
|
+
` Hint: The page loaded but this selector does not exist. Check your readySelector in reshot.config.json.`
|
|
1139
1268
|
)
|
|
1140
1269
|
);
|
|
1270
|
+
throw new Error(
|
|
1271
|
+
`Scenario readySelector "${readySelector}" not found after ${retryResult.attempts} attempt(s). ` +
|
|
1272
|
+
`The page loaded at ${currentUrl} but the selector does not exist. ` +
|
|
1273
|
+
`Update readySelector in reshot.config.json or remove it to skip this check.`
|
|
1274
|
+
);
|
|
1141
1275
|
} else if (retryResult.attempts > 1) {
|
|
1142
1276
|
console.log(
|
|
1143
1277
|
chalk.green(
|
|
@@ -1253,6 +1387,11 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
|
|
|
1253
1387
|
}
|
|
1254
1388
|
}
|
|
1255
1389
|
|
|
1390
|
+
// Promote the current page URL after successful interactive steps so
|
|
1391
|
+
// retries restore the page we actually navigated to, not the last
|
|
1392
|
+
// explicit goto target.
|
|
1393
|
+
lastGotoUrl = promoteLastGotoUrl(lastGotoUrl, engine.page.url());
|
|
1394
|
+
|
|
1256
1395
|
// Wait for animations/transitions - longer wait for multi-step flows
|
|
1257
1396
|
const isMultiStep = script.length > 3;
|
|
1258
1397
|
await engine.page.waitForTimeout(isMultiStep ? 500 : 150);
|
|
@@ -1296,6 +1435,12 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
|
|
|
1296
1435
|
` Hint: If data isn't loading, run 'reshot record' to refresh your session`
|
|
1297
1436
|
)
|
|
1298
1437
|
);
|
|
1438
|
+
failedSteps.push({
|
|
1439
|
+
stepIndex: stepIndex + 1,
|
|
1440
|
+
action: "waitFor",
|
|
1441
|
+
target: params.target,
|
|
1442
|
+
error: errMsg,
|
|
1443
|
+
});
|
|
1299
1444
|
}
|
|
1300
1445
|
} else if (waitResult.status === "timeout") {
|
|
1301
1446
|
if (!isOptional) {
|
|
@@ -1309,9 +1454,14 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
|
|
|
1309
1454
|
` Hint: If content isn't loading, run 'reshot record' to refresh your session`
|
|
1310
1455
|
)
|
|
1311
1456
|
);
|
|
1457
|
+
failedSteps.push({
|
|
1458
|
+
stepIndex: stepIndex + 1,
|
|
1459
|
+
action: "waitFor",
|
|
1460
|
+
target: params.target,
|
|
1461
|
+
error: `Element not found within ${waitTimeout}ms`,
|
|
1462
|
+
});
|
|
1312
1463
|
}
|
|
1313
1464
|
}
|
|
1314
|
-
// Continue with next steps - the scenario may still capture partial state
|
|
1315
1465
|
continue;
|
|
1316
1466
|
}
|
|
1317
1467
|
|
|
@@ -1407,7 +1557,17 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
|
|
|
1407
1557
|
// Non-critical — don't fail the capture
|
|
1408
1558
|
}
|
|
1409
1559
|
|
|
1410
|
-
return {
|
|
1560
|
+
return {
|
|
1561
|
+
success: failedSteps.length === 0,
|
|
1562
|
+
assets,
|
|
1563
|
+
skippedSteps,
|
|
1564
|
+
duplicatesSkipped,
|
|
1565
|
+
failedSteps,
|
|
1566
|
+
retriedSteps,
|
|
1567
|
+
privacy: privacyMeta,
|
|
1568
|
+
style: styleMeta,
|
|
1569
|
+
diagnostics: engine.getDiagnostics(),
|
|
1570
|
+
};
|
|
1411
1571
|
})(); // End of scenarioExecution async IIFE
|
|
1412
1572
|
|
|
1413
1573
|
// Race scenario execution against timeout
|
|
@@ -1436,7 +1596,15 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
|
|
|
1436
1596
|
// Ignore
|
|
1437
1597
|
}
|
|
1438
1598
|
|
|
1439
|
-
return {
|
|
1599
|
+
return {
|
|
1600
|
+
success: false,
|
|
1601
|
+
error: error.message,
|
|
1602
|
+
assets,
|
|
1603
|
+
skippedSteps,
|
|
1604
|
+
failedSteps,
|
|
1605
|
+
retriedSteps,
|
|
1606
|
+
diagnostics: engine.getDiagnostics(),
|
|
1607
|
+
};
|
|
1440
1608
|
} finally {
|
|
1441
1609
|
await engine.close();
|
|
1442
1610
|
}
|
|
@@ -1931,12 +2099,17 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
|
|
|
1931
2099
|
|
|
1932
2100
|
await fs.writeFile(sentinelPath, buffer);
|
|
1933
2101
|
sentinelPaths.push({ index: sentinelIndex, label, path: sentinelPath });
|
|
2102
|
+
if (firstSentinelTimestamp === null) {
|
|
2103
|
+
firstSentinelTimestamp = (Date.now() - startTime) / 1000;
|
|
2104
|
+
debug(`First sentinel captured at ${firstSentinelTimestamp.toFixed(2)}s`);
|
|
2105
|
+
}
|
|
1934
2106
|
sentinelIndex++;
|
|
1935
2107
|
return sentinelPath;
|
|
1936
2108
|
}
|
|
1937
2109
|
|
|
1938
2110
|
// Capture initial state BEFORE first navigation (placeholder - actual capture after goto)
|
|
1939
2111
|
let hasNavigated = false;
|
|
2112
|
+
let firstSentinelTimestamp = null;
|
|
1940
2113
|
|
|
1941
2114
|
// Execute all steps and capture timeline
|
|
1942
2115
|
for (let stepIndex = 0; stepIndex < script.length; stepIndex++) {
|
|
@@ -2180,8 +2353,16 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
|
|
|
2180
2353
|
});
|
|
2181
2354
|
} catch (e) {
|
|
2182
2355
|
if (!isOptional) {
|
|
2356
|
+
const currentUrl = page.url();
|
|
2183
2357
|
console.warn(
|
|
2184
|
-
chalk.yellow(` ⚠
|
|
2358
|
+
chalk.yellow(` ⚠ Element not found: ${params.target}`)
|
|
2359
|
+
);
|
|
2360
|
+
console.warn(chalk.gray(` URL: ${currentUrl}`));
|
|
2361
|
+
console.warn(chalk.gray(` Timeout: ${waitTimeout}ms`));
|
|
2362
|
+
console.warn(
|
|
2363
|
+
chalk.gray(
|
|
2364
|
+
` Hint: Verify the selector exists on the page. Run 'reshot record' to inspect.`
|
|
2365
|
+
)
|
|
2185
2366
|
);
|
|
2186
2367
|
}
|
|
2187
2368
|
}
|
|
@@ -2292,20 +2473,32 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
|
|
|
2292
2473
|
)
|
|
2293
2474
|
);
|
|
2294
2475
|
|
|
2295
|
-
// Convert to MP4 with ffmpeg, trimming
|
|
2296
|
-
//
|
|
2297
|
-
const
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2476
|
+
// Convert to MP4 with ffmpeg, trimming blank loading frames from start
|
|
2477
|
+
// and excess frames from end
|
|
2478
|
+
const startOffset = Math.max(0, (firstSentinelTimestamp || 0) - 0.3);
|
|
2479
|
+
const endTimestamp = finalTimestamp + 0.5;
|
|
2480
|
+
const contentDuration = endTimestamp - startOffset;
|
|
2481
|
+
if (startOffset > 0) {
|
|
2482
|
+
console.log(
|
|
2483
|
+
chalk.cyan(
|
|
2484
|
+
` 📹 Converting to MP4 (${startOffset.toFixed(1)}s–${endTimestamp.toFixed(1)}s, ${contentDuration.toFixed(1)}s content)...`
|
|
2485
|
+
)
|
|
2486
|
+
);
|
|
2487
|
+
} else {
|
|
2488
|
+
console.log(
|
|
2489
|
+
chalk.cyan(
|
|
2490
|
+
` 📹 Converting to MP4 (trimmed to ${contentDuration.toFixed(1)}s)...`
|
|
2491
|
+
)
|
|
2492
|
+
);
|
|
2493
|
+
}
|
|
2494
|
+
debug(`Running ffmpeg: start=${startOffset}s, duration=${contentDuration}s`);
|
|
2304
2495
|
await runFFmpegConvert([
|
|
2496
|
+
"-ss",
|
|
2497
|
+
startOffset.toFixed(2),
|
|
2305
2498
|
"-i",
|
|
2306
2499
|
recordedVideoPath,
|
|
2307
2500
|
"-t",
|
|
2308
|
-
|
|
2501
|
+
contentDuration.toFixed(2),
|
|
2309
2502
|
"-c:v",
|
|
2310
2503
|
"libx264",
|
|
2311
2504
|
"-preset",
|
|
@@ -2469,6 +2662,7 @@ async function runScenarioWithEngine(scenario, options = {}) {
|
|
|
2469
2662
|
timeout = 30000,
|
|
2470
2663
|
variantsConfig = {}, // Universal variant configuration
|
|
2471
2664
|
storageStateData = null,
|
|
2665
|
+
globalQuality = null,
|
|
2472
2666
|
quiet = false,
|
|
2473
2667
|
} = options;
|
|
2474
2668
|
|
|
@@ -2479,6 +2673,7 @@ async function runScenarioWithEngine(scenario, options = {}) {
|
|
|
2479
2673
|
return runScenarioWithStepByStepCapture(scenario, {
|
|
2480
2674
|
...options,
|
|
2481
2675
|
variantsConfig,
|
|
2676
|
+
globalQuality,
|
|
2482
2677
|
});
|
|
2483
2678
|
}
|
|
2484
2679
|
|
|
@@ -2493,6 +2688,7 @@ async function runScenarioWithEngine(scenario, options = {}) {
|
|
|
2493
2688
|
// Legacy behavior: only capture explicit screenshot steps
|
|
2494
2689
|
// Resolve variant configuration for this scenario
|
|
2495
2690
|
const variantConfig = resolveVariantConfig(scenario, variantsConfig);
|
|
2691
|
+
const forbidText = collectForbiddenText(globalQuality, scenario);
|
|
2496
2692
|
|
|
2497
2693
|
// Extract crop configuration from scenario output settings
|
|
2498
2694
|
const outputConfig = scenario.output || {};
|
|
@@ -2545,18 +2741,20 @@ async function runScenarioWithEngine(scenario, options = {}) {
|
|
|
2545
2741
|
storageStatePath: hasSession ? sessionPath : null, // Use saved session if available
|
|
2546
2742
|
storageStateData, // Pre-loaded auth state
|
|
2547
2743
|
hideDevtools: true, // Always hide dev overlays in captures
|
|
2744
|
+
injectWorkspaceStore: scenario.needsWorkspaceInjection !== false,
|
|
2548
2745
|
logger: quiet ? () => {} : (msg) => console.log(msg),
|
|
2549
2746
|
});
|
|
2550
2747
|
|
|
2551
2748
|
try {
|
|
2552
2749
|
await engine.init();
|
|
2750
|
+
await assertForbiddenTextAbsent(engine.page, forbidText);
|
|
2553
2751
|
const assets = await engine.runScript(script);
|
|
2554
2752
|
|
|
2555
2753
|
if (!quiet) console.log(
|
|
2556
2754
|
chalk.green(`\n ✔ Scenario completed: ${assets.length} assets captured`)
|
|
2557
2755
|
);
|
|
2558
2756
|
|
|
2559
|
-
return { success: true, assets };
|
|
2757
|
+
return { success: true, assets, diagnostics: engine.getDiagnostics() };
|
|
2560
2758
|
} catch (error) {
|
|
2561
2759
|
console.error(chalk.red(`\n ❌ Scenario failed: ${error.message}`));
|
|
2562
2760
|
|
|
@@ -2576,7 +2774,7 @@ async function runScenarioWithEngine(scenario, options = {}) {
|
|
|
2576
2774
|
// Ignore screenshot errors
|
|
2577
2775
|
}
|
|
2578
2776
|
|
|
2579
|
-
return { success: false, error: error.message };
|
|
2777
|
+
return { success: false, error: error.message, diagnostics: engine.getDiagnostics() };
|
|
2580
2778
|
} finally {
|
|
2581
2779
|
await engine.close();
|
|
2582
2780
|
}
|
|
@@ -2764,6 +2962,47 @@ function detectOptimalConcurrency() {
|
|
|
2764
2962
|
return Math.max(1, optimal);
|
|
2765
2963
|
}
|
|
2766
2964
|
|
|
2965
|
+
function normalizeAuthPreflightTargets(value) {
|
|
2966
|
+
if (Array.isArray(value)) {
|
|
2967
|
+
return value.map((item) => String(item || "").trim()).filter(Boolean);
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
const normalized = String(value || "").trim();
|
|
2971
|
+
return normalized ? [normalized] : [];
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
function resolveAuthPreflightTargets(config, options = {}) {
|
|
2975
|
+
const { scenarioKeys = null } = options;
|
|
2976
|
+
const scenarios = config.scenarios || [];
|
|
2977
|
+
const selectedScenarios =
|
|
2978
|
+
Array.isArray(scenarioKeys) && scenarioKeys.length > 0
|
|
2979
|
+
? scenarios.filter((scenario) => scenarioKeys.includes(scenario.key))
|
|
2980
|
+
: scenarios;
|
|
2981
|
+
const liveAuthScenarios = selectedScenarios.filter(
|
|
2982
|
+
(scenario) => scenario.captureClass === "live-auth",
|
|
2983
|
+
);
|
|
2984
|
+
const configuredTargets = normalizeAuthPreflightTargets(
|
|
2985
|
+
config.target?.authPreflightUrls || config.target?.authPreflightUrl,
|
|
2986
|
+
);
|
|
2987
|
+
const scenarioTargets = liveAuthScenarios
|
|
2988
|
+
.map((scenario) => scenario.authPreflightUrl || scenario.url)
|
|
2989
|
+
.filter(Boolean);
|
|
2990
|
+
const targets = Array.from(
|
|
2991
|
+
new Set([
|
|
2992
|
+
...configuredTargets,
|
|
2993
|
+
...scenarioTargets,
|
|
2994
|
+
]),
|
|
2995
|
+
);
|
|
2996
|
+
|
|
2997
|
+
return {
|
|
2998
|
+
selectedScenarioKeys: selectedScenarios.map((scenario) => scenario.key),
|
|
2999
|
+
liveAuthScenarioKeys: liveAuthScenarios.map((scenario) => scenario.key),
|
|
3000
|
+
targets: targets.length > 0 || liveAuthScenarios.length === 0
|
|
3001
|
+
? targets
|
|
3002
|
+
: ["/app/projects"],
|
|
3003
|
+
};
|
|
3004
|
+
}
|
|
3005
|
+
|
|
2767
3006
|
/**
|
|
2768
3007
|
* Run all scenarios from config
|
|
2769
3008
|
*/
|
|
@@ -2779,6 +3018,17 @@ async function runAllScenarios(config, options = {}) {
|
|
|
2779
3018
|
|
|
2780
3019
|
console.log(chalk.cyan("🎬 Running capture scenarios...\n"));
|
|
2781
3020
|
|
|
3021
|
+
const scenarios = config.scenarios || [];
|
|
3022
|
+
const toRun =
|
|
3023
|
+
scenarioKeys?.length > 0
|
|
3024
|
+
? scenarios.filter((scenario) => scenarioKeys.includes(scenario.key))
|
|
3025
|
+
: scenarios;
|
|
3026
|
+
|
|
3027
|
+
if (toRun.length === 0) {
|
|
3028
|
+
console.log(chalk.yellow("No scenarios to run"));
|
|
3029
|
+
return { success: true, results: [] };
|
|
3030
|
+
}
|
|
3031
|
+
|
|
2782
3032
|
// Auto-sync session from CDP browser if available
|
|
2783
3033
|
// This allows captures to use the authenticated session from a running Chrome instance
|
|
2784
3034
|
try {
|
|
@@ -2820,10 +3070,10 @@ async function runAllScenarios(config, options = {}) {
|
|
|
2820
3070
|
|
|
2821
3071
|
// Run auth pre-flight check if any scenario requires auth
|
|
2822
3072
|
const captureConfig = getCaptureConfig(config.capture || {});
|
|
2823
|
-
const
|
|
2824
|
-
const
|
|
3073
|
+
const authPreflight = resolveAuthPreflightTargets(config, { scenarioKeys });
|
|
3074
|
+
const hasLiveAuthScenarios = authPreflight.liveAuthScenarioKeys.length > 0;
|
|
2825
3075
|
|
|
2826
|
-
if (captureConfig.preflightCheck &&
|
|
3076
|
+
if (captureConfig.preflightCheck && hasLiveAuthScenarios) {
|
|
2827
3077
|
const sessionPath = getDefaultSessionPath();
|
|
2828
3078
|
const hasSession = fs.existsSync(sessionPath);
|
|
2829
3079
|
if (hasSession) {
|
|
@@ -2832,6 +3082,7 @@ async function runAllScenarios(config, options = {}) {
|
|
|
2832
3082
|
{
|
|
2833
3083
|
storageStatePath: sessionPath,
|
|
2834
3084
|
viewport: config.viewport || { width: 1280, height: 720 },
|
|
3085
|
+
authCheckUrl: authPreflight.targets,
|
|
2835
3086
|
}
|
|
2836
3087
|
);
|
|
2837
3088
|
if (!preflightResult.ok) {
|
|
@@ -2841,17 +3092,6 @@ async function runAllScenarios(config, options = {}) {
|
|
|
2841
3092
|
}
|
|
2842
3093
|
}
|
|
2843
3094
|
|
|
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
3095
|
// Use shared timestamp if provided (for variant expansion), otherwise generate new one
|
|
2856
3096
|
const runTimestamp = sharedTimestamp || generateVersionTimestamp();
|
|
2857
3097
|
|
|
@@ -2947,6 +3187,7 @@ async function runAllScenarios(config, options = {}) {
|
|
|
2947
3187
|
runTimestamp, // Pass timestamp for templating
|
|
2948
3188
|
storageStateData: ssData,
|
|
2949
3189
|
quiet,
|
|
3190
|
+
globalQuality: config.quality || null,
|
|
2950
3191
|
noPrivacy: options.noPrivacy,
|
|
2951
3192
|
noStyle: options.noStyle,
|
|
2952
3193
|
});
|
|
@@ -3099,6 +3340,7 @@ async function runAllScenarios(config, options = {}) {
|
|
|
3099
3340
|
|
|
3100
3341
|
module.exports = {
|
|
3101
3342
|
convertLegacySteps,
|
|
3343
|
+
substituteUrlVariables,
|
|
3102
3344
|
runScenarioWithEngine,
|
|
3103
3345
|
runScenarioWithStepByStepCapture,
|
|
3104
3346
|
runScenarioWithVideoCapture,
|
|
@@ -3110,8 +3352,13 @@ module.exports = {
|
|
|
3110
3352
|
waitForVisualStability,
|
|
3111
3353
|
// Error detection & retry
|
|
3112
3354
|
retryInteractiveStep,
|
|
3355
|
+
promoteLastGotoUrl,
|
|
3113
3356
|
executeWithRetry,
|
|
3114
3357
|
preflightAuthCheck,
|
|
3358
|
+
resolveAuthPreflightTargets,
|
|
3359
|
+
collectForbiddenText,
|
|
3360
|
+
assertForbiddenTextAbsent,
|
|
3361
|
+
normalizeVisibleText,
|
|
3115
3362
|
// New exports for output templating
|
|
3116
3363
|
resolveScenarioOutputDir,
|
|
3117
3364
|
generateVersionTimestamp,
|