@beryl-so/cli 0.1.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 +200 -145
- package/dist/adapters/cli.js +60 -17
- package/dist/adapters/mcp.js +26 -9
- package/dist/artifacts.js +132 -0
- package/dist/beryl-test-skill.js +136 -0
- package/dist/commands/account.js +3 -0
- package/dist/commands/auth.js +77 -5
- 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 +99 -16
- package/dist/commands/projects.js +47 -5
- package/dist/commands/runs.js +190 -9
- package/dist/commands/tests.js +113 -4
- package/dist/commands/watch.js +26 -5
- package/dist/commands/workspaces.js +3 -0
- package/dist/context.js +1 -0
- package/dist/detect.js +88 -0
- package/dist/lint.js +125 -0
- package/dist/local-run.js +168 -0
- package/dist/registry/index.js +17 -1
- package/dist/schema.generated.js +1269 -0
- package/package.json +1 -1
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",
|
|
@@ -86,13 +137,15 @@ export const initCommands = [
|
|
|
86
137
|
name: "local",
|
|
87
138
|
type: "boolean",
|
|
88
139
|
description: "Also wire the Playwright MCP so your coding agent can drive a local browser " +
|
|
89
|
-
"(for authoring tests yourself)"
|
|
140
|
+
"(for authoring tests yourself). Default: on whenever a coding agent is wired; " +
|
|
141
|
+
"pass --no-local to skip it",
|
|
90
142
|
},
|
|
91
143
|
],
|
|
92
144
|
examples: [
|
|
93
145
|
"npx @beryl-so/cli@latest init",
|
|
94
146
|
"beryl init --editor-tools claude-code",
|
|
95
|
-
"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",
|
|
96
149
|
],
|
|
97
150
|
async run(ctx, input) {
|
|
98
151
|
const cwd = process.cwd();
|
|
@@ -111,6 +164,7 @@ export const initCommands = [
|
|
|
111
164
|
}
|
|
112
165
|
let workspaceId;
|
|
113
166
|
let projectId;
|
|
167
|
+
let authoredLocally = false;
|
|
114
168
|
if (!flagBool(input, "no-pin")) {
|
|
115
169
|
const workspaces = (await client.get("/workspaces/"));
|
|
116
170
|
if (workspaces.length === 0)
|
|
@@ -124,7 +178,9 @@ export const initCommands = [
|
|
|
124
178
|
workspaceId = workspace.id;
|
|
125
179
|
const projects = (await client.get(`/workspaces/${workspaceId}/projects`));
|
|
126
180
|
const wantedProject = flagStr(input, "project");
|
|
127
|
-
|
|
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) {
|
|
128
184
|
projectId = (await ctx.requireProject({
|
|
129
185
|
args: {},
|
|
130
186
|
flags: { workspace: workspaceId, project: wantedProject },
|
|
@@ -134,13 +190,19 @@ export const initCommands = [
|
|
|
134
190
|
projectId = (await pick(ctx, "project", projects)).id;
|
|
135
191
|
}
|
|
136
192
|
else {
|
|
137
|
-
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";
|
|
138
196
|
const created = (await client.post(`/workspaces/${workspaceId}/projects`, {
|
|
139
197
|
root_url: url,
|
|
198
|
+
skip_exploration: authoredLocally,
|
|
140
199
|
}));
|
|
141
200
|
projectId = created.project_id;
|
|
142
|
-
|
|
143
|
-
|
|
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\`)`);
|
|
144
206
|
}
|
|
145
207
|
const localFile = path.join(cwd, LOCAL_CONFIG_FILENAME);
|
|
146
208
|
let local = {};
|
|
@@ -161,7 +223,10 @@ export const initCommands = [
|
|
|
161
223
|
: choice === "none"
|
|
162
224
|
? []
|
|
163
225
|
: [choice];
|
|
164
|
-
|
|
226
|
+
// Wiring an editor's MCP config at all implies the user wants to author tests there,
|
|
227
|
+
// and local authoring needs the Playwright MCP — so default it on. `--no-local` (parsed
|
|
228
|
+
// as an explicit false) opts out.
|
|
229
|
+
const local = input.flags.local ?? editors.length > 0;
|
|
165
230
|
for (const editor of editors) {
|
|
166
231
|
const file = editor === "claude-code"
|
|
167
232
|
? path.join(cwd, ".mcp.json")
|
|
@@ -173,16 +238,34 @@ export const initCommands = [
|
|
|
173
238
|
}
|
|
174
239
|
if (choice === "auto" && editors.length === 0)
|
|
175
240
|
ctx.err(dim("No coding agent detected — pass --editor-tools claude-code|cursor to wire one."));
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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` +
|
|
179
259
|
` ${cyan("beryl runs trigger --watch")} run the suite\n` +
|
|
180
260
|
` ${cyan("beryl explorations watch")} watch the agent work\n` +
|
|
181
261
|
` ${cyan("beryl tests list")} see authored tests` +
|
|
182
262
|
(local
|
|
183
263
|
? `\n\nPlaywright MCP is wired — your coding agent can explore the site locally ` +
|
|
184
264
|
`and push tests with ${cyan("beryl tests create")}.`
|
|
185
|
-
: "")
|
|
265
|
+
: "");
|
|
266
|
+
return {
|
|
267
|
+
data: { workspace: workspaceId ?? null, project: projectId ?? null, editors },
|
|
268
|
+
human: nextSteps,
|
|
186
269
|
};
|
|
187
270
|
},
|
|
188
271
|
},
|
|
@@ -1,13 +1,44 @@
|
|
|
1
1
|
import { saveGlobalConfig } from "../config.js";
|
|
2
|
+
import { detectAuthGating } from "../detect.js";
|
|
2
3
|
import { UsageError } from "../errors.js";
|
|
3
4
|
import { dim, green } from "../output.js";
|
|
4
5
|
import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
|
|
5
6
|
import { pollCurrentExploration, watchExploration } from "./watch.js";
|
|
7
|
+
// The API treats the auth choice as the visitor's answer and never infers it — a null
|
|
8
|
+
// choice leaves the project waiting on a login capture that never comes — so resolve it
|
|
9
|
+
// here. A saved login for the host settles it; otherwise probe the site.
|
|
10
|
+
async function resolveAuthChoice(ctx, workspaceId, url) {
|
|
11
|
+
let reusable;
|
|
12
|
+
try {
|
|
13
|
+
reusable = (await ctx.client.get(`/workspaces/${workspaceId}/projects/reusable-auth`, {
|
|
14
|
+
url,
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// a probe is still worth trying if the lookup fails
|
|
19
|
+
}
|
|
20
|
+
if (reusable?.available) {
|
|
21
|
+
ctx.err(dim("Detected a gated site — a saved login for this host is already on file."));
|
|
22
|
+
return "gated";
|
|
23
|
+
}
|
|
24
|
+
const detected = await detectAuthGating(url);
|
|
25
|
+
if (detected.choice) {
|
|
26
|
+
ctx.err(dim(`Detected a ${detected.choice} site — ${detected.reason}.`));
|
|
27
|
+
return detected.choice;
|
|
28
|
+
}
|
|
29
|
+
const question = `Could not tell whether ${url} needs a login — ${detected.reason}.`;
|
|
30
|
+
if (!ctx.interactive)
|
|
31
|
+
throw new UsageError(`${question}\n\nPass --auth public or --auth gated to say which it is.`);
|
|
32
|
+
const answer = await ctx.prompt(`${question}\nDoes testing it require signing in? [y/N] `);
|
|
33
|
+
return /^y(es)?$/i.test(answer) ? "gated" : "public";
|
|
34
|
+
}
|
|
6
35
|
export const projectCommands = [
|
|
7
36
|
{
|
|
8
37
|
name: "projects list",
|
|
9
38
|
summary: "List projects in the workspace",
|
|
10
39
|
scope: "workspace",
|
|
40
|
+
groupDefault: true,
|
|
41
|
+
groupSummary: "Create and manage projects — a site Beryl explores, authors tests for, and runs.",
|
|
11
42
|
async run(ctx, input) {
|
|
12
43
|
const ws = await ctx.requireWorkspace(input);
|
|
13
44
|
return { data: await ctx.client.get(`/workspaces/${ws}/projects`) };
|
|
@@ -32,7 +63,8 @@ export const projectCommands = [
|
|
|
32
63
|
name: "auth",
|
|
33
64
|
type: "string",
|
|
34
65
|
enum: ["public", "gated"],
|
|
35
|
-
description: "Whether the site needs a login (gated) or not (public)"
|
|
66
|
+
description: "Whether the site needs a login (gated) or not (public). Default: detected from the " +
|
|
67
|
+
"site — only asked when detection is genuinely unsure",
|
|
36
68
|
},
|
|
37
69
|
{
|
|
38
70
|
name: "allow-mutations",
|
|
@@ -50,7 +82,7 @@ export const projectCommands = [
|
|
|
50
82
|
{ name: "timeout", type: "number", description: "With --watch: max minutes to wait" },
|
|
51
83
|
],
|
|
52
84
|
examples: [
|
|
53
|
-
"beryl projects create https://app.example.com --
|
|
85
|
+
"beryl projects create https://app.example.com --watch",
|
|
54
86
|
"beryl projects create https://app.example.com --auth gated",
|
|
55
87
|
"beryl projects create https://app.example.com --auth public --no-explore",
|
|
56
88
|
],
|
|
@@ -59,10 +91,11 @@ export const projectCommands = [
|
|
|
59
91
|
if (noExplore && flagBool(input, "watch"))
|
|
60
92
|
throw new UsageError("--no-explore cannot be combined with --watch");
|
|
61
93
|
const ws = await ctx.requireWorkspace(input);
|
|
62
|
-
const
|
|
94
|
+
const url = arg(input, "url");
|
|
95
|
+
const auth = flagStr(input, "auth") ?? (await resolveAuthChoice(ctx, ws, url));
|
|
63
96
|
const created = (await ctx.client.post(`/workspaces/${ws}/projects`, {
|
|
64
|
-
root_url:
|
|
65
|
-
requires_auth_choice: auth
|
|
97
|
+
root_url: url,
|
|
98
|
+
requires_auth_choice: auth,
|
|
66
99
|
mutation_choice: flagBool(input, "allow-mutations") ? "allow_mutations" : null,
|
|
67
100
|
force_new_login: flagBool(input, "force-new-login"),
|
|
68
101
|
skip_exploration: noExplore,
|
|
@@ -73,6 +106,15 @@ export const projectCommands = [
|
|
|
73
106
|
human: `${green("Project created")}: ${created.project_id} ${dim("(no exploration started)")}\n` +
|
|
74
107
|
`Ready for your own tests — author a plan and push it with \`beryl tests create\`.`,
|
|
75
108
|
};
|
|
109
|
+
// The agent cannot start until a login is captured, so there is nothing to watch.
|
|
110
|
+
if (created.auth_status === "needs_capture")
|
|
111
|
+
return {
|
|
112
|
+
data: created,
|
|
113
|
+
human: `${green("Project created")}: ${created.project_id}\n` +
|
|
114
|
+
`The site needs a login before the agent can explore it. Capture one with ` +
|
|
115
|
+
`\`beryl credentials capture --project ${created.project_id}\` — the exploration ` +
|
|
116
|
+
`starts as soon as you do.`,
|
|
117
|
+
};
|
|
76
118
|
if (!flagBool(input, "watch"))
|
|
77
119
|
return { data: created };
|
|
78
120
|
ctx.err(dim(`project ${created.project_id} created — waiting for the agent to start…`));
|
package/dist/commands/runs.js
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import { downloadRunArtifacts, failureImages, isFailing, resultsOf, } from "../artifacts.js";
|
|
3
|
+
import { CliError, UsageError } from "../errors.js";
|
|
4
|
+
import { runSpecLocally } from "../local-run.js";
|
|
5
|
+
import { dim, green, red, yellow } from "../output.js";
|
|
2
6
|
import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
|
|
3
7
|
import { watchRun } from "./watch.js";
|
|
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
|
+
}
|
|
4
22
|
export const runCommands = [
|
|
5
23
|
{
|
|
6
24
|
name: "runs trigger",
|
|
@@ -12,31 +30,126 @@ export const runCommands = [
|
|
|
12
30
|
{ name: "test", type: "strings", description: "Run only these test ids (repeatable)" },
|
|
13
31
|
{ name: "env", type: "string", description: "Environment id to run against" },
|
|
14
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
|
+
},
|
|
15
39
|
{ name: "watch", type: "boolean", description: "Stream progress and exit non-zero on failure" },
|
|
16
40
|
{ name: "timeout", type: "number", description: "With --watch: max minutes to wait" },
|
|
41
|
+
{
|
|
42
|
+
name: "retries",
|
|
43
|
+
type: "number",
|
|
44
|
+
description: "Retry a failing test up to N times (0 disables); omit for the default",
|
|
45
|
+
},
|
|
17
46
|
],
|
|
18
47
|
examples: [
|
|
19
48
|
"beryl runs trigger --watch",
|
|
20
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",
|
|
21
51
|
"beryl runs trigger --test 4f… --test 9a…",
|
|
52
|
+
"beryl runs trigger --retries 0 --watch",
|
|
22
53
|
],
|
|
23
54
|
async run(ctx, input) {
|
|
24
55
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
25
56
|
const tests = input.flags.test;
|
|
57
|
+
const extraHeaders = parseHeaders(input.flags.header);
|
|
26
58
|
const created = (await ctx.client.post(`${projectPath(workspaceId, projectId)}/runs`, {
|
|
27
59
|
test_case_ids: tests && tests.length > 0 ? tests : null,
|
|
28
60
|
environment_id: flagStr(input, "env") ?? null,
|
|
29
61
|
target_url_override: flagStr(input, "url-override") ?? null,
|
|
62
|
+
max_retries: flagNum(input, "retries") ?? null,
|
|
63
|
+
extra_headers: extraHeaders,
|
|
30
64
|
}));
|
|
31
65
|
if (!flagBool(input, "watch"))
|
|
32
66
|
return { data: created };
|
|
33
67
|
return await watchRun(ctx, workspaceId, projectId, created.id, flagNum(input, "timeout"));
|
|
34
68
|
},
|
|
35
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
|
+
},
|
|
36
147
|
{
|
|
37
148
|
name: "runs list",
|
|
38
149
|
summary: "List recent runs",
|
|
39
150
|
scope: "project",
|
|
151
|
+
groupDefault: true,
|
|
152
|
+
groupSummary: "Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and download results.",
|
|
40
153
|
flags: [{ name: "env", type: "string", description: "Filter by environment id" }],
|
|
41
154
|
async run(ctx, input) {
|
|
42
155
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
@@ -49,13 +162,31 @@ export const runCommands = [
|
|
|
49
162
|
{
|
|
50
163
|
name: "runs get",
|
|
51
164
|
summary: "Show one run with its per-test results",
|
|
165
|
+
description: "Over MCP the failure screenshots come back as viewable image content, so an agent can " +
|
|
166
|
+
"look at the page that broke instead of guessing from the error string. Set screenshots " +
|
|
167
|
+
"to false to skip fetching them. Ignored outside MCP — the terminal cannot show an image.",
|
|
52
168
|
scope: "project",
|
|
53
169
|
args: [{ name: "run-id", description: "Run id", required: true }],
|
|
170
|
+
flags: [
|
|
171
|
+
{
|
|
172
|
+
name: "screenshots",
|
|
173
|
+
type: "boolean",
|
|
174
|
+
default: true,
|
|
175
|
+
description: "Attach failure screenshots as image content (MCP only; default true)",
|
|
176
|
+
},
|
|
177
|
+
],
|
|
54
178
|
async run(ctx, input) {
|
|
55
179
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
180
|
+
const data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs/${arg(input, "run-id")}`));
|
|
181
|
+
// Only the MCP adapter renders images; fetching bytes for a plain CLI run
|
|
182
|
+
// would be wasted network.
|
|
183
|
+
if (!ctx.mcp || input.flags.screenshots === false)
|
|
184
|
+
return { data };
|
|
185
|
+
const { images, skipped } = await failureImages(data, MAX_FAILURE_SCREENSHOTS);
|
|
186
|
+
// A silently-missing picture reads as "there was none" — say what was lost.
|
|
187
|
+
for (const note of skipped)
|
|
188
|
+
ctx.err(yellow(`! ${note}`));
|
|
189
|
+
return { data, images };
|
|
59
190
|
},
|
|
60
191
|
},
|
|
61
192
|
{
|
|
@@ -96,24 +227,74 @@ export const runCommands = [
|
|
|
96
227
|
},
|
|
97
228
|
{
|
|
98
229
|
name: "runs download",
|
|
99
|
-
summary: "Download a run's
|
|
230
|
+
summary: "Download a run's results, with its artifacts, to disk",
|
|
231
|
+
description: "With --dir, fetches the artifact bytes — screenshots, DOM snapshots, the Playwright " +
|
|
232
|
+
"trace zip, and the filmstrip frames of the failing tests — into <dir>/<test-result-id>/ " +
|
|
233
|
+
"alongside a run.json manifest. Artifact URLs are short-lived, so download rather than " +
|
|
234
|
+
"stash them. With --out (or neither), writes only the JSON manifest.",
|
|
100
235
|
scope: "project",
|
|
101
236
|
args: [{ name: "run-id", description: "Run id", required: true }],
|
|
102
|
-
flags: [
|
|
237
|
+
flags: [
|
|
238
|
+
{
|
|
239
|
+
name: "dir",
|
|
240
|
+
type: "string",
|
|
241
|
+
description: "Write run.json plus the artifact files into this directory",
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
name: "all-frames",
|
|
245
|
+
type: "boolean",
|
|
246
|
+
description: "With --dir: also fetch the filmstrip frames of passing tests",
|
|
247
|
+
},
|
|
248
|
+
{ name: "out", type: "string", description: "Write the JSON to a file instead of stdout" },
|
|
249
|
+
],
|
|
250
|
+
examples: [
|
|
251
|
+
"beryl runs download 7c1… --dir ./beryl-run",
|
|
252
|
+
"beryl runs download 7c1… --out run.json",
|
|
253
|
+
],
|
|
103
254
|
async run(ctx, input) {
|
|
104
255
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
105
|
-
const data = await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs/${arg(input, "run-id")}/download`);
|
|
256
|
+
const data = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/runs/${arg(input, "run-id")}/download`));
|
|
257
|
+
// --out and --dir are not alternatives: one says where the JSON goes, the other
|
|
258
|
+
// asks for the bytes as well. Honour both when both are given.
|
|
106
259
|
const out = flagStr(input, "out");
|
|
107
|
-
if (out)
|
|
260
|
+
if (out)
|
|
108
261
|
fs.writeFileSync(out, JSON.stringify(data, null, 2) + "\n");
|
|
109
|
-
|
|
262
|
+
const dir = flagStr(input, "dir");
|
|
263
|
+
if (dir) {
|
|
264
|
+
const report = await downloadRunArtifacts(data, dir, {
|
|
265
|
+
allFrames: flagBool(input, "all-frames"),
|
|
266
|
+
onProgress: (line) => ctx.err(dim(line)),
|
|
267
|
+
});
|
|
268
|
+
for (const failure of report.failures) {
|
|
269
|
+
ctx.err(yellow(`! could not fetch ${failure.url}: ${failure.error}`));
|
|
270
|
+
}
|
|
271
|
+
const failed = resultsOf(data).filter((r) => isFailing(r.status)).length;
|
|
272
|
+
// Exit codes are a CI contract: a bundle with zero artifacts on disk when the
|
|
273
|
+
// run had some is a failed download, not a success with a caveat.
|
|
274
|
+
const lostEverything = report.artifacts.length === 0 && report.failures.length > 0;
|
|
275
|
+
return {
|
|
276
|
+
data: {
|
|
277
|
+
directory: report.directory,
|
|
278
|
+
manifest: report.manifest,
|
|
279
|
+
artifacts: report.artifacts,
|
|
280
|
+
failed_tests: failed,
|
|
281
|
+
skipped: report.failures.length,
|
|
282
|
+
...(out ? { written: out } : {}),
|
|
283
|
+
},
|
|
284
|
+
human: `Wrote ${report.manifest} and ${report.artifacts.length} artifact file(s) to ${dir}` +
|
|
285
|
+
(report.failures.length ? ` (${report.failures.length} could not be fetched)` : "") +
|
|
286
|
+
(out ? `\nWrote ${out}` : ""),
|
|
287
|
+
...(lostEverything ? { exitCode: 1 } : {}),
|
|
288
|
+
};
|
|
110
289
|
}
|
|
290
|
+
if (out)
|
|
291
|
+
return { data: { written: out }, human: `Wrote ${out}` };
|
|
111
292
|
return { data, human: JSON.stringify(data, null, 2) };
|
|
112
293
|
},
|
|
113
294
|
},
|
|
114
295
|
{
|
|
115
296
|
name: "runs explain",
|
|
116
|
-
summary: "
|
|
297
|
+
summary: "Explain, with AI, why a test result failed",
|
|
117
298
|
scope: "project",
|
|
118
299
|
args: [{ name: "result-id", description: "Test result id (from `beryl runs get`)", required: true }],
|
|
119
300
|
async run(ctx, input) {
|