@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.
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 +305 -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,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 { storageStatePath, viewport = { width: 1280, height: 720 } } = options;
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
- // 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
- });
347
+ const authCheckTargets = Array.isArray(authCheckUrl)
348
+ ? authCheckUrl
349
+ : [authCheckUrl];
280
350
 
281
- // Check for auth redirect using shared utility
282
- const currentUrl = engine.page.url();
283
- const isAuthRedirect = isAuthRedirectUrl(currentUrl);
351
+ for (const authTarget of authCheckTargets) {
352
+ const preflightPath = authTarget.startsWith("http")
353
+ ? authTarget
354
+ : `${baseUrl}${authTarget}`;
284
355
 
285
- if (isAuthRedirect) {
286
- return {
287
- ok: false,
288
- message:
289
- "Auth session expired. Run `reshot record` to capture a fresh session.",
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
- // Wait for data to settle
294
- await engine.page.waitForTimeout(3000);
295
- await engine._waitForStability();
362
+ // Check for auth redirect using shared utility
363
+ const currentUrl = engine.page.url();
364
+ const isAuthRedirect = isAuthRedirectUrl(currentUrl);
296
365
 
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
- };
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
- console.log(chalk.green(" ✔ Auth pre-flight check passed"));
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), proceeding with current state`
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 { success: failedSteps.length === 0, assets, skippedSteps, duplicatesSkipped, failedSteps, retriedSteps, privacy: privacyMeta, style: styleMeta };
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 { success: false, error: error.message, assets, skippedSteps, failedSteps, retriedSteps };
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(` ⚠ Wait for ${params.target} timed out`)
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 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...`);
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
- trimDuration.toFixed(2),
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 scenarios = config.scenarios || [];
2824
- const hasAuthScenarios = scenarios.some((s) => s.requiresAuth);
3073
+ const authPreflight = resolveAuthPreflightTargets(config, { scenarioKeys });
3074
+ const hasLiveAuthScenarios = authPreflight.liveAuthScenarioKeys.length > 0;
2825
3075
 
2826
- if (captureConfig.preflightCheck && hasAuthScenarios) {
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,