@beryl-so/cli 0.2.0 → 0.5.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 +194 -149
- package/dist/adapters/cli.js +39 -11
- package/dist/adapters/mcp.js +6 -0
- package/dist/beryl-test-skill.js +136 -0
- package/dist/commands/account.js +3 -0
- package/dist/commands/auth.js +1 -0
- package/dist/commands/config-vars.js +1 -0
- package/dist/commands/credentials.js +2 -0
- package/dist/commands/environments.js +2 -0
- package/dist/commands/explorations.js +1 -0
- package/dist/commands/init.js +93 -14
- package/dist/commands/projects.js +1 -0
- package/dist/commands/runs.js +104 -2
- package/dist/commands/tests.js +23 -3
- package/dist/commands/workspaces.js +3 -0
- package/dist/local-run.js +168 -0
- package/dist/registry/index.js +3 -0
- package/dist/schema.generated.js +152 -1
- package/package.json +1 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// The `beryl-test` authoring skill, installed by `beryl init` into
|
|
2
|
+
// `.agents/skills/beryl-test/SKILL.md` (vendor-neutral, editor-agnostic). Kept as an
|
|
3
|
+
// embedded string so it ships in the published package (`files: ["dist"]`) with no
|
|
4
|
+
// build-time asset copy, and so there is ONE source for the guidance — not a copy in
|
|
5
|
+
// the CLI and another in the docs. Edit here; `init` writes it verbatim.
|
|
6
|
+
export const BERYL_TEST_SKILL_FILENAME = "SKILL.md";
|
|
7
|
+
export const BERYL_TEST_SKILL_DIR = "beryl-test";
|
|
8
|
+
export const BERYL_TEST_SKILL = `---
|
|
9
|
+
name: beryl-test
|
|
10
|
+
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\`.
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Authoring Beryl tests
|
|
14
|
+
|
|
15
|
+
Beryl is automated web-app testing: a test drives a real browser through a flow and
|
|
16
|
+
asserts the flow reached its meaningful outcome. \`beryl init\` has already wired two MCP
|
|
17
|
+
servers for you — **beryl** (create/list/run tests) and **playwright** (drive a real
|
|
18
|
+
browser). Your job is to author tests that keep passing as the app's markup drifts,
|
|
19
|
+
because Beryl can **heal** them — but only when you give it what it needs to.
|
|
20
|
+
|
|
21
|
+
Read this before authoring. The three ideas that make a test durable: a real **outcome
|
|
22
|
+
assertion**, a strong **natural-language intent**, and the **local run-fix loop**.
|
|
23
|
+
|
|
24
|
+
## 1. Author locally over the Playwright MCP
|
|
25
|
+
|
|
26
|
+
1. **Drive the flow in a real browser first.** Use the Playwright MCP to open the app and
|
|
27
|
+
walk the flow by hand — log in, fill the form, submit, whatever the flow is. You act on
|
|
28
|
+
elements by their accessibility ref from the latest page snapshot, not a guessed
|
|
29
|
+
selector. Watch what actually happens; don't author from imagination.
|
|
30
|
+
2. **Write it as an ActionPlan** — a JSON object whose \`steps\` are
|
|
31
|
+
\`{action, selector, url, value, ...}\`. Two structural rules the plan must satisfy:
|
|
32
|
+
- the **first executed step is a \`goto\`** (the flow has to start by navigating somewhere), and
|
|
33
|
+
- **at least one step is an \`expect\`** (a test that asserts nothing is not a test).
|
|
34
|
+
Optional \`before\` / \`after\` arrays hold setup and teardown; \`after\` runs even when a
|
|
35
|
+
main step fails, so a create/update/delete flow can clean up the record it made.
|
|
36
|
+
3. **Validate offline, then create:**
|
|
37
|
+
\`\`\`
|
|
38
|
+
beryl tests lint --file plan.json # check the plan against the schema, no network
|
|
39
|
+
beryl tests create --title "Log in" --file plan.json \\
|
|
40
|
+
--description "<the intent — see §3>"
|
|
41
|
+
\`\`\`
|
|
42
|
+
By default \`create\` verifies the plan in a real browser before accepting it. The full
|
|
43
|
+
ActionPlan JSON Schema is at
|
|
44
|
+
https://api.beryl.so/api/v1/schemas/action-plan.schema.json.
|
|
45
|
+
|
|
46
|
+
### The outcome assertion is the whole game
|
|
47
|
+
|
|
48
|
+
A flow is only worth banking if you can point at the **success signal** — the one
|
|
49
|
+
observable proof the flow worked. Get this right and everything else follows.
|
|
50
|
+
|
|
51
|
+
- The signal must be **true only if the flow succeeded**. A confirmation message that
|
|
52
|
+
appeared, an element that showed up or disappeared, content unique to where the flow
|
|
53
|
+
landed.
|
|
54
|
+
- **Never assert global chrome** — the nav bar, logo, footer, or cookie banner is on every
|
|
55
|
+
page, so asserting it tests nothing. "Was there anyway" means site-wide chrome, NOT the
|
|
56
|
+
destination's own distinctive content.
|
|
57
|
+
- For a **navigation** flow, the strongest signal is that the destination actually
|
|
58
|
+
**rendered**: assert its unique heading or a piece of content specific to that page (for
|
|
59
|
+
\`/pricing\`, the "Pricing" H1 or a plan name). Prefer that over the URL alone — a bare
|
|
60
|
+
"the URL is /pricing" passes even on a blank or broken page that never rendered.
|
|
61
|
+
Reserve a URL-only assertion for when the URL *is* the outcome (a form that lands on
|
|
62
|
+
\`/thank-you\`) and no distinctive destination content is available.
|
|
63
|
+
- \`expect\` kinds: \`text\` (a string is visible), \`url\` (the URL contains a value), \`gone\`
|
|
64
|
+
(an element disappeared — e.g. a spinner, or the item you just deleted).
|
|
65
|
+
- **If you can't name a success signal, the flow is not test-worthy.** Don't bank a test
|
|
66
|
+
that verifies nothing. Explore a different flow instead.
|
|
67
|
+
- **Don't work around a real app failure to make a test go green.** If the flow is
|
|
68
|
+
genuinely broken in the app, that's a finding to report — not something to paper over
|
|
69
|
+
with a weaker assertion.
|
|
70
|
+
|
|
71
|
+
## 2. What "durable" and "healable" mean here
|
|
72
|
+
|
|
73
|
+
Beryl's cloud runs your test on a schedule. When the app's markup drifts and a selector
|
|
74
|
+
stops matching, a heal-vs-fail agent decides whether to **heal** the test (silently
|
|
75
|
+
re-derive the selector/trajectory and keep it green) or **fail** it (surface a real
|
|
76
|
+
regression). It decides that against your test's **intent**:
|
|
77
|
+
|
|
78
|
+
- **The natural-language intent is the immutable anchor. Beryl never rewrites it.** It's
|
|
79
|
+
the description of what the test proves — the load-bearing statement the heal agent
|
|
80
|
+
judges every future run against.
|
|
81
|
+
- **Selectors and the trajectory are the healable "how".** A button moved, a class name
|
|
82
|
+
changed, a step needs an extra click — those are mechanics Beryl can re-derive on its
|
|
83
|
+
own, because your intent tells it what the flow was *for*.
|
|
84
|
+
- **A failed outcome assertion is a real regression Beryl will NOT silently heal green.**
|
|
85
|
+
If the success signal from §1 stops holding — the confirmation never appears, the page
|
|
86
|
+
never renders — that's the app breaking, and the test fails loudly. That is the point.
|
|
87
|
+
|
|
88
|
+
So a test is *healable* exactly when you gave it **a strong intent + a real outcome
|
|
89
|
+
assertion**. A test with a vague intent and a chrome-only assertion is brittle: Beryl
|
|
90
|
+
can't tell a real regression from cosmetic drift, so it either heals over real breakage or
|
|
91
|
+
fails on noise.
|
|
92
|
+
|
|
93
|
+
## 3. Writing the natural-language intent
|
|
94
|
+
|
|
95
|
+
Pass the intent as \`--description\` on \`beryl tests create\` (or \`tests set-plan\` when you
|
|
96
|
+
re-author). 1–3 sentences. This is the immutable anchor from §2 — write it well.
|
|
97
|
+
|
|
98
|
+
- **State the purpose, not the steps.** Not "clicks Sign in, types email and password,
|
|
99
|
+
clicks submit" — that's the trajectory, which Beryl already has and which will change.
|
|
100
|
+
Instead: *what does a green run prove is true about the app?*
|
|
101
|
+
- **Name the one observable outcome** that is true only if the flow worked — the same
|
|
102
|
+
success signal you asserted in §1, in words.
|
|
103
|
+
- **Never describe global chrome.** The intent is about the flow's destination and
|
|
104
|
+
outcome, not "the header is present".
|
|
105
|
+
|
|
106
|
+
Good:
|
|
107
|
+
> "Proves a returning user can sign in: after submitting valid credentials, the
|
|
108
|
+
> authenticated dashboard with the user's name in the header loads."
|
|
109
|
+
|
|
110
|
+
Weak (describes steps + asserts nothing meaningful):
|
|
111
|
+
> "Goes to /login, fills the form, and checks the page loaded."
|
|
112
|
+
|
|
113
|
+
## 4. The local run-fix loop
|
|
114
|
+
|
|
115
|
+
Iterate on your machine before you rely on the cloud. \`beryl runs local\` fetches the
|
|
116
|
+
test's rendered spec and runs it with your local \`@playwright/test\` — no cloud, no waiting
|
|
117
|
+
for a scheduled run.
|
|
118
|
+
|
|
119
|
+
\`\`\`
|
|
120
|
+
npm i -D @playwright/test && npx playwright install # once
|
|
121
|
+
beryl runs local <test-id> --url-override http://localhost:3000 --dir ./beryl-local
|
|
122
|
+
\`\`\`
|
|
123
|
+
|
|
124
|
+
- \`--url-override\` points the run at a local dev server or a preview deploy.
|
|
125
|
+
- \`--dir\` keeps the **spec, artifacts, and a JSON \`report.json\`** on disk so you (or your
|
|
126
|
+
coding agent) can read exactly what happened and iterate: read the report, see which step
|
|
127
|
+
or assertion failed and why, fix the plan, \`beryl tests set-plan\`, run again.
|
|
128
|
+
- It exits **0** if every test passed, **1** on a failure — so it drops straight into a
|
|
129
|
+
run-fix-run loop.
|
|
130
|
+
- **v1 is public / unauthenticated flows only.** A test that signs in first runs only in
|
|
131
|
+
Beryl's cloud (which holds the encrypted session — it's never handed to your disk);
|
|
132
|
+
\`runs local\` refuses it. Run those with \`beryl runs trigger\`.
|
|
133
|
+
|
|
134
|
+
Once the test passes locally against a real outcome, it's ready to bank and let Beryl run
|
|
135
|
+
and heal it.
|
|
136
|
+
`;
|
package/dist/commands/account.js
CHANGED
|
@@ -4,6 +4,7 @@ export const accountCommands = [
|
|
|
4
4
|
{
|
|
5
5
|
name: "account get",
|
|
6
6
|
summary: "Show your account profile",
|
|
7
|
+
groupSummary: "View and update your personal account profile.",
|
|
7
8
|
async run(ctx) {
|
|
8
9
|
return { data: await ctx.client.get("/account/") };
|
|
9
10
|
},
|
|
@@ -36,6 +37,7 @@ export const accountCommands = [
|
|
|
36
37
|
{
|
|
37
38
|
name: "feedback send",
|
|
38
39
|
summary: "Send product feedback to the Beryl team",
|
|
40
|
+
groupSummary: "Send product feedback to the Beryl team.",
|
|
39
41
|
args: [{ name: "message", description: "Your feedback", required: true }],
|
|
40
42
|
async run(ctx, input) {
|
|
41
43
|
return { data: await ctx.client.post("/feedback", { message: arg(input, "message") }) };
|
|
@@ -45,6 +47,7 @@ export const accountCommands = [
|
|
|
45
47
|
name: "billing usage",
|
|
46
48
|
summary: "Show plan usage: services and weekly AI units",
|
|
47
49
|
scope: "workspace",
|
|
50
|
+
groupSummary: "Review a workspace's plan usage, subscription, and invoices.",
|
|
48
51
|
async run(ctx, input) {
|
|
49
52
|
const ws = await ctx.requireWorkspace(input);
|
|
50
53
|
return { data: await ctx.client.get(`/workspaces/${ws}/billing/usage`) };
|
package/dist/commands/auth.js
CHANGED
|
@@ -169,6 +169,7 @@ export const authCommands = [
|
|
|
169
169
|
{
|
|
170
170
|
name: "tokens list",
|
|
171
171
|
summary: "List your personal access tokens",
|
|
172
|
+
groupSummary: "Manage the personal access tokens that authenticate the CLI and CI.",
|
|
172
173
|
async run(ctx) {
|
|
173
174
|
const tokens = (await ctx.client.get("/account/tokens"));
|
|
174
175
|
return { data: tokens };
|
|
@@ -17,6 +17,7 @@ export const configCommands = [
|
|
|
17
17
|
name: "config vars list",
|
|
18
18
|
summary: "List the project's config variables (visible to the agent during runs)",
|
|
19
19
|
scope: "project",
|
|
20
|
+
groupSummary: "Manage the variables, secrets, and files the agent can use while exploring and running.",
|
|
20
21
|
async run(ctx, input) {
|
|
21
22
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
22
23
|
return { data: await ctx.client.get(`${configPath(workspaceId, projectId)}/variables`) };
|
|
@@ -6,6 +6,7 @@ export const credentialCommands = [
|
|
|
6
6
|
name: "credentials list",
|
|
7
7
|
summary: "List the workspace's saved logins",
|
|
8
8
|
scope: "workspace",
|
|
9
|
+
groupSummary: "Manage saved logins Beryl reuses to test behind authentication, and attach them to projects.",
|
|
9
10
|
async run(ctx, input) {
|
|
10
11
|
const ws = await ctx.requireWorkspace(input);
|
|
11
12
|
return { data: await ctx.client.get(`/workspaces/${ws}/credentials`) };
|
|
@@ -103,6 +104,7 @@ export const credentialCommands = [
|
|
|
103
104
|
name: "auth-capture start",
|
|
104
105
|
summary: "Start a login-capture browser session for the project (non-interactive)",
|
|
105
106
|
scope: "project",
|
|
107
|
+
groupSummary: "Drive a browser session that captures a target-site login for Beryl to reuse.",
|
|
106
108
|
async run(ctx, input) {
|
|
107
109
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
108
110
|
return { data: await ctx.client.post(capturePath(workspaceId, projectId)) };
|
|
@@ -5,6 +5,7 @@ export const environmentCommands = [
|
|
|
5
5
|
name: "envs list",
|
|
6
6
|
summary: "List a project's environments",
|
|
7
7
|
scope: "project",
|
|
8
|
+
groupSummary: "Manage a project's environments — the URLs and auth Beryl runs tests against.",
|
|
8
9
|
async run(ctx, input) {
|
|
9
10
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
10
11
|
return { data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/environments`) };
|
|
@@ -95,6 +96,7 @@ export const environmentCommands = [
|
|
|
95
96
|
name: "schedule get",
|
|
96
97
|
summary: "Show the project's daily/weekly run schedule",
|
|
97
98
|
scope: "project",
|
|
99
|
+
groupSummary: "View and set the schedule on which Beryl runs a project's tests automatically.",
|
|
98
100
|
async run(ctx, input) {
|
|
99
101
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
100
102
|
return { data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/schedule`) };
|
|
@@ -3,6 +3,7 @@ import { watchExploration } from "./watch.js";
|
|
|
3
3
|
export const explorationCommands = [
|
|
4
4
|
{
|
|
5
5
|
name: "explorations list",
|
|
6
|
+
groupSummary: "Inspect the agent's exploration runs — how it crawled a site and authored its tests.",
|
|
6
7
|
summary: "List the agent's exploration passes for a project",
|
|
7
8
|
scope: "project",
|
|
8
9
|
async run(ctx, input) {
|
package/dist/commands/init.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { BERYL_TEST_SKILL, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME, } from "../beryl-test-skill.js";
|
|
3
4
|
import { LOCAL_CONFIG_FILENAME, loadConfig } from "../config.js";
|
|
4
5
|
import { CliError, UsageError } from "../errors.js";
|
|
5
6
|
import { ApiClient } from "../http.js";
|
|
@@ -14,6 +15,22 @@ const PLAYWRIGHT_SERVER_ENTRY = {
|
|
|
14
15
|
command: "npx",
|
|
15
16
|
args: ["@playwright/mcp@latest"],
|
|
16
17
|
};
|
|
18
|
+
const ACTION_PLAN_SCHEMA_URL = "https://api.beryl.so/api/v1/schemas/action-plan.schema.json";
|
|
19
|
+
// The authoring fork only appears when we create a fresh project with no tests yet; an
|
|
20
|
+
// explicit flag wins, otherwise a TTY prompt defaults to local on enter, and a
|
|
21
|
+
// non-interactive run defaults to local so CI never burns server exploration on a project
|
|
22
|
+
// the user means to hand-author.
|
|
23
|
+
async function resolveAuthoring(ctx, flag) {
|
|
24
|
+
if (flag === "local" || flag === "agent")
|
|
25
|
+
return flag;
|
|
26
|
+
if (!ctx.interactive)
|
|
27
|
+
return "local";
|
|
28
|
+
ctx.err(`\n${bold("How do you want to author tests for this project?")}`);
|
|
29
|
+
ctx.err(` ${green("1")}. Local / manual — with your own coding agent (Playwright MCP) or by hand ${dim("(default)")}`);
|
|
30
|
+
ctx.err(` 2. Beryl agent explores the site and authors tests for you`);
|
|
31
|
+
const answer = await ctx.prompt("Authoring [1-2]: ");
|
|
32
|
+
return answer.trim() === "2" ? "agent" : "local";
|
|
33
|
+
}
|
|
17
34
|
async function pick(ctx, kind, items) {
|
|
18
35
|
if (items.length === 1)
|
|
19
36
|
return items[0];
|
|
@@ -62,19 +79,53 @@ function detectEditors(cwd) {
|
|
|
62
79
|
editors.push("cursor");
|
|
63
80
|
return editors;
|
|
64
81
|
}
|
|
82
|
+
// The authoring skill lives in the vendor-neutral `.agents/skills/` dir (mirroring
|
|
83
|
+
// Momentic), NOT `.claude/` — any coding agent that reads `.agents/skills/` picks it up.
|
|
84
|
+
// Idempotent: an identical copy is left alone; a customer-EDITED copy is never clobbered —
|
|
85
|
+
// we notice and skip so their changes survive a re-run.
|
|
86
|
+
function writeSkill(cwd) {
|
|
87
|
+
const file = path.join(cwd, ".agents", "skills", BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME);
|
|
88
|
+
if (fs.existsSync(file)) {
|
|
89
|
+
// Compare with line endings normalized so a CRLF checkout of our own content still
|
|
90
|
+
// reads as unchanged (not falsely "customized") — we always write LF.
|
|
91
|
+
const norm = (s) => s.replace(/\r\n/g, "\n");
|
|
92
|
+
const current = fs.readFileSync(file, "utf8");
|
|
93
|
+
if (norm(current) === norm(BERYL_TEST_SKILL))
|
|
94
|
+
return { outcome: "unchanged", file };
|
|
95
|
+
return { outcome: "customized", file };
|
|
96
|
+
}
|
|
97
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
98
|
+
fs.writeFileSync(file, BERYL_TEST_SKILL);
|
|
99
|
+
return { outcome: "wrote", file };
|
|
100
|
+
}
|
|
65
101
|
export const initCommands = [
|
|
66
102
|
{
|
|
67
103
|
name: "init",
|
|
68
104
|
summary: "Set up Beryl in this repo — sign in, pin a project, wire up your coding agent",
|
|
69
105
|
description: "One-command onboarding: signs you in (emailed one-time code), pins this repo to a " +
|
|
70
|
-
"workspace and project via .beryl.json (offering to create the project
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
"
|
|
106
|
+
"workspace and project via .beryl.json (offering to create the project), and writes the " +
|
|
107
|
+
"MCP server config for your coding agent (.mcp.json for Claude Code, .cursor/mcp.json for " +
|
|
108
|
+
"Cursor). When creating a fresh project it asks how you want to author tests — locally with " +
|
|
109
|
+
"your own coding agent or by hand (the default), or by letting Beryl's agent explore and " +
|
|
110
|
+
"author them for you. Safe to re-run; every step skips what is already set up.",
|
|
74
111
|
interactive: true,
|
|
75
112
|
flags: [
|
|
76
113
|
{ name: "workspace", type: "string", description: "Workspace id or name to pin" },
|
|
77
|
-
{
|
|
114
|
+
{
|
|
115
|
+
name: "project",
|
|
116
|
+
type: "string",
|
|
117
|
+
description: "Project id, name, or URL to pin. With no projects yet, a URL here is the root URL of " +
|
|
118
|
+
"the project to create — supply it (plus --authoring) to run init non-interactively in CI",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "authoring",
|
|
122
|
+
type: "string",
|
|
123
|
+
enum: ["local", "agent"],
|
|
124
|
+
description: "How to author tests for a newly created project: `local` (author yourself with your " +
|
|
125
|
+
"coding agent or by hand — the project is created without server exploration) or " +
|
|
126
|
+
"`agent` (Beryl's agent explores the site and authors tests). Default: local. " +
|
|
127
|
+
"Pass this to skip the interactive prompt in CI",
|
|
128
|
+
},
|
|
78
129
|
{
|
|
79
130
|
name: "editor-tools",
|
|
80
131
|
type: "string",
|
|
@@ -93,7 +144,8 @@ export const initCommands = [
|
|
|
93
144
|
examples: [
|
|
94
145
|
"npx @beryl-so/cli@latest init",
|
|
95
146
|
"beryl init --editor-tools claude-code",
|
|
96
|
-
"beryl init --project https://app.example.com --
|
|
147
|
+
"beryl init --project https://app.example.com --authoring agent",
|
|
148
|
+
"beryl init --project https://app.example.com --authoring local --editor-tools none",
|
|
97
149
|
],
|
|
98
150
|
async run(ctx, input) {
|
|
99
151
|
const cwd = process.cwd();
|
|
@@ -112,6 +164,7 @@ export const initCommands = [
|
|
|
112
164
|
}
|
|
113
165
|
let workspaceId;
|
|
114
166
|
let projectId;
|
|
167
|
+
let authoredLocally = false;
|
|
115
168
|
if (!flagBool(input, "no-pin")) {
|
|
116
169
|
const workspaces = (await client.get("/workspaces/"));
|
|
117
170
|
if (workspaces.length === 0)
|
|
@@ -125,7 +178,9 @@ export const initCommands = [
|
|
|
125
178
|
workspaceId = workspace.id;
|
|
126
179
|
const projects = (await client.get(`/workspaces/${workspaceId}/projects`));
|
|
127
180
|
const wantedProject = flagStr(input, "project");
|
|
128
|
-
|
|
181
|
+
// With projects already present, --project pins an existing one; with none, it's the
|
|
182
|
+
// root URL of the project to create — which is what makes a fresh init CI-drivable.
|
|
183
|
+
if (wantedProject && projects.length > 0) {
|
|
129
184
|
projectId = (await ctx.requireProject({
|
|
130
185
|
args: {},
|
|
131
186
|
flags: { workspace: workspaceId, project: wantedProject },
|
|
@@ -135,13 +190,19 @@ export const initCommands = [
|
|
|
135
190
|
projectId = (await pick(ctx, "project", projects)).id;
|
|
136
191
|
}
|
|
137
192
|
else {
|
|
138
|
-
const url = await ctx.prompt("No projects yet. Root URL of the site to test: ");
|
|
193
|
+
const url = wantedProject ?? (await ctx.prompt("No projects yet. Root URL of the site to test: "));
|
|
194
|
+
const authoring = await resolveAuthoring(ctx, flagStr(input, "authoring"));
|
|
195
|
+
authoredLocally = authoring === "local";
|
|
139
196
|
const created = (await client.post(`/workspaces/${workspaceId}/projects`, {
|
|
140
197
|
root_url: url,
|
|
198
|
+
skip_exploration: authoredLocally,
|
|
141
199
|
}));
|
|
142
200
|
projectId = created.project_id;
|
|
143
|
-
|
|
144
|
-
|
|
201
|
+
if (authoredLocally)
|
|
202
|
+
ctx.err(`${green("✓")} Project created for ${url} ${dim("(no server exploration — you'll author the tests)")}`);
|
|
203
|
+
else
|
|
204
|
+
ctx.err(`${green("✓")} Project created — the agent is exploring ${url} and authoring tests ` +
|
|
205
|
+
`(watch with \`beryl explorations watch\`)`);
|
|
145
206
|
}
|
|
146
207
|
const localFile = path.join(cwd, LOCAL_CONFIG_FILENAME);
|
|
147
208
|
let local = {};
|
|
@@ -177,16 +238,34 @@ export const initCommands = [
|
|
|
177
238
|
}
|
|
178
239
|
if (choice === "auto" && editors.length === 0)
|
|
179
240
|
ctx.err(dim("No coding agent detected — pass --editor-tools claude-code|cursor to wire one."));
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
241
|
+
// Editor-agnostic: the authoring skill goes to `.agents/skills/` regardless of which
|
|
242
|
+
// (if any) editor MCP config we wrote, so any `.agents/skills/`-aware harness gets it.
|
|
243
|
+
const skill = writeSkill(cwd);
|
|
244
|
+
const skillRel = path.relative(cwd, skill.file);
|
|
245
|
+
if (skill.outcome === "customized")
|
|
246
|
+
ctx.err(`${dim("•")} Beryl authoring skill left as-is ${dim(`(${skillRel} — you edited it; delete it to reinstall)`)}`);
|
|
247
|
+
else
|
|
248
|
+
ctx.err(`${green("✓")} Beryl authoring skill ${skill.outcome === "wrote" ? "installed" : "already installed"} ${dim(skillRel)}`);
|
|
249
|
+
const nextSteps = authoredLocally
|
|
250
|
+
? `\n${bold("Beryl is set up — author your tests locally.")} Next steps:\n` +
|
|
251
|
+
(local
|
|
252
|
+
? ` ${dim("• Playwright MCP is wired — point your coding agent at it to drive a real browser and draft a plan.")}\n`
|
|
253
|
+
: ` ${dim("• Re-run with --editor-tools to wire the Playwright MCP for your coding agent, or author a plan by hand.")}\n`) +
|
|
254
|
+
` ${cyan("beryl tests lint --file plan.json")} validate a plan offline against the schema\n` +
|
|
255
|
+
` ${cyan("beryl tests create --file plan.json")} push a test from your plan\n` +
|
|
256
|
+
` ${cyan("beryl runs trigger --watch")} run the suite\n\n` +
|
|
257
|
+
`Author against the ActionPlan JSON Schema: ${cyan(ACTION_PLAN_SCHEMA_URL)}`
|
|
258
|
+
: `\n${bold("Beryl is set up.")} Try:\n` +
|
|
183
259
|
` ${cyan("beryl runs trigger --watch")} run the suite\n` +
|
|
184
260
|
` ${cyan("beryl explorations watch")} watch the agent work\n` +
|
|
185
261
|
` ${cyan("beryl tests list")} see authored tests` +
|
|
186
262
|
(local
|
|
187
263
|
? `\n\nPlaywright MCP is wired — your coding agent can explore the site locally ` +
|
|
188
264
|
`and push tests with ${cyan("beryl tests create")}.`
|
|
189
|
-
: "")
|
|
265
|
+
: "");
|
|
266
|
+
return {
|
|
267
|
+
data: { workspace: workspaceId ?? null, project: projectId ?? null, editors },
|
|
268
|
+
human: nextSteps,
|
|
190
269
|
};
|
|
191
270
|
},
|
|
192
271
|
},
|
|
@@ -38,6 +38,7 @@ export const projectCommands = [
|
|
|
38
38
|
summary: "List projects in the workspace",
|
|
39
39
|
scope: "workspace",
|
|
40
40
|
groupDefault: true,
|
|
41
|
+
groupSummary: "Create and manage projects — a site Beryl explores, authors tests for, and runs.",
|
|
41
42
|
async run(ctx, input) {
|
|
42
43
|
const ws = await ctx.requireWorkspace(input);
|
|
43
44
|
return { data: await ctx.client.get(`/workspaces/${ws}/projects`) };
|
package/dist/commands/runs.js
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { downloadRunArtifacts, failureImages, isFailing, resultsOf, } from "../artifacts.js";
|
|
3
|
-
import {
|
|
3
|
+
import { CliError, UsageError } from "../errors.js";
|
|
4
|
+
import { runSpecLocally } from "../local-run.js";
|
|
5
|
+
import { dim, green, red, yellow } from "../output.js";
|
|
4
6
|
import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
|
|
5
7
|
import { watchRun } from "./watch.js";
|
|
6
8
|
const MAX_FAILURE_SCREENSHOTS = 5;
|
|
9
|
+
export function parseHeaders(raw) {
|
|
10
|
+
if (!raw || raw.length === 0)
|
|
11
|
+
return null;
|
|
12
|
+
const headers = {};
|
|
13
|
+
for (const entry of raw) {
|
|
14
|
+
const eq = entry.indexOf("=");
|
|
15
|
+
const key = eq > 0 ? entry.slice(0, eq).trim() : "";
|
|
16
|
+
if (!key)
|
|
17
|
+
throw new UsageError(`--header must be KEY=VALUE, got: ${entry}`);
|
|
18
|
+
headers[key] = entry.slice(eq + 1);
|
|
19
|
+
}
|
|
20
|
+
return headers;
|
|
21
|
+
}
|
|
7
22
|
export const runCommands = [
|
|
8
23
|
{
|
|
9
24
|
name: "runs trigger",
|
|
@@ -15,6 +30,12 @@ export const runCommands = [
|
|
|
15
30
|
{ name: "test", type: "strings", description: "Run only these test ids (repeatable)" },
|
|
16
31
|
{ name: "env", type: "string", description: "Environment id to run against" },
|
|
17
32
|
{ name: "url-override", type: "string", description: "Replace the base URL (preview deploys)" },
|
|
33
|
+
{
|
|
34
|
+
name: "header",
|
|
35
|
+
type: "strings",
|
|
36
|
+
description: "Send a custom request header on every navigation, KEY=VALUE (repeatable). " +
|
|
37
|
+
"Reaches auth-walled preview deploys, e.g. --header x-vercel-protection-bypass=<token>",
|
|
38
|
+
},
|
|
18
39
|
{ name: "watch", type: "boolean", description: "Stream progress and exit non-zero on failure" },
|
|
19
40
|
{ name: "timeout", type: "number", description: "With --watch: max minutes to wait" },
|
|
20
41
|
{
|
|
@@ -26,28 +47,109 @@ export const runCommands = [
|
|
|
26
47
|
examples: [
|
|
27
48
|
"beryl runs trigger --watch",
|
|
28
49
|
"beryl runs trigger --url-override https://preview-123.example.com --watch --timeout 30",
|
|
50
|
+
"beryl runs trigger --url-override https://preview-123.example.com --header x-vercel-protection-bypass=<token> --watch",
|
|
29
51
|
"beryl runs trigger --test 4f… --test 9a…",
|
|
30
52
|
"beryl runs trigger --retries 0 --watch",
|
|
31
53
|
],
|
|
32
54
|
async run(ctx, input) {
|
|
33
55
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
34
56
|
const tests = input.flags.test;
|
|
57
|
+
const extraHeaders = parseHeaders(input.flags.header);
|
|
35
58
|
const created = (await ctx.client.post(`${projectPath(workspaceId, projectId)}/runs`, {
|
|
36
59
|
test_case_ids: tests && tests.length > 0 ? tests : null,
|
|
37
60
|
environment_id: flagStr(input, "env") ?? null,
|
|
38
61
|
target_url_override: flagStr(input, "url-override") ?? null,
|
|
39
62
|
max_retries: flagNum(input, "retries") ?? null,
|
|
63
|
+
extra_headers: extraHeaders,
|
|
40
64
|
}));
|
|
41
65
|
if (!flagBool(input, "watch"))
|
|
42
66
|
return { data: created };
|
|
43
67
|
return await watchRun(ctx, workspaceId, projectId, created.id, flagNum(input, "timeout"));
|
|
44
68
|
},
|
|
45
69
|
},
|
|
70
|
+
{
|
|
71
|
+
name: "runs local",
|
|
72
|
+
summary: "Run a banked test locally with your own Playwright (public flows)",
|
|
73
|
+
description: "Unlike `runs trigger`, this runs on YOUR machine, not Beryl's cloud — fetches the test's " +
|
|
74
|
+
"rendered spec, then runs it with your local @playwright/test (install it once with " +
|
|
75
|
+
"`npm i -D @playwright/test && npx playwright install`). Point --url-override at a local " +
|
|
76
|
+
"dev server or preview, and --dir to keep the spec, artifacts, and JSON report on disk so " +
|
|
77
|
+
"an agent can run-fix-run. v1 targets public/unauthenticated flows: an authenticated test " +
|
|
78
|
+
"refuses to run locally (those run in Beryl's cloud, which holds the session) — no session " +
|
|
79
|
+
"is ever decrypted to your disk. Exits 0 if every test passed, 1 on a test failure.",
|
|
80
|
+
scope: "project",
|
|
81
|
+
args: [{ name: "test-id", description: "Test id to run (from `beryl tests list`)", required: true }],
|
|
82
|
+
flags: [
|
|
83
|
+
{
|
|
84
|
+
name: "url-override",
|
|
85
|
+
type: "string",
|
|
86
|
+
description: "Run against this base URL instead of the environment's (e.g. http://localhost:3000)",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "dir",
|
|
90
|
+
type: "string",
|
|
91
|
+
description: "Write the spec, artifacts, and JSON report here (default: a temp dir)",
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
examples: [
|
|
95
|
+
"beryl runs local 4f…",
|
|
96
|
+
"beryl runs local 4f… --url-override http://localhost:3000 --dir ./beryl-local",
|
|
97
|
+
],
|
|
98
|
+
async run(ctx, input) {
|
|
99
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
100
|
+
const testId = arg(input, "test-id");
|
|
101
|
+
const script = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests/${testId}/script`, { base_url: flagStr(input, "url-override") }));
|
|
102
|
+
// v1 is public flows only. A gated test's session lives (encrypted) in the cloud and is
|
|
103
|
+
// never handed to a local runner, so refuse rather than run a spec doomed to fail at the
|
|
104
|
+
// login wall. Exit 1: it's a failure to run this test locally, not a usage error.
|
|
105
|
+
if (script.requires_auth) {
|
|
106
|
+
throw new CliError("This test signs in first, so it can only run in Beryl's cloud (which holds the " +
|
|
107
|
+
"session) — local runs are for public/unauthenticated flows. Run it with " +
|
|
108
|
+
"`beryl runs trigger`.");
|
|
109
|
+
}
|
|
110
|
+
let outcome;
|
|
111
|
+
try {
|
|
112
|
+
outcome = await runSpecLocally({
|
|
113
|
+
spec: script.content,
|
|
114
|
+
testName: testId,
|
|
115
|
+
dir: flagStr(input, "dir"),
|
|
116
|
+
onProgress: (line) => ctx.err(dim(line)),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
// Every failure to run the spec (missing Playwright, a compile error, a customer
|
|
121
|
+
// config filtering our spec away) already carries an actionable message — surface it
|
|
122
|
+
// as a CliError so the user sees that, not a raw Node stack trace.
|
|
123
|
+
throw new CliError(err instanceof Error ? err.message : String(err));
|
|
124
|
+
}
|
|
125
|
+
const summary = { passed: outcome.passed, failed: outcome.failed, results: outcome.results };
|
|
126
|
+
const marker = (status) => status === "passed" || status === "expected"
|
|
127
|
+
? green("✓")
|
|
128
|
+
: status === "skipped"
|
|
129
|
+
? dim("○")
|
|
130
|
+
: red("✗");
|
|
131
|
+
const lines = outcome.results.map((r) => ` ${marker(r.status)} ${r.name}${r.error ? `\n ${dim(r.error.split("\n")[0] ?? "")}` : ""}`);
|
|
132
|
+
const human = `${outcome.failed === 0 ? green("All tests passed") : red(`${outcome.failed} test(s) failed`)} ` +
|
|
133
|
+
`(${outcome.passed} passed, ${outcome.failed} failed)\n${lines.join("\n")}` +
|
|
134
|
+
(flagStr(input, "dir") ? `\n${dim(`Spec + artifacts + report in ${outcome.directory}`)}` : "");
|
|
135
|
+
const persisted = Boolean(flagStr(input, "dir"));
|
|
136
|
+
return {
|
|
137
|
+
// Only surface on-disk paths when --dir kept them; without it the run dir is deleted.
|
|
138
|
+
data: persisted
|
|
139
|
+
? { ...summary, directory: outcome.directory, report: outcome.report }
|
|
140
|
+
: summary,
|
|
141
|
+
human,
|
|
142
|
+
// Exit codes are a CI contract: any failing test → exit 1.
|
|
143
|
+
...(outcome.failed > 0 ? { exitCode: 1 } : {}),
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
},
|
|
46
147
|
{
|
|
47
148
|
name: "runs list",
|
|
48
149
|
summary: "List recent runs",
|
|
49
150
|
scope: "project",
|
|
50
151
|
groupDefault: true,
|
|
152
|
+
groupSummary: "Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and download results.",
|
|
51
153
|
flags: [{ name: "env", type: "string", description: "Filter by environment id" }],
|
|
52
154
|
async run(ctx, input) {
|
|
53
155
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
@@ -192,7 +294,7 @@ export const runCommands = [
|
|
|
192
294
|
},
|
|
193
295
|
{
|
|
194
296
|
name: "runs explain",
|
|
195
|
-
summary: "
|
|
297
|
+
summary: "Explain, with AI, why a test result failed",
|
|
196
298
|
scope: "project",
|
|
197
299
|
args: [{ name: "result-id", description: "Test result id (from `beryl runs get`)", required: true }],
|
|
198
300
|
async run(ctx, input) {
|
package/dist/commands/tests.js
CHANGED
|
@@ -35,6 +35,7 @@ export const testCommands = [
|
|
|
35
35
|
summary: "List the project's tests with their latest result",
|
|
36
36
|
scope: "project",
|
|
37
37
|
groupDefault: true,
|
|
38
|
+
groupSummary: "Author, inspect, version, and heal a project's tests — the checks Beryl runs on each run.",
|
|
38
39
|
flags: [{ name: "env", type: "string", description: "Filter by environment id" }],
|
|
39
40
|
async run(ctx, input) {
|
|
40
41
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
@@ -80,13 +81,23 @@ export const testCommands = [
|
|
|
80
81
|
flags: [
|
|
81
82
|
{ name: "title", type: "string", required: true, description: "Title for the new test" },
|
|
82
83
|
{ name: "file", type: "string", required: true, description: "Plan JSON file, or - for stdin" },
|
|
84
|
+
{
|
|
85
|
+
name: "description",
|
|
86
|
+
type: "string",
|
|
87
|
+
description: "1–3 sentences stating what this test proves — the immutable outcome Beryl's healing " +
|
|
88
|
+
"checks against. State the purpose, not the steps; the one observable signal that's true " +
|
|
89
|
+
"only if the flow worked.",
|
|
90
|
+
},
|
|
83
91
|
{
|
|
84
92
|
name: "no-verify",
|
|
85
93
|
type: "boolean",
|
|
86
94
|
description: "Skip the compile-time browser/AI verification — trust the authored plan as-is",
|
|
87
95
|
},
|
|
88
96
|
],
|
|
89
|
-
examples: [
|
|
97
|
+
examples: [
|
|
98
|
+
'beryl tests create --title "Checkout happy path" --file plan.json',
|
|
99
|
+
'beryl tests create --title "Checkout happy path" --file plan.json --description "Proves a shopper can buy a product: after paying, an order-confirmation page with an order number appears."',
|
|
100
|
+
],
|
|
90
101
|
async run(ctx, input) {
|
|
91
102
|
const title = flagStr(input, "title");
|
|
92
103
|
if (!title)
|
|
@@ -97,6 +108,7 @@ export const testCommands = [
|
|
|
97
108
|
title,
|
|
98
109
|
plan: readJsonFlag(input, "file"),
|
|
99
110
|
verify: !flagBool(input, "no-verify"),
|
|
111
|
+
description: flagStr(input, "description"),
|
|
100
112
|
}),
|
|
101
113
|
};
|
|
102
114
|
},
|
|
@@ -106,17 +118,25 @@ export const testCommands = [
|
|
|
106
118
|
summary: "Replace a test's step plan from a JSON file (creates a new version)",
|
|
107
119
|
description: "Accepts the same plan shape as `tests create`, including the optional `before` and " +
|
|
108
120
|
"`after` sections — `after` runs on pass and on fail, so cleanup happens even when the " +
|
|
109
|
-
"test goes red."
|
|
121
|
+
"test goes red. Pass `--description` when the re-authored plan changes what the test " +
|
|
122
|
+
"proves; omit it to keep the test's existing intent.",
|
|
110
123
|
scope: "project",
|
|
111
124
|
args: [{ name: "test-id", description: "Test id", required: true }],
|
|
112
125
|
flags: [
|
|
113
126
|
{ name: "file", type: "string", required: true, description: "Plan JSON file, or - for stdin" },
|
|
127
|
+
{
|
|
128
|
+
name: "description",
|
|
129
|
+
type: "string",
|
|
130
|
+
description: "1–3 sentences stating what this test proves — the immutable outcome Beryl's healing " +
|
|
131
|
+
"checks against. State the purpose, not the steps; the one observable signal that's true " +
|
|
132
|
+
"only if the flow worked. Omit to keep the test's existing intent.",
|
|
133
|
+
},
|
|
114
134
|
],
|
|
115
135
|
examples: ["beryl tests plan 4f… > plan.json # edit, then:", "beryl tests set-plan 4f… --file plan.json"],
|
|
116
136
|
async run(ctx, input) {
|
|
117
137
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
118
138
|
return {
|
|
119
|
-
data: await ctx.client.patch(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/plan`, { json_plan: readJsonFlag(input, "file") }),
|
|
139
|
+
data: await ctx.client.patch(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/plan`, { json_plan: readJsonFlag(input, "file"), description: flagStr(input, "description") }),
|
|
120
140
|
};
|
|
121
141
|
},
|
|
122
142
|
},
|