@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.
@@ -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 LLM_NOTICE = "The document is sent to the LLM that drives the agent, so leave out anything you " +
7
- "would not share with it.";
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(`Error: ${job.error}`);
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 reads a document and authors tests from it.",
42
- summary: "Create tests from a document (PRD, release notes) on Beryl's cloud",
43
- description: "Uploads a document (Markdown, plain text, PDF or DOCX, or --text) and queues a " +
44
- "job: the agent explores the environment, drafts one flow per scenario the document " +
45
- "describes, and authors one test per enabled surface of the project. New tests get " +
46
- "no groups. A job runs once; to run it again, use `jobs clone`. " +
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: [{ name: "file", description: "Path to the document" }],
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 { workspaceId, projectId } = await ctx.requireProject(input);
61
- const file = input.args.file;
131
+ const prompt = arg(input, "prompt");
132
+ const file = flagStr(input, "file");
62
133
  const text = flagStr(input, "text");
63
- if (Boolean(file) === Boolean(text)) {
64
- throw new UsageError("pass a document file or --text, not both");
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
- if (file)
147
+ form.set("prompt", prompt);
148
+ if (file) {
68
149
  form.set("file", new Blob([readDocument(file)]), path.basename(file));
69
- else
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 environment",
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, argList, flagBool, flagNum, flagStr, projectPath } from "./util.js";
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 <document>\`.`,
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 describing the flows: \`beryl jobs create <file>\`.`,
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: "Set which surfaces are enabled for this project",
200
- description: "Replaces the project's enabled surfaces with the ones listed: web-desktop, " +
201
- "web-tablet, web-mobile, android, ios. A new project has web-desktop only. At least " +
202
- "one is required; `projects get` shows the current set. Turning a surface off " +
203
- "removes no tests: delete a surface's tests to stop running them. android and ios " +
204
- "test the app named by each environment's app id for that surface (`envs update " +
205
- "--app-id android=<package> --app-id ios=<bundle id>`) from its newest uploaded build " +
206
- "(`config files upload app.apk`, `config files upload App.zip --kind ios_build`), on " +
207
- "Beryl's Android and iOS workers.",
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, one or more",
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 web-desktop web-mobile",
219
- "beryl projects surfaces web-desktop android ios",
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 data = (await ctx.client.patch(projectPath(workspaceId, projectId), {
224
- enabled_surfaces: argList(input, "surfaces"),
225
- }));
226
- return { data, human: `Enabled surfaces: ${data.enabled_surfaces.join(", ")}` };
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
  {
@@ -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
- { name: "timeout", type: "number", description: "With --watch: max minutes to wait" },
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: "Download a run's results, with its artifacts, to disk",
289
- description: "With --dir, fetches the artifact bytes (screenshots, DOM snapshots, the Playwright " +
290
- "trace zip, and the filmstrip frames of the failing tests) into <dir>/<test-result-id>/ " +
291
- "alongside a run.json manifest. Artifact URLs are short-lived, so download rather than " +
292
- "stash them. With --out (or neither), writes only the JSON manifest.",
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: [