@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 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):
@@ -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
- timeoutMs?: number | undefined;
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
- timeoutMs?: number | undefined;
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
- timeoutMs?: number | undefined;
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 };