@beryl-so/cli 0.17.0 → 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 +35 -7
- package/dist/adapters/cli.js +15 -1
- package/dist/adapters/mcp.js +44 -5
- package/dist/beryl-test-skill.js +265 -87
- package/dist/commands/accounts.js +331 -0
- 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 +0 -6
- package/dist/commands/runs.js +63 -22
- package/dist/commands/tests.js +6 -3
- package/dist/email-pump.js +5 -2
- package/dist/http.js +4 -1
- package/dist/local-exec.js +42 -20
- package/dist/playwright-install.js +118 -7
- package/dist/registry/index.js +4 -2
- package/dist/schema.generated.js +32 -0
- package/package.json +1 -1
- package/dist/commands/inboxes.js +0 -145
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",
|
|
@@ -93,11 +93,6 @@ export const projectCommands = [
|
|
|
93
93
|
description: "Whether the site needs a login (gated) or not (public). Default: detected from the " +
|
|
94
94
|
"site — only asked when detection is genuinely unsure",
|
|
95
95
|
},
|
|
96
|
-
{
|
|
97
|
-
name: "allow-mutations",
|
|
98
|
-
type: "boolean",
|
|
99
|
-
description: "Let the agent perform state-changing actions while exploring",
|
|
100
|
-
},
|
|
101
96
|
{ name: "force-new-login", type: "boolean", description: "Ignore any reusable saved login" },
|
|
102
97
|
{
|
|
103
98
|
name: "no-explore",
|
|
@@ -141,7 +136,6 @@ export const projectCommands = [
|
|
|
141
136
|
const created = (await ctx.client.post(`/workspaces/${ws}/projects`, {
|
|
142
137
|
root_url: url,
|
|
143
138
|
requires_auth_choice: auth,
|
|
144
|
-
mutation_choice: flagBool(input, "allow-mutations") ? "allow_mutations" : null,
|
|
145
139
|
force_new_login: flagBool(input, "force-new-login"),
|
|
146
140
|
skip_exploration: noExplore,
|
|
147
141
|
}));
|
package/dist/commands/runs.js
CHANGED
|
@@ -5,11 +5,33 @@ import { CliError, UsageError } from "../errors.js";
|
|
|
5
5
|
import { buildImportForm, executeLocalSpec, toRunEntry, } from "../local-exec.js";
|
|
6
6
|
import { countPlannedFrames, countWrittenFrames, PlaywrightMissingError, } from "../local-run.js";
|
|
7
7
|
import { dim, green, red, yellow } from "../output.js";
|
|
8
|
-
import { confirmInstall, installPlaywright } from "../playwright-install.js";
|
|
8
|
+
import { anyGap, confirmInstall, describeGaps, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
|
|
9
9
|
import { ProgressBar } from "../progress.js";
|
|
10
10
|
import { arg, flagBool, flagNum, flagStr, projectPath } from "./util.js";
|
|
11
11
|
import { watchRun } from "./watch.js";
|
|
12
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
|
+
}
|
|
13
35
|
export function parseHeaders(raw) {
|
|
14
36
|
if (!raw || raw.length === 0)
|
|
15
37
|
return null;
|
|
@@ -75,20 +97,24 @@ export const runCommands = [
|
|
|
75
97
|
name: "runs local",
|
|
76
98
|
summary: "Run tests on your machine with your own Playwright; results sync to Beryl",
|
|
77
99
|
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
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
"
|
|
82
|
-
"
|
|
83
|
-
"
|
|
84
|
-
"
|
|
85
|
-
"
|
|
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 " +
|
|
86
110
|
"(trigger source `local`) — history, replay, and reports all work; pass --no-sync to " +
|
|
87
|
-
"keep a run entirely off the record while iterating.
|
|
88
|
-
"
|
|
89
|
-
"
|
|
90
|
-
"
|
|
91
|
-
"
|
|
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.",
|
|
92
118
|
scope: "project",
|
|
93
119
|
args: [
|
|
94
120
|
{
|
|
@@ -129,6 +155,7 @@ export const runCommands = [
|
|
|
129
155
|
"beryl runs local 4f… --no-sync --dir ./beryl-local",
|
|
130
156
|
],
|
|
131
157
|
async run(ctx, input) {
|
|
158
|
+
await ensureRunnableLocally(ctx);
|
|
132
159
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
133
160
|
// Deduped: a repeated id would put the same test twice in one imported run,
|
|
134
161
|
// which the import manifest rejects.
|
|
@@ -180,21 +207,25 @@ export const runCommands = [
|
|
|
180
207
|
requiresAuth: Boolean(script.requires_auth),
|
|
181
208
|
usesEmail: /__vmInbox\(|__vmAwaitEmail\(/.test(script.content),
|
|
182
209
|
usesLoginPassword: Boolean(script.uses_login_password),
|
|
210
|
+
inlinedLoginSteps: script.inlined_login_steps ?? 0,
|
|
211
|
+
...(script.email ? { email: script.email } : {}),
|
|
183
212
|
...(script.login_config_error ? { loginConfigError: script.login_config_error } : {}),
|
|
184
213
|
});
|
|
185
214
|
}
|
|
186
215
|
if (specs.length === 0)
|
|
187
216
|
throw new CliError("No runnable tests were found.");
|
|
188
|
-
// A
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
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.
|
|
192
223
|
const skipped = specs.filter((s) => s.requiresAuth || s.loginConfigError);
|
|
193
224
|
const runnable = specs.filter((s) => !s.requiresAuth && !s.loginConfigError);
|
|
194
225
|
for (const s of skipped) {
|
|
195
226
|
ctx.err(yellow(s.requiresAuth
|
|
196
|
-
? `! ${s.title}:
|
|
197
|
-
`
|
|
227
|
+
? `! ${s.title}: needs a session only Beryl's cloud holds — skipped ` +
|
|
228
|
+
`(use \`beryl runs trigger\`).`
|
|
198
229
|
: `! ${s.title}: skipped — ${s.loginConfigError}`));
|
|
199
230
|
}
|
|
200
231
|
if (runnable.length === 0)
|
|
@@ -235,14 +266,24 @@ export const runCommands = [
|
|
|
235
266
|
const testStarted = new Date().toISOString();
|
|
236
267
|
let outcome;
|
|
237
268
|
let runError;
|
|
269
|
+
let lastStep = 0;
|
|
238
270
|
const attempt = () => executeLocalSpec({ client: ctx.client, workspaceId, projectId }, {
|
|
239
271
|
spec,
|
|
240
272
|
dir: dir ? path.join(dir, spec.id) : undefined,
|
|
241
273
|
harvest: sync,
|
|
242
274
|
loginPassword,
|
|
243
|
-
|
|
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
|
+
},
|
|
244
282
|
onSpawn: (runDir) => {
|
|
245
|
-
const ticker = setInterval(() =>
|
|
283
|
+
const ticker = setInterval(() => {
|
|
284
|
+
lastStep = countWrittenFrames(runDir);
|
|
285
|
+
bar.update({ ...state(), step: lastStep });
|
|
286
|
+
}, 300);
|
|
246
287
|
return () => clearInterval(ticker);
|
|
247
288
|
},
|
|
248
289
|
});
|
package/dist/commands/tests.js
CHANGED
|
@@ -162,9 +162,11 @@ export const testCommands = [
|
|
|
162
162
|
"terminal the CLI offers to install it). " +
|
|
163
163
|
"A red replay banks NOTHING: the failure evidence comes back (over MCP the screenshot is " +
|
|
164
164
|
"image content), you fix the plan file and re-run. The proving run is imported as the " +
|
|
165
|
-
"test's first run (--no-sync to skip). A plan that signs in with a
|
|
166
|
-
"replay locally (
|
|
167
|
-
"verification automatically.
|
|
165
|
+
"test's first run (--no-sync to skip). A plan that signs in with a session Beryl captured " +
|
|
166
|
+
"server-side cannot replay locally (that session never leaves Beryl's cloud) — it falls " +
|
|
167
|
+
"back to server-side verification automatically. A session-mode plan replays locally " +
|
|
168
|
+
"fine: the server renders it with its account's stored sign-in steps in front, so " +
|
|
169
|
+
"the same identity is exercised on your machine. Optional `before` and `after` arrays hold setup and teardown " +
|
|
168
170
|
"steps: `after` runs even when a main step fails, which is how a create/update/delete test " +
|
|
169
171
|
"cleans up the record it made on the runs that go red.",
|
|
170
172
|
scope: "project",
|
|
@@ -279,6 +281,7 @@ export const testCommands = [
|
|
|
279
281
|
requiresAuth: false,
|
|
280
282
|
usesEmail: /__vmInbox\(|__vmAwaitEmail\(/.test(content),
|
|
281
283
|
usesLoginPassword: compiled.uses_login_password,
|
|
284
|
+
email: compiled.email ?? undefined,
|
|
282
285
|
});
|
|
283
286
|
const startedAt = new Date().toISOString();
|
|
284
287
|
const attempts = 1 + Math.max(0, compiled.policy.error_retries);
|
package/dist/email-pump.js
CHANGED
|
@@ -10,14 +10,17 @@ export function startEmailPump(opts) {
|
|
|
10
10
|
let stopped = false;
|
|
11
11
|
const served = new Set();
|
|
12
12
|
const consumed = new Set();
|
|
13
|
-
let
|
|
13
|
+
let storedAfter = opts.storedAfter;
|
|
14
|
+
let since;
|
|
14
15
|
const fetchLatest = async (req, timeoutS) => {
|
|
15
16
|
try {
|
|
16
|
-
return (await opts.client.get(`/workspaces/${opts.workspaceId}/
|
|
17
|
+
return (await opts.client.get(`/workspaces/${opts.workspaceId}/mailboxes/${opts.inboxId}/emails/latest`, {
|
|
17
18
|
timeout_s: timeoutS,
|
|
18
19
|
since,
|
|
20
|
+
stored_after: storedAfter,
|
|
19
21
|
from_contains: req.from_contains,
|
|
20
22
|
subject_contains: req.subject_contains,
|
|
23
|
+
recipient_contains: opts.recipientContains,
|
|
21
24
|
}));
|
|
22
25
|
}
|
|
23
26
|
catch (err) {
|
package/dist/http.js
CHANGED
|
@@ -52,7 +52,10 @@ export class ApiClient {
|
|
|
52
52
|
}
|
|
53
53
|
if (response.status === 401) {
|
|
54
54
|
throw new AuthError(this.token
|
|
55
|
-
? "Authentication failed — the token is invalid, expired, or revoked. Run
|
|
55
|
+
? "Authentication failed — the token is invalid, expired, or revoked. Run " +
|
|
56
|
+
"`beryl login`. If you just did, and this is an MCP server started with an " +
|
|
57
|
+
"explicit BERYL_API_KEY/BERYL_TOKEN, that env var wins over the login — " +
|
|
58
|
+
"re-register the server to pick up the new token."
|
|
56
59
|
: "Not logged in. Run `beryl login` or set BERYL_API_KEY.");
|
|
57
60
|
}
|
|
58
61
|
if (!response.ok) {
|
package/dist/local-exec.js
CHANGED
|
@@ -7,6 +7,7 @@ import { PlaywrightMissingError, runSpecLocally, } from "./local-run.js";
|
|
|
7
7
|
export const IMPORT_MAX_ERROR_LEN = 5000;
|
|
8
8
|
export const IMPORT_MAX_FILE_BYTES = 14 * 1024 * 1024;
|
|
9
9
|
export const IMPORT_MAX_TOTAL_BYTES = 180 * 1024 * 1024;
|
|
10
|
+
export const IMPORT_MAX_FILES = 3600;
|
|
10
11
|
export function toRunEntry(spec, outcome, runError, startedAt, ordinal, redact) {
|
|
11
12
|
// Mirrors the cloud runner's redact_result: the spec types the secret into the
|
|
12
13
|
// page, so error text and the DOM snapshot can echo it back — scrub before the
|
|
@@ -51,14 +52,19 @@ export function toRunEntry(spec, outcome, runError, startedAt, ordinal, redact)
|
|
|
51
52
|
return entry;
|
|
52
53
|
// A frame over the per-file cap would force dropping mid-list and shift the
|
|
53
54
|
// index-aligned url/duration sidecars — drop the whole filmstrip instead.
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
// Drop the inlined sign-in's frames before anything is index-aligned to the stored
|
|
56
|
+
// plan: `runs explain` zips the plan's steps against these lists, so an off-by-K here
|
|
57
|
+
// shows every step's screenshot next to the wrong step.
|
|
58
|
+
const skip = Math.min(spec.inlinedLoginSteps ?? 0, harvested.frames.length);
|
|
59
|
+
const frames = harvested.frames.slice(skip);
|
|
60
|
+
if (frames.every((f) => f.length <= IMPORT_MAX_FILE_BYTES)) {
|
|
61
|
+
frames.forEach((bytes, i) => {
|
|
56
62
|
const name = `r${ordinal}-frame-${String(i).padStart(3, "0")}.png`;
|
|
57
63
|
entry.frames.push(name);
|
|
58
64
|
entry.files.push({ name, bytes });
|
|
59
65
|
});
|
|
60
|
-
entry.frame_urls = harvested.frameUrls;
|
|
61
|
-
entry.frame_durations_ms = harvested.frameDurationsMs;
|
|
66
|
+
entry.frame_urls = harvested.frameUrls.slice(skip);
|
|
67
|
+
entry.frame_durations_ms = harvested.frameDurationsMs.slice(skip);
|
|
62
68
|
}
|
|
63
69
|
if (harvested.screenshot && harvested.screenshot.length <= IMPORT_MAX_FILE_BYTES) {
|
|
64
70
|
entry.screenshot = `r${ordinal}-screenshot.png`;
|
|
@@ -90,6 +96,22 @@ export function buildImportForm(entries, opts) {
|
|
|
90
96
|
total -= frameBytes;
|
|
91
97
|
opts.onNote?.(`replay frames for test ${entry.test_case_id} dropped (upload size cap)`);
|
|
92
98
|
}
|
|
99
|
+
// File-count budget: the server parses at most RUN_IMPORT_MAX_FILES multipart
|
|
100
|
+
// parts, so a big suite's filmstrips can overflow on count with bytes to spare.
|
|
101
|
+
let fileCount = entries.reduce((n, e) => n + e.files.length, 0);
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
if (fileCount <= IMPORT_MAX_FILES)
|
|
104
|
+
break;
|
|
105
|
+
const frameFiles = entry.files.filter((f) => entry.frames.includes(f.name)).length;
|
|
106
|
+
if (frameFiles === 0)
|
|
107
|
+
continue;
|
|
108
|
+
entry.files = entry.files.filter((f) => !entry.frames.includes(f.name));
|
|
109
|
+
entry.frames = [];
|
|
110
|
+
entry.frame_urls = [];
|
|
111
|
+
entry.frame_durations_ms = [];
|
|
112
|
+
fileCount -= frameFiles;
|
|
113
|
+
opts.onNote?.(`replay frames for test ${entry.test_case_id} dropped (upload file-count cap)`);
|
|
114
|
+
}
|
|
93
115
|
const manifest = {
|
|
94
116
|
environment_id: opts.environmentId ?? null,
|
|
95
117
|
target_url_override: opts.targetUrlOverride ?? null,
|
|
@@ -108,9 +130,8 @@ export function buildImportForm(entries, opts) {
|
|
|
108
130
|
return form;
|
|
109
131
|
}
|
|
110
132
|
/**
|
|
111
|
-
* Run one rendered spec on this machine with its full service harness:
|
|
112
|
-
* inbox
|
|
113
|
-
* inbox/login sidecars, pump `await_email` requests over the API while Playwright
|
|
133
|
+
* Run one rendered spec on this machine with its full service harness: write the
|
|
134
|
+
* inbox/login sidecars and pump `await_email` requests over the API while Playwright
|
|
114
135
|
* runs. The one local-execution seam shared by `runs local` and the local-verify
|
|
115
136
|
* `tests create`. {@link PlaywrightMissingError} propagates (environmental — the
|
|
116
137
|
* caller decides whether to offer an install); any other throw is captured as
|
|
@@ -118,11 +139,18 @@ export function buildImportForm(entries, opts) {
|
|
|
118
139
|
*/
|
|
119
140
|
export async function executeLocalSpec(deps, opts) {
|
|
120
141
|
const { spec } = opts;
|
|
121
|
-
const inbox = spec.
|
|
122
|
-
?
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
142
|
+
const inbox = spec.email
|
|
143
|
+
? {
|
|
144
|
+
id: spec.email.inbox_id,
|
|
145
|
+
address: spec.email.address,
|
|
146
|
+
recipientContains: spec.email.recipient_contains ?? undefined,
|
|
147
|
+
// NOW, not the mailbox's creation time: it already holds every earlier run's
|
|
148
|
+
// mail, and an older code served as this run's would pass the step with a value
|
|
149
|
+
// the app then rejects. Sent as stored_after, which fences on the server's own
|
|
150
|
+
// INGESTION time and is clamped server-side — `since` compares against the
|
|
151
|
+
// sender's Date: header, so a fast local clock would fence out mail that arrived.
|
|
152
|
+
storedAfter: new Date().toISOString(),
|
|
153
|
+
}
|
|
126
154
|
: undefined;
|
|
127
155
|
try {
|
|
128
156
|
const outcome = await runSpecLocally({
|
|
@@ -146,7 +174,8 @@ export async function executeLocalSpec(deps, opts) {
|
|
|
146
174
|
client: deps.client,
|
|
147
175
|
workspaceId: deps.workspaceId,
|
|
148
176
|
inboxId: inbox.id,
|
|
149
|
-
|
|
177
|
+
storedAfter: inbox.storedAfter,
|
|
178
|
+
recipientContains: inbox.recipientContains,
|
|
150
179
|
onEvent: opts.onEvent,
|
|
151
180
|
})
|
|
152
181
|
: undefined;
|
|
@@ -166,11 +195,4 @@ export async function executeLocalSpec(deps, opts) {
|
|
|
166
195
|
// caller records, not an aborted command.
|
|
167
196
|
return { runError: err instanceof Error ? err.message : String(err) };
|
|
168
197
|
}
|
|
169
|
-
finally {
|
|
170
|
-
if (inbox) {
|
|
171
|
-
await deps.client
|
|
172
|
-
.del(`/workspaces/${deps.workspaceId}/inboxes/${inbox.id}`)
|
|
173
|
-
.catch(() => undefined);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
198
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
2
3
|
import { createRequire } from "node:module";
|
|
4
|
+
import os from "node:os";
|
|
3
5
|
import path from "node:path";
|
|
4
6
|
// The two commands that turn "nothing Playwright-related installed" into "local runs work":
|
|
5
7
|
// the test runner as a dev dep, then its browser binary. Kept as data so the CLI can both
|
|
@@ -7,24 +9,133 @@ import path from "node:path";
|
|
|
7
9
|
export const INSTALL_TEST_RUNNER = ["npm", "i", "-D", "@playwright/test"];
|
|
8
10
|
export const INSTALL_CHROMIUM = ["npx", "playwright", "install", "chromium"];
|
|
9
11
|
export const PLAYWRIGHT_INSTALL_COMMANDS = `${INSTALL_TEST_RUNNER.join(" ")} && ${INSTALL_CHROMIUM.join(" ")}`;
|
|
10
|
-
// Resolve
|
|
11
|
-
//
|
|
12
|
-
|
|
12
|
+
// Resolve from the project tree, not from wherever the globally-installed CLI happens to
|
|
13
|
+
// live — `createRequire` rooted at cwd walks up the same node_modules chain Playwright will.
|
|
14
|
+
const projectRequire = (cwd) => createRequire(path.join(cwd, "package.json"));
|
|
13
15
|
export function hasPlaywrightTest(cwd) {
|
|
14
16
|
try {
|
|
15
|
-
|
|
17
|
+
projectRequire(cwd).resolve("@playwright/test");
|
|
16
18
|
return true;
|
|
17
19
|
}
|
|
18
20
|
catch {
|
|
19
21
|
return false;
|
|
20
22
|
}
|
|
21
23
|
}
|
|
22
|
-
|
|
24
|
+
// A local run launches chromium headless, which Playwright serves from a separate
|
|
25
|
+
// `chromium_headless_shell` build (`playwright install chromium` fetches both). That is the
|
|
26
|
+
// only engine a run ever launches, so it is the only one we install or check for.
|
|
27
|
+
const HEADLESS_SHELL = "chromium-headless-shell";
|
|
28
|
+
// Written last by Playwright's downloader, so its presence means a complete browser.
|
|
29
|
+
const INSTALL_MARKER = "INSTALLATION_COMPLETE";
|
|
30
|
+
function playwrightCoreDir(cwd) {
|
|
31
|
+
const req = projectRequire(cwd);
|
|
32
|
+
try {
|
|
33
|
+
return path.dirname(req.resolve("playwright-core/package.json"));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Not hoisted (pnpm) — look from @playwright/test's own tree instead.
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const fromRunner = createRequire(req.resolve("@playwright/test"));
|
|
40
|
+
return path.dirname(fromRunner.resolve("playwright-core/package.json"));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Mirrors playwright-core's registry root: PLAYWRIGHT_BROWSERS_PATH ("0" means inside the
|
|
47
|
+
// package), else the per-platform cache dir. undefined on a platform Playwright doesn't
|
|
48
|
+
// support, where we have no verdict to offer.
|
|
49
|
+
function browsersRoot(coreDir) {
|
|
50
|
+
const override = process.env.PLAYWRIGHT_BROWSERS_PATH;
|
|
51
|
+
if (override === "0")
|
|
52
|
+
return coreDir ? path.join(coreDir, ".local-browsers") : undefined;
|
|
53
|
+
if (override)
|
|
54
|
+
return path.resolve(override);
|
|
55
|
+
const home = os.homedir();
|
|
56
|
+
if (process.platform === "darwin")
|
|
57
|
+
return path.join(home, "Library", "Caches", "ms-playwright");
|
|
58
|
+
if (process.platform === "win32") {
|
|
59
|
+
const local = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
|
|
60
|
+
return path.join(local, "ms-playwright");
|
|
61
|
+
}
|
|
62
|
+
if (process.platform === "linux") {
|
|
63
|
+
return path.join(process.env.XDG_CACHE_HOME || path.join(home, ".cache"), "ms-playwright");
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
// The exact directory this project's Playwright will look in: <root>/<browser name with
|
|
68
|
+
// dashes as underscores>-<revision>, straight out of playwright-core's browsers.json — so a
|
|
69
|
+
// browser downloaded for an older Playwright doesn't read as the one this one needs.
|
|
70
|
+
function wantedChromiumDir(coreDir) {
|
|
71
|
+
if (!coreDir)
|
|
72
|
+
return undefined;
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(coreDir, "browsers.json"), "utf8"));
|
|
75
|
+
const entry = parsed.browsers?.find((b) => b.name === HEADLESS_SHELL) ??
|
|
76
|
+
parsed.browsers?.find((b) => b.name === "chromium");
|
|
77
|
+
if (!entry?.name || !entry.revision)
|
|
78
|
+
return undefined;
|
|
79
|
+
return `${entry.name.replace(/-/g, "_")}-${entry.revision}`;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const isComplete = (dir) => fs.existsSync(path.join(dir, INSTALL_MARKER));
|
|
86
|
+
/**
|
|
87
|
+
* Is the browser binary a local run actually launches present? Without it every test in a
|
|
88
|
+
* run dies with the same `browserType.launch: Executable doesn't exist` — an environment
|
|
89
|
+
* problem that reads as a broken suite.
|
|
90
|
+
*/
|
|
91
|
+
export function hasChromiumBrowser(cwd) {
|
|
92
|
+
const coreDir = playwrightCoreDir(cwd);
|
|
93
|
+
const root = browsersRoot(coreDir);
|
|
94
|
+
// Nowhere known to look — say nothing rather than block; the run surfaces Playwright's
|
|
95
|
+
// own error if it really is missing.
|
|
96
|
+
if (!root)
|
|
97
|
+
return true;
|
|
98
|
+
const wanted = wantedChromiumDir(coreDir);
|
|
99
|
+
if (wanted)
|
|
100
|
+
return isComplete(path.join(root, wanted));
|
|
101
|
+
// Revision unknown (no resolvable playwright-core): any completed chromium build is the
|
|
102
|
+
// best evidence there is, and guessing wrong only costs an idempotent re-install.
|
|
103
|
+
try {
|
|
104
|
+
return fs
|
|
105
|
+
.readdirSync(root)
|
|
106
|
+
.some((d) => /^chromium(_headless_shell)?-\d+$/.test(d) && isComplete(path.join(root, d)));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export function playwrightGaps(cwd) {
|
|
113
|
+
const runner = !hasPlaywrightTest(cwd);
|
|
114
|
+
// With no runner there is no revision to judge a cached browser against, so the honest
|
|
115
|
+
// answer is the full install — not the npm half and a second failure right after it.
|
|
116
|
+
return { runner, browser: runner || !hasChromiumBrowser(cwd) };
|
|
117
|
+
}
|
|
118
|
+
export const anyGap = (gaps) => gaps.runner || gaps.browser;
|
|
119
|
+
export function describeGaps(gaps) {
|
|
120
|
+
if (gaps.runner && gaps.browser)
|
|
121
|
+
return "@playwright/test + the Chromium browser";
|
|
122
|
+
return gaps.browser ? "the Chromium browser" : "@playwright/test";
|
|
123
|
+
}
|
|
124
|
+
/** Only the commands the missing halves need — a present @playwright/test isn't reinstalled. */
|
|
125
|
+
export function installCommandsFor(gaps) {
|
|
126
|
+
const commands = [
|
|
127
|
+
...(gaps.runner ? [INSTALL_TEST_RUNNER.join(" ")] : []),
|
|
128
|
+
...(gaps.browser ? [INSTALL_CHROMIUM.join(" ")] : []),
|
|
129
|
+
];
|
|
130
|
+
return commands.length > 0 ? commands.join(" && ") : PLAYWRIGHT_INSTALL_COMMANDS;
|
|
131
|
+
}
|
|
132
|
+
export const installPrompt = (commands) => `Install local Playwright now (${commands})? [Y/n] `;
|
|
133
|
+
export const INSTALL_PROMPT = installPrompt(PLAYWRIGHT_INSTALL_COMMANDS);
|
|
23
134
|
// Ask (default-yes) whether to install. Returns false — not throwing — when there is no answer
|
|
24
135
|
// or the prompt fails, so callers uniformly fall back to printing the install hint.
|
|
25
|
-
export async function confirmInstall(prompt) {
|
|
136
|
+
export async function confirmInstall(prompt, commands = PLAYWRIGHT_INSTALL_COMMANDS) {
|
|
26
137
|
try {
|
|
27
|
-
return !/^n(o)?$/i.test(await prompt(
|
|
138
|
+
return !/^n(o)?$/i.test(await prompt(installPrompt(commands)));
|
|
28
139
|
}
|
|
29
140
|
catch {
|
|
30
141
|
return false;
|
package/dist/registry/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { accountCommands } from "../commands/account.js";
|
|
2
|
+
import { testAccountCommands } from "../commands/accounts.js";
|
|
2
3
|
import { authCommands } from "../commands/auth.js";
|
|
3
4
|
import { configCommands } from "../commands/config-vars.js";
|
|
4
5
|
import { environmentCommands } from "../commands/environments.js";
|
|
5
6
|
import { explorationCommands } from "../commands/explorations.js";
|
|
6
|
-
import {
|
|
7
|
+
import { mailboxCommands } from "../commands/mailboxes.js";
|
|
7
8
|
import { initCommands } from "../commands/init.js";
|
|
8
9
|
import { mcpCommands } from "../commands/mcp.js";
|
|
9
10
|
import { projectCommands } from "../commands/projects.js";
|
|
@@ -61,7 +62,8 @@ export const commands = [
|
|
|
61
62
|
...explorationCommands,
|
|
62
63
|
...configCommands,
|
|
63
64
|
...slackCommands,
|
|
64
|
-
...
|
|
65
|
+
...mailboxCommands,
|
|
66
|
+
...testAccountCommands,
|
|
65
67
|
...accountCommands,
|
|
66
68
|
...mcpCommands,
|
|
67
69
|
].map(withScopeFlags).map(hideFromMcp);
|
package/dist/schema.generated.js
CHANGED
|
@@ -25,6 +25,15 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
25
25
|
"title": "ActionType",
|
|
26
26
|
"type": "string"
|
|
27
27
|
},
|
|
28
|
+
"AuthMode": {
|
|
29
|
+
"description": "How an authenticated test reaches its logged-in start state. The AUTHOR sets\nthis, explicitly, on every plan that sets ``requires_auth`` \u2014 a submitted plan\nthat omits it is rejected, because the wrong guess banks a plan that runs logged\nout and fails somewhere misleading.\n\n``inline`` \u2014 the plan signs itself in: its own steps fill ``{{login_email}}`` /\n``{{login_password}}``, or it rides the project's captured session.\n\n``session`` \u2014 the test's account signs in ONCE per run, ahead of the tests; the\nresulting browser session is proved against a fresh context and then handed to every\nsession-mode test in the run. The plan itself carries NO sign-in steps, so it starts\nwhere the flow it actually tests begins. Requires the account to have a stored\nlogin plan (``accounts set-login``).",
|
|
30
|
+
"enum": [
|
|
31
|
+
"inline",
|
|
32
|
+
"session"
|
|
33
|
+
],
|
|
34
|
+
"title": "AuthMode",
|
|
35
|
+
"type": "string"
|
|
36
|
+
},
|
|
28
37
|
"DialogChoice": {
|
|
29
38
|
"enum": [
|
|
30
39
|
"accept",
|
|
@@ -865,6 +874,7 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
865
874
|
"inbox_address",
|
|
866
875
|
"login_email",
|
|
867
876
|
"login_password",
|
|
877
|
+
"mailbox_address",
|
|
868
878
|
"timestamp",
|
|
869
879
|
"unique",
|
|
870
880
|
"uuid"
|
|
@@ -1130,6 +1140,24 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
1130
1140
|
"$id": "https://api.beryl.so/api/v1/schemas/action-plan.schema.json",
|
|
1131
1141
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
1132
1142
|
"allOf": [
|
|
1143
|
+
{
|
|
1144
|
+
"$comment": "requires_auth demands an explicit auth_mode \u2014 there is no default. 'inline' when the plan signs itself in, 'session' when it rides its account's once-per-run session and carries no sign-in steps.",
|
|
1145
|
+
"if": {
|
|
1146
|
+
"properties": {
|
|
1147
|
+
"requires_auth": {
|
|
1148
|
+
"const": true
|
|
1149
|
+
}
|
|
1150
|
+
},
|
|
1151
|
+
"required": [
|
|
1152
|
+
"requires_auth"
|
|
1153
|
+
]
|
|
1154
|
+
},
|
|
1155
|
+
"then": {
|
|
1156
|
+
"required": [
|
|
1157
|
+
"auth_mode"
|
|
1158
|
+
]
|
|
1159
|
+
}
|
|
1160
|
+
},
|
|
1133
1161
|
{
|
|
1134
1162
|
"$comment": "The first executed step must be a goto, so the test loads a page before acting (that is before[0] when there is a setup section, else steps[0]).",
|
|
1135
1163
|
"else": {
|
|
@@ -1242,6 +1270,10 @@ export const ACTION_PLAN_SCHEMA = {
|
|
|
1242
1270
|
"default": null,
|
|
1243
1271
|
"title": "Auth Label"
|
|
1244
1272
|
},
|
|
1273
|
+
"auth_mode": {
|
|
1274
|
+
"$ref": "#/$defs/AuthMode",
|
|
1275
|
+
"default": "inline"
|
|
1276
|
+
},
|
|
1245
1277
|
"before": {
|
|
1246
1278
|
"items": {
|
|
1247
1279
|
"$ref": "#/$defs/PlanStep"
|
package/package.json
CHANGED