@unotest/web 0.9.1 → 0.11.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.
@@ -0,0 +1,787 @@
1
+ ---
2
+ name: write-e2e-test-ground
3
+ description: Write a new E2E test for the web application under test using @unotest/web, on a server running in GROUND mode (UNOTEST_SNAPSHOT=0 — no page snapshots; discovery via the semantic ground_element tool). Records actions through explore_step against the live browser, generates a DSL test file, and verifies it via run_test. Use when the user asks to write/cover/add a test AND the unotest-web server has no get_page_snapshot tool.
4
+ ---
5
+
6
+ # Skill: write-e2e-test-ground
7
+
8
+ You are writing an end-to-end test using `@unotest/web` against a web
9
+ app. The test lives in `unotest/e2e/<feature>/<name>.js` in the project
10
+ under test (NOT in the `@unotest/web` package itself). Scenarios are
11
+ grouped by feature/page in subfolders — never placed directly in the
12
+ `unotest/e2e/` root (the linter rejects that with `lint:scenario-in-root`).
13
+
14
+ **Default mode is verify-against-live-app.** A test that hasn't been
15
+ run is unverified — selectors may be invented, transitions may race,
16
+ the scenario may not actually pass. Producing a test you can't show
17
+ green is the failure case.
18
+
19
+ Two modes, picked at Phase 1:
20
+
21
+ - **Interactive (preferred)** — phases 1–5 below. The MCP server is up
22
+ (`new_context`, `ground_element`, `explore_start`, `explore_step`,
23
+ `run_test`, …). You drive the browser live, record the flow, generate
24
+ the DSL, save it, run it, iterate until green.
25
+
26
+ - **Draft-only (fallback)** — if no MCP / no working `explore_start`,
27
+ write the file from the description the user gave you, mark it as
28
+ unverified in your final reply ("⚠ unverified — no live browser —
29
+ run `npx @unotest/web e2e <name>` after fixing the env"), and STOP.
30
+ Don't pretend a draft is a working test.
31
+
32
+ **This server runs in ground mode (`UNOTEST_SNAPSHOT=0`).** Page
33
+ snapshots do not exist here — there is NO `get_page_snapshot` tool.
34
+ You normally need NO separate discovery call either: act directly
35
+ with an **INTENT LOCATOR** — the runner grounds it to the element
36
+ inside the same call:
37
+
38
+ ```
39
+ locator: {kind:"locator", steps:[{kind:"intent",
40
+ intent:"<the element in the user's words>",
41
+ ordinal?: "first" | "last" | N}]}
42
+ ```
43
+
44
+ One `explore_step` per action, no lookup round-trip. Positions go in
45
+ `ordinal` (typed), NEVER in the intent text ("first Pending row" →
46
+ intent `"the Pending row"` + `ordinal: "first"`). Name the target
47
+ itself, not its page region.
48
+
49
+ Lookup tools — only when you must LOOK before acting:
50
+
51
+ - `ground_element {intent, ordinal?}` — same grounding as a lookup:
52
+ returns the element line + top-20 candidates. Use to explore an
53
+ unknown page or after an intent-locator error. NOT needed to check
54
+ what an intent resolves to when you are about to use it in a step —
55
+ the step's own reply carries `groundedTo`.
56
+ - `find_element {role, name, near?}` — exact ARIA role+name query.
57
+
58
+ ## Workflow — record then generate then verify
59
+
60
+ Five phases, in this order. Don't skip phases.
61
+
62
+ ### Phase 0 — read exactly these files, nothing else
63
+
64
+ Three reads. Don't `find`/`tree`/`ls` the project. Don't open files
65
+ outside this list — they won't tell you anything Phase 1+ doesn't.
66
+
67
+ 1. **`unotest/e2e/_template.js`** — Read it. Shows the DSL syntax.
68
+ When your default JS instincts disagree with the template, the
69
+ template wins.
70
+ 2. **`unotest/e2e/_helpers/`** — `Glob` to list `*.js`. If a filename
71
+ matches the flow you're about to record ("signin", "seed",
72
+ "checkout"), `Read` THAT file only. If `signin_as(email, password)`
73
+ exists, the test calls it instead of re-recording the same steps.
74
+ No matches → skip; move on.
75
+ 3. **`unotest/.env`** — Read it. Pulls `APP_BASE_URL`,
76
+ `DATABASE_URL`, etc. that the scenario will reach for.
77
+
78
+ Then move to Phase 1. Three files in, exploration done.
79
+
80
+ ### Phase 1 — start the exploration
81
+
82
+ ```
83
+ explore_start { scenario_name: "<feature>/<name>" } → { explorationId }
84
+ ```
85
+
86
+ Keep the `explorationId` — every recorded step needs it.
87
+
88
+ `explore_start` opens the browser itself. Do NOT call `new_context`
89
+ first — it's for RESETTING the browser (Phase 5), not for starting.
90
+
91
+ ### Phase 2 — record the known steps as ONE batch
92
+
93
+ **Batch-first default.** When the task brief already spells out the
94
+ sequence (navigate → click X → click Y → verify), do NOT issue the
95
+ steps one `explore_step` at a time — send them as ONE `explore_steps`
96
+ call: `goto`, the actions, the CHECKS, and `autoRun: true`:
97
+
98
+ ```
99
+ explore_steps {
100
+ explorationId,
101
+ autoRun: true,
102
+ steps: [
103
+ {action: "goto", url: "<FULL absolute URL from the task>",
104
+ description: "Open <page>", section: "Open <page>"},
105
+ {action: "click",
106
+ locator: {kind:"locator", steps:[{kind:"intent",
107
+ intent:"<target in the user's words>"}]},
108
+ description: "Click <target>", section: "<goal of the block>"},
109
+ {action: "assert_url", pattern: "<expected substring>",
110
+ description: "Arrived at <page>", section: "<goal of the block>"},
111
+ {action: "assert_text",
112
+ locator: {kind:"locator", steps:[{kind:"intent",
113
+ intent:"\"<expected text>\""}]},
114
+ text: "<expected text>", options: {exact: false},
115
+ description: "Shows <expected text>", section: "<goal of the block>"}
116
+ ]
117
+ }
118
+ ```
119
+
120
+ Up to 25 steps, per-step shape identical to `explore_step` (each step
121
+ carries its OWN `section` + `description`). Sequential; stops at the
122
+ FIRST failure; the reply has per-step status + `firstError`; only the
123
+ steps that executed are recorded. Intent locators ground at execution
124
+ time against the then-current page — navigation mid-batch is fine.
125
+
126
+ Two intent rules that decide whether the batch survives first try:
127
+ - `goto` takes the FULL absolute URL (relative paths are rejected
128
+ unless the project config defines a baseUrl).
129
+ - Assert intents NAME THE VISIBLE TEXT of the target — quote it:
130
+ `"Space Rangers Junior Cadets"`, never a bare role like `main page
131
+ heading`: a page holds a dozen same-role units and the quoted text
132
+ is what disambiguates. Add a role word (`heading "…"`) ONLY when the
133
+ brief says the target is one — on many pages the text you are
134
+ verifying lives in a row or a link, and `heading "…"` then honestly
135
+ grounds to nothing.
136
+
137
+ **The brief has no verification step? Verify with the text you already
138
+ have.** The LAST thing the brief told you to click is named in the
139
+ brief, so that same name is on the page you land on — assert it:
140
+
141
+ ```
142
+ {action: "assert_text",
143
+ locator: {kind:"locator", steps:[{kind:"intent",
144
+ intent:"\"<the name you just clicked>\""}]},
145
+ text: "<the name you just clicked>", options: {exact: false},
146
+ section: "<goal of the block>", description: "Page shows <name>"}
147
+ ```
148
+
149
+ Quote the text and nothing else — no `heading`, no `title`, unless the
150
+ brief called it that. The intent is grounded against whatever unit
151
+ actually carries those words (a link inside a table row counts).
152
+
153
+ Do NOT go looking for something to assert. A `get_url` /
154
+ `get_active_context` / lookup round-trip before the assert costs calls
155
+ and buys nothing the brief didn't already give you. Where you ARE is
156
+ in the reply already: a step that changed the address carries `url`,
157
+ and what an intent hit carries `groundedTo`. `assert_url` is for
158
+ when the brief names the destination URL — otherwise the text you
159
+ clicked is the check. `autoRun` needs at least one verification step,
160
+ so a batch without one splits the flow in two.
161
+
162
+ **Record the checks as steps, not by hand.** `assert_text` /
163
+ `assert_visible` / `assert_hidden` / `assert_value` / `assert_count` /
164
+ `assert_url` execute live — a recorded assert has already passed
165
+ against the real page, and the saved test gets the matching
166
+ `assertText(...)` / `assertUrl(...)` lines. Never hand-edit assertions
167
+ into the generated file.
168
+
169
+ **A checkbox is verified with `assert_value` and `"true"` / `"false"`**
170
+ — it asserts the CHECKED STATE. Do not reach for a CSS `:checked`
171
+ selector: raw `locator(...)` is exactly what the recording flow exists
172
+ to avoid, and the same intent locator that ticked the box verifies it.
173
+
174
+ ```
175
+ explore_step {explorationId, action: "assert_value",
176
+ locator: {kind:"locator", steps:[{kind:"intent",
177
+ intent:"selection checkbox of the Pending row",
178
+ ordinal:"first"}]},
179
+ value: "true", description: "First Pending row is selected"}
180
+ ```
181
+
182
+ **`autoRun: true` finishes the whole flow in this one call** when every
183
+ step succeeded, at least one verification step (assert_* / wait_for_*)
184
+ is recorded, the draft has no blocking warnings (DYNAMIC_TEXT /
185
+ NO_DSL_PRIMITIVE — FRAGILE_LOCATOR passes and rides along in the
186
+ reply), and the target file is new: it saves
187
+ `unotest/e2e/<scenario_name>.js`, resets the browser context and runs
188
+ the test — the reply carries `autoRun.run.next.outcome` (passed ⇔
189
+ `"completed"`). Surface any returned `warnings` to the user. If a gate
190
+ fails, the reply says `autoRun.status: "skipped"` + reason, the session
191
+ stays active, and you finish with Phases 4–5 below.
192
+
193
+ **When a batch stops at step N:** the executed prefix is already
194
+ recorded — do not redo it. Repair step N with a single `explore_step`
195
+ (re-phrase the intent / add a preparatory `wait_for` or
196
+ `scroll_into_view`), then continue the remaining steps as a new batch
197
+ (`autoRun` works there too).
198
+
199
+ Fall back to single `explore_step` only when you genuinely don't know
200
+ the next step yet (need a `ground_element` probe or an ad-hoc look at
201
+ the page).
202
+
203
+ ### Phase 3 — discover, then act (loop)
204
+
205
+ For each page state you traverse (when NOT covered by the Phase 2
206
+ batch):
207
+
208
+ 1. **Act directly — intent locator, no discovery round-trip.**
209
+
210
+ ```
211
+ explore_step {
212
+ explorationId,
213
+ action: "check",
214
+ locator: {kind:"locator", steps:[{kind:"intent",
215
+ intent:"selection checkbox of the Pending row",
216
+ ordinal:"first"}]},
217
+ description: "Check the first Pending row",
218
+ section: "Select rows"
219
+ }
220
+ ```
221
+
222
+ The runner grounds intent → element → stable locator in ONE call.
223
+ The reply echoes `groundedTo` — the element line each intent hit
224
+ (`checkbox "Select row R-00147" (in row "…Pending…")`). Read it to
225
+ confirm the target instead of pre-probing with `ground_element`:
226
+ a probe grounds the same intent twice and buys nothing the reply
227
+ doesn't already say.
228
+ On `IntentResolveError`:
229
+ - `none` — re-phrase (name the target itself, not its page
230
+ region), or call `ground_element {intent}` to see the top-20
231
+ `candidates` of what IS perceived;
232
+ - resolves to a SET — add `ordinal` (`"first"` / `"last"` / N).
233
+
234
+ 2. **Act + record — pass the `[eN]` ref locator.**
235
+
236
+ ```
237
+ explore_step {
238
+ explorationId,
239
+ action: "click",
240
+ locator: {kind: "locator", steps: [{kind: "ref", ref: "e6"}]},
241
+ description: "Open sign-in dialog",
242
+ section: "Sign in"
243
+ }
244
+ ```
245
+
246
+ **CRITICAL — the locator is exactly one of three forms:**
247
+ - a single `{kind:"intent"}` step (preferred — no discovery call);
248
+ - `{kind:"locator", steps:[{kind:"ref", ref:"eN"}]}` where `eN` is
249
+ the **`ref`** field of a `find_element` reply;
250
+ - the ready-made **`locator`** object copied verbatim from a
251
+ `ground_element` reply (it already has that ref shape).
252
+
253
+ Never assemble a locator yourself from what those tools SHOW
254
+ (role, name, css) — that is not a fallback. Anything else
255
+ (synthesised `{kind:"getByRole",
256
+ ...}`, `{kind:"css", ...}`, multi-step chains) is **rejected by
257
+ `explore_step` in recording mode** with a `recording mode accepts
258
+ only ref locators` error. The recorder reads the ref off the live
259
+ DOM, picks the best stable identifier (testId → role+name → label →
260
+ text → href), verifies it uniquely matches the same element, and
261
+ writes the resolved form into the log. The agent never touches the
262
+ resolved locator — the saved test gets a clean `getByRole(...)` /
263
+ `getByTestId(...)` automatically.
264
+
265
+ **If the intent can't resolve** — that's a missing *preparatory*
266
+ step, not a reason to bypass the ref-resolver. Almost always one of:
267
+ - Off-screen → `scroll_into_view` action on the nearest visible
268
+ anchor, then retry the intent step.
269
+ - Async-rendered → `wait_for` / `wait_for_text`, then retry.
270
+ - Hover/click-revealed (menu, dropdown) → action on the trigger,
271
+ then retry.
272
+ - Inside iframe → `enter_frame`, then retry.
273
+
274
+ **`allowNoRef: true`** is an escape hatch for the truly-no-ref case
275
+ (Shadow DOM piercing, cross-context element with no shapshot entry).
276
+ It lets a hand-written locator through verbatim; the saved scenario
277
+ will be brittle. Use only after the preparatory steps above have
278
+ been exhausted. If you find yourself reaching for `allowNoRef`
279
+ repeatedly, stop and ask the user.
280
+
281
+ **Batching.** When the next several steps are known in advance
282
+ (fill + fill + click, a row of checks), send ONE
283
+ `explore_steps {explorationId?, steps:[…]}` call — up to 25 steps,
284
+ each with the same shape as `explore_step`. Sequential; stops at
285
+ the first failure; the reply carries per-step status +
286
+ `firstError`; only the steps that executed are recorded. A step that
287
+ CHANGED the address also carries `url` — that's where you are now,
288
+ no `get_url` / `get_active_context` round-trip needed.
289
+
290
+ **Record checks as assert steps** — `assert_text {locator, text,
291
+ options?: {exact}}`, `assert_visible {locator}`, `assert_hidden
292
+ {locator}`, `assert_value {locator, value}`, `assert_count
293
+ {locator, count}`, `assert_url {pattern}`. They execute live (poll
294
+ up to 5s), so a recorded assert has already passed against the real
295
+ page, and the generated test carries the matching `assertText(...)`
296
+ / `assertCount(...)` lines. Never hand-edit assertions into the
297
+ saved file.
298
+ Assert targets are looked up as WHOLE UNITS, not clickable
299
+ controls: the intent legally resolves to the heading / table row /
300
+ card itself — pass the `{kind:"intent"}` step and let the runner
301
+ pin it. Do NOT detour to `find_element` for assert targets. Name
302
+ the target by its VISIBLE TEXT, not by role alone: `heading
303
+ "Space Rangers Junior Cadets"`, not `main page heading` — a grid
304
+ page has a dozen look-alike headings and a role-only intent picks
305
+ the wrong one (the text in the intent is what disambiguates).
306
+ The final `explore_steps` batch of a scenario can add
307
+ `autoRun: true` — when every step succeeds, a verification step is
308
+ recorded and the draft has no blocking warnings (FRAGILE_LOCATOR
309
+ passes and is reported), it saves + resets context + runs the test
310
+ in the same call (reply: `autoRun.run.next.outcome`); otherwise it
311
+ reports `autoRun.status: "skipped"` + reason and the manual Phases
312
+ 4–5 apply.
313
+
314
+ When recording, `description` and `section` are **required**.
315
+ Adjacent same-section entries are wrapped in one `step("...", () => { … })`
316
+ block in the generated test, so the `section` must read
317
+ like a line in a human test PLAN — the user-facing GOAL of the block
318
+ ("Sign in", "Open first car card", "Apply status filter"), **never** a
319
+ generic mechanic ("Navigation", "Actions", "Click", "Load data"). Use the
320
+ SAME label for every step of one block; don't reuse a label for a
321
+ different block, and never split one block into two same-named blocks
322
+ (e.g. two "Navigation" groups around the login). The `description` is one
323
+ short line naming the target concretely ("Double-click the first car
324
+ row"), not a vague "click".
325
+
326
+ 3. **Re-discover** after every action that changes the page (navigate,
327
+ open dialog, async re-render). Refs from the previous snapshot are
328
+ stale — using them throws `StaleRefError`.
329
+
330
+ 4. **Wrong step?** Remove before continuing:
331
+ ```
332
+ explore_remove_step { explorationId, entryId: "<from explore_step result>" }
333
+ ```
334
+ To add an assertion you forgot (or any step that you want recorded
335
+ without executing again): `explore_record { explorationId, action,
336
+ …, description, section }` — same shape as `explore_step` but
337
+ no execution.
338
+
339
+ **Ad-hoc probes** — to try a click or fill without polluting the
340
+ recording, **omit `explorationId`**. Refs forwarded as-is to the
341
+ driver, no resolve/verify, nothing written.
342
+
343
+ ### Phase 4 — stop, generate, save
344
+
345
+ **Skip Phases 4–5 when the last `explore_steps` ran with `autoRun` and
346
+ replied `autoRun.status: "ran"`** — the scenario is already saved and
347
+ executed; `autoRun.run.next.outcome === "completed"` is the pass
348
+ signal. This manual flow is the fallback for `autoRun.status:
349
+ "skipped"` (its `reason` names the gate) and for step-by-step authoring.
350
+
351
+ ```
352
+ explore_stop { explorationId }
353
+ generate_dsl_from_exploration { explorationId } → { draftDsl, warnings }
354
+ ```
355
+
356
+ **Warnings are instructions, not commentary — from BOTH generate and
357
+ save.** Each carries its own fix recipe; a warning you read and skip
358
+ resurfaces as a red run or a rejected review. Act on every one before
359
+ moving to Phase 5:
360
+
361
+ - **`FRAGILE_LOCATOR`** — element resolved but its identifier is
362
+ fragile (no testId / aria-label; only deep text). Best fix: ask the
363
+ app team to add a `data-testid`, then re-record the step. Acceptable
364
+ fallback: keep the warning and document.
365
+ - **`DYNAMIC_TEXT`** — a matcher string carries a live value (counter /
366
+ date / amount): green today, red on the next page load. Replace with
367
+ the stable prefix or a regex matcher as the message suggests, then
368
+ regenerate.
369
+ - **`NO_DSL_PRIMITIVE`** — captured shape has no matching DSL function.
370
+ The line renders as `// SKIPPED …`. Fix the entry (`explore_remove_step`
371
+ + `explore_record` with a different action) or pass `force: true` on
372
+ save to keep the comment inline.
373
+ - **`NO_VERIFICATION`** (from save) — **the test is NOT ready.** It
374
+ performs actions but asserts nothing, so it stays green when the flow
375
+ silently breaks. Record an `assert_text` / `assert_url` (or
376
+ `wait_for_url` / `wait_for_text`) step — expected values taken from
377
+ the FACTUAL page (`get_url`), never guessed — and save again with
378
+ `overwrite: true`.
379
+
380
+ Then persist:
381
+
382
+ ```
383
+ save_exploration_as_test { explorationId, scenarioName: "<feature>/<name>" }
384
+ ```
385
+
386
+ Writes `unotest/e2e/<feature>/<name>.js`. `scenarioName` MUST include a
387
+ feature subfolder (e.g. `auth/login`) — a bare name is rejected. File
388
+ exists? Pass `overwrite: true`. Re-read the returned `warnings` — save
389
+ is the tool that reports `NO_VERIFICATION`.
390
+
391
+ ### Phase 5 — reset state, then run_test
392
+
393
+ The recorded scenario starts wherever you left the browser. The saved
394
+ test must start from a clean baseline. If your recording assumed
395
+ "already signed in", the test won't be repeatable.
396
+
397
+ Reset between recording and the first `run_test`:
398
+
399
+ ```
400
+ close_context { }
401
+ new_context { }
402
+ ```
403
+
404
+ If the scenario relies on seeded data (DB rows, API state), make sure
405
+ the seeder helpers run as the first scenario steps — `wipe_e2e_users();
406
+ seed_user(...)` belong in the SETUP section.
407
+
408
+ Then verify:
409
+
410
+ ```
411
+ run_test { name: "<scenario-name>" }
412
+ ```
413
+
414
+ `run_test` BLOCKS until the run settles — do NOT poll
415
+ `inspect_runtime` to wait for completion (it stays a diagnostic tool
416
+ for paused runs). The response carries `next.outcome`. **The scenario
417
+ passed only when `next.outcome === "completed"`.** Any other value
418
+ (`"failed"`, `"paused-failure"`, `"aborted"`) means the test failed —
419
+ read `next.error` and iterate. `"running"` means the settle-wait timed
420
+ out (`waitMs`, default 180s) — only then check `inspect_runtime`.
421
+ `status: "started"` is just a lifecycle marker, not a pass signal.
422
+
423
+ `paused-failure` keeps the browser context open and the runtime
424
+ addressable by `runtimeId`. See **Failure recovery** below.
425
+
426
+ Iterate until `next.outcome === "completed"`.
427
+
428
+ ## DSL cheat-sheet (web vocab)
429
+
430
+ Method chains on locators are encouraged:
431
+ `locator('.row').filter({hasText:'X'}).getByRole('button').click()`. Options-objects
432
+ are standard: `click(loc, {force: true, timeout: 5000})`. Raw template
433
+ strings are allowed for multi-line literals, but `${...}` interpolation
434
+ is a parse error — pass values as positional args.
435
+
436
+ What's **not allowed** (parser / runtime will reject): `import` /
437
+ `export` / `require`, top-level `await`, `class`, JSX, `while` /
438
+ `do…while` / `continue` / `++`. Bounded `for (i = 0; i < N; i = i + 1)`
439
+ loops ARE supported, and `break` is legal inside a loop body — poll with
440
+ `for` + `if (…) { break; }` + `pause(ms) // reason: …`. Index access
441
+ reads data (`docs[0].count`, bare variable only, never a Locator);
442
+ `obj.prop[i]` does not parse — assign the member first:
443
+ `m = state.matches; last = m[m.length - 1];`. `arr[i] = v` stays
444
+ rejected.
445
+
446
+ Navigation:
447
+ - `goto(url, opts?)`, `reload(opts?)`, `getUrl()`, `goBack()`, `goForward()`.
448
+
449
+ Actions (selector arg always first):
450
+ - `click(loc, opts?)`, `doubleClick(loc, opts?)`, `fill(loc, text, opts?)`,
451
+ `press(loc, key)`, `check(loc)`, `uncheck(loc)`, `selectOption(loc, value)`,
452
+ `hover(loc)`, `scrollIntoView(loc)`.
453
+
454
+ Selectors (locator hierarchy — D-22, hard rule):
455
+ 1. **`getByTestId(id)`** — most stable.
456
+ 2. **`getByRole(role, {name})`** — accessibility-tree based.
457
+ 3. **`getByLabel(text)`** — form controls bound to a `<label>`.
458
+ 4. **`getByText(text)`** — visible text; brittle to copy edits.
459
+ 5. **`locator(cssSelector)`** — last resort. Linter warns on `>` /
460
+ descendant combinators and hashed CSS-module / Tailwind-JIT classes.
461
+
462
+ **CSS selectors go through `locator("css-here")`, not `css("...")`.**
463
+ There is no `css(...)` function. Read the line right above this one:
464
+ when you reach for an attribute selector like `a[href*='catalogTree']`,
465
+ write `locator("a[href*='catalogTree']")` — **not** `css("a[href*='catalogTree']")`.
466
+ The validator rejects unknown function names as
467
+ `validator:unknown-function` errors at lint time. Same for any other
468
+ identifier you might guess — if it's not in this skill, it doesn't
469
+ exist; don't invent.
470
+
471
+ **Regex literals are allowed in matcher positions** — `getByText(/^Sets/)`,
472
+ `getByRole("link", {name: /^Sets\b/})`, `.filter({hasText: /items/i})`.
473
+ ES5 subset only (flags `g i m`; no lookbehind / named groups). Anywhere
474
+ else (`nth(/x/)` etc.) a regex is a type error.
475
+
476
+ When the live accessible name contains dynamic content (counts,
477
+ timestamps, user data), anchor the **stable prefix** — regex is the
478
+ preferred form because it can't collide the way substring matching does:
479
+
480
+ ```js
481
+ // Live text on the page: "Sets 21,414 items"
482
+ // BAD — captures the count, breaks on every page load:
483
+ click(getByText("Sets 21,414 items", {exact: true}));
484
+ // GOOD — regex anchored at the stable prefix:
485
+ click(getByRole("link", {name: /^Sets\b/}));
486
+ // RISKY — substring match: "Sets" also matches "Newest Sets" and
487
+ // "Most Wanted Sets" → strict-mode violation with 3 elements. Use only
488
+ // after check_locator proves it's unique:
489
+ click(getByRole("link", {name: "Sets"}));
490
+ ```
491
+
492
+ `getByRole` and `getByText` are **substring + word-boundary** by
493
+ default; omit `exact: true` whenever the captured name includes
494
+ content that may change.
495
+
496
+ **Any locator you write or edit BY HAND must pass `check_locator`
497
+ BEFORE run_test** — it proves `ofCount === 1` on the live page in one
498
+ call. Every strict-mode violation in past runs came from a hand-edited
499
+ locator that was never checked; a red run_test costs 10× more calls
500
+ than the check.
501
+
502
+ Prefer a semantic anchor over raw CSS: `locator("a[href*='…']")` is a
503
+ LAST resort (and substring-href can match several links — check it
504
+ too). A file whose locators are mostly `locator(css)` fails review.
505
+
506
+ Disambiguate with `.filter({hasText: '…'})` or `.filter({has: …})` —
507
+ **not** `.first()` / `.last()` / `.nth(N)` (linter flags
508
+ `lint:disambig-by-index`).
509
+
510
+ Waits:
511
+ - `waitFor(loc, opts?)`, `waitForText(text, opts?)`,
512
+ `waitForCount(loc, n, opts?)`, `waitForUrl(pattern)`, `pause(ms)` —
513
+ `pause` requires `// reason: <why>` comment immediately above (linter
514
+ flags `lint:pause-explicit` otherwise).
515
+ - `waitForText` waits for ≥1 VISIBLE occurrence (repeated text in a feed
516
+ is fine); substring by default — `{exact: true}` or a regex
517
+ (`waitForText(/\bhi\b/)`) when substring would false-match.
518
+ - `waitForCount` polls until the locator matches AT LEAST n elements
519
+ (`{exact: true}` → exactly n) — the "wait for reply №N" chat pattern;
520
+ pass `{timeout}` generously for slow producers (LLM replies).
521
+
522
+ Assertions:
523
+ - `assertText(loc, text, opts?)`, `assertVisible(loc)`, `assertHidden(loc)`,
524
+ `assertCount(loc, n)`, `assertUrl(pattern)`, `assertTrue(expr, msg?)`.
525
+
526
+ State reads:
527
+ - `getTitle()`, `getUrl()`, `getAttribute(loc, name)`, `getInnerText(loc)`,
528
+ `getInputValue(loc)`.
529
+
530
+ Sandbox primitives:
531
+ - `shell("cmd", "arg", …)` — `execFile` style, no shell interpretation.
532
+ - `dbQuery(sql, …params)`, `dbExec(sql, …params)` — parameterized.
533
+ - `apiCall(method, path, body?, headers?, opts?)` — path-only against
534
+ `sandbox.apiBaseUrl`; another host via `{base: 'API_BASE_X'}` — the
535
+ NAME of a `unotest/.env` variable, never a URL. Multipart upload:
536
+ pass `upload('fixtures/doc.pdf', {field?, fields?})` as the body
537
+ (relative path inside the project / `sandbox.uploadDir`; don't set
538
+ Content-Type yourself). A JSON body with a `file` key posts as plain
539
+ JSON — there is no key-name magic.
540
+
541
+ Escape hatch:
542
+ - `evaluate(\`js body\`, …args)` — raw backticks, no `${}`. Linter
543
+ warns `lint:evaluate-discouraged`. Use only when nothing above fits.
544
+
545
+ Time helpers: `nowMs()`, `today()`, `daysFromNow(n)`.
546
+
547
+ ## Helpers convention
548
+
549
+ Reusable flows live in `unotest/e2e/_helpers/<group>.js` as plain `.js`
550
+ exporting `snake_case` functions:
551
+
552
+ ```js
553
+ // unotest/e2e/_helpers/signin.js
554
+ function signin_as(email, password) {
555
+ // 2. ENTER — bring the app to the initial UI state
556
+ goto('/sign-in');
557
+
558
+ // 3. ACT — actions you are testing
559
+ fill(getByLabel('Email'), email);
560
+ fill(getByLabel('Password'), password);
561
+ click(getByRole('button', {name: 'Sign in'}));
562
+
563
+ // 4. ASSERT — UI + DB / API checks
564
+ waitForUrl('/dashboard');
565
+ }
566
+ ```
567
+
568
+ The runner auto-discovers helpers — no `import`. Call them like
569
+ built-in DSL functions.
570
+
571
+ **Naming:** `snake_case` distinguishes helpers from core DSL
572
+ (`camelCase`); `test_*` is reserved for entry functions; `flow_*` for
573
+ composite multi-step helpers.
574
+
575
+ **Saved test structure.** Every step in a `test_*` entry must sit inside a
576
+ `step("description", () => { … })` block (linter enforces). The title is the
577
+ plain-English intent of the group; when a step later breaks you read it
578
+ back to repair the group. The recorder groups by phase automatically; when
579
+ you hand-edit, keep each chunk inside its own block:
580
+
581
+ ```js
582
+ function test_dashboard_loads_with_widgets() {
583
+ step("Seed a fresh demo user", () => {
584
+ wipe_e2e_users();
585
+ seed_user('demo@example.com', 'secret');
586
+ });
587
+
588
+ step("Sign in as the demo user", () => {
589
+ signin_as('demo@example.com', 'secret');
590
+ });
591
+
592
+ step("Dashboard shows the Today widget", () => {
593
+ assertVisible(getByRole('region', {name: 'Today'}));
594
+ });
595
+ }
596
+ ```
597
+
598
+ (Helpers and `flow_*` composites are NOT wrapped — the rule applies only to
599
+ `test_*` scenario entries.)
600
+
601
+ ## Failure recovery
602
+
603
+ `paused-failure` from `run_test` keeps the browser context alive and
604
+ the runtime addressable by `runtimeId`. `run_test` also auto-attaches
605
+ you to that live page, so `ground_element` / `find_element` /
606
+ `check_locator` already target the page being debugged — no manual
607
+ `attach_debug_session`. Use the diagnostic tools:
608
+
609
+ 1. **`inspect_runtime { runtimeId }`** — `lastFailure` (error + line/col
610
+ + AST node) + `vars` (every assignment up to the pause). Tells you
611
+ what was observed vs expected and the state the scenario built.
612
+ 2. **`check_locator { locator }`** — paste the EXACT locator from the
613
+ failing line (e.g. `getByRole('row').filter({hasText:'Uma Quinn'}).first()`).
614
+ Reports, against the live paused page, how many elements it matches
615
+ and — when the chain ends in `.first()`/`.last()`/`.nth()` —
616
+ `pinned.ofCount`, the count that index op silently collapsed (a
617
+ `.first()` hiding 16 identical rows is the classic break), plus
618
+ per-match role/name/text/testId. Verify a selector at runtime
619
+ instead of guessing or re-running the whole test.
620
+ 3. **`ground_element { intent }`** — is the failing target perceived on
621
+ the CURRENT page at all? The `candidates` list also reveals whether
622
+ the page navigated somewhere unexpected (it shows what IS there).
623
+ 4. **`list_failures` + `get_failure_console` / `get_failure_trace` /
624
+ `get_failure_network`** — failure bundle. `get_failure_console` is
625
+ often the giveaway when the failure is really a runtime error in
626
+ the app.
627
+ 5. **`agent_fix { runId }`** — composes a structured fix-context
628
+ prompt. **It does NOT patch your code** (D-25); you apply edits.
629
+ 6. **`open_viewer { }`** — boots the localhost viewer for the project
630
+ if it isn't running, returns its URL. The viewer renders the block-
631
+ tree of the failing scenario with live status, plus a log-tail of
632
+ the child runner's stdio. Useful when you want a human (or yourself)
633
+ to look at the failure visually instead of paging through MCP-tool
634
+ output. Idempotent: a second call returns the same URL.
635
+
636
+ After editing the scenario, `abort_runtime { runtimeId }` to release
637
+ the old browser context, then `run_test` again.
638
+
639
+ ## `explore_step` cheat-sheet
640
+
641
+ | `action` | required | optional |
642
+ |--------------------|-----------------------------------------|----------------------|
643
+ | `goto` | `url` | `options` |
644
+ | `reload` | | `options` |
645
+ | `go_back` | | |
646
+ | `go_forward` | | |
647
+ | `click` | `locator` | `options` |
648
+ | `double_click` | `locator` | `options` |
649
+ | `fill` | `locator`, `value` | `options` |
650
+ | `press` | `locator`, `value` (the key) | `options` |
651
+ | `check` / `uncheck`| `locator` | `options` |
652
+ | `select_option` | `locator`, `value` | |
653
+ | `hover` | `locator` | `options` |
654
+ | `scroll_into_view` | `locator` | |
655
+ | `wait_for` | `locator` | `options` |
656
+ | `wait_for_text` | `value` (the text) | `options` |
657
+ | `wait_for_count` | `locator`, `count` | `options` |
658
+ | `wait_for_url` | `value` (the pattern) | `options` |
659
+ | `enter_frame` | `locator` | |
660
+ | `exit_frame` | | |
661
+
662
+ When recording (`explorationId` present): `description` + `section`
663
+ are required. Ad-hoc (no `explorationId`): no description/section,
664
+ no recording.
665
+
666
+ ## Failure modes you will hit
667
+
668
+ - **`StaleRefError`** — a ref from an earlier lookup is no longer in
669
+ the DOM. Intent locators don't go stale (grounded per call) — retry
670
+ with the intent step, or re-ground via `ground_element`.
671
+ - **`RefResolveError`** — element has no stable identifier (testId /
672
+ role+name / aria-label / placeholder / alt / title / text / href /
673
+ stable id / name attribute). Best fix: ask the app team to add
674
+ `data-testid`. Fallbacks: provide a hand-written locator with
675
+ `allowNoRef: true` — e.g. `{locator:{kind:"locator",steps:[{kind:
676
+ "getByPlaceholder",text:"Foo"}]}, allowNoRef:true}` — **do NOT** pass
677
+ a `{kind:"ref"}` step with `allowNoRef:true`, the ref still tries to
678
+ resolve. Or use `find_element({role, name, near})` and use its ref.
679
+ - **`AmbiguousResolveError`** — the resolved locator matches more than
680
+ one element (e.g. two `Sign in` buttons in different regions). Same
681
+ fix: add a testId or pass a more specific locator.
682
+
683
+ ## When a locator matches `N elements`
684
+
685
+ Every locator must resolve to exactly one element. When you see
686
+ `getByRole('row', {name: 'X'}) resolved to 301 elements`:
687
+
688
+ - **`check_locator { locator }`** (when a browser is live) — paste the
689
+ chain and it tells you the exact `count` and, for a `.first()`/`.nth()`
690
+ chain, `pinned.ofCount` (how many it collapsed). The fastest way to
691
+ confirm a locator is over-matching and by how much, without re-running.
692
+ - Narrow the `name` to a uniquely-identifying substring. `name`
693
+ substring-matches: pick wording that hits only one row (a header's
694
+ full label `"Pending (300)"` or a row-specific ID like `"573"`).
695
+ - Use `find_element({role: 'row', name: '573', near: '<table-ref>'})`
696
+ to skip manual disambiguation — scoped search returns the single ref
697
+ directly.
698
+ - Only use `.first()` / `.nth(N)` when the position itself is the
699
+ semantic anchor — and `check_locator` shows `pinned.ofCount === 1`.
700
+
701
+ ## Anti-patterns
702
+
703
+ - **Don't explore the project before Phase 1.** No `find`, no `tree`,
704
+ no broad `ls`. Phase 0 lists three reads — do those, then start
705
+ recording. You learn the page by ACTING on it (intent locators) and
706
+ by `ground_element`, not from static project structure.
707
+ - **Don't invent locators.** Pass a `{kind:"intent"}` step, or the
708
+ `locator` from a `ground_element` / `find_element` reply verbatim.
709
+ The recorder produces the stable form.
710
+ - **Don't put positions into intent text.** "first"/"last"/№ go into
711
+ `ordinal` as data — the grounder never parses positions out of
712
+ words.
713
+ - **Don't write `css(...)`.** That function does not exist. CSS
714
+ selectors go through `locator("css-here")`. Every time you reach for
715
+ `css("a[href*='...']")`, rewrite as `locator("a[href*='...']")`.
716
+ The validator rejects `css(...)` as `validator:unknown-function`
717
+ at lint time — your scenario will not pass `verify`.
718
+ - **Don't pass `getByRole('link', {name: 'Sets 21,414 items'})`** —
719
+ the count is dynamic; anchor the stable prefix with a regex matcher
720
+ (`{name: /^Sets\b/}`). Hand-written `name` strings are guesses; the
721
+ ref resolver reads the real accessible name from the live DOM.
722
+ - **Don't run run_test with a hand-edited locator you never
723
+ `check_locator`-ed.** Uniqueness on the live page is one call to
724
+ prove and every past strict-mode violation skipped it.
725
+ - **Don't use `.first()` / `.last()` / `.nth(N)` to disambiguate
726
+ multi-matches.** Element order is brittle. Use `.filter({hasText:
727
+ '…'})` or `.filter({has: someLocator})`. Linter flags index-based
728
+ picking as `lint:disambig-by-index`.
729
+ - **Don't reach for `locator(...)` with `>` combinators, hashed class
730
+ names, or `xpath=…`.** Stop and ask the user whether the app should
731
+ expose a `data-testid` or accessible name.
732
+ - **Don't omit `section` / `description` when recording.** They shape
733
+ the generated test into readable `step(...)` blocks — and they're
734
+ required.
735
+ - **Don't consider the task done without `next.outcome === "completed"`.**
736
+ "I recorded the flow, generated the file, looks right" is not done.
737
+ A green `run_test` is done.
738
+ - **Don't `import` / `export` / `await` / `class` / arrow-functions
739
+ in the saved scenario.** DSL, not Node.
740
+
741
+ ## Fast feedback
742
+
743
+ After editing any scenario (recorded or hand-written):
744
+
745
+ ```sh
746
+ npx @unotest/web lint
747
+ ```
748
+
749
+ The linter runs two passes:
750
+
751
+ 1. **Validator** (`validator:unknown-function`, `validator:dsl`) —
752
+ `error` severity (fails `npx @unotest/web lint`). Catches unknown function
753
+ names (`css`, typos), wrong arg kinds, malformed `for`/`if`
754
+ shapes. **If you see `validator:unknown-function`, you invented
755
+ a function name** — re-read the DSL cheat-sheet above and use the
756
+ real one.
757
+ 2. **Brittle-pattern linter** (`lint:deep-css`, `lint:xpath`,
758
+ `lint:obfuscated-class`, `lint:pause-explicit`,
759
+ `lint:evaluate-discouraged`) — `warn` severity. Doesn't gate
760
+ `verify` but indicates fragile locators that will break under
761
+ refactor.
762
+
763
+ If lint flags `import` / `export` / `await` / `const` — re-read the
764
+ DSL section above and rewrite as bare statements + function
765
+ definitions.
766
+
767
+ ## What this surface is NOT for
768
+
769
+ - **Production data mutation.** `apiCall` / `dbExec` against a
770
+ production base URL is a footgun — the `sandbox.*` pin model in
771
+ `unotest.config.{js,mjs,ts}` prevents it. Don't bypass.
772
+ - **Browser automation for scraping.** This is an E2E testing tool;
773
+ scraping violates many target sites' terms of service.
774
+ - **Calling external LLM APIs.** `agent_fix` builds prompts for the
775
+ agent **you** are. It does not delegate. No LLM client in this
776
+ package (D-25).
777
+
778
+ ## Commands you'll run
779
+
780
+ ```sh
781
+ npx @unotest/web init # one-time bootstrap (unotest/, config)
782
+ npx @unotest/web e2e <name> # run a scenario by basename
783
+ npx @unotest/web lint # validator + linter; errors gate verify
784
+ ```
785
+
786
+ Always use `npx @unotest/web …` — not `pnpm` / `yarn` (those don't
787
+ exist for end users).