@beryl-so/cli 0.14.1 → 0.21.4

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.
@@ -0,0 +1,198 @@
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 function toRunEntry(spec, outcome, runError, startedAt, ordinal, redact) {
12
+ // Mirrors the cloud runner's redact_result: the spec types the secret into the
13
+ // page, so error text and the DOM snapshot can echo it back — scrub before the
14
+ // bytes leave this machine. Frames and screenshots are pixels; nothing to scrub.
15
+ const scrub = (text) => (redact ? text.split(redact).join("***") : text);
16
+ const scrubBytes = (bytes) => redact && bytes.includes(redact)
17
+ ? Buffer.from(bytes.toString("utf8").split(redact).join("***"), "utf8")
18
+ : bytes;
19
+ const completedAt = new Date().toISOString();
20
+ const base = {
21
+ test_case_id: spec.id,
22
+ started_at: startedAt,
23
+ completed_at: completedAt,
24
+ frames: [],
25
+ frame_urls: [],
26
+ frame_durations_ms: [],
27
+ files: [],
28
+ };
29
+ if (!outcome) {
30
+ return {
31
+ ...base,
32
+ status: "errored",
33
+ phase: "main",
34
+ error_message: scrub(runError ?? "the spec did not run").slice(0, IMPORT_MAX_ERROR_LEN),
35
+ };
36
+ }
37
+ const result = outcome.results[0];
38
+ const status = result && (result.status === "passed" || result.status === "expected")
39
+ ? "passed"
40
+ : "failed";
41
+ const harvested = outcome.harvested;
42
+ const entry = {
43
+ ...base,
44
+ status,
45
+ phase: status === "failed" ? (harvested?.phase ?? "main") : "main",
46
+ ...(result?.error
47
+ ? { error_message: scrub(result.error).slice(0, IMPORT_MAX_ERROR_LEN) }
48
+ : {}),
49
+ ...(result?.duration_ms !== undefined ? { duration_ms: Math.max(0, result.duration_ms) } : {}),
50
+ };
51
+ if (!harvested)
52
+ return entry;
53
+ // A frame over the per-file cap would force dropping mid-list and shift the
54
+ // index-aligned url/duration sidecars — drop the whole filmstrip instead.
55
+ // Drop the inlined sign-in's frames before anything is index-aligned to the stored
56
+ // plan: `runs explain` zips the plan's steps against these lists, so an off-by-K here
57
+ // shows every step's screenshot next to the wrong step.
58
+ const skip = Math.min(spec.inlinedLoginSteps ?? 0, harvested.frames.length);
59
+ const frames = harvested.frames.slice(skip);
60
+ if (frames.every((f) => f.length <= IMPORT_MAX_FILE_BYTES)) {
61
+ frames.forEach((bytes, i) => {
62
+ const name = `r${ordinal}-frame-${String(i).padStart(3, "0")}.png`;
63
+ entry.frames.push(name);
64
+ entry.files.push({ name, bytes });
65
+ });
66
+ entry.frame_urls = harvested.frameUrls.slice(skip);
67
+ entry.frame_durations_ms = harvested.frameDurationsMs.slice(skip);
68
+ }
69
+ if (harvested.screenshot && harvested.screenshot.length <= IMPORT_MAX_FILE_BYTES) {
70
+ entry.screenshot = `r${ordinal}-screenshot.png`;
71
+ entry.files.push({ name: entry.screenshot, bytes: harvested.screenshot });
72
+ }
73
+ if (harvested.domSnapshot && harvested.domSnapshot.length <= IMPORT_MAX_FILE_BYTES) {
74
+ entry.dom_snapshot = `r${ordinal}-dom.html`;
75
+ entry.files.push({ name: entry.dom_snapshot, bytes: scrubBytes(harvested.domSnapshot) });
76
+ }
77
+ return entry;
78
+ }
79
+ export function buildImportForm(entries, opts) {
80
+ // Total-size budget: when a big suite would blow past the server's cap, shed whole
81
+ // filmstrips (largest droppable payload, replay-only) test by test — never
82
+ // mid-list, and never silently.
83
+ let total = entries.flatMap((e) => e.files).reduce((n, f) => n + f.bytes.length, 0);
84
+ for (const entry of entries) {
85
+ if (total <= IMPORT_MAX_TOTAL_BYTES)
86
+ break;
87
+ const frameBytes = entry.files
88
+ .filter((f) => entry.frames.includes(f.name))
89
+ .reduce((n, f) => n + f.bytes.length, 0);
90
+ if (frameBytes === 0)
91
+ continue;
92
+ entry.files = entry.files.filter((f) => !entry.frames.includes(f.name));
93
+ entry.frames = [];
94
+ entry.frame_urls = [];
95
+ entry.frame_durations_ms = [];
96
+ total -= frameBytes;
97
+ opts.onNote?.(`replay frames for test ${entry.test_case_id} dropped (upload size cap)`);
98
+ }
99
+ // File-count budget: the server parses at most RUN_IMPORT_MAX_FILES multipart
100
+ // parts, so a big suite's filmstrips can overflow on count with bytes to spare.
101
+ let fileCount = entries.reduce((n, e) => n + e.files.length, 0);
102
+ for (const entry of entries) {
103
+ if (fileCount <= IMPORT_MAX_FILES)
104
+ break;
105
+ const frameFiles = entry.files.filter((f) => entry.frames.includes(f.name)).length;
106
+ if (frameFiles === 0)
107
+ continue;
108
+ entry.files = entry.files.filter((f) => !entry.frames.includes(f.name));
109
+ entry.frames = [];
110
+ entry.frame_urls = [];
111
+ entry.frame_durations_ms = [];
112
+ fileCount -= frameFiles;
113
+ opts.onNote?.(`replay frames for test ${entry.test_case_id} dropped (upload file-count cap)`);
114
+ }
115
+ const manifest = {
116
+ environment_id: opts.environmentId ?? null,
117
+ target_url_override: opts.targetUrlOverride ?? null,
118
+ ...(opts.notifications !== undefined ? { notifications: opts.notifications } : {}),
119
+ started_at: opts.startedAt,
120
+ completed_at: opts.completedAt,
121
+ results: entries.map(({ files: _files, ...rest }) => rest),
122
+ };
123
+ const form = new FormData();
124
+ form.append("manifest", JSON.stringify(manifest));
125
+ for (const entry of entries) {
126
+ for (const file of entry.files) {
127
+ form.append("files", new Blob([new Uint8Array(file.bytes)]), file.name);
128
+ }
129
+ }
130
+ return form;
131
+ }
132
+ /**
133
+ * Run one rendered spec on this machine with its full service harness: write the
134
+ * inbox/login sidecars and pump `await_email` requests over the API while Playwright
135
+ * runs. The one local-execution seam shared by `runs local` and the local-verify
136
+ * `tests create`. {@link PlaywrightMissingError} propagates (environmental — the
137
+ * caller decides whether to offer an install); any other throw is captured as
138
+ * `runError` (the spec never ran: an errored result, not a crashed command).
139
+ */
140
+ export async function executeLocalSpec(deps, opts) {
141
+ const { spec } = opts;
142
+ const inbox = spec.email
143
+ ? {
144
+ id: spec.email.inbox_id,
145
+ address: spec.email.address,
146
+ recipientContains: spec.email.recipient_contains ?? undefined,
147
+ // NOW, not the mailbox's creation time: it already holds every earlier run's
148
+ // mail, and an older code served as this run's would pass the step with a value
149
+ // the app then rejects. Sent as stored_after, which fences on the server's own
150
+ // INGESTION time and is clamped server-side — `since` compares against the
151
+ // sender's Date: header, so a fast local clock would fence out mail that arrived.
152
+ storedAfter: new Date().toISOString(),
153
+ }
154
+ : undefined;
155
+ try {
156
+ const outcome = await runSpecLocally({
157
+ spec: spec.content,
158
+ testName: spec.title,
159
+ dir: opts.dir,
160
+ harvest: opts.harvest,
161
+ redact: spec.usesLoginPassword ? opts.loginPassword : undefined,
162
+ setup: inbox || (spec.usesLoginPassword && opts.loginPassword !== undefined)
163
+ ? (runDir) => {
164
+ if (inbox)
165
+ fs.writeFileSync(path.join(runDir, "email-inbox.json"), JSON.stringify({ address: inbox.address }), "utf8");
166
+ if (spec.usesLoginPassword && opts.loginPassword !== undefined)
167
+ fs.writeFileSync(path.join(runDir, "run-config.json"), JSON.stringify({ login_password: opts.loginPassword }), "utf8");
168
+ }
169
+ : undefined,
170
+ during: (runDir) => {
171
+ const pump = inbox
172
+ ? startEmailPump({
173
+ sidecarPath: path.join(runDir, "email-inbox.json"),
174
+ client: deps.client,
175
+ workspaceId: deps.workspaceId,
176
+ inboxId: inbox.id,
177
+ storedAfter: inbox.storedAfter,
178
+ recipientContains: inbox.recipientContains,
179
+ onEvent: opts.onEvent,
180
+ })
181
+ : undefined;
182
+ const extra = opts.onSpawn?.(runDir);
183
+ return () => {
184
+ extra?.();
185
+ pump?.stop();
186
+ };
187
+ },
188
+ });
189
+ return { outcome };
190
+ }
191
+ catch (err) {
192
+ if (err instanceof PlaywrightMissingError)
193
+ throw err;
194
+ // The spec never ran (compile error, filtered away) — an errored result the
195
+ // caller records, not an aborted command.
196
+ return { runError: err instanceof Error ? err.message : String(err) };
197
+ }
198
+ }
package/dist/local-run.js CHANGED
@@ -21,6 +21,13 @@ const ISOLATING_CONFIG = (testDir, artifactsDir) => `import { defineConfig } fro
21
21
  ` outputDir: ${JSON.stringify(artifactsDir)},\n` +
22
22
  ` fullyParallel: false,\n` +
23
23
  `});\n`;
24
+ export function copyTextScrubbed(src, dest, redact) {
25
+ if (!redact) {
26
+ fs.copyFileSync(src, dest);
27
+ return;
28
+ }
29
+ fs.writeFileSync(dest, fs.readFileSync(src, "utf8").split(redact).join("***"));
30
+ }
24
31
  const PASSING = new Set(["passed", "expected"]);
25
32
  const SKIPPED = new Set(["skipped"]);
26
33
  export class PlaywrightMissingError extends Error {
@@ -95,6 +102,105 @@ export function tally(results) {
95
102
  function parseReport(reportPath) {
96
103
  return parsePlaywrightReport(JSON.parse(fs.readFileSync(reportPath, "utf8")));
97
104
  }
105
+ /** How many filmstrip frames the rendered spec will write — the step total a live
106
+ * progress bar counts against. 0 when the spec was rendered without frame capture. */
107
+ export function countPlannedFrames(spec) {
108
+ return spec.match(/frames\/step-\d+\.png/g)?.length ?? 0;
109
+ }
110
+ export function countWrittenFrames(runDir) {
111
+ try {
112
+ return fs
113
+ .readdirSync(path.join(runDir, "frames"))
114
+ .filter((f) => /^step-\d+\.png$/.test(f)).length;
115
+ }
116
+ catch {
117
+ return 0;
118
+ }
119
+ }
120
+ const readIfExists = (p) => {
121
+ try {
122
+ return fs.readFileSync(p);
123
+ }
124
+ catch {
125
+ return undefined;
126
+ }
127
+ };
128
+ function findFirstFile(base, test) {
129
+ let entries;
130
+ try {
131
+ entries = fs.readdirSync(base, { withFileTypes: true });
132
+ }
133
+ catch {
134
+ return undefined;
135
+ }
136
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
137
+ const full = path.join(base, entry.name);
138
+ if (entry.isFile() && test(entry.name))
139
+ return readIfExists(full);
140
+ if (entry.isDirectory()) {
141
+ const nested = findFirstFile(full, test);
142
+ if (nested)
143
+ return nested;
144
+ }
145
+ }
146
+ return undefined;
147
+ }
148
+ // Mirrors the cloud runner's workdir harvest (_harvest_frames/_harvest_frame_urls/
149
+ // _harvest_frame_step_durations/_harvest_dom_snapshot/_harvest_phase): the same
150
+ // sorted step-*.png order drives all three frame lists so they stay index-aligned.
151
+ function harvestArtifacts(runDir, artifactsDir) {
152
+ const framesDir = path.join(runDir, "frames");
153
+ let framePaths = [];
154
+ try {
155
+ framePaths = fs
156
+ .readdirSync(framesDir)
157
+ .filter((f) => /^step-\d+\.png$/.test(f))
158
+ .sort()
159
+ .map((f) => path.join(framesDir, f));
160
+ }
161
+ catch {
162
+ // no frames dir — spec rendered without capture, or it never got that far
163
+ }
164
+ const frames = [];
165
+ const frameUrls = [];
166
+ const stamps = [];
167
+ for (const p of framePaths) {
168
+ const png = readIfExists(p);
169
+ if (!png)
170
+ continue;
171
+ frames.push(png);
172
+ frameUrls.push(readIfExists(p.replace(/\.png$/, ".url"))?.toString("utf8").trim() ?? "");
173
+ const raw = readIfExists(p.replace(/\.png$/, ".ts"))?.toString("utf8").trim();
174
+ const stamp = raw ? Number(raw) : NaN;
175
+ stamps.push(Number.isFinite(stamp) ? stamp : null);
176
+ }
177
+ const frameDurationsMs = stamps.map((stamp, i) => {
178
+ if (i === 0)
179
+ return 0;
180
+ const prev = stamps[i - 1];
181
+ return stamp !== null && prev !== null && prev !== undefined
182
+ ? Math.max(0, stamp - prev)
183
+ : null;
184
+ });
185
+ let phase = "main";
186
+ try {
187
+ const parsed = JSON.parse(fs.readFileSync(path.join(runDir, "phase.json"), "utf8"));
188
+ if (parsed.phase && ["setup", "main", "teardown"].includes(parsed.phase)) {
189
+ phase = parsed.phase;
190
+ }
191
+ }
192
+ catch {
193
+ // no phase sidecar — an unsectioned spec
194
+ }
195
+ return {
196
+ frames,
197
+ frameUrls,
198
+ frameDurationsMs,
199
+ screenshot: findFirstFile(artifactsDir, (n) => n.endsWith(".png")),
200
+ domSnapshot: readIfExists(path.join(runDir, "dom-snapshot.html")),
201
+ phase,
202
+ };
203
+ }
98
204
  /**
99
205
  * Write `spec` next to the caller's project, run it with their local @playwright/test, and
100
206
  * parse the JSON report into a structured pass/fail summary. Artifacts + the spec + report land
@@ -109,6 +215,11 @@ export async function runSpecLocally(opts) {
109
215
  // /tmp finds no node_modules and every run dies with "Cannot find module '@playwright/test'".
110
216
  // A run dir under cwd walks up into the project's node_modules; it's always cleaned up.
111
217
  const runDir = fs.mkdtempSync(path.join(cwd, ".beryl-local-"));
218
+ // The spec's sidecar helpers (__vmInbox/__vmRunConfig) read files via require(),
219
+ // which dies with "require is not defined" when the surrounding project is
220
+ // "type": "module" (the spec then compiles as ESM). Scoping the run dir back to
221
+ // CommonJS matches the cloud workdir; node_modules resolution still walks up.
222
+ fs.writeFileSync(path.join(runDir, "package.json"), '{"type":"commonjs"}\n');
112
223
  // Where the user-facing outputs (artifacts, spec copy, report) go: --dir if asked, else the
113
224
  // ephemeral run dir.
114
225
  const outDir = opts.dir ? path.resolve(opts.dir) : runDir;
@@ -123,8 +234,19 @@ export async function runSpecLocally(opts) {
123
234
  const { command, args: base } = playwrightBase(cwd);
124
235
  const args = [...base, `--config=${configPath}`, "--reporter=json"];
125
236
  opts.onProgress?.(`Running ${command} ${base.join(" ")} on ${opts.testName}…`);
237
+ await opts.setup?.(runDir);
238
+ const stop = opts.during?.(runDir);
126
239
  const env = { ...process.env, PLAYWRIGHT_JSON_OUTPUT_NAME: reportPath };
127
- const result = await runProcess(command, args, cwd, env);
240
+ // cwd is the RUN dir, not the project: the spec's relative writes (frames/,
241
+ // email-inbox.json, dom-snapshot.html, phase.json) must land where we harvest and
242
+ // clean up, exactly as the cloud runner keys them to its per-run workdir.
243
+ let result;
244
+ try {
245
+ result = await runProcess(command, args, runDir, env);
246
+ }
247
+ finally {
248
+ stop?.();
249
+ }
128
250
  if (looksLikePlaywrightMissing(result)) {
129
251
  cleanup();
130
252
  throw new PlaywrightMissingError(PLAYWRIGHT_INSTALL_HINT);
@@ -148,14 +270,25 @@ export async function runSpecLocally(opts) {
148
270
  cleanup();
149
271
  throw new Error(`Playwright ran no tests from the rendered spec.\n${detail}`);
150
272
  }
273
+ const harvested = opts.harvest ? harvestArtifacts(runDir, artifactsDir) : undefined;
151
274
  let keptSpec = specPath;
152
275
  let keptReport = reportPath;
153
276
  if (opts.dir) {
154
- // Persist the exact spec + report next to the artifacts before the run dir is removed.
277
+ // Persist the exact spec + report next to the artifacts before the run dir is removed,
278
+ // plus the spec's relative writes (filmstrip frames, failure DOM) that live in it.
279
+ // Text files are scrubbed like the uploaded bytes; frames are pixels.
155
280
  keptSpec = path.join(outDir, "beryl-local.spec.ts");
156
281
  keptReport = path.join(outDir, "report.json");
157
282
  fs.copyFileSync(specPath, keptSpec);
158
- fs.copyFileSync(reportPath, keptReport);
283
+ copyTextScrubbed(reportPath, keptReport, opts.redact);
284
+ const framesSrc = path.join(runDir, "frames");
285
+ if (fs.existsSync(framesSrc) && framesSrc !== path.join(outDir, "frames")) {
286
+ fs.cpSync(framesSrc, path.join(outDir, "frames"), { recursive: true, force: true });
287
+ }
288
+ const domSrc = path.join(runDir, "dom-snapshot.html");
289
+ if (fs.existsSync(domSrc) && domSrc !== path.join(outDir, "dom-snapshot.html")) {
290
+ copyTextScrubbed(domSrc, path.join(outDir, "dom-snapshot.html"), opts.redact);
291
+ }
159
292
  }
160
293
  else {
161
294
  // Without --dir the artifacts lived in the run dir we're about to delete, so their paths
@@ -165,5 +298,13 @@ export async function runSpecLocally(opts) {
165
298
  }
166
299
  cleanup();
167
300
  const { passed, failed } = tally(results);
168
- return { passed, failed, results, directory: outDir, spec: keptSpec, report: keptReport };
301
+ return {
302
+ passed,
303
+ failed,
304
+ results,
305
+ directory: outDir,
306
+ spec: keptSpec,
307
+ report: keptReport,
308
+ ...(harvested ? { harvested } : {}),
309
+ };
169
310
  }
package/dist/output.js CHANGED
@@ -16,6 +16,25 @@ export function statusColor(status) {
16
16
  return yellow(status);
17
17
  return status;
18
18
  }
19
+ // Humans read "2h ago", not "2026-07-28T09:51:45.921815Z". Past a month the
20
+ // relative form stops being informative, so it falls back to the plain date.
21
+ export function timeAgo(value) {
22
+ if (!value)
23
+ return "";
24
+ const t = new Date(String(value)).getTime();
25
+ if (Number.isNaN(t))
26
+ return "";
27
+ const s = Math.max(0, (Date.now() - t) / 1000);
28
+ if (s < 60)
29
+ return "just now";
30
+ if (s < 3600)
31
+ return `${Math.floor(s / 60)}m ago`;
32
+ if (s < 86400)
33
+ return `${Math.floor(s / 3600)}h ago`;
34
+ if (s < 30 * 86400)
35
+ return `${Math.floor(s / 86400)}d ago`;
36
+ return new Date(t).toISOString().slice(0, 10);
37
+ }
19
38
  const MAX_CELL = 60;
20
39
  function cell(value) {
21
40
  if (value === null || value === undefined)
@@ -1,5 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
2
3
  import { createRequire } from "node:module";
4
+ import os from "node:os";
3
5
  import path from "node:path";
4
6
  // The two commands that turn "nothing Playwright-related installed" into "local runs work":
5
7
  // the test runner as a dev dep, then its browser binary. Kept as data so the CLI can both
@@ -7,24 +9,133 @@ import path from "node:path";
7
9
  export const INSTALL_TEST_RUNNER = ["npm", "i", "-D", "@playwright/test"];
8
10
  export const INSTALL_CHROMIUM = ["npx", "playwright", "install", "chromium"];
9
11
  export const PLAYWRIGHT_INSTALL_COMMANDS = `${INSTALL_TEST_RUNNER.join(" ")} && ${INSTALL_CHROMIUM.join(" ")}`;
10
- // Resolve `@playwright/test` the way Playwright itself will at run time from the project
11
- // tree, not from wherever the globally-installed CLI happens to live. `createRequire` rooted
12
- // at cwd walks up the same node_modules chain, so this is true iff a local run would find it.
12
+ // Resolve from the project tree, not from wherever the globally-installed CLI happens to
13
+ // live `createRequire` rooted at cwd walks up the same node_modules chain Playwright will.
14
+ const projectRequire = (cwd) => createRequire(path.join(cwd, "package.json"));
13
15
  export function hasPlaywrightTest(cwd) {
14
16
  try {
15
- createRequire(path.join(cwd, "package.json")).resolve("@playwright/test");
17
+ projectRequire(cwd).resolve("@playwright/test");
16
18
  return true;
17
19
  }
18
20
  catch {
19
21
  return false;
20
22
  }
21
23
  }
22
- export const INSTALL_PROMPT = `Install local Playwright now (${PLAYWRIGHT_INSTALL_COMMANDS})? [Y/n] `;
24
+ // A local run launches chromium headless, which Playwright serves from a separate
25
+ // `chromium_headless_shell` build (`playwright install chromium` fetches both). That is the
26
+ // only engine a run ever launches, so it is the only one we install or check for.
27
+ const HEADLESS_SHELL = "chromium-headless-shell";
28
+ // Written last by Playwright's downloader, so its presence means a complete browser.
29
+ const INSTALL_MARKER = "INSTALLATION_COMPLETE";
30
+ function playwrightCoreDir(cwd) {
31
+ const req = projectRequire(cwd);
32
+ try {
33
+ return path.dirname(req.resolve("playwright-core/package.json"));
34
+ }
35
+ catch {
36
+ // Not hoisted (pnpm) — look from @playwright/test's own tree instead.
37
+ }
38
+ try {
39
+ const fromRunner = createRequire(req.resolve("@playwright/test"));
40
+ return path.dirname(fromRunner.resolve("playwright-core/package.json"));
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ // Mirrors playwright-core's registry root: PLAYWRIGHT_BROWSERS_PATH ("0" means inside the
47
+ // package), else the per-platform cache dir. undefined on a platform Playwright doesn't
48
+ // support, where we have no verdict to offer.
49
+ function browsersRoot(coreDir) {
50
+ const override = process.env.PLAYWRIGHT_BROWSERS_PATH;
51
+ if (override === "0")
52
+ return coreDir ? path.join(coreDir, ".local-browsers") : undefined;
53
+ if (override)
54
+ return path.resolve(override);
55
+ const home = os.homedir();
56
+ if (process.platform === "darwin")
57
+ return path.join(home, "Library", "Caches", "ms-playwright");
58
+ if (process.platform === "win32") {
59
+ const local = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
60
+ return path.join(local, "ms-playwright");
61
+ }
62
+ if (process.platform === "linux") {
63
+ return path.join(process.env.XDG_CACHE_HOME || path.join(home, ".cache"), "ms-playwright");
64
+ }
65
+ return undefined;
66
+ }
67
+ // The exact directory this project's Playwright will look in: <root>/<browser name with
68
+ // dashes as underscores>-<revision>, straight out of playwright-core's browsers.json — so a
69
+ // browser downloaded for an older Playwright doesn't read as the one this one needs.
70
+ function wantedChromiumDir(coreDir) {
71
+ if (!coreDir)
72
+ return undefined;
73
+ try {
74
+ const parsed = JSON.parse(fs.readFileSync(path.join(coreDir, "browsers.json"), "utf8"));
75
+ const entry = parsed.browsers?.find((b) => b.name === HEADLESS_SHELL) ??
76
+ parsed.browsers?.find((b) => b.name === "chromium");
77
+ if (!entry?.name || !entry.revision)
78
+ return undefined;
79
+ return `${entry.name.replace(/-/g, "_")}-${entry.revision}`;
80
+ }
81
+ catch {
82
+ return undefined;
83
+ }
84
+ }
85
+ const isComplete = (dir) => fs.existsSync(path.join(dir, INSTALL_MARKER));
86
+ /**
87
+ * Is the browser binary a local run actually launches present? Without it every test in a
88
+ * run dies with the same `browserType.launch: Executable doesn't exist` — an environment
89
+ * problem that reads as a broken suite.
90
+ */
91
+ export function hasChromiumBrowser(cwd) {
92
+ const coreDir = playwrightCoreDir(cwd);
93
+ const root = browsersRoot(coreDir);
94
+ // Nowhere known to look — say nothing rather than block; the run surfaces Playwright's
95
+ // own error if it really is missing.
96
+ if (!root)
97
+ return true;
98
+ const wanted = wantedChromiumDir(coreDir);
99
+ if (wanted)
100
+ return isComplete(path.join(root, wanted));
101
+ // Revision unknown (no resolvable playwright-core): any completed chromium build is the
102
+ // best evidence there is, and guessing wrong only costs an idempotent re-install.
103
+ try {
104
+ return fs
105
+ .readdirSync(root)
106
+ .some((d) => /^chromium(_headless_shell)?-\d+$/.test(d) && isComplete(path.join(root, d)));
107
+ }
108
+ catch {
109
+ return false;
110
+ }
111
+ }
112
+ export function playwrightGaps(cwd) {
113
+ const runner = !hasPlaywrightTest(cwd);
114
+ // With no runner there is no revision to judge a cached browser against, so the honest
115
+ // answer is the full install — not the npm half and a second failure right after it.
116
+ return { runner, browser: runner || !hasChromiumBrowser(cwd) };
117
+ }
118
+ export const anyGap = (gaps) => gaps.runner || gaps.browser;
119
+ export function describeGaps(gaps) {
120
+ if (gaps.runner && gaps.browser)
121
+ return "@playwright/test + the Chromium browser";
122
+ return gaps.browser ? "the Chromium browser" : "@playwright/test";
123
+ }
124
+ /** Only the commands the missing halves need — a present @playwright/test isn't reinstalled. */
125
+ export function installCommandsFor(gaps) {
126
+ const commands = [
127
+ ...(gaps.runner ? [INSTALL_TEST_RUNNER.join(" ")] : []),
128
+ ...(gaps.browser ? [INSTALL_CHROMIUM.join(" ")] : []),
129
+ ];
130
+ return commands.length > 0 ? commands.join(" && ") : PLAYWRIGHT_INSTALL_COMMANDS;
131
+ }
132
+ export const installPrompt = (commands) => `Install local Playwright now (${commands})? [Y/n] `;
133
+ export const INSTALL_PROMPT = installPrompt(PLAYWRIGHT_INSTALL_COMMANDS);
23
134
  // Ask (default-yes) whether to install. Returns false — not throwing — when there is no answer
24
135
  // or the prompt fails, so callers uniformly fall back to printing the install hint.
25
- export async function confirmInstall(prompt) {
136
+ export async function confirmInstall(prompt, commands = PLAYWRIGHT_INSTALL_COMMANDS) {
26
137
  try {
27
- return !/^n(o)?$/i.test(await prompt(INSTALL_PROMPT));
138
+ return !/^n(o)?$/i.test(await prompt(installPrompt(commands)));
28
139
  }
29
140
  catch {
30
141
  return false;
@@ -0,0 +1,44 @@
1
+ import { dim } from "./output.js";
2
+ const BAR_WIDTH = 18;
3
+ export function renderProgressLine(state) {
4
+ const stepFraction = state.stepTotal && state.stepTotal > 0
5
+ ? Math.min(1, (state.step ?? 0) / state.stepTotal)
6
+ : 0;
7
+ const fraction = state.total > 0 ? Math.min(1, (state.done + stepFraction) / state.total) : 0;
8
+ const filled = Math.round(fraction * BAR_WIDTH);
9
+ const bar = "█".repeat(filled) + "░".repeat(BAR_WIDTH - filled);
10
+ const pct = `${Math.round(fraction * 100)}%`.padStart(4);
11
+ const steps = state.stepTotal && state.stepTotal > 0
12
+ ? ` · step ${Math.min(state.step ?? 0, state.stepTotal)}/${state.stepTotal}`
13
+ : "";
14
+ const tally = state.failed > 0 ? ` · ✓${state.passed} ✗${state.failed}` : ` · ✓${state.passed}`;
15
+ return `[${bar}]${pct} · test ${Math.min(state.done + 1, state.total)}/${state.total}${tally} · ${state.title}${steps}`;
16
+ }
17
+ /** A single rewriting status line on a TTY; quiet elsewhere (the caller prints
18
+ * per-test boundary lines instead, so MCP/CI logs stay readable). */
19
+ export class ProgressBar {
20
+ stream;
21
+ lastLen = 0;
22
+ tty;
23
+ constructor(stream = process.stderr) {
24
+ this.stream = stream;
25
+ this.tty = Boolean(stream.isTTY);
26
+ }
27
+ get active() {
28
+ return this.tty;
29
+ }
30
+ update(state) {
31
+ if (!this.tty)
32
+ return;
33
+ const line = renderProgressLine(state);
34
+ const padded = line.padEnd(this.lastLen);
35
+ this.lastLen = line.length;
36
+ this.stream.write(`\r${dim(padded)}`);
37
+ }
38
+ clear() {
39
+ if (!this.tty || this.lastLen === 0)
40
+ return;
41
+ this.stream.write(`\r${" ".repeat(this.lastLen)}\r`);
42
+ this.lastLen = 0;
43
+ }
44
+ }
@@ -1,10 +1,10 @@
1
1
  import { accountCommands } from "../commands/account.js";
2
+ import { testAccountCommands } from "../commands/accounts.js";
2
3
  import { authCommands } from "../commands/auth.js";
3
4
  import { configCommands } from "../commands/config-vars.js";
4
- import { credentialCommands } from "../commands/credentials.js";
5
5
  import { environmentCommands } from "../commands/environments.js";
6
6
  import { explorationCommands } from "../commands/explorations.js";
7
- import { inboxCommands } from "../commands/inboxes.js";
7
+ import { mailboxCommands } from "../commands/mailboxes.js";
8
8
  import { initCommands } from "../commands/init.js";
9
9
  import { mcpCommands } from "../commands/mcp.js";
10
10
  import { projectCommands } from "../commands/projects.js";
@@ -62,8 +62,8 @@ export const commands = [
62
62
  ...explorationCommands,
63
63
  ...configCommands,
64
64
  ...slackCommands,
65
- ...credentialCommands,
66
- ...inboxCommands,
65
+ ...mailboxCommands,
66
+ ...testAccountCommands,
67
67
  ...accountCommands,
68
68
  ...mcpCommands,
69
69
  ].map(withScopeFlags).map(hideFromMcp);