@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
@@ -3,13 +3,14 @@
3
3
  // packages are tier 5, so importing it here would be the reverse of the one declared `cli → testing`
4
4
  // edge. A structural seam keeps the direction honest and survives the bundler changing underneath.
5
5
 
6
- import { mkdtempSync, rmSync } from 'node:fs';
7
- import { tmpdir } from 'node:os';
8
6
  import { basename, join } from 'node:path';
9
7
  import { islandMountMissing, islandNotBuilt } from './errors';
8
+ import { onFileBoundary } from './file-boundary';
10
9
  import { createIslandDocument, FakeElement, handlerFor, parseHtml } from './island-dom';
10
+ import { installGlobals } from './island-globals';
11
11
  import type { ResizeInput } from './island-observers';
12
12
  import { deliverResize } from './island-observers';
13
+ import { createScratchDir, removeScratchDir } from './island-scratch';
13
14
 
14
15
  /** The two fields a mounted island needs from a chunk. Anything else a bundler grows — a CSS
15
16
  * artifact, a source map, a dev/production flag — is invisible here on purpose. */
@@ -139,83 +140,13 @@ function entryOf(module: unknown, file: string): IslandEntry {
139
140
  return { mount: mount as IslandEntry['mount'] };
140
141
  }
141
142
 
142
- let moduleDir: string | undefined;
143
-
144
- /**
145
- * One directory per process, outside the app under test — so no test leaves a `.mjs` behind. Two
146
- * properties the module-scope `mkdtempSync` it replaces had neither of:
147
- *
148
- * LAZY. This module is on the `.` barrel, so importing `@ultimat3/testing` for `expect` alone
149
- * created a directory — in every test process in the repo, whether or not it ever mounts anything.
150
- *
151
- * REMOVED. `exit` and `rmSync`, because an exit handler runs synchronously and nothing else covers
152
- * every path: `mountIsland` restores and rethrows on a failed mount, so that run never reaches the
153
- * `Disposable`, and the directory is per PROCESS while the disposable is per mount.
154
- */
155
- function moduleDirPath(): string {
156
- const existing = moduleDir;
157
- if (existing !== undefined) return existing;
158
- const created = mkdtempSync(join(tmpdir(), 'ultimate-island-'));
159
- process.on('exit', () => {
160
- rmSync(created, { recursive: true, force: true });
161
- });
162
- moduleDir = created;
163
- return created;
164
- }
165
-
166
- /**
167
- * The scratch root, or `undefined` when nothing has been mounted. Read by the leak test, which
168
- * asserts both halves of the above from a CHILD process — the only place where "this process
169
- * created no directory" and "the directory is gone afterwards" are both observable.
170
- */
171
- export const islandModuleDir = (): string | undefined => moduleDir;
172
-
173
- /**
174
- * A temp file named by the chunk's own hash, NOT a `data:` URL. The URL form read better and
175
- * crashed the coverage reporter: `bun test --coverage` panics with `range end index N out of range
176
- * for slice of length 4096` on `import()` of any `data:` module whose URL exceeds ~4 kB, and an
177
- * island chunk is 12-55 kB. Measured on Bun 1.4.0, threshold at a URL length of 4032; without
178
- * `--coverage` the same import is fine, which is why only the per-package CI job ever saw it.
179
- *
180
- * The property the URL form was chosen for survives: the name is derived from the bytes, so an
181
- * edited island is a different module rather than a cache hit on the same path.
182
- */
183
- const modulePathFor = (code: string): string =>
184
- join(moduleDirPath(), `${Bun.SHA256.hash(code, 'hex').slice(0, 16)}.mjs`);
185
-
186
143
  /**
187
- * DESCRIPTORS, not values, and all-or-nothing.
188
- *
189
- * A saved value cannot tell "no such global" from "a global holding `undefined`", so a teardown
190
- * reading one deletes both — and `key in globalThis` flips behind whoever owned it. It also cannot
191
- * put an accessor back as an accessor.
192
- *
193
- * The rollback is the half with teeth. This runs BEFORE `mountIsland`'s own `try`, so an
194
- * assignment that throws — a getter-only own global among the caller's `globals` — would leave the
195
- * fake `document` already installed ahead of it for the whole rest of the process, which is the
196
- * exact leak the `catch` below exists to prevent.
144
+ * A temp FILE named by the chunk's own hash, NOT a `data:` URL: `bun test --coverage` panics on
145
+ * `import()` of a `data:` module past ~4 kB (Bun 1.4.0) and an island chunk is 12-55 kB. The file
146
+ * sits in the mount's OWN directory (`island-scratch.ts`), so a mount is a fresh module instance.
197
147
  */
198
- function installGlobals(values: Readonly<Record<string, unknown>>): () => void {
199
- const host = globalThis as unknown as Record<string, unknown>;
200
- const saved = Object.keys(values).map(
201
- (key) => [key, Object.getOwnPropertyDescriptor(host, key)] as const,
202
- );
203
- const restore = (): void => {
204
- for (const [key, descriptor] of saved) {
205
- if (descriptor === undefined) delete host[key];
206
- else Object.defineProperty(host, key, descriptor);
207
- }
208
- };
209
- try {
210
- // Assignment, not `defineProperty`: a global with a setter is meant to see the write, and
211
- // `Object.assign` over the lot would report the same failure with nothing rolled back.
212
- for (const [key, value] of Object.entries(values)) host[key] = value;
213
- } catch (error) {
214
- restore();
215
- throw error;
216
- }
217
- return restore;
218
- }
148
+ const modulePathFor = (dir: string, code: string): string =>
149
+ join(dir, `${Bun.SHA256.hash(code, 'hex').slice(0, 16)}.mjs`);
219
150
 
220
151
  /**
221
152
  * The island fixture. Everything it installs is process-global, so the result is `Disposable` and
@@ -230,9 +161,12 @@ function installGlobals(values: Readonly<Record<string, unknown>>): () => void {
230
161
  * clears (a listener on `document`, an interval whose callback reads it) was made against that one.
231
162
  */
232
163
  /**
233
- * Every mount not yet disposed. A worker runs many files in one global (22.7), so a mount a file
234
- * forgot to `using` is disposed at the file boundary (`file-boundary.ts`) instead of handing its
235
- * fake `window`/`document` to every later file.
164
+ * Every mount not yet disposed — from the moment it installs its globals, not from when `mount`
165
+ * answers. A worker runs many files in one global (22.7), so a mount a file forgot to `using` is
166
+ * disposed at the file boundary (`file-boundary.ts`) instead of handing its fake
167
+ * `window`/`document` to every later file. Registered BEFORE the awaits, because a mount that
168
+ * never settles — a test that timed out inside an island's `mount` — was otherwise nobody's: its
169
+ * globals stayed installed and its scratch directory stayed in the temp dir, one per timeout.
236
170
  */
237
171
  const liveMounts = new Set<() => void>();
238
172
 
@@ -249,6 +183,10 @@ export function disposeLiveIslands(): number {
249
183
  return pending.length;
250
184
  }
251
185
 
186
+ // Registered here, not by each preload: the repo's own preload and an app's both install the leak
187
+ // guard that runs the boundary, and a mount a file forgot is one only this module can see.
188
+ onFileBoundary(disposeLiveIslands);
189
+
252
190
  export async function mountIsland(options: MountIslandOptions): Promise<MountedIsland> {
253
191
  // `only`, because this fixture has always KNOWN which island it wants and asked for all of them
254
192
  // anyway — `island-bundle.ts` added the option for exactly this caller ("a test that mounts a
@@ -275,14 +213,35 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
275
213
 
276
214
  const { documentElement, globals, resizeObservers } = createIslandDocument();
277
215
  const restore = installGlobals({ ...globals, ...options.globals });
216
+ let dir: string | undefined;
217
+ let unmount: (() => void) | undefined;
218
+ let disposed = false;
219
+ const dispose = (): void => {
220
+ // Once: `using` and an `afterAll` that also disposes by hand are both real, and Solid's own
221
+ // disposer is idempotent while an island's `clearInterval` wrapper need not be.
222
+ if (disposed) return;
223
+ disposed = true;
224
+ liveMounts.delete(dispose);
225
+ try {
226
+ unmount?.();
227
+ } finally {
228
+ // Whatever the disposer did — including throw, which is the test's to see — the process
229
+ // gets its globals back, or the fake `document` reaches every later file in the run.
230
+ restore();
231
+ if (dir !== undefined) removeScratchDir(dir);
232
+ }
233
+ };
234
+ liveMounts.add(dispose);
278
235
  try {
236
+ dir = createScratchDir();
237
+ const scratch = dir;
279
238
  // Beside the entry, under the name its bytes import them by: the same relative resolution a
280
239
  // browser does under `/islands/`. Named by their content-addressed URL, so a rewrite is the same
281
240
  // bytes at the same name.
282
241
  for (const shared of bundle.shared ?? []) {
283
- await Bun.write(join(moduleDirPath(), basename(shared.url)), shared.code);
242
+ await Bun.write(join(scratch, basename(shared.url)), shared.code);
284
243
  }
285
- const path = modulePathFor(chunk.code);
244
+ const path = modulePathFor(scratch, chunk.code);
286
245
  await Bun.write(path, chunk.code);
287
246
  const entry = entryOf(await import(path), chunk.file);
288
247
  const el = new FakeElement('div');
@@ -296,23 +255,16 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
296
255
  // island's `mount` resumed AFTER the `restore()` below had taken the fake `document` back out
297
256
  // — so it failed with `document is not defined` inside whichever later test happened to be
298
257
  // running, with no thread back here.
299
- const unmount = disposerOf(await entry.mount(el, options.props));
300
- let disposed = false;
301
- const dispose = (): void => {
302
- // Once: `using` and an `afterAll` that also disposes by hand are both real, and Solid's own
303
- // disposer is idempotent while an island's `clearInterval` wrapper need not be.
304
- if (disposed) return;
305
- disposed = true;
306
- liveMounts.delete(dispose);
258
+ const stop = disposerOf(await entry.mount(el, options.props));
259
+ if (disposed) {
260
+ // The boundary took this mount back while `mount` was still running — its test is long
261
+ // over. What it started is stopped now, and nobody is left to see a throw from it.
307
262
  try {
308
- unmount?.();
309
- } finally {
310
- // Whatever the disposer did — including throw, which is the test's to see — the process
311
- // gets its globals back, or the fake `document` reaches every later file in the run.
312
- restore();
263
+ stop?.();
264
+ } catch {
265
+ // Its `document` is already gone; the stop was best-effort for that reason.
313
266
  }
314
- };
315
- liveMounts.add(dispose);
267
+ } else unmount = stop;
316
268
  const resolve = (target: string | FakeElement | null | undefined): FakeElement | null =>
317
269
  typeof target === 'string' ? el.querySelector(target) : (target ?? null);
318
270
  return {
@@ -348,7 +300,7 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
348
300
  } catch (error) {
349
301
  // A mount that throws restores the process before it rethrows: the alternative leaves every
350
302
  // later file in the run holding a fake `document`, which fails somewhere with no thread back.
351
- restore();
303
+ dispose();
352
304
  throw error;
353
305
  }
354
306
  }
@@ -6,7 +6,7 @@
6
6
  // Nothing here polls or sleeps: a job becomes due only because `clock.advance()` said so.
7
7
 
8
8
  import type { Actor, Ctx } from '@ultimat3/core';
9
- import { assert, createContext } from '@ultimat3/core';
9
+ import { assert, ctxOf } from '@ultimat3/core';
10
10
  import type {
11
11
  AnyJobHandle,
12
12
  EnqueueResult,
@@ -108,9 +108,9 @@ const tallyOf = (executions: readonly JobExecution[]): Record<string, StepTally>
108
108
  return steps;
109
109
  };
110
110
 
111
- export async function createRunJobs(): Promise<RunJobs> {
111
+ export async function testJobs(): Promise<RunJobs> {
112
112
  const jobs = await import('@ultimat3/jobs');
113
- const driver: JobDriver = jobs.createMemoryDriver();
113
+ const driver: JobDriver = jobs.memoryJobDriver();
114
114
  // Captured before the overwrite: the ambient driver is process-global, so without this the
115
115
  // next file to call `send()` enqueues into this test's dead queue instead of sending inline.
116
116
  const previous = jobs.jobDriver();
@@ -119,8 +119,8 @@ export async function createRunJobs(): Promise<RunJobs> {
119
119
  // answer one test published resumed the next test's waiting run. A fresh bus per fixture, and
120
120
  // the process's own handed back with the driver.
121
121
  const previousBus = jobs.eventBus();
122
- jobs.setEventBus(jobs.createMemoryEventBus());
123
- const anonymousWorker = createContext({ role: 'worker' });
122
+ jobs.setEventBus(jobs.memoryEventBus());
123
+ const anonymousWorker = ctxOf({ role: 'worker' });
124
124
  const renewals = frozenScheduler();
125
125
 
126
126
  const introspect = (): NonNullable<JobDriver['introspect']> => {
@@ -164,7 +164,7 @@ export async function createRunJobs(): Promise<RunJobs> {
164
164
  * row, no shutdown hook — the frozen clock and the test decide when a pass happens.
165
165
  */
166
166
  const round = async (ctx: Ctx): Promise<readonly JobExecution[]> => {
167
- const worker = jobs.createWorker({
167
+ const worker = jobs.jobWorker({
168
168
  driver,
169
169
  workerId: WORKER_ID,
170
170
  queues: queues(),
@@ -198,7 +198,7 @@ export async function createRunJobs(): Promise<RunJobs> {
198
198
  const ctx =
199
199
  options.actor === undefined
200
200
  ? anonymousWorker
201
- : createContext({ role: 'worker', actor: options.actor });
201
+ : ctxOf({ role: 'worker', actor: options.actor });
202
202
  for (let rounds = 0; rounds < MAX_ROUNDS; rounds += 1) {
203
203
  const batch = await round(ctx);
204
204
  if (batch.length === 0) return { executions: [...history], steps: tallyOf(history) };
@@ -244,11 +244,15 @@ export async function createRunJobs(): Promise<RunJobs> {
244
244
  ).length,
245
245
  inFlight: async () => (await live()).filter((record) => record.state === 'running').length,
246
246
  [Symbol.asyncDispose]: async (): Promise<void> => {
247
- await driver.close?.();
248
- renewals[Symbol.dispose]();
249
- jobs.setEventBus(previousBus);
250
- if (previous === undefined) jobs.resetJobDriver();
251
- else jobs.setJobDriver(previous);
247
+ // A `finally`: a close that throws must still hand every later file its bus and its driver.
248
+ try {
249
+ await driver.close?.();
250
+ } finally {
251
+ renewals[Symbol.dispose]();
252
+ jobs.setEventBus(previousBus);
253
+ if (previous === undefined) jobs.resetJobDriver();
254
+ else jobs.setJobDriver(previous);
255
+ }
252
256
  },
253
257
  });
254
258
  }
@@ -21,10 +21,10 @@ export interface TestMail extends Disposable {
21
21
 
22
22
  const idOf = (mail: MailRef): string => (typeof mail === 'string' ? mail : mail.id);
23
23
 
24
- export async function createTestMail(): Promise<TestMail> {
25
- const { createMemoryDriver, driverUnavailable, resetMailDriver, setMailDriver, tryMailDriver } =
24
+ export async function testMail(): Promise<TestMail> {
25
+ const { memoryMailDriver, driverUnavailable, resetMailDriver, setMailDriver, tryMailDriver } =
26
26
  await import('@ultimat3/mail');
27
- const memory = createMemoryDriver();
27
+ const memory = memoryMailDriver();
28
28
  const failuresLeft = new Map<string, number>();
29
29
  // The ambient driver is process-global; the fixture borrows it for one test and hands it back.
30
30
  const previous = tryMailDriver();
@@ -30,7 +30,7 @@ export interface TestNetwork extends Disposable {
30
30
  * no subsystem to import on demand. The test bodies rely on it — `network.offline()` is followed
31
31
  * on the next line by the mutation that has to observe it.
32
32
  */
33
- export function createTestNetwork(): TestNetwork {
33
+ export function testNetwork(): TestNetwork {
34
34
  // A process that unsealed on purpose (ULTIMATE_TEST_ALLOW_NET=1) still gets a working
35
35
  // `offline()`, and gets its unsealed fetch back on disposal rather than keeping ours.
36
36
  const sealedBefore = isNetworkSealed();
@@ -81,7 +81,7 @@ interface ShapeTally {
81
81
  * The threshold is `@ultimat3/entity`'s `N_PLUS_ONE_THRESHOLD` and there is no knob: a loop that
82
82
  * fails a test and a loop that warns in `x dev` have to be the same loop.
83
83
  */
84
- export async function createTestStatements(): Promise<TestStatements> {
84
+ export async function testStatements(): Promise<TestStatements> {
85
85
  // Imported on demand, like every other fixture factory here: a `packages/core` test that never
86
86
  // names `statements` must not load the database layer or the entity registry to run.
87
87
  const db = await import('@ultimat3/db');
@@ -13,7 +13,7 @@ import type { LiveReplicator } from '@ultimat3/realtime/server';
13
13
  import { startLiveReplicator } from '@ultimat3/realtime/server';
14
14
  import type { LiveFeed, LiveFeedPatch, LiveTarget } from './fixture-drivers';
15
15
  import type { LiveConnection, LiveNodeHandle } from './live-node';
16
- import { createLiveNode } from './live-node';
16
+ import { liveNode } from './live-node';
17
17
 
18
18
  interface Row {
19
19
  readonly id: string;
@@ -58,9 +58,9 @@ export interface SubscribeDriver {
58
58
  * once would each see the other's writes. `bun test` builds a fixture on first use and disposes it
59
59
  * with the test, which is exactly the lifetime this needs.
60
60
  */
61
- export async function createSubscribeDriver(): Promise<SubscribeDriver> {
61
+ export async function subscribeDriver(): Promise<SubscribeDriver> {
62
62
  const realtime = await import('@ultimat3/realtime');
63
- const node = await createLiveNode();
63
+ const node = await liveNode();
64
64
  const replicator = await startLiveReplicator({ registry: node.registry });
65
65
  const connections: LiveConnection[] = [];
66
66
  let sid = 0;
@@ -2,13 +2,13 @@
2
2
  // `actorFor`, …). Called by the preload, which is why an app never writes
3
3
  // `defineFixtures({ clock })` and why two apps cannot disagree about what `clock` means.
4
4
 
5
- import { createTestClock } from './fixture-clock';
5
+ import { testClock } from './fixture-clock';
6
6
  import { DRIVER_FIXTURE_NAMES, driverFixtures } from './fixture-drivers';
7
- import { createRunJobs } from './fixture-jobs';
8
- import { createTestMail } from './fixture-mail';
9
- import { createTestNetwork } from './fixture-network';
10
- import { createTestStatements } from './fixture-statements';
11
- import { createSubscribeDriver } from './fixture-subscribe';
7
+ import { testJobs } from './fixture-jobs';
8
+ import { testMail } from './fixture-mail';
9
+ import { testNetwork } from './fixture-network';
10
+ import { testStatements } from './fixture-statements';
11
+ import { subscribeDriver } from './fixture-subscribe';
12
12
  import { defineFixtures } from './fixtures';
13
13
 
14
14
  /**
@@ -24,7 +24,7 @@ export const FRAMEWORK_FIXTURE_NAMES = [
24
24
  'runJobs',
25
25
  'statements',
26
26
  // Moved here from `DRIVER_FIXTURE_NAMES` on 2026-08-20: the driver it was waiting for is
27
- // `createSubscribeDriver()`, and the framework can build one — a whole `sync` node in this
27
+ // `subscribeDriver()`, and the framework can build one — a whole `sync` node in this
28
28
  // process, over the change source `@ultimat3/entity`'s `setRowObserver` gives it. The four left
29
29
  // in that list all need something the framework genuinely cannot bundle: a browser, or a
30
30
  // second build.
@@ -48,14 +48,14 @@ export const ALL_FIXTURE_NAMES: readonly string[] = [
48
48
  export function registerFrameworkFixtures(): void {
49
49
  defineFixtures({
50
50
  ...driverFixtures(),
51
- clock: createTestClock,
52
- mail: createTestMail,
53
- network: createTestNetwork,
54
- runJobs: createRunJobs,
55
- statements: createTestStatements,
51
+ clock: testClock,
52
+ mail: testMail,
53
+ network: testNetwork,
54
+ runJobs: testJobs,
55
+ statements: testStatements,
56
56
  // After the spread, so it REPLACES the `unavailableFixture('subscribe')` declaration above it —
57
57
  // `defineFixtures` merges and the last registration wins, which is the same seam an app's own
58
58
  // driver uses.
59
- subscribe: async () => (await createSubscribeDriver()).subscribe,
59
+ subscribe: async () => (await subscribeDriver()).subscribe,
60
60
  });
61
61
  }
@@ -0,0 +1,46 @@
1
+ // One responsibility: a port the e2e app can listen on AND a browser will open. The OS hands out
2
+ // whatever is free in its ephemeral range, and a box whose range starts at 1024 hands out ports
3
+ // Chrome refuses outright — every navigation of the run then fails `net::ERR_UNSAFE_PORT`.
4
+
5
+ /** What `freePort` needs of a listener: the port the OS gave it, and a way to give it back. */
6
+ export interface PortProbe {
7
+ readonly port: number;
8
+ stop(): void;
9
+ }
10
+
11
+ /**
12
+ * Chromium's restricted ports (`net/base/port_util.cc`, `kRestrictedPorts`) from 1024 up — the
13
+ * ones an unprivileged listener can be handed. Below 1024 the OS never answers a `port: 0` ask.
14
+ */
15
+ export const BROWSER_REFUSED_PORTS: ReadonlySet<number> = new Set([
16
+ 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669,
17
+ 6679, 6697, 10_080,
18
+ ]);
19
+
20
+ /** Listen on a port of the OS's choosing. */
21
+ const listenOnAnyPort = (): PortProbe => {
22
+ const probe = Bun.serve({ port: 0, fetch: () => new Response() });
23
+ return { port: probe.port ?? 0, stop: () => void probe.stop(true) };
24
+ };
25
+
26
+ /**
27
+ * A port nothing holds right now, asked of the OS and handed to the child — and one a browser will
28
+ * open. A refused port is KEPT listening while the next is asked for, so the OS cannot answer with
29
+ * it again: the walk ends after at most one ask per refused port, with no counter and no retry.
30
+ */
31
+ export const freePort = (listen: () => PortProbe = listenOnAnyPort): number => {
32
+ const refused: PortProbe[] = [];
33
+ try {
34
+ for (;;) {
35
+ const probe = listen();
36
+ if (BROWSER_REFUSED_PORTS.has(probe.port)) {
37
+ refused.push(probe);
38
+ continue;
39
+ }
40
+ probe.stop();
41
+ return probe.port;
42
+ }
43
+ } finally {
44
+ for (const probe of refused) probe.stop();
45
+ }
46
+ };
package/src/harness.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { afterAll, beforeAll, describe, test } from 'bun:test';
6
6
  import { captureDeterminism, installDeterminism, restoreCapturedDeterminism } from './determinism';
7
+ import { AppNotBootedError } from './errors';
7
8
  import {
8
9
  allowHost,
9
10
  captureNetwork,
@@ -171,7 +172,7 @@ export function describeApp(
171
172
  booted = undefined;
172
173
  });
173
174
  body(() => {
174
- if (booted === undefined) throw new ReferenceError('app is not booted yet');
175
+ if (booted === undefined) throw new AppNotBootedError({ suite: name });
175
176
  return booted.handle;
176
177
  });
177
178
  });
package/src/index.ts CHANGED
@@ -27,6 +27,8 @@ export { afterAll, afterEach, beforeAll, beforeEach, describe, expect } from 'bu
27
27
  // `@ultimat3/http` is imported inside the function.
28
28
  export type { AuthRequestInit } from './auth-request';
29
29
  export { authRequest } from './auth-request';
30
+ export type { BudgetStoreLike } from './budget-store-conformance';
31
+ export { budgetStoreConformance } from './budget-store-conformance';
30
32
  // The browser-backed e2e driver and the raw-CDP browser under it — moved here from
31
33
  // `@ultimat3/cli` in 22.0.0. `installE2eDriver` is the ONE entry point an app's test preload calls;
32
34
  // `openE2eBrowserIfAvailable()` answers `undefined` on a machine with no Chrome, so a
@@ -35,9 +37,15 @@ export { authRequest } from './auth-request';
35
37
  export type { E2eBrowser, OpenE2eBrowserOptions } from './cdp-browser';
36
38
  export {
37
39
  DEFAULT_CDP_TIMEOUT_MS,
40
+ E2E_BROWSER_CLOSE_MS,
41
+ E2E_BROWSER_OPEN_MS,
42
+ E2E_GOTO_MS,
43
+ E2E_TAB_OPEN_MS,
38
44
  openE2eBrowser,
39
45
  openE2eBrowserIfAvailable,
40
46
  } from './cdp-browser';
47
+ export type { E2eBrowserLease, RunEndHook } from './cdp-browser-lease';
48
+ export { closeE2eBrowsersAtRunEnd, leaseE2eBrowser } from './cdp-browser-lease';
41
49
  export type {
42
50
  CdpConnection,
43
51
  CdpConnectionOptions,
@@ -50,6 +58,12 @@ export type { CdpE2eTabOptions, E2eTab } from './cdp-e2e-page';
50
58
  export { cdpE2eTab } from './cdp-e2e-page';
51
59
  export type { CdpE2eSessionOptions, E2eSession } from './cdp-e2e-session';
52
60
  export { cdpE2eSession } from './cdp-e2e-session';
61
+ export type {
62
+ CdpLaunchAttempt,
63
+ CdpTargetGone,
64
+ CdpTimeoutObservation,
65
+ CdpTimeoutReading,
66
+ } from './cdp-errors';
53
67
  export {
54
68
  CdpBrowserMissingError,
55
69
  CdpCallFailedError,
@@ -62,6 +76,9 @@ export {
62
76
  CHROME_PATH_ENV,
63
77
  CONTAINER_CHROME_ARGS,
64
78
  findChrome,
79
+ LAUNCH_ATTEMPTS,
80
+ LAUNCH_BUDGET_MS,
81
+ LAUNCH_TIMEOUT_MS,
65
82
  launchChrome,
66
83
  launchFoundChrome,
67
84
  } from './cdp-launch';
@@ -79,7 +96,6 @@ export {
79
96
  captureDeterminism,
80
97
  DEFAULT_NOW,
81
98
  DEFAULT_SEED,
82
- frozenClock,
83
99
  frozenNow,
84
100
  installDeterminism,
85
101
  isDeterminismInstalled,
@@ -88,9 +104,10 @@ export {
88
104
  seededRandom,
89
105
  seededUuid,
90
106
  setFrozenClock,
107
+ withFrozenClock,
91
108
  } from './determinism';
92
109
  export type { E2eApp, E2eAppMode, StartE2eAppOptions } from './e2e-app';
93
- export { startE2eApp } from './e2e-app';
110
+ export { E2E_APP_START_MS, E2E_APP_STOP_MS, startE2eApp } from './e2e-app';
94
111
  export { E2E_ROOT_ENV, e2eApp, e2eBaseUrl, e2eBrowser } from './e2e-browser-handle';
95
112
  export type { E2eDriverOptions } from './e2e-driver';
96
113
  export { e2eFixtures, installE2eDriver } from './e2e-driver';
@@ -121,6 +138,7 @@ export {
121
138
  } from './e2e-selection';
122
139
  export type { TestingErrorCode } from './errors';
123
140
  export {
141
+ AppNotBootedError,
124
142
  FixtureUnavailableError,
125
143
  NetworkOfflineError,
126
144
  NetworkSealedError,
@@ -146,7 +164,7 @@ export { clearPersister, persisterInstalled, usePersister } from './factory-pers
146
164
  export type { EntityRegistry, FactoryRegistry } from './factory-registry';
147
165
  export { factoriesFor } from './factory-registry';
148
166
  export type { TestClock, TestDuration } from './fixture-clock';
149
- export { createTestClock } from './fixture-clock';
167
+ export { testClock } from './fixture-clock';
150
168
  export type {
151
169
  DriverFixtureName,
152
170
  DriverFixtures,
@@ -179,15 +197,15 @@ export type {
179
197
  RunJobsOptions,
180
198
  StepTally,
181
199
  } from './fixture-jobs';
182
- export { createRunJobs } from './fixture-jobs';
200
+ export { testJobs } from './fixture-jobs';
183
201
  export type { MailRef, TestMail } from './fixture-mail';
184
- export { createTestMail } from './fixture-mail';
202
+ export { testMail } from './fixture-mail';
185
203
  export type { TestNetwork } from './fixture-network';
186
- export { createTestNetwork } from './fixture-network';
204
+ export { testNetwork } from './fixture-network';
187
205
  export type { ObservedStatement, StatementShape, TestStatements } from './fixture-statements';
188
- export { createTestStatements } from './fixture-statements';
206
+ export { testStatements } from './fixture-statements';
189
207
  export type { SubscribeDriver } from './fixture-subscribe';
190
- export { createSubscribeDriver } from './fixture-subscribe';
208
+ export { subscribeDriver } from './fixture-subscribe';
191
209
  export { fixtureTest as test } from './fixtures';
192
210
  export {
193
211
  ALL_FIXTURE_NAMES,
@@ -271,8 +289,9 @@ export {
271
289
  normalizeIslandName,
272
290
  } from './island-states-resolve';
273
291
  export { ISOLATED_ENV, releasePluginsAfterIsolatedFile } from './isolated-plugins';
292
+ export { jobDriverConformance } from './job-driver-conformance';
274
293
  export type { LiveConnection, LiveNodeHandle, LiveNodeOptions } from './live-node';
275
- export { createLiveNode } from './live-node';
294
+ export { liveNode } from './live-node';
276
295
  // The store a mutator's `local()` half writes into under test — the Map every app hand-rolled.
277
296
  export type { LocalRows, MemoryLocalTx } from './local-tx';
278
297
  export { memoryLocalTx } from './local-tx';
@@ -323,11 +342,11 @@ export type { SqlRunner, TemplateDbConfig, WorkerDatabase } from './template-db'
323
342
  export {
324
343
  acquireWorkerDatabase,
325
344
  cloneSql,
326
- createTemplateSql,
327
345
  DEFAULT_TEMPLATE,
328
346
  databaseNameFor,
329
347
  dropSql,
330
348
  lockSql,
349
+ templateSql,
331
350
  unlockSql,
332
351
  urlFor,
333
352
  workerId,