@beryl-so/cli 0.67.0 → 0.77.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -14
- package/dist/adapters/mcp.js +6 -4
- package/dist/beryl-test-skill.js +129 -107
- package/dist/commands/accounts.js +92 -10
- package/dist/commands/config-vars.js +87 -16
- package/dist/commands/environments.js +18 -1
- package/dist/commands/init.js +42 -108
- package/dist/commands/jobs.js +129 -0
- package/dist/commands/projects.js +68 -45
- package/dist/commands/runs.js +5 -432
- package/dist/commands/tests.js +34 -409
- package/dist/commands/third-party-integrations.js +26 -3
- package/dist/commands/watch.js +36 -0
- package/dist/lint.js +120 -6
- package/dist/registry/index.js +2 -0
- package/dist/schema.generated.js +152 -27
- package/dist/skill-tables.js +3 -0
- package/package.json +2 -3
- package/dist/email-pump.js +0 -138
- package/dist/local-exec.js +0 -239
- package/dist/local-run.js +0 -340
- package/dist/playwright-install.js +0 -220
- package/dist/progress.js +0 -44
package/dist/commands/runs.js
CHANGED
|
@@ -1,38 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
2
|
import { downloadRunArtifacts, failureImages, isFailing, resultsOf, } from "../artifacts.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { buildImportForm, establishAccountSession, executeLocalSpec, IMPORT_MAX_ERROR_LEN, specUsesThirdParty, THIRD_PARTY_USE_TRIGGER, toRunEntry, } from "../local-exec.js";
|
|
7
|
-
import { countPlannedFrames, countWrittenFrames, PlaywrightMissingError, } from "../local-run.js";
|
|
8
|
-
import { dim, green, red, yellow } from "../output.js";
|
|
9
|
-
import { anyGap, confirmInstall, describeGaps, installCommandsFor, installHint, installPlaywright, playwrightGaps, } from "../playwright-install.js";
|
|
10
|
-
import { ProgressBar } from "../progress.js";
|
|
3
|
+
import { UsageError } from "../errors.js";
|
|
4
|
+
import { dim, yellow } from "../output.js";
|
|
11
5
|
import { arg, flagBool, flagNum, flagStr, idsInGroup, projectPath } from "./util.js";
|
|
12
6
|
import { watchRun } from "./watch.js";
|
|
13
7
|
const MAX_FAILURE_SCREENSHOTS = 5;
|
|
14
|
-
// One filesystem check before a single spec is fetched. A missing browser binary otherwise
|
|
15
|
-
// fails EVERY test with the same launch error, which reads as a broken suite instead of a
|
|
16
|
-
// box that can't run tests. On a TTY it's an offer; over MCP / CI it's the exact commands.
|
|
17
|
-
async function ensureRunnableLocally(ctx) {
|
|
18
|
-
const gaps = playwrightGaps(process.cwd());
|
|
19
|
-
if (!anyGap(gaps))
|
|
20
|
-
return;
|
|
21
|
-
const commands = installCommandsFor(gaps, process.cwd());
|
|
22
|
-
const hint = installHint(process.cwd(), gaps);
|
|
23
|
-
if (!ctx.interactive || !(await confirmInstall(ctx.prompt, commands)))
|
|
24
|
-
throw new CliError(hint);
|
|
25
|
-
try {
|
|
26
|
-
await installPlaywright(process.cwd(), (line) => ctx.err(dim(line)));
|
|
27
|
-
}
|
|
28
|
-
catch (err) {
|
|
29
|
-
throw new CliError(`${err instanceof Error ? err.message : String(err)}\n\nFinish it by hand:\n ${commands}`);
|
|
30
|
-
}
|
|
31
|
-
const left = playwrightGaps(process.cwd());
|
|
32
|
-
if (anyGap(left))
|
|
33
|
-
throw new CliError(hint);
|
|
34
|
-
ctx.err(green(`✓ ${describeGaps(gaps)} installed`));
|
|
35
|
-
}
|
|
36
8
|
export function parseHeaders(raw) {
|
|
37
9
|
if (!raw || raw.length === 0)
|
|
38
10
|
return null;
|
|
@@ -131,406 +103,6 @@ export const runCommands = [
|
|
|
131
103
|
});
|
|
132
104
|
},
|
|
133
105
|
},
|
|
134
|
-
{
|
|
135
|
-
name: "runs local",
|
|
136
|
-
summary: "Run tests on your machine with Playwright; results sync to Beryl",
|
|
137
|
-
description: "Unlike `runs trigger`, the browser runs on YOUR machine: each test's rendered spec is " +
|
|
138
|
-
"fetched and run with the Playwright runner bundled in the CLI (your repo's own " +
|
|
139
|
-
"@playwright/test wins when present) and the Chromium binary it drives. " +
|
|
140
|
-
"Both are checked once before any spec is fetched, so a machine that can't run tests " +
|
|
141
|
-
"says so once instead of failing every test (on a terminal the CLI offers to install " +
|
|
142
|
-
"the missing browser; over MCP it prints the exact install command). " +
|
|
143
|
-
"Signup/OTP flows work: the CLI answers the spec's await_email steps over the API " +
|
|
144
|
-
"against the same mailbox the cloud runner uses. Authenticated tests work too: for a plan " +
|
|
145
|
-
"that signs itself in with {{login_email}}/{{login_password}}, the email is baked into " +
|
|
146
|
-
"the fetched spec and the password is fetched " +
|
|
147
|
-
"once over the logged secret-reveal route, handed to the spec the way the cloud runner " +
|
|
148
|
-
"does, and scrubbed from any error text or DOM snapshot before results upload; a test " +
|
|
149
|
-
"whose account has no stored password is skipped with the exact fix-it command. When the " +
|
|
150
|
-
"run finishes, the results and replay artifacts are imported into Beryl as a normal run " +
|
|
151
|
-
"(trigger source `local`), so history, replay, and reports all work; pass --no-sync to " +
|
|
152
|
-
"keep a run entirely off the record while iterating. Session-mode tests behave as " +
|
|
153
|
-
"in the cloud: their account signs in once per invocation and every session-mode " +
|
|
154
|
-
"test rides that session; a failed sign-in fails those tests with the same " +
|
|
155
|
-
"SESSION_* reason a cloud run reports. Only a test that depends on a session Beryl " +
|
|
156
|
-
"captured server-side, or that has third party integration steps (cloud only), is " +
|
|
157
|
-
"skipped, with a note. Run those with `runs trigger`. Point " +
|
|
158
|
-
"--url-override at a local dev server or preview, and --dir to keep specs, artifacts, " +
|
|
159
|
-
"and reports on disk. With no ids, a test with no plan yet is skipped quietly; one " +
|
|
160
|
-
"whose script can't be fetched is listed under `skipped` and fails the run. Exits 0 " +
|
|
161
|
-
"only if every executed test passed and every script was fetched.",
|
|
162
|
-
scope: "project",
|
|
163
|
-
args: [
|
|
164
|
-
{
|
|
165
|
-
name: "test-ids",
|
|
166
|
-
description: "Test ids to run (from `beryl tests list`); omit to run every active test",
|
|
167
|
-
variadic: true,
|
|
168
|
-
},
|
|
169
|
-
],
|
|
170
|
-
flags: [
|
|
171
|
-
{
|
|
172
|
-
name: "all",
|
|
173
|
-
type: "boolean",
|
|
174
|
-
description: "Run every active test in the project (the default when no ids are given)",
|
|
175
|
-
},
|
|
176
|
-
{
|
|
177
|
-
name: "group",
|
|
178
|
-
type: "string",
|
|
179
|
-
description: "Run only the active tests in this group (by name). Cannot be combined with test ids.",
|
|
180
|
-
},
|
|
181
|
-
{
|
|
182
|
-
name: "url-override",
|
|
183
|
-
type: "string",
|
|
184
|
-
description: "Run against this base URL instead of the environment's (e.g. http://localhost:3000)",
|
|
185
|
-
},
|
|
186
|
-
{
|
|
187
|
-
name: "env",
|
|
188
|
-
type: "string",
|
|
189
|
-
description: "Environment id to run against and attach the imported run to. Use for a " +
|
|
190
|
-
"standing environment; --url-override is for a throwaway host",
|
|
191
|
-
},
|
|
192
|
-
{
|
|
193
|
-
name: "sync",
|
|
194
|
-
type: "boolean",
|
|
195
|
-
default: true,
|
|
196
|
-
description: "Import the results into Beryl as a run when finished (--no-sync: local only, " +
|
|
197
|
-
"nothing recorded)",
|
|
198
|
-
},
|
|
199
|
-
{
|
|
200
|
-
name: "dir",
|
|
201
|
-
type: "string",
|
|
202
|
-
description: "Write each test's spec, artifacts, and JSON report under this directory",
|
|
203
|
-
},
|
|
204
|
-
],
|
|
205
|
-
examples: [
|
|
206
|
-
"beryl runs local",
|
|
207
|
-
"beryl runs local 4f… 9a…",
|
|
208
|
-
"beryl runs local --url-override http://localhost:3000",
|
|
209
|
-
"beryl runs local 4f… --no-sync --dir ./beryl-local",
|
|
210
|
-
],
|
|
211
|
-
async run(ctx, input) {
|
|
212
|
-
// Argument validation runs BEFORE the Chromium/Playwright precondition: a bad flag
|
|
213
|
-
// combination should say so on any machine, not report a missing browser.
|
|
214
|
-
// Deduped: a repeated id would put the same test twice in one imported run,
|
|
215
|
-
// which the import manifest rejects.
|
|
216
|
-
const explicitIds = [...new Set(input.args["test-ids"] ?? [])];
|
|
217
|
-
const group = flagStr(input, "group");
|
|
218
|
-
if (group && explicitIds.length > 0)
|
|
219
|
-
throw new UsageError("--group cannot be combined with explicit test ids");
|
|
220
|
-
if (explicitIds.length > 0 && flagBool(input, "all"))
|
|
221
|
-
throw new UsageError("--all cannot be combined with explicit test ids");
|
|
222
|
-
if (group && flagBool(input, "all"))
|
|
223
|
-
throw new UsageError("--all cannot be combined with --group");
|
|
224
|
-
// No ids and no group means the whole suite — `beryl runs local` alone is a
|
|
225
|
-
// complete local run.
|
|
226
|
-
const all = explicitIds.length === 0 && !group;
|
|
227
|
-
await ensureRunnableLocally(ctx);
|
|
228
|
-
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
229
|
-
const sync = input.flags.sync !== false;
|
|
230
|
-
const urlOverride = flagStr(input, "url-override");
|
|
231
|
-
const dir = flagStr(input, "dir");
|
|
232
|
-
const listed = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/tests`, {
|
|
233
|
-
environment_id: flagStr(input, "env"),
|
|
234
|
-
}));
|
|
235
|
-
// The list rows carry the authored name as nl_title (title is the customer's
|
|
236
|
-
// rename, usually unset) — fall through so the terminal shows names, not ids.
|
|
237
|
-
const titles = new Map(listed.map((t) => [t.id, t.title || t.nl_title || t.id.slice(0, 8)]));
|
|
238
|
-
const ids = all
|
|
239
|
-
? listed.filter((t) => t.is_active !== false).map((t) => t.id)
|
|
240
|
-
: group
|
|
241
|
-
? idsInGroup(listed, group)
|
|
242
|
-
: explicitIds;
|
|
243
|
-
if (ids.length === 0)
|
|
244
|
-
throw group
|
|
245
|
-
? new UsageError(`No active tests in group '${group}'.`)
|
|
246
|
-
: new CliError("This project has no tests to run.");
|
|
247
|
-
const fetchScript = (id) => ctx.client.get(`${projectPath(workspaceId, projectId)}/tests/${id}/script`, {
|
|
248
|
-
// frames only matter when the run will be imported: they become the replay.
|
|
249
|
-
// environment_id keeps the baked login_email on the same environment the
|
|
250
|
-
// LOGIN_PASSWORD reveal below is scoped to.
|
|
251
|
-
base_url: urlOverride,
|
|
252
|
-
frames: sync ? true : undefined,
|
|
253
|
-
environment_id: flagStr(input, "env"),
|
|
254
|
-
});
|
|
255
|
-
const toLocalSpec = (id, script) => ({
|
|
256
|
-
id,
|
|
257
|
-
title: titles.get(id) ?? id.slice(0, 8),
|
|
258
|
-
content: script.content,
|
|
259
|
-
requiresAuth: Boolean(script.requires_auth),
|
|
260
|
-
usesEmail: /__vmInbox\(|__vmAwaitEmail\(/.test(script.content),
|
|
261
|
-
usesLoginPassword: Boolean(script.uses_login_password),
|
|
262
|
-
usesThirdParty: specUsesThirdParty(script.content),
|
|
263
|
-
inlinedLoginSteps: script.inlined_login_steps ?? 0,
|
|
264
|
-
...(script.session_mode && script.account_id
|
|
265
|
-
? { sessionMode: true, accountId: script.account_id }
|
|
266
|
-
: {}),
|
|
267
|
-
...(script.email ? { email: script.email } : {}),
|
|
268
|
-
...(script.login_config_error ? { loginConfigError: script.login_config_error } : {}),
|
|
269
|
-
});
|
|
270
|
-
const specs = [];
|
|
271
|
-
// With --all, a test the server can't render (no plan yet, 409) is a quiet skip;
|
|
272
|
-
// any other fetch failure is listed and fails the run. An explicitly-requested id
|
|
273
|
-
// failing to render is the caller's problem.
|
|
274
|
-
const fetchFailed = [];
|
|
275
|
-
for (const id of ids) {
|
|
276
|
-
let script;
|
|
277
|
-
try {
|
|
278
|
-
script = await fetchScript(id);
|
|
279
|
-
}
|
|
280
|
-
catch (err) {
|
|
281
|
-
if (!all || err instanceof AuthError)
|
|
282
|
-
throw err;
|
|
283
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
284
|
-
if (err instanceof ApiError && err.status === 409) {
|
|
285
|
-
ctx.err(yellow(`! ${titles.get(id) ?? id}: no runnable script — skipped (${detail})`));
|
|
286
|
-
}
|
|
287
|
-
else {
|
|
288
|
-
fetchFailed.push(id);
|
|
289
|
-
ctx.err(red(`✗ ${titles.get(id) ?? id}: could not fetch its script (${detail})`));
|
|
290
|
-
}
|
|
291
|
-
continue;
|
|
292
|
-
}
|
|
293
|
-
specs.push(toLocalSpec(id, script));
|
|
294
|
-
}
|
|
295
|
-
if (specs.length === 0)
|
|
296
|
-
throw new CliError(fetchFailed.length
|
|
297
|
-
? `Could not fetch the script for ${fetchFailed.length} test(s); nothing ran.`
|
|
298
|
-
: "No runnable tests were found.");
|
|
299
|
-
// A test that leans on a session this CLI cannot establish gets no session here,
|
|
300
|
-
// so skip it rather than run a spec doomed at the login wall. A session-mode test
|
|
301
|
-
// is NOT in that set: its account's session is established below, once, exactly as
|
|
302
|
-
// a cloud run's setup phase does. A test whose account is missing its password
|
|
303
|
-
// would type a literal placeholder into the page: skip, with the server's fix-it
|
|
304
|
-
// message.
|
|
305
|
-
const skipped = specs.filter((s) => (s.requiresAuth && !(s.sessionMode && s.accountId)) ||
|
|
306
|
-
s.loginConfigError ||
|
|
307
|
-
s.usesThirdParty);
|
|
308
|
-
const runnable = specs.filter((s) => !skipped.includes(s));
|
|
309
|
-
for (const s of skipped) {
|
|
310
|
-
ctx.err(yellow(s.loginConfigError
|
|
311
|
-
? `! ${s.title}: skipped — ${s.loginConfigError}`
|
|
312
|
-
: s.usesThirdParty
|
|
313
|
-
? `! ${s.title}: skipped, ${THIRD_PARTY_USE_TRIGGER}.`
|
|
314
|
-
: `! ${s.title}: needs a session only Beryl's cloud holds, skipped ` +
|
|
315
|
-
`(use \`beryl runs trigger\`).`));
|
|
316
|
-
}
|
|
317
|
-
if (runnable.length === 0)
|
|
318
|
-
throw new CliError("None of the selected tests can run locally.");
|
|
319
|
-
// Once per account, before any test runs — the local counterpart of the cloud
|
|
320
|
-
// worker's run-setup phase, produced by the same server-side establish (stored-
|
|
321
|
-
// session cache, login replay, proof). A failure here fails only that account's
|
|
322
|
-
// tests, with the same stable-prefixed reason a cloud run reports; an account
|
|
323
|
-
// proven unable to carry a session gets its tests re-rendered to sign themselves
|
|
324
|
-
// in, the same degraded mode a cloud run applies.
|
|
325
|
-
const sessions = new Map();
|
|
326
|
-
const sessionErrors = new Map();
|
|
327
|
-
const accountIds = [
|
|
328
|
-
...new Set(runnable.filter((s) => s.sessionMode && s.accountId).map((s) => s.accountId)),
|
|
329
|
-
];
|
|
330
|
-
for (const accountId of accountIds) {
|
|
331
|
-
ctx.err(dim("Establishing the test account's session (once per run)…"));
|
|
332
|
-
const established = await establishAccountSession(ctx.client, projectPath(workspaceId, projectId), accountId);
|
|
333
|
-
if (established.session) {
|
|
334
|
-
sessions.set(accountId, established.session);
|
|
335
|
-
}
|
|
336
|
-
else if (established.unsupported) {
|
|
337
|
-
// The server just latched UNSUPPORTED, so a re-fetch renders these specs with
|
|
338
|
-
// their account's sign-in steps inlined — they run self-signing like any other.
|
|
339
|
-
// Whole-object replacement, not a merge: the inlined render carries no
|
|
340
|
-
// session_mode, and a merge would leave the stale sessionMode/accountId behind.
|
|
341
|
-
ctx.err(yellow(`! ${established.error} — signing in per test.`));
|
|
342
|
-
try {
|
|
343
|
-
for (const [i, s] of runnable.entries()) {
|
|
344
|
-
if (s.accountId !== accountId)
|
|
345
|
-
continue;
|
|
346
|
-
runnable[i] = toLocalSpec(s.id, await fetchScript(s.id));
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
catch (err) {
|
|
350
|
-
// A failed re-fetch fails only this account's tests, like any other
|
|
351
|
-
// establish failure — not the whole invocation.
|
|
352
|
-
sessionErrors.set(accountId, err instanceof Error ? err.message : String(err));
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
else {
|
|
356
|
-
sessionErrors.set(accountId, established.error ?? "SESSION_UNAVAILABLE");
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
// Revealed once for the batch over the member-gated, logged route — the same
|
|
360
|
-
// secret the cloud runner banks into run-config.json; never in the spec source.
|
|
361
|
-
let loginPassword;
|
|
362
|
-
if (runnable.some((s) => s.usesLoginPassword)) {
|
|
363
|
-
try {
|
|
364
|
-
const secret = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/config/secrets/LOGIN_PASSWORD/value`, { environment_id: flagStr(input, "env") }));
|
|
365
|
-
loginPassword = secret.value;
|
|
366
|
-
}
|
|
367
|
-
catch (err) {
|
|
368
|
-
throw new CliError("Could not reveal the LOGIN_PASSWORD secret for the saved login: " +
|
|
369
|
-
(err instanceof Error ? err.message : String(err)));
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
const startedAt = new Date().toISOString();
|
|
373
|
-
const entries = [];
|
|
374
|
-
const bar = new ProgressBar();
|
|
375
|
-
let installOffered = false;
|
|
376
|
-
let done = 0;
|
|
377
|
-
let passed = 0;
|
|
378
|
-
let failed = 0;
|
|
379
|
-
for (const spec of runnable) {
|
|
380
|
-
const stepTotal = countPlannedFrames(spec.content);
|
|
381
|
-
const state = () => ({
|
|
382
|
-
total: runnable.length,
|
|
383
|
-
done,
|
|
384
|
-
passed,
|
|
385
|
-
failed,
|
|
386
|
-
title: spec.title,
|
|
387
|
-
stepTotal,
|
|
388
|
-
});
|
|
389
|
-
bar.update(state());
|
|
390
|
-
if (!bar.active)
|
|
391
|
-
ctx.err(dim(`Running ${spec.title}…`));
|
|
392
|
-
const testStarted = new Date().toISOString();
|
|
393
|
-
// No proven session for this account: running the spec would open the page
|
|
394
|
-
// logged out and pass every assertion that tolerates that — fail it in the
|
|
395
|
-
// setup phase with the establish error, exactly as a cloud run does.
|
|
396
|
-
const sessionError = spec.accountId ? sessionErrors.get(spec.accountId) : undefined;
|
|
397
|
-
if (spec.sessionMode && sessionError !== undefined) {
|
|
398
|
-
entries.push({
|
|
399
|
-
test_case_id: spec.id,
|
|
400
|
-
status: "failed",
|
|
401
|
-
phase: "setup",
|
|
402
|
-
error_message: sessionError.slice(0, IMPORT_MAX_ERROR_LEN),
|
|
403
|
-
started_at: testStarted,
|
|
404
|
-
completed_at: new Date().toISOString(),
|
|
405
|
-
frames: [],
|
|
406
|
-
frame_urls: [],
|
|
407
|
-
frame_durations_ms: [],
|
|
408
|
-
files: [],
|
|
409
|
-
});
|
|
410
|
-
done += 1;
|
|
411
|
-
failed += 1;
|
|
412
|
-
bar.clear();
|
|
413
|
-
ctx.err(`${red("✗")} ${spec.title}\n ${dim(sessionError.split("\n")[0] ?? "")}`);
|
|
414
|
-
continue;
|
|
415
|
-
}
|
|
416
|
-
const session = spec.sessionMode ? sessions.get(spec.accountId ?? "") : undefined;
|
|
417
|
-
let outcome;
|
|
418
|
-
let runError;
|
|
419
|
-
let lastStep = 0;
|
|
420
|
-
const attempt = () => executeLocalSpec({ client: ctx.client, workspaceId, projectId }, {
|
|
421
|
-
spec,
|
|
422
|
-
dir: dir ? path.join(dir, spec.id) : undefined,
|
|
423
|
-
harvest: sync,
|
|
424
|
-
loginPassword,
|
|
425
|
-
session,
|
|
426
|
-
// Clear the rewriting bar before a mid-test log line, or the line is
|
|
427
|
-
// appended onto the live bar and fossilises it into scrollback.
|
|
428
|
-
onEvent: (line) => {
|
|
429
|
-
bar.clear();
|
|
430
|
-
ctx.err(dim(line));
|
|
431
|
-
bar.update({ ...state(), step: lastStep });
|
|
432
|
-
},
|
|
433
|
-
onSpawn: (runDir) => {
|
|
434
|
-
const ticker = setInterval(() => {
|
|
435
|
-
lastStep = countWrittenFrames(runDir);
|
|
436
|
-
bar.update({ ...state(), step: lastStep });
|
|
437
|
-
}, 300);
|
|
438
|
-
return () => clearInterval(ticker);
|
|
439
|
-
},
|
|
440
|
-
});
|
|
441
|
-
try {
|
|
442
|
-
try {
|
|
443
|
-
({ outcome, runError } = await attempt());
|
|
444
|
-
}
|
|
445
|
-
catch (err) {
|
|
446
|
-
// Local Playwright missing: on a TTY offer to install it once and retry,
|
|
447
|
-
// instead of only printing a hint the user then has to act on by hand.
|
|
448
|
-
if (err instanceof PlaywrightMissingError && ctx.interactive && !installOffered) {
|
|
449
|
-
installOffered = true;
|
|
450
|
-
bar.clear();
|
|
451
|
-
const cwd = process.cwd();
|
|
452
|
-
const commands = installCommandsFor(playwrightGaps(cwd), cwd);
|
|
453
|
-
if (!(await confirmInstall(ctx.prompt, commands)))
|
|
454
|
-
throw err;
|
|
455
|
-
try {
|
|
456
|
-
await installPlaywright(process.cwd(), (line) => ctx.err(dim(line)));
|
|
457
|
-
}
|
|
458
|
-
catch (installErr) {
|
|
459
|
-
throw new CliError(installErr instanceof Error ? installErr.message : String(installErr));
|
|
460
|
-
}
|
|
461
|
-
ctx.err(green("✓ Local Playwright installed — running the test…"));
|
|
462
|
-
({ outcome, runError } = await attempt());
|
|
463
|
-
}
|
|
464
|
-
else {
|
|
465
|
-
throw err;
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
catch (err) {
|
|
470
|
-
// Environmental failures abort the batch — nothing later would fare better.
|
|
471
|
-
if (err instanceof PlaywrightMissingError || err instanceof CliError) {
|
|
472
|
-
bar.clear();
|
|
473
|
-
throw err instanceof CliError ? err : new CliError(err.message);
|
|
474
|
-
}
|
|
475
|
-
// The spec never ran and the executor could not classify it — an errored
|
|
476
|
-
// result, not an aborted batch: the remaining tests still deserve their run.
|
|
477
|
-
runError = err instanceof Error ? err.message : String(err);
|
|
478
|
-
}
|
|
479
|
-
const entry = toRunEntry(spec, outcome, runError, testStarted, entries.length, [
|
|
480
|
-
...(spec.usesLoginPassword && loginPassword ? [loginPassword] : []),
|
|
481
|
-
...(session?.secrets ?? []),
|
|
482
|
-
]);
|
|
483
|
-
entries.push(entry);
|
|
484
|
-
done += 1;
|
|
485
|
-
if (entry.status === "passed")
|
|
486
|
-
passed += 1;
|
|
487
|
-
else
|
|
488
|
-
failed += 1;
|
|
489
|
-
bar.clear();
|
|
490
|
-
ctx.err(`${entry.status === "passed" ? green("✓") : red("✗")} ${spec.title}` +
|
|
491
|
-
`${entry.duration_ms ? dim(` (${(entry.duration_ms / 1000).toFixed(1)}s)`) : ""}` +
|
|
492
|
-
`${entry.error_message ? `\n ${dim(entry.error_message.split("\n")[0] ?? "")}` : ""}`);
|
|
493
|
-
}
|
|
494
|
-
bar.clear();
|
|
495
|
-
let runId;
|
|
496
|
-
if (sync && entries.length > 0) {
|
|
497
|
-
ctx.err(dim("Importing results into Beryl…"));
|
|
498
|
-
const imported = (await ctx.client.request("POST", `${projectPath(workspaceId, projectId)}/runs/import`, {
|
|
499
|
-
form: buildImportForm(entries, {
|
|
500
|
-
environmentId: flagStr(input, "env"),
|
|
501
|
-
targetUrlOverride: urlOverride,
|
|
502
|
-
startedAt,
|
|
503
|
-
completedAt: new Date().toISOString(),
|
|
504
|
-
onNote: (line) => ctx.err(yellow(`! ${line}`)),
|
|
505
|
-
}),
|
|
506
|
-
}));
|
|
507
|
-
runId = imported.id;
|
|
508
|
-
}
|
|
509
|
-
const summaryLine = failed === 0
|
|
510
|
-
? green(`All tests passed (${passed}/${entries.length})`)
|
|
511
|
-
: red(`${failed} test(s) failed`) + ` (${passed} passed)`;
|
|
512
|
-
const human = `${summaryLine}` +
|
|
513
|
-
(skipped.length ? `\n${yellow(`${skipped.length} skipped (see notes above)`)}` : "") +
|
|
514
|
-
(fetchFailed.length
|
|
515
|
-
? `\n${red(`${fetchFailed.length} could not be fetched (see notes above)`)}`
|
|
516
|
-
: "") +
|
|
517
|
-
(runId ? `\nSynced to Beryl as run ${runId} — \`beryl runs get ${runId}\`` : "") +
|
|
518
|
-
(!sync ? `\n${dim("--no-sync: nothing recorded in Beryl")}` : "") +
|
|
519
|
-
(dir ? `\n${dim(`Specs + artifacts + reports under ${path.resolve(dir)}`)}` : "");
|
|
520
|
-
return {
|
|
521
|
-
data: {
|
|
522
|
-
passed,
|
|
523
|
-
failed,
|
|
524
|
-
skipped: [...skipped.map((s) => s.id), ...fetchFailed],
|
|
525
|
-
results: entries.map(({ files: _files, ...rest }) => rest),
|
|
526
|
-
...(runId ? { run_id: runId } : {}),
|
|
527
|
-
},
|
|
528
|
-
human,
|
|
529
|
-
// Exit codes are a CI contract: any failing/errored or unfetchable test → exit 1.
|
|
530
|
-
...(failed > 0 || fetchFailed.length > 0 ? { exitCode: 1 } : {}),
|
|
531
|
-
};
|
|
532
|
-
},
|
|
533
|
-
},
|
|
534
106
|
{
|
|
535
107
|
name: "runs list",
|
|
536
108
|
summary: "List recent runs",
|
|
@@ -581,7 +153,8 @@ export const runCommands = [
|
|
|
581
153
|
"carries `heal` when Beryl healed it: its steps were rewritten with the intent " +
|
|
582
154
|
"unchanged, proved in a browser and it re-ran green; `version_no` is the revision it " +
|
|
583
155
|
"wrote (see `tests diff`) and `rationale` the agent's reason. A heal that did not turn " +
|
|
584
|
-
"the test green is not reported: a failure is a failure. " +
|
|
156
|
+
"the test green is not reported: a failure is a failure. A red that Beryl did not try " +
|
|
157
|
+
"to heal carries `heal_skip_reason` saying why. " +
|
|
585
158
|
"`runs report` returns the generated report document, `runs explain` an AI explanation " +
|
|
586
159
|
"of one failed result.",
|
|
587
160
|
scope: "project",
|
|
@@ -607,7 +180,7 @@ export const runCommands = [
|
|
|
607
180
|
name: "status",
|
|
608
181
|
type: "string",
|
|
609
182
|
description: "With --page, show only this bucket: passed, failed, errored, cancelled, " +
|
|
610
|
-
"
|
|
183
|
+
"or quarantined",
|
|
611
184
|
},
|
|
612
185
|
{
|
|
613
186
|
name: "group",
|