@unotest/web 0.44.0 → 0.46.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.
@@ -158,6 +158,7 @@ Two squiggle sources remain that typings cannot fix, both cosmetic:
158
158
  - [Storage](#storage) — localStorage / cookies
159
159
  - [Edge actions](#edge-actions) — drag, upload, paste
160
160
  - [Sandbox primitives](#sandbox-primitives) — shell / db / HTTP / files
161
+ - [Network mocks](#network-mocks) — what the page's requests get
161
162
  - [Typed getters](#typed-getters) — page properties
162
163
  - [Generators](#generators) — random fixture data + time
163
164
  - [`evaluate`](#evaluate) — escape hatch into the page
@@ -191,6 +192,16 @@ from — write a separate plan if you need them
191
192
  escape sequences, `[...]` is a character class where `/` does not
192
193
  close the literal. No literal line breaks inside a regex body.
193
194
 
195
+ ## The `timeout` option
196
+
197
+ Actions, navigation, waits and assertions take their time limit in
198
+ milliseconds as `timeout` (`timeoutMs` is accepted too, the name the
199
+ newer functions use); giving both in one call is refused.
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
+
194
205
  ---
195
206
 
196
207
  ## Builders
@@ -398,15 +409,54 @@ override per-call with `{timeout}`). Throw `AssertionError` on failure.
398
409
 
399
410
  ### `assertText(loc, expected, options?)`
400
411
 
401
- 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.
402
415
 
403
416
  ```js
404
417
  assertText(getByTestId('success-banner'), 'Saved successfully')
405
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/])
406
440
  ```
407
441
 
408
442
  **Options:** `timeout?`, `exact?` (default `true`).
409
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
+
410
460
  ### `assertVisible(loc, options?)` / `assertHidden(loc, options?)`
411
461
 
412
462
  ```js
@@ -481,6 +531,88 @@ probe = shell('grep', '-q', 'ready', 'app.log', {allowNonZero: true});
481
531
  assertTrue(probe.code == 0, 'app.log should contain "ready"');
482
532
  ```
483
533
 
534
+ ### `assertEqual(actual, expected)`
535
+
536
+ Deep equality, the way JSON sees the two values: key order does not
537
+ count, and a missing key differs from a key set to `null`. The failure
538
+ names the first path that differs and both values, then a line diff of
539
+ the two sides:
540
+
541
+ ```js
542
+ body = parseJson(getAttribute(locator('#state'), 'data-json'));
543
+ assertEqual(body.controls, {theme: 'dark', devices: ['pixel']});
544
+ // assertEqual: values differ at devices[0]: expected "pixel", got "iphone"
545
+ ```
546
+
547
+ ### `assertSchema(value, schema)`
548
+
549
+ Check a value against a JSON Schema (draft 2020-12) — typically one your
550
+ app generates from its own zod schemas (`z.toJSONSchema`) and the scenario
551
+ reads with `readJson`. The failure names every field that breaks the
552
+ schema, by path, with what is wrong there:
553
+
554
+ ```js
555
+ schema = readJson('unotest/fixtures/message.schema.json');
556
+ req = waitForRequest('**/api/messages');
557
+ assertSchema(req.postDataJson, schema);
558
+ // assertSchema: the value breaks the schema in 2 places:
559
+ // /room: must be >= 1
560
+ // /tags/1: must be string
561
+ ```
562
+
563
+ - A `$ref` inside the schema (`#/$defs/tag`) is followed; a `$ref` to
564
+ another file is refused. Put shared parts under `$defs`.
565
+ - A schema that declares another draft in `$schema`, or is itself
566
+ invalid, is refused before any value is checked.
567
+ - `format` (`email`, `uuid`, `date-time`) is not checked.
568
+ - The schema must be an object: pass what `readJson` returned, not the
569
+ file name.
570
+
571
+ ### `skip(reason, condition?)`
572
+
573
+ End the test as **skipped**: it does not apply here — a live-only check
574
+ with no live target configured, data this stand does not have. With the
575
+ condition true, or without one, the run reports skipped with the reason,
576
+ neither passed nor failed; nothing after the call runs, and `teardown(...)`
577
+ blocks registered before it still play. With the condition false the test
578
+ goes on.
579
+
580
+ `skip` is a test guard: it stands in the `test_*` body next to its steps,
581
+ like `step(...)` and `teardown(...)`, not inside one.
582
+
583
+ ```js
584
+ function test_live_payment() {
585
+ skip('LIVE_API is not set: no live target', env('LIVE_API') == null);
586
+ step("Pay on the live API", () => {
587
+ // …
588
+ });
589
+ }
590
+ ```
591
+
592
+ A decision on data the test reads goes after the step that reads it:
593
+
594
+ ```js
595
+ function test_refund() {
596
+ step("Find a paid order", () => {
597
+ orders = apiCall('GET', '/orders?status=paid');
598
+ });
599
+ skip('no paid order on this stand', orders.length == 0);
600
+ // …
601
+ }
602
+ ```
603
+
604
+ - The CLI prints `○ test: … skipped: <reason>` and counts it apart:
605
+ `0 passed, 0 failed, 0 errored, 1 skipped`. The exit code is 0.
606
+ - A collection counts it as `skipped=N(test=N)`.
607
+ - `run_test` answers `outcome: "skipped"` with `skipReason`.
608
+ - A skip after a failed `step.soft(...)` is a failed test, and so is a
609
+ skipped test whose teardown fails.
610
+ - The condition is `true` or `false`; anything else fails the test with
611
+ the value named — compare it: `env('LIVE_API') == null`.
612
+ - Call it at the level of the `test_*` body: inside a step or an `if`
613
+ block, in a helper or a `flow_*`, it is reported by
614
+ `lint:skip-outside-test`.
615
+
484
616
  ### `assertJudge(loc, rubric)`
485
617
 
486
618
  LLM-judge assert for **free-form text** — chat replies, generated
@@ -748,6 +880,33 @@ not just native `:disabled`. This is the wait to reach for instead of a
748
880
  `for` loop around `pause()`: most legacy `pause` calls in a helper layer
749
881
  are surrogates for exactly this.
750
882
 
883
+ ### `waitUntil(() => condition, options?)` → the truthy value
884
+
885
+ Poll a condition no single wait covers — a number crossing a threshold,
886
+ an API answering the way the test needs. The body runs again and again
887
+ until it is truthy; the call gives that value back.
888
+
889
+ ```js
890
+ waitUntil(() => parseJson(textContent(getByTestId('count'))) >= 5);
891
+ job = waitUntil(() => {
892
+ r = apiCall('GET', '/jobs/' + id);
893
+ if (r.body.state != 'done') { return false; }
894
+ return r.body;
895
+ }, {timeoutMs: 30000, intervalMs: 500, label: 'job done'});
896
+ ```
897
+
898
+ - The body is an arrow: an expression, or a block that gives its value
899
+ with `return`. A `return` there ends the wait, not the function
900
+ around it.
901
+ - Options: `{ timeoutMs?, intervalMs?, label? }`. `timeoutMs` defaults
902
+ to the wait timeout of the run (`defaultTimeoutMs`), `intervalMs` to
903
+ 250.
904
+ - Only a falsy value is retried; an error inside the body fails the
905
+ step at once.
906
+ - On timeout the step fails with the label, the number of tries and the
907
+ last value: `waitUntil "job done": still falsy after 30000 ms (60
908
+ tries); last value: false.`
909
+
751
910
  ### `waitForText(text, options?)`
752
911
 
753
912
  Wait until at least one **visible** element on the active page contains
@@ -881,13 +1040,18 @@ An unknown zone or a malformed locale is refused when the config loads.
881
1040
 
882
1041
  ## Multi-context
883
1042
 
884
- ### `setPage(index)`
1043
+ ### `setPage(index, options?)`
885
1044
 
886
1045
  Make tab `index` active. After this, every locator and assertion
887
1046
  targets that tab. Resets the frame stack — a `setPage` always lands on
888
1047
  the new tab's root document, never inside a frame inherited from the
889
1048
  previous tab.
890
1049
 
1050
+ By default the tab also comes to front, as a user would see it.
1051
+ `{activate: false}` switches only the tab the steps act on and leaves
1052
+ the visible tab as is — drive an extension panel in the background
1053
+ while the site stays in front. `bringToFront` shows a tab explicitly.
1054
+
891
1055
  Tabs open asynchronously: a click that triggers `window.open` (often
892
1056
  after a `fetch`) registers the new tab a beat later. `setPage` waits for
893
1057
  the tab at `index` to appear, then for its document to load, before
@@ -909,6 +1073,70 @@ having to track tab indexes. See
909
1073
  `docs/auto-page-switch/README.md`
910
1074
  and `UNOTEST_POPUP_GRACE_MS` in `unotest/.env`.
911
1075
 
1076
+ ```js
1077
+ setPage(1, {activate: false}); // steps act on the panel tab
1078
+ click(getByRole('button', {name: 'Run'}));
1079
+ // tab 0 (the site) is still the one the browser shows
1080
+ ```
1081
+
1082
+ ### `bringToFront(index?)`
1083
+
1084
+ Bring tab `index` to front — the tab the browser shows — without
1085
+ changing the tab the steps act on. No index: the active tab. An index
1086
+ no open tab has fails with `page index out of range`.
1087
+
1088
+ ```js
1089
+ setPage(1, {activate: false}); // steps on tab 1, tab 0 in front
1090
+ bringToFront(); // now tab 1 is shown too
1091
+ bringToFront(0); // show the site again; steps stay on 1
1092
+ ```
1093
+
1094
+ ### `newPage(url?)` → `number`
1095
+
1096
+ Open a new tab in the active browser context, make it active and bring
1097
+ it to front; returns its index. With `url` the tab opens it — a relative
1098
+ path resolves against the base URL, as in `goto`. Without one the tab
1099
+ stays blank. A tab `newPage` opened is the scenario's own: `waitForPage`
1100
+ never returns it.
1101
+
1102
+ ### `closePage(index?)`
1103
+
1104
+ Close tab `index`; no index: the active tab. Closing the active tab
1105
+ makes the tab that was active before it active again (and brings it to
1106
+ front); if that one is closed too, the tab at the index below. Closing
1107
+ another tab leaves the steps where they are — later tabs move down one
1108
+ index. The only open tab cannot be closed: the step fails with
1109
+ `cannot close the only open tab`.
1110
+
1111
+ ```js
1112
+ newPage('/settings'); // tab 1, active
1113
+ assertUrl('/settings');
1114
+ closePage(); // back on tab 0
1115
+ ```
1116
+
1117
+ ### `waitForPage(options?)` → `number`
1118
+
1119
+ Wait for a tab the scenario has not received yet, make it active and
1120
+ return its index. For tabs whose index you do not know up front: one a
1121
+ browser extension opens (`chrome.tabs.create`), one a click opens next
1122
+ to tabs already there. `waitForPage` returns the first tab the scenario
1123
+ has not received yet and waits if there is none. A tab counts as
1124
+ received when it was open as the test started, when `setPage` switched
1125
+ to it, or when an earlier `waitForPage` returned it. So a tab that
1126
+ opened before the call — a click a step earlier — still counts, and no
1127
+ tab is returned twice.
1128
+
1129
+ `{timeoutMs}` defaults to `UNOTEST_DEFAULT_NAVIGATION_TIMEOUT_MS`. With
1130
+ no new tab within it, the step fails with
1131
+ `no new tab within 30000 ms, 1 tab open`.
1132
+
1133
+ ```js
1134
+ click(getByRole('button', {name: 'Open report'}));
1135
+ tab = waitForPage(); // e.g. 1, now active
1136
+ assertUrl('/report');
1137
+ setPage(0);
1138
+ ```
1139
+
912
1140
  ### `newContext()` → `number` / `setContext(index)`
913
1141
 
914
1142
  A second browser context: another client of the same app, with its own
@@ -970,7 +1198,8 @@ own.
970
1198
  - **Traces and video:** not recorded for any context — unotest does not
971
1199
  capture Playwright traces or video today.
972
1200
  - **Browser profile:** with `launch.userDataDir` every page shares one
973
- context, so `newContext()` is refused there.
1201
+ context, and with `launch.profile: 'per-test'` the test is one browser,
1202
+ so `newContext()` is refused in both.
974
1203
  - **Debugging:** an agent that joins a paused run (`take_wheel`,
975
1204
  `attach_debug_session`) lands in the context the run is on (Chromium).
976
1205
  - **Names:** a function or variable of your own called `newContext` /
@@ -999,6 +1228,10 @@ Both fields are required. Anything else is refused on that line,
999
1228
  and changing it would need a new browser context — the page under test
1000
1229
  would be lost. See [Viewport and scale](#viewport-and-scale).
1001
1230
 
1231
+ `setViewport(null)` is refused too: once a size is set on an open page,
1232
+ Playwright keeps it, and nothing hands the page back to the window. For
1233
+ the real window, set `viewport: null` in the config.
1234
+
1002
1235
  #### Viewport and scale
1003
1236
 
1004
1237
  Where the window's size and scale come from, first match wins:
@@ -1020,6 +1253,21 @@ Where the window's size and scale come from, first match wins:
1020
1253
  becomes a 780×1688 PNG, the scale design mockups are usually drawn
1021
1254
  at. Every field is optional; a missing one comes from the preset or
1022
1255
  the default.
1256
+
1257
+ `viewport: null` turns emulation off: the page is as wide as the real
1258
+ browser window, and a resize of the window reaches the page. Headless
1259
+ has no window manager — the size comes from `--window-size` in
1260
+ `launch.args`:
1261
+
1262
+ ```js
1263
+ export default {
1264
+ viewport: null,
1265
+ launch: { args: ["--window-size=1440,900"] },
1266
+ };
1267
+ ```
1268
+
1269
+ A `device` next to `viewport: null` is refused: a preset is a size and
1270
+ a scale of its own.
1023
1271
  3. **A device preset.** `device: "iPhone 14"` applies Playwright's
1024
1272
  preset whole: size, scale, touch, mobile mode, user agent. The name
1025
1273
  is Playwright's, exactly; an unknown name is refused with the
@@ -1050,6 +1298,41 @@ exitFrame();
1050
1298
 
1051
1299
  Pop one iframe scope. Idempotent at depth 0.
1052
1300
 
1301
+ ### Extensions — `waitForServiceWorker` / `evaluateInServiceWorker` / `extensionId`
1302
+
1303
+ The background of a Chrome MV3 extension is a service worker, and
1304
+ `chrome.*` lives there. Extensions load only into a browser started on a
1305
+ profile — `launch.userDataDir` or `launch.profile: 'per-test'`, plus the
1306
+ `--load-extension` args in `unotest.config`; without one every call below
1307
+ fails at once, naming it. With `per-test` every test starts the worker
1308
+ from scratch, on empty storage.
1309
+ Headless needs `channel: "chromium"` (the headless shell ignores
1310
+ `--load-extension`).
1311
+
1312
+ ```js
1313
+ id = extensionId();
1314
+ evaluateInServiceWorker('(theme) => chrome.storage.local.set({theme: theme})', 'dark');
1315
+ stored = evaluateInServiceWorker('() => chrome.storage.local.get("theme")');
1316
+ goto('chrome-extension://' + id + '/popup.html');
1317
+ ```
1318
+
1319
+ - `waitForServiceWorker({timeoutMs?, url?})` → the worker's URL,
1320
+ `chrome-extension://<id>/sw.js`. A running worker comes back at once;
1321
+ otherwise it waits up to `timeoutMs` (default: the navigation timeout).
1322
+ `url` — a substring or `/regex/` when several extensions run.
1323
+ - `evaluateInServiceWorker(js, ...args)` — `evaluate` in the worker, same
1324
+ argument rules (one extra → the value, two or more → ONE array) and
1325
+ JSON return; a returned Promise is awaited. Not discouraged by the
1326
+ linter: `chrome.*` has no other way in.
1327
+ - `extensionId()` — the id out of the worker's URL.
1328
+ - MV3 stops an idle worker after ~30 s. Each call finds the worker anew,
1329
+ so nothing goes stale between steps.
1330
+ - `mockRoute` answers the worker's own `fetch` too, but only on a
1331
+ profile the browser opens for the first time. On a profile it opened
1332
+ before, Playwright does not route the worker's requests, and the
1333
+ worker reaches the network. A scenario that mocks them needs a fresh
1334
+ `launch.userDataDir`.
1335
+
1053
1336
  ---
1054
1337
 
1055
1338
  ## Storage
@@ -1185,6 +1468,37 @@ step("Start the listener", () => {
1185
1468
  });
1186
1469
  ```
1187
1470
 
1471
+ ### `waitForDownload(options?)` → `{ path, name }`
1472
+
1473
+ A file the page downloads — a link with `download`, a
1474
+ `Content-Disposition: attachment` response, a script-made blob. The
1475
+ browser records every download from the moment it opens, so the natural
1476
+ order works: click first, then ask. Returns the first download the
1477
+ scenario has not taken yet, waiting up to `{timeoutMs}` (default
1478
+ `UNOTEST_DEFAULT_NAVIGATION_TIMEOUT_MS`) when there is none. Each
1479
+ download is returned once.
1480
+
1481
+ - `path` — absolute path of the saved copy. Read it with `waitForFile`,
1482
+ `readFile` or `readFileInfo`: the downloads are one of the directories
1483
+ a scenario reads from.
1484
+ - `name` — the file name the site suggested.
1485
+ - `{name}` — substring or regex of the file name. A download that does
1486
+ not match stays for a later call.
1487
+
1488
+ In a run the file is saved with the run, under `download/`, and the
1489
+ viewer lists it under the step as a link. Outside a run (explore) it is
1490
+ saved under `~/.cache/unotest/web/downloads/`. With no download in time
1491
+ the step fails: `no download within 30000 ms`, plus the names of
1492
+ downloads that are there but did not match.
1493
+
1494
+ ```js
1495
+ step("Export the report", () => {
1496
+ click(getByRole('button', {name: 'Export CSV'}));
1497
+ report = waitForDownload({name: /\.csv$/});
1498
+ waitForFile(report.path, 'id,total');
1499
+ });
1500
+ ```
1501
+
1188
1502
  ### `waitForFile(path, pattern?, options?)` → `string`
1189
1503
 
1190
1504
  The file-side analogue of `waitForText` — polls a file until it exists
@@ -1193,6 +1507,27 @@ returns the file content. Relative `path` resolves against
1193
1507
  `sandbox.shellCwd`. `options`: `{timeoutMs}` (default 20 000 ms). On
1194
1508
  expiry the step fails with the file path and the last observed state.
1195
1509
 
1510
+ **Where the file oracles read.** `waitForFile`, `assertNoFile`,
1511
+ `waitForJsonLine`, `readJsonLine`, `assertNoJsonLine`, `waitForFileCount`
1512
+ and `assertFileCount` read by the same rule as `readFile`: a path relative
1513
+ to `sandbox.shellCwd`, or an absolute one inside the project, the run's
1514
+ downloads or a directory listed in `sandbox.readRoots`. Anything else —
1515
+ `../` out of them, an absolute path elsewhere, a symlink that leads out —
1516
+ fails the step with a scenario error naming the path and the directories
1517
+ it left. The rule holds on every read of a wait: a file that appears as a
1518
+ symlink out is refused when it appears, and a file that does not exist
1519
+ yet is judged by the nearest directory above it that does.
1520
+
1521
+ A background process that writes outside the project (a listener's log
1522
+ in `/tmp`) needs its directory in the config:
1523
+
1524
+ ```js
1525
+ // unotest/unotest.config.mjs
1526
+ export default {
1527
+ sandbox: { readRoots: ['/tmp/my-listener'] },
1528
+ };
1529
+ ```
1530
+
1196
1531
  ```js
1197
1532
  step("Wait for the bot reply in the JSONL feed", () => {
1198
1533
  line = waitForFile('events.jsonl', /"type":"reply"/, {timeoutMs: 30000});
@@ -1280,6 +1615,91 @@ step("Ask control question 3", () => {
1280
1615
  background listener's output); its timeout is what makes that safe,
1281
1616
  and what would only hide a wrong path here.
1282
1617
 
1618
+ ### `readJson(path)` → `any`
1619
+
1620
+ Read a whole JSON file once and return the parsed value. Use it for data
1621
+ the scenario must not repeat by hand: constants your app keeps in source
1622
+ (labels, device lists, limits) that a build script exports to JSON, or a
1623
+ fixture object. Walk the result with `.` and `[]`.
1624
+
1625
+ ```js
1626
+ step("Send button uses the app's own label", () => {
1627
+ shared = readJson('unotest/fixtures/shared.json');
1628
+ assertVisible(getByRole('button', {name: shared.chat.labels[0].send}));
1629
+ });
1630
+ ```
1631
+
1632
+ - The path is relative to `sandbox.shellCwd` (the project root by
1633
+ default), the same base `readJsonLine` uses. An absolute path, one
1634
+ that climbs out with `../`, or a symlink that leads out of the project
1635
+ is refused.
1636
+ - No poll: a missing file fails at once with `file not found: <path>`,
1637
+ a file that does not parse with `<path> is not valid JSON: …`.
1638
+ - Scenarios cannot import your app's modules, and that is on purpose:
1639
+ generate the JSON from them instead (see Scenarios, "App constants in
1640
+ scenarios").
1641
+
1642
+ ### `env(name)` → `string | null`
1643
+
1644
+ An environment value the test can do without. It reads what a bare
1645
+ `UPPER_SNAKE` name reads — the environment the run started in, then
1646
+ `unotest/.env` — but returns `null` for a name that is not set instead
1647
+ of failing the step. Use it to decide:
1648
+
1649
+ ```js
1650
+ if (env('LIVE_API') == null) {
1651
+ note('live API', 'not configured, checks run against fixtures');
1652
+ } else {
1653
+ apiCall('GET', env('LIVE_API') + '/health');
1654
+ }
1655
+ ```
1656
+
1657
+ A secret — a name from `unotest/.secrets`, or one a box injects — is
1658
+ refused: `env()` returns a plain string, and a credential must stay
1659
+ masked. Write the secret's bare name where its value is typed instead.
1660
+
1661
+ ### `readFile(path, options?)` → `string`
1662
+
1663
+ Read a whole file once and return its content: the text by default, or
1664
+ the bytes as base64 with `{encoding: 'base64'}`. Use it where a test
1665
+ compares what a file holds: an exported CSV, the first bytes of a
1666
+ download.
1667
+
1668
+ ```js
1669
+ step("Export is a PNG", () => {
1670
+ bytes = readFile('unotest/fixtures/files/image-3x2.png', {encoding: 'base64'});
1671
+ assertTrue(textContains(bytes, 'iVBORw0KGgo'), 'not a PNG signature');
1672
+ });
1673
+ ```
1674
+
1675
+ - `options.encoding`: `'utf8'` (default) or `'base64'`. Any other value
1676
+ or option key fails the step.
1677
+ - The path is relative to `sandbox.shellCwd` (the project root by
1678
+ default), or absolute when it lies inside the project, is a file
1679
+ `waitForDownload()` saved (`readFile(file.path)`) or lies in a directory
1680
+ listed in `sandbox.readRoots`. `../` out of them, an absolute path
1681
+ elsewhere, or a symlink that leads out is refused — also for a file that
1682
+ does not exist yet, under a directory that leads out.
1683
+ - No poll: a missing file fails at once with `file not found: <path>`.
1684
+
1685
+ ### `readFileInfo(path)` → `object`
1686
+
1687
+ What a test asserts about a file without reading it whole, for example
1688
+ a downloaded image (`readFileInfo(waitForDownload().path)`):
1689
+ `{size}` in bytes, plus `png: {width, height}` when the file is a PNG.
1690
+ The dimensions come from the PNG header, so a large image is not read.
1691
+
1692
+ ```js
1693
+ step("Fixture image is 3x2", () => {
1694
+ info = readFileInfo('unotest/fixtures/files/image-3x2.png');
1695
+ assertTrue(info.png.width == 3 && info.png.height == 2, 'size: ' + info.size);
1696
+ });
1697
+ ```
1698
+
1699
+ - A file that is not a PNG has no `png` field.
1700
+ - Same path rules as `readFile`. A missing file fails with
1701
+ `file not found: <path>`; a directory cannot be read and says so.
1702
+
1283
1703
  ### `waitForFileCount(path, pattern, count, options?)` → `number`
1284
1704
 
1285
1705
  Wait until **at least** `count` lines match, then return how many there
@@ -1409,6 +1829,97 @@ assertTrue(res.status == 202, 'ingestion not accepted');
1409
1829
 
1410
1830
  ---
1411
1831
 
1832
+ ## Network mocks
1833
+
1834
+ What the page's requests get, for the rest of the scenario: a fixed
1835
+ response, an abort, or a pass to the network. Masks are Playwright URL
1836
+ globs (`**/api/items*`), matched against every request of the browser
1837
+ context — every tab and frame. A scenario holds no callbacks between
1838
+ steps, so a mock is a fixed answer, not a handler.
1839
+
1840
+ - Where several masks match one request, the one mocked **last**
1841
+ answers. Mocking a mask again replaces its answer and makes it the
1842
+ latest.
1843
+ - Mocks end with the scenario; `unmockRoute` takes them off earlier.
1844
+
1845
+ ### `mockRoute(glob, response)`
1846
+
1847
+ ```js
1848
+ mockRoute('**/room/last-message/load', { body: { text: 'Hi from the mock' } });
1849
+ mockRoute('**/api/items', { status: 503, body: 'down', headers: { 'retry-after': '5' } });
1850
+ mockRoute('**/ads/**', { abort: true });
1851
+ ```
1852
+
1853
+ - `{status?, body?, headers?, contentType?}` — the response. `status`
1854
+ defaults to 200. An object or array `body` goes out as JSON with
1855
+ `contentType: 'application/json'`; a string body as it is.
1856
+ - `{abort: true}` — the request fails, as with no network.
1857
+ `{abort: true, record: true}` also keeps every request it stopped,
1858
+ for `assertNoRequest`.
1859
+ - `{continue: true}` — the request goes to the network.
1860
+ - A fixed response to `**/*` is refused: it would answer the page itself.
1861
+ `abort` and `continue` there are fine.
1862
+ - The page document of the `baseUrl` origin is never aborted, so a
1863
+ navigation to your site still loads. Only the document: its scripts,
1864
+ styles, images and API calls are ordinary requests, and an abort mask
1865
+ that matches them stops them.
1866
+
1867
+ A network allowlist is two masks — the later one wins where both match.
1868
+ The second one lets through everything your app loads from its own
1869
+ origin, not just the page:
1870
+
1871
+ ```js
1872
+ mockRoute('**/*', { abort: true, record: true }); // nothing leaves…
1873
+ mockRoute('**/localhost:*/**', { continue: true }); // …except the app's origin
1874
+ ```
1875
+
1876
+ For a site on a domain, the mask is its origin:
1877
+ `mockRoute('https://app.example.com/**', {continue: true})`.
1878
+
1879
+ ### `unmockRoute(glob?)`
1880
+
1881
+ Takes off the mock at `glob`, or every mock without an argument. What a
1882
+ recording abort kept stays for `assertNoRequest`.
1883
+
1884
+ ### `waitForRequest(glob, options?)` → `{url, method, headers, postData, postDataJson}`
1885
+
1886
+ Waits for a request matching `glob` and returns it. `postData` is the
1887
+ body as sent (null without one); `postDataJson` is that body read as
1888
+ JSON (null when it is not JSON).
1889
+
1890
+ ```js
1891
+ mockRoute('**/api/messages', { body: { ok: true } });
1892
+ click(getByRole('button', { name: 'Send' }));
1893
+ req = waitForRequest('**/api/messages');
1894
+ assertTrue(req.postDataJson.text == 'hello', 'sent: ' + req.postData);
1895
+ ```
1896
+
1897
+ - On a **mocked** mask it also finds a request sent before the call:
1898
+ the mock keeps what it answered, and each request is returned once.
1899
+ On any other mask only requests from the call on count — to catch one
1900
+ the previous step sent, mock the mask with `{continue: true}` first.
1901
+ - `{timeoutMs}` — default 20 000.
1902
+
1903
+ ### `assertNoRequest(glob, options?)`
1904
+
1905
+ Fails if the recording abort at `glob` stopped any request, naming
1906
+ them — a test that got its data from the internet it should not reach
1907
+ fails instead of passing by luck.
1908
+
1909
+ ```js
1910
+ mockRoute('**/*', { abort: true, record: true });
1911
+ mockRoute('**/localhost:*/**', { continue: true });
1912
+ // … the test …
1913
+ assertNoRequest('**/*');
1914
+ ```
1915
+
1916
+ - `glob` is the mask of a `mockRoute(glob, {abort: true, record: true})`.
1917
+ A mask without a recording abort is an error, not a pass: nothing
1918
+ watched it.
1919
+ - `{since}` — epoch ms, as `nowMs()` gives: only requests from then on.
1920
+
1921
+ ---
1922
+
1412
1923
  ## Typed getters
1413
1924
 
1414
1925
  Read page properties directly into a local variable.
@@ -1436,6 +1947,16 @@ source-order concatenation, hidden nodes included.
1436
1947
  Identical to `inputValue`; kept under typed-getters for symmetry with
1437
1948
  agent prompts that prefer the `get*` prefix.
1438
1949
 
1950
+ ### `boundingBox(loc)` → `{x, y, width, height}` | `null`
1951
+
1952
+ Where the element lies in the viewport, CSS pixels. `null` when it is
1953
+ not rendered (`display:none`, detached). Does not scroll it into view.
1954
+
1955
+ ```js
1956
+ box = boundingBox(getByRole('dialog'));
1957
+ assertTrue(box.width <= 480, json(box));
1958
+ ```
1959
+
1439
1960
  ### `getTitle()` → `string`
1440
1961
 
1441
1962
  Active page title.
@@ -1488,6 +2009,44 @@ out = shell('./status.sh');
1488
2009
  assertTrue(textContains(out.stdout, 'ready'), out.stderr);
1489
2010
  ```
1490
2011
 
2012
+ ### `textMatches(text, /re/)` → `false` | `string[]`
2013
+
2014
+ The first match of a regex literal: `false` on a miss, otherwise
2015
+ `[whole, group1, …]`, the array JavaScript's `match` gives. A group that
2016
+ took no part in the match is `null`. The array is truthy, so the call
2017
+ also works as a test.
2018
+
2019
+ ```js
2020
+ m = textMatches(getUrl(), /room=(\d+)/);
2021
+ assertTrue(m, getUrl());
2022
+ roomId = m[1];
2023
+ ```
2024
+
2025
+ A string pattern is refused: for a plain substring use `textContains`.
2026
+ A `g` flag does not change the answer, it is always the first match.
2027
+
2028
+ ### `parseJson(text)` → `any`
2029
+
2030
+ Parse a JSON string into a value you walk with `.` and `[]`: an
2031
+ apiCall body that came back as text, a `data-*` attribute, a stored
2032
+ setting. Invalid JSON fails the step and quotes the input; an empty
2033
+ string is not JSON.
2034
+
2035
+ ```js
2036
+ saved = parseJson(getLocalStorage('messages'));
2037
+ assertEqual(saved[0].type, 'hello');
2038
+ ```
2039
+
2040
+ ### `urlParam(url, key)` → `string` | `null`
2041
+
2042
+ A query parameter, decoded. `null` when the key is absent, `''` when it
2043
+ is present but empty. A path without a host (`/room?id=7`) works too.
2044
+
2045
+ ```js
2046
+ src = getAttribute(locator('iframe#chat'), 'src');
2047
+ assertEqual(urlParam(src, 'roomId'), 'r-7');
2048
+ ```
2049
+
1491
2050
  ### `json(value)` → `string`
1492
2051
 
1493
2052
  Serialize any value to a JSON string. The debug tool for API and data
@@ -1551,6 +2110,15 @@ result = evaluate(
1551
2110
  Backticks are **raw** — `${…}` interpolation is rejected at parse time
1552
2111
  (see `vendor/dsl/EXTENSIONS.md`).
1553
2112
 
2113
+ `evaluateOnLocator(loc, js, ...args)` — the same hatch on one element.
2114
+ The body gets the element first, then the extras by the same rule: one
2115
+ → the value itself, two or more → one array.
2116
+
2117
+ ```js
2118
+ color = evaluateOnLocator(getByRole('alert'), `(el) => getComputedStyle(el).color`);
2119
+ ok = evaluateOnLocator(getByRole('main'), `(el, [a, b]) => el.children.length > a + b`, 1, 2);
2120
+ ```
2121
+
1554
2122
  ---
1555
2123
 
1556
2124
  ## Diagnostics
@@ -1601,6 +2169,62 @@ screenshot('landing', {fullPage: true}); // full scroll height
1601
2169
  Keep explicit `screenshot()` calls for the moments a green run has to
1602
2170
  prove; the flag is for seeing the whole run afterwards.
1603
2171
 
2172
+ ### `consoleEntries(options?)` → `[{ level, text, url }]`
2173
+
2174
+ What the active page wrote to its console, oldest first. The page is
2175
+ recorded from the moment it opened, so the usual order works: act, then
2176
+ ask. An extension's own page (`chrome-extension://…/popup.html`) is a
2177
+ page like any other.
2178
+
2179
+ ```js
2180
+ click(getByRole('button', { name: 'Save' }));
2181
+ errors = consoleEntries({ level: 'error' });
2182
+ assertTrue(errors.length == 0, json(errors));
2183
+
2184
+ before = nowMs();
2185
+ click(getByRole('button', { name: 'Retry' }));
2186
+ assertTrue(consoleEntries({ level: ['warn', 'error'], since: before }).length == 1, 'one complaint');
2187
+ ```
2188
+
2189
+ - `level` of each entry: `log`, `info`, `warn`, `error` or `debug`
2190
+ (`console.table`, `console.dir` and the like read as `log`).
2191
+ - `url` — the page's address when the line was written.
2192
+ - `{level}` — one level or a list of them; an unknown level is refused.
2193
+ - `{since}` — epoch ms, as `nowMs()` gives: only lines from then on.
2194
+ - The last 500 lines of the context are kept, the oldest go first.
2195
+ - A service worker's console is not a page's: it is not here.
2196
+ - Independent of `UNOTEST_CONSOLE`, which decides only whether a run
2197
+ also writes `console.json` into its artifacts and failure bundles.
2198
+
2199
+ ### `pageErrors(options?)` → `[{ message, stack }]`
2200
+
2201
+ Errors the active page threw and nothing caught — an exception out of an
2202
+ event handler, a rejected promise nobody awaited. Oldest first; `stack`
2203
+ is empty when the browser gave none. The last 100 are kept.
2204
+
2205
+ ```js
2206
+ click(getByRole('button', { name: 'Load' }));
2207
+ thrown = pageErrors();
2208
+ assertTrue(thrown.length == 0, json(thrown));
2209
+ ```
2210
+
2211
+ - `{since}` — epoch ms, as `nowMs()` gives.
2212
+
2213
+ ### `assertNoConsoleErrors(options?)`
2214
+
2215
+ Fails when the active page logged console errors, or threw errors
2216
+ nothing caught, quoting the first three and counting the rest.
2217
+
2218
+ ```js
2219
+ goto('/checkout');
2220
+ assertNoConsoleErrors();
2221
+ assertNoConsoleErrors({ includePageErrors: false, since: before });
2222
+ ```
2223
+
2224
+ - `{includePageErrors}` — default `true`: an uncaught error is worse than
2225
+ a logged one. `false` looks at console errors only.
2226
+ - `{since}` — epoch ms, as `nowMs()` gives: only from then on.
2227
+
1604
2228
  ---
1605
2229
 
1606
2230
  ## Logging and notes
@@ -1826,14 +2450,38 @@ together) runs out are cut, and so is whatever a second Stop skips.
1826
2450
  Each such block is reported in the run result as a teardown failure —
1827
2451
  the stand may be left as the test set it.
1828
2452
 
1829
- **The run's own budget is a deadline.** `UNOTEST_MAX_DURATION_MS`
1830
- (default 5 min) ends the test the moment it runs out: an action still
2453
+ **The run's own budget is a deadline.** `testTimeoutMs` in config
2454
+ (default 5 min), `UNOTEST_MAX_DURATION_MS` for one run, or
2455
+ `setTestTimeout(ms)` inside the test ends the test the moment it runs out: an action still
1831
2456
  running is abandoned there, and one that finished past it fails the
1832
2457
  test on that line — the last action included. The abandoned browser
1833
2458
  call lives on until its own timeout or until the context closes; the
1834
2459
  teardown pass runs anyway, on `teardownTimeoutMs`, not on what the test
1835
2460
  left of its budget.
1836
2461
 
2462
+ ### `setTestTimeout(ms)`
2463
+
2464
+ This test's own time budget, counted from its start — Playwright's
2465
+ `test.setTimeout`. It replaces `testTimeoutMs` and
2466
+ `UNOTEST_MAX_DURATION_MS` for the test it runs in, longer or shorter:
2467
+
2468
+ ```js
2469
+ // unotest.config.mjs: testTimeoutMs: 30000 — every test gets 30 s
2470
+ function test_theme_rebuild() {
2471
+ setTestTimeout(90000); // this one rebuilds the theme, give it 90 s
2472
+ // …
2473
+ }
2474
+ ```
2475
+
2476
+ Running out is a failure, not a stop: the test is red — counted as
2477
+ errored (`‼`), like any run budget — with
2478
+ `setTestTimeout(90000) budget exhausted` (or `testTimeoutMs budget
2479
+ (30000ms) exhausted`), the action it cut is abandoned, and the teardown
2480
+ blocks play. Stop in the viewer aborts the run instead — same teardown,
2481
+ but the run reads as stopped, not failed. Call it in the `test_*` body:
2482
+ in a helper or a `flow_*` it is reported by
2483
+ `lint:set-test-timeout-outside-test`.
2484
+
1837
2485
  Rules (lint, `teardown-shape`): the label is a string literal; no
1838
2486
  `return` inside, no `break` out of it (a `break` in a loop inside is
1839
2487
  fine); no `teardown` inside a `teardown`. Allowed in tests, flows and