@ultimat3/testing 22.15.0 → 23.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.
@@ -154,6 +154,24 @@ export function restoreCapturedDeterminism(snapshot: DeterminismSnapshot): void
154
154
 
155
155
  export const isDeterminismInstalled = (): boolean => installed;
156
156
 
157
+ /**
158
+ * Told each time a test MOVES the clock — `advanceClock` and `setFrozenClock`. A frozen clock that
159
+ * nothing can hear move is one a timer can only ignore: `frozen-scheduler.ts` is the listener, and
160
+ * it is how the `runJobs` fixture renews a lease when time passes instead of on the wall clock.
161
+ */
162
+ const clockMoved = new Set<() => void>();
163
+
164
+ export function onClockMoved(listener: () => void): () => void {
165
+ clockMoved.add(listener);
166
+ return () => {
167
+ clockMoved.delete(listener);
168
+ };
169
+ }
170
+
171
+ const announceMove = (): void => {
172
+ for (const listener of [...clockMoved]) listener();
173
+ };
174
+
157
175
  /** Move the frozen clock forward. The only legal way for time to pass inside a test. */
158
176
  export function advanceClock(ms: number): Date {
159
177
  // A required parameter with no default, so no `??` and no ratchet can see it — and it writes the
@@ -161,6 +179,7 @@ export function advanceClock(ms: number): Date {
161
179
  // process, which no later `advance` and no later `set` undoes. Negative is legal: a test may
162
180
  // move the clock backwards.
163
181
  frozenAt += finiteOption('advanceClock', 'ms', ms);
182
+ announceMove();
164
183
  return new RealDate(frozenAt);
165
184
  }
166
185
 
@@ -168,6 +187,7 @@ export const frozenNow = (): Date => new RealDate(frozenAt);
168
187
 
169
188
  export function setFrozenClock(now: string | number): void {
170
189
  frozenAt = instantMs('setFrozenClock', now);
190
+ announceMove();
171
191
  }
172
192
 
173
193
  /** Run `body` with the clock frozen at `now`, then restore whatever was there before. */
package/src/e2e-app.ts CHANGED
@@ -12,6 +12,7 @@ import { join } from 'node:path';
12
12
  import { finiteCount } from '@ultimat3/core';
13
13
  import type { E2eAppMode } from './e2e-spawn';
14
14
  import { inherited, refuse, spawnE2eApp, xBin } from './e2e-spawn';
15
+ import { testSealKeyEnv } from './test-seal-key';
15
16
 
16
17
  export type { E2eAppMode } from './e2e-spawn';
17
18
 
@@ -35,6 +36,11 @@ export interface E2eApp {
35
36
  readonly base: string;
36
37
  /** The throwaway `.x` this app runs on — the one directory a test may inspect or corrupt. */
37
38
  readonly stateDir: string;
39
+ /**
40
+ * The last of what the app printed, bounded — what a failing e2e test carries in its message
41
+ * (`failure-context.ts`), so a live-path bug is readable without running `x dev` by hand.
42
+ */
43
+ log(): string;
38
44
  /** Kill the app and delete its state directory. Idempotent. */
39
45
  stop(): Promise<void>;
40
46
  /**
@@ -72,7 +78,13 @@ export async function startE2eApp(options: StartE2eAppOptions): Promise<E2eApp>
72
78
  // Before the state directory too: an app that cannot name its `x` leaves nothing behind.
73
79
  const bin = await xBin(options.root);
74
80
  const stateDir = await mkdtemp(join(tmpdir(), 'ultimate-e2e-'));
75
- const env: Record<string, string> = { ...options.env, ULTIMATE_STATE_DIR: stateDir };
81
+ // The app is a server, not a test process: it installs no seal key for itself. The harness hands
82
+ // down the throwaway one when the app's root has none, so a `.sealed()` column works under e2e.
83
+ const env: Record<string, string> = {
84
+ ...testSealKeyEnv({ root: options.root }),
85
+ ...options.env,
86
+ ULTIMATE_STATE_DIR: stateDir,
87
+ };
76
88
  const cleanup = (): Promise<void> => rm(stateDir, { recursive: true, force: true });
77
89
  try {
78
90
  x(bin, ['db', 'reset'], options.root, env);
@@ -93,6 +105,7 @@ export async function startE2eApp(options: StartE2eAppOptions): Promise<E2eApp>
93
105
  return {
94
106
  base: spawned.base,
95
107
  stateDir,
108
+ log: () => spawned.log(),
96
109
  restart: (next) => spawned.restart(next),
97
110
  async stop(): Promise<void> {
98
111
  await spawned.stop();
package/src/e2e-driver.ts CHANGED
@@ -6,6 +6,7 @@ import { test as bunTest } from 'bun:test';
6
6
  import type { E2eBrowserPage, E2ePageOptions } from './e2e-page';
7
7
  import { e2ePage } from './e2e-page';
8
8
  import { FixtureUnavailableError } from './errors';
9
+ import { withFailureContext } from './failure-context';
9
10
  import { unavailableFixture } from './fixture-drivers';
10
11
  import { defineFixtures } from './fixtures';
11
12
  import type { E2eBody, E2eFixtures, PageLike } from './test-types';
@@ -97,8 +98,16 @@ export function installE2eDriver(options: E2eDriverOptions): () => void {
97
98
  page: () => page,
98
99
  ...(newBuild === undefined ? {} : { deploy: () => ({ newBuild }) }),
99
100
  });
100
- useE2eDriver((name, body: E2eBody) => {
101
- bunTest(name, () => body(e2eFixtures(page, options.page, newBuild)));
101
+ useE2eDriver((name, body: E2eBody, test) => {
102
+ // Through `withFailureContext`, as `fixtureTest` is: the failing test carries the app's log.
103
+ const run = async (): Promise<void> => {
104
+ try {
105
+ await body(e2eFixtures(page, options.page, newBuild));
106
+ } catch (error) {
107
+ throw withFailureContext(error);
108
+ }
109
+ };
110
+ bunTest(name, run, test?.timeoutMs);
102
111
  });
103
112
  return () => {
104
113
  // Both halves, because both were installed. Putting the DECLARATION back — rather than
package/src/e2e-run.ts CHANGED
@@ -10,6 +10,7 @@ import { e2eBrowser, publishE2eRun, republishE2eBrowser } from './e2e-browser-ha
10
10
  import type { E2eDriverOptions } from './e2e-driver';
11
11
  import type { E2eBrowserPage } from './e2e-page';
12
12
  import { answersWithin } from './e2e-probe';
13
+ import { setFailureContext } from './failure-context';
13
14
 
14
15
  /** How long a live browser gets to answer `1` before it is declared hung and relaunched. */
15
16
  export const PROBE_MS = 5_000;
@@ -54,6 +55,9 @@ export async function startE2eRun(deps: E2eRunDeps): Promise<void> {
54
55
  };
55
56
  let browser = await openOrStop(app, deps.openBrowser);
56
57
  publishE2eRun({ browser, app });
58
+ // Every test that fails from here on carries the app's log tail: the server's half of a failure
59
+ // the browser can only report as "the row never arrived".
60
+ setFailureContext(() => app.log());
57
61
  // Installed ONCE, over a page that delegates to whichever browser is current: an `e2eTest` body
58
62
  // is bound to its fixtures when the file DEFINES it, so reinstalling on a relaunch would leave
59
63
  // every test defined before it driving a closed browser.
@@ -80,6 +84,7 @@ export async function startE2eRun(deps: E2eRunDeps): Promise<void> {
80
84
  });
81
85
 
82
86
  deps.afterAll(async () => {
87
+ setFailureContext(undefined);
83
88
  uninstall();
84
89
  browser.close();
85
90
  await app.stop();
package/src/e2e-spawn.ts CHANGED
@@ -45,6 +45,8 @@ const POLL_MS = 250;
45
45
  export interface SpawnedE2eApp {
46
46
  /** `http://localhost:<port>`, no trailing slash. */
47
47
  readonly base: string;
48
+ /** The last of what the CURRENT process printed, stdout and stderr, bounded. */
49
+ log(): string;
48
50
  /** Kill the process. Idempotent. */
49
51
  stop(): Promise<void>;
50
52
  /** Kill it and start it again on the SAME port, with `env` added — a deploy. Refused after `stop()`. */
@@ -142,6 +144,7 @@ export async function spawnE2eApp(options: SpawnE2eAppOptions): Promise<SpawnedE
142
144
  }
143
145
  return {
144
146
  base,
147
+ log: () => tail(),
145
148
  stop,
146
149
  async restart(next: Readonly<Record<string, string>> = {}): Promise<void> {
147
150
  // `stop()` is final. A child respawned here would be one no later `stop()` kills — the flag
package/src/errors.ts CHANGED
@@ -47,6 +47,7 @@ export const TESTING_ERROR_CODES = [
47
47
  'X_TEST_POLICY_EXPECTED',
48
48
  'X_TEST_OPENAPI_EXPECTED',
49
49
  'X_TEST_NUMBER_EXPECTED',
50
+ 'X_TEST_ISLAND_STATE_UNKNOWN',
50
51
  ] as const;
51
52
 
52
53
  export type TestingErrorCode = (typeof TESTING_ERROR_CODES)[number];
@@ -85,6 +86,7 @@ export const TESTING_ERROR_TITLES: Readonly<Record<TestingErrorCode, string>> =
85
86
  X_TEST_POLICY_EXPECTED: 'a matcher expected a policy and got something else',
86
87
  X_TEST_OPENAPI_EXPECTED: 'a matcher expected an OpenAPI document and got something else',
87
88
  X_TEST_NUMBER_EXPECTED: 'a matcher expected a finite number and got something else',
89
+ X_TEST_ISLAND_STATE_UNKNOWN: 'an island state manifest declares no state with that id',
88
90
  };
89
91
 
90
92
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -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
+ }
@@ -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, {
@@ -162,6 +245,8 @@ export async function createRunJobs(): Promise<RunJobs> {
162
245
  inFlight: async () => (await live()).filter((record) => record.state === 'running').length,
163
246
  [Symbol.asyncDispose]: async (): Promise<void> => {
164
247
  await driver.close?.();
248
+ renewals[Symbol.dispose]();
249
+ jobs.setEventBus(previousBus);
165
250
  if (previous === undefined) jobs.resetJobDriver();
166
251
  else jobs.setJobDriver(previous);
167
252
  },
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,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/index.ts CHANGED
@@ -23,6 +23,10 @@ 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';
26
30
  // The browser-backed e2e driver and the raw-CDP browser under it — moved here from
27
31
  // `@ultimat3/cli` in 22.0.0. `installE2eDriver` is the ONE entry point an app's test preload calls;
28
32
  // `openE2eBrowserIfAvailable()` answers `undefined` on a machine with no Chrome, so a
@@ -167,7 +171,14 @@ export type {
167
171
  SharedChunkLike,
168
172
  } from './fixture-island';
169
173
  export { mountIsland } from './fixture-island';
170
- export type { JobRunTrace, RunJobs, StepTally } from './fixture-jobs';
174
+ export type {
175
+ JobDrainAs,
176
+ JobEnqueueAs,
177
+ JobRunTrace,
178
+ RunJobs,
179
+ RunJobsOptions,
180
+ StepTally,
181
+ } from './fixture-jobs';
171
182
  export { createRunJobs } from './fixture-jobs';
172
183
  export type { MailRef, TestMail } from './fixture-mail';
173
184
  export { createTestMail } from './fixture-mail';
@@ -201,6 +212,14 @@ export {
201
212
  // The file an error tells the reader to edit — the island's own name with `.states.ts` where
202
213
  // `.tsx` was. Exported so the command that takes the pictures names the same file the refusal does.
203
214
  export { islandStatesFile } from './island-state-errors';
215
+ // One declared state, mounted: `describeIslandState` owns a block's mount and teardown,
216
+ // `mountIslandState` is the one mount a single test asks for with `using`.
217
+ export type { IslandStateMountOptions, IslandStateSuiteOptions } from './island-state-mount';
218
+ export {
219
+ describeIslandState,
220
+ ISLAND_MOUNT_TIMEOUT_MS,
221
+ mountIslandState,
222
+ } from './island-state-mount';
204
223
  export type {
205
224
  IslandRouteStub,
206
225
  IslandState,
@@ -254,6 +273,9 @@ export {
254
273
  export { ISOLATED_ENV, releasePluginsAfterIsolatedFile } from './isolated-plugins';
255
274
  export type { LiveConnection, LiveNodeHandle, LiveNodeOptions } from './live-node';
256
275
  export { createLiveNode } from './live-node';
276
+ // The store a mutator's `local()` half writes into under test — the Map every app hand-rolled.
277
+ export type { LocalRows, MemoryLocalTx } from './local-tx';
278
+ export { memoryLocalTx } from './local-tx';
257
279
  /**
258
280
  * The budget `toBeVisible(options?)` takes. Exported because it is in a public matcher's signature;
259
281
  * `retryUntil` and `RetryBudget` deliberately are NOT — nothing outside this package calls them,
@@ -272,6 +294,10 @@ export type { RegistryLeak, RegistrySample } from './registry-leak-guard';
272
294
  export { installRegistryLeakGuard, leakBetween, sampleRegistries } from './registry-leak-guard';
273
295
  export type { ProcessRegistrySnapshot } from './registry-snapshot';
274
296
  export { captureProcessRegistries, restoreProcessRegistries } from './registry-snapshot';
297
+ // What the server renders, for a unit test. `@ultimat3/render` is imported inside the two
298
+ // functions, so a tier-0 test importing this barrel for `expect` still loads no renderer.
299
+ export type { RenderedRoute, RenderedView, RouteModule, RouteRequest } from './render-view';
300
+ export { renderRoute, renderView } from './render-view';
275
301
  export type { DatabaseSnapshot } from './reusable-database';
276
302
  export { restoreDatabase, reusableDatabase, snapshotDatabase } from './reusable-database';
277
303
  export type { MockRoute, NetworkSnapshot, NetworkState } from './sealed-network';
@@ -308,12 +334,14 @@ export {
308
334
  } from './template-db';
309
335
  export type {
310
336
  E2eBody,
337
+ E2eDriver,
311
338
  E2eFixtures,
312
339
  EvalCase,
313
340
  EvalOptions,
314
341
  LocatorLike,
315
342
  OpenApiLike,
316
343
  PageLike,
344
+ TestOptions,
317
345
  TestType,
318
346
  } from './test-types';
319
347
  export {
@@ -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
+ }