@beryl-so/cli 0.29.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -191,13 +191,14 @@ Manage a project's environments — the URLs and auth Beryl runs tests against.
191
191
 
192
192
  ### schedule
193
193
 
194
- View and set the schedule on which Beryl runs a project's tests automatically.
194
+ View and set the schedule on which Beryl runs a project's tests automatically — the whole project, or one group with --group.
195
195
 
196
196
  | Command | Summary | MCP tool |
197
197
  | --- | --- | --- |
198
- | `beryl schedule get` | Show the project's daily/weekly run schedule | `schedule_get` |
198
+ | `beryl schedule get` | Show the project's run schedule and any per-group schedules | `schedule_get` |
199
199
  | `beryl schedule set` | Enable scheduled runs (daily, or weekly on a given day) | `schedule_set` |
200
200
  | `beryl schedule disable` | Turn scheduled runs off | `schedule_disable` |
201
+ | `beryl schedule remove` | Remove a group's schedule entirely (the group itself stays) | `schedule_remove` |
201
202
 
202
203
  ### tests
203
204
 
@@ -214,6 +215,7 @@ Author, inspect, version, and heal a project's tests — the checks Beryl runs o
214
215
  | `beryl tests create` | Create a test case from a JSON action plan — for tests authored locally, e.g. by your coding agent | `tests_create` |
215
216
  | `beryl tests set-plan <test-id>` | Replace a test's step plan from a JSON file (creates a new version) | `tests_set_plan` |
216
217
  | `beryl tests rename <test-id> <title>` | Rename a test | `tests_rename` |
218
+ | `beryl tests set-groups <test-id>` | Replace the groups a test belongs to | `tests_set_groups` |
217
219
  | `beryl tests quarantine <test-id> <state>` | Mute a flaky test: it keeps running, but its failures stop failing the run | `tests_quarantine` |
218
220
  | `beryl tests delete <test-id>` | Delete a test, its version history, and its results | `tests_delete` |
219
221
  | `beryl tests recompile <test-id>` | Validate + verify an edited plan against the live site before persisting | `tests_recompile` |
@@ -227,6 +229,19 @@ Author, inspect, version, and heal a project's tests — the checks Beryl runs o
227
229
  | `beryl tests script [test-id]` | Print the rendered Playwright spec for a test (or an unbanked plan file) | `tests_script` |
228
230
  | `beryl tests export <test-ids...>` | Export tests as Playwright .spec.ts files in a ZIP | `tests_export` |
229
231
 
232
+ ### groups
233
+
234
+ Manage a project's test groups — labels a test can carry any number of, used to filter, run, or schedule a slice of the suite. Groups are created only here (or in Settings → Groups); assigning a test to an unknown name is an error.
235
+
236
+ `beryl groups` with no subcommand runs `groups list`.
237
+
238
+ | Command | Summary | MCP tool |
239
+ | --- | --- | --- |
240
+ | `beryl groups list` | List the project's test groups and how many tests each holds | `groups_list` |
241
+ | `beryl groups create <name>` | Create a group | `groups_create` |
242
+ | `beryl groups rename <group> <name>` | Rename a group (tests keep their membership) | `groups_rename` |
243
+ | `beryl groups delete <group>` | Delete a group — its tests stay, its schedule (if any) goes with it | `groups_delete` |
244
+
230
245
  ### runs
231
246
 
232
247
  Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and download results.
@@ -289,6 +289,9 @@ ${JSON.stringify(BERYL_TEST_SKILL_EXAMPLE_PLAN, null, 2)
289
289
  description. Inspect the exact spec with \`beryl tests script --file plan.json\`.
290
290
  Full ActionPlan JSON Schema:
291
291
  https://api.beryl.so/api/v1/schemas/action-plan.schema.json.
292
+ Groups are optional and never block: after the test is banked, if \`beryl groups list\`
293
+ shows any, offer them once (\`tests set-groups <id> --group <name>\`); no answer = leave
294
+ it ungrouped and move on. Never create a group yourself — that's the user's call.
292
295
 
293
296
  ### The outcome assertion is the whole game
294
297
 
@@ -1,5 +1,6 @@
1
1
  import { UsageError } from "../errors.js";
2
- import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
2
+ import { arg, findGroup, flagBool, flagNum, flagStr, projectPath } from "./util.js";
3
+ const SCHEDULE_GROUP_FLAG = "Schedule one group (by name or id) instead of the whole project; the run covers the group's active tests at fire time";
3
4
  export const environmentCommands = [
4
5
  {
5
6
  name: "envs list",
@@ -92,9 +93,9 @@ export const environmentCommands = [
92
93
  },
93
94
  {
94
95
  name: "schedule get",
95
- summary: "Show the project's daily/weekly run schedule",
96
+ summary: "Show the project's run schedule and any per-group schedules",
96
97
  scope: "project",
97
- groupSummary: "View and set the schedule on which Beryl runs a project's tests automatically.",
98
+ groupSummary: "View and set the schedule on which Beryl runs a project's tests automatically — the whole project, or one group with --group.",
98
99
  async run(ctx, input) {
99
100
  const { workspaceId, projectId } = await ctx.requireProject(input);
100
101
  return { data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/schedule`) };
@@ -115,12 +116,16 @@ export const environmentCommands = [
115
116
  { name: "hour", type: "number", description: "Hour of day 0-23" },
116
117
  { name: "minute", type: "number", description: "Minute 0-59" },
117
118
  { name: "tz", type: "string", description: "IANA timezone (e.g. America/Los_Angeles)" },
119
+ { name: "group", type: "string", description: SCHEDULE_GROUP_FLAG },
120
+ ],
121
+ examples: [
122
+ "beryl schedule set --frequency daily --hour 6 --tz UTC",
123
+ "beryl schedule set --group Smoke --frequency weekly --day 0 --hour 9 --tz UTC",
118
124
  ],
119
- examples: ["beryl schedule set --frequency daily --hour 6 --tz UTC"],
120
125
  async run(ctx, input) {
121
126
  const { workspaceId, projectId } = await ctx.requireProject(input);
122
127
  return {
123
- data: await ctx.client.put(`${projectPath(workspaceId, projectId)}/schedule`, {
128
+ data: await ctx.client.put(await schedulePath(ctx, input, workspaceId, projectId), {
124
129
  enabled: true,
125
130
  frequency: flagStr(input, "frequency") ?? "daily",
126
131
  day_of_week: flagNum(input, "day") ?? null,
@@ -135,13 +140,40 @@ export const environmentCommands = [
135
140
  name: "schedule disable",
136
141
  summary: "Turn scheduled runs off",
137
142
  scope: "project",
143
+ flags: [{ name: "group", type: "string", description: SCHEDULE_GROUP_FLAG }],
138
144
  async run(ctx, input) {
139
145
  const { workspaceId, projectId } = await ctx.requireProject(input);
140
146
  return {
141
- data: await ctx.client.put(`${projectPath(workspaceId, projectId)}/schedule`, {
147
+ data: await ctx.client.put(await schedulePath(ctx, input, workspaceId, projectId), {
142
148
  enabled: false,
143
149
  }),
144
150
  };
145
151
  },
146
152
  },
153
+ {
154
+ name: "schedule remove",
155
+ summary: "Remove a group's schedule entirely (the group itself stays)",
156
+ scope: "project",
157
+ flags: [{ name: "group", type: "string", description: "Group name or id", required: true }],
158
+ async run(ctx, input) {
159
+ const { workspaceId, projectId } = await ctx.requireProject(input);
160
+ if (!flagStr(input, "group"))
161
+ throw new UsageError("--group is required");
162
+ await ctx.client.del(await schedulePath(ctx, input, workspaceId, projectId));
163
+ return { human: "Removed." };
164
+ },
165
+ },
147
166
  ];
167
+ async function schedulePath(ctx, input, workspaceId, projectId) {
168
+ const base = `${projectPath(workspaceId, projectId)}/schedule`;
169
+ const ref = flagStr(input, "group");
170
+ if (!ref)
171
+ return base;
172
+ const groups = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/groups`));
173
+ const group = findGroup(groups, ref);
174
+ if (!group) {
175
+ const names = groups.map((g) => g.name).join(", ") || "none yet";
176
+ throw new UsageError(`No group '${ref}' in this project (existing: ${names}).`);
177
+ }
178
+ return `${projectPath(workspaceId, projectId)}/groups/${group.id}/schedule`;
179
+ }
@@ -0,0 +1,69 @@
1
+ import { UsageError } from "../errors.js";
2
+ import { arg, findGroup, flagBool, projectPath } from "./util.js";
3
+ async function resolveGroup(ctx, workspaceId, projectId, ref) {
4
+ const groups = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/groups`));
5
+ const match = findGroup(groups, ref);
6
+ if (!match) {
7
+ const names = groups.map((g) => g.name).join(", ") || "none yet";
8
+ throw new UsageError(`No group '${ref}' in this project (existing: ${names}).`);
9
+ }
10
+ return match;
11
+ }
12
+ export const groupCommands = [
13
+ {
14
+ name: "groups list",
15
+ summary: "List the project's test groups and how many tests each holds",
16
+ scope: "project",
17
+ groupDefault: true,
18
+ groupSummary: "Manage a project's test groups — labels a test can carry any number of, used to filter, run, or schedule a slice of the suite. Groups are created only here (or in Settings → Groups); assigning a test to an unknown name is an error.",
19
+ async run(ctx, input) {
20
+ const { workspaceId, projectId } = await ctx.requireProject(input);
21
+ return { data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/groups`) };
22
+ },
23
+ },
24
+ {
25
+ name: "groups create",
26
+ summary: "Create a group",
27
+ scope: "project",
28
+ args: [{ name: "name", description: "Group name (unique per project)", required: true }],
29
+ examples: ["beryl groups create Smoke"],
30
+ async run(ctx, input) {
31
+ const { workspaceId, projectId } = await ctx.requireProject(input);
32
+ return {
33
+ data: await ctx.client.post(`${projectPath(workspaceId, projectId)}/groups`, {
34
+ name: arg(input, "name"),
35
+ }),
36
+ };
37
+ },
38
+ },
39
+ {
40
+ name: "groups rename",
41
+ summary: "Rename a group (tests keep their membership)",
42
+ scope: "project",
43
+ args: [
44
+ { name: "group", description: "Current group name or id", required: true },
45
+ { name: "name", description: "New name", required: true },
46
+ ],
47
+ async run(ctx, input) {
48
+ const { workspaceId, projectId } = await ctx.requireProject(input);
49
+ const group = await resolveGroup(ctx, workspaceId, projectId, arg(input, "group"));
50
+ return {
51
+ data: await ctx.client.patch(`${projectPath(workspaceId, projectId)}/groups/${group.id}`, { name: arg(input, "name") }),
52
+ };
53
+ },
54
+ },
55
+ {
56
+ name: "groups delete",
57
+ summary: "Delete a group — its tests stay, its schedule (if any) goes with it",
58
+ scope: "project",
59
+ args: [{ name: "group", description: "Group name or id", required: true }],
60
+ flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
61
+ async run(ctx, input) {
62
+ const { workspaceId, projectId } = await ctx.requireProject(input);
63
+ const group = await resolveGroup(ctx, workspaceId, projectId, arg(input, "group"));
64
+ await ctx.confirm(`Delete group '${group.name}' (${group.test_count ?? 0} tests leave it)?`, flagBool(input, "force"));
65
+ await ctx.client.del(`${projectPath(workspaceId, projectId)}/groups/${group.id}`);
66
+ return { human: "Deleted." };
67
+ },
68
+ },
69
+ ];
@@ -7,7 +7,7 @@ import { countPlannedFrames, countWrittenFrames, PlaywrightMissingError, } from
7
7
  import { dim, green, red, yellow } from "../output.js";
8
8
  import { anyGap, confirmInstall, describeGaps, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
9
9
  import { ProgressBar } from "../progress.js";
10
- import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
10
+ import { arg, flagBool, flagNum, flagStr, idsInGroup, projectPath } from "./util.js";
11
11
  import { watchRun } from "./watch.js";
12
12
  const MAX_FAILURE_SCREENSHOTS = 5;
13
13
  // One filesystem check before a single spec is fetched. A missing browser binary otherwise
@@ -50,10 +50,19 @@ export const runCommands = [
50
50
  name: "runs trigger",
51
51
  summary: "Trigger a test run (whole suite, a subset, or one environment)",
52
52
  description: "Runs execute in Beryl's cloud. With --watch the CLI streams live progress and " +
53
- "exits 0 only if every test passed — wire it straight into CI.",
53
+ "exits 0 only if every test passed — wire it straight into CI. A run with a " +
54
+ "heal-eligible failure completes only after Beryl has tried to heal it: a repaired " +
55
+ "test is re-run inside the same run and counts as passed (reported as `healed`), so " +
56
+ "the final counts, the report and the completion email all reflect the repair.",
54
57
  scope: "project",
55
58
  flags: [
56
59
  { name: "test", type: "strings", description: "Run only these test ids (repeatable)" },
60
+ {
61
+ name: "group",
62
+ type: "string",
63
+ description: "Run only the active tests in this group (by name) — resolved to ids before the run, " +
64
+ "so the run records exactly what it ran. Cannot be combined with --test.",
65
+ },
57
66
  { name: "env", type: "string", description: "Environment id to run against" },
58
67
  { name: "url-override", type: "string", description: "Replace the base URL (preview deploys)" },
59
68
  {
@@ -75,11 +84,22 @@ export const runCommands = [
75
84
  "beryl runs trigger --url-override https://preview-123.example.com --watch --timeout 30",
76
85
  "beryl runs trigger --url-override https://preview-123.example.com --header x-vercel-protection-bypass=<token> --watch",
77
86
  "beryl runs trigger --test 4f… --test 9a…",
87
+ "beryl runs trigger --group Smoke --watch",
78
88
  "beryl runs trigger --retries 0 --watch",
79
89
  ],
80
90
  async run(ctx, input) {
81
91
  const { workspaceId, projectId } = await ctx.requireProject(input);
82
- const tests = input.flags.test;
92
+ let tests = input.flags.test;
93
+ const group = flagStr(input, "group");
94
+ if (group && tests && tests.length > 0)
95
+ throw new UsageError("--group cannot be combined with --test");
96
+ if (group) {
97
+ const listed = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests`));
98
+ tests = idsInGroup(listed, group);
99
+ // Exiting 0 here would be a green CI run that tested nothing.
100
+ if (tests.length === 0)
101
+ throw new UsageError(`No active tests in group '${group}'.`);
102
+ }
83
103
  const extraHeaders = parseHeaders(input.flags.header);
84
104
  const created = (await ctx.client.post(`${projectPath(workspaceId, projectId)}/runs`, {
85
105
  test_case_ids: tests && tests.length > 0 ? tests : null,
@@ -131,6 +151,11 @@ export const runCommands = [
131
151
  type: "boolean",
132
152
  description: "Run every active test in the project (the default when no ids are given)",
133
153
  },
154
+ {
155
+ name: "group",
156
+ type: "string",
157
+ description: "Run only the active tests in this group (by name). Cannot be combined with test ids.",
158
+ },
134
159
  {
135
160
  name: "url-override",
136
161
  type: "string",
@@ -162,15 +187,23 @@ export const runCommands = [
162
187
  "beryl runs local 4f… --no-sync --dir ./beryl-local",
163
188
  ],
164
189
  async run(ctx, input) {
165
- await ensureRunnableLocally(ctx);
166
- const { workspaceId, projectId } = await ctx.requireProject(input);
190
+ // Argument validation runs BEFORE the Chromium/Playwright precondition: a bad flag
191
+ // combination should say so on any machine, not report a missing browser.
167
192
  // Deduped: a repeated id would put the same test twice in one imported run,
168
193
  // which the import manifest rejects.
169
194
  const explicitIds = [...new Set(input.args["test-ids"] ?? [])];
170
- // No ids means the whole suite — `beryl runs local` alone is a complete local run.
171
- const all = explicitIds.length === 0;
195
+ const group = flagStr(input, "group");
196
+ if (group && explicitIds.length > 0)
197
+ throw new UsageError("--group cannot be combined with explicit test ids");
172
198
  if (explicitIds.length > 0 && flagBool(input, "all"))
173
199
  throw new UsageError("--all cannot be combined with explicit test ids");
200
+ if (group && flagBool(input, "all"))
201
+ throw new UsageError("--all cannot be combined with --group");
202
+ // No ids and no group means the whole suite — `beryl runs local` alone is a
203
+ // complete local run.
204
+ const all = explicitIds.length === 0 && !group;
205
+ await ensureRunnableLocally(ctx);
206
+ const { workspaceId, projectId } = await ctx.requireProject(input);
174
207
  const sync = input.flags.sync !== false;
175
208
  const urlOverride = flagStr(input, "url-override");
176
209
  const dir = flagStr(input, "dir");
@@ -180,9 +213,15 @@ export const runCommands = [
180
213
  // The list rows carry the authored name as nl_title (title is the customer's
181
214
  // rename, usually unset) — fall through so the terminal shows names, not ids.
182
215
  const titles = new Map(listed.map((t) => [t.id, t.title || t.nl_title || t.id.slice(0, 8)]));
183
- const ids = all ? listed.filter((t) => t.is_active !== false).map((t) => t.id) : explicitIds;
216
+ const ids = all
217
+ ? listed.filter((t) => t.is_active !== false).map((t) => t.id)
218
+ : group
219
+ ? idsInGroup(listed, group)
220
+ : explicitIds;
184
221
  if (ids.length === 0)
185
- throw new CliError("This project has no tests to run.");
222
+ throw group
223
+ ? new UsageError(`No active tests in group '${group}'.`)
224
+ : new CliError("This project has no tests to run.");
186
225
  const fetchScript = (id) => ctx.client.get(`${projectPath(workspaceId, projectId)}/tests/${id}/script`, {
187
226
  // frames only matter when the run will be imported: they become the replay.
188
227
  // environment_id keeps the baked login_email on the same environment the
@@ -454,15 +493,36 @@ export const runCommands = [
454
493
  {
455
494
  name: "runs list",
456
495
  summary: "List recent runs",
496
+ description: "Returns the 50 most recent runs unless --page is given; pass --page to walk the " +
497
+ "full history a slice at a time.",
457
498
  scope: "project",
458
499
  groupDefault: true,
459
500
  groupSummary: "Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and download results.",
460
- flags: [{ name: "env", type: "string", description: "Filter by environment id" }],
501
+ flags: [
502
+ { name: "env", type: "string", description: "Filter by environment id" },
503
+ {
504
+ name: "page",
505
+ type: "number",
506
+ description: "Return only this 1-indexed page instead of the 50 most recent runs",
507
+ },
508
+ {
509
+ name: "page-size",
510
+ type: "number",
511
+ description: "Runs per page when --page is given (default 8, max 100)",
512
+ },
513
+ ],
461
514
  async run(ctx, input) {
462
515
  const { workspaceId, projectId } = await ctx.requireProject(input);
463
- const rows = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs`, {
464
- environment_id: flagStr(input, "env"),
465
- }));
516
+ const page = flagNum(input, "page");
517
+ const rows = page === undefined
518
+ ? (await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs`, {
519
+ environment_id: flagStr(input, "env"),
520
+ }))
521
+ : (await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs/page`, {
522
+ page,
523
+ page_size: flagNum(input, "page-size"),
524
+ environment_id: flagStr(input, "env"),
525
+ })).items;
466
526
  return { data: rows.map(({ test_results: _omit, ...row }) => row) };
467
527
  },
468
528
  },
@@ -7,7 +7,7 @@ import { PlaywrightMissingError } from "../local-run.js";
7
7
  import { dim, green, red, table, yellow } from "../output.js";
8
8
  import { confirmInstall, installPlaywright } from "../playwright-install.js";
9
9
  import { ACTION_PLAN_SCHEMA } from "../schema.generated.js";
10
- import { arg, argList, flagBool, flagNum, flagStr, projectPath, readJsonFlag } from "./util.js";
10
+ import { arg, argList, flagBool, flagNum, flagStr, flagStrings, inGroup, projectPath, readJsonFlag, } from "./util.js";
11
11
  const testPath = (ws, p, id) => `${projectPath(ws, p)}/tests/${id}`;
12
12
  // A duplicate-title 409 after a green replay is idempotent when the existing row
13
13
  // holds the same plan (a retry after a lost response) — success, not an error.
@@ -106,19 +106,62 @@ export const testCommands = [
106
106
  name: "tests list",
107
107
  summary: "List the project's tests with their latest result",
108
108
  description: "Prints a concise table by default (title / status / id / last result / last run). " +
109
- "Pass --wide for every field, or --json for the raw records.",
109
+ "Pass --wide for every field, or --json for the raw records. Returns every test " +
110
+ "unless --page is given; pass --page to walk a large project a slice at a time.",
110
111
  scope: "project",
111
112
  groupDefault: true,
112
113
  groupSummary: "Author, inspect, version, and heal a project's tests — the checks Beryl runs on each run.",
113
114
  flags: [
114
115
  { name: "env", type: "string", description: "Filter by environment id" },
116
+ {
117
+ name: "group",
118
+ type: "string",
119
+ description: "Show only tests in this group (by name; see `beryl groups list`)",
120
+ },
115
121
  { name: "wide", type: "boolean", description: "Show all columns, not the concise default" },
122
+ {
123
+ name: "page",
124
+ type: "number",
125
+ description: "Return only this 1-indexed page instead of every test",
126
+ },
127
+ {
128
+ name: "page-size",
129
+ type: "number",
130
+ description: "Tests per page when --page is given (default 20, max 100)",
131
+ },
132
+ {
133
+ name: "status",
134
+ type: "string",
135
+ description: "With --page, show only this bucket: passed, failed, or blocked",
136
+ },
137
+ {
138
+ name: "ungrouped",
139
+ type: "boolean",
140
+ description: "With --page, show only tests carrying no group",
141
+ },
116
142
  ],
117
143
  async run(ctx, input) {
118
144
  const { workspaceId, projectId } = await ctx.requireProject(input);
119
- const data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests`, {
120
- environment_id: flagStr(input, "env"),
121
- }));
145
+ const page = flagNum(input, "page");
146
+ const group = flagStr(input, "group");
147
+ let data;
148
+ if (page === undefined) {
149
+ data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests`, {
150
+ environment_id: flagStr(input, "env"),
151
+ }));
152
+ // GET /tests takes no group param; the paged route below filters server-side.
153
+ if (group)
154
+ data = data.filter((t) => inGroup(t, group));
155
+ }
156
+ else {
157
+ data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests/page`, {
158
+ page,
159
+ page_size: flagNum(input, "page-size"),
160
+ status: flagStr(input, "status"),
161
+ group: flagBool(input, "ungrouped") ? "__ungrouped__" : group,
162
+ environment_id: flagStr(input, "env"),
163
+ })).items;
164
+ }
122
165
  if (flagBool(input, "wide"))
123
166
  return { data };
124
167
  // `data` stays the full records so --json is unchanged; only the human table is trimmed.
@@ -201,6 +244,12 @@ export const testCommands = [
201
244
  description: "Replay against this base URL instead of the environment's (e.g. http://localhost:3000). " +
202
245
  "The banked test is then unproven against its real environment — the CLI says so.",
203
246
  },
247
+ {
248
+ name: "group",
249
+ type: "strings",
250
+ description: "Put the test in an existing project group (by name), repeatable. An unknown " +
251
+ "name is an error — create groups with `beryl groups create`. Omit for no group.",
252
+ },
204
253
  { name: "env", type: "string", description: "Environment id to compile and prove against" },
205
254
  {
206
255
  name: "sync",
@@ -230,10 +279,12 @@ export const testCommands = [
230
279
  const urlOverride = flagStr(input, "url-override");
231
280
  const env = flagStr(input, "env");
232
281
  const sync = input.flags.sync !== false;
282
+ const groups = flagStrings(input, "group");
233
283
  const bank = (extra) => ctx.client.post(`${projectPath(workspaceId, projectId)}/tests`, {
234
284
  title,
235
285
  plan,
236
286
  description,
287
+ groups,
237
288
  ...extra,
238
289
  });
239
290
  if (flagBool(input, "no-verify")) {
@@ -472,6 +523,35 @@ export const testCommands = [
472
523
  };
473
524
  },
474
525
  },
526
+ {
527
+ name: "tests set-groups",
528
+ summary: "Replace the groups a test belongs to",
529
+ description: "Groups are project-defined labels used to filter, run, or schedule a slice of the " +
530
+ "suite (`beryl runs trigger --group <name>`). This REPLACES the whole set — pass every " +
531
+ "group you want, or none to clear it. Names must already exist in the project " +
532
+ "(`beryl groups list`); an unknown name is an error, never a new group.",
533
+ scope: "project",
534
+ args: [{ name: "test-id", description: "Test id", required: true }],
535
+ flags: [
536
+ {
537
+ name: "group",
538
+ type: "strings",
539
+ description: "Group name, repeatable. Omit entirely to clear the set.",
540
+ },
541
+ ],
542
+ examples: [
543
+ "beryl tests set-groups 4f… --group Checkout --group Smoke",
544
+ "beryl tests set-groups 4f…",
545
+ ],
546
+ async run(ctx, input) {
547
+ const { workspaceId, projectId } = await ctx.requireProject(input);
548
+ return {
549
+ data: await ctx.client.patch(testPath(workspaceId, projectId, arg(input, "test-id")), {
550
+ groups: flagStrings(input, "group") ?? [],
551
+ }),
552
+ };
553
+ },
554
+ },
475
555
  {
476
556
  name: "tests quarantine",
477
557
  summary: "Mute a flaky test: it keeps running, but its failures stop failing the run",
@@ -54,3 +54,23 @@ export function readJsonFlag(input, name) {
54
54
  throw new UsageError(`--${name}: ${file} is not valid JSON (${err.message})`);
55
55
  }
56
56
  }
57
+ export function flagStrings(input, name) {
58
+ const v = input.flags[name];
59
+ return v && v.length > 0 ? v : undefined;
60
+ }
61
+ export function inGroup(row, group) {
62
+ const want = group.trim().toLowerCase();
63
+ return (row.groups ?? []).some((g) => g.toLowerCase() === want);
64
+ }
65
+ /** Resolve a --group name to the ids of the project's ACTIVE tests in it.
66
+ * Client-side on purpose: the run endpoint keeps one selection mechanism
67
+ * (test_case_ids), so a run always records the exact ids it ran even if group
68
+ * membership changes later. */
69
+ export function idsInGroup(rows, group) {
70
+ return rows.filter((t) => t.is_active !== false && inGroup(t, group)).map((t) => t.id);
71
+ }
72
+ /** Match a `--group` value against the project's groups by name (case-insensitive) or id. */
73
+ export function findGroup(groups, ref) {
74
+ const want = ref.trim().toLowerCase();
75
+ return groups.find((g) => g.id === ref || g.name.toLowerCase() === want);
76
+ }
@@ -68,7 +68,8 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes) {
68
68
  // is reported too, so a suite that only stays green by retrying can't hide it.
69
69
  const extra = (counters.cancelled ? yellow(`, ${counters.cancelled} cancelled`) : "") +
70
70
  (counters.quarantined ? yellow(`, ${counters.quarantined} quarantined`) : "") +
71
- (counters.flaky ? yellow(`, ${counters.flaky} flaky`) : "");
71
+ (counters.flaky ? yellow(`, ${counters.flaky} flaky`) : "") +
72
+ (counters.healed ? yellow(`, ${counters.healed} healed`) : "");
72
73
  const summary = failed > 0
73
74
  ? red(`${failed} failed`) + `, ${passed} passed` + extra
74
75
  : green(`${passed} passed`) + extra;
@@ -4,6 +4,7 @@ import { authCommands } from "../commands/auth.js";
4
4
  import { configCommands } from "../commands/config-vars.js";
5
5
  import { environmentCommands } from "../commands/environments.js";
6
6
  import { explorationCommands } from "../commands/explorations.js";
7
+ import { groupCommands } from "../commands/groups.js";
7
8
  import { healthCommands } from "../commands/health.js";
8
9
  import { mailboxCommands } from "../commands/mailboxes.js";
9
10
  import { initCommands } from "../commands/init.js";
@@ -57,6 +58,7 @@ export const commands = [
57
58
  ...projectCommands,
58
59
  ...environmentCommands,
59
60
  ...testCommands,
61
+ ...groupCommands,
60
62
  ...runCommands,
61
63
  ...healthCommands,
62
64
  ...explorationCommands,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.29.0",
3
+ "version": "0.32.0",
4
4
  "description": "Beryl on the command line — projects, runs, the exploring agent, and an MCP server over the same commands.",
5
5
  "license": "MIT",
6
6
  "type": "module",