@ultimat3/testing 24.0.0 → 25.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +12 -12
- package/README.md +55 -18
- package/package.json +16 -16
- package/src/auth-request.ts +1 -1
- package/src/cdp-browser-lease.ts +112 -0
- package/src/cdp-browser.ts +42 -1
- package/src/cdp-connection.ts +18 -4
- package/src/cdp-e2e-session.ts +120 -12
- package/src/cdp-errors.ts +25 -9
- package/src/cdp-launch-attempt.ts +112 -80
- package/src/cdp-launch-candidates.ts +62 -0
- package/src/cdp-launch-profile.ts +106 -0
- package/src/cdp-launch-reap.ts +68 -0
- package/src/cdp-launch-wire.ts +171 -0
- package/src/cdp-launch.ts +47 -22
- package/src/define-island-states.ts +47 -8
- package/src/determinism.ts +2 -2
- package/src/e2e-app.ts +14 -1
- package/src/e2e-errors.ts +14 -3
- package/src/e2e-locale.ts +12 -21
- package/src/e2e-page.ts +12 -26
- package/src/e2e-run.ts +21 -11
- package/src/e2e-service-worker.ts +92 -0
- package/src/e2e-spawn.ts +57 -9
- package/src/errors.ts +20 -65
- package/src/fixture-clock.ts +1 -1
- package/src/fixture-island.ts +34 -55
- package/src/fixture-jobs.ts +7 -7
- package/src/fixture-mail.ts +3 -3
- package/src/fixture-network.ts +1 -1
- package/src/fixture-statements.ts +1 -1
- package/src/fixture-subscribe.ts +3 -3
- package/src/framework-fixtures.ts +13 -13
- package/src/index.ts +17 -10
- package/src/island-dom.ts +49 -9
- package/src/island-globals.ts +73 -0
- package/src/island-scratch.ts +67 -3
- package/src/island-state-errors.ts +16 -0
- package/src/island-states.ts +23 -0
- package/src/live-node.ts +4 -4
- package/src/quiet-logs.ts +1 -1
- package/src/registry-leak-error.ts +81 -0
- package/src/registry-leak-guard.ts +4 -3
- package/src/registry-snapshot.ts +19 -1
- package/src/render-view.ts +3 -3
- package/src/sealed-network.ts +45 -4
- package/src/sealed-sockets.ts +59 -0
- package/src/template-db.ts +2 -2
- package/src/test-types.ts +6 -2
package/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
|
-
|
|
39
|
-
readonly
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
10
|
-
//
|
|
11
|
-
|
|
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
|
-
/**
|
|
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: {
|
|
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:
|
|
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
|
|
package/src/fixture-clock.ts
CHANGED
|
@@ -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
|
|
36
|
+
export async function testClock(): Promise<TestClock> {
|
|
37
37
|
const { toMs } = await import('@ultimat3/time');
|
|
38
38
|
const captured = captureDeterminism();
|
|
39
39
|
return {
|
package/src/fixture-island.ts
CHANGED
|
@@ -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
|
|
198
|
-
*
|
|
199
|
-
*
|
|
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;
|
|
@@ -267,24 +255,16 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
|
|
|
267
255
|
// island's `mount` resumed AFTER the `restore()` below had taken the fake `document` back out
|
|
268
256
|
// — so it failed with `document is not defined` inside whichever later test happened to be
|
|
269
257
|
// running, with no thread back here.
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
//
|
|
274
|
-
// disposer is idempotent while an island's `clearInterval` wrapper need not be.
|
|
275
|
-
if (disposed) return;
|
|
276
|
-
disposed = true;
|
|
277
|
-
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.
|
|
278
262
|
try {
|
|
279
|
-
|
|
280
|
-
}
|
|
281
|
-
//
|
|
282
|
-
// gets its globals back, or the fake `document` reaches every later file in the run.
|
|
283
|
-
restore();
|
|
284
|
-
removeScratchDir(scratch);
|
|
263
|
+
stop?.();
|
|
264
|
+
} catch {
|
|
265
|
+
// Its `document` is already gone; the stop was best-effort for that reason.
|
|
285
266
|
}
|
|
286
|
-
};
|
|
287
|
-
liveMounts.add(dispose);
|
|
267
|
+
} else unmount = stop;
|
|
288
268
|
const resolve = (target: string | FakeElement | null | undefined): FakeElement | null =>
|
|
289
269
|
typeof target === 'string' ? el.querySelector(target) : (target ?? null);
|
|
290
270
|
return {
|
|
@@ -320,8 +300,7 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
|
|
|
320
300
|
} catch (error) {
|
|
321
301
|
// A mount that throws restores the process before it rethrows: the alternative leaves every
|
|
322
302
|
// later file in the run holding a fake `document`, which fails somewhere with no thread back.
|
|
323
|
-
|
|
324
|
-
if (dir !== undefined) removeScratchDir(dir);
|
|
303
|
+
dispose();
|
|
325
304
|
throw error;
|
|
326
305
|
}
|
|
327
306
|
}
|
package/src/fixture-jobs.ts
CHANGED
|
@@ -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,
|
|
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
|
|
111
|
+
export async function testJobs(): Promise<RunJobs> {
|
|
112
112
|
const jobs = await import('@ultimat3/jobs');
|
|
113
|
-
const driver: JobDriver = jobs.
|
|
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.
|
|
123
|
-
const anonymousWorker =
|
|
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.
|
|
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
|
-
:
|
|
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) };
|
package/src/fixture-mail.ts
CHANGED
|
@@ -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
|
|
25
|
-
const {
|
|
24
|
+
export async function testMail(): Promise<TestMail> {
|
|
25
|
+
const { memoryMailDriver, driverUnavailable, resetMailDriver, setMailDriver, tryMailDriver } =
|
|
26
26
|
await import('@ultimat3/mail');
|
|
27
|
-
const memory =
|
|
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();
|
package/src/fixture-network.ts
CHANGED
|
@@ -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
|
|
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
|
|
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');
|
package/src/fixture-subscribe.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
61
|
+
export async function subscribeDriver(): Promise<SubscribeDriver> {
|
|
62
62
|
const realtime = await import('@ultimat3/realtime');
|
|
63
|
-
const node = await
|
|
63
|
+
const node = await liveNode();
|
|
64
64
|
const replicator = await startLiveReplicator({ registry: node.registry });
|
|
65
65
|
const connections: LiveConnection[] = [];
|
|
66
66
|
let sid = 0;
|