@unotest/web 0.49.0 → 0.49.1

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.
@@ -446,8 +446,16 @@ list that is still filling in passes once it is complete.
446
446
 
447
447
  ```js
448
448
  assertTexts(getByRole('listitem'), ['Apple', 'Banana', /^Cherry/])
449
+ labels = ['Apple', 'Banana'];
450
+ assertTexts(getByRole('listitem'), labels)
451
+ assertText(getByRole('listitem').first(), labels[0])
449
452
  ```
450
453
 
454
+ `expected` is any expression that holds a list: a literal, a variable,
455
+ an index read, a helper's result. A value known to be something else (a
456
+ string, a number) is refused by the linter; the rest is checked when the
457
+ step runs. The same holds for the expected text of `assertText`.
458
+
451
459
  **Options:** `timeout?`, `exact?` (default `true`).
452
460
 
453
461
  ### `assertAttribute(loc, name, expected, options?)`
@@ -610,18 +618,22 @@ function test_live_payment() {
610
618
  }
611
619
  ```
612
620
 
613
- A decision on data the test reads goes after the step that reads it:
621
+ A decision on data the test reads goes after the step that reads it, or
622
+ inside that step:
614
623
 
615
624
  ```js
616
625
  function test_refund() {
617
626
  step("Find a paid order", () => {
618
627
  orders = apiCall('GET', '/orders?status=paid');
628
+ if (orders.length == 0) { skip('no paid order on this stand'); }
619
629
  });
620
- skip('no paid order on this stand', orders.length == 0);
621
630
  // …
622
631
  }
623
632
  ```
624
633
 
634
+ The rest of the test does not run; teardown blocks already registered
635
+ still play.
636
+
625
637
  - The CLI prints `○ test: … skipped: <reason>` and counts it apart:
626
638
  `0 passed, 0 failed, 0 errored, 1 skipped`. The exit code is 0.
627
639
  - A collection counts it as `skipped=N(test=N)`.
@@ -630,9 +642,8 @@ function test_refund() {
630
642
  skipped test whose teardown fails.
631
643
  - The condition is `true` or `false`; anything else fails the test with
632
644
  the value named — compare it: `env('LIVE_API') == null`.
633
- - Call it at the level of the `test_*` body: inside a step or an `if`
634
- block, in a helper or a `flow_*`, it is reported by
635
- `lint:skip-outside-test`.
645
+ - Call it in the `test_*` body — at its top or inside a step or a block.
646
+ In a helper or a `flow_*` it is reported by `lint:skip-outside-test`.
636
647
 
637
648
  ### `assertJudge(loc, rubric)`
638
649
 
@@ -816,6 +827,19 @@ Read state without asserting. No polling.
816
827
 
817
828
  ### `count(loc)` → `number`
818
829
 
830
+ How many elements match now. To count by a condition, narrow the
831
+ locator: by text or by a child — `.filter({...})`; by the element's own
832
+ class or attribute — CSS. `assertCount` / `waitForCount` take the same
833
+ locator and wait for the number.
834
+
835
+ ```js
836
+ done = count(getByRole('row').filter({hasText: 'Done'}));
837
+ admins = count(getByRole('row').filter({has: getByTestId('admin-badge')}));
838
+ selected = count(locator('li.selected'));
839
+ external = count(locator('a[target="_blank"]'));
840
+ assertCount(getByRole('listitem').filter({hasNotText: 'Archived'}), 3)
841
+ ```
842
+
819
843
  ### `textContent(loc)` → `string`
820
844
 
821
845
  Empty string when the element has no text node (does not throw).
@@ -1719,8 +1743,8 @@ step("Send button uses the app's own label", () => {
1719
1743
  - No poll: a missing file fails at once with `file not found: <path>`,
1720
1744
  a file that does not parse with `<path> is not valid JSON: …`.
1721
1745
  - Scenarios cannot import your app's modules, and that is on purpose:
1722
- generate the JSON from them instead (see Scenarios, "App constants in
1723
- scenarios").
1746
+ generate the JSON from them instead — see
1747
+ [App constants in scenarios](https://docs.unotest.com/concepts/scenarios/#app-constants-in-scenarios).
1724
1748
 
1725
1749
  ### `env(name)` → `string | null`
1726
1750
 
@@ -2057,6 +2081,29 @@ assertTrue(computedStyle(getByRole('dialog'), 'opacity') == 1, 'dialog is faded'
2057
2081
  accent = computedStyle(locator('body'), '--accent');
2058
2082
  ```
2059
2083
 
2084
+ ### `getImageSize(loc)` → `{width, height}` | `null`
2085
+
2086
+ The natural size of an `<img>` — the pixels of the file, not its size on
2087
+ the page (that is `boundingBox`). `null` when the image has no pixels:
2088
+ the file is broken, or there is no `src`. The one query that waits: an
2089
+ image still loading is waited for up to the action timeout, so a good
2090
+ image is never read too early as broken.
2091
+
2092
+ - Not an `<img>` fails with a `LocatorError` naming the tag. An inline
2093
+ `<svg>` has no natural size — `boundingBox` gives its rendered one.
2094
+ - Still loading when the timeout runs out fails with a `TimeoutError`.
2095
+ A `loading="lazy"` image below the fold never starts —
2096
+ `scrollIntoView(loc)` it first.
2097
+ - An SVG file without its own `width` / `height` reports the browser's
2098
+ default (300 × 150 in Chromium): check it is not `null`, not the
2099
+ numbers.
2100
+
2101
+ ```js
2102
+ size = getImageSize(getByRole('img', {name: 'Avatar'}));
2103
+ assertTrue(size !== null, 'avatar is broken');
2104
+ assertTrue(size.width >= 256, json(size));
2105
+ ```
2106
+
2060
2107
  ### `hasAnimation(loc)` → `boolean`
2061
2108
 
2062
2109
  Whether the element or a descendant has an animation still running
@@ -2466,7 +2513,17 @@ The DSL is JavaScript-shaped but parsed by a small AST engine
2466
2513
  viewer shows it as its own row under the file. `e2e <file>` plays them
2467
2514
  in order; `e2e <file> --test <name>` plays one. Functions of the file
2468
2515
  are shared between its tests, state is not.
2469
- - **Assignments** — `name = expression;` (no `let`/`const`/`var`).
2516
+ - **Top level of a file — function definitions and `const` literals.**
2517
+ Nothing else there runs: `BASE = 'x'` above `test_*` would leave
2518
+ `BASE` undefined in the test, so `lint` refuses it
2519
+ (`validator:unsupported-statement`). A shared fixed value is a const:
2520
+ `const BASE = 'https://x.test';` — a literal only (number, string,
2521
+ `true` / `false` / `null`, or an array or object of those), visible in
2522
+ every function, as helper functions are. A computed one is a function:
2523
+ `function base_url() { return env('BASE'); }`.
2524
+ - **Assignments** — `name = expression;`, or `let name = …;` /
2525
+ `const name = …;`: see [let, const and lambdas](#let-const-and-lambdas).
2526
+ `var` is refused.
2470
2527
  - **`if` / `else`** and **`for` in the canonical shape**
2471
2528
  `for (i = 0; i < N; i = i + 1) { … }` (increment must be an
2472
2529
  assignment — `i++` is rejected everywhere). A loop over cases, each a
@@ -2490,8 +2547,8 @@ The DSL is JavaScript-shaped but parsed by a small AST engine
2490
2547
  - **Index access on data** — `docs[0]`, `docs[0].objectsCount`,
2491
2548
  `res.body.items[0].id`: see [Strings, objects, indexes](#strings-objects-indexes).
2492
2549
  Indexing a **Locator** is rejected — disambiguate with chain methods
2493
- (`.filter(...)`), and `arr[i] = v` assignment stays unsupported —
2494
- `arr.push(v)` appends: see [Logical not and arrays](#logical-not-and-arrays).
2550
+ (`.filter(...)`). `arr[i] = v` replaces an element, `arr.push(v)`
2551
+ appends, `arr.sortBy(key)` sorts: see [Logical not and arrays](#logical-not-and-arrays).
2495
2552
  - **Object literals** — as call arguments (`click(loc, {force: true})`)
2496
2553
  and as values in a variable: see [Strings, objects, indexes](#strings-objects-indexes).
2497
2554
  - **Member access** — `row.email` (dot form; `obj['key']` doesn't
@@ -2504,6 +2561,9 @@ The DSL is JavaScript-shaped but parsed by a small AST engine
2504
2561
  numeric strings coerce), `===` / `!==` (strict: type AND value must
2505
2562
  match, `1 === "1"` is false), `<` `<=` `>` `>=`, logical `&&` / `||`
2506
2563
  and `!` (see [Logical not and arrays](#logical-not-and-arrays)).
2564
+ `&&` and `||` short-circuit as in JS and give a boolean: the right side
2565
+ is skipped once the left one decides, so `x != null && x.a == 1` is a
2566
+ safe null guard (also inside `waitUntil`).
2507
2567
  Caveat: DOM reads (`textContent`, `getInputValue`, `getAttribute`)
2508
2568
  return **strings** — for comparing against a number use `==`
2509
2569
  (`getAttribute(loc, "data-count") === 5` is always false; write
@@ -2511,13 +2571,20 @@ The DSL is JavaScript-shaped but parsed by a small AST engine
2511
2571
 
2512
2572
  Not supported (and intentionally so): `while` / `do…while` /
2513
2573
  `continue` (bound loops keep every scenario terminating), `++`/`--`,
2514
- arrow functions, async/await (everything is sequenced by the AST
2574
+ functions as values (a lambda goes only into `filter` / `map` / `find` /
2575
+ `some`), async/await (everything is sequenced by the AST
2515
2576
  executor), classes, modules, promises, `try/catch` — for cleanup that
2516
2577
  must run whatever happens, see [`teardown`](#teardown--give-the-stand-back).
2517
2578
  Errors thrown from a primitive abort the scenario; if the runtime is
2518
2579
  paused (debugger / MCP step mode), the failure is captured for
2519
2580
  inspection.
2520
2581
 
2582
+ The only arrows are the fixed ones of `step(...)`, `teardown(...)` and
2583
+ `waitUntil(...)`, and their body may be a block: `waitUntil(() => {
2584
+ n = count(rows); return n > 3; })`. A callback (`a.map(x => …)`), a
2585
+ ternary, `+=`, `for…of`, `o.a = 2` and the like are refused by `lint`
2586
+ with what to write instead.
2587
+
2521
2588
  See `vendor/dsl/EXTENSIONS.md` for the
2522
2589
  exact grammar deltas from the upstream parser.
2523
2590
 
@@ -2549,7 +2616,7 @@ assertText(getByRole('cell', {name: /^Total/}), '1\u00A0250'); // NBSP between
2549
2616
 
2550
2617
  Since 0.44.0.
2551
2618
 
2552
- **Strings** answer four members — the whole list:
2619
+ **Strings** answer these members — the whole list:
2553
2620
 
2554
2621
  | Member | Returns | |
2555
2622
  |---|---|---|
@@ -2557,6 +2624,7 @@ Since 0.44.0.
2557
2624
  | `s.trim()` | `string` | whitespace off both ends |
2558
2625
  | `s.includes(search)` | `boolean` | substring probe, same as `textContains(s, search)` |
2559
2626
  | `s.startsWith(prefix)` | `boolean` | |
2627
+ | `s.split(separator)` | array of strings | as in JS: parts keep their spaces, a missing separator gives one part |
2560
2628
 
2561
2629
  They work wherever an expression does — in `assertTrue`, in `if`, on a
2562
2630
  literal, on a call result:
@@ -2566,9 +2634,10 @@ title = getTitle();
2566
2634
  assertTrue(title.trim().length > 0, 'empty title');
2567
2635
  if (out.stdout.includes('ready')) { log('up'); }
2568
2636
  assertTrue(getUrl().startsWith('https://'), getUrl());
2637
+ ids = 'a, b'.split(', '); // ['a', 'b']
2569
2638
  ```
2570
2639
 
2571
- Any other member is a lint error that names it and lists the four
2640
+ Any other member is a lint error that names it and lists them
2572
2641
  (`validator:unsupported-string-member`); `s.padStart(3)` does not
2573
2642
  reach the browser. A member of the list used on a value that turns out
2574
2643
  not to be a string at run time — a number, or `null` from a missing
@@ -2609,8 +2678,9 @@ assertTrue(!out.stdout.includes('error'), out.stdout);
2609
2678
  ```
2610
2679
 
2611
2680
  **`arr.push(x, …)`** appends to the array in place and returns its new
2612
- length. Other array methods (`pop`, `map`, `join`, …) are not there;
2613
- `length` is.
2681
+ length. Other JS array methods (`pop`, `join`, `reduce`, …) are not
2682
+ there; `length`, `sortBy` and the lambda methods `filter` / `map` /
2683
+ `find` / `some` ([let, const and lambdas](#let-const-and-lambdas)) are.
2614
2684
 
2615
2685
  ```js
2616
2686
  rows = getByRole('row');
@@ -2629,6 +2699,69 @@ that is not an array — a string, a number, a `null` field — fails the
2629
2699
  step with that type in the message; on a string the linter flags it
2630
2700
  first.
2631
2701
 
2702
+ **`arr[i] = v`** replaces element `i`, nested too (`grid[r][c] = v`); on
2703
+ an object it sets the key (`o["k"] = v`). The index is an integer from
2704
+ `0` to `arr.length`: the length itself appends, anything further out
2705
+ fails the step instead of leaving holes. The keys `__proto__`,
2706
+ `constructor` and `prototype` cannot be assigned, and an index read on
2707
+ an object sees its own keys only. Property assignment (`o.k = v`) stays
2708
+ unsupported.
2709
+
2710
+ **`arr.sortBy(key?, {desc?})`** sorts the array in place and returns it.
2711
+ With a key every element is an object and its field is compared;
2712
+ without one (`sortBy()`, or `sortBy(null, {desc: true})`) the elements
2713
+ themselves. The compared values are all numbers or all strings —
2714
+ anything else fails the step. The sort is stable: equal keys keep their
2715
+ order.
2716
+
2717
+ ```js
2718
+ controls = [{id: 'b', order: 2}, {id: 'a', order: 1}];
2719
+ controls.sortBy('order'); // a, b
2720
+ controls.sortBy('order', {desc: true});
2721
+ controls[0] = {id: 'z', order: 0};
2722
+ ```
2723
+
2724
+ ### let, const and lambdas
2725
+
2726
+ **`let x = …;`** and **`const x = …;`** declare a variable. They are the
2727
+ bare assignment `x = …;` with a name for intent: the scope is the
2728
+ function's, as for any variable here — a block does not make one.
2729
+ `for (let i = 0; i < n; i = i + 1)` declares the loop variable. A const
2730
+ needs a value and is not assigned again: `lint` refuses it
2731
+ (`validator:const-assign`) and a run fails at that line, as JavaScript
2732
+ does. The array or object it holds can still change (`ids.push(x)`). `var`
2733
+ is refused.
2734
+
2735
+ A **lambda** — `x => expr`, `(x, i) => expr`, or a block with `return` —
2736
+ goes into exactly four array methods, as their argument:
2737
+
2738
+ | Call | Returns |
2739
+ |---|---|
2740
+ | `a.filter(x => cond)` | a new array of the elements for which `cond` is truthy |
2741
+ | `a.map((x, i) => value)` | a new array of the values |
2742
+ | `a.find(x => cond)` | the first element for which `cond` is truthy, or `null` |
2743
+ | `a.some(x => cond)` | `true` if `cond` is truthy for any element |
2744
+
2745
+ ```js
2746
+ const rows = fetchData('orders');
2747
+ let open = rows.filter(r => r.status == 'open');
2748
+ ids = open.map((r, i) => i + ': ' + r.id);
2749
+ first = rows.find(r => {
2750
+ if (r.total > 100) { return true; }
2751
+ return false;
2752
+ });
2753
+ assertTrue(rows.some(r => r.id == '42'), json(ids));
2754
+ ```
2755
+
2756
+ The parameters live in a scope of their own, like a helper's: the body
2757
+ reads the variables around it, and what it assigns stays inside. The
2758
+ body runs element by element, so a call in it — `textContent(…)`,
2759
+ `count(…)` — is a step of its own. A lambda is not a value: it cannot be
2760
+ assigned, returned or passed anywhere else (`validator:lambda-position`),
2761
+ and on a value that is not an array the step fails. With a lambda,
2762
+ `filter` is the array method; with an options object it is still the
2763
+ locator step `getByRole('row').filter({hasText: 'x'})`.
2764
+
2632
2765
  ### `teardown` — give the stand back
2633
2766
 
2634
2767
  Since 0.44.0.
@@ -2758,8 +2891,21 @@ helpers. A function, parameter or variable of your own named
2758
2891
  file's leading comments, set apart from the first function by a blank
2759
2892
  line, mark every test in it. A marker trailing code (`foo(); //
2760
2893
  @expect-fail`) or inside a body is a comment, not a marker.
2894
+
2895
+ A marked test that fails is reported as failed on purpose:
2896
+ `✗ test: test_x (…ms) — failed on purpose (@expect-fail)` in `e2e`,
2897
+ `N of M failed on purpose` and `expected=N` in a collection, and the
2898
+ viewer shows it as expected rather than failing. A collection whose
2899
+ only failures are on purpose exits 0; `e2e` of such a test still exits
2900
+ 1 — the test did fail. A marked test that passes is a failure: the bug
2901
+ it guarded is gone, so either remove the marker or fix the test.
2761
2902
  - **Helpers stay in `unotest/e2e/_helpers/`** — these are project-owned,
2762
2903
  not part of the core surface. Use sandbox primitives there.
2904
+ - **A helper for one scenario can live in its file.** A function defined
2905
+ at the top level of a scenario is a helper like one in `_helpers/`,
2906
+ seen only by that file: the run calls it and `lint` knows its name. A
2907
+ misspelt call is reported wherever it stands, inside an object
2908
+ argument too (`apiCall('POST', '/pages', {html: page_html()})`).
2763
2909
  - **Two kinds of helper.** A `flow_*` helper is a composite journey —
2764
2910
  sign-in, checkout — and is *executable on its own*: the runner accepts it
2765
2911
  as an entry and the agent can replay it while recording. A plain
@@ -2781,4 +2927,6 @@ helpers. A function, parameter or variable of your own named
2781
2927
  and yields its result (`email = flow_signup()`), a rename hint when it
2782
2928
  only reads.
2783
2929
  - **Lint before you ship** — `npx @unotest/web lint` runs the AST
2784
- linter across `unotest/e2e/**/*.js`.
2930
+ linter across `unotest/e2e/**/*.js`. `lint <name>` checks one scenario,
2931
+ named as `e2e` takes it (`lint values/string-escapes`); a file or a
2932
+ folder works too.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unotest/web",
3
- "version": "0.49.0",
3
+ "version": "0.49.1",
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.49.0",
89
- "@unotest/protocol": "^0.49.0",
90
- "@unotest/dsl": "^0.49.0",
91
- "@unotest/grounder-client": "^0.49.0",
92
- "@unotest/viewer": "^0.49.0"
88
+ "@unotest/core": "^0.49.1",
89
+ "@unotest/dsl": "^0.49.1",
90
+ "@unotest/protocol": "^0.49.1",
91
+ "@unotest/grounder-client": "^0.49.1",
92
+ "@unotest/viewer": "^0.49.1"
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/grounder": "^0.49.0",
100
- "@unotest/judge": "^0.49.0"
99
+ "@unotest/judge": "^0.49.1",
100
+ "@unotest/grounder": "^0.49.1"
101
101
  }
102
102
  }
@@ -187,7 +187,7 @@ declare function assertText(locator: Locator, text: string | RegExp, options?: a
187
187
  *
188
188
  * The locator matches exactly expected.length elements, and element i's text matches expected[i] (string or /regex/, same rules as assertText), in order.
189
189
  */
190
- declare function assertTexts(locator: Locator, texts: unknown[], options?: any): void;
190
+ declare function assertTexts(locator: Locator, expected: unknown[], options?: any): void;
191
191
 
192
192
  /**
193
193
  * assertAttribute(locator, name, expected, { exact?, not?, timeout? })
@@ -546,6 +546,13 @@ declare function hasAttribute(locator: Locator, name: string): boolean;
546
546
  */
547
547
  declare function computedStyle(locator: Locator, property: string): string;
548
548
 
549
+ /**
550
+ * getImageSize(locator) → {width, height} | null
551
+ *
552
+ * Natural size of an <img>, once it has loaded or failed (waits up to the action timeout); null when it has no pixels — broken, or no src. Not an <img> fails.
553
+ */
554
+ declare function getImageSize(locator: Locator): any;
555
+
549
556
  /**
550
557
  * hasAnimation(locator) → bool
551
558
  *