@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.
- package/.claude/skills/write-e2e-test/SKILL.md +118 -38
- package/.claude/skills/write-e2e-test-ground/SKILL.md +787 -0
- package/CHANGELOG.md +297 -0
- package/dist/config/schema.d.ts +29 -10
- package/dist/config/schema.js +1 -1
- package/dist/driver/index.d.ts +5 -2
- package/dist/driver/index.js +1 -1
- package/dist/dsl/index.d.ts +19 -3
- package/dist/dsl/index.js +1 -1
- package/dist/dsl/web-dsl-language-service.js +1 -1
- package/dist/inspection/page-inject.d.ts +11 -35
- package/dist/inspection/page-inject.js +173 -1032
- package/dist/{interfaces-dbZG10RS.d.ts → interfaces-MxHi1Air.d.ts} +50 -3
- package/dist/mcp/server.js +1 -1
- package/dist/runner/cli.d.ts +11 -1
- package/dist/runner/cli.js +1 -1
- package/dist/runner/init/chrome-probe.js +1 -1
- package/dist/runner/init.js +1 -1
- package/dist/runner/install-chromium.js +1 -1
- package/dist/runner/prepare-fix.js +1 -1
- package/dist/runner/scaffold-workspace.js +1 -1
- package/dist/runner/serve-fixture.js +1 -1
- package/dist/runner/web-runner-adapter.js +1 -1
- package/guides/agent-integration.md +103 -2
- package/guides/dsl-reference.md +150 -13
- package/package.json +10 -38
- package/src/mcp/prompts/agent-test-author.md +71 -3
|
@@ -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 `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
`
|
|
236
|
-
|
|
237
|
-
`next.
|
|
238
|
-
|
|
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, `
|
|
255
|
-
|
|
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
|
-
**
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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),
|
|
289
|
-
|
|
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
|
|
351
|
+
// BAD — captures the count, breaks on every page load:
|
|
294
352
|
click(getByText("Sets 21,414 items", {exact: true}));
|
|
295
|
-
//
|
|
296
|
-
click(getByRole("link", {name: /^Sets/}));
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
314
|
-
`
|
|
315
|
-
|
|
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
|
|
509
|
-
|
|
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
|