@beryl-so/cli 0.42.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/runs.js +60 -3
- 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/runs.js
CHANGED
|
@@ -552,8 +552,13 @@ export const runCommands = [
|
|
|
552
552
|
description: "Over MCP the failure screenshots come back as viewable image content, so an agent can " +
|
|
553
553
|
"look at the page that broke instead of guessing from the error string. Set screenshots " +
|
|
554
554
|
"to false to skip fetching them. Ignored outside MCP (the terminal cannot show an image). " +
|
|
555
|
-
"Returns the run row with
|
|
556
|
-
"
|
|
555
|
+
"Returns the run row with every per-test result unless --page is given; pass --page to " +
|
|
556
|
+
"read a large run a slice at a time, optionally narrowed by --status, --group or --q, " +
|
|
557
|
+
"which keeps the output readable rather than saving a round trip. With --page the " +
|
|
558
|
+
"match total and page counts come back under results_page. Paged results are the run's " +
|
|
559
|
+
"settled tests only: anything still running, healing or queued is left out. " +
|
|
560
|
+
"`runs report` returns the generated report document, `runs explain` an AI explanation " +
|
|
561
|
+
"of one failed result.",
|
|
557
562
|
scope: "project",
|
|
558
563
|
args: [{ name: "run-id", description: "Run id", required: true }],
|
|
559
564
|
flags: [
|
|
@@ -563,10 +568,62 @@ export const runCommands = [
|
|
|
563
568
|
default: true,
|
|
564
569
|
description: "Attach failure screenshots as image content (MCP only; default true)",
|
|
565
570
|
},
|
|
571
|
+
{
|
|
572
|
+
name: "page",
|
|
573
|
+
type: "number",
|
|
574
|
+
description: "Return only this 1-indexed page of results instead of every result",
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
name: "page-size",
|
|
578
|
+
type: "number",
|
|
579
|
+
description: "Results per page when --page is given (default 20, max 100)",
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
name: "status",
|
|
583
|
+
type: "string",
|
|
584
|
+
description: "With --page, show only this bucket: passed, failed, errored, cancelled, or " +
|
|
585
|
+
"quarantined",
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
name: "group",
|
|
589
|
+
type: "string",
|
|
590
|
+
description: "With --page, show only results whose test is in this group (by name; see " +
|
|
591
|
+
"`beryl groups list`)",
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
name: "ungrouped",
|
|
595
|
+
type: "boolean",
|
|
596
|
+
description: "With --page, show only results whose test carries no group",
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
name: "q",
|
|
600
|
+
type: "string",
|
|
601
|
+
description: "With --page, show only results whose test title contains this text " +
|
|
602
|
+
"(case-insensitive)",
|
|
603
|
+
},
|
|
566
604
|
],
|
|
567
605
|
async run(ctx, input) {
|
|
568
606
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
569
|
-
const
|
|
607
|
+
const runPath = `${projectPath(workspaceId, projectId)}/runs/${arg(input, "run-id")}`;
|
|
608
|
+
const page = flagNum(input, "page");
|
|
609
|
+
// The run row (header, counters) only exists on the unpaged route, so --page still
|
|
610
|
+
// fetches it and then swaps test_results for the requested slice. This trims what
|
|
611
|
+
// the caller has to read, not what the api has to send.
|
|
612
|
+
const data = (await ctx.client.get(runPath));
|
|
613
|
+
if (page !== undefined) {
|
|
614
|
+
const { items, ...meta } = (await ctx.client.get(`${runPath}/results/page`, {
|
|
615
|
+
page,
|
|
616
|
+
page_size: flagNum(input, "page-size"),
|
|
617
|
+
status: flagStr(input, "status"),
|
|
618
|
+
group: flagBool(input, "ungrouped") ? "__ungrouped__" : flagStr(input, "group"),
|
|
619
|
+
q: flagStr(input, "q"),
|
|
620
|
+
}));
|
|
621
|
+
const paged = data;
|
|
622
|
+
paged.test_results = items;
|
|
623
|
+
// total / page / page_size / status_totals / group_counts ride along: without
|
|
624
|
+
// them a caller cannot tell whether another page exists short of asking for it.
|
|
625
|
+
paged.results_page = meta;
|
|
626
|
+
}
|
|
570
627
|
// Only the MCP adapter renders images; fetching bytes for a plain CLI run
|
|
571
628
|
// would be wasted network.
|
|
572
629
|
if (!ctx.mcp || input.flags.screenshots === false)
|
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