@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.
@@ -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
- }