@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.
@@ -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
- Default is exact match. Use `{exact: false}` for substring.
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
- Substring match against the active page's URL.
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`. Useful after a click
736
- that triggers navigation.
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, so `newContext()` is refused there.
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` plus the `--load-extension` args in
1193
- `unotest.config`; without one every call below fails at once, naming it.
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
- which takes absolute paths as they are, or with `readFile` /
1368
- `readFileInfo`.
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 or is a file
1524
- `waitForDownload()` saved (`readFile(file.path)`). `../` out of the
1525
- project, an absolute path elsewhere, or a symlink that leads out is
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.** `UNOTEST_MAX_DURATION_MS`
2242
- (default 5 min) ends the test the moment it runs out: an action still
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.45.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/core": "^0.45.0",
88
- "@unotest/dsl": "^0.45.0",
89
- "@unotest/protocol": "^0.45.0",
90
- "@unotest/viewer": "^0.45.0",
91
- "@unotest/grounder-client": "^0.45.0"
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.45.0",
99
- "@unotest/judge": "^0.45.0"
99
+ "@unotest/grounder": "^0.47.0",
100
+ "@unotest/judge": "^0.47.0"
100
101
  }
101
102
  }