@beryl-so/cli 0.64.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.
@@ -581,7 +581,8 @@ export const runCommands = [
581
581
  "carries `heal` when Beryl healed it: its steps were rewritten with the intent " +
582
582
  "unchanged, proved in a browser and it re-ran green; `version_no` is the revision it " +
583
583
  "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. " +
584
+ "the test green is not reported: a failure is a failure. A red that Beryl did not try " +
585
+ "to heal carries `heal_skip_reason` saying why. " +
585
586
  "`runs report` returns the generated report document, `runs explain` an AI explanation " +
586
587
  "of one failed result.",
587
588
  scope: "project",
@@ -607,7 +608,7 @@ export const runCommands = [
607
608
  name: "status",
608
609
  type: "string",
609
610
  description: "With --page, show only this bucket: passed, failed, errored, cancelled, " +
610
- "quarantined, or unstable (currently unstable in the run's environment)",
611
+ "or quarantined",
611
612
  },
612
613
  {
613
614
  name: "group",
@@ -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,31 +43,10 @@ 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.
92
- const LIST_COLUMNS = ["title", "status", "access", "id", "last_result_status", "last_run_at"];
49
+ const LIST_COLUMNS = ["title", "surface", "status", "access", "id", "last_result_status", "last_run_at"];
93
50
  const ACCESS_VALUES = ["read", "write"];
94
51
  function accessFlag(input, fallback) {
95
52
  const raw = flagStr(input, "access") ?? fallback;
@@ -101,6 +58,7 @@ function accessFlag(input, fallback) {
101
58
  function conciseTestRow(row) {
102
59
  return {
103
60
  title: row.nl_title,
61
+ surface: row.surface,
104
62
  status: row.quarantined ? "quarantined" : row.is_active ? "active" : "inactive",
105
63
  access: row.access,
106
64
  id: row.id,
@@ -189,7 +147,7 @@ export const testCommands = [
189
147
  name: "status",
190
148
  type: "string",
191
149
  description: "With --page, show only this bucket: passed, failing (failed or errored), " +
192
- "failed, errored, cancelled, quarantined, or unstable",
150
+ "failed, errored, cancelled, or quarantined",
193
151
  },
194
152
  {
195
153
  name: "ungrouped",
@@ -265,275 +223,33 @@ export const testCommands = [
265
223
  },
266
224
  {
267
225
  name: "tests create",
268
- summary: "Create a test case from a JSON action plan (for tests authored locally, e.g. by your coding agent)",
269
- description: "The plan is a JSON object whose steps are {action, selector, url, value, ...}: the first " +
270
- "EXECUTED step must be a goto, and at least one step must be an expect. Before anything is " +
271
- "banked, the plan is proven by replaying it in a browser ON YOUR MACHINE with the " +
272
- "Playwright runner bundled in the CLI (your repo's own @playwright/test wins when " +
273
- "present): the server renders the spec (`tests/compile`), the CLI runs it (minting " +
274
- "a run inbox for await_email steps and resolving the saved login exactly as a cloud run " +
275
- "would), and only a green replay creates the test (bound to the replayed plan by its hash). " +
276
- "This holds over MCP too: the replay runs on the machine hosting the MCP server, never on " +
277
- "Beryl's; if the Chromium browser is missing there the tool returns the install command " +
278
- "(`beryl init` installs it; on a terminal the CLI offers to). " +
279
- "A red replay banks NOTHING: the failure evidence comes back (over MCP the screenshot is " +
280
- "image content), you fix the plan file and re-run. No run is recorded: once a batch of " +
281
- "tests is banked, run their ids together once (`runs local`, or `runs trigger` for the " +
282
- "cloud) so they land as ONE run with real first results. A plan that signs in with a session Beryl captured " +
283
- "server-side cannot replay locally (that session never leaves Beryl's cloud). It falls " +
284
- "back to server-side verification automatically, and says so; so does a plan with third party " +
285
- "integration steps, which run only in Beryl's cloud. A session-mode plan replays locally " +
286
- "fine: the server renders it with its account's stored sign-in steps in front, so " +
287
- "the same identity is exercised on your machine. Optional `before` and `after` arrays hold setup and teardown " +
288
- "steps: `after` runs even when a main step fails, which is how a create/update/delete test " +
289
- "cleans up the record it made on the runs that go red. " +
290
- "Recovery: a 409 `duplicate_title` carries existing_test_id + existing_plan_hash, so " +
291
- "reconcile with that test (`tests get` / `tests set-plan`), don't rename-and-retry; a " +
292
- "409 `plan_hash_mismatch` means the submitted plan is not the bytes that were replayed " +
293
- "(re-run `tests create`); a 429 with Retry-After 30 means the verify slots are " +
294
- "saturated (wait and retry).",
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.
295
233
  scope: "project",
296
234
  flags: [
297
- { name: "title", type: "string", required: true, description: "Title for the new test" },
298
- { name: "file", type: "string", required: true, description: "Plan JSON file, or - for stdin" },
299
- {
300
- name: "description",
301
- type: "string",
302
- description: "One to three sentences stating what this test proves: the immutable outcome Beryl's healing " +
303
- "checks against. State the purpose, not the steps; the one observable signal that's true " +
304
- "only if the flow worked.",
305
- },
306
- {
307
- name: "no-verify",
308
- type: "boolean",
309
- description: "Skip verification entirely: bank the authored plan as-is, unproven",
310
- },
311
- {
312
- name: "url-override",
313
- type: "string",
314
- description: "Replay against this base URL instead of the environment's (e.g. http://localhost:3000). " +
315
- "The banked test is then unproven against its real environment, and the CLI says so.",
316
- },
317
- {
318
- name: "group",
319
- type: "strings",
320
- description: "Put the test in an existing project group (by name), repeatable. An unknown " +
321
- "name is an error (create groups with `beryl groups create`). Omit for no group.",
322
- },
323
- { name: "env", type: "string", description: "Environment id to compile and prove against" },
324
- {
325
- name: "access",
326
- type: "string",
327
- description: "read (default): the test only reads the app. write: any step creates, edits, " +
328
- "renames, deletes or toggles something; a writer must clean up in `after`",
329
- },
330
- {
331
- name: "dir",
332
- type: "string",
333
- description: "Keep the rendered spec, artifacts, and JSON report under this directory",
334
- },
335
- ],
336
- examples: [
337
- 'beryl tests create --title "Checkout happy path" --file plan.json',
338
- '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."',
339
- 'beryl tests create --title "Checkout happy path" --file plan.json --url-override http://localhost:3000',
340
- ],
341
- async run(ctx, input) {
342
- const title = flagStr(input, "title");
343
- if (!title)
344
- throw new UsageError("--title is required");
345
- const { workspaceId, projectId } = await ctx.requireProject(input);
346
- const plan = readJsonFlag(input, "file");
347
- const description = flagStr(input, "description");
348
- const urlOverride = flagStr(input, "url-override");
349
- const env = flagStr(input, "env");
350
- const groups = flagStrings(input, "group");
351
- const access = accessFlag(input, "read");
352
- const bank = (extra) => ctx.client.post(`${projectPath(workspaceId, projectId)}/tests`, {
353
- title,
354
- plan,
355
- description,
356
- groups,
357
- access,
358
- ...extra,
359
- });
360
- if (flagBool(input, "no-verify")) {
361
- const created = await bank({ verify: false });
362
- return {
363
- data: created,
364
- human: yellow(`Banked UNPROVEN (--no-verify): ${created.id}`),
365
- };
366
- }
367
- const compile = () => ctx.client.post(`${projectPath(workspaceId, projectId)}/tests/compile`, {
368
- plan,
369
- title,
370
- base_url: urlOverride,
371
- environment_id: env,
372
- });
373
- let compiled = await compile();
374
- const verifyInCloud = async (reason) => {
375
- ctx.err(yellow(`! ${reason}, so this plan is verified server-side instead.`));
376
- try {
377
- return { data: await bank({ verify: true, plan_hash: compiled.plan_hash }) };
378
- }
379
- catch (err) {
380
- const failure = verifyFailureResult(err);
381
- if (failure)
382
- return failure;
383
- const conflict = createConflictResult(err, compiled.plan_hash);
384
- if (conflict)
385
- return conflict;
386
- throw err;
387
- }
388
- };
389
- if (planUsesThirdParty(plan))
390
- return verifyInCloud(THIRD_PARTY_CLOUD_ONLY);
391
- // Session-mode plan: establish the account's session once (server-side, reused
392
- // from the stored one when it still proves live) and replay against it — the
393
- // cloud shape. An unsupported account is re-compiled to sign itself in.
394
- let session;
395
- if (compiled.session_mode && compiled.account_id) {
396
- ctx.err(dim("Establishing the test account's session…"));
397
- const established = await establishAccountSession(ctx.client, projectPath(workspaceId, projectId), compiled.account_id);
398
- if (established.session) {
399
- session = established.session;
400
- }
401
- else if (established.unsupported) {
402
- ctx.err(yellow(`! ${established.error} — the replay signs in itself.`));
403
- compiled = await compile();
404
- }
405
- else {
406
- throw new CliError(established.error ?? "SESSION_UNAVAILABLE");
407
- }
408
- }
409
- // A captured session lives encrypted in Beryl's cloud and is never handed to
410
- // this machine — the cloud is the only place this plan can be proven.
411
- if (compiled.requires_auth && !session) {
412
- return verifyInCloud("A captured session never leaves Beryl's cloud");
413
- }
414
- if (compiled.login_config_error)
415
- throw new CliError(compiled.login_config_error);
416
- let loginPassword;
417
- if (compiled.uses_login_password) {
418
- try {
419
- const secret = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/config/secrets/LOGIN_PASSWORD/value`, { environment_id: env }));
420
- loginPassword = secret.value;
421
- }
422
- catch (err) {
423
- throw new CliError("Could not reveal the LOGIN_PASSWORD secret for the saved login: " +
424
- (err instanceof Error ? err.message : String(err)));
425
- }
426
- }
427
- const specOf = (content) => ({
428
- id: "local-verify",
429
- title,
430
- content,
431
- requiresAuth: false,
432
- usesEmail: /__vmInbox\(|__vmAwaitEmail\(/.test(content),
433
- usesLoginPassword: compiled.uses_login_password,
434
- inlinedLoginSteps: compiled.inlined_login_steps ?? 0,
435
- email: compiled.email ?? undefined,
436
- });
437
- const attempts = 1 + Math.max(0, compiled.policy.error_retries);
438
- let outcome;
439
- let runError;
440
- let installOffered = false;
441
- for (let attempt = 1; attempt <= attempts; attempt++) {
442
- if (attempt > 1) {
443
- // Fresh render per attempt: the errored attempt may already have consumed
444
- // the {{unique}}/inbox handles its spec minted — replaying the same spec
445
- // would collide on exactly the uniqueness those handles exist to dodge.
446
- ctx.err(dim(`Replay errored — recompiling and retrying (${attempt}/${attempts})…`));
447
- compiled = await compile();
448
- }
449
- ctx.err(dim(`Replaying "${title}" locally before banking…`));
450
- try {
451
- ({ outcome, runError } = await executeLocalSpec({ client: ctx.client, workspaceId, projectId }, {
452
- spec: specOf(compiled.content),
453
- dir: flagStr(input, "dir"),
454
- harvest: true,
455
- loginPassword,
456
- session,
457
- onEvent: (line) => ctx.err(dim(line)),
458
- }));
459
- }
460
- catch (err) {
461
- if (err instanceof PlaywrightMissingError) {
462
- // On a TTY offer the install once and redo this attempt; over MCP or a
463
- // pipe the hint is the answer.
464
- if (ctx.interactive && !installOffered) {
465
- installOffered = true;
466
- const cwd = process.cwd();
467
- const commands = installCommandsFor(playwrightGaps(cwd), cwd);
468
- if (await confirmInstall(ctx.prompt, commands)) {
469
- await installPlaywright(cwd, (line) => ctx.err(dim(line)));
470
- attempt -= 1;
471
- continue;
472
- }
473
- }
474
- throw new CliError(err.message);
475
- }
476
- throw err;
477
- }
478
- // A completed replay — green or red — is a verdict; only an errored one
479
- // (the spec never ran) earns a retry.
480
- if (outcome)
481
- break;
482
- }
483
- if (!outcome) {
484
- return {
485
- data: { banked: false, verified: false, error: runError },
486
- human: red(`Replay errored after ${attempts} attempt(s) — nothing was banked.`) +
487
- `\n${runError ?? "the spec did not run"}` +
488
- `\nFix the environment (or retry), then re-run \`beryl tests create\`.`,
489
- exitCode: 1,
490
- };
491
- }
492
- const result = outcome.results[0];
493
- if (outcome.failed > 0 || outcome.passed === 0) {
494
- const shot = outcome.harvested?.screenshot;
495
- const parts = [
496
- `Plan failed its local replay — nothing was banked.`,
497
- ...(result?.error ? ["", result.error] : []),
498
- "",
499
- "Fix the plan file and re-run `beryl tests create`. The replay artifacts " +
500
- (flagStr(input, "dir")
501
- ? `are under ${outcome.directory}.`
502
- : "can be kept with --dir."),
503
- ];
504
- return {
505
- data: { banked: false, verified: false, error: result?.error },
506
- human: parts.join("\n"),
507
- images: shot
508
- ? [{ data: shot.toString("base64"), mimeType: "image/png" }]
509
- : undefined,
510
- exitCode: 1,
511
- };
512
- }
513
- let created;
514
- try {
515
- created = await bank({ verify: false, plan_hash: compiled.plan_hash });
516
- }
517
- catch (err) {
518
- const conflict = createConflictResult(err, compiled.plan_hash);
519
- if (conflict)
520
- return conflict;
521
- throw err;
522
- }
523
- if (urlOverride) {
524
- ctx.err(yellow(`! The proof ran against ${urlOverride}, not the environment's root URL — ` +
525
- "the banked test is unproven against its real target until a run there passes."));
526
- }
527
- return {
528
- data: created,
529
- human: green(`✓ Replay passed locally — banked as ${created.id}`),
530
- };
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);
531
247
  },
532
248
  },
533
249
  {
534
250
  name: "tests set-plan",
535
251
  summary: "Replace a test's step plan from a JSON file (creates a new version)",
536
- 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 " +
537
253
  "`after` sections. `after` runs on pass and on fail, so cleanup happens even when the " +
538
254
  "test goes red. Pass `--description` when the re-authored plan changes what the test " +
539
255
  "proves; omit it to keep the test's existing intent. Saves the edit with NO replay: " +
@@ -577,13 +293,39 @@ export const testCommands = [
577
293
  };
578
294
  },
579
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
+ },
580
321
  {
581
322
  name: "tests set-groups",
582
323
  summary: "Replace the groups a test belongs to",
583
324
  description: "Groups are project-defined labels used to filter, run, or schedule a slice of the " +
584
325
  "suite (`beryl runs trigger --group <name>`). This REPLACES the whole set: pass every " +
585
326
  "group you want, or none to clear it. Names must already exist in the project " +
586
- "(`beryl groups list`); an unknown name is an error, never a new group.",
327
+ "(`beryl groups list`); an unknown name is an error, never a new group. Every " +
328
+ "surface of the test carries the same set.",
587
329
  scope: "project",
588
330
  args: [{ name: "test-id", description: "Test id", required: true }],
589
331
  flags: [
@@ -611,7 +353,8 @@ export const testCommands = [
611
353
  summary: "Mark a test read or write",
612
354
  description: "read: the test only reads the app. write: it creates, edits, renames, deletes or " +
613
355
  "toggles something that persists. The label is yours to set; Beryl does not infer it " +
614
- "from the plan. A write test must clean up in `after`, ending with a proof expect.",
356
+ "from the plan. A write test must clean up in `after`, ending with a proof expect. " +
357
+ "The label covers every surface of the test, so each surface's plan must clean up.",
615
358
  scope: "project",
616
359
  args: [
617
360
  { name: "test-id", description: "Test id", required: true },
@@ -903,8 +646,8 @@ export const testCommands = [
903
646
  name: "tests script",
904
647
  summary: "Print the rendered Playwright spec for a test (or an unbanked plan file)",
905
648
  description: "With a test id, fetches the banked test's rendered .spec.ts. With --file, compiles a " +
906
- "plan JSON that has NOT been banked yet (the same render `tests create` proves locally) " +
907
- "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 " +
908
651
  "executable spec. `tests plan` returns the stored JSON plan it is rendered from, " +
909
652
  "`tests get` the metadata row.",
910
653
  scope: "project",
@@ -195,7 +195,10 @@ export const trackerCommands = [
195
195
  "revoked; ticket changes queue until you reconnect), the number of queued changes, " +
196
196
  "open tickets (filed in the tracker), `unfiled_tickets` the tracker hasn't accepted " +
197
197
  "yet with its `last_sync_error`, digest findings, and `last_sync`: what the latest run filed, " +
198
- "updated, closed or skipped. Change settings with `envs update --tracker-*`.",
198
+ "updated, closed or skipped. `reset_fields` names stored settings that were invalid and " +
199
+ "fell back to defaults (`*` = all of them); filing, auto-close and the digest stop " +
200
+ "(`last_sync.skipped: settings_invalid`) until they are saved again. Change settings " +
201
+ "with `envs update --tracker-*`.",
199
202
  scope: "project",
200
203
  flags: [ENV_FLAG],
201
204
  async run(ctx, input) {
@@ -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/context.js CHANGED
@@ -133,8 +133,7 @@ export function createContext(options) {
133
133
  projectId = projects[0].id;
134
134
  }
135
135
  else if (projects.length === 0) {
136
- throw new CliError("This workspace has no project yet — create one with " +
137
- "`beryl projects create <url>` (add --no-explore to author tests yourself).");
136
+ throw new CliError("This workspace has no project yet. Create one with `beryl projects create <url>`.");
138
137
  }
139
138
  else {
140
139
  projectId = await pickOne(ctx, projects, "project", ambiguousMessage(ctx, "project", projects));
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,