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