@reshotdev/screenshot 0.0.1-beta.12 → 0.0.1-beta.14
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 +67 -22
- package/package.json +18 -14
- package/src/commands/auth.js +37 -7
- package/src/commands/capture-dom.js +50 -0
- package/src/commands/compose.js +220 -0
- package/src/commands/doctor-release.js +7 -0
- package/src/commands/doctor-target.js +36 -4
- package/src/commands/drifts.js +13 -1
- package/src/commands/publish.js +183 -21
- package/src/commands/pull.js +9 -4
- package/src/commands/refresh.js +166 -0
- package/src/commands/setup-wizard.js +57 -3
- package/src/commands/status.js +22 -2
- package/src/commands/variation.js +194 -0
- package/src/index.js +190 -10
- package/src/lib/api-client.js +61 -35
- package/src/lib/auto-update/refresh.js +598 -0
- package/src/lib/auto-update/scene-runtime.compose.tsx +73 -0
- package/src/lib/auto-update/spec.js +89 -0
- package/src/lib/capture-engine.js +76 -2
- package/src/lib/capture-script-runner.js +289 -138
- package/src/lib/certification.js +23 -1
- package/src/lib/compose-context.js +156 -0
- package/src/lib/compose-pack.js +42 -0
- package/src/lib/compose-runtime.js +34 -0
- package/src/lib/compose-upload.js +142 -0
- package/src/lib/config.js +2 -2
- package/src/lib/dom-capture.js +64 -0
- package/src/lib/ensure-browser.js +147 -0
- package/src/lib/record-clip.js +83 -3
- package/src/lib/record-config.js +0 -4
- package/src/lib/release-doctor.js +11 -3
- package/src/lib/resolve-targets.js +60 -0
- package/src/lib/run-manifest.js +45 -0
- package/src/lib/ui-api-helpers.js +118 -0
- package/src/lib/ui-api.js +28 -820
- package/src/lib/ui-asset-cleanup.js +62 -0
- package/src/lib/ui-output-versions.js +165 -0
- package/src/lib/ui-recorder-routes.js +341 -0
- package/src/lib/ui-scenario-metadata.js +161 -0
- package/vendor/compose/dist/auto-update.cjs +5544 -0
- package/vendor/compose/dist/auto-update.mjs +5518 -0
- package/vendor/compose/dist/capture.cjs +1450 -0
- package/vendor/compose/dist/capture.mjs +1416 -0
- package/vendor/compose/dist/eligibility.cjs +5331 -0
- package/vendor/compose/dist/eligibility.mjs +5313 -0
- package/vendor/compose/dist/index.cjs +2046 -0
- package/vendor/compose/dist/index.mjs +1997 -0
- package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
- package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
- package/vendor/compose/dist/jsx-runtime.cjs +58 -0
- package/vendor/compose/dist/jsx-runtime.mjs +31 -0
- package/vendor/compose/dist/render.cjs +558 -0
- package/vendor/compose/dist/render.mjs +515 -0
- package/vendor/compose/dist/verify-cli.cjs +3806 -0
- package/vendor/compose/dist/verify-cli.mjs +3812 -0
- package/vendor/compose/dist/verify.cjs +3880 -0
- package/vendor/compose/dist/verify.mjs +3858 -0
- package/web/manager/dist/assets/{index-CvleJUur.js → index-D0S2otug.js} +56 -56
- package/web/manager/dist/index.html +1 -1
- package/src/commands/ingest.js +0 -458
- package/src/commands/setup.js +0 -165
- package/src/lib/playwright-runner.js +0 -252
package/src/commands/publish.js
CHANGED
|
@@ -15,7 +15,10 @@ const {
|
|
|
15
15
|
getStorageMode,
|
|
16
16
|
isPlatformAvailable,
|
|
17
17
|
} = require("../lib/storage-providers");
|
|
18
|
-
const {
|
|
18
|
+
const {
|
|
19
|
+
getLatestSuccessfulRunManifest,
|
|
20
|
+
getLatestUsableRunManifest,
|
|
21
|
+
} = require("../lib/run-manifest");
|
|
19
22
|
const pkg = require("../../package.json");
|
|
20
23
|
|
|
21
24
|
// Check if transactional flow should be used (R2 configured on server)
|
|
@@ -505,9 +508,9 @@ function collectAssetFilesFromDirectories(directories) {
|
|
|
505
508
|
}
|
|
506
509
|
|
|
507
510
|
function resolveManifestScopedScreenshotFiles(outputBaseDir, latestRunManifest) {
|
|
508
|
-
if (!latestRunManifest
|
|
511
|
+
if (!latestRunManifest) {
|
|
509
512
|
throw new Error(
|
|
510
|
-
"No
|
|
513
|
+
"No run manifest is available. Run `reshot run` first or use `reshot publish --all-output` to publish from the full output tree.",
|
|
511
514
|
);
|
|
512
515
|
}
|
|
513
516
|
|
|
@@ -518,7 +521,7 @@ function resolveManifestScopedScreenshotFiles(outputBaseDir, latestRunManifest)
|
|
|
518
521
|
|
|
519
522
|
if (scenarioDirs.length === 0) {
|
|
520
523
|
throw new Error(
|
|
521
|
-
"The
|
|
524
|
+
"The run manifest does not contain any successful scenario output directories.",
|
|
522
525
|
);
|
|
523
526
|
}
|
|
524
527
|
|
|
@@ -592,6 +595,11 @@ function buildPublishMetadata({
|
|
|
592
595
|
}) {
|
|
593
596
|
const scenarioDefinition = buildScenarioDefinition(scenarioConfig);
|
|
594
597
|
|
|
598
|
+
// Only attach git metadata when a real commit hash is available. Sending
|
|
599
|
+
// empty git values when the repo has no HEAD causes the platform to reject
|
|
600
|
+
// the batch with an opaque 400.
|
|
601
|
+
const hasGit = !!(gitInfo && gitInfo.commitHash);
|
|
602
|
+
|
|
595
603
|
return {
|
|
596
604
|
projectId,
|
|
597
605
|
publishSessionId, // Unique ID for this CLI publish run
|
|
@@ -606,10 +614,14 @@ function buildPublishMetadata({
|
|
|
606
614
|
publish: {
|
|
607
615
|
autoApprove,
|
|
608
616
|
},
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
617
|
+
...(hasGit
|
|
618
|
+
? {
|
|
619
|
+
git: {
|
|
620
|
+
commitHash: gitInfo.commitHash,
|
|
621
|
+
commitMessage: gitInfo.commitMessage,
|
|
622
|
+
},
|
|
623
|
+
}
|
|
624
|
+
: {}),
|
|
613
625
|
cli: {
|
|
614
626
|
version: pkg.version,
|
|
615
627
|
captureTimestamp: new Date().toISOString(),
|
|
@@ -677,6 +689,52 @@ async function publishWithTransactionalFlow(
|
|
|
677
689
|
}
|
|
678
690
|
}
|
|
679
691
|
|
|
692
|
+
// ── DOM scene sidecars (MHTML bundles next to each PNG) ─────────────
|
|
693
|
+
// The CLI emits an .mhtml alongside each PNG when scenario.domScene !==
|
|
694
|
+
// false. We upload them as sidecar assets linked to the parent PNG's
|
|
695
|
+
// visual version via _parentVisualKey, so the server can persist the
|
|
696
|
+
// sidecar's s3Path on the same VisualVersion row.
|
|
697
|
+
const domSceneSidecars = [];
|
|
698
|
+
for (const f of allFiles) {
|
|
699
|
+
// Only attach MHTML to PNG screenshots (not videos, not other sidecars).
|
|
700
|
+
if (path.extname(f.path).toLowerCase() !== ".png") continue;
|
|
701
|
+
if (f._isThumbnail || f._isDomScene) continue;
|
|
702
|
+
|
|
703
|
+
const candidate =
|
|
704
|
+
(f.asset && f.asset.domScenePath) ||
|
|
705
|
+
f.path.replace(/\.png$/i, ".mhtml");
|
|
706
|
+
if (!fs.existsSync(candidate)) continue;
|
|
707
|
+
|
|
708
|
+
const sidecarStat = fs.statSync(candidate);
|
|
709
|
+
domSceneSidecars.push({
|
|
710
|
+
group: f.group,
|
|
711
|
+
asset: {
|
|
712
|
+
captureKey: f.asset.captureKey,
|
|
713
|
+
path: candidate,
|
|
714
|
+
filename: path.basename(candidate),
|
|
715
|
+
},
|
|
716
|
+
scenarioConfig: f.scenarioConfig,
|
|
717
|
+
key: f.key,
|
|
718
|
+
visualKey: f.visualKey,
|
|
719
|
+
path: candidate,
|
|
720
|
+
size: sidecarStat.size,
|
|
721
|
+
contentType: "multipart/related",
|
|
722
|
+
hash: null,
|
|
723
|
+
diffData: null,
|
|
724
|
+
thumbnailPath: null,
|
|
725
|
+
_isDomScene: true,
|
|
726
|
+
_parentVisualKey: f.visualKey,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
if (domSceneSidecars.length > 0) {
|
|
730
|
+
console.log(
|
|
731
|
+
chalk.gray(
|
|
732
|
+
` Attaching ${domSceneSidecars.length} DOM scene sidecar(s)...`,
|
|
733
|
+
),
|
|
734
|
+
);
|
|
735
|
+
allFiles.push(...domSceneSidecars);
|
|
736
|
+
}
|
|
737
|
+
|
|
680
738
|
// Generate thumbnails for video files (first frame as PNG)
|
|
681
739
|
const videoExts = new Set([".mp4", ".webm", ".mov"]);
|
|
682
740
|
const videoFiles = allFiles.filter((f) =>
|
|
@@ -776,7 +834,10 @@ async function publishWithTransactionalFlow(
|
|
|
776
834
|
|
|
777
835
|
// Step 3: Upload files directly to R2 (parallel with concurrency limit)
|
|
778
836
|
console.log(chalk.gray(" Uploading files directly to R2..."));
|
|
779
|
-
const CONCURRENCY =
|
|
837
|
+
const CONCURRENCY = Math.min(
|
|
838
|
+
Math.max(parseInt(process.env.RESHOT_UPLOAD_CONCURRENCY || "10", 10), 1),
|
|
839
|
+
20,
|
|
840
|
+
);
|
|
780
841
|
const uploadQueue = [...allFiles];
|
|
781
842
|
const uploadResults = [];
|
|
782
843
|
|
|
@@ -818,21 +879,33 @@ async function publishWithTransactionalFlow(
|
|
|
818
879
|
|
|
819
880
|
// Build a map of thumbnail s3Paths keyed by parent visualKey
|
|
820
881
|
const thumbnailS3Paths = new Map();
|
|
882
|
+
// Build a map of DOM-scene MHTML s3Paths keyed by parent visualKey.
|
|
883
|
+
// The server attaches these to the parent VisualVersion as a sidecar.
|
|
884
|
+
const domSceneS3Paths = new Map();
|
|
885
|
+
const domSceneSizes = new Map();
|
|
821
886
|
for (const result of uploadResults) {
|
|
822
|
-
if (result.success
|
|
887
|
+
if (!result.success) continue;
|
|
888
|
+
if (result.file._isThumbnail) {
|
|
823
889
|
thumbnailS3Paths.set(result.file._parentVisualKey, result.s3Path);
|
|
890
|
+
} else if (result.file._isDomScene) {
|
|
891
|
+
domSceneS3Paths.set(result.file._parentVisualKey, result.s3Path);
|
|
892
|
+
domSceneSizes.set(result.file._parentVisualKey, result.file.size);
|
|
824
893
|
}
|
|
825
894
|
}
|
|
826
895
|
|
|
827
|
-
// Group successful uploads by scenario/variant (skip thumbnails
|
|
896
|
+
// Group successful uploads by scenario/variant (skip thumbnails + DOM
|
|
897
|
+
// sidecars — they're metadata attached to their parent asset).
|
|
898
|
+
const failedUploadKeys = [];
|
|
828
899
|
const groupMap = new Map();
|
|
829
900
|
for (const result of uploadResults) {
|
|
830
901
|
if (!result.success) {
|
|
831
|
-
if (!result.file._isThumbnail)
|
|
902
|
+
if (!result.file._isThumbnail && !result.file._isDomScene) {
|
|
903
|
+
failCount++;
|
|
904
|
+
failedUploadKeys.push(result.file.visualKey);
|
|
905
|
+
}
|
|
832
906
|
continue;
|
|
833
907
|
}
|
|
834
|
-
|
|
835
|
-
if (result.file._isThumbnail) continue;
|
|
908
|
+
if (result.file._isThumbnail || result.file._isDomScene) continue;
|
|
836
909
|
|
|
837
910
|
const groupKey = `${result.file.group.scenarioKey}::${result.file.group.variationSlug}`;
|
|
838
911
|
if (!groupMap.has(groupKey)) {
|
|
@@ -854,6 +927,10 @@ async function publishWithTransactionalFlow(
|
|
|
854
927
|
diffStatus: result.file.diffData?.diffStatus ?? null,
|
|
855
928
|
// Attach thumbnail for video assets
|
|
856
929
|
thumbnailS3Path: thumbnailS3Paths.get(result.file.visualKey) ?? null,
|
|
930
|
+
// Attach DOM scene sidecar (MHTML) — the source of truth for
|
|
931
|
+
// marketing-team variations.
|
|
932
|
+
domSceneS3Path: domSceneS3Paths.get(result.file.visualKey) ?? null,
|
|
933
|
+
domSceneSize: domSceneSizes.get(result.file.visualKey) ?? null,
|
|
857
934
|
});
|
|
858
935
|
}
|
|
859
936
|
|
|
@@ -942,7 +1019,7 @@ async function publishWithTransactionalFlow(
|
|
|
942
1019
|
}
|
|
943
1020
|
}
|
|
944
1021
|
|
|
945
|
-
return { successCount, failCount, skippedCount, viewUrl };
|
|
1022
|
+
return { successCount, failCount, skippedCount, viewUrl, failedUploadKeys };
|
|
946
1023
|
}
|
|
947
1024
|
|
|
948
1025
|
/**
|
|
@@ -1217,14 +1294,28 @@ function getGitInfo() {
|
|
|
1217
1294
|
try {
|
|
1218
1295
|
const commitHash = execSync("git rev-parse HEAD", {
|
|
1219
1296
|
encoding: "utf-8",
|
|
1297
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1220
1298
|
}).trim();
|
|
1221
1299
|
const commitMessage = execSync("git log -1 --pretty=%B", {
|
|
1222
1300
|
encoding: "utf-8",
|
|
1301
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1223
1302
|
}).trim();
|
|
1224
|
-
return { commitHash, commitMessage };
|
|
1303
|
+
return { commitHash, commitMessage, hasCommit: !!commitHash };
|
|
1225
1304
|
} catch (error) {
|
|
1226
|
-
|
|
1227
|
-
|
|
1305
|
+
// No git HEAD — either not a git repo or a brand-new repo with no commits.
|
|
1306
|
+
// Proceed WITHOUT git metadata rather than sending empty values that the
|
|
1307
|
+
// platform rejects with an opaque 400 "Batch request failed".
|
|
1308
|
+
console.log(
|
|
1309
|
+
chalk.yellow(
|
|
1310
|
+
" ⚠ No git commit found — publishing without commit metadata.",
|
|
1311
|
+
),
|
|
1312
|
+
);
|
|
1313
|
+
console.log(
|
|
1314
|
+
chalk.gray(
|
|
1315
|
+
" Tip: run `git commit` first to attach commit info to this publish.",
|
|
1316
|
+
),
|
|
1317
|
+
);
|
|
1318
|
+
return { commitHash: "", commitMessage: "", hasCommit: false };
|
|
1228
1319
|
}
|
|
1229
1320
|
}
|
|
1230
1321
|
|
|
@@ -1359,9 +1450,23 @@ async function publishCommand(options = {}) {
|
|
|
1359
1450
|
};
|
|
1360
1451
|
|
|
1361
1452
|
if (!releaseDoctor.ok) {
|
|
1362
|
-
console.log(chalk.red(" ✖ Release doctor failed. Fix the
|
|
1453
|
+
console.log(chalk.red(" ✖ Release doctor failed. Fix the issues below before publishing:\n"));
|
|
1454
|
+
const blockingIssues = releaseDoctor.summary?.blockingIssues || [];
|
|
1455
|
+
if (blockingIssues.length > 0) {
|
|
1456
|
+
for (const issue of blockingIssues) {
|
|
1457
|
+
const scope = issue.scope ? `${issue.scope}: ` : "";
|
|
1458
|
+
console.log(chalk.red(` ✖ ${scope}${issue.message}`));
|
|
1459
|
+
}
|
|
1460
|
+
} else {
|
|
1461
|
+
console.log(chalk.red(" ✖ Release gate checks failed (see report for details)."));
|
|
1462
|
+
}
|
|
1463
|
+
const advisories = releaseDoctor.summary?.advisories || [];
|
|
1464
|
+
for (const advisory of advisories) {
|
|
1465
|
+
const scope = advisory.scope ? `${advisory.scope}: ` : "";
|
|
1466
|
+
console.log(chalk.yellow(` ⚠ ${scope}${advisory.message}`));
|
|
1467
|
+
}
|
|
1363
1468
|
if (releaseDoctor.reportPath) {
|
|
1364
|
-
console.log(chalk.gray(
|
|
1469
|
+
console.log(chalk.gray(`\n Full report: ${releaseDoctor.reportPath}`));
|
|
1365
1470
|
}
|
|
1366
1471
|
if (!noExit) process.exit(1);
|
|
1367
1472
|
return {
|
|
@@ -1482,7 +1587,37 @@ async function publishCommand(options = {}) {
|
|
|
1482
1587
|
if (allOutput) {
|
|
1483
1588
|
screenshotFiles = findAssetFiles(outputBaseDir);
|
|
1484
1589
|
} else {
|
|
1485
|
-
const
|
|
1590
|
+
const usable = getLatestUsableRunManifest();
|
|
1591
|
+
if (!usable) {
|
|
1592
|
+
throw new Error(
|
|
1593
|
+
"No run manifest with successful scenarios found. Run `reshot run` first or use `reshot publish --all-output` to publish from the full output tree.",
|
|
1594
|
+
);
|
|
1595
|
+
}
|
|
1596
|
+
const { manifest: latestRunManifest, isFallback, isPartialSuccess } = usable;
|
|
1597
|
+
|
|
1598
|
+
if (isFallback) {
|
|
1599
|
+
const age = latestRunManifest.generatedAt
|
|
1600
|
+
? `generated ${latestRunManifest.generatedAt}`
|
|
1601
|
+
: "unknown age";
|
|
1602
|
+
console.log(
|
|
1603
|
+
chalk.yellow(
|
|
1604
|
+
`\n WARNING: Latest run has no successful scenarios. Falling back to older manifest (${age}).` +
|
|
1605
|
+
`\n The published screenshots may be STALE. Run \`reshot run\` to capture fresh screenshots.\n`,
|
|
1606
|
+
),
|
|
1607
|
+
);
|
|
1608
|
+
} else if (isPartialSuccess) {
|
|
1609
|
+
const allScenarios = latestRunManifest.scenarios || [];
|
|
1610
|
+
const failed = allScenarios.filter((s) => s.success === false);
|
|
1611
|
+
const succeeded = allScenarios.filter((s) => s.success !== false);
|
|
1612
|
+
console.log(
|
|
1613
|
+
chalk.yellow(
|
|
1614
|
+
`\n WARNING: Latest run had partial failures (${succeeded.length} passed, ${failed.length} failed).` +
|
|
1615
|
+
`\n Publishing only the ${succeeded.length} successful scenario(s).` +
|
|
1616
|
+
`\n Failed: ${failed.map((s) => s.key).join(", ")}\n`,
|
|
1617
|
+
),
|
|
1618
|
+
);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1486
1621
|
const manifestScoped = resolveManifestScopedScreenshotFiles(
|
|
1487
1622
|
outputBaseDir,
|
|
1488
1623
|
latestRunManifest,
|
|
@@ -1627,11 +1762,38 @@ async function publishCommand(options = {}) {
|
|
|
1627
1762
|
}
|
|
1628
1763
|
if (failCount > 0) {
|
|
1629
1764
|
console.log(chalk.red(` ✖ Failed: ${failCount}`));
|
|
1765
|
+
const failedKeys = viewUrl ? [] : (publishResult.failedUploadKeys || []);
|
|
1766
|
+
if (failedKeys.length > 0) {
|
|
1767
|
+
for (const key of failedKeys.slice(0, 5)) {
|
|
1768
|
+
console.log(chalk.red(` - ${key}`));
|
|
1769
|
+
}
|
|
1770
|
+
if (failedKeys.length > 5) {
|
|
1771
|
+
console.log(chalk.red(` ... and ${failedKeys.length - 5} more`));
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1630
1774
|
}
|
|
1631
1775
|
if (viewUrl) {
|
|
1632
1776
|
console.log(chalk.cyan(`\n 🔗 View in platform: ${viewUrl}`));
|
|
1633
1777
|
}
|
|
1634
1778
|
|
|
1779
|
+
if (!autoApprove && successCount > 0) {
|
|
1780
|
+
console.log(
|
|
1781
|
+
chalk.cyan(
|
|
1782
|
+
`\n ℹ ${successCount} visual${successCount === 1 ? "" : "s"} awaiting review (PENDING)`,
|
|
1783
|
+
),
|
|
1784
|
+
);
|
|
1785
|
+
console.log(
|
|
1786
|
+
chalk.gray(
|
|
1787
|
+
" To skip review for first-time captures, re-run with `reshot publish --auto-approve`",
|
|
1788
|
+
),
|
|
1789
|
+
);
|
|
1790
|
+
console.log(
|
|
1791
|
+
chalk.gray(
|
|
1792
|
+
" or approve them in the studio link above.",
|
|
1793
|
+
),
|
|
1794
|
+
);
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1635
1797
|
// Helpful guidance about diff percentages
|
|
1636
1798
|
if (diffManifests && diffManifests.size > 0) {
|
|
1637
1799
|
console.log(
|
package/src/commands/pull.js
CHANGED
|
@@ -255,7 +255,7 @@ async function pullCommand(options = {}) {
|
|
|
255
255
|
format = "json",
|
|
256
256
|
output = null,
|
|
257
257
|
full = false,
|
|
258
|
-
status = "
|
|
258
|
+
status = "all",
|
|
259
259
|
noExit = false,
|
|
260
260
|
} = options;
|
|
261
261
|
|
|
@@ -273,7 +273,10 @@ async function pullCommand(options = {}) {
|
|
|
273
273
|
return { success: false, error: "No reshot.config.json found" };
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
-
let projectId =
|
|
276
|
+
let projectId =
|
|
277
|
+
process.env.RESHOT_PROJECT_ID ||
|
|
278
|
+
projectConfig._metadata?.projectId ||
|
|
279
|
+
projectConfig.projectId;
|
|
277
280
|
if (!projectId) {
|
|
278
281
|
// Fall back to reading projectId from .reshot/settings.json (written by `reshot auth`)
|
|
279
282
|
try {
|
|
@@ -315,7 +318,7 @@ async function pullCommand(options = {}) {
|
|
|
315
318
|
if (settings.projectId && projectId !== settings.projectId) {
|
|
316
319
|
console.warn(
|
|
317
320
|
chalk.yellow(
|
|
318
|
-
` ⚠ Project ID mismatch:
|
|
321
|
+
` ⚠ Project ID mismatch: active project is ${projectId}, but authenticated project is ${settings.projectId}.`
|
|
319
322
|
)
|
|
320
323
|
);
|
|
321
324
|
console.warn(
|
|
@@ -356,9 +359,11 @@ async function pullCommand(options = {}) {
|
|
|
356
359
|
console.log(chalk.gray(` Possible reasons:`));
|
|
357
360
|
if (status === "approved") {
|
|
358
361
|
console.log(chalk.gray(` • No visuals approved yet. Try: reshot pull --status all`));
|
|
362
|
+
} else if (status === "pending") {
|
|
363
|
+
console.log(chalk.gray(` • No pending visuals. Try: reshot pull --status all`));
|
|
359
364
|
}
|
|
360
365
|
console.log(chalk.gray(` • No visuals published. Run: reshot publish`));
|
|
361
|
-
console.log(chalk.gray(` • Wrong project.
|
|
366
|
+
console.log(chalk.gray(` • Wrong project. Active project: ${projectId}`));
|
|
362
367
|
console.log(chalk.gray(` • Check: ${settings.platformUrl || "https://reshot.dev"}\n`));
|
|
363
368
|
} else {
|
|
364
369
|
console.log(chalk.green(` ✓ Fetched ${assetCount} visuals\n`));
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// refresh.js — Phase 5 auto-update loop (CI-runnable).
|
|
2
|
+
//
|
|
3
|
+
// `reshot refresh --composition <id>` or `reshot refresh --project <id>`:
|
|
4
|
+
// recapture each composition's source screen, run the calibrated drift check, and
|
|
5
|
+
// either re-publish (data changed, structure-stable) or flag for human review
|
|
6
|
+
// (structural redesign / lost eligibility). Idempotent: a re-run with no source
|
|
7
|
+
// changes is a no-op (0 renders, 0 review items).
|
|
8
|
+
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const chalk = require("chalk");
|
|
11
|
+
const { refresh, registerComposition, setScene } = require("../lib/auto-update/refresh");
|
|
12
|
+
|
|
13
|
+
// Read + parse the --scene <path> JSON (the spec.scene render config: camera/motion
|
|
14
|
+
// /timeline/targets/...). Returns undefined when no path was given.
|
|
15
|
+
function readSceneFile(scenePath) {
|
|
16
|
+
if (!scenePath) return undefined;
|
|
17
|
+
let raw;
|
|
18
|
+
try {
|
|
19
|
+
raw = fs.readFileSync(scenePath, "utf8");
|
|
20
|
+
} catch (error) {
|
|
21
|
+
throw new Error(`--scene: cannot read ${scenePath}: ${error.message}`);
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(raw);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
throw new Error(`--scene: ${scenePath} is not valid JSON: ${error.message}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseViewport(spec) {
|
|
31
|
+
if (!spec) return undefined;
|
|
32
|
+
const m = /^(\d+)x(\d+)(?:@(\d+(?:\.\d+)?))?$/.exec(String(spec).trim());
|
|
33
|
+
if (!m) throw new Error(`Invalid --viewport "${spec}" (expected WxH or WxH@scale, e.g. 1280x900 or 1280x900@2)`);
|
|
34
|
+
return { width: Number(m[1]), height: Number(m[2]), deviceScaleFactor: m[3] ? Number(m[3]) : 2 };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Build the capture-auth config (stored on the spec, re-resolved on every recapture)
|
|
38
|
+
// so the loop can reach authenticated /app screens. --storage-state wins if both set.
|
|
39
|
+
function buildAuth(options) {
|
|
40
|
+
if (options.storageState) return { mode: "storage-state", path: options.storageState };
|
|
41
|
+
if (options.demoAuth) {
|
|
42
|
+
return { mode: "demo-bootstrap", email: typeof options.demoAuth === "string" ? options.demoAuth : "demo@example.com" };
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ACTION_LABEL = {
|
|
48
|
+
publish: chalk.green("published"),
|
|
49
|
+
flag: chalk.yellow("flagged"),
|
|
50
|
+
skip: chalk.gray("skipped"),
|
|
51
|
+
error: chalk.red("errored"),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function printSummary(result) {
|
|
55
|
+
console.log(chalk.cyan("\nReshot refresh — per-composition result\n"));
|
|
56
|
+
for (const s of result.summaries) {
|
|
57
|
+
const label = ACTION_LABEL[s.action] || s.action;
|
|
58
|
+
const metrics = s.metrics
|
|
59
|
+
? ` diff=${s.metrics.pixelDiffPct}% ssim=${s.metrics.ssim}`
|
|
60
|
+
: "";
|
|
61
|
+
console.log(
|
|
62
|
+
` ${label} ${chalk.bold(s.slug)} (${s.compositionId})\n` +
|
|
63
|
+
` route=${s.route} eligible=${s.eligible} structureStable=${s.structureStable} quality=${s.qualityPass}${metrics}\n` +
|
|
64
|
+
` ${chalk.gray(s.reason)}`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
console.log(
|
|
68
|
+
chalk.cyan(
|
|
69
|
+
`\nrendersCreated=${result.rendersCreated} reviewItemsCreated=${result.reviewItemsCreated} ` +
|
|
70
|
+
`published=${result.published} flagged=${result.flagged} skipped=${result.skipped} errors=${result.errors}`,
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function refreshCommand(options = {}) {
|
|
76
|
+
// Attach/update the crisp <Scene>+motion render config on an already-enrolled
|
|
77
|
+
// composition (so the loop renders it animated instead of the video fallback).
|
|
78
|
+
if (options.setScene) {
|
|
79
|
+
const result = await setScene(options.composition, readSceneFile(options.scene));
|
|
80
|
+
if (options.json) console.log(JSON.stringify(result, null, 2));
|
|
81
|
+
else console.log(chalk.green("scene set ") + chalk.bold(result.compositionId) + ` (render=${result.mode})`);
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (options.register) {
|
|
86
|
+
const result = await registerComposition({
|
|
87
|
+
compositionId: options.composition,
|
|
88
|
+
projectId: options.project || process.env.RESHOT_PROJECT_ID,
|
|
89
|
+
url: options.url,
|
|
90
|
+
viewport: parseViewport(options.viewport),
|
|
91
|
+
composePath: options.compose,
|
|
92
|
+
slug: options.slug,
|
|
93
|
+
name: options.name,
|
|
94
|
+
scene: readSceneFile(options.scene),
|
|
95
|
+
auth: buildAuth(options),
|
|
96
|
+
});
|
|
97
|
+
if (options.json) {
|
|
98
|
+
console.log(JSON.stringify(result, null, 2));
|
|
99
|
+
} else {
|
|
100
|
+
console.log(
|
|
101
|
+
chalk.green("registered ") +
|
|
102
|
+
chalk.bold(result.slug || result.compositionId) +
|
|
103
|
+
` (route=${result.route}, eligible=${result.eligible}, render=${result.render})`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!options.composition && !options.project) {
|
|
110
|
+
throw new Error("Pass --composition <id> or --project <id> (or --register to enroll one).");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const result = await refresh({
|
|
114
|
+
compositionId: options.composition,
|
|
115
|
+
projectId: options.project,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (options.json) {
|
|
119
|
+
console.log(JSON.stringify(result, null, 2));
|
|
120
|
+
} else {
|
|
121
|
+
printSummary(result);
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function registerRefresh(program) {
|
|
127
|
+
program
|
|
128
|
+
.command("refresh")
|
|
129
|
+
.description("Recapture composition source screens and auto re-publish or flag drift for review")
|
|
130
|
+
.option("--composition <id>", "Refresh a single composition by id")
|
|
131
|
+
.option("--project <id>", "Refresh every composition with a stored spec in this project")
|
|
132
|
+
.option("--register", "Enroll a composition into the loop: capture its source screen as the accepted baseline")
|
|
133
|
+
.option("--set-scene", "Attach/update the crisp <Scene>+motion render config on an enrolled composition (with --composition + --scene)")
|
|
134
|
+
.option("--scene <path>", "[--register|--set-scene] JSON file with the scene render config (camera/motion/timeline/targets); without it the loop renders video")
|
|
135
|
+
.option("--url <url>", "[--register] source screen URL to recapture")
|
|
136
|
+
.option("--viewport <WxH>", "[--register] capture viewport, e.g. 1280x900 or 1280x900@2")
|
|
137
|
+
.option("--compose <path>", "[--register] path to the composition's .compose.tsx for re-rendering on publish")
|
|
138
|
+
.option("--slug <slug>", "[--register] composition slug (for the upload route + public URL)")
|
|
139
|
+
.option("--name <name>", "[--register] composition display name")
|
|
140
|
+
.option("--demo-auth [email]", "[--register] authenticate the capture via the seeded demo bootstrap (default email demo@example.com) — reaches /app screens")
|
|
141
|
+
.option("--storage-state <path>", "[--register] authenticate the capture with a Playwright storageState JSON exported from a real session")
|
|
142
|
+
.option("--json", "Output the structured result as JSON")
|
|
143
|
+
.option("--fail-on-error", "Exit non-zero (2) if any composition errored — for CI gating")
|
|
144
|
+
.action(async (options) => {
|
|
145
|
+
try {
|
|
146
|
+
const result = await refreshCommand(options);
|
|
147
|
+
if (shouldFailOnError(options, result)) {
|
|
148
|
+
console.error(chalk.red(`Refresh completed with ${result.errors} error(s) (--fail-on-error).`));
|
|
149
|
+
process.exit(2);
|
|
150
|
+
}
|
|
151
|
+
} catch (error) {
|
|
152
|
+
console.error(chalk.red("Error:"), error.message);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// CI gating: a completed refresh that had per-composition errors (a screen failed
|
|
159
|
+
// to capture/upload) still exits 0 by default (the batch is isolated, fail-safe).
|
|
160
|
+
// With --fail-on-error, CI can treat that as a non-zero (exit 2) — distinct from a
|
|
161
|
+
// hard crash (exit 1). Pure + exported so it is unit-testable without process.exit.
|
|
162
|
+
function shouldFailOnError(options, result) {
|
|
163
|
+
return Boolean(options && options.failOnError && result && result.errors > 0);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = { refreshCommand, registerRefresh, printSummary, shouldFailOnError };
|
|
@@ -7,6 +7,16 @@ const fs = require("fs-extra");
|
|
|
7
7
|
const path = require("path");
|
|
8
8
|
const config = require("../lib/config");
|
|
9
9
|
const { normalizeConfigContract } = require("../lib/target-contract");
|
|
10
|
+
const { detectCI } = require("../lib/ci-detect");
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether the current process can prompt the user interactively.
|
|
14
|
+
* False in CI or when stdin is not a TTY (piped / redirected), where an
|
|
15
|
+
* inquirer prompt would throw `ERR_USE_AFTER_CLOSE: readline`.
|
|
16
|
+
*/
|
|
17
|
+
function isInteractive() {
|
|
18
|
+
return !!process.stdin.isTTY && !detectCI().isCI;
|
|
19
|
+
}
|
|
10
20
|
|
|
11
21
|
/**
|
|
12
22
|
* Detect if this is a Git repository and if it's GitHub
|
|
@@ -153,7 +163,13 @@ function writeSetupReport({
|
|
|
153
163
|
* Main setup wizard
|
|
154
164
|
*/
|
|
155
165
|
async function setupWizard(options = {}) {
|
|
156
|
-
const {
|
|
166
|
+
const {
|
|
167
|
+
offline = false,
|
|
168
|
+
force = false,
|
|
169
|
+
noStudio = false,
|
|
170
|
+
project: linkedProjectId,
|
|
171
|
+
token: linkedToken,
|
|
172
|
+
} = options;
|
|
157
173
|
|
|
158
174
|
console.log(chalk.cyan.bold("\n🚀 Reshot Setup\n"));
|
|
159
175
|
|
|
@@ -182,8 +198,35 @@ async function setupWizard(options = {}) {
|
|
|
182
198
|
// No existing config
|
|
183
199
|
}
|
|
184
200
|
|
|
201
|
+
let didLinkFromOptions = false;
|
|
202
|
+
if (linkedProjectId && linkedToken && !offline) {
|
|
203
|
+
const authCommand = require("./auth");
|
|
204
|
+
const authResult = await authCommand({
|
|
205
|
+
projectId: linkedProjectId,
|
|
206
|
+
apiKey: linkedToken,
|
|
207
|
+
});
|
|
208
|
+
existingSettings = config.readSettings();
|
|
209
|
+
isAlreadyAuthed = !!(
|
|
210
|
+
existingSettings?.apiKey && existingSettings?.projectId
|
|
211
|
+
);
|
|
212
|
+
didLinkFromOptions = true;
|
|
213
|
+
console.log(
|
|
214
|
+
chalk.green("\n✔ Connected to"),
|
|
215
|
+
chalk.cyan(existingSettings.projectName || existingSettings.projectId),
|
|
216
|
+
);
|
|
217
|
+
if (authResult?.mode) {
|
|
218
|
+
console.log(chalk.gray(` Mode: ${authResult.mode}`));
|
|
219
|
+
}
|
|
220
|
+
} else if (linkedProjectId && !linkedToken && !offline) {
|
|
221
|
+
console.log(
|
|
222
|
+
chalk.gray(
|
|
223
|
+
`Setup will target project ${linkedProjectId}. Complete browser authentication when prompted.`,
|
|
224
|
+
),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
185
228
|
// If already set up and not forcing, show status and offer options
|
|
186
|
-
if ((isAlreadyAuthed || existingConfig) && !force) {
|
|
229
|
+
if ((isAlreadyAuthed || existingConfig) && !force && !didLinkFromOptions) {
|
|
187
230
|
console.log(
|
|
188
231
|
chalk.yellow("⚠ Reshot is already configured in this project.\n"),
|
|
189
232
|
);
|
|
@@ -512,7 +555,18 @@ async function setupWizard(options = {}) {
|
|
|
512
555
|
return;
|
|
513
556
|
}
|
|
514
557
|
|
|
515
|
-
// Offer to launch studio
|
|
558
|
+
// Offer to launch studio. In non-interactive environments (CI, piped stdin)
|
|
559
|
+
// an inquirer prompt throws `ERR_USE_AFTER_CLOSE: readline`, so skip the
|
|
560
|
+
// prompt and auto-answer the safe default (do NOT launch Studio).
|
|
561
|
+
if (!isInteractive()) {
|
|
562
|
+
console.log(
|
|
563
|
+
chalk.gray(
|
|
564
|
+
"Non-interactive environment detected — skipping Studio launch. Run `reshot studio` when you want the local UI.\n",
|
|
565
|
+
),
|
|
566
|
+
);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
|
|
516
570
|
const { launchStudio } = await inquirer.prompt([
|
|
517
571
|
{
|
|
518
572
|
type: "confirm",
|
package/src/commands/status.js
CHANGED
|
@@ -215,7 +215,7 @@ async function fetchJobsData(apiKey, projectId, limit = 5) {
|
|
|
215
215
|
async function fetchDriftsData(apiKey, projectId) {
|
|
216
216
|
try {
|
|
217
217
|
const response = await apiClient.getDrifts(apiKey, projectId, {
|
|
218
|
-
status: "
|
|
218
|
+
status: "pending",
|
|
219
219
|
});
|
|
220
220
|
return {
|
|
221
221
|
drifts: response.drifts || [],
|
|
@@ -304,6 +304,26 @@ async function buildStatusReport(options = {}) {
|
|
|
304
304
|
report.auth.hasApiKey = Boolean(apiKey);
|
|
305
305
|
report.auth.hasProjectId = Boolean(projectId);
|
|
306
306
|
|
|
307
|
+
// Loudly warn when the linked session (settings.json) and the committed
|
|
308
|
+
// config (reshot.config.json) disagree on the project — settings.json wins,
|
|
309
|
+
// so an edited/stale config projectId would otherwise be silently ignored.
|
|
310
|
+
const configProjectId =
|
|
311
|
+
reshotConfig?.projectId || reshotConfig?._metadata?.projectId || null;
|
|
312
|
+
if (
|
|
313
|
+
projectId &&
|
|
314
|
+
configProjectId &&
|
|
315
|
+
configProjectId !== projectId &&
|
|
316
|
+
!process.env.RESHOT_PROJECT_ID
|
|
317
|
+
) {
|
|
318
|
+
report.issues.push(
|
|
319
|
+
createIssue(
|
|
320
|
+
"config",
|
|
321
|
+
"warning",
|
|
322
|
+
`Project ID mismatch: using ${projectId} (from .reshot/settings.json) but reshot.config.json declares ${configProjectId}. Run \`reshot setup\` to reconcile.`,
|
|
323
|
+
),
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
307
327
|
if (!apiKey) {
|
|
308
328
|
report.issues.push(
|
|
309
329
|
createIssue(
|
|
@@ -386,7 +406,7 @@ async function displayDrifts(apiKey, projectId, limit = 10) {
|
|
|
386
406
|
console.log(chalk.bold("\n🔄 Drift Queue\n"));
|
|
387
407
|
|
|
388
408
|
try {
|
|
389
|
-
const response = await apiClient.getDrifts(apiKey, projectId, { status: "
|
|
409
|
+
const response = await apiClient.getDrifts(apiKey, projectId, { status: "pending" });
|
|
390
410
|
const drifts = response.drifts || [];
|
|
391
411
|
const stats = response.stats || {};
|
|
392
412
|
renderDrifts(drifts, stats, projectId, limit);
|