@reshotdev/screenshot 0.0.1-beta.2 → 0.0.1-beta.21

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 (81) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +138 -47
  3. package/package.json +27 -16
  4. package/src/commands/auth.js +159 -30
  5. package/src/commands/capture-dom.js +50 -0
  6. package/src/commands/certify.js +62 -0
  7. package/src/commands/compose.js +220 -0
  8. package/src/commands/doctor-release.js +74 -0
  9. package/src/commands/doctor-target.js +108 -0
  10. package/src/commands/drifts.js +16 -69
  11. package/src/commands/import-tests.js +13 -13
  12. package/src/commands/init.js +16 -277
  13. package/src/commands/publish.js +484 -257
  14. package/src/commands/pull.js +302 -35
  15. package/src/commands/refresh.js +166 -0
  16. package/src/commands/run.js +292 -12
  17. package/src/commands/setup-wizard.js +348 -496
  18. package/src/commands/status.js +334 -126
  19. package/src/commands/sync.js +28 -236
  20. package/src/commands/ui.js +1 -1
  21. package/src/commands/variation.js +194 -0
  22. package/src/commands/verify-publish.js +46 -0
  23. package/src/index.js +383 -118
  24. package/src/lib/api-client.js +172 -60
  25. package/src/lib/auto-update/refresh.js +598 -0
  26. package/src/lib/auto-update/scene-runtime.compose.tsx +73 -0
  27. package/src/lib/auto-update/spec.js +89 -0
  28. package/src/lib/capture-engine.js +179 -9
  29. package/src/lib/capture-script-runner.js +639 -214
  30. package/src/lib/certification.js +887 -0
  31. package/src/lib/compose-context.js +156 -0
  32. package/src/lib/compose-pack.js +42 -0
  33. package/src/lib/compose-runtime.js +34 -0
  34. package/src/lib/compose-upload.js +142 -0
  35. package/src/lib/config.js +186 -81
  36. package/src/lib/dom-capture.js +64 -0
  37. package/src/lib/ensure-browser.js +147 -0
  38. package/src/lib/output-path-template.js +3 -3
  39. package/src/lib/record-cdp.js +288 -16
  40. package/src/lib/record-clip.js +83 -3
  41. package/src/lib/record-config.js +1 -5
  42. package/src/lib/release-doctor.js +321 -0
  43. package/src/lib/resolve-targets.js +60 -0
  44. package/src/lib/run-manifest.js +148 -0
  45. package/src/lib/standalone-mode.js +1 -1
  46. package/src/lib/storage-providers.js +5 -5
  47. package/src/lib/style-engine.js +5 -5
  48. package/src/lib/target-contract.js +292 -0
  49. package/src/lib/ui-api-helpers.js +118 -0
  50. package/src/lib/ui-api.js +31 -824
  51. package/src/lib/ui-asset-cleanup.js +62 -0
  52. package/src/lib/ui-output-versions.js +165 -0
  53. package/src/lib/ui-recorder-routes.js +341 -0
  54. package/src/lib/ui-scenario-metadata.js +161 -0
  55. package/vendor/compose/dist/auto-update.cjs +5544 -0
  56. package/vendor/compose/dist/auto-update.mjs +5518 -0
  57. package/vendor/compose/dist/capture.cjs +1450 -0
  58. package/vendor/compose/dist/capture.mjs +1416 -0
  59. package/vendor/compose/dist/eligibility.cjs +5331 -0
  60. package/vendor/compose/dist/eligibility.mjs +5313 -0
  61. package/vendor/compose/dist/index.cjs +2046 -0
  62. package/vendor/compose/dist/index.mjs +1997 -0
  63. package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
  64. package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
  65. package/vendor/compose/dist/jsx-runtime.cjs +58 -0
  66. package/vendor/compose/dist/jsx-runtime.mjs +31 -0
  67. package/vendor/compose/dist/render.cjs +558 -0
  68. package/vendor/compose/dist/render.mjs +515 -0
  69. package/vendor/compose/dist/verify-cli.cjs +3806 -0
  70. package/vendor/compose/dist/verify-cli.mjs +3812 -0
  71. package/vendor/compose/dist/verify.cjs +3880 -0
  72. package/vendor/compose/dist/verify.mjs +3858 -0
  73. package/web/manager/dist/assets/index-D0S2otug.js +507 -0
  74. package/web/manager/dist/index.html +1 -1
  75. package/src/commands/ci-run.js +0 -123
  76. package/src/commands/ci-setup.js +0 -288
  77. package/src/commands/ingest.js +0 -458
  78. package/src/commands/setup.js +0 -137
  79. package/src/commands/validate-docs.js +0 -529
  80. package/src/lib/playwright-runner.js +0 -252
  81. package/web/manager/dist/assets/index--ZgioErz.js +0 -507
package/src/lib/config.js CHANGED
@@ -21,6 +21,11 @@ const {
21
21
  getConfigDefaults,
22
22
  validateCaptureRequirements,
23
23
  } = require("./standalone-mode");
24
+ const {
25
+ normalizeConfigContract,
26
+ validateNormalizedConfig,
27
+ getCertifiedScenarioKeys,
28
+ } = require("./target-contract");
24
29
 
25
30
  const SETTINGS_DIR = ".reshot";
26
31
  const SETTINGS_PATH = path.join(process.cwd(), SETTINGS_DIR, "settings.json");
@@ -64,7 +69,7 @@ function createAuthErrorResponse(message) {
64
69
  code: "AUTH_REQUIRED",
65
70
  };
66
71
  }
67
- const CONFIG_PATH = path.join(process.cwd(), "docsync.config.json");
72
+ const CONFIG_PATH = path.join(process.cwd(), "reshot.config.json");
68
73
  const WORKSPACE_PATH = path.join(process.cwd(), SETTINGS_DIR, "workspace.json");
69
74
 
70
75
  /**
@@ -279,13 +284,19 @@ function readConfig() {
279
284
  );
280
285
  }
281
286
 
282
- const config = fs.readJSONSync(CONFIG_PATH);
287
+ const rawConfig = fs.readJSONSync(CONFIG_PATH);
288
+ const config = normalizeConfigContract(rawConfig);
283
289
 
284
290
  // Validate required fields
285
291
  if (!config.scenarios || !Array.isArray(config.scenarios)) {
286
292
  throw new Error('Config must have a "scenarios" array');
287
293
  }
288
294
 
295
+ const targetValidation = validateNormalizedConfig(config);
296
+ if (!targetValidation.valid) {
297
+ throw new Error(targetValidation.errors.join("\n"));
298
+ }
299
+
289
300
  const validActions = [
290
301
  "click",
291
302
  "type",
@@ -319,9 +330,9 @@ function readConfig() {
319
330
  if (!scenario.key) {
320
331
  throw new Error(`Scenario "${scenario.name}" must have a "key" field`);
321
332
  }
322
- if (!/^[a-z0-9-]+$/i.test(scenario.key)) {
333
+ if (!/^[a-z0-9]([a-z0-9\-_/]*[a-z0-9])?$/i.test(scenario.key)) {
323
334
  throw new Error(
324
- `Scenario "${scenario.name}" has invalid key "${scenario.key}". Keys must be alphanumeric with hyphens only.`
335
+ `Scenario "${scenario.name}" has invalid key "${scenario.key}". Keys must start and end alphanumeric; hyphens, underscores, and slashes are allowed in between.`
325
336
  );
326
337
  }
327
338
  if (!scenario.url) {
@@ -470,84 +481,22 @@ function readConfig() {
470
481
  }
471
482
  }
472
483
 
473
- // Validate optional docs block
474
- if (config.docs !== undefined) {
475
- if (
476
- typeof config.docs !== "object" ||
477
- config.docs === null ||
478
- Array.isArray(config.docs)
479
- ) {
480
- throw new Error("docs must be an object");
481
- }
482
- if (
483
- config.docs.root !== undefined &&
484
- typeof config.docs.root !== "string"
485
- ) {
486
- throw new Error("docs.root must be a string");
487
- }
488
- if (
489
- config.docs.include !== undefined &&
490
- !Array.isArray(config.docs.include)
491
- ) {
492
- throw new Error("docs.include must be an array of strings");
493
- }
494
- if (
495
- config.docs.exclude !== undefined &&
496
- !Array.isArray(config.docs.exclude)
497
- ) {
498
- throw new Error("docs.exclude must be an array of strings");
499
- }
500
- }
501
-
502
484
  return config;
503
485
  }
504
486
 
505
487
  /**
506
- * Read docsync.config.json with DocSync-specific configuration
507
- * Returns the full config including the documentation block for ingestion
508
- * @returns {Object} DocSync configuration
488
+ * Read reshot.config.json without requiring scenarios array
489
+ * Used by sync and other commands that don't need scenario validation
490
+ * @returns {Object} Configuration
509
491
  */
510
- function readDocSyncConfig() {
492
+ function readConfigLenient() {
511
493
  if (!fs.existsSync(CONFIG_PATH)) {
512
494
  throw new Error(
513
495
  `Config file not found at ${CONFIG_PATH}. Run \`reshot init\` to create one.`
514
496
  );
515
497
  }
516
498
 
517
- const config = fs.readJSONSync(CONFIG_PATH);
518
-
519
- // Validate documentation block if present
520
- if (config.documentation) {
521
- const doc = config.documentation;
522
-
523
- // Validate required fields
524
- if (!doc.strategy) {
525
- throw new Error('documentation.strategy is required (git_pr or external_host)');
526
- }
527
-
528
- if (!['git_pr', 'external_host'].includes(doc.strategy)) {
529
- throw new Error('documentation.strategy must be "git_pr" or "external_host"');
530
- }
531
-
532
- // Validate optional fields
533
- if (doc.assetFormat && !['cdn_link', 'markdown'].includes(doc.assetFormat)) {
534
- throw new Error('documentation.assetFormat must be "cdn_link" or "markdown"');
535
- }
536
-
537
- if (doc.include && !Array.isArray(doc.include)) {
538
- throw new Error('documentation.include must be an array of glob patterns');
539
- }
540
-
541
- if (doc.exclude && !Array.isArray(doc.exclude)) {
542
- throw new Error('documentation.exclude must be an array of glob patterns');
543
- }
544
-
545
- if (doc.mappings && typeof doc.mappings !== 'object') {
546
- throw new Error('documentation.mappings must be an object');
547
- }
548
- }
549
-
550
- return config;
499
+ return normalizeConfigContract(fs.readJSONSync(CONFIG_PATH));
551
500
  }
552
501
 
553
502
  /**
@@ -555,7 +504,7 @@ function readDocSyncConfig() {
555
504
  * @param {Object} config - Config object to write
556
505
  */
557
506
  function writeConfig(config) {
558
- fs.writeJSONSync(CONFIG_PATH, config, { spaces: 2 });
507
+ fs.writeJSONSync(CONFIG_PATH, normalizeConfigContract(config), { spaces: 2 });
559
508
  }
560
509
 
561
510
  /**
@@ -701,10 +650,10 @@ function getPrivacyConfig(scenarioOverrides = {}) {
701
650
  * Default style configuration
702
651
  */
703
652
  const DEFAULT_STYLE_CONFIG = {
704
- enabled: true,
653
+ enabled: false,
705
654
  frame: "none",
706
- shadow: "medium",
707
- padding: 40,
655
+ shadow: "none",
656
+ padding: 0,
708
657
  background: "transparent",
709
658
  borderRadius: 0,
710
659
  };
@@ -851,8 +800,6 @@ async function initializeProject(projectId, apiKey, options = {}) {
851
800
  contextCount: 1,
852
801
  features: {
853
802
  visuals: true,
854
- docs: false,
855
- changelog: true,
856
803
  },
857
804
  },
858
805
  };
@@ -1040,19 +987,175 @@ function getModeInfo() {
1040
987
  };
1041
988
  }
1042
989
 
990
+ function readSettingsSafe() {
991
+ try {
992
+ return readSettings();
993
+ } catch (error) {
994
+ return null;
995
+ }
996
+ }
997
+
998
+ function collectTemplateVariables(value) {
999
+ if (typeof value !== "string" || value.length === 0) {
1000
+ return [];
1001
+ }
1002
+
1003
+ const variables = new Set();
1004
+ const regex = /\{\{(\w+)\}\}/g;
1005
+ let match = regex.exec(value);
1006
+
1007
+ while (match) {
1008
+ variables.add(match[1]);
1009
+ match = regex.exec(value);
1010
+ }
1011
+
1012
+ return [...variables];
1013
+ }
1014
+
1015
+ function hasDeterministicReadyContract(scenario) {
1016
+ if (!scenario || typeof scenario !== "object") {
1017
+ return false;
1018
+ }
1019
+
1020
+ if (scenario.readySelector) {
1021
+ return true;
1022
+ }
1023
+
1024
+ if (scenario.ready?.selector || scenario.ready?.expression) {
1025
+ return true;
1026
+ }
1027
+
1028
+ return Array.isArray(scenario.steps)
1029
+ ? scenario.steps.some(
1030
+ (step) => step?.action === "waitForSelector" && step?.selector,
1031
+ )
1032
+ : false;
1033
+ }
1034
+
1043
1035
  /**
1044
1036
  * Validate full configuration for capture readiness
1037
+ * @param {Object} [options] - Validation options
1038
+ * @param {string[]} [options.scenarioKeys] - Restrict validation to scenario keys
1039
+ * @param {boolean} [options.requireReadyContract] - Enforce deterministic readiness
1045
1040
  * @returns {Object} Validation result
1046
1041
  */
1047
- function validateConfig() {
1042
+ function validateConfig(options = {}) {
1043
+ const { scenarioKeys = null, requireReadyContract = false } = options;
1044
+
1048
1045
  try {
1049
1046
  const config = readConfig();
1050
- return validateCaptureRequirements(config);
1047
+ const baseResult = validateCaptureRequirements(config);
1048
+ const errors = [...baseResult.errors];
1049
+ const warnings = baseResult.warnings.filter(
1050
+ (warning) => warning !== "baseUrl should start with http:// or https://",
1051
+ );
1052
+ const scenarios = Array.isArray(config.scenarios) ? config.scenarios : [];
1053
+ const requestedScenarioKeys = Array.isArray(scenarioKeys)
1054
+ ? scenarioKeys.filter(Boolean)
1055
+ : [];
1056
+ const selectedScenarios = requestedScenarioKeys.length
1057
+ ? scenarios.filter((scenario) => requestedScenarioKeys.includes(scenario.key))
1058
+ : scenarios;
1059
+ const missingScenarioKeys = requestedScenarioKeys.filter(
1060
+ (key) => !scenarios.some((scenario) => scenario.key === key),
1061
+ );
1062
+
1063
+ if (missingScenarioKeys.length > 0) {
1064
+ errors.push(
1065
+ `Unknown scenario key(s): ${missingScenarioKeys.join(", ")}. ` +
1066
+ `Available scenarios: ${scenarios.map((scenario) => scenario.key).join(", ") || "none"}`,
1067
+ );
1068
+ }
1069
+
1070
+ if (config.baseUrl) {
1071
+ try {
1072
+ const parsedUrl = new URL(config.baseUrl);
1073
+ if (!["http:", "https:"].includes(parsedUrl.protocol)) {
1074
+ errors.push(
1075
+ `baseUrl must use http:// or https:// (received ${parsedUrl.protocol})`,
1076
+ );
1077
+ }
1078
+ } catch (error) {
1079
+ errors.push(
1080
+ `baseUrl must be a valid absolute URL. Received: ${config.baseUrl}`,
1081
+ );
1082
+ }
1083
+ }
1084
+
1085
+ const settings = readSettingsSafe();
1086
+ const configuredVariables = {
1087
+ ...(settings?.urlVariables || {}),
1088
+ };
1089
+ if (!configuredVariables.PROJECT_ID && settings?.projectId) {
1090
+ configuredVariables.PROJECT_ID = settings.projectId;
1091
+ }
1092
+
1093
+ const unresolvedVariables = [];
1094
+ for (const scenario of selectedScenarios) {
1095
+ for (const variableName of collectTemplateVariables(scenario.url)) {
1096
+ if (!configuredVariables[variableName] && !process.env[variableName]) {
1097
+ unresolvedVariables.push(`${scenario.key}:${variableName}`);
1098
+ }
1099
+ }
1100
+ }
1101
+
1102
+ if (unresolvedVariables.length > 0) {
1103
+ errors.push(
1104
+ `Unresolved URL variable(s): ${unresolvedVariables.join(", ")}. ` +
1105
+ `Set them in .reshot/settings.json under urlVariables or as environment variables.`,
1106
+ );
1107
+ }
1108
+
1109
+ const shouldRequireReadyContract =
1110
+ requireReadyContract || config.target?.tier === "certified";
1111
+ if (shouldRequireReadyContract) {
1112
+ const missingReadyContract = selectedScenarios
1113
+ .filter((scenario) => !hasDeterministicReadyContract(scenario))
1114
+ .map((scenario) => scenario.key);
1115
+
1116
+ if (missingReadyContract.length > 0) {
1117
+ errors.push(
1118
+ `Deterministic readiness is required for: ${missingReadyContract.join(", ")}. ` +
1119
+ `Add ready.selector, ready.expression, readySelector, or a waitForSelector step.`,
1120
+ );
1121
+ }
1122
+ }
1123
+
1124
+ const authScenarioCount = selectedScenarios.filter(
1125
+ (scenario) => scenario.captureClass === "live-auth" || scenario.requiresAuth,
1126
+ ).length;
1127
+ const liveAuthScenarioCount = selectedScenarios.filter(
1128
+ (scenario) => scenario.captureClass === "live-auth",
1129
+ ).length;
1130
+
1131
+ return {
1132
+ valid: errors.length === 0,
1133
+ errors,
1134
+ warnings,
1135
+ details: {
1136
+ baseUrl: config.baseUrl || null,
1137
+ selectedScenarioKeys: selectedScenarios.map((scenario) => scenario.key),
1138
+ missingScenarioKeys,
1139
+ unresolvedVariables,
1140
+ requiresReadyContract: shouldRequireReadyContract,
1141
+ authScenarioCount,
1142
+ liveAuthScenarioCount,
1143
+ },
1144
+ };
1051
1145
  } catch (e) {
1052
1146
  return {
1053
1147
  valid: false,
1054
1148
  errors: [e.message],
1055
1149
  warnings: [],
1150
+ details: {
1151
+ baseUrl: null,
1152
+ selectedScenarioKeys: [],
1153
+ missingScenarioKeys: [],
1154
+ unresolvedVariables: [],
1155
+ requiresReadyContract: requireReadyContract,
1156
+ authScenarioCount: 0,
1157
+ liveAuthScenarioCount: 0,
1158
+ },
1056
1159
  };
1057
1160
  }
1058
1161
  }
@@ -1230,8 +1333,10 @@ module.exports = {
1230
1333
  // Mode & validation
1231
1334
  getModeInfo,
1232
1335
  validateConfig,
1233
- // DocSync configuration
1234
- readDocSyncConfig,
1336
+ // Lenient config read (no scenario validation)
1337
+ readConfigLenient,
1338
+ // Certified target contract helpers
1339
+ getCertifiedScenarioKeys,
1235
1340
  // Paths
1236
1341
  SETTINGS_PATH,
1237
1342
  SETTINGS_DIR,
@@ -0,0 +1,64 @@
1
+ // dom-capture.js — CLI integration layer for the Tier-3 DOM-capture engine.
2
+ //
3
+ // The capture techniques (m7 hybrid primary, m4 CDP DOMSnapshot fallback) live in
4
+ // @reshot/compose/capture so they can reuse the deterministic renderer + the
5
+ // calibrated verify evaluator and be gated under `pnpm --dir packages/compose
6
+ // test`. This module is the thin CLI-side wrapper: it loads that engine (with a
7
+ // monorepo dist fallback, mirroring commands/compose.js) and exposes the two
8
+ // entry points the CLI needs.
9
+
10
+ const path = require("path");
11
+ const fs = require("fs-extra");
12
+ const { composeDistDir } = require("./compose-runtime");
13
+
14
+ function loadCaptureEngine() {
15
+ return require(path.join(composeDistDir(), "capture.cjs"));
16
+ }
17
+
18
+ /**
19
+ * Capture a DOM artifact from an ALREADY-NAVIGATED live page and write it next to
20
+ * the other capture outputs. Returns the metadata.domArtifact block (or null if
21
+ * capture failed — capture is additive and must never break the video path).
22
+ *
23
+ * @param {object} args
24
+ * @param {import('playwright').Page} args.page live page (capture as-is)
25
+ * @param {string} args.outputDir
26
+ * @param {string} args.slug
27
+ * @param {string|null} [args.csp]
28
+ */
29
+ async function captureDomArtifact({ page, outputDir, slug, csp = null }) {
30
+ try {
31
+ const { captureDom, writeArtifact } = loadCaptureEngine();
32
+ const snapshot = await captureDom(page, { csp });
33
+ const base = path.join(outputDir, slug);
34
+ const paths = await writeArtifact(snapshot, base);
35
+ return { path: paths.html, method: snapshot.method, sidecars: snapshot.surfaces };
36
+ } catch (error) {
37
+ const message = error instanceof Error ? error.message : String(error);
38
+ console.warn(` ⚠ DOM capture skipped: ${message}`);
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Standalone capture of a URL: navigates, captures, remounts, and writes the
45
+ * artifact + remounted.png + live.png into outDir. Returns a result summary.
46
+ */
47
+ async function captureDomFromUrl({ url, outDir, slug = "capture", settings }) {
48
+ const { captureUrl, writeArtifact } = loadCaptureEngine();
49
+ await fs.ensureDir(outDir);
50
+ const result = await captureUrl(url, settings ? { settings } : {});
51
+ const paths = await writeArtifact(result.snapshot, path.join(outDir, slug));
52
+ await fs.writeFile(path.join(outDir, "remounted.png"), result.remountPng);
53
+ await fs.writeFile(path.join(outDir, "live.png"), result.livePng);
54
+ return {
55
+ method: result.method,
56
+ artifact: paths.html,
57
+ meta: paths.meta,
58
+ remounted: path.join(outDir, "remounted.png"),
59
+ live: path.join(outDir, "live.png"),
60
+ sidecars: result.snapshot.surfaces,
61
+ };
62
+ }
63
+
64
+ module.exports = { loadCaptureEngine, captureDomArtifact, captureDomFromUrl };
@@ -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
+ };
@@ -54,8 +54,8 @@ const TEMPLATE_PRESETS = {
54
54
  // GitHub Pages / Static site friendly
55
55
  "static-site": "./public/screenshots/{{locale}}/{{scenario}}/{{name}}.{{ext}}",
56
56
 
57
- // CI/CD artifacts
58
- ci: "./artifacts/screenshots/{{scenario}}/{{viewport}}/{{variant}}/{{name}}.{{ext}}",
57
+ // Local CLI artifacts
58
+ cli: "./artifacts/screenshots/{{scenario}}/{{viewport}}/{{variant}}/{{name}}.{{ext}}",
59
59
  };
60
60
 
61
61
  /**
@@ -291,7 +291,7 @@ function getTemplatePresets() {
291
291
  { name: "docs", template: TEMPLATE_PRESETS.docs, description: "Simple docs asset structure" },
292
292
  { name: "variant-matrix", template: TEMPLATE_PRESETS["variant-matrix"], description: "Hierarchical by variant dimensions" },
293
293
  { name: "static-site", template: TEMPLATE_PRESETS["static-site"], description: "GitHub Pages / static site friendly" },
294
- { name: "ci", template: TEMPLATE_PRESETS.ci, description: "CI/CD artifact organization" },
294
+ { name: "cli", template: TEMPLATE_PRESETS.cli, description: "Local CLI artifact organization" },
295
295
  ];
296
296
  }
297
297