@unotest/web 0.44.0 → 0.45.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,12 @@ 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
+
194
201
  ---
195
202
 
196
203
  ## Builders
@@ -481,6 +488,19 @@ probe = shell('grep', '-q', 'ready', 'app.log', {allowNonZero: true});
481
488
  assertTrue(probe.code == 0, 'app.log should contain "ready"');
482
489
  ```
483
490
 
491
+ ### `assertEqual(actual, expected)`
492
+
493
+ Deep equality, the way JSON sees the two values: key order does not
494
+ count, and a missing key differs from a key set to `null`. The failure
495
+ names the first path that differs and both values, then a line diff of
496
+ the two sides:
497
+
498
+ ```js
499
+ body = parseJson(getAttribute(locator('#state'), 'data-json'));
500
+ assertEqual(body.controls, {theme: 'dark', devices: ['pixel']});
501
+ // assertEqual: values differ at devices[0]: expected "pixel", got "iphone"
502
+ ```
503
+
484
504
  ### `assertJudge(loc, rubric)`
485
505
 
486
506
  LLM-judge assert for **free-form text** — chat replies, generated
@@ -748,6 +768,33 @@ not just native `:disabled`. This is the wait to reach for instead of a
748
768
  `for` loop around `pause()`: most legacy `pause` calls in a helper layer
749
769
  are surrogates for exactly this.
750
770
 
771
+ ### `waitUntil(() => condition, options?)` → the truthy value
772
+
773
+ Poll a condition no single wait covers — a number crossing a threshold,
774
+ an API answering the way the test needs. The body runs again and again
775
+ until it is truthy; the call gives that value back.
776
+
777
+ ```js
778
+ waitUntil(() => parseJson(textContent(getByTestId('count'))) >= 5);
779
+ job = waitUntil(() => {
780
+ r = apiCall('GET', '/jobs/' + id);
781
+ if (r.body.state != 'done') { return false; }
782
+ return r.body;
783
+ }, {timeoutMs: 30000, intervalMs: 500, label: 'job done'});
784
+ ```
785
+
786
+ - The body is an arrow: an expression, or a block that gives its value
787
+ with `return`. A `return` there ends the wait, not the function
788
+ around it.
789
+ - Options: `{ timeoutMs?, intervalMs?, label? }`. `timeoutMs` defaults
790
+ to the wait timeout of the run (`defaultTimeoutMs`), `intervalMs` to
791
+ 250.
792
+ - Only a falsy value is retried; an error inside the body fails the
793
+ step at once.
794
+ - On timeout the step fails with the label, the number of tries and the
795
+ last value: `waitUntil "job done": still falsy after 30000 ms (60
796
+ tries); last value: false.`
797
+
751
798
  ### `waitForText(text, options?)`
752
799
 
753
800
  Wait until at least one **visible** element on the active page contains
@@ -881,13 +928,18 @@ An unknown zone or a malformed locale is refused when the config loads.
881
928
 
882
929
  ## Multi-context
883
930
 
884
- ### `setPage(index)`
931
+ ### `setPage(index, options?)`
885
932
 
886
933
  Make tab `index` active. After this, every locator and assertion
887
934
  targets that tab. Resets the frame stack — a `setPage` always lands on
888
935
  the new tab's root document, never inside a frame inherited from the
889
936
  previous tab.
890
937
 
938
+ By default the tab also comes to front, as a user would see it.
939
+ `{activate: false}` switches only the tab the steps act on and leaves
940
+ the visible tab as is — drive an extension panel in the background
941
+ while the site stays in front. `bringToFront` shows a tab explicitly.
942
+
891
943
  Tabs open asynchronously: a click that triggers `window.open` (often
892
944
  after a `fetch`) registers the new tab a beat later. `setPage` waits for
893
945
  the tab at `index` to appear, then for its document to load, before
@@ -909,6 +961,70 @@ having to track tab indexes. See
909
961
  `docs/auto-page-switch/README.md`
910
962
  and `UNOTEST_POPUP_GRACE_MS` in `unotest/.env`.
911
963
 
964
+ ```js
965
+ setPage(1, {activate: false}); // steps act on the panel tab
966
+ click(getByRole('button', {name: 'Run'}));
967
+ // tab 0 (the site) is still the one the browser shows
968
+ ```
969
+
970
+ ### `bringToFront(index?)`
971
+
972
+ Bring tab `index` to front — the tab the browser shows — without
973
+ changing the tab the steps act on. No index: the active tab. An index
974
+ no open tab has fails with `page index out of range`.
975
+
976
+ ```js
977
+ setPage(1, {activate: false}); // steps on tab 1, tab 0 in front
978
+ bringToFront(); // now tab 1 is shown too
979
+ bringToFront(0); // show the site again; steps stay on 1
980
+ ```
981
+
982
+ ### `newPage(url?)` → `number`
983
+
984
+ Open a new tab in the active browser context, make it active and bring
985
+ it to front; returns its index. With `url` the tab opens it — a relative
986
+ path resolves against the base URL, as in `goto`. Without one the tab
987
+ stays blank. A tab `newPage` opened is the scenario's own: `waitForPage`
988
+ never returns it.
989
+
990
+ ### `closePage(index?)`
991
+
992
+ Close tab `index`; no index: the active tab. Closing the active tab
993
+ makes the tab that was active before it active again (and brings it to
994
+ front); if that one is closed too, the tab at the index below. Closing
995
+ another tab leaves the steps where they are — later tabs move down one
996
+ index. The only open tab cannot be closed: the step fails with
997
+ `cannot close the only open tab`.
998
+
999
+ ```js
1000
+ newPage('/settings'); // tab 1, active
1001
+ assertUrl('/settings');
1002
+ closePage(); // back on tab 0
1003
+ ```
1004
+
1005
+ ### `waitForPage(options?)` → `number`
1006
+
1007
+ Wait for a tab the scenario has not received yet, make it active and
1008
+ return its index. For tabs whose index you do not know up front: one a
1009
+ browser extension opens (`chrome.tabs.create`), one a click opens next
1010
+ to tabs already there. `waitForPage` returns the first tab the scenario
1011
+ has not received yet and waits if there is none. A tab counts as
1012
+ received when it was open as the test started, when `setPage` switched
1013
+ to it, or when an earlier `waitForPage` returned it. So a tab that
1014
+ opened before the call — a click a step earlier — still counts, and no
1015
+ tab is returned twice.
1016
+
1017
+ `{timeoutMs}` defaults to `UNOTEST_DEFAULT_NAVIGATION_TIMEOUT_MS`. With
1018
+ no new tab within it, the step fails with
1019
+ `no new tab within 30000 ms, 1 tab open`.
1020
+
1021
+ ```js
1022
+ click(getByRole('button', {name: 'Open report'}));
1023
+ tab = waitForPage(); // e.g. 1, now active
1024
+ assertUrl('/report');
1025
+ setPage(0);
1026
+ ```
1027
+
912
1028
  ### `newContext()` → `number` / `setContext(index)`
913
1029
 
914
1030
  A second browser context: another client of the same app, with its own
@@ -999,6 +1115,10 @@ Both fields are required. Anything else is refused on that line,
999
1115
  and changing it would need a new browser context — the page under test
1000
1116
  would be lost. See [Viewport and scale](#viewport-and-scale).
1001
1117
 
1118
+ `setViewport(null)` is refused too: once a size is set on an open page,
1119
+ Playwright keeps it, and nothing hands the page back to the window. For
1120
+ the real window, set `viewport: null` in the config.
1121
+
1002
1122
  #### Viewport and scale
1003
1123
 
1004
1124
  Where the window's size and scale come from, first match wins:
@@ -1020,6 +1140,21 @@ Where the window's size and scale come from, first match wins:
1020
1140
  becomes a 780×1688 PNG, the scale design mockups are usually drawn
1021
1141
  at. Every field is optional; a missing one comes from the preset or
1022
1142
  the default.
1143
+
1144
+ `viewport: null` turns emulation off: the page is as wide as the real
1145
+ browser window, and a resize of the window reaches the page. Headless
1146
+ has no window manager — the size comes from `--window-size` in
1147
+ `launch.args`:
1148
+
1149
+ ```js
1150
+ export default {
1151
+ viewport: null,
1152
+ launch: { args: ["--window-size=1440,900"] },
1153
+ };
1154
+ ```
1155
+
1156
+ A `device` next to `viewport: null` is refused: a preset is a size and
1157
+ a scale of its own.
1023
1158
  3. **A device preset.** `device: "iPhone 14"` applies Playwright's
1024
1159
  preset whole: size, scale, touch, mobile mode, user agent. The name
1025
1160
  is Playwright's, exactly; an unknown name is refused with the
@@ -1050,6 +1185,39 @@ exitFrame();
1050
1185
 
1051
1186
  Pop one iframe scope. Idempotent at depth 0.
1052
1187
 
1188
+ ### Extensions — `waitForServiceWorker` / `evaluateInServiceWorker` / `extensionId`
1189
+
1190
+ The background of a Chrome MV3 extension is a service worker, and
1191
+ `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.
1194
+ Headless needs `channel: "chromium"` (the headless shell ignores
1195
+ `--load-extension`).
1196
+
1197
+ ```js
1198
+ id = extensionId();
1199
+ evaluateInServiceWorker('(theme) => chrome.storage.local.set({theme: theme})', 'dark');
1200
+ stored = evaluateInServiceWorker('() => chrome.storage.local.get("theme")');
1201
+ goto('chrome-extension://' + id + '/popup.html');
1202
+ ```
1203
+
1204
+ - `waitForServiceWorker({timeoutMs?, url?})` → the worker's URL,
1205
+ `chrome-extension://<id>/sw.js`. A running worker comes back at once;
1206
+ otherwise it waits up to `timeoutMs` (default: the navigation timeout).
1207
+ `url` — a substring or `/regex/` when several extensions run.
1208
+ - `evaluateInServiceWorker(js, ...args)` — `evaluate` in the worker, same
1209
+ argument rules (one extra → the value, two or more → ONE array) and
1210
+ JSON return; a returned Promise is awaited. Not discouraged by the
1211
+ linter: `chrome.*` has no other way in.
1212
+ - `extensionId()` — the id out of the worker's URL.
1213
+ - MV3 stops an idle worker after ~30 s. Each call finds the worker anew,
1214
+ so nothing goes stale between steps.
1215
+ - `mockRoute` answers the worker's own `fetch` too, but only on a
1216
+ profile the browser opens for the first time. On a profile it opened
1217
+ before, Playwright does not route the worker's requests, and the
1218
+ worker reaches the network. A scenario that mocks them needs a fresh
1219
+ `launch.userDataDir`.
1220
+
1053
1221
  ---
1054
1222
 
1055
1223
  ## Storage
@@ -1185,6 +1353,37 @@ step("Start the listener", () => {
1185
1353
  });
1186
1354
  ```
1187
1355
 
1356
+ ### `waitForDownload(options?)` → `{ path, name }`
1357
+
1358
+ A file the page downloads — a link with `download`, a
1359
+ `Content-Disposition: attachment` response, a script-made blob. The
1360
+ browser records every download from the moment it opens, so the natural
1361
+ order works: click first, then ask. Returns the first download the
1362
+ scenario has not taken yet, waiting up to `{timeoutMs}` (default
1363
+ `UNOTEST_DEFAULT_NAVIGATION_TIMEOUT_MS`) when there is none. Each
1364
+ download is returned once.
1365
+
1366
+ - `path` — absolute path of the saved copy. Read it with `waitForFile`,
1367
+ which takes absolute paths as they are, or with `readFile` /
1368
+ `readFileInfo`.
1369
+ - `name` — the file name the site suggested.
1370
+ - `{name}` — substring or regex of the file name. A download that does
1371
+ not match stays for a later call.
1372
+
1373
+ In a run the file is saved with the run, under `download/`, and the
1374
+ viewer lists it under the step as a link. Outside a run (explore) it is
1375
+ saved under `~/.cache/unotest/web/downloads/`. With no download in time
1376
+ the step fails: `no download within 30000 ms`, plus the names of
1377
+ downloads that are there but did not match.
1378
+
1379
+ ```js
1380
+ step("Export the report", () => {
1381
+ click(getByRole('button', {name: 'Export CSV'}));
1382
+ report = waitForDownload({name: /\.csv$/});
1383
+ waitForFile(report.path, 'id,total');
1384
+ });
1385
+ ```
1386
+
1188
1387
  ### `waitForFile(path, pattern?, options?)` → `string`
1189
1388
 
1190
1389
  The file-side analogue of `waitForText` — polls a file until it exists
@@ -1280,6 +1479,71 @@ step("Ask control question 3", () => {
1280
1479
  background listener's output); its timeout is what makes that safe,
1281
1480
  and what would only hide a wrong path here.
1282
1481
 
1482
+ ### `readJson(path)` → `any`
1483
+
1484
+ Read a whole JSON file once and return the parsed value. Use it for data
1485
+ the scenario must not repeat by hand: constants your app keeps in source
1486
+ (labels, device lists, limits) that a build script exports to JSON, or a
1487
+ fixture object. Walk the result with `.` and `[]`.
1488
+
1489
+ ```js
1490
+ step("Send button uses the app's own label", () => {
1491
+ shared = readJson('unotest/fixtures/shared.json');
1492
+ assertVisible(getByRole('button', {name: shared.chat.labels[0].send}));
1493
+ });
1494
+ ```
1495
+
1496
+ - The path is relative to `sandbox.shellCwd` (the project root by
1497
+ default), the same base `readJsonLine` uses. An absolute path, one
1498
+ that climbs out with `../`, or a symlink that leads out of the project
1499
+ is refused.
1500
+ - No poll: a missing file fails at once with `file not found: <path>`,
1501
+ a file that does not parse with `<path> is not valid JSON: …`.
1502
+ - Scenarios cannot import your app's modules, and that is on purpose:
1503
+ generate the JSON from them instead (see Scenarios, "App constants in
1504
+ scenarios").
1505
+
1506
+ ### `readFile(path, options?)` → `string`
1507
+
1508
+ Read a whole file once and return its content: the text by default, or
1509
+ the bytes as base64 with `{encoding: 'base64'}`. Use it where a test
1510
+ compares what a file holds: an exported CSV, the first bytes of a
1511
+ download.
1512
+
1513
+ ```js
1514
+ step("Export is a PNG", () => {
1515
+ bytes = readFile('unotest/fixtures/files/image-3x2.png', {encoding: 'base64'});
1516
+ assertTrue(textContains(bytes, 'iVBORw0KGgo'), 'not a PNG signature');
1517
+ });
1518
+ ```
1519
+
1520
+ - `options.encoding`: `'utf8'` (default) or `'base64'`. Any other value
1521
+ or option key fails the step.
1522
+ - 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.
1527
+ - No poll: a missing file fails at once with `file not found: <path>`.
1528
+
1529
+ ### `readFileInfo(path)` → `object`
1530
+
1531
+ What a test asserts about a file without reading it whole, for example
1532
+ a downloaded image (`readFileInfo(waitForDownload().path)`):
1533
+ `{size}` in bytes, plus `png: {width, height}` when the file is a PNG.
1534
+ The dimensions come from the PNG header, so a large image is not read.
1535
+
1536
+ ```js
1537
+ step("Fixture image is 3x2", () => {
1538
+ info = readFileInfo('unotest/fixtures/files/image-3x2.png');
1539
+ assertTrue(info.png.width == 3 && info.png.height == 2, 'size: ' + info.size);
1540
+ });
1541
+ ```
1542
+
1543
+ - A file that is not a PNG has no `png` field.
1544
+ - Same path rules as `readFile`. A missing file fails with
1545
+ `file not found: <path>`; a directory cannot be read and says so.
1546
+
1283
1547
  ### `waitForFileCount(path, pattern, count, options?)` → `number`
1284
1548
 
1285
1549
  Wait until **at least** `count` lines match, then return how many there
@@ -1409,6 +1673,97 @@ assertTrue(res.status == 202, 'ingestion not accepted');
1409
1673
 
1410
1674
  ---
1411
1675
 
1676
+ ## Network mocks
1677
+
1678
+ What the page's requests get, for the rest of the scenario: a fixed
1679
+ response, an abort, or a pass to the network. Masks are Playwright URL
1680
+ globs (`**/api/items*`), matched against every request of the browser
1681
+ context — every tab and frame. A scenario holds no callbacks between
1682
+ steps, so a mock is a fixed answer, not a handler.
1683
+
1684
+ - Where several masks match one request, the one mocked **last**
1685
+ answers. Mocking a mask again replaces its answer and makes it the
1686
+ latest.
1687
+ - Mocks end with the scenario; `unmockRoute` takes them off earlier.
1688
+
1689
+ ### `mockRoute(glob, response)`
1690
+
1691
+ ```js
1692
+ mockRoute('**/room/last-message/load', { body: { text: 'Hi from the mock' } });
1693
+ mockRoute('**/api/items', { status: 503, body: 'down', headers: { 'retry-after': '5' } });
1694
+ mockRoute('**/ads/**', { abort: true });
1695
+ ```
1696
+
1697
+ - `{status?, body?, headers?, contentType?}` — the response. `status`
1698
+ defaults to 200. An object or array `body` goes out as JSON with
1699
+ `contentType: 'application/json'`; a string body as it is.
1700
+ - `{abort: true}` — the request fails, as with no network.
1701
+ `{abort: true, record: true}` also keeps every request it stopped,
1702
+ for `assertNoRequest`.
1703
+ - `{continue: true}` — the request goes to the network.
1704
+ - A fixed response to `**/*` is refused: it would answer the page itself.
1705
+ `abort` and `continue` there are fine.
1706
+ - The page document of the `baseUrl` origin is never aborted, so a
1707
+ navigation to your site still loads. Only the document: its scripts,
1708
+ styles, images and API calls are ordinary requests, and an abort mask
1709
+ that matches them stops them.
1710
+
1711
+ A network allowlist is two masks — the later one wins where both match.
1712
+ The second one lets through everything your app loads from its own
1713
+ origin, not just the page:
1714
+
1715
+ ```js
1716
+ mockRoute('**/*', { abort: true, record: true }); // nothing leaves…
1717
+ mockRoute('**/localhost:*/**', { continue: true }); // …except the app's origin
1718
+ ```
1719
+
1720
+ For a site on a domain, the mask is its origin:
1721
+ `mockRoute('https://app.example.com/**', {continue: true})`.
1722
+
1723
+ ### `unmockRoute(glob?)`
1724
+
1725
+ Takes off the mock at `glob`, or every mock without an argument. What a
1726
+ recording abort kept stays for `assertNoRequest`.
1727
+
1728
+ ### `waitForRequest(glob, options?)` → `{url, method, headers, postData, postDataJson}`
1729
+
1730
+ Waits for a request matching `glob` and returns it. `postData` is the
1731
+ body as sent (null without one); `postDataJson` is that body read as
1732
+ JSON (null when it is not JSON).
1733
+
1734
+ ```js
1735
+ mockRoute('**/api/messages', { body: { ok: true } });
1736
+ click(getByRole('button', { name: 'Send' }));
1737
+ req = waitForRequest('**/api/messages');
1738
+ assertTrue(req.postDataJson.text == 'hello', 'sent: ' + req.postData);
1739
+ ```
1740
+
1741
+ - On a **mocked** mask it also finds a request sent before the call:
1742
+ the mock keeps what it answered, and each request is returned once.
1743
+ On any other mask only requests from the call on count — to catch one
1744
+ the previous step sent, mock the mask with `{continue: true}` first.
1745
+ - `{timeoutMs}` — default 20 000.
1746
+
1747
+ ### `assertNoRequest(glob, options?)`
1748
+
1749
+ Fails if the recording abort at `glob` stopped any request, naming
1750
+ them — a test that got its data from the internet it should not reach
1751
+ fails instead of passing by luck.
1752
+
1753
+ ```js
1754
+ mockRoute('**/*', { abort: true, record: true });
1755
+ mockRoute('**/localhost:*/**', { continue: true });
1756
+ // … the test …
1757
+ assertNoRequest('**/*');
1758
+ ```
1759
+
1760
+ - `glob` is the mask of a `mockRoute(glob, {abort: true, record: true})`.
1761
+ A mask without a recording abort is an error, not a pass: nothing
1762
+ watched it.
1763
+ - `{since}` — epoch ms, as `nowMs()` gives: only requests from then on.
1764
+
1765
+ ---
1766
+
1412
1767
  ## Typed getters
1413
1768
 
1414
1769
  Read page properties directly into a local variable.
@@ -1436,6 +1791,16 @@ source-order concatenation, hidden nodes included.
1436
1791
  Identical to `inputValue`; kept under typed-getters for symmetry with
1437
1792
  agent prompts that prefer the `get*` prefix.
1438
1793
 
1794
+ ### `boundingBox(loc)` → `{x, y, width, height}` | `null`
1795
+
1796
+ Where the element lies in the viewport, CSS pixels. `null` when it is
1797
+ not rendered (`display:none`, detached). Does not scroll it into view.
1798
+
1799
+ ```js
1800
+ box = boundingBox(getByRole('dialog'));
1801
+ assertTrue(box.width <= 480, json(box));
1802
+ ```
1803
+
1439
1804
  ### `getTitle()` → `string`
1440
1805
 
1441
1806
  Active page title.
@@ -1488,6 +1853,44 @@ out = shell('./status.sh');
1488
1853
  assertTrue(textContains(out.stdout, 'ready'), out.stderr);
1489
1854
  ```
1490
1855
 
1856
+ ### `textMatches(text, /re/)` → `false` | `string[]`
1857
+
1858
+ The first match of a regex literal: `false` on a miss, otherwise
1859
+ `[whole, group1, …]`, the array JavaScript's `match` gives. A group that
1860
+ took no part in the match is `null`. The array is truthy, so the call
1861
+ also works as a test.
1862
+
1863
+ ```js
1864
+ m = textMatches(getUrl(), /room=(\d+)/);
1865
+ assertTrue(m, getUrl());
1866
+ roomId = m[1];
1867
+ ```
1868
+
1869
+ A string pattern is refused: for a plain substring use `textContains`.
1870
+ A `g` flag does not change the answer, it is always the first match.
1871
+
1872
+ ### `parseJson(text)` → `any`
1873
+
1874
+ Parse a JSON string into a value you walk with `.` and `[]`: an
1875
+ apiCall body that came back as text, a `data-*` attribute, a stored
1876
+ setting. Invalid JSON fails the step and quotes the input; an empty
1877
+ string is not JSON.
1878
+
1879
+ ```js
1880
+ saved = parseJson(getLocalStorage('messages'));
1881
+ assertEqual(saved[0].type, 'hello');
1882
+ ```
1883
+
1884
+ ### `urlParam(url, key)` → `string` | `null`
1885
+
1886
+ A query parameter, decoded. `null` when the key is absent, `''` when it
1887
+ is present but empty. A path without a host (`/room?id=7`) works too.
1888
+
1889
+ ```js
1890
+ src = getAttribute(locator('iframe#chat'), 'src');
1891
+ assertEqual(urlParam(src, 'roomId'), 'r-7');
1892
+ ```
1893
+
1491
1894
  ### `json(value)` → `string`
1492
1895
 
1493
1896
  Serialize any value to a JSON string. The debug tool for API and data
@@ -1551,6 +1954,15 @@ result = evaluate(
1551
1954
  Backticks are **raw** — `${…}` interpolation is rejected at parse time
1552
1955
  (see `vendor/dsl/EXTENSIONS.md`).
1553
1956
 
1957
+ `evaluateOnLocator(loc, js, ...args)` — the same hatch on one element.
1958
+ The body gets the element first, then the extras by the same rule: one
1959
+ → the value itself, two or more → one array.
1960
+
1961
+ ```js
1962
+ color = evaluateOnLocator(getByRole('alert'), `(el) => getComputedStyle(el).color`);
1963
+ ok = evaluateOnLocator(getByRole('main'), `(el, [a, b]) => el.children.length > a + b`, 1, 2);
1964
+ ```
1965
+
1554
1966
  ---
1555
1967
 
1556
1968
  ## Diagnostics
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unotest/web",
3
- "version": "0.44.0",
3
+ "version": "0.45.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": {
@@ -84,18 +84,18 @@
84
84
  "tsx": "^4.19.2",
85
85
  "yaml": "^2.6.0",
86
86
  "zod": "^3.23.8",
87
- "@unotest/dsl": "^0.44.0",
88
- "@unotest/core": "^0.44.0",
89
- "@unotest/viewer": "^0.44.0",
90
- "@unotest/grounder-client": "^0.44.0",
91
- "@unotest/protocol": "^0.44.0"
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"
92
92
  },
93
93
  "devDependencies": {
94
94
  "@types/node": "^22.10.0",
95
95
  "javascript-obfuscator": "^4.1.1",
96
96
  "tsup": "^8.5.1",
97
97
  "typescript": "^5.7.2",
98
- "@unotest/judge": "^0.44.0",
99
- "@unotest/grounder": "^0.44.0"
98
+ "@unotest/grounder": "^0.45.0",
99
+ "@unotest/judge": "^0.45.0"
100
100
  }
101
101
  }