@reshotdev/screenshot 0.0.1-beta.12 → 0.0.1-beta.14

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 (63) hide show
  1. package/README.md +67 -22
  2. package/package.json +18 -14
  3. package/src/commands/auth.js +37 -7
  4. package/src/commands/capture-dom.js +50 -0
  5. package/src/commands/compose.js +220 -0
  6. package/src/commands/doctor-release.js +7 -0
  7. package/src/commands/doctor-target.js +36 -4
  8. package/src/commands/drifts.js +13 -1
  9. package/src/commands/publish.js +183 -21
  10. package/src/commands/pull.js +9 -4
  11. package/src/commands/refresh.js +166 -0
  12. package/src/commands/setup-wizard.js +57 -3
  13. package/src/commands/status.js +22 -2
  14. package/src/commands/variation.js +194 -0
  15. package/src/index.js +190 -10
  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 +76 -2
  21. package/src/lib/capture-script-runner.js +289 -138
  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 +2 -2
  28. package/src/lib/dom-capture.js +64 -0
  29. package/src/lib/ensure-browser.js +147 -0
  30. package/src/lib/record-clip.js +83 -3
  31. package/src/lib/record-config.js +0 -4
  32. package/src/lib/release-doctor.js +11 -3
  33. package/src/lib/resolve-targets.js +60 -0
  34. package/src/lib/run-manifest.js +45 -0
  35. package/src/lib/ui-api-helpers.js +118 -0
  36. package/src/lib/ui-api.js +28 -820
  37. package/src/lib/ui-asset-cleanup.js +62 -0
  38. package/src/lib/ui-output-versions.js +165 -0
  39. package/src/lib/ui-recorder-routes.js +341 -0
  40. package/src/lib/ui-scenario-metadata.js +161 -0
  41. package/vendor/compose/dist/auto-update.cjs +5544 -0
  42. package/vendor/compose/dist/auto-update.mjs +5518 -0
  43. package/vendor/compose/dist/capture.cjs +1450 -0
  44. package/vendor/compose/dist/capture.mjs +1416 -0
  45. package/vendor/compose/dist/eligibility.cjs +5331 -0
  46. package/vendor/compose/dist/eligibility.mjs +5313 -0
  47. package/vendor/compose/dist/index.cjs +2046 -0
  48. package/vendor/compose/dist/index.mjs +1997 -0
  49. package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
  50. package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
  51. package/vendor/compose/dist/jsx-runtime.cjs +58 -0
  52. package/vendor/compose/dist/jsx-runtime.mjs +31 -0
  53. package/vendor/compose/dist/render.cjs +558 -0
  54. package/vendor/compose/dist/render.mjs +515 -0
  55. package/vendor/compose/dist/verify-cli.cjs +3806 -0
  56. package/vendor/compose/dist/verify-cli.mjs +3812 -0
  57. package/vendor/compose/dist/verify.cjs +3880 -0
  58. package/vendor/compose/dist/verify.mjs +3858 -0
  59. package/web/manager/dist/assets/{index-CvleJUur.js → index-D0S2otug.js} +56 -56
  60. package/web/manager/dist/index.html +1 -1
  61. package/src/commands/ingest.js +0 -458
  62. package/src/commands/setup.js +0 -165
  63. package/src/lib/playwright-runner.js +0 -252
@@ -372,7 +372,12 @@ async function runDoctorTarget(options = {}) {
372
372
  const docSyncConfig = config.readConfig();
373
373
  const target = docSyncConfig.target;
374
374
  const scenarios = getSelectedScenarios(docSyncConfig, options.scenarioKeys);
375
- const timeoutMs = options.timeoutMs || 30_000;
375
+ const timeoutMs = options.timeoutMs || 15_000;
376
+ // Overall budget so the command fails fast instead of grinding through every
377
+ // scenario at the full per-step timeout (which read as an indefinite hang).
378
+ const overallTimeoutMs = options.overallTimeoutMs || Math.max(timeoutMs * 4, 60_000);
379
+ const startedAt = Date.now();
380
+ const overBudget = () => Date.now() - startedAt > overallTimeoutMs;
376
381
  const onProgress =
377
382
  typeof options.onProgress === "function" ? options.onProgress : null;
378
383
 
@@ -402,7 +407,23 @@ async function runDoctorTarget(options = {}) {
402
407
  const advisories = [];
403
408
  const info = [];
404
409
 
410
+ let budgetExceeded = false;
405
411
  for (const scenario of scenarios) {
412
+ if (overBudget()) {
413
+ budgetExceeded = true;
414
+ onProgress?.(
415
+ `Overall doctor budget (${overallTimeoutMs}ms) exceeded — stopping before "${scenario.key}".`,
416
+ );
417
+ blockingIssues.push(
418
+ createIssue(
419
+ "blocking",
420
+ "doctor_timeout",
421
+ `Target doctor exceeded its overall time budget of ${overallTimeoutMs}ms. Remaining scenarios were not audited.`,
422
+ { auditedScenarios: readinessAudits.length, totalScenarios: scenarios.length },
423
+ ),
424
+ );
425
+ break;
426
+ }
406
427
  onProgress?.(`Auditing routes for ${scenario.key}...`);
407
428
  const routeResults = [];
408
429
  for (const route of scenario.requiredRoutes || []) {
@@ -544,6 +565,7 @@ async function runDoctorTarget(options = {}) {
544
565
  }
545
566
 
546
567
  const ok =
568
+ !budgetExceeded &&
547
569
  requiredEnv.every((item) => item.present) &&
548
570
  (fixture.skipped || fixture.ok) &&
549
571
  captureSafe.ok &&
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+
3
+ const path = require("path");
4
+ const fs = require("fs-extra");
5
+
6
+ const DEFAULT_FORMATS = ["mp4", "webm", "poster"];
7
+ const VALID_FORMATS = new Set(["mp4", "webm", "poster", "gif"]);
8
+
9
+ async function resolveComposeContext(file, options = {}) {
10
+ const compositionPath = path.resolve(process.cwd(), file);
11
+ if (!(await fs.pathExists(compositionPath))) {
12
+ throw new Error(
13
+ `Composition file not found: ${compositionPath}\n` +
14
+ "Pass the path to a .compose.tsx file.",
15
+ );
16
+ }
17
+
18
+ const slug = options.slug || deriveSlug(compositionPath);
19
+ const compositionDir = path.dirname(compositionPath);
20
+ const metadataPath = resolveMetadataPath(compositionDir, slug);
21
+ const metadata = await readMetadata(metadataPath, slug);
22
+ const capturePath = resolveCapturePath({
23
+ metadata,
24
+ compositionDir,
25
+ slug,
26
+ });
27
+ await assertCaptureExists(capturePath, slug);
28
+
29
+ return {
30
+ compositionPath,
31
+ slug,
32
+ compositionDir,
33
+ metadataPath,
34
+ metadata,
35
+ capturePath,
36
+ };
37
+ }
38
+
39
+ function deriveSlug(filePath) {
40
+ const filename = path.basename(filePath);
41
+ const withoutKnownSuffix = filename.replace(/\.(compose\.)?[cm]?[tj]sx?$/i, "");
42
+ return withoutKnownSuffix || path.basename(filePath, path.extname(filePath));
43
+ }
44
+
45
+ function resolveOutBase(compositionPath, out) {
46
+ return path.resolve(
47
+ process.cwd(),
48
+ out || path.join(path.dirname(compositionPath), `${deriveSlug(compositionPath)}.composed`),
49
+ );
50
+ }
51
+
52
+ function resolveMetadataPath(compositionDir, slug) {
53
+ return path.join(compositionDir, `${slug}.metadata.json`);
54
+ }
55
+
56
+ async function readMetadata(metadataPath, slug) {
57
+ if (!(await fs.pathExists(metadataPath))) {
58
+ throw new Error(
59
+ `Missing metadata file for slug "${slug}": ${metadataPath}\n` +
60
+ `Expected a sibling ${slug}.metadata.json file. Re-record the clip with metadata enabled or pass --slug matching an existing metadata file.`,
61
+ );
62
+ }
63
+
64
+ try {
65
+ const metadata = await fs.readJson(metadataPath);
66
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
67
+ throw new Error("metadata must be a JSON object");
68
+ }
69
+ return metadata;
70
+ } catch (error) {
71
+ const message = error instanceof Error ? error.message : String(error);
72
+ throw new Error(`Could not read metadata file: ${metadataPath}\n${message}`);
73
+ }
74
+ }
75
+
76
+ function resolveCapturePath({ metadata, compositionDir, slug }) {
77
+ const declaredPath =
78
+ metadata.capturePath ||
79
+ metadata.workflowCapturePath ||
80
+ metadata.capture?.path ||
81
+ metadata.source?.capturePath ||
82
+ metadata.source?.path;
83
+
84
+ if (typeof declaredPath === "string" && declaredPath.trim()) {
85
+ return path.resolve(compositionDir, declaredPath);
86
+ }
87
+
88
+ return path.join(compositionDir, `workflow-capture-${slug}.mp4`);
89
+ }
90
+
91
+ async function assertCaptureExists(capturePath, slug) {
92
+ if (await fs.pathExists(capturePath)) {
93
+ return;
94
+ }
95
+
96
+ throw new Error(
97
+ `Missing workflow capture for slug "${slug}": ${capturePath}\n` +
98
+ `Expected workflow-capture-${slug}.mp4 next to the composition, or a capturePath in ${slug}.metadata.json.`,
99
+ );
100
+ }
101
+
102
+ function parseSize(value) {
103
+ const match = /^(\d+)x(\d+)$/i.exec(String(value || "").trim());
104
+ if (!match) {
105
+ throw new Error(`Invalid --size "${value}". Use WIDTHxHEIGHT, for example 1440x900.`);
106
+ }
107
+
108
+ const width = Number(match[1]);
109
+ const height = Number(match[2]);
110
+ if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) {
111
+ throw new Error(`Invalid --size "${value}". Width and height must be positive integers.`);
112
+ }
113
+
114
+ return { width, height };
115
+ }
116
+
117
+ function parseFormats(value, includeGif = false) {
118
+ const rawFormats = value == null || value === "" ? DEFAULT_FORMATS : String(value).split(",");
119
+ const formats = [];
120
+
121
+ for (const rawFormat of rawFormats) {
122
+ const format = rawFormat.trim().toLowerCase();
123
+ if (!format) continue;
124
+ if (!VALID_FORMATS.has(format)) {
125
+ throw new Error(
126
+ `Unknown compose format "${rawFormat}". Supported formats: ${Array.from(VALID_FORMATS).join(", ")}.`,
127
+ );
128
+ }
129
+ if (!formats.includes(format)) {
130
+ formats.push(format);
131
+ }
132
+ }
133
+
134
+ if (includeGif && !formats.includes("gif")) {
135
+ formats.push("gif");
136
+ }
137
+
138
+ if (formats.length === 0) {
139
+ throw new Error(`No compose formats selected. Use --formats=${DEFAULT_FORMATS.join(",")}.`);
140
+ }
141
+
142
+ return formats;
143
+ }
144
+
145
+ module.exports = {
146
+ DEFAULT_FORMATS,
147
+ assertCaptureExists,
148
+ deriveSlug,
149
+ parseFormats,
150
+ parseSize,
151
+ readMetadata,
152
+ resolveCapturePath,
153
+ resolveComposeContext,
154
+ resolveMetadataPath,
155
+ resolveOutBase,
156
+ };
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs-extra");
4
+
5
+ async function packFromExistingOutputs(outBase) {
6
+ const pack = {
7
+ mp4: `${outBase}.mp4`,
8
+ webm: `${outBase}.webm`,
9
+ poster: `${outBase}.webp`,
10
+ };
11
+ const gifPath = `${outBase}.gif`;
12
+ if (await fs.pathExists(gifPath)) {
13
+ pack.gif = gifPath;
14
+ }
15
+ return pack;
16
+ }
17
+
18
+ async function assertUploadPackExists(pack, skipRender) {
19
+ const required = [
20
+ ["rendered_mp4", pack.mp4],
21
+ ["rendered_webm", pack.webm],
22
+ ["rendered_poster", pack.poster],
23
+ ];
24
+
25
+ for (const [field, filePath] of required) {
26
+ if (!filePath || !(await fs.pathExists(filePath))) {
27
+ const rerenderHint = skipRender
28
+ ? " Run `reshot compose <file>` first or remove --skip-render."
29
+ : "";
30
+ throw new Error(`Missing ${field} output: ${filePath || "(not produced)"}.${rerenderHint}`);
31
+ }
32
+ }
33
+
34
+ if (pack.gif && !(await fs.pathExists(pack.gif))) {
35
+ throw new Error(`Missing rendered_gif output: ${pack.gif}.`);
36
+ }
37
+ }
38
+
39
+ module.exports = {
40
+ assertUploadPackExists,
41
+ packFromExistingOutputs,
42
+ };
@@ -0,0 +1,34 @@
1
+ // compose-runtime.js — locate the @reshot/compose build the CLI ships with.
2
+ //
3
+ // The compose engine is vendored (self-contained: motion-core and the pure-JS
4
+ // image deps are inlined; only esbuild + playwright-core stay external and are
5
+ // declared as CLI dependencies). The published package ships vendor/compose/dist;
6
+ // when developing inside the monorepo we fall back to the live package build.
7
+
8
+ const path = require("path");
9
+ const fs = require("fs-extra");
10
+
11
+ let cached;
12
+
13
+ /** Absolute path to the compose dist directory (capture.cjs, render.mjs, …). */
14
+ function composeDistDir() {
15
+ if (cached) return cached;
16
+ const candidates = [
17
+ // Shipped with the published CLI (also present in the standalone repo).
18
+ path.resolve(__dirname, "../../vendor/compose/dist"),
19
+ // Monorepo development: packages/compose/dist.
20
+ path.resolve(__dirname, "../../../../packages/compose/dist"),
21
+ ];
22
+ for (const dir of candidates) {
23
+ if (fs.existsSync(dir)) {
24
+ cached = dir;
25
+ return dir;
26
+ }
27
+ }
28
+ throw new Error(
29
+ "@reshot/compose build not found. Expected vendor/compose/dist " +
30
+ "(shipped with the CLI) or packages/compose/dist (monorepo dev).",
31
+ );
32
+ }
33
+
34
+ module.exports = { composeDistDir };
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs-extra");
4
+ const axios = require("axios");
5
+ const FormData = require("form-data");
6
+ const config = require("./config");
7
+ const apiClient = require("./api-client");
8
+
9
+ function resolveComposeProjectContext({ projectOption, settings }) {
10
+ const resolvedSettings =
11
+ settings !== undefined ? settings : readSettingsSafe();
12
+ const apiKey = process.env.RESHOT_API_KEY || resolvedSettings?.apiKey;
13
+ const projectId =
14
+ projectOption ||
15
+ process.env.RESHOT_PROJECT_ID ||
16
+ resolvedSettings?.projectId;
17
+
18
+ if (!apiKey) {
19
+ throw new Error(
20
+ "No API key found. Set RESHOT_API_KEY or run `reshot auth` to create .reshot/settings.json.",
21
+ );
22
+ }
23
+
24
+ if (!projectId) {
25
+ throw new Error(
26
+ "No project ID found. Pass --project, set RESHOT_PROJECT_ID, or authenticate with `reshot auth`.",
27
+ );
28
+ }
29
+
30
+ return { apiKey, projectId };
31
+ }
32
+
33
+ function readSettingsSafe() {
34
+ try {
35
+ return config.readSettings();
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ async function uploadComposition({
42
+ apiBaseUrl,
43
+ apiKey,
44
+ projectId,
45
+ name,
46
+ slug,
47
+ sourceTsx,
48
+ metadataJson,
49
+ pack,
50
+ autoApprove = false,
51
+ httpClient = axios,
52
+ }) {
53
+ const formData = new FormData();
54
+ formData.append("name", name);
55
+ formData.append("slug", slug);
56
+ formData.append("source_tsx", sourceTsx);
57
+ formData.append("metadata_json", metadataJson);
58
+ if (autoApprove) {
59
+ formData.append("auto_approve", "true");
60
+ }
61
+ formData.append("rendered_mp4", fs.createReadStream(pack.mp4));
62
+ formData.append("rendered_webm", fs.createReadStream(pack.webm));
63
+ formData.append("rendered_poster", fs.createReadStream(pack.poster));
64
+ if (pack.gif) {
65
+ formData.append("rendered_gif", fs.createReadStream(pack.gif));
66
+ }
67
+
68
+ const endpoint = `${apiBaseUrl.replace(/\/+$/, "")}/projects/${encodeURIComponent(projectId)}/compositions`;
69
+ const response = await httpClient.post(endpoint, formData, {
70
+ headers: {
71
+ ...formData.getHeaders(),
72
+ Authorization: `Bearer ${apiKey}`,
73
+ },
74
+ timeout: 180000,
75
+ maxBodyLength: Infinity,
76
+ maxContentLength: Infinity,
77
+ });
78
+
79
+ const data = unwrapApiResponse(response.data);
80
+ const attributionWarning = readResponseHeader(
81
+ response.headers,
82
+ "x-reshot-attribution-warning",
83
+ );
84
+
85
+ if (attributionWarning) {
86
+ return { ...data, attributionWarning };
87
+ }
88
+
89
+ return data;
90
+ }
91
+
92
+ function unwrapApiResponse(body) {
93
+ if (body && typeof body === "object" && body.success && body.data !== undefined) {
94
+ return body.data;
95
+ }
96
+ return body;
97
+ }
98
+
99
+ function readResponseHeader(headers, name) {
100
+ if (!headers) return null;
101
+ if (typeof headers.get === "function") {
102
+ return headers.get(name);
103
+ }
104
+ return headers[name] || headers[name.toLowerCase()] || null;
105
+ }
106
+
107
+ function buildDashboardUrl(response, apiBaseUrl, projectId) {
108
+ const platformUrl = apiBaseUrl.replace(/\/api\/?$/, "");
109
+ const dashboardUrl =
110
+ response?.dashboardUrl ||
111
+ response?.composition?.dashboardUrl ||
112
+ response?.render?.dashboardUrl;
113
+ if (dashboardUrl) {
114
+ return dashboardUrl;
115
+ }
116
+
117
+ const compositionId = response?.composition?.id || response?.id;
118
+ const base = `${platformUrl}/app/projects/${encodeURIComponent(projectId)}/compositions`;
119
+ return compositionId ? `${base}/${encodeURIComponent(compositionId)}` : base;
120
+ }
121
+
122
+ function humanizeName(slug) {
123
+ return String(slug)
124
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
125
+ .replace(/[-_]+/g, " ")
126
+ .trim() || slug;
127
+ }
128
+
129
+ function getComposeApiBaseUrl(explicitApiBaseUrl) {
130
+ return explicitApiBaseUrl || apiClient.getApiBaseUrl();
131
+ }
132
+
133
+ module.exports = {
134
+ buildDashboardUrl,
135
+ getComposeApiBaseUrl,
136
+ humanizeName,
137
+ readResponseHeader,
138
+ readSettingsSafe,
139
+ resolveComposeProjectContext,
140
+ unwrapApiResponse,
141
+ uploadComposition,
142
+ };
package/src/lib/config.js CHANGED
@@ -330,9 +330,9 @@ function readConfig() {
330
330
  if (!scenario.key) {
331
331
  throw new Error(`Scenario "${scenario.name}" must have a "key" field`);
332
332
  }
333
- if (!/^[a-z0-9-]+$/i.test(scenario.key)) {
333
+ if (!/^[a-z0-9]([a-z0-9\-_/]*[a-z0-9])?$/i.test(scenario.key)) {
334
334
  throw new Error(
335
- `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.`
336
336
  );
337
337
  }
338
338
  if (!scenario.url) {
@@ -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
+ };