@beryl-so/cli 0.14.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -222,7 +222,7 @@ Author, inspect, version, and heal a project's tests — the checks Beryl runs o
222
222
  | `beryl tests reset <test-id>` | Discard user edits and return the test to its latest system-authored version | `tests_reset` |
223
223
  | `beryl tests heal <test-id> <state>` | Turn self-healing on or off for a test | `tests_heal` |
224
224
  | `beryl tests history <test-id>` | Pass/fail history, streak, and stability for a test | `tests_history` |
225
- | `beryl tests script <test-id>` | Print the rendered Playwright spec for a test | `tests_script` |
225
+ | `beryl tests script [test-id]` | Print the rendered Playwright spec for a test (or an unbanked plan file) | `tests_script` |
226
226
  | `beryl tests export <test-ids...>` | Export tests as Playwright .spec.ts files in a ZIP | `tests_export` |
227
227
 
228
228
  ### runs
@@ -234,7 +234,7 @@ Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and downlo
234
234
  | Command | Summary | MCP tool |
235
235
  | --- | --- | --- |
236
236
  | `beryl runs trigger` | Trigger a test run (whole suite, a subset, or one environment) | `runs_trigger` |
237
- | `beryl runs local <test-id>` | Run a banked test locally with your own Playwright (public flows) | `runs_local` |
237
+ | `beryl runs local [test-ids...]` | Run tests on your machine with your own Playwright; results sync to Beryl | `runs_local` |
238
238
  | `beryl runs list` | List recent runs | `runs_list` |
239
239
  | `beryl runs get <run-id>` | Show one run with its per-test results | `runs_get` |
240
240
  | `beryl runs watch <run-id>` | Attach to a run and stream progress until it finishes | `runs_watch` |
@@ -267,7 +267,7 @@ Manage the variables, secrets, and files the agent can use while exploring and r
267
267
  | `beryl config vars delete <key>` | Delete a config variable | `config_vars_delete` |
268
268
  | `beryl config secrets list` | List the project's secrets (values are never returned) | `config_secrets_list` |
269
269
  | `beryl config secrets set <key> <value>` | Create a secret (write-only; re-setting a key replaces it) | `config_secrets_set` |
270
- | `beryl config secrets get <key>` | Show one secret's metadata (the value is never returned) | `config_secrets_get` |
270
+ | `beryl config secrets get <key>` | Show one secret's metadata, or reveal its value with --reveal | `config_secrets_get` |
271
271
  | `beryl config secrets delete <key>` | Delete a secret | `config_secrets_delete` |
272
272
  | `beryl config files list` | List files uploaded for the agent to use (e.g. CSVs, upload fixtures) | `config_files_list` |
273
273
  | `beryl config files get <file>` | Show one uploaded file's metadata | `config_files_get` |
@@ -288,31 +288,6 @@ Send run outcomes to a Slack channel via a per-project incoming webhook.
288
288
  | `beryl slack clear` | Remove the project's Slack webhook (stops all alerts) | — |
289
289
  | `beryl slack test` | Post a sample alert to the configured webhook | — |
290
290
 
291
- ### credentials
292
-
293
- Manage saved logins Beryl reuses to test behind authentication, and attach them to projects.
294
-
295
- | Command | Summary | MCP tool |
296
- | --- | --- | --- |
297
- | `beryl credentials list` | List the workspace's saved logins | `credentials_list` |
298
- | `beryl credentials get <credential-id>` | Show one saved login (status and freshness — never the session itself) | `credentials_get` |
299
- | `beryl credentials projects <credential-id>` | List the projects using a saved login | `credentials_projects` |
300
- | `beryl credentials delete <credential-id>` | Delete a saved login | `credentials_delete` |
301
- | `beryl credentials attach <credential-id>` | Attach a saved login to a project | `credentials_attach` |
302
- | `beryl credentials detach` | Detach the project's saved login | `credentials_detach` |
303
- | `beryl credentials recapture <credential-id>` | Start a re-capture for an expiring saved login (returns a live browser URL) | `credentials_recapture` |
304
- | `beryl credentials capture` | Capture a login for the project interactively: log in once in a real browser | — |
305
-
306
- ### auth-capture
307
-
308
- Drive a browser session that captures a target-site login for Beryl to reuse.
309
-
310
- | Command | Summary | MCP tool |
311
- | --- | --- | --- |
312
- | `beryl auth-capture start` | Start a login-capture browser session for the project (non-interactive) | `auth_capture_start` |
313
- | `beryl auth-capture capture <session-id>` | Save the session after the user has logged in via the live-view URL (first login or re-login) | `auth_capture_capture` |
314
- | `beryl auth-capture release <session-id>` | Release a login-capture browser session without capturing | `auth_capture_release` |
315
-
316
291
  ### inbox
317
292
 
318
293
  Email inboxes for testing flows that send mail — signups, OTPs, receipts.
@@ -97,8 +97,14 @@ ${JSON.stringify(BERYL_TEST_SKILL_EXAMPLE_PLAN, null, 2)
97
97
  beryl tests create --title "Log in" --file plan.json \\
98
98
  --description "<the intent — see §3>"
99
99
  \`\`\`
100
- By default \`create\` verifies the plan in a real browser before accepting it. The full
101
- ActionPlan JSON Schema is at
100
+ By default \`create\` proves the plan by replaying it in a browser ON YOUR MACHINE
101
+ (your local \`@playwright/test\` — §4's prerequisites apply) before anything is banked.
102
+ The same is true over the MCP: the replay runs wherever the MCP server runs — your
103
+ computer, never Beryl's — so the §4 install must exist there too.
104
+ A red replay banks nothing: fix the plan file and re-run \`create\`. The green proving
105
+ replay is imported as the test's first run (\`--no-sync\` to skip); \`--no-verify\` banks
106
+ unproven. Inspect the exact spec that would run with \`beryl tests script --file
107
+ plan.json\`. The full ActionPlan JSON Schema is at
102
108
  https://api.beryl.so/api/v1/schemas/action-plan.schema.json.
103
109
 
104
110
  ### The outcome assertion is the whole game
@@ -183,18 +189,31 @@ for a scheduled run.
183
189
 
184
190
  \`\`\`
185
191
  npm i -D @playwright/test && npx playwright install # once
186
- beryl runs local <test-id> --url-override http://localhost:3000 --dir ./beryl-local
192
+ beryl runs local <test-id> --no-sync --url-override http://localhost:3000 --dir ./beryl-local
193
+ beryl runs local # the whole suite, results recorded in Beryl
187
194
  \`\`\`
188
195
 
196
+ - Takes one or more test ids; with none it runs every active test in the project.
197
+ - **Results sync to Beryl by default** — the finished run is imported as a first-class
198
+ run (history, replay, report; trigger source \`local\`). While ITERATING on a draft,
199
+ pass \`--no-sync\` so every fix-loop attempt doesn't land in the project's run history.
189
200
  - \`--url-override\` points the run at a local dev server or a preview deploy.
190
201
  - \`--dir\` keeps the **spec, artifacts, and a JSON \`report.json\`** on disk so you (or your
191
202
  coding agent) can read exactly what happened and iterate: read the report, see which step
192
203
  or assertion failed and why, fix the plan, \`beryl tests set-plan\`, run again.
193
204
  - It exits **0** if every test passed, **1** on a failure — so it drops straight into a
194
205
  run-fix-run loop.
195
- - **v1 is public / unauthenticated flows only.** A test that signs in first runs only in
196
- Beryl's cloud (which holds the encrypted session — it's never handed to your disk);
197
- \`runs local\` refuses it. Run those with \`beryl runs trigger\`.
206
+ - \`await_email\` steps work locally: the CLI mints the run inbox and answers them over
207
+ the API, exactly as the cloud runner would.
208
+ - **Saved-login tests work locally.** A plan that fills \`{{login_email}}\` /
209
+ \`{{login_password}}\` runs fine: the email is baked into the fetched spec and the
210
+ password is revealed once over the logged secret-reveal route, then scrubbed from
211
+ any uploaded error text or DOM snapshot. If the LOGIN_EMAIL variable or
212
+ LOGIN_PASSWORD secret isn't set, the test is skipped with the exact fix-it command.
213
+ - **Captured-session tests stay cloud-only.** A test that signs in with a captured
214
+ browser session runs only in Beryl's cloud (which holds the encrypted session — it's
215
+ never handed to your disk); \`runs local\` skips it with a note. Run those with
216
+ \`beryl runs trigger\`.
198
217
 
199
218
  Once the test passes locally against a real outcome, it's ready to bank and let Beryl run
200
219
  and heal it.
@@ -238,12 +257,49 @@ For a magic-link flow, replace the code steps with
238
257
 
239
258
  Two caveats:
240
259
 
241
- - \`beryl tests create\` verifies an \`await_email\` plan like any other — the replay mints
242
- its own inbox, so the app's mail really is received and extracted before the test is
243
- accepted. (Note the replay signs up / sends mail for real; pass \`--no-verify\` only if
244
- that side effect is unwanted.) \`runs local\` cannot serve \`await_email\` the inbox
245
- lives in Beryl's cloud so iterate on these flows with
246
- \`beryl runs trigger --test <id> --watch\`.
260
+ - \`beryl tests create\` verifies an \`await_email\` plan like any other — its local
261
+ replay mints a fresh run inbox and answers each step over the API, so the app's mail
262
+ really is received and extracted before the test is accepted. (Note the replay signs
263
+ up / sends mail for real; pass \`--no-verify\` only if that side effect is unwanted.)
264
+ \`beryl runs local\` serves \`await_email\` the same way, so the whole local loop covers
265
+ OTP/signup flows end to end.
247
266
  - The outcome assertion discipline from §1 still applies: the green signal is the
248
267
  post-verification state (the welcome screen, the dashboard), not "an email arrived".
268
+
269
+ ## 6. Accounts: minted or saved-login — never captured
270
+
271
+ How a test gets an account is a fixed decision, made at the start of every authoring
272
+ session from \`projects get\`, which reports \`login_email_set\` / \`login_password_set\`.
273
+ Both paths are plain steps in the plan — no captured sessions, no saved browser
274
+ state, no human-in-the-loop login.
275
+
276
+ - **Saved login present (both flags true) → use it.** The project's dedicated test
277
+ account lives in config: the \`LOGIN_EMAIL\` variable + the \`LOGIN_PASSWORD\`
278
+ secret. Author the login as ordinary opening steps citing the reserved handles —
279
+ fill \`{{login_email}}\`, fill \`{{login_password}}\`, submit, assert the logged-in
280
+ shell. NEVER paste the real values into a plan: the handles resolve at run time
281
+ (the password never lands in the rendered spec and is scrubbed from artifacts).
282
+ To drive the real login live while authoring, read the values with
283
+ \`config vars get LOGIN_EMAIL\` and \`config secrets get LOGIN_PASSWORD --reveal\`.
284
+ \`tests create\` still replays the whole flow (locally, on your machine) before
285
+ banking, so a login that doesn't work is rejected with evidence — nothing is
286
+ banked on faith.
287
+ - **Not set → mint.** \`{{inbox_address}}\` is a fresh real mailbox, minted per test,
288
+ per run. Type it into the site's own signup form, \`await_email\` the code or link
289
+ (§5). Fresh every run means nothing expires, nothing rots, no state leaks between
290
+ tests. Do not ask the user for credentials — mint is the default path.
291
+ - **Handed credentials in chat? Bank them first** (\`config vars set LOGIN_EMAIL\`,
292
+ \`config secrets set LOGIN_PASSWORD\`), then author with the handles as above.
293
+ A DEDICATED test account only — never a real user's.
294
+ - **Never hard-code an email address or password in a plan.** Minted inboxes expire
295
+ and pasted values rot on rotation — the handles are the only durable references.
296
+ - **One identity per banked test.** A flow involving a second account — invite a
297
+ teammate and accept as them, share and open as the viewer — is not bankable as one
298
+ test yet: a run has one inbox, and \`await_email\` reads only that inbox. Bank the
299
+ half the app shows to account A ("the invitation is listed as pending", "the share
300
+ link is issued") — a real, strong outcome. While AUTHORING you can still verify the
301
+ full handshake live: mint a second inbox (\`inbox create\`), read its mail
302
+ (\`inbox read\`), drive both sides in the browser — then bank the single-identity
303
+ halves.
304
+ - SSO-only sites (no email+password form at all) remain webapp territory.
249
305
  `;
@@ -112,12 +112,33 @@ export const configCommands = [
112
112
  },
113
113
  {
114
114
  name: "config secrets get",
115
- summary: "Show one secret's metadata (the value is never returned)",
115
+ summary: "Show one secret's metadata, or reveal its value with --reveal",
116
116
  scope: "project",
117
117
  args: [{ name: "key", description: "Secret key or id", required: true }],
118
+ flags: [
119
+ {
120
+ name: "reveal",
121
+ type: "boolean",
122
+ description: "Return the decrypted value — the explicit read that lets the agent drive a " +
123
+ "real login while authoring. Member-gated; every reveal is logged",
124
+ },
125
+ {
126
+ name: "env",
127
+ type: "string",
128
+ description: "With --reveal: prefer the row scoped to this environment id when the key " +
129
+ "exists at both scopes (matches what a run against that environment resolves)",
130
+ },
131
+ ],
118
132
  async run(ctx, input) {
119
133
  const { workspaceId, projectId } = await ctx.requireProject(input);
120
- const rows = (await ctx.client.get(`${configPath(workspaceId, projectId)}/secrets`));
134
+ const base = `${configPath(workspaceId, projectId)}/secrets`;
135
+ if (flagBool(input, "reveal")) {
136
+ const env = flagStr(input, "env");
137
+ return {
138
+ data: await ctx.client.get(`${base}/${encodeURIComponent(arg(input, "key"))}/value`, env ? { environment_id: env } : undefined),
139
+ };
140
+ }
141
+ const rows = (await ctx.client.get(base));
121
142
  return { data: findByKey(rows, arg(input, "key"), "secret") };
122
143
  },
123
144
  },
@@ -1,27 +1,6 @@
1
+ import { extractCode } from "../email-extract.js";
1
2
  import { dim, green } from "../output.js";
2
3
  import { arg, flagBool, flagNum, flagStr } from "./util.js";
3
- // Mirrors the API's own extractor (email_inbox/step_resolver.py): a labelled digit run
4
- // ("your code is 654321") beats the bare fenced pattern, because a real sign-in mail is
5
- // full of innocent 4-8 digit runs — "© 2026", a support number — and the bare fence
6
- // would happily return the first of them.
7
- const CODE_PATTERN = /(?<!\d)(\d{4,8})(?!\d)/;
8
- const LABELLED_CODE_PATTERN = /(?:verification|security|one[\s-]?time|login|sign[\s-]?in|access|confirmation)?\s*(?:code|otp|passcode|pin)\b[^0-9]{0,20}(?<!\d)(\d{4,8})(?!\d)/i;
9
- function visibleText(html) {
10
- return html
11
- .replace(/<(style|script|head)\b[\s\S]*?<\/\1>/gi, " ")
12
- .replace(/<[^>]+>/g, " ");
13
- }
14
- function extractCode(email) {
15
- const body = email.body_text || (email.body_html ? visibleText(email.body_html) : "");
16
- for (const pattern of [LABELLED_CODE_PATTERN, CODE_PATTERN]) {
17
- for (const text of [body, email.subject ?? ""]) {
18
- const match = text.match(pattern);
19
- if (match)
20
- return match[1];
21
- }
22
- }
23
- return null;
24
- }
25
4
  export const inboxCommands = [
26
5
  {
27
6
  name: "inbox create",
@@ -1,6 +1,6 @@
1
1
  import { detectAuthGating } from "../detect.js";
2
2
  import { UsageError } from "../errors.js";
3
- import { dim, green } from "../output.js";
3
+ import { dim, green, table, timeAgo } from "../output.js";
4
4
  import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
5
5
  import { pollCurrentExploration, watchExploration } from "./watch.js";
6
6
  // The API treats the auth choice as the visitor's answer and never infers it — a null
@@ -40,7 +40,23 @@ export const projectCommands = [
40
40
  groupSummary: "Create and manage projects — a site Beryl explores, authors tests for, and runs.",
41
41
  async run(ctx, input) {
42
42
  const ws = await ctx.requireWorkspace(input);
43
- return { data: await ctx.client.get(`/workspaces/${ws}/projects`) };
43
+ const rows = (await ctx.client.get(`/workspaces/${ws}/projects`));
44
+ // Full rows stay in `data` for --json/MCP; the terminal gets the columns a
45
+ // human scans a project list for, with readable ages instead of raw ISO.
46
+ const human = table(rows.map((p) => {
47
+ const last = p.last_execution;
48
+ return {
49
+ name: p.name ?? p.root_url ?? p.id,
50
+ tests: p.test_count ?? 0,
51
+ status: p.status,
52
+ last_run: last
53
+ ? `${last.status} ${last.passed_count ?? 0}/${last.total_tests ?? "?"} · ${timeAgo(last.started_at)}`
54
+ : "",
55
+ updated: timeAgo(p.updated_at ?? p.created_at),
56
+ id: p.id,
57
+ };
58
+ }));
59
+ return { data: rows, human };
44
60
  },
45
61
  },
46
62
  {
@@ -140,9 +156,9 @@ export const projectCommands = [
140
156
  return {
141
157
  data: created,
142
158
  human: `${green("Project created")}: ${created.project_id}\n` +
143
- `The site needs a login before the agent can explore it. Capture one with ` +
144
- `\`beryl credentials capture --project ${created.project_id}\` — the exploration ` +
145
- `starts as soon as you do.`,
159
+ `The site needs a login before the agent can explore it. Capture one in the ` +
160
+ `Beryl webapp (open the project it walks you through the login) — the ` +
161
+ `exploration starts as soon as you do.`,
146
162
  };
147
163
  if (!flagBool(input, "watch"))
148
164
  return { data: created };
@@ -1,9 +1,12 @@
1
1
  import fs from "node:fs";
2
+ import path from "node:path";
2
3
  import { downloadRunArtifacts, failureImages, isFailing, resultsOf, } from "../artifacts.js";
3
4
  import { CliError, UsageError } from "../errors.js";
4
- import { PlaywrightMissingError, runSpecLocally } from "../local-run.js";
5
+ import { buildImportForm, executeLocalSpec, toRunEntry, } from "../local-exec.js";
6
+ import { countPlannedFrames, countWrittenFrames, PlaywrightMissingError, } from "../local-run.js";
5
7
  import { dim, green, red, yellow } from "../output.js";
6
8
  import { confirmInstall, installPlaywright } from "../playwright-install.js";
9
+ import { ProgressBar } from "../progress.js";
7
10
  import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
8
11
  import { watchRun } from "./watch.js";
9
12
  const MAX_FAILURE_SCREENSHOTS = 5;
@@ -70,99 +73,261 @@ export const runCommands = [
70
73
  },
71
74
  {
72
75
  name: "runs local",
73
- summary: "Run a banked test locally with your own Playwright (public flows)",
74
- description: "Unlike `runs trigger`, this runs on YOUR machine, not Beryl's cloud fetches the test's " +
75
- "rendered spec, then runs it with your local @playwright/test. On a terminal it offers to " +
76
- "install @playwright/test + chromium for you the first time they're missing (over MCP it " +
77
- "prints the install commands instead). Point --url-override at a local " +
78
- "dev server or preview, and --dir to keep the spec, artifacts, and JSON report on disk so " +
79
- "an agent can run-fix-run. v1 targets public/unauthenticated flows: an authenticated test " +
80
- "refuses to run locally (those run in Beryl's cloud, which holds the session) no session " +
81
- "is ever decrypted to your disk. Exits 0 if every test passed, 1 on a test failure.",
76
+ summary: "Run tests on your machine with your own Playwright; results sync to Beryl",
77
+ 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 " +
86
+ "(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.",
82
92
  scope: "project",
83
- args: [{ name: "test-id", description: "Test id to run (from `beryl tests list`)", required: true }],
93
+ args: [
94
+ {
95
+ name: "test-ids",
96
+ description: "Test ids to run (from `beryl tests list`); omit to run every active test",
97
+ variadic: true,
98
+ },
99
+ ],
84
100
  flags: [
101
+ {
102
+ name: "all",
103
+ type: "boolean",
104
+ description: "Run every active test in the project (the default when no ids are given)",
105
+ },
85
106
  {
86
107
  name: "url-override",
87
108
  type: "string",
88
109
  description: "Run against this base URL instead of the environment's (e.g. http://localhost:3000)",
89
110
  },
111
+ { name: "env", type: "string", description: "Environment id to attach the imported run to" },
112
+ {
113
+ name: "sync",
114
+ type: "boolean",
115
+ default: true,
116
+ description: "Import the results into Beryl as a run when finished (--no-sync: local only, " +
117
+ "nothing recorded)",
118
+ },
90
119
  {
91
120
  name: "dir",
92
121
  type: "string",
93
- description: "Write the spec, artifacts, and JSON report here (default: a temp dir)",
122
+ description: "Write each test's spec, artifacts, and JSON report under this directory",
94
123
  },
95
124
  ],
96
125
  examples: [
97
- "beryl runs local 4f…",
98
- "beryl runs local 4f… --url-override http://localhost:3000 --dir ./beryl-local",
126
+ "beryl runs local",
127
+ "beryl runs local 4f… 9a…",
128
+ "beryl runs local --url-override http://localhost:3000",
129
+ "beryl runs local 4f… --no-sync --dir ./beryl-local",
99
130
  ],
100
131
  async run(ctx, input) {
101
132
  const { workspaceId, projectId } = await ctx.requireProject(input);
102
- const testId = arg(input, "test-id");
103
- const script = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests/${testId}/script`, { base_url: flagStr(input, "url-override") }));
104
- // v1 is public flows only. A gated test's session lives (encrypted) in the cloud and is
105
- // never handed to a local runner, so refuse rather than run a spec doomed to fail at the
106
- // login wall. Exit 1: it's a failure to run this test locally, not a usage error.
107
- if (script.requires_auth) {
108
- throw new CliError("This test signs in first, so it can only run in Beryl's cloud (which holds the " +
109
- "session) local runs are for public/unauthenticated flows. Run it with " +
110
- "`beryl runs trigger`.");
133
+ // Deduped: a repeated id would put the same test twice in one imported run,
134
+ // which the import manifest rejects.
135
+ const explicitIds = [...new Set(input.args["test-ids"] ?? [])];
136
+ // No ids means the whole suite `beryl runs local` alone is a complete local run.
137
+ const all = explicitIds.length === 0;
138
+ if (explicitIds.length > 0 && flagBool(input, "all"))
139
+ throw new UsageError("--all cannot be combined with explicit test ids");
140
+ const sync = input.flags.sync !== false;
141
+ const urlOverride = flagStr(input, "url-override");
142
+ const dir = flagStr(input, "dir");
143
+ const listed = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests`, {
144
+ environment_id: flagStr(input, "env"),
145
+ }));
146
+ // The list rows carry the authored name as nl_title (title is the customer's
147
+ // rename, usually unset) — fall through so the terminal shows names, not ids.
148
+ const titles = new Map(listed.map((t) => [t.id, t.title || t.nl_title || t.id.slice(0, 8)]));
149
+ const ids = all ? listed.filter((t) => t.is_active !== false).map((t) => t.id) : explicitIds;
150
+ if (ids.length === 0)
151
+ throw new CliError("This project has no tests to run.");
152
+ const specs = [];
153
+ for (const id of ids) {
154
+ let script;
155
+ try {
156
+ script = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests/${id}/script`,
157
+ // frames only matter when the run will be imported: they become the replay.
158
+ // environment_id keeps the baked login_email on the same environment the
159
+ // LOGIN_PASSWORD reveal below is scoped to.
160
+ {
161
+ base_url: urlOverride,
162
+ frames: sync ? true : undefined,
163
+ environment_id: flagStr(input, "env"),
164
+ }));
165
+ }
166
+ catch (err) {
167
+ // With --all an unrenderable test (no plan yet) is a skip, not an abort;
168
+ // an explicitly-requested id failing to render is the caller's problem.
169
+ if (all) {
170
+ const detail = err instanceof Error ? err.message : String(err);
171
+ ctx.err(yellow(`! ${titles.get(id) ?? id}: no runnable script — skipped (${detail})`));
172
+ continue;
173
+ }
174
+ throw err;
175
+ }
176
+ specs.push({
177
+ id,
178
+ title: titles.get(id) ?? id.slice(0, 8),
179
+ content: script.content,
180
+ requiresAuth: Boolean(script.requires_auth),
181
+ usesEmail: /__vmInbox\(|__vmAwaitEmail\(/.test(script.content),
182
+ usesLoginPassword: Boolean(script.uses_login_password),
183
+ ...(script.login_config_error ? { loginConfigError: script.login_config_error } : {}),
184
+ });
111
185
  }
112
- const runOnce = () => runSpecLocally({
113
- spec: script.content,
114
- testName: testId,
115
- dir: flagStr(input, "dir"),
116
- onProgress: (line) => ctx.err(dim(line)),
117
- });
118
- let outcome;
119
- try {
186
+ if (specs.length === 0)
187
+ 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.
192
+ const skipped = specs.filter((s) => s.requiresAuth || s.loginConfigError);
193
+ const runnable = specs.filter((s) => !s.requiresAuth && !s.loginConfigError);
194
+ for (const s of skipped) {
195
+ 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\`).`
198
+ : `! ${s.title}: skipped — ${s.loginConfigError}`));
199
+ }
200
+ if (runnable.length === 0)
201
+ throw new CliError("None of the selected tests can run locally.");
202
+ // Revealed once for the batch over the member-gated, logged route — the same
203
+ // secret the cloud runner banks into run-config.json; never in the spec source.
204
+ let loginPassword;
205
+ if (runnable.some((s) => s.usesLoginPassword)) {
120
206
  try {
121
- outcome = await runOnce();
207
+ const secret = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/config/secrets/LOGIN_PASSWORD/value`, { environment_id: flagStr(input, "env") }));
208
+ loginPassword = secret.value;
122
209
  }
123
210
  catch (err) {
124
- // Local Playwright missing: on a TTY offer to install it and retry, instead of only
125
- // printing a hint the user then has to act on by hand. Non-interactively we can't
126
- // prompt, so we re-throw and the hint surfaces as before (no unprompted install).
127
- if (err instanceof PlaywrightMissingError && ctx.interactive) {
128
- // confirmInstall swallows a prompt failure into `false`, so a broken prompt falls
129
- // back to re-throwing the actionable missing-Playwright hint, not the prompt's error.
130
- if (!(await confirmInstall(ctx.prompt)))
211
+ throw new CliError("Could not reveal the LOGIN_PASSWORD secret for the saved login: " +
212
+ (err instanceof Error ? err.message : String(err)));
213
+ }
214
+ }
215
+ const startedAt = new Date().toISOString();
216
+ const entries = [];
217
+ const bar = new ProgressBar();
218
+ let installOffered = false;
219
+ let done = 0;
220
+ let passed = 0;
221
+ let failed = 0;
222
+ for (const spec of runnable) {
223
+ const stepTotal = countPlannedFrames(spec.content);
224
+ const state = () => ({
225
+ total: runnable.length,
226
+ done,
227
+ passed,
228
+ failed,
229
+ title: spec.title,
230
+ stepTotal,
231
+ });
232
+ bar.update(state());
233
+ if (!bar.active)
234
+ ctx.err(dim(`Running ${spec.title}…`));
235
+ const testStarted = new Date().toISOString();
236
+ let outcome;
237
+ let runError;
238
+ const attempt = () => executeLocalSpec({ client: ctx.client, workspaceId, projectId }, {
239
+ spec,
240
+ dir: dir ? path.join(dir, spec.id) : undefined,
241
+ harvest: sync,
242
+ loginPassword,
243
+ onEvent: (line) => ctx.err(dim(line)),
244
+ onSpawn: (runDir) => {
245
+ const ticker = setInterval(() => bar.update({ ...state(), step: countWrittenFrames(runDir) }), 300);
246
+ return () => clearInterval(ticker);
247
+ },
248
+ });
249
+ try {
250
+ try {
251
+ ({ outcome, runError } = await attempt());
252
+ }
253
+ catch (err) {
254
+ // Local Playwright missing: on a TTY offer to install it once and retry,
255
+ // instead of only printing a hint the user then has to act on by hand.
256
+ if (err instanceof PlaywrightMissingError && ctx.interactive && !installOffered) {
257
+ installOffered = true;
258
+ bar.clear();
259
+ if (!(await confirmInstall(ctx.prompt)))
260
+ throw err;
261
+ try {
262
+ await installPlaywright(process.cwd(), (line) => ctx.err(dim(line)));
263
+ }
264
+ catch (installErr) {
265
+ throw new CliError(installErr instanceof Error ? installErr.message : String(installErr));
266
+ }
267
+ ctx.err(green("✓ Local Playwright installed — running the test…"));
268
+ ({ outcome, runError } = await attempt());
269
+ }
270
+ else {
131
271
  throw err;
132
- await installPlaywright(process.cwd(), (line) => ctx.err(dim(line)));
133
- ctx.err(green("✓ Local Playwright installed — running the test…"));
134
- outcome = await runOnce();
272
+ }
135
273
  }
136
- else {
137
- throw err;
274
+ }
275
+ catch (err) {
276
+ // Environmental failures abort the batch — nothing later would fare better.
277
+ if (err instanceof PlaywrightMissingError || err instanceof CliError) {
278
+ bar.clear();
279
+ throw err instanceof CliError ? err : new CliError(err.message);
138
280
  }
281
+ // The spec never ran and the executor could not classify it — an errored
282
+ // result, not an aborted batch: the remaining tests still deserve their run.
283
+ runError = err instanceof Error ? err.message : String(err);
139
284
  }
285
+ const entry = toRunEntry(spec, outcome, runError, testStarted, entries.length, spec.usesLoginPassword ? loginPassword : undefined);
286
+ entries.push(entry);
287
+ done += 1;
288
+ if (entry.status === "passed")
289
+ passed += 1;
290
+ else
291
+ failed += 1;
292
+ bar.clear();
293
+ ctx.err(`${entry.status === "passed" ? green("✓") : red("✗")} ${spec.title}` +
294
+ `${entry.duration_ms ? dim(` (${(entry.duration_ms / 1000).toFixed(1)}s)`) : ""}` +
295
+ `${entry.error_message ? `\n ${dim(entry.error_message.split("\n")[0] ?? "")}` : ""}`);
140
296
  }
141
- catch (err) {
142
- // Every failure to run the spec (missing Playwright, a compile error, a customer
143
- // config filtering our spec away) already carries an actionable message — surface it
144
- // as a CliError so the user sees that, not a raw Node stack trace.
145
- throw new CliError(err instanceof Error ? err.message : String(err));
297
+ bar.clear();
298
+ let runId;
299
+ if (sync && entries.length > 0) {
300
+ ctx.err(dim("Importing results into Beryl…"));
301
+ const imported = (await ctx.client.request("POST", `${projectPath(workspaceId, projectId)}/runs/import`, {
302
+ form: buildImportForm(entries, {
303
+ environmentId: flagStr(input, "env"),
304
+ targetUrlOverride: urlOverride,
305
+ startedAt,
306
+ completedAt: new Date().toISOString(),
307
+ onNote: (line) => ctx.err(yellow(`! ${line}`)),
308
+ }),
309
+ }));
310
+ runId = imported.id;
146
311
  }
147
- const summary = { passed: outcome.passed, failed: outcome.failed, results: outcome.results };
148
- const marker = (status) => status === "passed" || status === "expected"
149
- ? green("✓")
150
- : status === "skipped"
151
- ? dim("")
152
- : red("");
153
- const lines = outcome.results.map((r) => ` ${marker(r.status)} ${r.name}${r.error ? `\n ${dim(r.error.split("\n")[0] ?? "")}` : ""}`);
154
- const human = `${outcome.failed === 0 ? green("All tests passed") : red(`${outcome.failed} test(s) failed`)} ` +
155
- `(${outcome.passed} passed, ${outcome.failed} failed)\n${lines.join("\n")}` +
156
- (flagStr(input, "dir") ? `\n${dim(`Spec + artifacts + report in ${outcome.directory}`)}` : "");
157
- const persisted = Boolean(flagStr(input, "dir"));
312
+ const summaryLine = failed === 0
313
+ ? green(`All tests passed (${passed}/${entries.length})`)
314
+ : red(`${failed} test(s) failed`) + ` (${passed} passed)`;
315
+ const human = `${summaryLine}` +
316
+ (skipped.length ? `\n${yellow(`${skipped.length} skipped (see notes above)`)}` : "") +
317
+ (runId ? `\nSynced to Beryl as run ${runId} — \`beryl runs get ${runId}\`` : "") +
318
+ (!sync ? `\n${dim("--no-sync: nothing recorded in Beryl")}` : "") +
319
+ (dir ? `\n${dim(`Specs + artifacts + reports under ${path.resolve(dir)}`)}` : "");
158
320
  return {
159
- // Only surface on-disk paths when --dir kept them; without it the run dir is deleted.
160
- data: persisted
161
- ? { ...summary, directory: outcome.directory, report: outcome.report }
162
- : summary,
321
+ data: {
322
+ passed,
323
+ failed,
324
+ skipped: skipped.map((s) => s.id),
325
+ results: entries.map(({ files: _files, ...rest }) => rest),
326
+ ...(runId ? { run_id: runId } : {}),
327
+ },
163
328
  human,
164
- // Exit codes are a CI contract: any failing test → exit 1.
165
- ...(outcome.failed > 0 ? { exitCode: 1 } : {}),
329
+ // Exit codes are a CI contract: any failing/errored test → exit 1.
330
+ ...(failed > 0 ? { exitCode: 1 } : {}),
166
331
  };
167
332
  },
168
333
  },