@beryl-so/cli 0.46.3 → 0.47.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 +17 -6
- package/dist/commands/runs.js +4 -5
- package/dist/commands/tests.js +57 -2
- package/dist/commands/watch.js +1 -3
- package/dist/lint.js +67 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -220,6 +220,7 @@ Author, inspect, version, and heal a project's tests: the checks Beryl runs on e
|
|
|
220
220
|
| `beryl tests set-plan <test-id>` | Replace a test's step plan from a JSON file (creates a new version) | `tests_set_plan` |
|
|
221
221
|
| `beryl tests rename <test-id> <title>` | Rename a test | `tests_rename` |
|
|
222
222
|
| `beryl tests set-groups <test-id>` | Replace the groups a test belongs to | `tests_set_groups` |
|
|
223
|
+
| `beryl tests set-access <test-id> <access>` | Mark a test read or write | `tests_set_access` |
|
|
223
224
|
| `beryl tests add-groups <test-ids...>` | Add groups to one or more tests, keeping the groups they already have | `tests_add_groups` |
|
|
224
225
|
| `beryl tests quarantine <test-id> <state>` | Mute a flaky test: it keeps running, but its failures stop failing the run | `tests_quarantine` |
|
|
225
226
|
| `beryl tests delete <test-ids...>` | Delete tests, their version history, and their results | `tests_delete` |
|
package/dist/beryl-test-skill.js
CHANGED
|
@@ -97,10 +97,11 @@ Then, per test:
|
|
|
97
97
|
1. **Drive the flow** in a real browser over the Playwright MCP — never author from
|
|
98
98
|
imagination (§2).
|
|
99
99
|
2. **Write the ActionPlan**, with one real outcome assertion (§2).
|
|
100
|
-
3. \`beryl tests lint --file plan.json\` — schema check,
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
100
|
+
3. \`beryl tests lint --file plan.json --access read|write\` — schema and access check,
|
|
101
|
+
offline, no network.
|
|
102
|
+
4. \`beryl tests create --title … --file … --access read|write --description "<the
|
|
103
|
+
intent>"\` — replays the plan locally, banks it only on green, records no run; a red
|
|
104
|
+
replay hands back the failure — fix and re-run (§3 for the intent, §4 for the loop).
|
|
104
105
|
5. When the batch is banked, run the new ids together ONCE (\`beryl runs local <id…>\`,
|
|
105
106
|
or \`beryl runs trigger\` for the cloud) so they land as one run with real first results.
|
|
106
107
|
|
|
@@ -151,6 +152,14 @@ The trade is drift — run 200 has 200 of everything run 1 created. So:
|
|
|
151
152
|
your own victim first — in \`before\` or the opening steps, \`{{unique}}\`-named —
|
|
152
153
|
then delete it and assert \`gone\`. Never aim a delete at standing data: run 2 would
|
|
153
154
|
have nothing to delete, and it eats the account's accumulated coverage.
|
|
155
|
+
- **Every test is \`read\` or \`write\`.** \`read\` (the default) only looks. A test
|
|
156
|
+
that creates, saves, deletes, renames, invites, toggles or uploads anything is
|
|
157
|
+
\`write\`: bank it with \`--access write\`, and end its \`after\` with an \`expect\`
|
|
158
|
+
that proves the undo: \`gone\`, \`hidden\`, \`have_count\` or \`count_delta\` for a
|
|
159
|
+
removal, \`have_text\`, \`have_value\` or \`persisted\` for a restored value.
|
|
160
|
+
Create refuses a plan that writes without the label and a \`write\` plan
|
|
161
|
+
without that closing proof; a \`write\` test whose teardown found nothing to remove
|
|
162
|
+
fails the run. Flip a banked test with \`beryl tests set-access <id> write\`.
|
|
154
163
|
|
|
155
164
|
### If there is no account yet
|
|
156
165
|
|
|
@@ -261,7 +270,8 @@ leaves Beryl's cloud).
|
|
|
261
270
|
gone) the teardown ends quietly right there instead of failing the test. Put it on
|
|
262
271
|
the \`wait_for\` or \`expect\` that probes for the record, so the delete steps
|
|
263
272
|
after it run only when there is something to delete; an optional \`click\` or
|
|
264
|
-
\`fill\` must be the last step of \`after
|
|
273
|
+
\`fill\` must be the last step of \`after\`, so a \`write\` test (§1), whose
|
|
274
|
+
\`after\` ends with the proof \`expect\`, never marks its delete optional.
|
|
265
275
|
|
|
266
276
|
The full action vocabulary:
|
|
267
277
|
|
|
@@ -432,7 +442,8 @@ this test alone.
|
|
|
432
442
|
| 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 |
|
|
433
443
|
| only makes mail arrive, read or not, no account created | nothing to clean up; mail is kept for 48 hours |
|
|
434
444
|
|
|
435
|
-
Every alias minted is listed by \`beryl mailbox identities\` with how its teardown
|
|
445
|
+
Every alias minted is listed by \`beryl mailbox identities\` with how its teardown
|
|
446
|
+
ended; the run detail shows the same outcome on each result.
|
|
436
447
|
|
|
437
448
|
The wiring is a three-part chain:
|
|
438
449
|
|
package/dist/commands/runs.js
CHANGED
|
@@ -557,11 +557,10 @@ export const runCommands = [
|
|
|
557
557
|
"which keeps the output readable rather than saving a round trip. With --page the " +
|
|
558
558
|
"match total and page counts come back under results_page. Paged results are the run's " +
|
|
559
559
|
"settled tests only: anything still running, healing or queued is left out. A result " +
|
|
560
|
-
"carries `heal` when Beryl
|
|
561
|
-
"unchanged, proved in a browser
|
|
562
|
-
"see `tests diff`)
|
|
563
|
-
"
|
|
564
|
-
"`heal_failed`. " +
|
|
560
|
+
"carries `heal` when Beryl healed it: its steps were rewritten with the intent " +
|
|
561
|
+
"unchanged, proved in a browser and it re-ran green; `version_no` is the revision it " +
|
|
562
|
+
"wrote (see `tests diff`) and `rationale` the agent's reason. A heal that did not turn " +
|
|
563
|
+
"the test green is not reported: a failure is a failure. " +
|
|
565
564
|
"`runs report` returns the generated report document, `runs explain` an AI explanation " +
|
|
566
565
|
"of one failed result.",
|
|
567
566
|
scope: "project",
|
package/dist/commands/tests.js
CHANGED
|
@@ -66,11 +66,20 @@ function verifyFailureResult(err) {
|
|
|
66
66
|
// The concise human table for `tests list` — the full TestResponse is a 24-column
|
|
67
67
|
// firehose of internal ids that wraps unreadably in a normal terminal. `--wide`
|
|
68
68
|
// (and `--json`) still expose every field.
|
|
69
|
-
const LIST_COLUMNS = ["title", "status", "id", "last_result_status", "last_run_at"];
|
|
69
|
+
const LIST_COLUMNS = ["title", "status", "access", "id", "last_result_status", "last_run_at"];
|
|
70
|
+
const ACCESS_VALUES = ["read", "write"];
|
|
71
|
+
function accessFlag(input, fallback) {
|
|
72
|
+
const raw = flagStr(input, "access") ?? fallback;
|
|
73
|
+
if (raw !== undefined && !ACCESS_VALUES.includes(raw)) {
|
|
74
|
+
throw new UsageError(`--access must be read or write, got "${raw}"`);
|
|
75
|
+
}
|
|
76
|
+
return raw;
|
|
77
|
+
}
|
|
70
78
|
function conciseTestRow(row) {
|
|
71
79
|
return {
|
|
72
80
|
title: row.nl_title,
|
|
73
81
|
status: row.quarantined ? "quarantined" : row.is_active ? "active" : "inactive",
|
|
82
|
+
access: row.access,
|
|
74
83
|
id: row.id,
|
|
75
84
|
last_result_status: row.last_result_status,
|
|
76
85
|
last_run_at: row.last_run_at,
|
|
@@ -98,14 +107,22 @@ export const testCommands = [
|
|
|
98
107
|
description: "The file is an account's signup or sign-in plan (for `accounts signup` / " +
|
|
99
108
|
"`accounts set-login`), so it may fill {{mailbox_address}}",
|
|
100
109
|
},
|
|
110
|
+
{
|
|
111
|
+
name: "access",
|
|
112
|
+
type: "string",
|
|
113
|
+
description: "The label the plan will be banked with, read (default) or write; a plan that " +
|
|
114
|
+
"creates, edits, deletes or toggles something must be write",
|
|
115
|
+
},
|
|
101
116
|
],
|
|
102
117
|
examples: [
|
|
103
118
|
"beryl tests lint --file plan.json",
|
|
119
|
+
"beryl tests lint --file plan.json --access write",
|
|
104
120
|
"beryl tests lint --file signup-plan.json --provisioning",
|
|
105
121
|
],
|
|
106
122
|
async run(_ctx, input) {
|
|
107
123
|
const issues = lintPlan(readJsonFlag(input, "file"), {
|
|
108
124
|
provisioning: flagBool(input, "provisioning"),
|
|
125
|
+
access: accessFlag(input, "read"),
|
|
109
126
|
});
|
|
110
127
|
const lines = issues.map((i) => `${i.severity === "warning" ? "warning" : "error"} ${i.path}: ${i.message}`);
|
|
111
128
|
if (issues.every((i) => i.severity === "warning")) {
|
|
@@ -134,6 +151,7 @@ export const testCommands = [
|
|
|
134
151
|
description: "Show only tests in this group (by name; see `beryl groups list`)",
|
|
135
152
|
},
|
|
136
153
|
{ name: "wide", type: "boolean", description: "Show all columns, not the concise default" },
|
|
154
|
+
{ name: "access", type: "string", description: "Only read or only write tests" },
|
|
137
155
|
{
|
|
138
156
|
name: "page",
|
|
139
157
|
type: "number",
|
|
@@ -164,14 +182,17 @@ export const testCommands = [
|
|
|
164
182
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
165
183
|
const page = flagNum(input, "page");
|
|
166
184
|
const group = flagStr(input, "group");
|
|
185
|
+
const access = accessFlag(input);
|
|
167
186
|
let data;
|
|
168
187
|
if (page === undefined) {
|
|
169
188
|
data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests`, {
|
|
170
189
|
environment_id: flagStr(input, "env"),
|
|
171
190
|
}));
|
|
172
|
-
// GET /tests takes no group param; the paged route
|
|
191
|
+
// GET /tests takes no group or access param; the paged route filters server-side.
|
|
173
192
|
if (group)
|
|
174
193
|
data = data.filter((t) => inGroup(t, group));
|
|
194
|
+
if (access)
|
|
195
|
+
data = data.filter((t) => t.access === access);
|
|
175
196
|
}
|
|
176
197
|
else {
|
|
177
198
|
data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests/page`, {
|
|
@@ -180,6 +201,7 @@ export const testCommands = [
|
|
|
180
201
|
status: flagStr(input, "status"),
|
|
181
202
|
group: flagBool(input, "ungrouped") ? "__ungrouped__" : group,
|
|
182
203
|
q: flagStr(input, "q"),
|
|
204
|
+
access,
|
|
183
205
|
environment_id: flagStr(input, "env"),
|
|
184
206
|
})).items;
|
|
185
207
|
}
|
|
@@ -274,6 +296,12 @@ export const testCommands = [
|
|
|
274
296
|
"name is an error (create groups with `beryl groups create`). Omit for no group.",
|
|
275
297
|
},
|
|
276
298
|
{ name: "env", type: "string", description: "Environment id to compile and prove against" },
|
|
299
|
+
{
|
|
300
|
+
name: "access",
|
|
301
|
+
type: "string",
|
|
302
|
+
description: "read (default): the test only reads the app. write: any step creates, edits, " +
|
|
303
|
+
"renames, deletes or toggles something; a writer must clean up in `after`",
|
|
304
|
+
},
|
|
277
305
|
{
|
|
278
306
|
name: "dir",
|
|
279
307
|
type: "string",
|
|
@@ -295,11 +323,13 @@ export const testCommands = [
|
|
|
295
323
|
const urlOverride = flagStr(input, "url-override");
|
|
296
324
|
const env = flagStr(input, "env");
|
|
297
325
|
const groups = flagStrings(input, "group");
|
|
326
|
+
const access = accessFlag(input, "read");
|
|
298
327
|
const bank = (extra) => ctx.client.post(`${projectPath(workspaceId, projectId)}/tests`, {
|
|
299
328
|
title,
|
|
300
329
|
plan,
|
|
301
330
|
description,
|
|
302
331
|
groups,
|
|
332
|
+
access,
|
|
303
333
|
...extra,
|
|
304
334
|
});
|
|
305
335
|
if (flagBool(input, "no-verify")) {
|
|
@@ -547,6 +577,31 @@ export const testCommands = [
|
|
|
547
577
|
};
|
|
548
578
|
},
|
|
549
579
|
},
|
|
580
|
+
{
|
|
581
|
+
name: "tests set-access",
|
|
582
|
+
summary: "Mark a test read or write",
|
|
583
|
+
description: "read: the test only reads the app. write: any step creates, edits, renames, deletes " +
|
|
584
|
+
"or toggles something. The server refuses a plan that writes on a read test, so flip " +
|
|
585
|
+
"the label before re-authoring such a plan. A write test must clean up in `after`.",
|
|
586
|
+
scope: "project",
|
|
587
|
+
args: [
|
|
588
|
+
{ name: "test-id", description: "Test id", required: true },
|
|
589
|
+
{ name: "access", description: "read | write", required: true },
|
|
590
|
+
],
|
|
591
|
+
examples: ["beryl tests set-access 4f… write", "beryl tests set-access 4f… read"],
|
|
592
|
+
async run(ctx, input) {
|
|
593
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
594
|
+
const access = arg(input, "access").toLowerCase();
|
|
595
|
+
if (!ACCESS_VALUES.includes(access)) {
|
|
596
|
+
throw new UsageError(`Expected "read" or "write", got "${arg(input, "access")}"`);
|
|
597
|
+
}
|
|
598
|
+
return {
|
|
599
|
+
data: await ctx.client.patch(testPath(workspaceId, projectId, arg(input, "test-id")), {
|
|
600
|
+
access,
|
|
601
|
+
}),
|
|
602
|
+
};
|
|
603
|
+
},
|
|
604
|
+
},
|
|
550
605
|
{
|
|
551
606
|
name: "tests add-groups",
|
|
552
607
|
summary: "Add groups to one or more tests, keeping the groups they already have",
|
package/dist/commands/watch.js
CHANGED
|
@@ -180,7 +180,6 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes, opts = {
|
|
|
180
180
|
total: run?.total_tests ?? settledCount(lastCounters),
|
|
181
181
|
flaky: run?.summary?.flaky ?? 0,
|
|
182
182
|
healed: run?.summary?.healed ?? 0,
|
|
183
|
-
heal_failed: run?.summary?.heal_failed ?? 0,
|
|
184
183
|
};
|
|
185
184
|
const finalStatus = failFastStop ? "failed" : (run?.status ?? "unknown");
|
|
186
185
|
// Quarantined reds are excluded from `failed` by the API (they land in their own
|
|
@@ -193,8 +192,7 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes, opts = {
|
|
|
193
192
|
const extra = (counters.cancelled ? yellow(`, ${counters.cancelled} cancelled`) : "") +
|
|
194
193
|
(counters.quarantined ? yellow(`, ${counters.quarantined} quarantined`) : "") +
|
|
195
194
|
(counters.flaky ? yellow(`, ${counters.flaky} flaky`) : "") +
|
|
196
|
-
(counters.healed ? yellow(`, ${counters.healed} healed`) : "")
|
|
197
|
-
(counters.heal_failed ? yellow(`, ${counters.heal_failed} heal failed`) : "");
|
|
195
|
+
(counters.healed ? yellow(`, ${counters.healed} healed`) : "");
|
|
198
196
|
const summary = failed > 0
|
|
199
197
|
? red(`${failed} failed`) + `, ${passed} passed` + extra
|
|
200
198
|
: green(`${passed} passed`) + extra;
|
package/dist/lint.js
CHANGED
|
@@ -9,6 +9,13 @@ export const MAILBOX_ADDRESS_IN_TEST = "This plan types the standing account's a
|
|
|
9
9
|
"and rides the account's session; one that needs an address the site can mail cites " +
|
|
10
10
|
"{{inbox_address}}, an alias only this test reads. Only the account's own signup and " +
|
|
11
11
|
"sign-in plans (accounts signup, accounts set-login) may fill {{mailbox_address}}.";
|
|
12
|
+
export const WRITE_NEEDS_LABEL = (label, detail) => `${label} (${detail}) changes the app, so this test must be marked access 'write' ` +
|
|
13
|
+
"(tests create --access write, or tests set-access <id> write) and clean up in " +
|
|
14
|
+
"'after'. A 'read' test only reads.";
|
|
15
|
+
export const WRITE_NEEDS_CLOSING_TEARDOWN = (detail) => "A 'write' test must end with an 'after' section that undoes what it changed and " +
|
|
16
|
+
"proves it: the last step of 'after' is an expect with expect_kind 'gone', 'hidden', " +
|
|
17
|
+
"'have_count', 'count_delta' (something removed) or 'have_text', 'have_value', " +
|
|
18
|
+
`'persisted' (something restored). ${detail}`;
|
|
12
19
|
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
20
|
"that misses ends the teardown, so the steps after it would silently not run. Guard " +
|
|
14
21
|
"them with an optional 'wait_for' or 'expect' instead, or move this step to the end.";
|
|
@@ -170,10 +177,69 @@ function identityIssues(plan, provisioning) {
|
|
|
170
177
|
? [{ path: "(root)", message: MINTED_IDENTITY_NEEDS_TEARDOWN }]
|
|
171
178
|
: [];
|
|
172
179
|
}
|
|
180
|
+
// Same verb list as the server's write_signal (api/app/ta_shared/schemas.py).
|
|
181
|
+
const WRITE_VERBS_RE = /\b(create|save|delete|remove|rename|revoke|enable|disable|invite|add|publish|archive|restore|quarantine|un-quarantine|turn on|turn off|join|decline)\b/i;
|
|
182
|
+
function writeSignal(plan) {
|
|
183
|
+
if (typeof plan !== "object" || plan === null)
|
|
184
|
+
return null;
|
|
185
|
+
const { before, steps: main, after } = plan;
|
|
186
|
+
if (steps(after).length > 0)
|
|
187
|
+
return ["after", "a teardown means something was created"];
|
|
188
|
+
const sectioned = steps(before).length > 0 || steps(after).length > 0;
|
|
189
|
+
const label = (section, i) => (sectioned ? `${section}[${i}]` : `step[${i}]`);
|
|
190
|
+
const all = [
|
|
191
|
+
...steps(before).map((s, i) => [label("before", i), s]),
|
|
192
|
+
...steps(main).map((s, i) => [label("steps", i), s]),
|
|
193
|
+
];
|
|
194
|
+
for (const [where, s] of all) {
|
|
195
|
+
if (s.action === "upload")
|
|
196
|
+
return [where, "upload"];
|
|
197
|
+
if (s.action === "click" && typeof s.selector === "string") {
|
|
198
|
+
const hit = WRITE_VERBS_RE.exec(s.selector);
|
|
199
|
+
if (hit)
|
|
200
|
+
return [where, `click on '${hit[0]}'`];
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
const TEARDOWN_PROOF_KINDS = new Set([
|
|
206
|
+
"gone",
|
|
207
|
+
"hidden",
|
|
208
|
+
"have_count",
|
|
209
|
+
"count_delta",
|
|
210
|
+
"have_text",
|
|
211
|
+
"have_value",
|
|
212
|
+
"persisted",
|
|
213
|
+
]);
|
|
214
|
+
function accessIssues(plan, access) {
|
|
215
|
+
if (access !== "write") {
|
|
216
|
+
const signal = writeSignal(plan);
|
|
217
|
+
return signal ? [{ path: "(root)", message: WRITE_NEEDS_LABEL(signal[0], signal[1]) }] : [];
|
|
218
|
+
}
|
|
219
|
+
if (typeof plan !== "object" || plan === null)
|
|
220
|
+
return [];
|
|
221
|
+
const after = steps(plan.after);
|
|
222
|
+
if (after.length === 0) {
|
|
223
|
+
return [{ path: "(root)", message: WRITE_NEEDS_CLOSING_TEARDOWN("This plan has no 'after'.") }];
|
|
224
|
+
}
|
|
225
|
+
const last = after[after.length - 1] ?? {};
|
|
226
|
+
const kind = typeof last.expect_kind === "string" ? last.expect_kind : undefined;
|
|
227
|
+
if (last.action !== "expect" || !kind || !TEARDOWN_PROOF_KINDS.has(kind)) {
|
|
228
|
+
const detail = `Its last step is ${String(last.action)}${kind ? ` ${kind}` : ""}.`;
|
|
229
|
+
return [{ path: "(root)", message: WRITE_NEEDS_CLOSING_TEARDOWN(detail) }];
|
|
230
|
+
}
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
173
233
|
// `provisioning`: the plan is an account's own signup or sign-in plan, the one place
|
|
174
234
|
// {{mailbox_address}} is legal (mirrors the server's validate_editable_plan flag).
|
|
235
|
+
// `access`: the label the plan will be banked with; given, a writing plan needs 'write'.
|
|
175
236
|
export function lintPlan(plan, opts = {}) {
|
|
176
237
|
const issues = validate(plan, ACTION_PLAN_SCHEMA, ACTION_PLAN_SCHEMA, "");
|
|
177
238
|
// The identity rules read a well-formed plan; a shape error is reported alone.
|
|
178
|
-
|
|
239
|
+
if (issues.length > 0)
|
|
240
|
+
return issues;
|
|
241
|
+
return [
|
|
242
|
+
...identityIssues(plan, opts.provisioning === true),
|
|
243
|
+
...(opts.access === undefined ? [] : accessIssues(plan, opts.access)),
|
|
244
|
+
];
|
|
179
245
|
}
|
package/package.json
CHANGED