@ultimat3/testing 24.0.0 → 25.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CLAUDE.md +12 -12
  2. package/README.md +55 -18
  3. package/package.json +16 -16
  4. package/src/auth-request.ts +1 -1
  5. package/src/cdp-browser-lease.ts +112 -0
  6. package/src/cdp-browser.ts +42 -1
  7. package/src/cdp-connection.ts +18 -4
  8. package/src/cdp-e2e-session.ts +120 -12
  9. package/src/cdp-errors.ts +25 -9
  10. package/src/cdp-launch-attempt.ts +112 -80
  11. package/src/cdp-launch-candidates.ts +62 -0
  12. package/src/cdp-launch-profile.ts +106 -0
  13. package/src/cdp-launch-reap.ts +68 -0
  14. package/src/cdp-launch-wire.ts +171 -0
  15. package/src/cdp-launch.ts +47 -22
  16. package/src/define-island-states.ts +47 -8
  17. package/src/determinism.ts +2 -2
  18. package/src/e2e-app.ts +14 -1
  19. package/src/e2e-errors.ts +14 -3
  20. package/src/e2e-locale.ts +12 -21
  21. package/src/e2e-page.ts +12 -26
  22. package/src/e2e-run.ts +21 -11
  23. package/src/e2e-service-worker.ts +92 -0
  24. package/src/e2e-spawn.ts +57 -9
  25. package/src/errors.ts +20 -65
  26. package/src/fixture-clock.ts +1 -1
  27. package/src/fixture-island.ts +39 -56
  28. package/src/fixture-jobs.ts +7 -7
  29. package/src/fixture-mail.ts +3 -3
  30. package/src/fixture-network.ts +1 -1
  31. package/src/fixture-statements.ts +1 -1
  32. package/src/fixture-subscribe.ts +3 -3
  33. package/src/framework-fixtures.ts +13 -13
  34. package/src/index.ts +17 -10
  35. package/src/island-dom.ts +49 -9
  36. package/src/island-globals.ts +73 -0
  37. package/src/island-scratch.ts +67 -3
  38. package/src/island-state-errors.ts +16 -0
  39. package/src/island-states.ts +23 -0
  40. package/src/live-node.ts +4 -4
  41. package/src/quiet-logs.ts +1 -1
  42. package/src/registry-leak-error.ts +81 -0
  43. package/src/registry-leak-guard.ts +4 -3
  44. package/src/registry-snapshot.ts +19 -1
  45. package/src/render-view.ts +3 -3
  46. package/src/sealed-network.ts +45 -4
  47. package/src/sealed-sockets.ts +59 -0
  48. package/src/template-db.ts +2 -2
  49. package/src/test-types.ts +6 -2
package/src/e2e-run.ts CHANGED
@@ -5,7 +5,9 @@
5
5
 
6
6
  import { finiteCount } from '@ultimat3/core';
7
7
  import type { E2eBrowser } from './cdp-browser';
8
+ import { E2E_BROWSER_CLOSE_MS, E2E_BROWSER_OPEN_MS } from './cdp-browser';
8
9
  import type { E2eApp } from './e2e-app';
10
+ import { E2E_APP_STOP_MS } from './e2e-app';
9
11
  import { e2eBrowser, publishE2eRun, republishE2eBrowser } from './e2e-browser-handle';
10
12
  import type { E2eDriverOptions } from './e2e-driver';
11
13
  import type { E2eBrowserPage } from './e2e-page';
@@ -35,8 +37,9 @@ export interface E2eRunDeps {
35
37
  readonly app: E2eApp;
36
38
  readonly openBrowser: () => Promise<E2eBrowser>;
37
39
  readonly install: (options: E2eDriverOptions) => () => void;
38
- readonly beforeEach: (hook: () => Promise<void>) => void;
39
- readonly afterAll: (hook: () => Promise<void>) => void;
40
+ /** Bun's, in the preload: the second argument is the hook's deadline, never left to the 5 s default. */
41
+ readonly beforeEach: (hook: () => Promise<void>, timeoutMs: number) => void;
42
+ readonly afterAll: (hook: () => Promise<void>, timeoutMs: number) => void;
40
43
  readonly probeMs?: number | undefined;
41
44
  }
42
45
 
@@ -74,19 +77,26 @@ export async function startE2eRun(deps: E2eRunDeps): Promise<void> {
74
77
  // call (run 8). So each test starts with a short probe, and a browser that fails it — or one a
75
78
  // deploy ran under — is closed and relaunched: the app is untouched, and the test gets a fresh
76
79
  // profile, a fresh SharedWorker and a fresh tab on the same origin.
77
- deps.beforeEach(async () => {
78
- const alive = !deployed && (await answersWithin(e2eBrowser().page, probeMs));
79
- if (alive) return;
80
- deployed = false;
81
- await browser.close();
82
- browser = await deps.openBrowser();
83
- republishE2eBrowser(browser);
84
- });
80
+ //
81
+ // Deadlines are the designed lengths of the work, because Bun's 5 s default killed both hooks
82
+ // mid-work: a relaunch cut short left its open in flight, assigned later over the next one's
83
+ // browser and never closed; a close cut short as the run exited left the profile half-removed.
84
+ deps.beforeEach(
85
+ async () => {
86
+ const alive = !deployed && (await answersWithin(e2eBrowser().page, probeMs));
87
+ if (alive) return;
88
+ deployed = false;
89
+ await browser.close();
90
+ browser = await deps.openBrowser();
91
+ republishE2eBrowser(browser);
92
+ },
93
+ probeMs + E2E_BROWSER_CLOSE_MS + E2E_BROWSER_OPEN_MS,
94
+ );
85
95
 
86
96
  deps.afterAll(async () => {
87
97
  setFailureContext(undefined);
88
98
  uninstall();
89
99
  await browser.close();
90
100
  await app.stop();
91
- });
101
+ }, E2E_BROWSER_CLOSE_MS + E2E_APP_STOP_MS);
92
102
  }
@@ -0,0 +1,92 @@
1
+ // Single responsibility: `waitForServiceWorker()` — wait for the worker to control the page, and
2
+ // hand the page over when it cannot claim it. The FIRST page of a run registers its worker on
3
+ // `load`, so it is committed before the worker activates, and `clients.claim()` can miss it: `ready`
4
+ // resolves, no `controllerchange` ever fires, and waiting longer waits for nothing (#678).
5
+
6
+ import { finiteCount } from '@ultimat3/core';
7
+ import { E2eServiceWorkerAbsentError } from './e2e-errors';
8
+ import type { E2eBrowserPage } from './e2e-page';
9
+
10
+ /**
11
+ * How long a page whose worker is ACTIVE waits to be claimed before it is reloaded. Activation
12
+ * sets `registration.active` — which resolves `ready` — before the activate handler reaches its
13
+ * `clients.claim()`, so the claim trails `ready` by the cache cleanup in between. A second is
14
+ * that gap with room; past it, the reload is cheaper than any further wait.
15
+ */
16
+ export const CLAIM_GRACE_MS = 1_000;
17
+
18
+ /** What the page answers. `unclaimed`: an active worker, and a document it does not control. */
19
+ export type ServiceWorkerState = 'controlled' | 'unclaimed' | 'absent';
20
+
21
+ /**
22
+ * The in-page half: `ready`, then the claim — an EVENT, not a poll, so the harness keeps exactly
23
+ * one retry loop. Bounded IN THE PAGE by `timeoutMs`: an unbounded wait is a hung test, and CI then
24
+ * reports a runner timeout with no assertion anywhere in it.
25
+ */
26
+ export const serviceWorkerExpression = (timeoutMs: number): string =>
27
+ `(() => {
28
+ const sw = navigator.serviceWorker;
29
+ if (!sw) return JSON.stringify({ state: 'absent' });
30
+ const claimed = new Promise((resolve) => {
31
+ if (sw.controller) { resolve('controlled'); return; }
32
+ sw.addEventListener('controllerchange', () => resolve('controlled'), { once: true });
33
+ });
34
+ const grace = () => new Promise((resolve) =>
35
+ setTimeout(() => resolve(sw.controller ? 'controlled' : 'unclaimed'), ${String(CLAIM_GRACE_MS)}));
36
+ const deadline = new Promise((resolve) => setTimeout(() => resolve('absent'), ${String(timeoutMs)}));
37
+ return Promise.race([sw.ready.then(() => Promise.race([claimed, grace()])), deadline])
38
+ .catch(() => 'absent')
39
+ .then((state) => JSON.stringify({ state }));
40
+ })()`;
41
+
42
+ const stateOf = (raw: unknown): ServiceWorkerState | undefined => {
43
+ const decoded = typeof raw === 'string' ? (JSON.parse(raw) as unknown) : raw;
44
+ const state =
45
+ typeof decoded === 'object' && decoded !== null
46
+ ? (decoded as Record<string, unknown>)['state']
47
+ : undefined;
48
+ return state === 'controlled' || state === 'unclaimed' || state === 'absent' ? state : undefined;
49
+ };
50
+
51
+ /**
52
+ * The URL to reload, fragment dropped: a navigation to the same URL plus a `#fragment` is a
53
+ * same-document navigation in Chromium, which scrolls and never replaces the uncontrolled
54
+ * document — the one thing the handover reload is for.
55
+ */
56
+ const withoutFragment = (href: string): string => {
57
+ const url = new URL(href);
58
+ url.hash = '';
59
+ return url.href;
60
+ };
61
+
62
+ export interface ServiceWorkerWait {
63
+ readonly page: E2eBrowserPage;
64
+ /** The whole budget, both checks and the reload between them. Screened by the caller. */
65
+ readonly timeoutMs: number;
66
+ /** The per-navigation deadline the reload is handed. */
67
+ readonly navigationTimeoutMs: number;
68
+ }
69
+
70
+ /**
71
+ * Wait for control; an unclaimed page is reloaded ONCE — that navigation is answered by the active
72
+ * worker, which is what control means — then asked again with what is left of the budget. A page
73
+ * with no worker at all is refused without a reload: the same request would answer the same.
74
+ */
75
+ export async function waitForServiceWorker(wait: ServiceWorkerWait): Promise<void> {
76
+ const { page, timeoutMs } = wait;
77
+ const began = performance.now();
78
+ const first = stateOf(await page.evaluate(serviceWorkerExpression(timeoutMs)));
79
+ if (first === 'controlled') return;
80
+ if (first === 'unclaimed') {
81
+ await page.goto(withoutFragment(page.url()), { timeout: wait.navigationTimeoutMs });
82
+ const left = Math.max(CLAIM_GRACE_MS, Math.round(timeoutMs - (performance.now() - began)));
83
+ const second = stateOf(await page.evaluate(serviceWorkerExpression(left)));
84
+ if (second === 'controlled') return;
85
+ throw new E2eServiceWorkerAbsentError({ url: page.url(), timeoutMs, reloaded: true });
86
+ }
87
+ throw new E2eServiceWorkerAbsentError({ url: page.url(), timeoutMs, reloaded: false });
88
+ }
89
+
90
+ /** A per-call budget, screened where it is read — it is interpolated into the in-page source. */
91
+ export const callBudget = (fallback: number, timeoutMs: number | undefined): number =>
92
+ timeoutMs === undefined ? fallback : finiteCount('waitForServiceWorker', 'timeoutMs', timeoutMs);
package/src/e2e-spawn.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  import { get } from 'node:http';
9
9
  // why: Bun exposes no path API — the CLI's bin is joined onto its package directory.
10
10
  import { dirname, join } from 'node:path';
11
- import { assert } from '@ultimat3/core';
11
+ import { assert, DRAIN_DEADLINE_DEFAULT_MS, READINESS_GRACE_DEFAULT_MS } from '@ultimat3/core';
12
12
  import { E2eAppFailedError } from './e2e-errors';
13
13
  import { freePort } from './free-port';
14
14
 
@@ -43,6 +43,29 @@ export async function xBin(root: string): Promise<string> {
43
43
  }
44
44
  const POLL_MS = 250;
45
45
 
46
+ /**
47
+ * How long one `/readyz` probe may hang before it counts as "not yet": an app that accepted the
48
+ * connection mid-boot and has not answered. The readiness deadline can overshoot by one of these.
49
+ */
50
+ export const READY_PROBE_MS = POLL_MS * 4;
51
+
52
+ /**
53
+ * What a SIGTERM'd app is given before it is killed outright: its own designed drain at the
54
+ * defaults — the readiness grace a deployed app holds, then `drain.deadlineMs` — because a stop
55
+ * shorter than the drain kills an app that was doing what it was told.
56
+ */
57
+ const TERM_GRACE_MS = READINESS_GRACE_DEFAULT_MS + DRAIN_DEADLINE_DEFAULT_MS;
58
+
59
+ /** After SIGKILL: the kernel's to deliver, bounded for a container whose init never reaps. */
60
+ const KILL_GRACE_MS = 5_000;
61
+
62
+ /**
63
+ * The longest `stop()` is DESIGNED to take: the drain, then the kill. THE deadline a hook that
64
+ * stops an e2e app derives from — unbounded, a wedged app held the hook until Bun killed it, and
65
+ * the browser close behind it in the same hook never ran.
66
+ */
67
+ export const E2E_APP_STOP_MS = TERM_GRACE_MS + KILL_GRACE_MS;
68
+
46
69
  export interface SpawnedE2eApp {
47
70
  /** `http://localhost:<port>`, no trailing slash. */
48
71
  readonly base: string;
@@ -63,6 +86,8 @@ export interface SpawnE2eAppOptions {
63
86
  readonly readyTimeoutMs: number;
64
87
  /** `xBin(root)`, when the caller already resolved it; resolved here otherwise, in `dev` mode only. */
65
88
  readonly bin?: string | undefined;
89
+ /** How long a SIGTERM'd app has before SIGKILL. Defaults to the app's own drain at its defaults. */
90
+ readonly termGraceMs?: number | undefined;
66
91
  }
67
92
 
68
93
  /**
@@ -70,9 +95,15 @@ export interface SpawnE2eAppOptions {
70
95
  * `NODE_ENV=test` rode along and the app resolved its environment as `test` — so a development-only
71
96
  * seam (the demo viewer an app installs instead of a sign-in route) was off and every page 401'd.
72
97
  * The app under e2e is a development app unless the caller's `env` says otherwise.
98
+ *
99
+ * Minus the two bindings `ULTIMATE_STATE_DIR` relocates, too — the database and the disk. The
100
+ * harness boots on a throwaway state directory, and a developer's exported `DATABASE_URL` (their
101
+ * integration-test Postgres) turned that into a reset of a database the run does not own: `x db
102
+ * reset` refused it, and the e2e step went red for an environment reason (#674). A run that WANTS
103
+ * an external one passes it in `startE2eApp({ env })`, which is spread after this.
73
104
  */
74
105
  export const inherited = (): Record<string, string | undefined> => {
75
- const { NODE_ENV: _node, ...rest } = Bun.env;
106
+ const { NODE_ENV: _node, DATABASE_URL: _db, S3_ENDPOINT: _disk, ...rest } = Bun.env;
76
107
  return { ...rest, ULTIMATE_ENV: 'development' };
77
108
  };
78
109
 
@@ -107,14 +138,17 @@ export async function spawnE2eApp(options: SpawnE2eAppOptions): Promise<SpawnedE
107
138
  stdout: 'pipe',
108
139
  stderr: 'pipe',
109
140
  });
141
+ const termGraceMs = options.termGraceMs ?? TERM_GRACE_MS;
142
+ // On the monotonic clock: counted in polls, every probe an app wedged mid-boot held for its
143
+ // whole `READY_PROBE_MS` stretched a 90 s deadline to 450 s, past any hook derived from it.
110
144
  const ready = async (child: ReturnType<typeof spawnApp>, tail: () => string): Promise<void> => {
111
- for (let waited = 0; waited < deadline; waited += POLL_MS) {
145
+ const until = performance.now() + deadline;
146
+ while (performance.now() < until) {
112
147
  if (child.exitCode !== null) break;
113
148
  if (await answersOk(`${base}/readyz`)) return;
114
149
  await Bun.sleep(POLL_MS);
115
150
  }
116
- child.kill();
117
- await child.exited;
151
+ await end(child, termGraceMs);
118
152
  throw refuse(`${command.join(' ')} never answered ${base}/readyz`, tail());
119
153
  };
120
154
 
@@ -126,8 +160,7 @@ export async function spawnE2eApp(options: SpawnE2eAppOptions): Promise<SpawnedE
126
160
  const stop = async (): Promise<void> => {
127
161
  if (stopped) return;
128
162
  stopped = true;
129
- child.kill();
130
- await child.exited;
163
+ await end(child, termGraceMs);
131
164
  };
132
165
  try {
133
166
  await ready(child, tail);
@@ -149,8 +182,7 @@ export async function spawnE2eApp(options: SpawnE2eAppOptions): Promise<SpawnedE
149
182
  );
150
183
  // The same port and the same state directory — a deploy, not a second app: a tab already
151
184
  // open on `base` sees the new build on its next request, and the data it wrote is still there.
152
- child.kill();
153
- await child.exited;
185
+ await end(child, termGraceMs);
154
186
  child = spawnApp(next);
155
187
  tail = drainTail(child.stdout, child.stderr);
156
188
  await ready(child, tail);
@@ -158,6 +190,22 @@ export async function spawnE2eApp(options: SpawnE2eAppOptions): Promise<SpawnedE
158
190
  };
159
191
  }
160
192
 
193
+ const within = (ms: number, work: Promise<unknown>): Promise<unknown> =>
194
+ Promise.race([work, Bun.sleep(ms)]);
195
+
196
+ /** SIGTERM, the grace, then SIGKILL: bounded at `termGraceMs + KILL_GRACE_MS`, never forever. */
197
+ async function end(child: Bun.Subprocess, termGraceMs: number): Promise<void> {
198
+ child.kill();
199
+ const exited =
200
+ (await within(
201
+ termGraceMs,
202
+ child.exited.then(() => true),
203
+ )) === true;
204
+ if (exited) return;
205
+ child.kill('SIGKILL');
206
+ await within(KILL_GRACE_MS, child.exited);
207
+ }
208
+
161
209
  const TAIL_CHARS = 16_000;
162
210
 
163
211
  /** Read both streams to their end in the background; answer the last `TAIL_CHARS` of either. */
package/src/errors.ts CHANGED
@@ -6,10 +6,9 @@ import {
6
6
  renderFixLiteral,
7
7
  UltimateError,
8
8
  } from '@ultimat3/core';
9
- // Type-only, so the cycle with the guard that throws it is erased at build: the code is declared
10
- // here because this file is the package's code registry, and the shape it reports lives with the
11
- // sampler that produces it.
12
- import type { RegistryLeak } from './registry-leak-guard';
9
+
10
+ // `X_TEST_REGISTRY_LEAK` is declared below, in this registry; its message lives beside it.
11
+ export { RegistryLeakError } from './registry-leak-error';
13
12
 
14
13
  export const TESTING_ERROR_CODES = [
15
14
  'X_TEST_NETWORK_SEALED',
@@ -107,15 +106,29 @@ registerErrorCodes(
107
106
  // answered 404, host included, on every error it has ever thrown; restating the replacement here
108
107
  // would be the same constant in eight places waiting to drift again.
109
108
 
110
- /** A test reached the network without a mock or an allowlist entry. Always a bug, never a flake. */
109
+ /**
110
+ * A test reached the network without a mock or an allowlist entry. Always a bug, never a flake.
111
+ * `transport` picks the repair: a `fetch` is mocked, a socket (`WebSocket`, `Bun.connect`) has no
112
+ * mock — the code under test takes its transport as a parameter, and the test hands one in.
113
+ */
111
114
  export class NetworkSealedError extends UltimateError {
112
- constructor(input: { url: string; method: string; allowed: readonly string[] }) {
115
+ constructor(input: {
116
+ url: string;
117
+ method: string;
118
+ allowed: readonly string[];
119
+ transport?: 'fetch' | 'socket' | undefined;
120
+ }) {
121
+ const allowCall = `allowHost('${hostOf(input.url)}')`;
122
+ const allow = `${allowCall} if it must be real`;
113
123
  super({
114
124
  code: 'X_TEST_NETWORK_SEALED',
115
125
  cause: `${input.method} ${input.url} was not mocked (allowed hosts: ${
116
126
  input.allowed.length > 0 ? input.allowed.join(', ') : 'none'
117
127
  })`,
118
- fix: `mockFetch('${input.url}', () => new Response('{}')) — or allowHost('${hostOf(input.url)}') if it must be real`,
128
+ fix:
129
+ input.transport === 'socket'
130
+ ? `${allowCall} # only if it must be real — otherwise inject the transport: pass the code under test its \`connect\`/\`client\` option, or dial a Bun.listen server on 127.0.0.1 (loopback is never sealed)`
131
+ : `mockFetch('${input.url}', () => new Response('{}')) — or ${allow}`,
119
132
  });
120
133
  }
121
134
  }
@@ -359,64 +372,6 @@ export class NetworkRaceError extends UltimateError {
359
372
  }
360
373
  }
361
374
 
362
- /**
363
- * Every value in this message is uncontrolled: the path arrives from `Bun.plugin`'s `onLoad`, and
364
- * the names are whatever the app under test passed to `declareTags()` / `registerTier()`. So the
365
- * sentence renders them (bounded, never throwing while describing a leak) and the command quotes
366
- * them (a fix has to parse after a path with a space or a quote in it lands in the middle of it).
367
- */
368
- const describeLeak = (leak: RegistryLeak): string => {
369
- const left = [
370
- ...(leak.tags.length > 0 ? [`cache tags declared ${renderCauseValue(leak.tags)}`] : []),
371
- ...(leak.tiers.length > 0 ? [`cache tiers registered ${renderCauseValue(leak.tiers)}`] : []),
372
- ];
373
- return `${renderCauseValue(leak.file)} left ${left.join(' and ')} after its last test`;
374
- };
375
-
376
- /** The placeholder is the cause's own sentence: it already names every file, in order. */
377
- const FILE_PLACEHOLDER = '<the file the cause names>';
378
-
379
- /**
380
- * The edit, spelled as the lines to paste — the imports included, since neither call is global.
381
- *
382
- * Both repairs isolate; neither resets. `afterAll(resetTiers)` was the first spelling of the tier
383
- * half and it is the bug wearing the fix's clothes: it drops the tiers, the revalidator and both
384
- * logs a NEIGHBOUR registered, and the guard reports additions only, so the damage lands on an
385
- * innocent file with nothing pointing back here. An error whose instruction causes the next defect
386
- * is worse than no instruction.
387
- */
388
- const repairFor = (leak: RegistryLeak): string => {
389
- const imports: string[] = [];
390
- const calls: string[] = [];
391
- if (leak.tags.length > 0) {
392
- imports.push('isolateDeclaredTags');
393
- calls.push('const restoreTags = isolateDeclaredTags(); afterAll(restoreTags);');
394
- }
395
- if (leak.tiers.length > 0) {
396
- imports.push('isolateTiers');
397
- calls.push('const restoreTiers = isolateTiers(); afterAll(restoreTiers);');
398
- }
399
- const file = renderFixLiteral(leak.file, FILE_PLACEHOLDER);
400
- return `in ${file} add: import { ${imports.join(', ')} } from '@ultimat3/cache'; ${calls.join(' ')}`;
401
- };
402
-
403
- /**
404
- * One error for every leaker in the run, not one per file: the run has already finished by the
405
- * time the last file can be judged, and two throws would report the second as an unhandled one.
406
- * The trailing command reproduces the failure on its own — the guard judges each file against its
407
- * own baseline, so one leaking file fails a one-file run exactly as it failed the whole suite.
408
- */
409
- export class RegistryLeakError extends UltimateError {
410
- constructor(input: { readonly leaks: readonly RegistryLeak[] }) {
411
- const files = input.leaks.map((leak) => renderFixLiteral(leak.file, FILE_PLACEHOLDER));
412
- super({
413
- code: 'X_TEST_REGISTRY_LEAK',
414
- cause: input.leaks.map(describeLeak).join('; '),
415
- fix: `${input.leaks.map(repairFor).join('; ')} — then re-run: bun test ${files.join(' ')}`,
416
- });
417
- }
418
- }
419
-
420
375
  /** Neither path is controlled by the framework: both arrive from the test's own call. */
421
376
  const ISLAND_PLACEHOLDER = '<the island path the cause names>';
422
377
 
@@ -33,7 +33,7 @@ export interface TestClock {
33
33
  * installed determinism once for the whole process, and uninstalling it here would hand the REAL
34
34
  * `Date` and the REAL `Math.random` to everything after. Restore only what was found.
35
35
  */
36
- export async function createTestClock(): Promise<TestClock> {
36
+ export async function testClock(): Promise<TestClock> {
37
37
  const { toMs } = await import('@ultimat3/time');
38
38
  const captured = captureDeterminism();
39
39
  return {
@@ -7,6 +7,7 @@ import { basename, join } from 'node:path';
7
7
  import { islandMountMissing, islandNotBuilt } from './errors';
8
8
  import { onFileBoundary } from './file-boundary';
9
9
  import { createIslandDocument, FakeElement, handlerFor, parseHtml } from './island-dom';
10
+ import { installGlobals } from './island-globals';
10
11
  import type { ResizeInput } from './island-observers';
11
12
  import { deliverResize } from './island-observers';
12
13
  import { createScratchDir, removeScratchDir } from './island-scratch';
@@ -147,40 +148,6 @@ function entryOf(module: unknown, file: string): IslandEntry {
147
148
  const modulePathFor = (dir: string, code: string): string =>
148
149
  join(dir, `${Bun.SHA256.hash(code, 'hex').slice(0, 16)}.mjs`);
149
150
 
150
- /**
151
- * DESCRIPTORS, not values, and all-or-nothing.
152
- *
153
- * A saved value cannot tell "no such global" from "a global holding `undefined`", so a teardown
154
- * reading one deletes both — and `key in globalThis` flips behind whoever owned it. It also cannot
155
- * put an accessor back as an accessor.
156
- *
157
- * The rollback is the half with teeth. This runs BEFORE `mountIsland`'s own `try`, so an
158
- * assignment that throws — a getter-only own global among the caller's `globals` — would leave the
159
- * fake `document` already installed ahead of it for the whole rest of the process, which is the
160
- * exact leak the `catch` below exists to prevent.
161
- */
162
- function installGlobals(values: Readonly<Record<string, unknown>>): () => void {
163
- const host = globalThis as unknown as Record<string, unknown>;
164
- const saved = Object.keys(values).map(
165
- (key) => [key, Object.getOwnPropertyDescriptor(host, key)] as const,
166
- );
167
- const restore = (): void => {
168
- for (const [key, descriptor] of saved) {
169
- if (descriptor === undefined) delete host[key];
170
- else Object.defineProperty(host, key, descriptor);
171
- }
172
- };
173
- try {
174
- // Assignment, not `defineProperty`: a global with a setter is meant to see the write, and
175
- // `Object.assign` over the lot would report the same failure with nothing rolled back.
176
- for (const [key, value] of Object.entries(values)) host[key] = value;
177
- } catch (error) {
178
- restore();
179
- throw error;
180
- }
181
- return restore;
182
- }
183
-
184
151
  /**
185
152
  * The island fixture. Everything it installs is process-global, so the result is `Disposable` and
186
153
  * the idiom is `using mounted = await mountIsland(…)` — a mount left installed hands a fake
@@ -194,9 +161,12 @@ function installGlobals(values: Readonly<Record<string, unknown>>): () => void {
194
161
  * clears (a listener on `document`, an interval whose callback reads it) was made against that one.
195
162
  */
196
163
  /**
197
- * Every mount not yet disposed. A worker runs many files in one global (22.7), so a mount a file
198
- * forgot to `using` is disposed at the file boundary (`file-boundary.ts`) instead of handing its
199
- * 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.
200
170
  */
201
171
  const liveMounts = new Set<() => void>();
202
172
 
@@ -244,6 +214,24 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
244
214
  const { documentElement, globals, resizeObservers } = createIslandDocument();
245
215
  const restore = installGlobals({ ...globals, ...options.globals });
246
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);
247
235
  try {
248
236
  dir = createScratchDir();
249
237
  const scratch = dir;
@@ -255,7 +243,11 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
255
243
  }
256
244
  const path = modulePathFor(scratch, chunk.code);
257
245
  await Bun.write(path, chunk.code);
258
- const entry = entryOf(await import(path), chunk.file);
246
+ // By `file:` URL, never the bare path: Bun on macOS caches a directory's entries on its first
247
+ // resolution, and a bare-path import of a file written there afterwards is "Cannot find module"
248
+ // (#704, Bun 1.4.0 arm64). The per-mount directory already avoids it; the URL form holds even
249
+ // if a later change ever imports twice from one directory.
250
+ const entry = entryOf(await import(Bun.pathToFileURL(path).href), chunk.file);
259
251
  const el = new FakeElement('div');
260
252
  // Before the shell and before `mount`: nothing observes the host yet, so this is a write and
261
253
  // never a notification.
@@ -267,24 +259,16 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
267
259
  // island's `mount` resumed AFTER the `restore()` below had taken the fake `document` back out
268
260
  // — so it failed with `document is not defined` inside whichever later test happened to be
269
261
  // running, with no thread back here.
270
- const unmount = disposerOf(await entry.mount(el, options.props));
271
- let disposed = false;
272
- const dispose = (): void => {
273
- // Once: `using` and an `afterAll` that also disposes by hand are both real, and Solid's own
274
- // disposer is idempotent while an island's `clearInterval` wrapper need not be.
275
- if (disposed) return;
276
- disposed = true;
277
- liveMounts.delete(dispose);
262
+ const stop = disposerOf(await entry.mount(el, options.props));
263
+ if (disposed) {
264
+ // The boundary took this mount back while `mount` was still running — its test is long
265
+ // over. What it started is stopped now, and nobody is left to see a throw from it.
278
266
  try {
279
- unmount?.();
280
- } finally {
281
- // Whatever the disposer did — including throw, which is the test's to see — the process
282
- // gets its globals back, or the fake `document` reaches every later file in the run.
283
- restore();
284
- removeScratchDir(scratch);
267
+ stop?.();
268
+ } catch {
269
+ // Its `document` is already gone; the stop was best-effort for that reason.
285
270
  }
286
- };
287
- liveMounts.add(dispose);
271
+ } else unmount = stop;
288
272
  const resolve = (target: string | FakeElement | null | undefined): FakeElement | null =>
289
273
  typeof target === 'string' ? el.querySelector(target) : (target ?? null);
290
274
  return {
@@ -320,8 +304,7 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
320
304
  } catch (error) {
321
305
  // A mount that throws restores the process before it rethrows: the alternative leaves every
322
306
  // later file in the run holding a fake `document`, which fails somewhere with no thread back.
323
- restore();
324
- if (dir !== undefined) removeScratchDir(dir);
307
+ dispose();
325
308
  throw error;
326
309
  }
327
310
  }
@@ -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) };
@@ -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;