@beryl-so/cli 0.78.0 → 0.92.1
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 +18 -7
- package/dist/adapters/mcp.js +23 -10
- package/dist/beryl-test-skill.js +177 -33
- package/dist/code-ref.js +81 -0
- package/dist/commands/accounts.js +74 -18
- package/dist/commands/config-vars.js +207 -20
- package/dist/commands/environments.js +14 -7
- package/dist/commands/init.js +2 -2
- package/dist/commands/jobs.js +152 -20
- package/dist/commands/projects.js +115 -20
- package/dist/commands/runs.js +68 -8
- package/dist/commands/tests.js +44 -9
- package/dist/commands/util.js +38 -6
- package/dist/commands/watch.js +3 -2
- package/dist/config.js +2 -0
- package/dist/lint.js +180 -3
- package/dist/schema.generated.js +178 -7
- package/dist/skill-tables.js +3 -0
- package/package.json +1 -1
package/dist/commands/jobs.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { SESSION_CAPTURE_EXTENSION_URL } from "../config.js";
|
|
3
4
|
import { UsageError } from "../errors.js";
|
|
4
5
|
import { arg, flagBool, flagStr, projectPath } from "./util.js";
|
|
5
6
|
const jobsPath = (ws, p) => `${projectPath(ws, p)}/test-creation-jobs`;
|
|
6
|
-
const
|
|
7
|
-
|
|
7
|
+
const DOCUMENT_SUFFIX = /\.(md|markdown|txt|text|pdf|docx)$/i;
|
|
8
|
+
const LLM_NOTICE = "The prompt and document are sent to the LLM that drives the agent, so leave out " +
|
|
9
|
+
"anything you would not share with it.";
|
|
8
10
|
function readDocument(file) {
|
|
9
11
|
try {
|
|
10
12
|
return new Uint8Array(fs.readFileSync(file));
|
|
@@ -16,10 +18,25 @@ function readDocument(file) {
|
|
|
16
18
|
throw new UsageError(`cannot read ${file} (${code ?? err.message})`);
|
|
17
19
|
}
|
|
18
20
|
}
|
|
21
|
+
function decodeDocument(encoded) {
|
|
22
|
+
const compact = encoded.replace(/\s+/g, "");
|
|
23
|
+
// Buffer.from decodes a truncated string to wrong bytes instead of failing.
|
|
24
|
+
if (!/^[A-Za-z0-9+/_-]+={0,2}$/.test(compact) || compact.replace(/=+$/, "").length % 4 === 1) {
|
|
25
|
+
throw new UsageError("--content-base64 is not valid base64");
|
|
26
|
+
}
|
|
27
|
+
return new Uint8Array(Buffer.from(compact, "base64"));
|
|
28
|
+
}
|
|
19
29
|
export function jobHuman(job) {
|
|
20
30
|
const lines = [`Job ${job.id}: ${job.status}`];
|
|
31
|
+
if (job.retry && job.cloned_from_id)
|
|
32
|
+
lines.push(`Retry of job ${job.cloned_from_id}`);
|
|
21
33
|
if (job.error)
|
|
22
|
-
lines.push(
|
|
34
|
+
lines.push(`${job.status === "partial" ? "Missing" : "Error"}: ${job.error}`);
|
|
35
|
+
if (job.needs)
|
|
36
|
+
lines.push(`Needs: ${job.needs.reason}`);
|
|
37
|
+
if (job.needs?.kind === "session_capture") {
|
|
38
|
+
lines.push(` Capture a signed-in session with the Beryl Chrome extension (${SESSION_CAPTURE_EXTENSION_URL}); the job resumes by itself.`);
|
|
39
|
+
}
|
|
23
40
|
for (const t of job.tests)
|
|
24
41
|
lines.push(` ${t.title ?? t.id} [${t.surface}] ${t.id}`);
|
|
25
42
|
if (!job.tests.length)
|
|
@@ -27,50 +44,125 @@ export function jobHuman(job) {
|
|
|
27
44
|
const skipped = job.skipped_flows ?? [];
|
|
28
45
|
if (skipped.length)
|
|
29
46
|
lines.push("Skipped scenarios:");
|
|
30
|
-
for (const s of skipped)
|
|
31
|
-
lines.push(` ${s.handle}: ${s.reason}`);
|
|
47
|
+
for (const s of skipped) {
|
|
48
|
+
lines.push(` ${s.handle}: ${s.reason}${s.detail ? ` (${s.detail})` : ""}`);
|
|
49
|
+
if (s.advice)
|
|
50
|
+
lines.push(` What to do: ${s.advice}`);
|
|
51
|
+
}
|
|
32
52
|
for (const step of job.surface_steps ?? []) {
|
|
53
|
+
if (step.gaps?.length)
|
|
54
|
+
lines.push(`Not authored [${step.surface}]: ${step.gaps.join(", ")}`);
|
|
33
55
|
for (const w of step.warnings ?? [])
|
|
34
56
|
lines.push(`Warning [${step.surface}]: ${w}`);
|
|
35
57
|
}
|
|
58
|
+
if (job.status === "partial" || job.status === "failed") {
|
|
59
|
+
lines.push(`Retry what is missing: beryl jobs retry ${job.id}`);
|
|
60
|
+
}
|
|
36
61
|
return lines.join("\n");
|
|
37
62
|
}
|
|
38
63
|
export const jobCommands = [
|
|
39
64
|
{
|
|
40
65
|
name: "jobs create",
|
|
41
|
-
groupSummary: "Test-creation jobs: the cloud agent
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
"
|
|
66
|
+
groupSummary: "Test-creation jobs: the cloud agent follows a prompt, reads any document it comes " +
|
|
67
|
+
"with, and authors tests.",
|
|
68
|
+
summary: "Create tests from a prompt, with an optional document, on Beryl's cloud",
|
|
69
|
+
description: "Queues a job from a prompt (what to test; a line is enough) and an optional " +
|
|
70
|
+
"document (Markdown, plain text, PDF or DOCX: --file, --text, or --content-base64 " +
|
|
71
|
+
"with --filename): the agent explores the environment, drafts one flow per " +
|
|
72
|
+
"scenario the prompt asks for, reading the document when there is one, and authors " +
|
|
73
|
+
"one test per enabled surface of the project. The agent signs " +
|
|
74
|
+
"in as the environment's default test account, or the one --account names, and the " +
|
|
75
|
+
"tests it saves sign in as that account too. New tests get " +
|
|
76
|
+
"no groups. A job runs once and ends done, partial (some tests saved, the rest " +
|
|
77
|
+
"listed with why) or failed; `jobs retry` authors what a partial or failed job " +
|
|
78
|
+
"left out, `jobs clone` runs the whole document again. With --ci (or a " +
|
|
79
|
+
"project CI token as BERYL_TOKEN, which always creates CI jobs) the job never " +
|
|
80
|
+
"pauses for input: a scenario behind a login it cannot pass is skipped with the " +
|
|
81
|
+
"reason. " +
|
|
47
82
|
LLM_NOTICE,
|
|
48
83
|
scope: "project",
|
|
49
|
-
args: [
|
|
84
|
+
args: [
|
|
85
|
+
{
|
|
86
|
+
name: "prompt",
|
|
87
|
+
description: "What to test: the instruction the agent follows; a line is enough",
|
|
88
|
+
required: true,
|
|
89
|
+
},
|
|
90
|
+
],
|
|
50
91
|
flags: [
|
|
92
|
+
{
|
|
93
|
+
name: "file",
|
|
94
|
+
type: "string",
|
|
95
|
+
description: "Path to a document (PRD, release notes) the agent reads with the prompt",
|
|
96
|
+
},
|
|
51
97
|
{ name: "text", type: "string", description: "The document as text, instead of a file" },
|
|
98
|
+
{
|
|
99
|
+
name: "content-base64",
|
|
100
|
+
type: "string",
|
|
101
|
+
description: "A binary document (PDF, DOCX) base64-encoded, instead of a file; needs --filename",
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "filename",
|
|
105
|
+
type: "string",
|
|
106
|
+
description: "With --content-base64: the document's name; its extension sets the type",
|
|
107
|
+
},
|
|
52
108
|
{
|
|
53
109
|
name: "env",
|
|
54
110
|
type: "string",
|
|
55
111
|
description: "Environment id to explore (defaults to the project's default)",
|
|
56
112
|
},
|
|
113
|
+
{
|
|
114
|
+
name: "account",
|
|
115
|
+
type: "string",
|
|
116
|
+
description: "Label of the test account to sign in as (see `accounts list`); defaults to the environment's default account",
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: "ci",
|
|
120
|
+
type: "boolean",
|
|
121
|
+
description: "Non-interactive job for CI: never waits for a captured session, skips what it blocks",
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
examples: [
|
|
125
|
+
'beryl jobs create "Cover the checkout flow: add an item, pay by card, see the confirmation" --env <env-id>',
|
|
126
|
+
'beryl jobs create "Draft a test for each scenario in the release notes" --file ./release-notes.md',
|
|
127
|
+
'beryl jobs create "Cover the admin console" --file ./admin-console.md --account admin',
|
|
128
|
+
'BERYL_TOKEN=beryl_ci_... beryl jobs create "Cover the brief" --file ./brief.md --ci --workspace <ws-id> --project <project-id>',
|
|
57
129
|
],
|
|
58
|
-
examples: ["beryl jobs create ./release-notes.md --env <env-id>"],
|
|
59
130
|
async run(ctx, input) {
|
|
60
|
-
const
|
|
61
|
-
const file = input
|
|
131
|
+
const prompt = arg(input, "prompt");
|
|
132
|
+
const file = flagStr(input, "file");
|
|
62
133
|
const text = flagStr(input, "text");
|
|
63
|
-
|
|
64
|
-
|
|
134
|
+
const encoded = flagStr(input, "content-base64");
|
|
135
|
+
const filename = flagStr(input, "filename");
|
|
136
|
+
if ([file, text, encoded].filter(Boolean).length > 1) {
|
|
137
|
+
throw new UsageError("pass at most one of --file, --text or --content-base64");
|
|
138
|
+
}
|
|
139
|
+
// The pre-0.88 form put the document first; refuse it rather than test "./brief.md".
|
|
140
|
+
if (!file && !text && !encoded && DOCUMENT_SUFFIX.test(prompt)) {
|
|
141
|
+
throw new UsageError("the first argument is the prompt; pass the document with --file");
|
|
142
|
+
}
|
|
143
|
+
if (Boolean(encoded) !== Boolean(filename)) {
|
|
144
|
+
throw new UsageError("--content-base64 and --filename go together");
|
|
65
145
|
}
|
|
66
146
|
const form = new FormData();
|
|
67
|
-
|
|
147
|
+
form.set("prompt", prompt);
|
|
148
|
+
if (file) {
|
|
68
149
|
form.set("file", new Blob([readDocument(file)]), path.basename(file));
|
|
69
|
-
|
|
150
|
+
}
|
|
151
|
+
else if (encoded) {
|
|
152
|
+
form.set("file", new Blob([decodeDocument(encoded)]), path.basename(filename));
|
|
153
|
+
}
|
|
154
|
+
else if (text) {
|
|
70
155
|
form.set("text", text);
|
|
156
|
+
}
|
|
157
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
71
158
|
const envId = flagStr(input, "env");
|
|
72
159
|
if (envId)
|
|
73
160
|
form.set("environment_id", envId);
|
|
161
|
+
const account = flagStr(input, "account");
|
|
162
|
+
if (account)
|
|
163
|
+
form.set("account", account);
|
|
164
|
+
if (flagBool(input, "ci"))
|
|
165
|
+
form.set("interactive", "false");
|
|
74
166
|
const job = (await ctx.client.request("POST", jobsPath(workspaceId, projectId), {
|
|
75
167
|
form,
|
|
76
168
|
}));
|
|
@@ -102,7 +194,7 @@ export const jobCommands = [
|
|
|
102
194
|
},
|
|
103
195
|
{
|
|
104
196
|
name: "jobs clone",
|
|
105
|
-
summary: "Run a job again: queue a new job with the same document and
|
|
197
|
+
summary: "Run a job again: queue a new job with the same prompt, document, environment and account",
|
|
106
198
|
description: LLM_NOTICE,
|
|
107
199
|
scope: "project",
|
|
108
200
|
args: [{ name: "job-id", description: "Job id to clone", required: true }],
|
|
@@ -112,6 +204,46 @@ export const jobCommands = [
|
|
|
112
204
|
return { data: job, human: `Job ${job.id} queued.` };
|
|
113
205
|
},
|
|
114
206
|
},
|
|
207
|
+
{
|
|
208
|
+
name: "jobs retry",
|
|
209
|
+
summary: "Author only what a partial or failed job left out, optionally with an edited prompt or document",
|
|
210
|
+
description: "Queues a new job that authors each scenario the job drafted but has no test on " +
|
|
211
|
+
"some surface, and each scenario it skipped, on the surfaces that miss them. The " +
|
|
212
|
+
"new tests join the same flows. --prompt replaces the job's instruction and " +
|
|
213
|
+
"--file or --text its document; anything not passed is reused from the job. " +
|
|
214
|
+
LLM_NOTICE,
|
|
215
|
+
scope: "project",
|
|
216
|
+
args: [{ name: "job-id", description: "Job id to retry", required: true }],
|
|
217
|
+
flags: [
|
|
218
|
+
{ name: "prompt", type: "string", description: "An edited instruction for the agent" },
|
|
219
|
+
{ name: "file", type: "string", description: "Path to an edited document (Markdown or plain text)" },
|
|
220
|
+
{ name: "text", type: "string", description: "The edited document as text, instead of --file" },
|
|
221
|
+
],
|
|
222
|
+
examples: [
|
|
223
|
+
"beryl jobs retry <job-id>",
|
|
224
|
+
'beryl jobs retry <job-id> --prompt "Refund lives under Orders; test it there"',
|
|
225
|
+
"beryl jobs retry <job-id> --file ./brief-v2.md",
|
|
226
|
+
],
|
|
227
|
+
async run(ctx, input) {
|
|
228
|
+
const prompt = flagStr(input, "prompt");
|
|
229
|
+
const file = flagStr(input, "file");
|
|
230
|
+
const text = flagStr(input, "text");
|
|
231
|
+
if (file && text)
|
|
232
|
+
throw new UsageError("pass --file or --text, not both");
|
|
233
|
+
const document = file ? Buffer.from(readDocument(file)).toString("utf-8") : text;
|
|
234
|
+
const body = {};
|
|
235
|
+
if (prompt !== undefined)
|
|
236
|
+
body.prompt = prompt;
|
|
237
|
+
if (document !== undefined)
|
|
238
|
+
body.document_text = document;
|
|
239
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
240
|
+
const job = (await ctx.client.post(`${jobsPath(workspaceId, projectId)}/${arg(input, "job-id")}/retry`, body));
|
|
241
|
+
return {
|
|
242
|
+
data: job,
|
|
243
|
+
human: `Job ${job.id} queued. Check it with \`beryl jobs get ${job.id}\`.`,
|
|
244
|
+
};
|
|
245
|
+
},
|
|
246
|
+
},
|
|
115
247
|
{
|
|
116
248
|
name: "jobs delete",
|
|
117
249
|
summary: "Delete a job; the flows and tests it created stay",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { detectAuthGating } from "../detect.js";
|
|
2
2
|
import { UsageError } from "../errors.js";
|
|
3
3
|
import { dim, green, table, timeAgo } from "../output.js";
|
|
4
|
-
import { arg,
|
|
4
|
+
import { arg, flagBool, flagNum, flagStr, flagStrings, 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
|
|
7
7
|
// choice leaves the project waiting on a login capture that never comes — so resolve it
|
|
@@ -117,7 +117,7 @@ export const projectCommands = [
|
|
|
117
117
|
data: created,
|
|
118
118
|
human: `${green("Project created")}: ${created.project_id} ${dim("(empty)")}\n` +
|
|
119
119
|
`Add a site with \`beryl envs update <env-id>\`, then create tests with ` +
|
|
120
|
-
`\`beryl jobs create <
|
|
120
|
+
`\`beryl jobs create "<what to test>"\`.`,
|
|
121
121
|
};
|
|
122
122
|
}
|
|
123
123
|
const auth = flagStr(input, "auth") ?? (await resolveAuthChoice(ctx, ws, url));
|
|
@@ -129,7 +129,8 @@ export const projectCommands = [
|
|
|
129
129
|
return {
|
|
130
130
|
data: created,
|
|
131
131
|
human: `${green("Project created")}: ${created.project_id}\n` +
|
|
132
|
-
`Create tests from a document
|
|
132
|
+
`Create tests from a prompt (and a document, if you have one): ` +
|
|
133
|
+
`\`beryl jobs create "<what to test>" --file <doc>\`.`,
|
|
133
134
|
};
|
|
134
135
|
},
|
|
135
136
|
},
|
|
@@ -194,36 +195,130 @@ export const projectCommands = [
|
|
|
194
195
|
return { data: await ctx.client.patch(projectPath(workspaceId, projectId), body) };
|
|
195
196
|
},
|
|
196
197
|
},
|
|
198
|
+
{
|
|
199
|
+
name: "projects heal",
|
|
200
|
+
summary: "Turn self-heal on or off for a project and limit its time and runs",
|
|
201
|
+
description: "Self-heal is off by default: a failed test fails right away. With it on, a run holds " +
|
|
202
|
+
"a failed test that has passed before while a heal agent tries to repair its plan " +
|
|
203
|
+
"against the app, and the test passes if the repair is proven by a replay. --minutes " +
|
|
204
|
+
"caps the heal agent's time on one failed test (from 1 up to the platform limit, 5 " +
|
|
205
|
+
"unless the server is configured otherwise; the proving replay comes on top; 0 goes " +
|
|
206
|
+
"back to that limit) and --max-per-run caps " +
|
|
207
|
+
"how many failed tests one run may heal (0 removes the cap); past it the rest fail " +
|
|
208
|
+
"without a heal. A test turned off with `tests heal <id> off` never heals. " +
|
|
209
|
+
"`projects get` shows the current values.",
|
|
210
|
+
scope: "project",
|
|
211
|
+
args: [{ name: "state", description: "on | off; omit to only change the limits" }],
|
|
212
|
+
flags: [
|
|
213
|
+
{ name: "minutes", type: "number", description: "Minutes the heal agent may spend on one failed test (0 = the platform limit, 5 by default)" },
|
|
214
|
+
{ name: "max-per-run", type: "number", description: "Failed tests one run may heal (0 = no cap)" },
|
|
215
|
+
],
|
|
216
|
+
examples: [
|
|
217
|
+
"beryl projects heal on",
|
|
218
|
+
"beryl projects heal on --minutes 2 --max-per-run 5",
|
|
219
|
+
"beryl projects heal --max-per-run 0",
|
|
220
|
+
"beryl projects heal off",
|
|
221
|
+
],
|
|
222
|
+
async run(ctx, input) {
|
|
223
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
224
|
+
const body = {};
|
|
225
|
+
const state = input.args.state;
|
|
226
|
+
if (typeof state === "string" && state) {
|
|
227
|
+
const lower = state.toLowerCase();
|
|
228
|
+
if (lower !== "on" && lower !== "off") {
|
|
229
|
+
throw new UsageError(`Expected "on" or "off", got "${state}"`);
|
|
230
|
+
}
|
|
231
|
+
body.self_heal = lower === "on";
|
|
232
|
+
}
|
|
233
|
+
const minutes = flagNum(input, "minutes");
|
|
234
|
+
const maxPerRun = flagNum(input, "max-per-run");
|
|
235
|
+
if (minutes !== undefined)
|
|
236
|
+
body.heal_max_minutes = minutes === 0 ? null : minutes;
|
|
237
|
+
if (maxPerRun !== undefined)
|
|
238
|
+
body.heal_max_per_run = maxPerRun === 0 ? null : maxPerRun;
|
|
239
|
+
if (Object.keys(body).length === 0) {
|
|
240
|
+
throw new UsageError("Pass on|off or at least one of --minutes, --max-per-run");
|
|
241
|
+
}
|
|
242
|
+
const data = (await ctx.client.patch(projectPath(workspaceId, projectId), body));
|
|
243
|
+
const minutesText = data.heal_max_minutes === null ? "default" : `${data.heal_max_minutes} min`;
|
|
244
|
+
const capText = data.heal_max_per_run === null ? "no cap" : `${data.heal_max_per_run}`;
|
|
245
|
+
return {
|
|
246
|
+
data,
|
|
247
|
+
human: `Self-heal: ${data.self_heal ? "on" : "off"} ` +
|
|
248
|
+
`(per heal: ${minutesText}, heals per run: ${capText})`,
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
},
|
|
197
252
|
{
|
|
198
253
|
name: "projects surfaces",
|
|
199
|
-
summary: "
|
|
200
|
-
description: "
|
|
201
|
-
"web-
|
|
202
|
-
"
|
|
203
|
-
"
|
|
204
|
-
"
|
|
205
|
-
"
|
|
206
|
-
"
|
|
207
|
-
"
|
|
254
|
+
summary: "Turn project surfaces on or off",
|
|
255
|
+
description: "Surfaces are web-desktop, web-tablet, web-mobile, android, ios; a new project has " +
|
|
256
|
+
"web-desktop only. `--add` turns surfaces on and `--remove` turns them off, keeping the " +
|
|
257
|
+
"rest. Listing surfaces sets the exact set instead, and is refused when it would turn " +
|
|
258
|
+
"off a surface that is on unless you pass `--replace`. At least one surface stays on. " +
|
|
259
|
+
"Prints the set before and after. Turning a surface off removes no tests: delete a " +
|
|
260
|
+
"surface's tests to stop running them. android and ios test the app named by each " +
|
|
261
|
+
"environment's app id for that surface (`envs update --app-id android=<package> " +
|
|
262
|
+
"--app-id ios=<bundle id>`) from its newest uploaded build (`config files upload " +
|
|
263
|
+
"app.apk`, `config files upload App.zip --kind ios_build`), on Beryl's Android and iOS " +
|
|
264
|
+
"workers.",
|
|
208
265
|
scope: "project",
|
|
209
266
|
args: [
|
|
210
267
|
{
|
|
211
268
|
name: "surfaces",
|
|
212
|
-
description: "web-desktop | web-tablet | web-mobile | android | ios
|
|
213
|
-
required: true,
|
|
269
|
+
description: "The exact set to enable: web-desktop | web-tablet | web-mobile | android | ios",
|
|
214
270
|
variadic: true,
|
|
215
271
|
},
|
|
216
272
|
],
|
|
273
|
+
flags: [
|
|
274
|
+
{ name: "add", type: "strings", description: "Turn this surface on (repeatable)" },
|
|
275
|
+
{ name: "remove", type: "strings", description: "Turn this surface off (repeatable)" },
|
|
276
|
+
{
|
|
277
|
+
name: "replace",
|
|
278
|
+
type: "boolean",
|
|
279
|
+
description: "Let a listed exact set turn off surfaces that are on now",
|
|
280
|
+
},
|
|
281
|
+
],
|
|
217
282
|
examples: [
|
|
218
|
-
"beryl projects surfaces
|
|
219
|
-
"beryl projects surfaces web-
|
|
283
|
+
"beryl projects surfaces --add android --add ios",
|
|
284
|
+
"beryl projects surfaces --remove web-tablet",
|
|
285
|
+
"beryl projects surfaces web-desktop web-mobile --replace",
|
|
220
286
|
],
|
|
221
287
|
async run(ctx, input) {
|
|
222
288
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
289
|
+
const listed = input.args.surfaces ?? [];
|
|
290
|
+
const add = flagStrings(input, "add");
|
|
291
|
+
const remove = flagStrings(input, "remove");
|
|
292
|
+
if (listed.length > 0 && (add || remove)) {
|
|
293
|
+
throw new UsageError("List the exact set, or use --add/--remove, not both");
|
|
294
|
+
}
|
|
295
|
+
if (listed.length === 0 && !add && !remove) {
|
|
296
|
+
throw new UsageError("Pass --add <surface>, --remove <surface>, or the exact set of surfaces");
|
|
297
|
+
}
|
|
298
|
+
const path = projectPath(workspaceId, projectId);
|
|
299
|
+
const before = (await ctx.client.get(path)).enabled_surfaces;
|
|
300
|
+
let body;
|
|
301
|
+
if (listed.length > 0) {
|
|
302
|
+
const dropped = before.filter((s) => !listed.includes(s));
|
|
303
|
+
if (dropped.length > 0 && !flagBool(input, "replace")) {
|
|
304
|
+
throw new UsageError(`This would turn off ${dropped.join(", ")} (on now: ${before.join(", ")}). ` +
|
|
305
|
+
"Use --add to turn surfaces on and keep the rest, or pass --replace to set exactly " +
|
|
306
|
+
`${listed.join(", ")}.`);
|
|
307
|
+
}
|
|
308
|
+
body = { enabled_surfaces: listed };
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
body = {};
|
|
312
|
+
if (add)
|
|
313
|
+
body.add_surfaces = add;
|
|
314
|
+
if (remove)
|
|
315
|
+
body.remove_surfaces = remove;
|
|
316
|
+
}
|
|
317
|
+
const data = (await ctx.client.patch(path, body));
|
|
318
|
+
return {
|
|
319
|
+
data: { ...data, previous_enabled_surfaces: before },
|
|
320
|
+
human: `Enabled surfaces: ${before.join(", ")} -> ${data.enabled_surfaces.join(", ")}`,
|
|
321
|
+
};
|
|
227
322
|
},
|
|
228
323
|
},
|
|
229
324
|
{
|
package/dist/commands/runs.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { downloadRunArtifacts, failureImages, isFailing, resultsOf, } from "../artifacts.js";
|
|
3
|
+
import { codeRefLabel, isCommitSha, resolveCodeRef } from "../code-ref.js";
|
|
3
4
|
import { UsageError } from "../errors.js";
|
|
4
5
|
import { dim, yellow } from "../output.js";
|
|
5
6
|
import { arg, flagBool, flagNum, flagStr, idsInGroup, projectPath } from "./util.js";
|
|
6
7
|
import { watchRun } from "./watch.js";
|
|
7
8
|
const MAX_FAILURE_SCREENSHOTS = 5;
|
|
9
|
+
export function queuedNote(run) {
|
|
10
|
+
if (run.status !== "queued")
|
|
11
|
+
return null;
|
|
12
|
+
const behind = run.waiting_for_run_id ? ` behind run ${run.waiting_for_run_id}` : "";
|
|
13
|
+
return (`queued at position ${run.queue_position ?? "?"}${behind}; a project runs one run ` +
|
|
14
|
+
"at a time, so it starts when the runs ahead finish");
|
|
15
|
+
}
|
|
8
16
|
export function parseHeaders(raw) {
|
|
9
17
|
if (!raw || raw.length === 0)
|
|
10
18
|
return null;
|
|
@@ -26,7 +34,10 @@ export const runCommands = [
|
|
|
26
34
|
"exits 0 only if every test passed, so wire it straight into CI. A run with a " +
|
|
27
35
|
"heal-eligible failure completes only after Beryl has tried to heal it: a repaired " +
|
|
28
36
|
"test is re-run inside the same run and counts as passed (reported as `healed`), so " +
|
|
29
|
-
"the final counts, the report and the completion email all reflect the repair."
|
|
37
|
+
"the final counts, the report and the completion email all reflect the repair. " +
|
|
38
|
+
"A project runs one run at a time: a run triggered while another is in flight comes " +
|
|
39
|
+
"back `queued` with its `queue_position` and starts on its own, in trigger order, " +
|
|
40
|
+
"when the runs ahead of it finish (--watch waits through the queue).",
|
|
30
41
|
scope: "project",
|
|
31
42
|
flags: [
|
|
32
43
|
{ name: "test", type: "strings", description: "Run only these test ids (repeatable)" },
|
|
@@ -46,7 +57,13 @@ export const runCommands = [
|
|
|
46
57
|
"Reaches auth-walled preview deploys, e.g. --header x-vercel-protection-bypass=<token>",
|
|
47
58
|
},
|
|
48
59
|
{ name: "watch", type: "boolean", description: "Follow progress and exit non-zero on failure" },
|
|
49
|
-
{
|
|
60
|
+
{
|
|
61
|
+
name: "timeout",
|
|
62
|
+
type: "number",
|
|
63
|
+
description: "With --watch: max minutes to wait. A test that waits for an asynchronous " +
|
|
64
|
+
"process (a wait_until step) can run for minutes, so set this generously for a " +
|
|
65
|
+
"suite that has one.",
|
|
66
|
+
},
|
|
50
67
|
{
|
|
51
68
|
name: "fail-fast",
|
|
52
69
|
type: "boolean",
|
|
@@ -58,6 +75,18 @@ export const runCommands = [
|
|
|
58
75
|
type: "number",
|
|
59
76
|
description: "Retry a failing test up to N times (0 disables); omit for the default",
|
|
60
77
|
},
|
|
78
|
+
{
|
|
79
|
+
name: "commit",
|
|
80
|
+
type: "string",
|
|
81
|
+
description: "Commit sha of your app this run tests, shown on the run. Detected automatically " +
|
|
82
|
+
"in GitHub Actions and GitLab CI (or from BERYL_COMMIT_SHA); this overrides it",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: "branch",
|
|
86
|
+
type: "string",
|
|
87
|
+
description: "Branch of your app this run tests, shown on the run. Detected automatically " +
|
|
88
|
+
"in GitHub Actions and GitLab CI (or from BERYL_BRANCH); this overrides it",
|
|
89
|
+
},
|
|
61
90
|
],
|
|
62
91
|
examples: [
|
|
63
92
|
"beryl runs trigger --watch",
|
|
@@ -66,6 +95,7 @@ export const runCommands = [
|
|
|
66
95
|
"beryl runs trigger --test 4f… --test 9a…",
|
|
67
96
|
"beryl runs trigger --group Smoke --group Checkout --watch",
|
|
68
97
|
"beryl runs trigger --retries 0 --watch",
|
|
98
|
+
"beryl runs trigger --commit $(git rev-parse HEAD) --branch main --watch",
|
|
69
99
|
],
|
|
70
100
|
async run(ctx, input) {
|
|
71
101
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
@@ -88,13 +118,24 @@ export const runCommands = [
|
|
|
88
118
|
tests = [...union];
|
|
89
119
|
}
|
|
90
120
|
const extraHeaders = parseHeaders(input.flags.header);
|
|
121
|
+
const commitFlag = flagStr(input, "commit");
|
|
122
|
+
if (commitFlag !== undefined && !isCommitSha(commitFlag.trim()))
|
|
123
|
+
ctx.err(yellow(`--commit ${commitFlag} is not a commit sha; the run won't record it`));
|
|
124
|
+
const codeRef = resolveCodeRef({ commit: commitFlag, branch: flagStr(input, "branch") });
|
|
91
125
|
const created = (await ctx.client.post(`${projectPath(workspaceId, projectId)}/runs`, {
|
|
92
126
|
test_case_ids: tests && tests.length > 0 ? tests : null,
|
|
93
127
|
environment_id: flagStr(input, "env") ?? null,
|
|
94
128
|
target_url_override: flagStr(input, "url-override") ?? null,
|
|
95
129
|
max_retries: flagNum(input, "retries") ?? null,
|
|
96
130
|
extra_headers: extraHeaders,
|
|
131
|
+
...codeRef,
|
|
97
132
|
}));
|
|
133
|
+
const label = codeRefLabel(codeRef);
|
|
134
|
+
if (label && !ctx.json)
|
|
135
|
+
ctx.err(dim(`testing ${label}`));
|
|
136
|
+
const note = queuedNote(created);
|
|
137
|
+
if (note && !ctx.json)
|
|
138
|
+
ctx.err(yellow(note));
|
|
98
139
|
if (!flagBool(input, "watch"))
|
|
99
140
|
return { data: created };
|
|
100
141
|
return await watchRun(ctx, workspaceId, projectId, created.id, flagNum(input, "timeout"), {
|
|
@@ -103,6 +144,20 @@ export const runCommands = [
|
|
|
103
144
|
});
|
|
104
145
|
},
|
|
105
146
|
},
|
|
147
|
+
{
|
|
148
|
+
name: "runs queue",
|
|
149
|
+
summary: "Show the run in flight and the runs waiting behind it",
|
|
150
|
+
description: "A project runs one run at a time. Returns `active` (the pending or running run, " +
|
|
151
|
+
"or null) and `queued`, the waiting runs in the order they will start, each with " +
|
|
152
|
+
"its `queue_position`. Cancel a waiting run with `runs cancel`.",
|
|
153
|
+
scope: "project",
|
|
154
|
+
async run(ctx, input) {
|
|
155
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
156
|
+
return {
|
|
157
|
+
data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs/queue`),
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
},
|
|
106
161
|
{
|
|
107
162
|
name: "runs list",
|
|
108
163
|
summary: "List recent runs",
|
|
@@ -259,7 +314,7 @@ export const runCommands = [
|
|
|
259
314
|
},
|
|
260
315
|
{
|
|
261
316
|
name: "runs cancel",
|
|
262
|
-
summary: "Cancel an in-flight run",
|
|
317
|
+
summary: "Cancel an in-flight run, or take a queued one out of the queue",
|
|
263
318
|
scope: "project",
|
|
264
319
|
args: [{ name: "run-id", description: "Run id", required: true }],
|
|
265
320
|
async run(ctx, input) {
|
|
@@ -273,6 +328,9 @@ export const runCommands = [
|
|
|
273
328
|
name: "runs report",
|
|
274
329
|
summary: "Show the generated report for a run",
|
|
275
330
|
description: "Returns the run's stored generated report (404 until it has been generated). " +
|
|
331
|
+
"`causes` groups the failures: every test that failed inside the same signup " +
|
|
332
|
+
"steps (a fresh {{inbox_address}} user) is one `signup` cause, so a broken signup " +
|
|
333
|
+
"reads as one problem; each other failure is its own `test` cause. " +
|
|
276
334
|
"`runs get` returns the raw run row with per-test results.",
|
|
277
335
|
scope: "project",
|
|
278
336
|
args: [{ name: "run-id", description: "Run id", required: true }],
|
|
@@ -285,11 +343,13 @@ export const runCommands = [
|
|
|
285
343
|
},
|
|
286
344
|
{
|
|
287
345
|
name: "runs download",
|
|
288
|
-
summary: "
|
|
289
|
-
description: "With --dir,
|
|
290
|
-
"
|
|
291
|
-
"
|
|
292
|
-
"
|
|
346
|
+
summary: "Get a run's results with signed artifact URLs, or download them to disk",
|
|
347
|
+
description: "With neither --dir nor --out, writes nothing: returns the JSON manifest, whose " +
|
|
348
|
+
"screenshot, DOM snapshot, trace and frame URLs are short-lived signed links any " +
|
|
349
|
+
"HTTP client can fetch. With --dir, fetches the artifact bytes (screenshots, DOM " +
|
|
350
|
+
"snapshots, the Playwright trace zip, and the filmstrip frames of the failing tests) " +
|
|
351
|
+
"into <dir>/<test-result-id>/ alongside a run.json manifest. With --out, writes only " +
|
|
352
|
+
"the manifest. The URLs expire, so fetch rather than stash them.",
|
|
293
353
|
scope: "project",
|
|
294
354
|
args: [{ name: "run-id", description: "Run id", required: true }],
|
|
295
355
|
flags: [
|