@unotest/web 0.24.0 → 0.26.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.
- package/CHANGELOG.md +347 -0
- package/README.md +65 -0
- package/dist/config/schema.d.ts +118 -13
- package/dist/config/schema.js +1 -1
- package/dist/driver/index.js +1 -1
- package/dist/dsl/index.js +1 -1
- package/dist/dsl/web-dsl-language-service.js +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/runner/cli.js +1 -1
- package/dist/runner/init/chrome-probe.js +1 -1
- package/dist/runner/init.js +1 -1
- package/dist/runner/install-chromium.js +1 -1
- package/dist/runner/prepare-fix.js +1 -1
- package/dist/runner/scaffold-workspace.js +1 -1
- package/dist/runner/serve-fixture.js +1 -1
- package/dist/runner/web-runner-adapter.js +1 -1
- package/guides/agent-integration.md +9 -0
- package/guides/dsl-reference.md +6 -2
- package/package.json +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,352 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.26.0] - 2026-08-30
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3eede7c: The viewer can now be hosted: it shows which test bundle an environment
|
|
8
|
+
is running, and lets you switch it.
|
|
9
|
+
|
|
10
|
+
This is the other half of `bundle push`. Nothing changes for a local
|
|
11
|
+
viewer — everything here appears only when something is hosting it.
|
|
12
|
+
|
|
13
|
+
- **The artifact root and the project root are now genuinely separate.**
|
|
14
|
+
The viewer already took `UNOTEST_ARTIFACTS_ROOT` for the run queue;
|
|
15
|
+
run discovery, the run index, snapshots, log capture and the viewer's
|
|
16
|
+
own lock file now honour it too. Locally the two are the same directory
|
|
17
|
+
and nothing moves. Where they differ — sources in a disposable copy of a
|
|
18
|
+
bundle, history in the environment beside it — the history is no longer
|
|
19
|
+
read out of (or written into) the sources.
|
|
20
|
+
- **Bundle badge in the status bar.** `main@1a2b3c4`, or `wip` for a
|
|
21
|
+
bundle packed from an uncommitted tree, with the author, the pinned
|
|
22
|
+
`@unotest/web` and who switched the environment last. Clicking it lists
|
|
23
|
+
the bundles pushed to this environment and switches to one — that runs
|
|
24
|
+
as a maintenance ticket on the host, so it waits for the environment to
|
|
25
|
+
be free instead of pulling the code out from under a running suite.
|
|
26
|
+
- **Runs remember their bundle.** `manifest.json` and `GET /api/runs`
|
|
27
|
+
carry `bundleId` (from `UNOTEST_BUNDLE_ID`, set by the host); the runs
|
|
28
|
+
list marks the ones that ran on another bundle, so "was this red run
|
|
29
|
+
even the code I am looking at?" has an answer.
|
|
30
|
+
- **The contract is a file, not a mode.** `@unotest/protocol` gains
|
|
31
|
+
`WorkspaceFile` — the host writes `unotest/.workspace.json` beside the
|
|
32
|
+
artifacts and the viewer reads it, the same way the guard's session
|
|
33
|
+
header narrows the UI. No file, no bundle UI; the viewer never learns
|
|
34
|
+
what a box is. Values of environment variables and secrets never appear
|
|
35
|
+
in it — only their names.
|
|
36
|
+
- **Queue tickets can name a bundle.** `QueueTicket.source.bundleId` is
|
|
37
|
+
recorded when a run is queued, so a run executes the code it was
|
|
38
|
+
ordered with even if the environment moved on while it waited.
|
|
39
|
+
|
|
40
|
+
- 6d1c612: `npx @unotest/web bundle push` — send your suite to a box, instead of
|
|
41
|
+
giving a box access to your repository.
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
npx @unotest/web bundle push --box https://tests.example.com
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
It packs `unotest/`, `unotest.config`, `package.json` and its lockfile
|
|
48
|
+
into one archive and uploads it. That is the whole channel: a box holds
|
|
49
|
+
no deploy key and no token for anybody's code and never pulls anything,
|
|
50
|
+
so nothing is on it that was not pushed to it. (There is no path-scoped
|
|
51
|
+
git credential anywhere — a key that can fetch `unotest/` can fetch the
|
|
52
|
+
product source next to it, which is why this direction is the only one
|
|
53
|
+
we offer.)
|
|
54
|
+
|
|
55
|
+
- **Uncommitted work travels.** The manifest records `dirty: true` and
|
|
56
|
+
the bundle shows up as _wip_, so pushing a fix you have not committed
|
|
57
|
+
yet is a normal loop, not a release step.
|
|
58
|
+
- **What would fail on the box is refused here.** A scenario that reads a
|
|
59
|
+
file outside `unotest/` (`MODULE_NOT_FOUND` on the box, hours later, on
|
|
60
|
+
a run nobody is watching), a missing lockfile (`npm ci` will not run
|
|
61
|
+
without one), no `@unotest/web` in the dependencies, a symlink. Each
|
|
62
|
+
problem names the file, the line and the fix.
|
|
63
|
+
- **Secrets do not travel.** `unotest/.env*` and `.secrets*` are excluded
|
|
64
|
+
even if committed: environment values belong to the environment and are
|
|
65
|
+
injected over the bundle when it runs.
|
|
66
|
+
- **In a git project, git decides what belongs.** The file list comes from
|
|
67
|
+
`git ls-files --cached --others --exclude-standard`, so whatever your
|
|
68
|
+
`.gitignore` keeps out — generated databases, recordings, `.runs` —
|
|
69
|
+
stays out. Content is read from the working tree, not the index.
|
|
70
|
+
- **The id is the content.** `bundleId` is a hash of the packed files, not
|
|
71
|
+
a commit sha (a dirty tree's sha is not unique), so re-pushing an
|
|
72
|
+
unchanged suite is an instant no-op and two people packing the same tree
|
|
73
|
+
get the same bundle.
|
|
74
|
+
- `--dry-run` packs and checks without uploading, `--out <file>` keeps the
|
|
75
|
+
archive (plain `tar.gz` — `tar -tzf` shows what is inside), `--json`
|
|
76
|
+
prints `{ bundleId, status }` for a CI job that runs it afterwards.
|
|
77
|
+
- `UNOTEST_BOX_URL` / `UNOTEST_BOX_TOKEN` supply the address and the
|
|
78
|
+
token, which a CI job usually takes from its secret store. Tokens are
|
|
79
|
+
issued per project on the box.
|
|
80
|
+
|
|
81
|
+
- dbfa36f: Your CI can now ask a box to run the suite it just pushed, and a box can
|
|
82
|
+
report the result back to GitHub.
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
npx @unotest/web bundle push --run --env test --collection smoke
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`--run` sends the second half of the CI recipe: upload the bundle, then
|
|
89
|
+
order a run of it. Two requests rather than one flag on the upload,
|
|
90
|
+
because a bundle is _content_ — pushing an identical tree twice is normal
|
|
91
|
+
and answers `exists` — while "run it" is an event. The same split is what
|
|
92
|
+
lets a deploy script run a bundle somebody pushed hours ago.
|
|
93
|
+
|
|
94
|
+
- **The box answers as soon as the runs are queued** (202) with their run
|
|
95
|
+
ids. A suite takes minutes to hours; an HTTP request held open for that
|
|
96
|
+
long is a timeout in somebody's proxy, not a result. The ids are real
|
|
97
|
+
from the moment you get them, so a CI log can print a link into the
|
|
98
|
+
viewer straight away.
|
|
99
|
+
- **`--env <name>` is required with `--run`.** Which environments a box
|
|
100
|
+
has is the operator's decision, and guessing one would be guessing
|
|
101
|
+
which system gets tested.
|
|
102
|
+
- **Without `--collection`, the box runs what that environment normally
|
|
103
|
+
runs** — the collections its `schedules` name. Naming them explicitly is
|
|
104
|
+
the deploy-check case; leaving it out is the "same as every night" case.
|
|
105
|
+
- **`--pr <number>` coalesces.** A newer push of the same pull request
|
|
106
|
+
withdraws its older runs that are still _waiting_; a run that already
|
|
107
|
+
started is never killed. Three pushes in five minutes cost one suite.
|
|
108
|
+
`--json` reports how many were withdrawn, so "my run vanished" has an
|
|
109
|
+
answer in the log of the run that replaced it.
|
|
110
|
+
- **Refusals are typed and say what to do**: no such environment, an
|
|
111
|
+
environment belonging to another project, a bundle the box no longer
|
|
112
|
+
holds, nothing to run.
|
|
113
|
+
|
|
114
|
+
On a hosted viewer, the queue panel now names the change each machine
|
|
115
|
+
ticket belongs to (`PR #412@1a2b3c4`) instead of a row of identical "ci"
|
|
116
|
+
entries, and the history gained a **this bundle only** filter for the
|
|
117
|
+
moment you are judging the bundle in front of you rather than the last
|
|
118
|
+
month of runs.
|
|
119
|
+
|
|
120
|
+
Nothing changes for a local `npx @unotest/web viewer`: there are no
|
|
121
|
+
bundles there, so neither the badge nor the filter appears.
|
|
122
|
+
|
|
123
|
+
- c43aa00: Runs of one project now take turns instead of colliding.
|
|
124
|
+
|
|
125
|
+
Until now nothing coordinated them: the viewer refused a second run with
|
|
126
|
+
HTTP 409, and that was the whole defence — a `npx @unotest/web e2e` in a
|
|
127
|
+
terminal, an agent's `run_test` and a scheduled suite would all start on
|
|
128
|
+
top of each other, driving the same browser and the same seeded database
|
|
129
|
+
at the same time. The 409 protected the UI, not the machine.
|
|
130
|
+
|
|
131
|
+
- **A filesystem queue**, in `unotest/.queue<target>[.<env>]/` beside the
|
|
132
|
+
runs root it belongs to. A run writes a ticket, waits until it is at
|
|
133
|
+
the head, takes a slot, runs, and gives the slot back. There is no
|
|
134
|
+
daemon: whoever holds the slot executes the run itself, so nothing new
|
|
135
|
+
has to be installed, started or kept alive. Only `open("wx")`, rename,
|
|
136
|
+
unlink and mtime — no `flock`, which lies on network and container
|
|
137
|
+
filesystems. A crashed producer is reaped by whoever comes next.
|
|
138
|
+
- **Every producer is in it**: the CLI (`e2e`, `collection`, `author`),
|
|
139
|
+
the viewer's Run button, and the MCP `run_test` (which spawns the CLI).
|
|
140
|
+
A collection is ONE run — its `workers` still run in parallel inside
|
|
141
|
+
it, because a child process carries its parent's lease and skips the
|
|
142
|
+
queue.
|
|
143
|
+
- **The viewer shows the queue**: the Active panel lists what is waiting,
|
|
144
|
+
including tickets a terminal or an agent wrote, with a button to take
|
|
145
|
+
any of them back out. `GET /api/queue`, `DELETE /api/queue/:ticket`,
|
|
146
|
+
and a `queue:changed` websocket message.
|
|
147
|
+
- **Config**: `queue.concurrency` (default 1) and `queue.enabled`
|
|
148
|
+
(default true) in `unotest.config`. Both the CLI and the viewer read
|
|
149
|
+
the same file, so they cannot disagree about how many slots exist.
|
|
150
|
+
- **Escape hatches**: `UNOTEST_NO_QUEUE=1` runs without queueing;
|
|
151
|
+
Ctrl-C while waiting gives up your place in line and runs nothing.
|
|
152
|
+
- **For boxes**: `UNOTEST_QUEUE_GLOBAL_DIR` + `UNOTEST_QUEUE_GLOBAL_SLOTS`
|
|
153
|
+
add a host-wide budget several environments share (a collection weighs
|
|
154
|
+
its worker count), and `UNOTEST_ARTIFACTS_ROOT` puts `.runs` / `.queue`
|
|
155
|
+
somewhere other than the directory the tests live in — so the history
|
|
156
|
+
outlives a throwaway copy of the tests.
|
|
157
|
+
|
|
158
|
+
### Migration notes
|
|
159
|
+
|
|
160
|
+
**`POST /api/run` no longer answers 409.** Ordering a run while another
|
|
161
|
+
one is active used to fail with `ActiveRunConflictError` (HTTP 409); it
|
|
162
|
+
now succeeds and the run waits. The response changed shape with it:
|
|
163
|
+
|
|
164
|
+
```diff
|
|
165
|
+
- { runId, kind, ref, pid, startedAt }
|
|
166
|
+
+ { runId, kind, ref, status: "queued" | "running", ticket, acceptedAt }
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`runId` is still final and immediately usable — open the tab on it as
|
|
170
|
+
before — but the run may not have started yet, and `pid` is not known at
|
|
171
|
+
that point. `POST /api/runs/:runId/abort` covers both states: it
|
|
172
|
+
withdraws a waiting ticket or SIGTERMs a running child.
|
|
173
|
+
|
|
174
|
+
Anything that treated 409 as "busy, try later" should now simply order
|
|
175
|
+
the run. Anything that relied on "only one run can exist" should read
|
|
176
|
+
`GET /api/queue`. To keep the old immediate-start behaviour (without the
|
|
177
|
+
protection), set `queue.enabled: false`.
|
|
178
|
+
|
|
179
|
+
- 8ec4177: Scheduled runs: declare what should run on its own, next to the tests it
|
|
180
|
+
runs.
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
// unotest.config.mjs
|
|
184
|
+
export default {
|
|
185
|
+
schedules: [
|
|
186
|
+
{ collection: "smoke", cron: "0 * * * *" },
|
|
187
|
+
{ collection: "nightly", cron: "0 3 * * *", prepare: "node seed.mjs" },
|
|
188
|
+
],
|
|
189
|
+
};
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
- **`schedules`** — a new config leaf: collection + cron, plus an
|
|
193
|
+
optional `env` (which environment the run happens in) and `prepare` (a
|
|
194
|
+
command run before the suite). Versioned with the tests, so "what is
|
|
195
|
+
supposed to run nightly" is in the repo instead of on a machine nobody
|
|
196
|
+
can see.
|
|
197
|
+
- **`npx @unotest/web schedules`** prints what is declared; `--json` is
|
|
198
|
+
the machine shape a scheduler reads back. Each entry also carries the
|
|
199
|
+
collection's `workers` resolved from its yaml manifest, so an executor
|
|
200
|
+
can weigh the run without parsing yaml itself.
|
|
201
|
+
- **`npx @unotest/web collection <name> --scheduled`** runs a collection
|
|
202
|
+
the way its schedule does: in the entry's `env` (unless you set one)
|
|
203
|
+
and with its `prepare` command first, inside the same run slot
|
|
204
|
+
(preparing touches the target, which is exactly what the run queue
|
|
205
|
+
serialises). A failed prepare exits **94**, not 1 — "the environment
|
|
206
|
+
was not prepared, nothing was tested" is a different event from "the
|
|
207
|
+
suite is red", and alerting has to tell them apart. A collection
|
|
208
|
+
listed in `schedules` more than once needs `--scheduled=<index>`
|
|
209
|
+
(zero-based) — guessing which entry's prepare to run is refused.
|
|
210
|
+
|
|
211
|
+
**Nothing in this package ticks.** `schedules` is data: no cron parser,
|
|
212
|
+
no timers, no background runs. Cron is executed by whatever hosts your
|
|
213
|
+
runs — your CI (`on: schedule` + `unotest-web collection`), or a box. A
|
|
214
|
+
clone of a repo that declares schedules therefore never starts running
|
|
215
|
+
suites by itself; `unotest-web schedules` says so out loud instead of
|
|
216
|
+
implying a timer that does not exist.
|
|
217
|
+
|
|
218
|
+
### Patch Changes
|
|
219
|
+
|
|
220
|
+
- Updated dependencies [3eede7c]
|
|
221
|
+
- Updated dependencies [6d1c612]
|
|
222
|
+
- Updated dependencies [dbfa36f]
|
|
223
|
+
- Updated dependencies [c43aa00]
|
|
224
|
+
- Updated dependencies [8ec4177]
|
|
225
|
+
- @unotest/viewer@0.26.0
|
|
226
|
+
- @unotest/protocol@0.26.0
|
|
227
|
+
- @unotest/core@0.26.0
|
|
228
|
+
- @unotest/dsl@0.26.0
|
|
229
|
+
- @unotest/grounder-client@0.26.0
|
|
230
|
+
|
|
231
|
+
## [0.25.0] - 2026-08-29
|
|
232
|
+
|
|
233
|
+
### Minor Changes
|
|
234
|
+
|
|
235
|
+
- adb9d72: Viewer: a pinned Home tab, a multi-row tab strip, and live tiles.
|
|
236
|
+
|
|
237
|
+
The viewer ships with `@unotest/web` and opens via `npx @unotest/web viewer`,
|
|
238
|
+
so the change lands here too. The project-health overview used to render
|
|
239
|
+
only when no tab was open — it disappeared exactly while you were working.
|
|
240
|
+
It is now a pinned first tab: always present, never closable, and where
|
|
241
|
+
"Close All" leaves you. The tab strip wraps into up to three rows instead
|
|
242
|
+
of scrolling sideways, tabs are pills (middle-click closes, `⋮` opens the
|
|
243
|
+
right-click actions), and the strip is a proper `tablist` for screen
|
|
244
|
+
readers. A scenario running right now breathes on the overview field: the
|
|
245
|
+
tile fades under a warm ring, still when paused. Full notes in
|
|
246
|
+
`@unotest/viewer`'s changelog.
|
|
247
|
+
|
|
248
|
+
- 70f2e11: Judge: provider edge cases, a startup preflight, and logs.
|
|
249
|
+
|
|
250
|
+
**Vertex `GOOGLE_CLOUD_LOCATION=global` could not work.** The endpoint was
|
|
251
|
+
built as `https://<location>-aiplatform.googleapis.com`, so `global` asked
|
|
252
|
+
`global-aiplatform.googleapis.com` — not a Vertex host. Google's edge
|
|
253
|
+
answered 404 with an HTML page, which arrived as "model not found". The
|
|
254
|
+
global endpoint has no region prefix; it is now special-cased. `global` is
|
|
255
|
+
the default in Google's own docs, so this was the first thing a Vertex user
|
|
256
|
+
hit. A model id containing a slash
|
|
257
|
+
(`publishers/anthropic/models/claude-haiku-4-5`) is now passed through
|
|
258
|
+
instead of being forced under `publishers/google`, and a pasted
|
|
259
|
+
`models/gemini-2.5-flash` no longer doubles the prefix on the Gemini API.
|
|
260
|
+
|
|
261
|
+
**Backend replies are classified instead of lumped together.**
|
|
262
|
+
|
|
263
|
+
- `401`/`403` → `config` with the variable to fix (`OPENAI_API_KEY`, ADC,
|
|
264
|
+
an expired `UNOTEST_JUDGE_ACCESS_TOKEN`), no longer indistinguishable
|
|
265
|
+
from a model fault.
|
|
266
|
+
- `429` and `5xx` → retried transparently with a short backoff that
|
|
267
|
+
honours `Retry-After`. A single rate limit used to kill a whole scenario.
|
|
268
|
+
The retry policy is a decorator over `JudgeProvider`, not a loop inside
|
|
269
|
+
the HTTP helper, so it covers every provider — `claude` spawns a process
|
|
270
|
+
and therefore had no re-send at all. A `claude` usage limit is
|
|
271
|
+
deliberately still reported rather than retried: the window is seconds,
|
|
272
|
+
the limit is not.
|
|
273
|
+
- A non-JSON error body says the endpoint is wrong rather than quoting an
|
|
274
|
+
HTML page back as the backend's opinion of the model. The body is kept
|
|
275
|
+
for `debug` logging.
|
|
276
|
+
- OpenAI `finish_reason: length` and Anthropic `stop_reason: max_tokens`
|
|
277
|
+
are named as token-limit truncation instead of surfacing as "did not
|
|
278
|
+
reply with the requested JSON verdict".
|
|
279
|
+
- The verdict parser accepts `"PASS"` and a JSON object embedded in prose —
|
|
280
|
+
providers without a JSON mode do both.
|
|
281
|
+
|
|
282
|
+
**`UNOTEST_JUDGE_VOTE=N` no longer fails on one bad ballot.** The N calls
|
|
283
|
+
ran under `Promise.all`, so voting — the reliability feature — tripled the
|
|
284
|
+
chance that one transient error killed the request. Errored ballots are now
|
|
285
|
+
dropped as long as a majority of the N calls still voted; a tie among the
|
|
286
|
+
survivors re-raises the failure rather than picking a side.
|
|
287
|
+
|
|
288
|
+
**The service checks its credentials before it says "listening".** Each
|
|
289
|
+
provider exposes a free probe (an ADC token exchange, `GET /models`,
|
|
290
|
+
`claude --version`) — no model call, no cost. A failing probe prints the
|
|
291
|
+
reason and exits non-zero (`UNOTEST_JUDGE_SKIP_PREFLIGHT=1` waives it), and
|
|
292
|
+
`npx @unotest/judge --check` runs just the probe for start scripts.
|
|
293
|
+
`GET /health` reports the same thing instead of an unconditional
|
|
294
|
+
`{"ok":true}`: `503` + `{"ok":false,"code":"config","error":"..."}`, cached
|
|
295
|
+
for a few seconds. The waiver covers the startup gate only — a service
|
|
296
|
+
started under it still answers `/health` from a real probe, and `--check`
|
|
297
|
+
still answers honestly, because the operator who reaches for the flag is
|
|
298
|
+
precisely the one whose credentials are dead. A static
|
|
299
|
+
`UNOTEST_JUDGE_ACCESS_TOKEN` is validated too, against Google's free
|
|
300
|
+
`tokeninfo`: those tokens live about an hour and refresh themselves never,
|
|
301
|
+
so "valid at startup" is a different question from "valid now".
|
|
302
|
+
|
|
303
|
+
ADC that expires on a daily reauth policy is a normal morning state, not a
|
|
304
|
+
one-off misconfiguration — it should not cost a five-minute run to
|
|
305
|
+
discover.
|
|
306
|
+
|
|
307
|
+
**New: `UNOTEST_JUDGE_LOG_LEVEL`** (falls back to the `UNOTEST_LOG_LEVEL`
|
|
308
|
+
`@unotest/web` already reads). At the default `info`, one line per judged
|
|
309
|
+
request — verdict, attempts, model, latency — with errors on stderr; a run
|
|
310
|
+
of 21 assertions with `VOTE=3` is 63 billed model calls that previously
|
|
311
|
+
left no trace. At `debug`, per call: the effective prompt (rubric
|
|
312
|
+
**including** any `UNOTEST_JUDGE_PREAMBLE`), each ballot with its
|
|
313
|
+
reasoning, and the raw provider reply. **The judged text is application
|
|
314
|
+
content** — debug writes it to stdout verbatim, which is why it is opt-in
|
|
315
|
+
and never the default.
|
|
316
|
+
|
|
317
|
+
An oversized request body now returns `413` with a reason; the socket used
|
|
318
|
+
to be destroyed while the client was still writing, so the client saw
|
|
319
|
+
`socket hang up`.
|
|
320
|
+
|
|
321
|
+
**`@unotest/web`:** the `judge:verdict` run artifact records `preamble`
|
|
322
|
+
beside `rubric` when one is set. The model saw the concatenation, but only
|
|
323
|
+
the split says which half came from the scenario and which from the
|
|
324
|
+
environment — without it, "a red step is explainable without a re-run" held
|
|
325
|
+
only for whoever also knew the env file. A judge request that outlives
|
|
326
|
+
`UNOTEST_JUDGE_TIMEOUT_MS` now says so, and names the service-side
|
|
327
|
+
`UNOTEST_JUDGE_CALL_TIMEOUT_MS` it may be racing.
|
|
328
|
+
|
|
329
|
+
The Claude CLI provider also stopped leaking a timer: the per-call budget
|
|
330
|
+
is now our own timer, cleared on every exit path. `child_process.spawn`'s
|
|
331
|
+
`timeout` option does not clear itself when the spawn fails, so a missing
|
|
332
|
+
binary held the event loop for the whole budget — 15s for a `--version`
|
|
333
|
+
probe, 120s for a verdict.
|
|
334
|
+
|
|
335
|
+
**Breaking (0.x minor):** `@unotest/judge` no longer exports `ExecFileFn` /
|
|
336
|
+
`ExecFileResult`; the Claude CLI provider takes `runImpl: RunProcess`
|
|
337
|
+
instead of `execImpl`, because the prompt moved from `argv` to stdin — a
|
|
338
|
+
long judged text used to overflow `ARG_MAX` and fail as a bare `E2BIG`.
|
|
339
|
+
|
|
340
|
+
### Patch Changes
|
|
341
|
+
|
|
342
|
+
- Updated dependencies [70f2e11]
|
|
343
|
+
- Updated dependencies [adb9d72]
|
|
344
|
+
- @unotest/protocol@0.25.0
|
|
345
|
+
- @unotest/viewer@0.25.0
|
|
346
|
+
- @unotest/core@0.25.0
|
|
347
|
+
- @unotest/dsl@0.25.0
|
|
348
|
+
- @unotest/grounder-client@0.25.0
|
|
349
|
+
|
|
3
350
|
## [0.24.0] - 2026-08-26
|
|
4
351
|
|
|
5
352
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -65,6 +65,71 @@ npx @unotest/web e2e <name> # one scenario
|
|
|
65
65
|
npx @unotest/web e2e # list / usage
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
+
Runs of one project take turns. A second run — from another terminal, the
|
|
69
|
+
viewer's Run button or an agent — joins a queue in `unotest/.queue/` and
|
|
70
|
+
starts when the machine is free, instead of fighting the first one over
|
|
71
|
+
the same browser and the same seeded data. It costs nothing when nothing
|
|
72
|
+
else is running, the viewer shows who is waiting (and can cancel them),
|
|
73
|
+
and Ctrl-C gives up your place in line. Raise `queue.concurrency` in
|
|
74
|
+
`unotest.config` to allow more at once, or set `UNOTEST_NO_QUEUE=1` for a
|
|
75
|
+
single command that must not wait. A collection is one run: its `workers`
|
|
76
|
+
still run in parallel inside it.
|
|
77
|
+
|
|
78
|
+
### Running on a schedule
|
|
79
|
+
|
|
80
|
+
Declare what should run on its own next to the tests it runs:
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
// unotest.config.mjs
|
|
84
|
+
export default {
|
|
85
|
+
schedules: [
|
|
86
|
+
{ collection: "smoke", cron: "0 * * * *" },
|
|
87
|
+
{ collection: "nightly", cron: "0 3 * * *", prepare: "node seed.mjs" },
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`npx @unotest/web schedules` prints them. Nothing in the CLI ticks: cron
|
|
93
|
+
is executed by whatever hosts your runs — your CI, or a box — so a clone
|
|
94
|
+
of your repo never starts running suites in the background. Run one now
|
|
95
|
+
with `npx @unotest/web collection nightly --scheduled` (that also runs its
|
|
96
|
+
`prepare` command, inside the same run slot).
|
|
97
|
+
|
|
98
|
+
### Sending the suite to a box
|
|
99
|
+
|
|
100
|
+
If your team runs a box (a machine that keeps the environments, the
|
|
101
|
+
schedules and the history in one place), tests get there by being pushed:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
npx @unotest/web bundle push --box https://tests.example.com
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
It packs `unotest/`, your `unotest.config`, `package.json` and its
|
|
108
|
+
lockfile — nothing else — and uploads that. **The box never reads your
|
|
109
|
+
repository**: it holds no key to it, so nothing is there that you did not
|
|
110
|
+
send. Uncommitted work is included and marked *wip*, which is what makes
|
|
111
|
+
this a fast loop rather than a release step. Secrets stay behind:
|
|
112
|
+
`unotest/.env*` and `.secrets*` never travel, because environment values
|
|
113
|
+
belong to the environment and are injected over the bundle when it runs.
|
|
114
|
+
|
|
115
|
+
What would break on the box is refused here instead — a scenario that
|
|
116
|
+
reads a file outside `unotest/`, a missing lockfile, no `@unotest/web`
|
|
117
|
+
pin. The bundle's id is a hash of its content, so pushing an unchanged
|
|
118
|
+
suite is a no-op.
|
|
119
|
+
|
|
120
|
+
From a CI job, push and run in one command:
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
npx @unotest/web bundle push --run --env test --collection smoke --pr 412
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The box answers as soon as the runs are queued and gives you their ids —
|
|
127
|
+
a suite takes minutes, and a request held open that long is a timeout,
|
|
128
|
+
not a result. Watch them in the viewer, or let the box report the checks
|
|
129
|
+
back to GitHub. `--pr` makes a newer push withdraw the older runs of the
|
|
130
|
+
same pull request that are still waiting, so three pushes in five minutes
|
|
131
|
+
cost one suite. Recipe: `guides/manuals/ci-setup.md`.
|
|
132
|
+
|
|
68
133
|
## 5. Watch it run — the viewer
|
|
69
134
|
|
|
70
135
|
`npx @unotest/web viewer` opens a local UI (no cloud, no account):
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -105,6 +105,49 @@ declare const LinterConfigSchema: z.ZodObject<{
|
|
|
105
105
|
rules: Partial<Record<"lint:deep-css" | "lint:xpath" | "lint:obfuscated-class" | "lint:pause-explicit" | "lint:disambig-by-index" | "lint:evaluate-discouraged" | "lint:scenario-in-root" | "lint:mustache-in-dsl" | "lint:goto-concat" | "lint:regex-invalid" | "lint:echo-assert" | "lint:api-call-file-body" | "lint:lint-ok-missing-reason" | "lint:flow-returns-value" | "validator:unknown-function" | "validator:arity" | "validator:arg-kind" | "validator:step-coverage" | "validator:step-shape" | "validator:meta-shape" | "validator:function-shape" | "validator:for-shape" | "validator:if-shape" | "validator:var-shape" | "validator:unsupported-statement" | "validator:unsupported-expression" | "validator:string-concat" | "validator:semantic-loss" | "validator:list-arg" | "validator:wrapped-format", "off" | "warn" | "error">>;
|
|
106
106
|
}>;
|
|
107
107
|
type LinterConfig = z.infer<typeof LinterConfigSchema>;
|
|
108
|
+
declare const QueueConfigSchema: z.ZodObject<{
|
|
109
|
+
/** Turn queueing off for this project. `UNOTEST_NO_QUEUE=1` does the
|
|
110
|
+
* same for a single command. */
|
|
111
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
112
|
+
/** How many runs may be in flight for one environment. 1 (the default)
|
|
113
|
+
* is strict FIFO — the machine runs one thing at a time. */
|
|
114
|
+
concurrency: z.ZodDefault<z.ZodNumber>;
|
|
115
|
+
}, "strip", z.ZodTypeAny, {
|
|
116
|
+
enabled: boolean;
|
|
117
|
+
concurrency: number;
|
|
118
|
+
}, {
|
|
119
|
+
enabled?: boolean | undefined;
|
|
120
|
+
concurrency?: number | undefined;
|
|
121
|
+
}>;
|
|
122
|
+
type QueueConfig = z.infer<typeof QueueConfigSchema>;
|
|
123
|
+
declare const ScheduleSchema: z.ZodObject<{
|
|
124
|
+
/** Collection name — file stem under `_collections/`. */
|
|
125
|
+
collection: z.ZodString;
|
|
126
|
+
/** Standard 5-field cron expression, interpreted in the timezone of
|
|
127
|
+
* whatever executes it (a box container is UTC unless its TZ says
|
|
128
|
+
* otherwise). Validated as a non-empty string and no further:
|
|
129
|
+
* checking the EXPRESSION would mean shipping the cron parser this
|
|
130
|
+
* package deliberately does not have. */
|
|
131
|
+
cron: z.ZodString;
|
|
132
|
+
/** Environment the run happens in (`.env.<name>` overlay + its own
|
|
133
|
+
* `.runs.<name>` root). Absent = base. */
|
|
134
|
+
env: z.ZodOptional<z.ZodString>;
|
|
135
|
+
/** Shell command run before the collection, inside the same queue slot
|
|
136
|
+
* — seeding a database, resetting a fixture. Its failure is reported
|
|
137
|
+
* as "the environment was not prepared", not as a red suite. */
|
|
138
|
+
prepare: z.ZodOptional<z.ZodString>;
|
|
139
|
+
}, "strip", z.ZodTypeAny, {
|
|
140
|
+
collection: string;
|
|
141
|
+
cron: string;
|
|
142
|
+
env?: string | undefined;
|
|
143
|
+
prepare?: string | undefined;
|
|
144
|
+
}, {
|
|
145
|
+
collection: string;
|
|
146
|
+
cron: string;
|
|
147
|
+
env?: string | undefined;
|
|
148
|
+
prepare?: string | undefined;
|
|
149
|
+
}>;
|
|
150
|
+
type ScheduleConfig = z.infer<typeof ScheduleSchema>;
|
|
108
151
|
declare const McpConfigSchema: z.ZodObject<{
|
|
109
152
|
transport: z.ZodLiteral<"stdio">;
|
|
110
153
|
}, "strip", z.ZodTypeAny, {
|
|
@@ -172,15 +215,15 @@ declare const WebServerConfigSchema: z.ZodObject<{
|
|
|
172
215
|
/** How long to wait for the URL to come up after spawning. */
|
|
173
216
|
timeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
174
217
|
}, "strip", z.ZodTypeAny, {
|
|
175
|
-
url: string;
|
|
176
|
-
timeoutMs: number;
|
|
177
218
|
command: string;
|
|
219
|
+
url: string;
|
|
178
220
|
reuseExistingServer: boolean;
|
|
221
|
+
timeoutMs: number;
|
|
179
222
|
}, {
|
|
180
|
-
url: string;
|
|
181
223
|
command: string;
|
|
182
|
-
|
|
224
|
+
url: string;
|
|
183
225
|
reuseExistingServer?: boolean | undefined;
|
|
226
|
+
timeoutMs?: number | undefined;
|
|
184
227
|
}>;
|
|
185
228
|
type WebServerConfig = z.infer<typeof WebServerConfigSchema>;
|
|
186
229
|
declare const UnotestConfigSchema: z.ZodObject<{
|
|
@@ -206,15 +249,15 @@ declare const UnotestConfigSchema: z.ZodObject<{
|
|
|
206
249
|
/** How long to wait for the URL to come up after spawning. */
|
|
207
250
|
timeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
208
251
|
}, "strip", z.ZodTypeAny, {
|
|
209
|
-
url: string;
|
|
210
|
-
timeoutMs: number;
|
|
211
252
|
command: string;
|
|
253
|
+
url: string;
|
|
212
254
|
reuseExistingServer: boolean;
|
|
255
|
+
timeoutMs: number;
|
|
213
256
|
}, {
|
|
214
|
-
url: string;
|
|
215
257
|
command: string;
|
|
216
|
-
|
|
258
|
+
url: string;
|
|
217
259
|
reuseExistingServer?: boolean | undefined;
|
|
260
|
+
timeoutMs?: number | undefined;
|
|
218
261
|
}>>;
|
|
219
262
|
browsers: z.ZodArray<z.ZodEnum<["chromium", "firefox", "webkit"]>, "many">;
|
|
220
263
|
channel: z.ZodOptional<z.ZodUnion<[z.ZodLiteral<"chrome">, z.ZodLiteral<"msedge">, z.ZodLiteral<"chrome-beta">, z.ZodNull]>>;
|
|
@@ -327,6 +370,48 @@ declare const UnotestConfigSchema: z.ZodObject<{
|
|
|
327
370
|
}, {
|
|
328
371
|
transport: "stdio";
|
|
329
372
|
}>;
|
|
373
|
+
queue: z.ZodObject<{
|
|
374
|
+
/** Turn queueing off for this project. `UNOTEST_NO_QUEUE=1` does the
|
|
375
|
+
* same for a single command. */
|
|
376
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
377
|
+
/** How many runs may be in flight for one environment. 1 (the default)
|
|
378
|
+
* is strict FIFO — the machine runs one thing at a time. */
|
|
379
|
+
concurrency: z.ZodDefault<z.ZodNumber>;
|
|
380
|
+
}, "strip", z.ZodTypeAny, {
|
|
381
|
+
enabled: boolean;
|
|
382
|
+
concurrency: number;
|
|
383
|
+
}, {
|
|
384
|
+
enabled?: boolean | undefined;
|
|
385
|
+
concurrency?: number | undefined;
|
|
386
|
+
}>;
|
|
387
|
+
/** Scheduled runs (data — nothing here executes them). */
|
|
388
|
+
schedules: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
389
|
+
/** Collection name — file stem under `_collections/`. */
|
|
390
|
+
collection: z.ZodString;
|
|
391
|
+
/** Standard 5-field cron expression, interpreted in the timezone of
|
|
392
|
+
* whatever executes it (a box container is UTC unless its TZ says
|
|
393
|
+
* otherwise). Validated as a non-empty string and no further:
|
|
394
|
+
* checking the EXPRESSION would mean shipping the cron parser this
|
|
395
|
+
* package deliberately does not have. */
|
|
396
|
+
cron: z.ZodString;
|
|
397
|
+
/** Environment the run happens in (`.env.<name>` overlay + its own
|
|
398
|
+
* `.runs.<name>` root). Absent = base. */
|
|
399
|
+
env: z.ZodOptional<z.ZodString>;
|
|
400
|
+
/** Shell command run before the collection, inside the same queue slot
|
|
401
|
+
* — seeding a database, resetting a fixture. Its failure is reported
|
|
402
|
+
* as "the environment was not prepared", not as a red suite. */
|
|
403
|
+
prepare: z.ZodOptional<z.ZodString>;
|
|
404
|
+
}, "strip", z.ZodTypeAny, {
|
|
405
|
+
collection: string;
|
|
406
|
+
cron: string;
|
|
407
|
+
env?: string | undefined;
|
|
408
|
+
prepare?: string | undefined;
|
|
409
|
+
}, {
|
|
410
|
+
collection: string;
|
|
411
|
+
cron: string;
|
|
412
|
+
env?: string | undefined;
|
|
413
|
+
prepare?: string | undefined;
|
|
414
|
+
}>, "many">>;
|
|
330
415
|
sandbox: z.ZodObject<{
|
|
331
416
|
/** Default cwd for `shell(cmd, ...args)`. Defaults to `process.cwd()`. */
|
|
332
417
|
shellCwd: z.ZodOptional<z.ZodString>;
|
|
@@ -403,6 +488,16 @@ declare const UnotestConfigSchema: z.ZodObject<{
|
|
|
403
488
|
mcp: {
|
|
404
489
|
transport: "stdio";
|
|
405
490
|
};
|
|
491
|
+
queue: {
|
|
492
|
+
enabled: boolean;
|
|
493
|
+
concurrency: number;
|
|
494
|
+
};
|
|
495
|
+
schedules: {
|
|
496
|
+
collection: string;
|
|
497
|
+
cron: string;
|
|
498
|
+
env?: string | undefined;
|
|
499
|
+
prepare?: string | undefined;
|
|
500
|
+
}[];
|
|
406
501
|
sandbox: {
|
|
407
502
|
shellCwd?: string | undefined;
|
|
408
503
|
shellTimeoutMs?: number | undefined;
|
|
@@ -413,10 +508,10 @@ declare const UnotestConfigSchema: z.ZodObject<{
|
|
|
413
508
|
};
|
|
414
509
|
baseUrl?: string | undefined;
|
|
415
510
|
webServer?: {
|
|
416
|
-
url: string;
|
|
417
|
-
timeoutMs: number;
|
|
418
511
|
command: string;
|
|
512
|
+
url: string;
|
|
419
513
|
reuseExistingServer: boolean;
|
|
514
|
+
timeoutMs: number;
|
|
420
515
|
} | undefined;
|
|
421
516
|
channel?: "chrome" | "msedge" | "chrome-beta" | null | undefined;
|
|
422
517
|
storageState?: string | undefined;
|
|
@@ -455,6 +550,10 @@ declare const UnotestConfigSchema: z.ZodObject<{
|
|
|
455
550
|
mcp: {
|
|
456
551
|
transport: "stdio";
|
|
457
552
|
};
|
|
553
|
+
queue: {
|
|
554
|
+
enabled?: boolean | undefined;
|
|
555
|
+
concurrency?: number | undefined;
|
|
556
|
+
};
|
|
458
557
|
sandbox: {
|
|
459
558
|
shellCwd?: string | undefined;
|
|
460
559
|
shellTimeoutMs?: number | undefined;
|
|
@@ -465,16 +564,22 @@ declare const UnotestConfigSchema: z.ZodObject<{
|
|
|
465
564
|
};
|
|
466
565
|
baseUrl?: string | undefined;
|
|
467
566
|
webServer?: {
|
|
468
|
-
url: string;
|
|
469
567
|
command: string;
|
|
470
|
-
|
|
568
|
+
url: string;
|
|
471
569
|
reuseExistingServer?: boolean | undefined;
|
|
570
|
+
timeoutMs?: number | undefined;
|
|
472
571
|
} | undefined;
|
|
473
572
|
channel?: "chrome" | "msedge" | "chrome-beta" | null | undefined;
|
|
474
573
|
storageState?: string | undefined;
|
|
574
|
+
schedules?: {
|
|
575
|
+
collection: string;
|
|
576
|
+
cron: string;
|
|
577
|
+
env?: string | undefined;
|
|
578
|
+
prepare?: string | undefined;
|
|
579
|
+
}[] | undefined;
|
|
475
580
|
}>;
|
|
476
581
|
type UnotestConfig = z.infer<typeof UnotestConfigSchema>;
|
|
477
582
|
type UnotestConfigInput = z.input<typeof UnotestConfigSchema>;
|
|
478
583
|
declare const DEFAULT_CONFIG: UnotestConfig;
|
|
479
584
|
|
|
480
|
-
export { type Browser, type BrowserChannel, BrowserChannelSchema, BrowserSchema, DEFAULT_CONFIG, type DialogPolicy, DialogPolicySchema, type FailureBundleConfig, FailureBundleConfigSchema, type LinterConfig, LinterConfigSchema, type LinterRuleId, LinterRuleIdSchema, type LinterSeverity, LinterSeveritySchema, type McpConfig, McpConfigSchema, type RetryConfig, RetryConfigSchema, type RetryReason, RetryReasonSchema, type SandboxConfig, SandboxConfigSchema, type UnotestConfig, type UnotestConfigInput, UnotestConfigSchema, type Viewport, ViewportSchema, type WebServerConfig, WebServerConfigSchema };
|
|
585
|
+
export { type Browser, type BrowserChannel, BrowserChannelSchema, BrowserSchema, DEFAULT_CONFIG, type DialogPolicy, DialogPolicySchema, type FailureBundleConfig, FailureBundleConfigSchema, type LinterConfig, LinterConfigSchema, type LinterRuleId, LinterRuleIdSchema, type LinterSeverity, LinterSeveritySchema, type McpConfig, McpConfigSchema, type QueueConfig, QueueConfigSchema, type RetryConfig, RetryConfigSchema, type RetryReason, RetryReasonSchema, type SandboxConfig, SandboxConfigSchema, type ScheduleConfig, ScheduleSchema, type UnotestConfig, type UnotestConfigInput, UnotestConfigSchema, type Viewport, ViewportSchema, type WebServerConfig, WebServerConfigSchema };
|