@unotest/web 0.5.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.md +551 -0
- package/CHANGELOG.md +158 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/bin/unotest-web.js +197 -0
- package/dist/config/schema.d.ts +356 -0
- package/dist/config/schema.js +1 -0
- package/dist/driver/index.d.ts +74 -0
- package/dist/driver/index.js +1 -0
- package/dist/dsl/index.d.ts +338 -0
- package/dist/dsl/index.js +1 -0
- package/dist/inspection/page-inject.d.ts +395 -0
- package/dist/inspection/page-inject.js +3533 -0
- package/dist/interfaces-iTfjd1zT.d.ts +407 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +1 -0
- package/dist/runner/cli.d.ts +22 -0
- package/dist/runner/cli.js +1 -0
- package/dist/runner/init.d.ts +6 -0
- package/dist/runner/init.js +1 -0
- package/dist/runner/install-chromium.d.ts +9 -0
- package/dist/runner/install-chromium.js +1 -0
- package/dist/runner/prepare-fix.d.ts +8 -0
- package/dist/runner/prepare-fix.js +1 -0
- package/dist/runner/serve-fixture.d.ts +2 -0
- package/dist/runner/serve-fixture.js +1 -0
- package/dist/runner/web-runner-adapter.d.ts +8 -0
- package/dist/runner/web-runner-adapter.js +1 -0
- package/examples/fixtures/break-fix-app/README.md +45 -0
- package/examples/fixtures/break-fix-app/index.html +101 -0
- package/examples/fixtures/break-fix-app/serve.mjs +95 -0
- package/package.json +115 -0
- package/src/mcp/prompts/agent-test-author.md +208 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
---
|
|
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.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Skill: write-e2e-test
|
|
7
|
+
|
|
8
|
+
You are writing an end-to-end test using `@unotest/web` against a web
|
|
9
|
+
app. The test lives in `unotest/e2e/<name>.js` in the project under
|
|
10
|
+
test (NOT in the `@unotest/web` package itself).
|
|
11
|
+
|
|
12
|
+
**Default mode is verify-against-live-app.** A test that hasn't been
|
|
13
|
+
run is unverified — selectors may be invented, transitions may race,
|
|
14
|
+
the scenario may not actually pass. Producing a test you can't show
|
|
15
|
+
green is the failure case.
|
|
16
|
+
|
|
17
|
+
Two modes, picked at Phase 1:
|
|
18
|
+
|
|
19
|
+
- **Interactive (preferred)** — phases 1–5 below. The MCP server is up
|
|
20
|
+
(`new_context`, `get_page_snapshot`, `explore_start`, `explore_step`,
|
|
21
|
+
`run_test`, …). You drive the browser live, record the flow, generate
|
|
22
|
+
the DSL, save it, run it, iterate until green.
|
|
23
|
+
|
|
24
|
+
- **Draft-only (fallback)** — if no MCP / no working `new_context`,
|
|
25
|
+
write the file from the description the user gave you, mark it as
|
|
26
|
+
unverified in your final reply ("⚠ unverified — no live browser —
|
|
27
|
+
run `npx @unotest/web e2e <name>` after fixing the env"), and STOP.
|
|
28
|
+
Don't pretend a draft is a working test.
|
|
29
|
+
|
|
30
|
+
## Workflow — record then generate then verify
|
|
31
|
+
|
|
32
|
+
Five phases, in this order. Don't skip phases.
|
|
33
|
+
|
|
34
|
+
### Phase 0 — read exactly these files, nothing else
|
|
35
|
+
|
|
36
|
+
Three reads. Don't `find`/`tree`/`ls` the project. Don't open files
|
|
37
|
+
outside this list — they won't tell you anything Phase 1+ doesn't.
|
|
38
|
+
|
|
39
|
+
1. **`unotest/e2e/_template.js`** — Read it. Shows the DSL syntax.
|
|
40
|
+
When your default JS instincts disagree with the template, the
|
|
41
|
+
template wins.
|
|
42
|
+
2. **`unotest/e2e/_helpers/`** — `Glob` to list `*.js`. If a filename
|
|
43
|
+
matches the flow you're about to record ("signin", "seed",
|
|
44
|
+
"checkout"), `Read` THAT file only. If `signin_as(email, password)`
|
|
45
|
+
exists, the test calls it instead of re-recording the same steps.
|
|
46
|
+
No matches → skip; move on.
|
|
47
|
+
3. **`unotest/.env`** — Read it. Pulls `APP_BASE_URL`,
|
|
48
|
+
`DATABASE_URL`, etc. that the scenario will reach for.
|
|
49
|
+
|
|
50
|
+
Then move to Phase 1. Three files in, exploration done.
|
|
51
|
+
|
|
52
|
+
### Phase 1 — start the exploration
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
new_context { } → { ok, url: "about:blank" }
|
|
56
|
+
explore_start { scenario_name: "<flow-name>" } → { explorationId }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Keep the `explorationId` — every recorded step needs it.
|
|
60
|
+
|
|
61
|
+
### Phase 2 — first recorded step: navigate
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
explore_step {
|
|
65
|
+
explorationId,
|
|
66
|
+
action: "goto",
|
|
67
|
+
url: "<starting URL>",
|
|
68
|
+
description: "Open <page>",
|
|
69
|
+
section: "Setup"
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`goto` is almost always the first recorded step. Wait for the page to
|
|
74
|
+
settle before discovering (the explore tooling waits for `load` by
|
|
75
|
+
default — usually enough).
|
|
76
|
+
|
|
77
|
+
### Phase 3 — discover, then act (loop)
|
|
78
|
+
|
|
79
|
+
For each page state you traverse:
|
|
80
|
+
|
|
81
|
+
1. **Discover** — `get_page_snapshot { }`. Default outline format:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
on_screen:
|
|
85
|
+
navigation:
|
|
86
|
+
- link "Sets 21,414 items" [e3]
|
|
87
|
+
- link "Themes" [e4]
|
|
88
|
+
main:
|
|
89
|
+
- heading "Browse the catalog" [e5]
|
|
90
|
+
- button "Sign in" [e6]
|
|
91
|
+
off_screen:
|
|
92
|
+
bottom:
|
|
93
|
+
- link "Help" [e21]
|
|
94
|
+
_meta:
|
|
95
|
+
viewport: 1280x720
|
|
96
|
+
totalNodes: 142
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Line grammar: `- [role] ["name"] [#testId] [eN]`. The `[eN]` ref
|
|
100
|
+
is what you'll pass back — refs are **wire-only**, never typed into
|
|
101
|
+
the saved test. Sections (navigation / main / dialog / form / cmp /
|
|
102
|
+
overlay) group related elements.
|
|
103
|
+
|
|
104
|
+
`off_screen` lists items past the viewport — they exist in the DOM
|
|
105
|
+
but aren't visible. Scroll first (`explore_step { action:
|
|
106
|
+
"scroll_into_view", locator: {kind:"locator", steps:[{kind:"ref", ref:"e21"}]} }`),
|
|
107
|
+
then re-snapshot.
|
|
108
|
+
|
|
109
|
+
2. **Act + record — pass the `[eN]` ref locator.**
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
explore_step {
|
|
113
|
+
explorationId,
|
|
114
|
+
action: "click",
|
|
115
|
+
locator: {kind: "locator", steps: [{kind: "ref", ref: "e6"}]},
|
|
116
|
+
description: "Open sign-in dialog",
|
|
117
|
+
section: "Sign in"
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**CRITICAL — pass `{kind:"ref", ref:"eN"}` from the most recent
|
|
122
|
+
`get_page_snapshot`.** Anything else (synthesised `{kind:"getByRole",
|
|
123
|
+
...}`, `{kind:"css", ...}`, multi-step chains) is **rejected by
|
|
124
|
+
`explore_step` in recording mode** with a `recording mode accepts
|
|
125
|
+
only ref locators` error. The recorder reads the ref off the live
|
|
126
|
+
DOM, picks the best stable identifier (testId → role+name → label →
|
|
127
|
+
text → href), verifies it uniquely matches the same element, and
|
|
128
|
+
writes the resolved form into the log. The agent never touches the
|
|
129
|
+
resolved locator — the saved test gets a clean `getByRole(...)` /
|
|
130
|
+
`getByTestId(...)` automatically.
|
|
131
|
+
|
|
132
|
+
**If the element you need isn't in the snapshot** — that's a missing
|
|
133
|
+
*preparatory* step, not a reason to bypass the ref-resolver. Almost
|
|
134
|
+
always one of:
|
|
135
|
+
- Off-screen → `scroll_into_view` action on the nearest visible
|
|
136
|
+
anchor, then re-`get_page_snapshot`.
|
|
137
|
+
- Async-rendered → `wait_for` / `wait_for_text`, then re-snapshot.
|
|
138
|
+
- Hover/click-revealed (menu, dropdown) → action on the trigger,
|
|
139
|
+
then re-snapshot.
|
|
140
|
+
- Inside iframe → `enter_frame`, then re-snapshot.
|
|
141
|
+
|
|
142
|
+
**`allowNoRef: true`** is an escape hatch for the truly-no-ref case
|
|
143
|
+
(Shadow DOM piercing, cross-context element with no shapshot entry).
|
|
144
|
+
It lets a hand-written locator through verbatim; the saved scenario
|
|
145
|
+
will be brittle. Use only after the preparatory steps above have
|
|
146
|
+
been exhausted. If you find yourself reaching for `allowNoRef`
|
|
147
|
+
repeatedly, stop and ask the user.
|
|
148
|
+
|
|
149
|
+
When recording, `description` and `section` are **required**.
|
|
150
|
+
Adjacent same-section entries collapse into one `//@collapse(...) …
|
|
151
|
+
//@endcollapse` block in the generated test, so the `section` must read
|
|
152
|
+
like a line in a human test PLAN — the user-facing GOAL of the block
|
|
153
|
+
("Sign in", "Open first car card", "Apply status filter"), **never** a
|
|
154
|
+
generic mechanic ("Navigation", "Actions", "Click", "Load data"). Use the
|
|
155
|
+
SAME label for every step of one block; don't reuse a label for a
|
|
156
|
+
different block, and never split one block into two same-named blocks
|
|
157
|
+
(e.g. two "Navigation" groups around the login). The `description` is one
|
|
158
|
+
short line naming the target concretely ("Double-click the first car
|
|
159
|
+
row"), not a vague "click".
|
|
160
|
+
|
|
161
|
+
3. **Re-discover** after every action that changes the page (navigate,
|
|
162
|
+
open dialog, async re-render). Refs from the previous snapshot are
|
|
163
|
+
stale — using them throws `StaleRefError`.
|
|
164
|
+
|
|
165
|
+
4. **Wrong step?** Remove before continuing:
|
|
166
|
+
```
|
|
167
|
+
explore_remove_step { explorationId, entryId: "<from explore_step result>" }
|
|
168
|
+
```
|
|
169
|
+
To add an assertion you forgot (or any step that you want recorded
|
|
170
|
+
without executing again): `explore_record { explorationId, action,
|
|
171
|
+
…, description, section }` — same shape as `explore_step` but
|
|
172
|
+
no execution.
|
|
173
|
+
|
|
174
|
+
**Ad-hoc probes** — to try a click or fill without polluting the
|
|
175
|
+
recording, **omit `explorationId`**. Refs forwarded as-is to the
|
|
176
|
+
driver, no resolve/verify, nothing written.
|
|
177
|
+
|
|
178
|
+
### Phase 4 — stop, generate, save
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
explore_stop { explorationId }
|
|
182
|
+
generate_dsl_from_exploration { explorationId } → { draftDsl, warnings }
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Read the warnings before saving:
|
|
186
|
+
|
|
187
|
+
- **`FRAGILE_LOCATOR`** — element resolved but its identifier is
|
|
188
|
+
fragile (no testId / aria-label; only deep text). Best fix: ask the
|
|
189
|
+
app team to add a `data-testid`, then re-record the step. Acceptable
|
|
190
|
+
fallback: keep the warning and document.
|
|
191
|
+
- **`NO_DSL_PRIMITIVE`** — captured shape has no matching DSL function.
|
|
192
|
+
The line renders as `// SKIPPED …`. Fix the entry (`explore_remove_step`
|
|
193
|
+
+ `explore_record` with a different action) or pass `force: true` on
|
|
194
|
+
save to keep the comment inline.
|
|
195
|
+
|
|
196
|
+
Then persist:
|
|
197
|
+
|
|
198
|
+
```
|
|
199
|
+
save_exploration_as_test { explorationId, scenarioName: "<name>" }
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Writes `unotest/e2e/<name>.js`. File exists? Pass `overwrite: true`.
|
|
203
|
+
|
|
204
|
+
### Phase 5 — reset state, then run_test
|
|
205
|
+
|
|
206
|
+
The recorded scenario starts wherever you left the browser. The saved
|
|
207
|
+
test must start from a clean baseline. If your recording assumed
|
|
208
|
+
"already signed in", the test won't be repeatable.
|
|
209
|
+
|
|
210
|
+
Reset between recording and the first `run_test`:
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
close_context { }
|
|
214
|
+
new_context { }
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
If the scenario relies on seeded data (DB rows, API state), make sure
|
|
218
|
+
the seeder helpers run as the first scenario steps — `wipe_e2e_users();
|
|
219
|
+
seed_user(...)` belong in the SETUP section.
|
|
220
|
+
|
|
221
|
+
Then verify:
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
run_test { name: "<scenario-name>" }
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The response carries `next.outcome`. **The scenario passed only when
|
|
228
|
+
`next.outcome === "completed"`.** Any other value (`"failed"`,
|
|
229
|
+
`"paused-failure"`, `"aborted"`) means the test failed — read
|
|
230
|
+
`next.error` and iterate. `status: "started"` is just a lifecycle
|
|
231
|
+
marker, not a pass signal.
|
|
232
|
+
|
|
233
|
+
`paused-failure` keeps the browser context open and the runtime
|
|
234
|
+
addressable by `runtimeId`. See **Failure recovery** below.
|
|
235
|
+
|
|
236
|
+
Iterate until `next.outcome === "completed"`.
|
|
237
|
+
|
|
238
|
+
## DSL cheat-sheet (web vocab)
|
|
239
|
+
|
|
240
|
+
Method chains on locators are encouraged:
|
|
241
|
+
`locator('.row').filter({hasText:'X'}).getByRole('button').click()`. Options-objects
|
|
242
|
+
are standard: `click(loc, {force: true, timeout: 5000})`. Raw template
|
|
243
|
+
strings are allowed for multi-line literals, but `${...}` interpolation
|
|
244
|
+
is a parse error — pass values as positional args.
|
|
245
|
+
|
|
246
|
+
What's **not allowed** (parser / runtime will reject): `import` /
|
|
247
|
+
`export` / `require`, top-level `await`, `class`, JSX, `for`/`while`
|
|
248
|
+
loops (use the `maxSteps` budget guards).
|
|
249
|
+
|
|
250
|
+
Navigation:
|
|
251
|
+
- `goto(url, opts?)`, `reload(opts?)`, `getUrl()`, `goBack()`, `goForward()`.
|
|
252
|
+
|
|
253
|
+
Actions (selector arg always first):
|
|
254
|
+
- `click(loc, opts?)`, `doubleClick(loc, opts?)`, `fill(loc, text, opts?)`,
|
|
255
|
+
`press(loc, key)`, `check(loc)`, `uncheck(loc)`, `selectOption(loc, value)`,
|
|
256
|
+
`hover(loc)`, `scrollIntoView(loc)`.
|
|
257
|
+
|
|
258
|
+
Selectors (locator hierarchy — D-22, hard rule):
|
|
259
|
+
1. **`getByTestId(id)`** — most stable.
|
|
260
|
+
2. **`getByRole(role, {name})`** — accessibility-tree based.
|
|
261
|
+
3. **`getByLabel(text)`** — form controls bound to a `<label>`.
|
|
262
|
+
4. **`getByText(text)`** — visible text; brittle to copy edits.
|
|
263
|
+
5. **`locator(cssSelector)`** — last resort. Linter warns on `>` /
|
|
264
|
+
descendant combinators and hashed CSS-module / Tailwind-JIT classes.
|
|
265
|
+
|
|
266
|
+
**CSS selectors go through `locator("css-here")`, not `css("...")`.**
|
|
267
|
+
There is no `css(...)` function. Read the line right above this one:
|
|
268
|
+
when you reach for an attribute selector like `a[href*='catalogTree']`,
|
|
269
|
+
write `locator("a[href*='catalogTree']")` — **not** `css("a[href*='catalogTree']")`.
|
|
270
|
+
The validator rejects unknown function names as
|
|
271
|
+
`validator:unknown-function` errors at lint time. Same for any other
|
|
272
|
+
identifier you might guess — if it's not in this skill, it doesn't
|
|
273
|
+
exist; don't invent.
|
|
274
|
+
|
|
275
|
+
**No regex literals.** `/pattern/` is a **parse error** (`Invalid
|
|
276
|
+
token Token(type: SLASH)`) — the DSL lexer has only one meaning for
|
|
277
|
+
`/`: division. Playwright-style `getByRole("link", {name: /^Sets/})`
|
|
278
|
+
will NOT parse.
|
|
279
|
+
|
|
280
|
+
When the live accessible name contains dynamic content (counts,
|
|
281
|
+
timestamps, user data), use **substring matching by dropping
|
|
282
|
+
`exact: true`**:
|
|
283
|
+
|
|
284
|
+
```js
|
|
285
|
+
// Live text on the page: "Sets 21,414 items"
|
|
286
|
+
// BAD — captures the count, breaks on every update:
|
|
287
|
+
click(getByText("Sets 21,414 items", {exact: true}));
|
|
288
|
+
// BAD — regex doesn't parse:
|
|
289
|
+
click(getByRole("link", {name: /^Sets/}));
|
|
290
|
+
// GOOD — substring match against the stable prefix:
|
|
291
|
+
click(getByRole("link", {name: "Sets"})); // matches "Sets …"
|
|
292
|
+
click(getByText("Sets", {exact: false})); // also fine; exact: false is the default
|
|
293
|
+
// ALSO GOOD — when href is stable:
|
|
294
|
+
click(locator("a[href*='catalogTree.asp?itemType=S']"));
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
`getByRole` and `getByText` are **substring + word-boundary** by
|
|
298
|
+
default; omit `exact: true` whenever the captured name includes
|
|
299
|
+
content that may change.
|
|
300
|
+
|
|
301
|
+
Disambiguate with `.filter({hasText: '…'})` or `.filter({has: …})` —
|
|
302
|
+
**not** `.first()` / `.last()` / `.nth(N)` (linter flags
|
|
303
|
+
`lint:disambig-by-index`).
|
|
304
|
+
|
|
305
|
+
Waits:
|
|
306
|
+
- `waitFor(loc, opts?)`, `waitForText(text)`, `waitForUrl(pattern)`,
|
|
307
|
+
`pause(ms)` — `pause` requires `// reason: <why>` comment immediately
|
|
308
|
+
above (linter flags `lint:pause-explicit` otherwise).
|
|
309
|
+
|
|
310
|
+
Assertions:
|
|
311
|
+
- `assertText(loc, text, opts?)`, `assertVisible(loc)`, `assertHidden(loc)`,
|
|
312
|
+
`assertCount(loc, n)`, `assertUrl(pattern)`, `assertTrue(expr, msg?)`.
|
|
313
|
+
|
|
314
|
+
State reads:
|
|
315
|
+
- `getTitle()`, `getUrl()`, `getAttribute(loc, name)`, `getInnerText(loc)`,
|
|
316
|
+
`getInputValue(loc)`.
|
|
317
|
+
|
|
318
|
+
Sandbox primitives:
|
|
319
|
+
- `shell("cmd", "arg", …)` — `execFile` style, no shell interpretation.
|
|
320
|
+
- `dbQuery(sql, …params)`, `dbExec(sql, …params)` — parameterized.
|
|
321
|
+
- `apiCall(method, path, body?, headers?)` — path-only against
|
|
322
|
+
`sandbox.apiBaseUrl`.
|
|
323
|
+
|
|
324
|
+
Escape hatch:
|
|
325
|
+
- `evaluate(\`js body\`, …args)` — raw backticks, no `${}`. Linter
|
|
326
|
+
warns `lint:evaluate-discouraged`. Use only when nothing above fits.
|
|
327
|
+
|
|
328
|
+
Time helpers: `nowMs()`, `today()`, `daysFromNow(n)`.
|
|
329
|
+
|
|
330
|
+
## Helpers convention
|
|
331
|
+
|
|
332
|
+
Reusable flows live in `unotest/e2e/_helpers/<group>.js` as plain `.js`
|
|
333
|
+
exporting `snake_case` functions:
|
|
334
|
+
|
|
335
|
+
```js
|
|
336
|
+
// unotest/e2e/_helpers/signin.js
|
|
337
|
+
function signin_as(email, password) {
|
|
338
|
+
// 2. ENTER — bring the app to the initial UI state
|
|
339
|
+
goto('/sign-in');
|
|
340
|
+
|
|
341
|
+
// 3. ACT — actions you are testing
|
|
342
|
+
fill(getByLabel('Email'), email);
|
|
343
|
+
fill(getByLabel('Password'), password);
|
|
344
|
+
click(getByRole('button', {name: 'Sign in'}));
|
|
345
|
+
|
|
346
|
+
// 4. ASSERT — UI + DB / API checks
|
|
347
|
+
waitForUrl('/dashboard');
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
The runner auto-discovers helpers — no `import`. Call them like
|
|
352
|
+
built-in DSL functions.
|
|
353
|
+
|
|
354
|
+
**Naming:** `snake_case` distinguishes helpers from core DSL
|
|
355
|
+
(`camelCase`); `test_*` is reserved for entry functions; `flow_*` for
|
|
356
|
+
composite multi-step helpers.
|
|
357
|
+
|
|
358
|
+
**Saved test structure.** Every step in a `test_*` entry must sit inside a
|
|
359
|
+
`//@collapse("description")` block (linter enforces). The title is the
|
|
360
|
+
plain-English intent of the group; when a step later breaks you read it
|
|
361
|
+
back to repair the group. The recorder groups by phase automatically; when
|
|
362
|
+
you hand-edit, keep each chunk inside its own block:
|
|
363
|
+
|
|
364
|
+
```js
|
|
365
|
+
function test_dashboard_loads_with_widgets() {
|
|
366
|
+
//@collapse("Seed a fresh demo user")
|
|
367
|
+
wipe_e2e_users();
|
|
368
|
+
seed_user('demo@example.com', 'secret');
|
|
369
|
+
//@endcollapse
|
|
370
|
+
|
|
371
|
+
//@collapse("Sign in as the demo user")
|
|
372
|
+
signin_as('demo@example.com', 'secret');
|
|
373
|
+
//@endcollapse
|
|
374
|
+
|
|
375
|
+
//@collapse("Dashboard shows the Today widget")
|
|
376
|
+
assertVisible(getByRole('region', {name: 'Today'}));
|
|
377
|
+
//@endcollapse
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
(Helpers and `flow_*` composites are NOT wrapped — the rule applies only to
|
|
382
|
+
`test_*` scenario entries.)
|
|
383
|
+
|
|
384
|
+
## Failure recovery
|
|
385
|
+
|
|
386
|
+
`paused-failure` from `run_test` keeps the browser context alive and
|
|
387
|
+
the runtime addressable by `runtimeId`. Use the diagnostic tools:
|
|
388
|
+
|
|
389
|
+
1. **`inspect_runtime { runtimeId }`** — `lastFailure` (error + line/col
|
|
390
|
+
+ AST node) + `vars` (every assignment up to the pause). Tells you
|
|
391
|
+
what was observed vs expected and the state the scenario built.
|
|
392
|
+
2. **`get_page_snapshot { }`** — current page outline. Look for whether
|
|
393
|
+
the locator is reachable, or whether the page navigated somewhere
|
|
394
|
+
unexpected.
|
|
395
|
+
3. **`list_failures` + `get_failure_console` / `get_failure_trace` /
|
|
396
|
+
`get_failure_network`** — failure bundle. `get_failure_console` is
|
|
397
|
+
often the giveaway when the failure is really a runtime error in
|
|
398
|
+
the app.
|
|
399
|
+
4. **`agent_fix { runId }`** — composes a structured fix-context
|
|
400
|
+
prompt. **It does NOT patch your code** (D-25); you apply edits.
|
|
401
|
+
5. **`open_viewer { }`** — boots the localhost viewer for the project
|
|
402
|
+
if it isn't running, returns its URL. The viewer renders the block-
|
|
403
|
+
tree of the failing scenario with live status, plus a log-tail of
|
|
404
|
+
the child runner's stdio. Useful when you want a human (or yourself)
|
|
405
|
+
to look at the failure visually instead of paging through MCP-tool
|
|
406
|
+
output. Idempotent: a second call returns the same URL.
|
|
407
|
+
|
|
408
|
+
After editing the scenario, `abort_runtime { runtimeId }` to release
|
|
409
|
+
the old browser context, then `run_test` again.
|
|
410
|
+
|
|
411
|
+
## `explore_step` cheat-sheet
|
|
412
|
+
|
|
413
|
+
| `action` | required | optional |
|
|
414
|
+
|--------------------|-----------------------------------------|----------------------|
|
|
415
|
+
| `goto` | `url` | `options` |
|
|
416
|
+
| `reload` | | `options` |
|
|
417
|
+
| `go_back` | | |
|
|
418
|
+
| `go_forward` | | |
|
|
419
|
+
| `click` | `locator` | `options` |
|
|
420
|
+
| `double_click` | `locator` | `options` |
|
|
421
|
+
| `fill` | `locator`, `value` | `options` |
|
|
422
|
+
| `press` | `locator`, `value` (the key) | `options` |
|
|
423
|
+
| `check` / `uncheck`| `locator` | `options` |
|
|
424
|
+
| `select_option` | `locator`, `value` | |
|
|
425
|
+
| `hover` | `locator` | `options` |
|
|
426
|
+
| `scroll_into_view` | `locator` | |
|
|
427
|
+
| `wait_for` | `locator` | `options` |
|
|
428
|
+
| `wait_for_text` | `value` (the text) | `options` |
|
|
429
|
+
| `wait_for_url` | `value` (the pattern) | `options` |
|
|
430
|
+
| `enter_frame` | `locator` | |
|
|
431
|
+
| `exit_frame` | | |
|
|
432
|
+
|
|
433
|
+
When recording (`explorationId` present): `description` + `section`
|
|
434
|
+
are required. Ad-hoc (no `explorationId`): no description/section,
|
|
435
|
+
no recording.
|
|
436
|
+
|
|
437
|
+
## Failure modes you will hit
|
|
438
|
+
|
|
439
|
+
- **`StaleRefError`** — ref no longer in the DOM. Re-`get_page_snapshot`
|
|
440
|
+
and use the new ref.
|
|
441
|
+
- **`RefResolveError`** — element has no stable identifier (testId /
|
|
442
|
+
role+name / aria-label / placeholder / alt / title / text / href /
|
|
443
|
+
stable id / name attribute). Best fix: ask the app team to add
|
|
444
|
+
`data-testid`. Fallbacks: provide a hand-written locator with
|
|
445
|
+
`allowNoRef: true` — e.g. `{locator:{kind:"locator",steps:[{kind:
|
|
446
|
+
"getByPlaceholder",text:"Foo"}]}, allowNoRef:true}` — **do NOT** pass
|
|
447
|
+
a `{kind:"ref"}` step with `allowNoRef:true`, the ref still tries to
|
|
448
|
+
resolve. Or use `find_element({role, name, near})` and use its ref.
|
|
449
|
+
- **`AmbiguousResolveError`** — the resolved locator matches more than
|
|
450
|
+
one element (e.g. two `Sign in` buttons in different regions). Same
|
|
451
|
+
fix: add a testId or pass a more specific locator.
|
|
452
|
+
|
|
453
|
+
## When a locator matches `N elements`
|
|
454
|
+
|
|
455
|
+
Every locator must resolve to exactly one element. When you see
|
|
456
|
+
`getByRole('row', {name: 'X'}) resolved to 301 elements`:
|
|
457
|
+
|
|
458
|
+
- Narrow the `name` to a uniquely-identifying substring. `name`
|
|
459
|
+
substring-matches: pick wording that hits only one row (a header's
|
|
460
|
+
full label `"Pending (300)"` or a row-specific ID like `"573"`).
|
|
461
|
+
- Use `find_element({role: 'row', name: '573', near: '<table-ref>'})`
|
|
462
|
+
to skip manual disambiguation — scoped search returns the single ref
|
|
463
|
+
directly.
|
|
464
|
+
- Only use `.first()` / `.nth(N)` when the position itself is the
|
|
465
|
+
semantic anchor.
|
|
466
|
+
|
|
467
|
+
## Anti-patterns
|
|
468
|
+
|
|
469
|
+
- **Don't explore the project before Phase 1.** No `find`, no `tree`,
|
|
470
|
+
no broad `ls`. Phase 0 lists three reads — do those, then start
|
|
471
|
+
recording. You learn the page from `get_page_snapshot`, not from
|
|
472
|
+
static project structure.
|
|
473
|
+
- **Don't invent locators.** Use a ref from `get_page_snapshot`. The
|
|
474
|
+
`[eN]` ref already exists in the snapshot — copy it as
|
|
475
|
+
`{kind:"locator", steps:[{kind:"ref", ref:"eN"}]}` into the
|
|
476
|
+
`explore_step` call. The recorder produces the stable form.
|
|
477
|
+
- **Don't write `css(...)`.** That function does not exist. CSS
|
|
478
|
+
selectors go through `locator("css-here")`. Every time you reach for
|
|
479
|
+
`css("a[href*='...']")`, rewrite as `locator("a[href*='...']")`.
|
|
480
|
+
The validator rejects `css(...)` as `validator:unknown-function`
|
|
481
|
+
at lint time — your scenario will not pass `verify`.
|
|
482
|
+
- **Don't write regex literals.** `/^Sets/` is a parse error.
|
|
483
|
+
Substring-match instead: `getByRole("link", {name: "Sets"})` is
|
|
484
|
+
already substring (no `exact: true`).
|
|
485
|
+
- **Don't pass `getByRole('link', {name: 'Sets 21,414 items'})`** —
|
|
486
|
+
the count is dynamic and the accessible name is usually `'Sets'`
|
|
487
|
+
alone. Hand-written `name` strings are guesses; the ref resolver
|
|
488
|
+
reads the real accessible name from the live DOM.
|
|
489
|
+
- **Don't use `.first()` / `.last()` / `.nth(N)` to disambiguate
|
|
490
|
+
multi-matches.** Element order is brittle. Use `.filter({hasText:
|
|
491
|
+
'…'})` or `.filter({has: someLocator})`. Linter flags index-based
|
|
492
|
+
picking as `lint:disambig-by-index`.
|
|
493
|
+
- **Don't reach for `locator(...)` with `>` combinators, hashed class
|
|
494
|
+
names, or `xpath=…`.** Stop and ask the user whether the app should
|
|
495
|
+
expose a `data-testid` or accessible name.
|
|
496
|
+
- **Don't omit `section` / `description` when recording.** They shape
|
|
497
|
+
the generated test into readable `//@collapse` blocks — and they're
|
|
498
|
+
required.
|
|
499
|
+
- **Don't consider the task done without `next.outcome === "completed"`.**
|
|
500
|
+
"I recorded the flow, generated the file, looks right" is not done.
|
|
501
|
+
A green `run_test` is done.
|
|
502
|
+
- **Don't `import` / `export` / `await` / `class` / arrow-functions
|
|
503
|
+
in the saved scenario.** DSL, not Node.
|
|
504
|
+
|
|
505
|
+
## Fast feedback
|
|
506
|
+
|
|
507
|
+
After editing any scenario (recorded or hand-written):
|
|
508
|
+
|
|
509
|
+
```sh
|
|
510
|
+
npx @unotest/web lint
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
The linter runs two passes:
|
|
514
|
+
|
|
515
|
+
1. **Validator** (`validator:unknown-function`, `validator:dsl`) —
|
|
516
|
+
`error` severity, gates `pnpm verify`. Catches unknown function
|
|
517
|
+
names (`css`, typos), wrong arg kinds, malformed `for`/`if`
|
|
518
|
+
shapes. **If you see `validator:unknown-function`, you invented
|
|
519
|
+
a function name** — re-read the DSL cheat-sheet above and use the
|
|
520
|
+
real one.
|
|
521
|
+
2. **Brittle-pattern linter** (`lint:deep-css`, `lint:xpath`,
|
|
522
|
+
`lint:obfuscated-class`, `lint:pause-explicit`,
|
|
523
|
+
`lint:evaluate-discouraged`) — `warn` severity. Doesn't gate
|
|
524
|
+
`verify` but indicates fragile locators that will break under
|
|
525
|
+
refactor.
|
|
526
|
+
|
|
527
|
+
If lint flags `import` / `export` / `await` / `const` — re-read the
|
|
528
|
+
DSL section above and rewrite as bare statements + function
|
|
529
|
+
definitions.
|
|
530
|
+
|
|
531
|
+
## What this surface is NOT for
|
|
532
|
+
|
|
533
|
+
- **Production data mutation.** `apiCall` / `dbExec` against a
|
|
534
|
+
production base URL is a footgun — the `sandbox.*` pin model in
|
|
535
|
+
`unotest.config.{js,mjs,ts}` prevents it. Don't bypass.
|
|
536
|
+
- **Browser automation for scraping.** This is an E2E testing tool;
|
|
537
|
+
scraping violates many target sites' terms of service.
|
|
538
|
+
- **Calling external LLM APIs.** `agent_fix` builds prompts for the
|
|
539
|
+
agent **you** are. It does not delegate. No LLM client in this
|
|
540
|
+
package (D-25).
|
|
541
|
+
|
|
542
|
+
## Commands you'll run
|
|
543
|
+
|
|
544
|
+
```sh
|
|
545
|
+
npx @unotest/web init # one-time bootstrap (unotest/, config)
|
|
546
|
+
npx @unotest/web e2e <name> # run a scenario by basename
|
|
547
|
+
npx @unotest/web lint # validator + linter; errors gate verify
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
Always use `npx @unotest/web …` — not `pnpm` / `yarn` (those don't
|
|
551
|
+
exist for end users).
|