@ultimat3/testing 23.0.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/e2e-spawn.ts CHANGED
@@ -10,6 +10,7 @@ import { get } from 'node:http';
10
10
  import { dirname, join } from 'node:path';
11
11
  import { assert } from '@ultimat3/core';
12
12
  import { E2eAppFailedError } from './e2e-errors';
13
+ import { freePort } from './free-port';
13
14
 
14
15
  /** `x dev` (sync included), or the production entry `apps/web/server.ts` under `ROLE=web`. */
15
16
  export type E2eAppMode = 'dev' | 'serve';
@@ -64,14 +65,6 @@ export interface SpawnE2eAppOptions {
64
65
  readonly bin?: string | undefined;
65
66
  }
66
67
 
67
- /** A port nothing holds right now, asked of the OS and handed to the child. */
68
- const freePort = (): number => {
69
- const probe = Bun.serve({ port: 0, fetch: () => new Response() });
70
- const port = probe.port ?? 0;
71
- probe.stop(true);
72
- return port;
73
- };
74
-
75
68
  /**
76
69
  * This process's environment minus what makes the child a TEST process. Spawned from `bun test`,
77
70
  * `NODE_ENV=test` rode along and the app resolved its environment as `test` — so a development-only
package/src/errors.ts CHANGED
@@ -48,6 +48,7 @@ export const TESTING_ERROR_CODES = [
48
48
  'X_TEST_OPENAPI_EXPECTED',
49
49
  'X_TEST_NUMBER_EXPECTED',
50
50
  'X_TEST_ISLAND_STATE_UNKNOWN',
51
+ 'X_TEST_APP_NOT_BOOTED',
51
52
  ] as const;
52
53
 
53
54
  export type TestingErrorCode = (typeof TESTING_ERROR_CODES)[number];
@@ -87,6 +88,7 @@ export const TESTING_ERROR_TITLES: Readonly<Record<TestingErrorCode, string>> =
87
88
  X_TEST_OPENAPI_EXPECTED: 'a matcher expected an OpenAPI document and got something else',
88
89
  X_TEST_NUMBER_EXPECTED: 'a matcher expected a finite number and got something else',
89
90
  X_TEST_ISLAND_STATE_UNKNOWN: 'an island state manifest declares no state with that id',
91
+ X_TEST_APP_NOT_BOOTED: 'describeApp handle read before boot',
90
92
  };
91
93
 
92
94
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -135,7 +137,18 @@ export class NondeterministicError extends UltimateError {
135
137
  super({
136
138
  code: 'X_TEST_NONDETERMINISTIC',
137
139
  cause: `${input.what} produced "${input.first}" then "${input.second}"`,
138
- fix: 'wrap the test in frozenClock() / seededRandom(), or remove the wall-clock read',
140
+ fix: "assertDeterministic('…', () => { installDeterminism(); return body(); })",
141
+ });
142
+ }
143
+ }
144
+
145
+ /** `describeApp`'s `app()` read while the block was still being registered, before `beforeAll`. */
146
+ export class AppNotBootedError extends UltimateError {
147
+ constructor(input: { suite: string }) {
148
+ super({
149
+ code: 'X_TEST_APP_NOT_BOOTED',
150
+ cause: `describeApp('${input.suite}') — app() was called while the describe block was being registered, before its beforeAll booted the app`,
151
+ fix: "test('…', () => { const handle = app(); })",
139
152
  });
140
153
  }
141
154
  }
package/src/factories.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  // caller composes, an association is a column whose value comes from another factory built with
7
7
  // the SAME strategy — `build()` leaves the parent in memory, `create()` writes it.
8
8
 
9
+ import { fnv1a } from '@ultimat3/core';
9
10
  import { seededRandom, seededUuid } from './determinism';
10
11
  import { FactoryTraitUnknownError } from './errors';
11
12
  import { persistRow } from './factory-persist';
@@ -91,15 +92,12 @@ export interface Factory<TRow, TTrait extends string = string> {
91
92
 
92
93
  /**
93
94
  * Two factories with the same seed emit the same uuids, so a default of `1` everywhere gave a user
94
- * and a post the same id — rows that only look related. FNV-1a over the table name keeps every
95
- * table on its own stream while staying a pure function of the schema, so the ids are still
95
+ * and a post the same id — rows that only look related. Core's FNV-1a over the table name keeps
96
+ * every table on its own stream while staying a pure function of the schema, so the ids are still
96
97
  * identical run to run and machine to machine.
97
98
  */
98
99
  export function seedFor(table: string): number {
99
- let hash = 0x811c9dc5;
100
- for (let i = 0; i < table.length; i += 1) {
101
- hash = Math.imul(hash ^ table.charCodeAt(i), 0x01000193) >>> 0;
102
- }
100
+ const hash = fnv1a(table);
103
101
  return hash === 0 ? 1 : hash;
104
102
  }
105
103
 
@@ -119,13 +119,8 @@ export function restoreGlobals(snapshot: Baseline, host: object = globalThis): r
119
119
  return touched;
120
120
  }
121
121
 
122
- /** Called by the leak guard as each test file loads. The first call only takes the baseline. */
123
- export function runFileBoundary(): void {
124
- if (baseline === undefined) {
125
- baseline = captureGlobals();
126
- envBaseline = { ...process.env };
127
- return;
128
- }
122
+ /** Every `onFileBoundary` hook, in order — also run once after the run's last file. */
123
+ export function runBoundaryHooks(): void {
129
124
  for (const hook of hooks) {
130
125
  try {
131
126
  hook();
@@ -133,6 +128,16 @@ export function runFileBoundary(): void {
133
128
  // A hook that throws (an island's own disposer) must not stop the rest of the reset.
134
129
  }
135
130
  }
131
+ }
132
+
133
+ /** Called by the leak guard as each test file loads. The first call only takes the baseline. */
134
+ export function runFileBoundary(): void {
135
+ if (baseline === undefined) {
136
+ baseline = captureGlobals();
137
+ envBaseline = { ...process.env };
138
+ return;
139
+ }
140
+ runBoundaryHooks();
136
141
  restoreGlobals(baseline);
137
142
  if (envBaseline !== undefined) restoreEnv(envBaseline);
138
143
  }
@@ -3,13 +3,13 @@
3
3
  // packages are tier 5, so importing it here would be the reverse of the one declared `cli → testing`
4
4
  // edge. A structural seam keeps the direction honest and survives the bundler changing underneath.
5
5
 
6
- import { mkdtempSync, rmSync } from 'node:fs';
7
- import { tmpdir } from 'node:os';
8
6
  import { basename, join } from 'node:path';
9
7
  import { islandMountMissing, islandNotBuilt } from './errors';
8
+ import { onFileBoundary } from './file-boundary';
10
9
  import { createIslandDocument, FakeElement, handlerFor, parseHtml } from './island-dom';
11
10
  import type { ResizeInput } from './island-observers';
12
11
  import { deliverResize } from './island-observers';
12
+ import { createScratchDir, removeScratchDir } from './island-scratch';
13
13
 
14
14
  /** The two fields a mounted island needs from a chunk. Anything else a bundler grows — a CSS
15
15
  * artifact, a source map, a dev/production flag — is invisible here on purpose. */
@@ -139,49 +139,13 @@ function entryOf(module: unknown, file: string): IslandEntry {
139
139
  return { mount: mount as IslandEntry['mount'] };
140
140
  }
141
141
 
142
- let moduleDir: string | undefined;
143
-
144
- /**
145
- * One directory per process, outside the app under test — so no test leaves a `.mjs` behind. Two
146
- * properties the module-scope `mkdtempSync` it replaces had neither of:
147
- *
148
- * LAZY. This module is on the `.` barrel, so importing `@ultimat3/testing` for `expect` alone
149
- * created a directory — in every test process in the repo, whether or not it ever mounts anything.
150
- *
151
- * REMOVED. `exit` and `rmSync`, because an exit handler runs synchronously and nothing else covers
152
- * every path: `mountIsland` restores and rethrows on a failed mount, so that run never reaches the
153
- * `Disposable`, and the directory is per PROCESS while the disposable is per mount.
154
- */
155
- function moduleDirPath(): string {
156
- const existing = moduleDir;
157
- if (existing !== undefined) return existing;
158
- const created = mkdtempSync(join(tmpdir(), 'ultimate-island-'));
159
- process.on('exit', () => {
160
- rmSync(created, { recursive: true, force: true });
161
- });
162
- moduleDir = created;
163
- return created;
164
- }
165
-
166
- /**
167
- * The scratch root, or `undefined` when nothing has been mounted. Read by the leak test, which
168
- * asserts both halves of the above from a CHILD process — the only place where "this process
169
- * created no directory" and "the directory is gone afterwards" are both observable.
170
- */
171
- export const islandModuleDir = (): string | undefined => moduleDir;
172
-
173
142
  /**
174
- * A temp file named by the chunk's own hash, NOT a `data:` URL. The URL form read better and
175
- * crashed the coverage reporter: `bun test --coverage` panics with `range end index N out of range
176
- * for slice of length 4096` on `import()` of any `data:` module whose URL exceeds ~4 kB, and an
177
- * island chunk is 12-55 kB. Measured on Bun 1.4.0, threshold at a URL length of 4032; without
178
- * `--coverage` the same import is fine, which is why only the per-package CI job ever saw it.
179
- *
180
- * The property the URL form was chosen for survives: the name is derived from the bytes, so an
181
- * edited island is a different module rather than a cache hit on the same path.
143
+ * A temp FILE named by the chunk's own hash, NOT a `data:` URL: `bun test --coverage` panics on
144
+ * `import()` of a `data:` module past ~4 kB (Bun 1.4.0) and an island chunk is 12-55 kB. The file
145
+ * sits in the mount's OWN directory (`island-scratch.ts`), so a mount is a fresh module instance.
182
146
  */
183
- const modulePathFor = (code: string): string =>
184
- join(moduleDirPath(), `${Bun.SHA256.hash(code, 'hex').slice(0, 16)}.mjs`);
147
+ const modulePathFor = (dir: string, code: string): string =>
148
+ join(dir, `${Bun.SHA256.hash(code, 'hex').slice(0, 16)}.mjs`);
185
149
 
186
150
  /**
187
151
  * DESCRIPTORS, not values, and all-or-nothing.
@@ -249,6 +213,10 @@ export function disposeLiveIslands(): number {
249
213
  return pending.length;
250
214
  }
251
215
 
216
+ // Registered here, not by each preload: the repo's own preload and an app's both install the leak
217
+ // guard that runs the boundary, and a mount a file forgot is one only this module can see.
218
+ onFileBoundary(disposeLiveIslands);
219
+
252
220
  export async function mountIsland(options: MountIslandOptions): Promise<MountedIsland> {
253
221
  // `only`, because this fixture has always KNOWN which island it wants and asked for all of them
254
222
  // anyway — `island-bundle.ts` added the option for exactly this caller ("a test that mounts a
@@ -275,14 +243,17 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
275
243
 
276
244
  const { documentElement, globals, resizeObservers } = createIslandDocument();
277
245
  const restore = installGlobals({ ...globals, ...options.globals });
246
+ let dir: string | undefined;
278
247
  try {
248
+ dir = createScratchDir();
249
+ const scratch = dir;
279
250
  // Beside the entry, under the name its bytes import them by: the same relative resolution a
280
251
  // browser does under `/islands/`. Named by their content-addressed URL, so a rewrite is the same
281
252
  // bytes at the same name.
282
253
  for (const shared of bundle.shared ?? []) {
283
- await Bun.write(join(moduleDirPath(), basename(shared.url)), shared.code);
254
+ await Bun.write(join(scratch, basename(shared.url)), shared.code);
284
255
  }
285
- const path = modulePathFor(chunk.code);
256
+ const path = modulePathFor(scratch, chunk.code);
286
257
  await Bun.write(path, chunk.code);
287
258
  const entry = entryOf(await import(path), chunk.file);
288
259
  const el = new FakeElement('div');
@@ -310,6 +281,7 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
310
281
  // Whatever the disposer did — including throw, which is the test's to see — the process
311
282
  // gets its globals back, or the fake `document` reaches every later file in the run.
312
283
  restore();
284
+ removeScratchDir(scratch);
313
285
  }
314
286
  };
315
287
  liveMounts.add(dispose);
@@ -349,6 +321,7 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
349
321
  // A mount that throws restores the process before it rethrows: the alternative leaves every
350
322
  // later file in the run holding a fake `document`, which fails somewhere with no thread back.
351
323
  restore();
324
+ if (dir !== undefined) removeScratchDir(dir);
352
325
  throw error;
353
326
  }
354
327
  }
@@ -244,11 +244,15 @@ export async function createRunJobs(): Promise<RunJobs> {
244
244
  ).length,
245
245
  inFlight: async () => (await live()).filter((record) => record.state === 'running').length,
246
246
  [Symbol.asyncDispose]: async (): Promise<void> => {
247
- await driver.close?.();
248
- renewals[Symbol.dispose]();
249
- jobs.setEventBus(previousBus);
250
- if (previous === undefined) jobs.resetJobDriver();
251
- else jobs.setJobDriver(previous);
247
+ // A `finally`: a close that throws must still hand every later file its bus and its driver.
248
+ try {
249
+ await driver.close?.();
250
+ } finally {
251
+ renewals[Symbol.dispose]();
252
+ jobs.setEventBus(previousBus);
253
+ if (previous === undefined) jobs.resetJobDriver();
254
+ else jobs.setJobDriver(previous);
255
+ }
252
256
  },
253
257
  });
254
258
  }
@@ -0,0 +1,46 @@
1
+ // One responsibility: a port the e2e app can listen on AND a browser will open. The OS hands out
2
+ // whatever is free in its ephemeral range, and a box whose range starts at 1024 hands out ports
3
+ // Chrome refuses outright — every navigation of the run then fails `net::ERR_UNSAFE_PORT`.
4
+
5
+ /** What `freePort` needs of a listener: the port the OS gave it, and a way to give it back. */
6
+ export interface PortProbe {
7
+ readonly port: number;
8
+ stop(): void;
9
+ }
10
+
11
+ /**
12
+ * Chromium's restricted ports (`net/base/port_util.cc`, `kRestrictedPorts`) from 1024 up — the
13
+ * ones an unprivileged listener can be handed. Below 1024 the OS never answers a `port: 0` ask.
14
+ */
15
+ export const BROWSER_REFUSED_PORTS: ReadonlySet<number> = new Set([
16
+ 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669,
17
+ 6679, 6697, 10_080,
18
+ ]);
19
+
20
+ /** Listen on a port of the OS's choosing. */
21
+ const listenOnAnyPort = (): PortProbe => {
22
+ const probe = Bun.serve({ port: 0, fetch: () => new Response() });
23
+ return { port: probe.port ?? 0, stop: () => void probe.stop(true) };
24
+ };
25
+
26
+ /**
27
+ * A port nothing holds right now, asked of the OS and handed to the child — and one a browser will
28
+ * open. A refused port is KEPT listening while the next is asked for, so the OS cannot answer with
29
+ * it again: the walk ends after at most one ask per refused port, with no counter and no retry.
30
+ */
31
+ export const freePort = (listen: () => PortProbe = listenOnAnyPort): number => {
32
+ const refused: PortProbe[] = [];
33
+ try {
34
+ for (;;) {
35
+ const probe = listen();
36
+ if (BROWSER_REFUSED_PORTS.has(probe.port)) {
37
+ refused.push(probe);
38
+ continue;
39
+ }
40
+ probe.stop();
41
+ return probe.port;
42
+ }
43
+ } finally {
44
+ for (const probe of refused) probe.stop();
45
+ }
46
+ };
package/src/harness.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { afterAll, beforeAll, describe, test } from 'bun:test';
6
6
  import { captureDeterminism, installDeterminism, restoreCapturedDeterminism } from './determinism';
7
+ import { AppNotBootedError } from './errors';
7
8
  import {
8
9
  allowHost,
9
10
  captureNetwork,
@@ -171,7 +172,7 @@ export function describeApp(
171
172
  booted = undefined;
172
173
  });
173
174
  body(() => {
174
- if (booted === undefined) throw new ReferenceError('app is not booted yet');
175
+ if (booted === undefined) throw new AppNotBootedError({ suite: name });
175
176
  return booted.handle;
176
177
  });
177
178
  });
package/src/index.ts CHANGED
@@ -27,6 +27,8 @@ export { afterAll, afterEach, beforeAll, beforeEach, describe, expect } from 'bu
27
27
  // `@ultimat3/http` is imported inside the function.
28
28
  export type { AuthRequestInit } from './auth-request';
29
29
  export { authRequest } from './auth-request';
30
+ export type { BudgetStoreLike } from './budget-store-conformance';
31
+ export { budgetStoreConformance } from './budget-store-conformance';
30
32
  // The browser-backed e2e driver and the raw-CDP browser under it — moved here from
31
33
  // `@ultimat3/cli` in 22.0.0. `installE2eDriver` is the ONE entry point an app's test preload calls;
32
34
  // `openE2eBrowserIfAvailable()` answers `undefined` on a machine with no Chrome, so a
@@ -50,6 +52,12 @@ export type { CdpE2eTabOptions, E2eTab } from './cdp-e2e-page';
50
52
  export { cdpE2eTab } from './cdp-e2e-page';
51
53
  export type { CdpE2eSessionOptions, E2eSession } from './cdp-e2e-session';
52
54
  export { cdpE2eSession } from './cdp-e2e-session';
55
+ export type {
56
+ CdpLaunchAttempt,
57
+ CdpTargetGone,
58
+ CdpTimeoutObservation,
59
+ CdpTimeoutReading,
60
+ } from './cdp-errors';
53
61
  export {
54
62
  CdpBrowserMissingError,
55
63
  CdpCallFailedError,
@@ -62,6 +70,8 @@ export {
62
70
  CHROME_PATH_ENV,
63
71
  CONTAINER_CHROME_ARGS,
64
72
  findChrome,
73
+ LAUNCH_ATTEMPTS,
74
+ LAUNCH_TIMEOUT_MS,
65
75
  launchChrome,
66
76
  launchFoundChrome,
67
77
  } from './cdp-launch';
@@ -121,6 +131,7 @@ export {
121
131
  } from './e2e-selection';
122
132
  export type { TestingErrorCode } from './errors';
123
133
  export {
134
+ AppNotBootedError,
124
135
  FixtureUnavailableError,
125
136
  NetworkOfflineError,
126
137
  NetworkSealedError,
@@ -271,6 +282,7 @@ export {
271
282
  normalizeIslandName,
272
283
  } from './island-states-resolve';
273
284
  export { ISOLATED_ENV, releasePluginsAfterIsolatedFile } from './isolated-plugins';
285
+ export { jobDriverConformance } from './job-driver-conformance';
274
286
  export type { LiveConnection, LiveNodeHandle, LiveNodeOptions } from './live-node';
275
287
  export { createLiveNode } from './live-node';
276
288
  // The store a mutator's `local()` half writes into under test — the Map every app hand-rolled.
package/src/island-dom.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  // Two neighbours at the 500-line ceiling: `island-selector.ts` is the selector grammar `find`
7
7
  // and `all` read, and `island-observers.ts` is the `ResizeObserver` an island measures with.
8
8
 
9
+ import { decodeEntities } from './island-html-entities';
9
10
  import type { FakeResizeObserver } from './island-observers';
10
11
  import { createResizeObservers, rectOf } from './island-observers';
11
12
  import { matchesSelector, parseSelector } from './island-selector';
@@ -350,8 +351,10 @@ const VOID_TAGS = new Set([
350
351
  'track',
351
352
  'wbr',
352
353
  ]);
354
+ // Group 6 is a comment: Solid compiles `<!>` as the marker its `nextSibling` walk counts, so it must
355
+ // be a node — read as text it merged into its neighbours and the walk landed one node short.
353
356
  const TOKEN =
354
- /<(\/?)([a-zA-Z][\w-]*)((?:\s+[^\s=/>]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>|([^<]+)/g;
357
+ /<(\/?)([a-zA-Z][\w-]*)((?:\s+[^\s=/>]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>|([^<]+)|(<!(?:--[\s\S]*?--|[^>]*)>)/g;
355
358
  const ATTRIBUTE = /([^\s=/>]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
356
359
 
357
360
  /** Only what `babel-preset-solid` emits into a template: tags, static attributes and text. */
@@ -360,8 +363,8 @@ export function parseHtml(html: string): FakeNode {
360
363
  const open: FakeNode[] = [root];
361
364
  for (const match of html.matchAll(TOKEN)) {
362
365
  const parent = open[open.length - 1] as FakeNode;
363
- if (match[5] !== undefined) {
364
- parent.appendChild(new FakeText(match[5]));
366
+ if (match[5] !== undefined || match[6] !== undefined) {
367
+ parent.appendChild(new FakeText(decodeEntities(match[5] ?? '')));
365
368
  continue;
366
369
  }
367
370
  if (match[1] === '/') {
@@ -370,7 +373,8 @@ export function parseHtml(html: string): FakeNode {
370
373
  }
371
374
  const element = new FakeElement(match[2] as string);
372
375
  for (const attr of (match[3] ?? '').matchAll(ATTRIBUTE)) {
373
- if (attr[1] !== undefined) 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];
@@ -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
+ ];