@beryl-so/cli 0.36.0 → 0.38.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
@@ -258,7 +258,7 @@ Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and downlo
258
258
  | `beryl runs local [test-ids...]` | Run tests on your machine with Playwright; results sync to Beryl | `runs_local` |
259
259
  | `beryl runs list` | List recent runs | `runs_list` |
260
260
  | `beryl runs get <run-id>` | Show one run with its per-test results | `runs_get` |
261
- | `beryl runs watch <run-id>` | Attach to a run and stream progress until it finishes | `runs_watch` |
261
+ | `beryl runs watch <run-id>` | Follow a run until it finishes | `runs_watch` |
262
262
  | `beryl runs cancel <run-id>` | Cancel an in-flight run | `runs_cancel` |
263
263
  | `beryl runs report <run-id>` | Show the generated report for a run | `runs_report` |
264
264
  | `beryl runs download <run-id>` | Download a run's results, with its artifacts, to disk | `runs_download` |
@@ -49,7 +49,7 @@ export const runCommands = [
49
49
  {
50
50
  name: "runs trigger",
51
51
  summary: "Trigger a test run (whole suite, a subset, or one environment)",
52
- description: "Runs execute in Beryl's cloud. With --watch the CLI streams live progress and " +
52
+ description: "Runs execute in Beryl's cloud. With --watch the CLI follows live progress and " +
53
53
  "exits 0 only if every test passed, so wire it straight into CI. A run with a " +
54
54
  "heal-eligible failure completes only after Beryl has tried to heal it: a repaired " +
55
55
  "test is re-run inside the same run and counts as passed (reported as `healed`), so " +
@@ -59,9 +59,10 @@ export const runCommands = [
59
59
  { name: "test", type: "strings", description: "Run only these test ids (repeatable)" },
60
60
  {
61
61
  name: "group",
62
- type: "string",
63
- description: "Run only the active tests in this group (by name), resolved to ids before the run, " +
64
- "so the run records exactly what it ran. Cannot be combined with --test.",
62
+ type: "strings",
63
+ description: "Run only the active tests in these groups (by name, repeatable, union of members), " +
64
+ "resolved to ids before the run so the run records exactly what it ran. " +
65
+ "Cannot be combined with --test.",
65
66
  },
66
67
  { name: "env", type: "string", description: "Environment id to run against" },
67
68
  { name: "url-override", type: "string", description: "Replace the base URL (preview deploys)" },
@@ -71,7 +72,7 @@ export const runCommands = [
71
72
  description: "Send a custom request header on every navigation, KEY=VALUE (repeatable). " +
72
73
  "Reaches auth-walled preview deploys, e.g. --header x-vercel-protection-bypass=<token>",
73
74
  },
74
- { name: "watch", type: "boolean", description: "Stream progress and exit non-zero on failure" },
75
+ { name: "watch", type: "boolean", description: "Follow progress and exit non-zero on failure" },
75
76
  { name: "timeout", type: "number", description: "With --watch: max minutes to wait" },
76
77
  {
77
78
  name: "retries",
@@ -84,21 +85,28 @@ export const runCommands = [
84
85
  "beryl runs trigger --url-override https://preview-123.example.com --watch --timeout 30",
85
86
  "beryl runs trigger --url-override https://preview-123.example.com --header x-vercel-protection-bypass=<token> --watch",
86
87
  "beryl runs trigger --test 4f… --test 9a…",
87
- "beryl runs trigger --group Smoke --watch",
88
+ "beryl runs trigger --group Smoke --group Checkout --watch",
88
89
  "beryl runs trigger --retries 0 --watch",
89
90
  ],
90
91
  async run(ctx, input) {
91
92
  const { workspaceId, projectId } = await ctx.requireProject(input);
92
93
  let tests = input.flags.test;
93
- const group = flagStr(input, "group");
94
- if (group && tests && tests.length > 0)
94
+ const groups = input.flags.group;
95
+ if (groups && groups.length > 0 && tests && tests.length > 0)
95
96
  throw new UsageError("--group cannot be combined with --test");
96
- if (group) {
97
+ if (groups && groups.length > 0) {
97
98
  const listed = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests`));
98
- tests = idsInGroup(listed, group);
99
- // Exiting 0 here would be a green CI run that tested nothing.
100
- if (tests.length === 0)
101
- throw new UsageError(`No active tests in group '${group}'.`);
99
+ const union = new Set();
100
+ for (const group of groups) {
101
+ const ids = idsInGroup(listed, group);
102
+ // Exiting 0 here would be a green CI run that tested nothing (or a typo'd
103
+ // group silently narrowing the run).
104
+ if (ids.length === 0)
105
+ throw new UsageError(`No active tests in group '${group}'.`);
106
+ for (const id of ids)
107
+ union.add(id);
108
+ }
109
+ tests = [...union];
102
110
  }
103
111
  const extraHeaders = parseHeaders(input.flags.header);
104
112
  const created = (await ctx.client.post(`${projectPath(workspaceId, projectId)}/runs`, {
@@ -563,7 +571,7 @@ export const runCommands = [
563
571
  },
564
572
  {
565
573
  name: "runs watch",
566
- summary: "Attach to a run and stream progress until it finishes",
574
+ summary: "Follow a run until it finishes",
567
575
  description: "Replays what already happened, then follows live. Exits 0 only if every test passed.",
568
576
  scope: "project",
569
577
  args: [{ name: "run-id", description: "Run id", required: true }],
@@ -1,70 +1,137 @@
1
1
  import { CliError } from "../errors.js";
2
- import { dim, green, red, statusColor, yellow } from "../output.js";
3
2
  import { sseStream } from "../sse.js";
3
+ import { dim, green, red, statusColor, yellow } from "../output.js";
4
4
  import { projectPath } from "./util.js";
5
5
  function withTimeout(minutes) {
6
6
  if (!minutes)
7
7
  return undefined;
8
8
  return AbortSignal.timeout(minutes * 60_000);
9
9
  }
10
+ const PROGRESS_WAIT_S = 25;
11
+ const HEARTBEAT_MS = 180_000;
12
+ const SETTLED = new Set(["passed", "failed", "errored", "cancelled"]);
13
+ const TERMINAL_RUN = new Set(["completed", "failed", "cancelled"]);
14
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
15
+ function sleep(ms) {
16
+ return new Promise((r) => setTimeout(r, ms));
17
+ }
18
+ function settledCount(c) {
19
+ return ((c.passed ?? 0) +
20
+ (c.failed ?? 0) +
21
+ (c.errored ?? 0) +
22
+ (c.cancelled ?? 0) +
23
+ (c.quarantined ?? 0));
24
+ }
25
+ // Watching a cloud run is a poll, not a stream: each request parks server-side
26
+ // for up to PROGRESS_WAIT_S and returns when a test settles, so a dropped
27
+ // request or an api deploy costs one retry instead of the whole watch.
10
28
  export async function watchRun(ctx, ws, project, runId, timeoutMinutes) {
11
- const signal = withTimeout(timeoutMinutes);
12
- let finalStatus = "unknown";
13
- let counters = {};
14
- const seen = new Set();
15
- for await (const ev of sseStream(ctx.client, `${projectPath(ws, project)}/runs/${runId}/stream`, signal)) {
16
- if (ctx.json) {
17
- ctx.out(JSON.stringify(ev));
29
+ const path = projectPath(ws, project);
30
+ const deadline = timeoutMinutes ? Date.now() + timeoutMinutes * 60_000 : undefined;
31
+ const tty = Boolean(process.stderr.isTTY) && !ctx.json;
32
+ const printed = new Set();
33
+ let since = 0;
34
+ let backoffMs = 2_000;
35
+ let lastHeartbeat = Date.now();
36
+ let spin = 0;
37
+ let run = null;
38
+ const clearStatusLine = () => {
39
+ if (tty)
40
+ process.stderr.write("\r\u001b[2K");
41
+ };
42
+ const printRow = (r) => {
43
+ const ok = r.status === "passed";
44
+ // A quarantined red still prints (the result is recorded and visible)
45
+ // but marked as muted so it doesn't read as a build-breaking failure.
46
+ const muted = Boolean(r.quarantined) && !ok;
47
+ const mark = ok ? green("✓") : muted ? yellow("⚠") : red("✗");
48
+ const label = muted ? yellow(`${r.status} (quarantined)`) : statusColor(r.status);
49
+ const duration = r.duration_ms ? dim(` ${Math.round(r.duration_ms / 1000)}s`) : "";
50
+ const errorText = r.error_message ? ` ${String(r.error_message).slice(0, 120)}` : "";
51
+ const error = errorText ? (muted ? dim(errorText) : red(errorText)) : "";
52
+ ctx.err(`${mark} ${label} ${dim(r.title || r.id)}${duration}${error}`);
53
+ };
54
+ if (!ctx.json)
55
+ ctx.err(dim(`watching run ${runId}`));
56
+ let lastCounters = {};
57
+ while (true) {
58
+ if (deadline && Date.now() > deadline) {
59
+ clearStatusLine();
60
+ throw new CliError(`Timed out after ${timeoutMinutes} minutes`, 1);
18
61
  }
19
- if (ev.counters)
20
- counters = ev.counters;
21
- const kind = ev.event;
22
- if (!ctx.json) {
23
- if (kind === "run_started") {
24
- ctx.err(dim(`run ${runId} started`));
62
+ let progress;
63
+ try {
64
+ progress = (await ctx.client.get(`${path}/runs/${runId}/progress?since_version=${since}&wait=${PROGRESS_WAIT_S}`));
65
+ backoffMs = 2_000;
66
+ }
67
+ catch (e) {
68
+ clearStatusLine();
69
+ if (!ctx.json)
70
+ ctx.err(dim(`watch request failed (${e.message}); retrying`));
71
+ await sleep(backoffMs);
72
+ backoffMs = Math.min(backoffMs * 2, 30_000);
73
+ continue;
74
+ }
75
+ lastCounters = progress.counters ?? {};
76
+ const version = Number(progress.version ?? 0);
77
+ const changed = version !== since || TERMINAL_RUN.has(progress.status);
78
+ since = version;
79
+ if (changed) {
80
+ try {
81
+ run = (await ctx.client.get(`${path}/runs/${runId}`));
82
+ backoffMs = 2_000;
25
83
  }
26
- else if (kind === "test_retrying") {
27
- ctx.err(yellow(`↻ retrying ${dim(String(ev.test_result_id))} (attempt ${ev.attempt_no} failed)`));
84
+ catch {
85
+ // Same backoff as the poll: a terminal run answers the next poll
86
+ // instantly, so retrying without a delay would busy-loop both endpoints.
87
+ await sleep(backoffMs);
88
+ backoffMs = Math.min(backoffMs * 2, 30_000);
89
+ continue;
28
90
  }
29
- else if (kind === "test_completed") {
30
- const key = `${ev.test_result_id}:${ev.status}`;
31
- if (!seen.has(key)) {
32
- seen.add(key);
33
- const ok = ev.status === "passed";
34
- // A quarantined red still prints — the result is recorded and visible —
35
- // but marked as muted so it doesn't read as a build-breaking failure.
36
- const muted = Boolean(ev.quarantined) && !ok;
37
- const mark = ok ? green("✓") : muted ? yellow("⚠") : red("✗");
38
- const label = muted
39
- ? yellow(`${String(ev.status)} (quarantined)`)
40
- : statusColor(String(ev.status));
41
- const duration = ev.duration_ms ? dim(` ${Math.round(Number(ev.duration_ms) / 1000)}s`) : "";
42
- const errorText = ev.error_message ? ` — ${String(ev.error_message).slice(0, 120)}` : "";
43
- const error = errorText ? (muted ? dim(errorText) : red(errorText)) : "";
44
- // A pass that took a retry is still a pass, but say so — a flake absorbed in
45
- // silence is how a suite that gates deploys stops being trusted.
46
- const flaky = ev.flaky ? yellow(` flaky (passed on attempt ${ev.attempt_no})`) : "";
47
- ctx.err(`${mark} ${label} ${dim(String(ev.test_result_id))}${duration}${flaky}${error}`);
91
+ for (const r of run?.test_results ?? []) {
92
+ if (SETTLED.has(r.status) && !printed.has(r.id)) {
93
+ printed.add(r.id);
94
+ clearStatusLine();
95
+ if (ctx.json)
96
+ ctx.out(JSON.stringify({ event: "test_completed", ...r }));
97
+ else
98
+ printRow(r);
48
99
  }
49
100
  }
101
+ if (run && TERMINAL_RUN.has(run.status))
102
+ break;
50
103
  }
51
- if (kind === "run_completed" || kind === "run_cancelled") {
52
- finalStatus = ev.status ?? kind;
53
- break;
104
+ const done = settledCount(lastCounters);
105
+ const total = lastCounters.total ?? "?";
106
+ if (tty) {
107
+ spin = (spin + 1) % SPINNER.length;
108
+ const reds = (lastCounters.failed ?? 0) + (lastCounters.errored ?? 0);
109
+ process.stderr.write(`\r\u001b[2K${SPINNER[spin]} running ${done}/${total} · ` +
110
+ `${green(`${lastCounters.passed ?? 0}✓`)} ${reds > 0 ? red(`${reds}✗`) : dim("0✗")}`);
54
111
  }
55
- if (kind === "error") {
56
- finalStatus = "error";
57
- break;
112
+ else if (!ctx.json && Date.now() - lastHeartbeat > HEARTBEAT_MS) {
113
+ lastHeartbeat = Date.now();
114
+ ctx.err(dim(`still running: ${done}/${total} settled`));
58
115
  }
59
116
  }
60
- if (signal?.aborted)
61
- throw new CliError(`Timed out after ${timeoutMinutes} minutes`, 1);
117
+ clearStatusLine();
118
+ const counters = {
119
+ passed: run?.passed_count ?? 0,
120
+ failed: run?.failed_count ?? 0,
121
+ errored: run?.errored_count ?? 0,
122
+ cancelled: run?.cancelled_count ?? 0,
123
+ quarantined: run?.quarantined_count ?? 0,
124
+ total: run?.total_tests ?? settledCount(lastCounters),
125
+ flaky: run?.summary?.flaky ?? 0,
126
+ healed: run?.summary?.healed ?? 0,
127
+ };
128
+ const finalStatus = run?.status ?? "unknown";
62
129
  // Quarantined reds are excluded from `failed` by the API (they land in their own
63
- // counter), so they never reach this sum and never flip the exit code but they
130
+ // counter), so they never reach this sum and never flip the exit code, but they
64
131
  // are always reported, so a green build never silently hides a muted failure.
65
132
  const failed = (counters.failed ?? 0) + (counters.errored ?? 0);
66
133
  const passed = counters.passed ?? 0;
67
- // A flaky pass likewise exits 0 absorbing a transient blip is the point but it
134
+ // A flaky pass likewise exits 0 (absorbing a transient blip is the point) but it
68
135
  // is reported too, so a suite that only stays green by retrying can't hide it.
69
136
  const extra = (counters.cancelled ? yellow(`, ${counters.cancelled} cancelled`) : "") +
70
137
  (counters.quarantined ? yellow(`, ${counters.quarantined} quarantined`) : "") +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.36.0",
3
+ "version": "0.38.0",
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",