@unotest/web 0.30.0 → 0.32.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.
@@ -37,6 +37,68 @@ function test_my_scenario() {
37
37
  > one block per logical chunk; anything is allowed inside a block (nested
38
38
  > blocks, control flow, comments).
39
39
 
40
+ ### `step(...)` forms — tags and soft steps
41
+
42
+ ```
43
+ step("label", () => { … });
44
+ step("label", {tag: <expr>}, () => { … });
45
+ step.soft("label", () => { … });
46
+ step.soft("label", {tag: <expr>}, () => { … });
47
+ ```
48
+
49
+ - **`label`** is a string literal — the intent a human reads.
50
+ - **`{tag: <expr>}`** names the case a data-driven step is on: any
51
+ expression (`q.id`, `textJoin(['q-', i])`), evaluated when the block
52
+ starts. It rides on every event of the block (`Question [q17]` in the
53
+ CLI line, a chip on the step in the viewer). `tag` is the only option.
54
+ - **`step.soft(...)`** — a failure inside the body is *recorded* instead
55
+ of stopping the run: the rest of that body is skipped (as after
56
+ `expect.soft` inside a Playwright `test.step`), execution continues
57
+ with the statement after the block, and the test still ends **failed**
58
+ with every soft failure listed:
59
+
60
+ ```
61
+ ✗ test: test_control_questions (12.4s)
62
+ 3 soft step(s) failed: Question [q17]: …; Question [q42]: …; Question [q58]: …
63
+ ✗ soft step "Question" [q17]: judge said no: …
64
+ ✗ soft step "Question" [q42]: …
65
+ ```
66
+
67
+ `return` / `break` / `continue` pass through a soft step untouched. A
68
+ hard failure after soft ones ends the run with *that* error; the soft
69
+ list is still attached. The failure bundle shows the page at the moment
70
+ of the **first** soft failure (`failure.json` carries `soft: true` and
71
+ `stepTag`); later ones are in the journal (`step-block:finished`) and in
72
+ `run_test`'s reply (`next.softFailures`).
73
+ - `step.soft` is allowed **inside `test_*` only** (validator
74
+ `step-shape`): a helper has no test to report a soft failure on.
75
+ Nesting is free — the usual shape is a plain outer step, a loop, and
76
+ tagged soft steps inside it. A soft failure does **not** change the
77
+ outer step's own outcome: the group is not an assertion.
78
+ - In the debugger, resuming past a failure inside a soft step continues
79
+ with the next statement and records nothing — same as resuming past
80
+ any failure on web.
81
+
82
+ The data-driven gate, in full (`readJsonLine` reads the cases; the outer
83
+ `step` satisfies step coverage):
84
+
85
+ ```js
86
+ function test_control_questions() {
87
+ step("Control questions", () => {
88
+ for (i = 1; i < 11; i = i + 1) {
89
+ q = readJsonLine('questions.jsonl', {slot: i});
90
+ step.soft("Question", {tag: q.id}, () => {
91
+ fill(getByRole('textbox', {name: 'Message'}), q.text);
92
+ press(getByRole('textbox', {name: 'Message'}), 'Enter');
93
+ answer = textContent(getByTestId('last-answer'));
94
+ screenshot(q.id);
95
+ assertJudge(answer, q.rubric);
96
+ });
97
+ }
98
+ });
99
+ }
100
+ ```
101
+
40
102
  > **Locator quality matters (D-22).** Prefer `getByTestId` →
41
103
  > `getByRole(name)` → `getByLabel` → `getByText` → `locator(css)`. The AST
42
104
  > linter (`unotest-web lint`) flags brittle selectors — see
@@ -303,8 +365,14 @@ check(getByRole('checkbox', {name: 'Subscribe'}))
303
365
 
304
366
  ```js
305
367
  hover(getByRole('button', {name: 'Help'}))
368
+ // A tall chat bubble whose menu appears only near its top edge: hover
369
+ // the centre and nothing shows — hover an offset inside the element.
370
+ hover(getByTestId('message-42'), {position: {x: 24, y: 8}})
306
371
  ```
307
372
 
373
+ **Options:** `force?`, `timeout?`, `position?` (no `modifiers` — that
374
+ is a `click` option).
375
+
308
376
  ### `selectOption(loc, value, options?)`
309
377
 
310
378
  Native `<select>` only. `value` can be a string, an array of strings, or
@@ -453,6 +521,11 @@ Semantics:
453
521
  a separate `preamble` field: the model saw the two concatenated, and
454
522
  the split says which half came from the scenario and which from the
455
523
  environment.
524
+ - **In the viewer** the verdict sits under the step (`judge ✓/✗ ·
525
+ rubric · text`, open by default when it said no), and the error card
526
+ of a failed judge step carries the whole verdict — rubric, judged
527
+ text, reasoning, model. See `note()` below for attaching the question
528
+ and the answer next to it.
456
529
  - A judge is not deterministic: the judge itself re-asks on `fail`
457
530
  (`UNOTEST_JUDGE_RETRIES`, first `pass` wins); temperature is pinned to 0
458
531
  where the backend accepts it (vertex/gemini). The runner's retry layer is
@@ -881,6 +954,30 @@ step("A personal chat must not leak into the project feed", () => {
881
954
  });
882
955
  ```
883
956
 
957
+ ### `readJsonLine(path, filter)` → `object`
958
+
959
+ The no-wait form of `waitForJsonLine` for a file that **already exists**:
960
+ read it once and return the first line matching `filter`. Use it for
961
+ fixtures and finished exports — a data-driven test reads its cases from
962
+ a JSONL file this way. Same filter rules as `waitForJsonLine` (strict
963
+ equality per key, dot paths for nesting, unparsable lines skipped).
964
+
965
+ ```js
966
+ step("Ask control question 3", () => {
967
+ q = readJsonLine('questions.jsonl', {slot: 3});
968
+ fill(getByRole('textbox', {name: 'Message'}), q.text);
969
+ screenshot(q.id);
970
+ });
971
+ ```
972
+
973
+ - No options and no poll: a missing file fails at once with
974
+ `file not found: <path>`, a missing line with
975
+ `no line matches {…} in <path>` plus the same parse stats and
976
+ closest-line hint `waitForJsonLine` reports on timeout.
977
+ - Pick `waitForJsonLine` when the file is still being written to (a
978
+ background listener's output); its timeout is what makes that safe,
979
+ and what would only hide a wrong path here.
980
+
884
981
  ### `waitForFileCount(path, pattern, count, options?)` → `number`
885
982
 
886
983
  Wait until **at least** `count` lines match, then return how many there
@@ -1152,8 +1249,18 @@ screenshot('bot-reply'); // 002-bot-reply.png
1152
1249
  screenshot('landing', {fullPage: true}); // full scroll height
1153
1250
  ```
1154
1251
 
1155
- - `name` is slugified to `[a-z0-9._-]` (`'Bot Reply!'` `bot-reply`);
1156
- omitted `shot`. Files are numbered in capture order.
1252
+ - `name` is any string value, not only a literal a data-driven test
1253
+ names its evidence after the case it is on:
1254
+
1255
+ ```js
1256
+ q = waitForJsonLine('questions.jsonl', {slot: 3});
1257
+ screenshot(q.id); // 003-q-17.png
1258
+ screenshot(textJoin(['q-', q.id, '-answer']));
1259
+ ```
1260
+
1261
+ - `name` is slugified to `[a-z0-9._-]` (`'Bot Reply!'` → `bot-reply`,
1262
+ `'Q 17/a'` → `q-17-a`); omitted → `shot`. Files are numbered in
1263
+ capture order.
1157
1264
  - **Options:** `fullPage?` (default `false` — viewport only).
1158
1265
  - Before capturing, the call waits for the network to go quiet and for a
1159
1266
  painted frame (up to 2s). Without it a `screenshot()` right after
@@ -1174,17 +1281,50 @@ screenshot('landing', {fullPage: true}); // full scroll height
1174
1281
 
1175
1282
  ---
1176
1283
 
1177
- ## Logging
1284
+ ## Logging and notes
1178
1285
 
1179
1286
  ### `log(...args)`
1180
1287
 
1181
- Routes through the runtime's `Logger.info`. Non-string args are
1182
- `JSON.stringify`'d.
1288
+ Writes one line to the runner's output (the terminal, the live System
1289
+ pane in the viewer) **and** to the run journal (`steps.jsonl`, as a
1290
+ `log` event under the current step), so a finished run still shows it
1291
+ under the step that wrote it and in the System pane. Non-string args
1292
+ are `JSON.stringify`'d.
1183
1293
 
1184
1294
  ```js
1185
1295
  log('user id:', userId);
1186
1296
  ```
1187
1297
 
1298
+ ### `note(label, value)`
1299
+
1300
+ Attach a labelled value to the **current step**: the question a
1301
+ data-driven case asked, the answer it got, an id worth seeing next to
1302
+ the verdict. Lives in the journal, not in stdout — the viewer shows it
1303
+ under the step (an `ⓘ N` chip on the row; the list opens by default
1304
+ when the step failed or a judge said no) during and after the run.
1305
+
1306
+ ```js
1307
+ step.soft("Question", {tag: q.id}, () => {
1308
+ note('question', q.text);
1309
+ fill(getByRole('textbox', {name: 'Message'}), q.text);
1310
+ press(getByRole('textbox', {name: 'Message'}), 'Enter');
1311
+ answer = textContent(getByTestId('last-answer'));
1312
+ note('answer', answer);
1313
+ assertJudge(getByTestId('last-answer'), q.rubric);
1314
+ });
1315
+ ```
1316
+
1317
+ - `label` is a string; `value` is anything — a string as it is, a
1318
+ number, an object or array serialized as JSON.
1319
+ - Long values are cut at 4 KB (marked `truncated`); secrets are masked
1320
+ like everywhere in the run directory.
1321
+ - Each `note` / `log` event records where it was called (`file`, `line`,
1322
+ `col`) and the nearest statement of the entry file (`entryLine`,
1323
+ `entryCol`) — a note from inside a helper lands under the entry step
1324
+ that called the helper.
1325
+ - Without a run journal (exploration, an ad-hoc runtime) `note` goes to
1326
+ the logger instead; nothing fails.
1327
+
1188
1328
  ---
1189
1329
 
1190
1330
  ## Variables, control flow, types
@@ -1192,8 +1332,13 @@ log('user id:', userId);
1192
1332
  The DSL is JavaScript-shaped but parsed by a small AST engine
1193
1333
  (`vendor/dsl/`), not V8. What's supported in scenario files:
1194
1334
 
1195
- - **`function test_*(): { … }`** — scenario entry; each top-level
1196
- `function test_*` is a separately runnable test.
1335
+ - **`function test_*(): { … }`** — the scenario entry, **exactly one per
1336
+ file**: a collection runs files, and the viewer projects a run onto
1337
+ the first `test_*` it finds. A second `test_*` is reported by
1338
+ `lint:one-test-per-file` (a warning in 0.31, an error in a later
1339
+ release) — fold the cases into one test with tagged steps
1340
+ (`step("…", {tag: id}, …)`), or move shared journeys to
1341
+ `unotest/e2e/_helpers/`.
1197
1342
  - **Assignments** — `name = expression;` (no `let`/`const`/`var`).
1198
1343
  - **`if` / `else`** and **`for` in the canonical shape**
1199
1344
  `for (i = 0; i < N; i = i + 1) { … }` (increment must be an
@@ -1249,8 +1394,10 @@ exact grammar deltas from the upstream parser.
1249
1394
  ## Conventions
1250
1395
 
1251
1396
  - **Snake-case test names** — `function test_login_happy_path()`.
1252
- - **One scenario file = one feature.** Multiple `test_*` entries are
1253
- fine when they share a feature surface.
1397
+ - **One scenario file = one test.** The file is the unit a collection
1398
+ runs and the viewer shows; several cases of one feature are one
1399
+ `test_*` with a loop and `step("…", {tag: id}, …)` per case, not
1400
+ several `test_*`.
1254
1401
  - **Helpers stay in `unotest/e2e/_helpers/`** — these are project-owned,
1255
1402
  not part of the core surface. Use sandbox primitives there.
1256
1403
  - **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.30.0",
3
+ "version": "0.32.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": {
@@ -83,18 +83,18 @@
83
83
  "tsx": "^4.19.2",
84
84
  "yaml": "^2.6.0",
85
85
  "zod": "^3.23.8",
86
- "@unotest/core": "^0.30.0",
87
- "@unotest/viewer": "^0.30.0",
88
- "@unotest/grounder-client": "^0.30.0",
89
- "@unotest/dsl": "^0.30.0",
90
- "@unotest/protocol": "^0.30.0"
86
+ "@unotest/core": "^0.32.0",
87
+ "@unotest/protocol": "^0.32.0",
88
+ "@unotest/grounder-client": "^0.32.0",
89
+ "@unotest/dsl": "^0.32.0",
90
+ "@unotest/viewer": "^0.32.0"
91
91
  },
92
92
  "devDependencies": {
93
93
  "@types/node": "^22.10.0",
94
94
  "javascript-obfuscator": "^4.1.1",
95
95
  "tsup": "^8.5.1",
96
96
  "typescript": "^5.7.2",
97
- "@unotest/judge": "^0.30.0",
98
- "@unotest/grounder": "^0.30.0"
97
+ "@unotest/grounder": "^0.32.0",
98
+ "@unotest/judge": "^0.32.0"
99
99
  }
100
100
  }
@@ -49,6 +49,14 @@ 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).
59
+
52
60
  ## Locator hierarchy (D-22, hard rule)
53
61
 
54
62
  When choosing a selector, prefer in this exact order:
@@ -310,7 +318,9 @@ A login (or any setup block) that many tests share should be a reusable
310
318
  - **Flow already exists** (it's in `availableFlows`): call
311
319
  `explore_run_flow({ explorationId, name })`. It replays the flow LIVE (you end
312
320
  up logged in) and records a single `flow_<name>()` call — don't re-record the
313
- steps.
321
+ steps. A flow with parameters (`params` in `availableFlows`) takes them as
322
+ `args`, positional — `args: ["{{LOGIN}}", "{{PASSWORD}}"]` replays on the
323
+ values and records `flow_login(LOGIN, PASSWORD)`; the count must match.
314
324
  - **First time** (no flow yet): record the block normally but put
315
325
  `flow:"signin"` on every step of it (`explore_step({…, flow:"signin"})`).
316
326
  On `save_exploration_as_test`, those steps are extracted into
@@ -120,9 +120,9 @@ declare function check(locator: Locator, options?: any): void;
120
120
  declare function uncheck(locator: Locator, options?: any): void;
121
121
 
122
122
  /**
123
- * hover(locator, { force?, timeout? })
123
+ * hover(locator, { force?, position?, timeout? })
124
124
  *
125
- * Hover over an element.
125
+ * Hover over an element. `position` is an {x, y} offset inside it — for a menu that only appears at one edge of a tall element.
126
126
  */
127
127
  declare function hover(locator: Locator, options?: any): void;
128
128
 
@@ -280,6 +280,13 @@ declare function randomNth(receiver: Locator): Locator;
280
280
  */
281
281
  declare function screenshot(name?: string, options?: any): string;
282
282
 
283
+ /**
284
+ * note(label, value)
285
+ *
286
+ * Attach a labelled value to the current step: shown in the viewer under the step (also after the run) and kept in the run journal. Any value — a string, a number, an object (recorded as JSON). Secrets are masked; long values are cut at 4 KB. Use it for the question a data-driven case asked and the answer it got.
287
+ */
288
+ declare function note(label: string, value: any): void;
289
+
283
290
  /**
284
291
  * evaluate(js, arg?) → any
285
292
  *
@@ -511,6 +518,13 @@ declare function waitForJsonLine(path: string, filter: any, options?: any): any;
511
518
  */
512
519
  declare function assertNoJsonLine(path: string, filter: any, options?: any): void;
513
520
 
521
+ /**
522
+ * readJsonLine(path, filter) → object
523
+ *
524
+ * First matching line of a JSONL file that already exists, no wait: one read, same key filter as waitForJsonLine. FAILS at once when the file is missing or no line matches — use it for fixtures and finished exports, waitForJsonLine for a file still being written.
525
+ */
526
+ declare function readJsonLine(path: string, filter: any): any;
527
+
514
528
  /**
515
529
  * waitForFileCount(path, pattern, count, options?) → number
516
530
  *
@@ -654,7 +668,7 @@ declare function getUrl(): string;
654
668
  /**
655
669
  * log(...args)
656
670
  *
657
- * Write to the run trace.
671
+ * Write a line to the runner's output AND to the run journal, under the current step — visible in the viewer's System pane after the run too.
658
672
  */
659
673
  declare function log(message: any, ...args: unknown[]): void;
660
674
 
@@ -714,8 +728,18 @@ declare function waitForCount(locator: Locator, count: number, options?: any): v
714
728
  */
715
729
  declare function pause(ms: number): void;
716
730
 
717
- /** step(label, body)
731
+ /** step(label, [options,] body)
718
732
  *
719
733
  * Groups statements under a labelled block. Every direct child of a
720
- * test_* function must sit inside a step(...). */
734
+ * test_* function must sit inside a step(...). `{tag}` names the case a
735
+ * data-driven step is on. */
721
736
  declare function step(label: string, body: () => void): void;
737
+ declare function step(label: string, options: { tag: unknown }, body: () => void): void;
738
+ declare namespace step {
739
+ /** step.soft(label, [options,] body)
740
+ *
741
+ * A failure inside is recorded and the run continues after the block;
742
+ * the test still ends failed. Allowed inside test_* only. */
743
+ function soft(label: string, body: () => void): void;
744
+ function soft(label: string, options: { tag: unknown }, body: () => void): void;
745
+ }
@@ -1,21 +0,0 @@
1
- import { BlockStatement } from '@unotest/dsl';
2
-
3
- type LintSeverity = 'error' | 'warning' | 'info';
4
- interface LintDiagnostic {
5
- rule: string;
6
- severity: LintSeverity;
7
- message: string;
8
- line: number;
9
- col: number;
10
- }
11
- interface LintOptions {
12
- /** Per-rule severity override. Default severities live with each rule. */
13
- ruleSeverity?: Partial<Record<string, LintSeverity>>;
14
- /** Pass through if the agent provided documented justification — e.g. a
15
- * `// reason:` comment immediately above the line. The lexer doesn't
16
- * preserve comments today; for v1 we accept a flag from the caller. */
17
- allowDisambigByIndex?: boolean;
18
- }
19
- declare function lintAst(ast: BlockStatement, opts?: LintOptions): LintDiagnostic[];
20
-
21
- export { type LintDiagnostic as L, type LintSeverity as a, type LintOptions as b, lintAst as l };