@beryl-so/cli 0.47.0 → 0.50.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/dist/beryl-test-skill.js +63 -13
- package/dist/commands/init.js +106 -22
- package/dist/commands/tests.js +5 -5
- package/dist/lint.js +6 -33
- package/dist/schema.generated.js +58 -0
- package/dist/skill-tables.js +2 -0
- package/package.json +1 -1
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 = {
|
|
@@ -147,19 +181,35 @@ The trade is drift — run 200 has 200 of everything run 1 created. So:
|
|
|
147
181
|
- **Assert relatively, never absolutely.** "The row I just created is present"
|
|
148
182
|
(\`expect.persisted\` on a \`{{unique}}\`-named row), not "there are 3 rows" — a count
|
|
149
183
|
assertion goes stale by itself.
|
|
150
|
-
- **
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
184
|
+
- **Cleanup lives in \`after\`, and it returns the account to the exact state you found
|
|
185
|
+
it.** \`after\` runs on pass AND fail; a trailing step inside \`steps\` does not, so
|
|
186
|
+
never put cleanup in \`steps\`.
|
|
187
|
+
- **Created something?** Delete it in \`after\`.
|
|
188
|
+
- **Deleting something?** Create your own victim first (in \`before\` or the opening
|
|
189
|
+
steps, \`{{unique}}\`-named), then delete it and prove \`gone\`; when the deleted thing
|
|
190
|
+
must persist, re-create it in \`after\` as it was. Never aim a delete at standing data:
|
|
191
|
+
run 2 has nothing to delete, and it eats the account's accumulated coverage.
|
|
192
|
+
- **Changed a value (rename, toggle)?** Restore it in \`after\`: set it back to what it
|
|
193
|
+
was. Capture the original BEFORE you change it with \`capture_text\` (or \`capture_value\`
|
|
194
|
+
for a form input), banking it under \`capture_as\`, then \`fill {{handle}}\` in \`after\`
|
|
195
|
+
to put it back.
|
|
196
|
+
- **You label each test \`read\` or \`write\`; Beryl takes your word for it.** Set it with
|
|
197
|
+
\`--access\` (default \`read\`). Beryl does NOT infer it from your selectors, so YOU
|
|
198
|
+
decide, by one test: **would the app be different after this test runs?**
|
|
199
|
+
- **\`write\`** = it leaves a change behind: it creates, edits, deletes, renames, moves,
|
|
200
|
+
uploads or toggles a record or setting that is still different once the test ends. A
|
|
201
|
+
\`write\` must undo itself: end \`after\` with an \`expect\` proving the undo: \`gone\`,
|
|
202
|
+
\`hidden\`, \`have_count\` or \`count_delta\` for a removal, \`have_text\`,
|
|
203
|
+
\`have_value\` or \`persisted\` for a restore.
|
|
204
|
+
- **\`read\`** = it only observes: navigating, searching, filtering, sorting, opening a
|
|
205
|
+
menu or dialog and reading it, or clicking a destructive button and then CANCELLING.
|
|
206
|
+
Nothing is committed. Opening a confirm dialog and cancelling is a \`read\`; a submit
|
|
207
|
+
that stays disabled is a \`read\`.
|
|
208
|
+
A flow that both changes and reads is a \`write\`. Only the \`write\` label is checked
|
|
209
|
+
(its closing teardown proof, and a \`write\` whose teardown removes nothing fails the
|
|
210
|
+
run); \`read\` is taken at your word, so a real write you mislabel \`read\` silently
|
|
211
|
+
drifts the account. Label deliberately, and flip a banked test with
|
|
212
|
+
\`beryl tests set-access <id> read|write\`.
|
|
163
213
|
|
|
164
214
|
### If there is no account yet
|
|
165
215
|
|
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
|
@@ -110,8 +110,8 @@ export const testCommands = [
|
|
|
110
110
|
{
|
|
111
111
|
name: "access",
|
|
112
112
|
type: "string",
|
|
113
|
-
description: "The label the plan will be banked with
|
|
114
|
-
"
|
|
113
|
+
description: "The label the plan will be banked with: read (default) or write. It is yours to " +
|
|
114
|
+
"set; Beryl does not infer it. Only a write is checked (its closing teardown proof)",
|
|
115
115
|
},
|
|
116
116
|
],
|
|
117
117
|
examples: [
|
|
@@ -580,9 +580,9 @@ export const testCommands = [
|
|
|
580
580
|
{
|
|
581
581
|
name: "tests set-access",
|
|
582
582
|
summary: "Mark a test read or write",
|
|
583
|
-
description: "read: the test only reads the app. write:
|
|
584
|
-
"
|
|
585
|
-
"the
|
|
583
|
+
description: "read: the test only reads the app. write: it creates, edits, renames, deletes or " +
|
|
584
|
+
"toggles something that persists. The label is yours to set; Beryl does not infer it " +
|
|
585
|
+
"from the plan. A write test must clean up in `after`, ending with a proof expect.",
|
|
586
586
|
scope: "project",
|
|
587
587
|
args: [
|
|
588
588
|
{ name: "test-id", description: "Test id", required: true },
|
package/dist/lint.js
CHANGED
|
@@ -9,9 +9,6 @@ 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
12
|
export const WRITE_NEEDS_CLOSING_TEARDOWN = (detail) => "A 'write' test must end with an 'after' section that undoes what it changed and " +
|
|
16
13
|
"proves it: the last step of 'after' is an expect with expect_kind 'gone', 'hidden', " +
|
|
17
14
|
"'have_count', 'count_delta' (something removed) or 'have_text', 'have_value', " +
|
|
@@ -177,31 +174,6 @@ function identityIssues(plan, provisioning) {
|
|
|
177
174
|
? [{ path: "(root)", message: MINTED_IDENTITY_NEEDS_TEARDOWN }]
|
|
178
175
|
: [];
|
|
179
176
|
}
|
|
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
177
|
const TEARDOWN_PROOF_KINDS = new Set([
|
|
206
178
|
"gone",
|
|
207
179
|
"hidden",
|
|
@@ -211,11 +183,11 @@ const TEARDOWN_PROOF_KINDS = new Set([
|
|
|
211
183
|
"have_value",
|
|
212
184
|
"persisted",
|
|
213
185
|
]);
|
|
186
|
+
// The read/write label is the author's call; Beryl no longer infers it from the plan.
|
|
187
|
+
// Only a `write` label is checked here: it must close its teardown with a proof.
|
|
214
188
|
function accessIssues(plan, access) {
|
|
215
|
-
if (access !== "write")
|
|
216
|
-
|
|
217
|
-
return signal ? [{ path: "(root)", message: WRITE_NEEDS_LABEL(signal[0], signal[1]) }] : [];
|
|
218
|
-
}
|
|
189
|
+
if (access !== "write")
|
|
190
|
+
return [];
|
|
219
191
|
if (typeof plan !== "object" || plan === null)
|
|
220
192
|
return [];
|
|
221
193
|
const after = steps(plan.after);
|
|
@@ -232,7 +204,8 @@ function accessIssues(plan, access) {
|
|
|
232
204
|
}
|
|
233
205
|
// `provisioning`: the plan is an account's own signup or sign-in plan, the one place
|
|
234
206
|
// {{mailbox_address}} is legal (mirrors the server's validate_editable_plan flag).
|
|
235
|
-
// `access`: the label the plan will be banked with
|
|
207
|
+
// `access`: the label the plan will be banked with. It is the author's call (Beryl no
|
|
208
|
+
// longer infers it from the plan); given, only a `write` is checked, for its closing proof.
|
|
236
209
|
export function lintPlan(plan, opts = {}) {
|
|
237
210
|
const issues = validate(plan, ACTION_PLAN_SCHEMA, ACTION_PLAN_SCHEMA, "");
|
|
238
211
|
// The identity rules read a well-formed plan; a shape error is reported alone.
|
package/dist/schema.generated.js
CHANGED
|
@@ -15,6 +15,8 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
15
15
|
"wait_for",
|
|
16
16
|
"expect",
|
|
17
17
|
"capture_count",
|
|
18
|
+
"capture_text",
|
|
19
|
+
"capture_value",
|
|
18
20
|
"await_email",
|
|
19
21
|
"upload",
|
|
20
22
|
"dialog",
|
|
@@ -325,6 +327,62 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
325
327
|
]
|
|
326
328
|
}
|
|
327
329
|
},
|
|
330
|
+
{
|
|
331
|
+
"if": {
|
|
332
|
+
"properties": {
|
|
333
|
+
"action": {
|
|
334
|
+
"const": "capture_text"
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
"required": [
|
|
338
|
+
"action"
|
|
339
|
+
]
|
|
340
|
+
},
|
|
341
|
+
"then": {
|
|
342
|
+
"properties": {
|
|
343
|
+
"capture_as": {
|
|
344
|
+
"minLength": 1,
|
|
345
|
+
"type": "string"
|
|
346
|
+
},
|
|
347
|
+
"selector": {
|
|
348
|
+
"minLength": 1,
|
|
349
|
+
"type": "string"
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
"required": [
|
|
353
|
+
"capture_as",
|
|
354
|
+
"selector"
|
|
355
|
+
]
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
"if": {
|
|
360
|
+
"properties": {
|
|
361
|
+
"action": {
|
|
362
|
+
"const": "capture_value"
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
"required": [
|
|
366
|
+
"action"
|
|
367
|
+
]
|
|
368
|
+
},
|
|
369
|
+
"then": {
|
|
370
|
+
"properties": {
|
|
371
|
+
"capture_as": {
|
|
372
|
+
"minLength": 1,
|
|
373
|
+
"type": "string"
|
|
374
|
+
},
|
|
375
|
+
"selector": {
|
|
376
|
+
"minLength": 1,
|
|
377
|
+
"type": "string"
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
"required": [
|
|
381
|
+
"capture_as",
|
|
382
|
+
"selector"
|
|
383
|
+
]
|
|
384
|
+
}
|
|
385
|
+
},
|
|
328
386
|
{
|
|
329
387
|
"if": {
|
|
330
388
|
"properties": {
|
package/dist/skill-tables.js
CHANGED
|
@@ -27,6 +27,8 @@ export const ACTION_DOCS = {
|
|
|
27
27
|
wait_for: "Wait for the `selector` element to appear",
|
|
28
28
|
expect: "Assert — kinds in the expect table below",
|
|
29
29
|
capture_count: "Bank the live count of `selector` matches under `capture_as` for a later `count_delta`",
|
|
30
|
+
capture_text: "Bank the `selector` element's visible text under `capture_as`, to restore it later via `{{handle}}`",
|
|
31
|
+
capture_value: "Bank the `selector` input's value under `capture_as`, to restore it later via `{{handle}}`",
|
|
30
32
|
await_email: "Await mail in the run inbox, bank the extracted value under `capture_as` (§5)",
|
|
31
33
|
upload: "Set a file input: `value` names a project config file — see below",
|
|
32
34
|
dialog: "Arm a one-shot accept/dismiss handler for the NEXT step's dialog — see below",
|
package/package.json
CHANGED