@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
package/src/lib/config.js CHANGED
@@ -21,6 +21,11 @@ const {
21
21
  getConfigDefaults,
22
22
  validateCaptureRequirements,
23
23
  } = require("./standalone-mode");
24
+ const {
25
+ normalizeConfigContract,
26
+ validateNormalizedConfig,
27
+ getCertifiedScenarioKeys,
28
+ } = require("./target-contract");
24
29
 
25
30
  const SETTINGS_DIR = ".reshot";
26
31
  const SETTINGS_PATH = path.join(process.cwd(), SETTINGS_DIR, "settings.json");
@@ -64,7 +69,7 @@ function createAuthErrorResponse(message) {
64
69
  code: "AUTH_REQUIRED",
65
70
  };
66
71
  }
67
- const CONFIG_PATH = path.join(process.cwd(), "docsync.config.json");
72
+ const CONFIG_PATH = path.join(process.cwd(), "reshot.config.json");
68
73
  const WORKSPACE_PATH = path.join(process.cwd(), SETTINGS_DIR, "workspace.json");
69
74
 
70
75
  /**
@@ -279,13 +284,19 @@ function readConfig() {
279
284
  );
280
285
  }
281
286
 
282
- const config = fs.readJSONSync(CONFIG_PATH);
287
+ const rawConfig = fs.readJSONSync(CONFIG_PATH);
288
+ const config = normalizeConfigContract(rawConfig);
283
289
 
284
290
  // Validate required fields
285
291
  if (!config.scenarios || !Array.isArray(config.scenarios)) {
286
292
  throw new Error('Config must have a "scenarios" array');
287
293
  }
288
294
 
295
+ const targetValidation = validateNormalizedConfig(config);
296
+ if (!targetValidation.valid) {
297
+ throw new Error(targetValidation.errors.join("\n"));
298
+ }
299
+
289
300
  const validActions = [
290
301
  "click",
291
302
  "type",
@@ -470,84 +481,22 @@ function readConfig() {
470
481
  }
471
482
  }
472
483
 
473
- // Validate optional docs block
474
- if (config.docs !== undefined) {
475
- if (
476
- typeof config.docs !== "object" ||
477
- config.docs === null ||
478
- Array.isArray(config.docs)
479
- ) {
480
- throw new Error("docs must be an object");
481
- }
482
- if (
483
- config.docs.root !== undefined &&
484
- typeof config.docs.root !== "string"
485
- ) {
486
- throw new Error("docs.root must be a string");
487
- }
488
- if (
489
- config.docs.include !== undefined &&
490
- !Array.isArray(config.docs.include)
491
- ) {
492
- throw new Error("docs.include must be an array of strings");
493
- }
494
- if (
495
- config.docs.exclude !== undefined &&
496
- !Array.isArray(config.docs.exclude)
497
- ) {
498
- throw new Error("docs.exclude must be an array of strings");
499
- }
500
- }
501
-
502
484
  return config;
503
485
  }
504
486
 
505
487
  /**
506
- * Read docsync.config.json with DocSync-specific configuration
507
- * Returns the full config including the documentation block for ingestion
508
- * @returns {Object} DocSync configuration
488
+ * Read reshot.config.json without requiring scenarios array
489
+ * Used by sync and other commands that don't need scenario validation
490
+ * @returns {Object} Configuration
509
491
  */
510
- function readDocSyncConfig() {
492
+ function readConfigLenient() {
511
493
  if (!fs.existsSync(CONFIG_PATH)) {
512
494
  throw new Error(
513
495
  `Config file not found at ${CONFIG_PATH}. Run \`reshot init\` to create one.`
514
496
  );
515
497
  }
516
498
 
517
- const config = fs.readJSONSync(CONFIG_PATH);
518
-
519
- // Validate documentation block if present
520
- if (config.documentation) {
521
- const doc = config.documentation;
522
-
523
- // Validate required fields
524
- if (!doc.strategy) {
525
- throw new Error('documentation.strategy is required (git_pr or external_host)');
526
- }
527
-
528
- if (!['git_pr', 'external_host'].includes(doc.strategy)) {
529
- throw new Error('documentation.strategy must be "git_pr" or "external_host"');
530
- }
531
-
532
- // Validate optional fields
533
- if (doc.assetFormat && !['cdn_link', 'markdown'].includes(doc.assetFormat)) {
534
- throw new Error('documentation.assetFormat must be "cdn_link" or "markdown"');
535
- }
536
-
537
- if (doc.include && !Array.isArray(doc.include)) {
538
- throw new Error('documentation.include must be an array of glob patterns');
539
- }
540
-
541
- if (doc.exclude && !Array.isArray(doc.exclude)) {
542
- throw new Error('documentation.exclude must be an array of glob patterns');
543
- }
544
-
545
- if (doc.mappings && typeof doc.mappings !== 'object') {
546
- throw new Error('documentation.mappings must be an object');
547
- }
548
- }
549
-
550
- return config;
499
+ return normalizeConfigContract(fs.readJSONSync(CONFIG_PATH));
551
500
  }
552
501
 
553
502
  /**
@@ -555,7 +504,7 @@ function readDocSyncConfig() {
555
504
  * @param {Object} config - Config object to write
556
505
  */
557
506
  function writeConfig(config) {
558
- fs.writeJSONSync(CONFIG_PATH, config, { spaces: 2 });
507
+ fs.writeJSONSync(CONFIG_PATH, normalizeConfigContract(config), { spaces: 2 });
559
508
  }
560
509
 
561
510
  /**
@@ -851,8 +800,6 @@ async function initializeProject(projectId, apiKey, options = {}) {
851
800
  contextCount: 1,
852
801
  features: {
853
802
  visuals: true,
854
- docs: false,
855
- changelog: true,
856
803
  },
857
804
  },
858
805
  };
@@ -1040,19 +987,175 @@ function getModeInfo() {
1040
987
  };
1041
988
  }
1042
989
 
990
+ function readSettingsSafe() {
991
+ try {
992
+ return readSettings();
993
+ } catch (error) {
994
+ return null;
995
+ }
996
+ }
997
+
998
+ function collectTemplateVariables(value) {
999
+ if (typeof value !== "string" || value.length === 0) {
1000
+ return [];
1001
+ }
1002
+
1003
+ const variables = new Set();
1004
+ const regex = /\{\{(\w+)\}\}/g;
1005
+ let match = regex.exec(value);
1006
+
1007
+ while (match) {
1008
+ variables.add(match[1]);
1009
+ match = regex.exec(value);
1010
+ }
1011
+
1012
+ return [...variables];
1013
+ }
1014
+
1015
+ function hasDeterministicReadyContract(scenario) {
1016
+ if (!scenario || typeof scenario !== "object") {
1017
+ return false;
1018
+ }
1019
+
1020
+ if (scenario.readySelector) {
1021
+ return true;
1022
+ }
1023
+
1024
+ if (scenario.ready?.selector || scenario.ready?.expression) {
1025
+ return true;
1026
+ }
1027
+
1028
+ return Array.isArray(scenario.steps)
1029
+ ? scenario.steps.some(
1030
+ (step) => step?.action === "waitForSelector" && step?.selector,
1031
+ )
1032
+ : false;
1033
+ }
1034
+
1043
1035
  /**
1044
1036
  * Validate full configuration for capture readiness
1037
+ * @param {Object} [options] - Validation options
1038
+ * @param {string[]} [options.scenarioKeys] - Restrict validation to scenario keys
1039
+ * @param {boolean} [options.requireReadyContract] - Enforce deterministic readiness
1045
1040
  * @returns {Object} Validation result
1046
1041
  */
1047
- function validateConfig() {
1042
+ function validateConfig(options = {}) {
1043
+ const { scenarioKeys = null, requireReadyContract = false } = options;
1044
+
1048
1045
  try {
1049
1046
  const config = readConfig();
1050
- return validateCaptureRequirements(config);
1047
+ const baseResult = validateCaptureRequirements(config);
1048
+ const errors = [...baseResult.errors];
1049
+ const warnings = baseResult.warnings.filter(
1050
+ (warning) => warning !== "baseUrl should start with http:// or https://",
1051
+ );
1052
+ const scenarios = Array.isArray(config.scenarios) ? config.scenarios : [];
1053
+ const requestedScenarioKeys = Array.isArray(scenarioKeys)
1054
+ ? scenarioKeys.filter(Boolean)
1055
+ : [];
1056
+ const selectedScenarios = requestedScenarioKeys.length
1057
+ ? scenarios.filter((scenario) => requestedScenarioKeys.includes(scenario.key))
1058
+ : scenarios;
1059
+ const missingScenarioKeys = requestedScenarioKeys.filter(
1060
+ (key) => !scenarios.some((scenario) => scenario.key === key),
1061
+ );
1062
+
1063
+ if (missingScenarioKeys.length > 0) {
1064
+ errors.push(
1065
+ `Unknown scenario key(s): ${missingScenarioKeys.join(", ")}. ` +
1066
+ `Available scenarios: ${scenarios.map((scenario) => scenario.key).join(", ") || "none"}`,
1067
+ );
1068
+ }
1069
+
1070
+ if (config.baseUrl) {
1071
+ try {
1072
+ const parsedUrl = new URL(config.baseUrl);
1073
+ if (!["http:", "https:"].includes(parsedUrl.protocol)) {
1074
+ errors.push(
1075
+ `baseUrl must use http:// or https:// (received ${parsedUrl.protocol})`,
1076
+ );
1077
+ }
1078
+ } catch (error) {
1079
+ errors.push(
1080
+ `baseUrl must be a valid absolute URL. Received: ${config.baseUrl}`,
1081
+ );
1082
+ }
1083
+ }
1084
+
1085
+ const settings = readSettingsSafe();
1086
+ const configuredVariables = {
1087
+ ...(settings?.urlVariables || {}),
1088
+ };
1089
+ if (!configuredVariables.PROJECT_ID && settings?.projectId) {
1090
+ configuredVariables.PROJECT_ID = settings.projectId;
1091
+ }
1092
+
1093
+ const unresolvedVariables = [];
1094
+ for (const scenario of selectedScenarios) {
1095
+ for (const variableName of collectTemplateVariables(scenario.url)) {
1096
+ if (!configuredVariables[variableName] && !process.env[variableName]) {
1097
+ unresolvedVariables.push(`${scenario.key}:${variableName}`);
1098
+ }
1099
+ }
1100
+ }
1101
+
1102
+ if (unresolvedVariables.length > 0) {
1103
+ errors.push(
1104
+ `Unresolved URL variable(s): ${unresolvedVariables.join(", ")}. ` +
1105
+ `Set them in .reshot/settings.json under urlVariables or as environment variables.`,
1106
+ );
1107
+ }
1108
+
1109
+ const shouldRequireReadyContract =
1110
+ requireReadyContract || config.target?.tier === "certified";
1111
+ if (shouldRequireReadyContract) {
1112
+ const missingReadyContract = selectedScenarios
1113
+ .filter((scenario) => !hasDeterministicReadyContract(scenario))
1114
+ .map((scenario) => scenario.key);
1115
+
1116
+ if (missingReadyContract.length > 0) {
1117
+ errors.push(
1118
+ `Deterministic readiness is required for: ${missingReadyContract.join(", ")}. ` +
1119
+ `Add ready.selector, ready.expression, readySelector, or a waitForSelector step.`,
1120
+ );
1121
+ }
1122
+ }
1123
+
1124
+ const authScenarioCount = selectedScenarios.filter(
1125
+ (scenario) => scenario.captureClass === "live-auth" || scenario.requiresAuth,
1126
+ ).length;
1127
+ const liveAuthScenarioCount = selectedScenarios.filter(
1128
+ (scenario) => scenario.captureClass === "live-auth",
1129
+ ).length;
1130
+
1131
+ return {
1132
+ valid: errors.length === 0,
1133
+ errors,
1134
+ warnings,
1135
+ details: {
1136
+ baseUrl: config.baseUrl || null,
1137
+ selectedScenarioKeys: selectedScenarios.map((scenario) => scenario.key),
1138
+ missingScenarioKeys,
1139
+ unresolvedVariables,
1140
+ requiresReadyContract: shouldRequireReadyContract,
1141
+ authScenarioCount,
1142
+ liveAuthScenarioCount,
1143
+ },
1144
+ };
1051
1145
  } catch (e) {
1052
1146
  return {
1053
1147
  valid: false,
1054
1148
  errors: [e.message],
1055
1149
  warnings: [],
1150
+ details: {
1151
+ baseUrl: null,
1152
+ selectedScenarioKeys: [],
1153
+ missingScenarioKeys: [],
1154
+ unresolvedVariables: [],
1155
+ requiresReadyContract: requireReadyContract,
1156
+ authScenarioCount: 0,
1157
+ liveAuthScenarioCount: 0,
1158
+ },
1056
1159
  };
1057
1160
  }
1058
1161
  }
@@ -1230,8 +1333,10 @@ module.exports = {
1230
1333
  // Mode & validation
1231
1334
  getModeInfo,
1232
1335
  validateConfig,
1233
- // DocSync configuration
1234
- readDocSyncConfig,
1336
+ // Lenient config read (no scenario validation)
1337
+ readConfigLenient,
1338
+ // Certified target contract helpers
1339
+ getCertifiedScenarioKeys,
1235
1340
  // Paths
1236
1341
  SETTINGS_PATH,
1237
1342
  SETTINGS_DIR,
@@ -2,6 +2,9 @@
2
2
  const { chromium } = require("playwright");
3
3
  const chalk = require("chalk");
4
4
  const http = require("http");
5
+ const fs = require("fs-extra");
6
+ const path = require("path");
7
+ const os = require("os");
5
8
 
6
9
  /**
7
10
  * Check if Chrome CDP endpoint is available
@@ -418,15 +421,14 @@ async function saveSessionState(outputPath) {
418
421
  console.log(chalk.gray(` Sanitized cookies: ${stats.fixed} fixed, ${stats.removed} removed, ${stats.stripped} stripped`));
419
422
  }
420
423
 
421
- // Ensure directory exists
422
- const fs = require("fs-extra");
423
- const path = require("path");
424
- fs.ensureDirSync(path.dirname(outputPath));
425
-
426
- // Write storage state to file
427
- fs.writeJsonSync(outputPath, storageState, { spaces: 2 });
424
+ const activePageUrl = page?.url?.() || null;
425
+ const artifactInfo = writeSessionArtifacts(outputPath, storageState, {
426
+ pageUrl: activePageUrl,
427
+ baseUrl: activePageUrl,
428
+ });
428
429
 
429
430
  console.log(chalk.green(` ✔ Session saved to: ${outputPath}`));
431
+ console.log(chalk.gray(` Metadata: ${artifactInfo.metadataPath}`));
430
432
  console.log(chalk.gray(` Cookies: ${storageState.cookies?.length || 0}`));
431
433
  console.log(chalk.gray(` Origins with localStorage: ${storageState.origins?.length || 0}`));
432
434
 
@@ -445,11 +447,249 @@ async function saveSessionState(outputPath) {
445
447
  * @returns {string}
446
448
  */
447
449
  function getDefaultSessionPath() {
448
- const path = require("path");
449
- const os = require("os");
450
450
  return path.join(os.homedir(), ".reshot", "session-state.json");
451
451
  }
452
452
 
453
+ function getSessionMetadataPath(sessionPath = getDefaultSessionPath()) {
454
+ if (sessionPath.endsWith(".json")) {
455
+ return sessionPath.replace(/\.json$/i, ".meta.json");
456
+ }
457
+
458
+ return `${sessionPath}.meta.json`;
459
+ }
460
+
461
+ function normalizeOrigin(value) {
462
+ if (!value || typeof value !== "string") {
463
+ return null;
464
+ }
465
+
466
+ try {
467
+ return new URL(value).origin.toLowerCase();
468
+ } catch {
469
+ return null;
470
+ }
471
+ }
472
+
473
+ function normalizeCookieDomain(domain) {
474
+ if (!domain || typeof domain !== "string") {
475
+ return null;
476
+ }
477
+
478
+ return domain.replace(/^\./, "").toLowerCase();
479
+ }
480
+
481
+ function extractSessionEvidence(storageState) {
482
+ const storageOrigins = Array.from(
483
+ new Set(
484
+ (storageState?.origins || [])
485
+ .map((origin) => normalizeOrigin(origin?.origin))
486
+ .filter(Boolean),
487
+ ),
488
+ );
489
+
490
+ const cookieDomains = Array.from(
491
+ new Set(
492
+ (storageState?.cookies || [])
493
+ .map((cookie) => normalizeCookieDomain(cookie?.domain))
494
+ .filter(Boolean),
495
+ ),
496
+ );
497
+
498
+ return { storageOrigins, cookieDomains };
499
+ }
500
+
501
+ function buildSessionMetadata(options = {}) {
502
+ const {
503
+ baseUrl = null,
504
+ pageUrl = null,
505
+ storageState = null,
506
+ capturedAt = new Date().toISOString(),
507
+ } = options;
508
+ const evidence = extractSessionEvidence(storageState);
509
+
510
+ return {
511
+ version: 1,
512
+ capturedAt,
513
+ sourceUrl: pageUrl || baseUrl || null,
514
+ sourceOrigin:
515
+ normalizeOrigin(pageUrl) ||
516
+ normalizeOrigin(baseUrl) ||
517
+ evidence.storageOrigins[0] ||
518
+ null,
519
+ storageOrigins: evidence.storageOrigins,
520
+ cookieDomains: evidence.cookieDomains,
521
+ };
522
+ }
523
+
524
+ function writeSessionArtifacts(sessionPath, storageState, options = {}) {
525
+ const metadataPath = getSessionMetadataPath(sessionPath);
526
+ const metadata = buildSessionMetadata({ ...options, storageState });
527
+
528
+ fs.ensureDirSync(path.dirname(sessionPath));
529
+ fs.writeJsonSync(sessionPath, storageState, { spaces: 2 });
530
+ fs.writeJsonSync(metadataPath, metadata, { spaces: 2 });
531
+
532
+ return { sessionPath, metadataPath, metadata };
533
+ }
534
+
535
+ function readSessionMetadata(sessionPath = getDefaultSessionPath()) {
536
+ const metadataPath = getSessionMetadataPath(sessionPath);
537
+
538
+ if (!fs.existsSync(metadataPath)) {
539
+ return {
540
+ exists: false,
541
+ metadataPath,
542
+ metadata: null,
543
+ error: null,
544
+ };
545
+ }
546
+
547
+ try {
548
+ return {
549
+ exists: true,
550
+ metadataPath,
551
+ metadata: fs.readJsonSync(metadataPath),
552
+ error: null,
553
+ };
554
+ } catch (error) {
555
+ return {
556
+ exists: true,
557
+ metadataPath,
558
+ metadata: null,
559
+ error: error.message,
560
+ };
561
+ }
562
+ }
563
+
564
+ function sessionHostMatchesCookieDomains(hostname, cookieDomains) {
565
+ if (!hostname) {
566
+ return false;
567
+ }
568
+
569
+ return cookieDomains.some(
570
+ (domain) => hostname === domain || hostname.endsWith(`.${domain}`),
571
+ );
572
+ }
573
+
574
+ function assessSessionHealth(
575
+ sessionPath = getDefaultSessionPath(),
576
+ baseUrl = null,
577
+ options = {},
578
+ ) {
579
+ const maxAgeMinutes = Number.isFinite(options.maxAgeMinutes)
580
+ ? options.maxAgeMinutes
581
+ : 360;
582
+ const expectedOrigin = normalizeOrigin(baseUrl);
583
+ const result = {
584
+ ok: false,
585
+ exists: fs.existsSync(sessionPath),
586
+ sessionPath,
587
+ metadataPath: getSessionMetadataPath(sessionPath),
588
+ expectedOrigin,
589
+ compatible: true,
590
+ stale: false,
591
+ ageMinutes: null,
592
+ metadata: null,
593
+ warnings: [],
594
+ issues: [],
595
+ evidence: {
596
+ sourceOrigin: null,
597
+ storageOrigins: [],
598
+ cookieDomains: [],
599
+ matchSource: null,
600
+ },
601
+ };
602
+
603
+ if (!result.exists) {
604
+ return result;
605
+ }
606
+
607
+ let storageState = null;
608
+ try {
609
+ storageState = fs.readJsonSync(sessionPath);
610
+ } catch (error) {
611
+ result.compatible = false;
612
+ result.issues.push(`Cached session file is unreadable: ${error.message}`);
613
+ return result;
614
+ }
615
+
616
+ const metadataInfo = readSessionMetadata(sessionPath);
617
+ if (metadataInfo.error) {
618
+ result.warnings.push(`Session metadata is unreadable: ${metadataInfo.error}`);
619
+ } else if (!metadataInfo.exists) {
620
+ result.warnings.push(
621
+ "Session metadata is missing; compatibility was inferred from stored cookies and origins.",
622
+ );
623
+ }
624
+
625
+ const inferredEvidence = extractSessionEvidence(storageState);
626
+ const metadata = metadataInfo.metadata || null;
627
+ const storageOrigins =
628
+ metadata?.storageOrigins
629
+ ?.map((origin) => normalizeOrigin(origin))
630
+ .filter(Boolean) || inferredEvidence.storageOrigins;
631
+ const cookieDomains =
632
+ metadata?.cookieDomains
633
+ ?.map((domain) => normalizeCookieDomain(domain))
634
+ .filter(Boolean) || inferredEvidence.cookieDomains;
635
+ const sourceOrigin =
636
+ normalizeOrigin(metadata?.sourceOrigin) ||
637
+ normalizeOrigin(metadata?.sourceUrl) ||
638
+ inferredEvidence.storageOrigins[0] ||
639
+ null;
640
+
641
+ result.metadata = metadata;
642
+ result.evidence = {
643
+ sourceOrigin,
644
+ storageOrigins,
645
+ cookieDomains,
646
+ matchSource: null,
647
+ };
648
+
649
+ const capturedAtMs = Date.parse(metadata?.capturedAt || "");
650
+ const sessionStat = fs.statSync(sessionPath);
651
+ const referenceTime = Number.isFinite(capturedAtMs)
652
+ ? capturedAtMs
653
+ : sessionStat.mtimeMs;
654
+ result.ageMinutes = Math.max(
655
+ 0,
656
+ Math.round((Date.now() - referenceTime) / 60000),
657
+ );
658
+ result.stale = result.ageMinutes >= maxAgeMinutes;
659
+
660
+ if (expectedOrigin) {
661
+ const expectedHost = new URL(baseUrl).hostname.toLowerCase();
662
+ const matchesSourceOrigin = sourceOrigin === expectedOrigin;
663
+ const matchesStorageOrigin = storageOrigins.includes(expectedOrigin);
664
+ const matchesCookieDomain = sessionHostMatchesCookieDomains(
665
+ expectedHost,
666
+ cookieDomains,
667
+ );
668
+
669
+ result.evidence.matchSource = matchesSourceOrigin
670
+ ? "sourceOrigin"
671
+ : matchesStorageOrigin
672
+ ? "storageOrigins"
673
+ : matchesCookieDomain
674
+ ? "cookieDomains"
675
+ : null;
676
+
677
+ const hasEvidence =
678
+ Boolean(sourceOrigin) ||
679
+ storageOrigins.length > 0 ||
680
+ cookieDomains.length > 0;
681
+ if (hasEvidence && !result.evidence.matchSource) {
682
+ result.compatible = false;
683
+ result.issues.push(
684
+ `Cached session targets ${sourceOrigin || storageOrigins[0] || cookieDomains[0]}, not ${expectedOrigin}.`,
685
+ );
686
+ }
687
+ }
688
+
689
+ result.ok = result.compatible && result.issues.length === 0;
690
+ return result;
691
+ }
692
+
453
693
  /**
454
694
  * Quietly check if CDP is available and sync the session if so.
455
695
  * This is called automatically before captures to use the live browser session.
@@ -459,13 +699,24 @@ function getDefaultSessionPath() {
459
699
  */
460
700
  async function autoSyncSessionFromCDP(outputPath = null, logger = null) {
461
701
  const log = logger || (() => {}); // Quiet by default
462
- const fs = require("fs-extra");
463
- const path = require("path");
464
- const os = require("os");
465
-
702
+
703
+ // Allow disabling CDP sync via env var (useful when session was created programmatically)
704
+ if (process.env.RESHOT_SKIP_CDP_SYNC === "1") {
705
+ return { synced: false, reason: "disabled" };
706
+ }
707
+
466
708
  const sessionPath = outputPath || path.join(os.homedir(), ".reshot", "session-state.json");
467
-
709
+
468
710
  try {
711
+ // Skip sync if cached session is very recent (likely just generated programmatically)
712
+ if (fs.existsSync(sessionPath)) {
713
+ const cachedAge = Date.now() - fs.statSync(sessionPath).mtimeMs;
714
+ if (cachedAge < 5 * 60 * 1000) {
715
+ log(chalk.gray(" → Cached session is fresh (<5min), skipping CDP sync"));
716
+ return { synced: false, reason: "cached_fresh" };
717
+ }
718
+ }
719
+
469
720
  // Step 1: Check if CDP endpoint is available (quietly)
470
721
  const endpointCheck = await checkCdpEndpoint("localhost", 9222);
471
722
 
@@ -513,9 +764,25 @@ async function autoSyncSessionFromCDP(outputPath = null, logger = null) {
513
764
  return { synced: false, reason: "empty_session" };
514
765
  }
515
766
 
767
+ const activePage =
768
+ context
769
+ .pages()
770
+ .find((candidate) => {
771
+ const currentUrl = candidate.url();
772
+ return (
773
+ currentUrl &&
774
+ !currentUrl.startsWith("chrome://") &&
775
+ !currentUrl.startsWith("devtools://") &&
776
+ !currentUrl.startsWith("chrome-extension://")
777
+ );
778
+ }) || context.pages()[0] || null;
779
+ const activePageUrl = activePage?.url?.() || null;
780
+
516
781
  // Step 4: Save to file
517
- fs.ensureDirSync(path.dirname(sessionPath));
518
- fs.writeJsonSync(sessionPath, storageState, { spaces: 2 });
782
+ writeSessionArtifacts(sessionPath, storageState, {
783
+ pageUrl: activePageUrl,
784
+ baseUrl: activePageUrl,
785
+ });
519
786
 
520
787
  log(chalk.green(` ✔ Auto-synced session from CDP browser`));
521
788
  log(chalk.gray(` Cookies: ${storageState.cookies?.length || 0}, localStorage origins: ${storageState.origins?.length || 0}`));
@@ -607,6 +874,11 @@ module.exports = {
607
874
  printChromeInstructions,
608
875
  saveSessionState,
609
876
  getDefaultSessionPath,
877
+ getSessionMetadataPath,
610
878
  autoSyncSessionFromCDP,
611
879
  sanitizeStorageState,
880
+ buildSessionMetadata,
881
+ writeSessionArtifacts,
882
+ readSessionMetadata,
883
+ assessSessionHealth,
612
884
  };
@@ -600,7 +600,7 @@ async function finalizeScenarioAndWriteConfig(
600
600
 
601
601
  console.log(
602
602
  chalk.green(
603
- "\n✔ docsync.config.json has been updated. Please review and commit the changes to your repository.\n"
603
+ "\n✔ reshot.config.json has been updated. Please review and commit the changes to your repository.\n"
604
604
  )
605
605
  );
606
606
  console.log(chalk.gray(`Scenario: ${result.scenarioName}`));