@reshotdev/screenshot 0.0.1-beta.13 → 0.0.1-beta.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reshotdev/screenshot",
3
- "version": "0.0.1-beta.13",
3
+ "version": "0.0.1-beta.15",
4
4
  "description": "Screenshot and video capture CLI",
5
5
  "author": "Reshot <hello@reshot.dev>",
6
6
  "license": "MIT",
@@ -22,7 +22,7 @@
22
22
  "playwright"
23
23
  ],
24
24
  "bin": {
25
- "reshot": "./src/index.js"
25
+ "reshot": "src/index.js"
26
26
  },
27
27
  "files": [
28
28
  "src/",
@@ -72,4 +72,4 @@
72
72
  "build": "pnpm run ui:build",
73
73
  "pack:check": "npm pack --dry-run"
74
74
  }
75
- }
75
+ }
@@ -52,6 +52,13 @@ async function doctorReleaseCommand(options = {}) {
52
52
  }
53
53
  }
54
54
 
55
+ const advisories = report.summary?.advisories || [];
56
+ if (advisories.length > 0) {
57
+ for (const advisory of advisories.slice(0, 10)) {
58
+ console.log(chalk.yellow(` ⚠ ${advisory.scope}: ${advisory.message}`));
59
+ }
60
+ }
61
+
55
62
  if (report.reportPath) {
56
63
  console.log(chalk.gray(`\n Report: ${report.reportPath}`));
57
64
  }
@@ -595,6 +595,11 @@ function buildPublishMetadata({
595
595
  }) {
596
596
  const scenarioDefinition = buildScenarioDefinition(scenarioConfig);
597
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
+
598
603
  return {
599
604
  projectId,
600
605
  publishSessionId, // Unique ID for this CLI publish run
@@ -609,10 +614,14 @@ function buildPublishMetadata({
609
614
  publish: {
610
615
  autoApprove,
611
616
  },
612
- git: {
613
- commitHash: gitInfo.commitHash,
614
- commitMessage: gitInfo.commitMessage,
615
- },
617
+ ...(hasGit
618
+ ? {
619
+ git: {
620
+ commitHash: gitInfo.commitHash,
621
+ commitMessage: gitInfo.commitMessage,
622
+ },
623
+ }
624
+ : {}),
616
625
  cli: {
617
626
  version: pkg.version,
618
627
  captureTimestamp: new Date().toISOString(),
@@ -620,6 +629,24 @@ function buildPublishMetadata({
620
629
  };
621
630
  }
622
631
 
632
+ /**
633
+ * Read pixel dimensions from a captured image so the platform can store and
634
+ * expose them (used by `reshot pull` for CLS-safe `<img width height>`
635
+ * embedding). Returns nulls for non-images or on any error — never throws.
636
+ */
637
+ async function getImageDimensions(filePath, contentType) {
638
+ if (!filePath || !(contentType || "").startsWith("image/")) {
639
+ return { width: null, height: null };
640
+ }
641
+ try {
642
+ const sharp = require("sharp");
643
+ const meta = await sharp(filePath).metadata();
644
+ return { width: meta.width ?? null, height: meta.height ?? null };
645
+ } catch {
646
+ return { width: null, height: null };
647
+ }
648
+ }
649
+
623
650
  /**
624
651
  * Publish using transactional flow (direct R2 upload with presigned URLs)
625
652
  */
@@ -906,12 +933,18 @@ async function publishWithTransactionalFlow(
906
933
  assets: [],
907
934
  });
908
935
  }
936
+ const dimensions = await getImageDimensions(
937
+ result.file.path,
938
+ result.file.contentType,
939
+ );
909
940
  groupMap.get(groupKey).assets.push({
910
941
  key: result.file.key,
911
942
  s3Path: result.s3Path,
912
943
  hash: result.file.hash,
913
944
  visualKey: result.file.visualKey,
914
945
  size: result.file.size,
946
+ width: dimensions.width,
947
+ height: dimensions.height,
915
948
  contentType: result.file.contentType,
916
949
  // Include diff data from CLI analysis
917
950
  diffPercentage: result.file.diffData?.diffPercentage ?? null,
@@ -1005,7 +1038,15 @@ async function publishWithTransactionalFlow(
1005
1038
  viewUrl = batchResult.viewUrl;
1006
1039
  }
1007
1040
  } catch (error) {
1008
- console.log(chalk.red(` ✖ Batch request failed: ${error.message}`));
1041
+ // Surface the server's descriptive error body (e.g. a validation
1042
+ // message) instead of just axios's opaque "Request failed with status
1043
+ // code 400" — otherwise a 1-line fix turns into a support ticket.
1044
+ const serverMsg =
1045
+ error.response?.data?.error || error.response?.data?.message;
1046
+ const detail = serverMsg
1047
+ ? `${error.message} — ${serverMsg}`
1048
+ : error.message;
1049
+ console.log(chalk.red(` ✖ Batch request failed: ${detail}`));
1009
1050
  failCount += chunk.length;
1010
1051
  }
1011
1052
  }
@@ -1285,14 +1326,28 @@ function getGitInfo() {
1285
1326
  try {
1286
1327
  const commitHash = execSync("git rev-parse HEAD", {
1287
1328
  encoding: "utf-8",
1329
+ stdio: ["ignore", "pipe", "ignore"],
1288
1330
  }).trim();
1289
1331
  const commitMessage = execSync("git log -1 --pretty=%B", {
1290
1332
  encoding: "utf-8",
1333
+ stdio: ["ignore", "pipe", "ignore"],
1291
1334
  }).trim();
1292
- return { commitHash, commitMessage };
1335
+ return { commitHash, commitMessage, hasCommit: !!commitHash };
1293
1336
  } catch (error) {
1294
- console.warn(chalk.yellow(" ⚠ Could not read git information"));
1295
- return { commitHash: "", commitMessage: "" };
1337
+ // No git HEAD — either not a git repo or a brand-new repo with no commits.
1338
+ // Proceed WITHOUT git metadata rather than sending empty values that the
1339
+ // platform rejects with an opaque 400 "Batch request failed".
1340
+ console.log(
1341
+ chalk.yellow(
1342
+ " ⚠ No git commit found — publishing without commit metadata.",
1343
+ ),
1344
+ );
1345
+ console.log(
1346
+ chalk.gray(
1347
+ " Tip: run `git commit` first to attach commit info to this publish.",
1348
+ ),
1349
+ );
1350
+ return { commitHash: "", commitMessage: "", hasCommit: false };
1296
1351
  }
1297
1352
  }
1298
1353
 
@@ -1427,9 +1482,23 @@ async function publishCommand(options = {}) {
1427
1482
  };
1428
1483
 
1429
1484
  if (!releaseDoctor.ok) {
1430
- console.log(chalk.red(" ✖ Release doctor failed. Fix the reported issues before publishing."));
1485
+ console.log(chalk.red(" ✖ Release doctor failed. Fix the issues below before publishing:\n"));
1486
+ const blockingIssues = releaseDoctor.summary?.blockingIssues || [];
1487
+ if (blockingIssues.length > 0) {
1488
+ for (const issue of blockingIssues) {
1489
+ const scope = issue.scope ? `${issue.scope}: ` : "";
1490
+ console.log(chalk.red(` ✖ ${scope}${issue.message}`));
1491
+ }
1492
+ } else {
1493
+ console.log(chalk.red(" ✖ Release gate checks failed (see report for details)."));
1494
+ }
1495
+ const advisories = releaseDoctor.summary?.advisories || [];
1496
+ for (const advisory of advisories) {
1497
+ const scope = advisory.scope ? `${advisory.scope}: ` : "";
1498
+ console.log(chalk.yellow(` ⚠ ${scope}${advisory.message}`));
1499
+ }
1431
1500
  if (releaseDoctor.reportPath) {
1432
- console.log(chalk.gray(` Report: ${releaseDoctor.reportPath}`));
1501
+ console.log(chalk.gray(`\n Full report: ${releaseDoctor.reportPath}`));
1433
1502
  }
1434
1503
  if (!noExit) process.exit(1);
1435
1504
  return {
@@ -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
@@ -545,7 +555,18 @@ async function setupWizard(options = {}) {
545
555
  return;
546
556
  }
547
557
 
548
- // 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
+
549
570
  const { launchStudio } = await inquirer.prompt([
550
571
  {
551
572
  type: "confirm",
package/src/index.js CHANGED
@@ -35,7 +35,9 @@ program
35
35
  .action(async (options) => {
36
36
  try {
37
37
  const setupWizard = require("./commands/setup-wizard");
38
- await setupWizard(options);
38
+ // Commander stores `--no-studio` as `options.studio === false`, not
39
+ // `options.noStudio`. Normalize it so the flag is honored end-to-end.
40
+ await setupWizard({ ...options, noStudio: options.studio === false });
39
41
  } catch (error) {
40
42
  console.error(chalk.red("Error:"), error.message);
41
43
  process.exit(1);
@@ -6,6 +6,7 @@ const path = require("path");
6
6
  const fs = require("fs-extra");
7
7
  const chalk = require("chalk");
8
8
  const { buildLaunchOptions } = require("./ci-detect");
9
+ const { launchChromium } = require("./ensure-browser");
9
10
  const {
10
11
  applyVariantToPage,
11
12
  applyStorageAndReload,
@@ -299,9 +300,9 @@ class CaptureEngine {
299
300
 
300
301
  const contextOptions = this._buildContextOptions();
301
302
 
302
- this.browser = await chromium.launch(buildLaunchOptions({
303
+ this.browser = await launchChromium(chromium, buildLaunchOptions({
303
304
  headless: this.headless,
304
- }));
305
+ }), this.logger);
305
306
  this.context = await this.browser.newContext(contextOptions);
306
307
  this.page = await this.context.newPage();
307
308
 
@@ -1106,8 +1106,10 @@ async function runScenarioWithStepByStepCapture(scenario, options = {}) {
1106
1106
  // Malformed JSON — fall through and treat as non-empty so the warning still fires.
1107
1107
  }
1108
1108
  }
1109
- if (hasSession && !sessionIsEmpty) {
1110
- // Validate session freshness with graduated warnings
1109
+ if (hasSession && !sessionIsEmpty && scenario.requiresAuth) {
1110
+ // Validate session freshness with graduated warnings.
1111
+ // Only relevant when this scenario actually requires auth — a leftover
1112
+ // session file should not trigger staleness warnings for public scenarios.
1111
1113
  const sessionStats = fs.statSync(sessionPath);
1112
1114
  const sessionAgeHours =
1113
1115
  (Date.now() - sessionStats.mtimeMs) / (1000 * 60 * 60);
@@ -1938,8 +1940,10 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1938
1940
  // Check for saved session state (auth cookies) - CRITICAL for authenticated scenarios
1939
1941
  const sessionPath = getDefaultSessionPath();
1940
1942
  const hasSession = fs.existsSync(sessionPath);
1941
- if (hasSession) {
1942
- // Validate session freshness
1943
+ if (hasSession && scenario.requiresAuth) {
1944
+ // Validate session freshness. Only relevant when this scenario actually
1945
+ // requires auth — a leftover session file should not trigger staleness
1946
+ // warnings for public scenarios.
1943
1947
  const sessionStats = fs.statSync(sessionPath);
1944
1948
  const sessionAgeHours = (Date.now() - sessionStats.mtimeMs) / (1000 * 60 * 60);
1945
1949
  if (sessionAgeHours > 24) {
@@ -1952,6 +1956,7 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1952
1956
  }
1953
1957
 
1954
1958
  const { chromium } = require("playwright");
1959
+ const { launchChromium } = require("./ensure-browser");
1955
1960
  // Use a unique temp directory for this recording to avoid conflicts
1956
1961
  const recordingId = `recording-${Date.now()}-${Math.random()
1957
1962
  .toString(36)
@@ -1978,7 +1983,7 @@ async function runScenarioWithVideoCapture(scenario, options = {}) {
1978
1983
  debug("Launching browser...");
1979
1984
 
1980
1985
  // Launch browser with video recording
1981
- browser = await chromium.launch(buildLaunchOptions({ headless }));
1986
+ browser = await launchChromium(chromium, buildLaunchOptions({ headless }));
1982
1987
  debug("Browser launched successfully");
1983
1988
 
1984
1989
  // Build context options with variant support using universal injector
@@ -0,0 +1,147 @@
1
+ // ensure-browser.js - Guarantees the correct Playwright browser build is present
2
+ //
3
+ // The CLI bundles a specific version of Playwright, which is pinned to an exact
4
+ // browser build. Telling users to run a bare `npx playwright install` can resolve
5
+ // a DIFFERENT Playwright version (and therefore a different browser build),
6
+ // leaving the bundled launcher unable to find its executable. To make the build
7
+ // match 1:1, we drive the BUNDLED Playwright's own installer.
8
+
9
+ const path = require("path");
10
+ const fs = require("fs");
11
+ const { spawnSync } = require("child_process");
12
+
13
+ // Matches Playwright's "missing executable" launch error.
14
+ const MISSING_EXECUTABLE_RE = /Executable doesn't exist|please run the following command to download new browsers|browserType\.launch.*Executable/i;
15
+
16
+ let installAttempted = false;
17
+
18
+ /**
19
+ * Resolve the bundled Playwright's CLI entrypoint and version.
20
+ * We resolve relative to the package.json so the path matches whatever
21
+ * Playwright build this CLI actually depends on.
22
+ * @returns {{ cliPath: string|null, version: string|null }}
23
+ */
24
+ function resolveBundledPlaywright() {
25
+ for (const pkg of ["playwright", "playwright-core"]) {
26
+ try {
27
+ const pkgJsonPath = require.resolve(`${pkg}/package.json`);
28
+ const cliPath = path.join(path.dirname(pkgJsonPath), "cli.js");
29
+ if (fs.existsSync(cliPath)) {
30
+ let version = null;
31
+ try {
32
+ version = require(pkgJsonPath).version;
33
+ } catch (_) {
34
+ /* ignore */
35
+ }
36
+ return { cliPath, version, pkg };
37
+ }
38
+ } catch (_) {
39
+ // try next package name
40
+ }
41
+ }
42
+ return { cliPath: null, version: null, pkg: null };
43
+ }
44
+
45
+ /**
46
+ * Build the EXACT install command that matches the bundled Playwright build.
47
+ * Used both to run the install and as the fallback message shown to the user.
48
+ * @returns {string}
49
+ */
50
+ function getInstallCommandString() {
51
+ const { cliPath } = resolveBundledPlaywright();
52
+ if (cliPath) {
53
+ return `node "${cliPath}" install chromium`;
54
+ }
55
+ return "npx playwright install chromium";
56
+ }
57
+
58
+ /**
59
+ * Install the chromium browser using the BUNDLED Playwright's own installer,
60
+ * so the browser build can never mismatch the bundled Playwright version.
61
+ * @param {(msg: string) => void} logger
62
+ * @returns {boolean} whether the install command ran successfully
63
+ */
64
+ function installBundledChromium(logger = console.log) {
65
+ const { cliPath, version } = resolveBundledPlaywright();
66
+ if (!cliPath) {
67
+ return false;
68
+ }
69
+
70
+ logger(
71
+ `\n⬇️ Installing the Chromium build for Playwright${
72
+ version ? ` ${version}` : ""
73
+ } (one-time setup)...`
74
+ );
75
+
76
+ // Install both the headless shell and full chromium so any launch path works.
77
+ const result = spawnSync(
78
+ process.execPath,
79
+ [cliPath, "install", "chromium", "chromium-headless-shell"],
80
+ { stdio: "inherit" }
81
+ );
82
+
83
+ if (result.error || result.status !== 0) {
84
+ logger(
85
+ `\n⚠ Automatic browser install failed. Please run this command manually:\n ${getInstallCommandString()}\n`
86
+ );
87
+ return false;
88
+ }
89
+
90
+ return true;
91
+ }
92
+
93
+ /**
94
+ * Determine whether an error is Playwright's "missing browser executable" error.
95
+ * @param {Error} err
96
+ * @returns {boolean}
97
+ */
98
+ function isMissingExecutableError(err) {
99
+ return !!err && MISSING_EXECUTABLE_RE.test(err.message || "");
100
+ }
101
+
102
+ /**
103
+ * Launch chromium, auto-installing the matching browser build on first run if
104
+ * the executable is missing. Retries the launch exactly once after installing.
105
+ *
106
+ * @param {import('playwright').BrowserType} chromium - the bundled chromium type
107
+ * @param {Object} launchOptions - options passed to chromium.launch()
108
+ * @param {(msg: string) => void} [logger]
109
+ * @returns {Promise<import('playwright').Browser>}
110
+ */
111
+ async function launchChromium(chromium, launchOptions = {}, logger = console.log) {
112
+ try {
113
+ return await chromium.launch(launchOptions);
114
+ } catch (err) {
115
+ if (!isMissingExecutableError(err)) {
116
+ throw err;
117
+ }
118
+
119
+ // Only attempt the auto-install once per process to avoid loops.
120
+ if (installAttempted) {
121
+ const e = new Error(
122
+ `${err.message}\n\nThe Chromium build for this CLI is missing. Run:\n ${getInstallCommandString()}`
123
+ );
124
+ throw e;
125
+ }
126
+ installAttempted = true;
127
+
128
+ const installed = installBundledChromium(logger);
129
+ if (!installed) {
130
+ const e = new Error(
131
+ `${err.message}\n\nThe Chromium build for this CLI is missing. Run:\n ${getInstallCommandString()}`
132
+ );
133
+ throw e;
134
+ }
135
+
136
+ // Retry once now that the matching browser build is installed.
137
+ return await chromium.launch(launchOptions);
138
+ }
139
+ }
140
+
141
+ module.exports = {
142
+ launchChromium,
143
+ installBundledChromium,
144
+ isMissingExecutableError,
145
+ getInstallCommandString,
146
+ resolveBundledPlaywright,
147
+ };
@@ -275,13 +275,21 @@ async function runReleaseDoctor(options = {}) {
275
275
  for (const issue of targetDoctor.summary?.advisories || []) {
276
276
  advisories.push({ scope: "target-doctor", ...issue });
277
277
  }
278
- if (!docsAssetMap.skipped) {
278
+ // A stale/mismatched docs asset map (e.g. src/data/reshot-assets.json left
279
+ // behind by an earlier `reshot pull`) describes a generated artifact, not the
280
+ // config being published. It must NOT hard-block a publish of the current
281
+ // config — surface it as an advisory with a concrete remedy instead.
282
+ if (!docsAssetMap.skipped && !docsAssetMap.ok) {
283
+ const remedy =
284
+ docsAssetMap.path
285
+ ? `Re-run \`reshot pull\` to regenerate it, or delete ${docsAssetMap.path}.`
286
+ : "Re-run `reshot pull` to regenerate it, or delete the stale src/data/reshot-assets.json.";
279
287
  for (const issue of docsAssetMap.issues) {
280
- blockingIssues.push({ scope: "docs-asset-map", message: issue });
288
+ advisories.push({ scope: "docs-asset-map", message: `${issue} ${remedy}` });
281
289
  }
282
290
  }
283
291
 
284
- const ok = preflight.ok && targetDoctor.ok && (docsAssetMap.skipped || docsAssetMap.ok);
292
+ const ok = preflight.ok && targetDoctor.ok;
285
293
  const report = {
286
294
  type: "ReleaseDoctorReport",
287
295
  stage: "doctor-release",