@beryl-so/cli 0.46.3 → 0.47.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
@@ -220,6 +220,7 @@ Author, inspect, version, and heal a project's tests: the checks Beryl runs on e
220
220
  | `beryl tests set-plan <test-id>` | Replace a test's step plan from a JSON file (creates a new version) | `tests_set_plan` |
221
221
  | `beryl tests rename <test-id> <title>` | Rename a test | `tests_rename` |
222
222
  | `beryl tests set-groups <test-id>` | Replace the groups a test belongs to | `tests_set_groups` |
223
+ | `beryl tests set-access <test-id> <access>` | Mark a test read or write | `tests_set_access` |
223
224
  | `beryl tests add-groups <test-ids...>` | Add groups to one or more tests, keeping the groups they already have | `tests_add_groups` |
224
225
  | `beryl tests quarantine <test-id> <state>` | Mute a flaky test: it keeps running, but its failures stop failing the run | `tests_quarantine` |
225
226
  | `beryl tests delete <test-ids...>` | Delete tests, their version history, and their results | `tests_delete` |
@@ -97,10 +97,11 @@ Then, per test:
97
97
  1. **Drive the flow** in a real browser over the Playwright MCP — never author from
98
98
  imagination (§2).
99
99
  2. **Write the ActionPlan**, with one real outcome assertion (§2).
100
- 3. \`beryl tests lint --file plan.json\` — schema check, offline, no network.
101
- 4. \`beryl tests create --title … --file … --description "<the intent>"\` — replays the
102
- plan locally, banks it only on green, records no run; a red replay hands back the
103
- failure — fix and re-run (§3 for the intent, §4 for the loop).
100
+ 3. \`beryl tests lint --file plan.json --access read|write\` — schema and access check,
101
+ offline, no network.
102
+ 4. \`beryl tests create --title … --file … --access read|write --description "<the
103
+ intent>"\` — replays the plan locally, banks it only on green, records no run; a red
104
+ replay hands back the failure — fix and re-run (§3 for the intent, §4 for the loop).
104
105
  5. When the batch is banked, run the new ids together ONCE (\`beryl runs local <id…>\`,
105
106
  or \`beryl runs trigger\` for the cloud) so they land as one run with real first results.
106
107
 
@@ -151,6 +152,14 @@ The trade is drift — run 200 has 200 of everything run 1 created. So:
151
152
  your own victim first — in \`before\` or the opening steps, \`{{unique}}\`-named —
152
153
  then delete it and assert \`gone\`. Never aim a delete at standing data: run 2 would
153
154
  have nothing to delete, and it eats the account's accumulated coverage.
155
+ - **Every test is \`read\` or \`write\`.** \`read\` (the default) only looks. A test
156
+ that creates, saves, deletes, renames, invites, toggles or uploads anything is
157
+ \`write\`: bank it with \`--access write\`, and end its \`after\` with an \`expect\`
158
+ that proves the undo: \`gone\`, \`hidden\`, \`have_count\` or \`count_delta\` for a
159
+ removal, \`have_text\`, \`have_value\` or \`persisted\` for a restored value.
160
+ Create refuses a plan that writes without the label and a \`write\` plan
161
+ without that closing proof; a \`write\` test whose teardown found nothing to remove
162
+ fails the run. Flip a banked test with \`beryl tests set-access <id> write\`.
154
163
 
155
164
  ### If there is no account yet
156
165
 
@@ -261,7 +270,8 @@ leaves Beryl's cloud).
261
270
  gone) the teardown ends quietly right there instead of failing the test. Put it on
262
271
  the \`wait_for\` or \`expect\` that probes for the record, so the delete steps
263
272
  after it run only when there is something to delete; an optional \`click\` or
264
- \`fill\` must be the last step of \`after\`.
273
+ \`fill\` must be the last step of \`after\`, so a \`write\` test (§1), whose
274
+ \`after\` ends with the proof \`expect\`, never marks its delete optional.
265
275
 
266
276
  The full action vocabulary:
267
277
 
@@ -432,7 +442,8 @@ this test alone.
432
442
  | creates an account, or signs in as one it created (a returning-user test: \`before\` signs up the alias and logs out, the steps sign in as it) | an \`after\` section that deletes it through the app, its steps marked \`optional\`; a plan that reads its signup mail is refused at save time without one |
433
443
  | only makes mail arrive, read or not, no account created | nothing to clean up; mail is kept for 48 hours |
434
444
 
435
- Every alias minted is listed by \`beryl mailbox identities\` with how its teardown ended.
445
+ Every alias minted is listed by \`beryl mailbox identities\` with how its teardown
446
+ ended; the run detail shows the same outcome on each result.
436
447
 
437
448
  The wiring is a three-part chain:
438
449
 
@@ -557,11 +557,10 @@ export const runCommands = [
557
557
  "which keeps the output readable rather than saving a round trip. With --page the " +
558
558
  "match total and page counts come back under results_page. Paged results are the run's " +
559
559
  "settled tests only: anything still running, healing or queued is left out. A result " +
560
- "carries `heal` when Beryl tried to repair it: `healed` (steps rewritten with the intent " +
561
- "unchanged, proved in a browser, re-ran green; `version_no` is the revision it wrote, " +
562
- "see `tests diff`) or `heal_failed` (the repair did not restore the flow, so the app " +
563
- "changed), with the agent's rationale. The run's `summary` counts both as `healed` and " +
564
- "`heal_failed`. " +
560
+ "carries `heal` when Beryl healed it: its steps were rewritten with the intent " +
561
+ "unchanged, proved in a browser and it re-ran green; `version_no` is the revision it " +
562
+ "wrote (see `tests diff`) and `rationale` the agent's reason. A heal that did not turn " +
563
+ "the test green is not reported: a failure is a failure. " +
565
564
  "`runs report` returns the generated report document, `runs explain` an AI explanation " +
566
565
  "of one failed result.",
567
566
  scope: "project",
@@ -66,11 +66,20 @@ function verifyFailureResult(err) {
66
66
  // The concise human table for `tests list` — the full TestResponse is a 24-column
67
67
  // firehose of internal ids that wraps unreadably in a normal terminal. `--wide`
68
68
  // (and `--json`) still expose every field.
69
- const LIST_COLUMNS = ["title", "status", "id", "last_result_status", "last_run_at"];
69
+ const LIST_COLUMNS = ["title", "status", "access", "id", "last_result_status", "last_run_at"];
70
+ const ACCESS_VALUES = ["read", "write"];
71
+ function accessFlag(input, fallback) {
72
+ const raw = flagStr(input, "access") ?? fallback;
73
+ if (raw !== undefined && !ACCESS_VALUES.includes(raw)) {
74
+ throw new UsageError(`--access must be read or write, got "${raw}"`);
75
+ }
76
+ return raw;
77
+ }
70
78
  function conciseTestRow(row) {
71
79
  return {
72
80
  title: row.nl_title,
73
81
  status: row.quarantined ? "quarantined" : row.is_active ? "active" : "inactive",
82
+ access: row.access,
74
83
  id: row.id,
75
84
  last_result_status: row.last_result_status,
76
85
  last_run_at: row.last_run_at,
@@ -98,14 +107,22 @@ export const testCommands = [
98
107
  description: "The file is an account's signup or sign-in plan (for `accounts signup` / " +
99
108
  "`accounts set-login`), so it may fill {{mailbox_address}}",
100
109
  },
110
+ {
111
+ name: "access",
112
+ type: "string",
113
+ description: "The label the plan will be banked with, read (default) or write; a plan that " +
114
+ "creates, edits, deletes or toggles something must be write",
115
+ },
101
116
  ],
102
117
  examples: [
103
118
  "beryl tests lint --file plan.json",
119
+ "beryl tests lint --file plan.json --access write",
104
120
  "beryl tests lint --file signup-plan.json --provisioning",
105
121
  ],
106
122
  async run(_ctx, input) {
107
123
  const issues = lintPlan(readJsonFlag(input, "file"), {
108
124
  provisioning: flagBool(input, "provisioning"),
125
+ access: accessFlag(input, "read"),
109
126
  });
110
127
  const lines = issues.map((i) => `${i.severity === "warning" ? "warning" : "error"} ${i.path}: ${i.message}`);
111
128
  if (issues.every((i) => i.severity === "warning")) {
@@ -134,6 +151,7 @@ export const testCommands = [
134
151
  description: "Show only tests in this group (by name; see `beryl groups list`)",
135
152
  },
136
153
  { name: "wide", type: "boolean", description: "Show all columns, not the concise default" },
154
+ { name: "access", type: "string", description: "Only read or only write tests" },
137
155
  {
138
156
  name: "page",
139
157
  type: "number",
@@ -164,14 +182,17 @@ export const testCommands = [
164
182
  const { workspaceId, projectId } = await ctx.requireProject(input);
165
183
  const page = flagNum(input, "page");
166
184
  const group = flagStr(input, "group");
185
+ const access = accessFlag(input);
167
186
  let data;
168
187
  if (page === undefined) {
169
188
  data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests`, {
170
189
  environment_id: flagStr(input, "env"),
171
190
  }));
172
- // GET /tests takes no group param; the paged route below filters server-side.
191
+ // GET /tests takes no group or access param; the paged route filters server-side.
173
192
  if (group)
174
193
  data = data.filter((t) => inGroup(t, group));
194
+ if (access)
195
+ data = data.filter((t) => t.access === access);
175
196
  }
176
197
  else {
177
198
  data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests/page`, {
@@ -180,6 +201,7 @@ export const testCommands = [
180
201
  status: flagStr(input, "status"),
181
202
  group: flagBool(input, "ungrouped") ? "__ungrouped__" : group,
182
203
  q: flagStr(input, "q"),
204
+ access,
183
205
  environment_id: flagStr(input, "env"),
184
206
  })).items;
185
207
  }
@@ -274,6 +296,12 @@ export const testCommands = [
274
296
  "name is an error (create groups with `beryl groups create`). Omit for no group.",
275
297
  },
276
298
  { name: "env", type: "string", description: "Environment id to compile and prove against" },
299
+ {
300
+ name: "access",
301
+ type: "string",
302
+ description: "read (default): the test only reads the app. write: any step creates, edits, " +
303
+ "renames, deletes or toggles something; a writer must clean up in `after`",
304
+ },
277
305
  {
278
306
  name: "dir",
279
307
  type: "string",
@@ -295,11 +323,13 @@ export const testCommands = [
295
323
  const urlOverride = flagStr(input, "url-override");
296
324
  const env = flagStr(input, "env");
297
325
  const groups = flagStrings(input, "group");
326
+ const access = accessFlag(input, "read");
298
327
  const bank = (extra) => ctx.client.post(`${projectPath(workspaceId, projectId)}/tests`, {
299
328
  title,
300
329
  plan,
301
330
  description,
302
331
  groups,
332
+ access,
303
333
  ...extra,
304
334
  });
305
335
  if (flagBool(input, "no-verify")) {
@@ -547,6 +577,31 @@ export const testCommands = [
547
577
  };
548
578
  },
549
579
  },
580
+ {
581
+ name: "tests set-access",
582
+ summary: "Mark a test read or write",
583
+ description: "read: the test only reads the app. write: any step creates, edits, renames, deletes " +
584
+ "or toggles something. The server refuses a plan that writes on a read test, so flip " +
585
+ "the label before re-authoring such a plan. A write test must clean up in `after`.",
586
+ scope: "project",
587
+ args: [
588
+ { name: "test-id", description: "Test id", required: true },
589
+ { name: "access", description: "read | write", required: true },
590
+ ],
591
+ examples: ["beryl tests set-access 4f… write", "beryl tests set-access 4f… read"],
592
+ async run(ctx, input) {
593
+ const { workspaceId, projectId } = await ctx.requireProject(input);
594
+ const access = arg(input, "access").toLowerCase();
595
+ if (!ACCESS_VALUES.includes(access)) {
596
+ throw new UsageError(`Expected "read" or "write", got "${arg(input, "access")}"`);
597
+ }
598
+ return {
599
+ data: await ctx.client.patch(testPath(workspaceId, projectId, arg(input, "test-id")), {
600
+ access,
601
+ }),
602
+ };
603
+ },
604
+ },
550
605
  {
551
606
  name: "tests add-groups",
552
607
  summary: "Add groups to one or more tests, keeping the groups they already have",
@@ -180,7 +180,6 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes, opts = {
180
180
  total: run?.total_tests ?? settledCount(lastCounters),
181
181
  flaky: run?.summary?.flaky ?? 0,
182
182
  healed: run?.summary?.healed ?? 0,
183
- heal_failed: run?.summary?.heal_failed ?? 0,
184
183
  };
185
184
  const finalStatus = failFastStop ? "failed" : (run?.status ?? "unknown");
186
185
  // Quarantined reds are excluded from `failed` by the API (they land in their own
@@ -193,8 +192,7 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes, opts = {
193
192
  const extra = (counters.cancelled ? yellow(`, ${counters.cancelled} cancelled`) : "") +
194
193
  (counters.quarantined ? yellow(`, ${counters.quarantined} quarantined`) : "") +
195
194
  (counters.flaky ? yellow(`, ${counters.flaky} flaky`) : "") +
196
- (counters.healed ? yellow(`, ${counters.healed} healed`) : "") +
197
- (counters.heal_failed ? yellow(`, ${counters.heal_failed} heal failed`) : "");
195
+ (counters.healed ? yellow(`, ${counters.healed} healed`) : "");
198
196
  const summary = failed > 0
199
197
  ? red(`${failed} failed`) + `, ${passed} passed` + extra
200
198
  : green(`${passed} passed`) + extra;
package/dist/lint.js CHANGED
@@ -9,6 +9,13 @@ export const MAILBOX_ADDRESS_IN_TEST = "This plan types the standing account's a
9
9
  "and rides the account's session; one that needs an address the site can mail cites " +
10
10
  "{{inbox_address}}, an alias only this test reads. Only the account's own signup and " +
11
11
  "sign-in plans (accounts signup, accounts set-login) may fill {{mailbox_address}}.";
12
+ export const WRITE_NEEDS_LABEL = (label, detail) => `${label} (${detail}) changes the app, so this test must be marked access 'write' ` +
13
+ "(tests create --access write, or tests set-access <id> write) and clean up in " +
14
+ "'after'. A 'read' test only reads.";
15
+ export const WRITE_NEEDS_CLOSING_TEARDOWN = (detail) => "A 'write' test must end with an 'after' section that undoes what it changed and " +
16
+ "proves it: the last step of 'after' is an expect with expect_kind 'gone', 'hidden', " +
17
+ "'have_count', 'count_delta' (something removed) or 'have_text', 'have_value', " +
18
+ `'persisted' (something restored). ${detail}`;
12
19
  export const OPTIONAL_TEARDOWN_ACTION_MUST_BE_LAST = (label, action) => `${label} (${action}) is optional but not the last step of 'after'. An optional step ` +
13
20
  "that misses ends the teardown, so the steps after it would silently not run. Guard " +
14
21
  "them with an optional 'wait_for' or 'expect' instead, or move this step to the end.";
@@ -170,10 +177,69 @@ function identityIssues(plan, provisioning) {
170
177
  ? [{ path: "(root)", message: MINTED_IDENTITY_NEEDS_TEARDOWN }]
171
178
  : [];
172
179
  }
180
+ // Same verb list as the server's write_signal (api/app/ta_shared/schemas.py).
181
+ const WRITE_VERBS_RE = /\b(create|save|delete|remove|rename|revoke|enable|disable|invite|add|publish|archive|restore|quarantine|un-quarantine|turn on|turn off|join|decline)\b/i;
182
+ function writeSignal(plan) {
183
+ if (typeof plan !== "object" || plan === null)
184
+ return null;
185
+ const { before, steps: main, after } = plan;
186
+ if (steps(after).length > 0)
187
+ return ["after", "a teardown means something was created"];
188
+ const sectioned = steps(before).length > 0 || steps(after).length > 0;
189
+ const label = (section, i) => (sectioned ? `${section}[${i}]` : `step[${i}]`);
190
+ const all = [
191
+ ...steps(before).map((s, i) => [label("before", i), s]),
192
+ ...steps(main).map((s, i) => [label("steps", i), s]),
193
+ ];
194
+ for (const [where, s] of all) {
195
+ if (s.action === "upload")
196
+ return [where, "upload"];
197
+ if (s.action === "click" && typeof s.selector === "string") {
198
+ const hit = WRITE_VERBS_RE.exec(s.selector);
199
+ if (hit)
200
+ return [where, `click on '${hit[0]}'`];
201
+ }
202
+ }
203
+ return null;
204
+ }
205
+ const TEARDOWN_PROOF_KINDS = new Set([
206
+ "gone",
207
+ "hidden",
208
+ "have_count",
209
+ "count_delta",
210
+ "have_text",
211
+ "have_value",
212
+ "persisted",
213
+ ]);
214
+ function accessIssues(plan, access) {
215
+ if (access !== "write") {
216
+ const signal = writeSignal(plan);
217
+ return signal ? [{ path: "(root)", message: WRITE_NEEDS_LABEL(signal[0], signal[1]) }] : [];
218
+ }
219
+ if (typeof plan !== "object" || plan === null)
220
+ return [];
221
+ const after = steps(plan.after);
222
+ if (after.length === 0) {
223
+ return [{ path: "(root)", message: WRITE_NEEDS_CLOSING_TEARDOWN("This plan has no 'after'.") }];
224
+ }
225
+ const last = after[after.length - 1] ?? {};
226
+ const kind = typeof last.expect_kind === "string" ? last.expect_kind : undefined;
227
+ if (last.action !== "expect" || !kind || !TEARDOWN_PROOF_KINDS.has(kind)) {
228
+ const detail = `Its last step is ${String(last.action)}${kind ? ` ${kind}` : ""}.`;
229
+ return [{ path: "(root)", message: WRITE_NEEDS_CLOSING_TEARDOWN(detail) }];
230
+ }
231
+ return [];
232
+ }
173
233
  // `provisioning`: the plan is an account's own signup or sign-in plan, the one place
174
234
  // {{mailbox_address}} is legal (mirrors the server's validate_editable_plan flag).
235
+ // `access`: the label the plan will be banked with; given, a writing plan needs 'write'.
175
236
  export function lintPlan(plan, opts = {}) {
176
237
  const issues = validate(plan, ACTION_PLAN_SCHEMA, ACTION_PLAN_SCHEMA, "");
177
238
  // The identity rules read a well-formed plan; a shape error is reported alone.
178
- return issues.length > 0 ? issues : identityIssues(plan, opts.provisioning === true);
239
+ if (issues.length > 0)
240
+ return issues;
241
+ return [
242
+ ...identityIssues(plan, opts.provisioning === true),
243
+ ...(opts.access === undefined ? [] : accessIssues(plan, opts.access)),
244
+ ];
179
245
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.46.3",
3
+ "version": "0.47.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",