@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
|
@@ -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).
|