@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.
@@ -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>`, or author tests over the CLI/MCP",
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, no exploration). Set a URL
123
- // later with `beryl envs update <env-id>` or author over the CLI/MCP.
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>\`, or author tests with ` +
136
- `\`beryl tests create\`.`,
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
- if (!explore)
147
- return {
148
- data: created,
149
- human: `${green("Project created")}: ${created.project_id}\n` +
150
- `Ready for your own tests: author a plan and push it with \`beryl tests create\`.`,
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, flaky tests, trend)",
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" },