@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.
@@ -604,11 +604,23 @@ the old browser context, then `run_test` again.
604
604
  | `wait_for_url` | `value` (the pattern) | `options` |
605
605
  | `enter_frame` | `locator` | |
606
606
  | `exit_frame` | | |
607
+ | `screenshot` | | `name`, `fullPage`, `locator`, `outline`, `evidenceOnly` |
607
608
 
608
609
  When recording (`explorationId` present): `description` + `section`
609
610
  are required. Ad-hoc (no `explorationId`): no description/section,
610
611
  no recording.
611
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
+
612
624
  ## Failure modes you will hit
613
625
 
614
626
  - **`StaleRefError`** — ref no longer in the DOM. Re-`get_page_snapshot`
@@ -735,11 +735,23 @@ the old browser context, then `run_test` again.
735
735
  | `wait_for_url` | `value` (the pattern) | `options` |
736
736
  | `enter_frame` | `locator` | |
737
737
  | `exit_frame` | | |
738
+ | `screenshot` | | `name`, `fullPage`, `locator`, `outline`, `evidenceOnly` |
738
739
 
739
740
  When recording (`explorationId` present): `description` + `section`
740
741
  are required. Ad-hoc (no `explorationId`): no description/section,
741
742
  no recording.
742
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
+
743
755
  ## Failure modes you will hit
744
756
 
745
757
  - **`StaleRefError`** — a ref from an earlier lookup is no longer in
package/CHANGELOG.md CHANGED
@@ -1,5 +1,388 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.32.0] - 2026-09-05
4
+
5
+ ### Minor Changes
6
+
7
+ - 442fadf: `npx @unotest/web box …` reads a box's results from a terminal: `box envs` lists the environments a read token may look at, `box runs` their history (`--latest` collapses it to one line per scenario with its failing streak), `box run <id>` explains one run — the failure, the soft steps, the judge's verdicts — `box queue` shows who is waiting, and `box screenshot` saves a frame the run captured.
8
+
9
+ `box run --download` fetches the run's `*.unotest.zip` into `.unotest/box/` and unpacks its failure bundle into `.unotest/failures/`, where `list_failures`, `get_failure_*` and `agent_fix` already look — so a run that failed on a box is debugged with the commands a local failure is. `--no-screenshots` asks the box itself to leave the step frames out (`GET /api/runs/:id/export?screenshots=0`), which is what makes the download smaller rather than only the disk.
10
+
11
+ Reading needs a personal read token in `UNOTEST_BOX_READ_TOKEN`; the project's `UNOTEST_BOX_TOKEN` still pushes bundles and values and cannot read runs.
12
+
13
+ The viewer publishes its archive reader as `@unotest/viewer/snapshot`, so the three places that open a `*.unotest.zip` — its server, its browser bundle and now the CLI — share one implementation and one message for a file that is not an archive.
14
+
15
+ - 69fbea1: Six MCP tools close the loop that `bundle push --run` opens: `box_run` says what became of a run the agent ordered on a box, `box_run_download` unpacks its failure bundle into `.unotest/failures/` so `get_failure_trace`, `get_failure_console`, `get_failure_a11y`, `get_failure_screenshot`, `get_failure_network` and `agent_fix` work on it unchanged, and `box_envs`, `box_runs`, `box_queue` and `box_screenshot` cover the cases where the agent has no run id, no environment name, a run that never started, or a page it would rather see than read about. Their descriptions carry the route, not just the arguments.
16
+
17
+ Without a read token the tools refuse with a message saying where to mint one, so an agent meets an instruction rather than an unexplained failure.
18
+
19
+ - 9099f64: Box: personal read tokens, so an agent can read a box's runs without a browser.
20
+
21
+ A user mints a token for themselves on the guard's new **Read tokens** page
22
+ (`/_guard/tokens`), sees the value once, and points a client at the box with
23
+ `UNOTEST_BOX_READ_TOKEN`. The token is always `readonly` whatever its owner's
24
+ role, may only `GET`, and names the environment it means in
25
+ `X-Unotest-Environment: <project>/<environment>` (`GET /_guard/api/envs` lists
26
+ them). It is not a machine identity: it follows its owner — revoked, disabled
27
+ or a lapsed seat all stop it, and the refusal says which. An administrator
28
+ sees every token on the box and can revoke one that is not theirs; issuing,
29
+ first use and revocation all land in the audit trail.
30
+
31
+ `UNOTEST_BOX_TOKEN` is unchanged: the project token still pushes bundles and
32
+ environment values, and read tokens cannot — asking with the wrong one now
33
+ says which token the route wants instead of a bare "unauthenticated".
34
+
35
+ A read token is never passed on to the viewer behind the guard, so it cannot
36
+ end up in the logs of a service that has no use for it. A box whose licence
37
+ has lapsed, and an environment whose viewer is not up yet, answer a token in
38
+ the read contract's shape rather than with a page or a bare 503 — "wait" and
39
+ "renew the licence" are not the same instruction as "your token is wrong".
40
+
41
+ ### Patch Changes
42
+
43
+ - 5266b30: The agent integration guide's tool catalog matches the server again. It listed fifteen per-action tools (`goto`, `click`, `fill`, `press`, …) that were removed when recording moved into `explore_step`, had no section at all for the ten exploration tools that replaced them, counted the debugger's eleven tools as six, and put the total at 39 when it is 50. An agent reading it as a map — which is exactly what this file is for — would call tools that do not exist and conclude the server is broken. Every name now comes from the list the test suite verifies against the real registry, and the section counts add up to the total.
44
+ - 442fadf: `BoxReadClient` reads a box's runs — environments, run history, one run's whole journal, a collection's children, the queue, a run's export zip and its individual artifacts — over the same HTTP routes a browser uses, authenticated with the personal read token in `UNOTEST_BOX_READ_TOKEN`. Every refusal arrives as a typed `BoxReadError` whose `kind` says what to do next, so throttling is never mistaken for a rejected token and a live run's export says "wait" rather than looking like a broken box. Configuration mistakes surface when a read is attempted rather than at startup, so a stale token in a project's `.env` cannot stop a local run that never touches a box.
45
+
46
+ The viewer gains a `@unotest/viewer/wire` entry point exporting its HTTP contract types (the runs page, a run's full snapshot, the queue payload and the snapshot manifest), so a client can name the shapes it parses instead of keeping a second copy of them.
47
+
48
+ - f770a25: The box address and the read token are resolved from `unotest/.env` and `unotest/.secrets` on every request, not once at startup, and the `box` CLI reads those files directly. `unotest-web box …` runs without loading the project config, so nothing had flattened `unotest/.env` into its environment — the command told people to put the address there and then refused with "no box address". And an MCP server that resolved once meant a token written while it ran was ignored until the editor reconnected it, which is exactly the restart this path exists to avoid. One rule for both settings, from both entry points: a flag, then the environment, then the project's files.
49
+ - 8aa0f30: Protocol contract for reading a box's run results with a personal read token: `UNOTEST_BOX_READ_TOKEN` (distinct from the project's `UNOTEST_BOX_TOKEN`, which stays a write credential for bundles and environment values), the `X-Unotest-Environment: <project>/<environment>` header every bearer request names its own environment with, the `/_guard/api/envs` listing and its `BoxReadEnvironment` entry, and typed refusals (`unauthorized`, `forbidden`, `unknown-environment`, `not-found`, `rate-limited`, `run-in-progress`, `unavailable`, `malformed`) with parsers that reject anything that is not a box answering. Throttling gets a code of its own rather than an `unauthorized` carrying `Retry-After`: a rejected token means mint a new one, a throttled one means wait and resend the same one. The viewer's `ViewerEnvOption` is now that same protocol type rather than a second copy of it. An environment whose viewer is still starting — the common state right after a bundle push — answers `unavailable` rather than looking like an unreachable box, so the advice is to wait rather than to check the address.
50
+ - 8aa0f30: A run exported as `*.unotest.zip` now carries everything needed to diagnose it away from the machine that produced it: `stdout.log` and `stderr.log` (previously dropped, which left the one artifact that explains a runner crash outside the bundle), the failing page's `page.html` reachable through the manifest, and the run's step screenshots — declared in a new `manifest.screenshots` list, because an importer keeps only what the manifest names and a frame absent from it did not survive the round trip.
51
+ - 5266b30: `UNOTEST_BOX_READ_TOKEN` is now read from `unotest/.secrets` as well as from the environment, with an exported value winning. This is what makes the `box_*` tools usable from an agent at all: an MCP server is started by the editor, so a token that exists only in a shell is a token the server never receives — the alternative was editing the editor's own JSON config and reconnecting the server. `unotest/.env` is deliberately not consulted: it travels inside a pushed bundle, while `.secrets` does not. The CLI resolves the token the same way, so one token serves both.
52
+ - 5266b30: `box run` and `box_run` now carry the `{tag}` of a failed soft step, in the JSON and in the printed line (`Rubric [judge-red]`, the shape a local run already prints). A data-driven test runs one `step.soft` over many cases, so its failures all share a label and the tag is the only thing that says which case failed — without it a remote reader saw "Rubric failed" three times and had to tell them apart by line number. The tag was in the run's journal all along; the summary dropped it.
53
+ - ee6bb51: Secrets injected by a box are masked. Masking works by value, and the registry of values was built from `unotest/.secrets` alone — a file that does not exist on a box, where the daemon passes the values as environment variables and names them in `UNOTEST_BOX_SECRET_NAMES`. The registry was therefore empty on every box run, and a password a scenario typed reached the run journal, the run's `stdout.log`, the viewer's System pane and anything an agent downloaded, in the clear. The runner now registers those values alongside the ones it reads from files, so masking no longer depends on where the run happens to be. Nothing changes on a developer's machine, where the variable is not set.
54
+ - 047ca68: Failure text no longer carries terminal colouring into files and replies.
55
+
56
+ Playwright paints its call log whenever the environment claims a terminal is
57
+ watching — `FORCE_COLOR`, which an MCP server inherits from whatever launched
58
+ it — and that message was copied verbatim into `steps.jsonl`, `runtime.json`
59
+ and the failure bundle. An agent reading the JSON got an escape sequence in
60
+ the middle of the sentence it was trying to parse, and the viewer rendered
61
+ it as a chewed-up word.
62
+
63
+ The colouring is dropped where a thrown error becomes our data, so every
64
+ reader of a failure gets the same clean text. Our own output is unaffected:
65
+ it paints at print time, which is where colour belongs.
66
+
67
+ The same on mobile: a failure's text in the run journal and in the report no
68
+ longer carries terminal escapes.
69
+
70
+ - 047ca68: A failure somebody paused on stays a failure, with the evidence to show for it.
71
+
72
+ A run driven through the debugger — `run_test` then `resume`, or the viewer's
73
+ Continue — reported `completed` after a failure it had paused on, wrote no
74
+ failure bundle and no `failure/` artifacts, and left `list_failures` with
75
+ nothing to show. Continuing past a failure is how it gets inspected; it was
76
+ never meant to retract it. The verdict is now decided where the run's own
77
+ events are seen, so the journal, `runtime.json` and the reply agree, and a
78
+ debug run leaves the same evidence a plain `unotest-web e2e` run does.
79
+
80
+ `abort_runtime` (and Stop, and SIGTERM) now also ends the run in
81
+ `runtime.json`, not only in `steps.jsonl`: the control file used to keep
82
+ saying `paused-step` about a run that was over, so anything reading it rather
83
+ than the journal saw a pause that never ended.
84
+
85
+ - c5d3cda: README links the documentation site instead of manuals that are not shipped in the package (`guides/manuals/*`), and says what a run on a box cannot reach: `localhost`, port-forwards, host tools, a judge service on your machine — with the recipe for the judge on a box.
86
+ - 442fadf: Secret values registered from `unotest/.secrets` are now masked in what the runner prints, not only in what it writes. The run journal and every failure artifact were redacted; the terminal line was not, and that line is also copied into the run's `stdout.log` / `stderr.log` and streamed to the viewer's System pane. Masking is applied once, where the logger is built, so child loggers and message arguments are covered too. The collection runner is covered by the same rule: its messages go through its logger rather than straight to the stream, and the logger it builds when a caller passes none reads the project's secrets the way the composition root's does.
87
+ - 047ca68: A `step.soft(...)` failure no longer stops a debug run — or goes missing from it.
88
+
89
+ Under a debugger (`run_test`, or `e2e --debug`), every soft failure raised
90
+ the debug wheel: the run stopped on each one and an agent had to `resume`
91
+ its way through them. Worse, it was then lost — a paused failure is consumed
92
+ where it paused, so it never reached the `step.soft` envelope that records
93
+ it, the envelope closed as if the block had passed, and a run with three
94
+ soft failures could finish green with none of them listed.
95
+
96
+ Pausing is now for a failure that ENDS the run. A failure under any
97
+ enclosing soft step is recorded and stepped over exactly as it is on the
98
+ command line, and `runtime.json` — which the `run_test` reply is built from —
99
+ carries every soft failure of the run, not just the last stop.
100
+
101
+ A hard failure still pauses: that is what the debugger is for.
102
+
103
+ The same holds on mobile: a `step.soft(...)` failure no longer stops a run
104
+ under the debugger, and every soft failure of a run is now part of its
105
+ runtime state instead of being lost at the pause.
106
+
107
+ - Updated dependencies [442fadf]
108
+ - Updated dependencies [442fadf]
109
+ - Updated dependencies [8aa0f30]
110
+ - Updated dependencies [8aa0f30]
111
+ - Updated dependencies [047ca68]
112
+ - Updated dependencies [047ca68]
113
+ - Updated dependencies [047ca68]
114
+ - @unotest/viewer@0.32.0
115
+ - @unotest/protocol@0.32.0
116
+ - @unotest/dsl@0.32.0
117
+ - @unotest/core@0.32.0
118
+ - @unotest/grounder-client@0.32.0
119
+
120
+ ## [0.31.0] - 2026-09-04
121
+
122
+ ### Minor Changes
123
+
124
+ - b8e105b: New DSL function `note(label, value)`: attach a labelled value to the
125
+ current step — the question a data-driven case asked, the answer it got
126
+ — kept in the run journal and shown in the viewer under the step, during
127
+ and after the run. Any value: a string as it is, anything else as JSON;
128
+ cut at 4 KB (marked `truncated`), secrets masked. `log(...)` now also
129
+ lands in the journal under the step that wrote it (the stdout line
130
+ stays), and `assertJudge` verdicts carry the position of their statement,
131
+ so the viewer can show a verdict under its step and the whole verdict on
132
+ a failed step's error card. Each of these events records the DSL call's
133
+ `file` / `line` / `col` and the nearest entry-file statement
134
+ (`entryLine` / `entryCol`) — a note from inside a helper is attributed to
135
+ the entry step that called the helper. Without a run journal
136
+ (exploration, an ad-hoc runtime) `note` goes to the logger; nothing
137
+ fails.
138
+ - 56784b3: `ExecutionWalker.run` takes a trailing `entryArgs` list and binds it by
139
+ position onto the entry function's parameters (missing ones `null`) in
140
+ the root scope before the body runs — what `explore_run_flow` needs to
141
+ replay a parameterised `flow_*` helper live.
142
+ - 56784b3: `explore_run_flow` replays parameterised flows: pass `args` (positional
143
+ JSON values, `"{{NAME}}"` for a variable) and the helper's parameters are
144
+ bound for the live replay — `flow_login(username, password)` no longer
145
+ fails with `Variable "username" is not defined`. `explore_start` lists
146
+ each flow's `params`; a wrong argument count is refused with the
147
+ signature. The recording keeps variable NAMES, so the saved test reads
148
+ `flow_login(LOGIN, PASSWORD);` — `flow_call` is now a regular action
149
+ plugin rendering the call with its arguments.
150
+ - 307582f: New `explore_step` action `screenshot` — the evidence step of an
151
+ exploration. It files a PNG under the session
152
+ (`.unotest/explorations/<explorationId>/screenshots/<NNN>-<name>.png`,
153
+ `adhoc/` without a session), replies with the absolute `path`, `width`
154
+ and `height`, and returns the image itself as an MCP image block (a JPEG
155
+ copy when the PNG is over 1 MiB; path only when both are). `name`
156
+ defaults to the section slug, `fullPage` captures the whole page,
157
+ `locator` captures one element, `outline: true` adds the page outline
158
+ to the reply, `evidenceOnly: true` keeps the file but records no step.
159
+ A recorded screenshot becomes `screenshot(name)` in the saved test; an
160
+ element capture degrades to a page capture with a comment and a
161
+ non-blocking `DEGRADED_STEP` warning. `explore_stop` now lists the
162
+ session's files in `artifacts`. Driver: `DriverPage.screenshotElement`.
163
+ - 6b61884: New linter rule `lint:external-variable-undeclared` (default `error`): a
164
+ bare `UPPER_SNAKE` identifier that no layer file declares — `unotest/.env`,
165
+ `unotest/.secrets`, or the `.env.<name>` / `.secrets.<name>` overlay of
166
+ the environment `lint --env <name>` / `UNOTEST_ENV` selects — is reported
167
+ with the files searched, instead of failing at that statement at run time
168
+ after the browser is up. A name bound in the file (assigned, a parameter,
169
+ a loop counter) is never flagged. The shell is not a declaration: a value
170
+ CI exports still needs its name in `unotest/.env` (`NAME=` with no value
171
+ declares it; the shell's value wins at run time, as before). As an error
172
+ it is not silenced by `// lint-ok:`; downgrade it in `linter.rules` if a
173
+ project needs that.
174
+
175
+ `run_test` (MCP) and `e2e` run the same check before the browser starts,
176
+ against what the run will actually resolve — files and the ambient shell
177
+ — so a suite that lives on a shell variable keeps running; a name that
178
+ would have failed in the scenario anyway now fails up front (`run_test`
179
+ answers `lint_failed`; `e2e` prints it and proceeds, like every pre-run
180
+ diagnostic).
181
+
182
+ The linter also descends into operator, array and property-access
183
+ operands now, so a regex or `{{mustache}}` literal inside `a + b` is
184
+ diagnosed the same as one on its own.
185
+
186
+ - eddb1db: New linter rule `lint:one-test-per-file`: a second (third, …) top-level
187
+ `function test_*` in one scenario file is reported at its declaration.
188
+ A file is the unit a collection runs and the viewer shows — the Steps
189
+ tree projects a run onto the first `test_*`, so the others executed with
190
+ nowhere to be seen. Fold the cases into one test with tagged steps
191
+ (`step("…", {tag: id}, () => { … })`, `step.soft` when a case must not
192
+ stop the run) or move shared journeys into `flow_*` helpers under
193
+ `unotest/e2e/_helpers/`; `flow_*` functions in a scenario file are not
194
+ counted.
195
+
196
+ **Migration window:** the rule is a **warning** in 0.31 — visible in
197
+ `lint`, the editor and `run_test`'s warnings, silenced per line with
198
+ `// lint-ok: <reason>`, never blocking. A later minor release flips the
199
+ default to `error` (which the pre-run gate of `run_test` refuses); split
200
+ files that hold several `test_*` before then.
201
+
202
+ - b0a00cb: New DSL function `readJsonLine(path, filter) → object`: read a JSONL file
203
+ that already exists and return the first line matching the key filter —
204
+ no polling. `waitForJsonLine` was the only structured input into a
205
+ scenario, and for a fixture or a finished export its 20s timeout only
206
+ masked a wrong path. `readJsonLine` takes the same filter (strict equality
207
+ per key, dot paths for nesting, unparsable lines skipped) and fails at
208
+ once: `file not found: <path>` when the file is missing, `no line matches
209
+ {…} in <path>` with parse stats and the closest line when nothing matches.
210
+ Data-driven tests read their cases with it and name evidence after them
211
+ (`screenshot(q.id)`).
212
+ - 100ab9d: Data-driven steps: `step("label", {tag: q.id}, () => { … })` names the
213
+ case an iteration is on, and `step.soft("label", [{tag}], () => { … })`
214
+ records a failure inside instead of stopping the run — the rest of that
215
+ body is skipped, the next case runs, and the test still ends **failed**
216
+ with every soft failure listed (`3 soft step(s) failed: Question [q17]:
217
+ …`, one line per case in the CLI output; `run_test` answers
218
+ `next.softFailures`; `inspect_runtime`'s `lastFailure` carries `stepTag`
219
+ and `soft`). The failure bundle shows the page at the moment of the
220
+ **first** soft failure, with `soft: true` and `stepTag` in
221
+ `failure.json`; later soft failures live in the journal. `step.soft` is
222
+ allowed inside `test_*` only; nesting is free, and the outer step is a
223
+ group, not an assertion — a soft failure inside does not change its
224
+ outcome. The run journal gains `step-block:started` / `step-block:finished`
225
+ envelopes per block (label, tag, soft, outcome, whole-block duration).
226
+ Editor typings declare the new forms.
227
+ - ad7c918: The run journal records every loop pass (`loop:iteration`, with the loop
228
+ statement's position and the pass index), which the viewer's new Trace
229
+ view uses to show a loop as one group per iteration. Also: when an error
230
+ unwinds through enclosing statements, `lastFailure` (the failure bundle's
231
+ position, `inspect_runtime`, the CLI's `in step "…" [tag]` line) now
232
+ names the innermost statement that raised it — the assert inside the
233
+ tagged step — instead of the outermost loop that re-threw it.
234
+
235
+ ### Patch Changes
236
+
237
+ - 9ae6d59: A bundle no longer declares the runner's own `UNOTEST_*` settings
238
+ (`UNOTEST_HEADED`, `UNOTEST_LOG_LEVEL`, …) from the suite's `.env`, so a box
239
+ stops warning on every push that an environment "neither sets nor holds"
240
+ names `env push` never sends. Both sides now share one rule
241
+ (`isRunnerSetting` in `@unotest/protocol`): those variables configure the
242
+ machine a run happens on, not the suite. A per-suite value such as the
243
+ navigation timeout travels with the bundle through `unotest.config.mjs`
244
+ (`defaultNavigationTimeoutMs`), not through `.env`.
245
+ - 917380d: `unotest.config.*` is merged with the defaults recursively. A partially
246
+ written section — `linter: { rules: { 'lint:deep-css': 'off' } }`,
247
+ `failureBundle: { tier3: { video: true } }`, `viewport: { width: 1920 }`
248
+ — now keeps the rest of that section's defaults instead of failing with
249
+ "config validation failed" (nested sections require all of their fields
250
+ at validation time, so a top-level replace made every partial section
251
+ invalid). Arrays and scalars still replace as a whole: `browsers:
252
+ ['firefox']` means firefox only. The setup manual's example config,
253
+ which was exactly such a partial `linter`, loads as written; its
254
+ `timeouts` block — never a config field — is corrected to
255
+ `defaultTimeoutMs` / `defaultNavigationTimeoutMs`.
256
+ - e048334: The viewer's editor flags an undeclared external variable
257
+ (`lint:external-variable-undeclared`), the way `unotest-web lint` and the
258
+ pre-run lint already do: a bare `UPPER_SNAKE` identifier that no
259
+ `unotest/.env` / `.secrets` file of the active environment declares is an
260
+ error in the editor, naming the files searched. Protocol:
261
+ `DslValidateContext` gains `externalNames` / `externalSources`; the
262
+ viewer supplies them from the active target + environment's layer files
263
+ (the same files the variables panel shows) and `@unotest/web`'s language
264
+ service turns them into the linter's lookup.
265
+ - dcf4c9e: Viewer editor: lint markers appear as soon as a file opens (or the page
266
+ reloads), not only after the first keystroke — the editor validates the
267
+ source it mounted with instead of waiting for a change event that a
268
+ reopened tab never fires.
269
+ - 6646584: Docs: `hover` now lists its options (`force`, `position`, `timeout`) in
270
+ the DSL reference, the editor signature and the generated typings — it
271
+ used to read as if it took none, while `position` is exactly what a
272
+ hover-revealed menu at one edge of a tall element needs. The agent
273
+ integration guide gains "Watching a collection run": the collection's
274
+ `steps.jsonl` journal (path, the four `collection-run:*` events, how to
275
+ tail it) as the progress source for a run started in the background,
276
+ where the CLI's per-scenario lines only arrive when the process ends.
277
+ - 630d6ce: `unotest-web lint` now receives its arguments: the bin dispatcher forwarded
278
+ only the command name, so `lint --env <name>` linted the base environment
279
+ and an explicit file argument was ignored (every file was linted instead).
280
+ `UNOTEST_ENV=<name>` worked all along, which is why the gap went unnoticed.
281
+ - f3bd3cc: `lint` works on a project that has not migrated to the 0.28 layout yet.
282
+ A `unotest.config.*` still at the project root used to stop `lint` with
283
+ the same refusal `e2e` gives — right for a command that would RUN on
284
+ defaults, wrong for one that only grades: nobody lints a suite during
285
+ the very migration that touches every file. `lint` now builds its
286
+ context on the default settings (rule severities, helpers dir), reads
287
+ `unotest/.env`, `.secrets` and `_helpers/` as usual, and reports the
288
+ layout once as the new `lint:legacy-layout` warning (default `warn`,
289
+ configurable like any rule) with the migration on one line. The exit
290
+ code is unaffected by it. `e2e`, `collection` and `bundle push` refuse
291
+ exactly as before.
292
+ - 224d016: The MCP server says when it is running a stale build. `run_test`'s
293
+ pre-spawn lint, flow discovery (`explore_start`), `explore_run_flow`,
294
+ `generate_dsl_from_exploration` and `save_exploration_as_test` parse and
295
+ render DSL in the server's own process, on the modules it loaded at
296
+ start — after a rebuild a new DSL feature came back as a false parse
297
+ error until the server was restarted, while the runner child was already
298
+ on the new build. Those replies now carry `serverStale: true` and a
299
+ `serverStaleWarning` ("restart the MCP server") once the code on disk is
300
+ newer than the server's start; nothing changes while it is fresh.
301
+ - 02757c3: `serverStale` now also rides on `explore_step` / `explore_steps` (locator
302
+ resolution and recording run in the server's process), `find_element`
303
+ and `ground_element` replies — the same flag `run_test` and the
304
+ exploration tools already carry once the build on disk is newer than the
305
+ running MCP server.
306
+ - dcf4c9e: `goto` with a relative path resolves against the runtime's own base URL
307
+ instead of the browser context's. In a CLI run the two coincide; in an
308
+ exploration recording against an `env` override, `explore_run_flow`
309
+ replayed `goto('/_guard/')` on the MCP session's shared context — created
310
+ with the server's base — and landed on the wrong host. The replay now
311
+ follows the session's `baseUrl` (and its variables, as before).
312
+ - f3bd3cc: `screenshot(name)` accepts any string value, not only a literal. The
313
+ runtime always slugified whatever it received, but the validator (and
314
+ therefore `lint` and `run_test`) rejected `screenshot(q.id)` and
315
+ `screenshot(textJoin(['q-', id]))` — a data-driven test had to name every
316
+ capture the same. The `name` slot is now string-like, as in `fill`; a
317
+ non-string argument is still an `arg-kind` error.
318
+ - dcf4c9e: A section label reopened after another section is called out instead of
319
+ silently splitting a step: `explore_step` / `explore_steps` reply with
320
+ `sectionHint` the moment it happens, and the generated draft carries a
321
+ non-blocking `SPLIT_SECTION` warning naming the reopened entries. The
322
+ recorded order of actions is never changed; `autoRun` and `save` are not
323
+ held back by it.
324
+ - 9ae6d59: A scenario that finished in the same instant the viewer looked at it is no
325
+ longer recorded as `interrupted` with a finish an hour before its start.
326
+ The runner ends a run by appending `run:finished` and then backdating the
327
+ heartbeat; the viewer read those two files in the opposite order, so a
328
+ steps snapshot taken a few milliseconds early could meet the backdated
329
+ heartbeat, and the provisional verdict stuck in the run index while the
330
+ collection reported the scenario passed. The heartbeat is read first now.
331
+ A viewer restart (which rebuilds the index from disk) already corrected
332
+ such records; runs indexed live are right the first time.
333
+ - 13a2d51: Viewer, Home tab: the tile of a scenario that is running now breathes
334
+ through colour and shadow only — the scale pulse is gone. It moved the
335
+ tile's box every frame, so an automation driver never saw the tile as
336
+ stable and a click on it waited out its timeout. A tile that never ran
337
+ no longer carries `data-age="fresh"`: it has no age, so the attribute is
338
+ absent.
339
+ - 91fb562: Viewer, Home tab: clicking a scenario tile opens the scenario's LAST RUN —
340
+ the run whose verdict the tile's colour is showing — instead of the test
341
+ file. A tile that has never run still opens the test (there is no run to
342
+ open), and so does every tile in schedule mode, where the schedule is
343
+ edited from the test. The legend now reads "hover for history · click to
344
+ open the last run".
345
+ - dcf4c9e: Viewer, Home tab: the tile field no longer refits itself in a loop. The
346
+ header and legend take the grid's width; when the legend wrapped at one
347
+ width and not at the next, the field's height changed, the fit followed,
348
+ the width changed back — at 60 fps every tile's box moved every frame,
349
+ and an automation click on ANY tile ("element is not stable") waited out
350
+ its timeout unless forced. Header and legend now never wrap (they grow
351
+ past the grid instead), so the fit converges and tiles stay put. A run
352
+ opened from a tile is titled by its scenario, like one opened from the
353
+ Runs tree, instead of by its run id.
354
+ - dcf4c9e: Recording no longer bakes relative time or counters into a locator. An
355
+ accessible name such as `Run smoke — passed, 7m ago`, `just now`,
356
+ `yesterday`, `5 passed`, `7 runs` or a trailing badge (`Inbox · 3`,
357
+ `Errors: 12`) now counts as volatile: the recorder emits the stable prefix
358
+ (`{name: "Run smoke — passed", exact: true}`, then `/^Run smoke — passed\b/`)
359
+ instead of the literal, and the draft carries the `DYNAMIC_TEXT` warning
360
+ with that prefix. Bare numbers elsewhere stay stable (`Page 2`, `Q4 2026`,
361
+ `v2`, `2FA`).
362
+ - Updated dependencies [b8e105b]
363
+ - Updated dependencies [b8e105b]
364
+ - Updated dependencies [b8e105b]
365
+ - Updated dependencies [9ae6d59]
366
+ - Updated dependencies [56784b3]
367
+ - Updated dependencies [e048334]
368
+ - Updated dependencies [dcf4c9e]
369
+ - Updated dependencies [100ab9d]
370
+ - Updated dependencies [100ab9d]
371
+ - Updated dependencies [100ab9d]
372
+ - Updated dependencies [100ab9d]
373
+ - Updated dependencies [ad7c918]
374
+ - Updated dependencies [ad7c918]
375
+ - Updated dependencies [ad7c918]
376
+ - Updated dependencies [9ae6d59]
377
+ - Updated dependencies [13a2d51]
378
+ - Updated dependencies [91fb562]
379
+ - Updated dependencies [dcf4c9e]
380
+ - @unotest/core@0.31.0
381
+ - @unotest/protocol@0.31.0
382
+ - @unotest/viewer@0.31.0
383
+ - @unotest/dsl@0.31.0
384
+ - @unotest/grounder-client@0.31.0
385
+
3
386
  ## [0.30.0] - 2026-09-03
4
387
 
5
388
  ### Minor Changes
package/README.md CHANGED
@@ -7,6 +7,11 @@ and commit.**
7
7
  MCP server + CLI runner + JavaScript DSL + semantic DOM snapshots +
8
8
  structured failure bundles.
9
9
 
10
+ Full documentation — manuals, the box, CI, the judge — lives at
11
+ [docs.unotest.com](https://docs.unotest.com). This README is the short
12
+ tour; the package ships only `guides/dsl-reference.md` and
13
+ `guides/agent-integration.md` alongside it.
14
+
10
15
  ---
11
16
 
12
17
  ## 1. Setup
@@ -170,7 +175,7 @@ a suite takes minutes, and a request held open that long is a timeout,
170
175
  not a result. Watch them in the viewer, or let the box report the checks
171
176
  back to GitHub. `--pr` makes a newer push withdraw the older runs of the
172
177
  same pull request that are still waiting, so three pushes in five minutes
173
- cost one suite. Recipe: `guides/manuals/ci-setup.md`.
178
+ cost one suite. Recipe: [Pushing suites → A minimal CI job](https://docs.unotest.com/box/pushing-suites/#a-minimal-ci-job).
174
179
 
175
180
  The values a suite runs with on the box — `APP_BASE_URL`, its `.env`
176
181
  settings, its secrets — are sent separately, from the same files a local
@@ -184,7 +189,66 @@ printf '%s' "$KEY" | npx @unotest/web env set dev API_KEY --secret
184
189
  `.env*` become the environment's variables, `.secrets*` its secrets,
185
190
  `APP_BASE_URL` its target; `UNOTEST_*` stay home. A value never prints.
186
191
  The token must be minted with `--values` on the box. Admins also see and
187
- change them on the box's admin page. Manual: `guides/manuals/box-values.md`.
192
+ change them on the box's admin page. Manual: [An environment's values on a
193
+ box](https://docs.unotest.com/box/environments-and-values/).
194
+
195
+ A run on the box happens inside the box's own container: nothing on your
196
+ machine is reachable from it — no `localhost`, no `kubectl port-forward`,
197
+ no tools installed on your laptop. Preconditions must probe the
198
+ environment's public URL, and `assertJudge` runs its judge in-process there
199
+ (`UNOTEST_JUDGE_MODE=local`, `@unotest/judge` in the suite's
200
+ `package.json`, the API key as a secret). Details:
201
+ [Judge on a box](https://docs.unotest.com/guides/judge/#on-a-box) and
202
+ [What a box cannot reach](https://docs.unotest.com/box/troubleshooting/#what-a-box-cannot-reach).
203
+
204
+ ### Reading results from a box
205
+
206
+ `bundle push --run` gives you a run id. Read what became of it from the
207
+ same terminal — no browser, no shell on the box:
208
+
209
+ ```sh
210
+ npx @unotest/web box runs --env acme/test --latest
211
+ npx @unotest/web box run checkout-mqf3pwr1 --env acme/test
212
+ ```
213
+
214
+ `box run` prints the failure, the results of soft steps and the judge's
215
+ verdicts. When you want the artifacts too:
216
+
217
+ ```sh
218
+ npx @unotest/web box run checkout-mqf3pwr1 --env acme/test --download
219
+ ```
220
+
221
+ That saves the run's `*.unotest.zip` under `.unotest/box/` and unpacks its
222
+ failure bundle into `.unotest/failures/` — where `list_failures`,
223
+ `get_failure_*` and `agent_fix` already look, so your agent debugs a box
224
+ failure with the commands it uses for a local one. Add `--no-screenshots`
225
+ to leave the step frames on the box; they are usually most of the bytes.
226
+
227
+ Reading uses a **personal** read token, not the project one. Put it in
228
+ `unotest/.secrets`, next to your other project secrets:
229
+
230
+ ```sh
231
+ UNOTEST_BOX_READ_TOKEN=ubr_...
232
+ ```
233
+
234
+ and the box's address in `unotest/.env` as `UNOTEST_BOX_URL`. Both files
235
+ are read directly, so this works the same from the terminal and from your
236
+ agent. Exporting either value in your shell works too and wins over the
237
+ file; `--box` and `--token` win over both.
238
+
239
+ The token is never read from `unotest/.env` — that file travels inside a
240
+ pushed bundle, and `.secrets` does not.
241
+
242
+ Keeping it in the file is what lets your agent read runs: an MCP server is
243
+ started by your editor, so a token that only exists in your shell is a
244
+ token the agent never sees. A token added while the server is running is
245
+ picked up on the next call — nothing to restart.
246
+
247
+ You mint it for yourself on the box's guard ("Read tokens"), it is shown
248
+ once, and it is revocable. It is read-only whatever your role on the box — it cannot start
249
+ a run or change a value. `UNOTEST_BOX_TOKEN` is the project's push
250
+ credential and cannot read runs. `box envs` lists the environments a token
251
+ may look at.
188
252
 
189
253
  ## 5. Watch it run — the viewer
190
254
 
@@ -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":