@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.
@@ -1,12 +1,6 @@
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";
9
- import { ACTION_PLAN_SCHEMA } from "../schema.generated.js";
2
+ import { UsageError } from "../errors.js";
3
+ import { table } from "../output.js";
10
4
  import { arg, argList, flagBool, flagNum, flagStr, flagStrings, inGroup, projectPath, readJsonFlag, } from "./util.js";
11
5
  const testPath = (ws, p, id) => `${projectPath(ws, p)}/tests/${id}`;
12
6
  const EVENT_VERBS = {
@@ -32,27 +26,6 @@ function eventLine(e) {
32
26
  : "";
33
27
  return `${when} ${verb}${groups} ${who}${why}`;
34
28
  }
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
29
  // The server transcodes a failure screenshot to WebP to fit it under the payload cap,
57
30
  // so the format is not knowable up front — sniff it off the decoded magic bytes rather
58
31
  // than asserting PNG.
@@ -65,27 +38,6 @@ export function sniffImageMime(b64) {
65
38
  return "image/jpeg";
66
39
  return "image/png";
67
40
  }
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
41
  // The concise human table for `tests list` — the full TestResponse is a 24-column
90
42
  // firehose of internal ids that wraps unreadably in a normal terminal. `--wide`
91
43
  // (and `--json`) still expose every field.
@@ -110,54 +62,6 @@ function conciseTestRow(row) {
110
62
  };
111
63
  }
112
64
  export const testCommands = [
113
- {
114
- name: "tests lint",
115
- summary: "Validate a plan JSON file offline, before sending it to the server",
116
- description: "Checks a plan against the published ActionPlan JSON Schema: every action's required " +
117
- "fields, plus the two structural rules (the first EXECUTED step must be a goto, and at " +
118
- "least one step across before + steps must be an expect). Also the identity rules: a " +
119
- "plan that signs up with {{inbox_address}} and reads the mail must delete the account " +
120
- "in `after`, and no test plan may type {{mailbox_address}} (errors; the server refuses " +
121
- "both too). Pass --provisioning for an account's signup or sign-in plan, which may fill " +
122
- "{{mailbox_address}}. Runs entirely locally, so a malformed plan fails here instead of " +
123
- "costing a server round-trip. " +
124
- `Schema: ${ACTION_PLAN_SCHEMA.$id}`,
125
- scope: "none",
126
- flags: [
127
- { name: "file", type: "string", required: true, description: "Plan JSON file, or - for stdin" },
128
- {
129
- name: "provisioning",
130
- type: "boolean",
131
- description: "The file is an account's signup or sign-in plan (for `accounts signup` / " +
132
- "`accounts set-login`), so it may fill {{mailbox_address}}",
133
- },
134
- {
135
- name: "access",
136
- type: "string",
137
- description: "The label the plan will be banked with: read (default) or write. It is yours to " +
138
- "set; Beryl does not infer it. Only a write is checked (its closing teardown proof and {{unique}} names)",
139
- },
140
- ],
141
- examples: [
142
- "beryl tests lint --file plan.json",
143
- "beryl tests lint --file plan.json --access write",
144
- "beryl tests lint --file signup-plan.json --provisioning",
145
- ],
146
- async run(_ctx, input) {
147
- const issues = lintPlan(readJsonFlag(input, "file"), {
148
- provisioning: flagBool(input, "provisioning"),
149
- access: accessFlag(input, "read"),
150
- });
151
- const lines = issues.map((i) => `${i.severity === "warning" ? "warning" : "error"} ${i.path}: ${i.message}`);
152
- if (issues.every((i) => i.severity === "warning")) {
153
- return {
154
- data: { valid: true, issues },
155
- human: ["Plan is valid.", ...lines].join("\n"),
156
- };
157
- }
158
- return { data: { valid: false, issues }, human: lines.join("\n"), exitCode: 1 };
159
- },
160
- },
161
65
  {
162
66
  name: "tests list",
163
67
  summary: "List the project's tests with their latest result",
@@ -190,7 +94,7 @@ export const testCommands = [
190
94
  name: "status",
191
95
  type: "string",
192
96
  description: "With --page, show only this bucket: passed, failing (failed or errored), " +
193
- "failed, errored, cancelled, quarantined, or unstable",
97
+ "failed, errored, cancelled, or quarantined",
194
98
  },
195
99
  {
196
100
  name: "ungrouped",
@@ -264,290 +168,10 @@ export const testCommands = [
264
168
  return { data: version.json_plan, human: JSON.stringify(version.json_plan, null, 2) };
265
169
  },
266
170
  },
267
- {
268
- 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`.",
297
- scope: "project",
298
- 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
- };
545
- },
546
- },
547
171
  {
548
172
  name: "tests set-plan",
549
173
  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 " +
174
+ description: "Accepts an ActionPlan (the shape `tests plan` returns), including the optional `before` and " +
551
175
  "`after` sections. `after` runs on pass and on fail, so cleanup happens even when the " +
552
176
  "test goes red. Pass `--description` when the re-authored plan changes what the test " +
553
177
  "proves; omit it to keep the test's existing intent. Saves the edit with NO replay: " +
@@ -591,6 +215,31 @@ export const testCommands = [
591
215
  };
592
216
  },
593
217
  },
218
+ {
219
+ name: "tests set-intent",
220
+ summary: "Edit what a test proves, the target heal judges against",
221
+ description: "The intent is the plain-language statement of what the test proves; heal judges every " +
222
+ "repair against it. It belongs to the test, so every surface of the test gets a new " +
223
+ "version carrying the new text (same plan, recorded as your edit; see `tests versions`). " +
224
+ "The plan is NOT regenerated: change the steps with `tests set-plan`. Re-explore keeps " +
225
+ "matching the same test after an edit.",
226
+ scope: "project",
227
+ args: [
228
+ { name: "test-id", description: "Test id", required: true },
229
+ { name: "intent", description: "New intent text", required: true },
230
+ ],
231
+ examples: [
232
+ 'beryl tests set-intent 4f… "Proves a shopper can check out and sees an order number."',
233
+ ],
234
+ async run(ctx, input) {
235
+ const { workspaceId, projectId } = await ctx.requireProject(input);
236
+ return {
237
+ data: await ctx.client.patch(testPath(workspaceId, projectId, arg(input, "test-id")), {
238
+ intent: arg(input, "intent"),
239
+ }),
240
+ };
241
+ },
242
+ },
594
243
  {
595
244
  name: "tests set-groups",
596
245
  summary: "Replace the groups a test belongs to",
@@ -917,38 +566,14 @@ export const testCommands = [
917
566
  },
918
567
  {
919
568
  name: "tests script",
920
- summary: "Print the rendered Playwright spec for a test (or an unbanked plan file)",
921
- 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 " +
924
- "executable spec. `tests plan` returns the stored JSON plan it is rendered from, " +
925
- "`tests get` the metadata row.",
569
+ summary: "Print the rendered Playwright spec for a test",
570
+ description: "Fetches the banked test's rendered .spec.ts: the executable spec. `tests plan` " +
571
+ "returns the stored JSON plan it is rendered from, `tests get` the metadata row.",
926
572
  scope: "project",
927
- args: [{ name: "test-id", description: "Test id (omit when passing --file)" }],
928
- flags: [
929
- { name: "file", type: "string", description: "Compile this plan JSON file instead of a banked test" },
930
- {
931
- name: "url-override",
932
- type: "string",
933
- description: "With --file: resolve relative gotos against this base URL",
934
- },
935
- { name: "env", type: "string", description: "With --file: environment id to render against" },
936
- ],
937
- examples: ["beryl tests script 4f…", "beryl tests script --file plan.json"],
573
+ args: [{ name: "test-id", description: "Test id", required: true }],
574
+ examples: ["beryl tests script 4f…"],
938
575
  async run(ctx, input) {
939
576
  const { workspaceId, projectId } = await ctx.requireProject(input);
940
- const id = input.args["test-id"];
941
- const file = flagStr(input, "file");
942
- if (Boolean(id) === Boolean(file))
943
- throw new UsageError("Pass exactly one of <test-id> or --file");
944
- if (file) {
945
- const data = (await ctx.client.post(`${projectPath(workspaceId, projectId)}/tests/compile`, {
946
- plan: readJsonFlag(input, "file"),
947
- base_url: flagStr(input, "url-override"),
948
- environment_id: flagStr(input, "env"),
949
- }));
950
- return { data: data, human: data.content };
951
- }
952
577
  const data = (await ctx.client.get(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/script`));
953
578
  const script = typeof data === "string" ? data : (data.script ?? data.content ?? data);
954
579
  return { data, human: typeof script === "string" ? script : JSON.stringify(data, null, 2) };
@@ -9,7 +9,8 @@ export const thirdPartyIntegrationCommands = [
9
9
  groupDefault: true,
10
10
  groupSummary: "Test a site's own integrations (Connect Slack) against Beryl's accounts on those providers.",
11
11
  description: "Lists the providers set up on this Beryl deployment. A test may use " +
12
- "`third_party_authorize` / `await_third_party` only for a provider listed here; " +
12
+ "`third_party_authorize` / `await_third_party` / `send_third_party` only for a " +
13
+ "provider listed here; " +
13
14
  "anything else is refused at save time.",
14
15
  scope: "project",
15
16
  async run(ctx, input) {
@@ -31,6 +32,8 @@ export const thirdPartyIntegrationCommands = [
31
32
  "channel in the app's settings; `await_third_party` then waits for the app's message " +
32
33
  "there. `recorded_app_ids` maps each environment id to the app whose first message " +
33
34
  "was seen there; an environment accepts only its own app once recorded. " +
35
+ "Pass `env` and `app` to record that app yourself (a Slack app id, `A...`), which " +
36
+ "`send_third_party` needs to know whose bot to mention. " +
34
37
  "A 409 means the provider is not set up on this deployment (see " +
35
38
  "`third-party-integrations list`).",
36
39
  scope: "project",
@@ -41,14 +44,34 @@ export const thirdPartyIntegrationCommands = [
41
44
  required: true,
42
45
  description: "Provider key from `third-party-integrations list`, e.g. slack",
43
46
  },
47
+ {
48
+ name: "env",
49
+ type: "string",
50
+ description: "Environment id to record `app` for (with `app`)",
51
+ },
52
+ {
53
+ name: "app",
54
+ type: "string",
55
+ description: "The connected app's id on the provider, e.g. a Slack app id (with `env`)",
56
+ },
57
+ ],
58
+ examples: [
59
+ "beryl third-party-integrations target --provider slack",
60
+ "beryl third-party-integrations target --provider slack --env <env-id> --app A0123ABCD",
44
61
  ],
45
- examples: ["beryl third-party-integrations target --provider slack"],
46
62
  async run(ctx, input) {
47
63
  const provider = flagStr(input, "provider");
48
64
  if (!provider)
49
65
  throw new UsageError("--provider is required");
66
+ const env = flagStr(input, "env");
67
+ const app = flagStr(input, "app");
68
+ if (Boolean(env) !== Boolean(app))
69
+ throw new UsageError("--env and --app go together");
50
70
  const { workspaceId, projectId } = await ctx.requireProject(input);
51
- const target = (await ctx.client.post(`${integrationsPath(workspaceId, projectId)}/${encodeURIComponent(provider)}/target`));
71
+ const base = `${integrationsPath(workspaceId, projectId)}/${encodeURIComponent(provider)}/target`;
72
+ const target = (env && app
73
+ ? await ctx.client.put(`${base}/apps/${encodeURIComponent(env)}`, { app_id: app })
74
+ : await ctx.client.post(base));
52
75
  return {
53
76
  data: target,
54
77
  human: `#${target.external_name}\n\n` +
@@ -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
  };