@ultimat3/testing 1.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.
@@ -0,0 +1,97 @@
1
+ // The fixtures the framework DECLARES but cannot build in this process: a browser for `page`,
2
+ // `budget`, `signIn` and `deploy`; a replicator feeding a live-query registry for `subscribe`.
3
+ // Each is a type a driver implements, plus a factory that says what is missing until one does.
4
+
5
+ import { fixtureUnavailable } from './errors';
6
+ import type { FixtureFactory, Fixtures } from './fixtures';
7
+
8
+ /** Per-route byte budgets, measured off the built output rather than declared. */
9
+ export interface TestBudget {
10
+ jsBytes(route: string): Promise<number>;
11
+ }
12
+
13
+ /** Put the browser session in this member's shoes. A row, because the app owns what a member is. */
14
+ export type SignIn = (member: Readonly<Record<string, unknown>>) => Promise<void>;
15
+
16
+ /** Version skew: same app, new immutable build id, while the page stays open. */
17
+ export interface TestDeploy {
18
+ newBuild(): Promise<void>;
19
+ }
20
+
21
+ /**
22
+ * What `query.live(input, { actor })` resolves to, named structurally so `@ultimat3/testing` does
23
+ * not take a dependency on `@ultimat3/query` for one type. The real `LiveQuery` satisfies it.
24
+ */
25
+ export interface LiveTarget {
26
+ readonly name: string;
27
+ readonly queryHash: string;
28
+ }
29
+
30
+ export interface LiveFeedPatch<R extends object> {
31
+ readonly op: 'insert' | 'update' | 'delete';
32
+ readonly row: R;
33
+ }
34
+
35
+ /** One subscriber's view: what it holds, what it was sent, and how it got there. */
36
+ export interface LiveFeed<R extends object> {
37
+ rows(): readonly R[];
38
+ row(id: string): R | undefined;
39
+ /** The optimistic twin a mutator applied locally, before the server confirmed it. */
40
+ local(id: string): R | undefined;
41
+ patches(): readonly LiveFeedPatch<R>[];
42
+ /** Resolves when every patch in flight has been applied — never a sleep. */
43
+ settled(): Promise<void>;
44
+ lsn(): string;
45
+ /** Set when a reconnect resumed from a cursor; undefined when it resnapshotted. */
46
+ resubscribedFrom(): string | undefined;
47
+ /** How many snapshots this subscriber received. A resume that refetched shows up here. */
48
+ snapshots(): number;
49
+ }
50
+
51
+ export type Subscribe = <R extends object>(
52
+ target: LiveTarget | Promise<LiveTarget>,
53
+ ) => Promise<LiveFeed<R>>;
54
+
55
+ export const DRIVER_FIXTURE_NAMES = ['budget', 'deploy', 'page', 'signIn', 'subscribe'] as const;
56
+
57
+ export type DriverFixtureName = (typeof DRIVER_FIXTURE_NAMES)[number];
58
+
59
+ /** What each one is waiting on. `Record` over the name union, so the two lists cannot drift. */
60
+ export const DRIVER_FIXTURE_NEEDS: Readonly<Record<DriverFixtureName, string>> = {
61
+ budget: 'the byte counts a browser run measures off the built output',
62
+ deploy: 'a second build to switch the running app to',
63
+ page: 'a browser driving the built app',
64
+ signIn: 'a browser session against the app’s own sign-in route',
65
+ subscribe: 'an in-process replicator feeding the live-query registry',
66
+ };
67
+
68
+ /**
69
+ * Declared rather than left out, because the name is the contract. An app that registered its own
70
+ * `page` would be deciding for itself what a page is, and two apps would then disagree — the same
71
+ * reason `clock` is not an app fixture. And an unregistered name fails as X_TEST_FIXTURE_UNKNOWN,
72
+ * whose fix ("register it") is the wrong instruction: what is missing is a driver, not a
73
+ * registration. So the name resolves, and asking for it without a driver says so.
74
+ *
75
+ * Throws when built, not when used. Building is where the test is still on its own first line, so
76
+ * the failure names the fixture instead of surfacing three awaits later as a missing method.
77
+ */
78
+ export const unavailableFixture =
79
+ <K extends DriverFixtureName>(name: K): FixtureFactory<Fixtures[K]> =>
80
+ () => {
81
+ throw fixtureUnavailable(name, DRIVER_FIXTURE_NEEDS[name]);
82
+ };
83
+
84
+ /** Each declaration carries the type its driver must satisfy — `defineFixtures` holds it to that. */
85
+ export type DriverFixtures = { readonly [K in DriverFixtureName]: FixtureFactory<Fixtures[K]> };
86
+
87
+ /**
88
+ * The declared bag, as `defineFixtures` takes it. A driver overrides these the ordinary way —
89
+ * `defineFixtures` merges, last registration wins.
90
+ */
91
+ export const driverFixtures = (): DriverFixtures => ({
92
+ budget: unavailableFixture('budget'),
93
+ deploy: unavailableFixture('deploy'),
94
+ page: unavailableFixture('page'),
95
+ signIn: unavailableFixture('signIn'),
96
+ subscribe: unavailableFixture('subscribe'),
97
+ });
@@ -0,0 +1,169 @@
1
+ // The `runJobs` fixture: a whole worker, in-process, driven by the frozen clock.
2
+ //
3
+ // A job test asserts the guarantees rather than the return value — a step replayed instead of
4
+ // re-run, a duplicate enqueue deduped, a `step.sleep('3d')` that parks the run instead of
5
+ // holding a connection — so the trace this returns is keyed by step name and counts executions.
6
+ // Nothing here polls or sleeps: a job becomes due only because `clock.advance()` said so.
7
+
8
+ import { assert, createContext } from '@ultimat3/core';
9
+ import type {
10
+ AnyJobHandle,
11
+ EnqueueResult,
12
+ JobDriver,
13
+ JobExecution,
14
+ JobHandle,
15
+ JobRecord,
16
+ StepStatus,
17
+ } from '@ultimat3/jobs';
18
+ import { frozenNow } from './determinism';
19
+
20
+ export interface StepTally {
21
+ /** Times the step body actually ran. A replay from storage does not count. */
22
+ readonly executions: number;
23
+ readonly attempts: number;
24
+ readonly status: StepStatus;
25
+ }
26
+
27
+ /**
28
+ * Cumulative for the life of the fixture, which is one test. A retry driven by
29
+ * `clock.advance()` is a second drain, and "provision ran once, nudge ran twice" is a claim
30
+ * about the whole run — a per-drain trace could not express it.
31
+ */
32
+ export interface JobRunTrace {
33
+ readonly executions: readonly JobExecution[];
34
+ /** `trace.steps['welcome-email'].executions` — the assertion a job test is written around. */
35
+ readonly steps: Readonly<Record<string, StepTally>>;
36
+ }
37
+
38
+ /** `AsyncDisposable`: the fixture installs the ambient job driver and restores it after the test. */
39
+ export interface RunJobs extends AsyncDisposable {
40
+ /** 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>;
43
+ /** Claim and execute everything due at the current instant, until nothing is. */
44
+ drain(): Promise<JobRunTrace>;
45
+ /** Live jobs — ready, delayed, running or suspended — optionally for one job only. */
46
+ depth(handle?: AnyJobHandle): Promise<number>;
47
+ /** Live jobs claimable right now. `clock.advance()` is what turns delayed into due. */
48
+ due(): Promise<number>;
49
+ inFlight(): Promise<number>;
50
+ }
51
+
52
+ const WORKER_ID = 'test-worker';
53
+ const VISIBILITY_TIMEOUT_MS = 30_000;
54
+ const CLAIM_LIMIT = 64;
55
+ /** A drain that has not settled in this many rounds is a runaway, not a slow queue. */
56
+ const MAX_ROUNDS = 100;
57
+ const LIVE_STATES: ReadonlySet<string> = new Set(['ready', 'delayed', 'running', 'suspended']);
58
+
59
+ const tallyOf = (executions: readonly JobExecution[]): Record<string, StepTally> => {
60
+ const steps: Record<string, StepTally> = {};
61
+ for (const execution of executions) {
62
+ const replayed = new Set(execution.replayed);
63
+ for (const step of execution.steps) {
64
+ const previous = steps[step.name];
65
+ const ran = replayed.has(step.name) ? 0 : 1;
66
+ steps[step.name] = {
67
+ executions: (previous?.executions ?? 0) + ran,
68
+ attempts: step.attempts,
69
+ status: step.status,
70
+ };
71
+ }
72
+ }
73
+ return steps;
74
+ };
75
+
76
+ export async function createRunJobs(): Promise<RunJobs> {
77
+ const jobs = await import('@ultimat3/jobs');
78
+ const driver: JobDriver = jobs.createMemoryDriver();
79
+ // Captured before the overwrite: the ambient driver is process-global, so without this the
80
+ // next file to call `send()` enqueues into this test's dead queue instead of sending inline.
81
+ const previous = jobs.jobDriver();
82
+ jobs.setJobDriver(driver);
83
+ const ctx = createContext({ role: 'worker' });
84
+
85
+ const introspect = (): NonNullable<JobDriver['introspect']> => {
86
+ const found = driver.introspect;
87
+ assert(
88
+ found !== undefined,
89
+ 'the in-memory job driver lost its introspection surface',
90
+ 'runJobs reads queue state through driver.introspect — do not replace the driver inside a test',
91
+ );
92
+ return found;
93
+ };
94
+
95
+ const live = async (name?: string): Promise<readonly JobRecord[]> => {
96
+ const rows = await introspect().list({ limit: 1000, ...(name === undefined ? {} : { name }) });
97
+ return rows.filter((record) => LIVE_STATES.has(record.state));
98
+ };
99
+
100
+ const queues = (): readonly string[] => [
101
+ ...new Set([jobs.DEFAULT_QUEUE, ...jobs.registeredJobs().map((handle) => handle.queue)]),
102
+ ];
103
+
104
+ const round = async (): Promise<readonly JobExecution[]> => {
105
+ const claimed = await driver.claim({
106
+ queues: queues(),
107
+ limit: CLAIM_LIMIT,
108
+ visibilityTimeoutMs: VISIBILITY_TIMEOUT_MS,
109
+ workerId: WORKER_ID,
110
+ });
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
+ }
121
+ return executions;
122
+ };
123
+
124
+ /** Every execution this fixture has driven, because the trace is cumulative. */
125
+ const history: JobExecution[] = [];
126
+
127
+ const drain = async (): Promise<JobRunTrace> => {
128
+ for (let rounds = 0; rounds < MAX_ROUNDS; rounds += 1) {
129
+ const batch = await round();
130
+ if (batch.length === 0) return { executions: [...history], steps: tallyOf(history) };
131
+ history.push(...batch);
132
+ }
133
+ assert(
134
+ false,
135
+ `runJobs.drain() ran ${MAX_ROUNDS} rounds without the queue settling`,
136
+ 'give the failing job a retry delay, or assert with runJobs.due() instead of draining a job that re-enqueues itself',
137
+ );
138
+ };
139
+
140
+ const enqueue = async <I>(handle: JobHandle<I>, input: I): Promise<EnqueueResult> =>
141
+ driver.enqueue({
142
+ name: handle.name,
143
+ queue: handle.queue,
144
+ input,
145
+ idempotencyKey: handle.idempotencyKeyFor(input),
146
+ maxAttempts: handle.retry.attempts,
147
+ });
148
+
149
+ const enqueueThenDrain = async <I>(handle: JobHandle<I>, input: I): Promise<JobRunTrace> => {
150
+ await enqueue(handle, input);
151
+ return drain();
152
+ };
153
+
154
+ return Object.assign(enqueueThenDrain, {
155
+ enqueue,
156
+ drain,
157
+ depth: async (handle?: AnyJobHandle) => (await live(handle?.name)).length,
158
+ due: async () =>
159
+ (await live()).filter(
160
+ (record) => record.state !== 'running' && record.runAt <= frozenNow().getTime(),
161
+ ).length,
162
+ inFlight: async () => (await live()).filter((record) => record.state === 'running').length,
163
+ [Symbol.asyncDispose]: async (): Promise<void> => {
164
+ await driver.close?.();
165
+ if (previous === undefined) jobs.resetJobDriver();
166
+ else jobs.setJobDriver(previous);
167
+ },
168
+ });
169
+ }
@@ -0,0 +1,61 @@
1
+ // The `mail` fixture: an in-memory outbox, plus the one failure a mail test actually needs.
2
+ //
3
+ // `mail.failOnce(nudgeEmail)` is how a job test proves that only the failed step retried. It is
4
+ // a transport failure rather than a thrown stub because a stub would bypass rendering, and a
5
+ // mail that fails to render is the bug this catches most often.
6
+
7
+ import type { MailDriver, MailMessage, SendResult, SentMail } from '@ultimat3/mail';
8
+
9
+ /** A `defineMail()` handle, or its id. Both read naturally at a call site. */
10
+ export type MailRef = string | { readonly id: string };
11
+
12
+ /** `Disposable`: the fixture installs the ambient mail driver and restores it after the test. */
13
+ export interface TestMail extends Disposable {
14
+ /** Newest first, so an assertion does not index backwards. */
15
+ outbox(): readonly SentMail[];
16
+ lastTo(address: string): SentMail | undefined;
17
+ /** The next send of this mail fails, once. Every later send succeeds. */
18
+ failOnce(mail: MailRef): void;
19
+ clear(): void;
20
+ }
21
+
22
+ const idOf = (mail: MailRef): string => (typeof mail === 'string' ? mail : mail.id);
23
+
24
+ export async function createTestMail(): Promise<TestMail> {
25
+ const { createMemoryDriver, driverUnavailable, resetMailDriver, setMailDriver, tryMailDriver } =
26
+ await import('@ultimat3/mail');
27
+ const memory = createMemoryDriver();
28
+ const failuresLeft = new Map<string, number>();
29
+ // The ambient driver is process-global; the fixture borrows it for one test and hands it back.
30
+ const previous = tryMailDriver();
31
+
32
+ const driver: MailDriver = {
33
+ name: 'test',
34
+ send(message: MailMessage): Promise<SendResult> {
35
+ const left = failuresLeft.get(message.mailId) ?? 0;
36
+ if (left === 0) return memory.send(message);
37
+ failuresLeft.set(message.mailId, left - 1);
38
+ return Promise.reject(
39
+ driverUnavailable(`mail.failOnce() failed "${message.mailId}" on purpose`),
40
+ );
41
+ },
42
+ };
43
+ setMailDriver(driver);
44
+
45
+ return {
46
+ outbox: () => memory.outbox(),
47
+ lastTo: (address) => memory.lastTo(address),
48
+ failOnce: (mail) => {
49
+ const id = idOf(mail);
50
+ failuresLeft.set(id, (failuresLeft.get(id) ?? 0) + 1);
51
+ },
52
+ clear: () => {
53
+ memory.clear();
54
+ failuresLeft.clear();
55
+ },
56
+ [Symbol.dispose]: (): void => {
57
+ if (previous === undefined) resetMailDriver();
58
+ else setMailDriver(previous);
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,61 @@
1
+ // The `network` fixture: pull the cable, put back exactly what was there. What an offline test
2
+ // needs is not a mock that answers differently — it is a request that fails the way a real one
3
+ // fails, so the app's own offline path runs instead of a branch written for the test.
4
+
5
+ import {
6
+ isNetworkSealed,
7
+ type NetworkState,
8
+ networkState,
9
+ sealNetwork,
10
+ setNetworkState,
11
+ unsealNetwork,
12
+ } from './sealed-network';
13
+
14
+ /** `Disposable`: the gate is process-global, so the fixture puts back the state it found. */
15
+ export interface TestNetwork extends Disposable {
16
+ /** Every request fails as it would with no route to the host. */
17
+ offline(): void;
18
+ /**
19
+ * Offline, and the connection was cut rather than closed — a subscriber must reconnect.
20
+ * Next to `offline()` because the two are different bugs: a clean offline is what a service
21
+ * worker answers, and a fixture with only one of them cannot tell a resume from a resubscribe.
22
+ */
23
+ drop(): void;
24
+ online(): void;
25
+ state(): NetworkState;
26
+ }
27
+
28
+ /**
29
+ * Synchronous, unlike `mail` and `runJobs`: the gate it drives lives in this package, so there is
30
+ * no subsystem to import on demand. The test bodies rely on it — `network.offline()` is followed
31
+ * on the next line by the mutation that has to observe it.
32
+ */
33
+ export function createTestNetwork(): TestNetwork {
34
+ // A process that unsealed on purpose (ULTIMATE_TEST_ALLOW_NET=1) still gets a working
35
+ // `offline()`, and gets its unsealed fetch back on disposal rather than keeping ours.
36
+ const sealedBefore = isNetworkSealed();
37
+ // Both halves of what was here, because disposal restores rather than assumes. An outer fixture
38
+ // already offline must not come back online because an inner one finished.
39
+ const stateBefore = networkState();
40
+ let sealedByUs = false;
41
+
42
+ const goto = (next: NetworkState): void => {
43
+ if (next !== 'online' && !isNetworkSealed()) {
44
+ sealNetwork();
45
+ sealedByUs = true;
46
+ }
47
+ setNetworkState(next);
48
+ };
49
+
50
+ return {
51
+ offline: () => goto('offline'),
52
+ drop: () => goto('dropped'),
53
+ online: () => goto('online'),
54
+ state: networkState,
55
+ [Symbol.dispose]: (): void => {
56
+ setNetworkState(stateBefore);
57
+ if (sealedByUs && !sealedBefore) unsealNetwork();
58
+ sealedByUs = false;
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,189 @@
1
+ // Fixture injection: `test('…', async ({ seed, actorFor }) => …)`.
2
+ //
3
+ // Bun's `test` passes a `done` callback as the first argument, so destructuring a fixture bag
4
+ // from it yields `undefined` for every key — silently, because the failure only surfaces later
5
+ // as "cannot read property of undefined", naming nothing useful. This wraps `bun:test` so the
6
+ // first argument is the fixture bag instead.
7
+ //
8
+ // Fixtures are registered by the app, not hardcoded here: `seed` and `billing` mean nothing to
9
+ // the framework. `defineFixtures` merges, so a package can add one without knowing the others.
10
+
11
+ import { test as bunTest } from 'bun:test';
12
+ import { fixtureUnknown } from './errors';
13
+ import type { TestClock } from './fixture-clock';
14
+ import type { SignIn, Subscribe, TestBudget, TestDeploy } from './fixture-drivers';
15
+ import type { RunJobs } from './fixture-jobs';
16
+ import type { TestMail } from './fixture-mail';
17
+ import type { TestNetwork } from './fixture-network';
18
+ import type { PageLike } from './test-types';
19
+
20
+ /** Built once per test, on first use. */
21
+ export type FixtureFactory<T = unknown> = () => T | Promise<T>;
22
+
23
+ /** The registry's own shape, where the built type is erased — what `fixtureSnapshot()` hands back. */
24
+ export type FixtureMap = Readonly<Record<string, FixtureFactory>>;
25
+
26
+ /**
27
+ * What a test body receives. Everything the framework owns is declared here and registered by the
28
+ * preload; apps widen it by augmenting `Fixtures`:
29
+ *
30
+ * ```ts
31
+ * declare module '@ultimat3/testing' {
32
+ * interface Fixtures {
33
+ * seed: (name: string) => SeedHandle;
34
+ * }
35
+ * }
36
+ * ```
37
+ *
38
+ * The last five are declared but driver-backed: destructuring one in a process with no driver
39
+ * fails as `X_TEST_FIXTURE_UNAVAILABLE`, naming what is missing. Typed here anyway, because the
40
+ * type is the contract a driver implements — see `fixture-drivers.ts`.
41
+ */
42
+ export interface Fixtures {
43
+ readonly clock: TestClock;
44
+ readonly mail: TestMail;
45
+ readonly network: TestNetwork;
46
+ readonly runJobs: RunJobs;
47
+ readonly budget: TestBudget;
48
+ readonly deploy: TestDeploy;
49
+ readonly page: PageLike;
50
+ readonly signIn: SignIn;
51
+ readonly subscribe: Subscribe;
52
+ }
53
+
54
+ /**
55
+ * A registration bag, with every name the framework declares held to the type it was declared
56
+ * with. A driver that registers a half-built `page` is a compile error at the registration, rather
57
+ * than a missing method three awaits into some later test — the same reason the name is declared
58
+ * at all. A key `Fixtures` does not name is the app's, and takes any factory: the framework has
59
+ * nothing to check it against until the app augments `Fixtures`.
60
+ *
61
+ * Written over the argument's own keys rather than as an intersection, so a value typed only as
62
+ * `FixtureMap` — a snapshot on its way back into the registry — still satisfies it.
63
+ */
64
+ export type FixtureRegistration<M> = {
65
+ readonly [K in keyof M]: K extends keyof Fixtures ? FixtureFactory<Fixtures[K]> : FixtureFactory;
66
+ };
67
+
68
+ const registry = new Map<string, FixtureFactory>();
69
+
70
+ export function defineFixtures<M extends FixtureRegistration<M>>(map: M): void {
71
+ for (const [name, factory] of Object.entries(map as FixtureMap)) registry.set(name, factory);
72
+ }
73
+
74
+ export function clearFixtures(): void {
75
+ registry.clear();
76
+ }
77
+
78
+ export function registeredFixtures(): readonly string[] {
79
+ return [...registry.keys()].sort();
80
+ }
81
+
82
+ /**
83
+ * A copy of the registry. The registry is process-global and bun shares one process across
84
+ * files, so a test that needs an empty one snapshots first and hands it back afterwards —
85
+ * otherwise every later file silently loses the fixtures the preload registered.
86
+ */
87
+ export function fixtureSnapshot(): FixtureMap {
88
+ return Object.fromEntries(registry);
89
+ }
90
+
91
+ /**
92
+ * The names a body destructures, read from its source.
93
+ *
94
+ * Reading source is unusual enough to justify: the alternative is building every registered
95
+ * fixture for every test, so one test that touches `page` would start a browser for the whole
96
+ * suite. Playwright resolves fixtures the same way and for the same reason. Only the first
97
+ * parameter is inspected, and only its top-level keys.
98
+ */
99
+ export function requestedFixtures(body: (...args: never[]) => unknown): readonly string[] {
100
+ const source = body.toString();
101
+ const open = source.indexOf('{');
102
+ if (open === -1) return [];
103
+ const close = source.indexOf('}', open);
104
+ if (close === -1) return [];
105
+ // Bail if the brace opens a body rather than a destructuring pattern — `async () => {`.
106
+ const beforeBrace = source.slice(0, open);
107
+ if (/\)\s*(?::[^=]*)?=>\s*$/.test(beforeBrace) || /\)\s*$/.test(beforeBrace)) return [];
108
+ return source
109
+ .slice(open + 1, close)
110
+ .split(',')
111
+ .map((part) => (part.split(':')[0] ?? '').trim())
112
+ .filter((name) => /^[A-Za-z_$][\w$]*$/.test(name));
113
+ }
114
+
115
+ export type FixtureBody = (fixtures: Fixtures) => void | Promise<void>;
116
+
117
+ /**
118
+ * A fixture that installs process-global state — the ambient job driver, the ambient mail
119
+ * driver — implements one of the standard disposal symbols to put it back. Bun shares one
120
+ * process across every test file, so a fixture that skips this does not leak within its own
121
+ * test: it leaks into every file that runs after it, and the failure surfaces somewhere else.
122
+ */
123
+ type MaybeDisposable = {
124
+ readonly [Symbol.asyncDispose]?: () => PromiseLike<void> | void;
125
+ readonly [Symbol.dispose]?: () => void;
126
+ };
127
+
128
+ const disposerOf = (value: unknown): (() => PromiseLike<void> | void) | undefined => {
129
+ if (value === null || (typeof value !== 'object' && typeof value !== 'function'))
130
+ return undefined;
131
+ const target = value as MaybeDisposable;
132
+ const asyncDispose = target[Symbol.asyncDispose];
133
+ if (typeof asyncDispose === 'function') return () => asyncDispose.call(target);
134
+ const dispose = target[Symbol.dispose];
135
+ return typeof dispose === 'function' ? () => dispose.call(target) : undefined;
136
+ };
137
+
138
+ /**
139
+ * Build what the body asked for, run it, dispose in reverse. Split out of `fixtureTest` because
140
+ * that one hands its callback to bun and returns nothing — teardown is the part most worth
141
+ * testing, and it cannot be observed through a registration. Not in the package's public API:
142
+ * `fixtureTest` stays the one way to write a test with fixtures.
143
+ */
144
+ export async function runWithFixtures(body: FixtureBody): Promise<void> {
145
+ const wanted = requestedFixtures(body as (...args: never[]) => unknown);
146
+ // Partial by construction — only what the body destructured is built. Handed over as the
147
+ // full `Fixtures` because the keys came from that same body: a key it did not name is a key
148
+ // it cannot read, so the missing ones are unobservable.
149
+ const bag: Partial<Fixtures> & Record<string, unknown> = {};
150
+ const built: unknown[] = [];
151
+ // Boxed rather than a bare `unknown`, so a body that throws a falsy value still reports.
152
+ let failure: { readonly error: unknown } | undefined;
153
+ try {
154
+ for (const key of wanted) {
155
+ const factory = registry.get(key);
156
+ // Naming the registered set turns "undefined is not an object" into a fixable message.
157
+ if (factory === undefined) throw fixtureUnknown(key, registeredFixtures());
158
+ const value = await factory();
159
+ built.push(value);
160
+ bag[key] = value;
161
+ }
162
+ await body(bag as Fixtures);
163
+ } catch (error) {
164
+ failure = { error };
165
+ }
166
+
167
+ // Every disposer runs even when an earlier one throws: a fixture that cannot clean up must
168
+ // not strand the ones built before it. The body's own failure wins, because a teardown error
169
+ // that replaced it would hide the assertion that actually broke.
170
+ for (const value of built.reverse()) {
171
+ try {
172
+ await disposerOf(value)?.();
173
+ } catch (error) {
174
+ failure ??= { error };
175
+ }
176
+ }
177
+ if (failure !== undefined) throw failure.error;
178
+ }
179
+
180
+ /**
181
+ * `test` with fixtures. Only what the body destructures is built, and each is awaited before
182
+ * the body runs — so a body reads `seed('dev')` directly instead of awaiting every fixture.
183
+ *
184
+ * Teardown runs in reverse build order whether the body passed or threw: a failing assertion
185
+ * must not be the reason the next file inherits a queue.
186
+ */
187
+ export function fixtureTest(name: string, body: FixtureBody): void {
188
+ bunTest(name, () => runWithFixtures(body));
189
+ }
@@ -0,0 +1,42 @@
1
+ // Registers the fixtures the FRAMEWORK owns, so an app registers only what it owns (`seed`,
2
+ // `actorFor`, …). Called by the preload, which is why an app never writes
3
+ // `defineFixtures({ clock })` and why two apps cannot disagree about what `clock` means.
4
+
5
+ import { createTestClock } from './fixture-clock';
6
+ import { DRIVER_FIXTURE_NAMES, driverFixtures } from './fixture-drivers';
7
+ import { createRunJobs } from './fixture-jobs';
8
+ import { createTestMail } from './fixture-mail';
9
+ import { createTestNetwork } from './fixture-network';
10
+ import { defineFixtures } from './fixtures';
11
+
12
+ /**
13
+ * Built in-process. Always available, in every test type — the first of the bag's two kinds of
14
+ * member. The other is `DRIVER_FIXTURE_NAMES`: declared here, built by a driver the process
15
+ * installs. See `fixture-drivers.ts` for why a declared-and-unavailable fixture beats an
16
+ * unregistered name.
17
+ */
18
+ export const FRAMEWORK_FIXTURE_NAMES = ['clock', 'mail', 'network', 'runJobs'] as const;
19
+
20
+ export { DRIVER_FIXTURE_NAMES };
21
+
22
+ /** Every name the framework puts in the bag, in the order `registeredFixtures()` reports them. */
23
+ export const ALL_FIXTURE_NAMES: readonly string[] = [
24
+ ...FRAMEWORK_FIXTURE_NAMES,
25
+ ...DRIVER_FIXTURE_NAMES,
26
+ ].sort();
27
+
28
+ /**
29
+ * Each factory imports its subsystem on demand, for the reason the whole registry is lazy: a test
30
+ * that never touches jobs must not pay for the queue, and a `packages/core` test must not boot
31
+ * mail. `defineFixtures` merges, so registering twice is idempotent — and so a driver registered
32
+ * afterwards replaces the declaration it was waiting on.
33
+ */
34
+ export function registerFrameworkFixtures(): void {
35
+ defineFixtures({
36
+ ...driverFixtures(),
37
+ clock: createTestClock,
38
+ mail: createTestMail,
39
+ network: createTestNetwork,
40
+ runJobs: createRunJobs,
41
+ });
42
+ }