@beryl-so/cli 0.14.1 → 0.21.4
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 +32 -29
- package/dist/adapters/cli.js +15 -1
- package/dist/adapters/mcp.js +44 -5
- package/dist/beryl-test-skill.js +281 -47
- package/dist/commands/accounts.js +331 -0
- package/dist/commands/config-vars.js +23 -2
- package/dist/commands/environments.js +0 -2
- package/dist/commands/init.js +43 -27
- package/dist/commands/mailboxes.js +159 -0
- package/dist/commands/mcp.js +25 -0
- package/dist/commands/projects.js +21 -11
- package/dist/commands/runs.js +273 -67
- package/dist/commands/tests.js +263 -19
- package/dist/context.js +33 -2
- package/dist/email-extract.js +99 -0
- package/dist/email-pump.js +105 -0
- package/dist/http.js +4 -1
- package/dist/local-exec.js +198 -0
- package/dist/local-run.js +145 -4
- package/dist/output.js +19 -0
- package/dist/playwright-install.js +118 -7
- package/dist/progress.js +44 -0
- package/dist/registry/index.js +4 -4
- package/dist/schema.generated.js +34 -0
- package/package.json +2 -2
- package/dist/commands/credentials.js +0 -135
- package/dist/commands/inboxes.js +0 -166
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { extractCode } from "../email-extract.js";
|
|
2
|
+
import { dim, green, table } from "../output.js";
|
|
3
|
+
import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
|
|
4
|
+
const mailboxPath = (ws) => `/workspaces/${ws}/mailboxes`;
|
|
5
|
+
export const mailboxCommands = [
|
|
6
|
+
{
|
|
7
|
+
name: "mailbox get",
|
|
8
|
+
summary: "The project's mailbox address",
|
|
9
|
+
groupDefault: true,
|
|
10
|
+
groupSummary: "The project's standing email addresses — where its tests receive sign-in mail.",
|
|
11
|
+
description: "Returns the address {{mailbox_address}} resolves to, creating it on first ask. " +
|
|
12
|
+
"Every test that reads mail receives here. A test needing an address the site has " +
|
|
13
|
+
"never seen cites {{inbox_address}} instead — that renders a `+tag` alias of this " +
|
|
14
|
+
"same mailbox, so a signup stays repeatable without a second address to manage.",
|
|
15
|
+
scope: "project",
|
|
16
|
+
async run(ctx, input) {
|
|
17
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
18
|
+
const mailbox = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/mailbox`));
|
|
19
|
+
return {
|
|
20
|
+
data: mailbox,
|
|
21
|
+
human: `${mailbox.address}\n\n` +
|
|
22
|
+
dim(`Read its mail with: beryl mailbox read ${mailbox.id}`),
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "mailbox list",
|
|
28
|
+
summary: "List the project's mailboxes",
|
|
29
|
+
scope: "project",
|
|
30
|
+
async run(ctx, input) {
|
|
31
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
32
|
+
const rows = (await ctx.client.get(mailboxPath(workspaceId), {
|
|
33
|
+
project_id: projectId,
|
|
34
|
+
}));
|
|
35
|
+
return {
|
|
36
|
+
data: rows,
|
|
37
|
+
human: table(rows.map((m) => ({
|
|
38
|
+
address: m.address + (m.is_default ? " *" : ""),
|
|
39
|
+
label: m.label ?? "",
|
|
40
|
+
id: m.id,
|
|
41
|
+
}))),
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "mailbox create",
|
|
47
|
+
summary: "Add a second mailbox to the project",
|
|
48
|
+
description: "Only needed when a flow requires two genuinely separate inboxes at the same time " +
|
|
49
|
+
"— both sides of an invite handshake. For an address the site has not seen before, " +
|
|
50
|
+
"cite {{inbox_address}} in the plan instead: it aliases the existing mailbox and " +
|
|
51
|
+
"costs nothing to manage.",
|
|
52
|
+
scope: "project",
|
|
53
|
+
flags: [
|
|
54
|
+
{ name: "label", type: "string", description: "What this mailbox is for, e.g. 'invitee'" },
|
|
55
|
+
{ name: "default", type: "boolean", description: "Make it the project's default" },
|
|
56
|
+
],
|
|
57
|
+
examples: ["beryl mailbox create --label invitee"],
|
|
58
|
+
async run(ctx, input) {
|
|
59
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
60
|
+
const mailbox = (await ctx.client.post(mailboxPath(workspaceId), {
|
|
61
|
+
project_id: projectId,
|
|
62
|
+
label: flagStr(input, "label") ?? null,
|
|
63
|
+
is_default: flagBool(input, "default"),
|
|
64
|
+
}));
|
|
65
|
+
return {
|
|
66
|
+
data: mailbox,
|
|
67
|
+
human: `${green("Created")} mailbox ${mailbox.id}\n\n ${mailbox.address}`,
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "mailbox delete",
|
|
73
|
+
summary: "Delete a mailbox and every email it has received",
|
|
74
|
+
description: "Destructive: the address is configured into the site under test, so removing it " +
|
|
75
|
+
"breaks every test that signs in through it.",
|
|
76
|
+
scope: "project",
|
|
77
|
+
args: [{ name: "mailbox-id", description: "Mailbox id from `beryl mailbox list`", required: true }],
|
|
78
|
+
flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
|
|
79
|
+
async run(ctx, input) {
|
|
80
|
+
const { workspaceId } = await ctx.requireProject(input);
|
|
81
|
+
const id = arg(input, "mailbox-id");
|
|
82
|
+
await ctx.confirm(`Delete mailbox ${id} and its emails?`, flagBool(input, "force"));
|
|
83
|
+
await ctx.client.del(`${mailboxPath(workspaceId)}/${id}`);
|
|
84
|
+
return { human: "Deleted." };
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: "mailbox read",
|
|
89
|
+
summary: "Read the latest email in a mailbox (waits for one to arrive)",
|
|
90
|
+
description: "Waits up to --timeout-s for a matching email and returns it (one blocking request; " +
|
|
91
|
+
"the server caps the wait at 50s — re-run to keep waiting). With --extract-code, " +
|
|
92
|
+
"also pulls the one-time code (4-8 digits) out of the body/subject. Use " +
|
|
93
|
+
"--recipient-contains to read only one `+tag` alias's mail when several identities " +
|
|
94
|
+
"share the mailbox. Exits non-zero if nothing arrives before the timeout.",
|
|
95
|
+
scope: "project",
|
|
96
|
+
args: [{ name: "mailbox-id", description: "Mailbox id from `beryl mailbox list`", required: true }],
|
|
97
|
+
flags: [
|
|
98
|
+
{
|
|
99
|
+
name: "timeout-s",
|
|
100
|
+
type: "number",
|
|
101
|
+
description: "Seconds to wait for a matching email (0 = don't wait; max 50, default 30)",
|
|
102
|
+
},
|
|
103
|
+
{ name: "since", type: "string", description: "Only emails received after this ISO timestamp" },
|
|
104
|
+
{ name: "from-contains", type: "string", description: "Only emails whose sender contains this" },
|
|
105
|
+
{
|
|
106
|
+
name: "subject-contains",
|
|
107
|
+
type: "string",
|
|
108
|
+
description: "Only emails whose subject contains this",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "recipient-contains",
|
|
112
|
+
type: "string",
|
|
113
|
+
description: "Only mail delivered to an address containing this (a +tag alias)",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "extract-code",
|
|
117
|
+
type: "boolean",
|
|
118
|
+
description: "Also return the one-time code found in the email as `code`",
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
examples: [
|
|
122
|
+
"beryl mailbox read mbx_123 --timeout-s 45 --json",
|
|
123
|
+
"beryl mailbox read mbx_123 --subject-contains code --extract-code --json",
|
|
124
|
+
],
|
|
125
|
+
async run(ctx, input) {
|
|
126
|
+
const { workspaceId } = await ctx.requireProject(input);
|
|
127
|
+
const email = (await ctx.client.get(`${mailboxPath(workspaceId)}/${arg(input, "mailbox-id")}/emails/latest`, {
|
|
128
|
+
timeout_s: flagNum(input, "timeout-s"),
|
|
129
|
+
since: flagStr(input, "since"),
|
|
130
|
+
from_contains: flagStr(input, "from-contains"),
|
|
131
|
+
subject_contains: flagStr(input, "subject-contains"),
|
|
132
|
+
recipient_contains: flagStr(input, "recipient-contains"),
|
|
133
|
+
}));
|
|
134
|
+
if (!flagBool(input, "extract-code"))
|
|
135
|
+
return { data: email };
|
|
136
|
+
return { data: { ...email, code: extractCode(email) } };
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: "mailbox emails",
|
|
141
|
+
summary: "List the emails a mailbox has received",
|
|
142
|
+
scope: "project",
|
|
143
|
+
args: [{ name: "mailbox-id", description: "Mailbox id from `beryl mailbox list`", required: true }],
|
|
144
|
+
flags: [
|
|
145
|
+
{ name: "since", type: "string", description: "Only emails received after this ISO timestamp" },
|
|
146
|
+
{
|
|
147
|
+
name: "limit",
|
|
148
|
+
type: "number",
|
|
149
|
+
description: "Return only the most recent N emails (newest first)",
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
async run(ctx, input) {
|
|
153
|
+
const { workspaceId } = await ctx.requireProject(input);
|
|
154
|
+
return {
|
|
155
|
+
data: await ctx.client.get(`${mailboxPath(workspaceId)}/${arg(input, "mailbox-id")}/emails`, { since: flagStr(input, "since"), limit: flagNum(input, "limit") }),
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
];
|
package/dist/commands/mcp.js
CHANGED
|
@@ -1,4 +1,29 @@
|
|
|
1
|
+
import { cliVersion, fetchLatestVersion, isBehind } from "../version-check.js";
|
|
1
2
|
export const mcpCommands = [
|
|
3
|
+
{
|
|
4
|
+
name: "version",
|
|
5
|
+
summary: "Show the running CLI version, API URL, and Node version",
|
|
6
|
+
description: "Answers \"which build am I actually talking to?\" — the one question a long-lived " +
|
|
7
|
+
"`beryl mcp` process can't otherwise answer, since it loads source at spawn and never " +
|
|
8
|
+
"hot-reloads. Needs no login, so it still works when a token is missing or broken. " +
|
|
9
|
+
"Also reports whether the running build is behind npm's `latest` (best-effort: " +
|
|
10
|
+
"`update_available` is null when the registry can't be reached, and the check is " +
|
|
11
|
+
"skipped entirely under BERYL_NO_UPDATE_CHECK).",
|
|
12
|
+
examples: ["beryl version", "beryl version --json"],
|
|
13
|
+
async run(ctx) {
|
|
14
|
+
const current = cliVersion();
|
|
15
|
+
const latest = process.env.BERYL_NO_UPDATE_CHECK ? null : await fetchLatestVersion();
|
|
16
|
+
return {
|
|
17
|
+
data: {
|
|
18
|
+
cli_version: current,
|
|
19
|
+
api_url: ctx.config.apiUrl,
|
|
20
|
+
node_version: process.version,
|
|
21
|
+
latest_version: latest,
|
|
22
|
+
update_available: latest === null ? null : isBehind(current, latest),
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
},
|
|
2
27
|
{
|
|
3
28
|
name: "mcp",
|
|
4
29
|
summary: "Run the Beryl MCP server (stdio) — every CLI command as an agent tool",
|
|
@@ -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
|
{
|
|
@@ -77,11 +93,6 @@ export const projectCommands = [
|
|
|
77
93
|
description: "Whether the site needs a login (gated) or not (public). Default: detected from the " +
|
|
78
94
|
"site — only asked when detection is genuinely unsure",
|
|
79
95
|
},
|
|
80
|
-
{
|
|
81
|
-
name: "allow-mutations",
|
|
82
|
-
type: "boolean",
|
|
83
|
-
description: "Let the agent perform state-changing actions while exploring",
|
|
84
|
-
},
|
|
85
96
|
{ name: "force-new-login", type: "boolean", description: "Ignore any reusable saved login" },
|
|
86
97
|
{
|
|
87
98
|
name: "no-explore",
|
|
@@ -125,7 +136,6 @@ export const projectCommands = [
|
|
|
125
136
|
const created = (await ctx.client.post(`/workspaces/${ws}/projects`, {
|
|
126
137
|
root_url: url,
|
|
127
138
|
requires_auth_choice: auth,
|
|
128
|
-
mutation_choice: flagBool(input, "allow-mutations") ? "allow_mutations" : null,
|
|
129
139
|
force_new_login: flagBool(input, "force-new-login"),
|
|
130
140
|
skip_exploration: noExplore,
|
|
131
141
|
}));
|
|
@@ -140,9 +150,9 @@ export const projectCommands = [
|
|
|
140
150
|
return {
|
|
141
151
|
data: created,
|
|
142
152
|
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.`,
|
|
153
|
+
`The site needs a login before the agent can explore it. Capture one in the ` +
|
|
154
|
+
`Beryl webapp (open the project — it walks you through the login) — the ` +
|
|
155
|
+
`exploration starts as soon as you do.`,
|
|
146
156
|
};
|
|
147
157
|
if (!flagBool(input, "watch"))
|
|
148
158
|
return { data: created };
|
package/dist/commands/runs.js
CHANGED
|
@@ -1,12 +1,37 @@
|
|
|
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
|
-
import { confirmInstall, installPlaywright } from "../playwright-install.js";
|
|
8
|
+
import { anyGap, confirmInstall, describeGaps, installCommandsFor, installPlaywright, playwrightGaps, } 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;
|
|
13
|
+
// One filesystem check before a single spec is fetched. A missing browser binary otherwise
|
|
14
|
+
// fails EVERY test with the same launch error, which reads as a broken suite instead of a
|
|
15
|
+
// box that can't run tests. On a TTY it's an offer; over MCP / CI it's the exact commands.
|
|
16
|
+
async function ensureRunnableLocally(ctx) {
|
|
17
|
+
const gaps = playwrightGaps(process.cwd());
|
|
18
|
+
if (!anyGap(gaps))
|
|
19
|
+
return;
|
|
20
|
+
const commands = installCommandsFor(gaps);
|
|
21
|
+
const hint = `Local runs need ${describeGaps(gaps)}. Install it, then re-run:\n ${commands}`;
|
|
22
|
+
if (!ctx.interactive || !(await confirmInstall(ctx.prompt, commands)))
|
|
23
|
+
throw new CliError(hint);
|
|
24
|
+
try {
|
|
25
|
+
await installPlaywright(process.cwd(), (line) => ctx.err(dim(line)));
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
throw new CliError(`${err instanceof Error ? err.message : String(err)}\n\nFinish it by hand:\n ${commands}`);
|
|
29
|
+
}
|
|
30
|
+
const left = playwrightGaps(process.cwd());
|
|
31
|
+
if (anyGap(left))
|
|
32
|
+
throw new CliError(hint);
|
|
33
|
+
ctx.err(green(`✓ ${describeGaps(gaps)} installed`));
|
|
34
|
+
}
|
|
10
35
|
export function parseHeaders(raw) {
|
|
11
36
|
if (!raw || raw.length === 0)
|
|
12
37
|
return null;
|
|
@@ -70,99 +95,280 @@ export const runCommands = [
|
|
|
70
95
|
},
|
|
71
96
|
{
|
|
72
97
|
name: "runs local",
|
|
73
|
-
summary: "Run
|
|
74
|
-
description: "Unlike `runs trigger`,
|
|
75
|
-
"
|
|
76
|
-
"
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
"
|
|
98
|
+
summary: "Run tests on your machine with your own Playwright; results sync to Beryl",
|
|
99
|
+
description: "Unlike `runs trigger`, the browser runs on YOUR machine — each test's rendered spec is " +
|
|
100
|
+
"fetched and run with your local @playwright/test and the Chromium binary it drives. " +
|
|
101
|
+
"Both are checked once before any spec is fetched, so a machine that can't run tests " +
|
|
102
|
+
"says so once instead of failing every test (on a terminal the CLI offers to install " +
|
|
103
|
+
"whichever half is missing; over MCP it prints the exact install commands). " +
|
|
104
|
+
"Signup/OTP flows work: the CLI answers the spec's await_email steps over the API " +
|
|
105
|
+
"against the same mailbox the cloud runner uses. Authenticated tests work too: a plan " +
|
|
106
|
+
"that signs itself in with {{login_email}}/{{login_password}} has its password fetched " +
|
|
107
|
+
"once over the logged secret-reveal route, handed to the spec the way the cloud runner " +
|
|
108
|
+
"does, and scrubbed from any error text or DOM snapshot before results upload. When the " +
|
|
109
|
+
"run finishes, the results and replay artifacts are imported into Beryl as a normal run " +
|
|
110
|
+
"(trigger source `local`) — history, replay, and reports all work; pass --no-sync to " +
|
|
111
|
+
"keep a run entirely off the record while iterating. Session-mode tests run here too: " +
|
|
112
|
+
"in the cloud their account signs in once per run and every test rides that session, " +
|
|
113
|
+
"and locally the server hands you the same test with that account's sign-in steps in " +
|
|
114
|
+
"front, so it proves the same thing on your machine. Only a test that depends on a " +
|
|
115
|
+
"session Beryl captured server-side is skipped, with a note. Point " +
|
|
116
|
+
"--url-override at a local dev server or preview, and --dir to keep specs, artifacts, " +
|
|
117
|
+
"and reports on disk. Exits 0 only if every executed test passed.",
|
|
82
118
|
scope: "project",
|
|
83
|
-
args: [
|
|
119
|
+
args: [
|
|
120
|
+
{
|
|
121
|
+
name: "test-ids",
|
|
122
|
+
description: "Test ids to run (from `beryl tests list`); omit to run every active test",
|
|
123
|
+
variadic: true,
|
|
124
|
+
},
|
|
125
|
+
],
|
|
84
126
|
flags: [
|
|
127
|
+
{
|
|
128
|
+
name: "all",
|
|
129
|
+
type: "boolean",
|
|
130
|
+
description: "Run every active test in the project (the default when no ids are given)",
|
|
131
|
+
},
|
|
85
132
|
{
|
|
86
133
|
name: "url-override",
|
|
87
134
|
type: "string",
|
|
88
135
|
description: "Run against this base URL instead of the environment's (e.g. http://localhost:3000)",
|
|
89
136
|
},
|
|
137
|
+
{ name: "env", type: "string", description: "Environment id to attach the imported run to" },
|
|
138
|
+
{
|
|
139
|
+
name: "sync",
|
|
140
|
+
type: "boolean",
|
|
141
|
+
default: true,
|
|
142
|
+
description: "Import the results into Beryl as a run when finished (--no-sync: local only, " +
|
|
143
|
+
"nothing recorded)",
|
|
144
|
+
},
|
|
90
145
|
{
|
|
91
146
|
name: "dir",
|
|
92
147
|
type: "string",
|
|
93
|
-
description: "Write
|
|
148
|
+
description: "Write each test's spec, artifacts, and JSON report under this directory",
|
|
94
149
|
},
|
|
95
150
|
],
|
|
96
151
|
examples: [
|
|
97
|
-
"beryl runs local
|
|
98
|
-
"beryl runs local 4f…
|
|
152
|
+
"beryl runs local",
|
|
153
|
+
"beryl runs local 4f… 9a…",
|
|
154
|
+
"beryl runs local --url-override http://localhost:3000",
|
|
155
|
+
"beryl runs local 4f… --no-sync --dir ./beryl-local",
|
|
99
156
|
],
|
|
100
157
|
async run(ctx, input) {
|
|
158
|
+
await ensureRunnableLocally(ctx);
|
|
101
159
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
throw new
|
|
109
|
-
|
|
110
|
-
|
|
160
|
+
// Deduped: a repeated id would put the same test twice in one imported run,
|
|
161
|
+
// which the import manifest rejects.
|
|
162
|
+
const explicitIds = [...new Set(input.args["test-ids"] ?? [])];
|
|
163
|
+
// No ids means the whole suite — `beryl runs local` alone is a complete local run.
|
|
164
|
+
const all = explicitIds.length === 0;
|
|
165
|
+
if (explicitIds.length > 0 && flagBool(input, "all"))
|
|
166
|
+
throw new UsageError("--all cannot be combined with explicit test ids");
|
|
167
|
+
const sync = input.flags.sync !== false;
|
|
168
|
+
const urlOverride = flagStr(input, "url-override");
|
|
169
|
+
const dir = flagStr(input, "dir");
|
|
170
|
+
const listed = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests`, {
|
|
171
|
+
environment_id: flagStr(input, "env"),
|
|
172
|
+
}));
|
|
173
|
+
// The list rows carry the authored name as nl_title (title is the customer's
|
|
174
|
+
// rename, usually unset) — fall through so the terminal shows names, not ids.
|
|
175
|
+
const titles = new Map(listed.map((t) => [t.id, t.title || t.nl_title || t.id.slice(0, 8)]));
|
|
176
|
+
const ids = all ? listed.filter((t) => t.is_active !== false).map((t) => t.id) : explicitIds;
|
|
177
|
+
if (ids.length === 0)
|
|
178
|
+
throw new CliError("This project has no tests to run.");
|
|
179
|
+
const specs = [];
|
|
180
|
+
for (const id of ids) {
|
|
181
|
+
let script;
|
|
182
|
+
try {
|
|
183
|
+
script = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests/${id}/script`,
|
|
184
|
+
// frames only matter when the run will be imported: they become the replay.
|
|
185
|
+
// environment_id keeps the baked login_email on the same environment the
|
|
186
|
+
// LOGIN_PASSWORD reveal below is scoped to.
|
|
187
|
+
{
|
|
188
|
+
base_url: urlOverride,
|
|
189
|
+
frames: sync ? true : undefined,
|
|
190
|
+
environment_id: flagStr(input, "env"),
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
// With --all an unrenderable test (no plan yet) is a skip, not an abort;
|
|
195
|
+
// an explicitly-requested id failing to render is the caller's problem.
|
|
196
|
+
if (all) {
|
|
197
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
198
|
+
ctx.err(yellow(`! ${titles.get(id) ?? id}: no runnable script — skipped (${detail})`));
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
specs.push({
|
|
204
|
+
id,
|
|
205
|
+
title: titles.get(id) ?? id.slice(0, 8),
|
|
206
|
+
content: script.content,
|
|
207
|
+
requiresAuth: Boolean(script.requires_auth),
|
|
208
|
+
usesEmail: /__vmInbox\(|__vmAwaitEmail\(/.test(script.content),
|
|
209
|
+
usesLoginPassword: Boolean(script.uses_login_password),
|
|
210
|
+
inlinedLoginSteps: script.inlined_login_steps ?? 0,
|
|
211
|
+
...(script.email ? { email: script.email } : {}),
|
|
212
|
+
...(script.login_config_error ? { loginConfigError: script.login_config_error } : {}),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (specs.length === 0)
|
|
216
|
+
throw new CliError("No runnable tests were found.");
|
|
217
|
+
// A test that leans on a session Beryl holds server-side gets no session here, so
|
|
218
|
+
// skip it rather than run a spec doomed at the login wall. Session-mode tests are
|
|
219
|
+
// NOT in that set: the server renders them with their account's own sign-in steps
|
|
220
|
+
// in front, so they sign themselves in here and run like any other. A test whose
|
|
221
|
+
// account is missing its password would type a literal placeholder into the page:
|
|
222
|
+
// same treatment, with the server's fix-it message.
|
|
223
|
+
const skipped = specs.filter((s) => s.requiresAuth || s.loginConfigError);
|
|
224
|
+
const runnable = specs.filter((s) => !s.requiresAuth && !s.loginConfigError);
|
|
225
|
+
for (const s of skipped) {
|
|
226
|
+
ctx.err(yellow(s.requiresAuth
|
|
227
|
+
? `! ${s.title}: needs a session only Beryl's cloud holds — skipped ` +
|
|
228
|
+
`(use \`beryl runs trigger\`).`
|
|
229
|
+
: `! ${s.title}: skipped — ${s.loginConfigError}`));
|
|
111
230
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
let outcome;
|
|
119
|
-
try {
|
|
231
|
+
if (runnable.length === 0)
|
|
232
|
+
throw new CliError("None of the selected tests can run locally.");
|
|
233
|
+
// Revealed once for the batch over the member-gated, logged route — the same
|
|
234
|
+
// secret the cloud runner banks into run-config.json; never in the spec source.
|
|
235
|
+
let loginPassword;
|
|
236
|
+
if (runnable.some((s) => s.usesLoginPassword)) {
|
|
120
237
|
try {
|
|
121
|
-
|
|
238
|
+
const secret = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/config/secrets/LOGIN_PASSWORD/value`, { environment_id: flagStr(input, "env") }));
|
|
239
|
+
loginPassword = secret.value;
|
|
122
240
|
}
|
|
123
241
|
catch (err) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
242
|
+
throw new CliError("Could not reveal the LOGIN_PASSWORD secret for the saved login: " +
|
|
243
|
+
(err instanceof Error ? err.message : String(err)));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const startedAt = new Date().toISOString();
|
|
247
|
+
const entries = [];
|
|
248
|
+
const bar = new ProgressBar();
|
|
249
|
+
let installOffered = false;
|
|
250
|
+
let done = 0;
|
|
251
|
+
let passed = 0;
|
|
252
|
+
let failed = 0;
|
|
253
|
+
for (const spec of runnable) {
|
|
254
|
+
const stepTotal = countPlannedFrames(spec.content);
|
|
255
|
+
const state = () => ({
|
|
256
|
+
total: runnable.length,
|
|
257
|
+
done,
|
|
258
|
+
passed,
|
|
259
|
+
failed,
|
|
260
|
+
title: spec.title,
|
|
261
|
+
stepTotal,
|
|
262
|
+
});
|
|
263
|
+
bar.update(state());
|
|
264
|
+
if (!bar.active)
|
|
265
|
+
ctx.err(dim(`Running ${spec.title}…`));
|
|
266
|
+
const testStarted = new Date().toISOString();
|
|
267
|
+
let outcome;
|
|
268
|
+
let runError;
|
|
269
|
+
let lastStep = 0;
|
|
270
|
+
const attempt = () => executeLocalSpec({ client: ctx.client, workspaceId, projectId }, {
|
|
271
|
+
spec,
|
|
272
|
+
dir: dir ? path.join(dir, spec.id) : undefined,
|
|
273
|
+
harvest: sync,
|
|
274
|
+
loginPassword,
|
|
275
|
+
// Clear the rewriting bar before a mid-test log line, or the line is
|
|
276
|
+
// appended onto the live bar and fossilises it into scrollback.
|
|
277
|
+
onEvent: (line) => {
|
|
278
|
+
bar.clear();
|
|
279
|
+
ctx.err(dim(line));
|
|
280
|
+
bar.update({ ...state(), step: lastStep });
|
|
281
|
+
},
|
|
282
|
+
onSpawn: (runDir) => {
|
|
283
|
+
const ticker = setInterval(() => {
|
|
284
|
+
lastStep = countWrittenFrames(runDir);
|
|
285
|
+
bar.update({ ...state(), step: lastStep });
|
|
286
|
+
}, 300);
|
|
287
|
+
return () => clearInterval(ticker);
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
try {
|
|
291
|
+
try {
|
|
292
|
+
({ outcome, runError } = await attempt());
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
// Local Playwright missing: on a TTY offer to install it once and retry,
|
|
296
|
+
// instead of only printing a hint the user then has to act on by hand.
|
|
297
|
+
if (err instanceof PlaywrightMissingError && ctx.interactive && !installOffered) {
|
|
298
|
+
installOffered = true;
|
|
299
|
+
bar.clear();
|
|
300
|
+
if (!(await confirmInstall(ctx.prompt)))
|
|
301
|
+
throw err;
|
|
302
|
+
try {
|
|
303
|
+
await installPlaywright(process.cwd(), (line) => ctx.err(dim(line)));
|
|
304
|
+
}
|
|
305
|
+
catch (installErr) {
|
|
306
|
+
throw new CliError(installErr instanceof Error ? installErr.message : String(installErr));
|
|
307
|
+
}
|
|
308
|
+
ctx.err(green("✓ Local Playwright installed — running the test…"));
|
|
309
|
+
({ outcome, runError } = await attempt());
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
131
312
|
throw err;
|
|
132
|
-
|
|
133
|
-
ctx.err(green("✓ Local Playwright installed — running the test…"));
|
|
134
|
-
outcome = await runOnce();
|
|
313
|
+
}
|
|
135
314
|
}
|
|
136
|
-
|
|
137
|
-
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
// Environmental failures abort the batch — nothing later would fare better.
|
|
318
|
+
if (err instanceof PlaywrightMissingError || err instanceof CliError) {
|
|
319
|
+
bar.clear();
|
|
320
|
+
throw err instanceof CliError ? err : new CliError(err.message);
|
|
138
321
|
}
|
|
322
|
+
// The spec never ran and the executor could not classify it — an errored
|
|
323
|
+
// result, not an aborted batch: the remaining tests still deserve their run.
|
|
324
|
+
runError = err instanceof Error ? err.message : String(err);
|
|
139
325
|
}
|
|
326
|
+
const entry = toRunEntry(spec, outcome, runError, testStarted, entries.length, spec.usesLoginPassword ? loginPassword : undefined);
|
|
327
|
+
entries.push(entry);
|
|
328
|
+
done += 1;
|
|
329
|
+
if (entry.status === "passed")
|
|
330
|
+
passed += 1;
|
|
331
|
+
else
|
|
332
|
+
failed += 1;
|
|
333
|
+
bar.clear();
|
|
334
|
+
ctx.err(`${entry.status === "passed" ? green("✓") : red("✗")} ${spec.title}` +
|
|
335
|
+
`${entry.duration_ms ? dim(` (${(entry.duration_ms / 1000).toFixed(1)}s)`) : ""}` +
|
|
336
|
+
`${entry.error_message ? `\n ${dim(entry.error_message.split("\n")[0] ?? "")}` : ""}`);
|
|
140
337
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
338
|
+
bar.clear();
|
|
339
|
+
let runId;
|
|
340
|
+
if (sync && entries.length > 0) {
|
|
341
|
+
ctx.err(dim("Importing results into Beryl…"));
|
|
342
|
+
const imported = (await ctx.client.request("POST", `${projectPath(workspaceId, projectId)}/runs/import`, {
|
|
343
|
+
form: buildImportForm(entries, {
|
|
344
|
+
environmentId: flagStr(input, "env"),
|
|
345
|
+
targetUrlOverride: urlOverride,
|
|
346
|
+
startedAt,
|
|
347
|
+
completedAt: new Date().toISOString(),
|
|
348
|
+
onNote: (line) => ctx.err(yellow(`! ${line}`)),
|
|
349
|
+
}),
|
|
350
|
+
}));
|
|
351
|
+
runId = imported.id;
|
|
146
352
|
}
|
|
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"));
|
|
353
|
+
const summaryLine = failed === 0
|
|
354
|
+
? green(`All tests passed (${passed}/${entries.length})`)
|
|
355
|
+
: red(`${failed} test(s) failed`) + ` (${passed} passed)`;
|
|
356
|
+
const human = `${summaryLine}` +
|
|
357
|
+
(skipped.length ? `\n${yellow(`${skipped.length} skipped (see notes above)`)}` : "") +
|
|
358
|
+
(runId ? `\nSynced to Beryl as run ${runId} — \`beryl runs get ${runId}\`` : "") +
|
|
359
|
+
(!sync ? `\n${dim("--no-sync: nothing recorded in Beryl")}` : "") +
|
|
360
|
+
(dir ? `\n${dim(`Specs + artifacts + reports under ${path.resolve(dir)}`)}` : "");
|
|
158
361
|
return {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
:
|
|
362
|
+
data: {
|
|
363
|
+
passed,
|
|
364
|
+
failed,
|
|
365
|
+
skipped: skipped.map((s) => s.id),
|
|
366
|
+
results: entries.map(({ files: _files, ...rest }) => rest),
|
|
367
|
+
...(runId ? { run_id: runId } : {}),
|
|
368
|
+
},
|
|
163
369
|
human,
|
|
164
|
-
// Exit codes are a CI contract: any failing test → exit 1.
|
|
165
|
-
...(
|
|
370
|
+
// Exit codes are a CI contract: any failing/errored test → exit 1.
|
|
371
|
+
...(failed > 0 ? { exitCode: 1 } : {}),
|
|
166
372
|
};
|
|
167
373
|
},
|
|
168
374
|
},
|