@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/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
 
@@ -0,0 +1,42 @@
1
+ // What a FAILED test carries beside its own message: under e2e, the spawned app's last log lines.
2
+ // The app's log was shown only when it failed to boot, so a live-path bug — a fanout that dropped a
3
+ // change, a 500 behind a page — failed with a browser assertion and nothing from the server.
4
+ // Held on `globalThis` under one `Symbol.for` key: the e2e preload and a test file may each hold
5
+ // their own copy of this module, as `e2e-browser-handle.ts` already explains.
6
+
7
+ const KEY = Symbol.for('ultimate.test.failure-context');
8
+
9
+ /** Bounded twice: a log line can be a megabyte of JSON, and an app can talk for minutes. */
10
+ const TAIL_LINES = 40;
11
+ const TAIL_CHARS = 4_000;
12
+
13
+ /** Install (or with `undefined`, remove) the source of a failure's context. */
14
+ export function setFailureContext(source: (() => string) | undefined): void {
15
+ if (source === undefined) Reflect.deleteProperty(globalThis, KEY);
16
+ else Object.defineProperty(globalThis, KEY, { value: source, configurable: true });
17
+ }
18
+
19
+ /** The last `TAIL_LINES` lines of `log`, and never more than `TAIL_CHARS` of them. */
20
+ export function appLogTail(log: string): string {
21
+ const lines = log.trimEnd().split('\n').slice(-TAIL_LINES).join('\n');
22
+ return lines.length <= TAIL_CHARS ? lines : lines.slice(-TAIL_CHARS);
23
+ }
24
+
25
+ /**
26
+ * `error` with the app's log tail appended to its message, when a source is installed and has
27
+ * anything to say. Only an `Error`'s message is extended — that is what the runner prints under
28
+ * the failing test — and a thrown non-Error is handed back exactly as it was.
29
+ */
30
+ export function withFailureContext(error: unknown): unknown {
31
+ const source = Reflect.get(globalThis, KEY) as (() => string) | undefined;
32
+ if (source === undefined || !(error instanceof Error)) return error;
33
+ const tail = appLogTail(source());
34
+ if (tail === '') return error;
35
+ const count = tail.split('\n').length;
36
+ Object.defineProperty(error, 'message', {
37
+ value: `${error.message}\n\n── the e2e app logged, last ${String(count)} line(s): ──\n${tail}`,
38
+ configurable: true,
39
+ writable: true,
40
+ });
41
+ return error;
42
+ }
@@ -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
  }
@@ -5,6 +5,7 @@
5
5
  // holding a connection — so the trace this returns is keyed by step name and counts executions.
6
6
  // Nothing here polls or sleeps: a job becomes due only because `clock.advance()` said so.
7
7
 
8
+ import type { Actor, Ctx } from '@ultimat3/core';
8
9
  import { assert, createContext } from '@ultimat3/core';
9
10
  import type {
10
11
  AnyJobHandle,
@@ -16,6 +17,7 @@ import type {
16
17
  StepStatus,
17
18
  } from '@ultimat3/jobs';
18
19
  import { frozenNow } from './determinism';
20
+ import { frozenScheduler } from './frozen-scheduler';
19
21
 
20
22
  export interface StepTally {
21
23
  /** Times the step body actually ran. A replay from storage does not count. */
@@ -30,18 +32,43 @@ export interface StepTally {
30
32
  * about the whole run — a per-drain trace could not express it.
31
33
  */
32
34
  export interface JobRunTrace {
35
+ /**
36
+ * Each run as the worker settled it. `executions[n].result` is what the BODY returned, on a
37
+ * completed run — `{ skipped: true }` and "did the work" are otherwise one trace.
38
+ */
33
39
  readonly executions: readonly JobExecution[];
34
40
  /** `trace.steps['welcome-email'].executions` — the assertion a job test is written around. */
35
41
  readonly steps: Readonly<Record<string, StepTally>>;
36
42
  }
37
43
 
44
+ /** How a test files the row: the half of `handle.as(actor, input)` that reaches the queue. */
45
+ export interface JobEnqueueAs {
46
+ /**
47
+ * The ENQUEUER's tenant on the row — the limiter's bucket and the idempotency namespace, so one
48
+ * key in two tenants is two jobs. Never the tenant the body runs under: that is the job's own
49
+ * declaration (`tenant: (input) => input.orgId`), read off the input.
50
+ */
51
+ readonly tenantId?: string;
52
+ }
53
+
54
+ /** Who the worker is: what an app wires through `WorkerOptions.context()`. */
55
+ export interface JobDrainAs {
56
+ /**
57
+ * The identity the body runs as. Its ORG is replaced by the job's declared tenant, exactly as a
58
+ * real worker's is (`jobRunActor`) — `tenant: 'none'` strips it. Absent is core's anonymous actor.
59
+ */
60
+ readonly actor?: Actor;
61
+ }
62
+
63
+ export interface RunJobsOptions extends JobEnqueueAs, JobDrainAs {}
64
+
38
65
  /** `AsyncDisposable`: the fixture installs the ambient job driver and restores it after the test. */
39
66
  export interface RunJobs extends AsyncDisposable {
40
67
  /** Enqueue and drain in one call — the common case. */
41
- <I>(handle: JobHandle<I>, input: I): Promise<JobRunTrace>;
42
- enqueue<I>(handle: JobHandle<I>, input: I): Promise<EnqueueResult>;
68
+ <I>(handle: JobHandle<I>, input: I, options?: RunJobsOptions): Promise<JobRunTrace>;
69
+ enqueue<I>(handle: JobHandle<I>, input: I, options?: JobEnqueueAs): Promise<EnqueueResult>;
43
70
  /** Claim and execute everything due at the current instant, until nothing is. */
44
- drain(): Promise<JobRunTrace>;
71
+ drain(options?: JobDrainAs): Promise<JobRunTrace>;
45
72
  /** Live jobs — ready, delayed, running or suspended — optionally for one job only. */
46
73
  depth(handle?: AnyJobHandle): Promise<number>;
47
74
  /** Live jobs claimable right now. `clock.advance()` is what turns delayed into due. */
@@ -52,6 +79,14 @@ export interface RunJobs extends AsyncDisposable {
52
79
  const WORKER_ID = 'test-worker';
53
80
  const VISIBILITY_TIMEOUT_MS = 30_000;
54
81
  const CLAIM_LIMIT = 64;
82
+ /**
83
+ * Lease and slot renewals, every millisecond of TEST time — on the frozen clock, through
84
+ * `frozenScheduler`, so any `clock.advance()` renews every running job once and the wall clock
85
+ * renews nothing. A renewal is how a running body learns its row was cancelled (`x jobs cancel`,
86
+ * an app's own cancel action): cancel, advance, and the drain settles. The LAPSE side of the lease
87
+ * reads the same clock, so a renewal can never report a lease lost that `clock.advance()` did not.
88
+ */
89
+ const RENEW_MS = 1;
55
90
  /** A drain that has not settled in this many rounds is a runaway, not a slow queue. */
56
91
  const MAX_ROUNDS = 100;
57
92
  const LIVE_STATES: ReadonlySet<string> = new Set(['ready', 'delayed', 'running', 'suspended']);
@@ -80,7 +115,13 @@ export async function createRunJobs(): Promise<RunJobs> {
80
115
  // next file to call `send()` enqueues into this test's dead queue instead of sending inline.
81
116
  const previous = jobs.jobDriver();
82
117
  jobs.setJobDriver(driver);
83
- const ctx = createContext({ role: 'worker' });
118
+ // The same for the event bus `step.waitForEvent` reads: it is ambient and it STORES, so an
119
+ // answer one test published resumed the next test's waiting run. A fresh bus per fixture, and
120
+ // the process's own handed back with the driver.
121
+ const previousBus = jobs.eventBus();
122
+ jobs.setEventBus(jobs.createMemoryEventBus());
123
+ const anonymousWorker = createContext({ role: 'worker' });
124
+ const renewals = frozenScheduler();
84
125
 
85
126
  const introspect = (): NonNullable<JobDriver['introspect']> => {
86
127
  const found = driver.introspect;
@@ -92,8 +133,22 @@ export async function createRunJobs(): Promise<RunJobs> {
92
133
  return found;
93
134
  };
94
135
 
136
+ // Walked by cursor: one `list()` answers a PAGE (`MAX_JOB_PAGE`), and a test that queued more
137
+ // than a page of jobs must still see every one of them.
95
138
  const live = async (name?: string): Promise<readonly JobRecord[]> => {
96
- const rows = await introspect().list({ limit: 1000, ...(name === undefined ? {} : { name }) });
139
+ const rows: JobRecord[] = [];
140
+ let after: string | undefined;
141
+ for (;;) {
142
+ const page = await introspect().list({
143
+ limit: jobs.MAX_JOB_PAGE,
144
+ ...(name === undefined ? {} : { name }),
145
+ ...(after === undefined ? {} : { after }),
146
+ });
147
+ rows.push(...page);
148
+ const last = page[page.length - 1];
149
+ if (last === undefined || page.length < jobs.MAX_JOB_PAGE) break;
150
+ after = jobs.jobCursor(last);
151
+ }
97
152
  return rows.filter((record) => LIVE_STATES.has(record.state));
98
153
  };
99
154
 
@@ -101,32 +156,51 @@ export async function createRunJobs(): Promise<RunJobs> {
101
156
  ...new Set([jobs.DEFAULT_QUEUE, ...jobs.registeredJobs().map((handle) => handle.queue)]),
102
157
  ];
103
158
 
104
- const round = async (): Promise<readonly JobExecution[]> => {
105
- const claimed = await driver.claim({
159
+ /**
160
+ * One pass, through a REAL worker's `tick()`: claim, admission — the in-process limiter and the
161
+ * fleet slot `job.concurrency` holds, keyed or not — then the run. Calling `executeJob` on what
162
+ * `claim()` answered skipped admission entirely, so `whenBusy: 'fail'` never refused and
163
+ * `concurrency: 1` never waited under this fixture. Never `start()`ed: no timer, no registry
164
+ * row, no shutdown hook — the frozen clock and the test decide when a pass happens.
165
+ */
166
+ const round = async (ctx: Ctx): Promise<readonly JobExecution[]> => {
167
+ const worker = jobs.createWorker({
168
+ driver,
169
+ workerId: WORKER_ID,
106
170
  queues: queues(),
107
- limit: CLAIM_LIMIT,
171
+ // A whole batch per pass, as `claim({ limit })` was: two runs of one key are in flight
172
+ // together only if one pass claims both.
173
+ concurrency: CLAIM_LIMIT,
108
174
  visibilityTimeoutMs: VISIBILITY_TIMEOUT_MS,
109
- workerId: WORKER_ID,
175
+ heartbeatIntervalMs: RENEW_MS,
176
+ schedule: renewals.schedule,
177
+ // A shed run is claimable on the next pass, not after a wait this clock would never reach.
178
+ pollIntervalMs: 0,
179
+ drainOnShutdown: false,
180
+ context: () => ctx,
110
181
  });
111
- const executions: JobExecution[] = [];
112
- for (const job of claimed) {
113
- const handle = jobs.getJob(job.name);
114
- assert(
115
- handle !== undefined,
116
- `queue holds job "${job.name}" but nothing registered it`,
117
- `import the module that declares job("${job.name}") from the test file — the registry is populated by the import, not by the queue`,
118
- );
119
- executions.push(await jobs.executeJob({ driver, claimed: job, handle, ctx }));
120
- }
182
+ const executions = await worker.tick();
183
+ // The worker PARKS a job no module registered — right for a fleet mid-deploy, where the pod
184
+ // beside it may know the name, and silence in a test that forgot an import.
185
+ const orphan = executions.find((run) => run.error === `no job registered as "${run.job}"`);
186
+ assert(
187
+ orphan === undefined,
188
+ `queue holds job "${orphan?.job ?? ''}" but nothing registered it`,
189
+ `import the module that declares job("${orphan?.job ?? ''}") from the test file — the registry is populated by the import, not by the queue`,
190
+ );
121
191
  return executions;
122
192
  };
123
193
 
124
194
  /** Every execution this fixture has driven, because the trace is cumulative. */
125
195
  const history: JobExecution[] = [];
126
196
 
127
- const drain = async (): Promise<JobRunTrace> => {
197
+ const drain = async (options: JobDrainAs = {}): Promise<JobRunTrace> => {
198
+ const ctx =
199
+ options.actor === undefined
200
+ ? anonymousWorker
201
+ : createContext({ role: 'worker', actor: options.actor });
128
202
  for (let rounds = 0; rounds < MAX_ROUNDS; rounds += 1) {
129
- const batch = await round();
203
+ const batch = await round(ctx);
130
204
  if (batch.length === 0) return { executions: [...history], steps: tallyOf(history) };
131
205
  history.push(...batch);
132
206
  }
@@ -137,18 +211,27 @@ export async function createRunJobs(): Promise<RunJobs> {
137
211
  );
138
212
  };
139
213
 
140
- const enqueue = async <I>(handle: JobHandle<I>, input: I): Promise<EnqueueResult> =>
214
+ const enqueue = async <I>(
215
+ handle: JobHandle<I>,
216
+ input: I,
217
+ options: JobEnqueueAs = {},
218
+ ): Promise<EnqueueResult> =>
141
219
  driver.enqueue({
142
220
  name: handle.name,
143
221
  queue: handle.queue,
144
222
  input,
145
223
  idempotencyKey: handle.idempotencyKeyFor(input),
146
224
  maxAttempts: handle.retry.attempts,
225
+ ...(options.tenantId === undefined ? {} : { tenantId: options.tenantId }),
147
226
  });
148
227
 
149
- const enqueueThenDrain = async <I>(handle: JobHandle<I>, input: I): Promise<JobRunTrace> => {
150
- await enqueue(handle, input);
151
- return drain();
228
+ const enqueueThenDrain = async <I>(
229
+ handle: JobHandle<I>,
230
+ input: I,
231
+ options: RunJobsOptions = {},
232
+ ): Promise<JobRunTrace> => {
233
+ await enqueue(handle, input, options);
234
+ return drain(options);
152
235
  };
153
236
 
154
237
  return Object.assign(enqueueThenDrain, {
@@ -161,9 +244,15 @@ export async function createRunJobs(): Promise<RunJobs> {
161
244
  ).length,
162
245
  inFlight: async () => (await live()).filter((record) => record.state === 'running').length,
163
246
  [Symbol.asyncDispose]: async (): Promise<void> => {
164
- await driver.close?.();
165
- if (previous === undefined) jobs.resetJobDriver();
166
- 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
+ }
167
256
  },
168
257
  });
169
258
  }
package/src/fixtures.ts CHANGED
@@ -10,13 +10,14 @@
10
10
 
11
11
  import { test as bunTest } from 'bun:test';
12
12
  import { fixtureUnknown } from './errors';
13
+ import { withFailureContext } from './failure-context';
13
14
  import type { TestClock } from './fixture-clock';
14
15
  import type { SignIn, Subscribe, TestBudget, TestDeploy } from './fixture-drivers';
15
16
  import type { RunJobs } from './fixture-jobs';
16
17
  import type { TestMail } from './fixture-mail';
17
18
  import type { TestNetwork } from './fixture-network';
18
19
  import type { TestStatements } from './fixture-statements';
19
- import type { PageLike } from './test-types';
20
+ import type { PageLike, TestOptions } from './test-types';
20
21
 
21
22
  /** Built once per test, on first use. */
22
23
  export type FixtureFactory<T = unknown> = () => T | Promise<T>;
@@ -222,7 +223,8 @@ export async function runWithFixtures(body: FixtureRunBody): Promise<void> {
222
223
  }
223
224
  await body(bag as FixtureBag);
224
225
  } catch (error) {
225
- failure = { error };
226
+ // The body's own failure, with the e2e app's log tail when one is running (`failure-context.ts`).
227
+ failure = { error: withFailureContext(error) };
226
228
  }
227
229
 
228
230
  // Every disposer runs even when an earlier one throws: a fixture that cannot clean up must
@@ -244,7 +246,10 @@ export async function runWithFixtures(body: FixtureRunBody): Promise<void> {
244
246
  *
245
247
  * Teardown runs in reverse build order whether the body passed or threw: a failing assertion
246
248
  * must not be the reason the next file inherits a queue.
249
+ *
250
+ * `options.timeoutMs` is the test's own deadline, the same key the six typed helpers take: a
251
+ * fixture that builds something slow (an island, a browser) is the test that needs one.
247
252
  */
248
- export function fixtureTest(name: string, body: FixtureBody): void {
249
- bunTest(name, () => runWithFixtures(body));
253
+ export function fixtureTest(name: string, body: FixtureBody, options?: TestOptions): void {
254
+ bunTest(name, () => runWithFixtures(body), options?.timeoutMs);
250
255
  }
@@ -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
+ };
@@ -0,0 +1,49 @@
1
+ // An interval scheduler on the FROZEN clock: a tick fires when a test moves time past it, never on
2
+ // the wall clock. What the `runJobs` fixture renews its leases and slots on, so a running body
3
+ // hears its cancel at the next `clock.advance()` and no job test pays for a real-time loop.
4
+
5
+ import type { IntervalScheduler } from '@ultimat3/jobs';
6
+ import { frozenNow, onClockMoved } from './determinism';
7
+
8
+ interface Armed {
9
+ readonly tick: () => void;
10
+ readonly everyMs: number;
11
+ dueAt: number;
12
+ }
13
+
14
+ export interface FrozenScheduler extends Disposable {
15
+ readonly schedule: IntervalScheduler;
16
+ /** Ticks armed right now. A number that does not fall back to `0` is a renewal nobody stopped. */
17
+ armed(): number;
18
+ }
19
+
20
+ /**
21
+ * One tick per armed renewal per move, however far the clock jumped: a renewal is idempotent, and
22
+ * firing a three-day advance as 259,200 one-second ticks would be the wall-clock loop again.
23
+ */
24
+ export function frozenScheduler(): FrozenScheduler {
25
+ const timers = new Set<Armed>();
26
+ const stopListening = onClockMoved(() => {
27
+ const now = frozenNow().getTime();
28
+ for (const timer of [...timers]) {
29
+ if (!timers.has(timer) || timer.dueAt > now) continue;
30
+ timer.dueAt = now + timer.everyMs;
31
+ timer.tick();
32
+ }
33
+ });
34
+ const schedule: IntervalScheduler = (tick, everyMs) => {
35
+ const timer: Armed = { tick, everyMs, dueAt: frozenNow().getTime() + everyMs };
36
+ timers.add(timer);
37
+ return () => {
38
+ timers.delete(timer);
39
+ };
40
+ };
41
+ return {
42
+ schedule,
43
+ armed: () => timers.size,
44
+ [Symbol.dispose]: () => {
45
+ stopListening();
46
+ timers.clear();
47
+ },
48
+ };
49
+ }
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
  });