@unotest/web 0.5.0 → 0.6.2

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,670 @@
1
+ ---
2
+ status: stable
3
+ last_updated: 2026-05-14
4
+ ---
5
+
6
+ # DSL Reference
7
+
8
+ Complete catalog of the built-in DSL functions registered by
9
+ `src/dsl/default-registry.ts` — what your
10
+ `.js` scenarios in `unotest/e2e/` can call out of the box.
11
+
12
+ Naming follows Playwright vocab (D-1). Where a function has dual semantics
13
+ (bare call vs. chained on a locator receiver), both shapes are valid:
14
+
15
+ ```js
16
+ click(getByRole('button', {name: 'Save'})) // bare
17
+ click(getByRole('button').filter({hasText: 'Save'})) // chained
18
+ ```
19
+
20
+ A scenario file looks like:
21
+
22
+ ```js
23
+ function test_my_scenario() {
24
+ //@collapse("Sign in and land on the dashboard")
25
+ goto('https://example.com');
26
+ fill(getByLabel('Email'), 'a@b.c');
27
+ click(getByRole('button', {name: 'Sign in'}));
28
+ assertUrl('/dashboard');
29
+ //@endcollapse
30
+ }
31
+ ```
32
+
33
+ > **Every step must sit inside a `//@collapse("...")` block** in a `test_*`
34
+ > entry (the AST validator enforces it; `flow_*` composites and helpers are
35
+ > exempt). The title is the plain-English intent of the group — what the
36
+ > agent reads back to repair a step when a selector drifts. Group by intent,
37
+ > one block per logical chunk; anything is allowed inside a block (nested
38
+ > blocks, control flow, comments).
39
+
40
+ > **Locator quality matters (D-22).** Prefer `getByTestId` →
41
+ > `getByRole(name)` → `getByLabel` → `getByText` → `locator(css)`. The AST
42
+ > linter (`unotest-web lint`) flags brittle selectors — see
43
+ > `docs/manuals/troubleshooting.md`.
44
+
45
+ ## Categories
46
+
47
+ - [Builders](#builders) — root locators
48
+ - [Chain helpers](#chain-helpers) — refine a locator
49
+ - [Actions](#actions) — drive interactions
50
+ - [Assertions](#assertions) — wait + verify
51
+ - [Queries](#queries) — read state without asserting
52
+ - [Navigation](#navigation) — load + wait
53
+ - [Multi-context](#multi-context) — multi-tab / iframes
54
+ - [Storage](#storage) — localStorage / cookies
55
+ - [Edge actions](#edge-actions) — drag, upload, paste
56
+ - [Sandbox primitives](#sandbox-primitives) — shell / db / HTTP
57
+ - [Typed getters](#typed-getters) — page properties
58
+ - [`evaluate`](#evaluate) — escape hatch into the page
59
+ - [Logging](#logging)
60
+
61
+ ---
62
+
63
+ ## Matcher arguments — `string | RegExp`
64
+
65
+ Selectors that map onto Playwright's `string | RegExp` matcher
66
+ parameter (`getByRole({name})`, `getByText`, `getByLabel`,
67
+ `getByPlaceholder`, `getByAltText`, `getByTitle`, `filter({hasText,
68
+ hasNotText})`) accept either:
69
+
70
+ - A string literal — matched as substring by default, exactly with
71
+ `{exact: true}`.
72
+ - A regex literal `/pattern/flags` — ECMA-262 5.1 subset.
73
+
74
+ ```js
75
+ getByRole("link", {name: /^Sets/})
76
+ getByText(/Items \d+/i)
77
+ filter({hasText: /done/i})
78
+ ```
79
+
80
+ **Supported regex flags:** `g`, `i`, `m`. Post-ES5 flags (`s u y d v`)
81
+ are rejected at parse time with a message naming the spec they came
82
+ from — write a separate plan if you need them
83
+ (`docs/dsl/regex-literal-es5.md` out-of-scope section).
84
+
85
+ **Escape rules:** standard ECMA-262 5.1 grammar — `\` introduces
86
+ escape sequences, `[...]` is a character class where `/` does not
87
+ close the literal. No literal line breaks inside a regex body.
88
+
89
+ ---
90
+
91
+ ## Builders
92
+
93
+ ### `locator(selector)`
94
+
95
+ CSS selector, last-resort escape hatch. Use only when nothing else works
96
+ — the linter discourages this with `lint:deep-css` for `>`-bearing
97
+ selectors.
98
+
99
+ ```js
100
+ locator('.error-banner')
101
+ ```
102
+
103
+ ### `getByRole(role, options?)`
104
+
105
+ ARIA role. `options.name` is the accessible name — string or regex
106
+ (see [Matcher arguments](#matcher-arguments--string--regexp)).
107
+ Substring by default, exact with `{exact: true}`.
108
+
109
+ ```js
110
+ getByRole('button', {name: 'Save'})
111
+ getByRole('heading', {name: 'Welcome', exact: true})
112
+ getByRole('link', {name: /^Sets/}) // regex matcher
113
+ ```
114
+
115
+ ### `getByText(text, options?)`
116
+
117
+ Visible text content.
118
+
119
+ ```js
120
+ getByText('Submit')
121
+ getByText('Save', {exact: true})
122
+ ```
123
+
124
+ ### `getByLabel(text, options?)`
125
+
126
+ Form control by associated `<label>`.
127
+
128
+ ```js
129
+ getByLabel('Email')
130
+ ```
131
+
132
+ ### `getByPlaceholder(text, options?)`
133
+
134
+ Input by its placeholder.
135
+
136
+ ```js
137
+ getByPlaceholder('Search…')
138
+ ```
139
+
140
+ ### `getByTestId(id)`
141
+
142
+ `data-testid` attribute. **Preferred** for stable selection.
143
+
144
+ ```js
145
+ getByTestId('checkout-submit')
146
+ ```
147
+
148
+ ### `getByAltText(text, options?)`
149
+
150
+ Image / area by `alt`.
151
+
152
+ ```js
153
+ getByAltText('Company logo')
154
+ ```
155
+
156
+ ### `getByTitle(text, options?)`
157
+
158
+ Element by its `title` attribute.
159
+
160
+ ```js
161
+ getByTitle('Close')
162
+ ```
163
+
164
+ ---
165
+
166
+ ## Chain helpers
167
+
168
+ Each requires a locator as the receiver / first argument.
169
+
170
+ ### `filter(options)`
171
+
172
+ Narrow down a locator. `hasText`, `hasNotText`, `has`, `hasNot`.
173
+
174
+ ```js
175
+ getByRole('row').filter({hasText: 'Alice'})
176
+ getByRole('row').filter({has: getByTestId('admin-badge')})
177
+ ```
178
+
179
+ ### `first()`, `last()`, `nth(index)`
180
+
181
+ Pick a single element from a multi-match.
182
+
183
+ ```js
184
+ getByRole('row').first()
185
+ getByRole('row').nth(2)
186
+ ```
187
+
188
+ Prefer `getByRole` + `filter({hasText})` over `nth()` — `nth()`-by-index is
189
+ flagged by `lint:disambig-by-index` because element order in the DOM is
190
+ brittle.
191
+
192
+ ### `contentFrame()`
193
+
194
+ Treat the matched element as an iframe and return a locator scoped to
195
+ its content document.
196
+
197
+ ```js
198
+ contentFrame(getByTestId('payment-iframe'))
199
+ ```
200
+
201
+ See also [`enterFrame`](#enterframeloc) for the active-context model.
202
+
203
+ ---
204
+
205
+ ## Actions
206
+
207
+ All wait for actionability (visible + stable + enabled).
208
+
209
+ ### `click(loc, options?)`
210
+
211
+ ```js
212
+ click(getByRole('button', {name: 'Save'}))
213
+ click(getByText('Submit'), {force: true})
214
+ ```
215
+
216
+ **Options:** `force?`, `timeout?`, `position?`, `modifiers?`.
217
+
218
+ ### `doubleClick(loc, options?)`
219
+
220
+ Same shape as `click`.
221
+
222
+ ### `fill(loc, value, options?)`
223
+
224
+ Sets the value of an `<input>`, `<textarea>`, or `[contenteditable]`.
225
+ Replaces existing content.
226
+
227
+ ```js
228
+ fill(getByLabel('Email'), 'a@b.c')
229
+ ```
230
+
231
+ ### `press(loc, key, options?)`
232
+
233
+ Send a key press. `key` follows the Playwright/Web key spec:
234
+ `'Enter'`, `'Tab'`, `'ArrowDown'`, `'Control+A'`, etc.
235
+
236
+ ```js
237
+ press(getByLabel('Search'), 'Enter')
238
+ press(getByLabel('Body'), 'Control+A')
239
+ ```
240
+
241
+ ### `check(loc, options?)` / `uncheck(loc, options?)`
242
+
243
+ Idempotent — no-op if already in the target state.
244
+
245
+ ```js
246
+ check(getByRole('checkbox', {name: 'Subscribe'}))
247
+ ```
248
+
249
+ ### `hover(loc, options?)`
250
+
251
+ ```js
252
+ hover(getByRole('button', {name: 'Help'}))
253
+ ```
254
+
255
+ ### `selectOption(loc, value, options?)`
256
+
257
+ Native `<select>` only. `value` can be a string, an array of strings, or
258
+ `{label}` / `{value}` / `{index}`.
259
+
260
+ ```js
261
+ selectOption(getByLabel('Country'), 'US')
262
+ selectOption(getByLabel('Tags'), ['a', 'b'])
263
+ ```
264
+
265
+ ### `scrollIntoView(loc)`
266
+
267
+ ```js
268
+ scrollIntoView(getByText('Footer'))
269
+ ```
270
+
271
+ ---
272
+
273
+ ## Assertions
274
+
275
+ Each polls until the condition holds or the timeout fires (default 5s;
276
+ override per-call with `{timeout}`). Throw `AssertionError` on failure.
277
+
278
+ ### `assertText(loc, expected, options?)`
279
+
280
+ Default is exact match. Use `{exact: false}` for substring.
281
+
282
+ ```js
283
+ assertText(getByTestId('success-banner'), 'Saved successfully')
284
+ assertText(getByRole('heading'), 'Welcome', {exact: false})
285
+ ```
286
+
287
+ **Options:** `timeout?`, `exact?` (default `true`).
288
+
289
+ ### `assertVisible(loc, options?)` / `assertHidden(loc, options?)`
290
+
291
+ ```js
292
+ assertVisible(getByTestId('toast'))
293
+ assertHidden(getByTestId('spinner'))
294
+ ```
295
+
296
+ ### `assertValue(loc, expected, options?)`
297
+
298
+ Compares `inputValue()` (`<input>`, `<textarea>`, native `<select>`).
299
+
300
+ ```js
301
+ assertValue(getByLabel('Email'), 'a@b.c')
302
+ ```
303
+
304
+ ### `assertCount(loc, expected, options?)`
305
+
306
+ ```js
307
+ assertCount(getByRole('listitem'), 5)
308
+ ```
309
+
310
+ ### `assertUrl(pattern, options?)`
311
+
312
+ Substring match against the active page's URL.
313
+
314
+ ```js
315
+ assertUrl('/dashboard')
316
+ assertUrl('https://example.com/checkout')
317
+ ```
318
+
319
+ ### `assertTrue(value, message)`
320
+
321
+ Generic boolean assert. Throws with `message` when `value` is falsy.
322
+
323
+ ```js
324
+ result = shell('echo', 'ok');
325
+ assertTrue(result.code == 0, 'shell exit code should be 0');
326
+ ```
327
+
328
+ ---
329
+
330
+ ## Queries
331
+
332
+ Read state without asserting. No polling.
333
+
334
+ ### `count(loc)` → `number`
335
+
336
+ ### `textContent(loc)` → `string`
337
+
338
+ Empty string when the element has no text node (does not throw).
339
+
340
+ ### `inputValue(loc)` → `string`
341
+
342
+ ### `isVisible(loc)` → `boolean`
343
+
344
+ ```js
345
+ n = count(getByRole('row'));
346
+ if (n > 10) {
347
+ log('too many rows:', n);
348
+ }
349
+ ```
350
+
351
+ ---
352
+
353
+ ## Navigation
354
+
355
+ ### `goto(url, options?)`
356
+
357
+ ```js
358
+ goto('https://example.com')
359
+ goto('/dashboard') // relative — resolved against the active page
360
+ ```
361
+
362
+ **Options:** `waitUntil?` (`'load'` | `'domcontentloaded'` |
363
+ `'networkidle'`), `timeout?`.
364
+
365
+ ### `reload(options?)` / `goBack(options?)` / `goForward(options?)`
366
+
367
+ ### `waitForUrl(pattern, options?)`
368
+
369
+ Wait until the active page URL contains `pattern`. Useful after a click
370
+ that triggers navigation.
371
+
372
+ ```js
373
+ click(getByRole('link', {name: 'Settings'}));
374
+ waitForUrl('/settings');
375
+ ```
376
+
377
+ ### `waitForNavigation(options?)`
378
+
379
+ Wait for the next navigation event.
380
+
381
+ ### `waitFor(loc, options?)`
382
+
383
+ Wait for a locator to be visible (default state).
384
+
385
+ ```js
386
+ waitFor(getByTestId('content-loaded'))
387
+ ```
388
+
389
+ ### `waitForText(text, options?)`
390
+
391
+ Wait for any element on the active page to contain `text`.
392
+
393
+ ### `pause(ms)`
394
+
395
+ Hard sleep. **Avoid** — the linter flags it (`lint:pause-explicit`). Use
396
+ `waitFor` / `waitForText` / assertion polling instead. Allowed only with
397
+ a justifying `// reason:` comment.
398
+
399
+ ```js
400
+ // reason: third-party widget has no completion signal we can hook into
401
+ pause(500);
402
+ ```
403
+
404
+ ---
405
+
406
+ ## Multi-context
407
+
408
+ ### `setPage(index)`
409
+
410
+ Make tab `index` active. After this, every locator and assertion
411
+ targets that tab. Resets the frame stack — a `setPage` always lands on
412
+ the new tab's root document, never inside a frame inherited from the
413
+ previous tab.
414
+
415
+ Tabs open asynchronously: a click that triggers `window.open` (often
416
+ after a `fetch`) registers the new tab a beat later. `setPage` waits for
417
+ the tab at `index` to appear, then for its document to load, before
418
+ switching — up to `UNOTEST_DEFAULT_NAVIGATION_TIMEOUT_MS`. You do **not**
419
+ need a manual wait between the click and `setPage`. Only if the tab never
420
+ appears within that budget does it fail with `page index out of range`.
421
+
422
+ ```js
423
+ click(getByRole('link', {name: 'Open in new tab'})); // opens tab 1
424
+ setPage(1);
425
+ assertText(getByRole('heading'), 'Detail view');
426
+ setPage(0);
427
+ ```
428
+
429
+ **Recording shortcut.** During `explore_step` recording, popup-watcher
430
+ emits `setPage(N);` automatically when a click opens a new tab — the
431
+ generated DSL ends up with the right `setPage` call without the agent
432
+ having to track tab indexes. See
433
+ `docs/auto-page-switch/README.md`
434
+ and `UNOTEST_POPUP_GRACE_MS` in `unotest/.env`.
435
+
436
+ ### `enterFrame(loc)`
437
+
438
+ Enter an iframe scope. Subsequent calls run inside the frame's document.
439
+
440
+ ```js
441
+ enterFrame(getByTestId('payment-iframe'));
442
+ fill(getByLabel('Card number'), '4242 4242 4242 4242');
443
+ exitFrame();
444
+ ```
445
+
446
+ ### `exitFrame()`
447
+
448
+ Pop one iframe scope. Idempotent at depth 0.
449
+
450
+ ---
451
+
452
+ ## Storage
453
+
454
+ ### `setLocalStorage(key, value)` / `getLocalStorage(key)` → `string`
455
+
456
+ Returns empty string when key is absent.
457
+
458
+ ```js
459
+ setLocalStorage('feature-flag', 'enabled');
460
+ v = getLocalStorage('feature-flag');
461
+ ```
462
+
463
+ ### `setCookie(name, value, options?)` / `getCookie(name)` → `string`
464
+
465
+ **Options:** `domain?`, `path?`, `expires?`, `httpOnly?`, `secure?`,
466
+ `sameSite?`.
467
+
468
+ ```js
469
+ setCookie('session', 'abc123', {path: '/', sameSite: 'Lax'});
470
+ ```
471
+
472
+ ---
473
+
474
+ ## Edge actions
475
+
476
+ ### `dragAndDrop(from, to, options?)`
477
+
478
+ Uses Playwright `loc.dragTo()` — mouse-event drag. Works for most
479
+ draggables; native HTML5 drag-and-drop (real `DragEvent`) is a known
480
+ limitation — see `KNOWN_ISSUES.md`.
481
+
482
+ ```js
483
+ dragAndDrop(getByTestId('todo-1'), getByTestId('list-done'))
484
+ ```
485
+
486
+ ### `uploadFile(loc, paths)`
487
+
488
+ `paths` can be a single string or an array.
489
+
490
+ ```js
491
+ uploadFile(getByLabel('Avatar'), '/abs/path/to/photo.png')
492
+ uploadFile(getByLabel('Documents'), ['/a.pdf', '/b.pdf'])
493
+ ```
494
+
495
+ ### `clipboardPaste(loc, text)`
496
+
497
+ Focuses the element and sets its value via the input-event path
498
+ (`input` / `change` dispatch). **Does not** dispatch a real
499
+ `ClipboardEvent`, so contenteditable rich-text targets that require the
500
+ real event won't accept formatting.
501
+
502
+ ```js
503
+ clipboardPaste(getByLabel('Notes'), 'pasted text');
504
+ ```
505
+
506
+ ---
507
+
508
+ ## Sandbox primitives
509
+
510
+ Project-agnostic core (D-13, D-27). Consumers wire connection / base-URL
511
+ config in `unotest.config.{js,mjs,ts}` under `sandbox.{shellCwd, database,
512
+ apiBaseUrl}` — the scenario **cannot** override.
513
+
514
+ ```js
515
+ // unotest.config.mjs
516
+ export default {
517
+ sandbox: {
518
+ shellCwd: process.cwd(),
519
+ database: 'postgres://app:app@localhost:5432/myapp_test',
520
+ apiBaseUrl: 'http://localhost:3000/api',
521
+ },
522
+ };
523
+ ```
524
+
525
+ ### `shell(cmd, ...args)` → `{stdout, stderr, code}`
526
+
527
+ `execFile`-style — **no shell interpretation**, no `&&` / `|` /
528
+ redirection. cwd from `sandbox.shellCwd`.
529
+
530
+ ```js
531
+ out = shell('pnpm', 'db:create-user', 'alice');
532
+ assertTrue(out.code == 0, out.stderr);
533
+ ```
534
+
535
+ ### `dbQuery(sql, ...params)` → `rows[]`
536
+
537
+ Parameterized SELECT. Driver picked by URL scheme:
538
+
539
+ - `postgres://…` / `postgresql://…` — needs `pg` peer dep
540
+ - `mysql://…` — needs `mysql2`
541
+ - `sqlite:…` / `*.db` / `*.sqlite` — needs `better-sqlite3`
542
+
543
+ ```js
544
+ rows = dbQuery('select id from users where email = ?', 'a@b.c');
545
+ ```
546
+
547
+ ### `dbExec(sql, ...params)` → `number` (affected rows)
548
+
549
+ INSERT / UPDATE / DELETE.
550
+
551
+ ```js
552
+ dbExec('delete from sessions where user_id = ?', userId);
553
+ ```
554
+
555
+ ### `apiCall(method, path, body?, headers?)` → `{status, body, headers}`
556
+
557
+ HTTP via global `fetch`. **`path` must be relative** (starts with `/`) —
558
+ absolute URLs throw to prevent scenarios from hitting arbitrary hosts.
559
+
560
+ ```js
561
+ res = apiCall('POST', '/auth/login', {email: 'a@b.c', password: 'x'});
562
+ assertTrue(res.status == 200, 'login failed');
563
+ ```
564
+
565
+ ---
566
+
567
+ ## Typed getters
568
+
569
+ Read page properties directly into a local variable.
570
+
571
+ ### `getAttribute(loc, name)` → `string`
572
+
573
+ ```js
574
+ href = getAttribute(getByRole('link', {name: 'Docs'}), 'href');
575
+ ```
576
+
577
+ ### `getInnerText(loc)` → `string`
578
+
579
+ Like `textContent` but trimmed and rendered (display-aware).
580
+
581
+ ### `getInputValue(loc)` → `string`
582
+
583
+ Identical to `inputValue`; kept under typed-getters for symmetry with
584
+ agent prompts that prefer the `get*` prefix.
585
+
586
+ ### `getTitle()` → `string`
587
+
588
+ Active page title.
589
+
590
+ ### `getUrl()` → `string`
591
+
592
+ Active page URL.
593
+
594
+ ---
595
+
596
+ ## `evaluate`
597
+
598
+ `evaluate(js, ...args)` — escape hatch. Runs `js` in the active page
599
+ context. Discouraged (`lint:evaluate-discouraged`) — prefer typed
600
+ getters or the action API.
601
+
602
+ ```js
603
+ height = evaluate(`document.documentElement.scrollHeight`);
604
+ ```
605
+
606
+ Multi-arg form (args destructured in the page-side function):
607
+
608
+ ```js
609
+ result = evaluate(
610
+ `function([a, b]) { return window.myApp.compute(a, b); }`,
611
+ 10, 20
612
+ );
613
+ ```
614
+
615
+ Backticks are **raw** — `${…}` interpolation is rejected at parse time
616
+ (see `vendor/dsl/EXTENSIONS.md`).
617
+
618
+ ---
619
+
620
+ ## Logging
621
+
622
+ ### `log(...args)`
623
+
624
+ Routes through the runtime's `Logger.info`. Non-string args are
625
+ `JSON.stringify`'d.
626
+
627
+ ```js
628
+ log('user id:', userId);
629
+ ```
630
+
631
+ ---
632
+
633
+ ## Variables, control flow, types
634
+
635
+ The DSL is JavaScript-shaped but parsed by a small AST engine
636
+ (`vendor/dsl/`), not V8. What's supported in scenario files:
637
+
638
+ - **`function test_*(): { … }`** — scenario entry; each top-level
639
+ `function test_*` is a separately runnable test.
640
+ - **Assignments** — `name = expression;` (no `let`/`const`/`var`).
641
+ - **`if` / `else`**, **`while`**, **`for (a; b; c)`**,
642
+ **`do { … } while (cond)`**, **`break`**, **`continue`**.
643
+ - **Increment / decrement** — `i++`, `i--`, `++i`, `--i` (numeric
644
+ variables only; postfix returns the old value, prefix the new).
645
+ - **Object / array literals** — `{name: 'x'}`, `[1, 2, 3]`.
646
+ - **Member access** — `row.email`, `result.headers['content-type']`.
647
+ - **Method chains** — `getByRole('row').filter({hasText:'X'}).click()`.
648
+ - **Backticks** — multi-line raw strings, no `${}` interpolation.
649
+ - **Booleans, numbers, strings, `null`**.
650
+
651
+ Not supported (and intentionally so): arrow functions, async/await
652
+ (everything is sequenced by the AST executor), classes, modules,
653
+ promises, `try/catch`. Errors thrown from a primitive abort the
654
+ scenario; if the runtime is paused (debugger / MCP step mode), the
655
+ failure is captured for inspection.
656
+
657
+ See `vendor/dsl/EXTENSIONS.md` for the
658
+ exact grammar deltas from the upstream parser.
659
+
660
+ ---
661
+
662
+ ## Conventions
663
+
664
+ - **Snake-case test names** — `function test_login_happy_path()`.
665
+ - **One scenario file = one feature.** Multiple `test_*` entries are
666
+ fine when they share a feature surface.
667
+ - **Helpers stay in `unotest/e2e/_helpers/`** — these are project-owned,
668
+ not part of the core surface. Use sandbox primitives there.
669
+ - **Lint before you ship** — `npx @unotest/web lint` runs the AST
670
+ linter across `unotest/e2e/**/*.js`.