@beryl-so/cli 0.46.4 → 0.48.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 +51 -6
- package/dist/commands/init.js +106 -22
- package/dist/commands/tests.js +57 -2
- 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
|
@@ -13,6 +13,40 @@ const indent = (text) => text
|
|
|
13
13
|
.join("\n");
|
|
14
14
|
export const BERYL_TEST_SKILL_FILENAME = "SKILL.md";
|
|
15
15
|
export const BERYL_TEST_SKILL_DIR = "beryl-test";
|
|
16
|
+
// The prose both editor pointers share. Cursor and Copilot read neither `.agents/skills/`
|
|
17
|
+
// nor `.claude/skills/`, so `init` writes them their own pointer instead. Thin on purpose:
|
|
18
|
+
// it sends the agent to the `guide` MCP tool rather than inlining the 500-line skill, and
|
|
19
|
+
// `guide` ships with the CLI so the real guidance never drifts from the installed version.
|
|
20
|
+
const BERYL_GUIDE_POINTER = `When writing, editing, or debugging Beryl tests in this repo, call the \`guide\`
|
|
21
|
+
MCP tool first. It returns the full authoring guide: the ActionPlan shape, outcome
|
|
22
|
+
assertions, test accounts and the project mailbox, and the local run-fix loop.
|
|
23
|
+
|
|
24
|
+
Then use the Beryl MCP tools: \`projects_list\` to find the project, and \`tests_create\`
|
|
25
|
+
to author a plan and replay it locally before it is banked.
|
|
26
|
+
|
|
27
|
+
The \`guide\` tool ships with the CLI and always matches the installed version, so treat
|
|
28
|
+
it as the source of truth over anything cached here.`;
|
|
29
|
+
// Cursor reads its own `.cursor/rules/*.mdc`, auto-loaded when alwaysApply is set.
|
|
30
|
+
export const BERYL_CURSOR_RULE_FILENAME = "beryl.mdc";
|
|
31
|
+
export const BERYL_CURSOR_RULE = `---
|
|
32
|
+
description: Authoring and editing Beryl end-to-end tests
|
|
33
|
+
alwaysApply: true
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
# Beryl test authoring
|
|
37
|
+
|
|
38
|
+
${BERYL_GUIDE_POINTER}
|
|
39
|
+
`;
|
|
40
|
+
// GitHub Copilot reads `.github/copilot-instructions.md`, a file the repo may already own,
|
|
41
|
+
// so our content lives in a marked section that `init` appends once and refreshes in place.
|
|
42
|
+
// The markers must stay byte-stable across releases or the refresh can't find the block.
|
|
43
|
+
export const BERYL_COPILOT_MARK_BEGIN = "<!-- BEGIN beryl (managed by beryl init) -->";
|
|
44
|
+
export const BERYL_COPILOT_MARK_END = "<!-- END beryl -->";
|
|
45
|
+
export const BERYL_COPILOT_SECTION = `${BERYL_COPILOT_MARK_BEGIN}
|
|
46
|
+
## Beryl test authoring
|
|
47
|
+
|
|
48
|
+
${BERYL_GUIDE_POINTER}
|
|
49
|
+
${BERYL_COPILOT_MARK_END}`;
|
|
16
50
|
// The example plan the skill shows verbatim. Exported so the test suite lints it with
|
|
17
51
|
// lintPlan — the skill's documented shape must pass `beryl tests lint` on the first try.
|
|
18
52
|
export const BERYL_TEST_SKILL_EXAMPLE_PLAN = {
|
|
@@ -97,10 +131,11 @@ Then, per test:
|
|
|
97
131
|
1. **Drive the flow** in a real browser over the Playwright MCP — never author from
|
|
98
132
|
imagination (§2).
|
|
99
133
|
2. **Write the ActionPlan**, with one real outcome assertion (§2).
|
|
100
|
-
3. \`beryl tests lint --file plan.json\` — schema check,
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
134
|
+
3. \`beryl tests lint --file plan.json --access read|write\` — schema and access check,
|
|
135
|
+
offline, no network.
|
|
136
|
+
4. \`beryl tests create --title … --file … --access read|write --description "<the
|
|
137
|
+
intent>"\` — replays the plan locally, banks it only on green, records no run; a red
|
|
138
|
+
replay hands back the failure — fix and re-run (§3 for the intent, §4 for the loop).
|
|
104
139
|
5. When the batch is banked, run the new ids together ONCE (\`beryl runs local <id…>\`,
|
|
105
140
|
or \`beryl runs trigger\` for the cloud) so they land as one run with real first results.
|
|
106
141
|
|
|
@@ -151,6 +186,14 @@ The trade is drift — run 200 has 200 of everything run 1 created. So:
|
|
|
151
186
|
your own victim first — in \`before\` or the opening steps, \`{{unique}}\`-named —
|
|
152
187
|
then delete it and assert \`gone\`. Never aim a delete at standing data: run 2 would
|
|
153
188
|
have nothing to delete, and it eats the account's accumulated coverage.
|
|
189
|
+
- **Every test is \`read\` or \`write\`.** \`read\` (the default) only looks. A test
|
|
190
|
+
that creates, saves, deletes, renames, invites, toggles or uploads anything is
|
|
191
|
+
\`write\`: bank it with \`--access write\`, and end its \`after\` with an \`expect\`
|
|
192
|
+
that proves the undo: \`gone\`, \`hidden\`, \`have_count\` or \`count_delta\` for a
|
|
193
|
+
removal, \`have_text\`, \`have_value\` or \`persisted\` for a restored value.
|
|
194
|
+
Create refuses a plan that writes without the label and a \`write\` plan
|
|
195
|
+
without that closing proof; a \`write\` test whose teardown found nothing to remove
|
|
196
|
+
fails the run. Flip a banked test with \`beryl tests set-access <id> write\`.
|
|
154
197
|
|
|
155
198
|
### If there is no account yet
|
|
156
199
|
|
|
@@ -261,7 +304,8 @@ leaves Beryl's cloud).
|
|
|
261
304
|
gone) the teardown ends quietly right there instead of failing the test. Put it on
|
|
262
305
|
the \`wait_for\` or \`expect\` that probes for the record, so the delete steps
|
|
263
306
|
after it run only when there is something to delete; an optional \`click\` or
|
|
264
|
-
\`fill\` must be the last step of \`after
|
|
307
|
+
\`fill\` must be the last step of \`after\`, so a \`write\` test (§1), whose
|
|
308
|
+
\`after\` ends with the proof \`expect\`, never marks its delete optional.
|
|
265
309
|
|
|
266
310
|
The full action vocabulary:
|
|
267
311
|
|
|
@@ -432,7 +476,8 @@ this test alone.
|
|
|
432
476
|
| 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
477
|
| only makes mail arrive, read or not, no account created | nothing to clean up; mail is kept for 48 hours |
|
|
434
478
|
|
|
435
|
-
Every alias minted is listed by \`beryl mailbox identities\` with how its teardown
|
|
479
|
+
Every alias minted is listed by \`beryl mailbox identities\` with how its teardown
|
|
480
|
+
ended; the run detail shows the same outcome on each result.
|
|
436
481
|
|
|
437
482
|
The wiring is a three-part chain:
|
|
438
483
|
|
package/dist/commands/init.js
CHANGED
|
@@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { BERYL_TEST_SKILL, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME, } from "../beryl-test-skill.js";
|
|
5
|
+
import { BERYL_COPILOT_MARK_BEGIN, BERYL_COPILOT_MARK_END, BERYL_COPILOT_SECTION, BERYL_CURSOR_RULE, BERYL_CURSOR_RULE_FILENAME, BERYL_TEST_SKILL, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME, } from "../beryl-test-skill.js";
|
|
6
6
|
import { loadConfig } from "../config.js";
|
|
7
7
|
import { AuthError, CliError } from "../errors.js";
|
|
8
8
|
import { ApiClient } from "../http.js";
|
|
@@ -27,8 +27,14 @@ const PLAYWRIGHT_SERVER_ENTRY = {
|
|
|
27
27
|
command: "npx",
|
|
28
28
|
args: ["@playwright/mcp@latest", "--headless"],
|
|
29
29
|
};
|
|
30
|
+
// VS Code's `.vscode/mcp.json` uses a top-level `servers` key and tags each stdio server
|
|
31
|
+
// with `type`, unlike Cursor/Claude's `mcpServers`. Same pinned commands, different shape.
|
|
32
|
+
const VSCODE_MCP_SERVER_ENTRY = { type: "stdio", ...MCP_SERVER_ENTRY };
|
|
33
|
+
const VSCODE_PLAYWRIGHT_SERVER_ENTRY = { type: "stdio", ...PLAYWRIGHT_SERVER_ENTRY };
|
|
30
34
|
const ACTION_PLAN_SCHEMA_URL = "https://api.beryl.so/api/v1/schemas/action-plan.schema.json";
|
|
31
|
-
|
|
35
|
+
// `key` is the container the editor expects: `mcpServers` for Claude/Cursor, `servers` for
|
|
36
|
+
// VS Code. `entries` are the editor-shaped server objects (VS Code tags them with `type`).
|
|
37
|
+
function mergeMcpConfig(file, key, entries) {
|
|
32
38
|
let existing = {};
|
|
33
39
|
if (fs.existsSync(file)) {
|
|
34
40
|
try {
|
|
@@ -38,16 +44,16 @@ function mergeMcpConfig(file) {
|
|
|
38
44
|
throw new CliError(`${file} exists but is not valid JSON (${err.message})`);
|
|
39
45
|
}
|
|
40
46
|
}
|
|
41
|
-
const servers = (existing
|
|
42
|
-
const beryl = JSON.stringify(servers.beryl) !== JSON.stringify(
|
|
47
|
+
const servers = (existing[key] ?? {});
|
|
48
|
+
const beryl = JSON.stringify(servers.beryl) !== JSON.stringify(entries.beryl);
|
|
43
49
|
if (beryl)
|
|
44
|
-
servers.beryl =
|
|
50
|
+
servers.beryl = entries.beryl;
|
|
45
51
|
// Never clobber a playwright server the user already wired up.
|
|
46
52
|
const playwright = servers.playwright === undefined;
|
|
47
53
|
if (playwright)
|
|
48
|
-
servers.playwright =
|
|
54
|
+
servers.playwright = entries.playwright;
|
|
49
55
|
if (beryl || playwright) {
|
|
50
|
-
existing
|
|
56
|
+
existing[key] = servers;
|
|
51
57
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
52
58
|
fs.writeFileSync(file, JSON.stringify(existing, null, 2) + "\n");
|
|
53
59
|
}
|
|
@@ -83,25 +89,54 @@ function claudeAddHint(name, entry) {
|
|
|
83
89
|
function cursorUserConfigPath() {
|
|
84
90
|
return path.join(os.homedir(), ".cursor", "mcp.json");
|
|
85
91
|
}
|
|
86
|
-
// Write
|
|
87
|
-
// that
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
92
|
+
// Write generated content that ships with the CLI and changes every release, so anything
|
|
93
|
+
// that differs is overwritten: a stale copy silently feeds agents months-old guidance,
|
|
94
|
+
// which is worse than losing a local edit the user can redo (and `beryl guide` still prints
|
|
95
|
+
// the same content). CRLF is normalized so a CRLF copy of our own content counts as current
|
|
96
|
+
// and isn't rewritten on every run; we always write LF.
|
|
97
|
+
function writeGeneratedFile(file, content) {
|
|
91
98
|
let outcome = "installed";
|
|
92
99
|
if (fs.existsSync(file)) {
|
|
93
|
-
// Compare with line endings normalized so a CRLF copy of our own content counts as
|
|
94
|
-
// current and isn't rewritten on every run — we always write LF.
|
|
95
100
|
const norm = (s) => s.replace(/\r\n/g, "\n");
|
|
96
|
-
|
|
97
|
-
if (norm(current) === norm(BERYL_TEST_SKILL))
|
|
101
|
+
if (norm(fs.readFileSync(file, "utf8")) === norm(content))
|
|
98
102
|
return { outcome: "unchanged", file };
|
|
99
103
|
outcome = "updated";
|
|
100
104
|
}
|
|
101
105
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
102
|
-
fs.writeFileSync(file,
|
|
106
|
+
fs.writeFileSync(file, content);
|
|
103
107
|
return { outcome, file };
|
|
104
108
|
}
|
|
109
|
+
const writeSkillFile = (file) => writeGeneratedFile(file, BERYL_TEST_SKILL);
|
|
110
|
+
// Cursor's equivalent of the skill: a repo-local `.cursor/rules/beryl.mdc`. Always repo-local
|
|
111
|
+
// even at `--scope user`, because Cursor has no global rules FILE (User Rules live in its
|
|
112
|
+
// settings UI), so there is nowhere else to put it.
|
|
113
|
+
const writeCursorRule = (cwd) => writeGeneratedFile(path.join(cwd, ".cursor", "rules", BERYL_CURSOR_RULE_FILENAME), BERYL_CURSOR_RULE);
|
|
114
|
+
const copilotInstructionsPath = (cwd) => path.join(cwd, ".github", "copilot-instructions.md");
|
|
115
|
+
// Copilot's `.github/copilot-instructions.md` may already hold the repo's own instructions,
|
|
116
|
+
// so we own only a marked section: refresh it in place when present, append it once when the
|
|
117
|
+
// file exists without it, create the file when it doesn't. Never rewrites the user's prose.
|
|
118
|
+
function writeCopilotInstructions(cwd) {
|
|
119
|
+
const file = copilotInstructionsPath(cwd);
|
|
120
|
+
const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
121
|
+
const begin = current.indexOf(BERYL_COPILOT_MARK_BEGIN);
|
|
122
|
+
if (begin !== -1) {
|
|
123
|
+
const endAt = current.indexOf(BERYL_COPILOT_MARK_END, begin);
|
|
124
|
+
const endFull = endAt === -1 ? current.length : endAt + BERYL_COPILOT_MARK_END.length;
|
|
125
|
+
if (current.slice(begin, endFull) === BERYL_COPILOT_SECTION)
|
|
126
|
+
return { outcome: "unchanged", file };
|
|
127
|
+
fs.writeFileSync(file, current.slice(0, begin) + BERYL_COPILOT_SECTION + current.slice(endFull));
|
|
128
|
+
return { outcome: "updated", file };
|
|
129
|
+
}
|
|
130
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
131
|
+
const body = current ? current.replace(/\n*$/, "\n\n") + BERYL_COPILOT_SECTION + "\n" : BERYL_COPILOT_SECTION + "\n";
|
|
132
|
+
fs.writeFileSync(file, body);
|
|
133
|
+
return { outcome: "installed", file };
|
|
134
|
+
}
|
|
135
|
+
const outcomeLabel = (o) => o === "installed"
|
|
136
|
+
? "installed"
|
|
137
|
+
: o === "updated"
|
|
138
|
+
? "updated (refreshed to this CLI's version)"
|
|
139
|
+
: "already current";
|
|
105
140
|
const skillLeaf = (root) => path.join(root, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME);
|
|
106
141
|
// The authoring skill follows the `--scope` flag, same as the MCP servers: under `user`
|
|
107
142
|
// (the default) it lands in the HOME `.agents/skills/` + `.claude/skills/` dirs, so the
|
|
@@ -126,7 +161,18 @@ export function refreshInstalledSkills(cwd) {
|
|
|
126
161
|
skillLeaf(path.join(root, ".agents", "skills")),
|
|
127
162
|
skillLeaf(path.join(root, ".claude", "skills")),
|
|
128
163
|
]));
|
|
129
|
-
|
|
164
|
+
const refreshed = [...files].filter((file) => fs.existsSync(file)).map(writeSkillFile);
|
|
165
|
+
// The Cursor rule and Copilot section are repo-local pointers; refresh them the same way so
|
|
166
|
+
// a reworded pointer reaches those editors on the next MCP spawn, matching the skill. Both
|
|
167
|
+
// are refreshed only when already installed, and the Copilot writer touches only its block.
|
|
168
|
+
const cursorRule = path.join(cwd, ".cursor", "rules", BERYL_CURSOR_RULE_FILENAME);
|
|
169
|
+
if (fs.existsSync(cursorRule))
|
|
170
|
+
refreshed.push(writeGeneratedFile(cursorRule, BERYL_CURSOR_RULE));
|
|
171
|
+
const copilot = copilotInstructionsPath(cwd);
|
|
172
|
+
if (fs.existsSync(copilot) && fs.readFileSync(copilot, "utf8").includes(BERYL_COPILOT_MARK_BEGIN)) {
|
|
173
|
+
refreshed.push(writeCopilotInstructions(cwd));
|
|
174
|
+
}
|
|
175
|
+
return refreshed;
|
|
130
176
|
}
|
|
131
177
|
// Local authoring drives a real browser, and the whole authoring workflow (walk the flow
|
|
132
178
|
// first, then bank the plan) depends on it — so the install is mandatory, not offered:
|
|
@@ -169,7 +215,11 @@ export const initCommands = [
|
|
|
169
215
|
"locally), since browser authoring and local runs depend on it. By default the " +
|
|
170
216
|
"servers are wired per-user (matching where your login token lives) via `claude mcp add " +
|
|
171
217
|
"-s user`; pass --scope project to write a committed .mcp.json for a shared repo " +
|
|
172
|
-
"instead.
|
|
218
|
+
"instead. With --cursor the same two servers are also wired into Cursor and a repo-local " +
|
|
219
|
+
".cursor/rules/beryl.mdc authoring rule is written (Cursor's equivalent of the skill, " +
|
|
220
|
+
"since it reads neither skills dir); --vscode does the same for VS Code / GitHub Copilot " +
|
|
221
|
+
"(.vscode/mcp.json plus a marked section in .github/copilot-instructions.md). No " +
|
|
222
|
+
"workspace/project pin and no URL prompt: ask Claude to write tests for " +
|
|
173
223
|
"your site and it resolves the workspace, project, and URL. Safe to re-run: every run " +
|
|
174
224
|
"refreshes the authoring skill to this CLI's version (overwriting an older or edited " +
|
|
175
225
|
"copy; `beryl guide` prints the same content), and skips whatever else is already set " +
|
|
@@ -188,13 +238,21 @@ export const initCommands = [
|
|
|
188
238
|
{
|
|
189
239
|
name: "cursor",
|
|
190
240
|
type: "boolean",
|
|
191
|
-
description: "Also
|
|
241
|
+
description: "Also wire the same two MCP servers into Cursor's config and write a repo-local " +
|
|
242
|
+
".cursor/rules/beryl.mdc authoring rule (Cursor's equivalent of the skill)",
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
name: "vscode",
|
|
246
|
+
type: "boolean",
|
|
247
|
+
description: "Also wire the two MCP servers into .vscode/mcp.json and add a Beryl section to " +
|
|
248
|
+
".github/copilot-instructions.md for GitHub Copilot (both repo-local)",
|
|
192
249
|
},
|
|
193
250
|
],
|
|
194
251
|
examples: [
|
|
195
252
|
"npx @beryl-so/cli@latest init",
|
|
196
253
|
"beryl init --scope project",
|
|
197
254
|
"beryl init --cursor",
|
|
255
|
+
"beryl init --vscode",
|
|
198
256
|
],
|
|
199
257
|
async run(ctx, input) {
|
|
200
258
|
const cwd = process.cwd();
|
|
@@ -256,17 +314,43 @@ export const initCommands = [
|
|
|
256
314
|
}
|
|
257
315
|
else {
|
|
258
316
|
const file = path.join(cwd, ".mcp.json");
|
|
259
|
-
const wrote = mergeMcpConfig(file
|
|
317
|
+
const wrote = mergeMcpConfig(file, "mcpServers", {
|
|
318
|
+
beryl: MCP_SERVER_ENTRY,
|
|
319
|
+
playwright: PLAYWRIGHT_SERVER_ENTRY,
|
|
320
|
+
});
|
|
260
321
|
const where = path.relative(cwd, file);
|
|
261
322
|
report("beryl MCP", wrote.beryl, where);
|
|
262
323
|
report("playwright MCP", wrote.playwright, where);
|
|
263
324
|
}
|
|
264
325
|
if (input.flags.cursor) {
|
|
265
326
|
const file = scope === "user" ? cursorUserConfigPath() : path.join(cwd, ".cursor", "mcp.json");
|
|
266
|
-
const wrote = mergeMcpConfig(file
|
|
327
|
+
const wrote = mergeMcpConfig(file, "mcpServers", {
|
|
328
|
+
beryl: MCP_SERVER_ENTRY,
|
|
329
|
+
playwright: PLAYWRIGHT_SERVER_ENTRY,
|
|
330
|
+
});
|
|
267
331
|
const where = scope === "user" ? file : path.relative(cwd, file);
|
|
268
332
|
report("cursor beryl MCP", wrote.beryl, where);
|
|
269
333
|
report("cursor playwright MCP", wrote.playwright, where);
|
|
334
|
+
const rule = writeCursorRule(cwd);
|
|
335
|
+
ctx.err(`${green("✓")} cursor authoring rule ${outcomeLabel(rule.outcome)} ${dim(path.relative(cwd, rule.file))}`);
|
|
336
|
+
if (scope === "user") {
|
|
337
|
+
ctx.err(dim(" • The rule is repo-local (Cursor has no global rules file); commit it to share it."));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// VS Code (Copilot) is always repo-local: `.vscode/mcp.json` for the servers and a
|
|
341
|
+
// marked section in `.github/copilot-instructions.md` for the guide pointer. VS Code's
|
|
342
|
+
// per-user MCP config is managed through its UI, not a portable file, so scope is moot.
|
|
343
|
+
if (input.flags.vscode) {
|
|
344
|
+
const file = path.join(cwd, ".vscode", "mcp.json");
|
|
345
|
+
const wrote = mergeMcpConfig(file, "servers", {
|
|
346
|
+
beryl: VSCODE_MCP_SERVER_ENTRY,
|
|
347
|
+
playwright: VSCODE_PLAYWRIGHT_SERVER_ENTRY,
|
|
348
|
+
});
|
|
349
|
+
const where = path.relative(cwd, file);
|
|
350
|
+
report("vscode beryl MCP", wrote.beryl, where);
|
|
351
|
+
report("vscode playwright MCP", wrote.playwright, where);
|
|
352
|
+
const inst = writeCopilotInstructions(cwd);
|
|
353
|
+
ctx.err(`${green("✓")} copilot instructions ${outcomeLabel(inst.outcome)} ${dim(path.relative(cwd, inst.file))}`);
|
|
270
354
|
}
|
|
271
355
|
const skills = writeSkills(cwd, scope);
|
|
272
356
|
for (const skill of skills) {
|
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/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