@beryl-so/cli 0.43.0 → 0.44.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
 
@@ -392,19 +403,20 @@ receipt — is testable with the \`await_email\` action. No setup, no configurat
392
403
  flag to turn on.
393
404
 
394
405
  **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:
406
+ handles put an address on the page; pick by what the test is about:
397
407
 
398
- | Handle | Renders as | Use it for |
408
+ | The test... | Cite | Then |
399
409
  |---|---|---|
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 |
402
-
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.
410
+ | signs in as the project's standing account, or only needs a code to arrive somewhere (a wrong code, a lockout, a resend countdown, the code screen) | \`{{mailbox_address}}\` | nothing to clean up |
411
+ | must hand the site an address it has never seen but creates no account (a code sent to a new address, never verified) | \`{{inbox_address}}\` | nothing on Beryl's side; the site may still keep a row |
412
+ | creates an account or an invitation (a signup, an accepted invite) | \`{{inbox_address}}\` | an \`after\` section that deletes it through the app, its steps marked \`optional\` |
413
+
414
+ \`{{inbox_address}}\` renders a \`+tag\` alias of the mailbox, fresh every run, so a
415
+ signup is repeatable; the mail lands in the same mailbox and Beryl reads only the
416
+ alias's own mail. Beryl records every alias it mints (\`beryl mailbox identities\`)
417
+ together with how the teardown ended. A plan that signs up with \`{{inbox_address}}\`
418
+ and reads the mail is refused at save time unless \`after\` deletes the account; one
419
+ that mints but never reads mail is flagged by \`tests lint\`. Mail is kept for 48 hours.
408
420
 
409
421
  The wiring is a three-part chain:
410
422
 
@@ -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
  },
@@ -11,7 +11,9 @@ export const mailboxCommands = [
11
11
  description: "Returns the address {{mailbox_address}} resolves to, creating it on first ask. " +
12
12
  "Every test that reads mail receives here. A test needing an address the site has " +
13
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.",
14
+ "same mailbox, so a signup stays repeatable without a second address to manage. " +
15
+ "Every alias minted is listed by `mailbox identities`, and a plan that signs up " +
16
+ "with one must delete the account in its `after` section.",
15
17
  scope: "project",
16
18
  async run(ctx, input) {
17
19
  const { workspaceId, projectId } = await ctx.requireProject(input);
@@ -42,6 +44,47 @@ export const mailboxCommands = [
42
44
  };
43
45
  },
44
46
  },
47
+ {
48
+ name: "mailbox identities",
49
+ summary: "The +tag addresses Beryl minted for this project's tests, and how each cleanup ended",
50
+ description: "Every {{inbox_address}} a run rendered is recorded: which test and run minted it, and " +
51
+ "what the runner saw of its teardown (clean, skipped, failed, unverified, or none when " +
52
+ "the plan has no `after` section). This is the list of identities that may still exist " +
53
+ "on the site under test. Rows are kept for 30 days.",
54
+ scope: "project",
55
+ flags: [
56
+ { name: "run", type: "string", description: "Only addresses minted by this run id" },
57
+ { name: "test", type: "string", description: "Only addresses minted by this test id" },
58
+ { name: "limit", type: "number", description: "Newest N rows (default 100, max 500)" },
59
+ ],
60
+ examples: ["beryl mailbox identities", "beryl mailbox identities --run run_123 --json"],
61
+ async run(ctx, input) {
62
+ const { workspaceId, projectId } = await ctx.requireProject(input);
63
+ const rows = (await ctx.client.get(`${mailboxPath(workspaceId)}/identities`, {
64
+ project_id: projectId,
65
+ run_id: flagStr(input, "run"),
66
+ test_id: flagStr(input, "test"),
67
+ limit: flagNum(input, "limit"),
68
+ }));
69
+ if (rows.length === 0) {
70
+ return {
71
+ data: rows,
72
+ human: dim("No minted addresses yet. Tests that sign up with {{inbox_address}} appear here."),
73
+ };
74
+ }
75
+ return {
76
+ data: rows,
77
+ human: table(rows.map((r) => ({
78
+ address: r.address,
79
+ teardown: r.teardown_outcome ?? "open",
80
+ source: r.source,
81
+ test: r.test_case_id ?? "",
82
+ run: r.test_run_id ?? "",
83
+ minted: r.minted_at,
84
+ }))),
85
+ };
86
+ },
87
+ },
45
88
  {
46
89
  name: "mailbox create",
47
90
  summary: "Add a second mailbox to the project",
@@ -82,7 +82,10 @@ 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 " +
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` (an error; the server refuses it too), and one that mints an address it " +
88
+ "never reads gets a warning to cite {{mailbox_address}}. Runs entirely locally, so a " +
86
89
  "malformed plan fails here instead of costing a server round-trip. " +
87
90
  `Schema: ${ACTION_PLAN_SCHEMA.$id}`,
88
91
  scope: "none",
@@ -92,14 +95,14 @@ export const testCommands = [
92
95
  examples: ["beryl tests lint --file plan.json"],
93
96
  async run(_ctx, input) {
94
97
  const issues = lintPlan(readJsonFlag(input, "file"));
95
- if (issues.length === 0) {
96
- return { data: { valid: true, issues: [] }, human: "Plan is valid." };
98
+ const lines = issues.map((i) => `${i.severity === "warning" ? "warning" : "error"} ${i.path}: ${i.message}`);
99
+ if (issues.every((i) => i.severity === "warning")) {
100
+ return {
101
+ data: { valid: true, issues },
102
+ human: ["Plan is valid.", ...lines].join("\n"),
103
+ };
97
104
  }
98
- return {
99
- data: { valid: false, issues },
100
- human: issues.map((i) => `${i.path}: ${i.message}`).join("\n"),
101
- exitCode: 1,
102
- };
105
+ return { data: { valid: false, issues }, human: lines.join("\n"), exitCode: 1 };
103
106
  },
104
107
  },
105
108
  {
package/dist/lint.js CHANGED
@@ -1,4 +1,16 @@
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 " +
7
+ "quietly), or cite {{mailbox_address}} if no new account is meant to exist.";
8
+ export const OPTIONAL_TEARDOWN_ACTION_MUST_BE_LAST = (label, action) => `${label} (${action}) is optional but not the last step of 'after'. An optional step ` +
9
+ "that misses ends the teardown, so the steps after it would silently not run. Guard " +
10
+ "them with an optional 'wait_for' or 'expect' instead, or move this step to the end.";
11
+ export const MINTED_IDENTITY_UNUSED = "This plan cites {{inbox_address}} but never reads the mail it triggers, so it mints " +
12
+ "an identity it never uses. Cite {{mailbox_address}} instead, unless the site must be " +
13
+ "handed an address it has never seen.";
2
14
  const typeOf = (value) => {
3
15
  if (value === null)
4
16
  return "null";
@@ -120,6 +132,43 @@ function validate(value, raw, root, path) {
120
132
  }
121
133
  return issues;
122
134
  }
135
+ const steps = (section) => Array.isArray(section)
136
+ ? section.filter((s) => typeof s === "object" && s !== null)
137
+ : [];
138
+ const citesInboxAddress = (step) => Object.values(step).some((v) => typeof v === "string" && v.includes("{{inbox_address}}"));
139
+ const TEARDOWN_GUARD_ACTIONS = new Set(["wait_for", "expect"]);
140
+ function optionalPlacementIssues(after) {
141
+ return after.flatMap((s, i) => s.optional === true && !TEARDOWN_GUARD_ACTIONS.has(String(s.action)) && i !== after.length - 1
142
+ ? [
143
+ {
144
+ path: `after[${i}]`,
145
+ message: OPTIONAL_TEARDOWN_ACTION_MUST_BE_LAST(`after[${i}]`, String(s.action)),
146
+ },
147
+ ]
148
+ : []);
149
+ }
150
+ function mintedIdentityIssues(plan) {
151
+ if (typeof plan !== "object" || plan === null)
152
+ return [];
153
+ const { before, steps: main, after } = plan;
154
+ const placement = optionalPlacementIssues(steps(after));
155
+ if (placement.length > 0)
156
+ return placement;
157
+ const body = [...steps(before), ...steps(main)];
158
+ if (![...body, ...steps(after)].some(citesInboxAddress))
159
+ return [];
160
+ const readsMail = body.some((s) => s.action === "await_email");
161
+ const tearsDown = steps(after).some((s) => s.action !== "expect");
162
+ if (readsMail && !tearsDown) {
163
+ return [{ path: "(root)", message: MINTED_IDENTITY_NEEDS_TEARDOWN }];
164
+ }
165
+ if (!readsMail) {
166
+ return [{ path: "(root)", message: MINTED_IDENTITY_UNUSED, severity: "warning" }];
167
+ }
168
+ return [];
169
+ }
123
170
  export function lintPlan(plan) {
124
- return validate(plan, ACTION_PLAN_SCHEMA, ACTION_PLAN_SCHEMA, "");
171
+ const issues = validate(plan, ACTION_PLAN_SCHEMA, ACTION_PLAN_SCHEMA, "");
172
+ // The identity rules read a well-formed plan; a shape error is reported alone.
173
+ return issues.length > 0 ? issues : mintedIdentityIssues(plan);
125
174
  }
@@ -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.44.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",