@beryl-so/cli 0.37.0 → 0.40.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 +6 -3
- package/dist/commands/account.js +40 -7
- package/dist/commands/runs.js +26 -6
- package/dist/commands/watch.js +206 -47
- 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` |
|
|
@@ -359,11 +359,14 @@ Send product feedback to the Beryl team.
|
|
|
359
359
|
|
|
360
360
|
### billing
|
|
361
361
|
|
|
362
|
-
Review a workspace's plan usage, subscription, and invoices.
|
|
362
|
+
Review a workspace's plan usage, subscription, and invoices; upgrade or switch plans.
|
|
363
363
|
|
|
364
364
|
| Command | Summary | MCP tool |
|
|
365
365
|
| --- | --- | --- |
|
|
366
|
-
| `beryl billing
|
|
366
|
+
| `beryl billing plans` | List the plans: price, cloud run minutes, storage, overage terms | none |
|
|
367
|
+
| `beryl billing usage` | Show plan usage: cloud run minutes, storage held, and this month's overage | none |
|
|
368
|
+
| `beryl billing checkout` | Get a Stripe Checkout link that moves a Free workspace onto Team or Business | none |
|
|
369
|
+
| `beryl billing plan` | Switch a paid workspace between Team and Business in place (prorated) | none |
|
|
367
370
|
| `beryl billing subscription` | Show the workspace's subscription | none |
|
|
368
371
|
| `beryl billing invoices` | List recent invoices | none |
|
|
369
372
|
| `beryl billing portal` | Get a Stripe billing-portal link for the workspace | none |
|
package/dist/commands/account.js
CHANGED
|
@@ -43,16 +43,53 @@ export const accountCommands = [
|
|
|
43
43
|
return { data: await ctx.client.post("/feedback", { message: arg(input, "message") }) };
|
|
44
44
|
},
|
|
45
45
|
},
|
|
46
|
+
{
|
|
47
|
+
name: "billing plans",
|
|
48
|
+
summary: "List the plans: price, cloud run minutes, storage, overage terms",
|
|
49
|
+
groupSummary: "Review a workspace's plan usage, subscription, and invoices; upgrade or switch plans.",
|
|
50
|
+
async run(ctx) {
|
|
51
|
+
return { data: await ctx.client.get("/billing/plans") };
|
|
52
|
+
},
|
|
53
|
+
},
|
|
46
54
|
{
|
|
47
55
|
name: "billing usage",
|
|
48
|
-
summary: "Show plan usage:
|
|
56
|
+
summary: "Show plan usage: cloud run minutes, storage held, and this month's overage",
|
|
49
57
|
scope: "workspace",
|
|
50
|
-
groupSummary: "Review a workspace's plan usage, subscription, and invoices.",
|
|
51
58
|
async run(ctx, input) {
|
|
52
59
|
const ws = await ctx.requireWorkspace(input);
|
|
53
60
|
return { data: await ctx.client.get(`/workspaces/${ws}/billing/usage`) };
|
|
54
61
|
},
|
|
55
62
|
},
|
|
63
|
+
{
|
|
64
|
+
name: "billing checkout",
|
|
65
|
+
summary: "Get a Stripe Checkout link that moves a Free workspace onto Team or Business",
|
|
66
|
+
scope: "workspace",
|
|
67
|
+
flags: [
|
|
68
|
+
{ name: "plan", type: "string", description: "team or business", required: true },
|
|
69
|
+
],
|
|
70
|
+
async run(ctx, input) {
|
|
71
|
+
const ws = await ctx.requireWorkspace(input);
|
|
72
|
+
const plan = flagStr(input, "plan");
|
|
73
|
+
if (plan !== "team" && plan !== "business")
|
|
74
|
+
throw new UsageError("--plan must be team or business");
|
|
75
|
+
return { data: await ctx.client.post(`/workspaces/${ws}/billing/checkout`, { plan }) };
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: "billing plan",
|
|
80
|
+
summary: "Switch a paid workspace between Team and Business in place (prorated)",
|
|
81
|
+
scope: "workspace",
|
|
82
|
+
flags: [
|
|
83
|
+
{ name: "plan", type: "string", description: "team or business", required: true },
|
|
84
|
+
],
|
|
85
|
+
async run(ctx, input) {
|
|
86
|
+
const ws = await ctx.requireWorkspace(input);
|
|
87
|
+
const plan = flagStr(input, "plan");
|
|
88
|
+
if (plan !== "team" && plan !== "business")
|
|
89
|
+
throw new UsageError("--plan must be team or business");
|
|
90
|
+
return { data: await ctx.client.post(`/workspaces/${ws}/billing/plan`, { plan }) };
|
|
91
|
+
},
|
|
92
|
+
},
|
|
56
93
|
{
|
|
57
94
|
name: "billing subscription",
|
|
58
95
|
summary: "Show the workspace's subscription",
|
|
@@ -82,11 +119,7 @@ export const accountCommands = [
|
|
|
82
119
|
scope: "workspace",
|
|
83
120
|
async run(ctx, input) {
|
|
84
121
|
const ws = await ctx.requireWorkspace(input);
|
|
85
|
-
return {
|
|
86
|
-
data: await ctx.client.post(`/workspaces/${ws}/billing/portal`, {
|
|
87
|
-
return_url: "https://beryl.so/settings/billing",
|
|
88
|
-
}),
|
|
89
|
-
};
|
|
122
|
+
return { data: await ctx.client.post(`/workspaces/${ws}/billing/portal`, {}) };
|
|
90
123
|
},
|
|
91
124
|
},
|
|
92
125
|
];
|
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,8 +72,14 @@ 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
|
+
{
|
|
78
|
+
name: "fail-fast",
|
|
79
|
+
type: "boolean",
|
|
80
|
+
description: "With --watch: cancel the run and exit 1 on the first failing test, instead of " +
|
|
81
|
+
"waiting for the rest of the suite. Built for CI gates; off by default",
|
|
82
|
+
},
|
|
77
83
|
{
|
|
78
84
|
name: "retries",
|
|
79
85
|
type: "number",
|
|
@@ -118,7 +124,10 @@ export const runCommands = [
|
|
|
118
124
|
}));
|
|
119
125
|
if (!flagBool(input, "watch"))
|
|
120
126
|
return { data: created };
|
|
121
|
-
return await watchRun(ctx, workspaceId, projectId, created.id, flagNum(input, "timeout")
|
|
127
|
+
return await watchRun(ctx, workspaceId, projectId, created.id, flagNum(input, "timeout"), {
|
|
128
|
+
failFast: flagBool(input, "fail-fast"),
|
|
129
|
+
ci: process.env.GITHUB_ACTIONS === "true",
|
|
130
|
+
});
|
|
122
131
|
},
|
|
123
132
|
},
|
|
124
133
|
{
|
|
@@ -571,14 +580,25 @@ export const runCommands = [
|
|
|
571
580
|
},
|
|
572
581
|
{
|
|
573
582
|
name: "runs watch",
|
|
574
|
-
summary: "
|
|
583
|
+
summary: "Follow a run until it finishes",
|
|
575
584
|
description: "Replays what already happened, then follows live. Exits 0 only if every test passed.",
|
|
576
585
|
scope: "project",
|
|
577
586
|
args: [{ name: "run-id", description: "Run id", required: true }],
|
|
578
|
-
flags: [
|
|
587
|
+
flags: [
|
|
588
|
+
{ name: "timeout", type: "number", description: "Max minutes to wait" },
|
|
589
|
+
{
|
|
590
|
+
name: "fail-fast",
|
|
591
|
+
type: "boolean",
|
|
592
|
+
description: "Cancel the run and exit 1 on the first failing test, instead of waiting for " +
|
|
593
|
+
"the rest of the suite. Built for CI gates; off by default",
|
|
594
|
+
},
|
|
595
|
+
],
|
|
579
596
|
async run(ctx, input) {
|
|
580
597
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
581
|
-
return await watchRun(ctx, workspaceId, projectId, arg(input, "run-id"), flagNum(input, "timeout")
|
|
598
|
+
return await watchRun(ctx, workspaceId, projectId, arg(input, "run-id"), flagNum(input, "timeout"), {
|
|
599
|
+
failFast: flagBool(input, "fail-fast"),
|
|
600
|
+
ci: process.env.GITHUB_ACTIONS === "true",
|
|
601
|
+
});
|
|
582
602
|
},
|
|
583
603
|
},
|
|
584
604
|
{
|
package/dist/commands/watch.js
CHANGED
|
@@ -1,70 +1,193 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import { CliError } from "../errors.js";
|
|
2
|
-
import { dim, green, red, statusColor, yellow } from "../output.js";
|
|
3
3
|
import { sseStream } from "../sse.js";
|
|
4
|
+
import { dim, green, red, statusColor, yellow } from "../output.js";
|
|
4
5
|
import { projectPath } from "./util.js";
|
|
5
6
|
function withTimeout(minutes) {
|
|
6
7
|
if (!minutes)
|
|
7
8
|
return undefined;
|
|
8
9
|
return AbortSignal.timeout(minutes * 60_000);
|
|
9
10
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
const PROGRESS_WAIT_S = 25;
|
|
12
|
+
const HEARTBEAT_MS = 180_000;
|
|
13
|
+
const SETTLED = new Set(["passed", "failed", "errored", "cancelled"]);
|
|
14
|
+
const FAILING = new Set(["failed", "errored"]);
|
|
15
|
+
const TERMINAL_RUN = new Set(["completed", "failed", "cancelled"]);
|
|
16
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
17
|
+
const CI_ERROR_MAX_LEN = 4_000;
|
|
18
|
+
function runPageUrl(apiUrl, projectId, runId) {
|
|
19
|
+
if (!apiUrl)
|
|
20
|
+
return null;
|
|
21
|
+
let host;
|
|
22
|
+
try {
|
|
23
|
+
host = new URL(apiUrl).hostname;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const webapp = host === "api.beryl.so"
|
|
29
|
+
? "https://beryl.so"
|
|
30
|
+
: host === "dev.beryl.so"
|
|
31
|
+
? "https://preview.beryl.so"
|
|
32
|
+
: null;
|
|
33
|
+
return webapp ? `${webapp}/projects/${projectId}/runs/${runId}` : null;
|
|
34
|
+
}
|
|
35
|
+
const escapeAnnotationData = (s) => s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
36
|
+
const escapeAnnotationProp = (s) => escapeAnnotationData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
|
|
37
|
+
function sleep(ms) {
|
|
38
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
39
|
+
}
|
|
40
|
+
function settledCount(c) {
|
|
41
|
+
return ((c.passed ?? 0) +
|
|
42
|
+
(c.failed ?? 0) +
|
|
43
|
+
(c.errored ?? 0) +
|
|
44
|
+
(c.cancelled ?? 0) +
|
|
45
|
+
(c.quarantined ?? 0));
|
|
46
|
+
}
|
|
47
|
+
// Watching a cloud run is a poll, not a stream: each request parks server-side
|
|
48
|
+
// for up to PROGRESS_WAIT_S and returns when a test settles, so a dropped
|
|
49
|
+
// request or an api deploy costs one retry instead of the whole watch.
|
|
50
|
+
export async function watchRun(ctx, ws, project, runId, timeoutMinutes, opts = {}) {
|
|
51
|
+
const path = projectPath(ws, project);
|
|
52
|
+
const deadline = timeoutMinutes ? Date.now() + timeoutMinutes * 60_000 : undefined;
|
|
53
|
+
const tty = Boolean(process.stderr.isTTY) && !ctx.json;
|
|
54
|
+
const ci = Boolean(opts.ci) && !ctx.json;
|
|
55
|
+
const printed = new Set();
|
|
56
|
+
let since = 0;
|
|
57
|
+
let backoffMs = 2_000;
|
|
58
|
+
let lastHeartbeat = Date.now();
|
|
59
|
+
let spin = 0;
|
|
60
|
+
let run = null;
|
|
61
|
+
let knownTotal = null;
|
|
62
|
+
const clearStatusLine = () => {
|
|
63
|
+
if (tty)
|
|
64
|
+
process.stderr.write("\r\u001b[2K");
|
|
65
|
+
};
|
|
66
|
+
const printRow = (r) => {
|
|
67
|
+
const ok = r.status === "passed";
|
|
68
|
+
// A quarantined red still prints (the result is recorded and visible)
|
|
69
|
+
// but marked as muted so it doesn't read as a build-breaking failure.
|
|
70
|
+
const muted = Boolean(r.quarantined) && !ok;
|
|
71
|
+
const mark = ok ? green("✓") : muted ? yellow("⚠") : red("✗");
|
|
72
|
+
const label = muted ? yellow(`${r.status} (quarantined)`) : statusColor(r.status);
|
|
73
|
+
const duration = r.duration_ms ? dim(` ${Math.round(r.duration_ms / 1000)}s`) : "";
|
|
74
|
+
const seq = ci ? dim(`${printed.size}/${knownTotal ?? "?"} `) : "";
|
|
75
|
+
const fullError = ci && !ok && !muted;
|
|
76
|
+
const errorText = r.error_message && !fullError ? ` ${String(r.error_message).slice(0, 120)}` : "";
|
|
77
|
+
const error = errorText ? (muted ? dim(errorText) : red(errorText)) : "";
|
|
78
|
+
ctx.err(`${seq}${mark} ${label} ${dim(r.title || r.id)}${duration}${error}`);
|
|
79
|
+
if (fullError && r.error_message) {
|
|
80
|
+
const detail = String(r.error_message).slice(0, CI_ERROR_MAX_LEN);
|
|
81
|
+
ctx.err(red(detail.replace(/^/gm, " ")));
|
|
82
|
+
process.stdout.write(`::error title=${escapeAnnotationProp(r.title || r.id)}::${escapeAnnotationData(detail)}\n`);
|
|
18
83
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
84
|
+
};
|
|
85
|
+
if (!ctx.json)
|
|
86
|
+
ctx.err(dim(`watching run ${runId}`));
|
|
87
|
+
let lastCounters = {};
|
|
88
|
+
let failFastStop = false;
|
|
89
|
+
while (true) {
|
|
90
|
+
if (deadline && Date.now() > deadline) {
|
|
91
|
+
clearStatusLine();
|
|
92
|
+
throw new CliError(`Timed out after ${timeoutMinutes} minutes`, 1);
|
|
93
|
+
}
|
|
94
|
+
let progress;
|
|
95
|
+
try {
|
|
96
|
+
progress = (await ctx.client.get(`${path}/runs/${runId}/progress?since_version=${since}&wait=${PROGRESS_WAIT_S}`));
|
|
97
|
+
backoffMs = 2_000;
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
clearStatusLine();
|
|
101
|
+
if (!ctx.json)
|
|
102
|
+
ctx.err(dim(`watch request failed (${e.message}); retrying`));
|
|
103
|
+
await sleep(backoffMs);
|
|
104
|
+
backoffMs = Math.min(backoffMs * 2, 30_000);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// Not every event carries counters, so an empty snapshot means "no news",
|
|
108
|
+
// not "zero settled": keep the last populated one.
|
|
109
|
+
if (progress.counters && Object.keys(progress.counters).length > 0)
|
|
110
|
+
lastCounters = progress.counters;
|
|
111
|
+
if (lastCounters.total)
|
|
112
|
+
knownTotal = lastCounters.total;
|
|
113
|
+
const version = Number(progress.version ?? 0);
|
|
114
|
+
const changed = version !== since || TERMINAL_RUN.has(progress.status);
|
|
115
|
+
since = version;
|
|
116
|
+
if (changed) {
|
|
117
|
+
try {
|
|
118
|
+
run = (await ctx.client.get(`${path}/runs/${runId}`));
|
|
119
|
+
backoffMs = 2_000;
|
|
25
120
|
}
|
|
26
|
-
|
|
27
|
-
|
|
121
|
+
catch {
|
|
122
|
+
// Same backoff as the poll: a terminal run answers the next poll
|
|
123
|
+
// instantly, so retrying without a delay would busy-loop both endpoints.
|
|
124
|
+
await sleep(backoffMs);
|
|
125
|
+
backoffMs = Math.min(backoffMs * 2, 30_000);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (run?.total_tests)
|
|
129
|
+
knownTotal = run.total_tests;
|
|
130
|
+
for (const r of run?.test_results ?? []) {
|
|
131
|
+
if (SETTLED.has(r.status) && !printed.has(r.id)) {
|
|
132
|
+
printed.add(r.id);
|
|
133
|
+
clearStatusLine();
|
|
134
|
+
if (ctx.json)
|
|
135
|
+
ctx.out(JSON.stringify({ event: "test_completed", ...r }));
|
|
136
|
+
else
|
|
137
|
+
printRow(r);
|
|
138
|
+
if (opts.failFast && FAILING.has(r.status) && !r.quarantined) {
|
|
139
|
+
failFastStop = true;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
28
143
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (!
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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}`);
|
|
144
|
+
if (failFastStop) {
|
|
145
|
+
clearStatusLine();
|
|
146
|
+
if (!ctx.json)
|
|
147
|
+
ctx.err(red(`fail-fast: cancelling run ${runId}`));
|
|
148
|
+
try {
|
|
149
|
+
await ctx.client.post(`${path}/runs/${runId}/cancel`);
|
|
48
150
|
}
|
|
151
|
+
catch {
|
|
152
|
+
// Best-effort: the cancel may race the worker or the run may already be
|
|
153
|
+
// terminal; the failure that triggered it decides the exit code anyway.
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
49
156
|
}
|
|
157
|
+
if (run && TERMINAL_RUN.has(run.status))
|
|
158
|
+
break;
|
|
50
159
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
160
|
+
const done = Math.max(settledCount(lastCounters), printed.size);
|
|
161
|
+
const total = lastCounters.total || knownTotal || "?";
|
|
162
|
+
if (tty) {
|
|
163
|
+
spin = (spin + 1) % SPINNER.length;
|
|
164
|
+
const reds = (lastCounters.failed ?? 0) + (lastCounters.errored ?? 0);
|
|
165
|
+
process.stderr.write(`\r\u001b[2K${SPINNER[spin]} running ${done}/${total} · ` +
|
|
166
|
+
`${green(`${lastCounters.passed ?? 0}✓`)} ${reds > 0 ? red(`${reds}✗`) : dim("0✗")}`);
|
|
54
167
|
}
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
168
|
+
else if (!ctx.json && Date.now() - lastHeartbeat > HEARTBEAT_MS) {
|
|
169
|
+
lastHeartbeat = Date.now();
|
|
170
|
+
ctx.err(dim(`still running: ${done}/${total} settled`));
|
|
58
171
|
}
|
|
59
172
|
}
|
|
60
|
-
|
|
61
|
-
|
|
173
|
+
clearStatusLine();
|
|
174
|
+
const counters = {
|
|
175
|
+
passed: run?.passed_count ?? 0,
|
|
176
|
+
failed: run?.failed_count ?? 0,
|
|
177
|
+
errored: run?.errored_count ?? 0,
|
|
178
|
+
cancelled: run?.cancelled_count ?? 0,
|
|
179
|
+
quarantined: run?.quarantined_count ?? 0,
|
|
180
|
+
total: run?.total_tests ?? settledCount(lastCounters),
|
|
181
|
+
flaky: run?.summary?.flaky ?? 0,
|
|
182
|
+
healed: run?.summary?.healed ?? 0,
|
|
183
|
+
};
|
|
184
|
+
const finalStatus = failFastStop ? "failed" : (run?.status ?? "unknown");
|
|
62
185
|
// 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
|
|
186
|
+
// counter), so they never reach this sum and never flip the exit code, but they
|
|
64
187
|
// are always reported, so a green build never silently hides a muted failure.
|
|
65
188
|
const failed = (counters.failed ?? 0) + (counters.errored ?? 0);
|
|
66
189
|
const passed = counters.passed ?? 0;
|
|
67
|
-
// A flaky pass likewise exits 0
|
|
190
|
+
// A flaky pass likewise exits 0 (absorbing a transient blip is the point) but it
|
|
68
191
|
// is reported too, so a suite that only stays green by retrying can't hide it.
|
|
69
192
|
const extra = (counters.cancelled ? yellow(`, ${counters.cancelled} cancelled`) : "") +
|
|
70
193
|
(counters.quarantined ? yellow(`, ${counters.quarantined} quarantined`) : "") +
|
|
@@ -75,8 +198,44 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes) {
|
|
|
75
198
|
: green(`${passed} passed`) + extra;
|
|
76
199
|
if (!ctx.json)
|
|
77
200
|
ctx.err(`\n${statusColor(finalStatus)}: ${summary}`);
|
|
201
|
+
const link = runPageUrl(ctx.client.baseUrl, project, runId);
|
|
202
|
+
if (ci && link)
|
|
203
|
+
ctx.err(dim(link));
|
|
204
|
+
if (ci && process.env.GITHUB_STEP_SUMMARY) {
|
|
205
|
+
const failures = (run?.test_results ?? []).filter((r) => FAILING.has(r.status) && !r.quarantined);
|
|
206
|
+
const md = [
|
|
207
|
+
`### Beryl run ${finalStatus}${failFastStop ? " (fail-fast)" : ""}`,
|
|
208
|
+
"",
|
|
209
|
+
"| passed | failed | errored | cancelled | quarantined | total |",
|
|
210
|
+
"| --- | --- | --- | --- | --- | --- |",
|
|
211
|
+
`| ${counters.passed} | ${counters.failed} | ${counters.errored} | ${counters.cancelled} | ${counters.quarantined} | ${counters.total} |`,
|
|
212
|
+
...(link ? ["", `[Open the run in Beryl](${link})`] : []),
|
|
213
|
+
...failures.flatMap((r) => [
|
|
214
|
+
"",
|
|
215
|
+
`**✗ ${r.title || r.id}**`,
|
|
216
|
+
"```",
|
|
217
|
+
String(r.error_message ?? "").slice(0, CI_ERROR_MAX_LEN),
|
|
218
|
+
"```",
|
|
219
|
+
]),
|
|
220
|
+
"",
|
|
221
|
+
];
|
|
222
|
+
try {
|
|
223
|
+
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md.join("\n"));
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// The summary is decoration; never fail the watch over it.
|
|
227
|
+
}
|
|
228
|
+
}
|
|
78
229
|
const exitCode = finalStatus === "completed" && failed === 0 ? 0 : 1;
|
|
79
|
-
return {
|
|
230
|
+
return {
|
|
231
|
+
data: {
|
|
232
|
+
run_id: runId,
|
|
233
|
+
status: finalStatus,
|
|
234
|
+
...(failFastStop ? { fail_fast: true } : {}),
|
|
235
|
+
...counters,
|
|
236
|
+
},
|
|
237
|
+
exitCode,
|
|
238
|
+
};
|
|
80
239
|
}
|
|
81
240
|
export async function watchExploration(ctx, ws, project, explorationId, timeoutMinutes) {
|
|
82
241
|
const signal = withTimeout(timeoutMinutes);
|
package/package.json
CHANGED