@btravstack/testing 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Benoit TRAVERS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ <!-- doctest: prelude
2
+ import { TestRuntimePort } from "@btravstack/testing";
3
+ import { Env } from "@btravstack/config";
4
+ import { Port, type Module, type Scope } from "@btravstack/di";
5
+ class Logger extends Port("Logger")<{
6
+ readonly info: (message: string) => void;
7
+ readonly lines: () => readonly string[];
8
+ }> {}
9
+ declare const OrderApi: Module<
10
+ InstanceType<typeof TestRuntimePort> | Logger,
11
+ never,
12
+ Env | Scope
13
+ >;
14
+ declare const RequestModule: Module<never, never, Logger>;
15
+ -->
16
+
17
+ # @btravstack/testing
18
+
19
+ > Test the applications [`@btravstack/core`](../core) boots. A `test.extend`
20
+ > fixture that starts a module with a test's defaults and stops every app it
21
+ > started when the test ends, a tap that hands back the very services a booted
22
+ > graph holds, an in-memory runtime, a clock that moves on demand โ€” the way
23
+ > `@nestjs/testing` is to NestJS, and kept out of the kernel so a production
24
+ > bundle never pulls the fakes in.
25
+
26
+ ๐Ÿ“– **[Documentation](https://btravstack.github.io/btravstack/how-to/test-an-application)** ยท
27
+ [Reference](https://btravstack.github.io/btravstack/reference/testing) ยท
28
+ [API Reference](https://btravstack.github.io/btravstack/api/testing/)
29
+
30
+ ```sh
31
+ pnpm add -D @btravstack/testing @btravstack/core @btravstack/config @btravstack/di unthrown
32
+ ```
33
+
34
+ `@btravstack/core`, `@btravstack/config`, `@btravstack/di` and `unthrown` are
35
+ peer dependencies; the package depends on nothing else โ€” not even `vitest`:
36
+ `bootFixture` is a plain `(ctx, use) => Promise<void>` function, which is
37
+ vitest's fixture protocol, so no import is needed to hand it to `test.extend`.
38
+ Node `>=20`.
39
+
40
+ ## A booted application, as a fixture
41
+
42
+ ```ts
43
+ import { bootFixture, tapped, type Boot } from "@btravstack/testing";
44
+ import { expect, test } from "vitest";
45
+
46
+ const it = test.extend<{ boot: Boot }>({
47
+ boot: bootFixture({ env: { PORT: "0", HOST: "127.0.0.1" } }),
48
+ });
49
+
50
+ it("serves on an ephemeral port", async ({ boot }) => {
51
+ const app = boot(OrderApi, { unit: RequestModule });
52
+
53
+ await expect(app.runtimeInfo()).toBeOkWith(
54
+ expect.objectContaining({ port: expect.any(Number) }),
55
+ );
56
+ });
57
+
58
+ it("holds the very logger the use cases write to", async ({ boot }) => {
59
+ const tap = tapped(OrderApi, [Logger]);
60
+ await boot(tap.module, { unit: RequestModule }).runtimeInfo();
61
+
62
+ const [logger] = tap.services();
63
+ expect(logger.lines()).toEqual([]);
64
+ });
65
+ ```
66
+
67
+ `boot` is `start` as a test hands it out: the same signature, the same
68
+ compile-time gate, minus `signals` โ€” always off โ€” with `probes: false` unless
69
+ a call asks for a port, `preDrainDelayMs: 0` and a silent `onEvent` as
70
+ defaults, each overridable by the fixture's `defaults` and again per call.
71
+ When the test ends every app it started is stopped, on every exit path, a
72
+ failing assertion included; a **`Defect`** on `exited` fails the test even
73
+ when the test never looked at it, while a modeled `Err` โ€” a startup failure
74
+ the test may be asserting โ€” passes through.
75
+
76
+ `tapped(module, [Port, โ€ฆ])` composes one more provider around `module`,
77
+ depending on those ports, and remembers what it was built with: `services()`
78
+ answers the very instances the running graph holds once it is built โ€” after
79
+ `runtimeInfo()` resolves, say โ€” and throws before. The gate refuses a port
80
+ `module` does not export.
81
+
82
+ ## What it ships
83
+
84
+ - **`bootFixture(defaults?)`** โ€” the fixture above; `Boot` and `BootDefaults`
85
+ are its types.
86
+ - **`tapped(module, ports)`** โ†’ `{ module, services() }` โ€” read services out
87
+ of a booted application.
88
+ - **`testRuntime(name?)`** / **`TestRuntimePort`** โ€” an in-memory `Runtime`
89
+ with `submit()` to hold a unit open across a drain, and a `module` that
90
+ provides it where a starter would.
91
+ - **`createFakeClock(start?)`** โ€” a `Clock` for `StartOptions.clock` whose
92
+ time moves only on `advance(ms)`.
93
+
94
+ The teardown rule: a `Defect` on `exited` fails the test, a modeled `Err` does
95
+ not.
96
+
97
+ ## License
98
+
99
+ [MIT](./LICENSE) ยฉ Benoit TRAVERS
package/dist/index.cjs ADDED
@@ -0,0 +1,226 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _btravstack_core = require("@btravstack/core");
3
+ let node_timers_promises = require("node:timers/promises");
4
+ let unthrown = require("unthrown");
5
+ let _btravstack_di = require("@btravstack/di");
6
+ //#region src/boot-fixture.ts
7
+ /**
8
+ * A `test.extend` fixture that hands the test a {@link Boot} and stops every
9
+ * application it started once the test is over โ€” on every exit path, a failing
10
+ * assertion included, which is what `test.extend`'s teardown is for.
11
+ *
12
+ * ```ts
13
+ * export const it = test.extend<{ boot: Boot }>({
14
+ * boot: bootFixture({ env: { PORT: "0", HOST: "127.0.0.1" } }),
15
+ * });
16
+ *
17
+ * it("answers", async ({ boot }) => {
18
+ * const app = boot(OrderApi, { unit: RequestModule });
19
+ * โ€ฆ
20
+ * });
21
+ * ```
22
+ *
23
+ * The defaults are a test's: `signals: false` always (process-wide handlers
24
+ * would fight across a file), `probes: false`, `preDrainDelayMs: 0` and a silent
25
+ * `onEvent` โ€” each overridable by `defaults` and again per call.
26
+ *
27
+ * Teardown is `stop()`, then `exited` is examined: a **`Defect`** fails the test
28
+ * even when the test never looked at `exited`, while a modeled `Err` passes
29
+ * through, since a startup failure is an outcome a test may be asserting.
30
+ */
31
+ const bootFixture = (defaults = {}) => async ({}, use) => {
32
+ const started = [];
33
+ const boot = ((module, options = {}) => {
34
+ const app = (0, _btravstack_core.start)(module, {
35
+ preDrainDelayMs: 0,
36
+ onEvent: () => {},
37
+ probes: false,
38
+ ...defaults,
39
+ ...options,
40
+ signals: false
41
+ });
42
+ started.push(app);
43
+ return app;
44
+ });
45
+ await use(boot);
46
+ for (const app of started) {
47
+ app.stop();
48
+ const exit = await app.exited;
49
+ if (exit.isDefect()) throw exit.cause;
50
+ }
51
+ };
52
+ //#endregion
53
+ //#region src/fake-clock.ts
54
+ const flush = () => (0, node_timers_promises.setTimeout)(0);
55
+ /**
56
+ * A {@link Clock} whose time only moves when a test says so.
57
+ *
58
+ * Pass it as `StartOptions.clock` to drive the drain's pre-drain delay and
59
+ * deadline explicitly, instead of waiting out the real 5s/20s defaults.
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * const clock = createFakeClock();
64
+ * const app = start(AppModule, { runtime, clock });
65
+ *
66
+ * app.requestDrain();
67
+ * await clock.advance(5_000); // the pre-drain delay
68
+ * await clock.advance(20_000); // the drain deadline
69
+ * ```
70
+ */
71
+ const createFakeClock = (start = 0) => {
72
+ let now = start;
73
+ let sleepers = [];
74
+ return {
75
+ now: () => now,
76
+ sleep: (ms, signal) => (0, unthrown.fromSafePromise)(new Promise((resolve) => {
77
+ if (ms <= 0 || signal?.aborted === true) {
78
+ resolve();
79
+ return;
80
+ }
81
+ const sleeper = {
82
+ due: now + ms,
83
+ resolve
84
+ };
85
+ sleepers = [...sleepers, sleeper];
86
+ signal?.addEventListener("abort", () => {
87
+ sleepers = sleepers.filter((pending) => pending !== sleeper);
88
+ resolve();
89
+ }, { once: true });
90
+ })),
91
+ advance: (ms) => (0, unthrown.fromSafePromise)((async () => {
92
+ await flush();
93
+ now += ms;
94
+ const due = sleepers.filter((sleeper) => sleeper.due <= now);
95
+ sleepers = sleepers.filter((sleeper) => sleeper.due > now);
96
+ for (const sleeper of due) sleeper.resolve();
97
+ await flush();
98
+ })())
99
+ };
100
+ };
101
+ //#endregion
102
+ //#region src/overridden.ts
103
+ /**
104
+ * The real composition root, with named providers substituted โ€” the testing half
105
+ * of "swapping an adapter is composing a different module", for the seam
106
+ * composition cannot reach: nothing can be layered over a graph that already
107
+ * provides a port, so the alternative was a parallel root that drifted silently.
108
+ *
109
+ * Each override is an ordinary `Provider(Port)(...)`, so the service type is
110
+ * checked against the port at that call. At run time an override REPLACES the
111
+ * base provider โ€” the base is never constructed โ€” and an override for a port the
112
+ * tree no longer provides is a `WiringDefect` ("nothing to override"), which is
113
+ * what turns fixture drift into a loud failure.
114
+ *
115
+ * It replaces ONE provider, never a subsystem: the replaced provider's siblings
116
+ * still construct, so swapping a whole adapter stack remains a different module
117
+ * composed in its place.
118
+ *
119
+ * An override's own deps deliberately do NOT widen the returned `Needs`: they
120
+ * resolve from the graph's INTERNALS, and typing them into `N` would force a
121
+ * root to re-export internals to be overridable. They are checked at build
122
+ * instead, by `plan`'s missing-provider defect.
123
+ */
124
+ const overridden = (module, overrides) => (0, _btravstack_di.Module)("Overridden")({
125
+ imports: [module],
126
+ provides: overrides.map((override) => (0, _btravstack_di.overrideProvider)(override)),
127
+ exports: [module]
128
+ });
129
+ //#endregion
130
+ //#region src/tapped.ts
131
+ const tapped = (module, ports) => {
132
+ let services;
133
+ const tap = (0, _btravstack_di.Provider)(Tap)(Object.fromEntries(ports.map((port, index) => [index, port])), { sync: (built) => {
134
+ services = ports.map((_, index) => built[index]);
135
+ return {};
136
+ } });
137
+ return {
138
+ module: (0, _btravstack_di.Module)("Tapped")({
139
+ imports: [module],
140
+ provides: [tap],
141
+ exports: [module]
142
+ }),
143
+ services: () => {
144
+ if (services === void 0) throw new Error("[testing] tapped(...).services() read before the graph was built");
145
+ return services;
146
+ }
147
+ };
148
+ };
149
+ var Tap = class extends (0, _btravstack_di.Port)("@btravstack/testing/Tap") {};
150
+ //#endregion
151
+ //#region src/test-runtime.ts
152
+ /** The in-memory runtime's port: what `TestRuntime.module` provides, and what a test composition exports. */
153
+ var TestRuntimePort = class extends _btravstack_core.RuntimePort {};
154
+ const testRuntime = (name = "test") => {
155
+ let run;
156
+ let accepting = false;
157
+ let serving;
158
+ let submitted = 0;
159
+ let onStarted;
160
+ const started = new Promise((resolve) => {
161
+ onStarted = resolve;
162
+ });
163
+ const make = () => ({
164
+ info: { name },
165
+ drain: (signal) => {
166
+ accepting = false;
167
+ return (0, unthrown.OkAsync)();
168
+ },
169
+ stop: () => {
170
+ accepting = false;
171
+ return (0, unthrown.OkAsync)();
172
+ }
173
+ });
174
+ const runtime = {
175
+ name,
176
+ module: (0, _btravstack_di.Module)("TestRuntime")({
177
+ provides: [(0, _btravstack_di.Provider)(TestRuntimePort)({ sync: () => runtime })],
178
+ exports: [TestRuntimePort]
179
+ }),
180
+ resolves: [],
181
+ start: (host) => {
182
+ run = host.run;
183
+ accepting = true;
184
+ serving = make();
185
+ onStarted();
186
+ return (0, unthrown.OkAsync)(serving);
187
+ },
188
+ started: () => serving !== void 0,
189
+ untilStarted: () => (0, unthrown.fromSafePromise)(started),
190
+ accepting: () => accepting,
191
+ serving: () => {
192
+ if (serving === void 0) throw new Error("[test-runtime] not started");
193
+ return serving;
194
+ },
195
+ submit: () => {
196
+ if (run === void 0 || !accepting) throw new Error("[test-runtime] not accepting work");
197
+ submitted += 1;
198
+ let settle;
199
+ const held = new Promise((resolve) => {
200
+ settle = resolve;
201
+ });
202
+ const forwarded = new AbortController();
203
+ const result = run({
204
+ kind: "test",
205
+ id: `${submitted}`
206
+ }, (_ctx, signal) => {
207
+ if (signal.aborted) forwarded.abort(signal.reason);
208
+ else signal.addEventListener("abort", () => forwarded.abort(signal.reason), { once: true });
209
+ return held;
210
+ });
211
+ return {
212
+ settle,
213
+ result,
214
+ signal: forwarded.signal
215
+ };
216
+ }
217
+ };
218
+ return runtime;
219
+ };
220
+ //#endregion
221
+ exports.TestRuntimePort = TestRuntimePort;
222
+ exports.bootFixture = bootFixture;
223
+ exports.createFakeClock = createFakeClock;
224
+ exports.overridden = overridden;
225
+ exports.tapped = tapped;
226
+ exports.testRuntime = testRuntime;
@@ -0,0 +1,170 @@
1
+ import { Env } from "@btravstack/config";
2
+ import { Clock, RunningApp, Runtime, RuntimeInfoOf, RuntimePort, Serving, StartGate, StartOptions } from "@btravstack/core";
3
+ import { AnyPort, Module, Provider, Scope, ServiceOf } from "@btravstack/di";
4
+ import { AsyncResult, Result } from "unthrown";
5
+ //#region src/boot-fixture.d.ts
6
+ /**
7
+ * `start`, as a test hands it out: the same signature and the same phantom
8
+ * gate, minus `signals` (always off) โ€” every application it starts is stopped
9
+ * when the test ends.
10
+ */
11
+ type Boot = <X, E, UnitX = never, UnitNeeds = never>(module: Module<X, E, Scope | Env> & StartGate<X, UnitNeeds>, options?: Omit<StartOptions<UnitX, UnitNeeds>, "signals">) => RunningApp<E, RuntimeInfoOf<X>>;
12
+ /** What every `boot` in the fixture starts with; a call's own options win. */
13
+ type BootDefaults = Omit<StartOptions, "signals" | "unit">;
14
+ /**
15
+ * A `test.extend` fixture that hands the test a {@link Boot} and stops every
16
+ * application it started once the test is over โ€” on every exit path, a failing
17
+ * assertion included, which is what `test.extend`'s teardown is for.
18
+ *
19
+ * ```ts
20
+ * export const it = test.extend<{ boot: Boot }>({
21
+ * boot: bootFixture({ env: { PORT: "0", HOST: "127.0.0.1" } }),
22
+ * });
23
+ *
24
+ * it("answers", async ({ boot }) => {
25
+ * const app = boot(OrderApi, { unit: RequestModule });
26
+ * โ€ฆ
27
+ * });
28
+ * ```
29
+ *
30
+ * The defaults are a test's: `signals: false` always (process-wide handlers
31
+ * would fight across a file), `probes: false`, `preDrainDelayMs: 0` and a silent
32
+ * `onEvent` โ€” each overridable by `defaults` and again per call.
33
+ *
34
+ * Teardown is `stop()`, then `exited` is examined: a **`Defect`** fails the test
35
+ * even when the test never looked at `exited`, while a modeled `Err` passes
36
+ * through, since a startup failure is an outcome a test may be asserting.
37
+ */
38
+ declare const bootFixture: (defaults?: BootDefaults) => ({}: object, use: (boot: Boot) => Promise<void>) => Promise<void>;
39
+ //#endregion
40
+ //#region src/fake-clock.d.ts
41
+ type FakeClock = Clock & {
42
+ /**
43
+ * Move the clock forward, resolving every sleep whose deadline has passed.
44
+ *
45
+ * The returned `AsyncResult` settles once the code under test has had a
46
+ * chance to react โ€” so `await clock.advance(5_000)` leaves the application
47
+ * where the elapsed time takes it, with no extra flushing at the call site.
48
+ */
49
+ readonly advance: (ms: number) => AsyncResult<void, never>;
50
+ };
51
+ /**
52
+ * A {@link Clock} whose time only moves when a test says so.
53
+ *
54
+ * Pass it as `StartOptions.clock` to drive the drain's pre-drain delay and
55
+ * deadline explicitly, instead of waiting out the real 5s/20s defaults.
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * const clock = createFakeClock();
60
+ * const app = start(AppModule, { runtime, clock });
61
+ *
62
+ * app.requestDrain();
63
+ * await clock.advance(5_000); // the pre-drain delay
64
+ * await clock.advance(20_000); // the drain deadline
65
+ * ```
66
+ */
67
+ declare const createFakeClock: (start?: number) => FakeClock;
68
+ //#endregion
69
+ //#region src/overridden.d.ts
70
+ /** One override's error channel, unioned across the tuple by `overridden`. */
71
+ type ErrorsOf<O extends readonly AnyProviderFor[]> = O[number] extends Provider<never, infer E, unknown> ? E : never;
72
+ /** The widest provider `overridden` accepts โ€” variance makes every real one assignable. */
73
+ type AnyProviderFor = Provider<never, unknown, unknown>;
74
+ /**
75
+ * The real composition root, with named providers substituted โ€” the testing half
76
+ * of "swapping an adapter is composing a different module", for the seam
77
+ * composition cannot reach: nothing can be layered over a graph that already
78
+ * provides a port, so the alternative was a parallel root that drifted silently.
79
+ *
80
+ * Each override is an ordinary `Provider(Port)(...)`, so the service type is
81
+ * checked against the port at that call. At run time an override REPLACES the
82
+ * base provider โ€” the base is never constructed โ€” and an override for a port the
83
+ * tree no longer provides is a `WiringDefect` ("nothing to override"), which is
84
+ * what turns fixture drift into a loud failure.
85
+ *
86
+ * It replaces ONE provider, never a subsystem: the replaced provider's siblings
87
+ * still construct, so swapping a whole adapter stack remains a different module
88
+ * composed in its place.
89
+ *
90
+ * An override's own deps deliberately do NOT widen the returned `Needs`: they
91
+ * resolve from the graph's INTERNALS, and typing them into `N` would force a
92
+ * root to re-export internals to be overridable. They are checked at build
93
+ * instead, by `plan`'s missing-provider defect.
94
+ */
95
+ declare const overridden: <X, E, N, const O extends readonly AnyProviderFor[]>(module: Module<X, E, N>, overrides: O) => Module<X, E | ErrorsOf<O>, N>;
96
+ //#endregion
97
+ //#region src/tapped.d.ts
98
+ /** The services behind `ports`, in order โ€” what `tapped(...).services()` answers once the graph is built. */
99
+ type ServicesOf<P extends readonly AnyPort[]> = { readonly [K in keyof P]: ServiceOf<InstanceType<P[K]>>; };
100
+ /**
101
+ * Read services out of a booted application.
102
+ *
103
+ * `start` hands the application context to the runtime alone, so a test that
104
+ * wants the very `Logger` the use cases write to โ€” not a fresh one โ€” has no
105
+ * `ctx.get` to reach it with. `tapped` composes one more provider around
106
+ * `module`, depending on `ports`, and remembers what it was built with:
107
+ *
108
+ * ```ts
109
+ * const tap = tapped(OrderApi, [Logger]);
110
+ * const app = boot(tap.module);
111
+ * await client.orders.place({ id: "o-1", quantity: 2 });
112
+ * const [logger] = tap.services();
113
+ * expect(logger.lines()).toContain(โ€ฆ);
114
+ * ```
115
+ *
116
+ * The returned module exports exactly what `module` exports, so the kernel still
117
+ * finds the runtime. `services()` is loud before the graph has been built:
118
+ * reading a tap nobody booted is a bug in the test, and an `undefined` a
119
+ * careless assertion could swallow is worse than a throw. The gate refuses a
120
+ * port `module` does not export โ€” an application-scope service is the only thing
121
+ * there is to tap.
122
+ */
123
+ type TapGate<P extends readonly AnyPort[], X> = [Exclude<InstanceType<P[number]>, X>] extends [never] ? unknown : {
124
+ readonly "NOT EXPORTED โ€” tap only what the module exports": Exclude<InstanceType<P[number]>, X>;
125
+ };
126
+ declare const tapped: <X, E, N, const P extends readonly AnyPort[]>(module: Module<X, E, N>, ports: P & TapGate<P, X>) => {
127
+ readonly module: Module<X, E, N>;
128
+ readonly services: () => ServicesOf<P>;
129
+ };
130
+ //#endregion
131
+ //#region src/test-runtime.d.ts
132
+ type SubmittedUnit<T, E> = {
133
+ readonly settle: (result: Result<T, E>) => void;
134
+ readonly result: AsyncResult<T, E>;
135
+ readonly signal: AbortSignal;
136
+ };
137
+ /**
138
+ * What `testRuntime` publishes on `Serving.info` โ€” its own name, the one thing
139
+ * an in-memory runtime genuinely knows about itself.
140
+ */
141
+ type TestRuntimeInfo = {
142
+ readonly name: string;
143
+ };
144
+ /** The in-memory runtime's port: what `TestRuntime.module` provides, and what a test composition exports. */
145
+ declare class TestRuntimePort extends RuntimePort<Runtime<never, TestRuntimeInfo>> {}
146
+ type TestRuntime = Runtime<never, TestRuntimeInfo> & {
147
+ /**
148
+ * A module providing this very runtime on `TestRuntimePort`: import it next
149
+ * to the module under test, export the port, and `start` finds it.
150
+ *
151
+ * It provides THIS object, so a wrapper built by spreading copies the module
152
+ * too and that module still boots the inner runtime โ€” a wrapper needs a module
153
+ * of its own.
154
+ */
155
+ readonly module: Module<TestRuntimePort, never, never>;
156
+ readonly started: () => boolean;
157
+ /** Resolves the first time the kernel calls `start` โ€” `start` itself stays pending until shutdown. */
158
+ readonly untilStarted: () => AsyncResult<void, never>;
159
+ /**
160
+ * Whether the runtime would still take new work โ€” false once `drain` or
161
+ * `stop` has been called, so a test can observe WHEN the kernel told it to
162
+ * stop accepting.
163
+ */
164
+ readonly accepting: () => boolean;
165
+ readonly serving: () => Serving<TestRuntimeInfo>;
166
+ readonly submit: <T = string, E = never>() => SubmittedUnit<T, E>;
167
+ };
168
+ declare const testRuntime: (name?: string) => TestRuntime;
169
+ //#endregion
170
+ export { type Boot, type BootDefaults, type FakeClock, type ServicesOf, type SubmittedUnit, type TestRuntime, type TestRuntimeInfo, TestRuntimePort, bootFixture, createFakeClock, overridden, tapped, testRuntime };
@@ -0,0 +1,170 @@
1
+ import { Clock, RunningApp, Runtime, RuntimeInfoOf, RuntimePort, Serving, StartGate, StartOptions } from "@btravstack/core";
2
+ import { AsyncResult, Result } from "unthrown";
3
+ import { AnyPort, Module, Provider, Scope, ServiceOf } from "@btravstack/di";
4
+ import { Env } from "@btravstack/config";
5
+ //#region src/boot-fixture.d.ts
6
+ /**
7
+ * `start`, as a test hands it out: the same signature and the same phantom
8
+ * gate, minus `signals` (always off) โ€” every application it starts is stopped
9
+ * when the test ends.
10
+ */
11
+ type Boot = <X, E, UnitX = never, UnitNeeds = never>(module: Module<X, E, Scope | Env> & StartGate<X, UnitNeeds>, options?: Omit<StartOptions<UnitX, UnitNeeds>, "signals">) => RunningApp<E, RuntimeInfoOf<X>>;
12
+ /** What every `boot` in the fixture starts with; a call's own options win. */
13
+ type BootDefaults = Omit<StartOptions, "signals" | "unit">;
14
+ /**
15
+ * A `test.extend` fixture that hands the test a {@link Boot} and stops every
16
+ * application it started once the test is over โ€” on every exit path, a failing
17
+ * assertion included, which is what `test.extend`'s teardown is for.
18
+ *
19
+ * ```ts
20
+ * export const it = test.extend<{ boot: Boot }>({
21
+ * boot: bootFixture({ env: { PORT: "0", HOST: "127.0.0.1" } }),
22
+ * });
23
+ *
24
+ * it("answers", async ({ boot }) => {
25
+ * const app = boot(OrderApi, { unit: RequestModule });
26
+ * โ€ฆ
27
+ * });
28
+ * ```
29
+ *
30
+ * The defaults are a test's: `signals: false` always (process-wide handlers
31
+ * would fight across a file), `probes: false`, `preDrainDelayMs: 0` and a silent
32
+ * `onEvent` โ€” each overridable by `defaults` and again per call.
33
+ *
34
+ * Teardown is `stop()`, then `exited` is examined: a **`Defect`** fails the test
35
+ * even when the test never looked at `exited`, while a modeled `Err` passes
36
+ * through, since a startup failure is an outcome a test may be asserting.
37
+ */
38
+ declare const bootFixture: (defaults?: BootDefaults) => ({}: object, use: (boot: Boot) => Promise<void>) => Promise<void>;
39
+ //#endregion
40
+ //#region src/fake-clock.d.ts
41
+ type FakeClock = Clock & {
42
+ /**
43
+ * Move the clock forward, resolving every sleep whose deadline has passed.
44
+ *
45
+ * The returned `AsyncResult` settles once the code under test has had a
46
+ * chance to react โ€” so `await clock.advance(5_000)` leaves the application
47
+ * where the elapsed time takes it, with no extra flushing at the call site.
48
+ */
49
+ readonly advance: (ms: number) => AsyncResult<void, never>;
50
+ };
51
+ /**
52
+ * A {@link Clock} whose time only moves when a test says so.
53
+ *
54
+ * Pass it as `StartOptions.clock` to drive the drain's pre-drain delay and
55
+ * deadline explicitly, instead of waiting out the real 5s/20s defaults.
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * const clock = createFakeClock();
60
+ * const app = start(AppModule, { runtime, clock });
61
+ *
62
+ * app.requestDrain();
63
+ * await clock.advance(5_000); // the pre-drain delay
64
+ * await clock.advance(20_000); // the drain deadline
65
+ * ```
66
+ */
67
+ declare const createFakeClock: (start?: number) => FakeClock;
68
+ //#endregion
69
+ //#region src/overridden.d.ts
70
+ /** One override's error channel, unioned across the tuple by `overridden`. */
71
+ type ErrorsOf<O extends readonly AnyProviderFor[]> = O[number] extends Provider<never, infer E, unknown> ? E : never;
72
+ /** The widest provider `overridden` accepts โ€” variance makes every real one assignable. */
73
+ type AnyProviderFor = Provider<never, unknown, unknown>;
74
+ /**
75
+ * The real composition root, with named providers substituted โ€” the testing half
76
+ * of "swapping an adapter is composing a different module", for the seam
77
+ * composition cannot reach: nothing can be layered over a graph that already
78
+ * provides a port, so the alternative was a parallel root that drifted silently.
79
+ *
80
+ * Each override is an ordinary `Provider(Port)(...)`, so the service type is
81
+ * checked against the port at that call. At run time an override REPLACES the
82
+ * base provider โ€” the base is never constructed โ€” and an override for a port the
83
+ * tree no longer provides is a `WiringDefect` ("nothing to override"), which is
84
+ * what turns fixture drift into a loud failure.
85
+ *
86
+ * It replaces ONE provider, never a subsystem: the replaced provider's siblings
87
+ * still construct, so swapping a whole adapter stack remains a different module
88
+ * composed in its place.
89
+ *
90
+ * An override's own deps deliberately do NOT widen the returned `Needs`: they
91
+ * resolve from the graph's INTERNALS, and typing them into `N` would force a
92
+ * root to re-export internals to be overridable. They are checked at build
93
+ * instead, by `plan`'s missing-provider defect.
94
+ */
95
+ declare const overridden: <X, E, N, const O extends readonly AnyProviderFor[]>(module: Module<X, E, N>, overrides: O) => Module<X, E | ErrorsOf<O>, N>;
96
+ //#endregion
97
+ //#region src/tapped.d.ts
98
+ /** The services behind `ports`, in order โ€” what `tapped(...).services()` answers once the graph is built. */
99
+ type ServicesOf<P extends readonly AnyPort[]> = { readonly [K in keyof P]: ServiceOf<InstanceType<P[K]>>; };
100
+ /**
101
+ * Read services out of a booted application.
102
+ *
103
+ * `start` hands the application context to the runtime alone, so a test that
104
+ * wants the very `Logger` the use cases write to โ€” not a fresh one โ€” has no
105
+ * `ctx.get` to reach it with. `tapped` composes one more provider around
106
+ * `module`, depending on `ports`, and remembers what it was built with:
107
+ *
108
+ * ```ts
109
+ * const tap = tapped(OrderApi, [Logger]);
110
+ * const app = boot(tap.module);
111
+ * await client.orders.place({ id: "o-1", quantity: 2 });
112
+ * const [logger] = tap.services();
113
+ * expect(logger.lines()).toContain(โ€ฆ);
114
+ * ```
115
+ *
116
+ * The returned module exports exactly what `module` exports, so the kernel still
117
+ * finds the runtime. `services()` is loud before the graph has been built:
118
+ * reading a tap nobody booted is a bug in the test, and an `undefined` a
119
+ * careless assertion could swallow is worse than a throw. The gate refuses a
120
+ * port `module` does not export โ€” an application-scope service is the only thing
121
+ * there is to tap.
122
+ */
123
+ type TapGate<P extends readonly AnyPort[], X> = [Exclude<InstanceType<P[number]>, X>] extends [never] ? unknown : {
124
+ readonly "NOT EXPORTED โ€” tap only what the module exports": Exclude<InstanceType<P[number]>, X>;
125
+ };
126
+ declare const tapped: <X, E, N, const P extends readonly AnyPort[]>(module: Module<X, E, N>, ports: P & TapGate<P, X>) => {
127
+ readonly module: Module<X, E, N>;
128
+ readonly services: () => ServicesOf<P>;
129
+ };
130
+ //#endregion
131
+ //#region src/test-runtime.d.ts
132
+ type SubmittedUnit<T, E> = {
133
+ readonly settle: (result: Result<T, E>) => void;
134
+ readonly result: AsyncResult<T, E>;
135
+ readonly signal: AbortSignal;
136
+ };
137
+ /**
138
+ * What `testRuntime` publishes on `Serving.info` โ€” its own name, the one thing
139
+ * an in-memory runtime genuinely knows about itself.
140
+ */
141
+ type TestRuntimeInfo = {
142
+ readonly name: string;
143
+ };
144
+ /** The in-memory runtime's port: what `TestRuntime.module` provides, and what a test composition exports. */
145
+ declare class TestRuntimePort extends RuntimePort<Runtime<never, TestRuntimeInfo>> {}
146
+ type TestRuntime = Runtime<never, TestRuntimeInfo> & {
147
+ /**
148
+ * A module providing this very runtime on `TestRuntimePort`: import it next
149
+ * to the module under test, export the port, and `start` finds it.
150
+ *
151
+ * It provides THIS object, so a wrapper built by spreading copies the module
152
+ * too and that module still boots the inner runtime โ€” a wrapper needs a module
153
+ * of its own.
154
+ */
155
+ readonly module: Module<TestRuntimePort, never, never>;
156
+ readonly started: () => boolean;
157
+ /** Resolves the first time the kernel calls `start` โ€” `start` itself stays pending until shutdown. */
158
+ readonly untilStarted: () => AsyncResult<void, never>;
159
+ /**
160
+ * Whether the runtime would still take new work โ€” false once `drain` or
161
+ * `stop` has been called, so a test can observe WHEN the kernel told it to
162
+ * stop accepting.
163
+ */
164
+ readonly accepting: () => boolean;
165
+ readonly serving: () => Serving<TestRuntimeInfo>;
166
+ readonly submit: <T = string, E = never>() => SubmittedUnit<T, E>;
167
+ };
168
+ declare const testRuntime: (name?: string) => TestRuntime;
169
+ //#endregion
170
+ export { type Boot, type BootDefaults, type FakeClock, type ServicesOf, type SubmittedUnit, type TestRuntime, type TestRuntimeInfo, TestRuntimePort, bootFixture, createFakeClock, overridden, tapped, testRuntime };
package/dist/index.mjs ADDED
@@ -0,0 +1,220 @@
1
+ import { RuntimePort, start } from "@btravstack/core";
2
+ import { setTimeout } from "node:timers/promises";
3
+ import { OkAsync, fromSafePromise } from "unthrown";
4
+ import { Module, Port, Provider, overrideProvider } from "@btravstack/di";
5
+ //#region src/boot-fixture.ts
6
+ /**
7
+ * A `test.extend` fixture that hands the test a {@link Boot} and stops every
8
+ * application it started once the test is over โ€” on every exit path, a failing
9
+ * assertion included, which is what `test.extend`'s teardown is for.
10
+ *
11
+ * ```ts
12
+ * export const it = test.extend<{ boot: Boot }>({
13
+ * boot: bootFixture({ env: { PORT: "0", HOST: "127.0.0.1" } }),
14
+ * });
15
+ *
16
+ * it("answers", async ({ boot }) => {
17
+ * const app = boot(OrderApi, { unit: RequestModule });
18
+ * โ€ฆ
19
+ * });
20
+ * ```
21
+ *
22
+ * The defaults are a test's: `signals: false` always (process-wide handlers
23
+ * would fight across a file), `probes: false`, `preDrainDelayMs: 0` and a silent
24
+ * `onEvent` โ€” each overridable by `defaults` and again per call.
25
+ *
26
+ * Teardown is `stop()`, then `exited` is examined: a **`Defect`** fails the test
27
+ * even when the test never looked at `exited`, while a modeled `Err` passes
28
+ * through, since a startup failure is an outcome a test may be asserting.
29
+ */
30
+ const bootFixture = (defaults = {}) => async ({}, use) => {
31
+ const started = [];
32
+ const boot = ((module, options = {}) => {
33
+ const app = start(module, {
34
+ preDrainDelayMs: 0,
35
+ onEvent: () => {},
36
+ probes: false,
37
+ ...defaults,
38
+ ...options,
39
+ signals: false
40
+ });
41
+ started.push(app);
42
+ return app;
43
+ });
44
+ await use(boot);
45
+ for (const app of started) {
46
+ app.stop();
47
+ const exit = await app.exited;
48
+ if (exit.isDefect()) throw exit.cause;
49
+ }
50
+ };
51
+ //#endregion
52
+ //#region src/fake-clock.ts
53
+ const flush = () => setTimeout(0);
54
+ /**
55
+ * A {@link Clock} whose time only moves when a test says so.
56
+ *
57
+ * Pass it as `StartOptions.clock` to drive the drain's pre-drain delay and
58
+ * deadline explicitly, instead of waiting out the real 5s/20s defaults.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * const clock = createFakeClock();
63
+ * const app = start(AppModule, { runtime, clock });
64
+ *
65
+ * app.requestDrain();
66
+ * await clock.advance(5_000); // the pre-drain delay
67
+ * await clock.advance(20_000); // the drain deadline
68
+ * ```
69
+ */
70
+ const createFakeClock = (start = 0) => {
71
+ let now = start;
72
+ let sleepers = [];
73
+ return {
74
+ now: () => now,
75
+ sleep: (ms, signal) => fromSafePromise(new Promise((resolve) => {
76
+ if (ms <= 0 || signal?.aborted === true) {
77
+ resolve();
78
+ return;
79
+ }
80
+ const sleeper = {
81
+ due: now + ms,
82
+ resolve
83
+ };
84
+ sleepers = [...sleepers, sleeper];
85
+ signal?.addEventListener("abort", () => {
86
+ sleepers = sleepers.filter((pending) => pending !== sleeper);
87
+ resolve();
88
+ }, { once: true });
89
+ })),
90
+ advance: (ms) => fromSafePromise((async () => {
91
+ await flush();
92
+ now += ms;
93
+ const due = sleepers.filter((sleeper) => sleeper.due <= now);
94
+ sleepers = sleepers.filter((sleeper) => sleeper.due > now);
95
+ for (const sleeper of due) sleeper.resolve();
96
+ await flush();
97
+ })())
98
+ };
99
+ };
100
+ //#endregion
101
+ //#region src/overridden.ts
102
+ /**
103
+ * The real composition root, with named providers substituted โ€” the testing half
104
+ * of "swapping an adapter is composing a different module", for the seam
105
+ * composition cannot reach: nothing can be layered over a graph that already
106
+ * provides a port, so the alternative was a parallel root that drifted silently.
107
+ *
108
+ * Each override is an ordinary `Provider(Port)(...)`, so the service type is
109
+ * checked against the port at that call. At run time an override REPLACES the
110
+ * base provider โ€” the base is never constructed โ€” and an override for a port the
111
+ * tree no longer provides is a `WiringDefect` ("nothing to override"), which is
112
+ * what turns fixture drift into a loud failure.
113
+ *
114
+ * It replaces ONE provider, never a subsystem: the replaced provider's siblings
115
+ * still construct, so swapping a whole adapter stack remains a different module
116
+ * composed in its place.
117
+ *
118
+ * An override's own deps deliberately do NOT widen the returned `Needs`: they
119
+ * resolve from the graph's INTERNALS, and typing them into `N` would force a
120
+ * root to re-export internals to be overridable. They are checked at build
121
+ * instead, by `plan`'s missing-provider defect.
122
+ */
123
+ const overridden = (module, overrides) => Module("Overridden")({
124
+ imports: [module],
125
+ provides: overrides.map((override) => overrideProvider(override)),
126
+ exports: [module]
127
+ });
128
+ //#endregion
129
+ //#region src/tapped.ts
130
+ const tapped = (module, ports) => {
131
+ let services;
132
+ const tap = Provider(Tap)(Object.fromEntries(ports.map((port, index) => [index, port])), { sync: (built) => {
133
+ services = ports.map((_, index) => built[index]);
134
+ return {};
135
+ } });
136
+ return {
137
+ module: Module("Tapped")({
138
+ imports: [module],
139
+ provides: [tap],
140
+ exports: [module]
141
+ }),
142
+ services: () => {
143
+ if (services === void 0) throw new Error("[testing] tapped(...).services() read before the graph was built");
144
+ return services;
145
+ }
146
+ };
147
+ };
148
+ var Tap = class extends Port("@btravstack/testing/Tap") {};
149
+ //#endregion
150
+ //#region src/test-runtime.ts
151
+ /** The in-memory runtime's port: what `TestRuntime.module` provides, and what a test composition exports. */
152
+ var TestRuntimePort = class extends RuntimePort {};
153
+ const testRuntime = (name = "test") => {
154
+ let run;
155
+ let accepting = false;
156
+ let serving;
157
+ let submitted = 0;
158
+ let onStarted;
159
+ const started = new Promise((resolve) => {
160
+ onStarted = resolve;
161
+ });
162
+ const make = () => ({
163
+ info: { name },
164
+ drain: (signal) => {
165
+ accepting = false;
166
+ return OkAsync();
167
+ },
168
+ stop: () => {
169
+ accepting = false;
170
+ return OkAsync();
171
+ }
172
+ });
173
+ const runtime = {
174
+ name,
175
+ module: Module("TestRuntime")({
176
+ provides: [Provider(TestRuntimePort)({ sync: () => runtime })],
177
+ exports: [TestRuntimePort]
178
+ }),
179
+ resolves: [],
180
+ start: (host) => {
181
+ run = host.run;
182
+ accepting = true;
183
+ serving = make();
184
+ onStarted();
185
+ return OkAsync(serving);
186
+ },
187
+ started: () => serving !== void 0,
188
+ untilStarted: () => fromSafePromise(started),
189
+ accepting: () => accepting,
190
+ serving: () => {
191
+ if (serving === void 0) throw new Error("[test-runtime] not started");
192
+ return serving;
193
+ },
194
+ submit: () => {
195
+ if (run === void 0 || !accepting) throw new Error("[test-runtime] not accepting work");
196
+ submitted += 1;
197
+ let settle;
198
+ const held = new Promise((resolve) => {
199
+ settle = resolve;
200
+ });
201
+ const forwarded = new AbortController();
202
+ const result = run({
203
+ kind: "test",
204
+ id: `${submitted}`
205
+ }, (_ctx, signal) => {
206
+ if (signal.aborted) forwarded.abort(signal.reason);
207
+ else signal.addEventListener("abort", () => forwarded.abort(signal.reason), { once: true });
208
+ return held;
209
+ });
210
+ return {
211
+ settle,
212
+ result,
213
+ signal: forwarded.signal
214
+ };
215
+ }
216
+ };
217
+ return runtime;
218
+ };
219
+ //#endregion
220
+ export { TestRuntimePort, bootFixture, createFakeClock, overridden, tapped, testRuntime };
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@btravstack/testing",
3
+ "version": "0.3.0",
4
+ "description": "Test the applications @btravstack/core boots: an in-memory runtime, a fake clock, a boot fixture that stops what it started, and a tap into a running graph",
5
+ "keywords": [
6
+ "dependency-injection",
7
+ "fixture",
8
+ "kernel",
9
+ "testing",
10
+ "typescript",
11
+ "unthrown",
12
+ "vitest"
13
+ ],
14
+ "homepage": "https://github.com/btravstack/btravstack#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/btravstack/btravstack/issues"
17
+ },
18
+ "license": "MIT",
19
+ "author": "Benoit TRAVERS <benoit.travers.fr@gmail.com>",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/btravstack/btravstack.git",
23
+ "directory": "packages/testing"
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "type": "module",
29
+ "sideEffects": false,
30
+ "main": "./dist/index.cjs",
31
+ "module": "./dist/index.mjs",
32
+ "types": "./dist/index.d.cts",
33
+ "exports": {
34
+ ".": {
35
+ "import": {
36
+ "types": "./dist/index.d.mts",
37
+ "default": "./dist/index.mjs"
38
+ },
39
+ "require": {
40
+ "types": "./dist/index.d.cts",
41
+ "default": "./dist/index.cjs"
42
+ }
43
+ },
44
+ "./package.json": "./package.json"
45
+ },
46
+ "devDependencies": {
47
+ "@btravstack/tsconfig": "0.2.0",
48
+ "@types/node": "26.2.0",
49
+ "@unthrown/vitest": "5.7.0",
50
+ "@vitest/coverage-v8": "4.1.11",
51
+ "tsdown": "0.22.14",
52
+ "typescript": "7.0.2",
53
+ "unthrown": "5.7.0",
54
+ "vitest": "4.1.11",
55
+ "@btravstack/config": "0.3.0",
56
+ "@btravstack/core": "0.3.0",
57
+ "@btravstack/di": "0.3.0"
58
+ },
59
+ "peerDependencies": {
60
+ "unthrown": "^5.0.0",
61
+ "@btravstack/core": "^0.3.0",
62
+ "@btravstack/di": "^0.3.0",
63
+ "@btravstack/config": "^0.3.0"
64
+ },
65
+ "engines": {
66
+ "node": ">=20"
67
+ },
68
+ "scripts": {
69
+ "build": "tsdown src/index.ts --format cjs,esm --dts --clean",
70
+ "dev": "tsdown src/index.ts --format cjs,esm --dts --watch",
71
+ "test": "vitest run --coverage",
72
+ "typecheck": "tsc --noEmit"
73
+ }
74
+ }