@beryl-so/cli 0.24.0 → 0.25.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.
@@ -5,6 +5,12 @@
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 { actionTable, expectTable, extractLine } from "./skill-tables.js";
9
+ import { cliVersion } from "./version-check.js";
10
+ const indent = (text) => text
11
+ .split("\n")
12
+ .map((line) => ` ${line}`)
13
+ .join("\n");
8
14
  export const BERYL_TEST_SKILL_FILENAME = "SKILL.md";
9
15
  export const BERYL_TEST_SKILL_DIR = "beryl-test";
10
16
  // The example plan the skill shows verbatim. Exported so the test suite lints it with
@@ -48,23 +54,21 @@ description: Author durable, healable end-to-end tests for a web app with Beryl.
48
54
 
49
55
  # Authoring Beryl tests
50
56
 
51
- Beryl is automated web-app testing: a test drives a real browser through a flow and
52
- asserts the flow reached its meaningful outcome. \`beryl init\` has already wired two MCP
53
- servers for you — **beryl** (create/list/run tests) and **playwright** (drive a real
54
- browser). Your job is to author tests that keep passing as the app's markup drifts,
55
- because Beryl can **heal** them — but only when you give it what it needs to.
57
+ beryl-test v${cliVersion()} installed by \`beryl init\`; the \`guide\` tool always returns the current version of this guide.
56
58
 
57
- Read this before authoring. The three ideas that make a test durable: a real **outcome
58
- assertion**, a strong **natural-language intent**, and the **local run-fix loop**.
59
+ A Beryl test drives a real browser through a flow and asserts the flow reached its
60
+ meaningful outcome. \`beryl init\` wired two MCP servers: **beryl** (create/list/run
61
+ tests) and **playwright** (drive a real browser). Beryl **heals** tests as the app's
62
+ markup drifts — but only when you supply the three things that make a test durable: a
63
+ real **outcome assertion**, a strong **natural-language intent**, and the **local
64
+ run-fix loop**.
59
65
 
60
66
  ## 0. Start here
61
67
 
62
- **Everything happens on your machine first.** You drive the flow in a real browser here,
63
- and \`tests create\` replays the plan here over MCP that means the machine running the
64
- MCP server, never Beryl's. A red replay banks nothing. Two exceptions to "proven
65
- locally": a plan that depends on a session only Beryl's cloud holds is verified
66
- server-side instead (create tells you when), and \`--no-verify\` banks unproven — avoid
67
- it. So a broken local browser is not a detail you can skip past; it is the whole loop.
68
+ **Everything happens on your machine first** you drive the flow here, and
69
+ \`tests create\` proves the plan here too, so a working local browser IS the loop.
70
+ Avoid \`--no-verify\` (banks unproven); replay mechanics and the cloud-session
71
+ exception are in \`tests create\`'s own description.
68
72
 
69
73
  Before the first plan:
70
74
 
@@ -78,10 +82,9 @@ beryl accounts check <id> # does that stored sign-in
78
82
  beryl mailbox get # the address they receive mail at (§5)
79
83
  \`\`\`
80
84
 
81
- \`accounts list\` comes FIRST, before you author anything. It decides whether a flow signs
82
- in as a standing account or acquires a new identity, and that choice changes the plan you
83
- write discovering it afterwards means rewriting. If a flow needs signing in, the
84
- account's login plan has to be stored before you can create the test at all.
85
+ \`accounts list\` comes FIRST: standing account vs new identity changes the plan you
86
+ write (discovering it afterwards means rewriting), and an authenticated test cannot be
87
+ created until its account's login plan is stored.
85
88
 
86
89
  Then, per test:
87
90
 
@@ -89,9 +92,9 @@ Then, per test:
89
92
  imagination (§2).
90
93
  2. **Write the ActionPlan**, with one real outcome assertion (§2).
91
94
  3. \`beryl tests lint --file plan.json\` — schema check, offline, no network.
92
- 4. \`beryl tests create --title … --file … --description "<the intent>"\` — replays the plan
93
- locally and banks it only if it goes green. A red replay banks nothing and hands back
94
- the failure; fix the file and re-run (§3 for the intent, §4 for the loop).
95
+ 4. \`beryl tests create --title … --file … --description "<the intent>"\` — replays the
96
+ plan locally, banks it only on green; a red replay hands back the failure — fix and
97
+ re-run (§3 for the intent, §4 for the loop).
95
98
  5. \`beryl runs local\` — re-run banked tests on your machine while iterating.
96
99
  6. \`beryl runs trigger\` — hand it to Beryl's cloud, on demand or on a schedule.
97
100
 
@@ -107,40 +110,41 @@ admin qa+admin@x7k2p9.email.beryl.so beryl otp ready
107
110
  \`\`\`
108
111
 
109
112
  Author the sign-in as ordinary opening steps citing the reserved handles — fill
110
- \`{{login_email}}\`, fill \`{{login_password}}\`, submit, assert the logged-in shell. Set
111
- \`requires_auth: true\` AND \`auth_mode\` — both, always. \`auth_mode\` has NO default and
112
- a \`requires_auth\` plan without it is rejected at create: \`"inline"\` when the plan
113
- signs itself in like this, \`"session"\` when it carries no sign-in steps and rides its
114
- account's once-per-run session (§ Session mode). Add \`auth_label\` only to pick a
115
- non-default identity (the row marked \`*\` is what a plan gets otherwise). The handles
116
- resolve at run time to whichever account the plan named, so one plan stays correct
117
- across environments.
113
+ \`{{login_email}}\`, fill \`{{login_password}}\`, submit, assert the logged-in shell.
114
+ Set \`requires_auth: true\` AND \`auth_mode\` — both, always; \`auth_mode\` has NO
115
+ default and create rejects a \`requires_auth\` plan without it. \`"inline"\` = the plan
116
+ signs itself in like this; \`"session"\` = no sign-in steps, rides the account's
117
+ once-per-run session (§ Session mode). \`auth_label\` picks a non-default identity
118
+ (the \`*\` row is the default). Handles resolve at run time to the named account, so
119
+ one plan stays correct across environments.
118
120
 
119
121
  **The account needs a stored login plan before any authenticated test can be created.**
120
- That plan is what a run replays to produce the session its tests ride, and replays again
122
+ A run replays that plan to produce the session its tests ride, and replays it again
121
123
  when the session expires — so \`tests create\` rejects a \`requires_auth\` plan whose
122
- account has none, and names the command. Store it once per account with
123
- \`beryl accounts set-login\` (§ Session mode); everything after that is just authoring.
124
+ account has none, naming the fix. Store it once per account with
125
+ \`beryl accounts set-login\` (§ Session mode).
124
126
 
125
- **NEVER paste a real email or password into a plan.** The handles resolve at run time —
127
+ **NEVER paste a real email or password into a plan.** Handles resolve at run time —
126
128
  the password never lands in the rendered spec and is scrubbed from artifacts. A pasted
127
- value is baked into the test forever and rots on the next rotation.
129
+ value is baked in forever and rots on the next rotation.
128
130
 
129
131
  ### Why this matters for coverage
130
132
 
131
- The account persists between runs, so it accumulates real data — and that is the point. A
132
- standing account reaches what a fresh signup never could: a populated list, filters with
133
- something to filter, run history, a dashboard with numbers in it. **When a flow needs
134
- pre-existing data, that is the signal to use the test account** rather than building the
135
- data inside the test.
133
+ The account persists between runs and accumulates real data — that is the point. It
134
+ reaches what a fresh signup never could: a populated list, filters with something to
135
+ filter, run history, a dashboard with numbers. **A flow that needs pre-existing data is
136
+ the signal to use the test account**, not to build the data inside the test.
136
137
 
137
138
  The trade is drift — run 200 has 200 of everything run 1 created. So:
138
139
 
139
140
  - **Assert relatively, never absolutely.** "The row I just created is present"
140
- (\`expect.persisted\` on a \`{{unique}}\`-named row), not "there are 3 rows". A count
141
- assertion is true in week one and false in week four with nobody having touched it.
142
- - **Clean up what you create** put the delete in the plan's \`after\` section, which runs
143
- on pass AND on fail, unlike a trailing step inside \`steps\`.
141
+ (\`expect.persisted\` on a \`{{unique}}\`-named row), not "there are 3 rows" a count
142
+ assertion goes stale by itself.
143
+ - **Own the resource lifecycle, both directions.** Creating? Delete it in \`after\`
144
+ (runs on pass AND fail, unlike a trailing step inside \`steps\`). Deleting? Create
145
+ your own victim first — in \`before\` or the opening steps, \`{{unique}}\`-named —
146
+ then delete it and assert \`gone\`. Never aim a delete at standing data: run 2 would
147
+ have nothing to delete, and it eats the account's accumulated coverage.
144
148
 
145
149
  ### If there is no account yet
146
150
 
@@ -153,32 +157,26 @@ An empty \`accounts list\` means nothing is set up. In order of preference:
153
157
  \`beryl accounts create --type beryl\`, then prove it with
154
158
  \`beryl accounts provision <id> --file <plan.json>\`. The plan is any ActionPlan that
155
159
  ends LOGGED IN — a signup filling \`{{mailbox_address}}\` and \`{{login_password}}\`
156
- when the account is new, or a **sign-in** when it already exists (you made it by hand,
157
- or a previous provision succeeded and the record was lost). Both prove the same thing.
158
- If the app has no password sign-in, pass \`--login-method otp\` (or \`magic_link\`)
159
- and let the plan \`await_email\` its way through — later sign-ins read the same mailbox.
160
+ for a new account, or a **sign-in** for one that already exists (hand-made, or the
161
+ record was lost); both prove the same thing. No password sign-in? Pass
162
+ \`--login-method otp\` (or \`magic_link\`) and let the plan \`await_email\` through —
163
+ later sign-ins read the same mailbox.
160
164
  3. **Neither** → the flow is not testable authenticated. Say so rather than guessing.
161
165
 
162
- A project with no accounts still falls back to the \`LOGIN_EMAIL\` variable +
163
- \`LOGIN_PASSWORD\` secret and the handles resolve the same way, so existing tests are
166
+ A project with no accounts falls back to the \`LOGIN_EMAIL\` variable +
167
+ \`LOGIN_PASSWORD\` secret with the same handle resolution, so existing tests are
164
168
  unaffected — but new work should create an account.
165
169
 
166
170
  ### Session mode: sign in once per run, not once per test
167
171
 
168
- Every \`requires_auth\` plan declares its \`auth_mode\` there is no default. With
169
- \`"inline"\` the test carries its own sign-in steps and re-types them on every run; that
170
- is fine and always available.
171
-
172
- \`auth_mode: "session"\` moves the sign-in out of the test. The account signs in ONCE at
173
- the start of the run, the resulting browser session is proved live, and every
174
- \`requires_auth\` test in that run rides it session-mode tests carry no sign-in steps
175
- at all, and inline ones simply start already signed in. The test then starts where the
176
- flow it actually tests begins — no login preamble in the plan, in the replay, or in the
177
- failure evidence. Nothing has to be turned on for it: every run signs in its accounts and
178
- hands out the sessions. What a session-mode test DOES need is a stored login plan on its
179
- account — with none, there is nothing to sign in with, and the test fails at setup with
180
- \`SESSION_NO_LOGIN_PLAN\` rather than falling back, because it has no sign-in steps of its
181
- own to fall back to.
172
+ \`"inline"\` re-types the plan's own sign-in steps every run; always available.
173
+
174
+ \`"session"\` moves the sign-in out of the test: the account signs in ONCE at the start
175
+ of the run, the session is proved live, and every \`requires_auth\` test rides it
176
+ (inline ones just start already signed in). No login preamble in the plan, the replay,
177
+ or the failure evidence; nothing to turn on. A session-mode test has no sign-in steps
178
+ to fall back to, so an account with no stored login plan fails at setup with
179
+ \`SESSION_NO_LOGIN_PLAN\`.
182
180
 
183
181
  Set it up once per account:
184
182
 
@@ -187,82 +185,89 @@ beryl accounts set-login <id> --file signin.json --probe probe.json
187
185
  beryl accounts check <id> # signs in NOW and proves it — do not skip this
188
186
  \`\`\`
189
187
 
190
- - **\`--file\`** is the SIGN-IN plan (not the signup): opens the login page, fills
191
- \`{{login_email}}\` / \`{{login_password}}\`, \`await_email\`s a code if the account
192
- needs one, and asserts the logged-in shell.
193
- - **\`--probe\`** is two steps: goto a gated page, then a **positive** assertion that only
194
- holds when signed in — \`expect.visible\` on the account menu or a "Sign out" control.
195
- It is required. \`hidden\`, \`count 0\` and a URL match on a redirect ALL pass against a
196
- logged-out page, so without a positive signal a dead session runs every test logged-out
197
- and the run still reports green.
188
+ What \`--file\` and \`--probe\` must contain, and the \`--base-hash\` read-before-write
189
+ rule for later edits, are in \`accounts set-login\` / \`accounts get-login\`'s own
190
+ descriptions.
198
191
 
199
- Then write the tests with \`requires_auth: true\` and \`auth_mode: "session"\`, and NO
192
+ Then write the tests with \`requires_auth: true\`, \`auth_mode: "session"\`, and NO
200
193
  sign-in steps.
201
194
 
202
- **Creates share the sign-in.** \`tests create\` replays a session-mode plan against the
203
- account's established session reused from the last proven sign-in when it is still
204
- live — so a batch of creates costs at most one sign-in, same as a run. Still create one
205
- at a time: two racing creates would race the same mailbox for mail.
195
+ **Creates share the sign-in.** \`tests create\` replays session-mode plans against the
196
+ account's established session (reused while it stays live), so a batch of creates costs
197
+ at most one sign-in but still create one at a time: racing creates race the same
198
+ mailbox for mail.
206
199
 
207
- Reading before writing matters here: the login plan self-heals, so
208
- \`beryl accounts get-login <id>\` first and pass its \`login_plan_hash\` back as
209
- \`--base-hash\` a blind overwrite would clobber a repair you never saw.
200
+ An expired session is never your problem: every run proves the stored session and, on a
201
+ miss, signs in again from the login plan. A "reconnect" message only appears when there
202
+ is no stored login plan to refresh from.
210
203
 
211
- An expired session is never your problem to notice. Beryl proves the stored session at
212
- the start of every run and, if it no longer works, treats that as a cache miss and signs
213
- in again from the login plan. Nothing asks a human to reconnect that message only
214
- appears for a project with no stored login plan to refresh from.
215
-
216
- What to expect when it does not work: \`accounts check\` returning
217
- \`SESSION_LOGIN_FAILED\` means the sign-in plan itself is wrong — fix it.
218
- \`SESSION_PROOF_FAILED\` means signing in worked but the session could not be carried into
219
- a fresh browser, because this app keeps its credential somewhere unextractable. That is not
220
- your bug, but it IS your move: the account is marked unsupported, and its session-mode
221
- tests fail at setup with \`SESSION_UNSUPPORTED\` on every run until you re-author them
222
- with \`auth_mode: "inline"\` and their own sign-in steps. Inline tests are unaffected.
223
-
224
- \`beryl runs local\` works on session-mode tests too — same shape as the cloud: one
225
- sign-in per invocation, shared by every session-mode test.
204
+ When \`accounts check\` fails, its description explains which half broke and each
205
+ \`SESSION_*\` reason. The one that changes your authoring: an account marked
206
+ unsupported (its session cannot survive a fresh browser) is not your bug, but it IS
207
+ your move re-author its session-mode tests with \`auth_mode: "inline"\` and their own
208
+ sign-in steps.
226
209
 
227
210
  ### Two identities in one test
228
211
 
229
- Still not bankable as one test: invite-a-teammate-and-accept-as-them needs two identities
230
- mid-flow. Bank the half the app shows to account A ("the invitation is listed as pending",
231
- "the share link is issued") — a real, strong outcome. While AUTHORING you can drive the
232
- full handshake live: create a second account (\`accounts create --label member\`), or add a
233
- second mailbox (\`mailbox create --label invitee\`) and read it (\`mailbox read\`).
212
+ Invite-a-teammate-and-accept-as-them needs two identities mid-flow — not bankable as
213
+ one test. Bank the half the app shows to account A ("the invitation is listed as
214
+ pending", "the share link is issued") — a real, strong outcome. While AUTHORING you can
215
+ drive the full handshake live: a second account (\`accounts create --label member\`), or
216
+ a second mailbox (\`mailbox create --label invitee\`, read with \`mailbox read\`).
217
+
218
+ SSO-only sites (no email+password form at all) ride a captured session instead: a human
219
+ signs in once through a live browser view in the Beryl webapp, Beryl stores the session
220
+ encrypted server-side and injects it into cloud runs — \`tests create\` verifies such
221
+ plans server-side, \`runs trigger\` runs them, \`runs local\` skips them (the session
222
+ never leaves Beryl's cloud).
234
223
 
235
- SSO-only sites (no email+password form at all) remain webapp territory.
236
224
  ## 2. Author locally over the Playwright MCP
237
225
 
238
- 1. **Drive the flow in a real browser first.** Use the Playwright MCP to open the app and
239
- walk the flow by hand — log in, fill the form, submit, whatever the flow is. You act on
240
- elements by their accessibility ref from the latest page snapshot, not a guessed
241
- selector. Watch what actually happens; don't author from imagination.
226
+ 1. **Drive the flow in a real browser first.** Walk it by hand over the Playwright MCP
227
+ — log in, fill, submit acting on elements by their accessibility ref from the
228
+ latest page snapshot, never a guessed selector. Watch what actually happens.
242
229
  2. **Write it as an ActionPlan** — a JSON object whose \`steps\` are
243
- \`{action, selector, url, value, ...}\`. Two structural rules the plan must satisfy:
244
- - the **first executed step is a \`goto\`** (the flow has to start by navigating somewhere), and
230
+ \`{action, selector, url, value, ...}\`. Two structural rules:
231
+ - the **first executed step is a \`goto\`**, and
245
232
  - **at least one step is an \`expect\`** (a test that asserts nothing is not a test).
246
233
  **A \`goto\` at your own app is a PATH, never a full URL** — \`/pricing\`, not
247
- \`https://app.example.com/pricing\`. The origin comes from the environment's root URL, so
248
- one plan runs against prod, staging and a preview. Bake the origin in and \`--env\` is
249
- silently ignored: the test keeps hitting whatever host you typed. Absolute URLs stay
250
- legal for OTHER origins (an OAuth handoff, a magic link on another domain).
251
- Optional \`before\` / \`after\` arrays hold setup and teardown; \`after\` runs even when a
252
- main step fails, so a create/update/delete flow can clean up the record it made.
234
+ \`https://app.example.com/pricing\`. The origin comes from the environment's root
235
+ URL, so one plan runs against prod, staging and a preview; bake the origin in and
236
+ \`--env\` is silently ignored. Absolute URLs stay legal for OTHER origins (an OAuth
237
+ handoff, a magic link on another domain).
238
+ Optional \`before\` / \`after\` arrays hold setup and teardown; \`after\` runs even
239
+ when a main step fails, so a create flow can clean up its record and a deletion
240
+ flow creates its own record in \`before\` first.
241
+
242
+ The full action vocabulary:
243
+
244
+ ${indent(actionTable())}
245
+
246
+ The routinely mis-written ones — exact semantics:
247
+ - \`upload\`'s \`value\` names a file in the project's CONFIG-FILE store
248
+ (\`config files upload\`), never a filesystem path.
249
+ - \`dialog\` arms a one-shot accept/dismiss handler for the dialog the NEXT step
250
+ triggers (armed before the click, or Playwright auto-dismisses it). No selector;
251
+ optional \`dialog_expect_text\` asserts the dialog's message.
252
+ - \`switch_tab\` / \`close_tab\`: \`value\` is a 0-based tab index OR a URL substring.
253
+ - \`scroll\` with no selector scrolls the PAGE — \`value\` is \`bottom\`, \`top\`,
254
+ \`up\`, \`down\`, or a signed pixel count; default one viewport down. With a
255
+ selector it scrolls that element into view.
256
+
257
+ Any element action can reach inside an iframe (Stripe/Adyen card fields) via the
258
+ \`iframe\` field — a URL-substring match on the frame's \`src\`; ignored by
259
+ page-level verbs.
253
260
 
254
261
  An \`expect\` step's shape is \`{action: "expect", expect_kind, selector, expect_text}\`:
255
- - \`expect_kind\` (required) is one of \`visible\`, \`attached\`, \`hidden\`, \`checked\`,
256
- \`enabled\`, \`disabled\`, \`have_text\`, \`have_value\`, \`have_url\`, \`have_title\`,
257
- \`have_count\`, \`persisted\`, \`gone\`, \`count_delta\`.
258
- - \`selector\` is required for every kind except the page-level \`have_url\` /
259
- \`have_title\` (those assert on the page, not an element).
260
- - \`expect_text\` carries the expected string for \`have_text\` / \`have_value\` (exact
261
- match on the element) and \`have_url\` / \`have_title\` (substring match on the page).
262
- There is no \`value\` field on an expect and no bare \`text\` / \`url\` kind.
263
- - Two kinds take other fields instead: \`have_count\` needs \`expect_count\`, and
264
- \`count_delta\` needs \`capture_ref\` + \`expect_delta\` (vs a baseline banked by an
265
- earlier \`capture_count\` step).
262
+
263
+ ${indent(expectTable())}
264
+
265
+ - \`expect_text\` is an exact match on the element for \`have_text\` / \`have_value\`,
266
+ a substring match on the page for \`have_url\` / \`have_title\` (page-level kinds
267
+ take no selector). There is no \`value\` field on an expect and no bare \`text\` /
268
+ \`url\` kind.
269
+ - \`have_count\` needs \`expect_count\`; \`count_delta\` needs \`capture_ref\` +
270
+ \`expect_delta\` (vs a baseline banked by an earlier \`capture_count\` step).
266
271
 
267
272
  A fully valid minimal plan ("the pricing page renders"):
268
273
 
@@ -278,89 +283,79 @@ ${JSON.stringify(BERYL_TEST_SKILL_EXAMPLE_PLAN, null, 2)
278
283
  beryl tests create --title "Log in" --file plan.json \\
279
284
  --description "<the intent — see §3>"
280
285
  \`\`\`
281
- By default \`create\` proves the plan by replaying it in a browser ON YOUR MACHINE
282
- (your local \`@playwright/test\` §4's prerequisites apply) before anything is banked.
283
- The same is true over the MCP: the replay runs wherever the MCP server runs — your
284
- computer, never Beryl's so the §4 install must exist there too.
285
- A red replay banks nothing: fix the plan file and re-run \`create\`. The green proving
286
- replay is imported as the test's first run (\`--no-sync\` to skip); \`--no-verify\` banks
287
- unproven. Inspect the exact spec that would run with \`beryl tests script --file
288
- plan.json\`. The full ActionPlan JSON Schema is at
286
+ \`create\` banks the plan only on a green local replay; a red one hands back the
287
+ failure fix and re-run. Replay mechanics (where it runs over MCP, \`--no-sync\`,
288
+ \`--no-verify\`, the cloud-session exception) are in \`tests create\`'s own
289
+ description. Inspect the exact spec with \`beryl tests script --file plan.json\`.
290
+ Full ActionPlan JSON Schema:
289
291
  https://api.beryl.so/api/v1/schemas/action-plan.schema.json.
290
292
 
291
293
  ### The outcome assertion is the whole game
292
294
 
293
295
  A flow is only worth banking if you can point at the **success signal** — the one
294
- observable proof the flow worked. Get this right and everything else follows.
295
-
296
- - The signal must be **true only if the flow succeeded**. A confirmation message that
297
- appeared, an element that showed up or disappeared, content unique to where the flow
298
- landed.
299
- - **Never assert global chrome** the nav bar, logo, footer, or cookie banner is on every
300
- page, so asserting it tests nothing. "Was there anyway" means site-wide chrome, NOT the
301
- destination's own distinctive content.
296
+ observable proof the flow worked.
297
+
298
+ - The signal must be **true only if the flow succeeded**: a confirmation that appeared,
299
+ an element that showed up or disappeared, content unique to where the flow landed.
300
+ - **Never assert global chrome** — nav bar, logo, footer, cookie banner are on every
301
+ page, so asserting them tests nothing. "Was there anyway" means site-wide chrome, NOT
302
+ the destination's own distinctive content.
302
303
  - For a **navigation** flow, the strongest signal is that the destination actually
303
- **rendered**: assert its unique heading or a piece of content specific to that page (for
304
- \`/pricing\`, the "Pricing" H1 or a plan name). Prefer that over the URL alone a bare
305
- "the URL is /pricing" passes even on a blank or broken page that never rendered.
306
- Reserve a URL-only assertion for when the URL *is* the outcome (a form that lands on
307
- \`/thank-you\`) and no distinctive destination content is available.
308
- - The usual outcome kinds: \`visible\` (the success element showed up), \`have_text\` (an
309
- element's text matches), \`have_url\` (the URL contains a value), \`gone\` (an element
310
- disappeared — e.g. a spinner, or the item you just deleted). §2 has the full
311
- \`expect_kind\` list and the step shape.
304
+ **rendered**: its unique heading or page-specific content (for \`/pricing\`, the
305
+ "Pricing" H1 or a plan name). A bare "the URL is /pricing" passes even on a blank,
306
+ broken page reserve URL-only assertions for when the URL *is* the outcome (a form
307
+ landing on \`/thank-you\`) and no distinctive content exists.
308
+ - The usual outcome kinds: \`visible\` (the success element showed up), \`have_text\`
309
+ (an element's text matches), \`have_url\` (the URL contains a value), \`gone\` (an
310
+ element disappeared — a spinner, the item you just deleted).
312
311
  - **\`have_text\` is an EXACT full-text match on the selector's element** — asserting
313
- \`have_text: "Documentation"\` on \`body\` fails, because \`body\`'s text includes all the
314
- nav chrome. Target the specific element that carries the text (the \`h1\`, the toast),
315
- or assert the page instead (\`have_title\`, which is a substring match). To check "this
316
- string is visible somewhere", use \`expect_kind: "visible"\` with a \`text=…\` selector.
317
- - **If you can't name a success signal, the flow is not test-worthy.** Don't bank a test
318
- that verifies nothing. Explore a different flow instead.
319
- - **Don't work around a real app failure to make a test go green.** If the flow is
320
- genuinely broken in the app, that's a finding to report not something to paper over
321
- with a weaker assertion.
312
+ \`have_text: "Documentation"\` on \`body\` fails because \`body\` includes all the nav
313
+ chrome. Target the element that carries the text (the \`h1\`, the toast), assert the
314
+ page with \`have_title\` (substring), or check "this string is visible somewhere"
315
+ with \`expect_kind: "visible"\` + a \`text=…\` selector.
316
+ - **If you can't name a success signal, the flow is not test-worthy.** Don't bank a
317
+ test that verifies nothing; explore a different flow.
318
+ - **Don't work around a real app failure to make a test go green.** A genuinely broken
319
+ flow is a finding to report, not something to paper over with a weaker assertion.
322
320
 
323
321
  ### Traps when authoring against a real app
324
322
 
325
323
  Every one of these has produced a wrong plan or a red \`tests create\`. Check them.
326
324
 
327
325
  1. **Your browser may already be signed in.** The Playwright MCP keeps a persistent
328
- profile, so a session can survive from earlier work. Author while signed in and you
329
- never see the gate — you will mark gated pages as public. **Log out first**, then
330
- confirm the page you think is gated really does redirect to the login.
331
- 2. **The sign-UP flow is not the sign-IN flow.** A brand-new address often gets an extra
332
- "create your account" step that a returning address skips entirely. An account's stored
333
- login plan must be the **returning** path — that is what runs on every future run. Drive
334
- it twice: once to create the account, once to see signing in again.
326
+ profile. Author while signed in and you never see the gate you will mark gated
327
+ pages as public. **Log out first**, then confirm the gated page really redirects to
328
+ the login.
329
+ 2. **The sign-UP flow is not the sign-IN flow.** A brand-new address often gets an
330
+ extra "create your account" step a returning address skips. A stored login plan must
331
+ be the **returning** path — drive it twice: once to create, once to sign in again.
335
332
  3. **Read the DOM, not just the accessibility tree, before picking a selector.** Two
336
- buttons can share a visible label ("Continue" and "Continue with Google"), and Beryl
337
- relaxes \`text=X\` to a case-insensitive SUBSTRING — so \`text=Continue\` is ambiguous.
338
- Find something unique (\`button[type=submit]\`, \`input[name=email]\`,
339
- \`input[autocomplete=one-time-code]\`) and use that.
340
- 4. **A single-page app can redirect after the first \`goto\`.** If \`/dashboard\` client-side
341
- redirects to \`/dashboard/<id>\`, a click fired straight after the goto lands on the
342
- pre-redirect render and its effect is discarded when the app re-renders a dialog that
343
- opens and instantly vanishes, for instance. Put a \`wait_for\` on something that exists
344
- only AFTER the redirect, then act.
345
- 5. **Never bake an id into a URL.** \`goto /projects/8ab46d63-.../settings\` breaks for any
346
- other account. Navigate to the stable entry point and click through
347
- (\`a[href$='/settings']\`), so the plan is about the app, not about your row.
348
- 6. **Assert durable content, not the empty state.** "No tests yet" is true today and false
349
- the moment anything exists. Prefer what is structural to the page — a section heading, a
350
- permanent explainer, a control that is always there.
333
+ buttons can share a label ("Continue" / "Continue with Google"), and Beryl relaxes
334
+ \`text=X\` to a case-insensitive SUBSTRING — \`text=Continue\` is ambiguous. Use
335
+ something unique (\`button[type=submit]\`, \`input[name=email]\`,
336
+ \`input[autocomplete=one-time-code]\`).
337
+ 4. **A single-page app can redirect after the first \`goto\`.** If \`/dashboard\`
338
+ client-side redirects to \`/dashboard/<id>\`, a click fired straight after the goto
339
+ lands on the pre-redirect render and is discarded on re-render. Put a \`wait_for\`
340
+ on something that exists only AFTER the redirect, then act.
341
+ 5. **Never bake an id into a URL.** \`goto /projects/8ab46d63-.../settings\` breaks for
342
+ any other account. Navigate to the stable entry point and click through
343
+ (\`a[href$='/settings']\`) the plan is about the app, not your row.
344
+ 6. **Assert durable content, not the empty state.** "No tests yet" is true today and
345
+ false the moment anything exists. Prefer what is structural a section heading, a
346
+ permanent explainer, an always-present control.
351
347
 
352
348
  ## 3. Writing the natural-language intent
353
349
 
354
- Pass the intent as \`--description\` on \`beryl tests create\` (or \`tests set-plan\` when you
355
- re-author). 1–3 sentences. This is the immutable anchor from §6 — write it well.
350
+ Pass the intent as \`--description\` on \`beryl tests create\` (or \`tests set-plan\`
351
+ when you re-author). 1–3 sentences. This is the immutable anchor from §6.
356
352
 
357
353
  - **State the purpose, not the steps.** Not "clicks Sign in, types email and password,
358
- clicks submit" — that's the trajectory, which Beryl already has and which will change.
359
- Instead: *what does a green run prove is true about the app?*
360
- - **Name the one observable outcome** that is true only if the flow worked — the same
361
- success signal you asserted in §2, in words.
362
- - **Never describe global chrome.** The intent is about the flow's destination and
363
- outcome, not "the header is present".
354
+ clicks submit" — the trajectory will change. Instead: *what does a green run prove is
355
+ true about the app?*
356
+ - **Name the one observable outcome** that is true only if the flow worked — §2's
357
+ success signal, in words.
358
+ - **Never describe global chrome.**
364
359
 
365
360
  Good:
366
361
  > "Proves a returning user can sign in: after submitting valid credentials, the
@@ -371,79 +366,63 @@ Weak (describes steps + asserts nothing meaningful):
371
366
 
372
367
  ## 4. The local run-fix loop
373
368
 
374
- Iterate on your machine before you rely on the cloud. \`beryl runs local\` fetches the
375
- test's rendered spec and runs it with your local \`@playwright/test\` — no cloud, no waiting
376
- for a scheduled run.
369
+ Iterate on your machine before you rely on the cloud. \`beryl runs local\` runs banked
370
+ tests with your local \`@playwright/test\` — no cloud, no waiting for a scheduled run.
377
371
 
378
372
  \`\`\`
379
- npm i -D @playwright/test && npx playwright install chromium # once
380
373
  beryl runs local <test-id> --no-sync --url-override http://localhost:3000 --dir ./beryl-local
381
374
  beryl runs local # the whole suite, results recorded in Beryl
382
375
  \`\`\`
383
376
 
384
- - Takes one or more test ids; with none it runs every active test in the project.
385
- - **Results sync to Beryl by default** — the finished run is imported as a first-class
386
- run (history, replay, report; trigger source \`local\`). While ITERATING on a draft,
387
- pass \`--no-sync\` so every fix-loop attempt doesn't land in the project's run history.
388
- - \`--url-override\` swaps the root URL for THIS run for a throwaway host (a dev server, a
389
- per-PR preview). A standing environment is \`--env <id>\` instead, not an override.
390
- - \`--dir\` keeps the **spec, artifacts, and a JSON \`report.json\`** on disk so you (or your
391
- coding agent) can read exactly what happened and iterate: read the report, see which step
392
- or assertion failed and why, fix the plan, \`beryl tests set-plan\`, run again.
393
- - It exits **0** if every test passed, **1** on a failure — so it drops straight into a
394
- run-fix-run loop.
395
- - \`await_email\` steps work locally: the CLI answers them over the API against the same
396
- mailbox the cloud runner would use, exactly as it would.
397
- - **Authenticated tests work locally.** A plan that signs itself in by filling
398
- \`{{login_email}}\` / \`{{login_password}}\` runs fine: the email is baked into the fetched
399
- spec and the password is revealed once over the logged secret-reveal route, then
400
- scrubbed from any uploaded error text or DOM snapshot. If the test account it names has
401
- no password stored, the test is skipped with the exact fix-it command.
402
- - A test that depends on a session Beryl holds server-side, rather than signing itself in,
403
- is skipped locally with a note — run those with \`beryl runs trigger\`.
404
-
405
- Once the test passes locally against a real outcome, it's ready to bank and let Beryl run
406
- and heal it.
377
+ The loop: draft \`tests lint\` \`tests create\` \`runs local --no-sync\` while
378
+ iterating (a synced run lands in the project's run history) read \`--dir\`'s
379
+ \`report.json\` to see which step or assertion failed and why fix the plan →
380
+ \`tests set-plan\` run again. Flag semantics, session-mode behavior, \`await_email\`
381
+ and authenticated-test handling, and exit codes are in \`runs local\`'s own description.
382
+
383
+ Once the test passes locally against a real outcome, it's ready to bank and let Beryl
384
+ run and heal it.
407
385
 
408
386
  ## 5. Testing an OTP / signup flow (\`await_email\`)
409
387
 
410
- A flow that emails the user — a signup verification code, a magic sign-in link, a receipt
411
- — is testable with the \`await_email\` action. No setup, no environment configuration, no
388
+ A flow that emails the user — a signup verification code, a magic sign-in link, a
389
+ receipt — is testable with the \`await_email\` action. No setup, no configuration, no
412
390
  flag to turn on.
413
391
 
414
- **The project has one permanent mailbox and all of its mail arrives there.** Two handles
415
- put an address on the page, and the one you cite decides which *identity* the test acts as:
392
+ **The project has one permanent mailbox and all of its mail arrives there.** Two
393
+ handles put an address on the page; the one you cite decides which *identity* the test
394
+ acts as:
416
395
 
417
396
  | Handle | Renders as | Use it for |
418
397
  |---|---|---|
419
398
  | \`{{mailbox_address}}\` | the mailbox's own address, the same every run | signing in as the project's standing test account (§1) |
420
399
  | \`{{inbox_address}}\` | a \`+tag\` alias of it, fresh every run | tests whose subject IS getting a NEW identity — a signup, an invited teammate |
421
400
 
422
- An alias is a real address the site has never issued, so a signup is repeatable run after
423
- run; the mail still lands in the same mailbox, and Beryl reads only the alias's own mail.
424
- Nothing expires and there is no second inbox to manage.
425
-
426
- Default to \`{{mailbox_address}}\`. Reach for \`{{inbox_address}}\` only when an existing
427
- account would be rejected — a signup form, or an invite you must accept as a second person.
401
+ An alias is a real address the site has never issued, so a signup is repeatable run
402
+ after run; the mail lands in the same mailbox and Beryl reads only the alias's own
403
+ mail. Nothing expires; there is no second inbox to manage. Default to
404
+ \`{{mailbox_address}}\`; reach for \`{{inbox_address}}\` only when an existing account
405
+ would be rejected a signup form, an invite accepted as a second person.
428
406
 
429
407
  The wiring is a three-part chain:
430
408
 
431
- 1. **Type the address into the app** — a \`fill\` with \`value: "{{inbox_address}}"\` (or
432
- \`{{mailbox_address}}\`). An alias is fresh every run, so a signup flow is repeatable by
433
- construction (no \`{{unique}}\` needed for the email itself; use \`{{unique}}\` for other
434
- must-not-collide values like a username).
409
+ 1. **Type the address into the app** — a \`fill\` with \`value: "{{inbox_address}}"\`
410
+ (or \`{{mailbox_address}}\`). The alias is fresh every run, so a signup is
411
+ repeatable by construction no \`{{unique}}\` needed for the email itself; use it
412
+ for other must-not-collide values like a username. The generator handles
413
+ \`{{unique}}\`, \`{{uuid}}\`, \`{{timestamp}}\` are each minted once per execution;
414
+ \`{{timestamp}}\` is second-resolution (a time, NOT a uniqueness guarantee —
415
+ \`{{unique}}\` is).
435
416
  2. **Await the mail and bank the extracted value** — an \`await_email\` step with:
436
- - \`extract\` (required): \`code\` (an OTP), \`link\` (the sign-in/verify URL), or
437
- \`pattern\` (your own regex in \`extract_pattern\`, exactly one capture group).
417
+ - \`extract\` (required): ${extractLine()}.
438
418
  - \`capture_as\` (required): the handle name the extracted string is banked under.
439
- - \`subject_contains\` / \`from_contains\` (optional): match the right mail when the app
440
- sends more than one.
441
- - \`wait_s\` (optional, 1–50, default 30): how many seconds the step blocks waiting
442
- for the mail to land.
443
- 3. **Use the banked value** — cite \`{{<capture_as>}}\` in a later step's \`value\` (fill the
444
- code) or \`url\` (goto the magic link). A captured handle is legal **only** in
445
- \`value\`/\`url\`; in a \`selector\`, \`option\`, or \`expect_text\` it would be used as
446
- literal text, and the linter rejects it there.
419
+ - \`subject_contains\` / \`from_contains\` (optional): match the right mail when the
420
+ app sends more than one.
421
+ - \`wait_s\` (optional, 1–50, default 30): seconds to block waiting for the mail.
422
+ 3. **Use the banked value** — cite \`{{<capture_as>}}\` in a later step's \`value\`
423
+ (fill the code) or \`url\` (goto the magic link). A captured handle is legal
424
+ **only** in \`value\`/\`url\`; in a \`selector\`, \`option\`, or \`expect_text\` it
425
+ would be literal text, and the linter rejects it there.
447
426
 
448
427
  A fully valid signup-with-OTP plan:
449
428
 
@@ -457,34 +436,32 @@ For a magic-link flow, replace the code steps with
457
436
 
458
437
  Two caveats:
459
438
 
460
- - \`beryl tests create\` verifies an \`await_email\` plan like any other its local
461
- replay receives at the project mailbox and answers each step over the API, so the app's
462
- mail really is received and extracted before the test is accepted. (Note the replay
463
- signs up / sends mail for real; pass \`--no-verify\` only if that side effect is
464
- unwanted.) \`beryl runs local\` serves \`await_email\` the same way, so the whole local
465
- loop covers OTP/signup flows end to end.
466
- - The outcome assertion discipline from §2 still applies: the green signal is the
467
- post-verification state (the welcome screen, the dashboard), not "an email arrived".
439
+ - \`beryl tests create\` verifies an \`await_email\` plan like any other: the local
440
+ replay receives at the project mailbox and answers each step over the API, so the
441
+ app's mail really is received and extracted before the test is accepted. The replay
442
+ signs up / sends mail FOR REAL pass \`--no-verify\` only if that side effect is
443
+ unwanted. \`beryl runs local\` serves \`await_email\` the same way, so the local loop
444
+ covers OTP/signup flows end to end.
445
+ - §2's outcome discipline still applies: the green signal is the post-verification
446
+ state (the welcome screen, the dashboard), not "an email arrived".
468
447
 
469
448
  ## 6. Why this shape: durable and healable
470
449
 
471
- Beryl's cloud runs your test on a schedule. When the app's markup drifts and a selector
472
- stops matching, a heal-vs-fail agent decides whether to **heal** the test (silently
473
- re-derive the selector/trajectory and keep it green) or **fail** it (surface a real
474
- regression). It decides that against your test's **intent**:
475
-
476
- - **The natural-language intent is the immutable anchor. Beryl never rewrites it.** It's
477
- the description of what the test proves — the load-bearing statement the heal agent
478
- judges every future run against.
479
- - **Selectors and the trajectory are the healable "how".** A button moved, a class name
480
- changed, a step needs an extra click — those are mechanics Beryl can re-derive on its
481
- own, because your intent tells it what the flow was *for*.
482
- - **A failed outcome assertion is a real regression Beryl will NOT silently heal green.**
483
- If the success signal from §2 stops holding — the confirmation never appears, the page
484
- never renders — that's the app breaking, and the test fails loudly. That is the point.
485
-
486
- So a test is *healable* exactly when you gave it **a strong intent + a real outcome
487
- assertion**. A test with a vague intent and a chrome-only assertion is brittle: Beryl
488
- can't tell a real regression from cosmetic drift, so it either heals over real breakage or
489
- fails on noise.
450
+ Beryl's cloud runs your test on a schedule. When markup drifts and a selector stops
451
+ matching, a heal-vs-fail agent decides whether to **heal** (silently re-derive the
452
+ selector/trajectory, stay green) or **fail** (surface a real regression) — judged
453
+ against your test's **intent**:
454
+
455
+ - **The intent is the immutable anchor; Beryl never rewrites it.** It states what the
456
+ test proves — what every future run is judged against.
457
+ - **Selectors and the trajectory are the healable "how"** — a moved button, a renamed
458
+ class, an extra click. Beryl re-derives those because your intent says what the flow
459
+ is *for*.
460
+ - **A failed outcome assertion is a real regression Beryl will NOT heal green.** If the
461
+ success signal stops holding, the app broke, and the test fails loudly. That is the
462
+ point.
463
+
464
+ So a test is *healable* exactly when it has **a strong intent + a real outcome
465
+ assertion**. With a vague intent and a chrome-only assertion Beryl can't tell
466
+ regression from cosmetic drift it either heals over real breakage or fails on noise.
490
467
  `;