@beryl-so/cli 0.7.1 → 0.8.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 +68 -0
- package/dist/commands/init.js +75 -7
- package/package.json +1 -1
package/dist/beryl-test-skill.js
CHANGED
|
@@ -19,6 +19,26 @@ export const BERYL_TEST_SKILL_EXAMPLE_PLAN = {
|
|
|
19
19
|
{ action: "expect", expect_kind: "visible", selector: "text=Pro plan" },
|
|
20
20
|
],
|
|
21
21
|
};
|
|
22
|
+
// The OTP/signup example the skill shows verbatim (§5). Exported so the test suite lints
|
|
23
|
+
// it with lintPlan, same as the minimal example — the documented await_email shape must
|
|
24
|
+
// pass `beryl tests lint` on the first try.
|
|
25
|
+
export const BERYL_TEST_SKILL_OTP_EXAMPLE_PLAN = {
|
|
26
|
+
steps: [
|
|
27
|
+
{ action: "goto", url: "https://app.example.com/signup" },
|
|
28
|
+
{ action: "fill", selector: "input[name=email]", value: "{{inbox_address}}" },
|
|
29
|
+
{ action: "click", selector: "button[type=submit]" },
|
|
30
|
+
{
|
|
31
|
+
action: "await_email",
|
|
32
|
+
extract: "code",
|
|
33
|
+
subject_contains: "verification code",
|
|
34
|
+
capture_as: "otp",
|
|
35
|
+
wait_s: 45,
|
|
36
|
+
},
|
|
37
|
+
{ action: "fill", selector: "input[name=code]", value: "{{otp}}" },
|
|
38
|
+
{ action: "click", selector: "text=Verify" },
|
|
39
|
+
{ action: "expect", expect_kind: "visible", selector: "text=Welcome" },
|
|
40
|
+
],
|
|
41
|
+
};
|
|
22
42
|
export const BERYL_TEST_SKILL = `---
|
|
23
43
|
name: beryl-test
|
|
24
44
|
description: Author durable, healable end-to-end tests for a web app with Beryl. Use when writing, running, or fixing a Beryl test locally with your own coding agent — drafting the plan over the Playwright MCP, writing the natural-language intent, and running it with \`beryl runs local\`.
|
|
@@ -176,4 +196,52 @@ beryl runs local <test-id> --url-override http://localhost:3000 --dir ./beryl-lo
|
|
|
176
196
|
|
|
177
197
|
Once the test passes locally against a real outcome, it's ready to bank and let Beryl run
|
|
178
198
|
and heal it.
|
|
199
|
+
|
|
200
|
+
## 5. Testing an OTP / signup flow (\`await_email\`)
|
|
201
|
+
|
|
202
|
+
A flow that emails the user — a signup verification code, a magic sign-in link, a receipt
|
|
203
|
+
— is testable with the \`await_email\` action. Beryl mints a **run-scoped inbox**
|
|
204
|
+
automatically whenever a plan contains an \`await_email\` step (or cites
|
|
205
|
+
\`{{inbox_address}}\`): no setup, no environment configuration, no flag to turn on. The
|
|
206
|
+
minted address is in scope from step 1 as the reserved \`{{inbox_address}}\` handle.
|
|
207
|
+
|
|
208
|
+
The wiring is a three-part chain:
|
|
209
|
+
|
|
210
|
+
1. **Type the minted address into the app** — a \`fill\` with \`value: "{{inbox_address}}"\`.
|
|
211
|
+
Every run gets a fresh address, so a signup flow is repeatable by construction (no
|
|
212
|
+
\`{{unique}}\` needed for the email itself; use \`{{unique}}\` for other must-not-collide
|
|
213
|
+
values like a username).
|
|
214
|
+
2. **Await the mail and bank the extracted value** — an \`await_email\` step with:
|
|
215
|
+
- \`extract\` (required): \`code\` (an OTP), \`link\` (the sign-in/verify URL), or
|
|
216
|
+
\`pattern\` (your own regex in \`extract_pattern\`, exactly one capture group).
|
|
217
|
+
- \`capture_as\` (required): the handle name the extracted string is banked under.
|
|
218
|
+
- \`subject_contains\` / \`from_contains\` (optional): match the right mail when the app
|
|
219
|
+
sends more than one.
|
|
220
|
+
- \`wait_s\` (optional, 1–50, default 30): how many seconds the step blocks waiting
|
|
221
|
+
for the mail to land.
|
|
222
|
+
3. **Use the banked value** — cite \`{{<capture_as>}}\` in a later step's \`value\` (fill the
|
|
223
|
+
code) or \`url\` (goto the magic link). A captured handle is legal **only** in
|
|
224
|
+
\`value\`/\`url\`; in a \`selector\`, \`option\`, or \`expect_text\` it would be used as
|
|
225
|
+
literal text, and the linter rejects it there.
|
|
226
|
+
|
|
227
|
+
A fully valid signup-with-OTP plan:
|
|
228
|
+
|
|
229
|
+
\`\`\`json
|
|
230
|
+
${JSON.stringify(BERYL_TEST_SKILL_OTP_EXAMPLE_PLAN, null, 2)}
|
|
231
|
+
\`\`\`
|
|
232
|
+
|
|
233
|
+
For a magic-link flow, replace the code steps with
|
|
234
|
+
\`{ "action": "await_email", "extract": "link", "capture_as": "signin_link" }\` followed by
|
|
235
|
+
\`{ "action": "goto", "url": "{{signin_link}}" }\`.
|
|
236
|
+
|
|
237
|
+
Two caveats:
|
|
238
|
+
|
|
239
|
+
- \`beryl tests create\` verifies an \`await_email\` plan like any other — the replay mints
|
|
240
|
+
its own inbox, so the app's mail really is received and extracted before the test is
|
|
241
|
+
accepted. (Note the replay signs up / sends mail for real; pass \`--no-verify\` only if
|
|
242
|
+
that side effect is unwanted.) \`runs local\` cannot serve \`await_email\` — the inbox
|
|
243
|
+
lives in Beryl's cloud — so iterate on these flows with
|
|
244
|
+
\`beryl runs trigger --test <id> --watch\`.
|
|
245
|
+
- The outcome assertion discipline from §1 still applies: the green signal is the
|
|
246
|
+
post-verification state (the welcome screen, the dashboard), not "an email arrived".
|
|
179
247
|
`;
|
package/dist/commands/init.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
import { BERYL_TEST_SKILL, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME, } from "../beryl-test-skill.js";
|
|
4
6
|
import { LOCAL_CONFIG_FILENAME, loadConfig } from "../config.js";
|
|
@@ -16,9 +18,12 @@ const MCP_SERVER_ENTRY = {
|
|
|
16
18
|
command: "npx",
|
|
17
19
|
args: ["-y", "@beryl-so/cli@latest", "mcp"],
|
|
18
20
|
};
|
|
21
|
+
// Headless: the coding agent drives this browser to author tests — nobody watches the
|
|
22
|
+
// window, and a headed default breaks on CI / headless boxes. Matches how most devs
|
|
23
|
+
// already wire their own user-scoped playwright.
|
|
19
24
|
const PLAYWRIGHT_SERVER_ENTRY = {
|
|
20
25
|
command: "npx",
|
|
21
|
-
args: ["@playwright/mcp@latest"],
|
|
26
|
+
args: ["@playwright/mcp@latest", "--headless"],
|
|
22
27
|
};
|
|
23
28
|
const ACTION_PLAN_SCHEMA_URL = "https://api.beryl.so/api/v1/schemas/action-plan.schema.json";
|
|
24
29
|
// The authoring fork only appears when we create a fresh project with no tests yet; an
|
|
@@ -76,6 +81,36 @@ function mergeMcpConfig(file, withPlaywright) {
|
|
|
76
81
|
}
|
|
77
82
|
return { beryl, playwright };
|
|
78
83
|
}
|
|
84
|
+
// A tool's config lives per-user (same as the PAT it needs, in ~/.config/beryl) — so wire the
|
|
85
|
+
// MCP servers per-user too, not in a committed .mcp.json that 401s for every teammate until
|
|
86
|
+
// they run `beryl login` anyway. We DON'T hand-edit ~/.claude.json (Claude Code owns it; a
|
|
87
|
+
// corrupt write breaks the user's whole CLI) — we shell out to `claude mcp add` and let Claude
|
|
88
|
+
// own the edit. Returns false when the `claude` binary isn't on PATH so the caller can fall
|
|
89
|
+
// back to a copy-paste command.
|
|
90
|
+
function claudeUserAdd(name, entry) {
|
|
91
|
+
try {
|
|
92
|
+
execFileSync("claude", ["mcp", "add", name, "-s", "user", "--", entry.command, ...entry.args], {
|
|
93
|
+
stdio: "ignore",
|
|
94
|
+
});
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
// A non-zero exit ALSO covers "already exists" — but so does a missing binary. Distinguish:
|
|
99
|
+
// ENOENT means no `claude` on PATH (fall back), anything else means it ran and declined
|
|
100
|
+
// (already configured — treat as success, nothing to do).
|
|
101
|
+
if (err.code === "ENOENT")
|
|
102
|
+
return false;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function claudeAddHint(name, entry) {
|
|
107
|
+
return `claude mcp add ${name} -s user -- ${entry.command} ${entry.args.join(" ")}`;
|
|
108
|
+
}
|
|
109
|
+
// ~/.cursor/mcp.json is Cursor's user-scope config — small and ours to write safely, unlike
|
|
110
|
+
// ~/.claude.json — so we merge it directly rather than shelling out.
|
|
111
|
+
function cursorUserConfigPath() {
|
|
112
|
+
return path.join(os.homedir(), ".cursor", "mcp.json");
|
|
113
|
+
}
|
|
79
114
|
function detectEditors(cwd) {
|
|
80
115
|
const editors = [];
|
|
81
116
|
if (fs.existsSync(path.join(cwd, ".claude")) || fs.existsSync(path.join(cwd, "CLAUDE.md")))
|
|
@@ -108,9 +143,11 @@ export const initCommands = [
|
|
|
108
143
|
name: "init",
|
|
109
144
|
summary: "Set up Beryl in this repo — sign in, pin a project, wire up your coding agent",
|
|
110
145
|
description: "One-command onboarding: signs you in (emailed one-time code), pins this repo to a " +
|
|
111
|
-
"workspace and project via .beryl.json (offering to create the project), and
|
|
112
|
-
"MCP
|
|
113
|
-
"
|
|
146
|
+
"workspace and project via .beryl.json (offering to create the project), and wires the " +
|
|
147
|
+
"MCP servers for your coding agent. By default they're wired per-user (matching where your " +
|
|
148
|
+
"login token lives) — via `claude mcp add -s user` for Claude Code, ~/.cursor/mcp.json for " +
|
|
149
|
+
"Cursor; pass --scope project to write a committed .mcp.json for a shared repo instead. " +
|
|
150
|
+
"When creating a fresh project it asks how you want to author tests — locally with " +
|
|
114
151
|
"your own coding agent or by hand (the default), or by letting Beryl's agent explore and " +
|
|
115
152
|
"author them for you. Safe to re-run; every step skips what is already set up.",
|
|
116
153
|
interactive: true,
|
|
@@ -138,6 +175,15 @@ export const initCommands = [
|
|
|
138
175
|
description: "Which coding agent to write MCP config for (default: auto-detect)",
|
|
139
176
|
},
|
|
140
177
|
{ name: "no-pin", type: "boolean", description: "Skip writing .beryl.json" },
|
|
178
|
+
{
|
|
179
|
+
name: "scope",
|
|
180
|
+
type: "string",
|
|
181
|
+
enum: ["user", "project"],
|
|
182
|
+
description: "Where to wire the MCP servers. `user` (default) configures them per-user (matching " +
|
|
183
|
+
"where your Beryl login token lives) via `claude mcp add -s user` / ~/.cursor/mcp.json. " +
|
|
184
|
+
"`project` writes a committed .mcp.json for a shared repo — every teammate still runs " +
|
|
185
|
+
"`beryl login` to authenticate",
|
|
186
|
+
},
|
|
141
187
|
{
|
|
142
188
|
name: "local",
|
|
143
189
|
type: "boolean",
|
|
@@ -149,6 +195,7 @@ export const initCommands = [
|
|
|
149
195
|
examples: [
|
|
150
196
|
"npx @beryl-so/cli@latest init",
|
|
151
197
|
"beryl init --editor-tools claude-code",
|
|
198
|
+
"beryl init --scope project",
|
|
152
199
|
"beryl init --project https://app.example.com --authoring agent",
|
|
153
200
|
"beryl init --project https://app.example.com --authoring local --editor-tools none",
|
|
154
201
|
],
|
|
@@ -232,14 +279,35 @@ export const initCommands = [
|
|
|
232
279
|
// and local authoring needs the Playwright MCP — so default it on. `--no-local` (parsed
|
|
233
280
|
// as an explicit false) opts out.
|
|
234
281
|
const local = input.flags.local ?? editors.length > 0;
|
|
282
|
+
const scope = (flagStr(input, "scope") ?? "user");
|
|
235
283
|
for (const editor of editors) {
|
|
284
|
+
if (scope === "user" && editor === "claude-code") {
|
|
285
|
+
// Claude Code owns ~/.claude.json — shell out to `claude mcp add` rather than write it.
|
|
286
|
+
const berylOk = claudeUserAdd("beryl", MCP_SERVER_ENTRY);
|
|
287
|
+
const playwrightOk = local ? claudeUserAdd("playwright", PLAYWRIGHT_SERVER_ENTRY) : undefined;
|
|
288
|
+
if (berylOk) {
|
|
289
|
+
ctx.err(`${green("✓")} claude-code MCP configured ${dim("(user scope)")}`);
|
|
290
|
+
if (playwrightOk)
|
|
291
|
+
ctx.err(`${green("✓")} claude-code Playwright MCP configured ${dim("(user scope)")}`);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
ctx.err(yellow("• `claude` not on PATH — run these to wire user-scope MCP servers:"));
|
|
295
|
+
ctx.err(` ${cyan(claudeAddHint("beryl", MCP_SERVER_ENTRY))}`);
|
|
296
|
+
if (local)
|
|
297
|
+
ctx.err(` ${cyan(claudeAddHint("playwright", PLAYWRIGHT_SERVER_ENTRY))}`);
|
|
298
|
+
}
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
236
301
|
const file = editor === "claude-code"
|
|
237
302
|
? path.join(cwd, ".mcp.json")
|
|
238
|
-
:
|
|
303
|
+
: scope === "user"
|
|
304
|
+
? cursorUserConfigPath()
|
|
305
|
+
: path.join(cwd, ".cursor", "mcp.json");
|
|
239
306
|
const wrote = mergeMcpConfig(file, local);
|
|
240
|
-
|
|
307
|
+
const where = scope === "user" ? file : path.relative(cwd, file);
|
|
308
|
+
ctx.err(`${green("✓")} ${editor} MCP ${wrote.beryl ? "configured" : "already configured"} ${dim(where)}`);
|
|
241
309
|
if (wrote.playwright !== undefined)
|
|
242
|
-
ctx.err(`${green("✓")} ${editor} Playwright MCP ${wrote.playwright ? "configured" : "already configured"} ${dim(
|
|
310
|
+
ctx.err(`${green("✓")} ${editor} Playwright MCP ${wrote.playwright ? "configured" : "already configured"} ${dim(where)}`);
|
|
243
311
|
}
|
|
244
312
|
if (choice === "auto" && editors.length === 0)
|
|
245
313
|
ctx.err(dim("No coding agent detected — pass --editor-tools claude-code|cursor to wire one."));
|
package/package.json
CHANGED