@unotest/web 0.43.2 → 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
@@ -545,6 +565,116 @@ Semantics:
545
565
  parsing rubric lines `must contain: <substring>` /
546
566
  `must not contain: <substring>`.
547
567
 
568
+ ### `assertScreenshot(name, options?)` / `assertScreenshot(loc, name, options?)`
569
+
570
+ Since 0.44.0.
571
+
572
+ The page — or one element — against a PNG baseline in the repo: a mockup
573
+ exported from Figma, or a snapshot you accepted earlier.
574
+
575
+ ```js
576
+ function test_chat_matches_figma() {
577
+ step("Chat screen matches the mockup", () => {
578
+ goto('/demo/chat');
579
+ waitForText('Hi!');
580
+ assertScreenshot('chat-empty', {
581
+ maxDiffRatio: 0.05, // share of pixels, 0..1
582
+ threshold: 32, // per channel, 0..255
583
+ mask: [getByTestId('clock'), getByTestId('avatar')], // elements on the page
584
+ ignore: [{x: 0, y: 0, width: 780, height: 94}], // areas of the baseline
585
+ });
586
+ });
587
+ }
588
+ ```
589
+
590
+ **The metric** is the one you would count by hand. A pixel *differs* when
591
+ any of its R, G, B, A channels differs by more than `threshold` (default
592
+ 32). The assert fails when the share of differing pixels among the
593
+ compared ones is **more than** `maxDiffRatio` (default 0 — every pixel
594
+ within the threshold). Exactly the limit passes: 5.0% differing at
595
+ `maxDiffRatio: 0.05` is green, 5.1% is red. No perceptual colour model —
596
+ the number in the report is the number you get.
597
+
598
+ **Masks.** `mask: [locator…]` — the boxes of those elements at the moment
599
+ of the snapshot; a locator that matches nothing is an error, not a
600
+ skipped mask. `ignore: [{x, y, width, height}…]` — rectangles in the
601
+ baseline's pixels, for what the page does not have (a phone's status bar
602
+ in a mockup). Everything masked is left out on both sides and out of the
603
+ share.
604
+
605
+ **Password fields** — `type="password"`, or `autocomplete` of
606
+ `current-password`, `new-password` or `one-time-code` — are painted over
607
+ in magenta (`#FF00FF`) on the snapshot, as on every screenshot the tool
608
+ takes, so a baseline accepted from a snapshot has them painted over too. A
609
+ mockup that shows such a field needs it covered with `ignore`.
610
+
611
+ **Size.** Snapshot and baseline are compared pixel for pixel, never
612
+ resampled. A different size fails at once with both sizes: match the
613
+ viewport and `deviceScaleFactor` to the baseline — a 780×1688 Figma
614
+ export at 2x is `viewport: {width: 390, height: 844, deviceScaleFactor: 2}`.
615
+
616
+ **The snapshot** is taken the way `screenshot()` takes one — after the
617
+ page settles — with animations finished and the caret hidden, and the
618
+ page's time frozen if you froze it. By default it is the viewport;
619
+ `fullPage: true` takes the whole page (not for an element). An image may
620
+ have at most 25 megapixels — a larger baseline or snapshot is refused
621
+ before it is read.
622
+
623
+ **The baseline** lives next to the scenario:
624
+ `unotest/e2e/<scenario path without .js>.screenshots/<name>.png` (the
625
+ name is lower-cased and dashed, as for `screenshot()`), and travels with
626
+ the scenario into a box bundle. Two names that become the same file in one
627
+ scenario (`"Checkout Page"` and `"checkout/page"`) are refused at the
628
+ second one; the same name twice in one run of `--update-screenshots` is
629
+ written twice, and the runner says that the last one wins. A symlink on
630
+ the way to a baseline is followed while it stays inside the project (a
631
+ shared directory of the suite); one that leads outside is refused.
632
+
633
+ - **Unreadable baseline** — a file that is not a PNG, or one over the
634
+ ceiling, fails the assert (not the run); the snapshot is saved as a
635
+ candidate to replace it.
636
+
637
+ - **No baseline yet** — the assert fails; the snapshot is saved in the
638
+ run's artifacts as a candidate under the baseline's own file name, and
639
+ the message says where it goes. Nothing is written into the repo on its
640
+ own: a first run that turned green by writing its own baseline would
641
+ check nothing.
642
+ - **Accept the current page** — `npx @unotest/web e2e <name>
643
+ --update-screenshots` (MCP: `run_test {updateScreenshots: true}`)
644
+ writes every snapshot of the run as its baseline; the asserts pass and
645
+ the journal marks them `updated`. Refused on a box.
646
+ - **It failed** — the run keeps three pictures: `expected`, `actual` and
647
+ `diff` (differing pixels red over a greyed-out snapshot, masked areas
648
+ grey). The viewer shows them side by side on the failed step; the
649
+ failure bundle and `run_test` (`next.visual`) point at them. A retried
650
+ attempt keeps its own pictures.
651
+
652
+ **Baselines for a box.** Fonts render differently on macOS and on the
653
+ box (Linux), so a baseline recorded on your laptop will not match there.
654
+ Take recorded baselines where the suite runs:
655
+
656
+ 1. Push the scenario with `assertScreenshot` and no baseline.
657
+ 2. The run on the box is red: `no baseline at …`, the candidate is in
658
+ the run's artifacts.
659
+ 3. Download it — the viewer's **Download as baseline** on the failed
660
+ step, or `npx @unotest/web box run <runId> --env <project>/<env>
661
+ --download` (MCP: `box_run_download`), which unpacks the candidates
662
+ next to the zip. The file is already named as the assert expects.
663
+ 4. Put it in `unotest/e2e/<scenario>.screenshots/`, commit, push — the
664
+ next run on the box is green.
665
+
666
+ A Figma export is the same on every platform: commit it as is.
667
+
668
+ Baselines count towards the bundle. Boxes accept 128 MB by default; a
669
+ larger suite is warned about before the upload, with its biggest files
670
+ and baselines — compare an element instead of a full page to keep them
671
+ small.
672
+
673
+ **Secrets and personal data.** A baseline is a file in your repo. Mask
674
+ every area that shows a secret, a token or someone's personal data
675
+ before the first snapshot is written — once committed, it is in the
676
+ history.
677
+
548
678
  ---
549
679
 
550
680
  ## Queries
@@ -638,6 +768,33 @@ not just native `:disabled`. This is the wait to reach for instead of a
638
768
  `for` loop around `pause()`: most legacy `pause` calls in a helper layer
639
769
  are surrogates for exactly this.
640
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
+
641
798
  ### `waitForText(text, options?)`
642
799
 
643
800
  Wait until at least one **visible** element on the active page contains
@@ -710,17 +867,79 @@ pause(500); // lint-ok: third-party widget has no completion signal to hook into
710
867
  helpers with known warnings stop drowning out new ones). Error-severity
711
868
  diagnostics are never suppressible.
712
869
 
870
+ ### `clockFreeze(iso)` / `clockTick(ms)` / `clockRun()`
871
+
872
+ The page clock. Stops time on the page, moves it by hand, lets it go.
873
+ For anything that animates on timers — a "typing…" indicator, a
874
+ countdown, a relative "5 minutes ago" — so a screenshot of it is the
875
+ same pixels on every run.
876
+
877
+ ```js
878
+ goto('/chat');
879
+ clockFreeze('2026-01-15T10:00:00Z'); // Date reads this; no timer fires
880
+ screenshot('chat-typing'); // same pixels every run
881
+ clockTick(1500); // 1.5 s pass: due timers fire
882
+ clockRun(); // time runs by itself again
883
+ ```
884
+
885
+ - `clockFreeze(iso)` — `Date.now()` and `new Date()` on the page read
886
+ `iso`; `setTimeout`, `setInterval` and `requestAnimationFrame` stand
887
+ still. Any instant, past or future: no timer fires on the way to it.
888
+ - `clockTick(ms)` — frozen time moves forward by `ms`, and every timer
889
+ due in that span fires, in order.
890
+ - `clockRun()` — time runs by itself again, from where it stands.
891
+
892
+ **When the clock is installed.** A fake clock only controls timers set
893
+ after it is in place, so a run puts it on the browser before the first
894
+ page loads — and only for a scenario that can reach a clock call
895
+ (directly, or through a helper it calls). Every other scenario keeps the
896
+ page's real time, as before. So `clockFreeze` works at any point of the
897
+ scenario, after `goto` included. Over MCP the browser has to be opened
898
+ with the clock: `new_context {clock: true}` (or `explore_start {clock:
899
+ true}`), then `explore_step` actions `clock_freeze {time}`,
900
+ `clock_tick {ms}`, `clock_run`.
901
+
902
+ **Coming from Playwright.** The names are flat: `clock.freeze(...)`,
903
+ `clock.tick(...)`, `clock.run()` (and `pauseAt`, `setFixedTime`,
904
+ `runFor`, `resume`) are refused with the name to use instead. Where
905
+ Playwright's `pauseAt` only moves forward, `clockFreeze` also takes a
906
+ date in the past.
907
+
908
+ Not with `launch.userDataDir`, once its shared context is open: the
909
+ pages already loaded in it keep real timers, so the clock is refused
910
+ there rather than half-applied.
911
+
912
+ ### Time zone and locale
913
+
914
+ `timezone` and `locale` in `unotest.config.mjs` set them for every page:
915
+
916
+ ```js
917
+ export default {
918
+ timezone: 'Europe/Madrid', // Date#toString, Intl, getTimezoneOffset
919
+ locale: 'es-ES', // navigator.language, Intl defaults, Accept-Language
920
+ };
921
+ ```
922
+
923
+ Unset, the page gets the machine's zone and the browser's locale.
924
+ `UNOTEST_TIMEZONE` / `UNOTEST_LOCALE` override the config for one run.
925
+ An unknown zone or a malformed locale is refused when the config loads.
926
+
713
927
  ---
714
928
 
715
929
  ## Multi-context
716
930
 
717
- ### `setPage(index)`
931
+ ### `setPage(index, options?)`
718
932
 
719
933
  Make tab `index` active. After this, every locator and assertion
720
934
  targets that tab. Resets the frame stack — a `setPage` always lands on
721
935
  the new tab's root document, never inside a frame inherited from the
722
936
  previous tab.
723
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
+
724
943
  Tabs open asynchronously: a click that triggers `window.open` (often
725
944
  after a `fetch`) registers the new tab a beat later. `setPage` waits for
726
945
  the tab at `index` to appear, then for its document to load, before
@@ -742,6 +961,216 @@ having to track tab indexes. See
742
961
  `docs/auto-page-switch/README.md`
743
962
  and `UNOTEST_POPUP_GRACE_MS` in `unotest/.env`.
744
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
+
1028
+ ### `newContext()` → `number` / `setContext(index)`
1029
+
1030
+ A second browser context: another client of the same app, with its own
1031
+ cookies, localStorage, sessionStorage and tabs. For anything that takes
1032
+ two users — one closes a ticket, the other still has it open.
1033
+
1034
+ `newContext()` opens one with the same options as the first (base URL,
1035
+ viewport and scale, `device`, time zone, locale, page clock), makes it
1036
+ active, and returns its number: the test's own context is `0`, the next
1037
+ is `1`. Nothing of the first is inherited — no cookie, no storage, no
1038
+ `storageState`. `setContext(i)` makes context `i` active; every locator,
1039
+ assertion, `setPage`, frame and storage call then acts in it. Each
1040
+ context keeps its own active tab and frame stack, so switching away and
1041
+ back lands where the scenario left off.
1042
+
1043
+ ```js
1044
+ function test_second_client_writes_to_closed_thread() {
1045
+ step("Client A opens the thread", () => {
1046
+ flow_login(LOGIN, PASSWORD);
1047
+ goto('/support/thread/42');
1048
+ });
1049
+ step("Client B opens the same thread", () => {
1050
+ clientB = newContext(); // returns 1, becomes active
1051
+ flow_login(LOGIN, PASSWORD);
1052
+ goto('/support/thread/42');
1053
+ });
1054
+ step("Client A closes the ticket", () => {
1055
+ setContext(0); // client A
1056
+ click(getByRole('button', {name: 'Close ticket'}));
1057
+ });
1058
+ step("Client B cannot write", () => {
1059
+ setContext(clientB);
1060
+ fill(getByRole('textbox', {name: 'Message'}), 'hello');
1061
+ press(getByRole('textbox', {name: 'Message'}), 'Enter');
1062
+ assertVisible(getByText('This ticket is closed'));
1063
+ });
1064
+ }
1065
+ ```
1066
+
1067
+ `flow_login` is your project's own sign-in helper (`unotest/e2e/_helpers/`);
1068
+ the rest is the DSL. A variable set inside one `step(...)` is visible
1069
+ in the next — `clientB` above — because a step block has no scope of its
1070
+ own.
1071
+
1072
+ - **Ceiling:** 4 contexts per test, `maxContexts` in the config. The next
1073
+ `newContext()` is refused, naming the key — a loop opening one per
1074
+ iteration stops there.
1075
+ - **End of the test:** every context is closed, in any outcome. One that
1076
+ fails to close is a warning in the run log.
1077
+ - **Journal:** once a second context exists, each step event carries
1078
+ `contextIndex` — the context the step **started** in (so
1079
+ `setContext(0)` issued from context 1 is marked `1`), and the viewer's
1080
+ Trace shows it as a `ctx N` chip. A one-context test writes exactly
1081
+ what it did before.
1082
+ - **Failure bundle:** the page, console and network of the context
1083
+ active at the failure, and `contextIndex` in `failure.json`. The other
1084
+ contexts are not in it: to keep a frame of client A, `screenshot()` it
1085
+ before switching.
1086
+ - **Traces and video:** not recorded for any context — unotest does not
1087
+ capture Playwright traces or video today.
1088
+ - **Browser profile:** with `launch.userDataDir` every page shares one
1089
+ context, so `newContext()` is refused there.
1090
+ - **Debugging:** an agent that joins a paused run (`take_wheel`,
1091
+ `attach_debug_session`) lands in the context the run is on (Chromium).
1092
+ - **Names:** a function or variable of your own called `newContext` /
1093
+ `setContext` (or any DSL name) is reported by `lint:shadows-dsl-function`
1094
+ — your function wins a call by that name, so the DSL's is out of reach
1095
+ until you rename it.
1096
+
1097
+ Over MCP, record it with `explore_step {action: "newContext"}` and
1098
+ `{action: "setContext", index}`; they become the same two calls in the
1099
+ saved test. The `new_context` tool is different: it resets the browser.
1100
+
1101
+ ### `setViewport({width, height})`
1102
+
1103
+ Resize the browser window mid-scenario. Every open tab is resized in
1104
+ place — no reload, the page keeps its state — and a tab opened afterwards
1105
+ starts at the same size. The size belongs to the browser, not to one tab.
1106
+
1107
+ ```js
1108
+ setViewport({width: 420, height: 900}); // a phone-width layout
1109
+ screenshot('card-narrow');
1110
+ setViewport({width: 1280, height: 720});
1111
+ ```
1112
+
1113
+ Both fields are required. Anything else is refused on that line,
1114
+ `deviceScaleFactor` included: the scale is fixed when the browser opens,
1115
+ and changing it would need a new browser context — the page under test
1116
+ would be lost. See [Viewport and scale](#viewport-and-scale).
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
+
1122
+ #### Viewport and scale
1123
+
1124
+ Where the window's size and scale come from, first match wins:
1125
+
1126
+ 1. **The MCP call.** `new_context` and `explore_start` take
1127
+ `viewport: {width, height, deviceScaleFactor}` and `device`. They
1128
+ replace the config's window for that context. `explore_start` with a
1129
+ window while a context is already open is refused — call
1130
+ `new_context` with it instead.
1131
+ 2. **The config.** `unotest.config.mjs`:
1132
+
1133
+ ```js
1134
+ export default {
1135
+ viewport: { width: 390, height: 844, deviceScaleFactor: 2 },
1136
+ };
1137
+ ```
1138
+
1139
+ `deviceScaleFactor: 2` doubles every screenshot: a 390×844 page
1140
+ becomes a 780×1688 PNG, the scale design mockups are usually drawn
1141
+ at. Every field is optional; a missing one comes from the preset or
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.
1158
+ 3. **A device preset.** `device: "iPhone 14"` applies Playwright's
1159
+ preset whole: size, scale, touch, mobile mode, user agent. The name
1160
+ is Playwright's, exactly; an unknown name is refused with the
1161
+ nearest ones. `viewport` next to `device` overrides the preset field
1162
+ by field.
1163
+ 4. **The default:** 1280×720 at scale 1.
1164
+
1165
+ A preset's viewport is the page area, smaller than the device's screen:
1166
+ `"iPhone 14"` is 390×664 (the browser chrome takes the rest of 844). To
1167
+ match a 390×844 mockup, set `viewport` explicitly:
1168
+ `{width: 390, height: 844, deviceScaleFactor: 2}`.
1169
+
1170
+ A mobile preset lays out a page with no `<meta name="viewport">` tag at
1171
+ 980 px wide, as a real phone does. Firefox has no mobile mode: a mobile
1172
+ preset on Firefox is refused — give it a plain `viewport` instead.
1173
+
745
1174
  ### `enterFrame(loc)`
746
1175
 
747
1176
  Enter an iframe scope. Subsequent calls run inside the frame's document.
@@ -756,6 +1185,39 @@ exitFrame();
756
1185
 
757
1186
  Pop one iframe scope. Idempotent at depth 0.
758
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
+
759
1221
  ---
760
1222
 
761
1223
  ## Storage
@@ -891,6 +1353,37 @@ step("Start the listener", () => {
891
1353
  });
892
1354
  ```
893
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
+
894
1387
  ### `waitForFile(path, pattern?, options?)` → `string`
895
1388
 
896
1389
  The file-side analogue of `waitForText` — polls a file until it exists
@@ -986,6 +1479,71 @@ step("Ask control question 3", () => {
986
1479
  background listener's output); its timeout is what makes that safe,
987
1480
  and what would only hide a wrong path here.
988
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
+
989
1547
  ### `waitForFileCount(path, pattern, count, options?)` → `number`
990
1548
 
991
1549
  Wait until **at least** `count` lines match, then return how many there
@@ -1115,6 +1673,97 @@ assertTrue(res.status == 202, 'ingestion not accepted');
1115
1673
 
1116
1674
  ---
1117
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
+
1118
1767
  ## Typed getters
1119
1768
 
1120
1769
  Read page properties directly into a local variable.
@@ -1142,6 +1791,16 @@ source-order concatenation, hidden nodes included.
1142
1791
  Identical to `inputValue`; kept under typed-getters for symmetry with
1143
1792
  agent prompts that prefer the `get*` prefix.
1144
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
+
1145
1804
  ### `getTitle()` → `string`
1146
1805
 
1147
1806
  Active page title.
@@ -1194,6 +1853,44 @@ out = shell('./status.sh');
1194
1853
  assertTrue(textContains(out.stdout, 'ready'), out.stderr);
1195
1854
  ```
1196
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
+
1197
1894
  ### `json(value)` → `string`
1198
1895
 
1199
1896
  Serialize any value to a JSON string. The debug tool for API and data
@@ -1257,6 +1954,15 @@ result = evaluate(
1257
1954
  Backticks are **raw** — `${…}` interpolation is rejected at parse time
1258
1955
  (see `vendor/dsl/EXTENSIONS.md`).
1259
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
+
1260
1966
  ---
1261
1967
 
1262
1968
  ## Diagnostics
@@ -1295,7 +2001,8 @@ screenshot('landing', {fullPage: true}); // full scroll height
1295
2001
  `load`, which on a client-rendered app fires before any content is on
1296
2002
  screen. A page that never goes quiet (polling, live feeds) costs the
1297
2003
  2s cap and is then captured as-is.
1298
- - Not a visual assertion: the test does not compare images. In runtimes
2004
+ - Not a visual assertion: `screenshot()` does not compare images — that is
2005
+ [`assertScreenshot`](#assertscreenshotname-options--assertscreenshotloc-name-options). In runtimes
1299
2006
  with no artifact dir (exploration) the call logs a warning, returns
1300
2007
  `null` and does NOT fail the run.
1301
2008
  - **Every step, without calling it:** `UNOTEST_STEP_SCREENSHOTS=1` makes
@@ -1386,13 +2093,12 @@ The DSL is JavaScript-shaped but parsed by a small AST engine
1386
2093
  ```
1387
2094
 
1388
2095
  - **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;`.
2096
+ - **Index access on data** — `docs[0]`, `docs[0].objectsCount`,
2097
+ `res.body.items[0].id`: see [Strings, objects, indexes](#strings-objects-indexes).
1392
2098
  Indexing a **Locator** is rejected — disambiguate with chain methods
1393
2099
  (`.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.
2100
+ - **Object literals** — as call arguments (`click(loc, {force: true})`)
2101
+ and as values in a variable: see [Strings, objects, indexes](#strings-objects-indexes).
1396
2102
  - **Member access** — `row.email` (dot form; `obj['key']` doesn't
1397
2103
  parse).
1398
2104
  - **Method chains** — `getByRole('row').filter({hasText:'X'}).click()`.
@@ -1409,13 +2115,143 @@ The DSL is JavaScript-shaped but parsed by a small AST engine
1409
2115
  Not supported (and intentionally so): `while` / `do…while` /
1410
2116
  `continue` (bound loops keep every scenario terminating), `++`/`--`,
1411
2117
  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.
2118
+ executor), classes, modules, promises, `try/catch` — for cleanup that
2119
+ must run whatever happens, see [`teardown`](#teardown--give-the-stand-back).
2120
+ Errors thrown from a primitive abort the scenario; if the runtime is
2121
+ paused (debugger / MCP step mode), the failure is captured for
2122
+ inspection.
1415
2123
 
1416
2124
  See `vendor/dsl/EXTENSIONS.md` for the
1417
2125
  exact grammar deltas from the upstream parser.
1418
2126
 
2127
+ ### Strings, objects, indexes
2128
+
2129
+ Since 0.44.0.
2130
+
2131
+ **Strings** answer four members — the whole list:
2132
+
2133
+ | Member | Returns | |
2134
+ |---|---|---|
2135
+ | `s.length` | `number` | a property: no parentheses |
2136
+ | `s.trim()` | `string` | whitespace off both ends |
2137
+ | `s.includes(search)` | `boolean` | substring probe, same as `textContains(s, search)` |
2138
+ | `s.startsWith(prefix)` | `boolean` | |
2139
+
2140
+ They work wherever an expression does — in `assertTrue`, in `if`, on a
2141
+ literal, on a call result:
2142
+
2143
+ ```js
2144
+ title = getTitle();
2145
+ assertTrue(title.trim().length > 0, 'empty title');
2146
+ if (out.stdout.includes('ready')) { log('up'); }
2147
+ assertTrue(getUrl().startsWith('https://'), getUrl());
2148
+ ```
2149
+
2150
+ Any other member is a lint error that names it and lists the four
2151
+ (`validator:unsupported-string-member`); `s.padStart(3)` does not
2152
+ reach the browser. A member of the list used on a value that turns out
2153
+ not to be a string at run time — a number, or `null` from a missing
2154
+ field — fails the step with the value's type in the message.
2155
+
2156
+ **Object literals** are values: keep one in a variable, read it back by
2157
+ field, hand it to a helper.
2158
+
2159
+ ```js
2160
+ user = {name: 'Alice', tags: ['admin'], address: {city: 'Riga'}};
2161
+ flow_sign_up(user);
2162
+ assertTrue(user.address.city == 'Riga');
2163
+ ```
2164
+
2165
+ Where a function wants a specific kind (a string, a locator), an
2166
+ object in that slot is still an `arg-kind` error.
2167
+
2168
+ **Indexes** go anywhere in a chain — no temporary variable:
2169
+
2170
+ ```js
2171
+ res = apiCall('GET', '/documents');
2172
+ assertTrue(res.body.items[0].status == 'ready', json(res.body));
2173
+ first = dbQuery('SELECT id FROM users ORDER BY id')[0].id;
2174
+ ```
2175
+
2176
+ ### `teardown` — give the stand back
2177
+
2178
+ Since 0.44.0.
2179
+
2180
+ ```js
2181
+ function test_ticket_closed_in_second_client() {
2182
+ step("Enable the stand flag", () => {
2183
+ teardown("Disable the stand flag", () => {
2184
+ apiCall('POST', '/stand/flags', {closeTickets: false});
2185
+ });
2186
+ apiCall('POST', '/stand/flags', {closeTickets: true});
2187
+ });
2188
+ step("…", () => { … });
2189
+ }
2190
+ ```
2191
+
2192
+ `teardown("label", () => { … })` **registers** a rollback — nothing in
2193
+ it runs where it stands. After the test body every registered block
2194
+ runs, **newest first**:
2195
+
2196
+ - whatever the outcome — passed, failed, over its time budget, or
2197
+ stopped (Stop in the viewer, `abort_runtime`, Ctrl-C), including a run
2198
+ stopped while paused at its failure in the debugger or in `run_test`;
2199
+ - each block even if an earlier one failed.
2200
+
2201
+ Put it **before** the call that changes the stand: then a failure in
2202
+ that very call is rolled back too. A flow or helper can carry its own
2203
+ rollback (`flow_enable_flag` registers the teardown that disables the
2204
+ flag); it still runs at the end of the test, not when the helper
2205
+ returns, with the helper's variables as they were when it registered.
2206
+ A block in the test itself sees the test's variables as they are when
2207
+ it runs.
2208
+
2209
+ What you see:
2210
+
2211
+ - the CLI prints `↺ teardown "Disable the stand flag"` for each block;
2212
+ the journal and the viewer show it as a frame marked **teardown**
2213
+ after the test's last step;
2214
+ - the failure bundle is taken **before** the rollback — its screenshot is
2215
+ the page the test broke on;
2216
+ - a green test whose teardown failed is **failed** (the stand was not
2217
+ given back); a failed test keeps its own error, and the teardown
2218
+ failures are listed next to it (CLI `↺ teardown "…" failed: …`,
2219
+ `run_test` → `next.teardownFailures`).
2220
+
2221
+ Stopping a run: the first Stop gives the stand back and then ends the
2222
+ run — the viewer shows **returning the stand…**, `abort_runtime` answers
2223
+ `status: "returning the stand…"`. A second Stop (or a second
2224
+ `abort_runtime`) skips what is left; the journal records
2225
+ `teardown skipped: second stop` with the blocks that did not run.
2226
+
2227
+ How long the first Stop can take: the rollback is cut at
2228
+ `teardownTimeoutMs` (60 s by default), and the run ends at the latest
2229
+ 15 s after that, saying so (`stop: the rollback did not finish within
2230
+ …`) — a Stop never hangs silently. A viewer that started the run
2231
+ force-kills it only if it is still alive 5 minutes after the first
2232
+ Stop — a safety net for a stuck process, not a wait for the rollback.
2233
+
2234
+ **When the stand is NOT given back.** A process that is killed
2235
+ (SIGKILL, a crashed machine) runs no teardown at all. Blocks still
2236
+ running when `teardownTimeoutMs` (config, default 60 s for all blocks
2237
+ together) runs out are cut, and so is whatever a second Stop skips.
2238
+ Each such block is reported in the run result as a teardown failure —
2239
+ the stand may be left as the test set it.
2240
+
2241
+ **The run's own budget is a deadline.** `UNOTEST_MAX_DURATION_MS`
2242
+ (default 5 min) ends the test the moment it runs out: an action still
2243
+ running is abandoned there, and one that finished past it fails the
2244
+ test on that line — the last action included. The abandoned browser
2245
+ call lives on until its own timeout or until the context closes; the
2246
+ teardown pass runs anyway, on `teardownTimeoutMs`, not on what the test
2247
+ left of its budget.
2248
+
2249
+ Rules (lint, `teardown-shape`): the label is a string literal; no
2250
+ `return` inside, no `break` out of it (a `break` in a loop inside is
2251
+ fine); no `teardown` inside a `teardown`. Allowed in tests, flows and
2252
+ helpers. A function, parameter or variable of your own named
2253
+ `teardown` is reported as `teardown-name-conflict` — rename it.
2254
+
1419
2255
  ---
1420
2256
 
1421
2257
  ## Conventions