@beryl-so/cli 0.67.0 → 0.77.1
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 +26 -14
- package/dist/adapters/mcp.js +6 -4
- package/dist/beryl-test-skill.js +129 -107
- package/dist/commands/accounts.js +92 -10
- package/dist/commands/config-vars.js +87 -16
- package/dist/commands/environments.js +18 -1
- package/dist/commands/init.js +42 -108
- package/dist/commands/jobs.js +129 -0
- package/dist/commands/projects.js +68 -45
- package/dist/commands/runs.js +5 -432
- package/dist/commands/tests.js +34 -409
- package/dist/commands/third-party-integrations.js +26 -3
- package/dist/commands/watch.js +36 -0
- package/dist/lint.js +120 -6
- package/dist/registry/index.js +2 -0
- package/dist/schema.generated.js +152 -27
- package/dist/skill-tables.js +3 -0
- package/package.json +2 -3
- package/dist/email-pump.js +0 -138
- package/dist/local-exec.js +0 -239
- package/dist/local-run.js +0 -340
- package/dist/playwright-install.js +0 -220
- package/dist/progress.js +0 -44
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { UsageError } from "../errors.js";
|
|
4
|
+
import { arg, flagBool, flagStr, projectPath } from "./util.js";
|
|
5
|
+
const jobsPath = (ws, p) => `${projectPath(ws, p)}/test-creation-jobs`;
|
|
6
|
+
const LLM_NOTICE = "The document is sent to the LLM that drives the agent, so leave out anything you " +
|
|
7
|
+
"would not share with it.";
|
|
8
|
+
function readDocument(file) {
|
|
9
|
+
try {
|
|
10
|
+
return new Uint8Array(fs.readFileSync(file));
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
const code = err.code;
|
|
14
|
+
if (code === "ENOENT")
|
|
15
|
+
throw new UsageError(`file not found: ${file}`);
|
|
16
|
+
throw new UsageError(`cannot read ${file} (${code ?? err.message})`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function jobHuman(job) {
|
|
20
|
+
const lines = [`Job ${job.id}: ${job.status}`];
|
|
21
|
+
if (job.error)
|
|
22
|
+
lines.push(`Error: ${job.error}`);
|
|
23
|
+
for (const t of job.tests)
|
|
24
|
+
lines.push(` ${t.title ?? t.id} [${t.surface}] ${t.id}`);
|
|
25
|
+
if (!job.tests.length)
|
|
26
|
+
lines.push(" No tests yet.");
|
|
27
|
+
const skipped = job.skipped_flows ?? [];
|
|
28
|
+
if (skipped.length)
|
|
29
|
+
lines.push("Skipped scenarios:");
|
|
30
|
+
for (const s of skipped)
|
|
31
|
+
lines.push(` ${s.handle}: ${s.reason}`);
|
|
32
|
+
for (const step of job.surface_steps ?? []) {
|
|
33
|
+
for (const w of step.warnings ?? [])
|
|
34
|
+
lines.push(`Warning [${step.surface}]: ${w}`);
|
|
35
|
+
}
|
|
36
|
+
return lines.join("\n");
|
|
37
|
+
}
|
|
38
|
+
export const jobCommands = [
|
|
39
|
+
{
|
|
40
|
+
name: "jobs create",
|
|
41
|
+
groupSummary: "Test-creation jobs: the cloud agent reads a document and authors tests from it.",
|
|
42
|
+
summary: "Create tests from a document (PRD, release notes) on Beryl's cloud",
|
|
43
|
+
description: "Uploads a document (Markdown, plain text, PDF or DOCX, or --text) and queues a " +
|
|
44
|
+
"job: the agent explores the environment, drafts one flow per scenario the document " +
|
|
45
|
+
"describes, and authors one test per enabled surface of the project. New tests get " +
|
|
46
|
+
"no groups. A job runs once; to run it again, use `jobs clone`. " +
|
|
47
|
+
LLM_NOTICE,
|
|
48
|
+
scope: "project",
|
|
49
|
+
args: [{ name: "file", description: "Path to the document" }],
|
|
50
|
+
flags: [
|
|
51
|
+
{ name: "text", type: "string", description: "The document as text, instead of a file" },
|
|
52
|
+
{
|
|
53
|
+
name: "env",
|
|
54
|
+
type: "string",
|
|
55
|
+
description: "Environment id to explore (defaults to the project's default)",
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
examples: ["beryl jobs create ./release-notes.md --env <env-id>"],
|
|
59
|
+
async run(ctx, input) {
|
|
60
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
61
|
+
const file = input.args.file;
|
|
62
|
+
const text = flagStr(input, "text");
|
|
63
|
+
if (Boolean(file) === Boolean(text)) {
|
|
64
|
+
throw new UsageError("pass a document file or --text, not both");
|
|
65
|
+
}
|
|
66
|
+
const form = new FormData();
|
|
67
|
+
if (file)
|
|
68
|
+
form.set("file", new Blob([readDocument(file)]), path.basename(file));
|
|
69
|
+
else
|
|
70
|
+
form.set("text", text);
|
|
71
|
+
const envId = flagStr(input, "env");
|
|
72
|
+
if (envId)
|
|
73
|
+
form.set("environment_id", envId);
|
|
74
|
+
const job = (await ctx.client.request("POST", jobsPath(workspaceId, projectId), {
|
|
75
|
+
form,
|
|
76
|
+
}));
|
|
77
|
+
return {
|
|
78
|
+
data: job,
|
|
79
|
+
human: `Job ${job.id} queued. Check it with \`beryl jobs get ${job.id}\`.`,
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "jobs list",
|
|
85
|
+
summary: "List the project's test-creation jobs",
|
|
86
|
+
scope: "project",
|
|
87
|
+
async run(ctx, input) {
|
|
88
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
89
|
+
return { data: await ctx.client.get(jobsPath(workspaceId, projectId)) };
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: "jobs get",
|
|
94
|
+
summary: "Show a job's status and the tests it created",
|
|
95
|
+
scope: "project",
|
|
96
|
+
args: [{ name: "job-id", description: "Job id", required: true }],
|
|
97
|
+
async run(ctx, input) {
|
|
98
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
99
|
+
const job = (await ctx.client.get(`${jobsPath(workspaceId, projectId)}/${arg(input, "job-id")}`));
|
|
100
|
+
return { data: job, human: jobHuman(job) };
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "jobs clone",
|
|
105
|
+
summary: "Run a job again: queue a new job with the same document and environment",
|
|
106
|
+
description: LLM_NOTICE,
|
|
107
|
+
scope: "project",
|
|
108
|
+
args: [{ name: "job-id", description: "Job id to clone", required: true }],
|
|
109
|
+
async run(ctx, input) {
|
|
110
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
111
|
+
const job = (await ctx.client.post(`${jobsPath(workspaceId, projectId)}/${arg(input, "job-id")}/clone`));
|
|
112
|
+
return { data: job, human: `Job ${job.id} queued.` };
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "jobs delete",
|
|
117
|
+
summary: "Delete a job; the flows and tests it created stay",
|
|
118
|
+
scope: "project",
|
|
119
|
+
args: [{ name: "job-id", description: "Job id", required: true }],
|
|
120
|
+
flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
|
|
121
|
+
async run(ctx, input) {
|
|
122
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
123
|
+
const jobId = arg(input, "job-id");
|
|
124
|
+
await ctx.confirm(`Delete job ${jobId}? Its tests stay.`, flagBool(input, "force"));
|
|
125
|
+
await ctx.client.del(`${jobsPath(workspaceId, projectId)}/${jobId}`);
|
|
126
|
+
return { human: "Deleted." };
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { detectAuthGating } from "../detect.js";
|
|
2
2
|
import { UsageError } from "../errors.js";
|
|
3
3
|
import { dim, green, table, timeAgo } from "../output.js";
|
|
4
|
-
import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
|
|
4
|
+
import { arg, argList, flagBool, flagNum, flagStr, projectPath } from "./util.js";
|
|
5
5
|
import { pollCurrentExploration, watchExploration } from "./watch.js";
|
|
6
6
|
// The API treats the auth choice as the visitor's answer and never infers it — a null
|
|
7
7
|
// choice leaves the project waiting on a login capture that never comes — so resolve it
|
|
@@ -84,7 +84,7 @@ export const projectCommands = [
|
|
|
84
84
|
name: "name",
|
|
85
85
|
type: "string",
|
|
86
86
|
description: "Name for an empty project when no URL is given. Add a URL later with " +
|
|
87
|
-
"`beryl envs update <env-id
|
|
87
|
+
"`beryl envs update <env-id>`",
|
|
88
88
|
},
|
|
89
89
|
{
|
|
90
90
|
name: "auth",
|
|
@@ -94,16 +94,6 @@ export const projectCommands = [
|
|
|
94
94
|
"site, and only asked when detection is genuinely unsure",
|
|
95
95
|
},
|
|
96
96
|
{ name: "force-new-login", type: "boolean", description: "Ignore any reusable saved login" },
|
|
97
|
-
{
|
|
98
|
-
name: "explore",
|
|
99
|
-
type: "boolean",
|
|
100
|
-
description: "Also start a cloud exploration that authors tests for you. Off by default: author " +
|
|
101
|
-
"tests with `beryl tests create` or your coding agent over MCP",
|
|
102
|
-
mcpDescription: "Leave unset: you author the tests yourself with `tests create`. Set only when the " +
|
|
103
|
-
"user explicitly asks for a cloud exploration",
|
|
104
|
-
},
|
|
105
|
-
{ name: "watch", type: "boolean", description: "Start the exploration (implies --explore) and stream it live" },
|
|
106
|
-
{ name: "timeout", type: "number", description: "With --watch: max minutes to wait" },
|
|
107
97
|
],
|
|
108
98
|
examples: [
|
|
109
99
|
"beryl projects create https://app.example.com",
|
|
@@ -111,29 +101,23 @@ export const projectCommands = [
|
|
|
111
101
|
'beryl projects create --name "Acme production"',
|
|
112
102
|
],
|
|
113
103
|
async run(ctx, input) {
|
|
114
|
-
const watch = flagBool(input, "watch");
|
|
115
|
-
if (input.flags.explore === false && watch)
|
|
116
|
-
throw new UsageError("--no-explore cannot be combined with --watch");
|
|
117
|
-
const explore = flagBool(input, "explore") || watch;
|
|
118
104
|
const ws = await ctx.requireWorkspace(input);
|
|
119
105
|
const rawUrl = input.args.url;
|
|
120
106
|
const url = typeof rawUrl === "string" ? rawUrl : "";
|
|
121
107
|
const name = flagStr(input, "name");
|
|
122
|
-
// Name-only: create a real empty project (no URL
|
|
123
|
-
//
|
|
108
|
+
// Name-only: create a real empty project (no URL). Set a URL later with
|
|
109
|
+
// `beryl envs update <env-id>`.
|
|
124
110
|
if (!url) {
|
|
125
111
|
if (!name)
|
|
126
112
|
throw new UsageError("Provide a URL, or --name to create an empty project");
|
|
127
|
-
if (explore)
|
|
128
|
-
throw new UsageError("--explore and --watch need a URL to explore");
|
|
129
113
|
const created = (await ctx.client.post(`/workspaces/${ws}/projects`, {
|
|
130
114
|
name,
|
|
131
115
|
}));
|
|
132
116
|
return {
|
|
133
117
|
data: created,
|
|
134
118
|
human: `${green("Project created")}: ${created.project_id} ${dim("(empty)")}\n` +
|
|
135
|
-
`Add a site with \`beryl envs update <env-id>\`,
|
|
136
|
-
`\`beryl
|
|
119
|
+
`Add a site with \`beryl envs update <env-id>\`, then create tests with ` +
|
|
120
|
+
`\`beryl jobs create <document>\`.`,
|
|
137
121
|
};
|
|
138
122
|
}
|
|
139
123
|
const auth = flagStr(input, "auth") ?? (await resolveAuthChoice(ctx, ws, url));
|
|
@@ -141,29 +125,12 @@ export const projectCommands = [
|
|
|
141
125
|
root_url: url,
|
|
142
126
|
requires_auth_choice: auth,
|
|
143
127
|
force_new_login: flagBool(input, "force-new-login"),
|
|
144
|
-
skip_exploration: !explore,
|
|
145
128
|
}));
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
};
|
|
152
|
-
// The agent cannot start until a login is captured, so there is nothing to watch.
|
|
153
|
-
if (created.auth_status === "needs_capture")
|
|
154
|
-
return {
|
|
155
|
-
data: created,
|
|
156
|
-
human: `${green("Project created")}: ${created.project_id}\n` +
|
|
157
|
-
`The site needs a login before the agent can explore it. Capture one in the ` +
|
|
158
|
-
`Beryl webapp (open the project — it walks you through the login) — the ` +
|
|
159
|
-
`exploration starts as soon as you do.`,
|
|
160
|
-
};
|
|
161
|
-
if (!flagBool(input, "watch"))
|
|
162
|
-
return { data: created };
|
|
163
|
-
ctx.err(dim(`project ${created.project_id} created — waiting for the agent to start…`));
|
|
164
|
-
const explorationId = await pollCurrentExploration(ctx, ws, created.project_id);
|
|
165
|
-
const watched = await watchExploration(ctx, ws, created.project_id, explorationId, flagNum(input, "timeout"));
|
|
166
|
-
return { data: { ...created, ...watched.data }, exitCode: watched.exitCode };
|
|
129
|
+
return {
|
|
130
|
+
data: created,
|
|
131
|
+
human: `${green("Project created")}: ${created.project_id}\n` +
|
|
132
|
+
`Create tests from a document describing the flows: \`beryl jobs create <file>\`.`,
|
|
133
|
+
};
|
|
167
134
|
},
|
|
168
135
|
},
|
|
169
136
|
{
|
|
@@ -227,6 +194,62 @@ export const projectCommands = [
|
|
|
227
194
|
return { data: await ctx.client.patch(projectPath(workspaceId, projectId), body) };
|
|
228
195
|
},
|
|
229
196
|
},
|
|
197
|
+
{
|
|
198
|
+
name: "projects surfaces",
|
|
199
|
+
summary: "Set which surfaces are enabled for this project",
|
|
200
|
+
description: "Replaces the project's enabled surfaces with the ones listed: web-desktop, " +
|
|
201
|
+
"web-tablet, web-mobile, android, ios. A new project has web-desktop only. At least " +
|
|
202
|
+
"one is required; `projects get` shows the current set. Turning a surface off " +
|
|
203
|
+
"removes no tests: delete a surface's tests to stop running them. android and ios " +
|
|
204
|
+
"test the app named by each environment's app id for that surface (`envs update " +
|
|
205
|
+
"--app-id android=<package> --app-id ios=<bundle id>`) from its newest uploaded build " +
|
|
206
|
+
"(`config files upload app.apk`, `config files upload App.zip --kind ios_build`), on " +
|
|
207
|
+
"Beryl's Android and iOS workers.",
|
|
208
|
+
scope: "project",
|
|
209
|
+
args: [
|
|
210
|
+
{
|
|
211
|
+
name: "surfaces",
|
|
212
|
+
description: "web-desktop | web-tablet | web-mobile | android | ios, one or more",
|
|
213
|
+
required: true,
|
|
214
|
+
variadic: true,
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
examples: [
|
|
218
|
+
"beryl projects surfaces web-desktop web-mobile",
|
|
219
|
+
"beryl projects surfaces web-desktop android ios",
|
|
220
|
+
],
|
|
221
|
+
async run(ctx, input) {
|
|
222
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
223
|
+
const data = (await ctx.client.patch(projectPath(workspaceId, projectId), {
|
|
224
|
+
enabled_surfaces: argList(input, "surfaces"),
|
|
225
|
+
}));
|
|
226
|
+
return { data, human: `Enabled surfaces: ${data.enabled_surfaces.join(", ")}` };
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
name: "projects findings-gate",
|
|
231
|
+
summary: "Turn the findings gate on each run on or off",
|
|
232
|
+
description: "With the gate on (new-since-green), every run also reports a findings gate: failed " +
|
|
233
|
+
"when the environment's Site Health has a finding first seen after the last green run " +
|
|
234
|
+
"or came back after it (every current finding if there is no green run yet), unknown " +
|
|
235
|
+
"when Site Health has not run there or is not recording findings (its issue tracker " +
|
|
236
|
+
"must be connected). The gate sits next to the test verdicts and never changes one, nor the " +
|
|
237
|
+
"exit code of `runs watch`; read it from `findings_gate` on `runs get`. Off by default.",
|
|
238
|
+
scope: "project",
|
|
239
|
+
args: [{ name: "mode", description: "off | new-since-green", required: true }],
|
|
240
|
+
examples: ["beryl projects findings-gate new-since-green", "beryl projects findings-gate off"],
|
|
241
|
+
async run(ctx, input) {
|
|
242
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
243
|
+
const mode = arg(input, "mode").toLowerCase().replace(/-/g, "_");
|
|
244
|
+
if (mode !== "off" && mode !== "new_since_green") {
|
|
245
|
+
throw new UsageError(`Expected "off" or "new-since-green", got "${arg(input, "mode")}"`);
|
|
246
|
+
}
|
|
247
|
+
const data = (await ctx.client.patch(projectPath(workspaceId, projectId), {
|
|
248
|
+
findings_gate: mode,
|
|
249
|
+
}));
|
|
250
|
+
return { data, human: `Findings gate: ${data.findings_gate.replace(/_/g, "-")}` };
|
|
251
|
+
},
|
|
252
|
+
},
|
|
230
253
|
{
|
|
231
254
|
name: "projects delete",
|
|
232
255
|
summary: "Delete a project and all its tests and runs",
|
|
@@ -258,7 +281,7 @@ export const projectCommands = [
|
|
|
258
281
|
},
|
|
259
282
|
{
|
|
260
283
|
name: "projects report",
|
|
261
|
-
summary: "Aggregate quality report across recent runs (pass rates,
|
|
284
|
+
summary: "Aggregate quality report across recent runs (pass rates, per-test history, trend)",
|
|
262
285
|
scope: "project",
|
|
263
286
|
flags: [
|
|
264
287
|
{ name: "runs", type: "number", description: "How many recent runs to aggregate" },
|