@reshotdev/screenshot 0.0.1-beta.11 → 0.0.1-beta.13
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/LICENSE +1 -1
- package/README.md +84 -51
- package/package.json +20 -16
- package/src/commands/auth.js +38 -8
- package/src/commands/capture-dom.js +50 -0
- package/src/commands/compose.js +220 -0
- package/src/commands/doctor-target.js +36 -4
- package/src/commands/drifts.js +13 -1
- package/src/commands/publish.js +137 -12
- package/src/commands/pull.js +13 -8
- package/src/commands/refresh.js +166 -0
- package/src/commands/setup-wizard.js +35 -2
- package/src/commands/status.js +22 -2
- package/src/commands/variation.js +194 -0
- package/src/index.js +189 -47
- 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 +73 -0
- package/src/lib/capture-script-runner.js +280 -134
- 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 +5 -5
- package/src/lib/dom-capture.js +64 -0
- package/src/lib/output-path-template.js +3 -3
- package/src/lib/record-clip.js +83 -3
- package/src/lib/record-config.js +0 -4
- package/src/lib/resolve-targets.js +60 -0
- package/src/lib/run-manifest.js +45 -0
- package/src/lib/storage-providers.js +1 -1
- package/src/lib/style-engine.js +5 -5
- 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-D2qqcFNN.js → index-D0S2otug.js} +56 -56
- package/web/manager/dist/index.html +1 -1
- package/src/commands/ci-run.js +0 -178
- package/src/commands/ci-setup.js +0 -288
- package/src/commands/ingest.js +0 -458
- package/src/commands/setup.js +0 -165
- package/src/lib/playwright-runner.js +0 -252
|
@@ -11,14 +11,46 @@ async function doctorTargetCommand(options = {}) {
|
|
|
11
11
|
.filter(Boolean)
|
|
12
12
|
: null;
|
|
13
13
|
|
|
14
|
+
// Emit an immediate banner BEFORE any async work so the command is never
|
|
15
|
+
// silent — previously it produced zero output while preparing fixtures and
|
|
16
|
+
// launching a browser, which read as a hang.
|
|
17
|
+
if (!options.json) {
|
|
18
|
+
console.log(chalk.cyan("🩺 Running target doctor…"));
|
|
19
|
+
console.log(
|
|
20
|
+
chalk.gray(
|
|
21
|
+
scenarioKeys
|
|
22
|
+
? ` scenarios: ${scenarioKeys.join(", ")}`
|
|
23
|
+
: " scenarios: all certified",
|
|
24
|
+
),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
const progressLogger = options.json
|
|
15
29
|
? null
|
|
16
30
|
: (message) => console.log(chalk.gray(` → ${message}`));
|
|
17
31
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
32
|
+
let report;
|
|
33
|
+
try {
|
|
34
|
+
report = await runDoctorTarget({
|
|
35
|
+
scenarioKeys,
|
|
36
|
+
onProgress: progressLogger,
|
|
37
|
+
timeoutMs: options.timeout ? Number(options.timeout) : undefined,
|
|
38
|
+
});
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// Fail fast with an actionable message + report path rather than hanging.
|
|
41
|
+
if (!options.json) {
|
|
42
|
+
console.error(chalk.red(`\n ✖ Target doctor aborted: ${error.message}`));
|
|
43
|
+
console.error(
|
|
44
|
+
chalk.gray(
|
|
45
|
+
" Confirm the dev server is reachable and the target is configured (see .reshot/reports).",
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
} else {
|
|
49
|
+
console.log(JSON.stringify({ ok: false, error: error.message }, null, 2));
|
|
50
|
+
}
|
|
51
|
+
process.exitCode = 1;
|
|
52
|
+
return { ok: false, error: error.message };
|
|
53
|
+
}
|
|
22
54
|
|
|
23
55
|
if (options.json) {
|
|
24
56
|
console.log(JSON.stringify(report, null, 2));
|
package/src/commands/drifts.js
CHANGED
|
@@ -234,7 +234,7 @@ async function batchDriftAction(apiKey, projectId, action, options = {}) {
|
|
|
234
234
|
|
|
235
235
|
try {
|
|
236
236
|
// Fetch all pending drifts
|
|
237
|
-
const response = await apiClient.getDrifts(apiKey, projectId, { status: "
|
|
237
|
+
const response = await apiClient.getDrifts(apiKey, projectId, { status: "pending" });
|
|
238
238
|
const drifts = response.drifts || [];
|
|
239
239
|
|
|
240
240
|
if (drifts.length === 0) {
|
|
@@ -268,6 +268,18 @@ async function batchDriftAction(apiKey, projectId, action, options = {}) {
|
|
|
268
268
|
console.log(chalk.red(` ${failed} drift(s) failed`));
|
|
269
269
|
}
|
|
270
270
|
} catch (error) {
|
|
271
|
+
const status = error.reshot?.status ?? error.response?.status;
|
|
272
|
+
if (status === 400 || status === 404) {
|
|
273
|
+
console.log(chalk.green(" ✓ No pending drifts to approve."));
|
|
274
|
+
console.log(
|
|
275
|
+
chalk.gray(
|
|
276
|
+
" Drifts only exist when a capture differs from a baseline. New captures\n" +
|
|
277
|
+
" with no prior version go to the review queue — use `reshot publish\n" +
|
|
278
|
+
" --auto-approve` or approve them in the studio."
|
|
279
|
+
)
|
|
280
|
+
);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
271
283
|
console.error(chalk.red("Error:"), error.message);
|
|
272
284
|
process.exit(1);
|
|
273
285
|
}
|
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
|
|
|
@@ -677,6 +680,52 @@ async function publishWithTransactionalFlow(
|
|
|
677
680
|
}
|
|
678
681
|
}
|
|
679
682
|
|
|
683
|
+
// ── DOM scene sidecars (MHTML bundles next to each PNG) ─────────────
|
|
684
|
+
// The CLI emits an .mhtml alongside each PNG when scenario.domScene !==
|
|
685
|
+
// false. We upload them as sidecar assets linked to the parent PNG's
|
|
686
|
+
// visual version via _parentVisualKey, so the server can persist the
|
|
687
|
+
// sidecar's s3Path on the same VisualVersion row.
|
|
688
|
+
const domSceneSidecars = [];
|
|
689
|
+
for (const f of allFiles) {
|
|
690
|
+
// Only attach MHTML to PNG screenshots (not videos, not other sidecars).
|
|
691
|
+
if (path.extname(f.path).toLowerCase() !== ".png") continue;
|
|
692
|
+
if (f._isThumbnail || f._isDomScene) continue;
|
|
693
|
+
|
|
694
|
+
const candidate =
|
|
695
|
+
(f.asset && f.asset.domScenePath) ||
|
|
696
|
+
f.path.replace(/\.png$/i, ".mhtml");
|
|
697
|
+
if (!fs.existsSync(candidate)) continue;
|
|
698
|
+
|
|
699
|
+
const sidecarStat = fs.statSync(candidate);
|
|
700
|
+
domSceneSidecars.push({
|
|
701
|
+
group: f.group,
|
|
702
|
+
asset: {
|
|
703
|
+
captureKey: f.asset.captureKey,
|
|
704
|
+
path: candidate,
|
|
705
|
+
filename: path.basename(candidate),
|
|
706
|
+
},
|
|
707
|
+
scenarioConfig: f.scenarioConfig,
|
|
708
|
+
key: f.key,
|
|
709
|
+
visualKey: f.visualKey,
|
|
710
|
+
path: candidate,
|
|
711
|
+
size: sidecarStat.size,
|
|
712
|
+
contentType: "multipart/related",
|
|
713
|
+
hash: null,
|
|
714
|
+
diffData: null,
|
|
715
|
+
thumbnailPath: null,
|
|
716
|
+
_isDomScene: true,
|
|
717
|
+
_parentVisualKey: f.visualKey,
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
if (domSceneSidecars.length > 0) {
|
|
721
|
+
console.log(
|
|
722
|
+
chalk.gray(
|
|
723
|
+
` Attaching ${domSceneSidecars.length} DOM scene sidecar(s)...`,
|
|
724
|
+
),
|
|
725
|
+
);
|
|
726
|
+
allFiles.push(...domSceneSidecars);
|
|
727
|
+
}
|
|
728
|
+
|
|
680
729
|
// Generate thumbnails for video files (first frame as PNG)
|
|
681
730
|
const videoExts = new Set([".mp4", ".webm", ".mov"]);
|
|
682
731
|
const videoFiles = allFiles.filter((f) =>
|
|
@@ -776,7 +825,10 @@ async function publishWithTransactionalFlow(
|
|
|
776
825
|
|
|
777
826
|
// Step 3: Upload files directly to R2 (parallel with concurrency limit)
|
|
778
827
|
console.log(chalk.gray(" Uploading files directly to R2..."));
|
|
779
|
-
const CONCURRENCY =
|
|
828
|
+
const CONCURRENCY = Math.min(
|
|
829
|
+
Math.max(parseInt(process.env.RESHOT_UPLOAD_CONCURRENCY || "10", 10), 1),
|
|
830
|
+
20,
|
|
831
|
+
);
|
|
780
832
|
const uploadQueue = [...allFiles];
|
|
781
833
|
const uploadResults = [];
|
|
782
834
|
|
|
@@ -818,21 +870,33 @@ async function publishWithTransactionalFlow(
|
|
|
818
870
|
|
|
819
871
|
// Build a map of thumbnail s3Paths keyed by parent visualKey
|
|
820
872
|
const thumbnailS3Paths = new Map();
|
|
873
|
+
// Build a map of DOM-scene MHTML s3Paths keyed by parent visualKey.
|
|
874
|
+
// The server attaches these to the parent VisualVersion as a sidecar.
|
|
875
|
+
const domSceneS3Paths = new Map();
|
|
876
|
+
const domSceneSizes = new Map();
|
|
821
877
|
for (const result of uploadResults) {
|
|
822
|
-
if (result.success
|
|
878
|
+
if (!result.success) continue;
|
|
879
|
+
if (result.file._isThumbnail) {
|
|
823
880
|
thumbnailS3Paths.set(result.file._parentVisualKey, result.s3Path);
|
|
881
|
+
} else if (result.file._isDomScene) {
|
|
882
|
+
domSceneS3Paths.set(result.file._parentVisualKey, result.s3Path);
|
|
883
|
+
domSceneSizes.set(result.file._parentVisualKey, result.file.size);
|
|
824
884
|
}
|
|
825
885
|
}
|
|
826
886
|
|
|
827
|
-
// Group successful uploads by scenario/variant (skip thumbnails
|
|
887
|
+
// Group successful uploads by scenario/variant (skip thumbnails + DOM
|
|
888
|
+
// sidecars — they're metadata attached to their parent asset).
|
|
889
|
+
const failedUploadKeys = [];
|
|
828
890
|
const groupMap = new Map();
|
|
829
891
|
for (const result of uploadResults) {
|
|
830
892
|
if (!result.success) {
|
|
831
|
-
if (!result.file._isThumbnail)
|
|
893
|
+
if (!result.file._isThumbnail && !result.file._isDomScene) {
|
|
894
|
+
failCount++;
|
|
895
|
+
failedUploadKeys.push(result.file.visualKey);
|
|
896
|
+
}
|
|
832
897
|
continue;
|
|
833
898
|
}
|
|
834
|
-
|
|
835
|
-
if (result.file._isThumbnail) continue;
|
|
899
|
+
if (result.file._isThumbnail || result.file._isDomScene) continue;
|
|
836
900
|
|
|
837
901
|
const groupKey = `${result.file.group.scenarioKey}::${result.file.group.variationSlug}`;
|
|
838
902
|
if (!groupMap.has(groupKey)) {
|
|
@@ -854,6 +918,10 @@ async function publishWithTransactionalFlow(
|
|
|
854
918
|
diffStatus: result.file.diffData?.diffStatus ?? null,
|
|
855
919
|
// Attach thumbnail for video assets
|
|
856
920
|
thumbnailS3Path: thumbnailS3Paths.get(result.file.visualKey) ?? null,
|
|
921
|
+
// Attach DOM scene sidecar (MHTML) — the source of truth for
|
|
922
|
+
// marketing-team variations.
|
|
923
|
+
domSceneS3Path: domSceneS3Paths.get(result.file.visualKey) ?? null,
|
|
924
|
+
domSceneSize: domSceneSizes.get(result.file.visualKey) ?? null,
|
|
857
925
|
});
|
|
858
926
|
}
|
|
859
927
|
|
|
@@ -942,7 +1010,7 @@ async function publishWithTransactionalFlow(
|
|
|
942
1010
|
}
|
|
943
1011
|
}
|
|
944
1012
|
|
|
945
|
-
return { successCount, failCount, skippedCount, viewUrl };
|
|
1013
|
+
return { successCount, failCount, skippedCount, viewUrl, failedUploadKeys };
|
|
946
1014
|
}
|
|
947
1015
|
|
|
948
1016
|
/**
|
|
@@ -1482,7 +1550,37 @@ async function publishCommand(options = {}) {
|
|
|
1482
1550
|
if (allOutput) {
|
|
1483
1551
|
screenshotFiles = findAssetFiles(outputBaseDir);
|
|
1484
1552
|
} else {
|
|
1485
|
-
const
|
|
1553
|
+
const usable = getLatestUsableRunManifest();
|
|
1554
|
+
if (!usable) {
|
|
1555
|
+
throw new Error(
|
|
1556
|
+
"No run manifest with successful scenarios found. Run `reshot run` first or use `reshot publish --all-output` to publish from the full output tree.",
|
|
1557
|
+
);
|
|
1558
|
+
}
|
|
1559
|
+
const { manifest: latestRunManifest, isFallback, isPartialSuccess } = usable;
|
|
1560
|
+
|
|
1561
|
+
if (isFallback) {
|
|
1562
|
+
const age = latestRunManifest.generatedAt
|
|
1563
|
+
? `generated ${latestRunManifest.generatedAt}`
|
|
1564
|
+
: "unknown age";
|
|
1565
|
+
console.log(
|
|
1566
|
+
chalk.yellow(
|
|
1567
|
+
`\n WARNING: Latest run has no successful scenarios. Falling back to older manifest (${age}).` +
|
|
1568
|
+
`\n The published screenshots may be STALE. Run \`reshot run\` to capture fresh screenshots.\n`,
|
|
1569
|
+
),
|
|
1570
|
+
);
|
|
1571
|
+
} else if (isPartialSuccess) {
|
|
1572
|
+
const allScenarios = latestRunManifest.scenarios || [];
|
|
1573
|
+
const failed = allScenarios.filter((s) => s.success === false);
|
|
1574
|
+
const succeeded = allScenarios.filter((s) => s.success !== false);
|
|
1575
|
+
console.log(
|
|
1576
|
+
chalk.yellow(
|
|
1577
|
+
`\n WARNING: Latest run had partial failures (${succeeded.length} passed, ${failed.length} failed).` +
|
|
1578
|
+
`\n Publishing only the ${succeeded.length} successful scenario(s).` +
|
|
1579
|
+
`\n Failed: ${failed.map((s) => s.key).join(", ")}\n`,
|
|
1580
|
+
),
|
|
1581
|
+
);
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1486
1584
|
const manifestScoped = resolveManifestScopedScreenshotFiles(
|
|
1487
1585
|
outputBaseDir,
|
|
1488
1586
|
latestRunManifest,
|
|
@@ -1627,11 +1725,38 @@ async function publishCommand(options = {}) {
|
|
|
1627
1725
|
}
|
|
1628
1726
|
if (failCount > 0) {
|
|
1629
1727
|
console.log(chalk.red(` ✖ Failed: ${failCount}`));
|
|
1728
|
+
const failedKeys = viewUrl ? [] : (publishResult.failedUploadKeys || []);
|
|
1729
|
+
if (failedKeys.length > 0) {
|
|
1730
|
+
for (const key of failedKeys.slice(0, 5)) {
|
|
1731
|
+
console.log(chalk.red(` - ${key}`));
|
|
1732
|
+
}
|
|
1733
|
+
if (failedKeys.length > 5) {
|
|
1734
|
+
console.log(chalk.red(` ... and ${failedKeys.length - 5} more`));
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1630
1737
|
}
|
|
1631
1738
|
if (viewUrl) {
|
|
1632
1739
|
console.log(chalk.cyan(`\n 🔗 View in platform: ${viewUrl}`));
|
|
1633
1740
|
}
|
|
1634
1741
|
|
|
1742
|
+
if (!autoApprove && successCount > 0) {
|
|
1743
|
+
console.log(
|
|
1744
|
+
chalk.cyan(
|
|
1745
|
+
`\n ℹ ${successCount} visual${successCount === 1 ? "" : "s"} awaiting review (PENDING)`,
|
|
1746
|
+
),
|
|
1747
|
+
);
|
|
1748
|
+
console.log(
|
|
1749
|
+
chalk.gray(
|
|
1750
|
+
" To skip review for first-time captures, re-run with `reshot publish --auto-approve`",
|
|
1751
|
+
),
|
|
1752
|
+
);
|
|
1753
|
+
console.log(
|
|
1754
|
+
chalk.gray(
|
|
1755
|
+
" or approve them in the studio link above.",
|
|
1756
|
+
),
|
|
1757
|
+
);
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1635
1760
|
// Helpful guidance about diff percentages
|
|
1636
1761
|
if (diffManifests && diffManifests.size > 0) {
|
|
1637
1762
|
console.log(
|
package/src/commands/pull.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// pull.js - Pull asset map from Reshot platform
|
|
2
|
-
// Generates JSON, TypeScript, or CSV files for
|
|
2
|
+
// Generates JSON, TypeScript, or CSV files for local workflows
|
|
3
3
|
const chalk = require("chalk");
|
|
4
4
|
const fs = require("fs-extra");
|
|
5
5
|
const path = require("path");
|
|
@@ -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`));
|
|
@@ -493,9 +498,9 @@ async function pullCommand(options = {}) {
|
|
|
493
498
|
console.log(chalk.gray(" • Width/Height - Dimensions in pixels\n"));
|
|
494
499
|
}
|
|
495
500
|
|
|
496
|
-
//
|
|
497
|
-
console.log(chalk.blue("━━━
|
|
498
|
-
console.log(chalk.white("Add to your
|
|
501
|
+
// Workflow integration tip
|
|
502
|
+
console.log(chalk.blue("━━━ Workflow Integration ━━━\n"));
|
|
503
|
+
console.log(chalk.white("Add to your local workflow:\n"));
|
|
499
504
|
console.log(chalk.cyan(" # package.json"));
|
|
500
505
|
console.log(chalk.cyan(' "scripts": {'));
|
|
501
506
|
console.log(chalk.cyan(` "prebuild": "npx reshot pull --format=${format}${output ? ` --output=${output}` : ''}"`));
|
|
@@ -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 };
|
|
@@ -153,7 +153,13 @@ function writeSetupReport({
|
|
|
153
153
|
* Main setup wizard
|
|
154
154
|
*/
|
|
155
155
|
async function setupWizard(options = {}) {
|
|
156
|
-
const {
|
|
156
|
+
const {
|
|
157
|
+
offline = false,
|
|
158
|
+
force = false,
|
|
159
|
+
noStudio = false,
|
|
160
|
+
project: linkedProjectId,
|
|
161
|
+
token: linkedToken,
|
|
162
|
+
} = options;
|
|
157
163
|
|
|
158
164
|
console.log(chalk.cyan.bold("\n🚀 Reshot Setup\n"));
|
|
159
165
|
|
|
@@ -182,8 +188,35 @@ async function setupWizard(options = {}) {
|
|
|
182
188
|
// No existing config
|
|
183
189
|
}
|
|
184
190
|
|
|
191
|
+
let didLinkFromOptions = false;
|
|
192
|
+
if (linkedProjectId && linkedToken && !offline) {
|
|
193
|
+
const authCommand = require("./auth");
|
|
194
|
+
const authResult = await authCommand({
|
|
195
|
+
projectId: linkedProjectId,
|
|
196
|
+
apiKey: linkedToken,
|
|
197
|
+
});
|
|
198
|
+
existingSettings = config.readSettings();
|
|
199
|
+
isAlreadyAuthed = !!(
|
|
200
|
+
existingSettings?.apiKey && existingSettings?.projectId
|
|
201
|
+
);
|
|
202
|
+
didLinkFromOptions = true;
|
|
203
|
+
console.log(
|
|
204
|
+
chalk.green("\n✔ Connected to"),
|
|
205
|
+
chalk.cyan(existingSettings.projectName || existingSettings.projectId),
|
|
206
|
+
);
|
|
207
|
+
if (authResult?.mode) {
|
|
208
|
+
console.log(chalk.gray(` Mode: ${authResult.mode}`));
|
|
209
|
+
}
|
|
210
|
+
} else if (linkedProjectId && !linkedToken && !offline) {
|
|
211
|
+
console.log(
|
|
212
|
+
chalk.gray(
|
|
213
|
+
`Setup will target project ${linkedProjectId}. Complete browser authentication when prompted.`,
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
185
218
|
// If already set up and not forcing, show status and offer options
|
|
186
|
-
if ((isAlreadyAuthed || existingConfig) && !force) {
|
|
219
|
+
if ((isAlreadyAuthed || existingConfig) && !force && !didLinkFromOptions) {
|
|
187
220
|
console.log(
|
|
188
221
|
chalk.yellow("⚠ Reshot is already configured in this project.\n"),
|
|
189
222
|
);
|
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);
|