@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 +21 -0
- package/README.md +99 -0
- package/dist/index.cjs +226 -0
- package/dist/index.d.cts +170 -0
- package/dist/index.d.mts +170 -0
- package/dist/index.mjs +220 -0
- package/package.json +74 -0
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;
|
package/dist/index.d.cts
ADDED
|
@@ -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 };
|
package/dist/index.d.mts
ADDED
|
@@ -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
|
+
}
|