@ultimat3/testing 24.0.0 → 25.1.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.
Files changed (49) hide show
  1. package/CLAUDE.md +12 -12
  2. package/README.md +55 -18
  3. package/package.json +16 -16
  4. package/src/auth-request.ts +1 -1
  5. package/src/cdp-browser-lease.ts +112 -0
  6. package/src/cdp-browser.ts +42 -1
  7. package/src/cdp-connection.ts +18 -4
  8. package/src/cdp-e2e-session.ts +120 -12
  9. package/src/cdp-errors.ts +25 -9
  10. package/src/cdp-launch-attempt.ts +112 -80
  11. package/src/cdp-launch-candidates.ts +62 -0
  12. package/src/cdp-launch-profile.ts +106 -0
  13. package/src/cdp-launch-reap.ts +68 -0
  14. package/src/cdp-launch-wire.ts +171 -0
  15. package/src/cdp-launch.ts +47 -22
  16. package/src/define-island-states.ts +47 -8
  17. package/src/determinism.ts +2 -2
  18. package/src/e2e-app.ts +14 -1
  19. package/src/e2e-errors.ts +14 -3
  20. package/src/e2e-locale.ts +12 -21
  21. package/src/e2e-page.ts +12 -26
  22. package/src/e2e-run.ts +21 -11
  23. package/src/e2e-service-worker.ts +92 -0
  24. package/src/e2e-spawn.ts +57 -9
  25. package/src/errors.ts +20 -65
  26. package/src/fixture-clock.ts +1 -1
  27. package/src/fixture-island.ts +39 -56
  28. package/src/fixture-jobs.ts +7 -7
  29. package/src/fixture-mail.ts +3 -3
  30. package/src/fixture-network.ts +1 -1
  31. package/src/fixture-statements.ts +1 -1
  32. package/src/fixture-subscribe.ts +3 -3
  33. package/src/framework-fixtures.ts +13 -13
  34. package/src/index.ts +17 -10
  35. package/src/island-dom.ts +49 -9
  36. package/src/island-globals.ts +73 -0
  37. package/src/island-scratch.ts +67 -3
  38. package/src/island-state-errors.ts +16 -0
  39. package/src/island-states.ts +23 -0
  40. package/src/live-node.ts +4 -4
  41. package/src/quiet-logs.ts +1 -1
  42. package/src/registry-leak-error.ts +81 -0
  43. package/src/registry-leak-guard.ts +4 -3
  44. package/src/registry-snapshot.ts +19 -1
  45. package/src/render-view.ts +3 -3
  46. package/src/sealed-network.ts +45 -4
  47. package/src/sealed-sockets.ts +59 -0
  48. package/src/template-db.ts +2 -2
  49. package/src/test-types.ts +6 -2
package/CLAUDE.md CHANGED
@@ -13,18 +13,18 @@ is its own entry point and not part of the barrel.
13
13
  | Rule | Detail |
14
14
  |---|---|
15
15
  | No mocks of the DB | clone a template database; `template-db.ts` is the only DB path |
16
- | No wall clock | `frozenClock` / `advanceClock`; `Date.now()` is frozen by the preload |
16
+ | No wall clock | `withFrozenClock` / `advanceClock`; `Date.now()` is frozen by the preload |
17
17
  | Frozen ≠ different | `globalThis.Date` becomes a subclass, so `FrozenDate[Symbol.hasInstance]` brands on the `[[DateValue]]` slot — `Date.prototype.getTime.call(value)` throws or it doesn't. |
18
18
  | Slot, not prototype | the brand is cross-realm on purpose: `instanceof RealDate` misses a `node:vm` or worker Date, and `Object.prototype.toString` is spoofable by `Symbol.toStringTag: 'Date'`. Only the slot is both |
19
- | No unmocked egress | `sealed-network.ts` patches fetch; a miss is `X_TEST_NETWORK_SEALED` |
19
+ | No unmocked egress | `sealed-network.ts` seals `fetch`; `sealed-sockets.ts` `WebSocket`, `Bun.connect` (not loopback); a miss is `X_TEST_NETWORK_SEALED` |
20
20
  | Self is not egress | a port core's `markListening()` announced passes through — a socket test never unseals |
21
- | Offline is a state, not a mock | `network.offline()` / `.drop()` fail every request as `X_TEST_NETWORK_OFFLINE`, ahead of the mocks — the app's own offline path runs |
21
+ | Offline is a state, not a mock | `network.offline()` / `.drop()` fail every dial, loopback too, as `X_TEST_NETWORK_OFFLINE` before any mock: the app's offline path runs |
22
22
  | One way offline | the `network` fixture. `setNetworkState` is the gate's only writer and is not exported — setting it from a test body skips the fixture's disposal and leaves every later file offline |
23
- | The seal key is a test process's, and nobody's export | `test-seal-key.ts`: installed by the preload only when `NODE_ENV === 'test'` and neither `ULTIMATE_SECRETS_KEY` nor `.secrets.key` exists; `startE2eApp` hands it to the child by VALUE (`testSealKeyEnv`), because the spawned app is a server and must never be able to install one. The constant is module-private and off the barrel (`test-seal-key.test.ts`). Never loosen the `NODE_ENV` check: the key is public |
23
+ | The seal key is a test process's, and nobody's export | `test-seal-key.ts`: installed by the preload only when `NODE_ENV === 'test'` and neither `ULTIMATE_SECRETS_KEY` nor `.secrets.key` exists; `startE2eApp` hands it to the child by VALUE (`testSealKeyEnv`): a spawned server must never install one. The constant is module-private and off the barrel (`test-seal-key.test.ts`). Never loosen the `NODE_ENV` check: the key is public |
24
24
  | `toEqualRow` never prints a server-only value | `matcher-row.ts` compares every OWN property (`toEqual` cannot see a `.sealed()` column — empty diff). A non-enumerable property that differs is NAMED; its value and its length stay out of the message, which lands in CI logs |
25
25
  | No retries | a flake is fixed or deleted the day it flakes; there is no `retry: 3` |
26
26
  | A mutator's `local()` runs against `memoryLocalTx()` | `local-tx.ts`: keyed rows with the record store's rules (update invents nothing, `undefined` leaves a column, rows frozen). Never a hand-rolled table per test |
27
- | A page test RENDERS, with no cast | `render-view.ts`: `renderRoute(module, { url, actor })` resolves `load` once and hands `meta` and the page that one object, as every render mode does; `renderView(Component, props)` is one component. `@ultimat3/render` is imported INSIDE both, never at module scope — the barrel is what a tier-0 test imports for `expect` |
27
+ | A page test RENDERS, with no cast | `render-view.ts`: `renderRoute(module, { url, actor })` resolves `load` once and hands `meta` and the page that one object, as every render mode does; `renderView(Component, props)` is one component. `@ultimat3/render` is imported inside both, never at module scope: a tier-0 test imports this barrel |
28
28
  | `toBeUltimateError` reads THREE fields | an `X_` code plus a `cause` plus a `fix`, all through core's `stringField`. `typeof value.code === 'string'` alone passed a Node `ENOENT` |
29
29
  | One matcher WAITS, and it is `toBeVisible` | `await expect(locator).toBeVisible()` retries `isVisible()` to a budget — 5000ms every 100ms, Playwright's own default, narrowable per call. |
30
30
  | The budget is counted in LOOKS, not milliseconds | this package freezes `Date.now()`, so a deadline computed from the clock never expires and the loop spins forever. `attemptsFor(budget)` is `1 + floor(timeout / interval)` |
@@ -36,7 +36,7 @@ is its own entry point and not part of the barrel.
36
36
  | Test names | the filename picks the step; a typed helper prefixes its own name. `testName(type, name)` is for an outer `describe` only — never on the inner `test` too, the prefix would print twice |
37
37
  | One body shape, five registrars | `test` (= `fixtureTest`), `unitTest`, `contractTest`, `liveTest`, `jobTest` all take `FixtureBody` + `{ timeoutMs }`: `unitTest('…', async ({ runJobs }) => …)`. Never `describe(testName('unit', …))` + `test` to get a fixture |
38
38
  | `runJobs` says who and what | `{ actor }` is the WORKER's identity (the org stays the job's declared tenant), `{ tenantId }` the enqueuer's row; `executions[n].result` is the body's return. A fresh event bus per fixture |
39
- | A `runJobs` pass is a real worker's `tick()` | `fixture-jobs.ts` builds an unstarted `createWorker` per pass, so admission runs: `concurrency` waits and `whenBusy: 'fail'` refuses (`X_JOB_KEY_BUSY`) exactly as in a fleet. Never `executeJob` on a bare `claim()`. The lease and slot renew every 1 ms of TEST time (`RENEW_MS`, `frozen-scheduler.ts` on `onClockMoved`), never on a wall-clock interval: cancel, `clock.advance(1)`, and the body hears `X_JOB_LEASE_LOST` — no hand-built worker |
39
+ | A `runJobs` pass is a real worker's `tick()` | `fixture-jobs.ts` builds an unstarted `jobWorker` per pass, so admission runs: `concurrency` waits and `whenBusy: 'fail'` refuses (`X_JOB_KEY_BUSY`) exactly as in a fleet. Never `executeJob` on a bare `claim()`. The lease and slot renew every 1 ms of TEST time (`RENEW_MS`, `frozen-scheduler.ts` on `onClockMoved`), never on a wall-clock interval: cancel, `clock.advance(1)`, and the body hears `X_JOB_LEASE_LOST` — no hand-built worker |
40
40
  | Per-test state is `per-test-reset.ts` | the app preload's `beforeEach` resets the jobs event bus — only when `@ultimat3/jobs` is already loaded, so a core test never loads it |
41
41
  | An island test is `describeIslandState` | one block per declared state: mounted in `beforeAll` (60 s), disposed in `afterAll`, one build per file. `mountIslandState` + `using` for one test's own mount; an undeclared id is `X_TEST_ISLAND_STATE_UNKNOWN` |
42
42
  | An authenticator is tested with `authRequest()` | the hook's own two arguments, real — never `as unknown as Parameters<…>` |
@@ -74,7 +74,7 @@ is its own entry point and not part of the barrel.
74
74
  | A boot that rejects is its own teardown | `acquireWorkerDatabase`, `seed` or `boot` throwing returns no `BootedHarness`, so no caller can ever reach `close()` |
75
75
  | A found template is not a migrated one | `template-db.ts` tolerates "already exists" for the `CREATE DATABASE` alone. `config.migrate` runs unconditionally; drop + clone hold the same lock |
76
76
  | Fixture teardown | a fixture that installs process-global state (the ambient job or mail driver) implements `Symbol.dispose` / `Symbol.asyncDispose` and restores what was there |
77
- | Building one by hand | `createRunJobs()` outside a fixture body is not disposed for you — dispose it in `afterEach`, or the next file inherits your queue and your event bus |
77
+ | Building one by hand | `testJobs()` outside a fixture body is not disposed for you — dispose it in `afterEach`, or the next file inherits your queue and your event bus |
78
78
  | Factory strategy | an association is built with the strategy that asked for it: `build()` never reaches a database, `create()` writes the parent first. Never a third strategy |
79
79
  | One write seam | `usePersister` is the only place `create()` writes. A factory that took a repo argument would put the seam at every call site |
80
80
  | Factory seeds | derived from the table name unless given, so two entities never draw the same uuid stream. `reset()` cascades into associated parents — a half-reset row is worse than none |
@@ -83,10 +83,10 @@ is its own entry point and not part of the barrel.
83
83
  | An island needs a BUILDER, not an import | `buildIslands` is `@ultimat3/cli`'s and both packages are tier 5; the one declared edge is `cli → testing`, so the reverse is a `bun run boundaries` failure. |
84
84
  | `mountIsland` AWAITS `mount` | `IslandEntry['mount']` returns `unknown`, not `void`, and the call is awaited |
85
85
  | Dispose STOPS the island, then restores the globals | when `mount` resolved to a function, `[Symbol.dispose]` calls it BEFORE `restore()` |
86
- | The micro-DOM is the fixture's, once **for islands** | `island-dom.ts`. `packages/ui/src/fake-dom.ts` is a second one for keyboard code, and `ui -> testing` is upward, so it is not a copy to collapse. `bun test` has no DOM and no DOM library may be added |
86
+ | The micro-DOM is the fixture's, once **for islands** | `island-dom.ts`. `packages/ui/src/fake-dom-fixture.ts` is a second one for keyboard code, and `ui -> testing` is upward: not a copy to collapse. `bun test` has no DOM and no DOM library may be added |
87
87
  | `style` and `classList` RECORD | `FakeStyle` is one declaration map behind all four spellings compiled Solid uses (static attribute, `setProperty`, `cssText`, `removeAttribute`), so a test can assert the component set `--form-gap` |
88
88
  | `classList` is the class attribute | not a list of its own, so `classList.toggle` — which the compiler emits INLINE for `classList={{ … }}`, no runtime helper between — and `className` can never answer one element two ways. |
89
- | A `document` listener is the documentElement's | no bubbling: `document.addEventListener` registers on `documentElement`, and `fire(mounted.documentElement, 'keydown', …)` drives it. One handler per type, last wins |
89
+ | A `document` listener is the documentElement's | no bubbling: `document.addEventListener` registers on `documentElement`, and `fire(mounted.documentElement, 'keydown', …)` drives it. Every listener per type runs; removal is by function identity |
90
90
  | `querySelector` skips `this` | descendants only, as the DOM's does. Matching the element it is called on made a host `<div>` answer `find('div')` with the container the test built rather than the markup the island rendered |
91
91
  | The selector grammar is SMALL and REFUSES | `island-selector.ts`: compounds of tag, `#id`, `.class`, `[attr]`, `[attr="value"]`, joined by space or `>`. Anything else is `X_TEST_ISLAND_SELECTOR_UNSUPPORTED` with the offset, never an empty answer |
92
92
  | The box is 0 until a test writes it | `clientHeight`, `scrollTop`, `scrollHeight` and the rest are writable numbers, default 0; `getBoundingClientRect()` derives from the offset box |
@@ -105,12 +105,12 @@ is its own entry point and not part of the barrel.
105
105
  | The island EXTENSION is not restated here | `.island.tsx` is `@ultimat3/render`'s `ISLAND_EXTENSION` and `render` is not a dependency of this package. |
106
106
  | A mount imports from its own temp DIRECTORY | `island-scratch.ts`: per mount (identical chunks stay two modules); removed on dispose, at the file boundary and in the run's `afterAll` — `exit` never fires under `bun test`. Never a `data:` URL (coverage panics past ~4 kB) |
107
107
  | Attaching a node MOVES it | `appendChild`, `insertBefore`, `replaceChild` detach it from its old parent first; `removeChild` clears `parentNode` |
108
- | Globals install all-or-nothing | `installGlobals` saves DESCRIPTORS, not values — a saved value cannot tell "no such global" from "a global holding `undefined`", and the teardown deleted both |
108
+ | Globals install in layers | `island-globals.ts`: a key's real DESCRIPTOR is saved by the first live mount, restored by the last, in any dispose order |
109
109
  | Which command shards | `bun test` is one process on one database — what a scaffolded app's `test` script runs |
110
110
 
111
111
  ## The frozen instant and the seed are screened
112
112
 
113
- `installDeterminism({ now })`, `setFrozenClock`, `frozenClock` and `advanceClock` go through one
113
+ `installDeterminism({ now })`, `setFrozenClock`, `withFrozenClock` and `advanceClock` go through one
114
114
  `instantMs`: an unreadable instant would make `Date.now()` answer `NaN` for every later file. The
115
115
  seed is screened too — `seed >>> 0` maps `NaN`, `0.5` and `2 ** 32` onto seed 0 silently.
116
116
  `finiteOption`/`finiteCount` from `@ultimat3/core` are the one form; `determinism-bounds.test.ts`
@@ -145,7 +145,7 @@ lives beside the `PageLike` it implements. `cli` imports it over the declared `c
145
145
  | `e2e-dom-fixture.ts` | a document small enough to hold in a test and real enough to RUN the expressions above |
146
146
  | `cdp-browser.ts` | the two doors: `openE2eBrowserIfAvailable()` (undefined when there is no browser) and `openE2eBrowser()` (refuses by name), and the close that undoes both halves |
147
147
  | `cdp-launch.ts` | which Chrome, its flags, the launch deadline (`LAUNCH_TIMEOUT_MS`, 60 s — a cold start is not a call) and the ONE relaunch (`LAUNCH_ATTEMPTS`) |
148
- | `cdp-launch-attempt.ts` | one start: its own process group, the first answer over the pipe; on none, a full reap (group killed, profile removed) before the next. ONE `close()`, a promise, always awaited — no sync close |
148
+ | `cdp-launch-attempt.ts` | one start in its own group; on no answer a full reap (group, then profile + singleton dir: `cdp-launch-profile.ts`). ONE awaited `close()` |
149
149
  | `cdp-connection.ts` | CDP over a transport — the launched Chrome's pipe (`cdp-pipe.ts`), a remote one's `WebSocket` (`cdpConnect`): reply correlation by `id`, event waiters, the per-call deadline; why one went unanswered is `cdp-wire-watch.ts` |
150
150
  | `cdp-e2e-session.ts` | the BROWSER half, `E2eSession`: every target auto-attached at browser level and PAUSED until its Network domain is on (a SharedWorker opens its socket at start-up) |
151
151
  | `cdp-e2e-page.ts` | one TAB, `E2eTab`: `E2eBrowserPage`'s five methods plus `reload`, `waitFor`, `indexedDbNames`, `close`. `offline()` forwards to the session — the switch is browser-wide |
package/README.md CHANGED
@@ -10,7 +10,8 @@ frozen clock. Never let a test reach the network unmocked — it fails by design
10
10
  | `harness.ts` | `describeApp()` / `testApp()` — boot an app in-process with its own database |
11
11
  | `template-db.ts` | N workers, N databases, one migrated template, `CREATE DATABASE ... TEMPLATE` |
12
12
  | `determinism.ts` | frozen clock, seeded RNG, seeded uuids, `assertDeterministic` |
13
- | `sealed-network.ts` | any unmocked egress fails, with the URL and the mock line |
13
+ | `sealed-network.ts` | any unmocked `fetch`, `WebSocket` or `Bun.connect` egress fails, with the URL and the line that fixes it |
14
+ | `sealed-sockets.ts` | the `WebSocket` and `Bun.connect` patches, behind `sealed-network.ts`'s one gate |
14
15
  | `factories.ts` | `defineFactory` — seeded rows, traits, associations, `build` vs `create` |
15
16
  | `factory-registry.ts` | `factoriesFor(registry)` — one factory per entity, defaults read off column names |
16
17
  | `factory-persist.ts` | `usePersister` — the one seam `create()` writes through |
@@ -39,9 +40,9 @@ frozen clock. Never let a test reach the network unmocked — it fails by design
39
40
  | `island-states-resolve.ts` | a name a reader typed → the manifest it meant; a manifest → the island file it claims |
40
41
  | `framework-fixtures.ts` | registers both sets; the app registers only what it owns |
41
42
  | `registry-leak-guard.ts` | fails the run naming the FILE that left a process-global registry dirty, and restores the ones that can be restored at the same boundary |
42
- | `registry-snapshot.ts` | `captureProcessRegistries()` / `restoreProcessRegistries()` — the locale config, the catalogs, the permission set and the role map, put back as a file inherited them. A module-scope declaration evaluates once per process (`bun test` without `--isolate`, `As of 2026-08`), so a neighbour's `clearPermissions()` is otherwise permanent |
43
+ | `registry-snapshot.ts` | `captureProcessRegistries()` / `restoreProcessRegistries()` — the locale config, the catalogs, the permission set, the role map, the tasks and the declared measurement actor, put back as a file inherited them. A module-scope declaration evaluates once per process (`bun test` without `--isolate`, `As of 2026-08`), so a neighbour's `clearPermissions()` is otherwise permanent |
43
44
  | `registry-isolation.ts` | `isolateEntityRegistry()` — an empty entity registry, and the process's back after. Its own entry point (`@ultimat3/testing/registry-isolation`), never the barrel: it value-imports `@ultimat3/entity`, and the barrel is what a tier-0 test imports for `expect` |
44
- | `quiet-logs.ts` | a green run prints the reporter and nothing else: the framework's log lines go to a sink that drops them. `LOG_LEVEL=info bun test <file>` shows them — naming `LOG_LEVEL` is the one escape hatch, and a level above `info` filters what the TERMINAL shows, never what the logger emits (below). A test asserting on log output hands `createLogger({ level, writer })` its own writer, or installs its own `setLogSink` and restores the previous one |
45
+ | `quiet-logs.ts` | a green run prints the reporter and nothing else: the framework's log lines go to a sink that drops them. `LOG_LEVEL=info bun test <file>` shows them — naming `LOG_LEVEL` is the one escape hatch, and a level above `info` filters what the TERMINAL shows, never what the logger emits (below). A test asserting on log output hands `structuredLogger({ level, writer })` its own writer, or installs its own `setLogSink` and restores the previous one |
45
46
  | `preload.ts` | the bunfig preload that installs all of the above |
46
47
 
47
48
  ## Install
@@ -93,7 +94,7 @@ with `{ runJobs }` — never `describe(testName('unit', …))` around a bare `te
93
94
  | `network` | `offline()` · `drop()` · `online()` · `state()` over the sealed network | the preload |
94
95
  | `runJobs` | a worker: call it to enqueue+drain, then `enqueue()` `drain()` `due()` `inFlight()` `depth()` — see [below](#a-job-under-runjobs) | the preload |
95
96
  | `statements` | every statement the test issued: `all()` `count(fingerprint?)` `shapes()` — and an N+1 throws | the preload |
96
- | `page` | the browser: `goto` `gotoStreamed` `getByRole` `evaluate` `waitForServiceWorker` | a browser driver |
97
+ | `page` | the browser: `goto` `gotoStreamed` `getByRole` `evaluate` `waitForServiceWorker({ timeoutMs? })` — an uncontrolled first page is reloaded once for its worker (`e2e-service-worker.ts`) | a browser driver |
97
98
  | `budget` | `jsBytes(route)` measured off the built output | a browser driver |
98
99
  | `signIn` | put the browser session in a member's shoes | a browser driver |
99
100
  | `deploy` | `newBuild()` — same app, new build id, page still open | a browser driver |
@@ -160,7 +161,7 @@ unitTest('the sweep runs as the billing worker', async ({ runJobs }) => {
160
161
  | **`{ actor }`** | on the call and on `drain()`: the identity the WORKER runs as — what an app wires through `WorkerOptions.context()`. Its org is replaced by the job's declared tenant, as a real worker's is; `tenant: 'none'` strips it. Absent is core's anonymous actor |
161
162
  | **`{ tenantId }`** | on the call and on `enqueue()`: the ENQUEUER's tenant on the row — the limiter's bucket and the dedupe namespace, the half of `handle.as(actor, input)` that reaches the queue. Never the tenant the body runs under: that is the job's own `tenant:` |
162
163
  | **one event bus per fixture** | `step.waitForEvent` reads an ambient bus that STORES; the fixture installs a fresh one and hands the process's back, so one test's `publishEvent` cannot resume the next test's run |
163
- | **a real worker's pass** | each round is `createWorker(...).tick()`: admission, the limiter and keyed `concurrency` (`whenBusy: 'fail'` settles `refused`, `X_JOB_KEY_BUSY`) hold exactly as in production |
164
+ | **a real worker's pass** | each round is `jobWorker(...).tick()`: admission, the limiter and keyed `concurrency` (`whenBusy: 'fail'` settles `refused`, `X_JOB_KEY_BUSY`) hold exactly as in production |
164
165
  | **a cancel reaches a running body** | the worker renews its lease on the TEST clock — every millisecond of it — and a renewal that misses the row aborts `ctx.signal` with `X_JOB_LEASE_LOST`: start `runJobs.drain()`, cancel the row (`cancelJob(jobDriver(), id)` or the app's own action), `clock.advance(1)`, await the drain. No hand-built worker, no sleep, no wall-clock timer |
165
166
 
166
167
  ## An N+1 fails the test it happened in
@@ -283,13 +284,13 @@ Two ship, for the ports an app may implement itself — a `JobDriver` and `@ulti
283
284
  `BudgetStore`:
284
285
 
285
286
  ```ts
286
- import { MemoryBudgetStore } from '@ultimat3/ai';
287
- import { createMemoryDriver } from '@ultimat3/jobs';
287
+ import { memoryBudgetStore } from '@ultimat3/ai';
288
+ import { memoryJobDriver } from '@ultimat3/jobs';
288
289
  import { behavesLike, budgetStoreConformance, describe, jobDriverConformance } from '@ultimat3/testing';
289
290
 
290
291
  // The shipped ports pass; put your own driver or store where these are.
291
- describe('my queue', () => behavesLike(jobDriverConformance, () => createMemoryDriver()));
292
- describe('my budget store', () => behavesLike(budgetStoreConformance, () => new MemoryBudgetStore()));
292
+ describe('my queue', () => behavesLike(jobDriverConformance, () => memoryJobDriver()));
293
+ describe('my budget store', () => behavesLike(budgetStoreConformance, () => memoryBudgetStore()));
293
294
  ```
294
295
 
295
296
  | Suite | Holds a store to |
@@ -331,7 +332,7 @@ and `JEST_WORKER_ID` itself, so that precedence is load-bearing rather than defe
331
332
 
332
333
  | State | Owned by | What teardown does |
333
334
  |---|---|---|
334
- | the seal on `fetch` | the preload | unseals only if this boot was the one that sealed |
335
+ | the seal on `fetch`, `WebSocket` and `Bun.connect` | the preload | unseals only if this boot was the one that sealed |
335
336
  | the frozen instant, `Math.random`, `globalThis.Date` | the preload (`ULTIMATE_TEST_NOW` / `ULTIMATE_TEST_SEED`) | `restoreCapturedDeterminism(captureDeterminism())` around the boot |
336
337
  | mocks, allow-listed hosts, the seen list | the boot | `resetNetwork()` |
337
338
  | the cloned worker database | the boot | `db.drop()`, in a `finally` — a rejecting `app.close()` reaches it |
@@ -349,11 +350,27 @@ X_TEST_NETWORK_SEALED
349
350
  fix: mockFetch('https://api.stripe.com/v1/charges', () => new Response('{}')) — or allowHost('api.stripe.com') if it must be real
350
351
  ```
351
352
 
352
- A server this process booted is exempt: `createServer().start()` announces its socket through
353
+ A server this process booted is exempt: `httpServer().start()` announces its socket through
353
354
  core's `markListening()`, so a test may call its own `handle.url()` on a kernel-assigned port with
354
355
  the seal fully on. Unsealing (`ULTIMATE_TEST_ALLOW_NET=1`) stays reserved for a deliberate live
355
356
  integration — never for a socket test.
356
357
 
358
+ **Three dials are sealed, and only three** — `fetch`, `new WebSocket(url)` and `Bun.connect({ hostname,
359
+ port })`, behind one gate: one allow-list (`allowHost('host[:port]')`), one offline state, one
360
+ `requestedUrls()` record. As of 2026-10:
361
+
362
+ | Dial | Refused with | Passes through |
363
+ |---|---|---|
364
+ | `fetch` | `X_TEST_NETWORK_SEALED` / `X_TEST_NETWORK_OFFLINE` | a mock, an allowed host, a port `markListening()` announced |
365
+ | `new WebSocket(url)` | the same, thrown by the constructor before it dials | an allowed host, any loopback host |
366
+ | `Bun.connect(…)` | the same, as the rejected promise | an allowed host, any loopback host, a unix socket |
367
+
368
+ A socket to loopback is not egress on any port: it is how a test reaches a server it `Bun.listen`ed
369
+ and the compose services the live suites dial. A socket test injects its transport (`connect`,
370
+ `client`) rather than mocking the dial, so there is no `mockFetch` for one. **Not sealed:**
371
+ `node:net`, `node:tls`, `node:http(s)`, `Bun.udpSocket` and the native clients (`Bun.sql`,
372
+ `Bun.redis`, `Bun.S3Client`) — a test that must not reach the internet through those injects them.
373
+
357
374
  ## Rendering a page or a component
358
375
 
359
376
  A page is a function of props and a route is a module, so a unit test renders either in process —
@@ -391,7 +408,7 @@ unitTest('the shell keeps the page in <main>', async () => {
391
408
  | **`renderView(Component, props)`** | one component. `props` is the component's own type — a renamed prop is a compile error in the test. A component that renders an `island()` is refused here: its timing is a route's |
392
409
  | **`view.html`** | the markup as the document carries it |
393
410
  | **`view.text`** | what a reader sees: tags, `<script>` and `<style>` bodies removed, entities decoded, whitespace collapsed. Compare with `t('<key>')` — markup escapes what the catalog does not |
394
- | **`actor`** | `load` and the page run inside `runWithContext(createContext({ actor }))`. Without one they run outside any request context, as a static page does |
411
+ | **`actor`** | `load` and the page run inside `runWithContext(ctxOf({ actor }))`. Without one they run outside any request context, as a static page does |
395
412
  | **the file stays `.test.ts`** | no JSX in a test: the registry-leak guard's loader covers `.test.ts` only, and children that need markup are a string |
396
413
 
397
414
  `@ultimat3/render` is imported inside the two functions, so the barrel still loads no renderer.
@@ -710,17 +727,37 @@ declared edge and the reverse is a `bun run boundaries` failure.
710
727
 
711
728
  ## Launching Chrome
712
729
 
713
- `launchChrome({ executable, timeoutMs, launchTimeoutMs? })` — what the `e2e` step and `x shot` both
714
- start a browser with. Driven over `--remote-debugging-pipe`; the first answer is readiness.
730
+ `launchChrome({ executable, timeoutMs, launchTimeoutMs?, wire? })` — what the `e2e` step and `x shot`
731
+ both start a browser with. Driven over `--remote-debugging-pipe`, and on Windows — where the parent
732
+ cannot hold Chrome's fds 3 and 4 — over `--remote-debugging-port=0` and the profile's
733
+ `DevToolsActivePort` (`wire: 'pipe' | 'port'` overrides). The first answer is readiness.
734
+
735
+ `CHROME_CANDIDATES` is this platform's install list, probed after `CHROME_PATH`: the `/usr/bin` names
736
+ on Linux, the app bundles on macOS, Chrome under `%ProgramFiles%` / `%ProgramFiles(x86)%` /
737
+ `%LOCALAPPDATA%` then Edge (`msedge.exe`) on Windows.
715
738
 
716
739
  | Export | What it is |
717
740
  |---|---|
718
741
  | `LAUNCH_TIMEOUT_MS` | `60_000` — the first answer's deadline per start. `launchTimeoutMs` defaults to the larger of this and `timeoutMs`; every later call has `timeoutMs` |
719
742
  | `LAUNCH_ATTEMPTS` | `2` — an unanswered start is reaped and started once more on a fresh profile. Never more |
720
- | `CdpLaunchAttempt` | one unanswered start, in `X_CDP_LAUNCH_FAILED`'s `meta.attempts`: `why` (`deadline` \| `closed`), `waitedMs`, `exitCode` (`null` when killed), `stderr` (its last lines) |
721
- | `LaunchedBrowser.close()` · `E2eBrowser.close()` | THE close, a promise, always awaited: the process, its whole process group, its profile directory. There is no synchronous close |
722
-
723
- `As of 2026-10-02`.
743
+ | `LAUNCH_BUDGET_MS` | derived: `LAUNCH_ATTEMPTS × (LAUNCH_TIMEOUT_MS + LAUNCH_REAP_MS)` — the longest a launch is designed to take, both starts and their bounded reaps |
744
+ | `E2E_BROWSER_OPEN_MS` | derived: `LAUNCH_BUDGET_MS` + the session's two setup calls + the first tab, at `DEFAULT_CDP_TIMEOUT_MS` each. THE deadline of every `beforeAll` that opens a browser — one below it is killed by Bun before the relaunch, and `X_CDP_LAUNCH_FAILED` never names the slow step |
745
+ | `E2E_BROWSER_CLOSE_MS` | derived: `4 × CLOSE_GRACE_MS` — SIGTERM's grace, SIGKILL's, the group reap, the profile's removal. THE deadline of an `afterAll` that closes a browser it holds (one that releases a lease takes `E2E_BROWSER_OPEN_MS`: it may await the open). At Bun's 5 s default a close cut short by the run's end left the profile half-removed in the temp root |
746
+ | `E2E_TAB_OPEN_MS` | derived: one more `session.newTab()` — `Target.createTarget`, then its attach |
747
+ | `E2E_GOTO_MS` | derived: one `tab.goto()` — the navigate raced by the load event, then two reads of the document |
748
+ | `E2E_APP_START_MS` | derived: `startE2eApp()` at its default deadline — `x db reset`, `x db seed` and `/readyz` (wall-clock), each its whole 90 s, then a refused boot's stop |
749
+ | `E2E_APP_STOP_MS` | derived: an e2e app's `stop()` — SIGTERM, the app's own drain at the defaults (readiness grace + `drain.deadlineMs`), then SIGKILL. Bounded: a wedged app no longer holds the hook forever |
750
+ | `CdpLaunchAttempt` | one unanswered start, in `X_CDP_LAUNCH_FAILED`'s `meta.attempts`: `why` (`deadline` \| `closed` \| `spawn` — the binary could not be started at all), `waitedMs`, `exitCode` (`null` when killed), `stderr` (its last lines), `detail` (what the launcher saw end it: the refused dial and its endpoint, the call that timed out, the spawn error) |
751
+ | `leaseE2eBrowser(key, open)` | ONE browser for every suite of the process that asks by `key`: `{ opened, release() }`, opened by the first, each suite its own tab. Closed by the last holder out — or, once a preload has called `closeE2eBrowsersAtRunEnd()`, at the run's end (Bun fires no `exit` under `bun test`, and runs one file to its end before loading the next) |
752
+ | `LaunchedBrowser.close()` · `E2eBrowser.close()` | THE close, a promise, always awaited: the process, its whole process group (on Windows its process tree, `taskkill /T /F`), and only then its profile directory — removed again while something re-creates it, bounded — and Chrome's `com.google.Chrome.XXXXXX` singleton-socket directory beside it, when it holds nothing but singleton files. There is no synchronous close |
753
+
754
+ Every hook that opens or closes a browser derives its deadline from these — the open, the close,
755
+ the app's start or stop, plus its own steps. `e2e-browser-hooks.test.ts` reads every suite under
756
+ `packages/*/e2e`, `examples/*/apps/*/e2e` and `dummy/*/apps/*/e2e`, follows each exported helper
757
+ that calls an opener (`acceptanceBrowser()`) or `startE2eApp`, resolves named deadlines through
758
+ their declarations, and refuses a literal, a missing deadline or a sum without the budget it needs.
759
+
760
+ `As of 2026-10-05`.
724
761
 
725
762
  ## The one assertion that waits
726
763
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/testing",
3
- "version": "24.0.0",
3
+ "version": "25.1.0",
4
4
  "description": "Test harness: cloned template DBs per worker, frozen clock, sealed network, 6 test types",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,26 +29,26 @@
29
29
  "LICENSE"
30
30
  ],
31
31
  "engines": {
32
- "bun": ">=1.4.0"
32
+ "bun": ">=1.4.2"
33
33
  },
34
34
  "scripts": {
35
35
  "typecheck": "tsc --noEmit -p tsconfig.json",
36
36
  "test": "bun test"
37
37
  },
38
38
  "dependencies": {
39
- "@ultimat3/action": "24.0.0",
40
- "@ultimat3/cache": "24.0.0",
41
- "@ultimat3/core": "24.0.0",
42
- "@ultimat3/db": "24.0.0",
43
- "@ultimat3/entity": "24.0.0",
44
- "@ultimat3/http": "24.0.0",
45
- "@ultimat3/i18n": "24.0.0",
46
- "@ultimat3/jobs": "24.0.0",
47
- "@ultimat3/mail": "24.0.0",
48
- "@ultimat3/policy": "24.0.0",
49
- "@ultimat3/query": "24.0.0",
50
- "@ultimat3/realtime": "24.0.0",
51
- "@ultimat3/render": "24.0.0",
52
- "@ultimat3/time": "24.0.0"
39
+ "@ultimat3/action": "25.1.0",
40
+ "@ultimat3/cache": "25.1.0",
41
+ "@ultimat3/core": "25.1.0",
42
+ "@ultimat3/db": "25.1.0",
43
+ "@ultimat3/entity": "25.1.0",
44
+ "@ultimat3/http": "25.1.0",
45
+ "@ultimat3/i18n": "25.1.0",
46
+ "@ultimat3/jobs": "25.1.0",
47
+ "@ultimat3/mail": "25.1.0",
48
+ "@ultimat3/policy": "25.1.0",
49
+ "@ultimat3/query": "25.1.0",
50
+ "@ultimat3/realtime": "25.1.0",
51
+ "@ultimat3/render": "25.1.0",
52
+ "@ultimat3/time": "25.1.0"
53
53
  }
54
54
  }
@@ -42,7 +42,7 @@ export async function authRequest(init: AuthRequestInit = {}): Promise<Parameter
42
42
  );
43
43
  }
44
44
  if (init.bearer !== undefined) headers.set('authorization', `Bearer ${init.bearer}`);
45
- const ctx = http.createRequestContext({
45
+ const ctx = http.requestContext({
46
46
  url,
47
47
  method,
48
48
  role: 'web',
@@ -0,0 +1,112 @@
1
+ // One responsibility: ONE browser shared by the suites of a test process that ask for it by the
2
+ // same key — opened by the first, a holder count per suite, closed by the last out or at the run's
3
+ // end. Opening and closing a browser is `cdp-browser.ts`; this file only decides WHEN.
4
+
5
+ import { afterAll } from 'bun:test';
6
+ import type { E2eBrowser } from './cdp-browser';
7
+ import { E2E_BROWSER_OPEN_MS } from './cdp-browser';
8
+
9
+ export interface E2eBrowserLease {
10
+ /** The shared browser: opened by the first lease of its key, `undefined` with no browser here. */
11
+ readonly opened: Promise<E2eBrowser | undefined>;
12
+ /** This holder is done. Idempotent. The last one out closes it, unless the run closes it at its end. */
13
+ release(): Promise<void>;
14
+ }
15
+
16
+ /** Registers a hook for the end of the run — a preload's `afterAll`, which Bun runs once, last. */
17
+ export type RunEndHook = (hook: () => Promise<void>, timeoutMs: number) => void;
18
+
19
+ export interface E2eBrowserLeases {
20
+ lease(key: string, open: () => Promise<E2eBrowser | undefined>): E2eBrowserLease;
21
+ /** From a PRELOAD: keep every leased browser open until the run ends, then close each. */
22
+ closeAtRunEnd(after: RunEndHook): void;
23
+ }
24
+
25
+ interface Held {
26
+ readonly opening: Promise<E2eBrowser | undefined>;
27
+ holders: number;
28
+ }
29
+
30
+ /** Its open already failed in front of the holder that awaited it: the close has nothing to add. */
31
+ const closeHeld = async (held: Held): Promise<void> => {
32
+ const browser = await held.opening.catch(() => undefined);
33
+ await browser?.close();
34
+ };
35
+
36
+ /**
37
+ * A fresh registry. Why the last holder alone cannot make "last" mean the last SUITE: Bun runs one
38
+ * test file to its end before it loads the next (measured on 1.4.2), so between two suites the
39
+ * count is 0; and `exit` and `beforeExit` never fire under `bun test`, so a browser kept "for the
40
+ * next one" past the last file is a Chrome and a profile left behind. Only a preload's `afterAll`
41
+ * runs after every file — under `--isolate` after each, with a fresh global, which is right too.
42
+ */
43
+ export function createE2eBrowserLeases(): E2eBrowserLeases {
44
+ const held = new Map<string, Held>();
45
+ let runEnd = false;
46
+ return {
47
+ lease(key, open) {
48
+ let shared = held.get(key);
49
+ if (shared === undefined) {
50
+ const made: Held = { opening: open(), holders: 0 };
51
+ held.set(key, made);
52
+ // A failed open is nobody's to share: the next holder opens afresh and meets its own error.
53
+ made.opening.catch(() => {
54
+ if (held.get(key) === made) held.delete(key);
55
+ });
56
+ shared = made;
57
+ }
58
+ const mine = shared;
59
+ mine.holders += 1;
60
+ let released = false;
61
+ return {
62
+ opened: mine.opening,
63
+ async release() {
64
+ if (released) return;
65
+ released = true;
66
+ mine.holders -= 1;
67
+ if (mine.holders > 0 || runEnd) return;
68
+ if (held.get(key) === mine) held.delete(key);
69
+ await closeHeld(mine);
70
+ },
71
+ };
72
+ },
73
+ closeAtRunEnd(after) {
74
+ if (runEnd) return;
75
+ runEnd = true;
76
+ // The deadline of an open: the hook awaits one still in flight before it can close it.
77
+ after(async () => {
78
+ const open = [...held.values()];
79
+ held.clear();
80
+ for (const one of open) await closeHeld(one);
81
+ }, E2E_BROWSER_OPEN_MS);
82
+ },
83
+ };
84
+ }
85
+
86
+ /**
87
+ * On `globalThis` under one `Symbol.for` key: the preload imports this module by path and a suite
88
+ * through `@ultimat3/testing`, and each may hold its own copy — `e2e-browser-handle.ts`'s reason.
89
+ */
90
+ const KEY = Symbol.for('ultimate.e2e.browserLeases');
91
+
92
+ const processLeases = (): E2eBrowserLeases => {
93
+ const known = Reflect.get(globalThis, KEY) as E2eBrowserLeases | undefined;
94
+ if (known !== undefined) return known;
95
+ const made = createE2eBrowserLeases();
96
+ Object.defineProperty(globalThis, KEY, { value: made, configurable: true });
97
+ return made;
98
+ };
99
+
100
+ /**
101
+ * The browser the suites of this process share under `key`; call it in a `beforeAll` and
102
+ * `release()` in the matching `afterAll`. Without `closeE2eBrowsersAtRunEnd` in the preload the
103
+ * last holder out closes it, which is one browser per suite again — never one left running.
104
+ */
105
+ export const leaseE2eBrowser = (
106
+ key: string,
107
+ open: () => Promise<E2eBrowser | undefined>,
108
+ ): E2eBrowserLease => processLeases().lease(key, open);
109
+
110
+ /** The preload's half: `closeE2eBrowsersAtRunEnd()` once, and leased browsers live for the run. */
111
+ export const closeE2eBrowsersAtRunEnd = (after: RunEndHook = afterAll): void =>
112
+ processLeases().closeAtRunEnd(after);
@@ -13,11 +13,52 @@ import type { E2eSession } from './cdp-e2e-session';
13
13
  import { cdpE2eSession } from './cdp-e2e-session';
14
14
  import { CdpBrowserMissingError } from './cdp-errors';
15
15
  import type { LaunchedBrowser } from './cdp-launch';
16
- import { CHROME_CANDIDATES, findChrome, launchChrome } from './cdp-launch';
16
+ import { CHROME_CANDIDATES, findChrome, LAUNCH_BUDGET_MS, launchChrome } from './cdp-launch';
17
+ import { LAUNCHED_CLOSE_MS } from './cdp-launch-attempt';
17
18
 
18
19
  /** How long a launch, a connect or a single CDP call may take. One number, three deadlines. */
19
20
  export const DEFAULT_CDP_TIMEOUT_MS = 30_000;
20
21
 
22
+ /**
23
+ * Sequential deadlines `cdpE2eSession` spends before it answers — `Target.setDiscoverTargets`,
24
+ * then `Target.setAutoAttach` — and `newTab()` spends per tab: `Target.createTarget`, then the
25
+ * attach that publishes it. Counts of calls in `cdp-e2e-session.ts`, held to it by
26
+ * `cdp-browser.test.ts` over a fake connection.
27
+ */
28
+ export const SESSION_SETUP_DEADLINES = 2;
29
+ export const TAB_OPEN_DEADLINES = 2;
30
+
31
+ /** The longest one more `session.newTab()` is designed to take, at the default per-call deadline. */
32
+ export const E2E_TAB_OPEN_MS = TAB_OPEN_DEADLINES * DEFAULT_CDP_TIMEOUT_MS;
33
+
34
+ /**
35
+ * The longest `openE2eBrowser()` is DESIGNED to take, at the default deadline: the whole launch
36
+ * budget — both starts and their reaps — then the session's setup and the first tab. THE deadline
37
+ * for a `beforeAll` or `afterAll` that opens or closes a browser: one below it is killed by Bun
38
+ * before the relaunch, and the failure that names the slow step never surfaces. Derived, never
39
+ * restated: `e2e-browser-hooks.test.ts` refuses a browser hook whose deadline does not start here.
40
+ */
41
+ export const E2E_BROWSER_OPEN_MS =
42
+ LAUNCH_BUDGET_MS + SESSION_SETUP_DEADLINES * DEFAULT_CDP_TIMEOUT_MS + E2E_TAB_OPEN_MS;
43
+
44
+ /**
45
+ * The longest `E2eBrowser.close()` is DESIGNED to take — the launched browser's close, every step
46
+ * bounded (`LAUNCHED_CLOSE_MS`). THE deadline for a hook that closes a browser it already holds: a
47
+ * hook left at Bun's 5 s default is killed mid-close, and when it is the run's last the process
48
+ * exits with the profile half-removed — the `x-e2e-chrome-*` directories found leaked in `/tmp`.
49
+ */
50
+ export const E2E_BROWSER_CLOSE_MS = LAUNCHED_CLOSE_MS;
51
+
52
+ /**
53
+ * Sequential deadlines one `tab.goto()` spends: the load event raced against `Page.navigate`'s
54
+ * reply (one deadline, two waiters), then the two reads of the document (`cdp-e2e-page.ts`). Held
55
+ * to the method by `cdp-browser.test.ts` over a fake connection, as the tab's count is.
56
+ */
57
+ export const GOTO_DEADLINES = 3;
58
+
59
+ /** The longest one `tab.goto()` is designed to take, at the default per-call deadline. */
60
+ export const E2E_GOTO_MS = GOTO_DEADLINES * DEFAULT_CDP_TIMEOUT_MS;
61
+
21
62
  export interface E2eBrowser {
22
63
  /** The first tab — what `installE2eDriver({ page })` drives. */
23
64
  readonly page: E2eTab;
@@ -11,7 +11,7 @@
11
11
  // small ones, on Bun's native `WebSocket`, with no dependency to add to a repo whose first
12
12
  // non-negotiable is that Bun's natives replace most of them.
13
13
 
14
- import { assert } from '@ultimat3/core';
14
+ import { assert, stringField } from '@ultimat3/core';
15
15
  import { CdpCallFailedError, CdpTimeoutError } from './cdp-errors';
16
16
  import { wireWatch } from './cdp-wire-watch';
17
17
 
@@ -99,6 +99,16 @@ export interface CdpConnectionOptions {
99
99
  readonly timeoutMs: number;
100
100
  }
101
101
 
102
+ /**
103
+ * What went wrong, in the socket's own words when it has any. Bun hands `onerror` an `ErrorEvent`
104
+ * whose `message` is the only place the reason lives — "Failed to connect", "Expected 101 status
105
+ * code" — and a refusal that drops it names the step and not the cause.
106
+ */
107
+ const failure = (event: unknown, what: string): string => {
108
+ const message = stringField(event, 'message');
109
+ return message === undefined || message === '' ? what : `${what}: ${message}`;
110
+ };
111
+
102
112
  /**
103
113
  * A remote browser over its WebSocket url. NOT the e2e driver's wire: measured in the dummy's
104
114
  * `offline-feed` suite on Bun 1.4.0, Bun's WebSocket client handed `onmessage` text spliced out of
@@ -125,11 +135,14 @@ export async function cdpConnect(options: CdpConnectionOptions): Promise<CdpConn
125
135
  };
126
136
  // `onerror` is the handshake's only until `listen` below reassigns it: a failure BEFORE open
127
137
  // has no pending call to abandon, and rejecting is the only way the caller hears about it.
128
- socket.onerror = (): void => {
138
+ socket.onerror = (event: Event): void => {
129
139
  clearTimeout(timer);
130
140
  socket.close();
131
141
  reject(
132
- new CdpCallFailedError({ method: 'connect', detail: 'the browser refused the connection' }),
142
+ new CdpCallFailedError({
143
+ method: 'connect',
144
+ detail: failure(event, 'the browser refused the connection'),
145
+ }),
133
146
  );
134
147
  };
135
148
  });
@@ -142,7 +155,8 @@ export async function cdpConnect(options: CdpConnectionOptions): Promise<CdpConn
142
155
  handlers.message(typeof event.data === 'string' ? event.data : '');
143
156
  };
144
157
  socket.onclose = (): void => handlers.closed('the browser closed the CDP connection');
145
- socket.onerror = (): void => handlers.closed('the CDP connection failed');
158
+ socket.onerror = (event: Event): void =>
159
+ handlers.closed(failure(event, 'the CDP connection failed'));
146
160
  },
147
161
  };
148
162
  return cdpConnectOver(transport, options.timeoutMs);