@unotest/web 0.43.2 → 0.44.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.
@@ -545,6 +545,116 @@ Semantics:
545
545
  parsing rubric lines `must contain: <substring>` /
546
546
  `must not contain: <substring>`.
547
547
 
548
+ ### `assertScreenshot(name, options?)` / `assertScreenshot(loc, name, options?)`
549
+
550
+ Since 0.44.0.
551
+
552
+ The page — or one element — against a PNG baseline in the repo: a mockup
553
+ exported from Figma, or a snapshot you accepted earlier.
554
+
555
+ ```js
556
+ function test_chat_matches_figma() {
557
+ step("Chat screen matches the mockup", () => {
558
+ goto('/demo/chat');
559
+ waitForText('Hi!');
560
+ assertScreenshot('chat-empty', {
561
+ maxDiffRatio: 0.05, // share of pixels, 0..1
562
+ threshold: 32, // per channel, 0..255
563
+ mask: [getByTestId('clock'), getByTestId('avatar')], // elements on the page
564
+ ignore: [{x: 0, y: 0, width: 780, height: 94}], // areas of the baseline
565
+ });
566
+ });
567
+ }
568
+ ```
569
+
570
+ **The metric** is the one you would count by hand. A pixel *differs* when
571
+ any of its R, G, B, A channels differs by more than `threshold` (default
572
+ 32). The assert fails when the share of differing pixels among the
573
+ compared ones is **more than** `maxDiffRatio` (default 0 — every pixel
574
+ within the threshold). Exactly the limit passes: 5.0% differing at
575
+ `maxDiffRatio: 0.05` is green, 5.1% is red. No perceptual colour model —
576
+ the number in the report is the number you get.
577
+
578
+ **Masks.** `mask: [locator…]` — the boxes of those elements at the moment
579
+ of the snapshot; a locator that matches nothing is an error, not a
580
+ skipped mask. `ignore: [{x, y, width, height}…]` — rectangles in the
581
+ baseline's pixels, for what the page does not have (a phone's status bar
582
+ in a mockup). Everything masked is left out on both sides and out of the
583
+ share.
584
+
585
+ **Password fields** — `type="password"`, or `autocomplete` of
586
+ `current-password`, `new-password` or `one-time-code` — are painted over
587
+ in magenta (`#FF00FF`) on the snapshot, as on every screenshot the tool
588
+ takes, so a baseline accepted from a snapshot has them painted over too. A
589
+ mockup that shows such a field needs it covered with `ignore`.
590
+
591
+ **Size.** Snapshot and baseline are compared pixel for pixel, never
592
+ resampled. A different size fails at once with both sizes: match the
593
+ viewport and `deviceScaleFactor` to the baseline — a 780×1688 Figma
594
+ export at 2x is `viewport: {width: 390, height: 844, deviceScaleFactor: 2}`.
595
+
596
+ **The snapshot** is taken the way `screenshot()` takes one — after the
597
+ page settles — with animations finished and the caret hidden, and the
598
+ page's time frozen if you froze it. By default it is the viewport;
599
+ `fullPage: true` takes the whole page (not for an element). An image may
600
+ have at most 25 megapixels — a larger baseline or snapshot is refused
601
+ before it is read.
602
+
603
+ **The baseline** lives next to the scenario:
604
+ `unotest/e2e/<scenario path without .js>.screenshots/<name>.png` (the
605
+ name is lower-cased and dashed, as for `screenshot()`), and travels with
606
+ the scenario into a box bundle. Two names that become the same file in one
607
+ scenario (`"Checkout Page"` and `"checkout/page"`) are refused at the
608
+ second one; the same name twice in one run of `--update-screenshots` is
609
+ written twice, and the runner says that the last one wins. A symlink on
610
+ the way to a baseline is followed while it stays inside the project (a
611
+ shared directory of the suite); one that leads outside is refused.
612
+
613
+ - **Unreadable baseline** — a file that is not a PNG, or one over the
614
+ ceiling, fails the assert (not the run); the snapshot is saved as a
615
+ candidate to replace it.
616
+
617
+ - **No baseline yet** — the assert fails; the snapshot is saved in the
618
+ run's artifacts as a candidate under the baseline's own file name, and
619
+ the message says where it goes. Nothing is written into the repo on its
620
+ own: a first run that turned green by writing its own baseline would
621
+ check nothing.
622
+ - **Accept the current page** — `npx @unotest/web e2e <name>
623
+ --update-screenshots` (MCP: `run_test {updateScreenshots: true}`)
624
+ writes every snapshot of the run as its baseline; the asserts pass and
625
+ the journal marks them `updated`. Refused on a box.
626
+ - **It failed** — the run keeps three pictures: `expected`, `actual` and
627
+ `diff` (differing pixels red over a greyed-out snapshot, masked areas
628
+ grey). The viewer shows them side by side on the failed step; the
629
+ failure bundle and `run_test` (`next.visual`) point at them. A retried
630
+ attempt keeps its own pictures.
631
+
632
+ **Baselines for a box.** Fonts render differently on macOS and on the
633
+ box (Linux), so a baseline recorded on your laptop will not match there.
634
+ Take recorded baselines where the suite runs:
635
+
636
+ 1. Push the scenario with `assertScreenshot` and no baseline.
637
+ 2. The run on the box is red: `no baseline at …`, the candidate is in
638
+ the run's artifacts.
639
+ 3. Download it — the viewer's **Download as baseline** on the failed
640
+ step, or `npx @unotest/web box run <runId> --env <project>/<env>
641
+ --download` (MCP: `box_run_download`), which unpacks the candidates
642
+ next to the zip. The file is already named as the assert expects.
643
+ 4. Put it in `unotest/e2e/<scenario>.screenshots/`, commit, push — the
644
+ next run on the box is green.
645
+
646
+ A Figma export is the same on every platform: commit it as is.
647
+
648
+ Baselines count towards the bundle. Boxes accept 128 MB by default; a
649
+ larger suite is warned about before the upload, with its biggest files
650
+ and baselines — compare an element instead of a full page to keep them
651
+ small.
652
+
653
+ **Secrets and personal data.** A baseline is a file in your repo. Mask
654
+ every area that shows a secret, a token or someone's personal data
655
+ before the first snapshot is written — once committed, it is in the
656
+ history.
657
+
548
658
  ---
549
659
 
550
660
  ## Queries
@@ -710,6 +820,63 @@ pause(500); // lint-ok: third-party widget has no completion signal to hook into
710
820
  helpers with known warnings stop drowning out new ones). Error-severity
711
821
  diagnostics are never suppressible.
712
822
 
823
+ ### `clockFreeze(iso)` / `clockTick(ms)` / `clockRun()`
824
+
825
+ The page clock. Stops time on the page, moves it by hand, lets it go.
826
+ For anything that animates on timers — a "typing…" indicator, a
827
+ countdown, a relative "5 minutes ago" — so a screenshot of it is the
828
+ same pixels on every run.
829
+
830
+ ```js
831
+ goto('/chat');
832
+ clockFreeze('2026-01-15T10:00:00Z'); // Date reads this; no timer fires
833
+ screenshot('chat-typing'); // same pixels every run
834
+ clockTick(1500); // 1.5 s pass: due timers fire
835
+ clockRun(); // time runs by itself again
836
+ ```
837
+
838
+ - `clockFreeze(iso)` — `Date.now()` and `new Date()` on the page read
839
+ `iso`; `setTimeout`, `setInterval` and `requestAnimationFrame` stand
840
+ still. Any instant, past or future: no timer fires on the way to it.
841
+ - `clockTick(ms)` — frozen time moves forward by `ms`, and every timer
842
+ due in that span fires, in order.
843
+ - `clockRun()` — time runs by itself again, from where it stands.
844
+
845
+ **When the clock is installed.** A fake clock only controls timers set
846
+ after it is in place, so a run puts it on the browser before the first
847
+ page loads — and only for a scenario that can reach a clock call
848
+ (directly, or through a helper it calls). Every other scenario keeps the
849
+ page's real time, as before. So `clockFreeze` works at any point of the
850
+ scenario, after `goto` included. Over MCP the browser has to be opened
851
+ with the clock: `new_context {clock: true}` (or `explore_start {clock:
852
+ true}`), then `explore_step` actions `clock_freeze {time}`,
853
+ `clock_tick {ms}`, `clock_run`.
854
+
855
+ **Coming from Playwright.** The names are flat: `clock.freeze(...)`,
856
+ `clock.tick(...)`, `clock.run()` (and `pauseAt`, `setFixedTime`,
857
+ `runFor`, `resume`) are refused with the name to use instead. Where
858
+ Playwright's `pauseAt` only moves forward, `clockFreeze` also takes a
859
+ date in the past.
860
+
861
+ Not with `launch.userDataDir`, once its shared context is open: the
862
+ pages already loaded in it keep real timers, so the clock is refused
863
+ there rather than half-applied.
864
+
865
+ ### Time zone and locale
866
+
867
+ `timezone` and `locale` in `unotest.config.mjs` set them for every page:
868
+
869
+ ```js
870
+ export default {
871
+ timezone: 'Europe/Madrid', // Date#toString, Intl, getTimezoneOffset
872
+ locale: 'es-ES', // navigator.language, Intl defaults, Accept-Language
873
+ };
874
+ ```
875
+
876
+ Unset, the page gets the machine's zone and the browser's locale.
877
+ `UNOTEST_TIMEZONE` / `UNOTEST_LOCALE` override the config for one run.
878
+ An unknown zone or a malformed locale is refused when the config loads.
879
+
713
880
  ---
714
881
 
715
882
  ## Multi-context
@@ -742,6 +909,133 @@ having to track tab indexes. See
742
909
  `docs/auto-page-switch/README.md`
743
910
  and `UNOTEST_POPUP_GRACE_MS` in `unotest/.env`.
744
911
 
912
+ ### `newContext()` → `number` / `setContext(index)`
913
+
914
+ A second browser context: another client of the same app, with its own
915
+ cookies, localStorage, sessionStorage and tabs. For anything that takes
916
+ two users — one closes a ticket, the other still has it open.
917
+
918
+ `newContext()` opens one with the same options as the first (base URL,
919
+ viewport and scale, `device`, time zone, locale, page clock), makes it
920
+ active, and returns its number: the test's own context is `0`, the next
921
+ is `1`. Nothing of the first is inherited — no cookie, no storage, no
922
+ `storageState`. `setContext(i)` makes context `i` active; every locator,
923
+ assertion, `setPage`, frame and storage call then acts in it. Each
924
+ context keeps its own active tab and frame stack, so switching away and
925
+ back lands where the scenario left off.
926
+
927
+ ```js
928
+ function test_second_client_writes_to_closed_thread() {
929
+ step("Client A opens the thread", () => {
930
+ flow_login(LOGIN, PASSWORD);
931
+ goto('/support/thread/42');
932
+ });
933
+ step("Client B opens the same thread", () => {
934
+ clientB = newContext(); // returns 1, becomes active
935
+ flow_login(LOGIN, PASSWORD);
936
+ goto('/support/thread/42');
937
+ });
938
+ step("Client A closes the ticket", () => {
939
+ setContext(0); // client A
940
+ click(getByRole('button', {name: 'Close ticket'}));
941
+ });
942
+ step("Client B cannot write", () => {
943
+ setContext(clientB);
944
+ fill(getByRole('textbox', {name: 'Message'}), 'hello');
945
+ press(getByRole('textbox', {name: 'Message'}), 'Enter');
946
+ assertVisible(getByText('This ticket is closed'));
947
+ });
948
+ }
949
+ ```
950
+
951
+ `flow_login` is your project's own sign-in helper (`unotest/e2e/_helpers/`);
952
+ the rest is the DSL. A variable set inside one `step(...)` is visible
953
+ in the next — `clientB` above — because a step block has no scope of its
954
+ own.
955
+
956
+ - **Ceiling:** 4 contexts per test, `maxContexts` in the config. The next
957
+ `newContext()` is refused, naming the key — a loop opening one per
958
+ iteration stops there.
959
+ - **End of the test:** every context is closed, in any outcome. One that
960
+ fails to close is a warning in the run log.
961
+ - **Journal:** once a second context exists, each step event carries
962
+ `contextIndex` — the context the step **started** in (so
963
+ `setContext(0)` issued from context 1 is marked `1`), and the viewer's
964
+ Trace shows it as a `ctx N` chip. A one-context test writes exactly
965
+ what it did before.
966
+ - **Failure bundle:** the page, console and network of the context
967
+ active at the failure, and `contextIndex` in `failure.json`. The other
968
+ contexts are not in it: to keep a frame of client A, `screenshot()` it
969
+ before switching.
970
+ - **Traces and video:** not recorded for any context — unotest does not
971
+ capture Playwright traces or video today.
972
+ - **Browser profile:** with `launch.userDataDir` every page shares one
973
+ context, so `newContext()` is refused there.
974
+ - **Debugging:** an agent that joins a paused run (`take_wheel`,
975
+ `attach_debug_session`) lands in the context the run is on (Chromium).
976
+ - **Names:** a function or variable of your own called `newContext` /
977
+ `setContext` (or any DSL name) is reported by `lint:shadows-dsl-function`
978
+ — your function wins a call by that name, so the DSL's is out of reach
979
+ until you rename it.
980
+
981
+ Over MCP, record it with `explore_step {action: "newContext"}` and
982
+ `{action: "setContext", index}`; they become the same two calls in the
983
+ saved test. The `new_context` tool is different: it resets the browser.
984
+
985
+ ### `setViewport({width, height})`
986
+
987
+ Resize the browser window mid-scenario. Every open tab is resized in
988
+ place — no reload, the page keeps its state — and a tab opened afterwards
989
+ starts at the same size. The size belongs to the browser, not to one tab.
990
+
991
+ ```js
992
+ setViewport({width: 420, height: 900}); // a phone-width layout
993
+ screenshot('card-narrow');
994
+ setViewport({width: 1280, height: 720});
995
+ ```
996
+
997
+ Both fields are required. Anything else is refused on that line,
998
+ `deviceScaleFactor` included: the scale is fixed when the browser opens,
999
+ and changing it would need a new browser context — the page under test
1000
+ would be lost. See [Viewport and scale](#viewport-and-scale).
1001
+
1002
+ #### Viewport and scale
1003
+
1004
+ Where the window's size and scale come from, first match wins:
1005
+
1006
+ 1. **The MCP call.** `new_context` and `explore_start` take
1007
+ `viewport: {width, height, deviceScaleFactor}` and `device`. They
1008
+ replace the config's window for that context. `explore_start` with a
1009
+ window while a context is already open is refused — call
1010
+ `new_context` with it instead.
1011
+ 2. **The config.** `unotest.config.mjs`:
1012
+
1013
+ ```js
1014
+ export default {
1015
+ viewport: { width: 390, height: 844, deviceScaleFactor: 2 },
1016
+ };
1017
+ ```
1018
+
1019
+ `deviceScaleFactor: 2` doubles every screenshot: a 390×844 page
1020
+ becomes a 780×1688 PNG, the scale design mockups are usually drawn
1021
+ at. Every field is optional; a missing one comes from the preset or
1022
+ the default.
1023
+ 3. **A device preset.** `device: "iPhone 14"` applies Playwright's
1024
+ preset whole: size, scale, touch, mobile mode, user agent. The name
1025
+ is Playwright's, exactly; an unknown name is refused with the
1026
+ nearest ones. `viewport` next to `device` overrides the preset field
1027
+ by field.
1028
+ 4. **The default:** 1280×720 at scale 1.
1029
+
1030
+ A preset's viewport is the page area, smaller than the device's screen:
1031
+ `"iPhone 14"` is 390×664 (the browser chrome takes the rest of 844). To
1032
+ match a 390×844 mockup, set `viewport` explicitly:
1033
+ `{width: 390, height: 844, deviceScaleFactor: 2}`.
1034
+
1035
+ A mobile preset lays out a page with no `<meta name="viewport">` tag at
1036
+ 980 px wide, as a real phone does. Firefox has no mobile mode: a mobile
1037
+ preset on Firefox is refused — give it a plain `viewport` instead.
1038
+
745
1039
  ### `enterFrame(loc)`
746
1040
 
747
1041
  Enter an iframe scope. Subsequent calls run inside the frame's document.
@@ -1295,7 +1589,8 @@ screenshot('landing', {fullPage: true}); // full scroll height
1295
1589
  `load`, which on a client-rendered app fires before any content is on
1296
1590
  screen. A page that never goes quiet (polling, live feeds) costs the
1297
1591
  2s cap and is then captured as-is.
1298
- - Not a visual assertion: the test does not compare images. In runtimes
1592
+ - Not a visual assertion: `screenshot()` does not compare images — that is
1593
+ [`assertScreenshot`](#assertscreenshotname-options--assertscreenshotloc-name-options). In runtimes
1299
1594
  with no artifact dir (exploration) the call logs a warning, returns
1300
1595
  `null` and does NOT fail the run.
1301
1596
  - **Every step, without calling it:** `UNOTEST_STEP_SCREENSHOTS=1` makes
@@ -1386,13 +1681,12 @@ The DSL is JavaScript-shaped but parsed by a small AST engine
1386
1681
  ```
1387
1682
 
1388
1683
  - **Array literals** — `[1, 2, 3]`.
1389
- - **Index access on data** — `docs[0]`, `docs[0].objectsCount`. The
1390
- index goes on a **bare variable** (grammar restriction), so pull
1391
- nested arrays into one first: `docs = res.body; n = docs[0].count;`.
1684
+ - **Index access on data** — `docs[0]`, `docs[0].objectsCount`,
1685
+ `res.body.items[0].id`: see [Strings, objects, indexes](#strings-objects-indexes).
1392
1686
  Indexing a **Locator** is rejected — disambiguate with chain methods
1393
1687
  (`.filter(...)`), and `arr[i] = v` assignment stays unsupported.
1394
- - **Object literals — as call arguments only** (`click(loc, {force:
1395
- true})`); a standalone `x = {name: 'x'}` is rejected.
1688
+ - **Object literals** — as call arguments (`click(loc, {force: true})`)
1689
+ and as values in a variable: see [Strings, objects, indexes](#strings-objects-indexes).
1396
1690
  - **Member access** — `row.email` (dot form; `obj['key']` doesn't
1397
1691
  parse).
1398
1692
  - **Method chains** — `getByRole('row').filter({hasText:'X'}).click()`.
@@ -1409,13 +1703,143 @@ The DSL is JavaScript-shaped but parsed by a small AST engine
1409
1703
  Not supported (and intentionally so): `while` / `do…while` /
1410
1704
  `continue` (bound loops keep every scenario terminating), `++`/`--`,
1411
1705
  arrow functions, async/await (everything is sequenced by the AST
1412
- executor), classes, modules, promises, `try/catch`. Errors thrown from
1413
- a primitive abort the scenario; if the runtime is paused (debugger /
1414
- MCP step mode), the failure is captured for inspection.
1706
+ executor), classes, modules, promises, `try/catch` — for cleanup that
1707
+ must run whatever happens, see [`teardown`](#teardown--give-the-stand-back).
1708
+ Errors thrown from a primitive abort the scenario; if the runtime is
1709
+ paused (debugger / MCP step mode), the failure is captured for
1710
+ inspection.
1415
1711
 
1416
1712
  See `vendor/dsl/EXTENSIONS.md` for the
1417
1713
  exact grammar deltas from the upstream parser.
1418
1714
 
1715
+ ### Strings, objects, indexes
1716
+
1717
+ Since 0.44.0.
1718
+
1719
+ **Strings** answer four members — the whole list:
1720
+
1721
+ | Member | Returns | |
1722
+ |---|---|---|
1723
+ | `s.length` | `number` | a property: no parentheses |
1724
+ | `s.trim()` | `string` | whitespace off both ends |
1725
+ | `s.includes(search)` | `boolean` | substring probe, same as `textContains(s, search)` |
1726
+ | `s.startsWith(prefix)` | `boolean` | |
1727
+
1728
+ They work wherever an expression does — in `assertTrue`, in `if`, on a
1729
+ literal, on a call result:
1730
+
1731
+ ```js
1732
+ title = getTitle();
1733
+ assertTrue(title.trim().length > 0, 'empty title');
1734
+ if (out.stdout.includes('ready')) { log('up'); }
1735
+ assertTrue(getUrl().startsWith('https://'), getUrl());
1736
+ ```
1737
+
1738
+ Any other member is a lint error that names it and lists the four
1739
+ (`validator:unsupported-string-member`); `s.padStart(3)` does not
1740
+ reach the browser. A member of the list used on a value that turns out
1741
+ not to be a string at run time — a number, or `null` from a missing
1742
+ field — fails the step with the value's type in the message.
1743
+
1744
+ **Object literals** are values: keep one in a variable, read it back by
1745
+ field, hand it to a helper.
1746
+
1747
+ ```js
1748
+ user = {name: 'Alice', tags: ['admin'], address: {city: 'Riga'}};
1749
+ flow_sign_up(user);
1750
+ assertTrue(user.address.city == 'Riga');
1751
+ ```
1752
+
1753
+ Where a function wants a specific kind (a string, a locator), an
1754
+ object in that slot is still an `arg-kind` error.
1755
+
1756
+ **Indexes** go anywhere in a chain — no temporary variable:
1757
+
1758
+ ```js
1759
+ res = apiCall('GET', '/documents');
1760
+ assertTrue(res.body.items[0].status == 'ready', json(res.body));
1761
+ first = dbQuery('SELECT id FROM users ORDER BY id')[0].id;
1762
+ ```
1763
+
1764
+ ### `teardown` — give the stand back
1765
+
1766
+ Since 0.44.0.
1767
+
1768
+ ```js
1769
+ function test_ticket_closed_in_second_client() {
1770
+ step("Enable the stand flag", () => {
1771
+ teardown("Disable the stand flag", () => {
1772
+ apiCall('POST', '/stand/flags', {closeTickets: false});
1773
+ });
1774
+ apiCall('POST', '/stand/flags', {closeTickets: true});
1775
+ });
1776
+ step("…", () => { … });
1777
+ }
1778
+ ```
1779
+
1780
+ `teardown("label", () => { … })` **registers** a rollback — nothing in
1781
+ it runs where it stands. After the test body every registered block
1782
+ runs, **newest first**:
1783
+
1784
+ - whatever the outcome — passed, failed, over its time budget, or
1785
+ stopped (Stop in the viewer, `abort_runtime`, Ctrl-C), including a run
1786
+ stopped while paused at its failure in the debugger or in `run_test`;
1787
+ - each block even if an earlier one failed.
1788
+
1789
+ Put it **before** the call that changes the stand: then a failure in
1790
+ that very call is rolled back too. A flow or helper can carry its own
1791
+ rollback (`flow_enable_flag` registers the teardown that disables the
1792
+ flag); it still runs at the end of the test, not when the helper
1793
+ returns, with the helper's variables as they were when it registered.
1794
+ A block in the test itself sees the test's variables as they are when
1795
+ it runs.
1796
+
1797
+ What you see:
1798
+
1799
+ - the CLI prints `↺ teardown "Disable the stand flag"` for each block;
1800
+ the journal and the viewer show it as a frame marked **teardown**
1801
+ after the test's last step;
1802
+ - the failure bundle is taken **before** the rollback — its screenshot is
1803
+ the page the test broke on;
1804
+ - a green test whose teardown failed is **failed** (the stand was not
1805
+ given back); a failed test keeps its own error, and the teardown
1806
+ failures are listed next to it (CLI `↺ teardown "…" failed: …`,
1807
+ `run_test` → `next.teardownFailures`).
1808
+
1809
+ Stopping a run: the first Stop gives the stand back and then ends the
1810
+ run — the viewer shows **returning the stand…**, `abort_runtime` answers
1811
+ `status: "returning the stand…"`. A second Stop (or a second
1812
+ `abort_runtime`) skips what is left; the journal records
1813
+ `teardown skipped: second stop` with the blocks that did not run.
1814
+
1815
+ How long the first Stop can take: the rollback is cut at
1816
+ `teardownTimeoutMs` (60 s by default), and the run ends at the latest
1817
+ 15 s after that, saying so (`stop: the rollback did not finish within
1818
+ …`) — a Stop never hangs silently. A viewer that started the run
1819
+ force-kills it only if it is still alive 5 minutes after the first
1820
+ Stop — a safety net for a stuck process, not a wait for the rollback.
1821
+
1822
+ **When the stand is NOT given back.** A process that is killed
1823
+ (SIGKILL, a crashed machine) runs no teardown at all. Blocks still
1824
+ running when `teardownTimeoutMs` (config, default 60 s for all blocks
1825
+ together) runs out are cut, and so is whatever a second Stop skips.
1826
+ Each such block is reported in the run result as a teardown failure —
1827
+ the stand may be left as the test set it.
1828
+
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
1831
+ running is abandoned there, and one that finished past it fails the
1832
+ test on that line — the last action included. The abandoned browser
1833
+ call lives on until its own timeout or until the context closes; the
1834
+ teardown pass runs anyway, on `teardownTimeoutMs`, not on what the test
1835
+ left of its budget.
1836
+
1837
+ Rules (lint, `teardown-shape`): the label is a string literal; no
1838
+ `return` inside, no `break` out of it (a `break` in a loop inside is
1839
+ fine); no `teardown` inside a `teardown`. Allowed in tests, flows and
1840
+ helpers. A function, parameter or variable of your own named
1841
+ `teardown` is reported as `teardown-name-conflict` — rename it.
1842
+
1419
1843
  ---
1420
1844
 
1421
1845
  ## Conventions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unotest/web",
3
- "version": "0.43.2",
3
+ "version": "0.44.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": {
@@ -80,21 +80,22 @@
80
80
  "chokidar": "^4.0.3",
81
81
  "dotenv": "^16.4.5",
82
82
  "playwright": "1.62.1",
83
+ "pngjs": "7.0.0",
83
84
  "tsx": "^4.19.2",
84
85
  "yaml": "^2.6.0",
85
86
  "zod": "^3.23.8",
86
- "@unotest/core": "^0.43.2",
87
- "@unotest/dsl": "^0.43.2",
88
- "@unotest/grounder-client": "^0.43.2",
89
- "@unotest/protocol": "^0.43.2",
90
- "@unotest/viewer": "^0.43.2"
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"
91
92
  },
92
93
  "devDependencies": {
93
94
  "@types/node": "^22.10.0",
94
95
  "javascript-obfuscator": "^4.1.1",
95
96
  "tsup": "^8.5.1",
96
97
  "typescript": "^5.7.2",
97
- "@unotest/judge": "^0.43.2",
98
- "@unotest/grounder": "^0.43.2"
98
+ "@unotest/judge": "^0.44.0",
99
+ "@unotest/grounder": "^0.44.0"
99
100
  }
100
101
  }
@@ -231,6 +231,13 @@ declare function assertUrl(pattern: string): void;
231
231
  */
232
232
  declare function assertJudge(locator: Locator, rubric: string): void;
233
233
 
234
+ /**
235
+ * assertScreenshot(name | locator, name?, { maxDiffRatio?, threshold?, mask?, ignore?, fullPage? })
236
+ *
237
+ * The page (or an element) against the PNG baseline unotest/e2e/<scenario>.screenshots/<name>.png. A pixel differs when any channel differs by more than threshold (default 32); fails when the share of differing pixels is MORE than maxDiffRatio (default 0). mask: locators, ignore: rectangles in baseline pixels — both left out of the comparison. No baseline: fails and saves the snapshot as a candidate; accept with e2e --update-screenshots. On failure the run keeps expected / actual / diff.
238
+ */
239
+ declare function assertScreenshot(nameOrLocator: any, nameOrOptions?: any, options?: any): void;
240
+
234
241
  /**
235
242
  * assertTrue(condition, message?)
236
243
  *
@@ -301,6 +308,27 @@ declare function evaluate(jsBody: string, ...args: unknown[]): any;
301
308
  */
302
309
  declare function setPage(indexOrTitle: string | number): void;
303
310
 
311
+ /**
312
+ * setViewport({width, height})
313
+ *
314
+ * Resize every tab of the browser, and the ones it opens later, without reloading. The scale (deviceScaleFactor) is set in the config, not here.
315
+ */
316
+ declare function setViewport(size: any): void;
317
+
318
+ /**
319
+ * newContext() → number
320
+ *
321
+ * Open another browser context — a separate client with its own cookies, storage and tabs — and make it active. Returns its number (the run's own is 0).
322
+ */
323
+ declare function newContext(): number;
324
+
325
+ /**
326
+ * setContext(index)
327
+ *
328
+ * Make browser context `index` active. Each keeps its own tab and frame, so switching back lands where it was.
329
+ */
330
+ declare function setContext(index: number): void;
331
+
304
332
  /**
305
333
  * enterFrame(locator)
306
334
  *
@@ -728,6 +756,27 @@ declare function waitForCount(locator: Locator, count: number, options?: any): v
728
756
  */
729
757
  declare function pause(ms: number): void;
730
758
 
759
+ /**
760
+ * clockFreeze(iso)
761
+ *
762
+ * Stop time on the page at an ISO instant: `Date` reads it and no timer fires until clockTick / clockRun. Any instant, past or future.
763
+ */
764
+ declare function clockFreeze(iso: string): void;
765
+
766
+ /**
767
+ * clockTick(ms)
768
+ *
769
+ * Move frozen time forward by ms, firing every timer due in it.
770
+ */
771
+ declare function clockTick(ms: number): void;
772
+
773
+ /**
774
+ * clockRun()
775
+ *
776
+ * Let time on the page run again from where it stands.
777
+ */
778
+ declare function clockRun(): void;
779
+
731
780
  /** step(label, [options,] body)
732
781
  *
733
782
  * Groups statements under a labelled block. Every direct child of a