@ultimat3/testing 23.0.0 → 24.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 +9 -9
- package/README.md +50 -5
- package/package.json +15 -15
- package/src/budget-store-conformance.ts +81 -0
- package/src/cdp-browser.ts +5 -10
- package/src/cdp-connection.ts +23 -6
- package/src/cdp-e2e-session.ts +10 -1
- package/src/cdp-errors.ts +113 -8
- package/src/cdp-launch-attempt.ts +192 -0
- package/src/cdp-launch.ts +49 -122
- package/src/cdp-offline-script.ts +33 -3
- package/src/cdp-wire-watch.ts +113 -0
- package/src/determinism.ts +33 -4
- package/src/e2e-app.ts +3 -0
- package/src/e2e-run.ts +2 -2
- package/src/e2e-spawn.ts +1 -8
- package/src/errors.ts +14 -1
- package/src/factories.ts +4 -6
- package/src/file-boundary.ts +12 -7
- package/src/fixture-island.ts +18 -45
- package/src/fixture-jobs.ts +9 -5
- package/src/free-port.ts +46 -0
- package/src/harness.ts +2 -1
- package/src/index.ts +12 -0
- package/src/island-dom.ts +8 -4
- package/src/island-html-entities.ts +27 -0
- package/src/island-scratch.ts +41 -0
- package/src/job-driver-checks.ts +177 -0
- package/src/job-driver-conformance.ts +21 -0
- package/src/live-node.ts +2 -0
- package/src/preload.ts +2 -8
- package/src/registry-leak-guard.ts +3 -1
- package/src/registry-snapshot.ts +22 -1
- package/src/sealed-network.ts +5 -1
- package/src/template-db.ts +6 -3
package/src/e2e-spawn.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { get } from 'node:http';
|
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
11
|
import { assert } 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';
|
|
@@ -64,14 +65,6 @@ export interface SpawnE2eAppOptions {
|
|
|
64
65
|
readonly bin?: string | undefined;
|
|
65
66
|
}
|
|
66
67
|
|
|
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
68
|
/**
|
|
76
69
|
* This process's environment minus what makes the child a TEST process. Spawned from `bun test`,
|
|
77
70
|
* `NODE_ENV=test` rode along and the app resolved its environment as `test` — so a development-only
|
package/src/errors.ts
CHANGED
|
@@ -48,6 +48,7 @@ export const TESTING_ERROR_CODES = [
|
|
|
48
48
|
'X_TEST_OPENAPI_EXPECTED',
|
|
49
49
|
'X_TEST_NUMBER_EXPECTED',
|
|
50
50
|
'X_TEST_ISLAND_STATE_UNKNOWN',
|
|
51
|
+
'X_TEST_APP_NOT_BOOTED',
|
|
51
52
|
] as const;
|
|
52
53
|
|
|
53
54
|
export type TestingErrorCode = (typeof TESTING_ERROR_CODES)[number];
|
|
@@ -87,6 +88,7 @@ export const TESTING_ERROR_TITLES: Readonly<Record<TestingErrorCode, string>> =
|
|
|
87
88
|
X_TEST_OPENAPI_EXPECTED: 'a matcher expected an OpenAPI document and got something else',
|
|
88
89
|
X_TEST_NUMBER_EXPECTED: 'a matcher expected a finite number and got something else',
|
|
89
90
|
X_TEST_ISLAND_STATE_UNKNOWN: 'an island state manifest declares no state with that id',
|
|
91
|
+
X_TEST_APP_NOT_BOOTED: 'describeApp handle read before boot',
|
|
90
92
|
};
|
|
91
93
|
|
|
92
94
|
// Titles must be registered for `format()` to render the contract's first line. Every code above is
|
|
@@ -135,7 +137,18 @@ export class NondeterministicError extends UltimateError {
|
|
|
135
137
|
super({
|
|
136
138
|
code: 'X_TEST_NONDETERMINISTIC',
|
|
137
139
|
cause: `${input.what} produced "${input.first}" then "${input.second}"`,
|
|
138
|
-
fix: '
|
|
140
|
+
fix: "assertDeterministic('…', () => { installDeterminism(); return body(); })",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `describeApp`'s `app()` read while the block was still being registered, before `beforeAll`. */
|
|
146
|
+
export class AppNotBootedError extends UltimateError {
|
|
147
|
+
constructor(input: { suite: string }) {
|
|
148
|
+
super({
|
|
149
|
+
code: 'X_TEST_APP_NOT_BOOTED',
|
|
150
|
+
cause: `describeApp('${input.suite}') — app() was called while the describe block was being registered, before its beforeAll booted the app`,
|
|
151
|
+
fix: "test('…', () => { const handle = app(); })",
|
|
139
152
|
});
|
|
140
153
|
}
|
|
141
154
|
}
|
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
|
|
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
|
-
|
|
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
|
|
package/src/file-boundary.ts
CHANGED
|
@@ -119,13 +119,8 @@ export function restoreGlobals(snapshot: Baseline, host: object = globalThis): r
|
|
|
119
119
|
return touched;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
/**
|
|
123
|
-
export function
|
|
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
|
}
|
package/src/fixture-island.ts
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
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';
|
|
11
10
|
import type { ResizeInput } from './island-observers';
|
|
12
11
|
import { deliverResize } from './island-observers';
|
|
12
|
+
import { createScratchDir, removeScratchDir } from './island-scratch';
|
|
13
13
|
|
|
14
14
|
/** The two fields a mounted island needs from a chunk. Anything else a bundler grows — a CSS
|
|
15
15
|
* artifact, a source map, a dev/production flag — is invisible here on purpose. */
|
|
@@ -139,49 +139,13 @@ function entryOf(module: unknown, file: string): IslandEntry {
|
|
|
139
139
|
return { mount: mount as IslandEntry['mount'] };
|
|
140
140
|
}
|
|
141
141
|
|
|
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
142
|
/**
|
|
174
|
-
* A temp
|
|
175
|
-
*
|
|
176
|
-
*
|
|
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.
|
|
143
|
+
* A temp FILE named by the chunk's own hash, NOT a `data:` URL: `bun test --coverage` panics on
|
|
144
|
+
* `import()` of a `data:` module past ~4 kB (Bun 1.4.0) and an island chunk is 12-55 kB. The file
|
|
145
|
+
* sits in the mount's OWN directory (`island-scratch.ts`), so a mount is a fresh module instance.
|
|
182
146
|
*/
|
|
183
|
-
const modulePathFor = (code: string): string =>
|
|
184
|
-
join(
|
|
147
|
+
const modulePathFor = (dir: string, code: string): string =>
|
|
148
|
+
join(dir, `${Bun.SHA256.hash(code, 'hex').slice(0, 16)}.mjs`);
|
|
185
149
|
|
|
186
150
|
/**
|
|
187
151
|
* DESCRIPTORS, not values, and all-or-nothing.
|
|
@@ -249,6 +213,10 @@ export function disposeLiveIslands(): number {
|
|
|
249
213
|
return pending.length;
|
|
250
214
|
}
|
|
251
215
|
|
|
216
|
+
// Registered here, not by each preload: the repo's own preload and an app's both install the leak
|
|
217
|
+
// guard that runs the boundary, and a mount a file forgot is one only this module can see.
|
|
218
|
+
onFileBoundary(disposeLiveIslands);
|
|
219
|
+
|
|
252
220
|
export async function mountIsland(options: MountIslandOptions): Promise<MountedIsland> {
|
|
253
221
|
// `only`, because this fixture has always KNOWN which island it wants and asked for all of them
|
|
254
222
|
// anyway — `island-bundle.ts` added the option for exactly this caller ("a test that mounts a
|
|
@@ -275,14 +243,17 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
|
|
|
275
243
|
|
|
276
244
|
const { documentElement, globals, resizeObservers } = createIslandDocument();
|
|
277
245
|
const restore = installGlobals({ ...globals, ...options.globals });
|
|
246
|
+
let dir: string | undefined;
|
|
278
247
|
try {
|
|
248
|
+
dir = createScratchDir();
|
|
249
|
+
const scratch = dir;
|
|
279
250
|
// Beside the entry, under the name its bytes import them by: the same relative resolution a
|
|
280
251
|
// browser does under `/islands/`. Named by their content-addressed URL, so a rewrite is the same
|
|
281
252
|
// bytes at the same name.
|
|
282
253
|
for (const shared of bundle.shared ?? []) {
|
|
283
|
-
await Bun.write(join(
|
|
254
|
+
await Bun.write(join(scratch, basename(shared.url)), shared.code);
|
|
284
255
|
}
|
|
285
|
-
const path = modulePathFor(chunk.code);
|
|
256
|
+
const path = modulePathFor(scratch, chunk.code);
|
|
286
257
|
await Bun.write(path, chunk.code);
|
|
287
258
|
const entry = entryOf(await import(path), chunk.file);
|
|
288
259
|
const el = new FakeElement('div');
|
|
@@ -310,6 +281,7 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
|
|
|
310
281
|
// Whatever the disposer did — including throw, which is the test's to see — the process
|
|
311
282
|
// gets its globals back, or the fake `document` reaches every later file in the run.
|
|
312
283
|
restore();
|
|
284
|
+
removeScratchDir(scratch);
|
|
313
285
|
}
|
|
314
286
|
};
|
|
315
287
|
liveMounts.add(dispose);
|
|
@@ -349,6 +321,7 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
|
|
|
349
321
|
// A mount that throws restores the process before it rethrows: the alternative leaves every
|
|
350
322
|
// later file in the run holding a fake `document`, which fails somewhere with no thread back.
|
|
351
323
|
restore();
|
|
324
|
+
if (dir !== undefined) removeScratchDir(dir);
|
|
352
325
|
throw error;
|
|
353
326
|
}
|
|
354
327
|
}
|
package/src/fixture-jobs.ts
CHANGED
|
@@ -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/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
|
|
@@ -50,6 +52,12 @@ export type { CdpE2eTabOptions, E2eTab } from './cdp-e2e-page';
|
|
|
50
52
|
export { cdpE2eTab } from './cdp-e2e-page';
|
|
51
53
|
export type { CdpE2eSessionOptions, E2eSession } from './cdp-e2e-session';
|
|
52
54
|
export { cdpE2eSession } from './cdp-e2e-session';
|
|
55
|
+
export type {
|
|
56
|
+
CdpLaunchAttempt,
|
|
57
|
+
CdpTargetGone,
|
|
58
|
+
CdpTimeoutObservation,
|
|
59
|
+
CdpTimeoutReading,
|
|
60
|
+
} from './cdp-errors';
|
|
53
61
|
export {
|
|
54
62
|
CdpBrowserMissingError,
|
|
55
63
|
CdpCallFailedError,
|
|
@@ -62,6 +70,8 @@ export {
|
|
|
62
70
|
CHROME_PATH_ENV,
|
|
63
71
|
CONTAINER_CHROME_ARGS,
|
|
64
72
|
findChrome,
|
|
73
|
+
LAUNCH_ATTEMPTS,
|
|
74
|
+
LAUNCH_TIMEOUT_MS,
|
|
65
75
|
launchChrome,
|
|
66
76
|
launchFoundChrome,
|
|
67
77
|
} from './cdp-launch';
|
|
@@ -121,6 +131,7 @@ export {
|
|
|
121
131
|
} from './e2e-selection';
|
|
122
132
|
export type { TestingErrorCode } from './errors';
|
|
123
133
|
export {
|
|
134
|
+
AppNotBootedError,
|
|
124
135
|
FixtureUnavailableError,
|
|
125
136
|
NetworkOfflineError,
|
|
126
137
|
NetworkSealedError,
|
|
@@ -271,6 +282,7 @@ export {
|
|
|
271
282
|
normalizeIslandName,
|
|
272
283
|
} from './island-states-resolve';
|
|
273
284
|
export { ISOLATED_ENV, releasePluginsAfterIsolatedFile } from './isolated-plugins';
|
|
285
|
+
export { jobDriverConformance } from './job-driver-conformance';
|
|
274
286
|
export type { LiveConnection, LiveNodeHandle, LiveNodeOptions } from './live-node';
|
|
275
287
|
export { createLiveNode } from './live-node';
|
|
276
288
|
// The store a mutator's `local()` half writes into under test — the Map every app hand-rolled.
|
package/src/island-dom.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// Two neighbours at the 500-line ceiling: `island-selector.ts` is the selector grammar `find`
|
|
7
7
|
// and `all` read, and `island-observers.ts` is the `ResizeObserver` an island measures with.
|
|
8
8
|
|
|
9
|
+
import { decodeEntities } from './island-html-entities';
|
|
9
10
|
import type { FakeResizeObserver } from './island-observers';
|
|
10
11
|
import { createResizeObservers, rectOf } from './island-observers';
|
|
11
12
|
import { matchesSelector, parseSelector } from './island-selector';
|
|
@@ -350,8 +351,10 @@ const VOID_TAGS = new Set([
|
|
|
350
351
|
'track',
|
|
351
352
|
'wbr',
|
|
352
353
|
]);
|
|
354
|
+
// Group 6 is a comment: Solid compiles `<!>` as the marker its `nextSibling` walk counts, so it must
|
|
355
|
+
// be a node — read as text it merged into its neighbours and the walk landed one node short.
|
|
353
356
|
const TOKEN =
|
|
354
|
-
/<(\/?)([a-zA-Z][\w-]*)((?:\s+[^\s=/>]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>|([^<]+)/g;
|
|
357
|
+
/<(\/?)([a-zA-Z][\w-]*)((?:\s+[^\s=/>]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>|([^<]+)|(<!(?:--[\s\S]*?--|[^>]*)>)/g;
|
|
355
358
|
const ATTRIBUTE = /([^\s=/>]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
356
359
|
|
|
357
360
|
/** Only what `babel-preset-solid` emits into a template: tags, static attributes and text. */
|
|
@@ -360,8 +363,8 @@ export function parseHtml(html: string): FakeNode {
|
|
|
360
363
|
const open: FakeNode[] = [root];
|
|
361
364
|
for (const match of html.matchAll(TOKEN)) {
|
|
362
365
|
const parent = open[open.length - 1] as FakeNode;
|
|
363
|
-
if (match[5] !== undefined) {
|
|
364
|
-
parent.appendChild(new FakeText(match[5]));
|
|
366
|
+
if (match[5] !== undefined || match[6] !== undefined) {
|
|
367
|
+
parent.appendChild(new FakeText(decodeEntities(match[5] ?? '')));
|
|
365
368
|
continue;
|
|
366
369
|
}
|
|
367
370
|
if (match[1] === '/') {
|
|
@@ -370,7 +373,8 @@ export function parseHtml(html: string): FakeNode {
|
|
|
370
373
|
}
|
|
371
374
|
const element = new FakeElement(match[2] as string);
|
|
372
375
|
for (const attr of (match[3] ?? '').matchAll(ATTRIBUTE)) {
|
|
373
|
-
if (attr[1] !== undefined)
|
|
376
|
+
if (attr[1] !== undefined)
|
|
377
|
+
element.setAttribute(attr[1], decodeEntities(attr[2] ?? attr[3] ?? attr[4] ?? ''));
|
|
374
378
|
}
|
|
375
379
|
parent.appendChild(element);
|
|
376
380
|
if (match[4] !== '/' && !VOID_TAGS.has(element.tagName)) open.push(element);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Character references in a compiled island template, decoded once — what a browser's parser does
|
|
2
|
+
// before an island reads `textContent` or `getAttribute`.
|
|
3
|
+
|
|
4
|
+
const NAMED = Object.freeze<Record<string, string>>({
|
|
5
|
+
amp: '&',
|
|
6
|
+
lt: '<',
|
|
7
|
+
gt: '>',
|
|
8
|
+
quot: '"',
|
|
9
|
+
apos: "'",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const REFERENCE = /&(?:#(\d+)|#[xX]([0-9a-fA-F]+)|([a-zA-Z]+));/g;
|
|
13
|
+
const MAX_CODE_POINT = 0x10ffff;
|
|
14
|
+
const REPLACEMENT = '\uFFFD';
|
|
15
|
+
|
|
16
|
+
/** The five named entities Solid's escaper writes, and numeric references; an unknown name as written. */
|
|
17
|
+
export function decodeEntities(text: string): string {
|
|
18
|
+
if (!text.includes('&')) return text;
|
|
19
|
+
return text.replace(REFERENCE, (whole, decimal?: string, hex?: string, name?: string) => {
|
|
20
|
+
if (name !== undefined) return Object.hasOwn(NAMED, name) ? (NAMED[name] as string) : whole;
|
|
21
|
+
const point = decimal !== undefined ? Number(decimal) : Number.parseInt(hex as string, 16);
|
|
22
|
+
// What the HTML parser does with a reference no character answers to: U+FFFD, never `\0` or a
|
|
23
|
+
// lone surrogate.
|
|
24
|
+
const invalid = point === 0 || point > MAX_CODE_POINT || (point >= 0xd800 && point <= 0xdfff);
|
|
25
|
+
return invalid ? REPLACEMENT : String.fromCodePoint(point);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The island fixture's scratch directories: one per mount, created when the mount needs it and
|
|
2
|
+
// removed when the mount ends. A per-PROCESS directory left one behind per `bun test` run, because
|
|
3
|
+
// `process.on('exit')` never fires under `bun test` (Bun 1.4.0, measured) — 5,000+ in `/tmp`.
|
|
4
|
+
|
|
5
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
6
|
+
// why: Bun exposes no tmpdir(), so only node:os answers the platform temp root.
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
const live = new Set<string>();
|
|
11
|
+
let exitHooked = false;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A fresh directory for one mount. Fresh, not shared: a module is cached by PATH, so two mounts
|
|
15
|
+
* of byte-identical chunks under one directory imported as ONE instance and shared its state.
|
|
16
|
+
*
|
|
17
|
+
* The `exit` handler is the backstop for a process that is not `bun test` (`bun run`, `x shot`);
|
|
18
|
+
* under `bun test` the file boundary and the run's `afterAll` dispose every mount instead.
|
|
19
|
+
*/
|
|
20
|
+
export function createScratchDir(): string {
|
|
21
|
+
const dir = mkdtempSync(join(tmpdir(), 'ultimate-island-'));
|
|
22
|
+
live.add(dir);
|
|
23
|
+
if (!exitHooked) {
|
|
24
|
+
exitHooked = true;
|
|
25
|
+
process.on('exit', removeAllScratchDirs);
|
|
26
|
+
}
|
|
27
|
+
return dir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Remove one mount's directory. Idempotent: dispose and a failed mount may both reach it. */
|
|
31
|
+
export function removeScratchDir(dir: string): void {
|
|
32
|
+
live.delete(dir);
|
|
33
|
+
rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function removeAllScratchDirs(): void {
|
|
37
|
+
for (const dir of [...live]) removeScratchDir(dir);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Every scratch directory whose mount has not ended — read by the leak test. */
|
|
41
|
+
export const islandScratchDirs = (): readonly string[] => [...live];
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// What every `JobDriver` must do, as checks over a driver: claim, ack, nack, the claim fence, lease
|
|
2
|
+
// expiry and the burial of a row whose lease lapsed on its final attempt. A custom `claim` that
|
|
3
|
+
// ignored `onExhausted` / `dropExhausted` compiled and re-claimed a poisoned row forever.
|
|
4
|
+
// Type-only imports: the barrel never loads `@ultimat3/jobs`.
|
|
5
|
+
|
|
6
|
+
import { expect } from 'bun:test';
|
|
7
|
+
import type { ClaimOptions, JobDriver, JobRecord, QueueStats } from '@ultimat3/jobs';
|
|
8
|
+
|
|
9
|
+
export interface JobDriverCheck {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
run(driver: JobDriver): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let minted = 0;
|
|
15
|
+
|
|
16
|
+
/** A queue and a job name of this check's own, so checks sharing one store never see each other. */
|
|
17
|
+
const scope = (): { readonly queue: string; readonly name: string } => {
|
|
18
|
+
minted += 1;
|
|
19
|
+
const tag = `${process.pid.toString(36)}-${minted.toString(36)}`;
|
|
20
|
+
return { queue: `conformance-${tag}`, name: `conformance.job-${tag}` };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const enqueue = async (
|
|
24
|
+
driver: JobDriver,
|
|
25
|
+
at: { readonly queue: string; readonly name: string },
|
|
26
|
+
maxAttempts: number,
|
|
27
|
+
key = 'k',
|
|
28
|
+
): Promise<string> =>
|
|
29
|
+
(
|
|
30
|
+
await driver.enqueue({
|
|
31
|
+
name: at.name,
|
|
32
|
+
queue: at.queue,
|
|
33
|
+
input: {},
|
|
34
|
+
idempotencyKey: key,
|
|
35
|
+
maxAttempts,
|
|
36
|
+
})
|
|
37
|
+
).id;
|
|
38
|
+
|
|
39
|
+
const claim = (
|
|
40
|
+
driver: JobDriver,
|
|
41
|
+
queue: string,
|
|
42
|
+
workerId: string,
|
|
43
|
+
extra: Partial<ClaimOptions> = {},
|
|
44
|
+
) => driver.claim({ queues: [queue], limit: 10, visibilityTimeoutMs: 30_000, workerId, ...extra });
|
|
45
|
+
|
|
46
|
+
const statsOf = async (driver: JobDriver, queue: string): Promise<QueueStats | undefined> =>
|
|
47
|
+
(await driver.stats()).find((each) => each.queue === queue);
|
|
48
|
+
|
|
49
|
+
interface Lapsed {
|
|
50
|
+
readonly id: string;
|
|
51
|
+
readonly queue: string;
|
|
52
|
+
readonly reported: readonly (readonly JobRecord[])[];
|
|
53
|
+
/** How many rows the second claim handed out. */
|
|
54
|
+
readonly handed: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Two claims, the first with a lease that has already lapsed (0 ms): the second re-claims the row
|
|
59
|
+
* or, on its final attempt, buries it. `drop` names the job in `dropExhausted`.
|
|
60
|
+
*/
|
|
61
|
+
async function lapse(driver: JobDriver, maxAttempts: number, drop = false): Promise<Lapsed> {
|
|
62
|
+
const at = scope();
|
|
63
|
+
const id = await enqueue(driver, at, maxAttempts);
|
|
64
|
+
expect(await claim(driver, at.queue, 'w1', { visibilityTimeoutMs: 0 })).toHaveLength(1);
|
|
65
|
+
const reported: JobRecord[][] = [];
|
|
66
|
+
const second = await claim(driver, at.queue, 'w2', {
|
|
67
|
+
...(drop ? { dropExhausted: [at.name] } : {}),
|
|
68
|
+
onExhausted: (dead) => reported.push([...dead]),
|
|
69
|
+
});
|
|
70
|
+
return { id, queue: at.queue, reported, handed: second.length };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const buried = (lapsed: Lapsed): readonly (readonly [string, string])[][] =>
|
|
74
|
+
lapsed.reported.map((rows) => rows.map((row) => [row.id, row.state] as const));
|
|
75
|
+
|
|
76
|
+
export const JOB_DRIVER_CHECKS: readonly JobDriverCheck[] = [
|
|
77
|
+
{
|
|
78
|
+
name: 'a row is claimed once, and an ack by its claim settles it',
|
|
79
|
+
async run(driver) {
|
|
80
|
+
const at = scope();
|
|
81
|
+
const id = await enqueue(driver, at, 3);
|
|
82
|
+
const [first, ...rest] = await claim(driver, at.queue, 'w1');
|
|
83
|
+
expect(rest).toEqual([]);
|
|
84
|
+
expect(first).toMatchObject({ id, state: 'running', claimedBy: 'w1' });
|
|
85
|
+
expect(await claim(driver, at.queue, 'w2')).toEqual([]);
|
|
86
|
+
expect(await driver.ack(id, { workerId: 'w1', claim: first?.claim ?? -1 })).toBe(true);
|
|
87
|
+
expect(await claim(driver, at.queue, 'w2')).toEqual([]);
|
|
88
|
+
expect((await statsOf(driver, at.queue))?.running ?? 0).toBe(0);
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'a live idempotency key dedupes onto the row that holds it',
|
|
93
|
+
async run(driver) {
|
|
94
|
+
const at = scope();
|
|
95
|
+
const id = await enqueue(driver, at, 3, 'same');
|
|
96
|
+
const again = await driver.enqueue({
|
|
97
|
+
name: at.name,
|
|
98
|
+
queue: at.queue,
|
|
99
|
+
input: {},
|
|
100
|
+
idempotencyKey: 'same',
|
|
101
|
+
maxAttempts: 3,
|
|
102
|
+
});
|
|
103
|
+
expect(again).toMatchObject({ id, deduped: true });
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: 'an empty queue list is refused, never read as every queue or as default',
|
|
108
|
+
async run(driver) {
|
|
109
|
+
const refused = await Promise.resolve()
|
|
110
|
+
.then(() => driver.claim({ queues: [], limit: 1, visibilityTimeoutMs: 1, workerId: 'w' }))
|
|
111
|
+
.then(
|
|
112
|
+
() => undefined,
|
|
113
|
+
(error: unknown) => error,
|
|
114
|
+
);
|
|
115
|
+
expect(refused).toMatchObject({ code: 'X_JOB_CLAIM_QUEUES_EMPTY' });
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'a nack hands the row back, and the claim it ended can no longer settle it',
|
|
120
|
+
async run(driver) {
|
|
121
|
+
const at = scope();
|
|
122
|
+
const id = await enqueue(driver, at, 3);
|
|
123
|
+
const [first] = await claim(driver, at.queue, 'w1');
|
|
124
|
+
const one = { workerId: 'w1', claim: first?.claim ?? -1 };
|
|
125
|
+
expect(await driver.nack(id, { ...one, delayMs: 0 })).toBe(true);
|
|
126
|
+
const [second] = await claim(driver, at.queue, 'w1');
|
|
127
|
+
expect(second?.id).toBe(id);
|
|
128
|
+
expect(second?.claim).toBeGreaterThan(one.claim);
|
|
129
|
+
expect(await driver.ack(id, one)).toBe(false);
|
|
130
|
+
expect(await driver.ack(id, { workerId: 'w1', claim: second?.claim ?? -1 })).toBe(true);
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: 'a dead-lettered row is counted dead and never claimed again',
|
|
135
|
+
async run(driver) {
|
|
136
|
+
const at = scope();
|
|
137
|
+
const id = await enqueue(driver, at, 3);
|
|
138
|
+
const [first] = await claim(driver, at.queue, 'w1');
|
|
139
|
+
const by = { workerId: 'w1', claim: first?.claim ?? -1, delayMs: 0, deadLetter: true };
|
|
140
|
+
expect(await driver.nack(id, by)).toBe(true);
|
|
141
|
+
expect(await claim(driver, at.queue, 'w1')).toEqual([]);
|
|
142
|
+
expect(await statsOf(driver, at.queue)).toMatchObject({ dead: 1 });
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: 'a lapsed lease is re-claimed, and the worker that lost it can neither renew nor settle',
|
|
147
|
+
async run(driver) {
|
|
148
|
+
const { id, handed } = await lapse(driver, 3);
|
|
149
|
+
expect(handed).toBe(1);
|
|
150
|
+
const renew = (workerId: string) =>
|
|
151
|
+
driver.heartbeat(id, { visibilityTimeoutMs: 30_000, workerId });
|
|
152
|
+
expect(await renew('w1')).toBe(false);
|
|
153
|
+
expect(await renew('w2')).toBe(true);
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: 'a lease lapsed on the final attempt is buried dead, reported once, never handed out',
|
|
158
|
+
async run(driver) {
|
|
159
|
+
const lapsed = await lapse(driver, 1);
|
|
160
|
+
expect(lapsed.handed).toBe(0);
|
|
161
|
+
expect(buried(lapsed)).toEqual([[[lapsed.id, 'dead']]]);
|
|
162
|
+
let again = 0;
|
|
163
|
+
const third = await claim(driver, lapsed.queue, 'w3', { onExhausted: () => (again += 1) });
|
|
164
|
+
expect([third.length, again]).toEqual([0, 0]);
|
|
165
|
+
expect(await statsOf(driver, lapsed.queue)).toMatchObject({ dead: 1, running: 0 });
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: 'dropExhausted buries the named job failed, not dead',
|
|
170
|
+
async run(driver) {
|
|
171
|
+
const lapsed = await lapse(driver, 1, true);
|
|
172
|
+
expect(lapsed.handed).toBe(0);
|
|
173
|
+
expect(buried(lapsed)).toEqual([[[lapsed.id, 'failed']]]);
|
|
174
|
+
expect(await statsOf(driver, lapsed.queue)).toMatchObject({ failed: 1, dead: 0 });
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
];
|