@unotest/web 0.29.0 → 0.31.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.
@@ -87,6 +87,15 @@ APP_BASE_URL) — same idea as run_test's per-call `env`.
87
87
  `explore_start` opens the browser itself. Do NOT call `new_context`
88
88
  first — it's for RESETTING the browser (Phase 5), not for starting.
89
89
 
90
+ **Variables in steps.** In a value field (`fill` value, `press` key,
91
+ `select_option` value, `goto` url) the bare NAME of a `.env`/`.secrets`
92
+ variable is a reference: `value: "PASSWORD"` types the secret live and
93
+ the saved test says `fill(loc, PASSWORD)`. In a locator's text or an
94
+ assertion's text a bare NAME is the literal it looks like — to reference
95
+ a variable there write `{{NAME}}` (`text: "{{GREETING}}"`); the reply
96
+ warns when a literal equals a variable's name. Never type a secret's
97
+ VALUE: it is recorded as the name anyway, and the reply says so.
98
+
90
99
  ### Phase 2 — first recorded step: navigate
91
100
 
92
101
  ```
@@ -418,7 +427,23 @@ Assertions:
418
427
 
419
428
  State reads:
420
429
  - `getTitle()`, `getUrl()`, `getAttribute(loc, name)`, `getInnerText(loc)`,
421
- `getInputValue(loc)`.
430
+ `getInputValue(loc)`, `count(loc)`; `textContains(haystack, needle)`
431
+ for a substring probe on a string you already hold
432
+ (`assertTrue(textContains(out.stdout, 'ready'))`).
433
+ - A `textarea` / `input` VALUE is not page text: `waitForText` and
434
+ `assertText` never see it. Check it with `assertValue(loc, expected)`
435
+ — it polls until the timeout (5 s by default, `{timeout}` to raise
436
+ it), so a value that fills in late is fine. For a substring of the
437
+ value there is no polling assert: read it in a loop —
438
+ `for (i = 0; i < 20; i = i + 1) { if (textContains(getInputValue(loc), 'x')) { break; } pause(200); // lint-ok: polling a textarea value }`
439
+ — then assert.
440
+ - Only the vocabulary above is expression language. `!` is rejected by
441
+ the parser (`unotest-web lint`: `parse error — Invalid token`);
442
+ `JSON.stringify` / `Date.now()` / `indexOf` and other JS globals and
443
+ string methods are rejected by the validator with the replacement
444
+ named (`use json(value)`, `use nowMs()`, `use textContains(haystack,
445
+ needle)`). Express the check as an assertion or a locator
446
+ (`assertHidden`, `assertCount`, `.filter({hasText})`, `textContains`).
422
447
 
423
448
  Sandbox primitives:
424
449
  - `shell("cmd", "arg", …, options?)` — `execFile` style, no shell interpretation. Non-zero exit fails the step; pass `{allowNonZero: true}` to inspect `res.code` yourself. Wall-clock budget `{timeoutMs}` (default 120s).
@@ -433,8 +458,17 @@ Sandbox primitives:
433
458
  JSON — there is no key-name magic.
434
459
 
435
460
  Escape hatch:
436
- - `evaluate(\`js body\`, …args)` — raw backticks, no `${}`. Linter
461
+ - `evaluate(\`js body\`, arg?)` — raw backticks, no `${}`. Linter
437
462
  warns `lint:evaluate-discouraged`. Use only when nothing above fits.
463
+ The body gets at most ONE argument — extra arguments reach it like
464
+ this: none → nothing, exactly one → the value itself, **with two or
465
+ more extra arguments the body receives ONE array** — destructure it:
466
+ `evaluate('function(n){ return n * 2 }', 21)` and
467
+ `evaluate('([a, b]) => a + b', a, b)`. The linter's warning says so
468
+ when it sees two or more.
469
+ - Native dialogs (`confirm()` / `alert()` / `prompt()`) are accepted by
470
+ the runner itself (`dialogPolicy: "accept"` by default) — a "Remove"
471
+ that asks first needs no special step.
438
472
 
439
473
  Time helpers: `nowMs()`, `today()`, `daysFromNow(n)`. Marker helper: `randomWord(len)` — random lowercase letters, digit-free. Debug helper: `json(value)` — serialize any value for an assert message: `assertTrue(res.status == 202, json(res.body))`.
440
474
 
@@ -570,11 +604,23 @@ the old browser context, then `run_test` again.
570
604
  | `wait_for_url` | `value` (the pattern) | `options` |
571
605
  | `enter_frame` | `locator` | |
572
606
  | `exit_frame` | | |
607
+ | `screenshot` | | `name`, `fullPage`, `locator`, `outline`, `evidenceOnly` |
573
608
 
574
609
  When recording (`explorationId` present): `description` + `section`
575
610
  are required. Ad-hoc (no `explorationId`): no description/section,
576
611
  no recording.
577
612
 
613
+ `screenshot` is the **evidence** step: it files a PNG under the session
614
+ (`.unotest/explorations/<explorationId>/screenshots/<NNN>-<name>.png`,
615
+ `adhoc/` without a session), replies with `{path, width, height}` and
616
+ the image itself, and becomes `screenshot(name)` in the saved test.
617
+ `name` defaults to the section slug; `locator` captures one element
618
+ (the saved test degrades that to a page capture); `outline: true` adds
619
+ the page outline to the reply; `evidenceOnly: true` keeps the file but
620
+ records no step. `explore_stop` lists every file in `artifacts`. Take
621
+ one wherever a human would want to check a green run after the fact —
622
+ do not write throwaway scenarios just to get a picture.
623
+
578
624
  ## Failure modes you will hit
579
625
 
580
626
  - **`StaleRefError`** — ref no longer in the DOM. Re-`get_page_snapshot`
@@ -635,6 +681,11 @@ Every locator must resolve to exactly one element. When you see
635
681
  multi-matches.** Element order is brittle. Use `.filter({hasText:
636
682
  '…'})` or `.filter({has: someLocator})`. Linter flags index-based
637
683
  picking as `lint:disambig-by-index`.
684
+ - **Don't assert "the first row of the list / audit".** On a shared
685
+ stand another run (or a person) writes rows between your steps. Give
686
+ your own data a unique marker (`randomWord()` in a name or note),
687
+ find your row by it (`.filter({hasText: marker})`), and remove what
688
+ you created at the end of the scenario.
638
689
  - **Don't reach for `locator(...)` with `>` combinators, hashed class
639
690
  names, or `xpath=…`.** Stop and ask the user whether the app should
640
691
  expose a `data-testid` or accessible name.
@@ -112,6 +112,15 @@ APP_BASE_URL) — same idea as run_test's per-call `env`.
112
112
  `explore_start` opens the browser itself. Do NOT call `new_context`
113
113
  first — it's for RESETTING the browser (Phase 5), not for starting.
114
114
 
115
+ **Variables in steps.** In a value field (`fill` value, `press` key,
116
+ `select_option` value, `goto` url) the bare NAME of a `.env`/`.secrets`
117
+ variable is a reference: `value: "PASSWORD"` types the secret live and
118
+ the saved test says `fill(loc, PASSWORD)`. In a locator's text or an
119
+ assertion's text a bare NAME is the literal it looks like — to reference
120
+ a variable there write `{{NAME}}` (`text: "{{GREETING}}"`); the reply
121
+ warns when a literal equals a variable's name. Never type a secret's
122
+ VALUE: it is recorded as the name anyway, and the reply says so.
123
+
115
124
  ### Phase 2 — record the known steps as ONE batch
116
125
 
117
126
  **Batch-first default.** When the task brief already spells out the
@@ -549,7 +558,23 @@ Assertions:
549
558
 
550
559
  State reads:
551
560
  - `getTitle()`, `getUrl()`, `getAttribute(loc, name)`, `getInnerText(loc)`,
552
- `getInputValue(loc)`.
561
+ `getInputValue(loc)`, `count(loc)`; `textContains(haystack, needle)`
562
+ for a substring probe on a string you already hold
563
+ (`assertTrue(textContains(out.stdout, 'ready'))`).
564
+ - A `textarea` / `input` VALUE is not page text: `waitForText` and
565
+ `assertText` never see it. Check it with `assertValue(loc, expected)`
566
+ — it polls until the timeout (5 s by default, `{timeout}` to raise
567
+ it), so a value that fills in late is fine. For a substring of the
568
+ value there is no polling assert: read it in a loop —
569
+ `for (i = 0; i < 20; i = i + 1) { if (textContains(getInputValue(loc), 'x')) { break; } pause(200); // lint-ok: polling a textarea value }`
570
+ — then assert.
571
+ - Only the vocabulary above is expression language. `!` is rejected by
572
+ the parser (`unotest-web lint`: `parse error — Invalid token`);
573
+ `JSON.stringify` / `Date.now()` / `indexOf` and other JS globals and
574
+ string methods are rejected by the validator with the replacement
575
+ named (`use json(value)`, `use nowMs()`, `use textContains(haystack,
576
+ needle)`). Express the check as an assertion or a locator
577
+ (`assertHidden`, `assertCount`, `.filter({hasText})`, `textContains`).
553
578
 
554
579
  Sandbox primitives:
555
580
  - `shell("cmd", "arg", …, options?)` — `execFile` style, no shell interpretation. Non-zero exit fails the step; pass `{allowNonZero: true}` to inspect `res.code` yourself. Wall-clock budget `{timeoutMs}` (default 120s).
@@ -564,8 +589,17 @@ Sandbox primitives:
564
589
  JSON — there is no key-name magic.
565
590
 
566
591
  Escape hatch:
567
- - `evaluate(\`js body\`, …args)` — raw backticks, no `${}`. Linter
592
+ - `evaluate(\`js body\`, arg?)` — raw backticks, no `${}`. Linter
568
593
  warns `lint:evaluate-discouraged`. Use only when nothing above fits.
594
+ The body gets at most ONE argument — extra arguments reach it like
595
+ this: none → nothing, exactly one → the value itself, **with two or
596
+ more extra arguments the body receives ONE array** — destructure it:
597
+ `evaluate('function(n){ return n * 2 }', 21)` and
598
+ `evaluate('([a, b]) => a + b', a, b)`. The linter's warning says so
599
+ when it sees two or more.
600
+ - Native dialogs (`confirm()` / `alert()` / `prompt()`) are accepted by
601
+ the runner itself (`dialogPolicy: "accept"` by default) — a "Remove"
602
+ that asks first needs no special step.
569
603
 
570
604
  Time helpers: `nowMs()`, `today()`, `daysFromNow(n)`. Marker helper: `randomWord(len)` — random lowercase letters, digit-free. Debug helper: `json(value)` — serialize any value for an assert message: `assertTrue(res.status == 202, json(res.body))`.
571
605
 
@@ -701,16 +735,31 @@ the old browser context, then `run_test` again.
701
735
  | `wait_for_url` | `value` (the pattern) | `options` |
702
736
  | `enter_frame` | `locator` | |
703
737
  | `exit_frame` | | |
738
+ | `screenshot` | | `name`, `fullPage`, `locator`, `outline`, `evidenceOnly` |
704
739
 
705
740
  When recording (`explorationId` present): `description` + `section`
706
741
  are required. Ad-hoc (no `explorationId`): no description/section,
707
742
  no recording.
708
743
 
744
+ `screenshot` is the **evidence** step: it files a PNG under the session
745
+ (`.unotest/explorations/<explorationId>/screenshots/<NNN>-<name>.png`,
746
+ `adhoc/` without a session), replies with `{path, width, height}` and
747
+ the image itself, and becomes `screenshot(name)` in the saved test.
748
+ `name` defaults to the section slug; `locator` captures one element
749
+ (the saved test degrades that to a page capture); `outline: true` adds
750
+ the page outline to the reply; `evidenceOnly: true` keeps the file but
751
+ records no step. `explore_stop` lists every file in `artifacts`. Take
752
+ one wherever a human would want to check a green run after the fact —
753
+ do not write throwaway scenarios just to get a picture.
754
+
709
755
  ## Failure modes you will hit
710
756
 
711
757
  - **`StaleRefError`** — a ref from an earlier lookup is no longer in
712
758
  the DOM. Intent locators don't go stale (grounded per call) — retry
713
759
  with the intent step, or re-ground via `ground_element`.
760
+ - **Grounder unavailable** — `explore_start` or the first intent step
761
+ says so; intent locators are off for the session. Use
762
+ `find_element({role, name, near?})` and its ref instead.
714
763
  - **`RefResolveError`** — element has no stable identifier (testId /
715
764
  role+name / aria-label / placeholder / alt / title / text / href /
716
765
  stable id / name attribute). Best fix: ask the app team to add
@@ -769,6 +818,11 @@ Every locator must resolve to exactly one element. When you see
769
818
  multi-matches.** Element order is brittle. Use `.filter({hasText:
770
819
  '…'})` or `.filter({has: someLocator})`. Linter flags index-based
771
820
  picking as `lint:disambig-by-index`.
821
+ - **Don't assert "the first row of the list / audit".** On a shared
822
+ stand another run (or a person) writes rows between your steps. Give
823
+ your own data a unique marker (`randomWord()` in a name or note),
824
+ find your row by it (`.filter({hasText: marker})`), and remove what
825
+ you created at the end of the scenario.
772
826
  - **Don't reach for `locator(...)` with `>` combinators, hashed class
773
827
  names, or `xpath=…`.** Stop and ask the user whether the app should
774
828
  expose a `data-testid` or accessible name.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,401 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.31.0] - 2026-09-04
4
+
5
+ ### Minor Changes
6
+
7
+ - b8e105b: New DSL function `note(label, value)`: attach a labelled value to the
8
+ current step — the question a data-driven case asked, the answer it got
9
+ — kept in the run journal and shown in the viewer under the step, during
10
+ and after the run. Any value: a string as it is, anything else as JSON;
11
+ cut at 4 KB (marked `truncated`), secrets masked. `log(...)` now also
12
+ lands in the journal under the step that wrote it (the stdout line
13
+ stays), and `assertJudge` verdicts carry the position of their statement,
14
+ so the viewer can show a verdict under its step and the whole verdict on
15
+ a failed step's error card. Each of these events records the DSL call's
16
+ `file` / `line` / `col` and the nearest entry-file statement
17
+ (`entryLine` / `entryCol`) — a note from inside a helper is attributed to
18
+ the entry step that called the helper. Without a run journal
19
+ (exploration, an ad-hoc runtime) `note` goes to the logger; nothing
20
+ fails.
21
+ - 56784b3: `ExecutionWalker.run` takes a trailing `entryArgs` list and binds it by
22
+ position onto the entry function's parameters (missing ones `null`) in
23
+ the root scope before the body runs — what `explore_run_flow` needs to
24
+ replay a parameterised `flow_*` helper live.
25
+ - 56784b3: `explore_run_flow` replays parameterised flows: pass `args` (positional
26
+ JSON values, `"{{NAME}}"` for a variable) and the helper's parameters are
27
+ bound for the live replay — `flow_login(username, password)` no longer
28
+ fails with `Variable "username" is not defined`. `explore_start` lists
29
+ each flow's `params`; a wrong argument count is refused with the
30
+ signature. The recording keeps variable NAMES, so the saved test reads
31
+ `flow_login(LOGIN, PASSWORD);` — `flow_call` is now a regular action
32
+ plugin rendering the call with its arguments.
33
+ - 307582f: New `explore_step` action `screenshot` — the evidence step of an
34
+ exploration. It files a PNG under the session
35
+ (`.unotest/explorations/<explorationId>/screenshots/<NNN>-<name>.png`,
36
+ `adhoc/` without a session), replies with the absolute `path`, `width`
37
+ and `height`, and returns the image itself as an MCP image block (a JPEG
38
+ copy when the PNG is over 1 MiB; path only when both are). `name`
39
+ defaults to the section slug, `fullPage` captures the whole page,
40
+ `locator` captures one element, `outline: true` adds the page outline
41
+ to the reply, `evidenceOnly: true` keeps the file but records no step.
42
+ A recorded screenshot becomes `screenshot(name)` in the saved test; an
43
+ element capture degrades to a page capture with a comment and a
44
+ non-blocking `DEGRADED_STEP` warning. `explore_stop` now lists the
45
+ session's files in `artifacts`. Driver: `DriverPage.screenshotElement`.
46
+ - 6b61884: New linter rule `lint:external-variable-undeclared` (default `error`): a
47
+ bare `UPPER_SNAKE` identifier that no layer file declares — `unotest/.env`,
48
+ `unotest/.secrets`, or the `.env.<name>` / `.secrets.<name>` overlay of
49
+ the environment `lint --env <name>` / `UNOTEST_ENV` selects — is reported
50
+ with the files searched, instead of failing at that statement at run time
51
+ after the browser is up. A name bound in the file (assigned, a parameter,
52
+ a loop counter) is never flagged. The shell is not a declaration: a value
53
+ CI exports still needs its name in `unotest/.env` (`NAME=` with no value
54
+ declares it; the shell's value wins at run time, as before). As an error
55
+ it is not silenced by `// lint-ok:`; downgrade it in `linter.rules` if a
56
+ project needs that.
57
+
58
+ `run_test` (MCP) and `e2e` run the same check before the browser starts,
59
+ against what the run will actually resolve — files and the ambient shell
60
+ — so a suite that lives on a shell variable keeps running; a name that
61
+ would have failed in the scenario anyway now fails up front (`run_test`
62
+ answers `lint_failed`; `e2e` prints it and proceeds, like every pre-run
63
+ diagnostic).
64
+
65
+ The linter also descends into operator, array and property-access
66
+ operands now, so a regex or `{{mustache}}` literal inside `a + b` is
67
+ diagnosed the same as one on its own.
68
+
69
+ - eddb1db: New linter rule `lint:one-test-per-file`: a second (third, …) top-level
70
+ `function test_*` in one scenario file is reported at its declaration.
71
+ A file is the unit a collection runs and the viewer shows — the Steps
72
+ tree projects a run onto the first `test_*`, so the others executed with
73
+ nowhere to be seen. Fold the cases into one test with tagged steps
74
+ (`step("…", {tag: id}, () => { … })`, `step.soft` when a case must not
75
+ stop the run) or move shared journeys into `flow_*` helpers under
76
+ `unotest/e2e/_helpers/`; `flow_*` functions in a scenario file are not
77
+ counted.
78
+
79
+ **Migration window:** the rule is a **warning** in 0.31 — visible in
80
+ `lint`, the editor and `run_test`'s warnings, silenced per line with
81
+ `// lint-ok: <reason>`, never blocking. A later minor release flips the
82
+ default to `error` (which the pre-run gate of `run_test` refuses); split
83
+ files that hold several `test_*` before then.
84
+
85
+ - b0a00cb: New DSL function `readJsonLine(path, filter) → object`: read a JSONL file
86
+ that already exists and return the first line matching the key filter —
87
+ no polling. `waitForJsonLine` was the only structured input into a
88
+ scenario, and for a fixture or a finished export its 20s timeout only
89
+ masked a wrong path. `readJsonLine` takes the same filter (strict equality
90
+ per key, dot paths for nesting, unparsable lines skipped) and fails at
91
+ once: `file not found: <path>` when the file is missing, `no line matches
92
+ {…} in <path>` with parse stats and the closest line when nothing matches.
93
+ Data-driven tests read their cases with it and name evidence after them
94
+ (`screenshot(q.id)`).
95
+ - 100ab9d: Data-driven steps: `step("label", {tag: q.id}, () => { … })` names the
96
+ case an iteration is on, and `step.soft("label", [{tag}], () => { … })`
97
+ records a failure inside instead of stopping the run — the rest of that
98
+ body is skipped, the next case runs, and the test still ends **failed**
99
+ with every soft failure listed (`3 soft step(s) failed: Question [q17]:
100
+ …`, one line per case in the CLI output; `run_test` answers
101
+ `next.softFailures`; `inspect_runtime`'s `lastFailure` carries `stepTag`
102
+ and `soft`). The failure bundle shows the page at the moment of the
103
+ **first** soft failure, with `soft: true` and `stepTag` in
104
+ `failure.json`; later soft failures live in the journal. `step.soft` is
105
+ allowed inside `test_*` only; nesting is free, and the outer step is a
106
+ group, not an assertion — a soft failure inside does not change its
107
+ outcome. The run journal gains `step-block:started` / `step-block:finished`
108
+ envelopes per block (label, tag, soft, outcome, whole-block duration).
109
+ Editor typings declare the new forms.
110
+ - ad7c918: The run journal records every loop pass (`loop:iteration`, with the loop
111
+ statement's position and the pass index), which the viewer's new Trace
112
+ view uses to show a loop as one group per iteration. Also: when an error
113
+ unwinds through enclosing statements, `lastFailure` (the failure bundle's
114
+ position, `inspect_runtime`, the CLI's `in step "…" [tag]` line) now
115
+ names the innermost statement that raised it — the assert inside the
116
+ tagged step — instead of the outermost loop that re-threw it.
117
+
118
+ ### Patch Changes
119
+
120
+ - 9ae6d59: A bundle no longer declares the runner's own `UNOTEST_*` settings
121
+ (`UNOTEST_HEADED`, `UNOTEST_LOG_LEVEL`, …) from the suite's `.env`, so a box
122
+ stops warning on every push that an environment "neither sets nor holds"
123
+ names `env push` never sends. Both sides now share one rule
124
+ (`isRunnerSetting` in `@unotest/protocol`): those variables configure the
125
+ machine a run happens on, not the suite. A per-suite value such as the
126
+ navigation timeout travels with the bundle through `unotest.config.mjs`
127
+ (`defaultNavigationTimeoutMs`), not through `.env`.
128
+ - 917380d: `unotest.config.*` is merged with the defaults recursively. A partially
129
+ written section — `linter: { rules: { 'lint:deep-css': 'off' } }`,
130
+ `failureBundle: { tier3: { video: true } }`, `viewport: { width: 1920 }`
131
+ — now keeps the rest of that section's defaults instead of failing with
132
+ "config validation failed" (nested sections require all of their fields
133
+ at validation time, so a top-level replace made every partial section
134
+ invalid). Arrays and scalars still replace as a whole: `browsers:
135
+ ['firefox']` means firefox only. The setup manual's example config,
136
+ which was exactly such a partial `linter`, loads as written; its
137
+ `timeouts` block — never a config field — is corrected to
138
+ `defaultTimeoutMs` / `defaultNavigationTimeoutMs`.
139
+ - e048334: The viewer's editor flags an undeclared external variable
140
+ (`lint:external-variable-undeclared`), the way `unotest-web lint` and the
141
+ pre-run lint already do: a bare `UPPER_SNAKE` identifier that no
142
+ `unotest/.env` / `.secrets` file of the active environment declares is an
143
+ error in the editor, naming the files searched. Protocol:
144
+ `DslValidateContext` gains `externalNames` / `externalSources`; the
145
+ viewer supplies them from the active target + environment's layer files
146
+ (the same files the variables panel shows) and `@unotest/web`'s language
147
+ service turns them into the linter's lookup.
148
+ - dcf4c9e: Viewer editor: lint markers appear as soon as a file opens (or the page
149
+ reloads), not only after the first keystroke — the editor validates the
150
+ source it mounted with instead of waiting for a change event that a
151
+ reopened tab never fires.
152
+ - 6646584: Docs: `hover` now lists its options (`force`, `position`, `timeout`) in
153
+ the DSL reference, the editor signature and the generated typings — it
154
+ used to read as if it took none, while `position` is exactly what a
155
+ hover-revealed menu at one edge of a tall element needs. The agent
156
+ integration guide gains "Watching a collection run": the collection's
157
+ `steps.jsonl` journal (path, the four `collection-run:*` events, how to
158
+ tail it) as the progress source for a run started in the background,
159
+ where the CLI's per-scenario lines only arrive when the process ends.
160
+ - 630d6ce: `unotest-web lint` now receives its arguments: the bin dispatcher forwarded
161
+ only the command name, so `lint --env <name>` linted the base environment
162
+ and an explicit file argument was ignored (every file was linted instead).
163
+ `UNOTEST_ENV=<name>` worked all along, which is why the gap went unnoticed.
164
+ - f3bd3cc: `lint` works on a project that has not migrated to the 0.28 layout yet.
165
+ A `unotest.config.*` still at the project root used to stop `lint` with
166
+ the same refusal `e2e` gives — right for a command that would RUN on
167
+ defaults, wrong for one that only grades: nobody lints a suite during
168
+ the very migration that touches every file. `lint` now builds its
169
+ context on the default settings (rule severities, helpers dir), reads
170
+ `unotest/.env`, `.secrets` and `_helpers/` as usual, and reports the
171
+ layout once as the new `lint:legacy-layout` warning (default `warn`,
172
+ configurable like any rule) with the migration on one line. The exit
173
+ code is unaffected by it. `e2e`, `collection` and `bundle push` refuse
174
+ exactly as before.
175
+ - 224d016: The MCP server says when it is running a stale build. `run_test`'s
176
+ pre-spawn lint, flow discovery (`explore_start`), `explore_run_flow`,
177
+ `generate_dsl_from_exploration` and `save_exploration_as_test` parse and
178
+ render DSL in the server's own process, on the modules it loaded at
179
+ start — after a rebuild a new DSL feature came back as a false parse
180
+ error until the server was restarted, while the runner child was already
181
+ on the new build. Those replies now carry `serverStale: true` and a
182
+ `serverStaleWarning` ("restart the MCP server") once the code on disk is
183
+ newer than the server's start; nothing changes while it is fresh.
184
+ - 02757c3: `serverStale` now also rides on `explore_step` / `explore_steps` (locator
185
+ resolution and recording run in the server's process), `find_element`
186
+ and `ground_element` replies — the same flag `run_test` and the
187
+ exploration tools already carry once the build on disk is newer than the
188
+ running MCP server.
189
+ - dcf4c9e: `goto` with a relative path resolves against the runtime's own base URL
190
+ instead of the browser context's. In a CLI run the two coincide; in an
191
+ exploration recording against an `env` override, `explore_run_flow`
192
+ replayed `goto('/_guard/')` on the MCP session's shared context — created
193
+ with the server's base — and landed on the wrong host. The replay now
194
+ follows the session's `baseUrl` (and its variables, as before).
195
+ - f3bd3cc: `screenshot(name)` accepts any string value, not only a literal. The
196
+ runtime always slugified whatever it received, but the validator (and
197
+ therefore `lint` and `run_test`) rejected `screenshot(q.id)` and
198
+ `screenshot(textJoin(['q-', id]))` — a data-driven test had to name every
199
+ capture the same. The `name` slot is now string-like, as in `fill`; a
200
+ non-string argument is still an `arg-kind` error.
201
+ - dcf4c9e: A section label reopened after another section is called out instead of
202
+ silently splitting a step: `explore_step` / `explore_steps` reply with
203
+ `sectionHint` the moment it happens, and the generated draft carries a
204
+ non-blocking `SPLIT_SECTION` warning naming the reopened entries. The
205
+ recorded order of actions is never changed; `autoRun` and `save` are not
206
+ held back by it.
207
+ - 9ae6d59: A scenario that finished in the same instant the viewer looked at it is no
208
+ longer recorded as `interrupted` with a finish an hour before its start.
209
+ The runner ends a run by appending `run:finished` and then backdating the
210
+ heartbeat; the viewer read those two files in the opposite order, so a
211
+ steps snapshot taken a few milliseconds early could meet the backdated
212
+ heartbeat, and the provisional verdict stuck in the run index while the
213
+ collection reported the scenario passed. The heartbeat is read first now.
214
+ A viewer restart (which rebuilds the index from disk) already corrected
215
+ such records; runs indexed live are right the first time.
216
+ - 13a2d51: Viewer, Home tab: the tile of a scenario that is running now breathes
217
+ through colour and shadow only — the scale pulse is gone. It moved the
218
+ tile's box every frame, so an automation driver never saw the tile as
219
+ stable and a click on it waited out its timeout. A tile that never ran
220
+ no longer carries `data-age="fresh"`: it has no age, so the attribute is
221
+ absent.
222
+ - 91fb562: Viewer, Home tab: clicking a scenario tile opens the scenario's LAST RUN —
223
+ the run whose verdict the tile's colour is showing — instead of the test
224
+ file. A tile that has never run still opens the test (there is no run to
225
+ open), and so does every tile in schedule mode, where the schedule is
226
+ edited from the test. The legend now reads "hover for history · click to
227
+ open the last run".
228
+ - dcf4c9e: Viewer, Home tab: the tile field no longer refits itself in a loop. The
229
+ header and legend take the grid's width; when the legend wrapped at one
230
+ width and not at the next, the field's height changed, the fit followed,
231
+ the width changed back — at 60 fps every tile's box moved every frame,
232
+ and an automation click on ANY tile ("element is not stable") waited out
233
+ its timeout unless forced. Header and legend now never wrap (they grow
234
+ past the grid instead), so the fit converges and tiles stay put. A run
235
+ opened from a tile is titled by its scenario, like one opened from the
236
+ Runs tree, instead of by its run id.
237
+ - dcf4c9e: Recording no longer bakes relative time or counters into a locator. An
238
+ accessible name such as `Run smoke — passed, 7m ago`, `just now`,
239
+ `yesterday`, `5 passed`, `7 runs` or a trailing badge (`Inbox · 3`,
240
+ `Errors: 12`) now counts as volatile: the recorder emits the stable prefix
241
+ (`{name: "Run smoke — passed", exact: true}`, then `/^Run smoke — passed\b/`)
242
+ instead of the literal, and the draft carries the `DYNAMIC_TEXT` warning
243
+ with that prefix. Bare numbers elsewhere stay stable (`Page 2`, `Q4 2026`,
244
+ `v2`, `2FA`).
245
+ - Updated dependencies [b8e105b]
246
+ - Updated dependencies [b8e105b]
247
+ - Updated dependencies [b8e105b]
248
+ - Updated dependencies [9ae6d59]
249
+ - Updated dependencies [56784b3]
250
+ - Updated dependencies [e048334]
251
+ - Updated dependencies [dcf4c9e]
252
+ - Updated dependencies [100ab9d]
253
+ - Updated dependencies [100ab9d]
254
+ - Updated dependencies [100ab9d]
255
+ - Updated dependencies [100ab9d]
256
+ - Updated dependencies [ad7c918]
257
+ - Updated dependencies [ad7c918]
258
+ - Updated dependencies [ad7c918]
259
+ - Updated dependencies [9ae6d59]
260
+ - Updated dependencies [13a2d51]
261
+ - Updated dependencies [91fb562]
262
+ - Updated dependencies [dcf4c9e]
263
+ - @unotest/core@0.31.0
264
+ - @unotest/protocol@0.31.0
265
+ - @unotest/viewer@0.31.0
266
+ - @unotest/dsl@0.31.0
267
+ - @unotest/grounder-client@0.31.0
268
+
269
+ ## [0.30.0] - 2026-09-03
270
+
271
+ ### Minor Changes
272
+
273
+ - 23de03c: On a hosted viewer (a box), the project and environment you are looking at
274
+ are now named and switched in the viewer's own tree header, in place of
275
+ the checkout directory it used to show there (`current`, which told
276
+ nobody anything). Previously the only way to another environment was
277
+ logging out and using the picker page. The chevrons appear on hover, like
278
+ the refresh button beside them, and only when something fronts the viewer
279
+ and offers it a list; a plain local viewer keeps the folder name it
280
+ always showed. Because
281
+ each environment on a box is its own viewer process, picking one is a full
282
+ page load into that viewer, and the menu shows which test bundle each
283
+ environment is running. The status bar's `.env.<name>` overlay switcher is
284
+ now labelled "env overlay", so the two senses of "environment" no longer
285
+ share a name. `@unotest/viewer/session` carries the two URLs this needs
286
+ (`environmentsUrl`, `switchEnvUrl`) and the `ViewerEnvOption` type, both
287
+ optional — a proxy that does not offer them gets the old behaviour.
288
+
289
+ ### Patch Changes
290
+
291
+ - A run started in the first moments after the viewer boots is now discovered: the run watcher finishes its initial scan of the day directory before reporting itself started, instead of filing such a run as history.
292
+ - 3f5cab7: `bundle push` and `env push` no longer send you to "the box's
293
+ admin page" for a project token — there is no such page; tokens are
294
+ issued per project by the box's operator, and the refusal hints and the
295
+ missing-token message now say so.
296
+ - 3f5cab7: The vocabulary's description of `evaluate` now says how extra
297
+ arguments reach the body — none → nothing, exactly one → the value
298
+ itself, two or more → one array — matching the runtime, the linter's
299
+ hint and the authoring skill; it used to read as if they were positional
300
+ (`evaluate(js, ...args)`), and so did the generated DSL typings.
301
+ - 3f5cab7: A scenario that answers a native `confirm()` — a "Remove" button that
302
+ asks first, say — no longer fails when `run_test` auto-attaches the MCP
303
+ session to the run's browser, or after `attach_debug_session`. The
304
+ attaching client now applies the same dialog policy the run does
305
+ (`dialogPolicy`, accept by default) to the page it watches; before, it
306
+ listened for no dialogs at all, and Playwright dismisses every dialog on
307
+ behalf of a client that has no listener — so the second pair of eyes was
308
+ answering "no" before the run's own accept could land. The same scenario
309
+ passed with an exploration session open only because that session
310
+ happened to keep the run's browser to itself. Under `manual` the attached
311
+ client leaves the dialog to whoever handles it instead of dismissing it.
312
+ - 9f2e244: On a box, an administrator can now see a secret's value from the guard's
313
+ values page — the eye reveals it in the row, the copy button puts it on
314
+ the clipboard without showing it — and every look is a `secret.revealed`
315
+ entry in the box's audit trail, naming who, which secret and whether it
316
+ was shown or copied. The page is one table now (target, variables,
317
+ secrets), edited in place, with a JSON view that changes the whole
318
+ environment at once; a secret left as `"••••"` there is kept as it is.
319
+ The token door `env push` / `env set` talk to is unchanged: secrets stay
320
+ write-only on the wire.
321
+
322
+ `@unotest/protocol` carries the contract: `boxEnvSecretRevealPath` /
323
+ `boxEnvSecretsRevealPath` with their response parsers, the session-door
324
+ batch `BoxEnvValuesReplaceRequest` (`null` for a secret means "keep") and
325
+ `parseBoxEnvValuesReplaceRequest`, and the refusal code `unknown-secret`
326
+ for a reveal or a keep that names a secret the environment does not
327
+ hold. `isEnvVarName` now refuses `__proto__`: it is spelled like a
328
+ variable, but a plain object cannot hold it and the value was silently
329
+ lost. The CLI explains an `unknown-secret` refusal.
330
+
331
+ - 3f5cab7: `evaluate(js, …args)` has always handed the body its extra arguments in
332
+ one of three shapes — none, the single value, or ONE array for two or
333
+ more — while the authoring skill described them as positional. The
334
+ skill now states the rule with both spellings
335
+ (`evaluate('function(n){…}', 21)` / `evaluate('([a, b]) => …', a, b)`),
336
+ and the `lint:evaluate-discouraged` warning adds the same hint when it
337
+ sees two or more extra arguments; the runtime is unchanged. The skill
338
+ also gains the getters it left out (`count`, `getInputValue`,
339
+ `textContains`), the note that `waitForText` never sees a textarea's
340
+ value, the parser's refusal of `!` / `indexOf` / string methods, the
341
+ shared-stand rule against "the first row" (mark your own data, filter
342
+ by it, clean up), that native dialogs are accepted by the runner, and
343
+ what to do when the grounder is unavailable.
344
+ - 3f5cab7: While recording with `explore_step` / `explore_steps`, the bare NAME of a
345
+ scenario variable is substituted only where the step types a value into
346
+ the page — `fill`'s value, `press`'s key, `select_option`'s value,
347
+ `goto`'s url. In a locator's text or an assertion's expected text a bare
348
+ name is now the literal it looks like: `assertText(el, "BOX_LAB_PASSWORD")`
349
+ expects those letters, where before the live step silently waited for
350
+ the secret's value and the saved test said something else. To reference
351
+ a variable in a matcher, write `{{NAME}}`; the reply warns when a literal
352
+ happens to equal a variable's name. And a secret's value typed into a
353
+ value field is recorded as the secret's name (the reply says which), so
354
+ the value never lands in the test file.
355
+ - 3f5cab7: `run_test` no longer reports `spawn_failed` about a runner that is still
356
+ starting. Two runs launched together on a busy box took longer than the
357
+ fixed 10-second wait to write their first `runtime.json`, and the second
358
+ was declared dead while it was booting a browser. The wait now lasts as
359
+ long as the runner child is alive (up to 60 seconds), ends early when the
360
+ child joins the run queue, and — when the child really dies — says so with
361
+ its exit code and the last lines of its stderr, which the runner now
362
+ writes to `spawn.stderr.log` in its run directory.
363
+
364
+ `explore_start` now reports whether the grounder is usable for the
365
+ session: `grounder: {available: true}`, or `{available: false, reason,
366
+ hint}` when grounding is off, the backend is unreachable, or a required
367
+ model is not pulled (`"embeddinggemma" is not available there — pull it:
368
+ \`ollama pull embeddinggemma\``). An intent step in such a session fails
369
+ with the same words, not with the backend's raw error; the probe is one
370
+ short request per session.
371
+
372
+ Secret masking no longer rewrites the runner's own identifiers in
373
+ `runtime.json`: with `LOGIN=admin`, the scenario path `guard-admin.js`,
374
+ the test function `test_guard_admin` and the run id kept their names in
375
+ `inspect_runtime` replies instead of turning into `guard-‹secret:LOGIN›.js`.
376
+ Masking by value is unchanged everywhere else; the run id, scenario
377
+ path, test function and call-stack names are an explicit exception.
378
+
379
+ - 3f5cab7: `save_exploration_as_test` (and `generate_dsl_from_exploration`) now
380
+ write scoped locators — a `getByRole` / `getByText` / `locator(css)` step
381
+ under a parent, such as the "Reveal" button inside one table row — as
382
+ the chain the DSL already runs:
383
+ `locator('tr[data-row="A"]').getByRole('button', {name: 'Reveal', exact: true})`.
384
+ Before, any recorded step whose locator went below its root was skipped
385
+ with `NO_DSL_PRIMITIVE`, and the save was refused, although the step
386
+ had executed and the hand-written form worked. Every locator is now
387
+ rendered in the same method-chain spelling — narrowing steps too
388
+ (`getByRole('row').filter({hasText: 'Alice'}).first()` instead of
389
+ `first(filter(getByRole('row'), {hasText: 'Alice'}))`); both spellings
390
+ remain valid input, the runtime is unchanged.
391
+ - Updated dependencies [23de03c]
392
+ - Updated dependencies [9f2e244]
393
+ - @unotest/viewer@0.30.0
394
+ - @unotest/protocol@0.30.0
395
+ - @unotest/core@0.30.0
396
+ - @unotest/dsl@0.30.0
397
+ - @unotest/grounder-client@0.30.0
398
+
3
399
  ## [0.29.0] - 2026-09-02
4
400
 
5
401
  ### Minor Changes
@@ -75,7 +75,9 @@ function dispatch(sub) {
75
75
  forwardArgs: args.slice(1),
76
76
  };
77
77
  case "lint":
78
- return { dist: "dist/runner/cli.js", src: "src/runner/cli.ts", forwardArgs: ["lint"] };
78
+ // Forwarded whole, like e2e: `--env <name>` and explicit file
79
+ // arguments belong to the command, not to the dispatcher.
80
+ return { dist: "dist/runner/cli.js", src: "src/runner/cli.ts", forwardArgs: args };
79
81
  case "viewer":
80
82
  return { dist: "dist/runner/cli.js", src: "src/runner/cli.ts", forwardArgs: ["viewer"] };
81
83
  case "prepare-fix":