@beryl-so/cli 0.62.1 → 0.63.2

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.
@@ -198,14 +198,16 @@ The trade is drift — run 200 has 200 of everything run 1 created. So:
198
198
  uploads or toggles a record or setting that is still different once the test ends. A
199
199
  \`write\` must undo itself: end \`after\` with an \`expect\` proving the undo: \`gone\`,
200
200
  \`hidden\`, \`have_count\` or \`count_delta\` for a removal, \`have_text\`,
201
- \`have_value\` or \`persisted\` for a restore.
201
+ \`have_value\` or \`persisted\` for a restore. Type what it creates with
202
+ \`{{unique}}\` (\`Report {{unique}}\`) and target that same name in \`after\` and in
203
+ the proof: a teardown or count that targets a fixed typed name is refused.
202
204
  - **\`read\`** = it only observes: navigating, searching, filtering, sorting, opening a
203
205
  menu or dialog and reading it, or clicking a destructive button and then CANCELLING.
204
206
  Nothing is committed. Opening a confirm dialog and cancelling is a \`read\`; a submit
205
207
  that stays disabled is a \`read\`.
206
208
  A flow that both changes and reads is a \`write\`. Only the \`write\` label is checked
207
- (its closing teardown proof, and a \`write\` whose teardown removes nothing fails the
208
- run); \`read\` is taken at your word, so a real write you mislabel \`read\` silently
209
+ (its closing teardown proof and its \`{{unique}}\` names, and a \`write\` whose teardown
210
+ removes nothing fails the run); \`read\` is taken at your word, so a real write you mislabel \`read\` silently
209
211
  drifts the account. Label deliberately, and flip a banked test with \`tests_set_access\`.
210
212
 
211
213
  ### If there is no account yet
@@ -134,7 +134,7 @@ export const testCommands = [
134
134
  name: "access",
135
135
  type: "string",
136
136
  description: "The label the plan will be banked with: read (default) or write. It is yours to " +
137
- "set; Beryl does not infer it. Only a write is checked (its closing teardown proof)",
137
+ "set; Beryl does not infer it. Only a write is checked (its closing teardown proof and {{unique}} names)",
138
138
  },
139
139
  ],
140
140
  examples: [
@@ -111,7 +111,7 @@ export const workspaceCommands = [
111
111
  scope: "workspace",
112
112
  args: [
113
113
  { name: "user-id", description: "Member's user id", required: true },
114
- { name: "role", description: "OWNER or USER", required: true },
114
+ { name: "role", description: "OWNER, USER or VIEWER", required: true },
115
115
  ],
116
116
  async run(ctx, input) {
117
117
  const ws = await ctx.requireWorkspace(input);
@@ -142,7 +142,7 @@ export const workspaceCommands = [
142
142
  scope: "workspace",
143
143
  args: [{ name: "email", description: "Invitee email", required: true }],
144
144
  flags: [
145
- { name: "role", type: "string", enum: ["OWNER", "USER"], description: "Role (default USER)" },
145
+ { name: "role", type: "string", enum: ["OWNER", "USER", "VIEWER"], description: "Role (default USER)" },
146
146
  ],
147
147
  async run(ctx, input) {
148
148
  const ws = await ctx.requireWorkspace(input);
package/dist/lint.js CHANGED
@@ -13,6 +13,10 @@ export const WRITE_NEEDS_CLOSING_TEARDOWN = (detail) => "A 'write' test must end
13
13
  "proves it: the last step of 'after' is an expect with expect_kind 'gone', 'hidden', " +
14
14
  "'have_count', 'count_delta' (something removed) or 'have_text', 'have_value', " +
15
15
  `'persisted' (something restored). ${detail}`;
16
+ export const WRITE_NEEDS_UNIQUE = (detail) => "A 'write' test must tie what it creates, cleans up and counts to {{unique}}, so a " +
17
+ "record another run left behind can never match: type the record's name with " +
18
+ "{{unique}} (e.g. 'Report {{unique}}') and target it by that same name in " +
19
+ `'after' and in the closing proof. ${detail}`;
16
20
  export const OPTIONAL_TEARDOWN_ACTION_MUST_BE_LAST = (label, action) => `${label} (${action}) is optional but not the last step of 'after'. An optional step ` +
17
21
  "that misses ends the teardown, so the steps after it would silently not run. Guard " +
18
22
  "them with an optional 'wait_for' or 'expect' instead, or move this step to the end.";
@@ -234,12 +238,63 @@ function accessIssues(plan, access) {
234
238
  const detail = `Its last step is ${String(last.action)}${kind ? ` ${kind}` : ""}.`;
235
239
  return [{ path: "(root)", message: WRITE_NEEDS_CLOSING_TEARDOWN(detail) }];
236
240
  }
237
- return [];
241
+ return uniqueAnchorIssues(plan);
242
+ }
243
+ const COUNT_KINDS = new Set(["gone", "have_count"]);
244
+ // Shorter typed values ("1", "ok") would "name" half the page's buttons.
245
+ const MIN_TYPED_NAME_LEN = 3;
246
+ const SELECTOR_TEXT_RE = /text=["']?([^"'>]+?)["']?\s*(?:>>|$)|(?:has-text|text-is|text)\(\s*["']([^"']+)["']\s*\)|\[(?:name|title|aria-label)\s*[*^$]?=\s*["']([^"']+)["']/g;
247
+ // {{inbox_address}} is minted fresh per execution too, so a record named by it is its own.
248
+ const anchored = (text) => typeof text === "string" && /\{\{\s*(unique|uuid|inbox_address)\s*\}\}/.test(text);
249
+ const selectorTexts = (selector) => typeof selector === "string"
250
+ ? [...selector.matchAll(SELECTOR_TEXT_RE)].map((m) => m[1] ?? m[2] ?? m[3] ?? "")
251
+ : [];
252
+ const hasWords = (needle, haystack) => new RegExp(`(?<![\\p{L}\\p{N}_])${needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\p{L}\\p{N}_])`, "u").test(haystack);
253
+ // "Delete Seed" names a typed "Seed", and `text=Seed` names a typed "Seed alpha".
254
+ const names = (text, typed) => hasWords(typed, text) || (typed.startsWith(text) && hasWords(text, typed));
255
+ // The server words the selector with Python's repr; match it so the messages agree.
256
+ const pyRepr = (v) => typeof v !== "string" ? "None" : v.includes("'") && !v.includes('"') ? `"${v}"` : `'${v}'`;
257
+ // Mirrors _assert_unique_anchors in api/app/ta_shared/schemas.py.
258
+ function uniqueAnchorIssues(plan) {
259
+ const body = [...steps(plan.before), ...steps(plan.steps)];
260
+ const after = steps(plan.after);
261
+ const labelled = ["before", "steps", "after"].flatMap((name) => steps(plan[name]).map((s, i) => [`${name}[${i}]`, s, name === "after"]));
262
+ const typed = body
263
+ .filter((s) => s.action === "fill" && typeof s.value === "string" && s.value.length >= MIN_TYPED_NAME_LEN)
264
+ .map((s) => s.value);
265
+ const refuse = (label, s, why) => [
266
+ {
267
+ path: label,
268
+ message: WRITE_NEEDS_UNIQUE(`${label} (${String(s.action)} ${pyRepr(s.selector)}) ${why}`),
269
+ },
270
+ ];
271
+ for (const [label, s, inAfter] of labelled) {
272
+ if (!inAfter && !COUNT_KINDS.has(String(s.expect_kind)))
273
+ continue;
274
+ for (const text of selectorTexts(s.selector)) {
275
+ for (const value of typed) {
276
+ if (!names(text, value))
277
+ continue;
278
+ if (!anchored(value)) {
279
+ return refuse(label, s, `targets ${pyRepr(value)}, typed without {{unique}}.`);
280
+ }
281
+ if (!anchored(text))
282
+ return refuse(label, s, "targets a fixed name.");
283
+ }
284
+ }
285
+ }
286
+ const last = after[after.length - 1] ?? {};
287
+ const named = typed.some((v) => anchored(v) &&
288
+ (String(last.selector ?? "").includes(v) ||
289
+ selectorTexts(last.selector).some((t) => names(t, v))));
290
+ return anchored(last.selector) && !named
291
+ ? refuse(`after[${after.length - 1}]`, last, "names a {{unique}} record the test never typed.")
292
+ : [];
238
293
  }
239
294
  // `provisioning`: the plan is an account's own signup or sign-in plan, the one place
240
295
  // {{mailbox_address}} is legal (mirrors the server's validate_editable_plan flag).
241
296
  // `access`: the label the plan will be banked with. It is the author's call (Beryl no
242
- // longer infers it from the plan); given, only a `write` is checked, for its closing proof.
297
+ // longer infers it from the plan); given, only a `write` is checked: its closing proof and {{unique}} names.
243
298
  export function lintPlan(plan, opts = {}) {
244
299
  const issues = validate(plan, ACTION_PLAN_SCHEMA, ACTION_PLAN_SCHEMA, "");
245
300
  // The identity rules read a well-formed plan; a shape error is reported alone.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.62.1",
3
+ "version": "0.63.2",
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",