@ultimat3/testing 22.15.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.
Files changed (52) hide show
  1. package/CLAUDE.md +37 -44
  2. package/README.md +297 -13
  3. package/package.json +16 -14
  4. package/src/auth-request.ts +54 -0
  5. package/src/budget-store-conformance.ts +81 -0
  6. package/src/cdp-browser.ts +5 -10
  7. package/src/cdp-connection.ts +23 -6
  8. package/src/cdp-e2e-session.ts +10 -1
  9. package/src/cdp-errors.ts +113 -8
  10. package/src/cdp-launch-attempt.ts +192 -0
  11. package/src/cdp-launch.ts +49 -122
  12. package/src/cdp-offline-script.ts +33 -3
  13. package/src/cdp-wire-watch.ts +113 -0
  14. package/src/determinism.ts +53 -4
  15. package/src/e2e-app.ts +17 -1
  16. package/src/e2e-driver.ts +11 -2
  17. package/src/e2e-run.ts +7 -2
  18. package/src/e2e-spawn.ts +4 -8
  19. package/src/errors.ts +16 -1
  20. package/src/factories.ts +4 -6
  21. package/src/failure-context.ts +42 -0
  22. package/src/file-boundary.ts +12 -7
  23. package/src/fixture-island.ts +18 -45
  24. package/src/fixture-jobs.ts +117 -28
  25. package/src/fixtures.ts +9 -4
  26. package/src/free-port.ts +46 -0
  27. package/src/frozen-scheduler.ts +49 -0
  28. package/src/harness.ts +2 -1
  29. package/src/index.ts +41 -1
  30. package/src/island-dom.ts +8 -4
  31. package/src/island-html-entities.ts +27 -0
  32. package/src/island-scratch.ts +41 -0
  33. package/src/island-state-errors.ts +19 -0
  34. package/src/island-state-mount.ts +129 -0
  35. package/src/job-driver-checks.ts +177 -0
  36. package/src/job-driver-conformance.ts +21 -0
  37. package/src/live-node.ts +2 -0
  38. package/src/local-tx.ts +87 -0
  39. package/src/matcher-row.ts +93 -0
  40. package/src/matcher-surface.ts +7 -0
  41. package/src/matchers.ts +6 -0
  42. package/src/per-test-reset.ts +43 -0
  43. package/src/preload.ts +14 -7
  44. package/src/quiet-logs.ts +56 -0
  45. package/src/registry-leak-guard.ts +3 -1
  46. package/src/registry-snapshot.ts +22 -1
  47. package/src/render-view.ts +118 -0
  48. package/src/sealed-network.ts +5 -1
  49. package/src/template-db.ts +6 -3
  50. package/src/test-seal-key.ts +51 -0
  51. package/src/test-types.ts +53 -30
  52. package/src/e2e-dom-fixture.ts +0 -122
package/src/index.ts CHANGED
@@ -23,6 +23,12 @@ export {
23
23
  // `test` is OURS (fixture-injecting); everything else passes through. Re-exported so an app
24
24
  // test has one import line, and so `expect` carries this package's matchers already installed.
25
25
  export { afterAll, afterEach, beforeAll, beforeEach, describe, expect } from 'bun:test';
26
+ // The two arguments `hooks.authenticate` is handed, for a test of an app's own authenticator.
27
+ // `@ultimat3/http` is imported inside the function.
28
+ export type { AuthRequestInit } from './auth-request';
29
+ export { authRequest } from './auth-request';
30
+ export type { BudgetStoreLike } from './budget-store-conformance';
31
+ export { budgetStoreConformance } from './budget-store-conformance';
26
32
  // The browser-backed e2e driver and the raw-CDP browser under it — moved here from
27
33
  // `@ultimat3/cli` in 22.0.0. `installE2eDriver` is the ONE entry point an app's test preload calls;
28
34
  // `openE2eBrowserIfAvailable()` answers `undefined` on a machine with no Chrome, so a
@@ -46,6 +52,12 @@ export type { CdpE2eTabOptions, E2eTab } from './cdp-e2e-page';
46
52
  export { cdpE2eTab } from './cdp-e2e-page';
47
53
  export type { CdpE2eSessionOptions, E2eSession } from './cdp-e2e-session';
48
54
  export { cdpE2eSession } from './cdp-e2e-session';
55
+ export type {
56
+ CdpLaunchAttempt,
57
+ CdpTargetGone,
58
+ CdpTimeoutObservation,
59
+ CdpTimeoutReading,
60
+ } from './cdp-errors';
49
61
  export {
50
62
  CdpBrowserMissingError,
51
63
  CdpCallFailedError,
@@ -58,6 +70,8 @@ export {
58
70
  CHROME_PATH_ENV,
59
71
  CONTAINER_CHROME_ARGS,
60
72
  findChrome,
73
+ LAUNCH_ATTEMPTS,
74
+ LAUNCH_TIMEOUT_MS,
61
75
  launchChrome,
62
76
  launchFoundChrome,
63
77
  } from './cdp-launch';
@@ -117,6 +131,7 @@ export {
117
131
  } from './e2e-selection';
118
132
  export type { TestingErrorCode } from './errors';
119
133
  export {
134
+ AppNotBootedError,
120
135
  FixtureUnavailableError,
121
136
  NetworkOfflineError,
122
137
  NetworkSealedError,
@@ -167,7 +182,14 @@ export type {
167
182
  SharedChunkLike,
168
183
  } from './fixture-island';
169
184
  export { mountIsland } from './fixture-island';
170
- export type { JobRunTrace, RunJobs, StepTally } from './fixture-jobs';
185
+ export type {
186
+ JobDrainAs,
187
+ JobEnqueueAs,
188
+ JobRunTrace,
189
+ RunJobs,
190
+ RunJobsOptions,
191
+ StepTally,
192
+ } from './fixture-jobs';
171
193
  export { createRunJobs } from './fixture-jobs';
172
194
  export type { MailRef, TestMail } from './fixture-mail';
173
195
  export { createTestMail } from './fixture-mail';
@@ -201,6 +223,14 @@ export {
201
223
  // The file an error tells the reader to edit — the island's own name with `.states.ts` where
202
224
  // `.tsx` was. Exported so the command that takes the pictures names the same file the refusal does.
203
225
  export { islandStatesFile } from './island-state-errors';
226
+ // One declared state, mounted: `describeIslandState` owns a block's mount and teardown,
227
+ // `mountIslandState` is the one mount a single test asks for with `using`.
228
+ export type { IslandStateMountOptions, IslandStateSuiteOptions } from './island-state-mount';
229
+ export {
230
+ describeIslandState,
231
+ ISLAND_MOUNT_TIMEOUT_MS,
232
+ mountIslandState,
233
+ } from './island-state-mount';
204
234
  export type {
205
235
  IslandRouteStub,
206
236
  IslandState,
@@ -252,8 +282,12 @@ export {
252
282
  normalizeIslandName,
253
283
  } from './island-states-resolve';
254
284
  export { ISOLATED_ENV, releasePluginsAfterIsolatedFile } from './isolated-plugins';
285
+ export { jobDriverConformance } from './job-driver-conformance';
255
286
  export type { LiveConnection, LiveNodeHandle, LiveNodeOptions } from './live-node';
256
287
  export { createLiveNode } from './live-node';
288
+ // The store a mutator's `local()` half writes into under test — the Map every app hand-rolled.
289
+ export type { LocalRows, MemoryLocalTx } from './local-tx';
290
+ export { memoryLocalTx } from './local-tx';
257
291
  /**
258
292
  * The budget `toBeVisible(options?)` takes. Exported because it is in a public matcher's signature;
259
293
  * `retryUntil` and `RetryBudget` deliberately are NOT — nothing outside this package calls them,
@@ -272,6 +306,10 @@ export type { RegistryLeak, RegistrySample } from './registry-leak-guard';
272
306
  export { installRegistryLeakGuard, leakBetween, sampleRegistries } from './registry-leak-guard';
273
307
  export type { ProcessRegistrySnapshot } from './registry-snapshot';
274
308
  export { captureProcessRegistries, restoreProcessRegistries } from './registry-snapshot';
309
+ // What the server renders, for a unit test. `@ultimat3/render` is imported inside the two
310
+ // functions, so a tier-0 test importing this barrel for `expect` still loads no renderer.
311
+ export type { RenderedRoute, RenderedView, RouteModule, RouteRequest } from './render-view';
312
+ export { renderRoute, renderView } from './render-view';
275
313
  export type { DatabaseSnapshot } from './reusable-database';
276
314
  export { restoreDatabase, reusableDatabase, snapshotDatabase } from './reusable-database';
277
315
  export type { MockRoute, NetworkSnapshot, NetworkState } from './sealed-network';
@@ -308,12 +346,14 @@ export {
308
346
  } from './template-db';
309
347
  export type {
310
348
  E2eBody,
349
+ E2eDriver,
311
350
  E2eFixtures,
312
351
  EvalCase,
313
352
  EvalOptions,
314
353
  LocatorLike,
315
354
  OpenApiLike,
316
355
  PageLike,
356
+ TestOptions,
317
357
  TestType,
318
358
  } from './test-types';
319
359
  export {
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) element.setAttribute(attr[1], attr[2] ?? attr[3] ?? attr[4] ?? '');
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];
@@ -234,3 +234,22 @@ export class IslandStatesAmbiguousError extends UltimateError {
234
234
  });
235
235
  }
236
236
  }
237
+
238
+ /**
239
+ * A state id the manifest does not declare — a test naming `idel`, or a state renamed in the
240
+ * manifest and not in the test beside it. Listing the declared ids is the whole value, for the
241
+ * reason `IslandStatesUnknownError` lists names: a typo and a rename are one symptom.
242
+ */
243
+ export class IslandStateUnknownError extends UltimateError {
244
+ constructor(input: {
245
+ readonly island: string;
246
+ readonly id: string;
247
+ readonly known: readonly string[];
248
+ }) {
249
+ super({
250
+ code: 'X_TEST_ISLAND_STATE_UNKNOWN',
251
+ cause: `${renderCauseValue(input.island)} declares no state ${renderCauseValue(input.id)}; declared: ${input.known.join(', ')}`,
252
+ fix: `name one of them instead: ${input.known[0] ?? ''} — or declare the state in ${at(input.island)}`,
253
+ });
254
+ }
255
+ }
@@ -0,0 +1,129 @@
1
+ // One declared island STATE, mounted: the lookup every island test hand-rolled, and the suite
2
+ // form that owns the `beforeAll` / `afterAll` a mount needs because it installs a process-global
3
+ // `document`. The props a test asserts on are the props `x shot --island` photographs, so the
4
+ // picture and the test cannot be of two different components.
5
+
6
+ import { afterAll, beforeAll, describe } from 'bun:test';
7
+ import { assert } from '@ultimat3/core';
8
+ import type {
9
+ IslandBuilder,
10
+ IslandBundleLike,
11
+ MountedIsland,
12
+ MountIslandOptions,
13
+ } from './fixture-island';
14
+ import { mountIsland } from './fixture-island';
15
+ import { IslandStateUnknownError } from './island-state-errors';
16
+ import type { IslandStatesManifest } from './island-states';
17
+ import type { TestOptions } from './test-types';
18
+ import { testName } from './test-types';
19
+
20
+ /** What a state's mount takes: `mountIsland`'s options, minus what the manifest already says. */
21
+ export type IslandStateMountOptions = Omit<MountIslandOptions, 'file' | 'props'>;
22
+
23
+ /** The suite form's: the mount's own, plus the deadline of the `beforeAll` that runs it. */
24
+ export interface IslandStateSuiteOptions extends IslandStateMountOptions, TestOptions {}
25
+
26
+ /**
27
+ * The deadline a block's mount gets when it names none. The first mount in a file pays the island
28
+ * build — a Babel pass and a browser bundle, seconds not milliseconds — and Bun's 5 s default is
29
+ * what a cold CI runner misses.
30
+ */
31
+ export const ISLAND_MOUNT_TIMEOUT_MS = 60_000;
32
+
33
+ /**
34
+ * Bundles already built in this process, per builder, by `<root>\0<island>`. A file with three
35
+ * states used to build its island three times; the source cannot change between two mounts of one
36
+ * test run, so the second is a lookup. A build that REJECTS is forgotten, so the next mount
37
+ * reports its own failure rather than a cached one.
38
+ */
39
+ const bundles = new WeakMap<IslandBuilder, Map<string, Promise<IslandBundleLike>>>();
40
+
41
+ const buildOnce =
42
+ (build: IslandBuilder): IslandBuilder =>
43
+ (root, options) => {
44
+ const built = bundles.get(build) ?? new Map<string, Promise<IslandBundleLike>>();
45
+ bundles.set(build, built);
46
+ const key = `${root}\0${options?.only ?? ''}`;
47
+ const known = built.get(key);
48
+ if (known !== undefined) return known;
49
+ const pending = build(root, options);
50
+ built.set(key, pending);
51
+ pending.catch(() => built.delete(key));
52
+ return pending;
53
+ };
54
+
55
+ /**
56
+ * Mount one state the manifest declares. `using island = await mountIslandState(states, 'idle',
57
+ * { build: buildIslands, root })` inside one test — `using` owns the teardown. A file asserting
58
+ * several things about one state uses `describeIslandState`, which owns it for the block.
59
+ */
60
+ export async function mountIslandState(
61
+ manifest: IslandStatesManifest,
62
+ id: string,
63
+ options: IslandStateMountOptions,
64
+ ): Promise<MountedIsland> {
65
+ const state = manifest.states.find((one) => one.id === id);
66
+ if (state === undefined) {
67
+ throw new IslandStateUnknownError({
68
+ island: manifest.island,
69
+ id,
70
+ known: manifest.states.map((one) => one.id),
71
+ });
72
+ }
73
+ return mountIsland({
74
+ ...options,
75
+ build: buildOnce(options.build),
76
+ file: manifest.island,
77
+ props: state.props,
78
+ });
79
+ }
80
+
81
+ /**
82
+ * One block per declared state: mounted before its first test, disposed after its last, with the
83
+ * island built once per file. `body` is handed an ACCESSOR, for `describeApp`'s reason — the
84
+ * block is declared at module scope and the mount does not exist until `beforeAll` has run:
85
+ *
86
+ * ```ts
87
+ * describeIslandState(counterStates, 'idle', { build: buildIslands, root }, (island) => {
88
+ * test('a click reaches the DOM', () => {
89
+ * expect(island().fire('button', 'click')).toBe(true);
90
+ * });
91
+ * });
92
+ * ```
93
+ *
94
+ * Calls `describe`, so it goes at declaration scope. The block is named for the `unit` step — an
95
+ * island test is `<name>.island.test.ts`, which that step owns.
96
+ */
97
+ export function describeIslandState(
98
+ manifest: IslandStatesManifest,
99
+ id: string,
100
+ options: IslandStateSuiteOptions,
101
+ body: (island: () => MountedIsland) => void,
102
+ ): void {
103
+ const { timeoutMs, ...mount } = options;
104
+ describe(testName('unit', `the ${manifest.name} island, ${id}`), () => {
105
+ let mounted: MountedIsland | undefined;
106
+ let over = false;
107
+ beforeAll(async () => {
108
+ const island = await mountIslandState(manifest, id, mount);
109
+ // A mount that outlived its deadline lands after `afterAll` has already run: disposed here,
110
+ // or its fake `document` is every later block's.
111
+ if (over) island[Symbol.dispose]();
112
+ else mounted = island;
113
+ }, timeoutMs ?? ISLAND_MOUNT_TIMEOUT_MS);
114
+ // Bun runs `afterAll` whether or not the mount resolved, so this reads what is there.
115
+ afterAll(() => {
116
+ over = true;
117
+ mounted?.[Symbol.dispose]();
118
+ mounted = undefined;
119
+ });
120
+ body(() => {
121
+ assert(
122
+ mounted !== undefined,
123
+ `the ${manifest.name} island's "${id}" state is not mounted — its mount failed, or the accessor was called outside this block's tests`,
124
+ 'call the accessor inside a test of this describeIslandState block; a failed mount is reported above this line, by its own error',
125
+ );
126
+ return mounted;
127
+ });
128
+ });
129
+ }
@@ -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
@@ -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.
@@ -0,0 +1,87 @@
1
+ // A mutator's `local()` half, run in a unit test: a `LocalTx` over rows held in memory, with the
2
+ // semantics of the page's record store. The twin under test is the app's; this is only the store
3
+ // it writes into, so a test asserts on rows and holds no cast and no hand-rolled table.
4
+
5
+ import type { LocalTable, LocalTx } from '@ultimat3/action';
6
+
7
+ /** `{ <table>: { <key>: <row> } }` — what the store holds, as a test seeds it and reads it back. */
8
+ export type LocalRows<TRow extends object = object> = Readonly<
9
+ Record<string, Readonly<Record<string, TRow>>>
10
+ >;
11
+
12
+ export interface MemoryLocalTx {
13
+ /** What `mutator.local(tx, input)` is handed. `tx.<table>` and `tx.table('<name>')` are one table. */
14
+ readonly tx: LocalTx;
15
+ /** What a table holds now, by key — after however many applications. `{}` for one never seen. */
16
+ rows<TRow extends object = object>(table: string): Readonly<Record<string, TRow>>;
17
+ }
18
+
19
+ /** `undefined` in a patch means "leave it alone", exactly as the record store reads it. */
20
+ const definedOf = (columns: object): Record<string, unknown> =>
21
+ Object.fromEntries(Object.entries(columns).filter(([, value]) => value !== undefined));
22
+
23
+ /**
24
+ * A client store for one test. Keyed, exactly like the real one: the browser holds no entity
25
+ * schema, so a row is addressed by the key its server twin answers under, never by a column.
26
+ *
27
+ * | Call | Does |
28
+ * |---|---|
29
+ * | `insert(key, row)` | stores the row under the key |
30
+ * | `upsert(key, row)` | merges over what is held; an `undefined` field leaves the column alone |
31
+ * | `update(key, patch)` | changed fields, or a function of the held row. A NO-OP for a key the table does not hold — a twin must not invent a row |
32
+ * | `delete(key)` | removes it |
33
+ */
34
+ export function memoryLocalTx(seed: LocalRows = {}): MemoryLocalTx {
35
+ const tables = new Map<string, Map<string, object>>();
36
+ const held = (name: string): Map<string, object> => {
37
+ const existing = tables.get(name);
38
+ if (existing !== undefined) return existing;
39
+ const created = new Map<string, object>();
40
+ tables.set(name, created);
41
+ return created;
42
+ };
43
+ // Frozen on the way in, as the record store freezes: a twin that mutated a row in place would
44
+ // pass here and corrupt the synced truth it was handed in a browser.
45
+ const stored = (row: object): object => Object.freeze({ ...row });
46
+ for (const [name, rows] of Object.entries(seed)) {
47
+ for (const [key, row] of Object.entries(rows)) held(name).set(key, stored(row));
48
+ }
49
+
50
+ // The one widening in this file: the store holds rows of whatever shape a test seeded, and a
51
+ // table is read back as the shape its caller names — the same door `tx.table<TRow>()` is.
52
+ const table = <TRow extends object>(name: string): LocalTable<TRow> => {
53
+ const rows = held(name) as Map<string, TRow>;
54
+ return {
55
+ get: (key) => rows.get(key),
56
+ all: () => [...rows.values()],
57
+ insert: (key, row) => {
58
+ rows.set(key, stored(row) as TRow);
59
+ },
60
+ upsert: (key, row) => {
61
+ rows.set(key, stored({ ...rows.get(key), ...definedOf(row) }) as TRow);
62
+ },
63
+ update: (key, patch) => {
64
+ const current = rows.get(key);
65
+ if (current === undefined) return;
66
+ const changed = typeof patch === 'function' ? patch(current) : patch;
67
+ rows.set(key, stored({ ...current, ...definedOf(changed) }) as TRow);
68
+ },
69
+ delete: (key) => {
70
+ rows.delete(key);
71
+ },
72
+ };
73
+ };
74
+
75
+ const tx = new Proxy({} as LocalTx, {
76
+ get: (_target, property) => {
77
+ if (typeof property !== 'string') return undefined;
78
+ return property === 'table' ? table : table(property);
79
+ },
80
+ });
81
+
82
+ return {
83
+ tx,
84
+ rows: <TRow extends object = object>(name: string): Readonly<Record<string, TRow>> =>
85
+ Object.fromEntries(tables.get(name) ?? []) as Record<string, TRow>,
86
+ };
87
+ }