@beryl-so/cli 0.78.0 → 0.92.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 +18 -7
- package/dist/adapters/mcp.js +23 -10
- package/dist/beryl-test-skill.js +177 -33
- package/dist/code-ref.js +81 -0
- package/dist/commands/accounts.js +74 -18
- package/dist/commands/config-vars.js +207 -20
- package/dist/commands/environments.js +14 -7
- package/dist/commands/init.js +2 -2
- package/dist/commands/jobs.js +152 -20
- package/dist/commands/projects.js +115 -20
- package/dist/commands/runs.js +68 -8
- package/dist/commands/tests.js +44 -9
- package/dist/commands/util.js +38 -6
- package/dist/commands/watch.js +3 -2
- package/dist/config.js +2 -0
- package/dist/lint.js +180 -3
- package/dist/schema.generated.js +178 -7
- package/dist/skill-tables.js +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,6 +54,11 @@ beryl runs trigger --url-override "$PREVIEW_URL" --watch --timeout 30
|
|
|
54
54
|
|
|
55
55
|
Exit codes: `0` success / all tests passed · `1` failure · `2` usage error · `3` auth error.
|
|
56
56
|
|
|
57
|
+
A project CI token (`BERYL_TOKEN=beryl_ci_…`, from the project's Settings, CI tab) is
|
|
58
|
+
enough to trigger runs and to turn a brief into tests: `beryl jobs create "Cover the brief" --file ./brief.md --ci`
|
|
59
|
+
(pass `--workspace` and `--project` ids). A CI job never waits for input; what a login
|
|
60
|
+
blocks is listed as skipped in `beryl jobs get`.
|
|
61
|
+
|
|
57
62
|
## MCP server
|
|
58
63
|
|
|
59
64
|
```bash
|
|
@@ -176,7 +181,8 @@ Create and manage projects: a site you author tests for and run.
|
|
|
176
181
|
| `beryl projects create [url]` | Create a project for a URL, or an empty one with just --name | `projects_create` |
|
|
177
182
|
| `beryl projects rename <name>` | Rename a project | `projects_rename` |
|
|
178
183
|
| `beryl projects quarantine [state]` | Turn auto quarantine on or off and tune when it mutes and releases a test | `projects_quarantine` |
|
|
179
|
-
| `beryl projects
|
|
184
|
+
| `beryl projects heal [state]` | Turn self-heal on or off for a project and limit its time and runs | `projects_heal` |
|
|
185
|
+
| `beryl projects surfaces [surfaces...]` | Turn project surfaces on or off | `projects_surfaces` |
|
|
180
186
|
| `beryl projects findings-gate <mode>` | Turn the findings gate on each run on or off | `projects_findings_gate` |
|
|
181
187
|
| `beryl projects delete` | Delete a project and all its tests and runs | `projects_delete` |
|
|
182
188
|
| `beryl projects re-explore` | Send the agent back in to run/heal existing tests and discover new flows | none |
|
|
@@ -217,7 +223,7 @@ Author, inspect, version, and heal a project's tests: the checks Beryl runs on e
|
|
|
217
223
|
| `beryl tests list` | List the project's tests with their latest result | `tests_list` |
|
|
218
224
|
| `beryl tests get <test-id>` | Show one test | `tests_get` |
|
|
219
225
|
| `beryl tests plan <test-id>` | Print a test's current step plan (JSON) | `tests_plan` |
|
|
220
|
-
| `beryl tests set-plan <test-id>` | Replace a test's step plan
|
|
226
|
+
| `beryl tests set-plan <test-id>` | Replace a test's step plan (creates a new version) | `tests_set_plan` |
|
|
221
227
|
| `beryl tests rename <test-id> <title>` | Rename a test | `tests_rename` |
|
|
222
228
|
| `beryl tests set-intent <test-id> <intent>` | Edit what a test proves, the target heal judges against | `tests_set_intent` |
|
|
223
229
|
| `beryl tests set-groups <test-id>` | Replace the groups a test belongs to | `tests_set_groups` |
|
|
@@ -259,12 +265,13 @@ Trigger a run of a project's tests (e.g. in CI), then watch, inspect, and downlo
|
|
|
259
265
|
| Command | Summary | MCP tool |
|
|
260
266
|
| --- | --- | --- |
|
|
261
267
|
| `beryl runs trigger` | Trigger a test run (whole suite, a subset, or one environment) | `runs_trigger` |
|
|
268
|
+
| `beryl runs queue` | Show the run in flight and the runs waiting behind it | `runs_queue` |
|
|
262
269
|
| `beryl runs list` | List recent runs | `runs_list` |
|
|
263
270
|
| `beryl runs get <run-id>` | Show one run with its per-test results | `runs_get` |
|
|
264
271
|
| `beryl runs watch <run-id>` | Follow a run until it finishes | `runs_watch` |
|
|
265
|
-
| `beryl runs cancel <run-id>` | Cancel an in-flight run | `runs_cancel` |
|
|
272
|
+
| `beryl runs cancel <run-id>` | Cancel an in-flight run, or take a queued one out of the queue | `runs_cancel` |
|
|
266
273
|
| `beryl runs report <run-id>` | Show the generated report for a run | `runs_report` |
|
|
267
|
-
| `beryl runs download <run-id>` |
|
|
274
|
+
| `beryl runs download <run-id>` | Get a run's results with signed artifact URLs, or download them to disk | `runs_download` |
|
|
268
275
|
| `beryl runs explain <result-id>` | Explain, with AI, why a test result failed | `runs_explain` |
|
|
269
276
|
|
|
270
277
|
### health
|
|
@@ -287,14 +294,15 @@ Site Health, or how your site reads to search engines and visitors: content, spe
|
|
|
287
294
|
|
|
288
295
|
### jobs
|
|
289
296
|
|
|
290
|
-
Test-creation jobs: the cloud agent
|
|
297
|
+
Test-creation jobs: the cloud agent follows a prompt, reads any document it comes with, and authors tests.
|
|
291
298
|
|
|
292
299
|
| Command | Summary | MCP tool |
|
|
293
300
|
| --- | --- | --- |
|
|
294
|
-
| `beryl jobs create
|
|
301
|
+
| `beryl jobs create <prompt>` | Create tests from a prompt, with an optional document, on Beryl's cloud | `jobs_create` |
|
|
295
302
|
| `beryl jobs list` | List the project's test-creation jobs | `jobs_list` |
|
|
296
303
|
| `beryl jobs get <job-id>` | Show a job's status and the tests it created | `jobs_get` |
|
|
297
|
-
| `beryl jobs clone <job-id>` | Run a job again: queue a new job with the same document and
|
|
304
|
+
| `beryl jobs clone <job-id>` | Run a job again: queue a new job with the same prompt, document, environment and account | `jobs_clone` |
|
|
305
|
+
| `beryl jobs retry <job-id>` | Author only what a partial or failed job left out, optionally with an edited prompt or document | `jobs_retry` |
|
|
298
306
|
| `beryl jobs delete <job-id>` | Delete a job; the flows and tests it created stay | `jobs_delete` |
|
|
299
307
|
|
|
300
308
|
### explorations
|
|
@@ -323,6 +331,9 @@ Manage the variables, secrets, and files the agent can use while exploring and r
|
|
|
323
331
|
| `beryl config secrets set <key> <value>` | Create a secret (write-only; re-setting a key replaces it) | `config_secrets_set` |
|
|
324
332
|
| `beryl config secrets get <key>` | Show one secret's metadata, or reveal its value with --reveal | `config_secrets_get` |
|
|
325
333
|
| `beryl config secrets delete <key>` | Delete a secret | `config_secrets_delete` |
|
|
334
|
+
| `beryl config signing get` | Which key signs Android builds converted from an .aab, with its fingerprints | `config_signing_get` |
|
|
335
|
+
| `beryl config signing set` | Sign converted Android builds with your own keystore | `config_signing_set` |
|
|
336
|
+
| `beryl config signing clear` | Forget your keystore; converted builds go back to Beryl's key | `config_signing_clear` |
|
|
326
337
|
| `beryl config files list` | List uploaded files: fixtures tests attach and Android and iOS app builds | `config_files_list` |
|
|
327
338
|
| `beryl config files get <file>` | Show one uploaded file's metadata | `config_files_get` |
|
|
328
339
|
| `beryl config files upload <file>` | Upload a file: a fixture an upload step attaches, or an Android or iOS app build | `config_files_upload` |
|
package/dist/adapters/mcp.js
CHANGED
|
@@ -42,8 +42,8 @@ function commandToTool(text) {
|
|
|
42
42
|
function flagsToParams(text, wrap) {
|
|
43
43
|
return text
|
|
44
44
|
.replace(/--json\b/g, "JSON output (always on over MCP)")
|
|
45
|
-
.replace(/--no-([a-z][a-
|
|
46
|
-
.replace(/--([a-z][a-
|
|
45
|
+
.replace(/--no-([a-z][a-z0-9-]*)/g, (_, name) => flagNames.has(`no-${name}`) ? wrap(`no-${name}`) : wrap(`${name}: false`))
|
|
46
|
+
.replace(/--([a-z][a-z0-9-]*)/g, (_, name) => wrap(name));
|
|
47
47
|
}
|
|
48
48
|
/** Registry text is written for `beryl --help`; the agent sees JSON parameters and tool
|
|
49
49
|
* names, so `--wide` becomes `wide`, `beryl groups list` becomes `groups_list`, and a
|
|
@@ -78,12 +78,14 @@ export function toolInputSchema(spec) {
|
|
|
78
78
|
properties[f.name] =
|
|
79
79
|
f.type === "strings"
|
|
80
80
|
? { type: "array", items: { type: "string" }, description, ...withDefault }
|
|
81
|
-
:
|
|
82
|
-
type:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
81
|
+
: f.type === "json"
|
|
82
|
+
? { type: "object", description }
|
|
83
|
+
: {
|
|
84
|
+
type: f.type,
|
|
85
|
+
description,
|
|
86
|
+
...(f.enum ? { enum: f.enum } : {}),
|
|
87
|
+
...withDefault,
|
|
88
|
+
};
|
|
87
89
|
if (f.required)
|
|
88
90
|
required.push(f.name);
|
|
89
91
|
}
|
|
@@ -156,6 +158,16 @@ export function exampleArgs(spec, example) {
|
|
|
156
158
|
// parseArgv fills declared defaults in; only what the example explicitly set teaches.
|
|
157
159
|
if (value === undefined || value === f?.default)
|
|
158
160
|
continue;
|
|
161
|
+
if (f?.type === "json") {
|
|
162
|
+
// Over MCP a json flag is an object; an example that passes a path teaches the wrong shape.
|
|
163
|
+
try {
|
|
164
|
+
out[name] = JSON.parse(String(value));
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
159
171
|
out[name] =
|
|
160
172
|
f?.type === "number" && typeof value === "string" && Number.isFinite(Number(value))
|
|
161
173
|
? Number(value)
|
|
@@ -280,8 +292,9 @@ export function mcpInstructions() {
|
|
|
280
292
|
"action plans replayed in real cloud browsers, signing in as a durable test " +
|
|
281
293
|
"account whose mail arrives at the project's own mailbox, so signup/OTP/" +
|
|
282
294
|
"magic-link flows are self-contained, with no human login needed. " +
|
|
283
|
-
"New tests are authored only by Beryl's cloud agent: give it a
|
|
284
|
-
"
|
|
295
|
+
"New tests are authored only by Beryl's cloud agent: give it a prompt saying what " +
|
|
296
|
+
"to test, plus a document if there is one, with `jobs_create`; there is no local " +
|
|
297
|
+
"authoring. " +
|
|
285
298
|
"The guide (plan shape, outcome assertions, accounts, email/OTP wiring, the " +
|
|
286
299
|
"run-fix loop) ships as both the beryl-test skill and the `guide` tool, same " +
|
|
287
300
|
`content. If a beryl-test skill stating v${version} is already loaded, do not ` +
|
package/dist/beryl-test-skill.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// embedded string so it ships in the published package (`files: ["dist"]`) with no
|
|
6
6
|
// build-time asset copy, and so there is ONE source for the guidance — not a copy in
|
|
7
7
|
// the CLI and another in the docs. Edit here; `init` writes it verbatim.
|
|
8
|
+
import { SESSION_CAPTURE_EXTENSION_URL } from "./config.js";
|
|
8
9
|
import { actionTable, expectTable, extractLine } from "./skill-tables.js";
|
|
9
10
|
import { cliVersion } from "./version-check.js";
|
|
10
11
|
const indent = (text) => text
|
|
@@ -23,7 +24,8 @@ ActionPlan shape, outcome assertions, test accounts and the project mailbox, and
|
|
|
23
24
|
local run-fix loop.
|
|
24
25
|
|
|
25
26
|
Then use the Beryl MCP tools: \`projects_list\` to find the project, \`jobs_create\` to
|
|
26
|
-
have Beryl's cloud agent author tests from a document, and
|
|
27
|
+
have Beryl's cloud agent author tests from a prompt (plus any document), and
|
|
28
|
+
\`runs_trigger\` to run them.
|
|
27
29
|
|
|
28
30
|
The \`guide\` tool ships with the CLI and always matches the installed version, so treat
|
|
29
31
|
it as the source of truth over anything cached here.`;
|
|
@@ -90,7 +92,7 @@ export const BERYL_TEST_SKILL_OTP_EXAMPLE_PLAN = {
|
|
|
90
92
|
};
|
|
91
93
|
export const BERYL_TEST_SKILL = `---
|
|
92
94
|
name: beryl-test
|
|
93
|
-
description: Create, run, and fix durable, healable end-to-end tests for a web app with Beryl. Use when asking Beryl's cloud agent to create tests from a document (\`jobs_create\`), running them in Beryl's cloud with \`runs_trigger\`, setting up test accounts, or fixing a banked test's plan with your own coding agent.
|
|
95
|
+
description: Create, run, and fix durable, healable end-to-end tests for a web app with Beryl. Use when asking Beryl's cloud agent to create tests from a prompt and an optional document (\`jobs_create\`), running them in Beryl's cloud with \`runs_trigger\`, setting up test accounts, or fixing a banked test's plan with your own coding agent.
|
|
94
96
|
---
|
|
95
97
|
|
|
96
98
|
# Beryl tests
|
|
@@ -108,15 +110,24 @@ it durable: a real **outcome assertion**, a strong **natural-language intent**,
|
|
|
108
110
|
## 0. Start here
|
|
109
111
|
|
|
110
112
|
**New tests are authored only by Beryl's cloud agent.** There is no local authoring: you
|
|
111
|
-
do not draft a new plan here. A human writes a
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
do not draft a new plan here. A human writes a prompt (what to test; a line is enough)
|
|
114
|
+
and, if they have one, attaches a document (a PRD, release notes, a list of flows);
|
|
115
|
+
\`jobs_create\` hands both to the agent, which explores the environment and authors one
|
|
116
|
+
test per scenario and surface. \`jobs_get\` shows the job's status, the tests it produced
|
|
117
|
+
and any scenario it skipped. The prompt and document are sent to the LLM that drives the
|
|
118
|
+
agent, so they must not hold anything the user would not share with it.
|
|
116
119
|
|
|
117
120
|
What you do here: set up the accounts tests sign in as (§1), trigger and read runs
|
|
118
121
|
(§4), and fix a banked test's plan when it needs it (§2).
|
|
119
122
|
|
|
123
|
+
Files: the Beryl tools may not share your filesystem, so read local files with your own
|
|
124
|
+
file tools and pass the content inline. A plan, session or probe goes in as a JSON
|
|
125
|
+
object (\`plan\`, \`session\`, \`probe\`); a document goes to \`jobs_create\` as
|
|
126
|
+
\`text\`, or a PDF or DOCX as \`content-base64\` plus \`filename\`, alongside the
|
|
127
|
+
required \`prompt\`. Outputs come back
|
|
128
|
+
the same way: \`tests_export\` with \`inline\` returns the spec files, and
|
|
129
|
+
\`runs_download\` with no \`dir\` returns signed artifact URLs to fetch.
|
|
130
|
+
|
|
120
131
|
Set up (once): if you cannot call the Beryl tools yet, run \`beryl init\` in a terminal
|
|
121
132
|
to wire them. Then, before the first job:
|
|
122
133
|
|
|
@@ -133,16 +144,20 @@ login plan is stored.
|
|
|
133
144
|
|
|
134
145
|
Then:
|
|
135
146
|
|
|
136
|
-
1. **Create tests** with \`jobs_create\` (the document, and the environment
|
|
137
|
-
explore). \`jobs_get\` shows progress, the tests it banked (each one already
|
|
147
|
+
1. **Create tests** with \`jobs_create\` (the prompt, any document, and the environment
|
|
148
|
+
to explore). \`jobs_get\` shows progress, the tests it banked (each one already
|
|
138
149
|
proven by a clean replay), and every scenario it skipped, with why.
|
|
139
150
|
2. A job can pause as \`awaiting_input\` when the app's login wall blocks it (no
|
|
140
151
|
self-serve signup). Its \`needs\` field says what to do: capture a signed-in
|
|
141
|
-
session with the Beryl
|
|
152
|
+
session with the Beryl Chrome extension (${SESSION_CAPTURE_EXTENSION_URL}). The job resumes by
|
|
142
153
|
itself and keeps everything it already banked.
|
|
143
|
-
3.
|
|
154
|
+
3. A job ends \`done\`, \`partial\` (some tests saved; its \`error\` and
|
|
155
|
+
\`skipped_flows\` say what is missing and why) or \`failed\`. \`jobs_retry\`
|
|
156
|
+
authors only what a partial or failed job left out, optionally with an edited
|
|
157
|
+
prompt or document; the new tests join the same flows.
|
|
158
|
+
4. When the job is done, run the new ids together ONCE with \`runs_trigger\` so they
|
|
144
159
|
land as one run with real first results.
|
|
145
|
-
|
|
160
|
+
5. **A test is red or wrong?** Fix its plan with the run-fix loop (§4).
|
|
146
161
|
|
|
147
162
|
## 1. First: who do your tests sign in as?
|
|
148
163
|
|
|
@@ -190,14 +205,21 @@ The trade is drift — run 200 has 200 of everything run 1 created. So:
|
|
|
190
205
|
it.** \`after\` runs on pass AND fail; a trailing step inside \`steps\` does not, so
|
|
191
206
|
never put cleanup in \`steps\`.
|
|
192
207
|
- **Created something?** Delete it in \`after\`.
|
|
208
|
+
- **Needs data the account doesn't have (an empty list, nothing to act on)?** Create it
|
|
209
|
+
in \`before\` the way a user would, \`{{unique}}\`-named (a \`wait_until\` when the app
|
|
210
|
+
builds it in the background), and remove it in \`after\`.
|
|
193
211
|
- **Deleting something?** Create your own victim first (in \`before\` or the opening
|
|
194
212
|
steps, \`{{unique}}\`-named), then delete it and prove \`gone\`; when the deleted thing
|
|
195
213
|
must persist, re-create it in \`after\` as it was. Never aim a delete at standing data:
|
|
196
214
|
run 2 has nothing to delete, and it eats the account's accumulated coverage.
|
|
197
|
-
- **Changed a value (
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
215
|
+
- **Changed a value already there (a setting, option, toggle, rename)?** Restore it in
|
|
216
|
+
\`after\`. \`capture_value\` the field BEFORE you change it (banking it under
|
|
217
|
+
\`capture_as\`), change it to a value different from the one you found, then set it
|
|
218
|
+
back from that handle: \`select\` with option \`{{handle}}\` for a dropdown,
|
|
219
|
+
\`fill {{handle}}\` for a text field, the opposite \`check\` / \`uncheck\` for a
|
|
220
|
+
toggle. Prove each one saved, not just shown: after the change, \`goto\` the page
|
|
221
|
+
again, reopen it and end with \`have_value\` on the field; in \`after\`, the same
|
|
222
|
+
after the restore, checking \`{{handle}}\`. A write that skips any of this is refused.
|
|
201
223
|
- **You label each test \`read\` or \`write\`; Beryl takes your word for it.** Set its
|
|
202
224
|
\`access\` (default \`read\`). Beryl does NOT infer it from your selectors, so YOU
|
|
203
225
|
decide, by one test: **would the app be different after this test runs?**
|
|
@@ -212,9 +234,9 @@ The trade is drift — run 200 has 200 of everything run 1 created. So:
|
|
|
212
234
|
menu or dialog and reading it, or clicking a destructive button and then CANCELLING.
|
|
213
235
|
Nothing is committed. Opening a confirm dialog and cancelling is a \`read\`; a submit
|
|
214
236
|
that stays disabled is a \`read\`.
|
|
215
|
-
A flow that both changes and reads is a \`write\`.
|
|
216
|
-
|
|
217
|
-
|
|
237
|
+
A flow that both changes and reads is a \`write\`. A \`write\` is checked for its closing
|
|
238
|
+
teardown proof and its \`{{unique}}\` names (and one whose teardown removes nothing fails
|
|
239
|
+
the run); \`read\` is taken at your word, so a real write you mislabel \`read\` silently
|
|
218
240
|
drifts the account. Label deliberately, and flip a banked test with \`tests_set_access\`.
|
|
219
241
|
|
|
220
242
|
### If there is no account yet
|
|
@@ -246,7 +268,7 @@ An empty \`accounts_list\` means nothing is set up. In order of preference:
|
|
|
246
268
|
|
|
247
269
|
Every saved login is a test account: \`{{login_email}}\` and \`{{login_password}}\` resolve
|
|
248
270
|
only from the environment's account, and a login password is never set with
|
|
249
|
-
\`config secrets set\`. The webapp's Test accounts
|
|
271
|
+
\`config secrets set\`. The webapp's Test accounts page edits the same accounts.
|
|
250
272
|
|
|
251
273
|
### Session mode: sign in once per run, not once per test
|
|
252
274
|
|
|
@@ -273,6 +295,13 @@ An expired session is never your problem: every run proves the stored session an
|
|
|
273
295
|
miss, signs in again from the login plan. A "reconnect" message only appears when there
|
|
274
296
|
is no stored login plan to refresh from.
|
|
275
297
|
|
|
298
|
+
A sign-in that breaks at run setup alerts the workspace once (run Slack channel and
|
|
299
|
+
email) with the code, the blocked tests, and the fix, and again only as a reminder or
|
|
300
|
+
when it is restored. Until then that account's tests error at setup with the same
|
|
301
|
+
\`SESSION_*\` code instead of running: session-mode tests always, inline ones too when
|
|
302
|
+
the cause is the account itself (rejected password, locked, captcha, a code that never
|
|
303
|
+
arrives). Fix the account, then \`accounts_check\`.
|
|
304
|
+
|
|
276
305
|
When \`accounts_check\` fails, its description explains which half broke and each
|
|
277
306
|
\`SESSION_*\` reason. The one that changes your plans: an account marked
|
|
278
307
|
unsupported (its session cannot survive a fresh browser) is not your bug, but it IS
|
|
@@ -281,11 +310,46 @@ sign-in steps.
|
|
|
281
310
|
|
|
282
311
|
### Two identities in one test
|
|
283
312
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
313
|
+
A \`sign_in_as\` step continues the test as another identity, so a multi-user flow is
|
|
314
|
+
one test: an admin invites a teammate who accepts, user A makes something private and
|
|
315
|
+
user B is refused, an admin changes a role and the user's access changes.
|
|
316
|
+
|
|
317
|
+
- \`"account": "<label>"\` opens that test account in its own browser, already signed
|
|
318
|
+
in on the run's session for it. The account needs a stored sign-in
|
|
319
|
+
(\`accounts_set_login\`) or a captured session, the same as a session-mode test's;
|
|
320
|
+
the run signs it in once at setup, and if that breaks the test errors at setup with
|
|
321
|
+
the \`SESSION_*\` code.
|
|
322
|
+
- \`"account": "inbox_address_2"\` (a fresh-address handle the plan types somewhere)
|
|
323
|
+
opens a signed-out browser for that throwaway user, so the next steps accept the
|
|
324
|
+
invitation or sign up as it.
|
|
325
|
+
- Switching back to an identity resumes its page where it was left. The test's own
|
|
326
|
+
\`auth_label\` names where it started; a test that starts signed out or as the
|
|
327
|
+
environment's default account cannot switch back to its start by name.
|
|
328
|
+
- \`after\` starts as the test's own account; add a \`sign_in_as\` there when the
|
|
329
|
+
cleanup needs another identity.
|
|
330
|
+
- Web plans only. The authoring replay (\`jobs_create\`, \`tests_recompile\`) does not
|
|
331
|
+
sign switched-to accounts in: add the step with \`tests_set_plan\` and prove it with
|
|
332
|
+
\`runs_trigger\`.
|
|
333
|
+
|
|
334
|
+
\`\`\`json
|
|
335
|
+
{
|
|
336
|
+
"requires_auth": true, "auth_mode": "session", "auth_label": "admin",
|
|
337
|
+
"steps": [
|
|
338
|
+
{"action": "goto", "url": "/settings/team"},
|
|
339
|
+
{"action": "fill", "selector": "#invite-email", "value": "{{inbox_address}}"},
|
|
340
|
+
{"action": "click", "selector": "text=Send invite"},
|
|
341
|
+
{"action": "sign_in_as", "account": "inbox_address"},
|
|
342
|
+
{"action": "await_email", "extract": "link", "capture_as": "invite"},
|
|
343
|
+
{"action": "goto", "url": "{{invite}}"},
|
|
344
|
+
{"action": "click", "selector": "text=Accept invitation"},
|
|
345
|
+
{"action": "expect", "expect_kind": "visible", "selector": "text=Welcome to the team"}
|
|
346
|
+
],
|
|
347
|
+
"after": [
|
|
348
|
+
{"action": "goto", "url": "/settings/team"},
|
|
349
|
+
{"action": "click", "selector": "text=Remove", "optional": true}
|
|
350
|
+
]
|
|
351
|
+
}
|
|
352
|
+
\`\`\`
|
|
289
353
|
|
|
290
354
|
SSO-only sites are not a dead end: with a dedicated Google test account they follow §1
|
|
291
355
|
item 2 like any password account. The captured session is the FALLBACK — no test account
|
|
@@ -382,10 +446,13 @@ teardown rules are unchanged; \`device\` only sets the frame it runs in.
|
|
|
382
446
|
|
|
383
447
|
### App tests (Android and iOS)
|
|
384
448
|
\`device: "android"\` and \`device: "ios"\` are not browsers: the plan drives the environment's
|
|
385
|
-
app (its \`app_ids.android\` from the newest APK,
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
449
|
+
app (its \`app_ids.android\` from the newest APK, uploaded as an .apk or an .aab Beryl converts
|
|
450
|
+
and signs, or \`app_ids.ios\` from the newest simulator build, uploaded to Files; the first
|
|
451
|
+
ready build fills the environment's app id) on an emulator or simulator in Beryl's cloud. A job authors them only
|
|
452
|
+
once the surface is on: \`projects_surfaces\` with \`add: ["android", "ios"]\` (CLI
|
|
453
|
+
\`beryl projects surfaces --add android --add ios\`), which keeps the web surfaces on. App tests
|
|
454
|
+
are written and verified by a test-creation job (\`jobs_create\`), never run locally. When you
|
|
455
|
+
edit one, its rules differ from a web plan's:
|
|
389
456
|
|
|
390
457
|
- The first step is \`launch\` (open the app), not \`goto\`. A later \`launch\` relaunches
|
|
391
458
|
with the app's data kept, which is how a plan proves something persisted.
|
|
@@ -520,8 +587,15 @@ this test alone.
|
|
|
520
587
|
| creates an account, or signs in as one it created (a returning-user test: \`before\` signs up the alias and logs out, the steps sign in as it) | an \`after\` section that deletes it through the app, its steps marked \`optional\`; a plan that reads its signup mail is refused at save time without one |
|
|
521
588
|
| only makes mail arrive, read or not, no account created | nothing to clean up; mail is kept for 48 hours |
|
|
522
589
|
|
|
523
|
-
Every alias minted is
|
|
524
|
-
|
|
590
|
+
Every alias minted is a throwaway test account owned by the execution that minted it:
|
|
591
|
+
\`accounts_list\` groups them as throwaway (and \`mailbox_identities\` filters them by run
|
|
592
|
+
or test) with how each teardown ended, for 30 days; the run detail shows the same
|
|
593
|
+
outcome on each result.
|
|
594
|
+
|
|
595
|
+
A test that needs several fresh users (an owner who invites a teammate) cites
|
|
596
|
+
\`{{inbox_address_2}}\` up to \`{{inbox_address_9}}\` for the others, each its own alias.
|
|
597
|
+
An \`await_email\` reading one of those sets \`recipient\` to the handle name
|
|
598
|
+
(\`"recipient": "inbox_address_2"\`); without it, it reads \`{{inbox_address}}\`'s mail.
|
|
525
599
|
|
|
526
600
|
The wiring is a three-part chain:
|
|
527
601
|
|
|
@@ -560,7 +634,73 @@ Two caveats:
|
|
|
560
634
|
- §2's outcome discipline still applies: the green signal is the post-verification
|
|
561
635
|
state (the welcome screen, the dashboard), not "an email arrived".
|
|
562
636
|
|
|
563
|
-
|
|
637
|
+
### Fresh users without typing the signup
|
|
638
|
+
|
|
639
|
+
When the test is about what a new user does, not about signup itself, a
|
|
640
|
+
\`create_account\` step signs one up for you: it replays the environment's default test
|
|
641
|
+
account's signup plan (the one \`accounts_signup\` stored) at that point, typing a fresh
|
|
642
|
+
alias and a password minted for this run, so every run gets a different user.
|
|
643
|
+
|
|
644
|
+
- \`"value": "{{inbox_address_2}}"\` names the alias it signs up (default
|
|
645
|
+
\`{{inbox_address}}\`). No earlier step may type that alias; later steps may cite it
|
|
646
|
+
(an \`await_email\` with \`recipient\`, a \`sign_in_as\` before the step to sign up in
|
|
647
|
+
its own browser).
|
|
648
|
+
- The browser continues signed in as the new user. To sign in as it again later, type
|
|
649
|
+
the same alias and \`{{created_password}}\`, the password it signed up with. Like any
|
|
650
|
+
signup, the test needs an \`after\` that deletes it, and \`create_account\` is not
|
|
651
|
+
allowed in \`after\`.
|
|
652
|
+
- The default account must have a stored signup plan, or the save is refused and a run
|
|
653
|
+
errors at setup with \`CREATE_ACCOUNT_NO_SIGNUP\`. A signup that breaks fails the
|
|
654
|
+
\`create_account\` step, and the run report groups those failures as one signup cause.
|
|
655
|
+
- Web plans only, run in the cloud (\`tests script\` and \`runs local\` refuse them).
|
|
656
|
+
Auto-heal skips these tests.
|
|
657
|
+
|
|
658
|
+
\`\`\`json
|
|
659
|
+
{
|
|
660
|
+
"steps": [
|
|
661
|
+
{"action": "goto", "url": "/"},
|
|
662
|
+
{"action": "create_account"},
|
|
663
|
+
{"action": "goto", "url": "/projects"},
|
|
664
|
+
{"action": "expect", "expect_kind": "visible", "selector": "text=Create your first project"}
|
|
665
|
+
],
|
|
666
|
+
"after": [
|
|
667
|
+
{"action": "goto", "url": "/settings/account"},
|
|
668
|
+
{"action": "click", "selector": "text=Delete account", "optional": true}
|
|
669
|
+
]
|
|
670
|
+
}
|
|
671
|
+
\`\`\`
|
|
672
|
+
|
|
673
|
+
## 6. Flows that wait for an asynchronous process (\`wait_until\`)
|
|
674
|
+
|
|
675
|
+
Some flows kick off work that finishes minutes later, not in the next frame: an export,
|
|
676
|
+
an import, AI generation, a job you start and watch. An ordinary step times out long
|
|
677
|
+
before the result lands. \`wait_until\` holds the browser and polls a selector until it
|
|
678
|
+
appears, past the per-step and per-test limits.
|
|
679
|
+
|
|
680
|
+
- Do the trigger step (click Export, start the job), then a \`wait_until\` on the region
|
|
681
|
+
that shows the FINISHED state, never the spinner.
|
|
682
|
+
- \`deadline_s\` is how long to keep polling (bounded); \`every_s\` is the gap between
|
|
683
|
+
checks; \`"reload": true\` reloads between polls when a status only updates on
|
|
684
|
+
navigation; \`fail_on\` lists selectors whose appearance means the process failed, so
|
|
685
|
+
it fails fast instead of waiting out the deadline. The deadline passing is itself a
|
|
686
|
+
failure.
|
|
687
|
+
- Assert the real outcome AFTER it: \`wait_until\` only reaches the finished state, the
|
|
688
|
+
\`expect\` is still the test. The deadline is added to the test's budget, so a plan
|
|
689
|
+
without the step runs exactly as before. A deadline that passes is never retried or
|
|
690
|
+
auto-healed; raise \`deadline_s\` or check why the process stalled.
|
|
691
|
+
|
|
692
|
+
\`\`\`json
|
|
693
|
+
{
|
|
694
|
+
"steps": [
|
|
695
|
+
{"action": "goto", "url": "/exports"},
|
|
696
|
+
{"action": "click", "selector": "role=button[name=Export]"},
|
|
697
|
+
{"action": "wait_until", "selector": "text=Download ready", "deadline_s": 600, "every_s": 20, "reload": true, "fail_on": ["text=Export failed"]},
|
|
698
|
+
{"action": "expect", "expect_kind": "visible", "selector": "a.download"}
|
|
699
|
+
]
|
|
700
|
+
}
|
|
701
|
+
\`\`\`
|
|
702
|
+
|
|
703
|
+
## 7. Why this shape: durable and healable
|
|
564
704
|
|
|
565
705
|
Beryl's cloud runs your test on a schedule. When markup drifts and a selector stops
|
|
566
706
|
matching, a heal-vs-fail agent decides whether to **heal** (silently re-derive the
|
|
@@ -580,7 +720,11 @@ So a test is *healable* exactly when it has **a strong intent + a real outcome
|
|
|
580
720
|
assertion**. With a vague intent and a chrome-only assertion Beryl can't tell
|
|
581
721
|
regression from cosmetic drift — it either heals over real breakage or fails on noise.
|
|
582
722
|
|
|
583
|
-
|
|
723
|
+
Healing is **off by default** for each project: a failed test fails right away. Turn
|
|
724
|
+
it on with \`projects_heal\` (\`beryl projects heal on\`), optionally with
|
|
725
|
+
\`--minutes\` (per heal attempt) and \`--max-per-run\` (heals one run may start).
|
|
726
|
+
|
|
727
|
+
## 8. Third party integration tests
|
|
584
728
|
|
|
585
729
|
An app's own integrations (its "Connect Slack" button, the message it then posts) are
|
|
586
730
|
testable against Beryl's account on that provider. Call
|
package/dist/code-ref.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
const SHA = /^[0-9a-f]{4,64}$/i;
|
|
3
|
+
const SHORT_SHA = 7;
|
|
4
|
+
const clean = (v) => typeof v === "string" ? v.trim() || undefined : undefined;
|
|
5
|
+
export const isCommitSha = (v) => SHA.test(v);
|
|
6
|
+
function prNumber(v) {
|
|
7
|
+
const n = typeof v === "string" ? Number(v.trim()) : v;
|
|
8
|
+
return typeof n === "number" && Number.isInteger(n) && n > 0 ? n : undefined;
|
|
9
|
+
}
|
|
10
|
+
// On a pull_request event GITHUB_SHA is GitHub's throwaway merge commit, not the
|
|
11
|
+
// commit the customer pushed; the PR head lives only in the event payload.
|
|
12
|
+
function githubEvent(env, readFile) {
|
|
13
|
+
const file = env.GITHUB_EVENT_PATH;
|
|
14
|
+
if (!file || !env.GITHUB_EVENT_NAME?.startsWith("pull_request"))
|
|
15
|
+
return {};
|
|
16
|
+
try {
|
|
17
|
+
const event = JSON.parse(readFile(file));
|
|
18
|
+
return event && typeof event === "object" ? event : {};
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Best-effort: an unreadable payload falls back to GITHUB_SHA.
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function fromGithub(env, readFile) {
|
|
26
|
+
const pr = githubEvent(env, readFile).pull_request;
|
|
27
|
+
const sha = clean(pr?.head?.sha) ?? clean(env.GITHUB_SHA);
|
|
28
|
+
const server = clean(env.GITHUB_SERVER_URL);
|
|
29
|
+
const repo = clean(env.GITHUB_REPOSITORY);
|
|
30
|
+
return {
|
|
31
|
+
commit_sha: sha,
|
|
32
|
+
branch: clean(env.GITHUB_HEAD_REF) ??
|
|
33
|
+
(env.GITHUB_REF_TYPE === "tag" ? undefined : clean(env.GITHUB_REF_NAME)),
|
|
34
|
+
commit_url: sha && server && repo ? `${server}/${repo}/commit/${sha}` : undefined,
|
|
35
|
+
pr_number: prNumber(pr?.number) ?? prNumber(/^refs\/pull\/(\d+)\//.exec(env.GITHUB_REF ?? "")?.[1]),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// In a merged-results MR pipeline CI_COMMIT_SHA is GitLab's merge commit; the source
|
|
39
|
+
// branch sha is the commit the customer pushed.
|
|
40
|
+
function fromGitlab(env) {
|
|
41
|
+
const sha = clean(env.CI_MERGE_REQUEST_SOURCE_BRANCH_SHA) ?? clean(env.CI_COMMIT_SHA);
|
|
42
|
+
const project = clean(env.CI_PROJECT_URL);
|
|
43
|
+
return {
|
|
44
|
+
commit_sha: sha,
|
|
45
|
+
branch: clean(env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME) ??
|
|
46
|
+
clean(env.CI_COMMIT_BRANCH) ??
|
|
47
|
+
(env.CI_COMMIT_TAG ? undefined : clean(env.CI_COMMIT_REF_NAME)),
|
|
48
|
+
commit_url: sha && project ? `${project}/-/commit/${sha}` : undefined,
|
|
49
|
+
pr_number: prNumber(env.CI_MERGE_REQUEST_IID),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export function detectCodeRef(env = process.env, readFile = (p) => fs.readFileSync(p, "utf8")) {
|
|
53
|
+
if (env.GITHUB_ACTIONS === "true")
|
|
54
|
+
return fromGithub(env, readFile);
|
|
55
|
+
if (env.GITLAB_CI === "true")
|
|
56
|
+
return fromGitlab(env);
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
// Precedence: flags, then BERYL_COMMIT_SHA / BERYL_BRANCH, then the CI provider. A
|
|
60
|
+
// detected commit_url or PR number only rides along with the sha it was built from.
|
|
61
|
+
export function resolveCodeRef(flags, env = process.env, readFile) {
|
|
62
|
+
const detected = detectCodeRef(env, readFile);
|
|
63
|
+
const explicitSha = clean(flags.commit) ?? clean(env.BERYL_COMMIT_SHA);
|
|
64
|
+
const sha = explicitSha ?? detected.commit_sha;
|
|
65
|
+
const sameCommit = !explicitSha || explicitSha.toLowerCase() === detected.commit_sha?.toLowerCase();
|
|
66
|
+
const ref = {
|
|
67
|
+
commit_sha: sha && isCommitSha(sha) ? sha.toLowerCase() : undefined,
|
|
68
|
+
branch: clean((clean(flags.branch) ?? clean(env.BERYL_BRANCH) ?? detected.branch)?.replace(/^refs\/heads\//, "")),
|
|
69
|
+
commit_url: sameCommit ? detected.commit_url : undefined,
|
|
70
|
+
pr_number: sameCommit ? detected.pr_number : undefined,
|
|
71
|
+
};
|
|
72
|
+
if (!ref.commit_sha)
|
|
73
|
+
ref.commit_url = undefined;
|
|
74
|
+
return Object.fromEntries(Object.entries(ref).filter(([, v]) => v !== undefined));
|
|
75
|
+
}
|
|
76
|
+
export function codeRefLabel(ref) {
|
|
77
|
+
const sha = ref.commit_sha?.slice(0, SHORT_SHA);
|
|
78
|
+
if (ref.branch && sha)
|
|
79
|
+
return `${ref.branch} @ ${sha}`;
|
|
80
|
+
return ref.branch ?? sha ?? null;
|
|
81
|
+
}
|