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