@unotest/core 0.25.0 → 0.26.1

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,129 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.26.1] - 2026-08-30
4
+
5
+ ### Patch Changes
6
+
7
+ - `packBundle` accepts `declaredVariables` — the external NAMES a suite
8
+ declares, carried in the manifest so a box can tell a pusher which of
9
+ them it has no value for. Names only; the files that hold the values
10
+ are excluded from every bundle as before.
11
+
12
+ - Updated dependencies [9424f6b]
13
+ - @unotest/protocol@0.26.1
14
+ - @unotest/dsl@0.26.1
15
+
16
+ ## [0.26.0] - 2026-08-30
17
+
18
+ ### Minor Changes
19
+
20
+ - 6d1c612: `npx @unotest/web bundle push` — send your suite to a box, instead of
21
+ giving a box access to your repository.
22
+
23
+ ```sh
24
+ npx @unotest/web bundle push --box https://tests.example.com
25
+ ```
26
+
27
+ It packs `unotest/`, `unotest.config`, `package.json` and its lockfile
28
+ into one archive and uploads it. That is the whole channel: a box holds
29
+ no deploy key and no token for anybody's code and never pulls anything,
30
+ so nothing is on it that was not pushed to it. (There is no path-scoped
31
+ git credential anywhere — a key that can fetch `unotest/` can fetch the
32
+ product source next to it, which is why this direction is the only one
33
+ we offer.)
34
+
35
+ - **Uncommitted work travels.** The manifest records `dirty: true` and
36
+ the bundle shows up as _wip_, so pushing a fix you have not committed
37
+ yet is a normal loop, not a release step.
38
+ - **What would fail on the box is refused here.** A scenario that reads a
39
+ file outside `unotest/` (`MODULE_NOT_FOUND` on the box, hours later, on
40
+ a run nobody is watching), a missing lockfile (`npm ci` will not run
41
+ without one), no `@unotest/web` in the dependencies, a symlink. Each
42
+ problem names the file, the line and the fix.
43
+ - **Secrets do not travel.** `unotest/.env*` and `.secrets*` are excluded
44
+ even if committed: environment values belong to the environment and are
45
+ injected over the bundle when it runs.
46
+ - **In a git project, git decides what belongs.** The file list comes from
47
+ `git ls-files --cached --others --exclude-standard`, so whatever your
48
+ `.gitignore` keeps out — generated databases, recordings, `.runs` —
49
+ stays out. Content is read from the working tree, not the index.
50
+ - **The id is the content.** `bundleId` is a hash of the packed files, not
51
+ a commit sha (a dirty tree's sha is not unique), so re-pushing an
52
+ unchanged suite is an instant no-op and two people packing the same tree
53
+ get the same bundle.
54
+ - `--dry-run` packs and checks without uploading, `--out <file>` keeps the
55
+ archive (plain `tar.gz` — `tar -tzf` shows what is inside), `--json`
56
+ prints `{ bundleId, status }` for a CI job that runs it afterwards.
57
+ - `UNOTEST_BOX_URL` / `UNOTEST_BOX_TOKEN` supply the address and the
58
+ token, which a CI job usually takes from its secret store. Tokens are
59
+ issued per project on the box.
60
+
61
+ - c43aa00: Runs of one project now take turns instead of colliding.
62
+
63
+ Until now nothing coordinated them: the viewer refused a second run with
64
+ HTTP 409, and that was the whole defence — a `npx @unotest/web e2e` in a
65
+ terminal, an agent's `run_test` and a scheduled suite would all start on
66
+ top of each other, driving the same browser and the same seeded database
67
+ at the same time. The 409 protected the UI, not the machine.
68
+
69
+ - **A filesystem queue**, in `unotest/.queue<target>[.<env>]/` beside the
70
+ runs root it belongs to. A run writes a ticket, waits until it is at
71
+ the head, takes a slot, runs, and gives the slot back. There is no
72
+ daemon: whoever holds the slot executes the run itself, so nothing new
73
+ has to be installed, started or kept alive. Only `open("wx")`, rename,
74
+ unlink and mtime — no `flock`, which lies on network and container
75
+ filesystems. A crashed producer is reaped by whoever comes next.
76
+ - **Every producer is in it**: the CLI (`e2e`, `collection`, `author`),
77
+ the viewer's Run button, and the MCP `run_test` (which spawns the CLI).
78
+ A collection is ONE run — its `workers` still run in parallel inside
79
+ it, because a child process carries its parent's lease and skips the
80
+ queue.
81
+ - **The viewer shows the queue**: the Active panel lists what is waiting,
82
+ including tickets a terminal or an agent wrote, with a button to take
83
+ any of them back out. `GET /api/queue`, `DELETE /api/queue/:ticket`,
84
+ and a `queue:changed` websocket message.
85
+ - **Config**: `queue.concurrency` (default 1) and `queue.enabled`
86
+ (default true) in `unotest.config`. Both the CLI and the viewer read
87
+ the same file, so they cannot disagree about how many slots exist.
88
+ - **Escape hatches**: `UNOTEST_NO_QUEUE=1` runs without queueing;
89
+ Ctrl-C while waiting gives up your place in line and runs nothing.
90
+ - **For boxes**: `UNOTEST_QUEUE_GLOBAL_DIR` + `UNOTEST_QUEUE_GLOBAL_SLOTS`
91
+ add a host-wide budget several environments share (a collection weighs
92
+ its worker count), and `UNOTEST_ARTIFACTS_ROOT` puts `.runs` / `.queue`
93
+ somewhere other than the directory the tests live in — so the history
94
+ outlives a throwaway copy of the tests.
95
+
96
+ ### Migration notes
97
+
98
+ **`POST /api/run` no longer answers 409.** Ordering a run while another
99
+ one is active used to fail with `ActiveRunConflictError` (HTTP 409); it
100
+ now succeeds and the run waits. The response changed shape with it:
101
+
102
+ ```diff
103
+ - { runId, kind, ref, pid, startedAt }
104
+ + { runId, kind, ref, status: "queued" | "running", ticket, acceptedAt }
105
+ ```
106
+
107
+ `runId` is still final and immediately usable — open the tab on it as
108
+ before — but the run may not have started yet, and `pid` is not known at
109
+ that point. `POST /api/runs/:runId/abort` covers both states: it
110
+ withdraws a waiting ticket or SIGTERMs a running child.
111
+
112
+ Anything that treated 409 as "busy, try later" should now simply order
113
+ the run. Anything that relied on "only one run can exist" should read
114
+ `GET /api/queue`. To keep the old immediate-start behaviour (without the
115
+ protection), set `queue.enabled: false`.
116
+
117
+ ### Patch Changes
118
+
119
+ - Updated dependencies [3eede7c]
120
+ - Updated dependencies [6d1c612]
121
+ - Updated dependencies [dbfa36f]
122
+ - Updated dependencies [c43aa00]
123
+ - Updated dependencies [8ec4177]
124
+ - @unotest/protocol@0.26.0
125
+ - @unotest/dsl@0.26.0
126
+
3
127
  ## [0.25.0] - 2026-08-29
4
128
 
5
129
  ### Patch Changes
package/README.md CHANGED
@@ -13,6 +13,13 @@ filesystem layer both `@unotest/web` and `@unotest/mobile` need:
13
13
  `process.env` always winning.
14
14
  - **`appendUniqueLines`** — idempotent `.gitignore` (or any line-based
15
15
  file) updater used by the runners' `init`.
16
+ - **`FsRunQueue`** — the filesystem run queue (`unotest/.queue…`) that
17
+ serialises RUNS of one project + environment across every producer:
18
+ a CLI in a terminal, the viewer's Run button, an agent. Ticket +
19
+ slot-lease model, with the crash recovery that implies (mtime
20
+ liveness, rename-quarantine reaping). Uses only `open("wx")`, rename,
21
+ unlink and mtime — never `flock`, which lies on the network and
22
+ container filesystems this has to work on.
16
23
 
17
24
  Depends only on `@unotest/protocol`. Unlike protocol (pure data, no fs),
18
25
  this package deliberately owns filesystem side effects.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { RunArtifact, RunManifest, RunSources, RuntimeStateFile, RuntimeState, DbgCommand, PickerMode, DbgCommandRecord, DbgCommandProbe } from '@unotest/protocol';
1
+ import { RunArtifact, RunManifest, RunSources, RuntimeStateFile, RuntimeState, DbgCommand, PickerMode, DbgCommandRecord, DbgCommandProbe, IRunQueue, EnqueueRunInput, RunQueueTicketHandle, QueueSnapshot, QueueTicket, RunQueueLease, BundleGitInfo, BundleManifest } from '@unotest/protocol';
2
+ export { isRecord } from '@unotest/protocol';
2
3
  import { ExecutionEvent } from '@unotest/dsl/executor';
3
4
 
4
5
  interface IRunArtifactWriter {
@@ -259,4 +260,225 @@ declare class UnotestError extends Error {
259
260
  constructor(message: string, context?: Record<string, unknown>);
260
261
  }
261
262
 
262
- export { type ArtifactRedactor, type DebugCommandsWatcherDeps, type DebugControlTarget, type DebugWatcherLogger, type E2EFlags, type GitignoreUpdate, type IRunArtifactWriter, JsonlRunArtifactWriter, type JsonlRunArtifactWriterOptions, type RunHeartbeat, type RuntimeExecState, type RuntimeInspection, type RuntimeInspectionInput, type RuntimeStateExtra, type RuntimeStateWriter, type RuntimeStateWriterDeps, UnotestError, type WriteManifestInput, type WriteRunSourcesInput, appendUniqueLines, applyEnvLayers, buildRuntimeInspection, createRunArtifactWriter, createRuntimeStateWriter, currentLocation, extractCallStack, levenshteinDistance, parseE2EFlags, readDebuggerBreakpoints, readEnvFile, readEnvLayers, startDebugCommandsWatcher, startRunHeartbeat, suggestClosest, toProtocolRuntimeState, writeRunManifest, writeRunSources };
263
+ interface GlobalPoolOptions {
264
+ /** Absolute directory shared by every environment on this host
265
+ * (`UNOTEST_QUEUE_GLOBAL_DIR`; in compose, a shared volume). */
266
+ dir: string;
267
+ /** Total WEIGHT the host can run at once
268
+ * (`UNOTEST_QUEUE_GLOBAL_SLOTS`). */
269
+ slots: number;
270
+ }
271
+ /** Read the optional host-wide weight pool from the environment. Present
272
+ * on a box where several environments share one machine's CPU and RAM;
273
+ * absent for an npm user, who then only has the per-environment queue.
274
+ * The ONE parser for every producer — the CLI and the viewer counting
275
+ * different pools over the same directory would defeat the pool. */
276
+ declare function globalPoolFromEnv(env?: NodeJS.ProcessEnv): GlobalPoolOptions | null;
277
+ interface FsRunQueueOptions {
278
+ /** Absolute queue root — `<artifacts root>/unotest/.queue[…]`. */
279
+ root: string;
280
+ /** Environment concurrency: how many RUNS may be in flight here. */
281
+ slots: number;
282
+ global?: GlobalPoolOptions;
283
+ staleAfterMs?: number;
284
+ touchIntervalMs?: number;
285
+ pollIntervalMs?: number;
286
+ /** Injected clock + entropy + timer, so the tests can drive staleness
287
+ * and races deterministically instead of sleeping through them. */
288
+ now?: () => number;
289
+ random?: () => string;
290
+ sleep?: (ms: number) => Promise<void>;
291
+ }
292
+ declare class FsRunQueue implements IRunQueue {
293
+ private readonly root;
294
+ private readonly ticketsDir;
295
+ private readonly runningDir;
296
+ private readonly staleAfterMs;
297
+ private readonly touchIntervalMs;
298
+ private readonly pollIntervalMs;
299
+ private readonly now;
300
+ private readonly random;
301
+ private readonly sleep;
302
+ private readonly envPool;
303
+ private readonly globalPool;
304
+ constructor(opts: FsRunQueueOptions);
305
+ enqueue(input: EnqueueRunInput): Promise<RunQueueTicketHandle>;
306
+ snapshot(): Promise<QueueSnapshot>;
307
+ cancel(ticketName: string): Promise<boolean>;
308
+ /** Reap what died, then drop `running/` entries with no lease behind
309
+ * them. Every producer calls this on its way past — a queue with any
310
+ * traffic needs no janitor process. */
311
+ sweep(): Promise<void>;
312
+ /** Wait until `handle` owns a slot. Public only for the handle it
313
+ * belongs to. */
314
+ acquire(handle: FsTicketHandle, signal?: AbortSignal): Promise<RunQueueLease>;
315
+ /** One claim attempt for the ticket sitting at `index` of the live
316
+ * queue. Null means "not now" — the caller waits and retries.
317
+ *
318
+ * Position rule: a ticket may attempt a claim when its index is below
319
+ * the environment's slot count. With the default concurrency of 1
320
+ * that is strict FIFO; above it, two eligible tickets may take a
321
+ * freed slot out of order, which is the price of not serialising
322
+ * every waiter behind the head of the queue.
323
+ *
324
+ * A maintenance ticket is exclusive: it waits for the head position
325
+ * and then takes EVERY slot, so nothing else runs while it mutates
326
+ * the environment. While one is at the head, NOBODY else may claim —
327
+ * without that drain rule a steady stream of later tickets would keep
328
+ * grabbing each freed slot and the head's all-or-nothing claim would
329
+ * starve forever. Detected from ticket names (the maintenance rank is
330
+ * the first character), so waiters never read each other's files. */
331
+ private tryClaim;
332
+ /** Live waiting tickets, in service order. */
333
+ private waitingOrder;
334
+ private readEntries;
335
+ private clampWeight;
336
+ /** Heartbeat + teardown for a lease — kept here so `FsLease` stays a
337
+ * handle and the filesystem knowledge stays in one class. */
338
+ releaseLease(handle: FsTicketHandle, envSlots: readonly string[], globalSlots: readonly string[] | null): Promise<void>;
339
+ releaseLeaseSync(handle: FsTicketHandle, envSlots: readonly string[], globalSlots: readonly string[] | null): void;
340
+ touchLease(handle: FsTicketHandle, envSlots: readonly string[], globalSlots: readonly string[] | null): Promise<void>;
341
+ get heartbeatIntervalMs(): number;
342
+ }
343
+ declare class FsTicketHandle implements RunQueueTicketHandle {
344
+ private readonly queue;
345
+ readonly name: string;
346
+ readonly ticket: QueueTicket;
347
+ constructor(queue: FsRunQueue, name: string, ticket: QueueTicket);
348
+ acquire(opts?: {
349
+ signal?: AbortSignal;
350
+ }): Promise<RunQueueLease>;
351
+ cancel(): Promise<void>;
352
+ }
353
+ /** Env every child of a lease holder inherits. The boundary the queue
354
+ * draws is "top-level run": a collection's scenario subprocesses carry
355
+ * this and skip the queue, so `workers` keeps meaning what it always
356
+ * meant instead of deadlocking behind its own parent. */
357
+ declare function leaseEnv(runId: string): Readonly<Record<string, string>>;
358
+
359
+ /** The ticket disappeared while its owner was waiting for a slot: it was
360
+ * cancelled (viewer `DELETE /api/queue/:ticket`) or reaped as stale
361
+ * (the owner was suspended long enough to look dead). Callers treat
362
+ * this as "this run will never start" — it is not a failure of the run,
363
+ * and there is nothing to retry automatically. */
364
+ declare class QueueTicketLostError extends UnotestError {
365
+ constructor(ticketName: string);
366
+ }
367
+ /** The caller's abort signal fired while waiting (Ctrl-C in a terminal,
368
+ * viewer shutdown). The ticket has already been withdrawn. */
369
+ declare class QueueWaitAbortedError extends UnotestError {
370
+ constructor(ticketName: string);
371
+ }
372
+ /** A queue directory that cannot be created / written. Distinct from a
373
+ * full queue: this one never resolves by waiting. */
374
+ declare class QueueUnavailableError extends UnotestError {
375
+ constructor(root: string, cause: string);
376
+ }
377
+
378
+ interface TarEntry {
379
+ /** Relative posix path inside the archive. */
380
+ path: string;
381
+ content: Uint8Array;
382
+ /** Only the exec bit travels. The rest of a file's mode is the packing
383
+ * machine's umask, and a suite that hashes differently because of the
384
+ * umask it was packed under would break the "same tree, same id"
385
+ * promise the whole upload path rests on. */
386
+ executable?: boolean;
387
+ }
388
+ /** Everything a member path must be before it is written or restored.
389
+ *
390
+ * Rejected on BOTH sides on purpose: the packing check is a courtesy to
391
+ * whoever is packing, the unpacking check is the one that stops an
392
+ * uploaded archive from writing outside the directory it was given. */
393
+ declare function assertSafeBundlePath(path: string): void;
394
+ /** Serialise entries into a tar archive. Entries are sorted by path and
395
+ * duplicates are refused: an archive with two members of the same name
396
+ * restores to whichever came last, which makes the id of a bundle depend
397
+ * on the order somebody happened to walk a directory. */
398
+ declare function writeTar(entries: readonly TarEntry[]): Buffer;
399
+ /** Restore entries from a tar archive.
400
+ *
401
+ * Strict by design: only plain files, only the ustar magic, only paths
402
+ * that stay inside the bundle. A member this refuses is not a member we
403
+ * would know what to do with — a symlink in an uploaded archive is a way
404
+ * to make the next reader follow a path off the box. */
405
+ declare function readTar(data: Uint8Array): TarEntry[];
406
+
407
+ type BundleFile = TarEntry;
408
+ /** FULL content hash of a payload: sha256 over the sorted list of
409
+ * (path, exec bit, content hash). Two trees with the same files have
410
+ * the same hash no matter how they were walked, compressed or
411
+ * transported. This is the integrity anchor — the manifest carries it
412
+ * whole, and the box verifies the WHOLE of it after unpacking. */
413
+ declare function computeTreeSha256(files: readonly BundleFile[]): string;
414
+ /** The short HANDLE derived from the full hash: a directory name, a URL
415
+ * segment, a run-manifest field. Identity and integrity are the full
416
+ * sha256; this is only how humans and paths refer to it. */
417
+ declare function computeBundleId(files: readonly BundleFile[]): string;
418
+ /** What the manifest carries that the files themselves cannot say. */
419
+ interface BundleProvenance {
420
+ createdAt: number;
421
+ webVersion: string;
422
+ author?: string;
423
+ git?: BundleGitInfo;
424
+ /** Names of the externals the suite declares. The files that hold them
425
+ * never travel (their values belong to an environment), so the names
426
+ * ride in the manifest — that is what lets a box say at upload which
427
+ * of them it cannot supply. */
428
+ declaredVariables?: readonly string[];
429
+ }
430
+ interface PackedBundle {
431
+ bundleId: string;
432
+ manifest: BundleManifest;
433
+ /** gzipped tar, ready to be written to a file or POSTed. */
434
+ archive: Buffer;
435
+ }
436
+ declare function packBundle(files: readonly BundleFile[], provenance: BundleProvenance): PackedBundle;
437
+ interface UnpackedBundle {
438
+ manifest: BundleManifest;
439
+ /** The suite, without the manifest. */
440
+ files: BundleFile[];
441
+ }
442
+ interface UnpackOptions {
443
+ /** Cap on the UNPACKED size, enforced while decompressing. Without one
444
+ * a 1 MB upload can expand into a full disk. */
445
+ maxBytes?: number;
446
+ }
447
+ declare function unpackBundle(archive: Uint8Array, options?: UnpackOptions): UnpackedBundle;
448
+
449
+ /** The archive is not a readable bundle: broken gzip, a truncated tar, a
450
+ * header whose checksum does not add up, a member that is not a plain
451
+ * file. Everything a box refuses BEFORE writing anything to disk. */
452
+ declare class BundleFormatError extends UnotestError {
453
+ constructor(reason: string, context?: Record<string, unknown>);
454
+ }
455
+ /** A member path a bundle may not carry.
456
+ *
457
+ * This is a security boundary, not tidiness: an archive is written by
458
+ * whoever uploaded it, and `../../etc/cron.d/x` or an absolute path in a
459
+ * tar header is the oldest way to make an unpacker write outside the
460
+ * directory it was told to. Paths are checked when PACKING and again
461
+ * when UNPACKING — the second check is the one that matters, because the
462
+ * first ran on the uploader's machine. */
463
+ declare class BundlePathError extends UnotestError {
464
+ constructor(path: string, reason: string);
465
+ }
466
+ /** The archive unpacks to more than the caller allowed.
467
+ *
468
+ * A separate error because the answer is different: a malformed archive
469
+ * is never worth retrying, an oversized one means "split the suite or
470
+ * raise the box's limit". It is also the defence against a small upload
471
+ * that expands to fill a disk — the limit is enforced DURING
472
+ * decompression, not after. */
473
+ declare class BundleTooLargeError extends UnotestError {
474
+ constructor(limitBytes: number);
475
+ }
476
+ /** The archive's content does not hash to the id its manifest claims.
477
+ * Either the upload was truncated or something rewrote it in flight; in
478
+ * both cases the box must not store it under an id that means something
479
+ * else — the id is what every ticket, symlink and cache key refers to. */
480
+ declare class BundleIdMismatchError extends UnotestError {
481
+ constructor(claimed: string, computed: string);
482
+ }
483
+
484
+ export { type ArtifactRedactor, type BundleFile, BundleFormatError, BundleIdMismatchError, BundlePathError, type BundleProvenance, BundleTooLargeError, type DebugCommandsWatcherDeps, type DebugControlTarget, type DebugWatcherLogger, type E2EFlags, FsRunQueue, type FsRunQueueOptions, type GitignoreUpdate, type GlobalPoolOptions, type IRunArtifactWriter, JsonlRunArtifactWriter, type JsonlRunArtifactWriterOptions, type PackedBundle, QueueTicketLostError, QueueUnavailableError, QueueWaitAbortedError, type RunHeartbeat, type RuntimeExecState, type RuntimeInspection, type RuntimeInspectionInput, type RuntimeStateExtra, type RuntimeStateWriter, type RuntimeStateWriterDeps, type TarEntry, UnotestError, type UnpackOptions, type UnpackedBundle, type WriteManifestInput, type WriteRunSourcesInput, appendUniqueLines, applyEnvLayers, assertSafeBundlePath, buildRuntimeInspection, computeBundleId, computeTreeSha256, createRunArtifactWriter, createRuntimeStateWriter, currentLocation, extractCallStack, globalPoolFromEnv, leaseEnv, levenshteinDistance, packBundle, parseE2EFlags, readDebuggerBreakpoints, readEnvFile, readEnvLayers, readTar, startDebugCommandsWatcher, startRunHeartbeat, suggestClosest, toProtocolRuntimeState, unpackBundle, writeRunManifest, writeRunSources, writeTar };