@beryl-so/cli 0.29.0 → 0.33.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.
@@ -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.
@@ -80,7 +80,7 @@ export const testCommands = [
80
80
  {
81
81
  name: "tests lint",
82
82
  summary: "Validate a plan JSON file offline, before sending it to the server",
83
- description: "Checks a plan against the published ActionPlan JSON Schema every action's required " +
83
+ description: "Checks a plan against the published ActionPlan JSON Schema: every action's required " +
84
84
  "fields, plus the two structural rules (the first EXECUTED step must be a goto, and at " +
85
85
  "least one step across before + steps must be an expect). Runs entirely locally, so a " +
86
86
  "malformed plan fails here instead of costing a server round-trip. " +
@@ -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
- groupSummary: "Author, inspect, version, and heal a project's tests the checks Beryl runs on each run.",
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.
@@ -129,7 +172,7 @@ export const testCommands = [
129
172
  name: "tests get",
130
173
  summary: "Show one test",
131
174
  description: "Returns the test's metadata row (status, flags, per-environment last result), not " +
132
- "the plan `tests plan` prints the stored JSON plan, `tests script` the rendered " +
175
+ "the plan. `tests plan` prints the stored JSON plan, `tests script` the rendered " +
133
176
  "Playwright spec.",
134
177
  scope: "project",
135
178
  args: [{ name: "test-id", description: "Test id", required: true }],
@@ -141,7 +184,7 @@ export const testCommands = [
141
184
  {
142
185
  name: "tests plan",
143
186
  summary: "Print a test's current step plan (JSON)",
144
- description: "Returns the stored json_plan of the test's current version `tests get` returns " +
187
+ description: "Returns the stored json_plan of the test's current version. `tests get` returns " +
145
188
  "the metadata row, `tests script` the rendered Playwright spec.",
146
189
  scope: "project",
147
190
  args: [{ name: "test-id", description: "Test id", required: true }],
@@ -155,30 +198,30 @@ export const testCommands = [
155
198
  },
156
199
  {
157
200
  name: "tests create",
158
- summary: "Create a test case from a JSON action plan for tests authored locally, e.g. by your coding agent",
201
+ summary: "Create a test case from a JSON action plan (for tests authored locally, e.g. by your coding agent)",
159
202
  description: "The plan is a JSON object whose steps are {action, selector, url, value, ...}: the first " +
160
203
  "EXECUTED step must be a goto, and at least one step must be an expect. Before anything is " +
161
204
  "banked, the plan is proven by replaying it in a browser ON YOUR MACHINE with your local " +
162
- "@playwright/test: the server renders the spec (`tests/compile`), the CLI runs it minting " +
205
+ "@playwright/test: the server renders the spec (`tests/compile`), the CLI runs it (minting " +
163
206
  "a run inbox for await_email steps and resolving the saved login exactly as a cloud run " +
164
- "would and only a green replay creates the test (bound to the replayed plan by its hash). " +
207
+ "would), and only a green replay creates the test (bound to the replayed plan by its hash). " +
165
208
  "This holds over MCP too: the replay runs on the machine hosting the MCP server, never on " +
166
209
  "Beryl's; if @playwright/test is missing there the tool returns the install commands (on a " +
167
210
  "terminal the CLI offers to install it). " +
168
211
  "A red replay banks NOTHING: the failure evidence comes back (over MCP the screenshot is " +
169
212
  "image content), you fix the plan file and re-run. The proving run is imported as the " +
170
213
  "test's first run (--no-sync to skip). A plan that signs in with a session Beryl captured " +
171
- "server-side cannot replay locally (that session never leaves Beryl's cloud) it falls " +
214
+ "server-side cannot replay locally (that session never leaves Beryl's cloud). It falls " +
172
215
  "back to server-side verification automatically, and says so. A session-mode plan replays locally " +
173
216
  "fine: the server renders it with its account's stored sign-in steps in front, so " +
174
217
  "the same identity is exercised on your machine. Optional `before` and `after` arrays hold setup and teardown " +
175
218
  "steps: `after` runs even when a main step fails, which is how a create/update/delete test " +
176
219
  "cleans up the record it made on the runs that go red. " +
177
- "Recovery: a 409 `duplicate_title` carries existing_test_id + existing_plan_hash " +
220
+ "Recovery: a 409 `duplicate_title` carries existing_test_id + existing_plan_hash, so " +
178
221
  "reconcile with that test (`tests get` / `tests set-plan`), don't rename-and-retry; a " +
179
222
  "409 `plan_hash_mismatch` means the submitted plan is not the bytes that were replayed " +
180
- "re-run `tests create`; a 429 with Retry-After 30 means the verify slots are " +
181
- "saturated wait and retry.",
223
+ "(re-run `tests create`); a 429 with Retry-After 30 means the verify slots are " +
224
+ "saturated (wait and retry).",
182
225
  scope: "project",
183
226
  flags: [
184
227
  { name: "title", type: "string", required: true, description: "Title for the new test" },
@@ -186,20 +229,26 @@ export const testCommands = [
186
229
  {
187
230
  name: "description",
188
231
  type: "string",
189
- description: "1–3 sentences stating what this test proves the immutable outcome Beryl's healing " +
232
+ description: "One to three sentences stating what this test proves: the immutable outcome Beryl's healing " +
190
233
  "checks against. State the purpose, not the steps; the one observable signal that's true " +
191
234
  "only if the flow worked.",
192
235
  },
193
236
  {
194
237
  name: "no-verify",
195
238
  type: "boolean",
196
- description: "Skip verification entirely bank the authored plan as-is, unproven",
239
+ description: "Skip verification entirely: bank the authored plan as-is, unproven",
197
240
  },
198
241
  {
199
242
  name: "url-override",
200
243
  type: "string",
201
244
  description: "Replay against this base URL instead of the environment's (e.g. http://localhost:3000). " +
202
- "The banked test is then unproven against its real environment the CLI says so.",
245
+ "The banked test is then unproven against its real environment, and the CLI says so.",
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.",
203
252
  },
204
253
  { name: "env", type: "string", description: "Environment id to compile and prove against" },
205
254
  {
@@ -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")) {
@@ -429,9 +480,9 @@ export const testCommands = [
429
480
  name: "tests set-plan",
430
481
  summary: "Replace a test's step plan from a JSON file (creates a new version)",
431
482
  description: "Accepts the same plan shape as `tests create`, including the optional `before` and " +
432
- "`after` sections `after` runs on pass and on fail, so cleanup happens even when the " +
483
+ "`after` sections. `after` runs on pass and on fail, so cleanup happens even when the " +
433
484
  "test goes red. Pass `--description` when the re-authored plan changes what the test " +
434
- "proves; omit it to keep the test's existing intent. Saves the edit with NO replay " +
485
+ "proves; omit it to keep the test's existing intent. Saves the edit with NO replay: " +
435
486
  "it rides into the next run unproven; `tests recompile` is the verify-first " +
436
487
  "alternative.",
437
488
  scope: "project",
@@ -441,7 +492,7 @@ export const testCommands = [
441
492
  {
442
493
  name: "description",
443
494
  type: "string",
444
- description: "1–3 sentences stating what this test proves the immutable outcome Beryl's healing " +
495
+ description: "One to three sentences stating what this test proves: the immutable outcome Beryl's healing " +
445
496
  "checks against. State the purpose, not the steps; the one observable signal that's true " +
446
497
  "only if the flow worked. Omit to keep the test's existing intent.",
447
498
  },
@@ -472,16 +523,45 @@ 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",
478
- description: "A quarantined test still executes and its result is still recorded and visible its " +
558
+ description: "A quarantined test still executes and its result is still recorded and visible. Its " +
479
559
  "red lands in the run's quarantined_count and gates neither the run's verdict nor exit " +
480
560
  "codes, so it can't red-light a deploy. Use " +
481
561
  "it on a persistently flaky test instead of deleting it (which destroys the history) or " +
482
562
  "asking support to deactivate it (which stops it running at all). After 5 consecutive " +
483
- "clean passes the test reports rehab_ready advisory only, nothing un-quarantines " +
484
- "itself. `off` un-quarantines.",
563
+ "clean passes the test reports rehab_ready (advisory only, nothing un-quarantines " +
564
+ "itself). `off` un-quarantines.",
485
565
  scope: "project",
486
566
  args: [
487
567
  { name: "test-id", description: "Test id", required: true },
@@ -518,11 +598,11 @@ export const testCommands = [
518
598
  summary: "Validate + verify an edited plan against the live site before persisting",
519
599
  description: "Unlike `tests set-plan` (which saves the edit and lets it ride into the next run), " +
520
600
  "this replays the edited plan against the live site before anything persists. A " +
521
- "deterministic replay failure (verdict `drop`) REJECTS the edit persisted:false, " +
522
- "the prior plan stays live and returns the failure evidence (over MCP the " +
601
+ "deterministic replay failure (verdict `drop`) REJECTS the edit (persisted:false, " +
602
+ "the prior plan stays live) and returns the failure evidence (over MCP the " +
523
603
  "screenshot is image content). Verdict `flag` (the runner errored, no verdict on " +
524
604
  "the flow) persists the plan but reports it unverified. Returns 429 with " +
525
- "Retry-After 30 when the 2 inline-verify slots are saturated wait and retry.",
605
+ "Retry-After 30 when the 2 inline-verify slots are saturated (wait and retry).",
526
606
  scope: "project",
527
607
  args: [{ name: "test-id", description: "Test id", required: true }],
528
608
  flags: [
@@ -591,7 +671,7 @@ export const testCommands = [
591
671
  {
592
672
  name: "tests restore",
593
673
  summary: "Restore a test to an earlier version",
594
- description: "Copies the named older version's plan forward as a NEW head version unlike " +
674
+ description: "Copies the named older version's plan forward as a NEW head version, unlike " +
595
675
  "`tests reset`, which flips authored_by back to `system` and leaves the plan " +
596
676
  "untouched.",
597
677
  scope: "project",
@@ -610,7 +690,7 @@ export const testCommands = [
610
690
  name: "tests reset",
611
691
  summary: "Discard user edits and return the test to its latest system-authored version",
612
692
  description: "Flips authored_by back to `system` WITHOUT changing the plan (the next " +
613
- "regeneration overwrites it) unlike `tests restore`, which copies an older " +
693
+ "regeneration overwrites it), unlike `tests restore`, which copies an older " +
614
694
  "version's plan forward as a new version.",
615
695
  scope: "project",
616
696
  args: [{ name: "test-id", description: "Test id", required: true }],
@@ -659,9 +739,9 @@ export const testCommands = [
659
739
  name: "tests script",
660
740
  summary: "Print the rendered Playwright spec for a test (or an unbanked plan file)",
661
741
  description: "With a test id, fetches the banked test's rendered .spec.ts. With --file, compiles a " +
662
- "plan JSON that has NOT been banked yet the same render `tests create` proves locally " +
742
+ "plan JSON that has NOT been banked yet (the same render `tests create` proves locally) " +
663
743
  "so you can inspect exactly what would run before creating anything. This returns the " +
664
- "executable spec `tests plan` returns the stored JSON plan it is rendered from, " +
744
+ "executable spec. `tests plan` returns the stored JSON plan it is rendered from, " +
665
745
  "`tests get` the metadata row.",
666
746
  scope: "project",
667
747
  args: [{ name: "test-id", description: "Test id (omit when passing --file)" }],
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.29.0",
4
- "description": "Beryl on the command line projects, runs, the exploring agent, and an MCP server over the same commands.",
3
+ "version": "0.33.0",
4
+ "description": "Beryl on the command line \u2014 projects, runs, the exploring agent, and an MCP server over the same commands.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "homepage": "https://beryl.so/docs/cli",