@beryl-so/cli 0.44.0 → 0.46.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 +18 -19
- package/dist/commands/mailboxes.js +5 -3
- package/dist/commands/projects.js +47 -0
- package/dist/commands/tests.js +21 -8
- package/dist/lint.js +22 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -175,6 +175,7 @@ Create and manage projects: a site Beryl explores, authors tests for, and runs.
|
|
|
175
175
|
| `beryl projects get` | Show one project, including its current exploration state | `projects_get` |
|
|
176
176
|
| `beryl projects create [url]` | Create a project. With a URL the agent starts exploring; with just --name an empty one | `projects_create` |
|
|
177
177
|
| `beryl projects rename <name>` | Rename a project | `projects_rename` |
|
|
178
|
+
| `beryl projects quarantine [state]` | Turn auto quarantine on or off and tune when it mutes and releases a test | `projects_quarantine` |
|
|
178
179
|
| `beryl projects delete` | Delete a project and all its tests and runs | `projects_delete` |
|
|
179
180
|
| `beryl projects re-explore` | Send the agent back in to run/heal existing tests and discover new flows | none |
|
|
180
181
|
| `beryl projects report` | Aggregate quality report across recent runs (pass rates, flaky tests, trend) | `projects_report` |
|
package/dist/beryl-test-skill.js
CHANGED
|
@@ -402,28 +402,27 @@ A flow that emails the user — a signup verification code, a magic sign-in link
|
|
|
402
402
|
receipt — is testable with the \`await_email\` action. No setup, no configuration, no
|
|
403
403
|
flag to turn on.
|
|
404
404
|
|
|
405
|
-
**The project has one permanent mailbox and all of its mail arrives there.**
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
that mints but never reads mail is flagged by \`tests lint\`. Mail is kept for 48 hours.
|
|
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.
|
|
412
|
+
|
|
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 |
|
|
417
|
+
|
|
418
|
+
Every alias minted is listed by \`beryl mailbox identities\` with how its teardown ended.
|
|
420
419
|
|
|
421
420
|
The wiring is a three-part chain:
|
|
422
421
|
|
|
423
|
-
1. **Type the address into the app
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
|
427
426
|
\`{{unique}}\`, \`{{uuid}}\`, \`{{timestamp}}\` are each minted once per execution;
|
|
428
427
|
\`{{timestamp}}\` is second-resolution (a time, NOT a uniqueness guarantee —
|
|
429
428
|
\`{{unique}}\` is).
|
|
@@ -9,9 +9,11 @@ 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. " +
|
|
15
17
|
"Every alias minted is listed by `mailbox identities`, and a plan that signs up " +
|
|
16
18
|
"with one must delete the account in its `after` section.",
|
|
17
19
|
scope: "project",
|
|
@@ -176,6 +176,53 @@ export const projectCommands = [
|
|
|
176
176
|
};
|
|
177
177
|
},
|
|
178
178
|
},
|
|
179
|
+
{
|
|
180
|
+
name: "projects quarantine",
|
|
181
|
+
summary: "Turn auto quarantine on or off and tune when it mutes and releases a test",
|
|
182
|
+
description: "With auto quarantine on, the end of every run checks the tests that ran: a test that " +
|
|
183
|
+
"failed --failures times in the last --days days with a pass in between is " +
|
|
184
|
+
"quarantined (straight failures are a regression and never are), and a quarantined " +
|
|
185
|
+
"test, manual or auto, is released after --passes first-attempt passes in a row. " +
|
|
186
|
+
"Pass on|off and/or any of the numbers; `projects get` shows the current values.",
|
|
187
|
+
scope: "project",
|
|
188
|
+
args: [{ name: "state", description: "on | off; omit to only tune the numbers" }],
|
|
189
|
+
flags: [
|
|
190
|
+
{ name: "failures", type: "number", description: "Failures in the window that quarantine a test (1-50, default 3)" },
|
|
191
|
+
{ name: "days", type: "number", description: "Window in days (1-90, default 7)" },
|
|
192
|
+
{ name: "passes", type: "number", description: "Passes in a row that release a test (1-50, default 5)" },
|
|
193
|
+
],
|
|
194
|
+
examples: [
|
|
195
|
+
"beryl projects quarantine on",
|
|
196
|
+
"beryl projects quarantine on --failures 3 --days 7 --passes 5",
|
|
197
|
+
"beryl projects quarantine --passes 3",
|
|
198
|
+
"beryl projects quarantine off",
|
|
199
|
+
],
|
|
200
|
+
async run(ctx, input) {
|
|
201
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
202
|
+
const body = {};
|
|
203
|
+
const state = input.args.state;
|
|
204
|
+
if (typeof state === "string" && state) {
|
|
205
|
+
const lower = state.toLowerCase();
|
|
206
|
+
if (lower !== "on" && lower !== "off") {
|
|
207
|
+
throw new UsageError(`Expected "on" or "off", got "${state}"`);
|
|
208
|
+
}
|
|
209
|
+
body.auto_quarantine = lower === "on";
|
|
210
|
+
}
|
|
211
|
+
const failures = flagNum(input, "failures");
|
|
212
|
+
const days = flagNum(input, "days");
|
|
213
|
+
const passes = flagNum(input, "passes");
|
|
214
|
+
if (failures !== undefined)
|
|
215
|
+
body.quarantine_failures = failures;
|
|
216
|
+
if (days !== undefined)
|
|
217
|
+
body.quarantine_days = days;
|
|
218
|
+
if (passes !== undefined)
|
|
219
|
+
body.quarantine_release_passes = passes;
|
|
220
|
+
if (Object.keys(body).length === 0) {
|
|
221
|
+
throw new UsageError("Pass on|off or at least one of --failures, --days, --passes");
|
|
222
|
+
}
|
|
223
|
+
return { data: await ctx.client.patch(projectPath(workspaceId, projectId), body) };
|
|
224
|
+
},
|
|
225
|
+
},
|
|
179
226
|
{
|
|
180
227
|
name: "projects delete",
|
|
181
228
|
summary: "Delete a project and all its tests and runs",
|
package/dist/commands/tests.js
CHANGED
|
@@ -84,17 +84,29 @@ export const testCommands = [
|
|
|
84
84
|
"fields, plus the two structural rules (the first EXECUTED step must be a goto, and at " +
|
|
85
85
|
"least one step across before + steps must be an expect). Also the identity rules: a " +
|
|
86
86
|
"plan that signs up with {{inbox_address}} and reads the mail must delete the account " +
|
|
87
|
-
"in `after
|
|
88
|
-
"
|
|
89
|
-
"malformed plan fails here instead of
|
|
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. " +
|
|
90
91
|
`Schema: ${ACTION_PLAN_SCHEMA.$id}`,
|
|
91
92
|
scope: "none",
|
|
92
93
|
flags: [
|
|
93
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",
|
|
94
105
|
],
|
|
95
|
-
examples: ["beryl tests lint --file plan.json"],
|
|
96
106
|
async run(_ctx, input) {
|
|
97
|
-
const issues = lintPlan(readJsonFlag(input, "file")
|
|
107
|
+
const issues = lintPlan(readJsonFlag(input, "file"), {
|
|
108
|
+
provisioning: flagBool(input, "provisioning"),
|
|
109
|
+
});
|
|
98
110
|
const lines = issues.map((i) => `${i.severity === "warning" ? "warning" : "error"} ${i.path}: ${i.message}`);
|
|
99
111
|
if (issues.every((i) => i.severity === "warning")) {
|
|
100
112
|
return {
|
|
@@ -575,9 +587,10 @@ export const testCommands = [
|
|
|
575
587
|
"red lands in the run's quarantined_count and gates neither the run's verdict nor exit " +
|
|
576
588
|
"codes, so it can't red-light a deploy. Use " +
|
|
577
589
|
"it on a persistently flaky test instead of deleting it (which destroys the history) or " +
|
|
578
|
-
"asking support to deactivate it (which stops it running at all). After
|
|
579
|
-
"
|
|
580
|
-
"
|
|
590
|
+
"asking support to deactivate it (which stops it running at all). After the project's " +
|
|
591
|
+
"release threshold of consecutive clean passes (default 5) the test reports " +
|
|
592
|
+
"rehab_ready: a suggestion while auto quarantine is off, the actual release once " +
|
|
593
|
+
"`projects quarantine on` has turned it on. `off` un-quarantines.",
|
|
581
594
|
scope: "project",
|
|
582
595
|
args: [
|
|
583
596
|
{ name: "test-id", description: "Test id", required: true },
|
package/dist/lint.js
CHANGED
|
@@ -3,14 +3,15 @@ import { ACTION_PLAN_SCHEMA } from "./schema.generated.js";
|
|
|
3
3
|
// keep them identical.
|
|
4
4
|
export const MINTED_IDENTITY_NEEDS_TEARDOWN = "This plan signs up with {{inbox_address}} and reads the mail it triggers, so it " +
|
|
5
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
|
-
|
|
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}}.";
|
|
8
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 ` +
|
|
9
13
|
"that misses ends the teardown, so the steps after it would silently not run. Guard " +
|
|
10
14
|
"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.";
|
|
14
15
|
const typeOf = (value) => {
|
|
15
16
|
if (value === null)
|
|
16
17
|
return "null";
|
|
@@ -135,7 +136,9 @@ function validate(value, raw, root, path) {
|
|
|
135
136
|
const steps = (section) => Array.isArray(section)
|
|
136
137
|
? section.filter((s) => typeof s === "object" && s !== null)
|
|
137
138
|
: [];
|
|
138
|
-
const
|
|
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");
|
|
139
142
|
const TEARDOWN_GUARD_ACTIONS = new Set(["wait_for", "expect"]);
|
|
140
143
|
function optionalPlacementIssues(after) {
|
|
141
144
|
return after.flatMap((s, i) => s.optional === true && !TEARDOWN_GUARD_ACTIONS.has(String(s.action)) && i !== after.length - 1
|
|
@@ -147,7 +150,7 @@ function optionalPlacementIssues(after) {
|
|
|
147
150
|
]
|
|
148
151
|
: []);
|
|
149
152
|
}
|
|
150
|
-
function
|
|
153
|
+
function identityIssues(plan, provisioning) {
|
|
151
154
|
if (typeof plan !== "object" || plan === null)
|
|
152
155
|
return [];
|
|
153
156
|
const { before, steps: main, after } = plan;
|
|
@@ -155,20 +158,22 @@ function mintedIdentityIssues(plan) {
|
|
|
155
158
|
if (placement.length > 0)
|
|
156
159
|
return placement;
|
|
157
160
|
const body = [...steps(before), ...steps(main)];
|
|
158
|
-
|
|
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))
|
|
159
166
|
return [];
|
|
160
167
|
const readsMail = body.some((s) => s.action === "await_email");
|
|
161
168
|
const tearsDown = steps(after).some((s) => s.action !== "expect");
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (!readsMail) {
|
|
166
|
-
return [{ path: "(root)", message: MINTED_IDENTITY_UNUSED, severity: "warning" }];
|
|
167
|
-
}
|
|
168
|
-
return [];
|
|
169
|
+
return readsMail && !tearsDown
|
|
170
|
+
? [{ path: "(root)", message: MINTED_IDENTITY_NEEDS_TEARDOWN }]
|
|
171
|
+
: [];
|
|
169
172
|
}
|
|
170
|
-
|
|
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 = {}) {
|
|
171
176
|
const issues = validate(plan, ACTION_PLAN_SCHEMA, ACTION_PLAN_SCHEMA, "");
|
|
172
177
|
// The identity rules read a well-formed plan; a shape error is reported alone.
|
|
173
|
-
return issues.length > 0 ? issues :
|
|
178
|
+
return issues.length > 0 ? issues : identityIssues(plan, opts.provisioning === true);
|
|
174
179
|
}
|
package/package.json
CHANGED