@unotest/web 0.45.0 → 0.47.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/CHANGELOG.md +83 -3
- package/README.md +7 -0
- package/dist/config/schema.d.ts +260 -73
- package/dist/config/schema.js +1 -1
- package/dist/driver/index.d.ts +10 -2
- package/dist/driver/index.js +1 -1
- package/dist/dsl/index.d.ts +24 -2
- package/dist/dsl/index.js +1 -1
- package/dist/dsl/web-dsl-language-service.js +1 -1
- package/dist/inspection/page-inject.d.ts +10 -2
- package/dist/inspection/page-inject.js +12 -0
- package/dist/{interfaces-OvnJKELk.d.ts → interfaces-LE6GsJ0c.d.ts} +64 -3
- package/dist/mcp/server.js +1 -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/dsl-reference.md +416 -17
- package/package.json +9 -8
- package/types/unotest-dsl.d.ts +122 -10
package/guides/dsl-reference.md
CHANGED
|
@@ -198,6 +198,10 @@ Actions, navigation, waits and assertions take their time limit in
|
|
|
198
198
|
milliseconds as `timeout` (`timeoutMs` is accepted too, the name the
|
|
199
199
|
newer functions use); giving both in one call is refused.
|
|
200
200
|
|
|
201
|
+
An option an action, a navigation, a wait or an assertion does not take
|
|
202
|
+
is refused with the ones it does, so a misspelled `{timout: 5000}` fails at once
|
|
203
|
+
instead of waiting the default.
|
|
204
|
+
|
|
201
205
|
---
|
|
202
206
|
|
|
203
207
|
## Builders
|
|
@@ -405,15 +409,54 @@ override per-call with `{timeout}`). Throw `AssertionError` on failure.
|
|
|
405
409
|
|
|
406
410
|
### `assertText(loc, expected, options?)`
|
|
407
411
|
|
|
408
|
-
|
|
412
|
+
`expected` is a string or a `/regex/`. A string must equal the element's
|
|
413
|
+
text; with `{exact: false}` it only has to occur in it. A regex is tested
|
|
414
|
+
against the text as is: `exact` does not anchor it, write `^…$` for that.
|
|
409
415
|
|
|
410
416
|
```js
|
|
411
417
|
assertText(getByTestId('success-banner'), 'Saved successfully')
|
|
412
418
|
assertText(getByRole('heading'), 'Welcome', {exact: false})
|
|
419
|
+
assertText(getByTestId('order-id'), /^#\d{4}$/)
|
|
420
|
+
assertText(getByRole('status'), 'Saving…', {not: true}) // waits until it changes
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
- `{not: true}` inverts the check and waits for it: the step passes as
|
|
424
|
+
soon as the text no longer matches, and fails at the timeout with the
|
|
425
|
+
text that still does.
|
|
426
|
+
- Empty text: `assertText(loc, '')`. Any text at all:
|
|
427
|
+
`assertText(loc, '', {not: true})`.
|
|
428
|
+
|
|
429
|
+
**Options:** `timeout?`, `exact?` (default `true`), `not?`.
|
|
430
|
+
|
|
431
|
+
### `assertTexts(loc, expected, options?)`
|
|
432
|
+
|
|
433
|
+
One entry per element, in order: the locator must match exactly
|
|
434
|
+
`expected.length` elements, and element `i` must match `expected[i]`
|
|
435
|
+
(string or regex, the rules of `assertText`). Polled as a whole, so a
|
|
436
|
+
list that is still filling in passes once it is complete.
|
|
437
|
+
|
|
438
|
+
```js
|
|
439
|
+
assertTexts(getByRole('listitem'), ['Apple', 'Banana', /^Cherry/])
|
|
413
440
|
```
|
|
414
441
|
|
|
415
442
|
**Options:** `timeout?`, `exact?` (default `true`).
|
|
416
443
|
|
|
444
|
+
### `assertAttribute(loc, name, expected, options?)`
|
|
445
|
+
|
|
446
|
+
Polls the attribute `name` until it matches `expected`: a string equals
|
|
447
|
+
it (`{exact: false}` — contains), a regex is tested against it. An
|
|
448
|
+
absent attribute matches nothing; with `{not: true}` the step passes
|
|
449
|
+
once the value stops matching or the attribute is gone.
|
|
450
|
+
|
|
451
|
+
```js
|
|
452
|
+
click(getByRole('button', {name: 'Menu'}));
|
|
453
|
+
assertAttribute(getByRole('button', {name: 'Menu'}), 'aria-expanded', 'true')
|
|
454
|
+
assertAttribute(getByRole('link', {name: 'Docs'}), 'href', /\/docs\//)
|
|
455
|
+
assertAttribute(getByRole('form'), 'aria-busy', 'true', {not: true})
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
**Options:** `timeout?`, `exact?` (default `true`), `not?`.
|
|
459
|
+
|
|
417
460
|
### `assertVisible(loc, options?)` / `assertHidden(loc, options?)`
|
|
418
461
|
|
|
419
462
|
```js
|
|
@@ -472,11 +515,16 @@ assertCount(getByRole('listitem'), 5)
|
|
|
472
515
|
|
|
473
516
|
### `assertUrl(pattern, options?)`
|
|
474
517
|
|
|
475
|
-
|
|
518
|
+
A string is a substring match against the active page's URL. A
|
|
519
|
+
`/regex/` is tested against the whole URL, unanchored: add `^` or `$`
|
|
520
|
+
yourself. A failure names the regex as `/source/flags`.
|
|
521
|
+
|
|
522
|
+
**Options:** `timeout?`.
|
|
476
523
|
|
|
477
524
|
```js
|
|
478
525
|
assertUrl('/dashboard')
|
|
479
526
|
assertUrl('https://example.com/checkout')
|
|
527
|
+
assertUrl(/\/cars\/\d+$/)
|
|
480
528
|
```
|
|
481
529
|
|
|
482
530
|
### `assertTrue(value, message)`
|
|
@@ -501,6 +549,75 @@ assertEqual(body.controls, {theme: 'dark', devices: ['pixel']});
|
|
|
501
549
|
// assertEqual: values differ at devices[0]: expected "pixel", got "iphone"
|
|
502
550
|
```
|
|
503
551
|
|
|
552
|
+
### `assertSchema(value, schema)`
|
|
553
|
+
|
|
554
|
+
Check a value against a JSON Schema (draft 2020-12) — typically one your
|
|
555
|
+
app generates from its own zod schemas (`z.toJSONSchema`) and the scenario
|
|
556
|
+
reads with `readJson`. The failure names every field that breaks the
|
|
557
|
+
schema, by path, with what is wrong there:
|
|
558
|
+
|
|
559
|
+
```js
|
|
560
|
+
schema = readJson('unotest/fixtures/message.schema.json');
|
|
561
|
+
req = waitForRequest('**/api/messages');
|
|
562
|
+
assertSchema(req.postDataJson, schema);
|
|
563
|
+
// assertSchema: the value breaks the schema in 2 places:
|
|
564
|
+
// /room: must be >= 1
|
|
565
|
+
// /tags/1: must be string
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
- A `$ref` inside the schema (`#/$defs/tag`) is followed; a `$ref` to
|
|
569
|
+
another file is refused. Put shared parts under `$defs`.
|
|
570
|
+
- A schema that declares another draft in `$schema`, or is itself
|
|
571
|
+
invalid, is refused before any value is checked.
|
|
572
|
+
- `format` (`email`, `uuid`, `date-time`) is not checked.
|
|
573
|
+
- The schema must be an object: pass what `readJson` returned, not the
|
|
574
|
+
file name.
|
|
575
|
+
|
|
576
|
+
### `skip(reason, condition?)`
|
|
577
|
+
|
|
578
|
+
End the test as **skipped**: it does not apply here — a live-only check
|
|
579
|
+
with no live target configured, data this stand does not have. With the
|
|
580
|
+
condition true, or without one, the run reports skipped with the reason,
|
|
581
|
+
neither passed nor failed; nothing after the call runs, and `teardown(...)`
|
|
582
|
+
blocks registered before it still play. With the condition false the test
|
|
583
|
+
goes on.
|
|
584
|
+
|
|
585
|
+
`skip` is a test guard: it stands in the `test_*` body next to its steps,
|
|
586
|
+
like `step(...)` and `teardown(...)`, not inside one.
|
|
587
|
+
|
|
588
|
+
```js
|
|
589
|
+
function test_live_payment() {
|
|
590
|
+
skip('LIVE_API is not set: no live target', env('LIVE_API') == null);
|
|
591
|
+
step("Pay on the live API", () => {
|
|
592
|
+
// …
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
A decision on data the test reads goes after the step that reads it:
|
|
598
|
+
|
|
599
|
+
```js
|
|
600
|
+
function test_refund() {
|
|
601
|
+
step("Find a paid order", () => {
|
|
602
|
+
orders = apiCall('GET', '/orders?status=paid');
|
|
603
|
+
});
|
|
604
|
+
skip('no paid order on this stand', orders.length == 0);
|
|
605
|
+
// …
|
|
606
|
+
}
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
- The CLI prints `○ test: … skipped: <reason>` and counts it apart:
|
|
610
|
+
`0 passed, 0 failed, 0 errored, 1 skipped`. The exit code is 0.
|
|
611
|
+
- A collection counts it as `skipped=N(test=N)`.
|
|
612
|
+
- `run_test` answers `outcome: "skipped"` with `skipReason`.
|
|
613
|
+
- A skip after a failed `step.soft(...)` is a failed test, and so is a
|
|
614
|
+
skipped test whose teardown fails.
|
|
615
|
+
- The condition is `true` or `false`; anything else fails the test with
|
|
616
|
+
the value named — compare it: `env('LIVE_API') == null`.
|
|
617
|
+
- Call it at the level of the `test_*` body: inside a step or an `if`
|
|
618
|
+
block, in a helper or a `flow_*`, it is reported by
|
|
619
|
+
`lint:skip-outside-test`.
|
|
620
|
+
|
|
504
621
|
### `assertJudge(loc, rubric)`
|
|
505
622
|
|
|
506
623
|
LLM-judge assert for **free-form text** — chat replies, generated
|
|
@@ -732,12 +849,16 @@ goto('/dashboard') // relative — resolved against the
|
|
|
732
849
|
|
|
733
850
|
### `waitForUrl(pattern, options?)`
|
|
734
851
|
|
|
735
|
-
Wait until the active page URL contains `pattern
|
|
736
|
-
that triggers
|
|
852
|
+
Wait until the active page URL contains `pattern` (a string) or matches
|
|
853
|
+
it (a `/regex/`, unanchored). Useful after a click that triggers
|
|
854
|
+
navigation.
|
|
855
|
+
|
|
856
|
+
**Options:** `timeout?`.
|
|
737
857
|
|
|
738
858
|
```js
|
|
739
859
|
click(getByRole('link', {name: 'Settings'}));
|
|
740
860
|
waitForUrl('/settings');
|
|
861
|
+
waitForUrl(/\/orders\/\d+/);
|
|
741
862
|
```
|
|
742
863
|
|
|
743
864
|
### `waitForNavigation(options?)`
|
|
@@ -852,6 +973,23 @@ waitForCount(locator('[data-id]'), 3, {timeout: 90000});
|
|
|
852
973
|
waitForText(/\bshipping\b/i, {timeout: 90000});
|
|
853
974
|
```
|
|
854
975
|
|
|
976
|
+
### `waitForAnimations(loc, options?)`
|
|
977
|
+
|
|
978
|
+
Wait until the element and its descendants have no animation still
|
|
979
|
+
running — a dialog done fading in, a card done sliding — before reading
|
|
980
|
+
or screenshotting it. Infinite animations (spinners) and paused ones are
|
|
981
|
+
not waited for: neither ends by itself, so waiting could only time out.
|
|
982
|
+
A static element returns at once. Same rule as `hasAnimation`.
|
|
983
|
+
|
|
984
|
+
Options: `{ timeout? }` (default 5000 ms). A timeout is an error naming
|
|
985
|
+
the element and how many animations were still running.
|
|
986
|
+
|
|
987
|
+
```js
|
|
988
|
+
click(getByRole('button', {name: 'Edit'}));
|
|
989
|
+
waitForAnimations(getByRole('dialog'));
|
|
990
|
+
assertScreenshot(getByRole('dialog'), 'edit-dialog');
|
|
991
|
+
```
|
|
992
|
+
|
|
855
993
|
### `pause(ms)`
|
|
856
994
|
|
|
857
995
|
Hard sleep. **Avoid** — the linter flags it (`lint:pause-explicit`). Use
|
|
@@ -1086,7 +1224,8 @@ own.
|
|
|
1086
1224
|
- **Traces and video:** not recorded for any context — unotest does not
|
|
1087
1225
|
capture Playwright traces or video today.
|
|
1088
1226
|
- **Browser profile:** with `launch.userDataDir` every page shares one
|
|
1089
|
-
context,
|
|
1227
|
+
context, and with `launch.profile: 'per-test'` the test is one browser,
|
|
1228
|
+
so `newContext()` is refused in both.
|
|
1090
1229
|
- **Debugging:** an agent that joins a paused run (`take_wheel`,
|
|
1091
1230
|
`attach_debug_session`) lands in the context the run is on (Chromium).
|
|
1092
1231
|
- **Names:** a function or variable of your own called `newContext` /
|
|
@@ -1119,6 +1258,27 @@ would be lost. See [Viewport and scale](#viewport-and-scale).
|
|
|
1119
1258
|
Playwright keeps it, and nothing hands the page back to the window. For
|
|
1120
1259
|
the real window, set `viewport: null` in the config.
|
|
1121
1260
|
|
|
1261
|
+
### `emulateMedia({reducedMotion?, colorScheme?})`
|
|
1262
|
+
|
|
1263
|
+
Change the media features the current tab reports, mid-scenario — what
|
|
1264
|
+
`matchMedia` and CSS `@media` see from that moment, without a reload.
|
|
1265
|
+
`reducedMotion`: `'reduce'` | `'no-preference'`; `colorScheme`:
|
|
1266
|
+
`'light'` | `'dark'` | `'no-preference'`. A feature left out keeps its
|
|
1267
|
+
current value. An unknown key, another value or an empty object is
|
|
1268
|
+
refused on that line.
|
|
1269
|
+
|
|
1270
|
+
```js
|
|
1271
|
+
emulateMedia({reducedMotion: 'reduce'}); // the app's no-animation path
|
|
1272
|
+
click(getByRole('button', {name: 'Edit'}));
|
|
1273
|
+
assertScreenshot(getByRole('dialog'), 'edit-dialog-still');
|
|
1274
|
+
```
|
|
1275
|
+
|
|
1276
|
+
It applies to **this tab only**: a tab or popup opened afterwards — even
|
|
1277
|
+
by the same browser context — starts without it; call `emulateMedia`
|
|
1278
|
+
again after `setPage` / `waitForPage`. An app that reads the color
|
|
1279
|
+
scheme once at load sees the change on its next load (`reload()`), not
|
|
1280
|
+
before.
|
|
1281
|
+
|
|
1122
1282
|
#### Viewport and scale
|
|
1123
1283
|
|
|
1124
1284
|
Where the window's size and scale come from, first match wins:
|
|
@@ -1189,8 +1349,10 @@ Pop one iframe scope. Idempotent at depth 0.
|
|
|
1189
1349
|
|
|
1190
1350
|
The background of a Chrome MV3 extension is a service worker, and
|
|
1191
1351
|
`chrome.*` lives there. Extensions load only into a browser started on a
|
|
1192
|
-
profile — `launch.userDataDir`
|
|
1193
|
-
`unotest.config`; without one every call below
|
|
1352
|
+
profile — `launch.userDataDir` or `launch.profile: 'per-test'`, plus the
|
|
1353
|
+
`--load-extension` args in `unotest.config`; without one every call below
|
|
1354
|
+
fails at once, naming it. With `per-test` every test starts the worker
|
|
1355
|
+
from scratch, on empty storage.
|
|
1194
1356
|
Headless needs `channel: "chromium"` (the headless shell ignores
|
|
1195
1357
|
`--load-extension`).
|
|
1196
1358
|
|
|
@@ -1364,8 +1526,8 @@ scenario has not taken yet, waiting up to `{timeoutMs}` (default
|
|
|
1364
1526
|
download is returned once.
|
|
1365
1527
|
|
|
1366
1528
|
- `path` — absolute path of the saved copy. Read it with `waitForFile`,
|
|
1367
|
-
|
|
1368
|
-
|
|
1529
|
+
`readFile` or `readFileInfo`: the downloads are one of the directories
|
|
1530
|
+
a scenario reads from.
|
|
1369
1531
|
- `name` — the file name the site suggested.
|
|
1370
1532
|
- `{name}` — substring or regex of the file name. A download that does
|
|
1371
1533
|
not match stays for a later call.
|
|
@@ -1392,6 +1554,27 @@ returns the file content. Relative `path` resolves against
|
|
|
1392
1554
|
`sandbox.shellCwd`. `options`: `{timeoutMs}` (default 20 000 ms). On
|
|
1393
1555
|
expiry the step fails with the file path and the last observed state.
|
|
1394
1556
|
|
|
1557
|
+
**Where the file oracles read.** `waitForFile`, `assertNoFile`,
|
|
1558
|
+
`waitForJsonLine`, `readJsonLine`, `assertNoJsonLine`, `waitForFileCount`
|
|
1559
|
+
and `assertFileCount` read by the same rule as `readFile`: a path relative
|
|
1560
|
+
to `sandbox.shellCwd`, or an absolute one inside the project, the run's
|
|
1561
|
+
downloads or a directory listed in `sandbox.readRoots`. Anything else —
|
|
1562
|
+
`../` out of them, an absolute path elsewhere, a symlink that leads out —
|
|
1563
|
+
fails the step with a scenario error naming the path and the directories
|
|
1564
|
+
it left. The rule holds on every read of a wait: a file that appears as a
|
|
1565
|
+
symlink out is refused when it appears, and a file that does not exist
|
|
1566
|
+
yet is judged by the nearest directory above it that does.
|
|
1567
|
+
|
|
1568
|
+
A background process that writes outside the project (a listener's log
|
|
1569
|
+
in `/tmp`) needs its directory in the config:
|
|
1570
|
+
|
|
1571
|
+
```js
|
|
1572
|
+
// unotest/unotest.config.mjs
|
|
1573
|
+
export default {
|
|
1574
|
+
sandbox: { readRoots: ['/tmp/my-listener'] },
|
|
1575
|
+
};
|
|
1576
|
+
```
|
|
1577
|
+
|
|
1395
1578
|
```js
|
|
1396
1579
|
step("Wait for the bot reply in the JSONL feed", () => {
|
|
1397
1580
|
line = waitForFile('events.jsonl', /"type":"reply"/, {timeoutMs: 30000});
|
|
@@ -1503,6 +1686,25 @@ step("Send button uses the app's own label", () => {
|
|
|
1503
1686
|
generate the JSON from them instead (see Scenarios, "App constants in
|
|
1504
1687
|
scenarios").
|
|
1505
1688
|
|
|
1689
|
+
### `env(name)` → `string | null`
|
|
1690
|
+
|
|
1691
|
+
An environment value the test can do without. It reads what a bare
|
|
1692
|
+
`UPPER_SNAKE` name reads — the environment the run started in, then
|
|
1693
|
+
`unotest/.env` — but returns `null` for a name that is not set instead
|
|
1694
|
+
of failing the step. Use it to decide:
|
|
1695
|
+
|
|
1696
|
+
```js
|
|
1697
|
+
if (env('LIVE_API') == null) {
|
|
1698
|
+
note('live API', 'not configured, checks run against fixtures');
|
|
1699
|
+
} else {
|
|
1700
|
+
apiCall('GET', env('LIVE_API') + '/health');
|
|
1701
|
+
}
|
|
1702
|
+
```
|
|
1703
|
+
|
|
1704
|
+
A secret — a name from `unotest/.secrets`, or one a box injects — is
|
|
1705
|
+
refused: `env()` returns a plain string, and a credential must stay
|
|
1706
|
+
masked. Write the secret's bare name where its value is typed instead.
|
|
1707
|
+
|
|
1506
1708
|
### `readFile(path, options?)` → `string`
|
|
1507
1709
|
|
|
1508
1710
|
Read a whole file once and return its content: the text by default, or
|
|
@@ -1520,10 +1722,11 @@ step("Export is a PNG", () => {
|
|
|
1520
1722
|
- `options.encoding`: `'utf8'` (default) or `'base64'`. Any other value
|
|
1521
1723
|
or option key fails the step.
|
|
1522
1724
|
- The path is relative to `sandbox.shellCwd` (the project root by
|
|
1523
|
-
default), or absolute when it lies inside the project
|
|
1524
|
-
`waitForDownload()` saved (`readFile(file.path)`)
|
|
1525
|
-
|
|
1526
|
-
refused
|
|
1725
|
+
default), or absolute when it lies inside the project, is a file
|
|
1726
|
+
`waitForDownload()` saved (`readFile(file.path)`) or lies in a directory
|
|
1727
|
+
listed in `sandbox.readRoots`. `../` out of them, an absolute path
|
|
1728
|
+
elsewhere, or a symlink that leads out is refused — also for a file that
|
|
1729
|
+
does not exist yet, under a directory that leads out.
|
|
1527
1730
|
- No poll: a missing file fails at once with `file not found: <path>`.
|
|
1528
1731
|
|
|
1529
1732
|
### `readFileInfo(path)` → `object`
|
|
@@ -1707,6 +1910,10 @@ mockRoute('**/ads/**', { abort: true });
|
|
|
1707
1910
|
navigation to your site still loads. Only the document: its scripts,
|
|
1708
1911
|
styles, images and API calls are ordinary requests, and an abort mask
|
|
1709
1912
|
that matches them stops them.
|
|
1913
|
+
- A mask reaches web requests only: http(s) and ws(s). An extension's
|
|
1914
|
+
own pages and scripts (`chrome-extension://`) load under `**/*`, so
|
|
1915
|
+
a network allowlist does not leave its UI dead. To mock one of them,
|
|
1916
|
+
start the mask with its scheme: `chrome-extension://<id>/panel.js`.
|
|
1710
1917
|
|
|
1711
1918
|
A network allowlist is two masks — the later one wins where both match.
|
|
1712
1919
|
The second one lets through everything your app loads from its own
|
|
@@ -1801,6 +2008,29 @@ box = boundingBox(getByRole('dialog'));
|
|
|
1801
2008
|
assertTrue(box.width <= 480, json(box));
|
|
1802
2009
|
```
|
|
1803
2010
|
|
|
2011
|
+
### `computedStyle(loc, property)` → `string`
|
|
2012
|
+
|
|
2013
|
+
The element's computed CSS value, as the browser resolved it — what
|
|
2014
|
+
`getComputedStyle(el).getPropertyValue(property)` gives, trimmed.
|
|
2015
|
+
Custom properties read too, inherited ones included; a property that is
|
|
2016
|
+
not set reads `""`. A missing or ambiguous element fails as
|
|
2017
|
+
`getAttribute` does.
|
|
2018
|
+
|
|
2019
|
+
```js
|
|
2020
|
+
assertTrue(computedStyle(getByRole('dialog'), 'opacity') == 1, 'dialog is faded');
|
|
2021
|
+
accent = computedStyle(locator('body'), '--accent');
|
|
2022
|
+
```
|
|
2023
|
+
|
|
2024
|
+
### `hasAnimation(loc)` → `boolean`
|
|
2025
|
+
|
|
2026
|
+
Whether the element or a descendant has an animation still running
|
|
2027
|
+
towards an end. Infinite (spinners) and paused animations do not count —
|
|
2028
|
+
the rule `waitForAnimations` waits by.
|
|
2029
|
+
|
|
2030
|
+
```js
|
|
2031
|
+
assertTrue(hasAnimation(getByRole('dialog')) === false, 'dialog still moving');
|
|
2032
|
+
```
|
|
2033
|
+
|
|
1804
2034
|
### `getTitle()` → `string`
|
|
1805
2035
|
|
|
1806
2036
|
Active page title.
|
|
@@ -1891,6 +2121,60 @@ src = getAttribute(locator('iframe#chat'), 'src');
|
|
|
1891
2121
|
assertEqual(urlParam(src, 'roomId'), 'r-7');
|
|
1892
2122
|
```
|
|
1893
2123
|
|
|
2124
|
+
### `toNumber(text, options?)` → `number`
|
|
2125
|
+
|
|
2126
|
+
A number as a page writes it, for arithmetic and comparison. What
|
|
2127
|
+
`parseInt`, `parseFloat` and `Number` would be in JS.
|
|
2128
|
+
|
|
2129
|
+
- Spaces group digits, NBSP and the narrow NBSP too: `'1 234 567'`.
|
|
2130
|
+
- A leading sign: `-`, `+` or the typographic minus `−`.
|
|
2131
|
+
- With both `,` and `.`, the last one is the decimal mark:
|
|
2132
|
+
`'1,234.56'` and `'1.234,56'` are both 1234.56. One mark used twice
|
|
2133
|
+
groups: `'1,234,567'`. One mark used once is the decimal mark:
|
|
2134
|
+
`'12,5'`, `'0.125'`.
|
|
2135
|
+
- `'1,234'` and `'1.234'` read both ways (1234 or 1.234), so they fail
|
|
2136
|
+
the step unless `{decimal: ','}` or `{decimal: '.'}` says which. With
|
|
2137
|
+
`{decimal}` the other mark may only group digits left of it.
|
|
2138
|
+
- Anything else fails the step and quotes the input: units and
|
|
2139
|
+
currency (`'440px'`, `'€12'`), exponents, an empty string. Cut the
|
|
2140
|
+
number out first with `textMatches`.
|
|
2141
|
+
- A number passes through unchanged.
|
|
2142
|
+
|
|
2143
|
+
```js
|
|
2144
|
+
total = toNumber(getInnerText(getByTestId('total'))); // "1 234,50"
|
|
2145
|
+
assertTrue(total > 1000, 'total: ' + total);
|
|
2146
|
+
width = toNumber(textMatches(computedStyle(phone, '--phone-w'), /(\d+)px/)[1]);
|
|
2147
|
+
```
|
|
2148
|
+
|
|
2149
|
+
### `round(n, digits?)` → `number`
|
|
2150
|
+
|
|
2151
|
+
Round to `digits` decimals, 0 to 10, default 0. Half goes away from
|
|
2152
|
+
zero, on the decimal as written: `round(2.5)` is 3, `round(-2.5)` is
|
|
2153
|
+
-3, `round(1.005, 2)` is 1.01. A string fails: read it with
|
|
2154
|
+
`toNumber` first.
|
|
2155
|
+
|
|
2156
|
+
```js
|
|
2157
|
+
payout = round(stake * odds, 2);
|
|
2158
|
+
```
|
|
2159
|
+
|
|
2160
|
+
### `formatNumber(n, options?)` → `string`
|
|
2161
|
+
|
|
2162
|
+
A number as text, with digit grouping, through `Intl.NumberFormat`.
|
|
2163
|
+
|
|
2164
|
+
**Options:** `locale?` (default `'en-US'`), `minFractionDigits?`,
|
|
2165
|
+
`maxFractionDigits?` (0 to 20; without them, up to 3 decimals).
|
|
2166
|
+
|
|
2167
|
+
- Without a locale the answer is the same on every machine, whatever
|
|
2168
|
+
its system language: `formatNumber(1234.5)` is `'1,234.5'`.
|
|
2169
|
+
- With another locale the text comes from the ICU data of the Node
|
|
2170
|
+
that runs the scenario, spaces included (`fr-FR` puts a narrow NBSP
|
|
2171
|
+
between thousands). Compare it with what the page shows rather than
|
|
2172
|
+
with a string typed in the scenario.
|
|
2173
|
+
|
|
2174
|
+
```js
|
|
2175
|
+
assertText(getByTestId('payout'), formatNumber(payout, {minFractionDigits: 2, maxFractionDigits: 2}));
|
|
2176
|
+
```
|
|
2177
|
+
|
|
1894
2178
|
### `json(value)` → `string`
|
|
1895
2179
|
|
|
1896
2180
|
Serialize any value to a JSON string. The debug tool for API and data
|
|
@@ -1959,10 +2243,20 @@ The body gets the element first, then the extras by the same rule: one
|
|
|
1959
2243
|
→ the value itself, two or more → one array.
|
|
1960
2244
|
|
|
1961
2245
|
```js
|
|
1962
|
-
color = evaluateOnLocator(getByRole('alert'), `(el) => getComputedStyle(el).color`);
|
|
1963
2246
|
ok = evaluateOnLocator(getByRole('main'), `(el, [a, b]) => el.children.length > a + b`, 1, 2);
|
|
1964
2247
|
```
|
|
1965
2248
|
|
|
2249
|
+
Reach for it only for what no typed call reads — a style is
|
|
2250
|
+
`computedStyle`, a box is `boundingBox`. Overflow is one such case: is
|
|
2251
|
+
the text cut off? There is no getter for `scrollWidth`, so the line says
|
|
2252
|
+
why it uses the hatch, and the linter takes the reason:
|
|
2253
|
+
|
|
2254
|
+
```js
|
|
2255
|
+
// Truncated with an ellipsis: the content is wider than the cell.
|
|
2256
|
+
overflows = evaluateOnLocator(getByRole('cell', {name: /^R-00001/}), `(el) => el.scrollWidth > el.clientWidth`); // lint-ok: no typed getter reads scrollWidth
|
|
2257
|
+
assertTrue(overflows === false, 'the id is cut off');
|
|
2258
|
+
```
|
|
2259
|
+
|
|
1966
2260
|
---
|
|
1967
2261
|
|
|
1968
2262
|
## Diagnostics
|
|
@@ -2013,6 +2307,62 @@ screenshot('landing', {fullPage: true}); // full scroll height
|
|
|
2013
2307
|
Keep explicit `screenshot()` calls for the moments a green run has to
|
|
2014
2308
|
prove; the flag is for seeing the whole run afterwards.
|
|
2015
2309
|
|
|
2310
|
+
### `consoleEntries(options?)` → `[{ level, text, url }]`
|
|
2311
|
+
|
|
2312
|
+
What the active page wrote to its console, oldest first. The page is
|
|
2313
|
+
recorded from the moment it opened, so the usual order works: act, then
|
|
2314
|
+
ask. An extension's own page (`chrome-extension://…/popup.html`) is a
|
|
2315
|
+
page like any other.
|
|
2316
|
+
|
|
2317
|
+
```js
|
|
2318
|
+
click(getByRole('button', { name: 'Save' }));
|
|
2319
|
+
errors = consoleEntries({ level: 'error' });
|
|
2320
|
+
assertTrue(errors.length == 0, json(errors));
|
|
2321
|
+
|
|
2322
|
+
before = nowMs();
|
|
2323
|
+
click(getByRole('button', { name: 'Retry' }));
|
|
2324
|
+
assertTrue(consoleEntries({ level: ['warn', 'error'], since: before }).length == 1, 'one complaint');
|
|
2325
|
+
```
|
|
2326
|
+
|
|
2327
|
+
- `level` of each entry: `log`, `info`, `warn`, `error` or `debug`
|
|
2328
|
+
(`console.table`, `console.dir` and the like read as `log`).
|
|
2329
|
+
- `url` — the page's address when the line was written.
|
|
2330
|
+
- `{level}` — one level or a list of them; an unknown level is refused.
|
|
2331
|
+
- `{since}` — epoch ms, as `nowMs()` gives: only lines from then on.
|
|
2332
|
+
- The last 500 lines of the context are kept, the oldest go first.
|
|
2333
|
+
- A service worker's console is not a page's: it is not here.
|
|
2334
|
+
- Independent of `UNOTEST_CONSOLE`, which decides only whether a run
|
|
2335
|
+
also writes `console.json` into its artifacts and failure bundles.
|
|
2336
|
+
|
|
2337
|
+
### `pageErrors(options?)` → `[{ message, stack }]`
|
|
2338
|
+
|
|
2339
|
+
Errors the active page threw and nothing caught — an exception out of an
|
|
2340
|
+
event handler, a rejected promise nobody awaited. Oldest first; `stack`
|
|
2341
|
+
is empty when the browser gave none. The last 100 are kept.
|
|
2342
|
+
|
|
2343
|
+
```js
|
|
2344
|
+
click(getByRole('button', { name: 'Load' }));
|
|
2345
|
+
thrown = pageErrors();
|
|
2346
|
+
assertTrue(thrown.length == 0, json(thrown));
|
|
2347
|
+
```
|
|
2348
|
+
|
|
2349
|
+
- `{since}` — epoch ms, as `nowMs()` gives.
|
|
2350
|
+
|
|
2351
|
+
### `assertNoConsoleErrors(options?)`
|
|
2352
|
+
|
|
2353
|
+
Fails when the active page logged console errors, or threw errors
|
|
2354
|
+
nothing caught, quoting the first three and counting the rest.
|
|
2355
|
+
|
|
2356
|
+
```js
|
|
2357
|
+
goto('/checkout');
|
|
2358
|
+
assertNoConsoleErrors();
|
|
2359
|
+
assertNoConsoleErrors({ includePageErrors: false, since: before });
|
|
2360
|
+
```
|
|
2361
|
+
|
|
2362
|
+
- `{includePageErrors}` — default `true`: an uncaught error is worse than
|
|
2363
|
+
a logged one. `false` looks at console errors only.
|
|
2364
|
+
- `{since}` — epoch ms, as `nowMs()` gives: only from then on.
|
|
2365
|
+
|
|
2016
2366
|
---
|
|
2017
2367
|
|
|
2018
2368
|
## Logging and notes
|
|
@@ -2103,7 +2453,8 @@ The DSL is JavaScript-shaped but parsed by a small AST engine
|
|
|
2103
2453
|
parse).
|
|
2104
2454
|
- **Method chains** — `getByRole('row').filter({hasText:'X'}).click()`.
|
|
2105
2455
|
- **Backticks** — multi-line raw strings, no `${}` interpolation.
|
|
2106
|
-
- **Booleans, numbers, strings, `null
|
|
2456
|
+
- **Booleans, numbers, strings, `null`** — escapes in strings: see
|
|
2457
|
+
[Escape rules](#escape-rules).
|
|
2107
2458
|
- **Comparison operators** — `==` / `!=` (loose: `"1" == 1` is true,
|
|
2108
2459
|
numeric strings coerce), `===` / `!==` (strict: type AND value must
|
|
2109
2460
|
match, `1 === "1"` is false), `<` `<=` `>` `>=`, logical `&&` / `||`.
|
|
@@ -2124,6 +2475,30 @@ inspection.
|
|
|
2124
2475
|
See `vendor/dsl/EXTENSIONS.md` for the
|
|
2125
2476
|
exact grammar deltas from the upstream parser.
|
|
2126
2477
|
|
|
2478
|
+
### Escape rules
|
|
2479
|
+
|
|
2480
|
+
In a quoted string (`'…'` or `"…"`):
|
|
2481
|
+
|
|
2482
|
+
| Escape | Gives |
|
|
2483
|
+
|---|---|
|
|
2484
|
+
| `\n` `\t` `\r` `\f` `\v` | the control character |
|
|
2485
|
+
| `\\` | one backslash |
|
|
2486
|
+
| `\'` in `'…'`, `\"` in `"…"` | the quote |
|
|
2487
|
+
| `\0` (no digit after it) | the NUL character |
|
|
2488
|
+
| `\xHH` | the character with that hex code: `"\xA0"` is a non-breaking space |
|
|
2489
|
+
| `\uXXXX` | the same with four hex digits: `'\u00A0'` |
|
|
2490
|
+
|
|
2491
|
+
A malformed `\x` / `\u` (`'\u12'`, `'\xG1'`) is a parse error at the
|
|
2492
|
+
backslash. Any other backslash pair stays **as written** — `'\d+'` is
|
|
2493
|
+
three characters, which is what a regex built from a string needs. The
|
|
2494
|
+
same goes for `\b`: a word boundary there, never a backspace. `\01` and
|
|
2495
|
+
the other quote (`"\'"`) are kept as written too. Backtick strings are raw: no
|
|
2496
|
+
escape decodes inside `` `…` ``.
|
|
2497
|
+
|
|
2498
|
+
```js
|
|
2499
|
+
assertText(getByRole('cell', {name: /^Total/}), '1\u00A0250'); // NBSP between digit groups
|
|
2500
|
+
```
|
|
2501
|
+
|
|
2127
2502
|
### Strings, objects, indexes
|
|
2128
2503
|
|
|
2129
2504
|
Since 0.44.0.
|
|
@@ -2238,14 +2613,38 @@ together) runs out are cut, and so is whatever a second Stop skips.
|
|
|
2238
2613
|
Each such block is reported in the run result as a teardown failure —
|
|
2239
2614
|
the stand may be left as the test set it.
|
|
2240
2615
|
|
|
2241
|
-
**The run's own budget is a deadline.** `
|
|
2242
|
-
(default 5 min)
|
|
2616
|
+
**The run's own budget is a deadline.** `testTimeoutMs` in config
|
|
2617
|
+
(default 5 min), `UNOTEST_MAX_DURATION_MS` for one run, or
|
|
2618
|
+
`setTestTimeout(ms)` inside the test ends the test the moment it runs out: an action still
|
|
2243
2619
|
running is abandoned there, and one that finished past it fails the
|
|
2244
2620
|
test on that line — the last action included. The abandoned browser
|
|
2245
2621
|
call lives on until its own timeout or until the context closes; the
|
|
2246
2622
|
teardown pass runs anyway, on `teardownTimeoutMs`, not on what the test
|
|
2247
2623
|
left of its budget.
|
|
2248
2624
|
|
|
2625
|
+
### `setTestTimeout(ms)`
|
|
2626
|
+
|
|
2627
|
+
This test's own time budget, counted from its start — Playwright's
|
|
2628
|
+
`test.setTimeout`. It replaces `testTimeoutMs` and
|
|
2629
|
+
`UNOTEST_MAX_DURATION_MS` for the test it runs in, longer or shorter:
|
|
2630
|
+
|
|
2631
|
+
```js
|
|
2632
|
+
// unotest.config.mjs: testTimeoutMs: 30000 — every test gets 30 s
|
|
2633
|
+
function test_theme_rebuild() {
|
|
2634
|
+
setTestTimeout(90000); // this one rebuilds the theme, give it 90 s
|
|
2635
|
+
// …
|
|
2636
|
+
}
|
|
2637
|
+
```
|
|
2638
|
+
|
|
2639
|
+
Running out is a failure, not a stop: the test is red — counted as
|
|
2640
|
+
errored (`‼`), like any run budget — with
|
|
2641
|
+
`setTestTimeout(90000) budget exhausted` (or `testTimeoutMs budget
|
|
2642
|
+
(30000ms) exhausted`), the action it cut is abandoned, and the teardown
|
|
2643
|
+
blocks play. Stop in the viewer aborts the run instead — same teardown,
|
|
2644
|
+
but the run reads as stopped, not failed. Call it in the `test_*` body:
|
|
2645
|
+
in a helper or a `flow_*` it is reported by
|
|
2646
|
+
`lint:set-test-timeout-outside-test`.
|
|
2647
|
+
|
|
2249
2648
|
Rules (lint, `teardown-shape`): the label is a string literal; no
|
|
2250
2649
|
`return` inside, no `break` out of it (a `break` in a loop inside is
|
|
2251
2650
|
fine); no `teardown` inside a `teardown`. Allowed in tests, flows and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unotest/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.0",
|
|
4
4
|
"description": "AI-native E2E testing for web applications. MCP server (run_test / step / resume / inspect_runtime / agent_fix) + CLI runner + JavaScript DSL scenarios on a sandboxed AST engine + semantic DOM snapshots.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
"dependencies": {
|
|
78
78
|
"@faker-js/faker": "^10.5.0",
|
|
79
79
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
80
|
+
"ajv": "^8.20.0",
|
|
80
81
|
"chokidar": "^4.0.3",
|
|
81
82
|
"dotenv": "^16.4.5",
|
|
82
83
|
"playwright": "1.62.1",
|
|
@@ -84,18 +85,18 @@
|
|
|
84
85
|
"tsx": "^4.19.2",
|
|
85
86
|
"yaml": "^2.6.0",
|
|
86
87
|
"zod": "^3.23.8",
|
|
87
|
-
"@unotest/
|
|
88
|
-
"@unotest/dsl": "^0.
|
|
89
|
-
"@unotest/
|
|
90
|
-
"@unotest/
|
|
91
|
-
"@unotest/
|
|
88
|
+
"@unotest/grounder-client": "^0.47.0",
|
|
89
|
+
"@unotest/dsl": "^0.47.0",
|
|
90
|
+
"@unotest/core": "^0.47.0",
|
|
91
|
+
"@unotest/protocol": "^0.47.0",
|
|
92
|
+
"@unotest/viewer": "^0.47.0"
|
|
92
93
|
},
|
|
93
94
|
"devDependencies": {
|
|
94
95
|
"@types/node": "^22.10.0",
|
|
95
96
|
"javascript-obfuscator": "^4.1.1",
|
|
96
97
|
"tsup": "^8.5.1",
|
|
97
98
|
"typescript": "^5.7.2",
|
|
98
|
-
"@unotest/grounder": "^0.
|
|
99
|
-
"@unotest/judge": "^0.
|
|
99
|
+
"@unotest/grounder": "^0.47.0",
|
|
100
|
+
"@unotest/judge": "^0.47.0"
|
|
100
101
|
}
|
|
101
102
|
}
|