@ultimat3/testing 24.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.
- package/CLAUDE.md +12 -12
- package/README.md +55 -18
- package/package.json +16 -16
- package/src/auth-request.ts +1 -1
- package/src/cdp-browser-lease.ts +112 -0
- package/src/cdp-browser.ts +42 -1
- package/src/cdp-connection.ts +18 -4
- package/src/cdp-e2e-session.ts +120 -12
- package/src/cdp-errors.ts +25 -9
- package/src/cdp-launch-attempt.ts +112 -80
- package/src/cdp-launch-candidates.ts +62 -0
- package/src/cdp-launch-profile.ts +106 -0
- package/src/cdp-launch-reap.ts +68 -0
- package/src/cdp-launch-wire.ts +171 -0
- package/src/cdp-launch.ts +47 -22
- package/src/define-island-states.ts +47 -8
- package/src/determinism.ts +2 -2
- package/src/e2e-app.ts +14 -1
- package/src/e2e-errors.ts +14 -3
- package/src/e2e-locale.ts +12 -21
- package/src/e2e-page.ts +12 -26
- package/src/e2e-run.ts +21 -11
- package/src/e2e-service-worker.ts +92 -0
- package/src/e2e-spawn.ts +57 -9
- package/src/errors.ts +20 -65
- package/src/fixture-clock.ts +1 -1
- package/src/fixture-island.ts +34 -55
- package/src/fixture-jobs.ts +7 -7
- package/src/fixture-mail.ts +3 -3
- package/src/fixture-network.ts +1 -1
- package/src/fixture-statements.ts +1 -1
- package/src/fixture-subscribe.ts +3 -3
- package/src/framework-fixtures.ts +13 -13
- package/src/index.ts +17 -10
- package/src/island-dom.ts +49 -9
- package/src/island-globals.ts +73 -0
- package/src/island-scratch.ts +67 -3
- package/src/island-state-errors.ts +16 -0
- package/src/island-states.ts +23 -0
- package/src/live-node.ts +4 -4
- package/src/quiet-logs.ts +1 -1
- package/src/registry-leak-error.ts +81 -0
- package/src/registry-leak-guard.ts +4 -3
- package/src/registry-snapshot.ts +19 -1
- package/src/render-view.ts +3 -3
- package/src/sealed-network.ts +45 -4
- package/src/sealed-sockets.ts +59 -0
- package/src/template-db.ts +2 -2
- 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 | `
|
|
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`
|
|
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
|
|
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`)
|
|
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
|
|
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 `
|
|
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 | `
|
|
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
|
|
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.
|
|
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
|
|
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`, `
|
|
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
|
|
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
|
|
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
|
|
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 `
|
|
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 `
|
|
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 {
|
|
287
|
-
import {
|
|
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, () =>
|
|
292
|
-
describe('my budget store', () => behavesLike(budgetStoreConformance, () =>
|
|
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: `
|
|
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(
|
|
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`
|
|
714
|
-
start a browser with. Driven over `--remote-debugging-pipe
|
|
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
|
-
| `
|
|
721
|
-
| `
|
|
722
|
-
|
|
723
|
-
`
|
|
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": "
|
|
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.
|
|
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": "
|
|
40
|
-
"@ultimat3/cache": "
|
|
41
|
-
"@ultimat3/core": "
|
|
42
|
-
"@ultimat3/db": "
|
|
43
|
-
"@ultimat3/entity": "
|
|
44
|
-
"@ultimat3/http": "
|
|
45
|
-
"@ultimat3/i18n": "
|
|
46
|
-
"@ultimat3/jobs": "
|
|
47
|
-
"@ultimat3/mail": "
|
|
48
|
-
"@ultimat3/policy": "
|
|
49
|
-
"@ultimat3/query": "
|
|
50
|
-
"@ultimat3/realtime": "
|
|
51
|
-
"@ultimat3/render": "
|
|
52
|
-
"@ultimat3/time": "
|
|
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
|
}
|
package/src/auth-request.ts
CHANGED
|
@@ -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.
|
|
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);
|
package/src/cdp-browser.ts
CHANGED
|
@@ -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;
|
package/src/cdp-connection.ts
CHANGED
|
@@ -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({
|
|
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 =>
|
|
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);
|