@reshotdev/screenshot 0.0.1-beta.7 → 0.0.1-beta.9

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.
@@ -2,6 +2,7 @@
2
2
  const chalk = require("chalk");
3
3
  const oraModule = require("ora");
4
4
  const ora = oraModule.default || oraModule;
5
+ const { execSync } = require("child_process");
5
6
 
6
7
  const {
7
8
  writeSettings,
@@ -100,10 +101,14 @@ async function setupCommand(options = {}) {
100
101
  if (hasConfig && !force) {
101
102
  console.log(chalk.green("✔ Configuration found:"), chalk.cyan("reshot.config.json"));
102
103
  } else if (hasConfig && force) {
103
- console.log(chalk.yellow("⚠ Overwriting existing reshot.config.json..."));
104
- const newConfig = createDefaultConfig(existingSettings);
105
- writeConfig(newConfig);
106
- console.log(chalk.green("✔ Configuration updated"));
104
+ // Patch projectId in existing config instead of wiping scenarios
105
+ const fs = require("fs");
106
+ const path = require("path");
107
+ const configPath = path.join(process.cwd(), "reshot.config.json");
108
+ const existingConfig = JSON.parse(fs.readFileSync(configPath, "utf-8"));
109
+ existingConfig.projectId = existingSettings.projectId;
110
+ fs.writeFileSync(configPath, JSON.stringify(existingConfig, null, 2) + "\n");
111
+ console.log(chalk.green("✔ Configuration updated (projectId synced)"));
107
112
  } else {
108
113
  // No config - create it
109
114
  console.log(chalk.gray("Creating reshot.config.json..."));
@@ -112,6 +117,29 @@ async function setupCommand(options = {}) {
112
117
  console.log(chalk.green("✔ Configuration created:"), chalk.cyan("reshot.config.json"));
113
118
  }
114
119
 
120
+ // Step 3.5: Ensure @reshotdev/screenshot is in devDependencies
121
+ const fs = require("fs");
122
+ const path = require("path");
123
+ const pkgJsonPath = path.join(process.cwd(), "package.json");
124
+ if (fs.existsSync(pkgJsonPath)) {
125
+ const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
126
+ const hasDep =
127
+ pkgJson.devDependencies?.["@reshotdev/screenshot"] ||
128
+ pkgJson.dependencies?.["@reshotdev/screenshot"];
129
+ if (!hasDep) {
130
+ console.log(chalk.gray(" Adding @reshotdev/screenshot to devDependencies..."));
131
+ const usePnpm = fs.existsSync(path.join(process.cwd(), "pnpm-lock.yaml"));
132
+ const useYarn = fs.existsSync(path.join(process.cwd(), "yarn.lock"));
133
+ const cmd = usePnpm ? "pnpm add -D" : useYarn ? "yarn add -D" : "npm install -D";
134
+ try {
135
+ execSync(`${cmd} @reshotdev/screenshot`, { stdio: "inherit" });
136
+ console.log(chalk.green(" ✔ Added @reshotdev/screenshot to devDependencies"));
137
+ } catch {
138
+ console.log(chalk.yellow(" ⚠ Could not auto-install. Run manually: " + cmd + " @reshotdev/screenshot"));
139
+ }
140
+ }
141
+ }
142
+
115
143
  // Step 4: Success summary
116
144
  console.log(chalk.green("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
117
145
  console.log(chalk.green.bold("✔ Project initialized successfully!"));
@@ -128,9 +156,9 @@ async function setupCommand(options = {}) {
128
156
  await uiCommand({ open: true });
129
157
  } else {
130
158
  console.log(chalk.gray("Next steps:"));
131
- console.log(chalk.gray(" • Run"), chalk.cyan("reshot studio"), chalk.gray("to open the management UI"));
132
- console.log(chalk.gray(" • Run"), chalk.cyan("reshot record"), chalk.gray("to capture your first visual"));
133
- console.log(chalk.gray(" • Run"), chalk.cyan("reshot run"), chalk.gray("to execute scenarios\n"));
159
+ console.log(chalk.gray(" • Run"), chalk.cyan("reshot run"), chalk.gray("to generate your first local capture"));
160
+ console.log(chalk.gray(" • Run"), chalk.cyan("reshot publish"), chalk.gray("when you are ready for hosted assets and review workflows"));
161
+ console.log(chalk.gray(" • Run"), chalk.cyan("reshot studio"), chalk.gray("to inspect output locally\n"));
134
162
  }
135
163
  }
136
164
 
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+
3
+ const chalk = require("chalk");
4
+ const { runVerifyPublish } = require("../lib/certification");
5
+
6
+ async function verifyPublishCommand(options = {}) {
7
+ const scenarioKeys = options.scenarios
8
+ ? String(options.scenarios)
9
+ .split(",")
10
+ .map((value) => value.trim())
11
+ .filter(Boolean)
12
+ : null;
13
+
14
+ const report = await runVerifyPublish({
15
+ scenarioKeys,
16
+ tag: options.tag,
17
+ message: options.message,
18
+ });
19
+
20
+ if (options.json) {
21
+ console.log(JSON.stringify(report, null, 2));
22
+ } else {
23
+ console.log(chalk.cyan("\n📦 Publish Verification\n"));
24
+ console.log(
25
+ report.ok
26
+ ? chalk.green(" ✔ Publish, pull, and hosted delivery verified")
27
+ : chalk.red(" ✖ Publish verification failed"),
28
+ );
29
+ console.log(chalk.gray(` Published: ${report.publishResult.assetsProcessed || 0}`));
30
+ console.log(chalk.gray(` Pull repairs: ${report.pullResult.normalizationRepairs || 0}`));
31
+ const failedChecks = (report.deliveryChecks || []).filter((check) => !check.ok);
32
+ if (failedChecks.length > 0) {
33
+ for (const check of failedChecks.slice(0, 10)) {
34
+ console.log(chalk.red(` ✖ ${check.scenario}/${check.assetKey}: ${check.reason}`));
35
+ }
36
+ }
37
+ }
38
+
39
+ if (!report.ok) {
40
+ process.exitCode = 1;
41
+ }
42
+
43
+ return report;
44
+ }
45
+
46
+ module.exports = verifyPublishCommand;
package/src/index.js CHANGED
@@ -153,19 +153,96 @@ program
153
153
  }
154
154
  });
155
155
 
156
+ // Capture: Compatibility alias for older docs and snippets
157
+ program
158
+ .command("capture")
159
+ .description("Compatibility alias for `reshot run` (prefer `reshot run` and `reshot publish`)")
160
+ .option("-s, --scenarios <keys>", "Comma-separated list of scenario keys")
161
+ .option("--mode <mode>", "Compatibility mode: preview | publish", "preview")
162
+ .option("--config <path>", "Compatibility config path (defaults to reshot.config.json)")
163
+ .option("--base-url <url>", "Temporary base URL override for compatibility flows")
164
+ .option("--tag <tag>", "Version tag when mode=publish")
165
+ .option("-m, --message <message>", "Publish message when mode=publish")
166
+ .option("--no-headless", "Run browser in visible mode")
167
+ .option("-c, --concurrency <n>", "Number of parallel browser workers", parseInt)
168
+ .option("--debug", "Enable verbose debug logging")
169
+ .action(async (options) => {
170
+ if (options.debug) {
171
+ process.env.RESHOT_DEBUG = "1";
172
+ }
173
+ if (options.config && options.config !== "reshot.config.json") {
174
+ console.log(
175
+ chalk.yellow(
176
+ "Compatibility mode currently uses reshot.config.json from the working directory.",
177
+ ),
178
+ );
179
+ }
180
+ if (options.baseUrl) {
181
+ process.env.RESHOT_BASE_URL = options.baseUrl;
182
+ console.log(
183
+ chalk.gray(
184
+ `Using temporary base URL override for this run: ${options.baseUrl}`,
185
+ ),
186
+ );
187
+ }
188
+ try {
189
+ const runCommand = require("./commands/run");
190
+ const scenarioKeys = options.scenarios
191
+ ? options.scenarios.split(",").map((s) => s.trim())
192
+ : null;
193
+ await runCommand({
194
+ scenarioKeys,
195
+ headless: options.headless,
196
+ concurrency: options.concurrency,
197
+ });
198
+
199
+ if (String(options.mode || "preview").toLowerCase() === "publish") {
200
+ const publishCommand = require("./commands/publish");
201
+ await publishCommand({
202
+ tag: options.tag,
203
+ message: options.message,
204
+ });
205
+ }
206
+ } catch (error) {
207
+ console.error(chalk.red("Error:"), error.message);
208
+ if (options.debug && error.stack) {
209
+ console.error(chalk.gray(error.stack));
210
+ }
211
+ process.exit(1);
212
+ }
213
+ });
214
+
156
215
  // Record: Interactive visual recording from live browser
157
216
  program
158
217
  .command("record [title]")
159
218
  .description("Interactively record visuals from a live browser session")
219
+ .option("--name <title>", "Compatibility alias for the scenario title")
160
220
  .option("--browser", "Launch Chrome with remote debugging before recording")
161
221
  .option("-p, --port <port>", "Chrome debugging port (default: 9222)")
162
222
  .option("--url <url>", "URL to open when launching browser")
223
+ .option("--refresh-session", "Only refresh the auth session (no recording prompts)")
163
224
  .option("--debug", "Enable verbose debug logging")
164
225
  .action(async (title, options) => {
165
226
  if (options.debug) {
166
227
  process.env.RESHOT_DEBUG = "1";
167
228
  }
229
+ const resolvedTitle = title || options.name;
168
230
  try {
231
+ // If --refresh-session, just sync the session and exit
232
+ if (options.refreshSession) {
233
+ const { autoSyncSessionFromCDP, getDefaultSessionPath } = require("./lib/record-cdp");
234
+ const sessionPath = getDefaultSessionPath();
235
+ console.log(chalk.gray(" Syncing session from active browser..."));
236
+ const result = await autoSyncSessionFromCDP(sessionPath);
237
+ if (result.synced) {
238
+ console.log(chalk.green(" ✔ Session refreshed at " + sessionPath));
239
+ } else {
240
+ console.log(chalk.yellow(" ⚠ No active CDP browser found. Launch Chrome with remote debugging first:"));
241
+ console.log(chalk.gray(" reshot record --browser --refresh-session"));
242
+ }
243
+ return;
244
+ }
245
+
169
246
  // If --browser flag, launch Chrome first
170
247
  if (options.browser) {
171
248
  const chromeCommand = require("./commands/chrome");
@@ -178,7 +255,7 @@ program
178
255
  }
179
256
 
180
257
  const recordCommand = require("./commands/record");
181
- await recordCommand(title);
258
+ await recordCommand(resolvedTitle);
182
259
  } catch (error) {
183
260
  console.error(chalk.red("Error:"), error.message);
184
261
  if (options.debug && error.stack) {
@@ -259,6 +336,62 @@ program
259
336
  }
260
337
  });
261
338
 
339
+ // Doctor: Validate target contract and readiness
340
+ const doctor = program.command("doctor").description("Validate target configuration and readiness");
341
+
342
+ doctor
343
+ .command("target")
344
+ .description("Audit the certified target contract before capture")
345
+ .option("-s, --scenarios <keys>", "Comma-separated list of scenario keys")
346
+ .option("--json", "Output JSON report")
347
+ .action(async (options) => {
348
+ try {
349
+ const doctorTargetCommand = require("./commands/doctor-target");
350
+ await doctorTargetCommand(options);
351
+ } catch (error) {
352
+ console.error(chalk.red("Error:"), error.message);
353
+ process.exit(1);
354
+ }
355
+ });
356
+
357
+ // Verify: Publish/pull/hosted delivery verification
358
+ const verify = program.command("verify").description("Verify publish and delivery flows");
359
+
360
+ verify
361
+ .command("publish")
362
+ .description("Verify publish, pull, and hosted asset delivery")
363
+ .option("-s, --scenarios <keys>", "Comma-separated list of scenario keys")
364
+ .option("--tag <tag>", "Version tag for verification publish")
365
+ .option("-m, --message <message>", "Publish message override")
366
+ .option("--json", "Output JSON report")
367
+ .action(async (options) => {
368
+ try {
369
+ const verifyPublishCommand = require("./commands/verify-publish");
370
+ await verifyPublishCommand(options);
371
+ } catch (error) {
372
+ console.error(chalk.red("Error:"), error.message);
373
+ process.exit(1);
374
+ }
375
+ });
376
+
377
+ // Certify: full certified-target release gate
378
+ program
379
+ .command("certify")
380
+ .description("Run the full certified target pipeline")
381
+ .option("-s, --scenarios <keys>", "Comma-separated list of scenario keys")
382
+ .option("--tag <tag>", "Version tag for certification publish")
383
+ .option("-m, --message <message>", "Publish message override")
384
+ .option("--json", "Output JSON report")
385
+ .action(async (options) => {
386
+ try {
387
+ const certifyCommand = require("./commands/certify");
388
+ await certifyCommand(options);
389
+ } catch (error) {
390
+ console.error(chalk.red("Error:"), error.message);
391
+ process.exit(1);
392
+ }
393
+ });
394
+
262
395
  // CI: CI/CD integration commands
263
396
  const ciCommand = program
264
397
  .command("ci")
@@ -337,9 +470,22 @@ Actions:
337
470
  // ============================================================================
338
471
 
339
472
  // Auth: Standalone authentication (for re-auth scenarios)
340
- program
473
+ const auth = program
341
474
  .command("auth")
342
- .description("Authenticate with Reshot Cloud")
475
+ .description("Authenticate with Reshot Cloud (or set RESHOT_API_KEY + RESHOT_PROJECT_ID env vars)")
476
+ .action(async () => {
477
+ try {
478
+ const authCommand = require("./commands/auth");
479
+ await authCommand();
480
+ } catch (error) {
481
+ console.error(chalk.red("Error:"), error.message);
482
+ process.exit(1);
483
+ }
484
+ });
485
+
486
+ auth
487
+ .command("login")
488
+ .description("Compatibility alias for `reshot auth`")
343
489
  .action(async () => {
344
490
  try {
345
491
  const authCommand = require("./commands/auth");
@@ -5,6 +5,51 @@ const fs = require("fs");
5
5
 
6
6
  const PRODUCTION_API_URL = "https://reshot.dev/api";
7
7
 
8
+ function summarizeApiBody(body) {
9
+ if (body == null) return "";
10
+ if (typeof body === "string") return body.trim();
11
+ if (typeof body !== "object") return String(body);
12
+
13
+ const candidates = [
14
+ body.message,
15
+ body.error?.message,
16
+ body.error,
17
+ body.reason,
18
+ body.details,
19
+ ].filter(Boolean);
20
+
21
+ for (const candidate of candidates) {
22
+ if (typeof candidate === "string" && candidate.trim()) {
23
+ return candidate.trim();
24
+ }
25
+ }
26
+
27
+ try {
28
+ return JSON.stringify(body);
29
+ } catch {
30
+ return String(body);
31
+ }
32
+ }
33
+
34
+ function createApiError(kind, endpoint, error) {
35
+ const status = error.response?.status || null;
36
+ const statusText = error.response?.statusText || "";
37
+ const bodySummary = summarizeApiBody(error.response?.data);
38
+ const baseMessage = bodySummary || error.message || "Unknown API error";
39
+ const statusLabel = status ? `${status}${statusText ? ` ${statusText}` : ""}` : "request failed";
40
+ const wrapped = new Error(`${kind} (${statusLabel}) at ${endpoint}: ${baseMessage}`);
41
+ wrapped.response = error.response;
42
+ wrapped.code = error.code;
43
+ wrapped.reshot = {
44
+ kind,
45
+ endpoint,
46
+ status,
47
+ statusText,
48
+ bodySummary,
49
+ };
50
+ return wrapped;
51
+ }
52
+
8
53
  function getApiBaseUrl() {
9
54
  // 1. Explicit env var override (for CI or local dev)
10
55
  if (process.env.RESHOT_API_BASE_URL) {
@@ -210,20 +255,7 @@ async function publishAssetsV1(apiKey, metadata, assets) {
210
255
  });
211
256
  return response.data;
212
257
  } catch (error) {
213
- if (error.response) {
214
- const status = error.response.status;
215
- const errorMsg = error.response.data?.error || error.message;
216
-
217
- // Create an error that preserves the response for auth detection
218
- const err = new Error(
219
- status === 401 || status === 403
220
- ? `Authentication failed: ${errorMsg}`
221
- : `Failed to publish assets: ${errorMsg}`,
222
- );
223
- err.response = error.response;
224
- throw err;
225
- }
226
- throw new Error(`Failed to publish assets: ${error.message}`);
258
+ throw createApiError("publish_ingest_failure", `${baseUrl}/v1/publish`, error);
227
259
  }
228
260
  }
229
261
 
@@ -700,15 +732,23 @@ async function exportVisuals(projectId, options = {}) {
700
732
  }
701
733
 
702
734
  return withRetry(async () => {
703
- const response = await axios.get(
704
- `${baseUrl}/projects/${projectId}/visuals/export`,
705
- {
706
- params: { format, status },
707
- headers: { Authorization: `Bearer ${apiKey}` },
708
- timeout: 60000,
709
- },
710
- );
711
- return response.data;
735
+ try {
736
+ const response = await axios.get(
737
+ `${baseUrl}/projects/${projectId}/visuals/export`,
738
+ {
739
+ params: { format, status },
740
+ headers: { Authorization: `Bearer ${apiKey}` },
741
+ timeout: 60000,
742
+ },
743
+ );
744
+ return response.data;
745
+ } catch (error) {
746
+ throw createApiError(
747
+ "export_visuals_failure",
748
+ `${baseUrl}/projects/${projectId}/visuals/export`,
749
+ error,
750
+ );
751
+ }
712
752
  });
713
753
  }
714
754
 
@@ -838,6 +878,7 @@ module.exports = {
838
878
  getProjectConfig,
839
879
  postChangelogDrafts,
840
880
  getApiBaseUrl,
881
+ createApiError,
841
882
  syncPushAssets,
842
883
  getSyncStatus,
843
884
  // New transactional flow
@@ -98,6 +98,8 @@ class CaptureEngine {
98
98
  this.capturedAssets = [];
99
99
  this.logger = options.logger || console.log;
100
100
  this.headless = options.headless !== false; // Default to headless
101
+ this.injectWorkspaceStore = options.injectWorkspaceStore !== false;
102
+ this.diagnostics = [];
101
103
 
102
104
  // Storage state path for authenticated sessions
103
105
  // If provided, loads cookies/localStorage from file to preserve auth state
@@ -289,7 +291,9 @@ class CaptureEngine {
289
291
  logVariantSummary(this.variantConfig, this.logger);
290
292
  }
291
293
 
292
- await this._injectWorkspaceStore();
294
+ if (this.injectWorkspaceStore) {
295
+ await this._injectWorkspaceStore();
296
+ }
293
297
 
294
298
  // Inject privacy masking CSS (after variant injection, before captures)
295
299
  this._privacyInjectionOk = true;
@@ -306,24 +310,76 @@ class CaptureEngine {
306
310
  this._authResponseDetected = false;
307
311
  this.page.on("response", (response) => {
308
312
  const status = response.status();
313
+ const url = response.url();
309
314
  if (
310
315
  (status === 401 || status === 403) &&
311
316
  response.request().resourceType() === "document"
312
317
  ) {
313
318
  this._authResponseDetected = true;
314
319
  }
320
+
321
+ if (status >= 400 && response.request().resourceType() === "document") {
322
+ this._recordDiagnostic("response_error", "error", {
323
+ url,
324
+ status,
325
+ resourceType: response.request().resourceType(),
326
+ });
327
+ }
315
328
  });
316
329
 
317
330
  // Set up error handling
318
331
  this.page.on("pageerror", (err) => {
319
332
  const firstLine = (err.message || '').split('\n')[0].slice(0, 200);
333
+ this._recordDiagnostic("pageerror", "error", {
334
+ message: err.message || String(err),
335
+ });
320
336
  this.logger(chalk.yellow(` [Page Error] ${firstLine}`));
321
337
  });
338
+ this.page.on("console", (message) => {
339
+ const type = message.type();
340
+ const text = message.text();
341
+ const severity =
342
+ type === "error"
343
+ ? "error"
344
+ : type === "warning"
345
+ ? "warning"
346
+ : "info";
347
+
348
+ if (severity === "error" || severity === "warning") {
349
+ this._recordDiagnostic("console", severity, {
350
+ message: text,
351
+ consoleType: type,
352
+ cspViolation: /content security policy|csp/i.test(text),
353
+ });
354
+ }
355
+ });
356
+ this.page.on("requestfailed", (request) => {
357
+ this._recordDiagnostic("requestfailed", "error", {
358
+ url: request.url(),
359
+ method: request.method(),
360
+ resourceType: request.resourceType(),
361
+ message: request.failure()?.errorText || "Request failed",
362
+ });
363
+ });
322
364
 
323
365
  this.logger(chalk.green(" ✔ Browser initialized"));
324
366
  return this;
325
367
  }
326
368
 
369
+ _recordDiagnostic(kind, severity, details = {}) {
370
+ this.diagnostics.push({
371
+ id: `${Date.now()}-${this.diagnostics.length + 1}`,
372
+ kind,
373
+ severity,
374
+ ...details,
375
+ capturedAt: new Date().toISOString(),
376
+ });
377
+ }
378
+
379
+ getDiagnostics() {
380
+ return [...this.diagnostics];
381
+ }
382
+
327
383
  /**
328
384
  * Hide development overlays (Next.js devtools, Vercel toolbar, etc.)
329
385
  * Injects CSS to hide common development UI elements before each navigation
@@ -512,10 +568,15 @@ class CaptureEngine {
512
568
  }
513
569
 
514
570
  // Check for auth redirect after navigation (URL patterns + HTTP 401/403)
571
+ // Skip the check if the scenario explicitly targeted this URL (e.g. capturing /login)
515
572
  const currentUrl = this.page.url();
573
+ const targetPath = url.startsWith("http") ? new URL(url).pathname : url;
574
+ const currentPath = (() => { try { return new URL(currentUrl).pathname; } catch { return currentUrl; } })();
575
+ const isIntentionalTarget = currentPath === targetPath;
516
576
  const isAuthRedirect =
517
- isAuthRedirectUrl(currentUrl, this._customAuthPatterns) ||
518
- this._authResponseDetected;
577
+ !isIntentionalTarget &&
578
+ (isAuthRedirectUrl(currentUrl, this._customAuthPatterns) ||
579
+ this._authResponseDetected);
519
580
  if (isAuthRedirect) {
520
581
  const errorMsg = `Auth redirect detected: navigated to ${currentUrl}. Session may be expired. Re-run \`reshot record\` to refresh session, or export a fresh Playwright storage state to .reshot/auth-state.json.`;
521
582
  this.logger(chalk.red(` ✖ ${errorMsg}`));