@unotest/web 0.48.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.
- package/.claude/skills/write-e2e-test/SKILL.md +4 -0
- package/.claude/skills/write-e2e-test-ground/SKILL.md +4 -0
- package/CHANGELOG.md +184 -3
- package/README.md +2 -1
- package/bin/unotest-web.js +24 -8
- package/dist/config/schema.d.ts +33 -12
- package/dist/config/schema.js +1 -1
- package/dist/driver/index.d.ts +8 -2
- package/dist/driver/index.js +1 -1
- package/dist/dsl/index.d.ts +26 -3
- package/dist/dsl/index.js +1 -1
- package/dist/dsl/web-dsl-language-service.d.ts +1 -1
- package/dist/dsl/web-dsl-language-service.js +1 -1
- package/dist/inspection/page-inject.d.ts +17 -2
- package/dist/inspection/page-inject.js +21 -0
- package/dist/{interfaces-CTMGjJIs.d.ts → interfaces-D5ePwBX6.d.ts} +13 -1
- package/dist/{linter-BWSAYBQy.d.ts → linter-RrjB1n9R.d.ts} +5 -0
- package/dist/mcp/server.js +1 -1
- package/dist/runner/cli.d.ts +4 -3
- package/dist/runner/cli.js +1 -1
- package/dist/runner/init/chrome-probe.js +1 -1
- package/dist/runner/init.d.ts +6 -1
- package/dist/runner/init.js +1 -1
- package/dist/runner/install-chromium.js +1 -1
- package/dist/runner/prepare-fix.js +1 -1
- package/dist/runner/scaffold-workspace.js +1 -1
- package/dist/runner/serve-fixture.js +1 -1
- package/dist/runner/web-runner-adapter.js +1 -1
- package/guides/dsl-reference.md +219 -33
- package/package.json +8 -8
- package/src/mcp/prompts/agent-test-author.md +10 -7
- package/types/unotest-dsl.d.ts +8 -1
package/guides/dsl-reference.md
CHANGED
|
@@ -290,11 +290,15 @@ Each requires a locator as the receiver / first argument.
|
|
|
290
290
|
|
|
291
291
|
### `filter(options)`
|
|
292
292
|
|
|
293
|
-
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.
|
|
294
297
|
|
|
295
298
|
```js
|
|
296
299
|
getByRole('row').filter({hasText: 'Alice'})
|
|
297
300
|
getByRole('row').filter({has: getByTestId('admin-badge')})
|
|
301
|
+
locator('tr.done') // own class: CSS, not filter
|
|
298
302
|
```
|
|
299
303
|
|
|
300
304
|
### `first()`, `last()`, `nth(index)`
|
|
@@ -442,8 +446,16 @@ list that is still filling in passes once it is complete.
|
|
|
442
446
|
|
|
443
447
|
```js
|
|
444
448
|
assertTexts(getByRole('listitem'), ['Apple', 'Banana', /^Cherry/])
|
|
449
|
+
labels = ['Apple', 'Banana'];
|
|
450
|
+
assertTexts(getByRole('listitem'), labels)
|
|
451
|
+
assertText(getByRole('listitem').first(), labels[0])
|
|
445
452
|
```
|
|
446
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
|
+
|
|
447
459
|
**Options:** `timeout?`, `exact?` (default `true`).
|
|
448
460
|
|
|
449
461
|
### `assertAttribute(loc, name, expected, options?)`
|
|
@@ -553,7 +565,7 @@ assertTrue(probe.code == 0, 'app.log should contain "ready"');
|
|
|
553
565
|
Deep equality, the way JSON sees the two values: key order does not
|
|
554
566
|
count, and a missing key differs from a key set to `null`. The failure
|
|
555
567
|
names the first path that differs and both values, then a line diff of
|
|
556
|
-
the two sides:
|
|
568
|
+
the two sides, every line of them — nothing is folded away:
|
|
557
569
|
|
|
558
570
|
```js
|
|
559
571
|
body = parseJson(getAttribute(locator('#state'), 'data-json'));
|
|
@@ -606,18 +618,22 @@ function test_live_payment() {
|
|
|
606
618
|
}
|
|
607
619
|
```
|
|
608
620
|
|
|
609
|
-
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:
|
|
610
623
|
|
|
611
624
|
```js
|
|
612
625
|
function test_refund() {
|
|
613
626
|
step("Find a paid order", () => {
|
|
614
627
|
orders = apiCall('GET', '/orders?status=paid');
|
|
628
|
+
if (orders.length == 0) { skip('no paid order on this stand'); }
|
|
615
629
|
});
|
|
616
|
-
skip('no paid order on this stand', orders.length == 0);
|
|
617
630
|
// …
|
|
618
631
|
}
|
|
619
632
|
```
|
|
620
633
|
|
|
634
|
+
The rest of the test does not run; teardown blocks already registered
|
|
635
|
+
still play.
|
|
636
|
+
|
|
621
637
|
- The CLI prints `○ test: … skipped: <reason>` and counts it apart:
|
|
622
638
|
`0 passed, 0 failed, 0 errored, 1 skipped`. The exit code is 0.
|
|
623
639
|
- A collection counts it as `skipped=N(test=N)`.
|
|
@@ -626,9 +642,8 @@ function test_refund() {
|
|
|
626
642
|
skipped test whose teardown fails.
|
|
627
643
|
- The condition is `true` or `false`; anything else fails the test with
|
|
628
644
|
the value named — compare it: `env('LIVE_API') == null`.
|
|
629
|
-
- Call it
|
|
630
|
-
|
|
631
|
-
`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`.
|
|
632
647
|
|
|
633
648
|
### `assertJudge(loc, rubric)`
|
|
634
649
|
|
|
@@ -812,6 +827,19 @@ Read state without asserting. No polling.
|
|
|
812
827
|
|
|
813
828
|
### `count(loc)` → `number`
|
|
814
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
|
+
|
|
815
843
|
### `textContent(loc)` → `string`
|
|
816
844
|
|
|
817
845
|
Empty string when the element has no text node (does not throw).
|
|
@@ -1272,7 +1300,8 @@ a cookie or storage value, the page clock, `emulateMedia`, a second page
|
|
|
1272
1300
|
or context. It opens the browser context again with the same options;
|
|
1273
1301
|
after any of those it is refused, since the new context would lose them.
|
|
1274
1302
|
On a browser profile (`launch.userDataDir`, `launch.profile`) it is
|
|
1275
|
-
refused always. For a whole suite, set `viewport: null` in the config
|
|
1303
|
+
refused always. For a whole suite, set `viewport: null` in the config;
|
|
1304
|
+
for the runs of one collection, in its YAML (below).
|
|
1276
1305
|
|
|
1277
1306
|
### `emulateMedia({reducedMotion?, colorScheme?})`
|
|
1278
1307
|
|
|
@@ -1304,7 +1333,21 @@ Where the window's size and scale come from, first match wins:
|
|
|
1304
1333
|
replace the config's window for that context. `explore_start` with a
|
|
1305
1334
|
window while a context is already open is refused — call
|
|
1306
1335
|
`new_context` with it instead.
|
|
1307
|
-
2. **The
|
|
1336
|
+
2. **The collection.** `viewport:` in `unotest/e2e/_collections/<name>.yaml`
|
|
1337
|
+
applies to every scenario that collection runs; the same scenario run
|
|
1338
|
+
on its own keeps the config's window. `null` is the real window,
|
|
1339
|
+
`{width, height}` a size (whole pixels, both required):
|
|
1340
|
+
|
|
1341
|
+
```yaml
|
|
1342
|
+
viewport: null
|
|
1343
|
+
scenarios:
|
|
1344
|
+
- shots/gallery
|
|
1345
|
+
```
|
|
1346
|
+
|
|
1347
|
+
It works with a browser profile, where `setViewport(null)` cannot. A
|
|
1348
|
+
`viewport: null` here over a config `device` is refused before the
|
|
1349
|
+
first scenario starts, as in the config.
|
|
1350
|
+
3. **The config.** `unotest.config.mjs`:
|
|
1308
1351
|
|
|
1309
1352
|
```js
|
|
1310
1353
|
export default {
|
|
@@ -1331,12 +1374,12 @@ Where the window's size and scale come from, first match wins:
|
|
|
1331
1374
|
|
|
1332
1375
|
A `device` next to `viewport: null` is refused: a preset is a size and
|
|
1333
1376
|
a scale of its own.
|
|
1334
|
-
|
|
1377
|
+
4. **A device preset.** `device: "iPhone 14"` applies Playwright's
|
|
1335
1378
|
preset whole: size, scale, touch, mobile mode, user agent. The name
|
|
1336
1379
|
is Playwright's, exactly; an unknown name is refused with the
|
|
1337
1380
|
nearest ones. `viewport` next to `device` overrides the preset field
|
|
1338
1381
|
by field.
|
|
1339
|
-
|
|
1382
|
+
5. **The default:** 1280×720 at scale 1.
|
|
1340
1383
|
|
|
1341
1384
|
A preset's viewport is the page area, smaller than the device's screen:
|
|
1342
1385
|
`"iPhone 14"` is 390×664 (the browser chrome takes the rest of 844). To
|
|
@@ -1700,8 +1743,8 @@ step("Send button uses the app's own label", () => {
|
|
|
1700
1743
|
- No poll: a missing file fails at once with `file not found: <path>`,
|
|
1701
1744
|
a file that does not parse with `<path> is not valid JSON: …`.
|
|
1702
1745
|
- Scenarios cannot import your app's modules, and that is on purpose:
|
|
1703
|
-
generate the JSON from them instead
|
|
1704
|
-
scenarios
|
|
1746
|
+
generate the JSON from them instead — see
|
|
1747
|
+
[App constants in scenarios](https://docs.unotest.com/concepts/scenarios/#app-constants-in-scenarios).
|
|
1705
1748
|
|
|
1706
1749
|
### `env(name)` → `string | null`
|
|
1707
1750
|
|
|
@@ -2038,6 +2081,29 @@ assertTrue(computedStyle(getByRole('dialog'), 'opacity') == 1, 'dialog is faded'
|
|
|
2038
2081
|
accent = computedStyle(locator('body'), '--accent');
|
|
2039
2082
|
```
|
|
2040
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
|
+
|
|
2041
2107
|
### `hasAnimation(loc)` → `boolean`
|
|
2042
2108
|
|
|
2043
2109
|
Whether the element or a descendant has an animation still running
|
|
@@ -2430,6 +2496,10 @@ step.soft("Question", {tag: q.id}, () => {
|
|
|
2430
2496
|
that called the helper.
|
|
2431
2497
|
- Without a run journal (exploration, an ad-hoc runtime) `note` goes to
|
|
2432
2498
|
the logger instead; nothing fails.
|
|
2499
|
+
- When a statement fails, the notes of its `step(...)` block (the
|
|
2500
|
+
innermost one, in call order) come with the failure: under the error
|
|
2501
|
+
in the `e2e` output as `note label: value`, and as `notes` in the
|
|
2502
|
+
failure `run_test` returns. A green step prints nothing extra.
|
|
2433
2503
|
|
|
2434
2504
|
---
|
|
2435
2505
|
|
|
@@ -2438,14 +2508,22 @@ step.soft("Question", {tag: q.id}, () => {
|
|
|
2438
2508
|
The DSL is JavaScript-shaped but parsed by a small AST engine
|
|
2439
2509
|
(`vendor/dsl/`), not V8. What's supported in scenario files:
|
|
2440
2510
|
|
|
2441
|
-
- **`function test_*(): { … }`** —
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
`
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
`
|
|
2448
|
-
|
|
2511
|
+
- **`function test_*(): { … }`** — a test. A file may hold several; each
|
|
2512
|
+
is its own run with its own browser context, retry and verdict, and the
|
|
2513
|
+
viewer shows it as its own row under the file. `e2e <file>` plays them
|
|
2514
|
+
in order; `e2e <file> --test <name>` plays one. Functions of the file
|
|
2515
|
+
are shared between its tests, state is not.
|
|
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.
|
|
2449
2527
|
- **`if` / `else`** and **`for` in the canonical shape**
|
|
2450
2528
|
`for (i = 0; i < N; i = i + 1) { … }` (increment must be an
|
|
2451
2529
|
assignment — `i++` is rejected everywhere). A loop over cases, each a
|
|
@@ -2469,8 +2547,8 @@ The DSL is JavaScript-shaped but parsed by a small AST engine
|
|
|
2469
2547
|
- **Index access on data** — `docs[0]`, `docs[0].objectsCount`,
|
|
2470
2548
|
`res.body.items[0].id`: see [Strings, objects, indexes](#strings-objects-indexes).
|
|
2471
2549
|
Indexing a **Locator** is rejected — disambiguate with chain methods
|
|
2472
|
-
(`.filter(...)`)
|
|
2473
|
-
`arr.
|
|
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).
|
|
2474
2552
|
- **Object literals** — as call arguments (`click(loc, {force: true})`)
|
|
2475
2553
|
and as values in a variable: see [Strings, objects, indexes](#strings-objects-indexes).
|
|
2476
2554
|
- **Member access** — `row.email` (dot form; `obj['key']` doesn't
|
|
@@ -2483,6 +2561,9 @@ The DSL is JavaScript-shaped but parsed by a small AST engine
|
|
|
2483
2561
|
numeric strings coerce), `===` / `!==` (strict: type AND value must
|
|
2484
2562
|
match, `1 === "1"` is false), `<` `<=` `>` `>=`, logical `&&` / `||`
|
|
2485
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`).
|
|
2486
2567
|
Caveat: DOM reads (`textContent`, `getInputValue`, `getAttribute`)
|
|
2487
2568
|
return **strings** — for comparing against a number use `==`
|
|
2488
2569
|
(`getAttribute(loc, "data-count") === 5` is always false; write
|
|
@@ -2490,13 +2571,20 @@ The DSL is JavaScript-shaped but parsed by a small AST engine
|
|
|
2490
2571
|
|
|
2491
2572
|
Not supported (and intentionally so): `while` / `do…while` /
|
|
2492
2573
|
`continue` (bound loops keep every scenario terminating), `++`/`--`,
|
|
2493
|
-
|
|
2574
|
+
functions as values (a lambda goes only into `filter` / `map` / `find` /
|
|
2575
|
+
`some`), async/await (everything is sequenced by the AST
|
|
2494
2576
|
executor), classes, modules, promises, `try/catch` — for cleanup that
|
|
2495
2577
|
must run whatever happens, see [`teardown`](#teardown--give-the-stand-back).
|
|
2496
2578
|
Errors thrown from a primitive abort the scenario; if the runtime is
|
|
2497
2579
|
paused (debugger / MCP step mode), the failure is captured for
|
|
2498
2580
|
inspection.
|
|
2499
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
|
+
|
|
2500
2588
|
See `vendor/dsl/EXTENSIONS.md` for the
|
|
2501
2589
|
exact grammar deltas from the upstream parser.
|
|
2502
2590
|
|
|
@@ -2528,7 +2616,7 @@ assertText(getByRole('cell', {name: /^Total/}), '1\u00A0250'); // NBSP between
|
|
|
2528
2616
|
|
|
2529
2617
|
Since 0.44.0.
|
|
2530
2618
|
|
|
2531
|
-
**Strings** answer
|
|
2619
|
+
**Strings** answer these members — the whole list:
|
|
2532
2620
|
|
|
2533
2621
|
| Member | Returns | |
|
|
2534
2622
|
|---|---|---|
|
|
@@ -2536,6 +2624,7 @@ Since 0.44.0.
|
|
|
2536
2624
|
| `s.trim()` | `string` | whitespace off both ends |
|
|
2537
2625
|
| `s.includes(search)` | `boolean` | substring probe, same as `textContains(s, search)` |
|
|
2538
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 |
|
|
2539
2628
|
|
|
2540
2629
|
They work wherever an expression does — in `assertTrue`, in `if`, on a
|
|
2541
2630
|
literal, on a call result:
|
|
@@ -2545,9 +2634,10 @@ title = getTitle();
|
|
|
2545
2634
|
assertTrue(title.trim().length > 0, 'empty title');
|
|
2546
2635
|
if (out.stdout.includes('ready')) { log('up'); }
|
|
2547
2636
|
assertTrue(getUrl().startsWith('https://'), getUrl());
|
|
2637
|
+
ids = 'a, b'.split(', '); // ['a', 'b']
|
|
2548
2638
|
```
|
|
2549
2639
|
|
|
2550
|
-
Any other member is a lint error that names it and lists
|
|
2640
|
+
Any other member is a lint error that names it and lists them
|
|
2551
2641
|
(`validator:unsupported-string-member`); `s.padStart(3)` does not
|
|
2552
2642
|
reach the browser. A member of the list used on a value that turns out
|
|
2553
2643
|
not to be a string at run time — a number, or `null` from a missing
|
|
@@ -2588,8 +2678,9 @@ assertTrue(!out.stdout.includes('error'), out.stdout);
|
|
|
2588
2678
|
```
|
|
2589
2679
|
|
|
2590
2680
|
**`arr.push(x, …)`** appends to the array in place and returns its new
|
|
2591
|
-
length. Other array methods (`pop`, `
|
|
2592
|
-
`length`
|
|
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.
|
|
2593
2684
|
|
|
2594
2685
|
```js
|
|
2595
2686
|
rows = getByRole('row');
|
|
@@ -2608,6 +2699,69 @@ that is not an array — a string, a number, a `null` field — fails the
|
|
|
2608
2699
|
step with that type in the message; on a string the linter flags it
|
|
2609
2700
|
first.
|
|
2610
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
|
+
|
|
2611
2765
|
### `teardown` — give the stand back
|
|
2612
2766
|
|
|
2613
2767
|
Since 0.44.0.
|
|
@@ -2716,12 +2870,42 @@ helpers. A function, parameter or variable of your own named
|
|
|
2716
2870
|
## Conventions
|
|
2717
2871
|
|
|
2718
2872
|
- **Snake-case test names** — `function test_login_happy_path()`.
|
|
2719
|
-
- **
|
|
2720
|
-
runs
|
|
2721
|
-
|
|
2722
|
-
|
|
2873
|
+
- **Tests of one feature share a file.** Several `test_*` in a file are
|
|
2874
|
+
several runs — a collection counts and reports each. Many cases of ONE
|
|
2875
|
+
behaviour can still be one test with a loop and
|
|
2876
|
+
`step("…", {tag: id}, …)` per case.
|
|
2877
|
+
- **Expected failures — `// @expect-fail`.** A test that is supposed to
|
|
2878
|
+
fail carries the marker in a `//` comment. Where it sits says which
|
|
2879
|
+
tests it is about:
|
|
2880
|
+
|
|
2881
|
+
```js
|
|
2882
|
+
// @expect-fail <- every test in this file
|
|
2883
|
+
|
|
2884
|
+
// The badge must stay hidden.
|
|
2885
|
+
// @expect-fail <- test_badge_hidden only
|
|
2886
|
+
function test_badge_hidden() { … }
|
|
2887
|
+
```
|
|
2888
|
+
|
|
2889
|
+
The `//` lines directly above a `function test_*` — no blank line
|
|
2890
|
+
between — belong to that test, the first one in the file included. The
|
|
2891
|
+
file's leading comments, set apart from the first function by a blank
|
|
2892
|
+
line, mark every test in it. A marker trailing code (`foo(); //
|
|
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.
|
|
2723
2902
|
- **Helpers stay in `unotest/e2e/_helpers/`** — these are project-owned,
|
|
2724
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()})`).
|
|
2725
2909
|
- **Two kinds of helper.** A `flow_*` helper is a composite journey —
|
|
2726
2910
|
sign-in, checkout — and is *executable on its own*: the runner accepts it
|
|
2727
2911
|
as an entry and the agent can replay it while recording. A plain
|
|
@@ -2743,4 +2927,6 @@ helpers. A function, parameter or variable of your own named
|
|
|
2743
2927
|
and yields its result (`email = flow_signup()`), a rename hint when it
|
|
2744
2928
|
only reads.
|
|
2745
2929
|
- **Lint before you ship** — `npx @unotest/web lint` runs the AST
|
|
2746
|
-
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.
|
|
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.
|
|
89
|
-
"@unotest/
|
|
90
|
-
"@unotest/
|
|
91
|
-
"@unotest/grounder-client": "^0.
|
|
92
|
-
"@unotest/viewer": "^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/judge": "^0.
|
|
100
|
-
"@unotest/grounder": "^0.
|
|
99
|
+
"@unotest/judge": "^0.49.1",
|
|
100
|
+
"@unotest/grounder": "^0.49.1"
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
package/types/unotest-dsl.d.ts
CHANGED
|
@@ -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,
|
|
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
|
*
|