@beryl-so/cli 0.17.0 → 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.
@@ -1,4 +1,29 @@
1
+ import { cliVersion, fetchLatestVersion, isBehind } from "../version-check.js";
1
2
  export const mcpCommands = [
3
+ {
4
+ name: "version",
5
+ summary: "Show the running CLI version, API URL, and Node version",
6
+ description: "Answers \"which build am I actually talking to?\" — the one question a long-lived " +
7
+ "`beryl mcp` process can't otherwise answer, since it loads source at spawn and never " +
8
+ "hot-reloads. Needs no login, so it still works when a token is missing or broken. " +
9
+ "Also reports whether the running build is behind npm's `latest` (best-effort: " +
10
+ "`update_available` is null when the registry can't be reached, and the check is " +
11
+ "skipped entirely under BERYL_NO_UPDATE_CHECK).",
12
+ examples: ["beryl version", "beryl version --json"],
13
+ async run(ctx) {
14
+ const current = cliVersion();
15
+ const latest = process.env.BERYL_NO_UPDATE_CHECK ? null : await fetchLatestVersion();
16
+ return {
17
+ data: {
18
+ cli_version: current,
19
+ api_url: ctx.config.apiUrl,
20
+ node_version: process.version,
21
+ latest_version: latest,
22
+ update_available: latest === null ? null : isBehind(current, latest),
23
+ },
24
+ };
25
+ },
26
+ },
2
27
  {
3
28
  name: "mcp",
4
29
  summary: "Run the Beryl MCP server (stdio) — every CLI command as an agent tool",
@@ -93,11 +93,6 @@ export const projectCommands = [
93
93
  description: "Whether the site needs a login (gated) or not (public). Default: detected from the " +
94
94
  "site — only asked when detection is genuinely unsure",
95
95
  },
96
- {
97
- name: "allow-mutations",
98
- type: "boolean",
99
- description: "Let the agent perform state-changing actions while exploring",
100
- },
101
96
  { name: "force-new-login", type: "boolean", description: "Ignore any reusable saved login" },
102
97
  {
103
98
  name: "no-explore",
@@ -141,7 +136,6 @@ export const projectCommands = [
141
136
  const created = (await ctx.client.post(`/workspaces/${ws}/projects`, {
142
137
  root_url: url,
143
138
  requires_auth_choice: auth,
144
- mutation_choice: flagBool(input, "allow-mutations") ? "allow_mutations" : null,
145
139
  force_new_login: flagBool(input, "force-new-login"),
146
140
  skip_exploration: noExplore,
147
141
  }));
@@ -5,11 +5,33 @@ import { CliError, UsageError } from "../errors.js";
5
5
  import { buildImportForm, executeLocalSpec, toRunEntry, } from "../local-exec.js";
6
6
  import { countPlannedFrames, countWrittenFrames, PlaywrightMissingError, } from "../local-run.js";
7
7
  import { dim, green, red, yellow } from "../output.js";
8
- import { confirmInstall, installPlaywright } from "../playwright-install.js";
8
+ import { anyGap, confirmInstall, describeGaps, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
9
9
  import { ProgressBar } from "../progress.js";
10
10
  import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
11
11
  import { watchRun } from "./watch.js";
12
12
  const MAX_FAILURE_SCREENSHOTS = 5;
13
+ // One filesystem check before a single spec is fetched. A missing browser binary otherwise
14
+ // fails EVERY test with the same launch error, which reads as a broken suite instead of a
15
+ // box that can't run tests. On a TTY it's an offer; over MCP / CI it's the exact commands.
16
+ async function ensureRunnableLocally(ctx) {
17
+ const gaps = playwrightGaps(process.cwd());
18
+ if (!anyGap(gaps))
19
+ return;
20
+ const commands = installCommandsFor(gaps);
21
+ const hint = `Local runs need ${describeGaps(gaps)}. Install it, then re-run:\n ${commands}`;
22
+ if (!ctx.interactive || !(await confirmInstall(ctx.prompt, commands)))
23
+ throw new CliError(hint);
24
+ try {
25
+ await installPlaywright(process.cwd(), (line) => ctx.err(dim(line)));
26
+ }
27
+ catch (err) {
28
+ throw new CliError(`${err instanceof Error ? err.message : String(err)}\n\nFinish it by hand:\n ${commands}`);
29
+ }
30
+ const left = playwrightGaps(process.cwd());
31
+ if (anyGap(left))
32
+ throw new CliError(hint);
33
+ ctx.err(green(`✓ ${describeGaps(gaps)} installed`));
34
+ }
13
35
  export function parseHeaders(raw) {
14
36
  if (!raw || raw.length === 0)
15
37
  return null;
@@ -75,20 +97,24 @@ export const runCommands = [
75
97
  name: "runs local",
76
98
  summary: "Run tests on your machine with your own Playwright; results sync to Beryl",
77
99
  description: "Unlike `runs trigger`, the browser runs on YOUR machine — each test's rendered spec is " +
78
- "fetched and run with your local @playwright/test (on a terminal the CLI offers to " +
79
- "install it the first time it's missing; over MCP it prints the install commands). " +
80
- "Signup/OTP flows work: the CLI mints a fresh run inbox and answers the spec's " +
81
- "await_email steps over the API, exactly as the cloud runner would. Saved-login tests " +
82
- "({{login_email}}/{{login_password}}) work too: the password is fetched once over the " +
83
- "logged secret-reveal route, handed to the spec the way the cloud runner does, and " +
84
- "scrubbed from any error text or DOM snapshot before results upload. When the run " +
85
- "finishes, the results and replay artifacts are imported into Beryl as a normal run " +
100
+ "fetched and run with your local @playwright/test and the Chromium binary it drives. " +
101
+ "Both are checked once before any spec is fetched, so a machine that can't run tests " +
102
+ "says so once instead of failing every test (on a terminal the CLI offers to install " +
103
+ "whichever half is missing; over MCP it prints the exact install commands). " +
104
+ "Signup/OTP flows work: the CLI answers the spec's await_email steps over the API " +
105
+ "against the same mailbox the cloud runner uses. Authenticated tests work too: a plan " +
106
+ "that signs itself in with {{login_email}}/{{login_password}} has its password fetched " +
107
+ "once over the logged secret-reveal route, handed to the spec the way the cloud runner " +
108
+ "does, and scrubbed from any error text or DOM snapshot before results upload. When the " +
109
+ "run finishes, the results and replay artifacts are imported into Beryl as a normal run " +
86
110
  "(trigger source `local`) — history, replay, and reports all work; pass --no-sync to " +
87
- "keep a run entirely off the record while iterating. A captured-session test still " +
88
- "refuses to run locally (its session lives encrypted in Beryl's cloud and is never " +
89
- "decrypted to your disk) it is skipped with a note. Point --url-override at a local " +
90
- "dev server or preview, and --dir to keep specs, artifacts, and reports on disk. " +
91
- "Exits 0 only if every executed test passed.",
111
+ "keep a run entirely off the record while iterating. Session-mode tests run here too: " +
112
+ "in the cloud their account signs in once per run and every test rides that session, " +
113
+ "and locally the server hands you the same test with that account's sign-in steps in " +
114
+ "front, so it proves the same thing on your machine. Only a test that depends on a " +
115
+ "session Beryl captured server-side is skipped, with a note. Point " +
116
+ "--url-override at a local dev server or preview, and --dir to keep specs, artifacts, " +
117
+ "and reports on disk. Exits 0 only if every executed test passed.",
92
118
  scope: "project",
93
119
  args: [
94
120
  {
@@ -129,6 +155,7 @@ export const runCommands = [
129
155
  "beryl runs local 4f… --no-sync --dir ./beryl-local",
130
156
  ],
131
157
  async run(ctx, input) {
158
+ await ensureRunnableLocally(ctx);
132
159
  const { workspaceId, projectId } = await ctx.requireProject(input);
133
160
  // Deduped: a repeated id would put the same test twice in one imported run,
134
161
  // which the import manifest rejects.
@@ -180,21 +207,25 @@ export const runCommands = [
180
207
  requiresAuth: Boolean(script.requires_auth),
181
208
  usesEmail: /__vmInbox\(|__vmAwaitEmail\(/.test(script.content),
182
209
  usesLoginPassword: Boolean(script.uses_login_password),
210
+ inlinedLoginSteps: script.inlined_login_steps ?? 0,
211
+ ...(script.email ? { email: script.email } : {}),
183
212
  ...(script.login_config_error ? { loginConfigError: script.login_config_error } : {}),
184
213
  });
185
214
  }
186
215
  if (specs.length === 0)
187
216
  throw new CliError("No runnable tests were found.");
188
- // A captured-session test's login lives encrypted in Beryl's cloud and is never
189
- // handed to a local runner — skip it rather than run a spec doomed at the wall.
190
- // A saved-login test missing its backing config would type a literal placeholder
191
- // into the page: same treatment, with the server's fix-it message.
217
+ // A test that leans on a session Beryl holds server-side gets no session here, so
218
+ // skip it rather than run a spec doomed at the login wall. Session-mode tests are
219
+ // NOT in that set: the server renders them with their account's own sign-in steps
220
+ // in front, so they sign themselves in here and run like any other. A test whose
221
+ // account is missing its password would type a literal placeholder into the page:
222
+ // same treatment, with the server's fix-it message.
192
223
  const skipped = specs.filter((s) => s.requiresAuth || s.loginConfigError);
193
224
  const runnable = specs.filter((s) => !s.requiresAuth && !s.loginConfigError);
194
225
  for (const s of skipped) {
195
226
  ctx.err(yellow(s.requiresAuth
196
- ? `! ${s.title}: signs in with a captured session, so it only runs in Beryl's ` +
197
- `cloud — skipped (use \`beryl runs trigger\`).`
227
+ ? `! ${s.title}: needs a session only Beryl's cloud holds — skipped ` +
228
+ `(use \`beryl runs trigger\`).`
198
229
  : `! ${s.title}: skipped — ${s.loginConfigError}`));
199
230
  }
200
231
  if (runnable.length === 0)
@@ -235,14 +266,24 @@ export const runCommands = [
235
266
  const testStarted = new Date().toISOString();
236
267
  let outcome;
237
268
  let runError;
269
+ let lastStep = 0;
238
270
  const attempt = () => executeLocalSpec({ client: ctx.client, workspaceId, projectId }, {
239
271
  spec,
240
272
  dir: dir ? path.join(dir, spec.id) : undefined,
241
273
  harvest: sync,
242
274
  loginPassword,
243
- onEvent: (line) => ctx.err(dim(line)),
275
+ // Clear the rewriting bar before a mid-test log line, or the line is
276
+ // appended onto the live bar and fossilises it into scrollback.
277
+ onEvent: (line) => {
278
+ bar.clear();
279
+ ctx.err(dim(line));
280
+ bar.update({ ...state(), step: lastStep });
281
+ },
244
282
  onSpawn: (runDir) => {
245
- const ticker = setInterval(() => bar.update({ ...state(), step: countWrittenFrames(runDir) }), 300);
283
+ const ticker = setInterval(() => {
284
+ lastStep = countWrittenFrames(runDir);
285
+ bar.update({ ...state(), step: lastStep });
286
+ }, 300);
246
287
  return () => clearInterval(ticker);
247
288
  },
248
289
  });
@@ -162,9 +162,11 @@ export const testCommands = [
162
162
  "terminal the CLI offers to install it). " +
163
163
  "A red replay banks NOTHING: the failure evidence comes back (over MCP the screenshot is " +
164
164
  "image content), you fix the plan file and re-run. The proving run is imported as the " +
165
- "test's first run (--no-sync to skip). A plan that signs in with a captured session cannot " +
166
- "replay locally (the session never leaves Beryl's cloud) — it falls back to server-side " +
167
- "verification automatically. Optional `before` and `after` arrays hold setup and teardown " +
165
+ "test's first run (--no-sync to skip). A plan that signs in with a session Beryl captured " +
166
+ "server-side cannot replay locally (that session never leaves Beryl's cloud) — it falls " +
167
+ "back to server-side verification automatically. A session-mode plan replays locally " +
168
+ "fine: the server renders it with its account's stored sign-in steps in front, so " +
169
+ "the same identity is exercised on your machine. Optional `before` and `after` arrays hold setup and teardown " +
168
170
  "steps: `after` runs even when a main step fails, which is how a create/update/delete test " +
169
171
  "cleans up the record it made on the runs that go red.",
170
172
  scope: "project",
@@ -279,6 +281,7 @@ export const testCommands = [
279
281
  requiresAuth: false,
280
282
  usesEmail: /__vmInbox\(|__vmAwaitEmail\(/.test(content),
281
283
  usesLoginPassword: compiled.uses_login_password,
284
+ email: compiled.email ?? undefined,
282
285
  });
283
286
  const startedAt = new Date().toISOString();
284
287
  const attempts = 1 + Math.max(0, compiled.policy.error_retries);
@@ -10,14 +10,17 @@ export function startEmailPump(opts) {
10
10
  let stopped = false;
11
11
  const served = new Set();
12
12
  const consumed = new Set();
13
- let since = opts.since;
13
+ let storedAfter = opts.storedAfter;
14
+ let since;
14
15
  const fetchLatest = async (req, timeoutS) => {
15
16
  try {
16
- return (await opts.client.get(`/workspaces/${opts.workspaceId}/inboxes/${opts.inboxId}/emails/latest`, {
17
+ return (await opts.client.get(`/workspaces/${opts.workspaceId}/mailboxes/${opts.inboxId}/emails/latest`, {
17
18
  timeout_s: timeoutS,
18
19
  since,
20
+ stored_after: storedAfter,
19
21
  from_contains: req.from_contains,
20
22
  subject_contains: req.subject_contains,
23
+ recipient_contains: opts.recipientContains,
21
24
  }));
22
25
  }
23
26
  catch (err) {
package/dist/http.js CHANGED
@@ -52,7 +52,10 @@ export class ApiClient {
52
52
  }
53
53
  if (response.status === 401) {
54
54
  throw new AuthError(this.token
55
- ? "Authentication failed — the token is invalid, expired, or revoked. Run `beryl login`."
55
+ ? "Authentication failed — the token is invalid, expired, or revoked. Run " +
56
+ "`beryl login`. If you just did, and this is an MCP server started with an " +
57
+ "explicit BERYL_API_KEY/BERYL_TOKEN, that env var wins over the login — " +
58
+ "re-register the server to pick up the new token."
56
59
  : "Not logged in. Run `beryl login` or set BERYL_API_KEY.");
57
60
  }
58
61
  if (!response.ok) {
@@ -7,6 +7,7 @@ import { PlaywrightMissingError, runSpecLocally, } from "./local-run.js";
7
7
  export const IMPORT_MAX_ERROR_LEN = 5000;
8
8
  export const IMPORT_MAX_FILE_BYTES = 14 * 1024 * 1024;
9
9
  export const IMPORT_MAX_TOTAL_BYTES = 180 * 1024 * 1024;
10
+ export const IMPORT_MAX_FILES = 3600;
10
11
  export function toRunEntry(spec, outcome, runError, startedAt, ordinal, redact) {
11
12
  // Mirrors the cloud runner's redact_result: the spec types the secret into the
12
13
  // page, so error text and the DOM snapshot can echo it back — scrub before the
@@ -51,14 +52,19 @@ export function toRunEntry(spec, outcome, runError, startedAt, ordinal, redact)
51
52
  return entry;
52
53
  // A frame over the per-file cap would force dropping mid-list and shift the
53
54
  // index-aligned url/duration sidecars — drop the whole filmstrip instead.
54
- if (harvested.frames.every((f) => f.length <= IMPORT_MAX_FILE_BYTES)) {
55
- harvested.frames.forEach((bytes, i) => {
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) => {
56
62
  const name = `r${ordinal}-frame-${String(i).padStart(3, "0")}.png`;
57
63
  entry.frames.push(name);
58
64
  entry.files.push({ name, bytes });
59
65
  });
60
- entry.frame_urls = harvested.frameUrls;
61
- entry.frame_durations_ms = harvested.frameDurationsMs;
66
+ entry.frame_urls = harvested.frameUrls.slice(skip);
67
+ entry.frame_durations_ms = harvested.frameDurationsMs.slice(skip);
62
68
  }
63
69
  if (harvested.screenshot && harvested.screenshot.length <= IMPORT_MAX_FILE_BYTES) {
64
70
  entry.screenshot = `r${ordinal}-screenshot.png`;
@@ -90,6 +96,22 @@ export function buildImportForm(entries, opts) {
90
96
  total -= frameBytes;
91
97
  opts.onNote?.(`replay frames for test ${entry.test_case_id} dropped (upload size cap)`);
92
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
+ }
93
115
  const manifest = {
94
116
  environment_id: opts.environmentId ?? null,
95
117
  target_url_override: opts.targetUrlOverride ?? null,
@@ -108,9 +130,8 @@ export function buildImportForm(entries, opts) {
108
130
  return form;
109
131
  }
110
132
  /**
111
- * Run one rendered spec on this machine with its full service harness: mint a run
112
- * inbox when the spec awaits email (deleted after, best-effort), write the
113
- * inbox/login sidecars, pump `await_email` requests over the API while Playwright
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
114
135
  * runs. The one local-execution seam shared by `runs local` and the local-verify
115
136
  * `tests create`. {@link PlaywrightMissingError} propagates (environmental — the
116
137
  * caller decides whether to offer an install); any other throw is captured as
@@ -118,11 +139,18 @@ export function buildImportForm(entries, opts) {
118
139
  */
119
140
  export async function executeLocalSpec(deps, opts) {
120
141
  const { spec } = opts;
121
- const inbox = spec.usesEmail
122
- ? (await deps.client.post(`/workspaces/${deps.workspaceId}/inboxes`, {
123
- ttl_hours: 1,
124
- project_id: deps.projectId,
125
- }))
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
+ }
126
154
  : undefined;
127
155
  try {
128
156
  const outcome = await runSpecLocally({
@@ -146,7 +174,8 @@ export async function executeLocalSpec(deps, opts) {
146
174
  client: deps.client,
147
175
  workspaceId: deps.workspaceId,
148
176
  inboxId: inbox.id,
149
- since: inbox.created_at,
177
+ storedAfter: inbox.storedAfter,
178
+ recipientContains: inbox.recipientContains,
150
179
  onEvent: opts.onEvent,
151
180
  })
152
181
  : undefined;
@@ -166,11 +195,4 @@ export async function executeLocalSpec(deps, opts) {
166
195
  // caller records, not an aborted command.
167
196
  return { runError: err instanceof Error ? err.message : String(err) };
168
197
  }
169
- finally {
170
- if (inbox) {
171
- await deps.client
172
- .del(`/workspaces/${deps.workspaceId}/inboxes/${inbox.id}`)
173
- .catch(() => undefined);
174
- }
175
- }
176
198
  }
@@ -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;
@@ -1,9 +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
5
  import { environmentCommands } from "../commands/environments.js";
5
6
  import { explorationCommands } from "../commands/explorations.js";
6
- import { inboxCommands } from "../commands/inboxes.js";
7
+ import { mailboxCommands } from "../commands/mailboxes.js";
7
8
  import { initCommands } from "../commands/init.js";
8
9
  import { mcpCommands } from "../commands/mcp.js";
9
10
  import { projectCommands } from "../commands/projects.js";
@@ -61,7 +62,8 @@ export const commands = [
61
62
  ...explorationCommands,
62
63
  ...configCommands,
63
64
  ...slackCommands,
64
- ...inboxCommands,
65
+ ...mailboxCommands,
66
+ ...testAccountCommands,
65
67
  ...accountCommands,
66
68
  ...mcpCommands,
67
69
  ].map(withScopeFlags).map(hideFromMcp);
@@ -25,6 +25,15 @@ export const ACTION_PLAN_SCHEMA = {
25
25
  "title": "ActionType",
26
26
  "type": "string"
27
27
  },
28
+ "AuthMode": {
29
+ "description": "How an authenticated test reaches its logged-in start state. The AUTHOR sets\nthis, explicitly, on every plan that sets ``requires_auth`` \u2014 a submitted plan\nthat omits it is rejected, because the wrong guess banks a plan that runs logged\nout and fails somewhere misleading.\n\n``inline`` \u2014 the plan signs itself in: its own steps fill ``{{login_email}}`` /\n``{{login_password}}``, or it rides the project's captured session.\n\n``session`` \u2014 the test's account signs in ONCE per run, ahead of the tests; the\nresulting browser session is proved against a fresh context and then handed to every\nsession-mode test in the run. The plan itself carries NO sign-in steps, so it starts\nwhere the flow it actually tests begins. Requires the account to have a stored\nlogin plan (``accounts set-login``).",
30
+ "enum": [
31
+ "inline",
32
+ "session"
33
+ ],
34
+ "title": "AuthMode",
35
+ "type": "string"
36
+ },
28
37
  "DialogChoice": {
29
38
  "enum": [
30
39
  "accept",
@@ -865,6 +874,7 @@ export const ACTION_PLAN_SCHEMA = {
865
874
  "inbox_address",
866
875
  "login_email",
867
876
  "login_password",
877
+ "mailbox_address",
868
878
  "timestamp",
869
879
  "unique",
870
880
  "uuid"
@@ -1130,6 +1140,24 @@ export const ACTION_PLAN_SCHEMA = {
1130
1140
  "$id": "https://api.beryl.so/api/v1/schemas/action-plan.schema.json",
1131
1141
  "$schema": "https://json-schema.org/draft/2020-12/schema",
1132
1142
  "allOf": [
1143
+ {
1144
+ "$comment": "requires_auth demands an explicit auth_mode \u2014 there is no default. 'inline' when the plan signs itself in, 'session' when it rides its account's once-per-run session and carries no sign-in steps.",
1145
+ "if": {
1146
+ "properties": {
1147
+ "requires_auth": {
1148
+ "const": true
1149
+ }
1150
+ },
1151
+ "required": [
1152
+ "requires_auth"
1153
+ ]
1154
+ },
1155
+ "then": {
1156
+ "required": [
1157
+ "auth_mode"
1158
+ ]
1159
+ }
1160
+ },
1133
1161
  {
1134
1162
  "$comment": "The first executed step must be a goto, so the test loads a page before acting (that is before[0] when there is a setup section, else steps[0]).",
1135
1163
  "else": {
@@ -1242,6 +1270,10 @@ export const ACTION_PLAN_SCHEMA = {
1242
1270
  "default": null,
1243
1271
  "title": "Auth Label"
1244
1272
  },
1273
+ "auth_mode": {
1274
+ "$ref": "#/$defs/AuthMode",
1275
+ "default": "inline"
1276
+ },
1245
1277
  "before": {
1246
1278
  "items": {
1247
1279
  "$ref": "#/$defs/PlanStep"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.17.0",
3
+ "version": "0.21.4",
4
4
  "description": "Beryl on the command line — projects, runs, the exploring agent, and an MCP server over the same commands.",
5
5
  "license": "MIT",
6
6
  "type": "module",