@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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: write-e2e-test
3
- description: Write a new E2E test for the web application under test using @unotest/web. Records actions through explore_step (execute + record in one call) 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 for a feature, flow, or page.
3
+ description: Write a new E2E test for the web application under test using @unotest/web. Records actions through explore_step (execute + record in one call) 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 for a feature, flow, or page. If the unotest-web server runs WITHOUT page snapshots (no get_page_snapshot tool, UNOTEST_SNAPSHOT=0), use write-e2e-test-ground instead.
4
4
  ---
5
5
 
6
6
  # Skill: write-e2e-test
@@ -23,12 +23,13 @@ Two modes, picked at Phase 1:
23
23
  `run_test`, …). You drive the browser live, record the flow, generate
24
24
  the DSL, save it, run it, iterate until green.
25
25
 
26
- - **Draft-only (fallback)** — if no MCP / no working `new_context`,
26
+ - **Draft-only (fallback)** — if no MCP / no working `explore_start`,
27
27
  write the file from the description the user gave you, mark it as
28
28
  unverified in your final reply ("⚠ unverified — no live browser —
29
29
  run `npx @unotest/web e2e <name>` after fixing the env"), and STOP.
30
30
  Don't pretend a draft is a working test.
31
31
 
32
+
32
33
  ## Workflow — record then generate then verify
33
34
 
34
35
  Five phases, in this order. Don't skip phases.
@@ -54,12 +55,14 @@ Then move to Phase 1. Three files in, exploration done.
54
55
  ### Phase 1 — start the exploration
55
56
 
56
57
  ```
57
- new_context { } → { ok, url: "about:blank" }
58
58
  explore_start { scenario_name: "<feature>/<name>" } → { explorationId }
59
59
  ```
60
60
 
61
61
  Keep the `explorationId` — every recorded step needs it.
62
62
 
63
+ `explore_start` opens the browser itself. Do NOT call `new_context`
64
+ first — it's for RESETTING the browser (Phase 5), not for starting.
65
+
63
66
  ### Phase 2 — first recorded step: navigate
64
67
 
65
68
  ```
@@ -124,7 +127,8 @@ For each page state you traverse:
124
127
  ```
125
128
 
126
129
  **CRITICAL — pass `{kind:"ref", ref:"eN"}` from the most recent
127
- `get_page_snapshot`.** Anything else (synthesised `{kind:"getByRole",
130
+ `get_page_snapshot`.** Anything else
131
+ (synthesised `{kind:"getByRole",
128
132
  ...}`, `{kind:"css", ...}`, multi-step chains) is **rejected by
129
133
  `explore_step` in recording mode** with a `recording mode accepts
130
134
  only ref locators` error. The recorder reads the ref off the live
@@ -151,6 +155,31 @@ For each page state you traverse:
151
155
  been exhausted. If you find yourself reaching for `allowNoRef`
152
156
  repeatedly, stop and ask the user.
153
157
 
158
+ **Batching.** When the next several steps are known in advance
159
+ (fill + fill + click, a row of checks), send ONE
160
+ `explore_steps {explorationId?, steps:[…]}` call — up to 25 steps,
161
+ each with the same shape as `explore_step`. Sequential; stops at
162
+ the first failure; the reply carries per-step status +
163
+ `firstError`; only the steps that executed are recorded. A step that
164
+ CHANGED the address also carries `url` — that's where you are now,
165
+ no `get_url` / `get_active_context` round-trip needed.
166
+
167
+ **Record checks as assert steps** — `assert_text {locator, text,
168
+ options?: {exact}}`, `assert_visible {locator}`, `assert_hidden
169
+ {locator}`, `assert_value {locator, value}`, `assert_count
170
+ {locator, count}`, `assert_url {pattern}`. They execute live (poll
171
+ up to 5s), so a recorded assert has already passed against the real
172
+ page, and the generated test carries the matching `assertText(...)`
173
+ / `assertCount(...)` lines. Never hand-edit assertions into the
174
+ saved file.
175
+ The final `explore_steps` batch of a scenario can add
176
+ `autoRun: true` — when every step succeeds, a verification step is
177
+ recorded and the draft has no blocking warnings (FRAGILE_LOCATOR
178
+ passes and is reported), it saves + resets context + runs the test
179
+ in the same call (reply: `autoRun.run.next.outcome`); otherwise it
180
+ reports `autoRun.status: "skipped"` + reason and the manual Phases
181
+ 4–5 apply.
182
+
154
183
  When recording, `description` and `section` are **required**.
155
184
  Adjacent same-section entries are wrapped in one `step("...", () => { … })`
156
185
  block in the generated test, so the `section` must read
@@ -182,21 +211,40 @@ driver, no resolve/verify, nothing written.
182
211
 
183
212
  ### Phase 4 — stop, generate, save
184
213
 
214
+ **Skip Phases 4–5 when the last `explore_steps` ran with `autoRun` and
215
+ replied `autoRun.status: "ran"`** — the scenario is already saved and
216
+ executed; `autoRun.run.next.outcome === "completed"` is the pass
217
+ signal. This manual flow is the fallback for `autoRun.status:
218
+ "skipped"` (its `reason` names the gate) and for step-by-step authoring.
219
+
185
220
  ```
186
221
  explore_stop { explorationId }
187
222
  generate_dsl_from_exploration { explorationId } → { draftDsl, warnings }
188
223
  ```
189
224
 
190
- Read the warnings before saving:
225
+ **Warnings are instructions, not commentary — from BOTH generate and
226
+ save.** Each carries its own fix recipe; a warning you read and skip
227
+ resurfaces as a red run or a rejected review. Act on every one before
228
+ moving to Phase 5:
191
229
 
192
230
  - **`FRAGILE_LOCATOR`** — element resolved but its identifier is
193
231
  fragile (no testId / aria-label; only deep text). Best fix: ask the
194
232
  app team to add a `data-testid`, then re-record the step. Acceptable
195
233
  fallback: keep the warning and document.
234
+ - **`DYNAMIC_TEXT`** — a matcher string carries a live value (counter /
235
+ date / amount): green today, red on the next page load. Replace with
236
+ the stable prefix or a regex matcher as the message suggests, then
237
+ regenerate.
196
238
  - **`NO_DSL_PRIMITIVE`** — captured shape has no matching DSL function.
197
239
  The line renders as `// SKIPPED …`. Fix the entry (`explore_remove_step`
198
240
  + `explore_record` with a different action) or pass `force: true` on
199
241
  save to keep the comment inline.
242
+ - **`NO_VERIFICATION`** (from save) — **the test is NOT ready.** It
243
+ performs actions but asserts nothing, so it stays green when the flow
244
+ silently breaks. Record an `assert_text` / `assert_url` (or
245
+ `wait_for_url` / `wait_for_text`) step — expected values taken from
246
+ the FACTUAL page (`get_url`), never guessed — and save again with
247
+ `overwrite: true`.
200
248
 
201
249
  Then persist:
202
250
 
@@ -206,7 +254,8 @@ save_exploration_as_test { explorationId, scenarioName: "<feature>/<name>" }
206
254
 
207
255
  Writes `unotest/e2e/<feature>/<name>.js`. `scenarioName` MUST include a
208
256
  feature subfolder (e.g. `auth/login`) — a bare name is rejected. File
209
- exists? Pass `overwrite: true`.
257
+ exists? Pass `overwrite: true`. Re-read the returned `warnings` — save
258
+ is the tool that reports `NO_VERIFICATION`.
210
259
 
211
260
  ### Phase 5 — reset state, then run_test
212
261
 
@@ -231,11 +280,14 @@ Then verify:
231
280
  run_test { name: "<scenario-name>" }
232
281
  ```
233
282
 
234
- The response carries `next.outcome`. **The scenario passed only when
235
- `next.outcome === "completed"`.** Any other value (`"failed"`,
236
- `"paused-failure"`, `"aborted"`) means the test failed read
237
- `next.error` and iterate. `status: "started"` is just a lifecycle
238
- marker, not a pass signal.
283
+ `run_test` BLOCKS until the run settles do NOT poll
284
+ `inspect_runtime` to wait for completion (it stays a diagnostic tool
285
+ for paused runs). The response carries `next.outcome`. **The scenario
286
+ passed only when `next.outcome === "completed"`.** Any other value
287
+ (`"failed"`, `"paused-failure"`, `"aborted"`) means the test failed —
288
+ read `next.error` and iterate. `"running"` means the settle-wait timed
289
+ out (`waitMs`, default 180s) — only then check `inspect_runtime`.
290
+ `status: "started"` is just a lifecycle marker, not a pass signal.
239
291
 
240
292
  `paused-failure` keeps the browser context open and the runtime
241
293
  addressable by `runtimeId`. See **Failure recovery** below.
@@ -251,8 +303,14 @@ strings are allowed for multi-line literals, but `${...}` interpolation
251
303
  is a parse error — pass values as positional args.
252
304
 
253
305
  What's **not allowed** (parser / runtime will reject): `import` /
254
- `export` / `require`, top-level `await`, `class`, JSX, `for`/`while`
255
- loops (use the `maxSteps` budget guards).
306
+ `export` / `require`, top-level `await`, `class`, JSX, `while` /
307
+ `do…while` / `continue` / `++`. Bounded `for (i = 0; i < N; i = i + 1)`
308
+ loops ARE supported, and `break` is legal inside a loop body — poll with
309
+ `for` + `if (…) { break; }` + `pause(ms) // reason: …`. Index access
310
+ reads data (`docs[0].count`, bare variable only, never a Locator);
311
+ `obj.prop[i]` does not parse — assign the member first:
312
+ `m = state.matches; last = m[m.length - 1];`. `arr[i] = v` stays
313
+ rejected.
256
314
 
257
315
  Navigation:
258
316
  - `goto(url, opts?)`, `reload(opts?)`, `getUrl()`, `goBack()`, `goForward()`.
@@ -279,40 +337,56 @@ The validator rejects unknown function names as
279
337
  identifier you might guess — if it's not in this skill, it doesn't
280
338
  exist; don't invent.
281
339
 
282
- **No regex literals.** `/pattern/` is a **parse error** (`Invalid
283
- token Token(type: SLASH)`) the DSL lexer has only one meaning for
284
- `/`: division. Playwright-style `getByRole("link", {name: /^Sets/})`
285
- will NOT parse.
340
+ **Regex literals are allowed in matcher positions** — `getByText(/^Sets/)`,
341
+ `getByRole("link", {name: /^Sets\b/})`, `.filter({hasText: /items/i})`.
342
+ ES5 subset only (flags `g i m`; no lookbehind / named groups). Anywhere
343
+ else (`nth(/x/)` etc.) a regex is a type error.
286
344
 
287
345
  When the live accessible name contains dynamic content (counts,
288
- timestamps, user data), use **substring matching by dropping
289
- `exact: true`**:
346
+ timestamps, user data), anchor the **stable prefix** regex is the
347
+ preferred form because it can't collide the way substring matching does:
290
348
 
291
349
  ```js
292
350
  // Live text on the page: "Sets 21,414 items"
293
- // BAD — captures the count, breaks on every update:
351
+ // BAD — captures the count, breaks on every page load:
294
352
  click(getByText("Sets 21,414 items", {exact: true}));
295
- // BAD — regex doesn't parse:
296
- click(getByRole("link", {name: /^Sets/}));
297
- // GOOD — substring match against the stable prefix:
298
- click(getByRole("link", {name: "Sets"})); // matches "Sets …"
299
- click(getByText("Sets", {exact: false})); // also fine; exact: false is the default
300
- // ALSO GOOD — when href is stable:
301
- click(locator("a[href*='catalogTree.asp?itemType=S']"));
353
+ // GOOD — regex anchored at the stable prefix:
354
+ click(getByRole("link", {name: /^Sets\b/}));
355
+ // RISKY — substring match: "Sets" also matches "Newest Sets" and
356
+ // "Most Wanted Sets" strict-mode violation with 3 elements. Use only
357
+ // after check_locator proves it's unique:
358
+ click(getByRole("link", {name: "Sets"}));
302
359
  ```
303
360
 
304
361
  `getByRole` and `getByText` are **substring + word-boundary** by
305
362
  default; omit `exact: true` whenever the captured name includes
306
363
  content that may change.
307
364
 
365
+ **Any locator you write or edit BY HAND must pass `check_locator`
366
+ BEFORE run_test** — it proves `ofCount === 1` on the live page in one
367
+ call. Every strict-mode violation in past runs came from a hand-edited
368
+ locator that was never checked; a red run_test costs 10× more calls
369
+ than the check.
370
+
371
+ Prefer a semantic anchor over raw CSS: `locator("a[href*='…']")` is a
372
+ LAST resort (and substring-href can match several links — check it
373
+ too). A file whose locators are mostly `locator(css)` fails review.
374
+
308
375
  Disambiguate with `.filter({hasText: '…'})` or `.filter({has: …})` —
309
376
  **not** `.first()` / `.last()` / `.nth(N)` (linter flags
310
377
  `lint:disambig-by-index`).
311
378
 
312
379
  Waits:
313
- - `waitFor(loc, opts?)`, `waitForText(text)`, `waitForUrl(pattern)`,
314
- `pause(ms)` `pause` requires `// reason: <why>` comment immediately
315
- above (linter flags `lint:pause-explicit` otherwise).
380
+ - `waitFor(loc, opts?)`, `waitForText(text, opts?)`,
381
+ `waitForCount(loc, n, opts?)`, `waitForUrl(pattern)`, `pause(ms)`
382
+ `pause` requires `// reason: <why>` comment immediately above (linter
383
+ flags `lint:pause-explicit` otherwise).
384
+ - `waitForText` waits for ≥1 VISIBLE occurrence (repeated text in a feed
385
+ is fine); substring by default — `{exact: true}` or a regex
386
+ (`waitForText(/\bhi\b/)`) when substring would false-match.
387
+ - `waitForCount` polls until the locator matches AT LEAST n elements
388
+ (`{exact: true}` → exactly n) — the "wait for reply №N" chat pattern;
389
+ pass `{timeout}` generously for slow producers (LLM replies).
316
390
 
317
391
  Assertions:
318
392
  - `assertText(loc, text, opts?)`, `assertVisible(loc)`, `assertHidden(loc)`,
@@ -325,8 +399,13 @@ State reads:
325
399
  Sandbox primitives:
326
400
  - `shell("cmd", "arg", …)` — `execFile` style, no shell interpretation.
327
401
  - `dbQuery(sql, …params)`, `dbExec(sql, …params)` — parameterized.
328
- - `apiCall(method, path, body?, headers?)` — path-only against
329
- `sandbox.apiBaseUrl`.
402
+ - `apiCall(method, path, body?, headers?, opts?)` — path-only against
403
+ `sandbox.apiBaseUrl`; another host via `{base: 'API_BASE_X'}` — the
404
+ NAME of a `unotest/.env` variable, never a URL. Multipart upload:
405
+ pass `upload('fixtures/doc.pdf', {field?, fields?})` as the body
406
+ (relative path inside the project / `sandbox.uploadDir`; don't set
407
+ Content-Type yourself). A JSON body with a `file` key posts as plain
408
+ JSON — there is no key-name magic.
330
409
 
331
410
  Escape hatch:
332
411
  - `evaluate(\`js body\`, …args)` — raw backticks, no `${}`. Linter
@@ -444,6 +523,7 @@ the old browser context, then `run_test` again.
444
523
  | `scroll_into_view` | `locator` | |
445
524
  | `wait_for` | `locator` | `options` |
446
525
  | `wait_for_text` | `value` (the text) | `options` |
526
+ | `wait_for_count` | `locator`, `count` | `options` |
447
527
  | `wait_for_url` | `value` (the pattern) | `options` |
448
528
  | `enter_frame` | `locator` | |
449
529
  | `exit_frame` | | |
@@ -501,13 +581,13 @@ Every locator must resolve to exactly one element. When you see
501
581
  `css("a[href*='...']")`, rewrite as `locator("a[href*='...']")`.
502
582
  The validator rejects `css(...)` as `validator:unknown-function`
503
583
  at lint time — your scenario will not pass `verify`.
504
- - **Don't write regex literals.** `/^Sets/` is a parse error.
505
- Substring-match instead: `getByRole("link", {name: "Sets"})` is
506
- already substring (no `exact: true`).
507
584
  - **Don't pass `getByRole('link', {name: 'Sets 21,414 items'})`** —
508
- the count is dynamic and the accessible name is usually `'Sets'`
509
- alone. Hand-written `name` strings are guesses; the ref resolver
510
- reads the real accessible name from the live DOM.
585
+ the count is dynamic; anchor the stable prefix with a regex matcher
586
+ (`{name: /^Sets\b/}`). Hand-written `name` strings are guesses; the
587
+ ref resolver reads the real accessible name from the live DOM.
588
+ - **Don't run run_test with a hand-edited locator you never
589
+ `check_locator`-ed.** Uniqueness on the live page is one call to
590
+ prove and every past strict-mode violation skipped it.
511
591
  - **Don't use `.first()` / `.last()` / `.nth(N)` to disambiguate
512
592
  multi-matches.** Element order is brittle. Use `.filter({hasText:
513
593
  '…'})` or `.filter({has: someLocator})`. Linter flags index-based