@beryl-so/cli 0.67.0 → 0.77.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -14
- package/dist/adapters/mcp.js +6 -4
- package/dist/beryl-test-skill.js +129 -107
- package/dist/commands/accounts.js +92 -10
- package/dist/commands/config-vars.js +87 -16
- package/dist/commands/environments.js +18 -1
- package/dist/commands/init.js +42 -108
- package/dist/commands/jobs.js +129 -0
- package/dist/commands/projects.js +68 -45
- package/dist/commands/runs.js +5 -432
- package/dist/commands/tests.js +34 -409
- package/dist/commands/third-party-integrations.js +26 -3
- package/dist/commands/watch.js +36 -0
- package/dist/lint.js +120 -6
- package/dist/registry/index.js +2 -0
- package/dist/schema.generated.js +152 -27
- package/dist/skill-tables.js +3 -0
- package/package.json +2 -3
- package/dist/email-pump.js +0 -138
- package/dist/local-exec.js +0 -239
- package/dist/local-run.js +0 -340
- package/dist/playwright-install.js +0 -220
- package/dist/progress.js +0 -44
package/dist/commands/tests.js
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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,
|
|
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
|
|
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
|
|
921
|
-
description: "
|
|
922
|
-
"
|
|
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
|
|
928
|
-
|
|
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
|
|
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
|
|
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` +
|
package/dist/commands/watch.js
CHANGED
|
@@ -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
|
};
|