@beryl-so/cli 0.43.0 → 0.45.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
@@ -317,6 +317,7 @@ The project's standing email addresses, where its tests receive sign-in mail.
317
317
  | --- | --- | --- |
318
318
  | `beryl mailbox get` | The project's mailbox address | `mailbox_get` |
319
319
  | `beryl mailbox list` | List the project's mailboxes | `mailbox_list` |
320
+ | `beryl mailbox identities` | The +tag addresses Beryl minted for this project's tests, and how each cleanup ended | `mailbox_identities` |
320
321
  | `beryl mailbox create` | Add a second mailbox to the project | `mailbox_create` |
321
322
  | `beryl mailbox delete <mailbox-id>` | Delete a mailbox and every email it has received | `mailbox_delete` |
322
323
  | `beryl mailbox read <mailbox-id>` | Read the latest email in a mailbox (waits for one to arrive) | `mailbox_read` |
@@ -46,6 +46,12 @@ export const BERYL_TEST_SKILL_OTP_EXAMPLE_PLAN = {
46
46
  { action: "click", selector: "text=Verify" },
47
47
  { action: "expect", expect_kind: "visible", selector: "text=Welcome" },
48
48
  ],
49
+ after: [
50
+ { action: "goto", url: "/account" },
51
+ { action: "wait_for", selector: "text=Delete account", optional: true },
52
+ { action: "click", selector: "text=Delete account" },
53
+ { action: "click", selector: "[role=dialog] button:has-text(\"Delete\")" },
54
+ ],
49
55
  };
50
56
  export const BERYL_TEST_SKILL = `---
51
57
  name: beryl-test
@@ -238,7 +244,12 @@ never leaves Beryl's cloud).
238
244
  handoff, a magic link on another domain).
239
245
  Optional \`before\` / \`after\` arrays hold setup and teardown; \`after\` runs even
240
246
  when a main step fails, so a create flow can clean up its record — and a deletion
241
- flow creates its own record in \`before\` first.
247
+ flow creates its own record in \`before\` first. A step in \`after\` may carry
248
+ \`"optional": true\`: if it misses (the record was never created, or is already
249
+ gone) the teardown ends quietly right there instead of failing the test. Put it on
250
+ the \`wait_for\` or \`expect\` that probes for the record, so the delete steps
251
+ after it run only when there is something to delete; an optional \`click\` or
252
+ \`fill\` must be the last step of \`after\`.
242
253
 
243
254
  The full action vocabulary:
244
255
 
@@ -391,27 +402,27 @@ A flow that emails the user — a signup verification code, a magic sign-in link
391
402
  receipt — is testable with the \`await_email\` action. No setup, no configuration, no
392
403
  flag to turn on.
393
404
 
394
- **The project has one permanent mailbox and all of its mail arrives there.** Two
395
- handles put an address on the page; the one you cite decides which *identity* the test
396
- acts as:
405
+ **The project has one permanent mailbox and all of its mail arrives there.** The
406
+ standing account is used one way only, \`requires_auth: true\` (its session is injected
407
+ before the first step); a test never types its address (the save refuses it), only the
408
+ account's own \`signup\` / \`set-login\` plans fill \`{{mailbox_address}}\`. Every flow that
409
+ sends mail (a signup, a sign-in code, a wrong code, a lockout, a resend timer, an
410
+ invitation) cites \`{{inbox_address}}\`, a \`+tag\` alias minted per execution and read by
411
+ this test alone.
397
412
 
398
- | Handle | Renders as | Use it for |
399
- |---|---|---|
400
- | \`{{mailbox_address}}\` | the mailbox's own address, the same every run | signing in as the project's standing test account (§1) |
401
- | \`{{inbox_address}}\` | a \`+tag\` alias of it, fresh every run | tests whose subject IS getting a NEW identity a signup, an invited teammate |
413
+ | The test... | Then |
414
+ |---|---|
415
+ | 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 |
416
+ | only makes mail arrive, read or not, no account created | nothing to clean up; mail is kept for 48 hours |
402
417
 
403
- An alias is a real address the site has never issued, so a signup is repeatable run
404
- after run; the mail lands in the same mailbox and Beryl reads only the alias's own
405
- mail. Nothing expires; there is no second inbox to manage. Default to
406
- \`{{mailbox_address}}\`; reach for \`{{inbox_address}}\` only when an existing account
407
- would be rejected — a signup form, an invite accepted as a second person.
418
+ Every alias minted is listed by \`beryl mailbox identities\` with how its teardown ended.
408
419
 
409
420
  The wiring is a three-part chain:
410
421
 
411
- 1. **Type the address into the app** a \`fill\` with \`value: "{{inbox_address}}"\`
412
- (or \`{{mailbox_address}}\`). The alias is fresh every run, so a signup is
413
- repeatable by construction — no \`{{unique}}\` needed for the email itself; use it
414
- for other must-not-collide values like a username. The generator handles
422
+ 1. **Type the address into the app**: a \`fill\` with \`value: "{{inbox_address}}"\`.
423
+ The alias is fresh every run, so a signup is repeatable by construction, no
424
+ \`{{unique}}\` needed for the email itself; use it for other must-not-collide
425
+ values like a username. The generator handles
415
426
  \`{{unique}}\`, \`{{uuid}}\`, \`{{timestamp}}\` are each minted once per execution;
416
427
  \`{{timestamp}}\` is second-resolution (a time, NOT a uniqueness guarantee —
417
428
  \`{{unique}}\` is).
@@ -52,7 +52,8 @@ export const testAccountCommands = [
52
52
  return {
53
53
  data: rows,
54
54
  human: dim("No test accounts. Authenticated tests fall back to the LOGIN_EMAIL variable " +
55
- "and LOGIN_PASSWORD secret, or mint a throwaway with {{inbox_address}}."),
55
+ "and LOGIN_PASSWORD secret. A flow that must sign up fresh cites " +
56
+ "{{inbox_address}} and deletes the account in `after`."),
56
57
  };
57
58
  return { data: rows, human: accountTable(rows) };
58
59
  },
@@ -9,9 +9,13 @@ export const mailboxCommands = [
9
9
  groupDefault: true,
10
10
  groupSummary: "The project's standing email addresses, where its tests receive sign-in mail.",
11
11
  description: "Returns the address {{mailbox_address}} resolves to, creating it on first ask. " +
12
- "Every test that reads mail receives here. A test needing an address the site has " +
13
- "never seen cites {{inbox_address}} instead. That renders a `+tag` alias of this " +
14
- "same mailbox, so a signup stays repeatable without a second address to manage.",
12
+ "Every test that reads mail receives here, at its own `+tag` alias of this mailbox: " +
13
+ "a plan cites {{inbox_address}}, minted per execution and read by that test alone, " +
14
+ "so parallel tests never consume each other's codes. A test never types the bare " +
15
+ "address (the save refuses it); only an account's own signup and sign-in plans " +
16
+ "(`accounts signup`, `accounts set-login`) may fill it. " +
17
+ "Every alias minted is listed by `mailbox identities`, and a plan that signs up " +
18
+ "with one must delete the account in its `after` section.",
15
19
  scope: "project",
16
20
  async run(ctx, input) {
17
21
  const { workspaceId, projectId } = await ctx.requireProject(input);
@@ -42,6 +46,47 @@ export const mailboxCommands = [
42
46
  };
43
47
  },
44
48
  },
49
+ {
50
+ name: "mailbox identities",
51
+ summary: "The +tag addresses Beryl minted for this project's tests, and how each cleanup ended",
52
+ description: "Every {{inbox_address}} a run rendered is recorded: which test and run minted it, and " +
53
+ "what the runner saw of its teardown (clean, skipped, failed, unverified, or none when " +
54
+ "the plan has no `after` section). This is the list of identities that may still exist " +
55
+ "on the site under test. Rows are kept for 30 days.",
56
+ scope: "project",
57
+ flags: [
58
+ { name: "run", type: "string", description: "Only addresses minted by this run id" },
59
+ { name: "test", type: "string", description: "Only addresses minted by this test id" },
60
+ { name: "limit", type: "number", description: "Newest N rows (default 100, max 500)" },
61
+ ],
62
+ examples: ["beryl mailbox identities", "beryl mailbox identities --run run_123 --json"],
63
+ async run(ctx, input) {
64
+ const { workspaceId, projectId } = await ctx.requireProject(input);
65
+ const rows = (await ctx.client.get(`${mailboxPath(workspaceId)}/identities`, {
66
+ project_id: projectId,
67
+ run_id: flagStr(input, "run"),
68
+ test_id: flagStr(input, "test"),
69
+ limit: flagNum(input, "limit"),
70
+ }));
71
+ if (rows.length === 0) {
72
+ return {
73
+ data: rows,
74
+ human: dim("No minted addresses yet. Tests that sign up with {{inbox_address}} appear here."),
75
+ };
76
+ }
77
+ return {
78
+ data: rows,
79
+ human: table(rows.map((r) => ({
80
+ address: r.address,
81
+ teardown: r.teardown_outcome ?? "open",
82
+ source: r.source,
83
+ test: r.test_case_id ?? "",
84
+ run: r.test_run_id ?? "",
85
+ minted: r.minted_at,
86
+ }))),
87
+ };
88
+ },
89
+ },
45
90
  {
46
91
  name: "mailbox create",
47
92
  summary: "Add a second mailbox to the project",
@@ -82,24 +82,39 @@ export const testCommands = [
82
82
  summary: "Validate a plan JSON file offline, before sending it to the server",
83
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
- "least one step across before + steps must be an expect). Runs entirely locally, so a " +
86
- "malformed plan fails here instead of costing a server round-trip. " +
85
+ "least one step across before + steps must be an expect). Also the identity rules: a " +
86
+ "plan that signs up with {{inbox_address}} and reads the mail must delete the account " +
87
+ "in `after`, and no test plan may type {{mailbox_address}} (errors; the server refuses " +
88
+ "both too). Pass --provisioning for an account's signup or sign-in plan, which may fill " +
89
+ "{{mailbox_address}}. Runs entirely locally, so a malformed plan fails here instead of " +
90
+ "costing a server round-trip. " +
87
91
  `Schema: ${ACTION_PLAN_SCHEMA.$id}`,
88
92
  scope: "none",
89
93
  flags: [
90
94
  { name: "file", type: "string", required: true, description: "Plan JSON file, or - for stdin" },
95
+ {
96
+ name: "provisioning",
97
+ type: "boolean",
98
+ description: "The file is an account's signup or sign-in plan (for `accounts signup` / " +
99
+ "`accounts set-login`), so it may fill {{mailbox_address}}",
100
+ },
101
+ ],
102
+ examples: [
103
+ "beryl tests lint --file plan.json",
104
+ "beryl tests lint --file signup-plan.json --provisioning",
91
105
  ],
92
- examples: ["beryl tests lint --file plan.json"],
93
106
  async run(_ctx, input) {
94
- const issues = lintPlan(readJsonFlag(input, "file"));
95
- if (issues.length === 0) {
96
- return { data: { valid: true, issues: [] }, human: "Plan is valid." };
107
+ const issues = lintPlan(readJsonFlag(input, "file"), {
108
+ provisioning: flagBool(input, "provisioning"),
109
+ });
110
+ const lines = issues.map((i) => `${i.severity === "warning" ? "warning" : "error"} ${i.path}: ${i.message}`);
111
+ if (issues.every((i) => i.severity === "warning")) {
112
+ return {
113
+ data: { valid: true, issues },
114
+ human: ["Plan is valid.", ...lines].join("\n"),
115
+ };
97
116
  }
98
- return {
99
- data: { valid: false, issues },
100
- human: issues.map((i) => `${i.path}: ${i.message}`).join("\n"),
101
- exitCode: 1,
102
- };
117
+ return { data: { valid: false, issues }, human: lines.join("\n"), exitCode: 1 };
103
118
  },
104
119
  },
105
120
  {
package/dist/lint.js CHANGED
@@ -1,4 +1,17 @@
1
1
  import { ACTION_PLAN_SCHEMA } from "./schema.generated.js";
2
+ // Word for word what the server's save gate says (api/app/ta_shared/schemas.py);
3
+ // keep them identical.
4
+ export const MINTED_IDENTITY_NEEDS_TEARDOWN = "This plan signs up with {{inbox_address}} and reads the mail it triggers, so it " +
5
+ "leaves an account behind on the site. Add an 'after' section that deletes it " +
6
+ "through the app (mark steps optional so a missing account ends the teardown quietly).";
7
+ export const MAILBOX_ADDRESS_IN_TEST = "This plan types the standing account's address ({{mailbox_address}}) into the app. " +
8
+ "A test never signs in that way: one that needs to be signed in sets requires_auth " +
9
+ "and rides the account's session; one that needs an address the site can mail cites " +
10
+ "{{inbox_address}}, an alias only this test reads. Only the account's own signup and " +
11
+ "sign-in plans (accounts signup, accounts set-login) may fill {{mailbox_address}}.";
12
+ 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
+ "that misses ends the teardown, so the steps after it would silently not run. Guard " +
14
+ "them with an optional 'wait_for' or 'expect' instead, or move this step to the end.";
2
15
  const typeOf = (value) => {
3
16
  if (value === null)
4
17
  return "null";
@@ -120,6 +133,47 @@ function validate(value, raw, root, path) {
120
133
  }
121
134
  return issues;
122
135
  }
123
- export function lintPlan(plan) {
124
- return validate(plan, ACTION_PLAN_SCHEMA, ACTION_PLAN_SCHEMA, "");
136
+ const steps = (section) => Array.isArray(section)
137
+ ? section.filter((s) => typeof s === "object" && s !== null)
138
+ : [];
139
+ const cites = (handle) => (step) => Object.values(step).some((v) => typeof v === "string" && v.includes(`{{${handle}}}`));
140
+ const citesInboxAddress = cites("inbox_address");
141
+ const citesMailboxAddress = cites("mailbox_address");
142
+ const TEARDOWN_GUARD_ACTIONS = new Set(["wait_for", "expect"]);
143
+ function optionalPlacementIssues(after) {
144
+ return after.flatMap((s, i) => s.optional === true && !TEARDOWN_GUARD_ACTIONS.has(String(s.action)) && i !== after.length - 1
145
+ ? [
146
+ {
147
+ path: `after[${i}]`,
148
+ message: OPTIONAL_TEARDOWN_ACTION_MUST_BE_LAST(`after[${i}]`, String(s.action)),
149
+ },
150
+ ]
151
+ : []);
152
+ }
153
+ function identityIssues(plan, provisioning) {
154
+ if (typeof plan !== "object" || plan === null)
155
+ return [];
156
+ const { before, steps: main, after } = plan;
157
+ const placement = optionalPlacementIssues(steps(after));
158
+ if (placement.length > 0)
159
+ return placement;
160
+ const body = [...steps(before), ...steps(main)];
161
+ const all = [...body, ...steps(after)];
162
+ if (!provisioning && all.some(citesMailboxAddress)) {
163
+ return [{ path: "(root)", message: MAILBOX_ADDRESS_IN_TEST }];
164
+ }
165
+ if (!all.some(citesInboxAddress))
166
+ return [];
167
+ const readsMail = body.some((s) => s.action === "await_email");
168
+ const tearsDown = steps(after).some((s) => s.action !== "expect");
169
+ return readsMail && !tearsDown
170
+ ? [{ path: "(root)", message: MINTED_IDENTITY_NEEDS_TEARDOWN }]
171
+ : [];
172
+ }
173
+ // `provisioning`: the plan is an account's own signup or sign-in plan, the one place
174
+ // {{mailbox_address}} is legal (mirrors the server's validate_editable_plan flag).
175
+ export function lintPlan(plan, opts = {}) {
176
+ const issues = validate(plan, ACTION_PLAN_SCHEMA, ACTION_PLAN_SCHEMA, "");
177
+ // 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);
125
179
  }
@@ -1050,6 +1050,11 @@ export const ACTION_PLAN_SCHEMA = {
1050
1050
  "default": null,
1051
1051
  "title": "Option"
1052
1052
  },
1053
+ "optional": {
1054
+ "default": false,
1055
+ "title": "Optional",
1056
+ "type": "boolean"
1057
+ },
1053
1058
  "selector": {
1054
1059
  "anyOf": [
1055
1060
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.43.0",
3
+ "version": "0.45.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",