@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 +1 -0
- package/dist/beryl-test-skill.js +28 -17
- package/dist/commands/accounts.js +2 -1
- package/dist/commands/mailboxes.js +48 -3
- package/dist/commands/tests.js +26 -11
- package/dist/lint.js +56 -2
- 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
|
|
|
@@ -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.**
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
|
399
|
-
|
|
400
|
-
| \`
|
|
401
|
-
|
|
|
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
|
-
|
|
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
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
|
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
|
|
13
|
-
"
|
|
14
|
-
"
|
|
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",
|
package/dist/commands/tests.js
CHANGED
|
@@ -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).
|
|
86
|
-
"
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
}
|
package/dist/schema.generated.js
CHANGED
package/package.json
CHANGED