@beryl-so/cli 0.67.0 → 0.73.2

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.
@@ -1,13 +1,12 @@
1
1
  import fs from "node:fs";
2
- import { CliError, UsageError } from "../errors.js";
3
- import { ApiError } from "../http.js";
4
- import { lintPlan, planUsesThirdParty } from "../lint.js";
5
- import { establishAccountSession, executeLocalSpec, THIRD_PARTY_CLOUD_ONLY, } from "../local-exec.js";
6
- import { PlaywrightMissingError } from "../local-run.js";
7
- import { dim, green, red, table, yellow } from "../output.js";
8
- import { confirmInstall, installCommandsFor, installPlaywright, playwrightGaps, } from "../playwright-install.js";
2
+ import { UsageError } from "../errors.js";
3
+ import { lintPlan } from "../lint.js";
4
+ import { table } from "../output.js";
9
5
  import { ACTION_PLAN_SCHEMA } from "../schema.generated.js";
10
6
  import { arg, argList, flagBool, flagNum, flagStr, flagStrings, inGroup, projectPath, readJsonFlag, } from "./util.js";
7
+ export const TESTS_CREATE_RETIRED = "`tests create` is retired: tests are now created by the cloud test-creation job. " +
8
+ "Describe the flows in a document and pass it to `beryl jobs create`. `runs local` " +
9
+ "still runs your banked tests on this machine.";
11
10
  const testPath = (ws, p, id) => `${projectPath(ws, p)}/tests/${id}`;
12
11
  const EVENT_VERBS = {
13
12
  QUARANTINED: "quarantined",
@@ -32,27 +31,6 @@ function eventLine(e) {
32
31
  : "";
33
32
  return `${when} ${verb}${groups} ${who}${why}`;
34
33
  }
35
- // A duplicate-title 409 after a green replay is idempotent when the existing row
36
- // holds the same plan (a retry after a lost response) — success, not an error.
37
- // A different plan under the same title is a real conflict.
38
- function createConflictResult(err, planHash) {
39
- if (!(err instanceof ApiError) || err.status !== 409)
40
- return undefined;
41
- const detail = err.detail;
42
- if (typeof detail !== "object" || detail === null || detail.code !== "duplicate_title")
43
- return undefined;
44
- if (detail.existing_plan_hash && detail.existing_plan_hash === planHash) {
45
- return {
46
- data: { id: detail.existing_test_id, already_existed: true },
47
- human: green(`✓ Already banked as ${detail.existing_test_id} (same plan) — nothing to do.`),
48
- };
49
- }
50
- return {
51
- human: (detail.message ?? "A test with this title already exists.") +
52
- "\nIts plan differs from yours — use `tests set-plan` to update it, or pick a new title.",
53
- exitCode: 1,
54
- };
55
- }
56
34
  // The server transcodes a failure screenshot to WebP to fit it under the payload cap,
57
35
  // so the format is not knowable up front — sniff it off the decoded magic bytes rather
58
36
  // than asserting PNG.
@@ -65,27 +43,6 @@ export function sniffImageMime(b64) {
65
43
  return "image/jpeg";
66
44
  return "image/png";
67
45
  }
68
- // A verify-failure 422 carries evidence (a11y page state + failure screenshot).
69
- // Returned as a CommandResult rather than thrown: a thrown error is text-only in
70
- // the MCP adapter, and the screenshot only reaches the agent as image content.
71
- function verifyFailureResult(err) {
72
- if (!(err instanceof ApiError) || err.status !== 422)
73
- return undefined;
74
- const detail = err.detail;
75
- if (typeof detail !== "object" || detail === null || !detail.message)
76
- return undefined;
77
- const parts = [detail.message];
78
- if (detail.error_context) {
79
- parts.push("", "----- Page state at failure (accessibility snapshot) -----", detail.error_context);
80
- }
81
- return {
82
- human: parts.join("\n"),
83
- images: detail.screenshot_b64
84
- ? [{ data: detail.screenshot_b64, mimeType: sniffImageMime(detail.screenshot_b64) }]
85
- : undefined,
86
- exitCode: 1,
87
- };
88
- }
89
46
  // The concise human table for `tests list` — the full TestResponse is a 24-column
90
47
  // firehose of internal ids that wraps unreadably in a normal terminal. `--wide`
91
48
  // (and `--json`) still expose every field.
@@ -190,7 +147,7 @@ export const testCommands = [
190
147
  name: "status",
191
148
  type: "string",
192
149
  description: "With --page, show only this bucket: passed, failing (failed or errored), " +
193
- "failed, errored, cancelled, quarantined, or unstable",
150
+ "failed, errored, cancelled, or quarantined",
194
151
  },
195
152
  {
196
153
  name: "ungrouped",
@@ -266,288 +223,33 @@ export const testCommands = [
266
223
  },
267
224
  {
268
225
  name: "tests create",
269
- summary: "Create a test case from a JSON action plan (for tests authored locally, e.g. by your coding agent)",
270
- description: "The plan is a JSON object whose steps are {action, selector, url, value, ...}: the first " +
271
- "EXECUTED step must be a goto, and at least one step must be an expect. Before anything is " +
272
- "banked, the plan is proven by replaying it in a browser ON YOUR MACHINE with the " +
273
- "Playwright runner bundled in the CLI (your repo's own @playwright/test wins when " +
274
- "present): the server renders the spec (`tests/compile`), the CLI runs it (minting " +
275
- "a run inbox for await_email steps and resolving the saved login exactly as a cloud run " +
276
- "would), and only a green replay creates the test (bound to the replayed plan by its hash). " +
277
- "This holds over MCP too: the replay runs on the machine hosting the MCP server, never on " +
278
- "Beryl's; if the Chromium browser is missing there the tool returns the install command " +
279
- "(`beryl init` installs it; on a terminal the CLI offers to). " +
280
- "A red replay banks NOTHING: the failure evidence comes back (over MCP the screenshot is " +
281
- "image content), you fix the plan file and re-run. No run is recorded: once a batch of " +
282
- "tests is banked, run their ids together once (`runs local`, or `runs trigger` for the " +
283
- "cloud) so they land as ONE run with real first results. A plan that signs in with a session Beryl captured " +
284
- "server-side cannot replay locally (that session never leaves Beryl's cloud). It falls " +
285
- "back to server-side verification automatically, and says so; so does a plan with third party " +
286
- "integration steps, which run only in Beryl's cloud. A session-mode plan replays locally " +
287
- "fine: the server renders it with its account's stored sign-in steps in front, so " +
288
- "the same identity is exercised on your machine. Optional `before` and `after` arrays hold setup and teardown " +
289
- "steps: `after` runs even when a main step fails, which is how a create/update/delete test " +
290
- "cleans up the record it made on the runs that go red. " +
291
- "Recovery: a 409 `duplicate_title` carries existing_test_id + existing_plan_hash, so " +
292
- "reconcile with that test (`tests get` / `tests set-plan`), don't rename-and-retry; a " +
293
- "409 `plan_hash_mismatch` means the submitted plan is not the bytes that were replayed " +
294
- "(re-run `tests create`); a 429 with Retry-After 30 means the verify slots are " +
295
- "saturated (wait and retry). A server-side verify that could not run (verdict `flag`) " +
296
- "still banks the test but returns verified:false and exits 1; prove it with `tests recompile`.",
226
+ summary: "Retired: tests are now created by the cloud test-creation job (`jobs create`)",
227
+ description: "Local test authoring is retired: every test is authored by Beryl's cloud agent, so all " +
228
+ "plans are built one way. Give the agent a document describing the flows with " +
229
+ "`jobs create`. This command creates nothing and exits 2. Local runs are unchanged: " +
230
+ "`runs local` still runs banked tests on your machine.",
231
+ // Project scope and the old flags stay declared so an old invocation parses and
232
+ // reaches this message instead of an unknown-flag error.
297
233
  scope: "project",
298
234
  flags: [
299
- { name: "title", type: "string", required: true, description: "Title for the new test" },
300
- { name: "file", type: "string", required: true, description: "Plan JSON file, or - for stdin" },
301
- {
302
- name: "description",
303
- type: "string",
304
- description: "One to three sentences stating what this test proves: the immutable outcome Beryl's healing " +
305
- "checks against. State the purpose, not the steps; the one observable signal that's true " +
306
- "only if the flow worked.",
307
- },
308
- {
309
- name: "no-verify",
310
- type: "boolean",
311
- description: "Skip verification entirely: bank the authored plan as-is, unproven",
312
- },
313
- {
314
- name: "url-override",
315
- type: "string",
316
- description: "Replay against this base URL instead of the environment's (e.g. http://localhost:3000). " +
317
- "The banked test is then unproven against its real environment, and the CLI says so.",
318
- },
319
- {
320
- name: "group",
321
- type: "strings",
322
- description: "Put the test in an existing project group (by name), repeatable. An unknown " +
323
- "name is an error (create groups with `beryl groups create`). Omit for no group.",
324
- },
325
- { name: "env", type: "string", description: "Environment id to compile and prove against" },
326
- {
327
- name: "access",
328
- type: "string",
329
- description: "read (default): the test only reads the app. write: any step creates, edits, " +
330
- "renames, deletes or toggles something; a writer must clean up in `after`",
331
- },
332
- {
333
- name: "dir",
334
- type: "string",
335
- description: "Keep the rendered spec, artifacts, and JSON report under this directory",
336
- },
337
- ],
338
- examples: [
339
- 'beryl tests create --title "Checkout happy path" --file plan.json',
340
- 'beryl tests create --title "Checkout happy path" --file plan.json --description "Proves a shopper can buy a product: after paying, an order-confirmation page with an order number appears."',
341
- 'beryl tests create --title "Checkout happy path" --file plan.json --url-override http://localhost:3000',
342
- ],
343
- async run(ctx, input) {
344
- const title = flagStr(input, "title");
345
- if (!title)
346
- throw new UsageError("--title is required");
347
- const { workspaceId, projectId } = await ctx.requireProject(input);
348
- const plan = readJsonFlag(input, "file");
349
- const description = flagStr(input, "description");
350
- const urlOverride = flagStr(input, "url-override");
351
- const env = flagStr(input, "env");
352
- const groups = flagStrings(input, "group");
353
- const access = accessFlag(input, "read");
354
- const bank = (extra) => ctx.client.post(`${projectPath(workspaceId, projectId)}/tests`, {
355
- title,
356
- plan,
357
- description,
358
- groups,
359
- access,
360
- ...extra,
361
- });
362
- if (flagBool(input, "no-verify")) {
363
- const created = await bank({ verify: false });
364
- return {
365
- data: created,
366
- human: yellow(`Banked UNPROVEN (--no-verify): ${created.id}`),
367
- };
368
- }
369
- const compile = () => ctx.client.post(`${projectPath(workspaceId, projectId)}/tests/compile`, {
370
- plan,
371
- title,
372
- base_url: urlOverride,
373
- environment_id: env,
374
- });
375
- let compiled = await compile();
376
- const verifyInCloud = async (reason) => {
377
- ctx.err(yellow(`! ${reason}, so this plan is verified server-side instead.`));
378
- try {
379
- const created = await bank({ verify: true, plan_hash: compiled.plan_hash });
380
- if (created.verified === false) {
381
- // The replay could not run (verdict "flag"): saved, but nothing was proven.
382
- return {
383
- data: created,
384
- human: yellow(`Banked UNVERIFIED: ${created.id}. The server-side replay could not run` +
385
- (created.verify_message ? ` (${String(created.verify_message)})` : "") +
386
- "; the test is saved but unproven. Prove it with `tests recompile`."),
387
- exitCode: 1,
388
- };
389
- }
390
- return { data: created };
391
- }
392
- catch (err) {
393
- const failure = verifyFailureResult(err);
394
- if (failure)
395
- return failure;
396
- const conflict = createConflictResult(err, compiled.plan_hash);
397
- if (conflict)
398
- return conflict;
399
- throw err;
400
- }
401
- };
402
- if (planUsesThirdParty(plan))
403
- return verifyInCloud(THIRD_PARTY_CLOUD_ONLY);
404
- // Session-mode plan: establish the account's session once (server-side, reused
405
- // from the stored one when it still proves live) and replay against it — the
406
- // cloud shape. An unsupported account is re-compiled to sign itself in.
407
- let session;
408
- if (compiled.session_mode && compiled.account_id) {
409
- ctx.err(dim("Establishing the test account's session…"));
410
- const established = await establishAccountSession(ctx.client, projectPath(workspaceId, projectId), compiled.account_id);
411
- if (established.session) {
412
- session = established.session;
413
- }
414
- else if (established.unsupported) {
415
- ctx.err(yellow(`! ${established.error} — the replay signs in itself.`));
416
- compiled = await compile();
417
- }
418
- else {
419
- throw new CliError(established.error ?? "SESSION_UNAVAILABLE");
420
- }
421
- }
422
- // A captured session lives encrypted in Beryl's cloud and is never handed to
423
- // this machine — the cloud is the only place this plan can be proven.
424
- if (compiled.requires_auth && !session) {
425
- return verifyInCloud("A captured session never leaves Beryl's cloud");
426
- }
427
- if (compiled.login_config_error)
428
- throw new CliError(compiled.login_config_error);
429
- let loginPassword;
430
- if (compiled.uses_login_password) {
431
- try {
432
- const secret = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/config/secrets/LOGIN_PASSWORD/value`, { environment_id: env }));
433
- loginPassword = secret.value;
434
- }
435
- catch (err) {
436
- throw new CliError("Could not reveal the LOGIN_PASSWORD secret for the saved login: " +
437
- (err instanceof Error ? err.message : String(err)));
438
- }
439
- }
440
- const specOf = (content) => ({
441
- id: "local-verify",
442
- title,
443
- content,
444
- requiresAuth: false,
445
- usesEmail: /__vmInbox\(|__vmAwaitEmail\(/.test(content),
446
- usesLoginPassword: compiled.uses_login_password,
447
- inlinedLoginSteps: compiled.inlined_login_steps ?? 0,
448
- email: compiled.email ?? undefined,
449
- });
450
- const attempts = 1 + Math.max(0, compiled.policy.error_retries);
451
- let outcome;
452
- let runError;
453
- let installOffered = false;
454
- for (let attempt = 1; attempt <= attempts; attempt++) {
455
- if (attempt > 1) {
456
- // Fresh render per attempt: the errored attempt may already have consumed
457
- // the {{unique}}/inbox handles its spec minted — replaying the same spec
458
- // would collide on exactly the uniqueness those handles exist to dodge.
459
- ctx.err(dim(`Replay errored — recompiling and retrying (${attempt}/${attempts})…`));
460
- compiled = await compile();
461
- }
462
- ctx.err(dim(`Replaying "${title}" locally before banking…`));
463
- try {
464
- ({ outcome, runError } = await executeLocalSpec({ client: ctx.client, workspaceId, projectId }, {
465
- spec: specOf(compiled.content),
466
- dir: flagStr(input, "dir"),
467
- harvest: true,
468
- loginPassword,
469
- session,
470
- onEvent: (line) => ctx.err(dim(line)),
471
- }));
472
- }
473
- catch (err) {
474
- if (err instanceof PlaywrightMissingError) {
475
- // On a TTY offer the install once and redo this attempt; over MCP or a
476
- // pipe the hint is the answer.
477
- if (ctx.interactive && !installOffered) {
478
- installOffered = true;
479
- const cwd = process.cwd();
480
- const commands = installCommandsFor(playwrightGaps(cwd), cwd);
481
- if (await confirmInstall(ctx.prompt, commands)) {
482
- await installPlaywright(cwd, (line) => ctx.err(dim(line)));
483
- attempt -= 1;
484
- continue;
485
- }
486
- }
487
- throw new CliError(err.message);
488
- }
489
- throw err;
490
- }
491
- // A completed replay — green or red — is a verdict; only an errored one
492
- // (the spec never ran) earns a retry.
493
- if (outcome)
494
- break;
495
- }
496
- if (!outcome) {
497
- return {
498
- data: { banked: false, verified: false, error: runError },
499
- human: red(`Replay errored after ${attempts} attempt(s) — nothing was banked.`) +
500
- `\n${runError ?? "the spec did not run"}` +
501
- `\nFix the environment (or retry), then re-run \`beryl tests create\`.`,
502
- exitCode: 1,
503
- };
504
- }
505
- const result = outcome.results[0];
506
- if (outcome.failed > 0 || outcome.passed === 0) {
507
- const shot = outcome.harvested?.screenshot;
508
- const parts = [
509
- `Plan failed its local replay — nothing was banked.`,
510
- ...(result?.error ? ["", result.error] : []),
511
- "",
512
- "Fix the plan file and re-run `beryl tests create`. The replay artifacts " +
513
- (flagStr(input, "dir")
514
- ? `are under ${outcome.directory}.`
515
- : "can be kept with --dir."),
516
- ];
517
- return {
518
- data: { banked: false, verified: false, error: result?.error },
519
- human: parts.join("\n"),
520
- images: shot
521
- ? [{ data: shot.toString("base64"), mimeType: "image/png" }]
522
- : undefined,
523
- exitCode: 1,
524
- };
525
- }
526
- let created;
527
- try {
528
- created = await bank({ verify: false, plan_hash: compiled.plan_hash });
529
- }
530
- catch (err) {
531
- const conflict = createConflictResult(err, compiled.plan_hash);
532
- if (conflict)
533
- return conflict;
534
- throw err;
535
- }
536
- if (urlOverride) {
537
- ctx.err(yellow(`! The proof ran against ${urlOverride}, not the environment's root URL — ` +
538
- "the banked test is unproven against its real target until a run there passes."));
539
- }
540
- // The server saw verify:false; the proof is the local replay, unless it ran off-target.
541
- return {
542
- data: { ...created, verified: !urlOverride, verdict: "keep", verified_by: "local" },
543
- human: green(`✓ Replay passed locally — banked as ${created.id}`),
544
- };
235
+ ["title", "string"],
236
+ ["file", "string"],
237
+ ["description", "string"],
238
+ ["no-verify", "boolean"],
239
+ ["url-override", "string"],
240
+ ["group", "strings"],
241
+ ["env", "string"],
242
+ ["access", "string"],
243
+ ["dir", "string"],
244
+ ].map(([name, type]) => ({ name, type, description: "Ignored" })),
245
+ async run() {
246
+ throw new UsageError(TESTS_CREATE_RETIRED);
545
247
  },
546
248
  },
547
249
  {
548
250
  name: "tests set-plan",
549
251
  summary: "Replace a test's step plan from a JSON file (creates a new version)",
550
- description: "Accepts the same plan shape as `tests create`, including the optional `before` and " +
252
+ description: "Accepts an ActionPlan (the shape `tests plan` returns), including the optional `before` and " +
551
253
  "`after` sections. `after` runs on pass and on fail, so cleanup happens even when the " +
552
254
  "test goes red. Pass `--description` when the re-authored plan changes what the test " +
553
255
  "proves; omit it to keep the test's existing intent. Saves the edit with NO replay: " +
@@ -591,6 +293,31 @@ export const testCommands = [
591
293
  };
592
294
  },
593
295
  },
296
+ {
297
+ name: "tests set-intent",
298
+ summary: "Edit what a test proves, the target heal judges against",
299
+ description: "The intent is the plain-language statement of what the test proves; heal judges every " +
300
+ "repair against it. It belongs to the test, so every surface of the test gets a new " +
301
+ "version carrying the new text (same plan, recorded as your edit; see `tests versions`). " +
302
+ "The plan is NOT regenerated: change the steps with `tests set-plan`. Re-explore keeps " +
303
+ "matching the same test after an edit.",
304
+ scope: "project",
305
+ args: [
306
+ { name: "test-id", description: "Test id", required: true },
307
+ { name: "intent", description: "New intent text", required: true },
308
+ ],
309
+ examples: [
310
+ 'beryl tests set-intent 4f… "Proves a shopper can check out and sees an order number."',
311
+ ],
312
+ async run(ctx, input) {
313
+ const { workspaceId, projectId } = await ctx.requireProject(input);
314
+ return {
315
+ data: await ctx.client.patch(testPath(workspaceId, projectId, arg(input, "test-id")), {
316
+ intent: arg(input, "intent"),
317
+ }),
318
+ };
319
+ },
320
+ },
594
321
  {
595
322
  name: "tests set-groups",
596
323
  summary: "Replace the groups a test belongs to",
@@ -919,8 +646,8 @@ export const testCommands = [
919
646
  name: "tests script",
920
647
  summary: "Print the rendered Playwright spec for a test (or an unbanked plan file)",
921
648
  description: "With a test id, fetches the banked test's rendered .spec.ts. With --file, compiles a " +
922
- "plan JSON that has NOT been banked yet (the same render `tests create` proves locally) " +
923
- "so you can inspect exactly what would run before creating anything. This returns the " +
649
+ "plan JSON that has NOT been banked yet, so you can inspect exactly what an edited " +
650
+ "plan would run before saving it. This returns the " +
924
651
  "executable spec. `tests plan` returns the stored JSON plan it is rendered from, " +
925
652
  "`tests get` the metadata row.",
926
653
  scope: "project",
@@ -16,6 +16,7 @@ const FAILING = new Set(["failed", "errored"]);
16
16
  const TERMINAL_RUN = new Set(["completed", "failed", "cancelled"]);
17
17
  const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
18
18
  const CI_ERROR_MAX_LEN = 4_000;
19
+ const SUMMARY_WAIT_TRIES = 10;
19
20
  function runPageUrl(apiUrl, projectId, runId) {
20
21
  if (!apiUrl)
21
22
  return null;
@@ -35,6 +36,16 @@ function runPageUrl(apiUrl, projectId, runId) {
35
36
  }
36
37
  const escapeAnnotationData = (s) => s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
37
38
  const escapeAnnotationProp = (s) => escapeAnnotationData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
39
+ // Reported on its own line, never folded into the verdict summary or the exit code.
40
+ export function findingsGateLine(gate) {
41
+ if (gate.status === "passed")
42
+ return "findings gate passed: no new Site Health findings since the last green run";
43
+ if (gate.status === "failed") {
44
+ const n = gate.new_count ?? 0;
45
+ return `findings gate failed: ${n} new Site Health finding${n === 1 ? "" : "s"} since the last green run`;
46
+ }
47
+ return `findings gate unknown: ${gate.reason ?? "not evaluated"}`;
48
+ }
38
49
  // Auth, client and wrong-host errors fail identically on every retry; only a network
39
50
  // blip, a timeout, throttling or a 5xx is worth waiting out.
40
51
  function isRetryable(e) {
@@ -91,6 +102,8 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes, opts = {
91
102
  ctx.err(red(detail.replace(/^/gm, " ")));
92
103
  process.stdout.write(`::error title=${escapeAnnotationProp(r.title || r.id)}::${escapeAnnotationData(detail)}\n`);
93
104
  }
105
+ if (!ok && r.heal_skip_reason)
106
+ ctx.err(dim(` ${r.heal_skip_reason}`));
94
107
  };
95
108
  if (!ctx.json)
96
109
  ctx.err(dim(`watching run ${runId}`));
@@ -187,6 +200,19 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes, opts = {
187
200
  }
188
201
  }
189
202
  clearStatusLine();
203
+ // A finalized run turns terminal a moment before its summary (and findings gate) is
204
+ // written; a run that ended failed is never finalized, so there is nothing to wait for.
205
+ const awaitingSummary = (r) => Boolean(r && TERMINAL_RUN.has(r.status) && r.status !== "failed" && !r.summary);
206
+ for (let i = 0; i < SUMMARY_WAIT_TRIES && awaitingSummary(run); i++) {
207
+ await sleep(1_000);
208
+ try {
209
+ run = (await ctx.client.get(`${path}/runs/${runId}`));
210
+ }
211
+ catch (e) {
212
+ if (!isRetryable(e))
213
+ throw e;
214
+ }
215
+ }
190
216
  const counters = {
191
217
  passed: run?.passed_count ?? 0,
192
218
  failed: run?.failed_count ?? 0,
@@ -214,6 +240,13 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes, opts = {
214
240
  : green(`${passed} passed`) + extra;
215
241
  if (!ctx.json)
216
242
  ctx.err(`\n${statusColor(finalStatus)}: ${summary}`);
243
+ const gate = run?.findings_gate ?? null;
244
+ if (gate && !ctx.json) {
245
+ const line = findingsGateLine(gate);
246
+ ctx.err(gate.status === "passed" ? green(line) : gate.status === "failed" ? red(line) : yellow(line));
247
+ for (const f of gate.new_findings ?? [])
248
+ ctx.err(dim(` ${f.severity} ${f.page_path} ${f.title}`));
249
+ }
217
250
  const link = runPageUrl(ctx.client.baseUrl, project, runId);
218
251
  if (ci && link)
219
252
  ctx.err(dim(link));
@@ -225,6 +258,7 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes, opts = {
225
258
  "| passed | failed | errored | cancelled | quarantined | total |",
226
259
  "| --- | --- | --- | --- | --- | --- |",
227
260
  `| ${counters.passed} | ${counters.failed} | ${counters.errored} | ${counters.cancelled} | ${counters.quarantined} | ${counters.total} |`,
261
+ ...(gate ? ["", findingsGateLine(gate)] : []),
228
262
  ...(link ? ["", `[Open the run in Beryl](${link})`] : []),
229
263
  ...failures.flatMap((r) => [
230
264
  "",
@@ -232,6 +266,7 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes, opts = {
232
266
  "```",
233
267
  String(r.error_message ?? "").slice(0, CI_ERROR_MAX_LEN),
234
268
  "```",
269
+ ...(r.heal_skip_reason ? ["", r.heal_skip_reason] : []),
235
270
  ]),
236
271
  "",
237
272
  ];
@@ -249,6 +284,7 @@ export async function watchRun(ctx, ws, project, runId, timeoutMinutes, opts = {
249
284
  status: finalStatus,
250
285
  ...(failFastStop ? { fail_fast: true } : {}),
251
286
  ...counters,
287
+ ...(gate ? { findings_gate: gate } : {}),
252
288
  },
253
289
  exitCode,
254
290
  };
package/dist/lint.js CHANGED
@@ -182,17 +182,11 @@ function identityIssues(plan, provisioning) {
182
182
  : [];
183
183
  }
184
184
  export const THIRD_PARTY_AUTHORIZE = "third_party_authorize";
185
- export const AWAIT_THIRD_PARTY = "await_third_party";
186
185
  const executed = (plan) => [
187
186
  ...steps(plan.before),
188
187
  ...steps(plan.steps),
189
188
  ...steps(plan.after),
190
189
  ];
191
- export function planUsesThirdParty(plan) {
192
- if (typeof plan !== "object" || plan === null)
193
- return false;
194
- return executed(plan).some((s) => s.action === THIRD_PARTY_AUTHORIZE || s.action === AWAIT_THIRD_PARTY);
195
- }
196
190
  // Mirrors the server's ActionPlan validator, which raises the first sorted mismatch only.
197
191
  function thirdPartyIssues(plan) {
198
192
  if (typeof plan !== "object" || plan === null)
@@ -162,10 +162,10 @@ export async function establishAccountSession(client, projectBase, accountId) {
162
162
  /**
163
163
  * Run one rendered spec on this machine with its full service harness: write the
164
164
  * inbox/login sidecars and pump `await_email` requests over the API while Playwright
165
- * runs. The one local-execution seam shared by `runs local` and the local-verify
166
- * `tests create`. {@link PlaywrightMissingError} propagates (environmental — the
167
- * caller decides whether to offer an install); any other throw is captured as
168
- * `runError` (the spec never ran: an errored result, not a crashed command).
165
+ * runs. The local-execution seam behind `runs local`. {@link PlaywrightMissingError}
166
+ * propagates (environmental: the caller decides whether to offer an install); any other
167
+ * throw is captured as `runError` (the spec never ran: an errored result, not a crashed
168
+ * command).
169
169
  */
170
170
  export async function executeLocalSpec(deps, opts) {
171
171
  const { spec } = opts;
@@ -8,6 +8,7 @@ import { groupCommands } from "../commands/groups.js";
8
8
  import { healthCommands } from "../commands/health.js";
9
9
  import { mailboxCommands } from "../commands/mailboxes.js";
10
10
  import { initCommands } from "../commands/init.js";
11
+ import { jobCommands } from "../commands/jobs.js";
11
12
  import { mcpCommands } from "../commands/mcp.js";
12
13
  import { projectCommands } from "../commands/projects.js";
13
14
  import { runCommands } from "../commands/runs.js";
@@ -68,6 +69,7 @@ export const commands = [
68
69
  ...runCommands,
69
70
  ...healthCommands,
70
71
  ...trackerCommands,
72
+ ...jobCommands,
71
73
  ...explorationCommands,
72
74
  ...configCommands,
73
75
  ...mailboxCommands,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beryl-so/cli",
3
- "version": "0.67.0",
3
+ "version": "0.73.2",
4
4
  "description": "Beryl on the command line: projects, tests, runs, and an MCP server over the same commands.",
5
5
  "license": "MIT",
6
6
  "type": "module",