@beryl-so/cli 0.33.0 → 0.34.2

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 CHANGED
@@ -216,8 +216,9 @@ Author, inspect, version, and heal a project's tests: the checks Beryl runs on e
216
216
  | `beryl tests set-plan <test-id>` | Replace a test's step plan from a JSON file (creates a new version) | `tests_set_plan` |
217
217
  | `beryl tests rename <test-id> <title>` | Rename a test | `tests_rename` |
218
218
  | `beryl tests set-groups <test-id>` | Replace the groups a test belongs to | `tests_set_groups` |
219
+ | `beryl tests add-groups <test-ids...>` | Add groups to one or more tests, keeping the groups they already have | `tests_add_groups` |
219
220
  | `beryl tests quarantine <test-id> <state>` | Mute a flaky test: it keeps running, but its failures stop failing the run | `tests_quarantine` |
220
- | `beryl tests delete <test-id>` | Delete a test, its version history, and its results | `tests_delete` |
221
+ | `beryl tests delete <test-ids...>` | Delete tests, their version history, and their results | `tests_delete` |
221
222
  | `beryl tests recompile <test-id>` | Validate + verify an edited plan against the live site before persisting | `tests_recompile` |
222
223
  | `beryl tests versions <test-id>` | List a test's version history | `tests_versions` |
223
224
  | `beryl tests version <test-id> <version-no>` | Show one specific version of a test (including its plan) | `tests_version` |
@@ -251,7 +252,7 @@ Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and downlo
251
252
  | Command | Summary | MCP tool |
252
253
  | --- | --- | --- |
253
254
  | `beryl runs trigger` | Trigger a test run (whole suite, a subset, or one environment) | `runs_trigger` |
254
- | `beryl runs local [test-ids...]` | Run tests on your machine with your own Playwright; results sync to Beryl | `runs_local` |
255
+ | `beryl runs local [test-ids...]` | Run tests on your machine with Playwright; results sync to Beryl | `runs_local` |
255
256
  | `beryl runs list` | List recent runs | `runs_list` |
256
257
  | `beryl runs get <run-id>` | Show one run with its per-test results | `runs_get` |
257
258
  | `beryl runs watch <run-id>` | Attach to a run and stream progress until it finishes | `runs_watch` |
@@ -73,7 +73,7 @@ exception are in \`tests create\`'s own description.
73
73
  Before the first plan:
74
74
 
75
75
  \`\`\`
76
- npm i -D @playwright/test && npx playwright install chromium # once, per project
76
+ npx @beryl-so/cli@latest init # once per machine: installs the Chromium browser (the runner ships with the CLI)
77
77
  beryl envs list # the root URL gotos resolve against (§2)
78
78
  beryl envs update <id> --url https://app.example.com # set it if root_url is empty — required
79
79
  beryl accounts list # who do authenticated tests sign in as? (§1)
@@ -370,7 +370,7 @@ Weak (describes steps + asserts nothing meaningful):
370
370
  ## 4. The local run-fix loop
371
371
 
372
372
  Iterate on your machine before you rely on the cloud. \`beryl runs local\` runs banked
373
- tests with your local \`@playwright/test\` — no cloud, no waiting for a scheduled run.
373
+ tests with the Playwright runner bundled in the CLI — no cloud, no waiting for a scheduled run.
374
374
 
375
375
  \`\`\`
376
376
  beryl runs local <test-id> --no-sync --url-override http://localhost:3000 --dir ./beryl-local
@@ -175,7 +175,7 @@ export const authCommands = [
175
175
  const human = `${green("Logged in")} as ${me.name} <${me.email}>` +
176
176
  `\n${dim(`Token saved to ${saved}`)}`;
177
177
  return {
178
- data: { email: me.email, name: me.name, config: saved },
178
+ data: { id: me.id, email: me.email, name: me.name, config: saved },
179
179
  human,
180
180
  };
181
181
  },
@@ -8,6 +8,7 @@ import { AuthError, CliError } from "../errors.js";
8
8
  import { ApiClient } from "../http.js";
9
9
  import { bold, cyan, dim, green, red, yellow } from "../output.js";
10
10
  import { anyGap, describeGaps, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
11
+ import { aliasSessionToAccount, captureCliEvent, flushTelemetry, shutdownTelemetry, } from "../telemetry.js";
11
12
  import { cliVersion, warnIfStale } from "../version-check.js";
12
13
  import { authCommands } from "./auth.js";
13
14
  import { flagStr } from "./util.js";
@@ -117,16 +118,16 @@ function writeSkills(cwd, scope) {
117
118
  writeSkillFile(skillLeaf(path.join(root, ".claude", "skills"))),
118
119
  ];
119
120
  }
120
- // Local authoring drives a real browser via `@playwright/test` + chromium, and the whole
121
- // authoring workflow (walk the flow first, then bank the plan) depends on it — so the
122
- // install is mandatory, not offered: missing means install now, no prompt, no opt-out.
123
- // Both halves count: the npm package without the browser binary is a real state, and it
124
- // fails every local run with the same launch error. Never throws a failed install must not
125
- // fail `init`, which has already done its wiring; it prints the exact commands instead.
121
+ // Local authoring drives a real browser, and the whole authoring workflow (walk the flow
122
+ // first, then bank the plan) depends on it — so the install is mandatory, not offered:
123
+ // missing means install now, no prompt, no opt-out. The runner ships with the CLI; the
124
+ // browser is the per-machine half, fetched for the runner's own revision. Never throws a
125
+ // failed install must not fail `init`, which has already done its wiring; it prints the
126
+ // exact commands instead.
126
127
  async function ensureLocalPlaywright(ctx, cwd) {
127
128
  const gaps = playwrightGaps(cwd);
128
129
  if (!anyGap(gaps)) {
129
- ctx.err(`${green("✓")} @playwright/test + Chromium already installed ${dim("(local runs ready)")}`);
130
+ ctx.err(`${green("✓")} Playwright + Chromium already installed ${dim("(local runs ready)")}`);
130
131
  return;
131
132
  }
132
133
  try {
@@ -141,7 +142,7 @@ async function ensureLocalPlaywright(ctx, cwd) {
141
142
  catch (err) {
142
143
  ctx.err(`${red("✗")} Playwright install failed: ${err.message}`);
143
144
  ctx.err(`${dim("•")} Finish the install by hand — local runs and browser authoring need it:\n` +
144
- ` ${cyan(installCommandsFor(gaps))}`);
145
+ ` ${cyan(installCommandsFor(gaps, cwd))}`);
145
146
  }
146
147
  }
147
148
  export const initCommands = [
@@ -153,8 +154,9 @@ export const initCommands = [
153
154
  "agent. Nothing is detected and nothing is conditional: every run wires the beryl AND " +
154
155
  "playwright MCP servers, writes the authoring skill (user scope: your home " +
155
156
  ".agents/skills/ + .claude/skills/, so it follows you into every session; --scope " +
156
- "project: the repo's own dirs, committed for teammates), and installs @playwright/test " +
157
- "+ chromium if missing, since browser authoring and local runs depend on it. By default the " +
157
+ "project: the repo's own dirs, committed for teammates), and installs the Chromium " +
158
+ "browser if missing (the Playwright runner ships with the CLI, so any repo can run tests " +
159
+ "locally), since browser authoring and local runs depend on it. By default the " +
158
160
  "servers are wired per-user (matching where your login token lives) via `claude mcp add " +
159
161
  "-s user`; pass --scope project to write a committed .mcp.json for a shared repo " +
160
162
  "instead. No workspace/project pin and no URL prompt: ask Claude to write tests for " +
@@ -187,6 +189,10 @@ export const initCommands = [
187
189
  async run(ctx, input) {
188
190
  const cwd = process.cwd();
189
191
  let { client, config } = ctx;
192
+ const scope = (flagStr(input, "scope") ?? "user");
193
+ captureCliEvent("cli_init_started", config.apiUrl, { scope });
194
+ flushTelemetry();
195
+ let accountId;
190
196
  const signIn = async () => {
191
197
  // Non-interactive skips the picker and lets `login` fail with its own
192
198
  // "interactive input required" message, exactly as before.
@@ -194,7 +200,11 @@ export const initCommands = [
194
200
  ? await ctx.prompt("Sign in: [1] Browser (opens beryl.so) [2] Email me a code — [1]: ")
195
201
  : "";
196
202
  const login = authCommands.find((c) => c.name === "login");
197
- await login.run(ctx, { args: {}, flags: answer.trim() === "2" ? { otp: true } : {} });
203
+ const result = await login.run(ctx, {
204
+ args: {},
205
+ flags: answer.trim() === "2" ? { otp: true } : {},
206
+ });
207
+ accountId = (result ?? {}).data?.id;
198
208
  config = loadConfig();
199
209
  if (!config.token)
200
210
  throw new CliError("Login did not persist a token");
@@ -203,6 +213,7 @@ export const initCommands = [
203
213
  if (config.token) {
204
214
  try {
205
215
  const me = (await client.get("/account/"));
216
+ accountId = me.id;
206
217
  ctx.err(`${green("✓")} Signed in as ${me.email}`);
207
218
  }
208
219
  catch (err) {
@@ -215,7 +226,10 @@ export const initCommands = [
215
226
  else {
216
227
  await signIn();
217
228
  }
218
- const scope = (flagStr(input, "scope") ?? "user");
229
+ if (accountId) {
230
+ aliasSessionToAccount(accountId);
231
+ flushTelemetry();
232
+ }
219
233
  const report = (label, fresh, where) => ctx.err(`${green("✓")} ${label} ${fresh ? "configured" : "already configured"} ${dim(where)}`);
220
234
  if (scope === "user") {
221
235
  // Claude Code owns ~/.claude.json — shell out to `claude mcp add` rather than write it.
@@ -267,6 +281,7 @@ export const initCommands = [
267
281
  // The .mcp.json entry is pinned to @latest, but a global install / old npx cache
268
282
  // still wins resolution — so tell the user when the CLI they just ran is stale.
269
283
  await warnIfStale(cliVersion(), (msg) => ctx.err(yellow(msg)));
284
+ await shutdownTelemetry();
270
285
  return {
271
286
  data: {
272
287
  scope,
@@ -5,7 +5,7 @@ import { CliError, UsageError } from "../errors.js";
5
5
  import { buildImportForm, establishAccountSession, executeLocalSpec, IMPORT_MAX_ERROR_LEN, 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 { anyGap, confirmInstall, describeGaps, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
8
+ import { anyGap, confirmInstall, describeGaps, installCommandsFor, installHint, installPlaywright, playwrightGaps, } from "../playwright-install.js";
9
9
  import { ProgressBar } from "../progress.js";
10
10
  import { arg, flagBool, flagNum, flagStr, idsInGroup, projectPath } from "./util.js";
11
11
  import { watchRun } from "./watch.js";
@@ -17,8 +17,8 @@ async function ensureRunnableLocally(ctx) {
17
17
  const gaps = playwrightGaps(process.cwd());
18
18
  if (!anyGap(gaps))
19
19
  return;
20
- const commands = installCommandsFor(gaps);
21
- const hint = `Local runs need ${describeGaps(gaps)}. Install it, then re-run:\n ${commands}`;
20
+ const commands = installCommandsFor(gaps, process.cwd());
21
+ const hint = installHint(process.cwd(), gaps);
22
22
  if (!ctx.interactive || !(await confirmInstall(ctx.prompt, commands)))
23
23
  throw new CliError(hint);
24
24
  try {
@@ -115,12 +115,13 @@ export const runCommands = [
115
115
  },
116
116
  {
117
117
  name: "runs local",
118
- summary: "Run tests on your machine with your own Playwright; results sync to Beryl",
118
+ summary: "Run tests on your machine with Playwright; results sync to Beryl",
119
119
  description: "Unlike `runs trigger`, the browser runs on YOUR machine: each test's rendered spec is " +
120
- "fetched and run with your local @playwright/test and the Chromium binary it drives. " +
120
+ "fetched and run with the Playwright runner bundled in the CLI (your repo's own " +
121
+ "@playwright/test wins when present) and the Chromium binary it drives. " +
121
122
  "Both are checked once before any spec is fetched, so a machine that can't run tests " +
122
123
  "says so once instead of failing every test (on a terminal the CLI offers to install " +
123
- "whichever half is missing; over MCP it prints the exact install commands). " +
124
+ "the missing browser; over MCP it prints the exact install command). " +
124
125
  "Signup/OTP flows work: the CLI answers the spec's await_email steps over the API " +
125
126
  "against the same mailbox the cloud runner uses. Authenticated tests work too: for a plan " +
126
127
  "that signs itself in with {{login_email}}/{{login_password}}, the email is baked into " +
@@ -412,7 +413,9 @@ export const runCommands = [
412
413
  if (err instanceof PlaywrightMissingError && ctx.interactive && !installOffered) {
413
414
  installOffered = true;
414
415
  bar.clear();
415
- if (!(await confirmInstall(ctx.prompt)))
416
+ const cwd = process.cwd();
417
+ const commands = installCommandsFor(playwrightGaps(cwd), cwd);
418
+ if (!(await confirmInstall(ctx.prompt, commands)))
416
419
  throw err;
417
420
  try {
418
421
  await installPlaywright(process.cwd(), (line) => ctx.err(dim(line)));
@@ -5,7 +5,7 @@ import { lintPlan } from "../lint.js";
5
5
  import { buildImportForm, establishAccountSession, executeLocalSpec, toRunEntry, } from "../local-exec.js";
6
6
  import { PlaywrightMissingError } from "../local-run.js";
7
7
  import { dim, green, red, table, yellow } from "../output.js";
8
- import { confirmInstall, installPlaywright } from "../playwright-install.js";
8
+ import { confirmInstall, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
9
9
  import { ACTION_PLAN_SCHEMA } from "../schema.generated.js";
10
10
  import { arg, argList, flagBool, flagNum, flagStr, flagStrings, inGroup, projectPath, readJsonFlag, } from "./util.js";
11
11
  const testPath = (ws, p, id) => `${projectPath(ws, p)}/tests/${id}`;
@@ -139,6 +139,11 @@ export const testCommands = [
139
139
  type: "boolean",
140
140
  description: "With --page, show only tests carrying no group",
141
141
  },
142
+ {
143
+ name: "q",
144
+ type: "string",
145
+ description: "With --page, show only tests whose title contains this text (case-insensitive)",
146
+ },
142
147
  ],
143
148
  async run(ctx, input) {
144
149
  const { workspaceId, projectId } = await ctx.requireProject(input);
@@ -159,6 +164,7 @@ export const testCommands = [
159
164
  page_size: flagNum(input, "page-size"),
160
165
  status: flagStr(input, "status"),
161
166
  group: flagBool(input, "ungrouped") ? "__ungrouped__" : group,
167
+ q: flagStr(input, "q"),
162
168
  environment_id: flagStr(input, "env"),
163
169
  })).items;
164
170
  }
@@ -201,13 +207,14 @@ export const testCommands = [
201
207
  summary: "Create a test case from a JSON action plan (for tests authored locally, e.g. by your coding agent)",
202
208
  description: "The plan is a JSON object whose steps are {action, selector, url, value, ...}: the first " +
203
209
  "EXECUTED step must be a goto, and at least one step must be an expect. Before anything is " +
204
- "banked, the plan is proven by replaying it in a browser ON YOUR MACHINE with your local " +
205
- "@playwright/test: the server renders the spec (`tests/compile`), the CLI runs it (minting " +
210
+ "banked, the plan is proven by replaying it in a browser ON YOUR MACHINE with the " +
211
+ "Playwright runner bundled in the CLI (your repo's own @playwright/test wins when " +
212
+ "present): the server renders the spec (`tests/compile`), the CLI runs it (minting " +
206
213
  "a run inbox for await_email steps and resolving the saved login exactly as a cloud run " +
207
214
  "would), and only a green replay creates the test (bound to the replayed plan by its hash). " +
208
215
  "This holds over MCP too: the replay runs on the machine hosting the MCP server, never on " +
209
- "Beryl's; if @playwright/test is missing there the tool returns the install commands (on a " +
210
- "terminal the CLI offers to install it). " +
216
+ "Beryl's; if the Chromium browser is missing there the tool returns the install command " +
217
+ "(`beryl init` installs it; on a terminal the CLI offers to). " +
211
218
  "A red replay banks NOTHING: the failure evidence comes back (over MCP the screenshot is " +
212
219
  "image content), you fix the plan file and re-run. The proving run is imported as the " +
213
220
  "test's first run (--no-sync to skip). A plan that signs in with a session Beryl captured " +
@@ -393,8 +400,10 @@ export const testCommands = [
393
400
  // pipe the hint is the answer.
394
401
  if (ctx.interactive && !installOffered) {
395
402
  installOffered = true;
396
- if (await confirmInstall(ctx.prompt)) {
397
- await installPlaywright(process.cwd(), (line) => ctx.err(dim(line)));
403
+ const cwd = process.cwd();
404
+ const commands = installCommandsFor(playwrightGaps(cwd), cwd);
405
+ if (await confirmInstall(ctx.prompt, commands)) {
406
+ await installPlaywright(cwd, (line) => ctx.err(dim(line)));
398
407
  attempt -= 1;
399
408
  continue;
400
409
  }
@@ -552,6 +561,39 @@ export const testCommands = [
552
561
  };
553
562
  },
554
563
  },
564
+ {
565
+ name: "tests add-groups",
566
+ summary: "Add groups to one or more tests, keeping the groups they already have",
567
+ description: "The additive counterpart of `tests set-groups`: every listed test gains the given " +
568
+ "groups and loses none, so you don't need to know what each test already carries. " +
569
+ "Names must already exist in the project (`beryl groups list`); an unknown name is " +
570
+ "an error, never a new group.",
571
+ scope: "project",
572
+ args: [{ name: "test-ids", description: "One or more test ids", required: true, variadic: true }],
573
+ flags: [
574
+ {
575
+ name: "group",
576
+ type: "strings",
577
+ description: "Group name to add, repeatable (at least one)",
578
+ },
579
+ ],
580
+ examples: ["beryl tests add-groups 4f… 9a… --group Smoke", "beryl tests add-groups 4f… --group Checkout --group Smoke"],
581
+ async run(ctx, input) {
582
+ const { workspaceId, projectId } = await ctx.requireProject(input);
583
+ const add = flagStrings(input, "group");
584
+ if (!add)
585
+ throw new UsageError("Pass at least one --group");
586
+ const ids = argList(input, "test-ids");
587
+ const data = (await ctx.client.post(`${projectPath(workspaceId, projectId)}/tests/bulk-groups`, {
588
+ test_case_ids: ids,
589
+ add,
590
+ }));
591
+ return {
592
+ data,
593
+ human: `Added ${add.join(", ")} to ${data.updated} of ${ids.length} test${ids.length === 1 ? "" : "s"} (the rest already had them).`,
594
+ };
595
+ },
596
+ },
555
597
  {
556
598
  name: "tests quarantine",
557
599
  summary: "Mute a flaky test: it keeps running, but its failures stop failing the run",
@@ -581,16 +623,28 @@ export const testCommands = [
581
623
  },
582
624
  {
583
625
  name: "tests delete",
584
- summary: "Delete a test, its version history, and its results",
626
+ summary: "Delete tests, their version history, and their results",
627
+ description: "Several ids are deleted together in one transaction: an unknown id fails the whole " +
628
+ "call and nothing is deleted.",
585
629
  scope: "project",
586
- args: [{ name: "test-id", description: "Test id", required: true }],
630
+ args: [{ name: "test-ids", description: "One or more test ids", required: true, variadic: true }],
587
631
  flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
632
+ examples: ["beryl tests delete 4f… --force", "beryl tests delete 4f… 9a… 1c…"],
588
633
  async run(ctx, input) {
589
634
  const { workspaceId, projectId } = await ctx.requireProject(input);
590
- const testId = arg(input, "test-id");
591
- await ctx.confirm(`Delete test ${testId} and all its history?`, flagBool(input, "force"));
592
- await ctx.client.del(testPath(workspaceId, projectId, testId));
593
- return { human: "Deleted." };
635
+ const ids = argList(input, "test-ids");
636
+ const single = ids.length === 1 ? ids[0] : undefined;
637
+ const what = single ? `test ${single} and all its history` : `${ids.length} tests and all their history`;
638
+ await ctx.confirm(`Delete ${what}?`, flagBool(input, "force"));
639
+ if (single) {
640
+ await ctx.client.del(testPath(workspaceId, projectId, single));
641
+ }
642
+ else {
643
+ await ctx.client.post(`${projectPath(workspaceId, projectId)}/tests/bulk-delete`, {
644
+ test_case_ids: ids,
645
+ });
646
+ }
647
+ return { human: ids.length === 1 ? "Deleted." : `Deleted ${ids.length} tests.` };
594
648
  },
595
649
  },
596
650
  {
package/dist/local-run.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { PLAYWRIGHT_INSTALL_COMMANDS } from "./playwright-install.js";
4
+ import { bundledRunnerCli, installHint, PLAYWRIGHT_INSTALL_COMMANDS, } from "./playwright-install.js";
5
5
  // We invoke @playwright/test as an external rather than bundling it — the CLI's zero-runtime-dep
6
6
  // rule. In a node_modules install `playwright` is on PATH; otherwise fall back to `npx playwright`,
7
7
  // exactly as the cloud runner does. The install commands come from playwright-install.ts so the
@@ -51,25 +51,36 @@ function runProcess(command, args, cwd, env) {
51
51
  child.on("close", (code) => resolve({ code, stdout, stderr }));
52
52
  });
53
53
  }
54
- // `npx playwright` is the portable fallback when no local binary is on PATH it resolves
55
- // the project's @playwright/test without us hard-coding a node_modules path.
54
+ // The repo's own runner first; else the one bundled with the CLI; `npx playwright` is the
55
+ // last resort for a CLI install that somehow lost its dependencies.
56
56
  function playwrightBase(cwd) {
57
57
  const binName = process.platform === "win32" ? "playwright.cmd" : "playwright";
58
58
  const local = path.join(cwd, "node_modules", ".bin", binName);
59
59
  if (fs.existsSync(local))
60
- return { command: local, args: ["test"] };
60
+ return { command: local, args: ["test"], label: "playwright test" };
61
+ const bundled = bundledRunnerCli();
62
+ if (bundled) {
63
+ return {
64
+ command: process.execPath,
65
+ args: [bundled, "test"],
66
+ label: "playwright test (bundled with the CLI)",
67
+ nodePath: path.resolve(path.dirname(bundled), "..", ".."),
68
+ };
69
+ }
61
70
  const npx = process.platform === "win32" ? "npx.cmd" : "npx";
62
- return { command: npx, args: ["playwright", "test"] };
71
+ return { command: npx, args: ["playwright", "test"], label: "npx playwright test" };
63
72
  }
64
73
  // "unknown command 'test'" is the Python `playwright` shim (no `test` subcommand); the module
65
- // errors mean @playwright/test isn't installed. Either way the actionable answer is the same
66
- // install hint, not a stack trace.
74
+ // errors mean @playwright/test isn't installed; "Executable doesn't exist" is the runner
75
+ // launching a browser revision that was never downloaded. Either way the actionable answer
76
+ // is the same install hint, not a stack trace.
67
77
  function looksLikePlaywrightMissing(r) {
68
78
  if (r.spawnError?.code === "ENOENT")
69
79
  return true;
70
80
  const blob = `${r.stdout}\n${r.stderr}`;
71
81
  return (/unknown command ['"]?test/i.test(blob) ||
72
82
  /Cannot find module ['"]@playwright\/test/i.test(blob) ||
83
+ /Executable doesn't exist/i.test(blob) ||
73
84
  /npm ERR!.*could not determine executable|npx.*not found/i.test(blob));
74
85
  }
75
86
  export function parsePlaywrightReport(data) {
@@ -249,12 +260,15 @@ export async function runSpecLocally(opts) {
249
260
  fs.writeFileSync(configPath, ISOLATING_CONFIG(runDir, artifactsDir));
250
261
  const reportPath = path.join(runDir, "report.json");
251
262
  const cleanup = () => fs.rmSync(runDir, { recursive: true, force: true });
252
- const { command, args: base } = playwrightBase(cwd);
263
+ const { command, args: base, label, nodePath } = playwrightBase(cwd);
253
264
  const args = [...base, `--config=${configPath}`, "--reporter=json"];
254
- opts.onProgress?.(`Running ${command} ${base.join(" ")} on ${opts.testName}…`);
265
+ opts.onProgress?.(`Running ${label} on ${opts.testName}…`);
255
266
  await opts.setup?.(runDir);
256
267
  const stop = opts.during?.(runDir);
257
268
  const env = { ...process.env, PLAYWRIGHT_JSON_OUTPUT_NAME: reportPath };
269
+ if (nodePath) {
270
+ env.NODE_PATH = [nodePath, process.env.NODE_PATH].filter(Boolean).join(path.delimiter);
271
+ }
258
272
  // cwd is the RUN dir, not the project: the spec's relative writes (frames/,
259
273
  // email-inbox.json, dom-snapshot.html, phase.json) must land where we harvest and
260
274
  // clean up, exactly as the cloud runner keys them to its per-run workdir.
@@ -267,7 +281,7 @@ export async function runSpecLocally(opts) {
267
281
  }
268
282
  if (looksLikePlaywrightMissing(result)) {
269
283
  cleanup();
270
- throw new PlaywrightMissingError(PLAYWRIGHT_INSTALL_HINT);
284
+ throw new PlaywrightMissingError(installHint(cwd));
271
285
  }
272
286
  let results;
273
287
  try {
@@ -12,6 +12,40 @@ export const PLAYWRIGHT_INSTALL_COMMANDS = `${INSTALL_TEST_RUNNER.join(" ")} &&
12
12
  // Resolve from the project tree, not from wherever the globally-installed CLI happens to
13
13
  // live — `createRequire` rooted at cwd walks up the same node_modules chain Playwright will.
14
14
  const projectRequire = (cwd) => createRequire(path.join(cwd, "package.json"));
15
+ // @playwright/test is a dependency of the CLI, so a repo needs nothing installed to run
16
+ // locally: the runner comes with `npx @beryl-so/cli`, and only the browser is per-machine.
17
+ // A repo's own @playwright/test still wins when present (its version, its browser revision).
18
+ const cliRequire = createRequire(import.meta.url);
19
+ // cli.js is the package's bin, not an export, so it is located via package.json.
20
+ function bundledRunner() {
21
+ try {
22
+ const pkgPath = cliRequire.resolve("@playwright/test/package.json");
23
+ const { version } = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
24
+ return { cli: path.join(path.dirname(pkgPath), "cli.js"), version };
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ }
30
+ export const bundledRunnerCli = () => bundledRunner()?.cli;
31
+ // By hand, the browser must be fetched for the runner that will launch it: the repo's own
32
+ // (`npx playwright` resolves it) or, in a bare repo, the CLI's pinned version. An unpinned
33
+ // `npx playwright install` there downloads the registry's latest revision, which is not it.
34
+ export function browserInstallCommand(cwd) {
35
+ const bundled = bundledRunner();
36
+ if (hasPlaywrightTest(cwd) || !bundled)
37
+ return INSTALL_CHROMIUM.join(" ");
38
+ const [npx, pkg, ...rest] = INSTALL_CHROMIUM;
39
+ return [npx, `${pkg}@${bundled.version}`, ...rest].join(" ");
40
+ }
41
+ function bundledCoreDir() {
42
+ try {
43
+ return path.dirname(cliRequire.resolve("playwright-core/package.json"));
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ }
15
49
  export function hasPlaywrightTest(cwd) {
16
50
  try {
17
51
  projectRequire(cwd).resolve("@playwright/test");
@@ -89,7 +123,7 @@ const isComplete = (dir) => fs.existsSync(path.join(dir, INSTALL_MARKER));
89
123
  * problem that reads as a broken suite.
90
124
  */
91
125
  export function hasChromiumBrowser(cwd) {
92
- const coreDir = playwrightCoreDir(cwd);
126
+ const coreDir = hasPlaywrightTest(cwd) ? playwrightCoreDir(cwd) : bundledCoreDir();
93
127
  const root = browsersRoot(coreDir);
94
128
  // Nowhere known to look — say nothing rather than block; the run surfaces Playwright's
95
129
  // own error if it really is missing.
@@ -110,7 +144,7 @@ export function hasChromiumBrowser(cwd) {
110
144
  }
111
145
  }
112
146
  export function playwrightGaps(cwd) {
113
- const runner = !hasPlaywrightTest(cwd);
147
+ const runner = !hasPlaywrightTest(cwd) && !bundledRunnerCli();
114
148
  // With no runner there is no revision to judge a cached browser against, so the honest
115
149
  // answer is the full install — not the npm half and a second failure right after it.
116
150
  return { runner, browser: runner || !hasChromiumBrowser(cwd) };
@@ -122,10 +156,10 @@ export function describeGaps(gaps) {
122
156
  return gaps.browser ? "the Chromium browser" : "@playwright/test";
123
157
  }
124
158
  /** Only the commands the missing halves need — a present @playwright/test isn't reinstalled. */
125
- export function installCommandsFor(gaps) {
159
+ export function installCommandsFor(gaps, cwd = process.cwd()) {
126
160
  const commands = [
127
161
  ...(gaps.runner ? [INSTALL_TEST_RUNNER.join(" ")] : []),
128
- ...(gaps.browser ? [INSTALL_CHROMIUM.join(" ")] : []),
162
+ ...(gaps.browser ? [browserInstallCommand(cwd)] : []),
129
163
  ];
130
164
  return commands.length > 0 ? commands.join(" && ") : PLAYWRIGHT_INSTALL_COMMANDS;
131
165
  }
@@ -160,10 +194,19 @@ function runInherit(command, args, cwd) {
160
194
  * the browser binary — an installed runner with no browser still fails a real run.
161
195
  */
162
196
  export async function installPlaywright(cwd, onStep) {
163
- if (!hasPlaywrightTest(cwd)) {
197
+ const bundled = bundledRunnerCli();
198
+ if (!hasPlaywrightTest(cwd) && !bundled) {
164
199
  onStep?.(`Installing @playwright/test — ${INSTALL_TEST_RUNNER.join(" ")}`);
165
200
  await runInherit(INSTALL_TEST_RUNNER[0], INSTALL_TEST_RUNNER.slice(1), cwd);
166
201
  }
167
- onStep?.(`Installing the Chromium browser — ${INSTALL_CHROMIUM.join(" ")}`);
202
+ onStep?.(`Installing the Chromium browser — ${browserInstallCommand(cwd)}`);
203
+ if (!hasPlaywrightTest(cwd) && bundled) {
204
+ await runInherit(process.execPath, [bundled, ...INSTALL_CHROMIUM.slice(2)], cwd);
205
+ return;
206
+ }
168
207
  await runInherit(INSTALL_CHROMIUM[0], INSTALL_CHROMIUM.slice(1), cwd);
169
208
  }
209
+ export function installHint(cwd, gaps = playwrightGaps(cwd)) {
210
+ const commands = installCommandsFor(gaps, cwd);
211
+ return `Local runs need ${describeGaps(gaps)}. Install it, then re-run:\n ${commands}`;
212
+ }
package/dist/telemetry.js CHANGED
@@ -233,6 +233,24 @@ export function createPostHogClient() {
233
233
  posthog ??= new PostHog(POSTHOG_KEY, { host: POSTHOG_HOST, disableGeoip: false });
234
234
  return posthog;
235
235
  }
236
+ // `beryl init` starts before there is an account to attribute to: its events land on the
237
+ // process session id, and the session is aliased onto the account once init has signed in,
238
+ // so the install joins the same person as the signup and the later MCP work.
239
+ export function captureCliEvent(event, apiUrl, properties = {}) {
240
+ createPostHogClient().capture({
241
+ distinctId: sessionId,
242
+ event,
243
+ properties: { ...baseEventProperties(apiUrl), ...properties },
244
+ });
245
+ }
246
+ export function aliasSessionToAccount(userId) {
247
+ createPostHogClient().alias({ distinctId: userId, alias: sessionId });
248
+ }
249
+ export function flushTelemetry() {
250
+ // Fire-and-forget: init keeps running (login, installs) while the batch goes out, and a
251
+ // failed send must never surface to the user.
252
+ void posthog?.flush().catch(() => { });
253
+ }
236
254
  export function analyticsOptions(apiUrl, identify) {
237
255
  const properties = baseEventProperties(apiUrl);
238
256
  return {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.33.0",
4
- "description": "Beryl on the command line \u2014 projects, runs, the exploring agent, and an MCP server over the same commands.",
3
+ "version": "0.34.2",
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",
7
7
  "homepage": "https://beryl.so/docs/cli",
@@ -32,11 +32,11 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@modelcontextprotocol/sdk": "^1.29.0",
35
+ "@playwright/test": "^1.61.1",
35
36
  "@posthog/mcp": "^0.11.6",
36
37
  "posthog-node": "^5.49.1"
37
38
  },
38
39
  "devDependencies": {
39
- "@playwright/test": "^1.61.1",
40
40
  "@types/node": "^26.1.1",
41
41
  "tsx": "^4.23.1",
42
42
  "typescript": "^7.0.2",