@beryl-so/cli 0.9.1 → 0.14.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 +13 -6
- package/dist/adapters/mcp.js +8 -1
- package/dist/beryl-test-skill.js +3 -1
- package/dist/commands/credentials.js +3 -46
- package/dist/commands/inboxes.js +46 -9
- package/dist/commands/init.js +121 -97
- package/dist/commands/projects.js +33 -3
- package/dist/commands/runs.js +31 -9
- package/dist/commands/tests.js +57 -9
- package/dist/local-run.js +6 -5
- package/dist/playwright-install.js +58 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -71,6 +71,14 @@ Set up Beryl in this repo — sign in and wire up your coding agent
|
|
|
71
71
|
| --- | --- | --- |
|
|
72
72
|
| `beryl init` | Set up Beryl in this repo — sign in and wire up your coding agent | — |
|
|
73
73
|
|
|
74
|
+
### guide
|
|
75
|
+
|
|
76
|
+
Print the Beryl test-authoring guide
|
|
77
|
+
|
|
78
|
+
| Command | Summary | MCP tool |
|
|
79
|
+
| --- | --- | --- |
|
|
80
|
+
| `beryl guide` | Print the Beryl test-authoring guide | `guide` |
|
|
81
|
+
|
|
74
82
|
### login
|
|
75
83
|
|
|
76
84
|
Authenticate the CLI with your Beryl account
|
|
@@ -160,7 +168,7 @@ Create and manage projects — a site Beryl explores, authors tests for, and run
|
|
|
160
168
|
| --- | --- | --- |
|
|
161
169
|
| `beryl projects list` | List projects in the workspace | `projects_list` |
|
|
162
170
|
| `beryl projects get` | Show one project, including its current exploration state | `projects_get` |
|
|
163
|
-
| `beryl projects create
|
|
171
|
+
| `beryl projects create [url]` | Create a project — with a URL the agent starts exploring; with just --name an empty one | `projects_create` |
|
|
164
172
|
| `beryl projects rename <name>` | Rename a project | `projects_rename` |
|
|
165
173
|
| `beryl projects delete` | Delete a project and all its tests and runs | `projects_delete` |
|
|
166
174
|
| `beryl projects re-explore` | Send the agent back in — run/heal existing tests and discover new flows | — |
|
|
@@ -302,19 +310,18 @@ Drive a browser session that captures a target-site login for Beryl to reuse.
|
|
|
302
310
|
| Command | Summary | MCP tool |
|
|
303
311
|
| --- | --- | --- |
|
|
304
312
|
| `beryl auth-capture start` | Start a login-capture browser session for the project (non-interactive) | `auth_capture_start` |
|
|
305
|
-
| `beryl auth-capture
|
|
306
|
-
| `beryl auth-capture capture <session-id>` | Capture the session after the user has logged in via the live-view URL | `auth_capture_capture` |
|
|
307
|
-
| `beryl auth-capture refresh <session-id>` | Capture a refreshed session for a project whose login is expiring | `auth_capture_refresh` |
|
|
313
|
+
| `beryl auth-capture capture <session-id>` | Save the session after the user has logged in via the live-view URL (first login or re-login) | `auth_capture_capture` |
|
|
308
314
|
| `beryl auth-capture release <session-id>` | Release a login-capture browser session without capturing | `auth_capture_release` |
|
|
309
315
|
|
|
310
316
|
### inbox
|
|
311
317
|
|
|
312
|
-
|
|
318
|
+
Email inboxes for testing flows that send mail — signups, OTPs, receipts.
|
|
313
319
|
|
|
314
320
|
| Command | Summary | MCP tool |
|
|
315
321
|
| --- | --- | --- |
|
|
316
|
-
| `beryl inbox create` | Mint
|
|
322
|
+
| `beryl inbox create` | Mint an email inbox that Beryl receives mail for | `inbox_create` |
|
|
317
323
|
| `beryl inbox list` | List the workspace's inboxes, newest first | `inbox_list` |
|
|
324
|
+
| `beryl inbox delete <inbox-id>` | Delete an inbox and every email it has received | `inbox_delete` |
|
|
318
325
|
| `beryl inbox read <inbox-id>` | Read the latest email from an inbox (waits for one to arrive) | `inbox_read` |
|
|
319
326
|
| `beryl inbox emails <inbox-id>` | List the emails an inbox has received | `inbox_emails` |
|
|
320
327
|
|
package/dist/adapters/mcp.js
CHANGED
|
@@ -75,7 +75,14 @@ export async function serveMcp(baseCtx) {
|
|
|
75
75
|
// Fire-and-forget staleness warning: a stale MCP server silently exposes fewer
|
|
76
76
|
// tools, and stderr is the one channel a stdio MCP server can safely log to.
|
|
77
77
|
void warnIfStale(cliVersion(), (msg) => console.error(msg));
|
|
78
|
-
const server = new Server({ name: "beryl", version: cliVersion() }, {
|
|
78
|
+
const server = new Server({ name: "beryl", version: cliVersion() }, {
|
|
79
|
+
capabilities: { tools: {} },
|
|
80
|
+
instructions: "Beryl authors, runs, and heals end-to-end tests for any web app: tests are JSON " +
|
|
81
|
+
"action plans replayed in real cloud browsers, with per-run email inboxes that make " +
|
|
82
|
+
"signup/OTP/magic-link flows fully self-contained (no human login needed). Before " +
|
|
83
|
+
"authoring your first test plan, call the `guide` tool — it returns the full " +
|
|
84
|
+
"authoring guide (plan shape, outcome assertions, email/OTP wiring, run-fix loop).",
|
|
85
|
+
});
|
|
79
86
|
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
80
87
|
tools: mcpTools().map((spec) => ({
|
|
81
88
|
name: toolName(spec),
|
package/dist/beryl-test-skill.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// The `beryl-test` authoring skill, installed by `beryl init` into
|
|
2
|
-
// `.agents/skills/beryl-test/SKILL.md` (vendor-neutral, editor-agnostic)
|
|
2
|
+
// `.agents/skills/beryl-test/SKILL.md` (vendor-neutral, editor-agnostic) — and, when
|
|
3
|
+
// claude-code is a selected editor, ALSO into the `.claude/skills/beryl-test/SKILL.md`
|
|
4
|
+
// that Claude Code actually indexes (see `writeSkills` in `commands/init.ts`). Kept as an
|
|
3
5
|
// embedded string so it ships in the published package (`files: ["dist"]`) with no
|
|
4
6
|
// build-time asset copy, and so there is ONE source for the guidance — not a copy in
|
|
5
7
|
// the CLI and another in the docs. Edit here; `init` writes it verbatim.
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { UsageError } from "../errors.js";
|
|
2
1
|
import { dim, green, yellow } from "../output.js";
|
|
3
|
-
import { arg, flagBool
|
|
2
|
+
import { arg, flagBool } from "./util.js";
|
|
4
3
|
const capturePath = (ws, p) => `/auth-capture/workspaces/${ws}/projects/${p}/sessions`;
|
|
5
4
|
export const credentialCommands = [
|
|
6
5
|
{
|
|
@@ -98,7 +97,7 @@ export const credentialCommands = [
|
|
|
98
97
|
.del(`${capturePath(workspaceId, projectId)}/${session.session_id}`)
|
|
99
98
|
.catch(() => { });
|
|
100
99
|
}
|
|
101
|
-
return { human: `${green("Login captured.")} ${dim("The agent can
|
|
100
|
+
return { human: `${green("Login captured.")} ${dim("The agent can test the gated app with it.")}` };
|
|
102
101
|
},
|
|
103
102
|
},
|
|
104
103
|
{
|
|
@@ -111,40 +110,9 @@ export const credentialCommands = [
|
|
|
111
110
|
return { data: await ctx.client.post(capturePath(workspaceId, projectId)) };
|
|
112
111
|
},
|
|
113
112
|
},
|
|
114
|
-
{
|
|
115
|
-
name: "auth-capture login",
|
|
116
|
-
summary: "Log into the target site headlessly with credentials (no human at the browser)",
|
|
117
|
-
description: "Drives the login inside the capture session started by `auth-capture start`, so " +
|
|
118
|
-
"an agent can complete start → login → capture with zero human intervention. The " +
|
|
119
|
-
"credentials are sent to the server, typed into the target site over the wire, and " +
|
|
120
|
-
"never stored, logged, or returned — the captured session stays encrypted " +
|
|
121
|
-
"server-side. Follow with `auth-capture capture` to snapshot the authenticated session.",
|
|
122
|
-
scope: "project",
|
|
123
|
-
args: [
|
|
124
|
-
{ name: "session-id", description: "Session id from auth-capture start", required: true },
|
|
125
|
-
],
|
|
126
|
-
flags: [
|
|
127
|
-
{ name: "username", type: "string", description: "Login username / email", required: true },
|
|
128
|
-
{ name: "password", type: "string", description: "Login password", required: true },
|
|
129
|
-
{
|
|
130
|
-
name: "login-url",
|
|
131
|
-
type: "string",
|
|
132
|
-
description: "Explicit login page URL (defaults to the session's current page)",
|
|
133
|
-
},
|
|
134
|
-
],
|
|
135
|
-
async run(ctx, input) {
|
|
136
|
-
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
137
|
-
const username = flagStr(input, "username");
|
|
138
|
-
const password = flagStr(input, "password");
|
|
139
|
-
if (!username || !password)
|
|
140
|
-
throw new UsageError("--username and --password are required");
|
|
141
|
-
await ctx.client.post(`${capturePath(workspaceId, projectId)}/${arg(input, "session-id")}/login`, { username, password, login_url: flagStr(input, "login-url") ?? null });
|
|
142
|
-
return { human: "Logged in." };
|
|
143
|
-
},
|
|
144
|
-
},
|
|
145
113
|
{
|
|
146
114
|
name: "auth-capture capture",
|
|
147
|
-
summary: "
|
|
115
|
+
summary: "Save the session after the user has logged in via the live-view URL (first login or re-login)",
|
|
148
116
|
scope: "project",
|
|
149
117
|
args: [{ name: "session-id", description: "Session id from auth-capture start", required: true }],
|
|
150
118
|
async run(ctx, input) {
|
|
@@ -153,17 +121,6 @@ export const credentialCommands = [
|
|
|
153
121
|
return { human: "Captured." };
|
|
154
122
|
},
|
|
155
123
|
},
|
|
156
|
-
{
|
|
157
|
-
name: "auth-capture refresh",
|
|
158
|
-
summary: "Capture a refreshed session for a project whose login is expiring",
|
|
159
|
-
scope: "project",
|
|
160
|
-
args: [{ name: "session-id", description: "Session id from auth-capture start", required: true }],
|
|
161
|
-
async run(ctx, input) {
|
|
162
|
-
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
163
|
-
await ctx.client.post(`${capturePath(workspaceId, projectId)}/${arg(input, "session-id")}/capture-refresh`);
|
|
164
|
-
return { human: "Captured." };
|
|
165
|
-
},
|
|
166
|
-
},
|
|
167
124
|
{
|
|
168
125
|
name: "auth-capture release",
|
|
169
126
|
summary: "Release a login-capture browser session without capturing",
|
package/dist/commands/inboxes.js
CHANGED
|
@@ -25,25 +25,35 @@ function extractCode(email) {
|
|
|
25
25
|
export const inboxCommands = [
|
|
26
26
|
{
|
|
27
27
|
name: "inbox create",
|
|
28
|
-
summary: "Mint
|
|
29
|
-
groupSummary: "
|
|
28
|
+
summary: "Mint an email inbox that Beryl receives mail for",
|
|
29
|
+
groupSummary: "Email inboxes for testing flows that send mail — signups, OTPs, receipts.",
|
|
30
30
|
description: "Creates a receiving address under Beryl's inbound email domain and returns it. " +
|
|
31
31
|
"Use it wherever a test needs a real, readable mailbox — e.g. as the --email for " +
|
|
32
|
-
"`beryl signup`, then read the code back with `beryl inbox read --extract-code`."
|
|
32
|
+
"`beryl signup`, then read the code back with `beryl inbox read --extract-code`. " +
|
|
33
|
+
"Pass --permanent to mint the workspace's single permanent mailbox (no TTL).",
|
|
33
34
|
scope: "workspace",
|
|
34
35
|
flags: [
|
|
36
|
+
{
|
|
37
|
+
name: "permanent",
|
|
38
|
+
type: "boolean",
|
|
39
|
+
description: "Mint the workspace's permanent mailbox (no TTL); one per workspace",
|
|
40
|
+
},
|
|
35
41
|
{
|
|
36
42
|
name: "ttl-hours",
|
|
37
43
|
type: "number",
|
|
38
|
-
description: "Hours before the inbox expires and stops receiving (1-168, default 24)",
|
|
44
|
+
description: "Hours before the inbox expires and stops receiving (1-168, default 24; ignored with --permanent)",
|
|
39
45
|
},
|
|
40
46
|
{ name: "project", type: "string", description: "Attach the inbox to a project id" },
|
|
41
47
|
],
|
|
42
|
-
examples: [
|
|
48
|
+
examples: [
|
|
49
|
+
"beryl inbox create --json",
|
|
50
|
+
"beryl inbox create --ttl-hours 2",
|
|
51
|
+
"beryl inbox create --permanent",
|
|
52
|
+
],
|
|
43
53
|
async run(ctx, input) {
|
|
44
54
|
const ws = await ctx.requireWorkspace(input);
|
|
45
55
|
const inbox = (await ctx.client.post(`/workspaces/${ws}/inboxes`, {
|
|
46
|
-
ttl_hours: flagNum(input, "ttl-hours") ?? 24,
|
|
56
|
+
ttl_hours: flagBool(input, "permanent") ? null : (flagNum(input, "ttl-hours") ?? 24),
|
|
47
57
|
project_id: flagStr(input, "project") ?? null,
|
|
48
58
|
}));
|
|
49
59
|
return {
|
|
@@ -58,11 +68,33 @@ export const inboxCommands = [
|
|
|
58
68
|
summary: "List the workspace's inboxes, newest first",
|
|
59
69
|
description: "Every inbox the workspace has minted with `beryl inbox create`. Expired inboxes " +
|
|
60
70
|
"stop receiving and are hard-deleted by a background sweep, so they drop off " +
|
|
61
|
-
"this list shortly after their TTL.",
|
|
71
|
+
"this list shortly after their TTL. Pass --permanent for just the permanent mailbox.",
|
|
72
|
+
scope: "workspace",
|
|
73
|
+
flags: [
|
|
74
|
+
{
|
|
75
|
+
name: "permanent",
|
|
76
|
+
type: "boolean",
|
|
77
|
+
description: "Only the workspace's permanent mailbox (no TTL, not run/project scoped)",
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
async run(ctx, input) {
|
|
81
|
+
const ws = await ctx.requireWorkspace(input);
|
|
82
|
+
const query = flagBool(input, "permanent") ? { permanent: true } : undefined;
|
|
83
|
+
return { data: await ctx.client.get(`/workspaces/${ws}/inboxes`, query) };
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "inbox delete",
|
|
88
|
+
summary: "Delete an inbox and every email it has received",
|
|
62
89
|
scope: "workspace",
|
|
90
|
+
args: [{ name: "inbox-id", description: "Inbox id from `beryl inbox create`", required: true }],
|
|
91
|
+
flags: [{ name: "force", type: "boolean", description: "Skip the confirmation prompt" }],
|
|
63
92
|
async run(ctx, input) {
|
|
64
93
|
const ws = await ctx.requireWorkspace(input);
|
|
65
|
-
|
|
94
|
+
const id = arg(input, "inbox-id");
|
|
95
|
+
await ctx.confirm(`Delete inbox ${id} and its emails?`, flagBool(input, "force"));
|
|
96
|
+
await ctx.client.del(`/workspaces/${ws}/inboxes/${id}`);
|
|
97
|
+
return { human: "Deleted." };
|
|
66
98
|
},
|
|
67
99
|
},
|
|
68
100
|
{
|
|
@@ -118,11 +150,16 @@ export const inboxCommands = [
|
|
|
118
150
|
args: [{ name: "inbox-id", description: "Inbox id from `beryl inbox create`", required: true }],
|
|
119
151
|
flags: [
|
|
120
152
|
{ name: "since", type: "string", description: "Only emails received after this ISO timestamp" },
|
|
153
|
+
{
|
|
154
|
+
name: "limit",
|
|
155
|
+
type: "number",
|
|
156
|
+
description: "Return only the most recent N emails (newest first)",
|
|
157
|
+
},
|
|
121
158
|
],
|
|
122
159
|
async run(ctx, input) {
|
|
123
160
|
const ws = await ctx.requireWorkspace(input);
|
|
124
161
|
return {
|
|
125
|
-
data: await ctx.client.get(`/workspaces/${ws}/inboxes/${arg(input, "inbox-id")}/emails`, { since: flagStr(input, "since") }),
|
|
162
|
+
data: await ctx.client.get(`/workspaces/${ws}/inboxes/${arg(input, "inbox-id")}/emails`, { since: flagStr(input, "since"), limit: flagNum(input, "limit") }),
|
|
126
163
|
};
|
|
127
164
|
},
|
|
128
165
|
},
|
package/dist/commands/init.js
CHANGED
|
@@ -6,7 +6,8 @@ import { BERYL_TEST_SKILL, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME, } fr
|
|
|
6
6
|
import { loadConfig } from "../config.js";
|
|
7
7
|
import { CliError } from "../errors.js";
|
|
8
8
|
import { ApiClient } from "../http.js";
|
|
9
|
-
import { bold, cyan, dim, green, yellow } from "../output.js";
|
|
9
|
+
import { bold, cyan, dim, green, red, yellow } from "../output.js";
|
|
10
|
+
import { hasPlaywrightTest, installPlaywright, PLAYWRIGHT_INSTALL_COMMANDS, } from "../playwright-install.js";
|
|
10
11
|
import { cliVersion, warnIfStale } from "../version-check.js";
|
|
11
12
|
import { authCommands } from "./auth.js";
|
|
12
13
|
import { flagStr } from "./util.js";
|
|
@@ -26,7 +27,7 @@ const PLAYWRIGHT_SERVER_ENTRY = {
|
|
|
26
27
|
args: ["@playwright/mcp@latest", "--headless"],
|
|
27
28
|
};
|
|
28
29
|
const ACTION_PLAN_SCHEMA_URL = "https://api.beryl.so/api/v1/schemas/action-plan.schema.json";
|
|
29
|
-
function mergeMcpConfig(file
|
|
30
|
+
function mergeMcpConfig(file) {
|
|
30
31
|
let existing = {};
|
|
31
32
|
if (fs.existsSync(file)) {
|
|
32
33
|
try {
|
|
@@ -40,13 +41,10 @@ function mergeMcpConfig(file, withPlaywright) {
|
|
|
40
41
|
const beryl = JSON.stringify(servers.beryl) !== JSON.stringify(MCP_SERVER_ENTRY);
|
|
41
42
|
if (beryl)
|
|
42
43
|
servers.beryl = MCP_SERVER_ENTRY;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (playwright)
|
|
48
|
-
servers.playwright = PLAYWRIGHT_SERVER_ENTRY;
|
|
49
|
-
}
|
|
44
|
+
// Never clobber a playwright server the user already wired up.
|
|
45
|
+
const playwright = servers.playwright === undefined;
|
|
46
|
+
if (playwright)
|
|
47
|
+
servers.playwright = PLAYWRIGHT_SERVER_ENTRY;
|
|
50
48
|
if (beryl || playwright) {
|
|
51
49
|
existing.mcpServers = servers;
|
|
52
50
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
@@ -84,20 +82,10 @@ function claudeAddHint(name, entry) {
|
|
|
84
82
|
function cursorUserConfigPath() {
|
|
85
83
|
return path.join(os.homedir(), ".cursor", "mcp.json");
|
|
86
84
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (fs.existsSync(path.join(cwd, ".cursor")))
|
|
92
|
-
editors.push("cursor");
|
|
93
|
-
return editors;
|
|
94
|
-
}
|
|
95
|
-
// The authoring skill lives in the vendor-neutral `.agents/skills/` dir (mirroring
|
|
96
|
-
// Momentic), NOT `.claude/` — any coding agent that reads `.agents/skills/` picks it up.
|
|
97
|
-
// Idempotent: an identical copy is left alone; a customer-EDITED copy is never clobbered —
|
|
98
|
-
// we notice and skip so their changes survive a re-run.
|
|
99
|
-
function writeSkill(cwd) {
|
|
100
|
-
const file = path.join(cwd, ".agents", "skills", BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME);
|
|
85
|
+
// Write the skill to one `.../beryl-test/SKILL.md` file. Idempotent: an identical copy is
|
|
86
|
+
// left alone; a customer-EDITED copy is never clobbered — we notice and skip so their
|
|
87
|
+
// changes survive a re-run.
|
|
88
|
+
function writeSkillFile(file) {
|
|
101
89
|
if (fs.existsSync(file)) {
|
|
102
90
|
// Compare with line endings normalized so a CRLF checkout of our own content still
|
|
103
91
|
// reads as unchanged (not falsely "customized") — we always write LF.
|
|
@@ -111,47 +99,79 @@ function writeSkill(cwd) {
|
|
|
111
99
|
fs.writeFileSync(file, BERYL_TEST_SKILL);
|
|
112
100
|
return { outcome: "wrote", file };
|
|
113
101
|
}
|
|
102
|
+
const skillLeaf = (root) => path.join(root, BERYL_TEST_SKILL_DIR, BERYL_TEST_SKILL_FILENAME);
|
|
103
|
+
// The authoring skill follows the `--scope` flag, same as the MCP servers: under `user`
|
|
104
|
+
// (the default) it lands in the HOME `.agents/skills/` + `.claude/skills/` dirs, so the
|
|
105
|
+
// knowledge travels with the user into every session the user-scoped MCP tools do —
|
|
106
|
+
// otherwise an agent outside this repo has all the tools and none of the guide. Under
|
|
107
|
+
// `project` both copies stay repo-local (committed, so teammates get them with the repo).
|
|
108
|
+
// `.agents/skills/` is the vendor-neutral location; Claude Code does NOT index it, so the
|
|
109
|
+
// same string also goes to `.claude/skills/`, which Claude Code auto-discovers. Both are
|
|
110
|
+
// written with the same idempotent / never-clobber-a-customer-edit behavior. Existing
|
|
111
|
+
// repo-local copies from earlier inits are left untouched.
|
|
112
|
+
function writeSkills(cwd, scope) {
|
|
113
|
+
const root = scope === "user" ? os.homedir() : cwd;
|
|
114
|
+
return [
|
|
115
|
+
writeSkillFile(skillLeaf(path.join(root, ".agents", "skills"))),
|
|
116
|
+
writeSkillFile(skillLeaf(path.join(root, ".claude", "skills"))),
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
// Local authoring drives a real browser via `@playwright/test` + chromium, and the whole
|
|
120
|
+
// authoring workflow (walk the flow first, then bank the plan) depends on it — so the
|
|
121
|
+
// install is mandatory, not offered: missing means install now, no prompt, no opt-out.
|
|
122
|
+
// Never throws — a failed install must not fail `init`, which has already done its wiring;
|
|
123
|
+
// it prints the exact commands to finish by hand instead.
|
|
124
|
+
async function ensureLocalPlaywright(ctx, cwd) {
|
|
125
|
+
if (hasPlaywrightTest(cwd)) {
|
|
126
|
+
ctx.err(`${green("✓")} @playwright/test already installed ${dim("(local runs ready)")}`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
await installPlaywright(cwd, (line) => ctx.err(dim(line)));
|
|
131
|
+
ctx.err(`${green("✓")} Local Playwright installed ${dim("(local runs ready)")}`);
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
ctx.err(`${red("✗")} Playwright install failed: ${err.message}`);
|
|
135
|
+
ctx.err(`${dim("•")} Finish the install by hand — local runs and browser authoring need it:\n` +
|
|
136
|
+
` ${cyan(PLAYWRIGHT_INSTALL_COMMANDS)}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
114
139
|
export const initCommands = [
|
|
115
140
|
{
|
|
116
141
|
name: "init",
|
|
117
142
|
summary: "Set up Beryl in this repo — sign in and wire up your coding agent",
|
|
118
|
-
description: "One-command onboarding: signs you in (emailed one-time code) and wires
|
|
119
|
-
"
|
|
120
|
-
"
|
|
121
|
-
"
|
|
122
|
-
"
|
|
123
|
-
"
|
|
124
|
-
"
|
|
143
|
+
description: "One-command onboarding: signs you in (emailed one-time code) and wires up your coding " +
|
|
144
|
+
"agent. Nothing is detected and nothing is conditional — every run wires the beryl AND " +
|
|
145
|
+
"playwright MCP servers, installs the authoring skill (user scope: your home " +
|
|
146
|
+
".agents/skills/ + .claude/skills/, so it follows you into every session; --scope " +
|
|
147
|
+
"project: the repo's own dirs, committed for teammates), and installs @playwright/test " +
|
|
148
|
+
"+ chromium if missing — browser authoring and local runs depend on it. By default the " +
|
|
149
|
+
"servers are wired per-user (matching where your login token lives) via `claude mcp add " +
|
|
150
|
+
"-s user`; pass --scope project to write a committed .mcp.json for a shared repo " +
|
|
151
|
+
"instead. No workspace/project pin and no URL prompt — ask Claude to write tests for " +
|
|
152
|
+
"your site and it resolves the workspace, project, and URL. Safe to re-run; every step " +
|
|
153
|
+
"is idempotent and skips what is already set up.",
|
|
125
154
|
interactive: true,
|
|
126
155
|
flags: [
|
|
127
|
-
{
|
|
128
|
-
name: "editor-tools",
|
|
129
|
-
type: "string",
|
|
130
|
-
enum: ["claude-code", "cursor", "both", "none"],
|
|
131
|
-
description: "Which coding agent to write MCP config for (default: auto-detect)",
|
|
132
|
-
},
|
|
133
156
|
{
|
|
134
157
|
name: "scope",
|
|
135
158
|
type: "string",
|
|
136
159
|
enum: ["user", "project"],
|
|
137
160
|
description: "Where to wire the MCP servers. `user` (default) configures them per-user (matching " +
|
|
138
|
-
"where your Beryl login token lives) via `claude mcp add -s user`
|
|
139
|
-
"
|
|
140
|
-
"
|
|
161
|
+
"where your Beryl login token lives) via `claude mcp add -s user`. `project` writes a " +
|
|
162
|
+
"committed .mcp.json for a shared repo — every teammate still runs `beryl login` to " +
|
|
163
|
+
"authenticate",
|
|
141
164
|
},
|
|
142
165
|
{
|
|
143
|
-
name: "
|
|
166
|
+
name: "cursor",
|
|
144
167
|
type: "boolean",
|
|
145
|
-
description: "Also
|
|
146
|
-
"(for authoring tests yourself). Default: on whenever a coding agent is wired; " +
|
|
147
|
-
"pass --no-local to skip it",
|
|
168
|
+
description: "Also write the same two MCP servers to Cursor's config",
|
|
148
169
|
},
|
|
149
170
|
],
|
|
150
171
|
examples: [
|
|
151
172
|
"npx @beryl-so/cli@latest init",
|
|
152
|
-
"beryl init --editor-tools claude-code",
|
|
153
173
|
"beryl init --scope project",
|
|
154
|
-
"beryl init --
|
|
174
|
+
"beryl init --cursor",
|
|
155
175
|
],
|
|
156
176
|
async run(ctx, input) {
|
|
157
177
|
const cwd = process.cwd();
|
|
@@ -168,72 +188,76 @@ export const initCommands = [
|
|
|
168
188
|
throw new CliError("Login did not persist a token");
|
|
169
189
|
client = new ApiClient(config.apiUrl, config.token);
|
|
170
190
|
}
|
|
171
|
-
const choice = flagStr(input, "editor-tools") ?? "auto";
|
|
172
|
-
const editors = choice === "auto"
|
|
173
|
-
? detectEditors(cwd)
|
|
174
|
-
: choice === "both"
|
|
175
|
-
? ["claude-code", "cursor"]
|
|
176
|
-
: choice === "none"
|
|
177
|
-
? []
|
|
178
|
-
: [choice];
|
|
179
|
-
// Wiring an editor's MCP config at all implies the user wants to author tests there,
|
|
180
|
-
// and local authoring needs the Playwright MCP — so default it on. `--no-local` (parsed
|
|
181
|
-
// as an explicit false) opts out.
|
|
182
|
-
const local = input.flags.local ?? editors.length > 0;
|
|
183
191
|
const scope = (flagStr(input, "scope") ?? "user");
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
ctx.err(` ${cyan(claudeAddHint("beryl", MCP_SERVER_ENTRY))}`);
|
|
197
|
-
if (local)
|
|
198
|
-
ctx.err(` ${cyan(claudeAddHint("playwright", PLAYWRIGHT_SERVER_ENTRY))}`);
|
|
199
|
-
}
|
|
200
|
-
continue;
|
|
192
|
+
const report = (label, fresh, where) => ctx.err(`${green("✓")} ${label} ${fresh ? "configured" : "already configured"} ${dim(where)}`);
|
|
193
|
+
if (scope === "user") {
|
|
194
|
+
// Claude Code owns ~/.claude.json — shell out to `claude mcp add` rather than write it.
|
|
195
|
+
const berylOk = claudeUserAdd("beryl", MCP_SERVER_ENTRY);
|
|
196
|
+
const playwrightOk = claudeUserAdd("playwright", PLAYWRIGHT_SERVER_ENTRY);
|
|
197
|
+
if (berylOk && playwrightOk) {
|
|
198
|
+
ctx.err(`${green("✓")} beryl + playwright MCP configured ${dim("(user scope)")}`);
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
ctx.err(yellow("• `claude` not on PATH — run these to wire user-scope MCP servers:"));
|
|
202
|
+
ctx.err(` ${cyan(claudeAddHint("beryl", MCP_SERVER_ENTRY))}`);
|
|
203
|
+
ctx.err(` ${cyan(claudeAddHint("playwright", PLAYWRIGHT_SERVER_ENTRY))}`);
|
|
201
204
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
const file = path.join(cwd, ".mcp.json");
|
|
208
|
+
const wrote = mergeMcpConfig(file);
|
|
209
|
+
const where = path.relative(cwd, file);
|
|
210
|
+
report("beryl MCP", wrote.beryl, where);
|
|
211
|
+
report("playwright MCP", wrote.playwright, where);
|
|
212
|
+
}
|
|
213
|
+
if (input.flags.cursor) {
|
|
214
|
+
const file = scope === "user" ? cursorUserConfigPath() : path.join(cwd, ".cursor", "mcp.json");
|
|
215
|
+
const wrote = mergeMcpConfig(file);
|
|
208
216
|
const where = scope === "user" ? file : path.relative(cwd, file);
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
217
|
+
report("cursor beryl MCP", wrote.beryl, where);
|
|
218
|
+
report("cursor playwright MCP", wrote.playwright, where);
|
|
219
|
+
}
|
|
220
|
+
const skills = writeSkills(cwd, scope);
|
|
221
|
+
for (const skill of skills) {
|
|
222
|
+
const rel = path.relative(cwd, skill.file);
|
|
223
|
+
const skillWhere = rel.startsWith("..") ? skill.file : rel;
|
|
224
|
+
if (skill.outcome === "customized")
|
|
225
|
+
ctx.err(`${dim("•")} Beryl authoring skill left as-is ${dim(`(${skillWhere} — you edited it; delete it to reinstall)`)}`);
|
|
226
|
+
else
|
|
227
|
+
ctx.err(`${green("✓")} Beryl authoring skill ${skill.outcome === "wrote" ? "installed" : "already installed"} ${dim(skillWhere)}`);
|
|
212
228
|
}
|
|
213
|
-
|
|
214
|
-
ctx.err(dim("No coding agent detected — pass --editor-tools claude-code|cursor to wire one."));
|
|
215
|
-
// Editor-agnostic: the authoring skill goes to `.agents/skills/` regardless of which
|
|
216
|
-
// (if any) editor MCP config we wrote, so any `.agents/skills/`-aware harness gets it.
|
|
217
|
-
const skill = writeSkill(cwd);
|
|
218
|
-
const skillRel = path.relative(cwd, skill.file);
|
|
219
|
-
if (skill.outcome === "customized")
|
|
220
|
-
ctx.err(`${dim("•")} Beryl authoring skill left as-is ${dim(`(${skillRel} — you edited it; delete it to reinstall)`)}`);
|
|
221
|
-
else
|
|
222
|
-
ctx.err(`${green("✓")} Beryl authoring skill ${skill.outcome === "wrote" ? "installed" : "already installed"} ${dim(skillRel)}`);
|
|
229
|
+
await ensureLocalPlaywright(ctx, cwd);
|
|
223
230
|
const nextSteps = `\n${bold("Beryl is set up — now open your editor and ask Claude to write tests.")}\n` +
|
|
224
231
|
` ${dim('• Say: "write tests for https://your-app.com" — Claude picks your workspace/project')}\n` +
|
|
225
232
|
` ${dim(" and sets the URL for you (no pin, no prompt).")}\n` +
|
|
226
|
-
(
|
|
227
|
-
? ` ${dim("• Playwright MCP is wired — Claude can drive a real browser to author from your plan.")}\n`
|
|
228
|
-
: ` ${dim("• Re-run with --editor-tools to wire the Playwright MCP for local authoring.")}\n`) +
|
|
233
|
+
` ${dim("• Playwright MCP is wired — Claude can drive a real browser to author from your plan.")}\n` +
|
|
229
234
|
`\nAuthor against the ActionPlan JSON Schema: ${cyan(ACTION_PLAN_SCHEMA_URL)}`;
|
|
230
235
|
// The .mcp.json entry is pinned to @latest, but a global install / old npx cache
|
|
231
236
|
// still wins resolution — so tell the user when the CLI they just ran is stale.
|
|
232
237
|
await warnIfStale(cliVersion(), (msg) => ctx.err(yellow(msg)));
|
|
233
238
|
return {
|
|
234
|
-
data: {
|
|
239
|
+
data: {
|
|
240
|
+
scope,
|
|
241
|
+
skills: skills.map((s) => {
|
|
242
|
+
const rel = path.relative(cwd, s.file);
|
|
243
|
+
return rel.startsWith("..") ? s.file : rel;
|
|
244
|
+
}),
|
|
245
|
+
},
|
|
235
246
|
human: nextSteps,
|
|
236
247
|
};
|
|
237
248
|
},
|
|
238
249
|
},
|
|
250
|
+
{
|
|
251
|
+
name: "guide",
|
|
252
|
+
summary: "Print the Beryl test-authoring guide",
|
|
253
|
+
description: "The full guide to authoring durable, healable tests: the ActionPlan shape, outcome " +
|
|
254
|
+
"assertions, natural-language intent, per-run email inboxes for OTP/signup flows " +
|
|
255
|
+
"({{inbox_address}} + await_email), and the local run-fix loop. The same content " +
|
|
256
|
+
"`beryl init` installs as the beryl-test skill — call this before authoring your " +
|
|
257
|
+
"first plan when no skill is installed (works without logging in).",
|
|
258
|
+
examples: ["beryl guide"],
|
|
259
|
+
async run() {
|
|
260
|
+
return { human: BERYL_TEST_SKILL };
|
|
261
|
+
},
|
|
262
|
+
},
|
|
239
263
|
];
|
|
@@ -54,10 +54,22 @@ export const projectCommands = [
|
|
|
54
54
|
},
|
|
55
55
|
{
|
|
56
56
|
name: "projects create",
|
|
57
|
-
summary: "Create a project — the agent starts exploring
|
|
57
|
+
summary: "Create a project — with a URL the agent starts exploring; with just --name an empty one",
|
|
58
58
|
scope: "workspace",
|
|
59
|
-
args: [
|
|
59
|
+
args: [
|
|
60
|
+
{
|
|
61
|
+
name: "url",
|
|
62
|
+
description: "Root URL of the site to test. Omit to create an empty project (see --name)",
|
|
63
|
+
required: false,
|
|
64
|
+
},
|
|
65
|
+
],
|
|
60
66
|
flags: [
|
|
67
|
+
{
|
|
68
|
+
name: "name",
|
|
69
|
+
type: "string",
|
|
70
|
+
description: "Name for an empty project when no URL is given. Add a URL later with " +
|
|
71
|
+
"`beryl envs update <env-id>`, or author tests over the CLI/MCP",
|
|
72
|
+
},
|
|
61
73
|
{
|
|
62
74
|
name: "auth",
|
|
63
75
|
type: "string",
|
|
@@ -84,13 +96,31 @@ export const projectCommands = [
|
|
|
84
96
|
"beryl projects create https://app.example.com --watch",
|
|
85
97
|
"beryl projects create https://app.example.com --auth gated",
|
|
86
98
|
"beryl projects create https://app.example.com --auth public --no-explore",
|
|
99
|
+
'beryl projects create --name "Acme production"',
|
|
87
100
|
],
|
|
88
101
|
async run(ctx, input) {
|
|
89
102
|
const noExplore = flagBool(input, "no-explore");
|
|
90
103
|
if (noExplore && flagBool(input, "watch"))
|
|
91
104
|
throw new UsageError("--no-explore cannot be combined with --watch");
|
|
92
105
|
const ws = await ctx.requireWorkspace(input);
|
|
93
|
-
const
|
|
106
|
+
const rawUrl = input.args.url;
|
|
107
|
+
const url = typeof rawUrl === "string" ? rawUrl : "";
|
|
108
|
+
const name = flagStr(input, "name");
|
|
109
|
+
// Name-only: create a real empty project (no URL, no exploration). Set a URL
|
|
110
|
+
// later with `beryl envs update <env-id>` or author over the CLI/MCP.
|
|
111
|
+
if (!url) {
|
|
112
|
+
if (!name)
|
|
113
|
+
throw new UsageError("Provide a URL to explore, or --name to create an empty project");
|
|
114
|
+
const created = (await ctx.client.post(`/workspaces/${ws}/projects`, {
|
|
115
|
+
name,
|
|
116
|
+
}));
|
|
117
|
+
return {
|
|
118
|
+
data: created,
|
|
119
|
+
human: `${green("Project created")}: ${created.project_id} ${dim("(empty)")}\n` +
|
|
120
|
+
`Add a site with \`beryl envs update <env-id>\`, or author tests with ` +
|
|
121
|
+
`\`beryl tests create\`.`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
94
124
|
const auth = flagStr(input, "auth") ?? (await resolveAuthChoice(ctx, ws, url));
|
|
95
125
|
const created = (await ctx.client.post(`/workspaces/${ws}/projects`, {
|
|
96
126
|
root_url: url,
|
package/dist/commands/runs.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { downloadRunArtifacts, failureImages, isFailing, resultsOf, } from "../artifacts.js";
|
|
3
3
|
import { CliError, UsageError } from "../errors.js";
|
|
4
|
-
import { runSpecLocally } from "../local-run.js";
|
|
4
|
+
import { PlaywrightMissingError, runSpecLocally } from "../local-run.js";
|
|
5
5
|
import { dim, green, red, yellow } from "../output.js";
|
|
6
|
+
import { confirmInstall, installPlaywright } from "../playwright-install.js";
|
|
6
7
|
import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
|
|
7
8
|
import { watchRun } from "./watch.js";
|
|
8
9
|
const MAX_FAILURE_SCREENSHOTS = 5;
|
|
@@ -71,8 +72,9 @@ export const runCommands = [
|
|
|
71
72
|
name: "runs local",
|
|
72
73
|
summary: "Run a banked test locally with your own Playwright (public flows)",
|
|
73
74
|
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
|
|
75
|
-
"
|
|
75
|
+
"rendered spec, then runs it with your local @playwright/test. On a terminal it offers to " +
|
|
76
|
+
"install @playwright/test + chromium for you the first time they're missing (over MCP it " +
|
|
77
|
+
"prints the install commands instead). Point --url-override at a local " +
|
|
76
78
|
"dev server or preview, and --dir to keep the spec, artifacts, and JSON report on disk so " +
|
|
77
79
|
"an agent can run-fix-run. v1 targets public/unauthenticated flows: an authenticated test " +
|
|
78
80
|
"refuses to run locally (those run in Beryl's cloud, which holds the session) — no session " +
|
|
@@ -107,14 +109,34 @@ export const runCommands = [
|
|
|
107
109
|
"session) — local runs are for public/unauthenticated flows. Run it with " +
|
|
108
110
|
"`beryl runs trigger`.");
|
|
109
111
|
}
|
|
112
|
+
const runOnce = () => runSpecLocally({
|
|
113
|
+
spec: script.content,
|
|
114
|
+
testName: testId,
|
|
115
|
+
dir: flagStr(input, "dir"),
|
|
116
|
+
onProgress: (line) => ctx.err(dim(line)),
|
|
117
|
+
});
|
|
110
118
|
let outcome;
|
|
111
119
|
try {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
120
|
+
try {
|
|
121
|
+
outcome = await runOnce();
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
// Local Playwright missing: on a TTY offer to install it and retry, instead of only
|
|
125
|
+
// printing a hint the user then has to act on by hand. Non-interactively we can't
|
|
126
|
+
// prompt, so we re-throw and the hint surfaces as before (no unprompted install).
|
|
127
|
+
if (err instanceof PlaywrightMissingError && ctx.interactive) {
|
|
128
|
+
// confirmInstall swallows a prompt failure into `false`, so a broken prompt falls
|
|
129
|
+
// back to re-throwing the actionable missing-Playwright hint, not the prompt's error.
|
|
130
|
+
if (!(await confirmInstall(ctx.prompt)))
|
|
131
|
+
throw err;
|
|
132
|
+
await installPlaywright(process.cwd(), (line) => ctx.err(dim(line)));
|
|
133
|
+
ctx.err(green("✓ Local Playwright installed — running the test…"));
|
|
134
|
+
outcome = await runOnce();
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
118
140
|
}
|
|
119
141
|
catch (err) {
|
|
120
142
|
// Every failure to run the spec (missing Playwright, a compile error, a customer
|
package/dist/commands/tests.js
CHANGED
|
@@ -1,10 +1,44 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { UsageError } from "../errors.js";
|
|
3
|
+
import { ApiError } from "../http.js";
|
|
3
4
|
import { lintPlan } from "../lint.js";
|
|
4
5
|
import { table } from "../output.js";
|
|
5
6
|
import { ACTION_PLAN_SCHEMA } from "../schema.generated.js";
|
|
6
7
|
import { arg, argList, flagBool, flagNum, flagStr, projectPath, readJsonFlag } from "./util.js";
|
|
7
8
|
const testPath = (ws, p, id) => `${projectPath(ws, p)}/tests/${id}`;
|
|
9
|
+
// The server transcodes a failure screenshot to WebP to fit it under the payload cap,
|
|
10
|
+
// so the format is not knowable up front — sniff it off the decoded magic bytes rather
|
|
11
|
+
// than asserting PNG.
|
|
12
|
+
export function sniffImageMime(b64) {
|
|
13
|
+
const head = Buffer.from(b64.slice(0, 24), "base64");
|
|
14
|
+
if (head.subarray(0, 4).toString("latin1") === "RIFF" && head.subarray(8, 12).toString("latin1") === "WEBP") {
|
|
15
|
+
return "image/webp";
|
|
16
|
+
}
|
|
17
|
+
if (head.subarray(0, 3).toString("hex") === "ffd8ff")
|
|
18
|
+
return "image/jpeg";
|
|
19
|
+
return "image/png";
|
|
20
|
+
}
|
|
21
|
+
// A verify-failure 422 carries evidence (a11y page state + failure screenshot).
|
|
22
|
+
// Returned as a CommandResult rather than thrown: a thrown error is text-only in
|
|
23
|
+
// the MCP adapter, and the screenshot only reaches the agent as image content.
|
|
24
|
+
function verifyFailureResult(err) {
|
|
25
|
+
if (!(err instanceof ApiError) || err.status !== 422)
|
|
26
|
+
return undefined;
|
|
27
|
+
const detail = err.detail;
|
|
28
|
+
if (typeof detail !== "object" || detail === null || !detail.message)
|
|
29
|
+
return undefined;
|
|
30
|
+
const parts = [detail.message];
|
|
31
|
+
if (detail.error_context) {
|
|
32
|
+
parts.push("", "----- Page state at failure (accessibility snapshot) -----", detail.error_context);
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
human: parts.join("\n"),
|
|
36
|
+
images: detail.screenshot_b64
|
|
37
|
+
? [{ data: detail.screenshot_b64, mimeType: sniffImageMime(detail.screenshot_b64) }]
|
|
38
|
+
: undefined,
|
|
39
|
+
exitCode: 1,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
8
42
|
// The concise human table for `tests list` — the full TestResponse is a 24-column
|
|
9
43
|
// firehose of internal ids that wraps unreadably in a normal terminal. `--wide`
|
|
10
44
|
// (and `--json`) still expose every field.
|
|
@@ -124,14 +158,22 @@ export const testCommands = [
|
|
|
124
158
|
if (!title)
|
|
125
159
|
throw new UsageError("--title is required");
|
|
126
160
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
161
|
+
try {
|
|
162
|
+
return {
|
|
163
|
+
data: await ctx.client.post(`${projectPath(workspaceId, projectId)}/tests`, {
|
|
164
|
+
title,
|
|
165
|
+
plan: readJsonFlag(input, "file"),
|
|
166
|
+
verify: !flagBool(input, "no-verify"),
|
|
167
|
+
description: flagStr(input, "description"),
|
|
168
|
+
}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
const failure = verifyFailureResult(err);
|
|
173
|
+
if (failure)
|
|
174
|
+
return failure;
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
135
177
|
},
|
|
136
178
|
},
|
|
137
179
|
{
|
|
@@ -227,8 +269,14 @@ export const testCommands = [
|
|
|
227
269
|
],
|
|
228
270
|
async run(ctx, input) {
|
|
229
271
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
272
|
+
const res = (await ctx.client.post(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/plan/recompile`, { json_plan: readJsonFlag(input, "file") }));
|
|
273
|
+
// Base64 in the JSON output would flood the caller's context — hand the
|
|
274
|
+
// failure screenshot over as image content instead.
|
|
275
|
+
const shot = typeof res.screenshot_b64 === "string" ? res.screenshot_b64 : undefined;
|
|
276
|
+
delete res.screenshot_b64;
|
|
230
277
|
return {
|
|
231
|
-
data:
|
|
278
|
+
data: res,
|
|
279
|
+
images: shot ? [{ data: shot, mimeType: sniffImageMime(shot) }] : undefined,
|
|
232
280
|
};
|
|
233
281
|
},
|
|
234
282
|
},
|
package/dist/local-run.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
|
|
5
|
-
// as an external rather than bundling it — the CLI's zero-runtime-dep
|
|
6
|
-
// install `playwright` is on PATH; otherwise fall back to `npx playwright`,
|
|
7
|
-
// cloud runner does.
|
|
4
|
+
import { PLAYWRIGHT_INSTALL_COMMANDS } from "./playwright-install.js";
|
|
5
|
+
// We invoke @playwright/test as an external rather than bundling it — the CLI's zero-runtime-dep
|
|
6
|
+
// rule. In a node_modules install `playwright` is on PATH; otherwise fall back to `npx playwright`,
|
|
7
|
+
// exactly as the cloud runner does. The install commands come from playwright-install.ts so the
|
|
8
|
+
// hint text and the actual installer never drift apart.
|
|
8
9
|
export const PLAYWRIGHT_INSTALL_HINT = "Local Playwright not found. Install it in this project, then re-run:\n" +
|
|
9
|
-
|
|
10
|
+
` ${PLAYWRIGHT_INSTALL_COMMANDS}`;
|
|
10
11
|
// Isolate the run from any playwright.config.ts in the customer's repo: a stray `testMatch`
|
|
11
12
|
// would exclude our spec (a zero-test run that reads as a false pass), and a `use.baseURL` /
|
|
12
13
|
// `use.storageState` / `globalSetup` there would silently retarget or reauth the run we mean
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
// The two commands that turn "nothing Playwright-related installed" into "local runs work":
|
|
5
|
+
// the test runner as a dev dep, then its browser binary. Kept as data so the CLI can both
|
|
6
|
+
// run them and print them verbatim for the non-interactive / copy-paste path.
|
|
7
|
+
export const INSTALL_TEST_RUNNER = ["npm", "i", "-D", "@playwright/test"];
|
|
8
|
+
export const INSTALL_CHROMIUM = ["npx", "playwright", "install", "chromium"];
|
|
9
|
+
export const PLAYWRIGHT_INSTALL_COMMANDS = `${INSTALL_TEST_RUNNER.join(" ")} && ${INSTALL_CHROMIUM.join(" ")}`;
|
|
10
|
+
// Resolve `@playwright/test` the way Playwright itself will at run time — from the project
|
|
11
|
+
// tree, not from wherever the globally-installed CLI happens to live. `createRequire` rooted
|
|
12
|
+
// at cwd walks up the same node_modules chain, so this is true iff a local run would find it.
|
|
13
|
+
export function hasPlaywrightTest(cwd) {
|
|
14
|
+
try {
|
|
15
|
+
createRequire(path.join(cwd, "package.json")).resolve("@playwright/test");
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export const INSTALL_PROMPT = `Install local Playwright now (${PLAYWRIGHT_INSTALL_COMMANDS})? [Y/n] `;
|
|
23
|
+
// Ask (default-yes) whether to install. Returns false — not throwing — when there is no answer
|
|
24
|
+
// or the prompt fails, so callers uniformly fall back to printing the install hint.
|
|
25
|
+
export async function confirmInstall(prompt) {
|
|
26
|
+
try {
|
|
27
|
+
return !/^n(o)?$/i.test(await prompt(INSTALL_PROMPT));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const withCmdExt = (cmd) => process.platform === "win32" && (cmd === "npm" || cmd === "npx") ? `${cmd}.cmd` : cmd;
|
|
34
|
+
function runInherit(command, args, cwd) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
// stdio inherited: an install is slow and the user wants to watch npm/browser-download
|
|
37
|
+
// progress live, exactly as if they'd typed it themselves.
|
|
38
|
+
const child = spawn(withCmdExt(command), [...args], { cwd, stdio: "inherit" });
|
|
39
|
+
child.on("error", reject);
|
|
40
|
+
child.on("close", (code) => code === 0
|
|
41
|
+
? resolve()
|
|
42
|
+
: reject(new Error(`\`${command} ${args.join(" ")}\` exited with code ${code}`)));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Install the local Playwright test runner + chromium into `cwd`, streaming each step's output.
|
|
47
|
+
* Throws if either step exits non-zero (so the caller surfaces the failure, not a silent partial
|
|
48
|
+
* install). Skips the `@playwright/test` step when it is already resolvable, but always ensures
|
|
49
|
+
* the browser binary — an installed runner with no browser still fails a real run.
|
|
50
|
+
*/
|
|
51
|
+
export async function installPlaywright(cwd, onStep) {
|
|
52
|
+
if (!hasPlaywrightTest(cwd)) {
|
|
53
|
+
onStep?.(`Installing @playwright/test — ${INSTALL_TEST_RUNNER.join(" ")}`);
|
|
54
|
+
await runInherit(INSTALL_TEST_RUNNER[0], INSTALL_TEST_RUNNER.slice(1), cwd);
|
|
55
|
+
}
|
|
56
|
+
onStep?.(`Installing the Chromium browser — ${INSTALL_CHROMIUM.join(" ")}`);
|
|
57
|
+
await runInherit(INSTALL_CHROMIUM[0], INSTALL_CHROMIUM.slice(1), cwd);
|
|
58
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beryl-so/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "Beryl on the command line — projects, runs, the exploring agent, and an MCP server over the same commands.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -34,9 +34,13 @@
|
|
|
34
34
|
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
+
"@playwright/test": "^1.61.1",
|
|
37
38
|
"@types/node": "^26.1.1",
|
|
38
39
|
"tsx": "^4.23.1",
|
|
39
40
|
"typescript": "^7.0.2",
|
|
40
41
|
"vitest": "^4.1.10"
|
|
42
|
+
},
|
|
43
|
+
"overrides": {
|
|
44
|
+
"@hono/node-server": "2.0.11"
|
|
41
45
|
}
|
|
42
46
|
}
|