@unotest/viewer 0.32.0 → 0.35.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,302 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.35.0] - 2026-09-15
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [79c99ab]
8
+ - Updated dependencies [dee96a1]
9
+ - @unotest/protocol@0.35.0
10
+ - @unotest/core@0.35.0
11
+ - @unotest/dsl@0.35.0
12
+
13
+ ## [0.34.0] - 2026-09-07
14
+
15
+ ### Patch Changes
16
+
17
+ - 32a495e: Failure artifacts survive a run that is isolated from its own artifact root.
18
+
19
+ A run executed inside a container sees only what is mounted into it. The
20
+ failure bundle's directory is derived from the artifact root, which on such
21
+ a run is not one of those mounts — so it could not be created, and a failed
22
+ test produced no screenshot, no trace and no page HTML at all. Worse, that
23
+ one error took the always-on copies in the run directory down with it: both
24
+ sinks shared a single try, so the artifact that does not depend on the
25
+ bundle was lost too.
26
+
27
+ Two changes. `UNOTEST_FAILURES_ROOT` now says where bundles live, for
28
+ whoever spawns the run to point at a directory it can actually write
29
+ (unset, everything behaves exactly as before). And the two sinks are
30
+ independent: an unusable bundle root no longer costs you the run
31
+ directory's screenshot and page HTML.
32
+
33
+ The viewer serves failure assets from either layout, so a bundle written
34
+ inside the environment's runs tree renders like a local one.
35
+
36
+ - Updated dependencies [32a495e]
37
+ - Updated dependencies [c6b91bb]
38
+ - @unotest/protocol@0.34.0
39
+ - @unotest/core@0.34.0
40
+ - @unotest/dsl@0.34.0
41
+
42
+ ## [0.33.0] - 2026-09-06
43
+
44
+ ### Minor Changes
45
+
46
+ - 5d1bde3: Run isolation, stage 1: a box no longer executes a test bundle inside its
47
+ own daemon.
48
+
49
+ box-runner (new, private): the run sidecar — the only service on a box
50
+ holding the docker socket, and the only one that starts a container. Three
51
+ authenticated routes on the `box` network (`POST /runs`, `GET
52
+ /runs/:id/stream` NDJSON, `DELETE /runs/:id`), a shared secret compared in
53
+ constant time, and an API that cannot be told anything about how a
54
+ container is built: no path, image, network, user, mount or flag. A request
55
+ names a project, an environment, a bundle, a scenario and the environment's
56
+ values; the bind sources are derived from the first three, resolved with
57
+ `realpath` and re-checked against the projects root, and must already
58
+ exist. The container is created over the Docker Engine API (pinned
59
+ `v1.43`) straight over the socket — no `docker` binary in the image and no
60
+ client dependency, and a container that is a JSON document has no place
61
+ for a value to become a flag. A daemon that refuses or is not there comes
62
+ back as `502 {reason}` before the run is accepted, or as an `error` event
63
+ on the stream after — never as a run that "exited with code null". An
64
+ environment variable the box does not forward is named in the sidecar's
65
+ log rather than dropped silently. A run gets the bundle tree read-only as its working directory, the
66
+ environment's `unotest/.runs.<env>` read-write, a `noexec` tmpfs `/tmp`,
67
+ a read-only rootfs, all capabilities dropped, `no-new-privileges`, its own
68
+ uid in boxd's group, memory/pids/cpu limits clamped to both the operator's ceilings and the
69
+ host's own size (docker refuses a container bigger than the machine
70
+ instead of clamping it), a 512 MB `/dev/shm`
71
+ (docker's default 64 MB kills Chromium mid-page; the host's IPC namespace
72
+ is deliberately not borrowed), and the `runs` network only. The container engine is an interface, so every rule is asserted on
73
+ the container that would have reached docker — including the cases where
74
+ the answer is no container at all.
75
+
76
+ boxd: running the SUITE is its own contract (`IScenarioRunner`), separate
77
+ from running a command (`ICommandRunner`, still the daemon's own `npm ci`
78
+ and viewers). `ContainerScenarioRunner` talks to the sidecar,
79
+ `ProcessScenarioRunner` keeps the old child-process shape for a box without
80
+ docker; `BOXD_RUNNER_KIND=container|process` chooses, and half a container
81
+ configuration refuses to start. Reading a bundle's schedules
82
+ (`unotest-web schedules --json`) executes the project's config module, so it
83
+ goes the same way. A run's environment is built from named parts
84
+ (`run-environment.ts`) instead of inheriting `process.env`, and its debug
85
+ tree is pointed at the run's own directory rather than the read-only
86
+ sources. New metrics
87
+ `boxd_run_container_total{outcome}` and `boxd_run_container_start_seconds`.
88
+ An environment's runs directory is created by the daemon with `2775`, so
89
+ the run's uid may write into it and the daemon's group may read it back.
90
+
91
+ box-kit: `secretsMatch` (constant-time secret comparison, moved out of
92
+ dist-service), `splitLines`, and `envNumber` / `envPositiveNumber` — the
93
+ env-reading rule the box-side services share.
94
+
95
+ `npm ci` of a bundle is a container of its own (`kind: "install"`): the
96
+ bundle tree is its only mount and it is writable, there is no environment
97
+ and no artifacts directory in reach, and a dependency's install scripts
98
+ RUN — a native module builds or fetches its binary as usual. That is what
99
+ running them inside the daemon could never allow. The container's last
100
+ steps, only on success, check the tree against the box's size cap, set
101
+ the final modes on what the install created and write the install marker
102
+ — each with an exit code of its own, so the daemon's log names the step
103
+ that failed — so a killed or oversized
104
+ install leaves a tree the box will not mount; a failed install takes the
105
+ tree with it. The daemon no longer passes `--ignore-scripts` and no
106
+ longer walks the tree afterwards.
107
+
108
+ The daemon runs with `umask 002` and clears `node_modules` before each
109
+ install, so a tree it created is one the install container (another uid
110
+ in its group) can actually write.
111
+
112
+ An install that fails KEEPS the tree, unmarked: nothing mounts it and the
113
+ next attempt reinstalls in place (`npm ci` wipes `node_modules` itself).
114
+ And a bundle directory holding a manifest with neither an archive nor a
115
+ tree behind it no longer counts as "this box has it" — a push of the same
116
+ content brings it back instead of being answered `already had this exact
117
+ bundle`.
118
+
119
+ web: `bundle push` and the docs say that a dependency's install scripts
120
+ run on a box, in isolation — the earlier advice to vendor them is gone.
121
+
122
+ A run ordered in the viewer now goes through the daemon's queue instead
123
+ of being spawned by the viewer.
124
+
125
+ `POST/GET/DELETE /api/box/envs/<project>/<env>/runs[/<runId>]` (paths and
126
+ wire types in `@unotest/protocol`) takes a `manual` ticket like every
127
+ other producer. Two callers may use it, and neither is taken on its word:
128
+ the guard, proving it is the guard with a shared secret from `box-init`
129
+ (the actor header is read only next to it — a viewer container could
130
+ otherwise claim to be any admin), and a viewer, with a per-environment
131
+ token the daemon minted when it started that viewer plus a short-lived
132
+ single-use ticket the guard signed over WHO clicked. The guard gates the
133
+ route under `admin` and requires an `Origin` on a mutation; the daemon
134
+ checks the token's environment, the ticket's environment and its `jti`
135
+ against the path.
136
+
137
+ `@unotest/viewer` gets `BoxdRunner`: on a box the Run button orders and
138
+ the Stop button asks the daemon, and the progress still comes from the
139
+ run's journal on disk. A local viewer spawns as it always did — the
140
+ composition root picks by whether a box handed it credentials.
141
+
142
+ - 348aba7: Run isolation, stage 2b and 3: an environment's viewer runs in a
143
+ container of its own, and a box ships the sidecar in its compose stack.
144
+
145
+ The viewer was the last thing on a box that executed a bundle's code next
146
+ to the daemon's state: it runs the UI, the language service and the
147
+ linter out of the bundle's own `node_modules`. It is now a container the
148
+ run sidecar starts and the daemon asks for — `POST /viewers`, one per
149
+ environment, addressed by a name both services derive
150
+ (`unotest-viewer-<project>-<env>-<8 hex>`, port 7788).
151
+
152
+ Each viewer gets a NETWORK of its own with exactly two other containers
153
+ attached: the guard, which proxies people at it, and the daemon, which it
154
+ orders runs from. Not the sidecar's network, not the runs network, and
155
+ above all not another viewer's — a viewer has no authentication of its
156
+ own, and two on one network would be two containers of untrusted code
157
+ with a route to each other. That network is INTERNAL: a viewer's server
158
+ makes no outbound call, so it gets none, while the network a run joins
159
+ stays open because a test drives an application. The name of a viewer and
160
+ its network is a function of the project/environment PAIR rather than of
161
+ the string they join to, so two environments whose names concatenate the
162
+ same way cannot end up sharing one. Its mounts are the bundle tree read-only and
163
+ the environment's `unotest/` read-write (the run history it renders and
164
+ the queue it withdraws tickets from); the environment's secrets and its
165
+ `current` link sit in the directory above and are mounted by nobody. Its
166
+ environment is assembled by the daemon — the target, the environment's
167
+ variables and secrets, its own credential — and the daemon's own
168
+ `process.env` no longer travels.
169
+
170
+ **Editing a suite's files from the viewer on a box no longer works**: the
171
+ sources are read-only there. Such edits never survived the next `bundle
172
+ push` anyway, which replaced the tree. Everything else in the UI is
173
+ unchanged.
174
+
175
+ Readiness is the docker daemon's own healthcheck, probed inside the
176
+ container and read through the sidecar — the daemon no longer waits on a
177
+ lock file, and nothing dials a viewer from the process holding the docker
178
+ socket. A viewer's output is streamed into the daemon's log and
179
+ reattached when the stream drops. A container whose bundle and values
180
+ still match is ADOPTED across a restart of either service, keeping the
181
+ credential it was born with; one whose environment is gone is stopped,
182
+ with its network and its token. The sidecar recognises its own containers
183
+ by label, never by name alone, and enforces its own ceiling on how many
184
+ viewers may exist (`BOX_RUNNER_MAX_VIEWERS`) — boxd knows how many
185
+ environments there are right up until boxd is the thing that was
186
+ compromised.
187
+
188
+ A run somebody stopped now ENDS in the viewer instead of hanging.
189
+ Stopping a run stops its container gracefully (SIGTERM, then SIGKILL
190
+ after `BOX_RUNNER_STOP_GRACE_SECONDS`, default 10) so the suite writes
191
+ the terminal event of its own journal; if it could not — the grace ran
192
+ out, the machine died — the daemon appends it. The same grace applies to
193
+ a run killed on its timeout, so a run's budget is the timeout plus the
194
+ grace.
195
+
196
+ A viewer that dies is noticed in about a second and replaced: the log
197
+ stream the daemon holds open ends with the container, and the 404 that
198
+ answers its immediate reattach is the signal. The route comes out of the
199
+ table at once — the guard then says the environment has no viewer instead
200
+ of proxying at a dead address — and a fresh container is started, on the
201
+ same backoff the daemon uses for everything else it cannot reach.
202
+
203
+ A restart of the sidecar is no longer an outage. While it cannot be
204
+ reached the daemon keeps the routes it published (the viewer containers
205
+ are up, and the guard reaches them directly) and tries again with a short
206
+ backoff instead of waiting for its next five-minute sweep. Editing a file
207
+ in the viewer on a box now answers 403 with the reason — the sources are
208
+ read-only there — rather than a blank 500.
209
+
210
+ The viewer no longer collapses the daemon's refusals into a blank 500:
211
+ the box's own status and reason reach the browser, so "that click has
212
+ already been used" (409), "the box cannot verify the caller" (503) and
213
+ "the box daemon is not answering" (503) are told apart by the person who
214
+ clicked.
215
+
216
+ Deployment: `compose.yaml` gains the `runner` service (the only holder of
217
+ the docker socket, the release ships its bundled `box-runner.mjs` beside
218
+ `boxd.mjs`), fixed container names for the guard and the daemon, a
219
+ `box_secrets` volume whose secrets `box-init` generates, and a one-off
220
+ migration of the projects tree to the group layout an install container
221
+ needs. Two values an operator fills in: `BOX_PROJECTS_HOST_DIR` (a bind
222
+ source is resolved by the docker daemon on the host) and `BOX_DOCKER_GID`.
223
+ Step by step in `docs/manuals/box-run-isolation-cutover.md`.
224
+
225
+ ### Patch Changes
226
+
227
+ - 9158968: Viewer: a run that started before the page was reloaded is shown again.
228
+
229
+ The live sidebar was rebuilt from WebSocket events only, and those speak
230
+ from the moment the page connects — a collection already in progress
231
+ never re-sent its `started` event, so after a reload it was missing from
232
+ ACTIVE, then reappeared nameless with no scenario list once its next
233
+ scenario began, while its children showed up one by one. On load, and on
234
+ an environment or target switch, the viewer now asks the server which
235
+ runs are live and replays each one's journal into the same state the
236
+ live stream feeds, so a reload mid-run shows the collection with its
237
+ name, scenarios and progress.
238
+
239
+ - b3e1220: Viewer: a session that may not edit variables does not see credential-shaped
240
+ values, nor the project's absolute path.
241
+
242
+ Behind the guard, a readonly session could read `GET /api/variables` and
243
+ get every box variable's value — `PASSWORD=…` included when the operator
244
+ had pushed it as a variable rather than a secret. The viewer now reads the
245
+ capabilities the guard stamps on each request: when the caller cannot edit
246
+ variables, a value whose name matches `PASSWORD | PASSWD | SECRET | TOKEN |
247
+ KEY | CREDENTIAL` is sent empty and flagged `secret`, and `GET /api/targets`
248
+ leaves out `projectRoot`. Display-masking by capability, not authorization —
249
+ a viewer without a proxy in front is unchanged, and the guard still refuses
250
+ every write.
251
+
252
+ - bd6b7ba: Viewer: a collection run no longer shows up nameless and without scenarios.
253
+
254
+ The journal tailer moved its cursor to the end of the file even when the
255
+ last line was still being written, so the half already on disk was lost
256
+ and the other half was later read as garbage. The longest line in a
257
+ journal is `collection-run:started` with the scenario list, so that was
258
+ the one it usually caught: the run appeared under its id instead of its
259
+ collection name, with "collection has no scenarios" and a `2/0 done`
260
+ counter. The cursor now stops at the last complete line and the rest is
261
+ read on the next change.
262
+
263
+ - 8846a06: Viewer: a run started the moment the viewer came up is shown, not lost
264
+ until the next change.
265
+
266
+ Watching a directory becomes live a moment after the watcher reports it
267
+ is ready, and a run directory created inside that gap was not merely
268
+ late — it was invisible until something else changed in the same day,
269
+ which in a quiet environment is the next run. Measured at 1-3% on macOS
270
+ with a busy machine, and the window is exactly "open the viewer, start a
271
+ run". The viewer now proves the watch delivers before it declares itself
272
+ up: it creates a directory of its own and waits to hear about it, so
273
+ boot completes on evidence rather than on a promise. A tree it cannot
274
+ write to, or a probe that never comes back, costs a warning in the log
275
+ and boots as before.
276
+
277
+ A directory whose name starts with a dot is never reported as a run — an
278
+ editor's leftovers, or the watcher's own probe, can no longer appear in
279
+ the run list as a phantom.
280
+
281
+ Run queue: a waiter whose process paused is no longer mistaken for dead
282
+ — the queue stamps its own files with its own clock.
283
+
284
+ A run waiting for a slot proves it is alive by touching its ticket, and
285
+ what it is judged against was the modification time the filesystem
286
+ wrote: on a network share that is the server's clock, and against a
287
+ machine busy enough to keep a process off the CPU, a ticket written a
288
+ moment ago could look like it belonged to a process that died. The queue
289
+ now stamps every file it creates with the same clock it judges them by.
290
+
291
+ - Updated dependencies [2abfd4d]
292
+ - Updated dependencies [b3e1220]
293
+ - Updated dependencies [b3e1220]
294
+ - Updated dependencies [5d1bde3]
295
+ - Updated dependencies [8846a06]
296
+ - @unotest/protocol@0.33.0
297
+ - @unotest/core@0.33.0
298
+ - @unotest/dsl@0.33.0
299
+
3
300
  ## [0.32.0] - 2026-09-05
4
301
 
5
302
  ### Minor Changes
@@ -1,5 +1,17 @@
1
1
  import { IRunnerAdapter, IDslLanguageService, IProcessLauncher } from '@unotest/protocol';
2
2
 
3
+ /** What a BOX handed this viewer so it can order runs from the daemon
4
+ * instead of spawning them itself. Present only there: a local viewer
5
+ * owns its runs, and a box's viewer owns none. */
6
+ interface ViewerBoxConfig {
7
+ /** The daemon, on the box's own network — never a public address. */
8
+ readonly daemonUrl: string;
9
+ /** This viewer's own credential, minted when it started. It says WHICH
10
+ * environment is asking; who clicked is a separate, signed claim. */
11
+ readonly token: string;
12
+ readonly project: string;
13
+ readonly environment: string;
14
+ }
3
15
  interface BoxInjectedNames {
4
16
  /** Non-secret: the viewer has their values in its own environment. */
5
17
  readonly variables: readonly string[];
@@ -82,6 +94,14 @@ declare function startViewerServer(opts: StartViewerOptions): Promise<ViewerHand
82
94
  /** The box's injected-variable names, when a box supervisor set them.
83
95
  * Both lists come together or not at all; an environment with no secrets
84
96
  * still has a target URL, so "set" means the variables list is present. */
97
+ /** The credentials a BOX hands its viewer so it can order runs from the
98
+ * daemon: where the daemon is, this viewer's own token, and which
99
+ * project and environment it speaks for. All four or nothing — a token
100
+ * with no address is a viewer that cannot ask, and an address with no
101
+ * token is one that will be refused, and neither is a shape a box ever
102
+ * produces. A local viewer has none of them and spawns its own runs, as
103
+ * it always did. */
104
+ declare function boxRunnerFrom(env: NodeJS.ProcessEnv): ViewerBoxConfig | undefined;
85
105
  declare function boxInjectedFrom(env: NodeJS.ProcessEnv): BoxInjectedNames | undefined;
86
106
  /** Everything the standalone launcher reads from its environment, in one
87
107
  * pure place so the plumbing is testable without starting a server.
@@ -93,4 +113,4 @@ declare function boxInjectedFrom(env: NodeJS.ProcessEnv): BoxInjectedNames | und
93
113
  declare function viewerCliOptions(env: NodeJS.ProcessEnv, cwd: string): Omit<StartViewerOptions, "runners">;
94
114
  declare function runViewerCli(): Promise<void>;
95
115
 
96
- export { type RunnerEntry, type StartViewerOptions, type ViewerHandle, boxInjectedFrom, runViewerCli, startViewerServer, viewerCliOptions };
116
+ export { type RunnerEntry, type StartViewerOptions, type ViewerHandle, boxInjectedFrom, boxRunnerFrom, runViewerCli, startViewerServer, viewerCliOptions };