@beryl-so/cli 0.11.1 → 0.17.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 +11 -30
- package/dist/adapters/mcp.js +8 -1
- package/dist/beryl-test-skill.js +68 -12
- package/dist/commands/config-vars.js +23 -2
- package/dist/commands/inboxes.js +1 -22
- package/dist/commands/init.js +93 -122
- package/dist/commands/projects.js +21 -5
- package/dist/commands/runs.js +231 -66
- package/dist/commands/tests.js +305 -16
- package/dist/context.js +33 -2
- package/dist/email-extract.js +99 -0
- package/dist/email-pump.js +102 -0
- package/dist/local-exec.js +176 -0
- package/dist/local-run.js +145 -4
- package/dist/output.js +19 -0
- package/dist/progress.js +44 -0
- package/dist/registry/index.js +0 -2
- package/dist/schema.generated.js +2 -0
- package/package.json +3 -2
- package/dist/commands/credentials.js +0 -178
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { detectAuthGating } from "../detect.js";
|
|
2
2
|
import { UsageError } from "../errors.js";
|
|
3
|
-
import { dim, green } from "../output.js";
|
|
3
|
+
import { dim, green, table, timeAgo } from "../output.js";
|
|
4
4
|
import { arg, 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
|
|
@@ -40,7 +40,23 @@ export const projectCommands = [
|
|
|
40
40
|
groupSummary: "Create and manage projects — a site Beryl explores, authors tests for, and runs.",
|
|
41
41
|
async run(ctx, input) {
|
|
42
42
|
const ws = await ctx.requireWorkspace(input);
|
|
43
|
-
|
|
43
|
+
const rows = (await ctx.client.get(`/workspaces/${ws}/projects`));
|
|
44
|
+
// Full rows stay in `data` for --json/MCP; the terminal gets the columns a
|
|
45
|
+
// human scans a project list for, with readable ages instead of raw ISO.
|
|
46
|
+
const human = table(rows.map((p) => {
|
|
47
|
+
const last = p.last_execution;
|
|
48
|
+
return {
|
|
49
|
+
name: p.name ?? p.root_url ?? p.id,
|
|
50
|
+
tests: p.test_count ?? 0,
|
|
51
|
+
status: p.status,
|
|
52
|
+
last_run: last
|
|
53
|
+
? `${last.status} ${last.passed_count ?? 0}/${last.total_tests ?? "?"} · ${timeAgo(last.started_at)}`
|
|
54
|
+
: "",
|
|
55
|
+
updated: timeAgo(p.updated_at ?? p.created_at),
|
|
56
|
+
id: p.id,
|
|
57
|
+
};
|
|
58
|
+
}));
|
|
59
|
+
return { data: rows, human };
|
|
44
60
|
},
|
|
45
61
|
},
|
|
46
62
|
{
|
|
@@ -140,9 +156,9 @@ export const projectCommands = [
|
|
|
140
156
|
return {
|
|
141
157
|
data: created,
|
|
142
158
|
human: `${green("Project created")}: ${created.project_id}\n` +
|
|
143
|
-
`The site needs a login before the agent can explore it. Capture one
|
|
144
|
-
|
|
145
|
-
`starts as soon as you do.`,
|
|
159
|
+
`The site needs a login before the agent can explore it. Capture one in the ` +
|
|
160
|
+
`Beryl webapp (open the project — it walks you through the login) — the ` +
|
|
161
|
+
`exploration starts as soon as you do.`,
|
|
146
162
|
};
|
|
147
163
|
if (!flagBool(input, "watch"))
|
|
148
164
|
return { data: created };
|
package/dist/commands/runs.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
2
3
|
import { downloadRunArtifacts, failureImages, isFailing, resultsOf, } from "../artifacts.js";
|
|
3
4
|
import { CliError, UsageError } from "../errors.js";
|
|
4
|
-
import {
|
|
5
|
+
import { buildImportForm, executeLocalSpec, toRunEntry, } from "../local-exec.js";
|
|
6
|
+
import { countPlannedFrames, countWrittenFrames, PlaywrightMissingError, } from "../local-run.js";
|
|
5
7
|
import { dim, green, red, yellow } from "../output.js";
|
|
6
8
|
import { confirmInstall, installPlaywright } from "../playwright-install.js";
|
|
9
|
+
import { ProgressBar } from "../progress.js";
|
|
7
10
|
import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
|
|
8
11
|
import { watchRun } from "./watch.js";
|
|
9
12
|
const MAX_FAILURE_SCREENSHOTS = 5;
|
|
@@ -70,99 +73,261 @@ export const runCommands = [
|
|
|
70
73
|
},
|
|
71
74
|
{
|
|
72
75
|
name: "runs local",
|
|
73
|
-
summary: "Run
|
|
74
|
-
description: "Unlike `runs trigger`,
|
|
75
|
-
"
|
|
76
|
-
"install
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
"
|
|
76
|
+
summary: "Run tests on your machine with your own Playwright; results sync to Beryl",
|
|
77
|
+
description: "Unlike `runs trigger`, the browser runs on YOUR machine — each test's rendered spec is " +
|
|
78
|
+
"fetched and run with your local @playwright/test (on a terminal the CLI offers to " +
|
|
79
|
+
"install it the first time it's missing; over MCP it prints the install commands). " +
|
|
80
|
+
"Signup/OTP flows work: the CLI mints a fresh run inbox and answers the spec's " +
|
|
81
|
+
"await_email steps over the API, exactly as the cloud runner would. Saved-login tests " +
|
|
82
|
+
"({{login_email}}/{{login_password}}) work too: the password is fetched once over the " +
|
|
83
|
+
"logged secret-reveal route, handed to the spec the way the cloud runner does, and " +
|
|
84
|
+
"scrubbed from any error text or DOM snapshot before results upload. When the run " +
|
|
85
|
+
"finishes, the results and replay artifacts are imported into Beryl as a normal run " +
|
|
86
|
+
"(trigger source `local`) — history, replay, and reports all work; pass --no-sync to " +
|
|
87
|
+
"keep a run entirely off the record while iterating. A captured-session test still " +
|
|
88
|
+
"refuses to run locally (its session lives encrypted in Beryl's cloud and is never " +
|
|
89
|
+
"decrypted to your disk) — it is skipped with a note. Point --url-override at a local " +
|
|
90
|
+
"dev server or preview, and --dir to keep specs, artifacts, and reports on disk. " +
|
|
91
|
+
"Exits 0 only if every executed test passed.",
|
|
82
92
|
scope: "project",
|
|
83
|
-
args: [
|
|
93
|
+
args: [
|
|
94
|
+
{
|
|
95
|
+
name: "test-ids",
|
|
96
|
+
description: "Test ids to run (from `beryl tests list`); omit to run every active test",
|
|
97
|
+
variadic: true,
|
|
98
|
+
},
|
|
99
|
+
],
|
|
84
100
|
flags: [
|
|
101
|
+
{
|
|
102
|
+
name: "all",
|
|
103
|
+
type: "boolean",
|
|
104
|
+
description: "Run every active test in the project (the default when no ids are given)",
|
|
105
|
+
},
|
|
85
106
|
{
|
|
86
107
|
name: "url-override",
|
|
87
108
|
type: "string",
|
|
88
109
|
description: "Run against this base URL instead of the environment's (e.g. http://localhost:3000)",
|
|
89
110
|
},
|
|
111
|
+
{ name: "env", type: "string", description: "Environment id to attach the imported run to" },
|
|
112
|
+
{
|
|
113
|
+
name: "sync",
|
|
114
|
+
type: "boolean",
|
|
115
|
+
default: true,
|
|
116
|
+
description: "Import the results into Beryl as a run when finished (--no-sync: local only, " +
|
|
117
|
+
"nothing recorded)",
|
|
118
|
+
},
|
|
90
119
|
{
|
|
91
120
|
name: "dir",
|
|
92
121
|
type: "string",
|
|
93
|
-
description: "Write
|
|
122
|
+
description: "Write each test's spec, artifacts, and JSON report under this directory",
|
|
94
123
|
},
|
|
95
124
|
],
|
|
96
125
|
examples: [
|
|
97
|
-
"beryl runs local
|
|
98
|
-
"beryl runs local 4f…
|
|
126
|
+
"beryl runs local",
|
|
127
|
+
"beryl runs local 4f… 9a…",
|
|
128
|
+
"beryl runs local --url-override http://localhost:3000",
|
|
129
|
+
"beryl runs local 4f… --no-sync --dir ./beryl-local",
|
|
99
130
|
],
|
|
100
131
|
async run(ctx, input) {
|
|
101
132
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
throw new
|
|
109
|
-
|
|
110
|
-
|
|
133
|
+
// Deduped: a repeated id would put the same test twice in one imported run,
|
|
134
|
+
// which the import manifest rejects.
|
|
135
|
+
const explicitIds = [...new Set(input.args["test-ids"] ?? [])];
|
|
136
|
+
// No ids means the whole suite — `beryl runs local` alone is a complete local run.
|
|
137
|
+
const all = explicitIds.length === 0;
|
|
138
|
+
if (explicitIds.length > 0 && flagBool(input, "all"))
|
|
139
|
+
throw new UsageError("--all cannot be combined with explicit test ids");
|
|
140
|
+
const sync = input.flags.sync !== false;
|
|
141
|
+
const urlOverride = flagStr(input, "url-override");
|
|
142
|
+
const dir = flagStr(input, "dir");
|
|
143
|
+
const listed = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests`, {
|
|
144
|
+
environment_id: flagStr(input, "env"),
|
|
145
|
+
}));
|
|
146
|
+
// The list rows carry the authored name as nl_title (title is the customer's
|
|
147
|
+
// rename, usually unset) — fall through so the terminal shows names, not ids.
|
|
148
|
+
const titles = new Map(listed.map((t) => [t.id, t.title || t.nl_title || t.id.slice(0, 8)]));
|
|
149
|
+
const ids = all ? listed.filter((t) => t.is_active !== false).map((t) => t.id) : explicitIds;
|
|
150
|
+
if (ids.length === 0)
|
|
151
|
+
throw new CliError("This project has no tests to run.");
|
|
152
|
+
const specs = [];
|
|
153
|
+
for (const id of ids) {
|
|
154
|
+
let script;
|
|
155
|
+
try {
|
|
156
|
+
script = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests/${id}/script`,
|
|
157
|
+
// frames only matter when the run will be imported: they become the replay.
|
|
158
|
+
// environment_id keeps the baked login_email on the same environment the
|
|
159
|
+
// LOGIN_PASSWORD reveal below is scoped to.
|
|
160
|
+
{
|
|
161
|
+
base_url: urlOverride,
|
|
162
|
+
frames: sync ? true : undefined,
|
|
163
|
+
environment_id: flagStr(input, "env"),
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
// With --all an unrenderable test (no plan yet) is a skip, not an abort;
|
|
168
|
+
// an explicitly-requested id failing to render is the caller's problem.
|
|
169
|
+
if (all) {
|
|
170
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
171
|
+
ctx.err(yellow(`! ${titles.get(id) ?? id}: no runnable script — skipped (${detail})`));
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
specs.push({
|
|
177
|
+
id,
|
|
178
|
+
title: titles.get(id) ?? id.slice(0, 8),
|
|
179
|
+
content: script.content,
|
|
180
|
+
requiresAuth: Boolean(script.requires_auth),
|
|
181
|
+
usesEmail: /__vmInbox\(|__vmAwaitEmail\(/.test(script.content),
|
|
182
|
+
usesLoginPassword: Boolean(script.uses_login_password),
|
|
183
|
+
...(script.login_config_error ? { loginConfigError: script.login_config_error } : {}),
|
|
184
|
+
});
|
|
111
185
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
186
|
+
if (specs.length === 0)
|
|
187
|
+
throw new CliError("No runnable tests were found.");
|
|
188
|
+
// A captured-session test's login lives encrypted in Beryl's cloud and is never
|
|
189
|
+
// handed to a local runner — skip it rather than run a spec doomed at the wall.
|
|
190
|
+
// A saved-login test missing its backing config would type a literal placeholder
|
|
191
|
+
// into the page: same treatment, with the server's fix-it message.
|
|
192
|
+
const skipped = specs.filter((s) => s.requiresAuth || s.loginConfigError);
|
|
193
|
+
const runnable = specs.filter((s) => !s.requiresAuth && !s.loginConfigError);
|
|
194
|
+
for (const s of skipped) {
|
|
195
|
+
ctx.err(yellow(s.requiresAuth
|
|
196
|
+
? `! ${s.title}: signs in with a captured session, so it only runs in Beryl's ` +
|
|
197
|
+
`cloud — skipped (use \`beryl runs trigger\`).`
|
|
198
|
+
: `! ${s.title}: skipped — ${s.loginConfigError}`));
|
|
199
|
+
}
|
|
200
|
+
if (runnable.length === 0)
|
|
201
|
+
throw new CliError("None of the selected tests can run locally.");
|
|
202
|
+
// Revealed once for the batch over the member-gated, logged route — the same
|
|
203
|
+
// secret the cloud runner banks into run-config.json; never in the spec source.
|
|
204
|
+
let loginPassword;
|
|
205
|
+
if (runnable.some((s) => s.usesLoginPassword)) {
|
|
120
206
|
try {
|
|
121
|
-
|
|
207
|
+
const secret = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/config/secrets/LOGIN_PASSWORD/value`, { environment_id: flagStr(input, "env") }));
|
|
208
|
+
loginPassword = secret.value;
|
|
122
209
|
}
|
|
123
210
|
catch (err) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
211
|
+
throw new CliError("Could not reveal the LOGIN_PASSWORD secret for the saved login: " +
|
|
212
|
+
(err instanceof Error ? err.message : String(err)));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const startedAt = new Date().toISOString();
|
|
216
|
+
const entries = [];
|
|
217
|
+
const bar = new ProgressBar();
|
|
218
|
+
let installOffered = false;
|
|
219
|
+
let done = 0;
|
|
220
|
+
let passed = 0;
|
|
221
|
+
let failed = 0;
|
|
222
|
+
for (const spec of runnable) {
|
|
223
|
+
const stepTotal = countPlannedFrames(spec.content);
|
|
224
|
+
const state = () => ({
|
|
225
|
+
total: runnable.length,
|
|
226
|
+
done,
|
|
227
|
+
passed,
|
|
228
|
+
failed,
|
|
229
|
+
title: spec.title,
|
|
230
|
+
stepTotal,
|
|
231
|
+
});
|
|
232
|
+
bar.update(state());
|
|
233
|
+
if (!bar.active)
|
|
234
|
+
ctx.err(dim(`Running ${spec.title}…`));
|
|
235
|
+
const testStarted = new Date().toISOString();
|
|
236
|
+
let outcome;
|
|
237
|
+
let runError;
|
|
238
|
+
const attempt = () => executeLocalSpec({ client: ctx.client, workspaceId, projectId }, {
|
|
239
|
+
spec,
|
|
240
|
+
dir: dir ? path.join(dir, spec.id) : undefined,
|
|
241
|
+
harvest: sync,
|
|
242
|
+
loginPassword,
|
|
243
|
+
onEvent: (line) => ctx.err(dim(line)),
|
|
244
|
+
onSpawn: (runDir) => {
|
|
245
|
+
const ticker = setInterval(() => bar.update({ ...state(), step: countWrittenFrames(runDir) }), 300);
|
|
246
|
+
return () => clearInterval(ticker);
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
try {
|
|
250
|
+
try {
|
|
251
|
+
({ outcome, runError } = await attempt());
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
// Local Playwright missing: on a TTY offer to install it once and retry,
|
|
255
|
+
// instead of only printing a hint the user then has to act on by hand.
|
|
256
|
+
if (err instanceof PlaywrightMissingError && ctx.interactive && !installOffered) {
|
|
257
|
+
installOffered = true;
|
|
258
|
+
bar.clear();
|
|
259
|
+
if (!(await confirmInstall(ctx.prompt)))
|
|
260
|
+
throw err;
|
|
261
|
+
try {
|
|
262
|
+
await installPlaywright(process.cwd(), (line) => ctx.err(dim(line)));
|
|
263
|
+
}
|
|
264
|
+
catch (installErr) {
|
|
265
|
+
throw new CliError(installErr instanceof Error ? installErr.message : String(installErr));
|
|
266
|
+
}
|
|
267
|
+
ctx.err(green("✓ Local Playwright installed — running the test…"));
|
|
268
|
+
({ outcome, runError } = await attempt());
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
131
271
|
throw err;
|
|
132
|
-
|
|
133
|
-
ctx.err(green("✓ Local Playwright installed — running the test…"));
|
|
134
|
-
outcome = await runOnce();
|
|
272
|
+
}
|
|
135
273
|
}
|
|
136
|
-
|
|
137
|
-
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
// Environmental failures abort the batch — nothing later would fare better.
|
|
277
|
+
if (err instanceof PlaywrightMissingError || err instanceof CliError) {
|
|
278
|
+
bar.clear();
|
|
279
|
+
throw err instanceof CliError ? err : new CliError(err.message);
|
|
138
280
|
}
|
|
281
|
+
// The spec never ran and the executor could not classify it — an errored
|
|
282
|
+
// result, not an aborted batch: the remaining tests still deserve their run.
|
|
283
|
+
runError = err instanceof Error ? err.message : String(err);
|
|
139
284
|
}
|
|
285
|
+
const entry = toRunEntry(spec, outcome, runError, testStarted, entries.length, spec.usesLoginPassword ? loginPassword : undefined);
|
|
286
|
+
entries.push(entry);
|
|
287
|
+
done += 1;
|
|
288
|
+
if (entry.status === "passed")
|
|
289
|
+
passed += 1;
|
|
290
|
+
else
|
|
291
|
+
failed += 1;
|
|
292
|
+
bar.clear();
|
|
293
|
+
ctx.err(`${entry.status === "passed" ? green("✓") : red("✗")} ${spec.title}` +
|
|
294
|
+
`${entry.duration_ms ? dim(` (${(entry.duration_ms / 1000).toFixed(1)}s)`) : ""}` +
|
|
295
|
+
`${entry.error_message ? `\n ${dim(entry.error_message.split("\n")[0] ?? "")}` : ""}`);
|
|
140
296
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
297
|
+
bar.clear();
|
|
298
|
+
let runId;
|
|
299
|
+
if (sync && entries.length > 0) {
|
|
300
|
+
ctx.err(dim("Importing results into Beryl…"));
|
|
301
|
+
const imported = (await ctx.client.request("POST", `${projectPath(workspaceId, projectId)}/runs/import`, {
|
|
302
|
+
form: buildImportForm(entries, {
|
|
303
|
+
environmentId: flagStr(input, "env"),
|
|
304
|
+
targetUrlOverride: urlOverride,
|
|
305
|
+
startedAt,
|
|
306
|
+
completedAt: new Date().toISOString(),
|
|
307
|
+
onNote: (line) => ctx.err(yellow(`! ${line}`)),
|
|
308
|
+
}),
|
|
309
|
+
}));
|
|
310
|
+
runId = imported.id;
|
|
146
311
|
}
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
`(${outcome.passed} passed, ${outcome.failed} failed)\n${lines.join("\n")}` +
|
|
156
|
-
(flagStr(input, "dir") ? `\n${dim(`Spec + artifacts + report in ${outcome.directory}`)}` : "");
|
|
157
|
-
const persisted = Boolean(flagStr(input, "dir"));
|
|
312
|
+
const summaryLine = failed === 0
|
|
313
|
+
? green(`All tests passed (${passed}/${entries.length})`)
|
|
314
|
+
: red(`${failed} test(s) failed`) + ` (${passed} passed)`;
|
|
315
|
+
const human = `${summaryLine}` +
|
|
316
|
+
(skipped.length ? `\n${yellow(`${skipped.length} skipped (see notes above)`)}` : "") +
|
|
317
|
+
(runId ? `\nSynced to Beryl as run ${runId} — \`beryl runs get ${runId}\`` : "") +
|
|
318
|
+
(!sync ? `\n${dim("--no-sync: nothing recorded in Beryl")}` : "") +
|
|
319
|
+
(dir ? `\n${dim(`Specs + artifacts + reports under ${path.resolve(dir)}`)}` : "");
|
|
158
320
|
return {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
:
|
|
321
|
+
data: {
|
|
322
|
+
passed,
|
|
323
|
+
failed,
|
|
324
|
+
skipped: skipped.map((s) => s.id),
|
|
325
|
+
results: entries.map(({ files: _files, ...rest }) => rest),
|
|
326
|
+
...(runId ? { run_id: runId } : {}),
|
|
327
|
+
},
|
|
163
328
|
human,
|
|
164
|
-
// Exit codes are a CI contract: any failing test → exit 1.
|
|
165
|
-
...(
|
|
329
|
+
// Exit codes are a CI contract: any failing/errored test → exit 1.
|
|
330
|
+
...(failed > 0 ? { exitCode: 1 } : {}),
|
|
166
331
|
};
|
|
167
332
|
},
|
|
168
333
|
},
|