@unotest/web 0.46.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -515,11 +515,16 @@ assertCount(getByRole('listitem'), 5)
515
515
 
516
516
  ### `assertUrl(pattern, options?)`
517
517
 
518
- Substring match against the active page's URL.
518
+ A string is a substring match against the active page's URL. A
519
+ `/regex/` is tested against the whole URL, unanchored: add `^` or `$`
520
+ yourself. A failure names the regex as `/source/flags`.
521
+
522
+ **Options:** `timeout?`.
519
523
 
520
524
  ```js
521
525
  assertUrl('/dashboard')
522
526
  assertUrl('https://example.com/checkout')
527
+ assertUrl(/\/cars\/\d+$/)
523
528
  ```
524
529
 
525
530
  ### `assertTrue(value, message)`
@@ -844,12 +849,16 @@ goto('/dashboard') // relative — resolved against the
844
849
 
845
850
  ### `waitForUrl(pattern, options?)`
846
851
 
847
- Wait until the active page URL contains `pattern`. Useful after a click
848
- that triggers navigation.
852
+ Wait until the active page URL contains `pattern` (a string) or matches
853
+ it (a `/regex/`, unanchored). Useful after a click that triggers
854
+ navigation.
855
+
856
+ **Options:** `timeout?`.
849
857
 
850
858
  ```js
851
859
  click(getByRole('link', {name: 'Settings'}));
852
860
  waitForUrl('/settings');
861
+ waitForUrl(/\/orders\/\d+/);
853
862
  ```
854
863
 
855
864
  ### `waitForNavigation(options?)`
@@ -964,6 +973,23 @@ waitForCount(locator('[data-id]'), 3, {timeout: 90000});
964
973
  waitForText(/\bshipping\b/i, {timeout: 90000});
965
974
  ```
966
975
 
976
+ ### `waitForAnimations(loc, options?)`
977
+
978
+ Wait until the element and its descendants have no animation still
979
+ running — a dialog done fading in, a card done sliding — before reading
980
+ or screenshotting it. Infinite animations (spinners) and paused ones are
981
+ not waited for: neither ends by itself, so waiting could only time out.
982
+ A static element returns at once. Same rule as `hasAnimation`.
983
+
984
+ Options: `{ timeout? }` (default 5000 ms). A timeout is an error naming
985
+ the element and how many animations were still running.
986
+
987
+ ```js
988
+ click(getByRole('button', {name: 'Edit'}));
989
+ waitForAnimations(getByRole('dialog'));
990
+ assertScreenshot(getByRole('dialog'), 'edit-dialog');
991
+ ```
992
+
967
993
  ### `pause(ms)`
968
994
 
969
995
  Hard sleep. **Avoid** — the linter flags it (`lint:pause-explicit`). Use
@@ -1232,6 +1258,27 @@ would be lost. See [Viewport and scale](#viewport-and-scale).
1232
1258
  Playwright keeps it, and nothing hands the page back to the window. For
1233
1259
  the real window, set `viewport: null` in the config.
1234
1260
 
1261
+ ### `emulateMedia({reducedMotion?, colorScheme?})`
1262
+
1263
+ Change the media features the current tab reports, mid-scenario — what
1264
+ `matchMedia` and CSS `@media` see from that moment, without a reload.
1265
+ `reducedMotion`: `'reduce'` | `'no-preference'`; `colorScheme`:
1266
+ `'light'` | `'dark'` | `'no-preference'`. A feature left out keeps its
1267
+ current value. An unknown key, another value or an empty object is
1268
+ refused on that line.
1269
+
1270
+ ```js
1271
+ emulateMedia({reducedMotion: 'reduce'}); // the app's no-animation path
1272
+ click(getByRole('button', {name: 'Edit'}));
1273
+ assertScreenshot(getByRole('dialog'), 'edit-dialog-still');
1274
+ ```
1275
+
1276
+ It applies to **this tab only**: a tab or popup opened afterwards — even
1277
+ by the same browser context — starts without it; call `emulateMedia`
1278
+ again after `setPage` / `waitForPage`. An app that reads the color
1279
+ scheme once at load sees the change on its next load (`reload()`), not
1280
+ before.
1281
+
1235
1282
  #### Viewport and scale
1236
1283
 
1237
1284
  Where the window's size and scale come from, first match wins:
@@ -1863,6 +1910,10 @@ mockRoute('**/ads/**', { abort: true });
1863
1910
  navigation to your site still loads. Only the document: its scripts,
1864
1911
  styles, images and API calls are ordinary requests, and an abort mask
1865
1912
  that matches them stops them.
1913
+ - A mask reaches web requests only: http(s) and ws(s). An extension's
1914
+ own pages and scripts (`chrome-extension://`) load under `**/*`, so
1915
+ a network allowlist does not leave its UI dead. To mock one of them,
1916
+ start the mask with its scheme: `chrome-extension://<id>/panel.js`.
1866
1917
 
1867
1918
  A network allowlist is two masks — the later one wins where both match.
1868
1919
  The second one lets through everything your app loads from its own
@@ -1957,6 +2008,29 @@ box = boundingBox(getByRole('dialog'));
1957
2008
  assertTrue(box.width <= 480, json(box));
1958
2009
  ```
1959
2010
 
2011
+ ### `computedStyle(loc, property)` → `string`
2012
+
2013
+ The element's computed CSS value, as the browser resolved it — what
2014
+ `getComputedStyle(el).getPropertyValue(property)` gives, trimmed.
2015
+ Custom properties read too, inherited ones included; a property that is
2016
+ not set reads `""`. A missing or ambiguous element fails as
2017
+ `getAttribute` does.
2018
+
2019
+ ```js
2020
+ assertTrue(computedStyle(getByRole('dialog'), 'opacity') == 1, 'dialog is faded');
2021
+ accent = computedStyle(locator('body'), '--accent');
2022
+ ```
2023
+
2024
+ ### `hasAnimation(loc)` → `boolean`
2025
+
2026
+ Whether the element or a descendant has an animation still running
2027
+ towards an end. Infinite (spinners) and paused animations do not count —
2028
+ the rule `waitForAnimations` waits by.
2029
+
2030
+ ```js
2031
+ assertTrue(hasAnimation(getByRole('dialog')) === false, 'dialog still moving');
2032
+ ```
2033
+
1960
2034
  ### `getTitle()` → `string`
1961
2035
 
1962
2036
  Active page title.
@@ -2047,6 +2121,60 @@ src = getAttribute(locator('iframe#chat'), 'src');
2047
2121
  assertEqual(urlParam(src, 'roomId'), 'r-7');
2048
2122
  ```
2049
2123
 
2124
+ ### `toNumber(text, options?)` → `number`
2125
+
2126
+ A number as a page writes it, for arithmetic and comparison. What
2127
+ `parseInt`, `parseFloat` and `Number` would be in JS.
2128
+
2129
+ - Spaces group digits, NBSP and the narrow NBSP too: `'1 234 567'`.
2130
+ - A leading sign: `-`, `+` or the typographic minus `−`.
2131
+ - With both `,` and `.`, the last one is the decimal mark:
2132
+ `'1,234.56'` and `'1.234,56'` are both 1234.56. One mark used twice
2133
+ groups: `'1,234,567'`. One mark used once is the decimal mark:
2134
+ `'12,5'`, `'0.125'`.
2135
+ - `'1,234'` and `'1.234'` read both ways (1234 or 1.234), so they fail
2136
+ the step unless `{decimal: ','}` or `{decimal: '.'}` says which. With
2137
+ `{decimal}` the other mark may only group digits left of it.
2138
+ - Anything else fails the step and quotes the input: units and
2139
+ currency (`'440px'`, `'€12'`), exponents, an empty string. Cut the
2140
+ number out first with `textMatches`.
2141
+ - A number passes through unchanged.
2142
+
2143
+ ```js
2144
+ total = toNumber(getInnerText(getByTestId('total'))); // "1 234,50"
2145
+ assertTrue(total > 1000, 'total: ' + total);
2146
+ width = toNumber(textMatches(computedStyle(phone, '--phone-w'), /(\d+)px/)[1]);
2147
+ ```
2148
+
2149
+ ### `round(n, digits?)` → `number`
2150
+
2151
+ Round to `digits` decimals, 0 to 10, default 0. Half goes away from
2152
+ zero, on the decimal as written: `round(2.5)` is 3, `round(-2.5)` is
2153
+ -3, `round(1.005, 2)` is 1.01. A string fails: read it with
2154
+ `toNumber` first.
2155
+
2156
+ ```js
2157
+ payout = round(stake * odds, 2);
2158
+ ```
2159
+
2160
+ ### `formatNumber(n, options?)` → `string`
2161
+
2162
+ A number as text, with digit grouping, through `Intl.NumberFormat`.
2163
+
2164
+ **Options:** `locale?` (default `'en-US'`), `minFractionDigits?`,
2165
+ `maxFractionDigits?` (0 to 20; without them, up to 3 decimals).
2166
+
2167
+ - Without a locale the answer is the same on every machine, whatever
2168
+ its system language: `formatNumber(1234.5)` is `'1,234.5'`.
2169
+ - With another locale the text comes from the ICU data of the Node
2170
+ that runs the scenario, spaces included (`fr-FR` puts a narrow NBSP
2171
+ between thousands). Compare it with what the page shows rather than
2172
+ with a string typed in the scenario.
2173
+
2174
+ ```js
2175
+ assertText(getByTestId('payout'), formatNumber(payout, {minFractionDigits: 2, maxFractionDigits: 2}));
2176
+ ```
2177
+
2050
2178
  ### `json(value)` → `string`
2051
2179
 
2052
2180
  Serialize any value to a JSON string. The debug tool for API and data
@@ -2115,10 +2243,20 @@ The body gets the element first, then the extras by the same rule: one
2115
2243
  → the value itself, two or more → one array.
2116
2244
 
2117
2245
  ```js
2118
- color = evaluateOnLocator(getByRole('alert'), `(el) => getComputedStyle(el).color`);
2119
2246
  ok = evaluateOnLocator(getByRole('main'), `(el, [a, b]) => el.children.length > a + b`, 1, 2);
2120
2247
  ```
2121
2248
 
2249
+ Reach for it only for what no typed call reads — a style is
2250
+ `computedStyle`, a box is `boundingBox`. Overflow is one such case: is
2251
+ the text cut off? There is no getter for `scrollWidth`, so the line says
2252
+ why it uses the hatch, and the linter takes the reason:
2253
+
2254
+ ```js
2255
+ // Truncated with an ellipsis: the content is wider than the cell.
2256
+ overflows = evaluateOnLocator(getByRole('cell', {name: /^R-00001/}), `(el) => el.scrollWidth > el.clientWidth`); // lint-ok: no typed getter reads scrollWidth
2257
+ assertTrue(overflows === false, 'the id is cut off');
2258
+ ```
2259
+
2122
2260
  ---
2123
2261
 
2124
2262
  ## Diagnostics
@@ -2315,7 +2453,8 @@ The DSL is JavaScript-shaped but parsed by a small AST engine
2315
2453
  parse).
2316
2454
  - **Method chains** — `getByRole('row').filter({hasText:'X'}).click()`.
2317
2455
  - **Backticks** — multi-line raw strings, no `${}` interpolation.
2318
- - **Booleans, numbers, strings, `null`**.
2456
+ - **Booleans, numbers, strings, `null`** — escapes in strings: see
2457
+ [Escape rules](#escape-rules).
2319
2458
  - **Comparison operators** — `==` / `!=` (loose: `"1" == 1` is true,
2320
2459
  numeric strings coerce), `===` / `!==` (strict: type AND value must
2321
2460
  match, `1 === "1"` is false), `<` `<=` `>` `>=`, logical `&&` / `||`.
@@ -2336,6 +2475,30 @@ inspection.
2336
2475
  See `vendor/dsl/EXTENSIONS.md` for the
2337
2476
  exact grammar deltas from the upstream parser.
2338
2477
 
2478
+ ### Escape rules
2479
+
2480
+ In a quoted string (`'…'` or `"…"`):
2481
+
2482
+ | Escape | Gives |
2483
+ |---|---|
2484
+ | `\n` `\t` `\r` `\f` `\v` | the control character |
2485
+ | `\\` | one backslash |
2486
+ | `\'` in `'…'`, `\"` in `"…"` | the quote |
2487
+ | `\0` (no digit after it) | the NUL character |
2488
+ | `\xHH` | the character with that hex code: `"\xA0"` is a non-breaking space |
2489
+ | `\uXXXX` | the same with four hex digits: `'\u00A0'` |
2490
+
2491
+ A malformed `\x` / `\u` (`'\u12'`, `'\xG1'`) is a parse error at the
2492
+ backslash. Any other backslash pair stays **as written** — `'\d+'` is
2493
+ three characters, which is what a regex built from a string needs. The
2494
+ same goes for `\b`: a word boundary there, never a backspace. `\01` and
2495
+ the other quote (`"\'"`) are kept as written too. Backtick strings are raw: no
2496
+ escape decodes inside `` `…` ``.
2497
+
2498
+ ```js
2499
+ assertText(getByRole('cell', {name: /^Total/}), '1\u00A0250'); // NBSP between digit groups
2500
+ ```
2501
+
2339
2502
  ### Strings, objects, indexes
2340
2503
 
2341
2504
  Since 0.44.0.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unotest/web",
3
- "version": "0.46.0",
3
+ "version": "0.47.0",
4
4
  "description": "AI-native E2E testing for web applications. MCP server (run_test / step / resume / inspect_runtime / agent_fix) + CLI runner + JavaScript DSL scenarios on a sandboxed AST engine + semantic DOM snapshots.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -85,18 +85,18 @@
85
85
  "tsx": "^4.19.2",
86
86
  "yaml": "^2.6.0",
87
87
  "zod": "^3.23.8",
88
- "@unotest/core": "^0.46.0",
89
- "@unotest/dsl": "^0.46.0",
90
- "@unotest/protocol": "^0.46.0",
91
- "@unotest/viewer": "^0.46.0",
92
- "@unotest/grounder-client": "^0.46.0"
88
+ "@unotest/grounder-client": "^0.47.0",
89
+ "@unotest/dsl": "^0.47.0",
90
+ "@unotest/core": "^0.47.0",
91
+ "@unotest/protocol": "^0.47.0",
92
+ "@unotest/viewer": "^0.47.0"
93
93
  },
94
94
  "devDependencies": {
95
95
  "@types/node": "^22.10.0",
96
96
  "javascript-obfuscator": "^4.1.1",
97
97
  "tsup": "^8.5.1",
98
98
  "typescript": "^5.7.2",
99
- "@unotest/judge": "^0.46.0",
100
- "@unotest/grounder": "^0.46.0"
99
+ "@unotest/grounder": "^0.47.0",
100
+ "@unotest/judge": "^0.47.0"
101
101
  }
102
102
  }
@@ -234,9 +234,9 @@ declare function assertCount(locator: Locator, count: number): void;
234
234
  /**
235
235
  * assertUrl(pattern, { timeout? })
236
236
  *
237
- * Current URL contains `pattern`.
237
+ * Current URL contains `pattern` (a string) or matches it (a /regex/, unanchored). A failure names the pattern as /source/flags.
238
238
  */
239
- declare function assertUrl(pattern: string): void;
239
+ declare function assertUrl(pattern: string | RegExp, options?: any): void;
240
240
 
241
241
  /**
242
242
  * assertJudge(locator, rubric)
@@ -413,6 +413,13 @@ declare function closePage(index?: number): void;
413
413
  */
414
414
  declare function setViewport(size: any): void;
415
415
 
416
+ /**
417
+ * emulateMedia({ reducedMotion?, colorScheme? })
418
+ *
419
+ * Change the media features the current tab reports from now on — what matchMedia and CSS @media see: reducedMotion 'reduce' | 'no-preference', colorScheme 'light' | 'dark' | 'no-preference'. This tab only: a tab or popup opened later starts without it.
420
+ */
421
+ declare function emulateMedia(media: any): void;
422
+
416
423
  /**
417
424
  * newContext() → number
418
425
  *
@@ -472,9 +479,9 @@ declare function goForward(): void;
472
479
  /**
473
480
  * waitForUrl(pattern, { timeout? })
474
481
  *
475
- * Wait until the URL contains `pattern` (substring match).
482
+ * Wait until the URL contains `pattern` (a string, substring match) or matches it (a /regex/, unanchored).
476
483
  */
477
- declare function waitForUrl(pattern: string): void;
484
+ declare function waitForUrl(pattern: string | RegExp, options?: any): void;
478
485
 
479
486
  /**
480
487
  * waitForNavigation({ timeout? })
@@ -532,6 +539,20 @@ declare function isEnabled(locator: Locator): boolean;
532
539
  */
533
540
  declare function hasAttribute(locator: Locator, name: string): boolean;
534
541
 
542
+ /**
543
+ * computedStyle(locator, property) → string
544
+ *
545
+ * The element's computed CSS value, as the browser resolved it: computedStyle(card, "opacity") → "0.5", custom properties too ("--accent"). "" when the property is not set.
546
+ */
547
+ declare function computedStyle(locator: Locator, property: string): string;
548
+
549
+ /**
550
+ * hasAnimation(locator) → bool
551
+ *
552
+ * Whether the element or a descendant has an animation still running towards an end. Infinite (spinners) and paused ones do not count — the same rule waitForAnimations waits by.
553
+ */
554
+ declare function hasAnimation(locator: Locator): boolean;
555
+
535
556
  /**
536
557
  * randomFirstName() → string
537
558
  *
@@ -630,6 +651,27 @@ declare function parseJson(text: string): any;
630
651
  */
631
652
  declare function urlParam(url: string, key: string): any;
632
653
 
654
+ /**
655
+ * toNumber(text, { decimal? }) → number
656
+ *
657
+ * A number as a page writes it: '1 234,5', '1,234.56', '−12'. Spaces (NBSP too) group digits; with both ',' and '.' the last one is the decimal mark; one mark used twice groups. '1,234' and '1.234' read both ways and FAIL unless {decimal: ',' | '.'} says which. Units and currency ('440px', '€12') FAIL, quoting the input — cut the number out with textMatches first. A number passes through.
658
+ */
659
+ declare function toNumber(text: any, options?: any): number;
660
+
661
+ /**
662
+ * round(n, digits?) → number
663
+ *
664
+ * Round to `digits` decimals (0..10, default 0), half away from zero on the decimal as written: round(2.5) → 3, round(-2.5) → -3, round(1.005, 2) → 1.01.
665
+ */
666
+ declare function round(n: number, digits?: number): number;
667
+
668
+ /**
669
+ * formatNumber(n, { locale?, minFractionDigits?, maxFractionDigits? }) → string
670
+ *
671
+ * A number as text, via Intl.NumberFormat with digit grouping. Without a locale it is en-US on every machine: formatNumber(1234.5) → '1,234.5'. Another locale's output (its spaces and marks) comes from the ICU of the Node that runs it.
672
+ */
673
+ declare function formatNumber(n: number, options?: any): string;
674
+
633
675
  /**
634
676
  * env(name) → string | null
635
677
  *
@@ -903,6 +945,13 @@ declare function waitForText(text: string | RegExp, options?: any): void;
903
945
  */
904
946
  declare function waitForCount(locator: Locator, count: number, options?: any): void;
905
947
 
948
+ /**
949
+ * waitForAnimations(locator, { timeout? })
950
+ *
951
+ * Wait until the element and its descendants have no animation still running — a dialog done fading in, a card done sliding. Infinite ones (spinners) and paused ones are not waited for. Default timeout 5000 ms; a timeout names the element and how many were left.
952
+ */
953
+ declare function waitForAnimations(locator: Locator, options?: any): void;
954
+
906
955
  /**
907
956
  * pause(ms)
908
957
  *
@@ -941,7 +990,7 @@ declare function clockRun(): void;
941
990
  /**
942
991
  * mockRoute(glob, response)
943
992
  *
944
- * From now on, requests matching `glob` (a Playwright URL glob: `**\/api/items*`) get a fixed answer: `{status?, body?, headers?, contentType?}` (an object body goes as JSON), `{abort: true, record?}` or `{continue: true}` (to the network). Where several masks match, the one mocked last answers; mocking a mask again replaces its answer. A response to `**\/*` is refused; the site's own page is never aborted. Mocks last until the scenario ends.
993
+ * From now on, requests matching `glob` (a Playwright URL glob: `**\/api/items*`) get a fixed answer: `{status?, body?, headers?, contentType?}` (an object body goes as JSON), `{abort: true, record?}` or `{continue: true}` (to the network). Where several masks match, the one mocked last answers; mocking a mask again replaces its answer. A response to `**\/*` is refused; the site's own page is never aborted. A mask reaches http(s) and ws(s) only, so an extension's own pages and scripts load under `**\/*`; to mock them, start the mask with their scheme (`chrome-extension://<id>/x.js`). Mocks last until the scenario ends.
945
994
  */
946
995
  declare function mockRoute(glob: string, response: any): void;
947
996