@beryl-so/cli 0.37.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 +1 -1
- package/dist/commands/runs.js +3 -3
- package/dist/commands/watch.js +112 -45
- package/package.json +1 -1
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>` |
|
|
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` |
|
package/dist/commands/runs.js
CHANGED
|
@@ -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
|
|
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 " +
|
|
@@ -72,7 +72,7 @@ export const runCommands = [
|
|
|
72
72
|
description: "Send a custom request header on every navigation, KEY=VALUE (repeatable). " +
|
|
73
73
|
"Reaches auth-walled preview deploys, e.g. --header x-vercel-protection-bypass=<token>",
|
|
74
74
|
},
|
|
75
|
-
{ name: "watch", type: "boolean", description: "
|
|
75
|
+
{ name: "watch", type: "boolean", description: "Follow progress and exit non-zero on failure" },
|
|
76
76
|
{ name: "timeout", type: "number", description: "With --watch: max minutes to wait" },
|
|
77
77
|
{
|
|
78
78
|
name: "retries",
|
|
@@ -571,7 +571,7 @@ export const runCommands = [
|
|
|
571
571
|
},
|
|
572
572
|
{
|
|
573
573
|
name: "runs watch",
|
|
574
|
-
summary: "
|
|
574
|
+
summary: "Follow a run until it finishes",
|
|
575
575
|
description: "Replays what already happened, then follows live. Exits 0 only if every test passed.",
|
|
576
576
|
scope: "project",
|
|
577
577
|
args: [{ name: "run-id", description: "Run id", required: true }],
|
package/dist/commands/watch.js
CHANGED
|
@@ -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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 (
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
|
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
|
|
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