@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.
- package/.claude/hooks/log-agent.sh +24 -0
- package/.claude/skills/{write-e2e-test.md → write-e2e-test/SKILL.md} +25 -18
- package/CHANGELOG.md +69 -0
- package/README.md +59 -84
- package/bin/unotest-web.js +10 -32
- package/dist/config/schema.d.ts +9 -9
- package/dist/config/schema.js +1 -1
- package/dist/driver/index.js +1 -1
- package/dist/dsl/index.d.ts +25 -20
- package/dist/dsl/index.js +1 -1
- package/dist/linter-DzAzJpab.d.ts +21 -0
- package/dist/mcp/server.js +1 -1
- package/dist/runner/cli.d.ts +10 -1
- package/dist/runner/cli.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/serve-fixture.js +1 -1
- package/dist/runner/web-runner-adapter.js +1 -1
- package/guides/agent-integration.md +297 -0
- package/guides/dsl-reference.md +670 -0
- package/package.json +30 -39
- package/src/mcp/prompts/agent-test-author.md +71 -1
|
@@ -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`.
|