@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.
Files changed (38) hide show
  1. package/README.md +65 -7
  2. package/package.json +9 -2
  3. package/src/commands/auth.js +108 -26
  4. package/src/commands/certify.js +62 -0
  5. package/src/commands/ci-run.js +57 -2
  6. package/src/commands/ci-setup.js +5 -5
  7. package/src/commands/doctor-release.js +67 -0
  8. package/src/commands/doctor-target.js +49 -0
  9. package/src/commands/drifts.js +5 -70
  10. package/src/commands/import-tests.js +13 -13
  11. package/src/commands/ingest.js +10 -10
  12. package/src/commands/init.js +16 -277
  13. package/src/commands/publish.js +204 -237
  14. package/src/commands/pull.js +253 -23
  15. package/src/commands/run.js +292 -12
  16. package/src/commands/setup-wizard.js +277 -499
  17. package/src/commands/setup.js +41 -13
  18. package/src/commands/status.js +313 -125
  19. package/src/commands/sync.js +28 -236
  20. package/src/commands/ui.js +1 -1
  21. package/src/commands/verify-publish.js +46 -0
  22. package/src/index.js +194 -94
  23. package/src/lib/api-client.js +121 -35
  24. package/src/lib/capture-engine.js +103 -7
  25. package/src/lib/capture-script-runner.js +359 -58
  26. package/src/lib/certification.js +865 -0
  27. package/src/lib/config.js +181 -76
  28. package/src/lib/record-cdp.js +288 -16
  29. package/src/lib/record-config.js +1 -1
  30. package/src/lib/release-doctor.js +313 -0
  31. package/src/lib/run-manifest.js +103 -0
  32. package/src/lib/standalone-mode.js +1 -1
  33. package/src/lib/storage-providers.js +4 -4
  34. package/src/lib/target-contract.js +292 -0
  35. package/src/lib/ui-api.js +6 -7
  36. package/web/manager/dist/assets/{index--ZgioErz.js → index-D2qqcFNN.js} +1 -1
  37. package/web/manager/dist/index.html +1 -1
  38. 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 { storageStatePath, viewport = { width: 1280, height: 720 } } = options;
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
- // Navigate to projects page (a page that requires auth + data)
276
- await engine.page.goto(`${baseUrl}/app/projects`, {
277
- waitUntil: "domcontentloaded",
278
- timeout: 15000,
279
- });
401
+ const authCheckTargets = Array.isArray(authCheckUrl)
402
+ ? authCheckUrl
403
+ : [authCheckUrl];
280
404
 
281
- // Check for auth redirect using shared utility
282
- const currentUrl = engine.page.url();
283
- const isAuthRedirect = isAuthRedirectUrl(currentUrl);
405
+ for (const authTarget of authCheckTargets) {
406
+ const preflightPath = authTarget.startsWith("http")
407
+ ? authTarget
408
+ : `${baseUrl}${authTarget}`;
284
409
 
285
- if (isAuthRedirect) {
286
- return {
287
- ok: false,
288
- message:
289
- "Auth session expired. Run `reshot record` to capture a fresh session.",
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
- // Wait for data to settle
294
- await engine.page.waitForTimeout(3000);
295
- await engine._waitForStability();
416
+ // Check for auth redirect using shared utility
417
+ const currentUrl = engine.page.url();
418
+ const isAuthRedirect = isAuthRedirectUrl(currentUrl);
296
419
 
297
- // Check for error state
298
- const errorState = await engine._detectErrorState();
299
- if (errorState.hasError) {
300
- return {
301
- ok: false,
302
- message: `Auth session appears valid but data fetching failed (${errorState.errorType}). This usually means your JWT has expired. Run \`reshot record\` to refresh.`,
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
- console.log(chalk.green(" ✔ Auth pre-flight check passed"));
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), proceeding with current state`
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 { success: failedSteps.length === 0, assets, skippedSteps, duplicatesSkipped, failedSteps, retriedSteps, privacy: privacyMeta, style: styleMeta };
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 { success: false, error: error.message, assets, skippedSteps, failedSteps, retriedSteps };
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(` ⚠ Wait for ${params.target} timed out`)
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 to actual content duration
2296
- // Add a small buffer (0.5s) after the final action
2297
- const trimDuration = finalTimestamp + 0.5;
2298
- console.log(
2299
- chalk.cyan(
2300
- ` 📹 Converting to MP4 (trimmed to ${trimDuration.toFixed(1)}s)...`
2301
- )
2302
- );
2303
- debug(`Running ffmpeg conversion with trim to ${trimDuration}s...`);
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
- trimDuration.toFixed(2),
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 scenarios = config.scenarios || [];
2824
- const hasAuthScenarios = scenarios.some((s) => s.requiresAuth);
3127
+ const authPreflight = resolveAuthPreflightTargets(config, { scenarioKeys });
3128
+ const hasLiveAuthScenarios = authPreflight.liveAuthScenarioKeys.length > 0;
2825
3129
 
2826
- if (captureConfig.preflightCheck && hasAuthScenarios) {
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,