@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 +1 -0
- package/dist/beryl-test-skill.js +24 -12
- package/dist/commands/accounts.js +2 -1
- package/dist/commands/mailboxes.js +44 -1
- package/dist/commands/tests.js +11 -8
- package/dist/lint.js +50 -1
- package/dist/schema.generated.js +5 -0
- package/package.json +1 -1
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` |
|
package/dist/beryl-test-skill.js
CHANGED
|
@@ -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;
|
|
396
|
-
acts as:
|
|
406
|
+
handles put an address on the page; pick by what the test is about:
|
|
397
407
|
|
|
398
|
-
|
|
|
408
|
+
| The test... | Cite | Then |
|
|
399
409
|
|---|---|---|
|
|
400
|
-
|
|
|
401
|
-
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
|
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",
|
package/dist/commands/tests.js
CHANGED
|
@@ -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).
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/schema.generated.js
CHANGED
package/package.json
CHANGED