@ultimat3/testing 23.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CLAUDE.md +20 -20
  2. package/README.md +95 -13
  3. package/package.json +16 -16
  4. package/src/auth-request.ts +1 -1
  5. package/src/budget-store-conformance.ts +81 -0
  6. package/src/cdp-browser-lease.ts +112 -0
  7. package/src/cdp-browser.ts +47 -11
  8. package/src/cdp-connection.ts +41 -10
  9. package/src/cdp-e2e-session.ts +130 -13
  10. package/src/cdp-errors.ts +129 -8
  11. package/src/cdp-launch-attempt.ts +224 -0
  12. package/src/cdp-launch-candidates.ts +62 -0
  13. package/src/cdp-launch-profile.ts +106 -0
  14. package/src/cdp-launch-reap.ts +68 -0
  15. package/src/cdp-launch-wire.ts +171 -0
  16. package/src/cdp-launch.ts +92 -140
  17. package/src/cdp-offline-script.ts +33 -3
  18. package/src/cdp-wire-watch.ts +113 -0
  19. package/src/define-island-states.ts +47 -8
  20. package/src/determinism.ts +35 -6
  21. package/src/e2e-app.ts +17 -1
  22. package/src/e2e-errors.ts +14 -3
  23. package/src/e2e-locale.ts +12 -21
  24. package/src/e2e-page.ts +12 -26
  25. package/src/e2e-run.ts +22 -12
  26. package/src/e2e-service-worker.ts +92 -0
  27. package/src/e2e-spawn.ts +58 -17
  28. package/src/errors.ts +34 -66
  29. package/src/factories.ts +4 -6
  30. package/src/file-boundary.ts +12 -7
  31. package/src/fixture-clock.ts +1 -1
  32. package/src/fixture-island.ts +50 -98
  33. package/src/fixture-jobs.ts +16 -12
  34. package/src/fixture-mail.ts +3 -3
  35. package/src/fixture-network.ts +1 -1
  36. package/src/fixture-statements.ts +1 -1
  37. package/src/fixture-subscribe.ts +3 -3
  38. package/src/framework-fixtures.ts +13 -13
  39. package/src/free-port.ts +46 -0
  40. package/src/harness.ts +2 -1
  41. package/src/index.ts +29 -10
  42. package/src/island-dom.ts +57 -13
  43. package/src/island-globals.ts +73 -0
  44. package/src/island-html-entities.ts +27 -0
  45. package/src/island-scratch.ts +105 -0
  46. package/src/island-state-errors.ts +16 -0
  47. package/src/island-states.ts +23 -0
  48. package/src/job-driver-checks.ts +177 -0
  49. package/src/job-driver-conformance.ts +21 -0
  50. package/src/live-node.ts +6 -4
  51. package/src/preload.ts +2 -8
  52. package/src/quiet-logs.ts +1 -1
  53. package/src/registry-leak-error.ts +81 -0
  54. package/src/registry-leak-guard.ts +7 -4
  55. package/src/registry-snapshot.ts +41 -2
  56. package/src/render-view.ts +3 -3
  57. package/src/sealed-network.ts +50 -5
  58. package/src/sealed-sockets.ts +59 -0
  59. package/src/template-db.ts +8 -5
  60. package/src/test-types.ts +6 -2
package/src/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
- readonly listeners = new Map<string, (event: unknown) => void>();
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.listeners.set(name, fn);
249
- }
250
- removeEventListener(name: string): void {
251
- this.listeners.delete(name);
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.listeners.get('scroll')?.({ currentTarget: this, target: 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) element.setAttribute(attr[1], attr[2] ?? attr[3] ?? attr[4] ?? '');
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 => documentElement.removeEventListener(name),
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 a listener it attached with
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.listeners.get(type);
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
@@ -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 `createSyncNode` takes no mutation hook to forward.
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 createLiveNode(options: LiveNodeOptions = {}): Promise<LiveNodeHandle> {
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.createContext({ role: 'sync', buildId });
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.createSyncNode({
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.