@beryl-so/cli 0.54.1 → 0.61.0
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 +24 -5
- package/dist/beryl-test-skill.js +27 -0
- package/dist/commands/environments.js +35 -2
- package/dist/commands/health.js +380 -19
- package/dist/commands/runs.js +13 -7
- package/dist/commands/tests.js +93 -25
- package/dist/commands/third-party-integrations.js +59 -0
- package/dist/commands/tracker.js +295 -0
- package/dist/commands/workspaces.js +10 -4
- package/dist/http.js +2 -2
- package/dist/lint.js +35 -0
- package/dist/local-exec.js +5 -0
- package/dist/registry/index.js +4 -0
- package/dist/schema.generated.js +97 -2
- package/dist/skill-tables.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -191,7 +191,7 @@ Manage a project's environments: the URLs and auth Beryl runs tests against.
|
|
|
191
191
|
| `beryl envs list` | List a project's environments | `envs_list` |
|
|
192
192
|
| `beryl envs get <env-id>` | Show one environment | `envs_get` |
|
|
193
193
|
| `beryl envs create <name> <url>` | Add an environment (e.g. staging) to a project | `envs_create` |
|
|
194
|
-
| `beryl envs update <env-id>` | Update an environment's name, URL, or
|
|
194
|
+
| `beryl envs update <env-id>` | Update an environment's name, URL, auth settings, Site Health screen sizes, schedule, CI and pages, or issue tracker settings | `envs_update` |
|
|
195
195
|
| `beryl envs delete <env-id>` | Delete an environment | `envs_delete` |
|
|
196
196
|
|
|
197
197
|
### schedule
|
|
@@ -223,7 +223,8 @@ Author, inspect, version, and heal a project's tests: the checks Beryl runs on e
|
|
|
223
223
|
| `beryl tests set-groups <test-id>` | Replace the groups a test belongs to | `tests_set_groups` |
|
|
224
224
|
| `beryl tests set-access <test-id> <access>` | Mark a test read or write | `tests_set_access` |
|
|
225
225
|
| `beryl tests add-groups <test-ids...>` | Add groups to one or more tests, keeping the groups they already have | `tests_add_groups` |
|
|
226
|
-
| `beryl tests quarantine <
|
|
226
|
+
| `beryl tests quarantine <state> <test-ids...>` | Mute flaky tests: they keep running, but their failures stop failing the run | `tests_quarantine` |
|
|
227
|
+
| `beryl tests events <test-id>` | Who quarantined, grouped or renamed a test, and why | `tests_events` |
|
|
227
228
|
| `beryl tests delete <test-ids...>` | Delete tests, their version history, and their results | `tests_delete` |
|
|
228
229
|
| `beryl tests recompile <test-id>` | Validate + verify an edited plan against the live site before persisting | `tests_recompile` |
|
|
229
230
|
| `beryl tests versions <test-id>` | List a test's version history | `tests_versions` |
|
|
@@ -269,14 +270,21 @@ Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and downlo
|
|
|
269
270
|
|
|
270
271
|
### health
|
|
271
272
|
|
|
272
|
-
Site Health, or how your site reads to search engines and visitors: content, speed,
|
|
273
|
+
Site Health, or how your site reads to search engines and visitors: content, speed, links, security, and a visual check of layout at phone, tablet and desktop sizes, graded from a real check of your live pages.
|
|
273
274
|
|
|
274
275
|
`beryl health` with no subcommand runs `health get`.
|
|
275
276
|
|
|
276
277
|
| Command | Summary | MCP tool |
|
|
277
278
|
| --- | --- | --- |
|
|
278
|
-
| `beryl health get` | Show the latest Site Health report
|
|
279
|
-
| `beryl health run` | Run a fresh Site Health check
|
|
279
|
+
| `beryl health get` | Show the latest Site Health report, or one report by id | `health_get` |
|
|
280
|
+
| `beryl health run` | Run a fresh Site Health check, or a visual check of one page | `health_run` |
|
|
281
|
+
| `beryl health intended <key>` | Mark a visual finding as intended, so it stops counting against the grade | `health_intended` |
|
|
282
|
+
| `beryl health tracker status` | Show how Site Health files findings as issue tracker tickets for an environment | `health_tracker_status` |
|
|
283
|
+
| `beryl health tracker tickets` | List the findings Site Health tracks in the issue tracker, with their tickets | `health_tracker_tickets` |
|
|
284
|
+
| `beryl health tracker connect` | Get the link that connects an issue tracker to the workspace | `health_tracker_connect` |
|
|
285
|
+
| `beryl health tracker disconnect` | Remove the workspace's issue tracker connection | `health_tracker_disconnect` |
|
|
286
|
+
| `beryl health tracker teams` | List the teams of the connected issue tracker | `health_tracker_teams` |
|
|
287
|
+
| `beryl health tracker projects` | List a tracker team's projects | `health_tracker_projects` |
|
|
280
288
|
|
|
281
289
|
### explorations
|
|
282
290
|
|
|
@@ -326,6 +334,17 @@ The project's standing email addresses, where its tests receive sign-in mail.
|
|
|
326
334
|
| `beryl mailbox read <mailbox-id>` | Read the latest email in a mailbox (waits for one to arrive) | `mailbox_read` |
|
|
327
335
|
| `beryl mailbox emails <mailbox-id>` | List the emails a mailbox has received | `mailbox_emails` |
|
|
328
336
|
|
|
337
|
+
### third-party-integrations
|
|
338
|
+
|
|
339
|
+
Test a site's own integrations (Connect Slack) against Beryl's accounts on those providers.
|
|
340
|
+
|
|
341
|
+
`beryl third-party-integrations` with no subcommand runs `third-party-integrations list`.
|
|
342
|
+
|
|
343
|
+
| Command | Summary | MCP tool |
|
|
344
|
+
| --- | --- | --- |
|
|
345
|
+
| `beryl third-party-integrations list` | The third party providers this project's tests can connect to | `third_party_integrations_list` |
|
|
346
|
+
| `beryl third-party-integrations target` | The channel this project's tests pick when the app asks where to post | `third_party_integrations_target` |
|
|
347
|
+
|
|
329
348
|
### accounts
|
|
330
349
|
|
|
331
350
|
Durable identities on the site under test: what an authenticated test signs in as.
|
package/dist/beryl-test-skill.js
CHANGED
|
@@ -551,4 +551,31 @@ against your test's **intent**:
|
|
|
551
551
|
So a test is *healable* exactly when it has **a strong intent + a real outcome
|
|
552
552
|
assertion**. With a vague intent and a chrome-only assertion Beryl can't tell
|
|
553
553
|
regression from cosmetic drift — it either heals over real breakage or fails on noise.
|
|
554
|
+
|
|
555
|
+
## 7. Third party integration tests
|
|
556
|
+
|
|
557
|
+
An app's own integrations (its "Connect Slack" button, the message it then posts) are
|
|
558
|
+
testable against Beryl's account on that provider. Call
|
|
559
|
+
\`third_party_integrations_list\` first; if the provider is not listed, do not write the
|
|
560
|
+
test.
|
|
561
|
+
|
|
562
|
+
Two shapes:
|
|
563
|
+
|
|
564
|
+
- **Connect from scratch**: declare \`"third_party_integrations": ["slack"]\`, click the
|
|
565
|
+
app's Connect button, then \`{ "action": "third_party_authorize", "provider": "slack" }\`
|
|
566
|
+
approves the consent page and returns to the app. Pick the channel
|
|
567
|
+
\`third_party_integrations_target\` names, trigger the app's message, then
|
|
568
|
+
\`{ "action": "await_third_party", "provider": "slack", "expect_text": "..." }\`.
|
|
569
|
+
- **Integration intact**: no declaration. Trigger the app's message, then
|
|
570
|
+
\`await_third_party\`.
|
|
571
|
+
|
|
572
|
+
Rules:
|
|
573
|
+
|
|
574
|
+
- Always tear down what the test connected in \`after\`: disconnect the integration in
|
|
575
|
+
the app.
|
|
576
|
+
- These steps run only in Beryl's cloud: \`tests_create\` verifies server-side and
|
|
577
|
+
\`runs_local\` skips them, so run them with \`runs_trigger\`.
|
|
578
|
+
- A setup error saying Beryl's access "is being restored" is on Beryl's side: do not
|
|
579
|
+
edit the test.
|
|
580
|
+
- Never add steps that browse Slack itself; the actions above do all of it.
|
|
554
581
|
`;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { UsageError } from "../errors.js";
|
|
2
|
-
import {
|
|
2
|
+
import { HEALTH_RUN_SETTING_FLAGS, VIEWPORTS, runSettingsFromFlags } from "./health.js";
|
|
3
|
+
import { TRACKER_SETTING_FLAGS, trackerSettingsFromFlags } from "./tracker.js";
|
|
4
|
+
import { arg, findGroup, flagBool, flagNum, flagStr, flagStrings, projectPath } from "./util.js";
|
|
3
5
|
const SCHEDULE_GROUPS_FLAG = "Comma-separated group names or ids the schedule targets (the run covers the union of their active tests at fire time); omit to run every active test";
|
|
4
6
|
const SCHEDULE_CADENCE_FLAGS = [
|
|
5
7
|
{
|
|
@@ -65,13 +67,30 @@ export const environmentCommands = [
|
|
|
65
67
|
},
|
|
66
68
|
{
|
|
67
69
|
name: "envs update",
|
|
68
|
-
summary: "Update an environment's name, URL, or
|
|
70
|
+
summary: "Update an environment's name, URL, auth settings, Site Health screen sizes, schedule, CI and pages, or issue tracker settings",
|
|
69
71
|
scope: "project",
|
|
70
72
|
args: [{ name: "env-id", description: "Environment id", required: true }],
|
|
71
73
|
flags: [
|
|
72
74
|
{ name: "name", type: "string", description: "New name" },
|
|
73
75
|
{ name: "url", type: "string", description: "New root URL" },
|
|
74
76
|
{ name: "auth", type: "string", enum: ["public", "gated"], description: "New auth choice" },
|
|
77
|
+
{
|
|
78
|
+
name: "viewport",
|
|
79
|
+
type: "strings",
|
|
80
|
+
enum: ["all", ...VIEWPORTS],
|
|
81
|
+
description: "Screen size the Site Health visual checks run at, repeatable; replaces the set. " +
|
|
82
|
+
"Pass `--viewport all` to reset to all five.",
|
|
83
|
+
},
|
|
84
|
+
...HEALTH_RUN_SETTING_FLAGS,
|
|
85
|
+
...TRACKER_SETTING_FLAGS,
|
|
86
|
+
],
|
|
87
|
+
examples: [
|
|
88
|
+
"beryl envs update <env-id> --tracker linear --tracker-team <team-id>",
|
|
89
|
+
"beryl envs update <env-id> --tracker-threshold medium --tracker-max-tickets 5",
|
|
90
|
+
"beryl envs update <env-id> --tracker-priority high=urgent --tracker-pause on",
|
|
91
|
+
"beryl envs update <env-id> --health-schedule daily --health-time 06:00 --health-timezone Europe/Berlin",
|
|
92
|
+
"beryl envs update <env-id> --health-pages crawl --health-max-pages 25 --health-exclude '/blog/*'",
|
|
93
|
+
"beryl envs update <env-id> --health-ci main_only --health-ci-fail-on medium",
|
|
75
94
|
],
|
|
76
95
|
async run(ctx, input) {
|
|
77
96
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
@@ -82,6 +101,20 @@ export const environmentCommands = [
|
|
|
82
101
|
body.root_url = flagStr(input, "url");
|
|
83
102
|
if (flagStr(input, "auth"))
|
|
84
103
|
body.requires_auth_choice = flagStr(input, "auth");
|
|
104
|
+
const viewports = flagStrings(input, "viewport");
|
|
105
|
+
if (viewports) {
|
|
106
|
+
const bad = viewports.find((v) => v !== "all" && !VIEWPORTS.includes(v));
|
|
107
|
+
if (bad) {
|
|
108
|
+
throw new UsageError(`--viewport must be one of: all, ${VIEWPORTS.join(", ")}`);
|
|
109
|
+
}
|
|
110
|
+
body.site_health_viewports = viewports.includes("all") ? [] : viewports;
|
|
111
|
+
}
|
|
112
|
+
const tracker = trackerSettingsFromFlags(input);
|
|
113
|
+
if (tracker)
|
|
114
|
+
body.site_health_tracker = tracker;
|
|
115
|
+
const runs = runSettingsFromFlags(input);
|
|
116
|
+
if (runs)
|
|
117
|
+
body.site_health_runs = runs;
|
|
85
118
|
if (Object.keys(body).length === 0)
|
|
86
119
|
throw new UsageError("Nothing to update");
|
|
87
120
|
return {
|
package/dist/commands/health.js
CHANGED
|
@@ -1,43 +1,404 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { UsageError } from "../errors.js";
|
|
2
|
+
import { arg, flagBool, flagNum, flagStr, flagStrings, projectPath } from "./util.js";
|
|
2
3
|
const ENV_FLAG = {
|
|
3
4
|
name: "env",
|
|
4
5
|
type: "string",
|
|
5
6
|
description: "Environment id (defaults to the project's default environment)",
|
|
6
7
|
};
|
|
8
|
+
export const VIEWPORTS = [
|
|
9
|
+
"phone",
|
|
10
|
+
"tablet_portrait",
|
|
11
|
+
"tablet_landscape",
|
|
12
|
+
"desktop",
|
|
13
|
+
"desktop_wide",
|
|
14
|
+
];
|
|
15
|
+
const SCREENSHOTS_FLAG = {
|
|
16
|
+
name: "screenshots",
|
|
17
|
+
type: "boolean",
|
|
18
|
+
description: "Include the annotated screenshots (as images over MCP, data URIs in --json)",
|
|
19
|
+
};
|
|
20
|
+
// Annotated evidence is spent from the agent's context window; the top few carry it.
|
|
21
|
+
const MAX_MCP_IMAGES = 3;
|
|
22
|
+
const WAIT_POLL_MS = 5000;
|
|
23
|
+
const WAIT_LIMIT_MS = 6 * 60 * 1000;
|
|
24
|
+
function toImage(dataUri) {
|
|
25
|
+
const [, mimeType, data] = /^data:([^;]+);base64,(.*)$/.exec(dataUri) ?? [];
|
|
26
|
+
return mimeType && data ? { data, mimeType } : undefined;
|
|
27
|
+
}
|
|
28
|
+
// Over MCP the screenshots go out as image blocks and never as inline base64 text.
|
|
29
|
+
function present(ctx, report, withShots) {
|
|
30
|
+
if (!withShots || !ctx.mcp)
|
|
31
|
+
return { data: report };
|
|
32
|
+
const images = [];
|
|
33
|
+
for (const cat of report.categories ?? []) {
|
|
34
|
+
for (const f of cat.findings ?? []) {
|
|
35
|
+
if (f.screenshot && !f.intended && f.status !== "pass" && images.length < MAX_MCP_IMAGES) {
|
|
36
|
+
const img = toImage(f.screenshot);
|
|
37
|
+
if (img)
|
|
38
|
+
images.push(img);
|
|
39
|
+
}
|
|
40
|
+
delete f.screenshot;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const stripped = { ...report, screenshots: null };
|
|
44
|
+
return { data: stripped, images: images.length ? images : undefined };
|
|
45
|
+
}
|
|
46
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
47
|
+
// A run fans out to up to 100 pages, each on its own worker.
|
|
48
|
+
const CI_WAIT_LIMIT_MS = 30 * 60 * 1000;
|
|
49
|
+
const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
|
50
|
+
const ON_OFF = ["on", "off"];
|
|
51
|
+
/** `envs update` flags for when Site Health runs and which pages it checks. */
|
|
52
|
+
export const HEALTH_RUN_SETTING_FLAGS = [
|
|
53
|
+
{
|
|
54
|
+
name: "health-schedule",
|
|
55
|
+
type: "string",
|
|
56
|
+
enum: ["off", "daily", "weekly"],
|
|
57
|
+
description: "Run Site Health on a schedule (default off)",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "health-time",
|
|
61
|
+
type: "string",
|
|
62
|
+
placeholder: "HH:MM",
|
|
63
|
+
description: "Time of day scheduled runs start, 24h, in --health-timezone (default 09:00)",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "health-day",
|
|
67
|
+
type: "string",
|
|
68
|
+
enum: DAYS,
|
|
69
|
+
description: "Day weekly runs start (default mon)",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "health-timezone",
|
|
73
|
+
type: "string",
|
|
74
|
+
description: "IANA timezone for the schedule, e.g. Europe/Berlin (default UTC)",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "health-ci",
|
|
78
|
+
type: "string",
|
|
79
|
+
enum: ["off", "every_build", "main_only", "deploy_only"],
|
|
80
|
+
description: "Which CI builds `health run --ci` checks; other builds skip and pass (default off)",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: "health-ci-fail-on",
|
|
84
|
+
type: "string",
|
|
85
|
+
enum: ["high", "medium", "never"],
|
|
86
|
+
description: "Lowest finding severity that fails the CI build; never reports only (default high)",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "health-ci-tickets",
|
|
90
|
+
type: "string",
|
|
91
|
+
enum: ON_OFF,
|
|
92
|
+
description: "CI runs file tracker tickets; off leaves the findings to the PR (default off)",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: "health-pages",
|
|
96
|
+
type: "string",
|
|
97
|
+
enum: ["homepage", "paths", "sitemap", "crawl"],
|
|
98
|
+
description: "Pages a run checks: the homepage, --health-path list, sitemap.xml, or a crawl from " +
|
|
99
|
+
"the homepage (default homepage)",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "health-path",
|
|
103
|
+
type: "strings",
|
|
104
|
+
description: "Page path for the paths mode, repeatable; replaces the list. `none` clears it",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "health-crawl-depth",
|
|
108
|
+
type: "number",
|
|
109
|
+
description: "Link hops from the homepage in crawl mode, 1-5 (default 2)",
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "health-max-pages",
|
|
113
|
+
type: "number",
|
|
114
|
+
description: "Most pages one run checks, homepage included, 1-100 (default 25)",
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "health-include",
|
|
118
|
+
type: "strings",
|
|
119
|
+
description: "Path pattern a page must match, e.g. '/blog/*', repeatable; replaces the list. `none` clears it",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: "health-exclude",
|
|
123
|
+
type: "strings",
|
|
124
|
+
description: "Path pattern to skip, repeatable; replaces the list. `none` clears it",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: "health-logged-in",
|
|
128
|
+
type: "strings",
|
|
129
|
+
description: "Path pattern checked signed in with the environment's saved test sign-in, repeatable; " +
|
|
130
|
+
"the rest run logged out. `none` clears it",
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: "health-retries",
|
|
134
|
+
type: "number",
|
|
135
|
+
description: "Extra attempts when a page check fails or times out, 0-3 (default 1)",
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
function list(input, name) {
|
|
139
|
+
const values = flagStrings(input, name);
|
|
140
|
+
if (!values)
|
|
141
|
+
return undefined;
|
|
142
|
+
return values.includes("none") ? [] : values;
|
|
143
|
+
}
|
|
144
|
+
/** The `site_health_runs` PATCH body from `envs update` flags; undefined when none were given. */
|
|
145
|
+
export function runSettingsFromFlags(input) {
|
|
146
|
+
const body = {};
|
|
147
|
+
const strings = [
|
|
148
|
+
["health-schedule", "schedule"],
|
|
149
|
+
["health-timezone", "schedule_timezone"],
|
|
150
|
+
["health-ci", "ci_mode"],
|
|
151
|
+
["health-ci-fail-on", "ci_fail_on"],
|
|
152
|
+
["health-pages", "pages"],
|
|
153
|
+
];
|
|
154
|
+
for (const [flag, field] of strings) {
|
|
155
|
+
const v = flagStr(input, flag);
|
|
156
|
+
if (v !== undefined)
|
|
157
|
+
body[field] = v;
|
|
158
|
+
}
|
|
159
|
+
const time = flagStr(input, "health-time");
|
|
160
|
+
if (time !== undefined) {
|
|
161
|
+
const m = /^(\d{1,2}):(\d{2})$/.exec(time);
|
|
162
|
+
if (!m || Number(m[1]) > 23 || Number(m[2]) > 59) {
|
|
163
|
+
throw new UsageError("--health-time must be HH:MM, 24h, e.g. 09:30");
|
|
164
|
+
}
|
|
165
|
+
body.schedule_hour = Number(m[1]);
|
|
166
|
+
body.schedule_minute = Number(m[2]);
|
|
167
|
+
}
|
|
168
|
+
const day = flagStr(input, "health-day");
|
|
169
|
+
if (day !== undefined) {
|
|
170
|
+
const idx = DAYS.indexOf(day.toLowerCase());
|
|
171
|
+
if (idx < 0)
|
|
172
|
+
throw new UsageError(`--health-day must be one of: ${DAYS.join(", ")}`);
|
|
173
|
+
body.schedule_day_of_week = idx;
|
|
174
|
+
}
|
|
175
|
+
const tickets = flagStr(input, "health-ci-tickets");
|
|
176
|
+
if (tickets !== undefined) {
|
|
177
|
+
if (!ON_OFF.includes(tickets))
|
|
178
|
+
throw new UsageError("--health-ci-tickets must be on or off");
|
|
179
|
+
body.ci_files_tickets = tickets === "on";
|
|
180
|
+
}
|
|
181
|
+
const lists = [
|
|
182
|
+
["health-path", "paths"],
|
|
183
|
+
["health-include", "include"],
|
|
184
|
+
["health-exclude", "exclude"],
|
|
185
|
+
["health-logged-in", "logged_in"],
|
|
186
|
+
];
|
|
187
|
+
for (const [flag, field] of lists) {
|
|
188
|
+
const v = list(input, flag);
|
|
189
|
+
if (v !== undefined)
|
|
190
|
+
body[field] = v;
|
|
191
|
+
}
|
|
192
|
+
const numbers = [
|
|
193
|
+
["health-crawl-depth", "crawl_depth"],
|
|
194
|
+
["health-max-pages", "max_pages"],
|
|
195
|
+
["health-retries", "retries"],
|
|
196
|
+
];
|
|
197
|
+
for (const [flag, field] of numbers) {
|
|
198
|
+
const v = flagNum(input, flag);
|
|
199
|
+
if (v !== undefined)
|
|
200
|
+
body[field] = v;
|
|
201
|
+
}
|
|
202
|
+
return Object.keys(body).length ? body : undefined;
|
|
203
|
+
}
|
|
204
|
+
function ciBranch() {
|
|
205
|
+
const env = process.env;
|
|
206
|
+
return (env.GITHUB_HEAD_REF ||
|
|
207
|
+
env.GITHUB_REF_NAME ||
|
|
208
|
+
env.CI_COMMIT_REF_NAME ||
|
|
209
|
+
env.CIRCLE_BRANCH ||
|
|
210
|
+
env.BITBUCKET_BRANCH ||
|
|
211
|
+
env.BRANCH_NAME ||
|
|
212
|
+
undefined);
|
|
213
|
+
}
|
|
214
|
+
// Markdown, so a CI step can post it as the PR comment as is.
|
|
215
|
+
export function ciMarkdown(run) {
|
|
216
|
+
if (run.skipped)
|
|
217
|
+
return `Beryl Site Health: skipped (${run.reason ?? "CI mode"}).`;
|
|
218
|
+
const f = run.findings ?? {};
|
|
219
|
+
const verdict = run.status === "failed"
|
|
220
|
+
? "failed to complete"
|
|
221
|
+
: run.gate?.passed
|
|
222
|
+
? "passed"
|
|
223
|
+
: `failed (findings at or above ${run.gate?.fail_on})`;
|
|
224
|
+
const lines = [
|
|
225
|
+
`**Beryl Site Health ${verdict}**`,
|
|
226
|
+
"",
|
|
227
|
+
`${run.pages?.length ?? 0} page(s) checked. Open findings: ${f.high ?? 0} high, ` +
|
|
228
|
+
`${f.medium ?? 0} medium, ${f.low ?? 0} low.`,
|
|
229
|
+
];
|
|
230
|
+
if (run.failing?.length) {
|
|
231
|
+
lines.push("", "| Page | Severity | Finding |", "| --- | --- | --- |");
|
|
232
|
+
for (const x of run.failing)
|
|
233
|
+
lines.push(`| ${x.page_path} | ${x.severity} | ${x.title} |`);
|
|
234
|
+
}
|
|
235
|
+
const broken = (run.pages ?? []).filter((p) => p.status === "failed" || p.problem);
|
|
236
|
+
if (broken.length) {
|
|
237
|
+
lines.push("", "Pages that could not be checked:");
|
|
238
|
+
for (const p of broken) {
|
|
239
|
+
lines.push(`- ${p.page_path}: ${p.problem ?? p.error ?? "check failed"}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return lines.join("\n");
|
|
243
|
+
}
|
|
244
|
+
async function runCi(ctx, input, base) {
|
|
245
|
+
let run = (await ctx.client.post(`${base}/runs`, {
|
|
246
|
+
trigger: "ci",
|
|
247
|
+
branch: flagStr(input, "branch") ?? ciBranch(),
|
|
248
|
+
deploy: flagBool(input, "deploy"),
|
|
249
|
+
}, { environment_id: flagStr(input, "env") }));
|
|
250
|
+
if (run.skipped)
|
|
251
|
+
return { data: run, human: ciMarkdown(run) };
|
|
252
|
+
const deadline = Date.now() + CI_WAIT_LIMIT_MS;
|
|
253
|
+
while (run.status === "queued" || run.status === "running") {
|
|
254
|
+
if (Date.now() > deadline) {
|
|
255
|
+
return { data: run, human: "Beryl Site Health: timed out waiting for the run.", exitCode: 1 };
|
|
256
|
+
}
|
|
257
|
+
await sleep(WAIT_POLL_MS);
|
|
258
|
+
run = (await ctx.client.get(`${base}/runs/${run.run_id}`));
|
|
259
|
+
}
|
|
260
|
+
return { data: run, human: ciMarkdown(run), exitCode: run.gate?.passed ? 0 : 1 };
|
|
261
|
+
}
|
|
7
262
|
export const healthCommands = [
|
|
8
263
|
{
|
|
9
264
|
name: "health get",
|
|
10
265
|
groupDefault: true,
|
|
11
266
|
groupSummary: "Site Health, or how your site reads to search engines and visitors: content, " +
|
|
12
|
-
"speed,
|
|
13
|
-
|
|
267
|
+
"speed, links, security, and a visual check of layout at phone, tablet and desktop " +
|
|
268
|
+
"sizes, graded from a real check of your live pages.",
|
|
269
|
+
summary: "Show the latest Site Health report, or one report by id",
|
|
14
270
|
description: "A check runs automatically when a project or environment gets its URL. While one " +
|
|
15
|
-
"is in flight the report comes back with status queued/running and no grades yet
|
|
16
|
-
"call again to pick up the finished result."
|
|
271
|
+
"is in flight the report comes back with status queued/running and no grades yet, " +
|
|
272
|
+
"plus the `stage` a running check is on; call again to pick up the finished result. Visual findings sit in the `layout` " +
|
|
273
|
+
"category, which also grades them per screen size (`screens`: phone, tablet, " +
|
|
274
|
+
"desktop) and as `mobile` (phone and tablet only). Each carries a `key`, the " +
|
|
275
|
+
"`selector`, measured values, the `viewports` it shows at and a fix hint in " +
|
|
276
|
+
"`recommendation`. The `performance` category grades Google PageSpeed on mobile " +
|
|
277
|
+
"and on desktop separately (`speeds`, each with its own Core Web Vitals) and takes " +
|
|
278
|
+
"the worse of the two; one that couldn't be measured is left out. Pass --report for a single-page check started with " +
|
|
279
|
+
"`health run --path`.",
|
|
17
280
|
scope: "project",
|
|
18
|
-
flags: [
|
|
281
|
+
flags: [
|
|
282
|
+
ENV_FLAG,
|
|
283
|
+
{ name: "report", type: "string", description: "Report id (from `health run`)" },
|
|
284
|
+
{
|
|
285
|
+
name: "run",
|
|
286
|
+
type: "string",
|
|
287
|
+
description: "Run id: every page of one run, finding counts and the CI gate verdict",
|
|
288
|
+
},
|
|
289
|
+
SCREENSHOTS_FLAG,
|
|
290
|
+
],
|
|
19
291
|
async run(ctx, input) {
|
|
20
292
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
293
|
+
const runId = flagStr(input, "run");
|
|
294
|
+
if (runId) {
|
|
295
|
+
return {
|
|
296
|
+
data: await ctx.client.get(`${projectPath(workspaceId, projectId)}/seo-report/runs/${runId}`),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
const withShots = flagBool(input, "screenshots");
|
|
300
|
+
const reportId = flagStr(input, "report");
|
|
301
|
+
const base = `${projectPath(workspaceId, projectId)}/seo-report`;
|
|
302
|
+
const report = (await ctx.client.get(reportId ? `${base}/${reportId}` : base, {
|
|
303
|
+
environment_id: reportId ? undefined : flagStr(input, "env"),
|
|
304
|
+
screenshots: withShots ? "true" : "false",
|
|
305
|
+
}));
|
|
306
|
+
return present(ctx, report, withShots);
|
|
26
307
|
},
|
|
27
308
|
},
|
|
28
309
|
{
|
|
29
310
|
name: "health run",
|
|
30
|
-
summary: "Run a fresh Site Health check
|
|
31
|
-
description: "Queues a
|
|
32
|
-
"
|
|
311
|
+
summary: "Run a fresh Site Health check, or a visual check of one page",
|
|
312
|
+
description: "Queues a check and returns immediately (or, with --wait, once it finishes; a check " +
|
|
313
|
+
"takes one to three minutes). Without --path it is the full Site Health run of the " +
|
|
314
|
+
"environment URL. With --path it runs only the visual checks (clipping, overlap, tap " +
|
|
315
|
+
"targets, scroll traps, content stability, scroll-through) on that page, logged out " +
|
|
316
|
+
"unless it matches the environment's --health-logged-in patterns, " +
|
|
317
|
+
"at the chosen --viewport sizes (default: the environment's configured set). Returns " +
|
|
318
|
+
"the in-flight report instead of stacking a second one of the same scope. A full run " +
|
|
319
|
+
"also checks the environment's configured pages (see `envs update --health-pages`); " +
|
|
320
|
+
"each page gets its own report, grouped under the homepage report's `run_id`.\n\n" +
|
|
321
|
+
"With --ci it starts a CI run, waits for every page, prints a Markdown summary you can " +
|
|
322
|
+
"post as the PR comment, and exits 1 when findings at or above the environment's " +
|
|
323
|
+
"fail-on severity exist, a page couldn't be checked, or the run failed. Builds that the " +
|
|
324
|
+
"environment's CI mode doesn't cover skip and exit 0. CI runs file tracker tickets only when --health-ci-tickets is on.",
|
|
325
|
+
scope: "project",
|
|
326
|
+
flags: [
|
|
327
|
+
ENV_FLAG,
|
|
328
|
+
{ name: "path", type: "string", description: "Page path to check, e.g. /pricing" },
|
|
329
|
+
{
|
|
330
|
+
name: "viewport",
|
|
331
|
+
type: "strings",
|
|
332
|
+
enum: VIEWPORTS,
|
|
333
|
+
description: "Screen size, repeatable: phone (390x844), tablet_portrait (768x1024), " +
|
|
334
|
+
"tablet_landscape (1024x768), desktop (1440x900), desktop_wide (1920x1080)",
|
|
335
|
+
},
|
|
336
|
+
{ name: "wait", type: "boolean", description: "Wait for the check to finish" },
|
|
337
|
+
SCREENSHOTS_FLAG,
|
|
338
|
+
{
|
|
339
|
+
name: "ci",
|
|
340
|
+
type: "boolean",
|
|
341
|
+
description: "CI run: wait, print a PR-ready summary, exit 1 at or above the fail-on severity",
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
name: "branch",
|
|
345
|
+
type: "string",
|
|
346
|
+
description: "Branch of the CI build (default: read from the CI environment)",
|
|
347
|
+
},
|
|
348
|
+
{ name: "deploy", type: "boolean", description: "The CI build is a deploy (for --health-ci deploy_only)" },
|
|
349
|
+
],
|
|
350
|
+
examples: [
|
|
351
|
+
"beryl health run",
|
|
352
|
+
"beryl health run --path /pricing --viewport phone --wait",
|
|
353
|
+
"beryl health run --ci",
|
|
354
|
+
"beryl health run --ci --deploy --env <env-id>",
|
|
355
|
+
],
|
|
356
|
+
async run(ctx, input) {
|
|
357
|
+
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
358
|
+
const base = `${projectPath(workspaceId, projectId)}/seo-report`;
|
|
359
|
+
const path = flagStr(input, "path");
|
|
360
|
+
const viewports = flagStrings(input, "viewport");
|
|
361
|
+
if (flagBool(input, "ci")) {
|
|
362
|
+
if (path || viewports)
|
|
363
|
+
throw new UsageError("--ci runs the configured pages; drop --path and --viewport");
|
|
364
|
+
return runCi(ctx, input, base);
|
|
365
|
+
}
|
|
366
|
+
const bad = viewports?.find((v) => !VIEWPORTS.includes(v));
|
|
367
|
+
if (bad)
|
|
368
|
+
throw new UsageError(`--viewport must be one of: ${VIEWPORTS.join(", ")}`);
|
|
369
|
+
const body = path || viewports ? { path, viewports } : undefined;
|
|
370
|
+
let report = (await ctx.client.post(base, body, {
|
|
371
|
+
environment_id: flagStr(input, "env"),
|
|
372
|
+
}));
|
|
373
|
+
const withShots = flagBool(input, "screenshots");
|
|
374
|
+
if (!flagBool(input, "wait"))
|
|
375
|
+
return { data: report };
|
|
376
|
+
const deadline = Date.now() + WAIT_LIMIT_MS;
|
|
377
|
+
while (report.status === "queued" || report.status === "running") {
|
|
378
|
+
if (Date.now() > deadline)
|
|
379
|
+
return { data: report, exitCode: 1 };
|
|
380
|
+
await sleep(WAIT_POLL_MS);
|
|
381
|
+
report = (await ctx.client.get(`${base}/${report.id}`, {
|
|
382
|
+
screenshots: withShots ? "true" : "false",
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
const result = present(ctx, report, withShots);
|
|
386
|
+
return report.status === "failed" ? { ...result, exitCode: 1 } : result;
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
name: "health intended",
|
|
391
|
+
summary: "Mark a visual finding as intended, so it stops counting against the grade",
|
|
392
|
+
description: "Takes the finding's `key` from `health get`. The finding stays in the report, flagged " +
|
|
393
|
+
"intended, and is left out of the grade on this report and every later run of the " +
|
|
394
|
+
"environment. --clear removes the mark.",
|
|
33
395
|
scope: "project",
|
|
34
|
-
|
|
35
|
-
|
|
396
|
+
args: [{ name: "key", description: "Finding key", required: true }],
|
|
397
|
+
flags: [ENV_FLAG, { name: "clear", type: "boolean", description: "Remove the mark" }],
|
|
36
398
|
async run(ctx, input) {
|
|
37
399
|
const { workspaceId, projectId } = await ctx.requireProject(input);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
};
|
|
400
|
+
const report = (await ctx.client.put(`${projectPath(workspaceId, projectId)}/seo-report/intended`, { key: arg(input, "key"), intended: !flagBool(input, "clear") }, { environment_id: flagStr(input, "env") }));
|
|
401
|
+
return present(ctx, report, false);
|
|
41
402
|
},
|
|
42
403
|
},
|
|
43
404
|
];
|
package/dist/commands/runs.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { downloadRunArtifacts, failureImages, isFailing, resultsOf, } from "../artifacts.js";
|
|
4
4
|
import { CliError, UsageError } from "../errors.js";
|
|
5
|
-
import { buildImportForm, establishAccountSession, executeLocalSpec, IMPORT_MAX_ERROR_LEN, toRunEntry, } from "../local-exec.js";
|
|
5
|
+
import { buildImportForm, establishAccountSession, executeLocalSpec, IMPORT_MAX_ERROR_LEN, specUsesThirdParty, THIRD_PARTY_USE_TRIGGER, toRunEntry, } from "../local-exec.js";
|
|
6
6
|
import { countPlannedFrames, countWrittenFrames, PlaywrightMissingError, } from "../local-run.js";
|
|
7
7
|
import { dim, green, red, yellow } from "../output.js";
|
|
8
8
|
import { anyGap, confirmInstall, describeGaps, installCommandsFor, installHint, installPlaywright, playwrightGaps, } from "../playwright-install.js";
|
|
@@ -152,7 +152,8 @@ export const runCommands = [
|
|
|
152
152
|
"in the cloud: their account signs in once per invocation and every session-mode " +
|
|
153
153
|
"test rides that session; a failed sign-in fails those tests with the same " +
|
|
154
154
|
"SESSION_* reason a cloud run reports. Only a test that depends on a session Beryl " +
|
|
155
|
-
"captured server-side
|
|
155
|
+
"captured server-side, or that has third party integration steps (cloud only), is " +
|
|
156
|
+
"skipped, with a note. Run those with `runs trigger`. Point " +
|
|
156
157
|
"--url-override at a local dev server or preview, and --dir to keep specs, artifacts, " +
|
|
157
158
|
"and reports on disk. Exits 0 only if every executed test passed.",
|
|
158
159
|
scope: "project",
|
|
@@ -255,6 +256,7 @@ export const runCommands = [
|
|
|
255
256
|
requiresAuth: Boolean(script.requires_auth),
|
|
256
257
|
usesEmail: /__vmInbox\(|__vmAwaitEmail\(/.test(script.content),
|
|
257
258
|
usesLoginPassword: Boolean(script.uses_login_password),
|
|
259
|
+
usesThirdParty: specUsesThirdParty(script.content),
|
|
258
260
|
inlinedLoginSteps: script.inlined_login_steps ?? 0,
|
|
259
261
|
...(script.session_mode && script.account_id
|
|
260
262
|
? { sessionMode: true, accountId: script.account_id }
|
|
@@ -288,13 +290,17 @@ export const runCommands = [
|
|
|
288
290
|
// a cloud run's setup phase does. A test whose account is missing its password
|
|
289
291
|
// would type a literal placeholder into the page: skip, with the server's fix-it
|
|
290
292
|
// message.
|
|
291
|
-
const skipped = specs.filter((s) => (s.requiresAuth && !(s.sessionMode && s.accountId)) ||
|
|
293
|
+
const skipped = specs.filter((s) => (s.requiresAuth && !(s.sessionMode && s.accountId)) ||
|
|
294
|
+
s.loginConfigError ||
|
|
295
|
+
s.usesThirdParty);
|
|
292
296
|
const runnable = specs.filter((s) => !skipped.includes(s));
|
|
293
297
|
for (const s of skipped) {
|
|
294
298
|
ctx.err(yellow(s.loginConfigError
|
|
295
299
|
? `! ${s.title}: skipped — ${s.loginConfigError}`
|
|
296
|
-
:
|
|
297
|
-
|
|
300
|
+
: s.usesThirdParty
|
|
301
|
+
? `! ${s.title}: skipped, ${THIRD_PARTY_USE_TRIGGER}.`
|
|
302
|
+
: `! ${s.title}: needs a session only Beryl's cloud holds, skipped ` +
|
|
303
|
+
`(use \`beryl runs trigger\`).`));
|
|
298
304
|
}
|
|
299
305
|
if (runnable.length === 0)
|
|
300
306
|
throw new CliError("None of the selected tests can run locally.");
|
|
@@ -585,8 +591,8 @@ export const runCommands = [
|
|
|
585
591
|
{
|
|
586
592
|
name: "status",
|
|
587
593
|
type: "string",
|
|
588
|
-
description: "With --page, show only this bucket: passed, failed, errored, cancelled,
|
|
589
|
-
"quarantined",
|
|
594
|
+
description: "With --page, show only this bucket: passed, failed, errored, cancelled, " +
|
|
595
|
+
"quarantined, or unstable (currently unstable in the run's environment)",
|
|
590
596
|
},
|
|
591
597
|
{
|
|
592
598
|
name: "group",
|