@unotest/web 0.47.0 → 0.49.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.
@@ -79,6 +79,11 @@ step.soft("label", {tag: <expr>}, () => { … });
79
79
  with the next statement and records nothing — same as resuming past
80
80
  any failure on web.
81
81
 
82
+ Porting a `@playwright/test` spec with several `test(...)` calls — one
83
+ `step.soft` per former test — and running one test over a table or a
84
+ JSONL file of cases are worked through in
85
+ [Port a @playwright/test spec](https://docs.unotest.com/guides/port-a-spec/).
86
+
82
87
  The data-driven gate, in full (`readJsonLine` reads the cases; the outer
83
88
  `step` satisfies step coverage):
84
89
 
@@ -285,11 +290,15 @@ Each requires a locator as the receiver / first argument.
285
290
 
286
291
  ### `filter(options)`
287
292
 
288
- Narrow down a locator. `hasText`, `hasNotText`, `has`, `hasNot`.
293
+ Narrow down a locator. `hasText`, `hasNotText`, `has`, `hasNot` — any
294
+ other key is refused, at lint time (`lint:filter-unknown-option`) and at
295
+ run time. A class or an attribute of the element itself goes into a CSS
296
+ `locator(...)` instead.
289
297
 
290
298
  ```js
291
299
  getByRole('row').filter({hasText: 'Alice'})
292
300
  getByRole('row').filter({has: getByTestId('admin-badge')})
301
+ locator('tr.done') // own class: CSS, not filter
293
302
  ```
294
303
 
295
304
  ### `first()`, `last()`, `nth(index)`
@@ -459,9 +468,11 @@ assertAttribute(getByRole('form'), 'aria-busy', 'true', {not: true})
459
468
 
460
469
  ### `assertVisible(loc, options?)` / `assertHidden(loc, options?)`
461
470
 
471
+ **Options:** `timeout?`.
472
+
462
473
  ```js
463
474
  assertVisible(getByTestId('toast'))
464
- assertHidden(getByTestId('spinner'))
475
+ assertHidden(getByTestId('spinner'), {timeout: 10000})
465
476
  ```
466
477
 
467
478
  ### `assertNeverAppears(loc, options)`
@@ -486,6 +497,8 @@ the whole window.
486
497
 
487
498
  Compares `inputValue()` (`<input>`, `<textarea>`, native `<select>`).
488
499
 
500
+ **Options:** `timeout?`.
501
+
489
502
  ```js
490
503
  assertValue(getByLabel('Email'), 'a@b.c')
491
504
  ```
@@ -509,8 +522,11 @@ value, and so does the failure.
509
522
 
510
523
  ### `assertCount(loc, expected, options?)`
511
524
 
525
+ **Options:** `timeout?`.
526
+
512
527
  ```js
513
528
  assertCount(getByRole('listitem'), 5)
529
+ assertCount(getByRole('row'), 20, {timeout: 15000})
514
530
  ```
515
531
 
516
532
  ### `assertUrl(pattern, options?)`
@@ -541,7 +557,7 @@ assertTrue(probe.code == 0, 'app.log should contain "ready"');
541
557
  Deep equality, the way JSON sees the two values: key order does not
542
558
  count, and a missing key differs from a key set to `null`. The failure
543
559
  names the first path that differs and both values, then a line diff of
544
- the two sides:
560
+ the two sides, every line of them — nothing is folded away:
545
561
 
546
562
  ```js
547
563
  body = parseJson(getAttribute(locator('#state'), 'data-json'));
@@ -1254,9 +1270,14 @@ Both fields are required. Anything else is refused on that line,
1254
1270
  and changing it would need a new browser context — the page under test
1255
1271
  would be lost. See [Viewport and scale](#viewport-and-scale).
1256
1272
 
1257
- `setViewport(null)` is refused too: once a size is set on an open page,
1258
- Playwright keeps it, and nothing hands the page back to the window. For
1259
- the real window, set `viewport: null` in the config.
1273
+ `setViewport(null)` runs the test on the real window — but only before
1274
+ the test does anything in its browser: the first navigation, `mockRoute`,
1275
+ a cookie or storage value, the page clock, `emulateMedia`, a second page
1276
+ or context. It opens the browser context again with the same options;
1277
+ after any of those it is refused, since the new context would lose them.
1278
+ On a browser profile (`launch.userDataDir`, `launch.profile`) it is
1279
+ refused always. For a whole suite, set `viewport: null` in the config;
1280
+ for the runs of one collection, in its YAML (below).
1260
1281
 
1261
1282
  ### `emulateMedia({reducedMotion?, colorScheme?})`
1262
1283
 
@@ -1288,7 +1309,21 @@ Where the window's size and scale come from, first match wins:
1288
1309
  replace the config's window for that context. `explore_start` with a
1289
1310
  window while a context is already open is refused — call
1290
1311
  `new_context` with it instead.
1291
- 2. **The config.** `unotest.config.mjs`:
1312
+ 2. **The collection.** `viewport:` in `unotest/e2e/_collections/<name>.yaml`
1313
+ applies to every scenario that collection runs; the same scenario run
1314
+ on its own keeps the config's window. `null` is the real window,
1315
+ `{width, height}` a size (whole pixels, both required):
1316
+
1317
+ ```yaml
1318
+ viewport: null
1319
+ scenarios:
1320
+ - shots/gallery
1321
+ ```
1322
+
1323
+ It works with a browser profile, where `setViewport(null)` cannot. A
1324
+ `viewport: null` here over a config `device` is refused before the
1325
+ first scenario starts, as in the config.
1326
+ 3. **The config.** `unotest.config.mjs`:
1292
1327
 
1293
1328
  ```js
1294
1329
  export default {
@@ -1315,12 +1350,12 @@ Where the window's size and scale come from, first match wins:
1315
1350
 
1316
1351
  A `device` next to `viewport: null` is refused: a preset is a size and
1317
1352
  a scale of its own.
1318
- 3. **A device preset.** `device: "iPhone 14"` applies Playwright's
1353
+ 4. **A device preset.** `device: "iPhone 14"` applies Playwright's
1319
1354
  preset whole: size, scale, touch, mobile mode, user agent. The name
1320
1355
  is Playwright's, exactly; an unknown name is refused with the
1321
1356
  nearest ones. `viewport` next to `device` overrides the preset field
1322
1357
  by field.
1323
- 4. **The default:** 1280×720 at scale 1.
1358
+ 5. **The default:** 1280×720 at scale 1.
1324
1359
 
1325
1360
  A preset's viewport is the page area, smaller than the device's screen:
1326
1361
  `"iPhone 14"` is 390×664 (the browser chrome takes the rest of 844). To
@@ -1367,9 +1402,10 @@ goto('chrome-extension://' + id + '/popup.html');
1367
1402
  `chrome-extension://<id>/sw.js`. A running worker comes back at once;
1368
1403
  otherwise it waits up to `timeoutMs` (default: the navigation timeout).
1369
1404
  `url` — a substring or `/regex/` when several extensions run.
1370
- - `evaluateInServiceWorker(js, ...args)` — `evaluate` in the worker, same
1371
- argument rules (one extra → the value, two or more → ONE array) and
1372
- JSON return; a returned Promise is awaited. Not discouraged by the
1405
+ - `evaluateInServiceWorker(js, ...args)` — `evaluate` in the worker. The
1406
+ extras reach the body by the same rule as `evaluate`: none →
1407
+ `undefined`, one → the value itself, two or more → one array. JSON
1408
+ return; a returned Promise is awaited. Not discouraged by the
1373
1409
  linter: `chrome.*` has no other way in.
1374
1410
  - `extensionId()` — the id out of the worker's URL.
1375
1411
  - MV3 stops an idle worker after ~30 s. Each call finds the worker anew,
@@ -2226,12 +2262,16 @@ getters or the action API.
2226
2262
  height = evaluate(`document.documentElement.scrollHeight`);
2227
2263
  ```
2228
2264
 
2229
- Multi-arg form (args destructured in the page-side function):
2265
+ The extra arguments reach the body by one rule: none → `undefined`,
2266
+ exactly one → the value itself, two or more → ONE array the body
2267
+ destructures. So one argument is `function(x)`, not `function([x])` —
2268
+ that one fails with `2 is not iterable`.
2230
2269
 
2231
2270
  ```js
2271
+ doubled = evaluate(`function(n) { return n * 2; }`, 21); // one → the value
2232
2272
  result = evaluate(
2233
2273
  `function([a, b]) { return window.myApp.compute(a, b); }`,
2234
- 10, 20
2274
+ 10, 20 // two → one array
2235
2275
  );
2236
2276
  ```
2237
2277
 
@@ -2239,8 +2279,9 @@ Backticks are **raw** — `${…}` interpolation is rejected at parse time
2239
2279
  (see `vendor/dsl/EXTENSIONS.md`).
2240
2280
 
2241
2281
  `evaluateOnLocator(loc, js, ...args)` — the same hatch on one element.
2242
- The body gets the element first, then the extras by the same rule: one
2243
- → the value itself, two or more → one array.
2282
+ The body gets the element first, then the extras by the same rule as
2283
+ `evaluate`: none → `undefined`, one → the value itself, two or more → one
2284
+ array.
2244
2285
 
2245
2286
  ```js
2246
2287
  ok = evaluateOnLocator(getByRole('main'), `(el, [a, b]) => el.children.length > a + b`, 1, 2);
@@ -2408,6 +2449,10 @@ step.soft("Question", {tag: q.id}, () => {
2408
2449
  that called the helper.
2409
2450
  - Without a run journal (exploration, an ad-hoc runtime) `note` goes to
2410
2451
  the logger instead; nothing fails.
2452
+ - When a statement fails, the notes of its `step(...)` block (the
2453
+ innermost one, in call order) come with the failure: under the error
2454
+ in the `e2e` output as `note label: value`, and as `notes` in the
2455
+ failure `run_test` returns. A green step prints nothing extra.
2411
2456
 
2412
2457
  ---
2413
2458
 
@@ -2416,17 +2461,16 @@ step.soft("Question", {tag: q.id}, () => {
2416
2461
  The DSL is JavaScript-shaped but parsed by a small AST engine
2417
2462
  (`vendor/dsl/`), not V8. What's supported in scenario files:
2418
2463
 
2419
- - **`function test_*(): { … }`** — the scenario entry, **exactly one per
2420
- file**: a collection runs files, and the viewer projects a run onto
2421
- the first `test_*` it finds. A second `test_*` is reported by
2422
- `lint:one-test-per-file` (a warning in 0.31, an error in a later
2423
- release) — fold the cases into one test with tagged steps
2424
- (`step("…", {tag: id}, …)`), or move shared journeys to
2425
- `unotest/e2e/_helpers/`.
2464
+ - **`function test_*(): { … }`** — a test. A file may hold several; each
2465
+ is its own run with its own browser context, retry and verdict, and the
2466
+ viewer shows it as its own row under the file. `e2e <file>` plays them
2467
+ in order; `e2e <file> --test <name>` plays one. Functions of the file
2468
+ are shared between its tests, state is not.
2426
2469
  - **Assignments** — `name = expression;` (no `let`/`const`/`var`).
2427
2470
  - **`if` / `else`** and **`for` in the canonical shape**
2428
2471
  `for (i = 0; i < N; i = i + 1) { … }` (increment must be an
2429
- assignment — `i++` is rejected everywhere).
2472
+ assignment — `i++` is rejected everywhere). A loop over cases, each a
2473
+ tagged soft step: [Port a @playwright/test spec](https://docs.unotest.com/guides/port-a-spec/).
2430
2474
  - **`break`** — inside a loop body only. Typical polling loop:
2431
2475
 
2432
2476
  ```js
@@ -2446,7 +2490,8 @@ The DSL is JavaScript-shaped but parsed by a small AST engine
2446
2490
  - **Index access on data** — `docs[0]`, `docs[0].objectsCount`,
2447
2491
  `res.body.items[0].id`: see [Strings, objects, indexes](#strings-objects-indexes).
2448
2492
  Indexing a **Locator** is rejected — disambiguate with chain methods
2449
- (`.filter(...)`), and `arr[i] = v` assignment stays unsupported.
2493
+ (`.filter(...)`), and `arr[i] = v` assignment stays unsupported —
2494
+ `arr.push(v)` appends: see [Logical not and arrays](#logical-not-and-arrays).
2450
2495
  - **Object literals** — as call arguments (`click(loc, {force: true})`)
2451
2496
  and as values in a variable: see [Strings, objects, indexes](#strings-objects-indexes).
2452
2497
  - **Member access** — `row.email` (dot form; `obj['key']` doesn't
@@ -2457,7 +2502,8 @@ The DSL is JavaScript-shaped but parsed by a small AST engine
2457
2502
  [Escape rules](#escape-rules).
2458
2503
  - **Comparison operators** — `==` / `!=` (loose: `"1" == 1` is true,
2459
2504
  numeric strings coerce), `===` / `!==` (strict: type AND value must
2460
- match, `1 === "1"` is false), `<` `<=` `>` `>=`, logical `&&` / `||`.
2505
+ match, `1 === "1"` is false), `<` `<=` `>` `>=`, logical `&&` / `||`
2506
+ and `!` (see [Logical not and arrays](#logical-not-and-arrays)).
2461
2507
  Caveat: DOM reads (`textContent`, `getInputValue`, `getAttribute`)
2462
2508
  return **strings** — for comparing against a number use `==`
2463
2509
  (`getAttribute(loc, "data-count") === 5` is always false; write
@@ -2548,6 +2594,41 @@ assertTrue(res.body.items[0].status == 'ready', json(res.body));
2548
2594
  first = dbQuery('SELECT id FROM users ORDER BY id')[0].id;
2549
2595
  ```
2550
2596
 
2597
+ ### Logical not and arrays
2598
+
2599
+ Since 0.48.0.
2600
+
2601
+ **`!x`** negates, as in JS: the result is always `true` or `false`, and
2602
+ `0`, `""`, `null` and `false` are the falsy values — the same ones `if`
2603
+ treats as false. An array or an object is truthy, empty or not. `!`
2604
+ binds tighter than any other operator: `!a && b` is `(!a) && b`.
2605
+
2606
+ ```js
2607
+ if (!isVisible(getByRole('dialog'))) { log('dialog closed'); }
2608
+ assertTrue(!out.stdout.includes('error'), out.stdout);
2609
+ ```
2610
+
2611
+ **`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.
2614
+
2615
+ ```js
2616
+ rows = getByRole('row');
2617
+ ids = [];
2618
+ for (i = 0; i < count(rows); i = i + 1) {
2619
+ ids.push(textContent(rows.nth(i)));
2620
+ }
2621
+ assertTrue(ids.length > 0, json(ids));
2622
+ ```
2623
+
2624
+ A variable holds the array itself, not a copy, as in JS: after
2625
+ `b = a; a.push(1)`, `b` has the new element too. So does a caller whose
2626
+ array a helper pushed onto. A helper that assigns a new array to its
2627
+ parameter (`list = []`) changes only its own name. `push` on a value
2628
+ that is not an array — a string, a number, a `null` field — fails the
2629
+ step with that type in the message; on a string the linter flags it
2630
+ first.
2631
+
2551
2632
  ### `teardown` — give the stand back
2552
2633
 
2553
2634
  Since 0.44.0.
@@ -2656,10 +2737,27 @@ helpers. A function, parameter or variable of your own named
2656
2737
  ## Conventions
2657
2738
 
2658
2739
  - **Snake-case test names** — `function test_login_happy_path()`.
2659
- - **One scenario file = one test.** The file is the unit a collection
2660
- runs and the viewer shows; several cases of one feature are one
2661
- `test_*` with a loop and `step("…", {tag: id}, …)` per case, not
2662
- several `test_*`.
2740
+ - **Tests of one feature share a file.** Several `test_*` in a file are
2741
+ several runs — a collection counts and reports each. Many cases of ONE
2742
+ behaviour can still be one test with a loop and
2743
+ `step("…", {tag: id}, …)` per case.
2744
+ - **Expected failures — `// @expect-fail`.** A test that is supposed to
2745
+ fail carries the marker in a `//` comment. Where it sits says which
2746
+ tests it is about:
2747
+
2748
+ ```js
2749
+ // @expect-fail <- every test in this file
2750
+
2751
+ // The badge must stay hidden.
2752
+ // @expect-fail <- test_badge_hidden only
2753
+ function test_badge_hidden() { … }
2754
+ ```
2755
+
2756
+ The `//` lines directly above a `function test_*` — no blank line
2757
+ between — belong to that test, the first one in the file included. The
2758
+ file's leading comments, set apart from the first function by a blank
2759
+ line, mark every test in it. A marker trailing code (`foo(); //
2760
+ @expect-fail`) or inside a body is a comment, not a marker.
2663
2761
  - **Helpers stay in `unotest/e2e/_helpers/`** — these are project-owned,
2664
2762
  not part of the core surface. Use sandbox primitives there.
2665
2763
  - **Two kinds of helper.** A `flow_*` helper is a composite journey —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unotest/web",
3
- "version": "0.47.0",
3
+ "version": "0.49.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/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"
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"
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.47.0",
100
- "@unotest/judge": "^0.47.0"
99
+ "@unotest/grounder": "^0.49.0",
100
+ "@unotest/judge": "^0.49.0"
101
101
  }
102
102
  }
@@ -49,13 +49,16 @@ The linter rejects a test in the e2e root (`lint:scenario-in-root`), and
49
49
  from the area of the app under test. Shared helpers live in
50
50
  `unotest/e2e/_helpers/`; `_template.js` is a starting point to copy.
51
51
 
52
- One `test_*` per file. The file is what a collection runs and what the viewer
53
- shows (its Steps tree projects the run onto the first `test_*`; any other
54
- runs unseen). Several cases of one feature are ONE test with a loop and
55
- `step("…", {tag: id}, () => { … })` per case — `step.soft` when a failed case
56
- must not stop the run; shared journeys are `flow_*` helpers in
57
- `unotest/e2e/_helpers/`. A second `test_*` is flagged by
58
- `lint:one-test-per-file` (a warning today, an error in a later release).
52
+ A file may hold several `test_*` — the tests of one feature. Each test is
53
+ its own run: its own browser context, retry, verdict and row in the viewer,
54
+ under the file. Functions of the file are shared between them; state is
55
+ not — every test starts from a fresh context. `run_test` plays one test:
56
+ in a file of several, name it (`run_test {name, test: "test_…"}`); without
57
+ it the reply is `test_required` with the file's tests. Many cases of one
58
+ behaviour can still be ONE test with a loop and
59
+ `step("…", {tag: id}, () => { … })` per case — `step.soft` when a failed
60
+ case must not stop the run; shared journeys are `flow_*` helpers in
61
+ `unotest/e2e/_helpers/`.
59
62
 
60
63
  ## Locator hierarchy (D-22, hard rule)
61
64
 
@@ -201,14 +201,14 @@ declare function assertAttribute(locator: Locator, name: string, value: string |
201
201
  *
202
202
  * Element is visible.
203
203
  */
204
- declare function assertVisible(locator: Locator): void;
204
+ declare function assertVisible(locator: Locator, options?: any): void;
205
205
 
206
206
  /**
207
207
  * assertHidden(locator, { timeout? })
208
208
  *
209
209
  * Element is hidden or detached.
210
210
  */
211
- declare function assertHidden(locator: Locator): void;
211
+ declare function assertHidden(locator: Locator, options?: any): void;
212
212
 
213
213
  /**
214
214
  * assertNeverAppears(locator, { withinMs })
@@ -222,14 +222,14 @@ declare function assertNeverAppears(locator: Locator, options: any): void;
222
222
  *
223
223
  * Input's value equals `expected`. On a checkbox/radio/switch: its checked state — pass 'true' or 'false'.
224
224
  */
225
- declare function assertValue(locator: Locator, value: string): void;
225
+ declare function assertValue(locator: Locator, value: string, options?: any): void;
226
226
 
227
227
  /**
228
228
  * assertCount(locator, expected, { timeout? })
229
229
  *
230
230
  * Number of matches equals `expected`.
231
231
  */
232
- declare function assertCount(locator: Locator, count: number): void;
232
+ declare function assertCount(locator: Locator, count: number, options?: any): void;
233
233
 
234
234
  /**
235
235
  * assertUrl(pattern, { timeout? })