@ultimat3/testing 23.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CLAUDE.md +20 -20
  2. package/README.md +95 -13
  3. package/package.json +16 -16
  4. package/src/auth-request.ts +1 -1
  5. package/src/budget-store-conformance.ts +81 -0
  6. package/src/cdp-browser-lease.ts +112 -0
  7. package/src/cdp-browser.ts +47 -11
  8. package/src/cdp-connection.ts +41 -10
  9. package/src/cdp-e2e-session.ts +130 -13
  10. package/src/cdp-errors.ts +129 -8
  11. package/src/cdp-launch-attempt.ts +224 -0
  12. package/src/cdp-launch-candidates.ts +62 -0
  13. package/src/cdp-launch-profile.ts +106 -0
  14. package/src/cdp-launch-reap.ts +68 -0
  15. package/src/cdp-launch-wire.ts +171 -0
  16. package/src/cdp-launch.ts +92 -140
  17. package/src/cdp-offline-script.ts +33 -3
  18. package/src/cdp-wire-watch.ts +113 -0
  19. package/src/define-island-states.ts +47 -8
  20. package/src/determinism.ts +35 -6
  21. package/src/e2e-app.ts +17 -1
  22. package/src/e2e-errors.ts +14 -3
  23. package/src/e2e-locale.ts +12 -21
  24. package/src/e2e-page.ts +12 -26
  25. package/src/e2e-run.ts +22 -12
  26. package/src/e2e-service-worker.ts +92 -0
  27. package/src/e2e-spawn.ts +58 -17
  28. package/src/errors.ts +34 -66
  29. package/src/factories.ts +4 -6
  30. package/src/file-boundary.ts +12 -7
  31. package/src/fixture-clock.ts +1 -1
  32. package/src/fixture-island.ts +50 -98
  33. package/src/fixture-jobs.ts +16 -12
  34. package/src/fixture-mail.ts +3 -3
  35. package/src/fixture-network.ts +1 -1
  36. package/src/fixture-statements.ts +1 -1
  37. package/src/fixture-subscribe.ts +3 -3
  38. package/src/framework-fixtures.ts +13 -13
  39. package/src/free-port.ts +46 -0
  40. package/src/harness.ts +2 -1
  41. package/src/index.ts +29 -10
  42. package/src/island-dom.ts +57 -13
  43. package/src/island-globals.ts +73 -0
  44. package/src/island-html-entities.ts +27 -0
  45. package/src/island-scratch.ts +105 -0
  46. package/src/island-state-errors.ts +16 -0
  47. package/src/island-states.ts +23 -0
  48. package/src/job-driver-checks.ts +177 -0
  49. package/src/job-driver-conformance.ts +21 -0
  50. package/src/live-node.ts +6 -4
  51. package/src/preload.ts +2 -8
  52. package/src/quiet-logs.ts +1 -1
  53. package/src/registry-leak-error.ts +81 -0
  54. package/src/registry-leak-guard.ts +7 -4
  55. package/src/registry-snapshot.ts +41 -2
  56. package/src/render-view.ts +3 -3
  57. package/src/sealed-network.ts +50 -5
  58. package/src/sealed-sockets.ts +59 -0
  59. package/src/template-db.ts +8 -5
  60. package/src/test-types.ts +6 -2
package/src/e2e-errors.ts CHANGED
@@ -95,11 +95,22 @@ export class E2eLocatorAmbiguousError extends UltimateError {
95
95
  * test that hangs, and CI then reports a runner timeout with no assertion anywhere in it.
96
96
  */
97
97
  export class E2eServiceWorkerAbsentError extends UltimateError {
98
- constructor(input: { readonly url: string; readonly timeoutMs: number }) {
98
+ constructor(input: {
99
+ readonly url: string;
100
+ readonly timeoutMs: number;
101
+ /**
102
+ * True when a worker was ACTIVE and the page was reloaded once for it to answer — a first
103
+ * page `clients.claim()` missed (#678) — and the reload was not controlled either.
104
+ */
105
+ readonly reloaded?: boolean | undefined;
106
+ }) {
99
107
  super({
100
108
  code: 'X_E2E_SERVICE_WORKER_ABSENT',
101
- cause: `no service worker took control of ${renderCauseValue(input.url)} within ${String(input.timeoutMs)}ms`,
102
- fix: `x build --target static — the worker is generated by the pwa build; then confirm the route at ${renderFixLiteral(input.url, URL_PLACEHOLDER)} registers it`,
109
+ cause:
110
+ input.reloaded === true
111
+ ? `a service worker is active for ${renderCauseValue(input.url)} but did not control it, even reloaded once, within ${String(input.timeoutMs)}ms`
112
+ : `no service worker took control of ${renderCauseValue(input.url)} within ${String(input.timeoutMs)}ms`,
113
+ fix: `x build --target static — the worker is generated by the pwa build; then confirm the route at ${renderFixLiteral(input.url, URL_PLACEHOLDER)} registers it, or raise this call's budget: await page.waitForServiceWorker({ timeoutMs: 30_000 })`,
103
114
  });
104
115
  }
105
116
  }
package/src/e2e-locale.ts CHANGED
@@ -1,30 +1,21 @@
1
- // The locale an e2e run's browser asks for: the app's own `defaultLocale`, read off `app.config.ts`
2
- // the way the CLI reads its other boot facts. `@ultimat3/testing` sits below `@ultimat3/cli`, so the
3
- // file name and the export name are restated here rather than imported from it.
4
-
5
- import { join } from 'node:path'; // why: Bun ships no path join.
6
-
7
- /** The app root's config module and the export holding the config — `x new` writes both. */
8
- const APP_CONFIG_FILE = 'app.config.ts';
9
- const APP_CONFIG_EXPORT = 'config';
1
+ // The locale an e2e run's browser asks for: the default the app's `defineCatalogs()` declared —
2
+ // the one place an app names its locales since 25.0.0 (`app.config.ts`'s `defaultLocale` and
3
+ // `locales` keys were deleted). Read through `@ultimat3/i18n/app-catalogs`, the one reader the CLI
4
+ // shares; what is this file's own is the answer to a module that will not load.
10
5
 
11
6
  /**
12
- * The app's `defaultLocale`, or `undefined` when it cannot be read — and then nothing is pinned.
13
- * Never a guessed `en`: pinning the wrong language would make every run photograph and assert on a
14
- * page no visitor at the unprefixed URL sees, which is worse than the runner's own Chrome locale.
15
- * The import runs the config module, which may validate an environment this process lacks.
7
+ * The app's default locale, or `undefined` when it cannot be read — and then nothing is pinned.
8
+ * Never a guessed `en`: `loadAppCatalogs` answers only when a `defineCatalogs()` was declared.
9
+ * Pinning the wrong language would make every run photograph and assert on a page no visitor at
10
+ * the unprefixed URL sees. The import runs the app's module, which may validate an environment
11
+ * this process lacks — so a module that throws is "nothing pinned", never a failed run.
16
12
  */
17
13
  export async function e2eDefaultLocale(root: string): Promise<string | undefined> {
18
- const path = join(root, APP_CONFIG_FILE);
19
- if (!(await Bun.file(path).exists())) return undefined;
20
- let module: Record<string, unknown>;
14
+ // Dynamic, like every non-core dependency of this package: only the e2e preload pays for it.
15
+ const { loadAppCatalogs } = await import('@ultimat3/i18n/app-catalogs');
21
16
  try {
22
- module = (await import(path)) as Record<string, unknown>;
17
+ return (await loadAppCatalogs(root))?.defaultLocale;
23
18
  } catch {
24
19
  return undefined;
25
20
  }
26
- const config = Object.hasOwn(module, APP_CONFIG_EXPORT) ? module[APP_CONFIG_EXPORT] : undefined;
27
- if (typeof config !== 'object' || config === null) return undefined;
28
- const locale = (config as Record<string, unknown>)['defaultLocale'];
29
- return typeof locale === 'string' && locale !== '' ? locale : undefined;
30
21
  }
package/src/e2e-page.ts CHANGED
@@ -3,10 +3,10 @@
3
3
  // may import the other — so the join is here, in the one package allowed to know about both.
4
4
 
5
5
  import { finiteCount } from '@ultimat3/core';
6
- import { E2eServiceWorkerAbsentError } from './e2e-errors';
7
6
  import { evaluateClosure } from './e2e-evaluate';
8
7
  import { e2eLocator } from './e2e-locator';
9
8
  import type { E2eSelection } from './e2e-selection';
9
+ import { callBudget, waitForServiceWorker } from './e2e-service-worker';
10
10
  import type { LocatorLike, PageLike } from './test-types';
11
11
 
12
12
  /**
@@ -39,7 +39,10 @@ export interface E2ePageOptions {
39
39
  readonly baseUrl: string;
40
40
  /** Per-navigation deadline, handed to the driver rather than enforced here. */
41
41
  readonly timeoutMs?: number | undefined;
42
- /** How long `waitForServiceWorker()` waits before refusing. Bounded IN THE PAGE. */
42
+ /**
43
+ * How long `waitForServiceWorker()` waits before refusing, the handover reload included. Bounded
44
+ * IN THE PAGE. One test raises its own with `waitForServiceWorker({ timeoutMs })`.
45
+ */
43
46
  readonly serviceWorkerTimeoutMs?: number | undefined;
44
47
  }
45
48
 
@@ -77,24 +80,6 @@ const firstFlushExpression = (url: string): string =>
77
80
  .then((chunk) => { reader.cancel().catch(() => {}); return chunk; }); })
78
81
  .then((chunk) => JSON.stringify({ html: new TextDecoder().decode(chunk.value || new Uint8Array()) })))()`;
79
82
 
80
- /**
81
- * `ready` alone is not control: a first load activates a worker that is not yet the page's
82
- * controller, and an offline assertion made in that window tests nothing. So this waits for
83
- * `controllerchange` too — an EVENT, not a poll, so the harness still has exactly one retry loop.
84
- */
85
- const serviceWorkerExpression = (timeoutMs: number): string =>
86
- `(() => {
87
- if (!navigator.serviceWorker) return JSON.stringify({ controlled: false });
88
- const controlled = new Promise((resolve) => {
89
- if (navigator.serviceWorker.controller) { resolve(true); return; }
90
- navigator.serviceWorker.addEventListener('controllerchange', () => resolve(true), { once: true });
91
- });
92
- const deadline = new Promise((resolve) => setTimeout(() => resolve(false), ${String(timeoutMs)}));
93
- return Promise.race([navigator.serviceWorker.ready.then(() => controlled), deadline])
94
- .catch(() => false)
95
- .then((ok) => JSON.stringify({ controlled: ok === true }));
96
- })()`;
97
-
98
83
  /**
99
84
  * The adapter. Every member re-reads the live page: a `PageLike` handed to a test outlives every
100
85
  * navigation the test makes, so nothing here may capture a URL, a document or an element.
@@ -132,12 +117,13 @@ export function e2ePage(options: E2ePageOptions): PageLike {
132
117
  const html = readField(await page.evaluate(firstFlushExpression(target)), 'html');
133
118
  return { html: typeof html === 'string' ? html : '' };
134
119
  },
135
- waitForServiceWorker: async () => {
136
- const raw = await page.evaluate(serviceWorkerExpression(swTimeout));
137
- if (readField(raw, 'controlled') !== true) {
138
- throw new E2eServiceWorkerAbsentError({ url: page.url(), timeoutMs: swTimeout });
139
- }
140
- },
120
+ // The handover is `e2e-service-worker.ts`'s: an unclaimed first page is reloaded once (#678).
121
+ waitForServiceWorker: async (waitOptions) =>
122
+ waitForServiceWorker({
123
+ page,
124
+ timeoutMs: callBudget(swTimeout, waitOptions?.timeoutMs),
125
+ navigationTimeoutMs: timeout,
126
+ }),
141
127
  evaluate: <T>(fn: () => T): Promise<T> => evaluateClosure(page, fn) as unknown as Promise<T>,
142
128
  locator: (selector) => locate({ kind: 'css', selector, first: false }),
143
129
  getByRole: (role, roleOptions) =>
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
- 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
- browser.close();
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,8 +8,9 @@
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
+ import { freePort } from './free-port';
13
14
 
14
15
  /** `x dev` (sync included), or the production entry `apps/web/server.ts` under `ROLE=web`. */
15
16
  export type E2eAppMode = 'dev' | 'serve';
@@ -42,6 +43,29 @@ export async function xBin(root: string): Promise<string> {
42
43
  }
43
44
  const POLL_MS = 250;
44
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
+
45
69
  export interface SpawnedE2eApp {
46
70
  /** `http://localhost:<port>`, no trailing slash. */
47
71
  readonly base: string;
@@ -62,24 +86,24 @@ export interface SpawnE2eAppOptions {
62
86
  readonly readyTimeoutMs: number;
63
87
  /** `xBin(root)`, when the caller already resolved it; resolved here otherwise, in `dev` mode only. */
64
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;
65
91
  }
66
92
 
67
- /** A port nothing holds right now, asked of the OS and handed to the child. */
68
- const freePort = (): number => {
69
- const probe = Bun.serve({ port: 0, fetch: () => new Response() });
70
- const port = probe.port ?? 0;
71
- probe.stop(true);
72
- return port;
73
- };
74
-
75
93
  /**
76
94
  * This process's environment minus what makes the child a TEST process. Spawned from `bun test`,
77
95
  * `NODE_ENV=test` rode along and the app resolved its environment as `test` — so a development-only
78
96
  * seam (the demo viewer an app installs instead of a sign-in route) was off and every page 401'd.
79
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.
80
104
  */
81
105
  export const inherited = (): Record<string, string | undefined> => {
82
- const { NODE_ENV: _node, ...rest } = Bun.env;
106
+ const { NODE_ENV: _node, DATABASE_URL: _db, S3_ENDPOINT: _disk, ...rest } = Bun.env;
83
107
  return { ...rest, ULTIMATE_ENV: 'development' };
84
108
  };
85
109
 
@@ -114,14 +138,17 @@ export async function spawnE2eApp(options: SpawnE2eAppOptions): Promise<SpawnedE
114
138
  stdout: 'pipe',
115
139
  stderr: 'pipe',
116
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.
117
144
  const ready = async (child: ReturnType<typeof spawnApp>, tail: () => string): Promise<void> => {
118
- for (let waited = 0; waited < deadline; waited += POLL_MS) {
145
+ const until = performance.now() + deadline;
146
+ while (performance.now() < until) {
119
147
  if (child.exitCode !== null) break;
120
148
  if (await answersOk(`${base}/readyz`)) return;
121
149
  await Bun.sleep(POLL_MS);
122
150
  }
123
- child.kill();
124
- await child.exited;
151
+ await end(child, termGraceMs);
125
152
  throw refuse(`${command.join(' ')} never answered ${base}/readyz`, tail());
126
153
  };
127
154
 
@@ -133,8 +160,7 @@ export async function spawnE2eApp(options: SpawnE2eAppOptions): Promise<SpawnedE
133
160
  const stop = async (): Promise<void> => {
134
161
  if (stopped) return;
135
162
  stopped = true;
136
- child.kill();
137
- await child.exited;
163
+ await end(child, termGraceMs);
138
164
  };
139
165
  try {
140
166
  await ready(child, tail);
@@ -156,8 +182,7 @@ export async function spawnE2eApp(options: SpawnE2eAppOptions): Promise<SpawnedE
156
182
  );
157
183
  // The same port and the same state directory — a deploy, not a second app: a tab already
158
184
  // open on `base` sees the new build on its next request, and the data it wrote is still there.
159
- child.kill();
160
- await child.exited;
185
+ await end(child, termGraceMs);
161
186
  child = spawnApp(next);
162
187
  tail = drainTail(child.stdout, child.stderr);
163
188
  await ready(child, tail);
@@ -165,6 +190,22 @@ export async function spawnE2eApp(options: SpawnE2eAppOptions): Promise<SpawnedE
165
190
  };
166
191
  }
167
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
+
168
209
  const TAIL_CHARS = 16_000;
169
210
 
170
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',
@@ -48,6 +47,7 @@ export const TESTING_ERROR_CODES = [
48
47
  'X_TEST_OPENAPI_EXPECTED',
49
48
  'X_TEST_NUMBER_EXPECTED',
50
49
  'X_TEST_ISLAND_STATE_UNKNOWN',
50
+ 'X_TEST_APP_NOT_BOOTED',
51
51
  ] as const;
52
52
 
53
53
  export type TestingErrorCode = (typeof TESTING_ERROR_CODES)[number];
@@ -87,6 +87,7 @@ export const TESTING_ERROR_TITLES: Readonly<Record<TestingErrorCode, string>> =
87
87
  X_TEST_OPENAPI_EXPECTED: 'a matcher expected an OpenAPI document and got something else',
88
88
  X_TEST_NUMBER_EXPECTED: 'a matcher expected a finite number and got something else',
89
89
  X_TEST_ISLAND_STATE_UNKNOWN: 'an island state manifest declares no state with that id',
90
+ X_TEST_APP_NOT_BOOTED: 'describeApp handle read before boot',
90
91
  };
91
92
 
92
93
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -105,15 +106,29 @@ registerErrorCodes(
105
106
  // answered 404, host included, on every error it has ever thrown; restating the replacement here
106
107
  // would be the same constant in eight places waiting to drift again.
107
108
 
108
- /** 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
+ */
109
114
  export class NetworkSealedError extends UltimateError {
110
- 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`;
111
123
  super({
112
124
  code: 'X_TEST_NETWORK_SEALED',
113
125
  cause: `${input.method} ${input.url} was not mocked (allowed hosts: ${
114
126
  input.allowed.length > 0 ? input.allowed.join(', ') : 'none'
115
127
  })`,
116
- 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}`,
117
132
  });
118
133
  }
119
134
  }
@@ -135,7 +150,18 @@ export class NondeterministicError extends UltimateError {
135
150
  super({
136
151
  code: 'X_TEST_NONDETERMINISTIC',
137
152
  cause: `${input.what} produced "${input.first}" then "${input.second}"`,
138
- fix: 'wrap the test in frozenClock() / seededRandom(), or remove the wall-clock read',
153
+ fix: "assertDeterministic('…', () => { installDeterminism(); return body(); })",
154
+ });
155
+ }
156
+ }
157
+
158
+ /** `describeApp`'s `app()` read while the block was still being registered, before `beforeAll`. */
159
+ export class AppNotBootedError extends UltimateError {
160
+ constructor(input: { suite: string }) {
161
+ super({
162
+ code: 'X_TEST_APP_NOT_BOOTED',
163
+ cause: `describeApp('${input.suite}') — app() was called while the describe block was being registered, before its beforeAll booted the app`,
164
+ fix: "test('…', () => { const handle = app(); })",
139
165
  });
140
166
  }
141
167
  }
@@ -346,64 +372,6 @@ export class NetworkRaceError extends UltimateError {
346
372
  }
347
373
  }
348
374
 
349
- /**
350
- * Every value in this message is uncontrolled: the path arrives from `Bun.plugin`'s `onLoad`, and
351
- * the names are whatever the app under test passed to `declareTags()` / `registerTier()`. So the
352
- * sentence renders them (bounded, never throwing while describing a leak) and the command quotes
353
- * them (a fix has to parse after a path with a space or a quote in it lands in the middle of it).
354
- */
355
- const describeLeak = (leak: RegistryLeak): string => {
356
- const left = [
357
- ...(leak.tags.length > 0 ? [`cache tags declared ${renderCauseValue(leak.tags)}`] : []),
358
- ...(leak.tiers.length > 0 ? [`cache tiers registered ${renderCauseValue(leak.tiers)}`] : []),
359
- ];
360
- return `${renderCauseValue(leak.file)} left ${left.join(' and ')} after its last test`;
361
- };
362
-
363
- /** The placeholder is the cause's own sentence: it already names every file, in order. */
364
- const FILE_PLACEHOLDER = '<the file the cause names>';
365
-
366
- /**
367
- * The edit, spelled as the lines to paste — the imports included, since neither call is global.
368
- *
369
- * Both repairs isolate; neither resets. `afterAll(resetTiers)` was the first spelling of the tier
370
- * half and it is the bug wearing the fix's clothes: it drops the tiers, the revalidator and both
371
- * logs a NEIGHBOUR registered, and the guard reports additions only, so the damage lands on an
372
- * innocent file with nothing pointing back here. An error whose instruction causes the next defect
373
- * is worse than no instruction.
374
- */
375
- const repairFor = (leak: RegistryLeak): string => {
376
- const imports: string[] = [];
377
- const calls: string[] = [];
378
- if (leak.tags.length > 0) {
379
- imports.push('isolateDeclaredTags');
380
- calls.push('const restoreTags = isolateDeclaredTags(); afterAll(restoreTags);');
381
- }
382
- if (leak.tiers.length > 0) {
383
- imports.push('isolateTiers');
384
- calls.push('const restoreTiers = isolateTiers(); afterAll(restoreTiers);');
385
- }
386
- const file = renderFixLiteral(leak.file, FILE_PLACEHOLDER);
387
- return `in ${file} add: import { ${imports.join(', ')} } from '@ultimat3/cache'; ${calls.join(' ')}`;
388
- };
389
-
390
- /**
391
- * One error for every leaker in the run, not one per file: the run has already finished by the
392
- * time the last file can be judged, and two throws would report the second as an unhandled one.
393
- * The trailing command reproduces the failure on its own — the guard judges each file against its
394
- * own baseline, so one leaking file fails a one-file run exactly as it failed the whole suite.
395
- */
396
- export class RegistryLeakError extends UltimateError {
397
- constructor(input: { readonly leaks: readonly RegistryLeak[] }) {
398
- const files = input.leaks.map((leak) => renderFixLiteral(leak.file, FILE_PLACEHOLDER));
399
- super({
400
- code: 'X_TEST_REGISTRY_LEAK',
401
- cause: input.leaks.map(describeLeak).join('; '),
402
- fix: `${input.leaks.map(repairFor).join('; ')} — then re-run: bun test ${files.join(' ')}`,
403
- });
404
- }
405
- }
406
-
407
375
  /** Neither path is controlled by the framework: both arrive from the test's own call. */
408
376
  const ISLAND_PLACEHOLDER = '<the island path the cause names>';
409
377
 
package/src/factories.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  // caller composes, an association is a column whose value comes from another factory built with
7
7
  // the SAME strategy — `build()` leaves the parent in memory, `create()` writes it.
8
8
 
9
+ import { fnv1a } from '@ultimat3/core';
9
10
  import { seededRandom, seededUuid } from './determinism';
10
11
  import { FactoryTraitUnknownError } from './errors';
11
12
  import { persistRow } from './factory-persist';
@@ -91,15 +92,12 @@ export interface Factory<TRow, TTrait extends string = string> {
91
92
 
92
93
  /**
93
94
  * Two factories with the same seed emit the same uuids, so a default of `1` everywhere gave a user
94
- * and a post the same id — rows that only look related. FNV-1a over the table name keeps every
95
- * table on its own stream while staying a pure function of the schema, so the ids are still
95
+ * and a post the same id — rows that only look related. Core's FNV-1a over the table name keeps
96
+ * every table on its own stream while staying a pure function of the schema, so the ids are still
96
97
  * identical run to run and machine to machine.
97
98
  */
98
99
  export function seedFor(table: string): number {
99
- let hash = 0x811c9dc5;
100
- for (let i = 0; i < table.length; i += 1) {
101
- hash = Math.imul(hash ^ table.charCodeAt(i), 0x01000193) >>> 0;
102
- }
100
+ const hash = fnv1a(table);
103
101
  return hash === 0 ? 1 : hash;
104
102
  }
105
103
 
@@ -119,13 +119,8 @@ export function restoreGlobals(snapshot: Baseline, host: object = globalThis): r
119
119
  return touched;
120
120
  }
121
121
 
122
- /** Called by the leak guard as each test file loads. The first call only takes the baseline. */
123
- export function runFileBoundary(): void {
124
- if (baseline === undefined) {
125
- baseline = captureGlobals();
126
- envBaseline = { ...process.env };
127
- return;
128
- }
122
+ /** Every `onFileBoundary` hook, in order — also run once after the run's last file. */
123
+ export function runBoundaryHooks(): void {
129
124
  for (const hook of hooks) {
130
125
  try {
131
126
  hook();
@@ -133,6 +128,16 @@ export function runFileBoundary(): void {
133
128
  // A hook that throws (an island's own disposer) must not stop the rest of the reset.
134
129
  }
135
130
  }
131
+ }
132
+
133
+ /** Called by the leak guard as each test file loads. The first call only takes the baseline. */
134
+ export function runFileBoundary(): void {
135
+ if (baseline === undefined) {
136
+ baseline = captureGlobals();
137
+ envBaseline = { ...process.env };
138
+ return;
139
+ }
140
+ runBoundaryHooks();
136
141
  restoreGlobals(baseline);
137
142
  if (envBaseline !== undefined) restoreEnv(envBaseline);
138
143
  }
@@ -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 {