@ultimat3/testing 23.0.0 → 25.0.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 (60) hide show
  1. package/CLAUDE.md +20 -20
  2. package/README.md +95 -13
  3. package/package.json +16 -16
  4. package/src/auth-request.ts +1 -1
  5. package/src/budget-store-conformance.ts +81 -0
  6. package/src/cdp-browser-lease.ts +112 -0
  7. package/src/cdp-browser.ts +47 -11
  8. package/src/cdp-connection.ts +41 -10
  9. package/src/cdp-e2e-session.ts +130 -13
  10. package/src/cdp-errors.ts +129 -8
  11. package/src/cdp-launch-attempt.ts +224 -0
  12. package/src/cdp-launch-candidates.ts +62 -0
  13. package/src/cdp-launch-profile.ts +106 -0
  14. package/src/cdp-launch-reap.ts +68 -0
  15. package/src/cdp-launch-wire.ts +171 -0
  16. package/src/cdp-launch.ts +92 -140
  17. package/src/cdp-offline-script.ts +33 -3
  18. package/src/cdp-wire-watch.ts +113 -0
  19. package/src/define-island-states.ts +47 -8
  20. package/src/determinism.ts +35 -6
  21. package/src/e2e-app.ts +17 -1
  22. package/src/e2e-errors.ts +14 -3
  23. package/src/e2e-locale.ts +12 -21
  24. package/src/e2e-page.ts +12 -26
  25. package/src/e2e-run.ts +22 -12
  26. package/src/e2e-service-worker.ts +92 -0
  27. package/src/e2e-spawn.ts +58 -17
  28. package/src/errors.ts +34 -66
  29. package/src/factories.ts +4 -6
  30. package/src/file-boundary.ts +12 -7
  31. package/src/fixture-clock.ts +1 -1
  32. package/src/fixture-island.ts +50 -98
  33. package/src/fixture-jobs.ts +16 -12
  34. package/src/fixture-mail.ts +3 -3
  35. package/src/fixture-network.ts +1 -1
  36. package/src/fixture-statements.ts +1 -1
  37. package/src/fixture-subscribe.ts +3 -3
  38. package/src/framework-fixtures.ts +13 -13
  39. package/src/free-port.ts +46 -0
  40. package/src/harness.ts +2 -1
  41. package/src/index.ts +29 -10
  42. package/src/island-dom.ts +57 -13
  43. package/src/island-globals.ts +73 -0
  44. package/src/island-html-entities.ts +27 -0
  45. package/src/island-scratch.ts +105 -0
  46. package/src/island-state-errors.ts +16 -0
  47. package/src/island-states.ts +23 -0
  48. package/src/job-driver-checks.ts +177 -0
  49. package/src/job-driver-conformance.ts +21 -0
  50. package/src/live-node.ts +6 -4
  51. package/src/preload.ts +2 -8
  52. package/src/quiet-logs.ts +1 -1
  53. package/src/registry-leak-error.ts +81 -0
  54. package/src/registry-leak-guard.ts +7 -4
  55. package/src/registry-snapshot.ts +41 -2
  56. package/src/render-view.ts +3 -3
  57. package/src/sealed-network.ts +50 -5
  58. package/src/sealed-sockets.ts +59 -0
  59. package/src/template-db.ts +8 -5
  60. 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<…>` |
@@ -72,20 +72,21 @@ is its own entry point and not part of the barrel.
72
72
  | Teardown restores, never uninstalls | `describeApp`/`testApp` capture the seal and the determinism snapshot before booting and put those back |
73
73
  | Teardown is a `finally` | an `app.close()` that rejects still reaches `db.drop()` and still restores the process state; the first failure is what the caller sees. |
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
- | A found template is not a migrated one | `template-db.ts` tolerates "already exists" for the `CREATE DATABASE` alone. `config.migrate` runs unconditionally and un-swallowed |
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 |
81
81
  | Shared examples | `behavesLike` calls `describe`, so it goes at declaration scope; bun rejects a `describe` inside a test body |
82
+ | A port's contract is shared examples | `jobDriverConformance`, `budgetStoreConformance` — `behavesLike(…, () => yours)` |
82
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. |
83
84
  | `mountIsland` AWAITS `mount` | `IslandEntry['mount']` returns `unknown`, not `void`, and the call is awaited |
84
85
  | Dispose STOPS the island, then restores the globals | when `mount` resolved to a function, `[Symbol.dispose]` calls it BEFORE `restore()` |
85
- | 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 |
86
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` |
87
- | `classList` is the class attribute | not a list of its own, so `classList.toggle` — what the compiler emits INLINE for `classList={{ … }}`, with no runtime helper in front of it — and `className` can never answer one element two ways. |
88
- | 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 |
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. Every listener per type runs; removal is by function identity |
89
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 |
90
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 |
91
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 |
@@ -102,16 +103,14 @@ is its own entry point and not part of the barrel.
102
103
  | Loose in, strict out | `findIslandStates` resolves `Settings`, `settings`, `settings.island.tsx` and the full path to one manifest, and refuses a name nothing answers to by listing EVERY valid one |
103
104
  | The disk check is not in `defineIslandStates` | a declaration evaluates wherever it is imported from, so a rule that reads the filesystem at import time fails on the cwd rather than on the path. |
104
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. |
105
- | The chunk is imported from a temp FILE | named by its SHA-256, so an edit is a new module and nothing is left in the app. Never a `data:` URL: `bun test --coverage` panics importing one past ~4 kB (Bun 1.4.0); `fixture-island.test.ts` pins it |
106
- | The scratch directory is lazy and removed | made on the first mount, deleted on `exit`; never at module scope — this module is on the `.` barrel |
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 |
109
- | Which command shards | `bun test` is one process on one database, and that is still what a scaffolded app's `test` script runs. |
110
-
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
+ | Which command shards | `bun test` is one process on one database — what a scaffolded app's `test` script runs |
111
110
 
112
111
  ## The frozen instant and the seed are screened
113
112
 
114
- `installDeterminism({ now })`, `setFrozenClock`, `frozenClock` and `advanceClock` go through one
113
+ `installDeterminism({ now })`, `setFrozenClock`, `withFrozenClock` and `advanceClock` go through one
115
114
  `instantMs`: an unreadable instant would make `Date.now()` answer `NaN` for every later file. The
116
115
  seed is screened too — `seed >>> 0` maps `NaN`, `0.5` and `2 ** 32` onto seed 0 silently.
117
116
  `finiteOption`/`finiteCount` from `@ultimat3/core` are the one form; `determinism-bounds.test.ts`
@@ -145,11 +144,12 @@ lives beside the `PageLike` it implements. `cli` imports it over the declared `c
145
144
  | `e2e-errors.ts` | one constructor per refusal |
146
145
  | `e2e-dom-fixture.ts` | a document small enough to hold in a test and real enough to RUN the expressions above |
147
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 |
148
- | `cdp-launch.ts` | which Chrome, and starting it — the candidate list, the flags, and the endpoint read off its stderr |
149
- | `cdp-connection.ts` | CDP over Bun's own `WebSocket`: request framing, reply correlation by `id`, one-shot event waiters, the per-call deadline |
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 in its own group; on no answer a full reap (group, then profile + singleton dir: `cdp-launch-profile.ts`). ONE awaited `close()` |
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 |
152
- | `e2e-app.ts` | `startE2eApp({ root, mode, seed })`: reset + seed + spawn on a THROWAWAY `ULTIMATE_STATE_DIR` and free ports, `/readyz`-gated, spawned through the app's own `@ultimat3/cli` bin (`xBin`); `stop()` removes the directory |
152
+ | `e2e-app.ts` | `startE2eApp({ root, mode, seed })`: reset + seed + spawn on a THROWAWAY `ULTIMATE_STATE_DIR` and free ports a browser opens (`free-port.ts`), `/readyz`-gated, spawned through the app's own `@ultimat3/cli` bin (`xBin`); `stop()` removes the directory |
153
153
  | `failure-context.ts` | a FAILED test's message ends with the e2e app's last 40 log lines (≤ 4 kB): `e2e-run.ts` installs `app.log()`, `fixtureTest` and `e2eTest` append it. Never on a pass |
154
154
  | `e2e-preload.ts` + `e2e-browser-handle.ts` | the `e2e` step's preload (`@ultimat3/testing/e2e-preload`): with `ULTIMATE_E2E_ROOT` set it spawns the app, opens ONE browser, installs its first tab, and publishes `e2eBrowser()`, `e2eApp()`, `e2eBaseUrl()` |
155
155
  | `packages/cli/src/verify-e2e.ts` | `withE2eApp` (cli's): in an app on a machine with Chrome, the `e2e` step runs the suite with that preload; otherwise exactly as before |
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
@@ -217,6 +218,14 @@ It registers `page` and nothing else. `budget`, `signIn` and `deploy` keep refus
217
218
  browser's own network state and a second build id are not things a page port can answer for, and a
218
219
  fixture that silently no-opped would make the assertion after it read as proof.
219
220
 
221
+ **A browser call that is never answered says why.** `X_CDP_TIMEOUT` from the connection carries
222
+ `meta.reading` — `target-gone` (session detached, target destroyed or crashed), `lost-in-transport`
223
+ (`meta.framesDropped` frames arrived unparseable after the call) or `no-answer` — beside
224
+ `framesArrived`, `lastFrames` (the last 8), `transportOpen` and `navigations`; `cause` is the
225
+ one-line reading. `E2eSession.offline(false)` rejects with `X_CDP_CALL_FAILED` when a page that is
226
+ still there refuses to take `navigator.onLine` back: the restore is attempted regardless, and the
227
+ page stays in the set.
228
+
220
229
  Each helper prefixes the test name with its type (`job · onboards an org`), which is what
221
230
  `bun test --test-name-pattern "job · "` selects — the six lines of `x verify` come from the tests
222
231
  themselves, not from a directory convention.
@@ -271,14 +280,36 @@ failure line reads `publishPost > behaves like an authenticated action > denies
271
280
  — which subject, and which shared rule. `behavesLike` calls `describe`, so it goes at declaration
272
281
  scope, never inside a test body.
273
282
 
283
+ Two ship, for the ports an app may implement itself — a `JobDriver` and `@ultimat3/ai`'s
284
+ `BudgetStore`:
285
+
286
+ ```ts
287
+ import { memoryBudgetStore } from '@ultimat3/ai';
288
+ import { memoryJobDriver } from '@ultimat3/jobs';
289
+ import { behavesLike, budgetStoreConformance, describe, jobDriverConformance } from '@ultimat3/testing';
290
+
291
+ // The shipped ports pass; put your own driver or store where these are.
292
+ describe('my queue', () => behavesLike(jobDriverConformance, () => memoryJobDriver()));
293
+ describe('my budget store', () => behavesLike(budgetStoreConformance, () => memoryBudgetStore()));
294
+ ```
295
+
296
+ | Suite | Holds a store to |
297
+ |---|---|
298
+ | `jobDriverConformance` | claim once · ack/nack fenced on the claim · a live key dedupes · `queues: []` refused · dead-letter never re-claimed · a lapsed lease re-claimed, the loser unable to renew · a lease lapsed on the final attempt buried, reported through `onExhausted` once, `failed` under `dropExhausted` |
299
+ | `budgetStoreConformance` | 24 concurrent `take`s on one key never overspend · a refused take spends nothing · negative `add` · `reset(key)` scoped |
300
+
301
+ The subject is called once per test. Each check claims from a queue — or counts under a key — of
302
+ its own, so one shared store is enough.
303
+
274
304
  ## Parallel databases
275
305
 
276
306
  ```ts
277
307
  const db = await acquireWorkerDatabase({ adminUrl, migrate });
278
308
  ```
279
309
 
280
- The first worker creates the template under a Postgres advisory lock and migrates it once; every
281
- worker then clones it copy-on-write. With no Postgres configured it falls back to PGlite, so
310
+ The first worker creates the template under a Postgres advisory lock and migrates it; every
311
+ worker then clones it copy-on-write, under the same lock — a clone waits only 5 s for a session on
312
+ the template to leave, and the next worker's migration holds one. With no Postgres configured it falls back to PGlite, so
282
313
  `bun test` works on a laptop with nothing installed.
283
314
 
284
315
  **The gate shards; a bare `bun test` does not.** `As of 2026-08`:
@@ -286,9 +317,9 @@ worker then clones it copy-on-write. With no Postgres configured it falls back t
286
317
  | Command | Processes | Worker ids | Databases |
287
318
  |---|---|---|---|
288
319
  | `bun test` (what a scaffolded app's `test` script still runs) | 1 | `0` | one |
289
- | `x verify` (`unit`, `contract`, `job`, `eval`; `live` and `e2e` stay serial) | `clamp(round(cpus * 1.5), 2, 8)` | `0..N-1`, from `ULTIMATE_TEST_WORKER` | N |
320
+ | `x verify` (`unit`, `contract`, `job`, `eval`; `live` and `e2e` stay serial) | `clamp(round(cpus * 1.5), 2, 8)` | `1..N`, from Bun's own `BUN_TEST_WORKER_ID` — `0..N-1` from `ULTIMATE_TEST_WORKER` in its coverage run | N |
290
321
  | `bun test --parallel[=N]` | N (default: CPU count) | `1..N`, from Bun's own `BUN_TEST_WORKER_ID` | N |
291
- | `x test --workers N` | N | `0..N-1`, from `ULTIMATE_TEST_WORKER` | N |
322
+ | `x test --workers N` | N | `1..N`, from Bun's own `BUN_TEST_WORKER_ID` | N |
292
323
 
293
324
  `ULTIMATE_TEST_WORKER` is read first so a runner-assigned shard always beats the index Bun assigns
294
325
  its own `--parallel` worker — measured on Bun 1.3.14, `--parallel` populates `BUN_TEST_WORKER_ID`
@@ -301,7 +332,7 @@ and `JEST_WORKER_ID` itself, so that precedence is load-bearing rather than defe
301
332
 
302
333
  | State | Owned by | What teardown does |
303
334
  |---|---|---|
304
- | 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 |
305
336
  | the frozen instant, `Math.random`, `globalThis.Date` | the preload (`ULTIMATE_TEST_NOW` / `ULTIMATE_TEST_SEED`) | `restoreCapturedDeterminism(captureDeterminism())` around the boot |
306
337
  | mocks, allow-listed hosts, the seen list | the boot | `resetNetwork()` |
307
338
  | the cloned worker database | the boot | `db.drop()`, in a `finally` — a rejecting `app.close()` reaches it |
@@ -319,11 +350,27 @@ X_TEST_NETWORK_SEALED
319
350
  fix: mockFetch('https://api.stripe.com/v1/charges', () => new Response('{}')) — or allowHost('api.stripe.com') if it must be real
320
351
  ```
321
352
 
322
- 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
323
354
  core's `markListening()`, so a test may call its own `handle.url()` on a kernel-assigned port with
324
355
  the seal fully on. Unsealing (`ULTIMATE_TEST_ALLOW_NET=1`) stays reserved for a deliberate live
325
356
  integration — never for a socket test.
326
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
+
327
374
  ## Rendering a page or a component
328
375
 
329
376
  A page is a function of props and a route is a module, so a unit test renders either in process —
@@ -361,7 +408,7 @@ unitTest('the shell keeps the page in <main>', async () => {
361
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 |
362
409
  | **`view.html`** | the markup as the document carries it |
363
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 |
364
- | **`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 |
365
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 |
366
413
 
367
414
  `@ultimat3/render` is imported inside the two functions, so the barrel still loads no renderer.
@@ -678,6 +725,40 @@ This package still owns the vocabulary, the expansion and the refusals, and owns
678
725
  capture, the harness page and the crop are `@ultimat3/cli`'s, because `cli → testing` is the
679
726
  declared edge and the reverse is a `bun run boundaries` failure.
680
727
 
728
+ ## Launching Chrome
729
+
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.
738
+
739
+ | Export | What it is |
740
+ |---|---|
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` |
742
+ | `LAUNCH_ATTEMPTS` | `2` — an unanswered start is reaped and started once more on a fresh profile. Never more |
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`.
761
+
681
762
  ## The one assertion that waits
682
763
 
683
764
  ```ts
@@ -707,7 +788,7 @@ fails on a page that simply has not painted yet.
707
788
  ## Errors
708
789
 
709
790
  `X_TEST_NETWORK_SEALED` `X_TEST_DB_UNAVAILABLE` `X_TEST_NONDETERMINISTIC` `X_TEST_FIXTURE_UNKNOWN`
710
- `X_TEST_FACTORY_TRAIT_UNKNOWN` `X_TEST_FACTORY_NOT_PERSISTED` `X_TEST_REGISTRY_LEAK`
791
+ `X_TEST_APP_NOT_BOOTED` `X_TEST_FACTORY_TRAIT_UNKNOWN` `X_TEST_FACTORY_NOT_PERSISTED` `X_TEST_REGISTRY_LEAK`
711
792
  `X_TEST_ISLAND_NOT_BUILT` `X_TEST_ISLAND_NO_MOUNT` `X_TEST_ISLAND_SELECTOR_UNSUPPORTED`
712
793
  `X_TEST_ISLAND_STATES_EMPTY` `X_TEST_ISLAND_STATE_UNKNOWN`
713
794
  `X_TEST_ISLAND_STATES_NOT_PURE` `X_TEST_ISLAND_STATES_MISSING_FILE` `X_TEST_ISLAND_STATES_UNKNOWN`
@@ -722,6 +803,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
722
803
 
723
804
  | Class | Code | Declared in |
724
805
  |---|---|---|
806
+ | `AppNotBootedError` | `X_TEST_APP_NOT_BOOTED` | `src/errors.ts` |
725
807
  | `FixtureUnavailableError` | `X_TEST_FIXTURE_UNAVAILABLE` | `src/errors.ts` |
726
808
  | `NetworkOfflineError` | `X_TEST_NETWORK_OFFLINE` | `src/errors.ts` |
727
809
  | `NetworkSealedError` | `X_TEST_NETWORK_SEALED` | `src/errors.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/testing",
3
- "version": "23.0.0",
3
+ "version": "25.0.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": "23.0.0",
40
- "@ultimat3/cache": "23.0.0",
41
- "@ultimat3/core": "23.0.0",
42
- "@ultimat3/db": "23.0.0",
43
- "@ultimat3/entity": "23.0.0",
44
- "@ultimat3/http": "23.0.0",
45
- "@ultimat3/i18n": "23.0.0",
46
- "@ultimat3/jobs": "23.0.0",
47
- "@ultimat3/mail": "23.0.0",
48
- "@ultimat3/policy": "23.0.0",
49
- "@ultimat3/query": "23.0.0",
50
- "@ultimat3/realtime": "23.0.0",
51
- "@ultimat3/render": "23.0.0",
52
- "@ultimat3/time": "23.0.0"
39
+ "@ultimat3/action": "25.0.0",
40
+ "@ultimat3/cache": "25.0.0",
41
+ "@ultimat3/core": "25.0.0",
42
+ "@ultimat3/db": "25.0.0",
43
+ "@ultimat3/entity": "25.0.0",
44
+ "@ultimat3/http": "25.0.0",
45
+ "@ultimat3/i18n": "25.0.0",
46
+ "@ultimat3/jobs": "25.0.0",
47
+ "@ultimat3/mail": "25.0.0",
48
+ "@ultimat3/policy": "25.0.0",
49
+ "@ultimat3/query": "25.0.0",
50
+ "@ultimat3/realtime": "25.0.0",
51
+ "@ultimat3/render": "25.0.0",
52
+ "@ultimat3/time": "25.0.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,81 @@
1
+ // The `BudgetStore` contract of `@ultimat3/ai` as shared examples, for a store of your own (Redis,
2
+ // SQL): `behavesLike(budgetStoreConformance, () => store)`. The race is the point — `take` is the
3
+ // one atomic step, and a store that awaits between its read and its write overspends under it.
4
+ // Structural, so this package takes no dependency on `ai`: a `BudgetStore` is assignable.
5
+
6
+ import { expect, test } from 'bun:test';
7
+ import type { SharedExamples } from './shared-examples';
8
+ import { sharedExamples } from './shared-examples';
9
+
10
+ type Maybe<T> = T | Promise<T>;
11
+
12
+ /** The four members of `@ultimat3/ai`'s `BudgetStore`, structurally. */
13
+ export interface BudgetStoreLike {
14
+ spent(key: string): Maybe<number>;
15
+ add(key: string, tokens: number): Maybe<void>;
16
+ take(key: string, tokens: number, limit: number): Maybe<{ readonly taken: boolean }>;
17
+ reset(key?: string): Maybe<void>;
18
+ }
19
+
20
+ export interface BudgetStoreCheck {
21
+ readonly name: string;
22
+ run(store: BudgetStoreLike): Promise<void>;
23
+ }
24
+
25
+ /** Concurrent takers, and the ceiling only some of them fit under. */
26
+ const BUDGET_RACE_TAKERS = 24;
27
+ const RACE_LIMIT = 5;
28
+
29
+ let minted = 0;
30
+ const keyOf = (): string => {
31
+ minted += 1;
32
+ return `conformance:${process.pid.toString(36)}:${minted.toString(36)}`;
33
+ };
34
+
35
+ export const BUDGET_STORE_CHECKS: readonly BudgetStoreCheck[] = [
36
+ {
37
+ name: `${BUDGET_RACE_TAKERS} concurrent takes against one key never overspend the limit`,
38
+ async run(store) {
39
+ const key = keyOf();
40
+ const takes = Array.from({ length: BUDGET_RACE_TAKERS }, async () =>
41
+ store.take(key, 1, RACE_LIMIT),
42
+ );
43
+ const taken = (await Promise.all(takes)).filter((each) => each.taken).length;
44
+ expect([taken, await store.spent(key)]).toEqual([RACE_LIMIT, RACE_LIMIT]);
45
+ },
46
+ },
47
+ {
48
+ name: 'a refused take spends nothing, and a take that fits exactly is taken',
49
+ async run(store) {
50
+ const key = keyOf();
51
+ expect((await store.take(key, 3, 4)).taken).toBe(true);
52
+ expect((await store.take(key, 2, 4)).taken).toBe(false);
53
+ expect(await store.spent(key)).toBe(3);
54
+ expect((await store.take(key, 1, 4)).taken).toBe(true);
55
+ expect(await store.spent(key)).toBe(4);
56
+ },
57
+ },
58
+ {
59
+ name: 'add takes a negative credit, and reset(key) clears that key alone',
60
+ async run(store) {
61
+ const [key, other] = [keyOf(), keyOf()];
62
+ await store.add(key, 5);
63
+ await store.add(key, -2);
64
+ await store.add(other, 1);
65
+ expect(await store.spent(key)).toBe(3);
66
+ await store.reset(key);
67
+ expect([await store.spent(key), await store.spent(other)]).toEqual([0, 1]);
68
+ },
69
+ },
70
+ ];
71
+
72
+ export const budgetStoreConformance: SharedExamples<Maybe<BudgetStoreLike>> = sharedExamples(
73
+ 'a budget store',
74
+ (subject) => {
75
+ for (const check of BUDGET_STORE_CHECKS) {
76
+ test(check.name, async () => {
77
+ await check.run(await subject());
78
+ });
79
+ }
80
+ },
81
+ );
@@ -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);