@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.
Files changed (66) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +84 -51
  3. package/package.json +20 -16
  4. package/src/commands/auth.js +38 -8
  5. package/src/commands/capture-dom.js +50 -0
  6. package/src/commands/compose.js +220 -0
  7. package/src/commands/doctor-target.js +36 -4
  8. package/src/commands/drifts.js +13 -1
  9. package/src/commands/publish.js +137 -12
  10. package/src/commands/pull.js +13 -8
  11. package/src/commands/refresh.js +166 -0
  12. package/src/commands/setup-wizard.js +35 -2
  13. package/src/commands/status.js +22 -2
  14. package/src/commands/variation.js +194 -0
  15. package/src/index.js +189 -47
  16. package/src/lib/api-client.js +61 -35
  17. package/src/lib/auto-update/refresh.js +598 -0
  18. package/src/lib/auto-update/scene-runtime.compose.tsx +73 -0
  19. package/src/lib/auto-update/spec.js +89 -0
  20. package/src/lib/capture-engine.js +73 -0
  21. package/src/lib/capture-script-runner.js +280 -134
  22. package/src/lib/certification.js +23 -1
  23. package/src/lib/compose-context.js +156 -0
  24. package/src/lib/compose-pack.js +42 -0
  25. package/src/lib/compose-runtime.js +34 -0
  26. package/src/lib/compose-upload.js +142 -0
  27. package/src/lib/config.js +5 -5
  28. package/src/lib/dom-capture.js +64 -0
  29. package/src/lib/output-path-template.js +3 -3
  30. package/src/lib/record-clip.js +83 -3
  31. package/src/lib/record-config.js +0 -4
  32. package/src/lib/resolve-targets.js +60 -0
  33. package/src/lib/run-manifest.js +45 -0
  34. package/src/lib/storage-providers.js +1 -1
  35. package/src/lib/style-engine.js +5 -5
  36. package/src/lib/ui-api-helpers.js +118 -0
  37. package/src/lib/ui-api.js +28 -820
  38. package/src/lib/ui-asset-cleanup.js +62 -0
  39. package/src/lib/ui-output-versions.js +165 -0
  40. package/src/lib/ui-recorder-routes.js +341 -0
  41. package/src/lib/ui-scenario-metadata.js +161 -0
  42. package/vendor/compose/dist/auto-update.cjs +5544 -0
  43. package/vendor/compose/dist/auto-update.mjs +5518 -0
  44. package/vendor/compose/dist/capture.cjs +1450 -0
  45. package/vendor/compose/dist/capture.mjs +1416 -0
  46. package/vendor/compose/dist/eligibility.cjs +5331 -0
  47. package/vendor/compose/dist/eligibility.mjs +5313 -0
  48. package/vendor/compose/dist/index.cjs +2046 -0
  49. package/vendor/compose/dist/index.mjs +1997 -0
  50. package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
  51. package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
  52. package/vendor/compose/dist/jsx-runtime.cjs +58 -0
  53. package/vendor/compose/dist/jsx-runtime.mjs +31 -0
  54. package/vendor/compose/dist/render.cjs +558 -0
  55. package/vendor/compose/dist/render.mjs +515 -0
  56. package/vendor/compose/dist/verify-cli.cjs +3806 -0
  57. package/vendor/compose/dist/verify-cli.mjs +3812 -0
  58. package/vendor/compose/dist/verify.cjs +3880 -0
  59. package/vendor/compose/dist/verify.mjs +3858 -0
  60. package/web/manager/dist/assets/{index-D2qqcFNN.js → index-D0S2otug.js} +56 -56
  61. package/web/manager/dist/index.html +1 -1
  62. package/src/commands/ci-run.js +0 -178
  63. package/src/commands/ci-setup.js +0 -288
  64. package/src/commands/ingest.js +0 -458
  65. package/src/commands/setup.js +0 -165
  66. package/src/lib/playwright-runner.js +0 -252
@@ -0,0 +1,194 @@
1
+ // variation.js - Render variations from a captured DOM scene.
2
+ //
3
+ // Workflow:
4
+ // 1. Resolve the source MHTML — either from the local .reshot/output tree
5
+ // or by downloading from the platform CDN (R2 public URL stored on
6
+ // the VisualVersion as domSceneS3Path).
7
+ // 2. Open the MHTML in a fresh headless Chromium.
8
+ // 3. Apply the variation manifest (JS mutations + viewport).
9
+ // 4. Screenshot.
10
+ //
11
+ // This is the v1 marketing-operator entry point. The dashboard UI on top
12
+ // of it is the next deliverable; the CLI exists so the loop is usable
13
+ // the moment Phase 1 ships.
14
+ //
15
+ // Manifest format (JSON):
16
+ //
17
+ // {
18
+ // "viewport": { "width": 1440, "height": 900, "deviceScaleFactor": 2 },
19
+ // "colorScheme": "light" | "dark",
20
+ // "mutations": [
21
+ // { "remove": "aside, nav" }, // delete elements
22
+ // { "replaceText": [
23
+ // { "find": "ari@tempo.example", "with": "alice@acme.com" }
24
+ // ]},
25
+ // { "limit": { "selector": "tbody tr", "count": 5 } }, // keep first N
26
+ // { "setText": { "selector": "[data-author]", "text": "alice@acme.com" } },
27
+ // { "setAttr": { "selector": "img.logo", "name": "src", "value": "..." } },
28
+ // { "evaluate": "/* arbitrary page.evaluate script */" }
29
+ // ]
30
+ // }
31
+
32
+ const fs = require("fs-extra");
33
+ const path = require("node:path");
34
+ const chalk = require("chalk");
35
+ const { chromium } = require("playwright");
36
+
37
+ /**
38
+ * Runs in the browser context. Pure function — no closures over Node
39
+ * state. `manifestJson` is the only input. Keep this synchronous; if
40
+ * the page has React rehydration scripts, give them time before calling
41
+ * by waiting in the caller, then apply mutations as the *last* DOM
42
+ * write so they aren't immediately overwritten.
43
+ */
44
+ function applyMutationsInBrowser(manifestJson) {
45
+ const m = JSON.parse(manifestJson);
46
+ for (const mut of m.mutations || []) {
47
+ if (mut.remove) {
48
+ document.querySelectorAll(mut.remove).forEach((el) => el.remove());
49
+ }
50
+ if (mut.replaceText) {
51
+ for (const r of mut.replaceText) {
52
+ document.body.innerHTML = document.body.innerHTML
53
+ .split(r.find)
54
+ .join(r.with);
55
+ }
56
+ }
57
+ if (mut.limit) {
58
+ const els = document.querySelectorAll(mut.limit.selector);
59
+ els.forEach((el, i) => {
60
+ if (i >= mut.limit.count) el.remove();
61
+ });
62
+ }
63
+ if (mut.setText) {
64
+ document.querySelectorAll(mut.setText.selector).forEach((el) => {
65
+ el.textContent = mut.setText.text;
66
+ });
67
+ }
68
+ if (mut.setAttr) {
69
+ document.querySelectorAll(mut.setAttr.selector).forEach((el) => {
70
+ el.setAttribute(mut.setAttr.name, mut.setAttr.value);
71
+ });
72
+ }
73
+ if (mut.evaluate) {
74
+ // eslint-disable-next-line no-new-func
75
+ new Function(mut.evaluate)();
76
+ }
77
+ }
78
+ }
79
+
80
+ async function resolveSourceMhtml({ source, scenarioKey, captureKey, theme }) {
81
+ // 1) Explicit HTTPS URL → download to tmp, return local path. The local
82
+ // file MUST end in .mhtml — Chromium detects MHTML by extension, not by
83
+ // sniffing magic bytes, and our CDN serves under .related (the extension
84
+ // derived from multipart/related contentType).
85
+ if (source && /^https?:\/\//.test(source)) {
86
+ const tmpDir = path.join(require("os").tmpdir(), "reshot-variation");
87
+ fs.ensureDirSync(tmpDir);
88
+ const localPath = path.join(tmpDir, `${Date.now()}-variation.mhtml`);
89
+ const res = await fetch(source);
90
+ if (!res.ok) {
91
+ throw new Error(`Failed to fetch source MHTML: ${res.status} ${res.statusText}`);
92
+ }
93
+ const buf = Buffer.from(await res.arrayBuffer());
94
+ await fs.writeFile(localPath, buf);
95
+ return { kind: "remote", path: localPath };
96
+ }
97
+
98
+ // 2) Explicit local path
99
+ if (source && fs.existsSync(source)) {
100
+ return { kind: "local", path: source };
101
+ }
102
+
103
+ // 3) Look in .reshot/output tree
104
+ if (scenarioKey && captureKey) {
105
+ const root = path.join(process.cwd(), ".reshot", "output", scenarioKey);
106
+ if (fs.existsSync(root)) {
107
+ const runs = fs.readdirSync(root)
108
+ .filter((d) => /^\d{4}-\d{2}-\d{2}/.test(d))
109
+ .sort()
110
+ .reverse();
111
+ for (const run of runs) {
112
+ const candidate = path.join(
113
+ root,
114
+ run,
115
+ theme ? `theme-${theme}` : "default",
116
+ `${captureKey}.mhtml`,
117
+ );
118
+ if (fs.existsSync(candidate)) {
119
+ return { kind: "local", path: candidate };
120
+ }
121
+ }
122
+ }
123
+ }
124
+
125
+ return null;
126
+ }
127
+
128
+ async function variationCommand(options) {
129
+ const {
130
+ source,
131
+ scenario,
132
+ capture,
133
+ theme = "light",
134
+ manifest: manifestPath,
135
+ output,
136
+ headless = true,
137
+ } = options;
138
+
139
+ if (!manifestPath || !fs.existsSync(manifestPath)) {
140
+ throw new Error(`Manifest not found: ${manifestPath}`);
141
+ }
142
+ if (!output) {
143
+ throw new Error("--output <path.png> is required");
144
+ }
145
+
146
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
147
+ const viewport = manifest.viewport || {
148
+ width: 1440,
149
+ height: 900,
150
+ deviceScaleFactor: 2,
151
+ };
152
+
153
+ console.log(chalk.cyan("🎨 Variation render"));
154
+ console.log(chalk.gray(` manifest: ${manifestPath}`));
155
+
156
+ const sourceRef = await resolveSourceMhtml({
157
+ source,
158
+ scenarioKey: scenario,
159
+ captureKey: capture,
160
+ theme,
161
+ });
162
+ if (!sourceRef) {
163
+ throw new Error(
164
+ "Could not resolve source MHTML. Provide --source <path> or --scenario <key> --capture <key>.",
165
+ );
166
+ }
167
+ console.log(chalk.gray(` source: ${sourceRef.path}`));
168
+
169
+ const browser = await chromium.launch({ headless });
170
+ const ctx = await browser.newContext({
171
+ viewport: { width: viewport.width, height: viewport.height },
172
+ deviceScaleFactor: viewport.deviceScaleFactor || 2,
173
+ colorScheme: manifest.colorScheme === "dark" ? "dark" : "light",
174
+ javaScriptEnabled: true,
175
+ });
176
+ const page = await ctx.newPage();
177
+
178
+ const fileUrl = `file://${path.resolve(sourceRef.path)}`;
179
+ await page.goto(fileUrl, { waitUntil: "domcontentloaded" });
180
+ await page.waitForLoadState("load");
181
+ await page.waitForTimeout(400);
182
+
183
+ if (manifest.mutations?.length) {
184
+ await page.evaluate(applyMutationsInBrowser, JSON.stringify(manifest));
185
+ await page.waitForTimeout(200);
186
+ }
187
+
188
+ fs.ensureDirSync(path.dirname(output));
189
+ await page.screenshot({ path: output });
190
+ console.log(chalk.green(`✔ Rendered: ${output}`));
191
+ await browser.close();
192
+ }
193
+
194
+ module.exports = variationCommand;
package/src/index.js CHANGED
@@ -8,6 +8,8 @@ require("dotenv").config({
8
8
 
9
9
  const { Command } = require("commander");
10
10
  const chalk = require("chalk");
11
+ const fs = require("fs-extra");
12
+ const path = require("path");
11
13
  const pkg = require("../package.json");
12
14
 
13
15
  const program = new Command();
@@ -26,6 +28,8 @@ program
26
28
  .command("setup")
27
29
  .description("Set up the local or hosted workflow from scratch")
28
30
  .option("--offline", "Stay local-only and skip hosted authentication")
31
+ .option("--project <id>", "Connect setup to an existing Reshot project")
32
+ .option("--token <token>", "Publish token for non-interactive project linking")
29
33
  .option("--no-studio", "Skip offering to launch Studio after setup")
30
34
  .option("--force", "Force re-initialization even if already set up")
31
35
  .action(async (options) => {
@@ -38,6 +42,43 @@ program
38
42
  }
39
43
  });
40
44
 
45
+ program
46
+ .command("record-clip [target]")
47
+ .description("Record a scenario as a summary MP4 (alias for `reshot run --format summary-video`)")
48
+ .option("-s, --scenarios <keys>", "Comma-separated scenario keys")
49
+ .option("--out <dir>", "Copy the generated MP4 and metadata into this directory")
50
+ .option("--no-headless", "Run browser in visible mode")
51
+ .option("--debug", "Enable verbose debug logging")
52
+ .action(async (target, options) => {
53
+ if (options.debug) {
54
+ process.env.RESHOT_DEBUG = "1";
55
+ }
56
+ try {
57
+ const runCommand = require("./commands/run");
58
+ const scenarioKeys = resolveRecordClipScenarioKeys(target, options);
59
+ const result = await runCommand({
60
+ scenarioKeys,
61
+ headless: options.headless,
62
+ format: "summary-video",
63
+ noExit: true,
64
+ });
65
+
66
+ if (options.out && result?.success !== false) {
67
+ await copyRecordClipOutputs(result, target, options.out);
68
+ }
69
+
70
+ if (result?.success === false) {
71
+ process.exit(1);
72
+ }
73
+ } catch (error) {
74
+ console.error(chalk.red("Error:"), error.message);
75
+ if (options.debug && error.stack) {
76
+ console.error(chalk.gray(error.stack));
77
+ }
78
+ process.exit(1);
79
+ }
80
+ });
81
+
41
82
  program.addHelpText(
42
83
  "after",
43
84
  `
@@ -113,7 +154,7 @@ program
113
154
 
114
155
  // Run: Execute scenarios from config (automated visual capture)
115
156
  program
116
- .command("run")
157
+ .command("run [target]")
117
158
  .description("Execute visual capture scenarios from config")
118
159
  .option("-s, --scenarios <keys>", "Comma-separated list of scenario keys")
119
160
  .option("--no-headless", "Run browser in visible mode")
@@ -135,15 +176,13 @@ program
135
176
  .option("--no-style", "Disable style processing")
136
177
  .option("--cloud", "Compare against cloud baselines")
137
178
  .option("--debug", "Enable verbose debug logging")
138
- .action(async (options) => {
179
+ .action(async (target, options) => {
139
180
  if (options.debug) {
140
181
  process.env.RESHOT_DEBUG = "1";
141
182
  }
142
183
  try {
143
184
  const runCommand = require("./commands/run");
144
- const scenarioKeys = options.scenarios
145
- ? options.scenarios.split(",").map((s) => s.trim())
146
- : null;
185
+ const scenarioKeys = resolveScenarioKeysFromTarget(target, options);
147
186
  await runCommand({
148
187
  scenarioKeys,
149
188
  headless: options.headless,
@@ -297,6 +336,18 @@ program
297
336
  }
298
337
  });
299
338
 
339
+ // Compose: Render a local JSX composition into a video pack
340
+ const { registerCompose } = require("./commands/compose");
341
+ registerCompose(program);
342
+
343
+ // Capture-DOM: Capture a self-contained DOM reconstruction artifact from a URL
344
+ const { registerCaptureDom } = require("./commands/capture-dom");
345
+ registerCaptureDom(program);
346
+
347
+ // Refresh: Phase 5 auto-update loop — recapture, drift-check, re-publish or flag
348
+ const { registerRefresh } = require("./commands/refresh");
349
+ registerRefresh(program);
350
+
300
351
  // ============================================================================
301
352
  // PUBLISHING & INTEGRATION COMMANDS
302
353
  // ============================================================================
@@ -317,26 +368,51 @@ program
317
368
  .action(async (options) => {
318
369
  try {
319
370
  const publishCommand = require("./commands/publish");
320
- await publishCommand({
371
+ const result = await publishCommand({
321
372
  ...options,
322
373
  outputJson: options.outputJson,
323
374
  autoApprove: options.autoApprove,
324
375
  skipReleaseDoctor: options.skipReleaseDoctor,
325
376
  });
377
+ if (result && result.success === false) {
378
+ process.exit(1);
379
+ }
326
380
  } catch (error) {
327
381
  console.error(chalk.red("Error:"), error.message);
328
382
  process.exit(1);
329
383
  }
330
384
  });
331
385
 
332
- // Pull: Generate asset map for build pipelines
386
+ // Variation: Render a variation from a captured DOM scene (MHTML).
387
+ // Beta — see docs/variation-pipeline.md.
388
+ program
389
+ .command("variation")
390
+ .description("Render a variation from a captured DOM scene (beta)")
391
+ .option("-s, --source <path>", "Path to source .mhtml (overrides --scenario/--capture)")
392
+ .option("--scenario <key>", "Scenario key under .reshot/output/")
393
+ .option("--capture <key>", "Capture key (e.g., 'observation-detail')")
394
+ .option("--theme <name>", "Theme variant: light | dark", "light")
395
+ .option("-m, --manifest <path>", "Path to variation manifest (.json)")
396
+ .option("-o, --output <path>", "Output PNG path")
397
+ .option("--no-headless", "Run browser visibly for debugging")
398
+ .action(async (options) => {
399
+ try {
400
+ const variationCommand = require("./commands/variation");
401
+ await variationCommand(options);
402
+ } catch (error) {
403
+ console.error(chalk.red("Error:"), error.message);
404
+ process.exit(1);
405
+ }
406
+ });
407
+
408
+ // Pull: Generate asset map for local workflows
333
409
  program
334
410
  .command("pull")
335
- .description("Pull asset map for your build pipeline")
411
+ .description("Pull asset map for your capture workflow")
336
412
  .option("-f, --format <format>", "Output format: json, ts, csv", "json")
337
413
  .option("-o, --output <path>", "Output file path")
338
414
  .option("--full", "Include full metadata in TypeScript output")
339
- .option("--status <status>", "Filter: approved, pending, all", "approved")
415
+ .option("--status <status>", "Filter: approved, pending, all", "all")
340
416
  .action(async (options) => {
341
417
  try {
342
418
  const pullCommand = require("./commands/pull");
@@ -359,6 +435,7 @@ doctor
359
435
  .command("target")
360
436
  .description("Audit the certified target contract before capture")
361
437
  .option("-s, --scenarios <keys>", "Comma-separated list of scenario keys")
438
+ .option("--timeout <ms>", "Per-step timeout in milliseconds (default 15000)")
362
439
  .option("--json", "Output JSON report")
363
440
  .action(async (options) => {
364
441
  try {
@@ -424,42 +501,6 @@ program
424
501
  }
425
502
  });
426
503
 
427
- // CI: CI/CD integration commands
428
- const ciCommand = program
429
- .command("ci")
430
- .description("CI/CD integration commands");
431
-
432
- ciCommand
433
- .command("setup")
434
- .description("Interactive CI/CD setup wizard (GitHub Actions, etc.)")
435
- .action(async () => {
436
- try {
437
- const ciSetup = require("./commands/ci-setup");
438
- await ciSetup();
439
- } catch (error) {
440
- console.error(chalk.red("Error:"), error.message);
441
- process.exit(1);
442
- }
443
- });
444
-
445
- ciCommand
446
- .command("run")
447
- .description("Run capture + publish in one step (CI-optimized)")
448
- .option("-c, --config <path>", "Path to reshot.config.json")
449
- .option("--tag <tag>", "Version tag for publish")
450
- .option("-m, --message <message>", "Commit message for publish")
451
- .option("--dry-run", "Preview without uploading")
452
- .option("--no-publish", "Run capture only, skip publish")
453
- .option("--skip-release-doctor", "Skip the composed release gate precheck")
454
- .action(async (options) => {
455
- try {
456
- const ciRun = require("./commands/ci-run");
457
- await ciRun(options);
458
- } catch (error) {
459
- console.error(chalk.red("Error:"), error.message);
460
- process.exit(1);
461
- }
462
- });
463
504
 
464
505
  // ============================================================================
465
506
  // DRIFT MANAGEMENT COMMANDS
@@ -505,7 +546,23 @@ Actions:
505
546
  // Auth: Standalone authentication (for re-auth scenarios)
506
547
  const auth = program
507
548
  .command("auth")
508
- .description("Authenticate with Reshot Cloud (or set RESHOT_API_KEY + RESHOT_PROJECT_ID env vars)")
549
+ .description(
550
+ "Link this CLI to a Reshot project. Opens a browser to approve the session and stores a project API key locally.",
551
+ )
552
+ .addHelpText(
553
+ "after",
554
+ `
555
+ Authentication paths:
556
+ Interactive (default) Opens your browser, you approve the session, and the
557
+ CLI saves a project API key to .reshot/settings.json.
558
+ Non-interactive (CI) Set RESHOT_API_KEY and RESHOT_PROJECT_ID and the CLI
559
+ links without any browser or prompt.
560
+
561
+ Examples:
562
+ reshot auth Browser-based login
563
+ RESHOT_API_KEY=… RESHOT_PROJECT_ID=… reshot auth Headless / CI login
564
+ `,
565
+ )
509
566
  .action(async () => {
510
567
  try {
511
568
  const authCommand = require("./commands/auth");
@@ -518,7 +575,24 @@ const auth = program
518
575
 
519
576
  auth
520
577
  .command("login")
521
- .description("Compatibility alias for `reshot auth`")
578
+ .description(
579
+ "Alias for `reshot auth` — opens the browser approval flow (or uses RESHOT_API_KEY in CI).",
580
+ )
581
+ .action(async () => {
582
+ try {
583
+ const authCommand = require("./commands/auth");
584
+ await authCommand();
585
+ } catch (error) {
586
+ console.error(chalk.red("Error:"), error.message);
587
+ process.exit(1);
588
+ }
589
+ });
590
+
591
+ program
592
+ .command("login")
593
+ .description(
594
+ "Alias for `reshot auth` — link this CLI to a project via browser approval (or RESHOT_API_KEY in CI).",
595
+ )
522
596
  .action(async () => {
523
597
  try {
524
598
  const authCommand = require("./commands/auth");
@@ -559,4 +633,72 @@ program
559
633
  }
560
634
  });
561
635
 
636
+ function resolveRecordClipScenarioKeys(target, options = {}) {
637
+ return resolveScenarioKeysFromTarget(target, options);
638
+ }
639
+
640
+ function resolveScenarioKeysFromTarget(target, options = {}) {
641
+ if (options.scenarios) {
642
+ return options.scenarios.split(",").map((value) => value.trim()).filter(Boolean);
643
+ }
644
+
645
+ if (!target) {
646
+ return null;
647
+ }
648
+
649
+ const absoluteTarget = path.resolve(process.cwd(), target);
650
+ if (!fs.existsSync(absoluteTarget)) {
651
+ return [target];
652
+ }
653
+
654
+ const source = fs.readFileSync(absoluteTarget, "utf8");
655
+ const config = require("./lib/config").readConfig();
656
+ const scenarios = config.scenarios || [];
657
+ const mentioned = scenarios.find((scenario) => source.includes(scenario.key));
658
+ if (mentioned) {
659
+ return [mentioned.key];
660
+ }
661
+
662
+ const basename = path.basename(target).replace(/\.(spec\.)?[cm]?[tj]sx?$/i, "");
663
+ const byName = scenarios.find((scenario) => {
664
+ const normalizedKey = String(scenario.key || "").replace(/^dogfood-/, "");
665
+ return scenario.key === basename || normalizedKey === basename;
666
+ });
667
+ if (byName) {
668
+ return [byName.key];
669
+ }
670
+
671
+ throw new Error(
672
+ `Could not map ${target} to a configured scenario. Add the scenario key to the spec file or pass --scenarios <key>.`,
673
+ );
674
+ }
675
+
676
+ async function copyRecordClipOutputs(result, target, outDir) {
677
+ const firstScenario = (result.results || []).find((item) => item?.success !== false);
678
+ const outputDir = firstScenario?.outputDir;
679
+ if (!outputDir) {
680
+ throw new Error("No summary-video output directory was produced.");
681
+ }
682
+
683
+ const slug = target
684
+ ? path.basename(target).replace(/\.(spec\.)?[cm]?[tj]sx?$/i, "")
685
+ : firstScenario.key || "summary-video";
686
+ const destinationDir = path.resolve(process.cwd(), outDir);
687
+ await fs.ensureDir(destinationDir);
688
+
689
+ const copies = [
690
+ ["summary-video.mp4", `${slug}.mp4`],
691
+ ["summary-video.metadata.json", `${slug}.metadata.json`],
692
+ ["sentinels.json", `${slug}.sentinels.json`],
693
+ ];
694
+
695
+ for (const [fromName, toName] of copies) {
696
+ const sourcePath = path.join(outputDir, fromName);
697
+ if (await fs.pathExists(sourcePath)) {
698
+ await fs.copy(sourcePath, path.join(destinationDir, toName));
699
+ console.log(chalk.gray(` copied ${toName}`));
700
+ }
701
+ }
702
+ }
703
+
562
704
  program.parse(process.argv);
@@ -119,6 +119,17 @@ async function withRetry(fn, options = {}) {
119
119
  throw error;
120
120
  }
121
121
 
122
+ // Respect Retry-After header on 429 rate limit responses
123
+ const retryAfterHeader = error.response?.headers?.["retry-after"];
124
+ if (retryAfterHeader && statusCode === 429) {
125
+ const retryMs = (parseInt(retryAfterHeader, 10) || 5) * 1000;
126
+ console.log(
127
+ ` ⚠ Rate limited (attempt ${attempt}/${maxRetries}), retrying in ${retryMs / 1000}s...`,
128
+ );
129
+ await sleep(retryMs);
130
+ continue;
131
+ }
132
+
122
133
  // Calculate delay with exponential backoff + jitter
123
134
  const delay = Math.min(
124
135
  initialDelay * Math.pow(2, attempt - 1) + Math.random() * 1000,
@@ -649,17 +660,21 @@ async function publishBatch(apiKey, payload) {
649
660
  throw new Error("API key is required to publish");
650
661
  }
651
662
 
652
- const response = await axios.post(`${baseUrl}/v1/publish/batch`, payload, {
653
- headers: {
654
- "Content-Type": "application/json",
655
- Authorization: `Bearer ${apiKey}`,
663
+ return withRetry(
664
+ async () => {
665
+ const response = await axios.post(`${baseUrl}/v1/publish/batch`, payload, {
666
+ headers: {
667
+ "Content-Type": "application/json",
668
+ Authorization: `Bearer ${apiKey}`,
669
+ },
670
+ timeout: 60000,
671
+ maxBodyLength: Infinity,
672
+ maxContentLength: Infinity,
673
+ });
674
+ return response.data;
656
675
  },
657
- timeout: 60000,
658
- maxBodyLength: Infinity,
659
- maxContentLength: Infinity,
660
- });
661
-
662
- return response.data;
676
+ { maxRetries: 2, retryOn: [500, 502, 503, 504, 429, "ECONNRESET", "ETIMEDOUT"] },
677
+ );
663
678
  }
664
679
 
665
680
  /**
@@ -809,40 +824,49 @@ async function commitIngest(apiKey, projectId, uploadResults, git, cli) {
809
824
  * Get drift records for a project
810
825
  */
811
826
  async function getDrifts(apiKey, projectId, options = {}) {
812
- return withRetry(async () => {
813
- const params = new URLSearchParams();
814
- if (options.status) params.set("status", options.status);
815
- if (options.journeyKey) params.set("journeyKey", options.journeyKey);
827
+ const params = new URLSearchParams();
828
+ if (options.status) params.set("status", options.status);
829
+ if (options.journeyKey) params.set("journeyKey", options.journeyKey);
830
+ const endpoint = `${baseUrl}/v1/projects/${projectId}/drifts?${params.toString()}`;
816
831
 
817
- const response = await axios.get(
818
- `${baseUrl}/v1/projects/${projectId}/drifts?${params.toString()}`,
819
- {
832
+ try {
833
+ return await withRetry(async () => {
834
+ const response = await axios.get(endpoint, {
820
835
  headers: { Authorization: `Bearer ${apiKey}` },
821
836
  timeout: 30000,
822
- },
823
- );
824
- return response.data;
825
- });
837
+ });
838
+ return response.data;
839
+ });
840
+ } catch (error) {
841
+ // Surface HTTP status + endpoint + server message instead of axios's
842
+ // bare "Request failed with status code N".
843
+ throw createApiError("Drifts request failed", endpoint, error);
844
+ }
826
845
  }
827
846
 
828
847
  /**
829
848
  * Get sync jobs for a project
830
849
  */
831
850
  async function getSyncJobs(apiKey, projectId, options = {}) {
832
- return withRetry(async () => {
833
- const response = await axios.post(
834
- `${baseUrl}/v1/projects/${projectId}/sync-jobs`,
835
- {
836
- limit: options.limit || 10,
837
- status: options.status,
838
- },
839
- {
840
- headers: { Authorization: `Bearer ${apiKey}` },
841
- timeout: 30000,
842
- },
843
- );
844
- return response.data;
845
- });
851
+ const endpoint = `${baseUrl}/v1/projects/${projectId}/sync-jobs`;
852
+ try {
853
+ return await withRetry(async () => {
854
+ const response = await axios.post(
855
+ endpoint,
856
+ {
857
+ limit: options.limit || 10,
858
+ status: options.status,
859
+ },
860
+ {
861
+ headers: { Authorization: `Bearer ${apiKey}` },
862
+ timeout: 30000,
863
+ },
864
+ );
865
+ return response.data;
866
+ });
867
+ } catch (error) {
868
+ throw createApiError("Sync jobs request failed", endpoint, error);
869
+ }
846
870
  }
847
871
 
848
872
  /**
@@ -898,4 +922,6 @@ module.exports = {
898
922
  getDrifts,
899
923
  getSyncJobs,
900
924
  driftAction,
925
+ // Testing
926
+ withRetry,
901
927
  };