@beryl-so/cli 0.11.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.
@@ -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
  },