@beryl-so/cli 0.73.2 → 0.78.0
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 +6 -10
- package/dist/adapters/mcp.js +1 -1
- package/dist/beryl-test-skill.js +90 -52
- package/dist/commands/accounts.js +31 -2
- package/dist/commands/config-vars.js +86 -15
- package/dist/commands/environments.js +18 -1
- package/dist/commands/init.js +32 -98
- package/dist/commands/jobs.js +4 -0
- package/dist/commands/projects.js +19 -45
- package/dist/commands/runs.js +6 -433
- package/dist/commands/tests.js +5 -107
- package/dist/commands/third-party-integrations.js +26 -3
- package/dist/commands/watch.js +21 -10
- package/dist/lint.js +120 -0
- package/dist/schema.generated.js +183 -28
- package/dist/skill-tables.js +4 -0
- package/package.json +2 -3
- package/dist/email-pump.js +0 -138
- package/dist/local-exec.js +0 -239
- package/dist/local-run.js +0 -340
- package/dist/playwright-install.js +0 -220
- package/dist/progress.js +0 -44
package/dist/local-exec.js
DELETED
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { startEmailPump } from "./email-pump.js";
|
|
4
|
-
import { PlaywrightMissingError, runSpecLocally, } from "./local-run.js";
|
|
5
|
-
// Client-side mirrors of the server's RUN_IMPORT_* caps: stay under them rather than
|
|
6
|
-
// discover them as a 413/422.
|
|
7
|
-
export const IMPORT_MAX_ERROR_LEN = 5000;
|
|
8
|
-
export const IMPORT_MAX_FILE_BYTES = 14 * 1024 * 1024;
|
|
9
|
-
export const IMPORT_MAX_TOTAL_BYTES = 180 * 1024 * 1024;
|
|
10
|
-
export const IMPORT_MAX_FILES = 3600;
|
|
11
|
-
export const THIRD_PARTY_CLOUD_ONLY = "third party integration steps run only in Beryl's cloud";
|
|
12
|
-
export const THIRD_PARTY_USE_TRIGGER = `${THIRD_PARTY_CLOUD_ONLY}; use \`beryl runs trigger\``;
|
|
13
|
-
// The rendered await_third_party helper, and the authorize step's wait to leave the provider's host.
|
|
14
|
-
const THIRD_PARTY_SPEC_MARKER = /__vmThirdParty\(|\.test\(u\.hostname\)/;
|
|
15
|
-
export const specUsesThirdParty = (content) => THIRD_PARTY_SPEC_MARKER.test(content);
|
|
16
|
-
export function toRunEntry(spec, outcome, runError, startedAt, ordinal, redact) {
|
|
17
|
-
// Mirrors the cloud runner's redact_result: the spec types secrets into the page
|
|
18
|
-
// and an injected session authenticates its requests, so error text and the DOM
|
|
19
|
-
// snapshot can echo them back — scrub before the bytes leave this machine. Frames
|
|
20
|
-
// and screenshots are pixels; nothing to scrub.
|
|
21
|
-
const values = (Array.isArray(redact) ? redact : [redact]).filter((v) => Boolean(v));
|
|
22
|
-
const scrub = (text) => values.reduce((out, v) => out.split(v).join("***"), text);
|
|
23
|
-
const scrubBytes = (bytes) => values.some((v) => bytes.includes(v))
|
|
24
|
-
? Buffer.from(scrub(bytes.toString("utf8")), "utf8")
|
|
25
|
-
: bytes;
|
|
26
|
-
const completedAt = new Date().toISOString();
|
|
27
|
-
const base = {
|
|
28
|
-
test_case_id: spec.id,
|
|
29
|
-
started_at: startedAt,
|
|
30
|
-
completed_at: completedAt,
|
|
31
|
-
frames: [],
|
|
32
|
-
frame_urls: [],
|
|
33
|
-
frame_durations_ms: [],
|
|
34
|
-
files: [],
|
|
35
|
-
};
|
|
36
|
-
if (!outcome) {
|
|
37
|
-
return {
|
|
38
|
-
...base,
|
|
39
|
-
status: "errored",
|
|
40
|
-
phase: "main",
|
|
41
|
-
error_message: scrub(runError ?? "the spec did not run").slice(0, IMPORT_MAX_ERROR_LEN),
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
const result = outcome.results[0];
|
|
45
|
-
const status = result && (result.status === "passed" || result.status === "expected")
|
|
46
|
-
? "passed"
|
|
47
|
-
: "failed";
|
|
48
|
-
const harvested = outcome.harvested;
|
|
49
|
-
const entry = {
|
|
50
|
-
...base,
|
|
51
|
-
status,
|
|
52
|
-
phase: status === "failed" ? (harvested?.phase ?? "main") : "main",
|
|
53
|
-
...(result?.error
|
|
54
|
-
? { error_message: scrub(result.error).slice(0, IMPORT_MAX_ERROR_LEN) }
|
|
55
|
-
: {}),
|
|
56
|
-
...(result?.duration_ms !== undefined ? { duration_ms: Math.max(0, result.duration_ms) } : {}),
|
|
57
|
-
};
|
|
58
|
-
if (!harvested)
|
|
59
|
-
return entry;
|
|
60
|
-
// A frame over the per-file cap would force dropping mid-list and shift the
|
|
61
|
-
// index-aligned url/duration sidecars — drop the whole filmstrip instead.
|
|
62
|
-
// Drop the inlined sign-in's frames before anything is index-aligned to the stored
|
|
63
|
-
// plan: `runs explain` zips the plan's steps against these lists, so an off-by-K here
|
|
64
|
-
// shows every step's screenshot next to the wrong step.
|
|
65
|
-
const skip = Math.min(spec.inlinedLoginSteps ?? 0, harvested.frames.length);
|
|
66
|
-
const frames = harvested.frames.slice(skip);
|
|
67
|
-
if (frames.every((f) => f.length <= IMPORT_MAX_FILE_BYTES)) {
|
|
68
|
-
frames.forEach((bytes, i) => {
|
|
69
|
-
const name = `r${ordinal}-frame-${String(i).padStart(3, "0")}.png`;
|
|
70
|
-
entry.frames.push(name);
|
|
71
|
-
entry.files.push({ name, bytes });
|
|
72
|
-
});
|
|
73
|
-
entry.frame_urls = harvested.frameUrls.slice(skip);
|
|
74
|
-
entry.frame_durations_ms = harvested.frameDurationsMs.slice(skip);
|
|
75
|
-
}
|
|
76
|
-
if (harvested.screenshot && harvested.screenshot.length <= IMPORT_MAX_FILE_BYTES) {
|
|
77
|
-
entry.screenshot = `r${ordinal}-screenshot.png`;
|
|
78
|
-
entry.files.push({ name: entry.screenshot, bytes: harvested.screenshot });
|
|
79
|
-
}
|
|
80
|
-
if (harvested.domSnapshot && harvested.domSnapshot.length <= IMPORT_MAX_FILE_BYTES) {
|
|
81
|
-
entry.dom_snapshot = `r${ordinal}-dom.html`;
|
|
82
|
-
entry.files.push({ name: entry.dom_snapshot, bytes: scrubBytes(harvested.domSnapshot) });
|
|
83
|
-
}
|
|
84
|
-
return entry;
|
|
85
|
-
}
|
|
86
|
-
export function buildImportForm(entries, opts) {
|
|
87
|
-
// Total-size budget: when a big suite would blow past the server's cap, shed whole
|
|
88
|
-
// filmstrips (largest droppable payload, replay-only) test by test — never
|
|
89
|
-
// mid-list, and never silently.
|
|
90
|
-
let total = entries.flatMap((e) => e.files).reduce((n, f) => n + f.bytes.length, 0);
|
|
91
|
-
for (const entry of entries) {
|
|
92
|
-
if (total <= IMPORT_MAX_TOTAL_BYTES)
|
|
93
|
-
break;
|
|
94
|
-
const frameBytes = entry.files
|
|
95
|
-
.filter((f) => entry.frames.includes(f.name))
|
|
96
|
-
.reduce((n, f) => n + f.bytes.length, 0);
|
|
97
|
-
if (frameBytes === 0)
|
|
98
|
-
continue;
|
|
99
|
-
entry.files = entry.files.filter((f) => !entry.frames.includes(f.name));
|
|
100
|
-
entry.frames = [];
|
|
101
|
-
entry.frame_urls = [];
|
|
102
|
-
entry.frame_durations_ms = [];
|
|
103
|
-
total -= frameBytes;
|
|
104
|
-
opts.onNote?.(`replay frames for test ${entry.test_case_id} dropped (upload size cap)`);
|
|
105
|
-
}
|
|
106
|
-
// File-count budget: the server parses at most RUN_IMPORT_MAX_FILES multipart
|
|
107
|
-
// parts, so a big suite's filmstrips can overflow on count with bytes to spare.
|
|
108
|
-
let fileCount = entries.reduce((n, e) => n + e.files.length, 0);
|
|
109
|
-
for (const entry of entries) {
|
|
110
|
-
if (fileCount <= IMPORT_MAX_FILES)
|
|
111
|
-
break;
|
|
112
|
-
const frameFiles = entry.files.filter((f) => entry.frames.includes(f.name)).length;
|
|
113
|
-
if (frameFiles === 0)
|
|
114
|
-
continue;
|
|
115
|
-
entry.files = entry.files.filter((f) => !entry.frames.includes(f.name));
|
|
116
|
-
entry.frames = [];
|
|
117
|
-
entry.frame_urls = [];
|
|
118
|
-
entry.frame_durations_ms = [];
|
|
119
|
-
fileCount -= frameFiles;
|
|
120
|
-
opts.onNote?.(`replay frames for test ${entry.test_case_id} dropped (upload file-count cap)`);
|
|
121
|
-
}
|
|
122
|
-
const manifest = {
|
|
123
|
-
environment_id: opts.environmentId ?? null,
|
|
124
|
-
target_url_override: opts.targetUrlOverride ?? null,
|
|
125
|
-
started_at: opts.startedAt,
|
|
126
|
-
completed_at: opts.completedAt,
|
|
127
|
-
results: entries.map(({ files: _files, ...rest }) => rest),
|
|
128
|
-
};
|
|
129
|
-
const form = new FormData();
|
|
130
|
-
form.append("manifest", JSON.stringify(manifest));
|
|
131
|
-
for (const entry of entries) {
|
|
132
|
-
for (const file of entry.files) {
|
|
133
|
-
form.append("files", new Blob([new Uint8Array(file.bytes)]), file.name);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
return form;
|
|
137
|
-
}
|
|
138
|
-
/** Establish (or reuse) an account's proven session server-side — the same establish a
|
|
139
|
-
* cloud run's setup phase does, so one sign-in serves cloud and local alike. */
|
|
140
|
-
export async function establishAccountSession(client, projectBase, accountId) {
|
|
141
|
-
let res;
|
|
142
|
-
try {
|
|
143
|
-
res = (await client.request("POST", `${projectBase}/test-accounts/${accountId}/session`));
|
|
144
|
-
}
|
|
145
|
-
catch (err) {
|
|
146
|
-
return { error: err instanceof Error ? err.message : String(err) };
|
|
147
|
-
}
|
|
148
|
-
if (res.ok && res.storage_state) {
|
|
149
|
-
return {
|
|
150
|
-
session: {
|
|
151
|
-
storageState: res.storage_state,
|
|
152
|
-
initScripts: res.init_scripts ?? [],
|
|
153
|
-
secrets: res.secrets ?? [],
|
|
154
|
-
},
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
return {
|
|
158
|
-
error: res.error ?? "SESSION_UNAVAILABLE",
|
|
159
|
-
unsupported: Boolean(res.unsupported),
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
/**
|
|
163
|
-
* Run one rendered spec on this machine with its full service harness: write the
|
|
164
|
-
* inbox/login sidecars and pump `await_email` requests over the API while Playwright
|
|
165
|
-
* runs. The local-execution seam behind `runs local`. {@link PlaywrightMissingError}
|
|
166
|
-
* propagates (environmental: the caller decides whether to offer an install); any other
|
|
167
|
-
* throw is captured as `runError` (the spec never ran: an errored result, not a crashed
|
|
168
|
-
* command).
|
|
169
|
-
*/
|
|
170
|
-
export async function executeLocalSpec(deps, opts) {
|
|
171
|
-
const { spec } = opts;
|
|
172
|
-
const inbox = spec.email
|
|
173
|
-
? {
|
|
174
|
-
id: spec.email.inbox_id,
|
|
175
|
-
address: spec.email.address,
|
|
176
|
-
recipientContains: spec.email.recipient_contains ?? undefined,
|
|
177
|
-
// NOW, not the mailbox's creation time: it already holds every earlier run's
|
|
178
|
-
// mail, and an older code served as this run's would pass the step with a value
|
|
179
|
-
// the app then rejects. Sent as stored_after, which fences on the server's own
|
|
180
|
-
// INGESTION time and is clamped server-side — `since` compares against the
|
|
181
|
-
// sender's Date: header, so a fast local clock would fence out mail that arrived.
|
|
182
|
-
storedAfter: new Date().toISOString(),
|
|
183
|
-
}
|
|
184
|
-
: undefined;
|
|
185
|
-
try {
|
|
186
|
-
const outcome = await runSpecLocally({
|
|
187
|
-
spec: spec.content,
|
|
188
|
-
testName: spec.title,
|
|
189
|
-
dir: opts.dir,
|
|
190
|
-
harvest: opts.harvest,
|
|
191
|
-
redact: [
|
|
192
|
-
...(spec.usesLoginPassword && opts.loginPassword ? [opts.loginPassword] : []),
|
|
193
|
-
...(opts.session?.secrets ?? []),
|
|
194
|
-
],
|
|
195
|
-
...(opts.session
|
|
196
|
-
? {
|
|
197
|
-
authSession: {
|
|
198
|
-
storageState: opts.session.storageState,
|
|
199
|
-
initScripts: opts.session.initScripts,
|
|
200
|
-
},
|
|
201
|
-
}
|
|
202
|
-
: {}),
|
|
203
|
-
setup: inbox || (spec.usesLoginPassword && opts.loginPassword !== undefined)
|
|
204
|
-
? (runDir) => {
|
|
205
|
-
if (inbox)
|
|
206
|
-
fs.writeFileSync(path.join(runDir, "email-inbox.json"), JSON.stringify({ address: inbox.address }), "utf8");
|
|
207
|
-
if (spec.usesLoginPassword && opts.loginPassword !== undefined)
|
|
208
|
-
fs.writeFileSync(path.join(runDir, "run-config.json"), JSON.stringify({ login_password: opts.loginPassword }), "utf8");
|
|
209
|
-
}
|
|
210
|
-
: undefined,
|
|
211
|
-
during: (runDir) => {
|
|
212
|
-
const pump = inbox
|
|
213
|
-
? startEmailPump({
|
|
214
|
-
sidecarPath: path.join(runDir, "email-inbox.json"),
|
|
215
|
-
client: deps.client,
|
|
216
|
-
workspaceId: deps.workspaceId,
|
|
217
|
-
inboxId: inbox.id,
|
|
218
|
-
storedAfter: inbox.storedAfter,
|
|
219
|
-
recipientContains: inbox.recipientContains,
|
|
220
|
-
onEvent: opts.onEvent,
|
|
221
|
-
})
|
|
222
|
-
: undefined;
|
|
223
|
-
const extra = opts.onSpawn?.(runDir);
|
|
224
|
-
return () => {
|
|
225
|
-
extra?.();
|
|
226
|
-
pump?.stop();
|
|
227
|
-
};
|
|
228
|
-
},
|
|
229
|
-
});
|
|
230
|
-
return { outcome };
|
|
231
|
-
}
|
|
232
|
-
catch (err) {
|
|
233
|
-
if (err instanceof PlaywrightMissingError)
|
|
234
|
-
throw err;
|
|
235
|
-
// The spec never ran (compile error, filtered away) — an errored result the
|
|
236
|
-
// caller records, not an aborted command.
|
|
237
|
-
return { runError: err instanceof Error ? err.message : String(err) };
|
|
238
|
-
}
|
|
239
|
-
}
|
package/dist/local-run.js
DELETED
|
@@ -1,340 +0,0 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { installHint, PLAYWRIGHT_INSTALL_COMMANDS, resolvePlaywrightRunner, } from "./playwright-install.js";
|
|
5
|
-
// @playwright/test is invoked as an external, never bundled — the CLI's zero-runtime-dep rule.
|
|
6
|
-
// The install commands come from playwright-install.ts so hint and installer never drift apart.
|
|
7
|
-
export const PLAYWRIGHT_INSTALL_HINT = "Local Playwright not found. Install it in this project, then re-run:\n" +
|
|
8
|
-
` ${PLAYWRIGHT_INSTALL_COMMANDS}`;
|
|
9
|
-
// Isolate the run from any playwright.config.ts in the customer's repo: a stray `testMatch`
|
|
10
|
-
// would exclude our spec (a zero-test run that reads as a false pass), and a `use.baseURL` /
|
|
11
|
-
// `use.storageState` / `globalSetup` there would silently retarget or reauth the run we mean
|
|
12
|
-
// to be a clean local execution of exactly this one spec. `testDir` pins us to our own dir.
|
|
13
|
-
// Reporter is left to the CLI flag + PLAYWRIGHT_JSON_OUTPUT_NAME (as the cloud runner does), so
|
|
14
|
-
// the report lands at exactly our path regardless of what a reporter here would default to.
|
|
15
|
-
const ISOLATING_CONFIG = (testDir, artifactsDir) => `import { defineConfig } from "@playwright/test";\n` +
|
|
16
|
-
`export default defineConfig({\n` +
|
|
17
|
-
` testDir: ${JSON.stringify(testDir)},\n` +
|
|
18
|
-
` testMatch: "beryl-local.spec.ts",\n` +
|
|
19
|
-
` outputDir: ${JSON.stringify(artifactsDir)},\n` +
|
|
20
|
-
` fullyParallel: false,\n` +
|
|
21
|
-
`});\n`;
|
|
22
|
-
export function scrubText(text, redact) {
|
|
23
|
-
let out = text;
|
|
24
|
-
for (const value of redact ?? []) {
|
|
25
|
-
if (value)
|
|
26
|
-
out = out.split(value).join("***");
|
|
27
|
-
}
|
|
28
|
-
return out;
|
|
29
|
-
}
|
|
30
|
-
export function copyTextScrubbed(src, dest, redact) {
|
|
31
|
-
if (!redact?.length) {
|
|
32
|
-
fs.copyFileSync(src, dest);
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
fs.writeFileSync(dest, scrubText(fs.readFileSync(src, "utf8"), redact));
|
|
36
|
-
}
|
|
37
|
-
const PASSING = new Set(["passed", "expected"]);
|
|
38
|
-
const SKIPPED = new Set(["skipped"]);
|
|
39
|
-
export class PlaywrightMissingError extends Error {
|
|
40
|
-
}
|
|
41
|
-
function runProcess(command, args, cwd, env) {
|
|
42
|
-
return new Promise((resolve) => {
|
|
43
|
-
const child = spawn(command, args, { cwd, env });
|
|
44
|
-
let stdout = "";
|
|
45
|
-
let stderr = "";
|
|
46
|
-
child.stdout.on("data", (d) => (stdout += d.toString()));
|
|
47
|
-
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
48
|
-
child.on("error", (err) => resolve({ code: null, stdout, stderr, spawnError: err }));
|
|
49
|
-
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
// Resolved from the run dir — the exact place the spec imports from — so the process we
|
|
53
|
-
// spawn and the module the spec loads are the same copy. `npx playwright` is the last
|
|
54
|
-
// resort for a CLI install that somehow lost its dependencies.
|
|
55
|
-
export function playwrightBase(runDir) {
|
|
56
|
-
const runner = resolvePlaywrightRunner(runDir);
|
|
57
|
-
if (runner?.source === "project") {
|
|
58
|
-
return { command: process.execPath, args: [runner.cli, "test"], label: "playwright test" };
|
|
59
|
-
}
|
|
60
|
-
if (runner) {
|
|
61
|
-
return {
|
|
62
|
-
command: process.execPath,
|
|
63
|
-
args: [runner.cli, "test"],
|
|
64
|
-
label: "playwright test (bundled with the CLI)",
|
|
65
|
-
nodePath: path.resolve(path.dirname(runner.cli), "..", ".."),
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
const npx = process.platform === "win32" ? "npx.cmd" : "npx";
|
|
69
|
-
return { command: npx, args: ["playwright", "test"], label: "npx playwright test" };
|
|
70
|
-
}
|
|
71
|
-
// "unknown command 'test'" is the Python `playwright` shim (no `test` subcommand); the module
|
|
72
|
-
// errors mean @playwright/test isn't installed; "Executable doesn't exist" is the runner
|
|
73
|
-
// launching a browser revision that was never downloaded. Either way the actionable answer
|
|
74
|
-
// is the same install hint, not a stack trace.
|
|
75
|
-
function looksLikePlaywrightMissing(r) {
|
|
76
|
-
if (r.spawnError?.code === "ENOENT")
|
|
77
|
-
return true;
|
|
78
|
-
const blob = `${r.stdout}\n${r.stderr}`;
|
|
79
|
-
return (/unknown command ['"]?test/i.test(blob) ||
|
|
80
|
-
/Cannot find module ['"]@playwright\/test/i.test(blob) ||
|
|
81
|
-
/Executable doesn't exist/i.test(blob) ||
|
|
82
|
-
/npm ERR!.*could not determine executable|npx.*not found/i.test(blob));
|
|
83
|
-
}
|
|
84
|
-
export function parsePlaywrightReport(data) {
|
|
85
|
-
const results = [];
|
|
86
|
-
const walk = (suites) => {
|
|
87
|
-
for (const suite of suites) {
|
|
88
|
-
for (const spec of suite.specs ?? []) {
|
|
89
|
-
for (const test of spec.tests ?? []) {
|
|
90
|
-
// Last attempt is the verdict — retries can precede it.
|
|
91
|
-
const last = test.results?.[test.results.length - 1];
|
|
92
|
-
const errors = (last?.errors ?? []).map((e) => e.message).filter(Boolean);
|
|
93
|
-
const artifacts = (last?.attachments ?? [])
|
|
94
|
-
.map((a) => a.path)
|
|
95
|
-
.filter((p) => Boolean(p));
|
|
96
|
-
results.push({
|
|
97
|
-
name: spec.title ?? "(unnamed)",
|
|
98
|
-
status: last?.status ?? "unknown",
|
|
99
|
-
duration_ms: last?.duration,
|
|
100
|
-
...(errors.length ? { error: errors.join("\n") } : {}),
|
|
101
|
-
artifacts,
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
if (suite.suites)
|
|
106
|
-
walk(suite.suites);
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
walk(data.suites ?? []);
|
|
110
|
-
return results;
|
|
111
|
-
}
|
|
112
|
-
// passed/failed exclude skips; an empty set is neither — the caller treats it as a failure
|
|
113
|
-
// because a run that executed nothing is not evidence of a pass.
|
|
114
|
-
export function tally(results) {
|
|
115
|
-
const passed = results.filter((r) => PASSING.has(r.status)).length;
|
|
116
|
-
const skipped = results.filter((r) => SKIPPED.has(r.status)).length;
|
|
117
|
-
return { passed, failed: results.length - passed - skipped };
|
|
118
|
-
}
|
|
119
|
-
function parseReport(reportPath) {
|
|
120
|
-
return parsePlaywrightReport(JSON.parse(fs.readFileSync(reportPath, "utf8")));
|
|
121
|
-
}
|
|
122
|
-
/** How many filmstrip frames the rendered spec will write — the step total a live
|
|
123
|
-
* progress bar counts against. 0 when the spec was rendered without frame capture. */
|
|
124
|
-
export function countPlannedFrames(spec) {
|
|
125
|
-
return spec.match(/frames\/step-\d+\.png/g)?.length ?? 0;
|
|
126
|
-
}
|
|
127
|
-
export function countWrittenFrames(runDir) {
|
|
128
|
-
try {
|
|
129
|
-
return fs
|
|
130
|
-
.readdirSync(path.join(runDir, "frames"))
|
|
131
|
-
.filter((f) => /^step-\d+\.png$/.test(f)).length;
|
|
132
|
-
}
|
|
133
|
-
catch {
|
|
134
|
-
return 0;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
const readIfExists = (p) => {
|
|
138
|
-
try {
|
|
139
|
-
return fs.readFileSync(p);
|
|
140
|
-
}
|
|
141
|
-
catch {
|
|
142
|
-
return undefined;
|
|
143
|
-
}
|
|
144
|
-
};
|
|
145
|
-
function findFirstFile(base, test) {
|
|
146
|
-
let entries;
|
|
147
|
-
try {
|
|
148
|
-
entries = fs.readdirSync(base, { withFileTypes: true });
|
|
149
|
-
}
|
|
150
|
-
catch {
|
|
151
|
-
return undefined;
|
|
152
|
-
}
|
|
153
|
-
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
154
|
-
const full = path.join(base, entry.name);
|
|
155
|
-
if (entry.isFile() && test(entry.name))
|
|
156
|
-
return readIfExists(full);
|
|
157
|
-
if (entry.isDirectory()) {
|
|
158
|
-
const nested = findFirstFile(full, test);
|
|
159
|
-
if (nested)
|
|
160
|
-
return nested;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
return undefined;
|
|
164
|
-
}
|
|
165
|
-
// Mirrors the cloud runner's workdir harvest (_harvest_frames/_harvest_frame_urls/
|
|
166
|
-
// _harvest_frame_step_durations/_harvest_dom_snapshot/_harvest_phase): the same
|
|
167
|
-
// sorted step-*.png order drives all three frame lists so they stay index-aligned.
|
|
168
|
-
function harvestArtifacts(runDir, artifactsDir) {
|
|
169
|
-
const framesDir = path.join(runDir, "frames");
|
|
170
|
-
let framePaths = [];
|
|
171
|
-
try {
|
|
172
|
-
framePaths = fs
|
|
173
|
-
.readdirSync(framesDir)
|
|
174
|
-
.filter((f) => /^step-\d+\.png$/.test(f))
|
|
175
|
-
.sort()
|
|
176
|
-
.map((f) => path.join(framesDir, f));
|
|
177
|
-
}
|
|
178
|
-
catch {
|
|
179
|
-
// no frames dir — spec rendered without capture, or it never got that far
|
|
180
|
-
}
|
|
181
|
-
const frames = [];
|
|
182
|
-
const frameUrls = [];
|
|
183
|
-
const stamps = [];
|
|
184
|
-
for (const p of framePaths) {
|
|
185
|
-
const png = readIfExists(p);
|
|
186
|
-
if (!png)
|
|
187
|
-
continue;
|
|
188
|
-
frames.push(png);
|
|
189
|
-
frameUrls.push(readIfExists(p.replace(/\.png$/, ".url"))?.toString("utf8").trim() ?? "");
|
|
190
|
-
const raw = readIfExists(p.replace(/\.png$/, ".ts"))?.toString("utf8").trim();
|
|
191
|
-
const stamp = raw ? Number(raw) : NaN;
|
|
192
|
-
stamps.push(Number.isFinite(stamp) ? stamp : null);
|
|
193
|
-
}
|
|
194
|
-
const frameDurationsMs = stamps.map((stamp, i) => {
|
|
195
|
-
if (i === 0)
|
|
196
|
-
return 0;
|
|
197
|
-
const prev = stamps[i - 1];
|
|
198
|
-
return stamp !== null && prev !== null && prev !== undefined
|
|
199
|
-
? Math.max(0, stamp - prev)
|
|
200
|
-
: null;
|
|
201
|
-
});
|
|
202
|
-
let phase = "main";
|
|
203
|
-
try {
|
|
204
|
-
const parsed = JSON.parse(fs.readFileSync(path.join(runDir, "phase.json"), "utf8"));
|
|
205
|
-
if (parsed.phase && ["setup", "main", "teardown"].includes(parsed.phase)) {
|
|
206
|
-
phase = parsed.phase;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
catch {
|
|
210
|
-
// no phase sidecar — an unsectioned spec
|
|
211
|
-
}
|
|
212
|
-
return {
|
|
213
|
-
frames,
|
|
214
|
-
frameUrls,
|
|
215
|
-
frameDurationsMs,
|
|
216
|
-
screenshot: findFirstFile(artifactsDir, (n) => n.endsWith(".png")),
|
|
217
|
-
domSnapshot: readIfExists(path.join(runDir, "dom-snapshot.html")),
|
|
218
|
-
phase,
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
/**
|
|
222
|
-
* Write `spec` next to the caller's project, run it with their local @playwright/test, and
|
|
223
|
-
* parse the JSON report into a structured pass/fail summary. Artifacts + the spec + report land
|
|
224
|
-
* in `dir` when given (kept), otherwise in a temp dir that is cleaned up. Throws
|
|
225
|
-
* {@link PlaywrightMissingError} when no runnable @playwright/test is found so the caller can
|
|
226
|
-
* print an install hint.
|
|
227
|
-
*/
|
|
228
|
-
export async function runSpecLocally(opts) {
|
|
229
|
-
const cwd = process.cwd();
|
|
230
|
-
// The spec + isolating config MUST sit inside the project tree: Playwright resolves
|
|
231
|
-
// `@playwright/test` (imported by both) by walking UP from the config file, so a config in
|
|
232
|
-
// /tmp finds no node_modules and every run dies with "Cannot find module '@playwright/test'".
|
|
233
|
-
// A run dir under cwd walks up into the project's node_modules; it's always cleaned up.
|
|
234
|
-
const runDir = fs.mkdtempSync(path.join(cwd, ".beryl-local-"));
|
|
235
|
-
// The spec's sidecar helpers (__vmInbox/__vmRunConfig) read files via require(),
|
|
236
|
-
// which dies with "require is not defined" when the surrounding project is
|
|
237
|
-
// "type": "module" (the spec then compiles as ESM). Scoping the run dir back to
|
|
238
|
-
// CommonJS matches the cloud workdir; node_modules resolution still walks up.
|
|
239
|
-
fs.writeFileSync(path.join(runDir, "package.json"), '{"type":"commonjs"}\n');
|
|
240
|
-
// Where the user-facing outputs (artifacts, spec copy, report) go: --dir if asked, else the
|
|
241
|
-
// ephemeral run dir.
|
|
242
|
-
const outDir = opts.dir ? path.resolve(opts.dir) : runDir;
|
|
243
|
-
fs.mkdirSync(outDir, { recursive: true });
|
|
244
|
-
let specContent = opts.spec;
|
|
245
|
-
if (opts.authSession) {
|
|
246
|
-
// Mirror the cloud runner's staging: write the session files into the workdir and
|
|
247
|
-
// point the spec's storageState reference at the absolute path (auth-init.json is
|
|
248
|
-
// read relative to cwd, which is the run dir — same as the cloud workdir).
|
|
249
|
-
const storagePath = path.join(runDir, "auth-state.json");
|
|
250
|
-
fs.writeFileSync(storagePath, opts.authSession.storageState, "utf8");
|
|
251
|
-
fs.writeFileSync(path.join(runDir, "auth-init.json"), JSON.stringify(opts.authSession.initScripts), "utf8");
|
|
252
|
-
specContent = specContent.split('"auth-state.json"').join(JSON.stringify(storagePath));
|
|
253
|
-
}
|
|
254
|
-
const specPath = path.join(runDir, "beryl-local.spec.ts");
|
|
255
|
-
fs.writeFileSync(specPath, specContent);
|
|
256
|
-
const configPath = path.join(runDir, "beryl-local.config.ts");
|
|
257
|
-
const artifactsDir = path.join(outDir, "artifacts");
|
|
258
|
-
fs.writeFileSync(configPath, ISOLATING_CONFIG(runDir, artifactsDir));
|
|
259
|
-
const reportPath = path.join(runDir, "report.json");
|
|
260
|
-
const cleanup = () => fs.rmSync(runDir, { recursive: true, force: true });
|
|
261
|
-
const { command, args: base, label, nodePath } = playwrightBase(runDir);
|
|
262
|
-
const args = [...base, `--config=${configPath}`, "--reporter=json"];
|
|
263
|
-
opts.onProgress?.(`Running ${label} on ${opts.testName}…`);
|
|
264
|
-
await opts.setup?.(runDir);
|
|
265
|
-
const stop = opts.during?.(runDir);
|
|
266
|
-
const env = { ...process.env, PLAYWRIGHT_JSON_OUTPUT_NAME: reportPath };
|
|
267
|
-
if (nodePath) {
|
|
268
|
-
env.NODE_PATH = [nodePath, process.env.NODE_PATH].filter(Boolean).join(path.delimiter);
|
|
269
|
-
}
|
|
270
|
-
// cwd is the RUN dir, not the project: the spec's relative writes (frames/,
|
|
271
|
-
// email-inbox.json, dom-snapshot.html, phase.json) must land where we harvest and
|
|
272
|
-
// clean up, exactly as the cloud runner keys them to its per-run workdir.
|
|
273
|
-
let result;
|
|
274
|
-
try {
|
|
275
|
-
result = await runProcess(command, args, runDir, env);
|
|
276
|
-
}
|
|
277
|
-
finally {
|
|
278
|
-
stop?.();
|
|
279
|
-
}
|
|
280
|
-
if (looksLikePlaywrightMissing(result)) {
|
|
281
|
-
cleanup();
|
|
282
|
-
throw new PlaywrightMissingError(installHint(cwd));
|
|
283
|
-
}
|
|
284
|
-
let results;
|
|
285
|
-
try {
|
|
286
|
-
results = parseReport(reportPath);
|
|
287
|
-
}
|
|
288
|
-
catch (err) {
|
|
289
|
-
// No parsable report on a non-zero exit means the spec never ran (compile/launch error) —
|
|
290
|
-
// surface stderr/stdout so it isn't a silent failure.
|
|
291
|
-
const detail = (result.stderr || result.stdout || err.message).trim();
|
|
292
|
-
cleanup();
|
|
293
|
-
throw new Error(`Playwright produced no readable report — the spec did not run.\n${detail}`);
|
|
294
|
-
}
|
|
295
|
-
// A run that executed zero tests is a failure, not a pass — an isolating config matching
|
|
296
|
-
// exactly our spec should never yield an empty set, so an empty one means the spec was
|
|
297
|
-
// filtered/skipped away and there is no evidence it ran.
|
|
298
|
-
if (results.length === 0) {
|
|
299
|
-
const detail = (result.stderr || result.stdout || "no tests were run").trim();
|
|
300
|
-
cleanup();
|
|
301
|
-
throw new Error(`Playwright ran no tests from the rendered spec.\n${detail}`);
|
|
302
|
-
}
|
|
303
|
-
const harvested = opts.harvest ? harvestArtifacts(runDir, artifactsDir) : undefined;
|
|
304
|
-
let keptSpec = specPath;
|
|
305
|
-
let keptReport = reportPath;
|
|
306
|
-
if (opts.dir) {
|
|
307
|
-
// Persist the exact spec + report next to the artifacts before the run dir is removed,
|
|
308
|
-
// plus the spec's relative writes (filmstrip frames, failure DOM) that live in it.
|
|
309
|
-
// Text files are scrubbed like the uploaded bytes; frames are pixels.
|
|
310
|
-
keptSpec = path.join(outDir, "beryl-local.spec.ts");
|
|
311
|
-
keptReport = path.join(outDir, "report.json");
|
|
312
|
-
fs.copyFileSync(specPath, keptSpec);
|
|
313
|
-
copyTextScrubbed(reportPath, keptReport, opts.redact);
|
|
314
|
-
const framesSrc = path.join(runDir, "frames");
|
|
315
|
-
if (fs.existsSync(framesSrc) && framesSrc !== path.join(outDir, "frames")) {
|
|
316
|
-
fs.cpSync(framesSrc, path.join(outDir, "frames"), { recursive: true, force: true });
|
|
317
|
-
}
|
|
318
|
-
const domSrc = path.join(runDir, "dom-snapshot.html");
|
|
319
|
-
if (fs.existsSync(domSrc) && domSrc !== path.join(outDir, "dom-snapshot.html")) {
|
|
320
|
-
copyTextScrubbed(domSrc, path.join(outDir, "dom-snapshot.html"), opts.redact);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
else {
|
|
324
|
-
// Without --dir the artifacts lived in the run dir we're about to delete, so their paths
|
|
325
|
-
// would dangle — don't hand back paths to files that no longer exist.
|
|
326
|
-
for (const r of results)
|
|
327
|
-
r.artifacts = [];
|
|
328
|
-
}
|
|
329
|
-
cleanup();
|
|
330
|
-
const { passed, failed } = tally(results);
|
|
331
|
-
return {
|
|
332
|
-
passed,
|
|
333
|
-
failed,
|
|
334
|
-
results,
|
|
335
|
-
directory: outDir,
|
|
336
|
-
spec: keptSpec,
|
|
337
|
-
report: keptReport,
|
|
338
|
-
...(harvested ? { harvested } : {}),
|
|
339
|
-
};
|
|
340
|
-
}
|