@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/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';
|
|
@@ -198,9 +199,24 @@ export class FakeClassList {
|
|
|
198
199
|
}
|
|
199
200
|
}
|
|
200
201
|
|
|
202
|
+
/** One `addEventListener` call, alive until its own `removeEventListener`. */
|
|
203
|
+
interface Registration {
|
|
204
|
+
readonly fn: (event: unknown) => void;
|
|
205
|
+
removed: boolean;
|
|
206
|
+
}
|
|
207
|
+
|
|
201
208
|
export class FakeElement extends FakeNode {
|
|
202
209
|
readonly attributes = new Map<string, string>();
|
|
203
|
-
|
|
210
|
+
/**
|
|
211
|
+
* EVERY listener per type, in the order added, deduplicated by identity — the DOM's own rule. One
|
|
212
|
+
* per type was a double that lost listeners: `@ultimat3/ui`'s Menu closes on an Escape it
|
|
213
|
+
* registers on `document`, and its focus trap's keydown, registered next, replaced it.
|
|
214
|
+
*
|
|
215
|
+
* A REGISTRATION per add, not the bare function, so a dispatch can tell "removed before its
|
|
216
|
+
* turn" (skipped, as a browser skips it) from "removed and added again" (a new registration the
|
|
217
|
+
* running dispatch never saw, so also skipped) — a `Set` of functions answers both as present.
|
|
218
|
+
*/
|
|
219
|
+
readonly #listeners = new Map<string, Map<(event: unknown) => void, Registration>>();
|
|
204
220
|
/** Plain object, so `delete el.dataset.theme` behaves as the DOM's `DOMStringMap` does. */
|
|
205
221
|
readonly dataset: Record<string, string> = {};
|
|
206
222
|
readonly classList: FakeClassList = new FakeClassList(this);
|
|
@@ -245,10 +261,34 @@ export class FakeElement extends FakeNode {
|
|
|
245
261
|
else this.attributes.delete(name);
|
|
246
262
|
}
|
|
247
263
|
addEventListener(name: string, fn: (event: unknown) => void): void {
|
|
248
|
-
this
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
264
|
+
let held = this.#listeners.get(name);
|
|
265
|
+
if (held === undefined) {
|
|
266
|
+
held = new Map();
|
|
267
|
+
this.#listeners.set(name, held);
|
|
268
|
+
}
|
|
269
|
+
if (!held.has(fn)) held.set(fn, { fn, removed: false });
|
|
270
|
+
}
|
|
271
|
+
/** By IDENTITY: a different function — even one that does the same thing — removes nothing. */
|
|
272
|
+
removeEventListener(name: string, fn: (event: unknown) => void): void {
|
|
273
|
+
const held = this.#listeners.get(name);
|
|
274
|
+
const registration = held?.get(fn);
|
|
275
|
+
if (held === undefined || registration === undefined) return;
|
|
276
|
+
registration.removed = true;
|
|
277
|
+
held.delete(fn);
|
|
278
|
+
if (held.size === 0) this.#listeners.delete(name);
|
|
279
|
+
}
|
|
280
|
+
/** Every listener of `name`, as one call — `undefined` when there is none to run. */
|
|
281
|
+
listenerFor(name: string): ((event: unknown) => void) | undefined {
|
|
282
|
+
const held = this.#listeners.get(name);
|
|
283
|
+
if (held === undefined || held.size === 0) return undefined;
|
|
284
|
+
// The registrations as of NOW, for the DOM's rule: one added during the dispatch waits for the
|
|
285
|
+
// next, and one removed before its turn does not run.
|
|
286
|
+
const snapshot = [...held.values()];
|
|
287
|
+
return (event: unknown): void => {
|
|
288
|
+
for (const registration of snapshot) {
|
|
289
|
+
if (!registration.removed) registration.fn(event);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
252
292
|
}
|
|
253
293
|
override cloneNode(deep?: boolean): FakeElement {
|
|
254
294
|
const copy = new FakeElement(this.tagName);
|
|
@@ -294,7 +334,7 @@ export class FakeElement extends FakeNode {
|
|
|
294
334
|
if (options.left !== undefined) this.scrollLeft = options.left;
|
|
295
335
|
if (options.top !== undefined) this.scrollTop = options.top;
|
|
296
336
|
}
|
|
297
|
-
this.
|
|
337
|
+
this.listenerFor('scroll')?.({ currentTarget: this, target: this });
|
|
298
338
|
}
|
|
299
339
|
/**
|
|
300
340
|
* A compound of tag, `#id`, `.class`, `[attr]` and `[attr="value"]`, joined by the descendant
|
|
@@ -350,8 +390,10 @@ const VOID_TAGS = new Set([
|
|
|
350
390
|
'track',
|
|
351
391
|
'wbr',
|
|
352
392
|
]);
|
|
393
|
+
// Group 6 is a comment: Solid compiles `<!>` as the marker its `nextSibling` walk counts, so it must
|
|
394
|
+
// be a node — read as text it merged into its neighbours and the walk landed one node short.
|
|
353
395
|
const TOKEN =
|
|
354
|
-
/<(\/?)([a-zA-Z][\w-]*)((?:\s+[^\s=/>]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>|([^<]+)/g;
|
|
396
|
+
/<(\/?)([a-zA-Z][\w-]*)((?:\s+[^\s=/>]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>|([^<]+)|(<!(?:--[\s\S]*?--|[^>]*)>)/g;
|
|
355
397
|
const ATTRIBUTE = /([^\s=/>]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
356
398
|
|
|
357
399
|
/** Only what `babel-preset-solid` emits into a template: tags, static attributes and text. */
|
|
@@ -360,8 +402,8 @@ export function parseHtml(html: string): FakeNode {
|
|
|
360
402
|
const open: FakeNode[] = [root];
|
|
361
403
|
for (const match of html.matchAll(TOKEN)) {
|
|
362
404
|
const parent = open[open.length - 1] as FakeNode;
|
|
363
|
-
if (match[5] !== undefined) {
|
|
364
|
-
parent.appendChild(new FakeText(match[5]));
|
|
405
|
+
if (match[5] !== undefined || match[6] !== undefined) {
|
|
406
|
+
parent.appendChild(new FakeText(decodeEntities(match[5] ?? '')));
|
|
365
407
|
continue;
|
|
366
408
|
}
|
|
367
409
|
if (match[1] === '/') {
|
|
@@ -370,7 +412,8 @@ export function parseHtml(html: string): FakeNode {
|
|
|
370
412
|
}
|
|
371
413
|
const element = new FakeElement(match[2] as string);
|
|
372
414
|
for (const attr of (match[3] ?? '').matchAll(ATTRIBUTE)) {
|
|
373
|
-
if (attr[1] !== undefined)
|
|
415
|
+
if (attr[1] !== undefined)
|
|
416
|
+
element.setAttribute(attr[1], decodeEntities(attr[2] ?? attr[3] ?? attr[4] ?? ''));
|
|
374
417
|
}
|
|
375
418
|
parent.appendChild(element);
|
|
376
419
|
if (match[4] !== '/' && !VOID_TAGS.has(element.tagName)) open.push(element);
|
|
@@ -410,7 +453,8 @@ export function createIslandDocument(): IslandDocument {
|
|
|
410
453
|
// reaches them through the surface `MountedIsland` already exposes.
|
|
411
454
|
addEventListener: (name: string, fn: (event: unknown) => void): void =>
|
|
412
455
|
documentElement.addEventListener(name, fn),
|
|
413
|
-
removeEventListener: (name: string): void =>
|
|
456
|
+
removeEventListener: (name: string, fn: (event: unknown) => void): void =>
|
|
457
|
+
documentElement.removeEventListener(name, fn),
|
|
414
458
|
};
|
|
415
459
|
return {
|
|
416
460
|
documentElement,
|
|
@@ -430,7 +474,7 @@ export function createIslandDocument(): IslandDocument {
|
|
|
430
474
|
};
|
|
431
475
|
}
|
|
432
476
|
|
|
433
|
-
/** The delegated handler Solid parks on the node as `$$click`, or
|
|
477
|
+
/** The delegated handler Solid parks on the node as `$$click`, or EVERY listener attached with
|
|
434
478
|
* `addEventListener` — a compiled island uses one or the other and a driver must accept both. */
|
|
435
479
|
export function handlerFor(
|
|
436
480
|
element: FakeElement,
|
|
@@ -438,5 +482,5 @@ export function handlerFor(
|
|
|
438
482
|
): ((event: unknown) => void) | undefined {
|
|
439
483
|
const delegated = (element as unknown as Record<string, unknown>)[`$$${type}`];
|
|
440
484
|
if (typeof delegated === 'function') return delegated as (event: unknown) => void;
|
|
441
|
-
return element.
|
|
485
|
+
return element.listenerFor(type);
|
|
442
486
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The process globals the island fixture installs, as LAYERS over one snapshot of the real ones.
|
|
2
|
+
// Each mount used to save what it found and restore that on dispose — right only newest-first: a
|
|
3
|
+
// worker disposing A then B had B "restore" A's fake `document`/`Element` for every later file.
|
|
4
|
+
|
|
5
|
+
/** One mount's globals, newest last. */
|
|
6
|
+
const layers: Readonly<Record<string, unknown>>[] = [];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The REAL descriptor of every key any live layer installs, taken the first time a layer installs
|
|
10
|
+
* it while no other live layer holds it — so 0→1 for each key — and put back only when the last
|
|
11
|
+
* layer goes (1→0). `undefined` is "no such global", which is restored as a delete.
|
|
12
|
+
*
|
|
13
|
+
* DESCRIPTORS, not values: a saved value cannot tell "no such global" from "a global holding
|
|
14
|
+
* `undefined`", and it cannot put an accessor back as an accessor.
|
|
15
|
+
*/
|
|
16
|
+
const real = new Map<string, PropertyDescriptor | undefined>();
|
|
17
|
+
|
|
18
|
+
const host = globalThis as unknown as Record<string, unknown>;
|
|
19
|
+
|
|
20
|
+
const putBack = (key: string, descriptor: PropertyDescriptor | undefined): void => {
|
|
21
|
+
if (descriptor === undefined) delete host[key];
|
|
22
|
+
else Object.defineProperty(host, key, descriptor);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The process as the remaining layers say it should be: each key holds the NEWEST live layer's
|
|
27
|
+
* value, and a key no live layer names is real again. Assignment for a layer's value, for
|
|
28
|
+
* `installGlobals`' reason — a global with a setter is meant to see the write.
|
|
29
|
+
*/
|
|
30
|
+
function settle(): void {
|
|
31
|
+
for (const [key, descriptor] of real) {
|
|
32
|
+
const owner = layers.findLast((layer) => Object.hasOwn(layer, key));
|
|
33
|
+
if (owner !== undefined) {
|
|
34
|
+
host[key] = owner[key];
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
putBack(key, descriptor);
|
|
38
|
+
real.delete(key);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Install `values` over the process, answering the undo. All-or-nothing: an assignment that throws
|
|
44
|
+
* — a getter-only own global among the caller's `globals` — settles the process back before it
|
|
45
|
+
* rethrows, or the fake `document` installed ahead of it would stay for the rest of the run.
|
|
46
|
+
*
|
|
47
|
+
* The undo is idempotent and ORDER-FREE: it removes this layer and settles, so whichever mount
|
|
48
|
+
* goes last hands the process the real globals, and a mount disposed while a newer one lives
|
|
49
|
+
* leaves the newer one's in place.
|
|
50
|
+
*/
|
|
51
|
+
export function installGlobals(values: Readonly<Record<string, unknown>>): () => void {
|
|
52
|
+
for (const key of Object.keys(values)) {
|
|
53
|
+
if (!real.has(key)) real.set(key, Object.getOwnPropertyDescriptor(host, key));
|
|
54
|
+
}
|
|
55
|
+
const layer = { ...values };
|
|
56
|
+
layers.push(layer);
|
|
57
|
+
let removed = false;
|
|
58
|
+
const remove = (): void => {
|
|
59
|
+
if (removed) return;
|
|
60
|
+
removed = true;
|
|
61
|
+
layers.splice(layers.indexOf(layer), 1);
|
|
62
|
+
settle();
|
|
63
|
+
};
|
|
64
|
+
try {
|
|
65
|
+
// Assignment, not `defineProperty`: `Object.assign` over the lot would report the same
|
|
66
|
+
// failure with nothing rolled back.
|
|
67
|
+
for (const [key, value] of Object.entries(values)) host[key] = value;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
remove();
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
return remove;
|
|
73
|
+
}
|
|
@@ -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,105 @@
|
|
|
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`. A
|
|
4
|
+
// KILLED process reaches no cleanup at all, so each directory carries its owner's pid and the next
|
|
5
|
+
// process sweeps a dead owner's.
|
|
6
|
+
|
|
7
|
+
// why: Bun has no directory API — a scratch DIRECTORY is made, listed, aged and removed, synchronously
|
|
8
|
+
import { mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
9
|
+
// why: Bun exposes no tmpdir(), so only node:os answers the platform temp root.
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const live = new Set<string>();
|
|
14
|
+
let exitHooked = false;
|
|
15
|
+
let swept = false;
|
|
16
|
+
|
|
17
|
+
const PREFIX = 'ultimate-island-';
|
|
18
|
+
/** `ultimate-island-<pid>-<mkdtemp's six>` — this module's own spelling since 2026-10-06. */
|
|
19
|
+
const OWNED = /^ultimate-island-(\d+)-[A-Za-z0-9]{6}$/;
|
|
20
|
+
/** The spelling before it, with no owner to ask: swept once it is a day old. */
|
|
21
|
+
const UNOWNED = /^ultimate-island-[A-Za-z0-9]{6}$/;
|
|
22
|
+
const UNOWNED_AGE_MS = 24 * 60 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
/** `kill(pid, 0)` delivers nothing and answers whether the pid exists; EPERM is alive. */
|
|
25
|
+
const pidAlive = (pid: number): boolean => {
|
|
26
|
+
try {
|
|
27
|
+
process.kill(pid, 0);
|
|
28
|
+
return true;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return (error as { code?: unknown }).code === 'EPERM';
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Remove every island directory under `root` whose owning process is gone — the gate's step
|
|
36
|
+
* deadline SIGKILLs a stalled `bun test`, and nothing in a killed process runs. Answers what it
|
|
37
|
+
* removed. Best-effort and silent: a directory another sweeper removed first, or one this user
|
|
38
|
+
* cannot read, is simply not this process's to report.
|
|
39
|
+
*/
|
|
40
|
+
export function sweepDeadScratchDirs(
|
|
41
|
+
root: string,
|
|
42
|
+
alive: (pid: number) => boolean,
|
|
43
|
+
now: number,
|
|
44
|
+
): readonly string[] {
|
|
45
|
+
const removed: string[] = [];
|
|
46
|
+
let names: readonly string[];
|
|
47
|
+
try {
|
|
48
|
+
names = readdirSync(root).filter((name) => name.startsWith(PREFIX));
|
|
49
|
+
} catch {
|
|
50
|
+
return removed;
|
|
51
|
+
}
|
|
52
|
+
for (const name of names) {
|
|
53
|
+
const path = join(root, name);
|
|
54
|
+
try {
|
|
55
|
+
const owner = OWNED.exec(name)?.[1];
|
|
56
|
+
const dead =
|
|
57
|
+
owner !== undefined
|
|
58
|
+
? !alive(Number(owner))
|
|
59
|
+
: UNOWNED.test(name) && now - statSync(path).mtimeMs > UNOWNED_AGE_MS;
|
|
60
|
+
if (!dead) continue;
|
|
61
|
+
rmSync(path, { recursive: true, force: true });
|
|
62
|
+
removed.push(path);
|
|
63
|
+
} catch {
|
|
64
|
+
// Gone already, or not ours to remove.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return removed;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A fresh directory for one mount. Fresh, not shared: a module is cached by PATH, so two mounts
|
|
72
|
+
* of byte-identical chunks under one directory imported as ONE instance and shared its state.
|
|
73
|
+
*
|
|
74
|
+
* The `exit` handler is the backstop for a process that is not `bun test` (`bun run`, `x shot`);
|
|
75
|
+
* under `bun test` the file boundary and the run's `afterAll` dispose every mount instead.
|
|
76
|
+
*/
|
|
77
|
+
export function createScratchDir(): string {
|
|
78
|
+
const dir = mkdtempSync(join(tmpdir(), `${PREFIX}${String(process.pid)}-`));
|
|
79
|
+
// Once per process, at its first mount: the cost is one read of the temp dir. "Now" is the new
|
|
80
|
+
// directory's own mtime — the FILESYSTEM's clock, the one the swept mtimes were written by —
|
|
81
|
+
// never `Date.now()`, which a test process has frozen at whatever instant the suite chose.
|
|
82
|
+
if (!swept) {
|
|
83
|
+
swept = true;
|
|
84
|
+
sweepDeadScratchDirs(tmpdir(), pidAlive, statSync(dir).mtimeMs);
|
|
85
|
+
}
|
|
86
|
+
live.add(dir);
|
|
87
|
+
if (!exitHooked) {
|
|
88
|
+
exitHooked = true;
|
|
89
|
+
process.on('exit', removeAllScratchDirs);
|
|
90
|
+
}
|
|
91
|
+
return dir;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Remove one mount's directory. Idempotent: dispose and a failed mount may both reach it. */
|
|
95
|
+
export function removeScratchDir(dir: string): void {
|
|
96
|
+
live.delete(dir);
|
|
97
|
+
rmSync(dir, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function removeAllScratchDirs(): void {
|
|
101
|
+
for (const dir of [...live]) removeScratchDir(dir);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Every scratch directory whose mount has not ended — read by the leak test. */
|
|
105
|
+
export const islandScratchDirs = (): readonly string[] => [...live];
|
|
@@ -135,6 +135,22 @@ export class IslandStateStubInvalidError extends UltimateError {
|
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
/**
|
|
139
|
+
* A `records` stub whose rows are not the envelope's shape — record type -> record key -> row
|
|
140
|
+
* object, `removed` a list of keys per type. The client decodes that shape behind the header and
|
|
141
|
+
* refuses anything else (`X_CLIENT_RECORD_ENVELOPE_INVALID`), so the picture would be of that
|
|
142
|
+
* refusal. Its own class beside the one above, sharing one code: a stub that cannot answer.
|
|
143
|
+
*/
|
|
144
|
+
export class IslandStateRecordsInvalidError extends UltimateError {
|
|
145
|
+
constructor(input: { readonly island: string; readonly path: string; readonly reason: string }) {
|
|
146
|
+
super({
|
|
147
|
+
code: 'X_TEST_ISLAND_STATE_STUB_INVALID',
|
|
148
|
+
cause: `${input.path} ${input.reason}, so the page store would refuse the envelope rather than adopt it`,
|
|
149
|
+
fix: `respond: { kind: 'records', data: [{ id: 'r1' }], records: { runs: { 'r1': { id: 'r1' } } } } — in ${at(input.island)}`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
138
154
|
/**
|
|
139
155
|
* A states file that imports the component it describes. The whole design rests on this file being
|
|
140
156
|
* readable from Bun with no browser and no bundle — the command must know the complete expected
|
package/src/island-states.ts
CHANGED
|
@@ -25,8 +25,31 @@ export const DEFAULT_ISLAND_VIEWPORT: IslandViewport = { width: 1280, height: 80
|
|
|
25
25
|
*/
|
|
26
26
|
export const ISLAND_SHOT_TIME_ZONE = 'UTC';
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The rows a `records` stub hands the page store: record type -> record key -> row — core's
|
|
30
|
+
* `RecordEnvelope['records']`, restated structurally because this file imports nothing.
|
|
31
|
+
*/
|
|
32
|
+
export type IslandStubRecords = Readonly<
|
|
33
|
+
Record<string, Readonly<Record<string, Readonly<Record<string, unknown>>>>>
|
|
34
|
+
>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What a stubbed request answers. `json` is a bare body. `records` is what a query declaring an
|
|
38
|
+
* entity's `rows:` answers on the wire — the envelope `{ data, records, removed? }` behind
|
|
39
|
+
* `x-ultimate-records: 1` — so the island's page store adopts the rows exactly as it would from
|
|
40
|
+
* the server; a `json` stub of the same body is read as bare rows and adopts nothing (#683).
|
|
41
|
+
*/
|
|
28
42
|
export type IslandStubResponse =
|
|
29
43
|
| { readonly kind: 'json'; readonly status?: number; readonly body: unknown }
|
|
44
|
+
| {
|
|
45
|
+
readonly kind: 'records';
|
|
46
|
+
readonly status?: number;
|
|
47
|
+
/** The read's own answer — what the island's `await` resolves to. */
|
|
48
|
+
readonly data: unknown;
|
|
49
|
+
readonly records: IslandStubRecords;
|
|
50
|
+
/** Record keys the store drops, by record type. */
|
|
51
|
+
readonly removed?: Readonly<Record<string, readonly string[]>>;
|
|
52
|
+
}
|
|
30
53
|
| { readonly kind: 'pending' }
|
|
31
54
|
| { readonly kind: 'offline' };
|
|
32
55
|
|
|
@@ -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
|
+
];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The `JobDriver` contract as shared examples: `behavesLike(jobDriverConformance, () => driver)`
|
|
2
|
+
// runs every check in `job-driver-checks.ts` against a driver of your own, one test per check.
|
|
3
|
+
// The subject is called once per test — hand back a fresh driver, or one store with no rows a
|
|
4
|
+
// check could mistake for its own (each check claims only from a queue it named).
|
|
5
|
+
|
|
6
|
+
import { test } from 'bun:test';
|
|
7
|
+
import type { JobDriver } from '@ultimat3/jobs';
|
|
8
|
+
import { JOB_DRIVER_CHECKS } from './job-driver-checks';
|
|
9
|
+
import type { SharedExamples } from './shared-examples';
|
|
10
|
+
import { sharedExamples } from './shared-examples';
|
|
11
|
+
|
|
12
|
+
export const jobDriverConformance: SharedExamples<JobDriver | Promise<JobDriver>> = sharedExamples(
|
|
13
|
+
'a job driver',
|
|
14
|
+
(subject) => {
|
|
15
|
+
for (const check of JOB_DRIVER_CHECKS) {
|
|
16
|
+
test(check.name, async () => {
|
|
17
|
+
await check.run(await subject());
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
);
|
package/src/live-node.ts
CHANGED
|
@@ -78,7 +78,7 @@ export interface LiveNodeOptions {
|
|
|
78
78
|
/** Pins the reconnect epoch, so a test can force a refetch by changing it. */
|
|
79
79
|
readonly epoch?: string;
|
|
80
80
|
// No `onMutate`: the socket carries no writes any more (plan 101, decision 2) — a mutation goes
|
|
81
|
-
// over HTTP as an action, and `
|
|
81
|
+
// over HTTP as an action, and `syncNode` takes no mutation hook to forward.
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
export interface LiveNodeHandle {
|
|
@@ -115,7 +115,7 @@ const ACTORS = new Map<string, Actor | null>();
|
|
|
115
115
|
|
|
116
116
|
let sequence = 0;
|
|
117
117
|
|
|
118
|
-
export async function
|
|
118
|
+
export async function liveNode(options: LiveNodeOptions = {}): Promise<LiveNodeHandle> {
|
|
119
119
|
const core = await import('@ultimat3/core');
|
|
120
120
|
const query = await import('@ultimat3/query');
|
|
121
121
|
const realtime = await import('@ultimat3/realtime/server');
|
|
@@ -126,7 +126,7 @@ export async function createLiveNode(options: LiveNodeOptions = {}): Promise<Liv
|
|
|
126
126
|
const hub = new realtime.ChannelHub({ transport, sockets });
|
|
127
127
|
const registry = new realtime.LiveQueryRegistry({ source: new realtime.RingChangeBuffer() });
|
|
128
128
|
|
|
129
|
-
const ctx = core.
|
|
129
|
+
const ctx = core.ctxOf({ role: 'sync', buildId });
|
|
130
130
|
let live = 0;
|
|
131
131
|
for (const target of query.listQueries()) {
|
|
132
132
|
if (!target.isLive) continue;
|
|
@@ -143,7 +143,7 @@ export async function createLiveNode(options: LiveNodeOptions = {}): Promise<Liv
|
|
|
143
143
|
// app that declared nothing. Said here, where the count is known.
|
|
144
144
|
if (live === 0) throw liveNodeUnavailable();
|
|
145
145
|
|
|
146
|
-
const node = realtime.
|
|
146
|
+
const node = realtime.syncNode({
|
|
147
147
|
hub,
|
|
148
148
|
registry,
|
|
149
149
|
transport,
|
|
@@ -178,6 +178,8 @@ export async function createLiveNode(options: LiveNodeOptions = {}): Promise<Liv
|
|
|
178
178
|
data = upgradeOptions.data;
|
|
179
179
|
return true;
|
|
180
180
|
},
|
|
181
|
+
// No socket behind this harness, so no peer address: a health path answers the verdict.
|
|
182
|
+
requestIP: () => null,
|
|
181
183
|
};
|
|
182
184
|
// `/_x/sync` is `SyncNodeOptions.path`'s default and `handleUpgrade` answers 404 to
|
|
183
185
|
// anything else. The `c` parameter is this connection's actor key — see `ACTORS` above.
|