@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 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 surfaces <surfaces...>` | Set which surfaces are enabled for this project | `projects_surfaces` |
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 from a JSON file (creates a new version) | `tests_set_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>` | Download a run's results, with its artifacts, to disk | `runs_download` |
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 reads a document and authors tests from it.
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 [file]` | Create tests from a document (PRD, release notes) on Beryl's cloud | `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 environment | `jobs_clone` |
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` |
@@ -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-z-]*)/g, (_, name) => flagNames.has(`no-${name}`) ? wrap(`no-${name}`) : wrap(`${name}: false`))
46
- .replace(/--([a-z][a-z-]*)/g, (_, name) => wrap(name));
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: f.type,
83
- description,
84
- ...(f.enum ? { enum: f.enum } : {}),
85
- ...withDefault,
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 document describing " +
284
- "the flows with `jobs_create`; there is no local authoring. " +
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 ` +
@@ -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 \`runs_trigger\` to run them.
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 document (a PRD, release notes, a list of
112
- flows) and \`jobs_create\` hands it to the agent, which explores the environment and
113
- authors one test per scenario and surface. \`jobs_get\` shows the job's status, the tests
114
- it produced and any scenario it skipped. The document is sent to the LLM that drives the
115
- agent, so it must not hold anything the user would not share with it.
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 to
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 browser extension from the webapp. The job resumes by
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. When the job is done, run the new ids together ONCE with \`runs_trigger\` so they
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
- 4. **A test is red or wrong?** Fix its plan with the run-fix loop (§4).
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 (rename, toggle)?** Restore it in \`after\`: set it back to what it
198
- was. Capture the original BEFORE you change it with \`capture_text\` (or \`capture_value\`
199
- for a form input), banking it under \`capture_as\`, then \`fill {{handle}}\` in \`after\`
200
- to put it back.
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\`. Only the \`write\` label is checked
216
- (its closing teardown proof and its \`{{unique}}\` names, and a \`write\` whose teardown
217
- removes nothing fails the run); \`read\` is taken at your word, so a real write you mislabel \`read\` silently
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 tab edits the same 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
- Invite-a-teammate-and-accept-as-them needs two identities mid-flow, so it is not bankable
285
- as one test. Bank the half the app shows to account A ("the invitation is listed as
286
- pending", "the share link is issued"): a real, strong outcome. While fixing a plan you can
287
- drive the full handshake live: a second account (\`accounts_create\`, labelled), or a
288
- second mailbox (\`mailbox_create\`, read with \`mailbox_read\`).
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, or \`app_ids.ios\` from the newest simulator
386
- build, uploaded to Files) on an emulator or simulator in Beryl's cloud. App tests are written
387
- and verified by a test-creation job (\`jobs_create\`), never run locally. When you edit one,
388
- its rules differ from a web plan's:
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 listed by \`mailbox_identities\` with how its teardown
524
- ended; the run detail shows the same outcome on each result.
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
- ## 6. Why this shape: durable and healable
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
- ## 7. Third party integration tests
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
@@ -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
+ }