@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.
- package/README.md +67 -22
- package/package.json +18 -14
- package/src/commands/auth.js +37 -7
- package/src/commands/capture-dom.js +50 -0
- package/src/commands/compose.js +220 -0
- package/src/commands/doctor-release.js +7 -0
- package/src/commands/doctor-target.js +36 -4
- package/src/commands/drifts.js +13 -1
- package/src/commands/publish.js +183 -21
- package/src/commands/pull.js +9 -4
- package/src/commands/refresh.js +166 -0
- package/src/commands/setup-wizard.js +57 -3
- package/src/commands/status.js +22 -2
- package/src/commands/variation.js +194 -0
- package/src/index.js +190 -10
- package/src/lib/api-client.js +61 -35
- package/src/lib/auto-update/refresh.js +598 -0
- package/src/lib/auto-update/scene-runtime.compose.tsx +73 -0
- package/src/lib/auto-update/spec.js +89 -0
- package/src/lib/capture-engine.js +76 -2
- package/src/lib/capture-script-runner.js +289 -138
- package/src/lib/certification.js +23 -1
- package/src/lib/compose-context.js +156 -0
- package/src/lib/compose-pack.js +42 -0
- package/src/lib/compose-runtime.js +34 -0
- package/src/lib/compose-upload.js +142 -0
- package/src/lib/config.js +2 -2
- package/src/lib/dom-capture.js +64 -0
- package/src/lib/ensure-browser.js +147 -0
- package/src/lib/record-clip.js +83 -3
- package/src/lib/record-config.js +0 -4
- package/src/lib/release-doctor.js +11 -3
- package/src/lib/resolve-targets.js +60 -0
- package/src/lib/run-manifest.js +45 -0
- package/src/lib/ui-api-helpers.js +118 -0
- package/src/lib/ui-api.js +28 -820
- package/src/lib/ui-asset-cleanup.js +62 -0
- package/src/lib/ui-output-versions.js +165 -0
- package/src/lib/ui-recorder-routes.js +341 -0
- package/src/lib/ui-scenario-metadata.js +161 -0
- package/vendor/compose/dist/auto-update.cjs +5544 -0
- package/vendor/compose/dist/auto-update.mjs +5518 -0
- package/vendor/compose/dist/capture.cjs +1450 -0
- package/vendor/compose/dist/capture.mjs +1416 -0
- package/vendor/compose/dist/eligibility.cjs +5331 -0
- package/vendor/compose/dist/eligibility.mjs +5313 -0
- package/vendor/compose/dist/index.cjs +2046 -0
- package/vendor/compose/dist/index.mjs +1997 -0
- package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
- package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
- package/vendor/compose/dist/jsx-runtime.cjs +58 -0
- package/vendor/compose/dist/jsx-runtime.mjs +31 -0
- package/vendor/compose/dist/render.cjs +558 -0
- package/vendor/compose/dist/render.mjs +515 -0
- package/vendor/compose/dist/verify-cli.cjs +3806 -0
- package/vendor/compose/dist/verify-cli.mjs +3812 -0
- package/vendor/compose/dist/verify.cjs +3880 -0
- package/vendor/compose/dist/verify.mjs +3858 -0
- package/web/manager/dist/assets/{index-CvleJUur.js → index-D0S2otug.js} +56 -56
- package/web/manager/dist/index.html +1 -1
- package/src/commands/ingest.js +0 -458
- package/src/commands/setup.js +0 -165
- package/src/lib/playwright-runner.js +0 -252
package/src/lib/certification.js
CHANGED
|
@@ -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 ||
|
|
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-]
|
|
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
|
|
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
|
+
};
|