better-effect 0.10.0 → 0.12.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/README.md +464 -11
- package/dist/adapters/iti.d.mts +4 -4
- package/dist/adapters/iti.d.mts.map +1 -1
- package/dist/adapters/iti.mjs +20 -14
- package/dist/adapters/iti.mjs.map +1 -1
- package/dist/effect-CK4YZaX0.mjs +559 -0
- package/dist/effect-CK4YZaX0.mjs.map +1 -0
- package/dist/hono.d.mts +18 -13
- package/dist/hono.d.mts.map +1 -1
- package/dist/hono.mjs +9 -5
- package/dist/hono.mjs.map +1 -1
- package/dist/{index-DklNCz7w.d.mts → index-B1nH4luq.d.mts} +74 -7
- package/dist/index-B1nH4luq.d.mts.map +1 -0
- package/dist/index-BMdU_Z72.d.mts +18 -0
- package/dist/index-BMdU_Z72.d.mts.map +1 -0
- package/dist/{index-BafDna0B.d.mts → index-CX6iAoCB.d.mts} +168 -174
- package/dist/index-CX6iAoCB.d.mts.map +1 -0
- package/dist/index-GYJ4qm9K.d.mts +179 -0
- package/dist/index-GYJ4qm9K.d.mts.map +1 -0
- package/dist/{index-CULgSzUw.d.mts → index-heuaRmXR.d.mts} +5 -5
- package/dist/{index-CULgSzUw.d.mts.map → index-heuaRmXR.d.mts.map} +1 -1
- package/dist/index.d.mts +6 -5
- package/dist/index.mjs +9 -539
- package/dist/index.mjs.map +1 -1
- package/dist/{internal-identity-DmUpBeeL.mjs → internal-identity-CbXoZIfw.mjs} +3 -3
- package/dist/internal-identity-CbXoZIfw.mjs.map +1 -0
- package/dist/{signal-C1bagvrO.mjs → layer-D56fRdLt.mjs} +62 -64
- package/dist/layer-D56fRdLt.mjs.map +1 -0
- package/dist/node-context-DWsCUBRa.d.mts +12 -0
- package/dist/node-context-DWsCUBRa.d.mts.map +1 -0
- package/dist/node-context-lggRF4bR.mjs +25 -0
- package/dist/node-context-lggRF4bR.mjs.map +1 -0
- package/dist/node.d.mts +51 -0
- package/dist/node.d.mts.map +1 -0
- package/dist/node.mjs +233 -0
- package/dist/node.mjs.map +1 -0
- package/dist/{index-C7KX5rAP.d.mts → outcome-CB43bnFW.d.mts} +140 -27
- package/dist/outcome-CB43bnFW.d.mts.map +1 -0
- package/dist/program-metadata-DhQCCirO.mjs +260 -0
- package/dist/program-metadata-DhQCCirO.mjs.map +1 -0
- package/dist/registration-drnZk0SY.mjs +182 -0
- package/dist/registration-drnZk0SY.mjs.map +1 -0
- package/dist/runtime/explicit.d.mts +1 -1
- package/dist/runtime/node.d.mts +3 -12
- package/dist/runtime/node.mjs +2 -24
- package/dist/runtime-CBMrX8Wl.mjs +776 -0
- package/dist/runtime-CBMrX8Wl.mjs.map +1 -0
- package/dist/signal-D2UTcaaG.mjs +105 -0
- package/dist/signal-D2UTcaaG.mjs.map +1 -0
- package/dist/{standard-services-DW-i4UuA.mjs → standard-services-GBZ32tll.mjs} +200 -16
- package/dist/standard-services-GBZ32tll.mjs.map +1 -0
- package/dist/standard-services.d.mts +3 -3
- package/dist/standard-services.mjs +3 -3
- package/dist/testing.d.mts +257 -2
- package/dist/testing.d.mts.map +1 -0
- package/dist/testing.mjs +1159 -2
- package/dist/testing.mjs.map +1 -0
- package/package.json +8 -3
- package/dist/effect-DAMqvegy.mjs +0 -544
- package/dist/effect-DAMqvegy.mjs.map +0 -1
- package/dist/errors-Dnjhzbt0.mjs +0 -99
- package/dist/errors-Dnjhzbt0.mjs.map +0 -1
- package/dist/index-BafDna0B.d.mts.map +0 -1
- package/dist/index-C7KX5rAP.d.mts.map +0 -1
- package/dist/index-DklNCz7w.d.mts.map +0 -1
- package/dist/internal-identity-DmUpBeeL.mjs.map +0 -1
- package/dist/map-layer-backend-gal-mcRv.mjs +0 -53
- package/dist/map-layer-backend-gal-mcRv.mjs.map +0 -1
- package/dist/map-layer-backend-rNwfH0Bz.d.mts +0 -42
- package/dist/map-layer-backend-rNwfH0Bz.d.mts.map +0 -1
- package/dist/runtime/node.d.mts.map +0 -1
- package/dist/runtime/node.mjs.map +0 -1
- package/dist/signal-C1bagvrO.mjs.map +0 -1
- package/dist/standard-services-DW-i4UuA.mjs.map +0 -1
package/dist/testing.mjs
CHANGED
|
@@ -1,2 +1,1159 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { i as LayerDisposeError, r as DuplicateServiceError, s as ServiceTagCollisionError, u as ServiceNotFoundError } from "./registration-drnZk0SY.mjs";
|
|
2
|
+
import { g as RuntimeContextOverlapError, h as RuntimeContextNotConfiguredError } from "./context-BUEf1qjL.mjs";
|
|
3
|
+
import { t as isPromiseLike } from "./runtime-CDcCF5cb.mjs";
|
|
4
|
+
import { o as ScopeCloseError, s as ScopeClosedError } from "./signal-D2UTcaaG.mjs";
|
|
5
|
+
import { n as Service, r as ServiceRuntime, t as Layer } from "./layer-D56fRdLt.mjs";
|
|
6
|
+
import { i as Scope, o as ScopeRuntime } from "./program-metadata-DhQCCirO.mjs";
|
|
7
|
+
import { i as MapLayerBackend, n as RuntimeObserver, r as classifyRuntimeOutcome, t as Runtime } from "./runtime-CBMrX8Wl.mjs";
|
|
8
|
+
import { d as Random, h as IdGenerator, l as LoggerTest, p as RandomSeeded, r as ClockTest, s as Logger, t as Clock, v as IdGeneratorTest } from "./standard-services-GBZ32tll.mjs";
|
|
9
|
+
//#region src/testing/contracts.ts
|
|
10
|
+
/** Thrown by a conformance scenario when an adapter violates its contract. */
|
|
11
|
+
var ConformanceError = class extends Error {
|
|
12
|
+
scenario;
|
|
13
|
+
constructor(scenario, detail, cause) {
|
|
14
|
+
super(`${scenario}: ${detail}`, { cause });
|
|
15
|
+
this.scenario = scenario;
|
|
16
|
+
this.name = "ConformanceError";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
var ContractService = class extends Service()("@better-effect/testing/ContractService") {};
|
|
20
|
+
var MissingContractService = class extends Service()("@better-effect/testing/MissingContractService") {};
|
|
21
|
+
var CompatibleRegisteredService = class extends Service()("@better-effect/testing/CompatibleService") {
|
|
22
|
+
read() {
|
|
23
|
+
return "registered";
|
|
24
|
+
}
|
|
25
|
+
write() {
|
|
26
|
+
return "registered-only";
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
var CompatibleRequestedService = class extends Service()("@better-effect/testing/CompatibleService") {
|
|
30
|
+
read() {
|
|
31
|
+
return "requested";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
var IncompatibleRegisteredService = class extends Service()("@better-effect/testing/IncompatibleService") {
|
|
35
|
+
read() {
|
|
36
|
+
return "registered";
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
var IncompatibleRequestedService = class extends Service()("@better-effect/testing/IncompatibleService") {
|
|
40
|
+
write() {
|
|
41
|
+
return "requested";
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const layerScenario = (name, run) => ({
|
|
45
|
+
name,
|
|
46
|
+
async run() {
|
|
47
|
+
try {
|
|
48
|
+
await run();
|
|
49
|
+
} catch (cause) {
|
|
50
|
+
if (cause instanceof ConformanceError) throw cause;
|
|
51
|
+
throw new ConformanceError(name, `unexpected failure (${describeCause(cause)})`, cause);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
const fail = (scenario, detail) => {
|
|
56
|
+
throw new ConformanceError(scenario, detail);
|
|
57
|
+
};
|
|
58
|
+
const assert = (condition, scenario, detail) => {
|
|
59
|
+
if (!condition) fail(scenario, detail);
|
|
60
|
+
};
|
|
61
|
+
const describeCause = (cause) => cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause);
|
|
62
|
+
const rejected = async (value, scenario, expectation) => {
|
|
63
|
+
const outcome = await Promise.resolve(value).then(() => ({ rejected: false }), (cause) => ({
|
|
64
|
+
rejected: true,
|
|
65
|
+
cause
|
|
66
|
+
}));
|
|
67
|
+
if (outcome.rejected) return outcome.cause;
|
|
68
|
+
fail(scenario, `expected ${expectation} to reject`);
|
|
69
|
+
};
|
|
70
|
+
const withBackend = async (options, run) => {
|
|
71
|
+
const backend = await options.makeBackend();
|
|
72
|
+
try {
|
|
73
|
+
return await run(backend);
|
|
74
|
+
} finally {
|
|
75
|
+
try {
|
|
76
|
+
await backend.disposeAll();
|
|
77
|
+
} finally {
|
|
78
|
+
await options.cleanup?.(backend);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const expectMissingService = (cause, service, scenario) => {
|
|
83
|
+
assert(cause instanceof ServiceNotFoundError, scenario, `expected ServiceNotFoundError for tag "${service.serviceTag}", received ${describeCause(cause)}`);
|
|
84
|
+
if (cause instanceof ServiceNotFoundError) assert(cause.service.serviceTag === service.serviceTag, scenario, `expected missing tag "${service.serviceTag}", received "${cause.service.serviceTag}"`);
|
|
85
|
+
};
|
|
86
|
+
const expectDuplicate = (cause, service, scenario) => {
|
|
87
|
+
assert(cause instanceof DuplicateServiceError, scenario, `expected DuplicateServiceError for tag "${service.serviceTag}", received ${describeCause(cause)}`);
|
|
88
|
+
if (cause instanceof DuplicateServiceError) assert(cause.service.serviceTag === service.serviceTag, scenario, `expected duplicate tag "${service.serviceTag}", received "${cause.service.serviceTag}"`);
|
|
89
|
+
};
|
|
90
|
+
const expectCollision = (cause, service, scenario) => {
|
|
91
|
+
assert(cause instanceof ServiceTagCollisionError, scenario, `expected ServiceTagCollisionError for tag "${service.serviceTag}", received ${describeCause(cause)}`);
|
|
92
|
+
if (cause instanceof ServiceTagCollisionError) assert(cause.incoming.serviceTag === service.serviceTag, scenario, `expected collision tag "${service.serviceTag}", received "${cause.incoming.serviceTag}"`);
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Return runner-neutral scenarios for a LayerBackend implementation.
|
|
96
|
+
*
|
|
97
|
+
* Every scenario creates a fresh backend and invokes `cleanup` in `finally`,
|
|
98
|
+
* so consumers can register `scenario.run` directly with their test runner.
|
|
99
|
+
*/
|
|
100
|
+
const layerBackendContract = (options) => [
|
|
101
|
+
layerScenario("LayerBackend registers providers lazily", async () => {
|
|
102
|
+
await withBackend(options, async (backend) => {
|
|
103
|
+
let acquisitions = 0;
|
|
104
|
+
await backend.register({
|
|
105
|
+
service: ContractService,
|
|
106
|
+
acquire: () => {
|
|
107
|
+
acquisitions++;
|
|
108
|
+
return new ContractService();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
assert(acquisitions === 0, "LayerBackend registers providers lazily", `expected no acquisitions during registration for tag "${ContractService.serviceTag}", received ${acquisitions}`);
|
|
112
|
+
});
|
|
113
|
+
}),
|
|
114
|
+
layerScenario("LayerBackend resolves the registered Service token", async () => {
|
|
115
|
+
await withBackend(options, async (backend) => {
|
|
116
|
+
const instance = new ContractService();
|
|
117
|
+
await backend.register({
|
|
118
|
+
service: ContractService,
|
|
119
|
+
acquire: () => instance
|
|
120
|
+
});
|
|
121
|
+
const resolved = await backend.resolve(ContractService);
|
|
122
|
+
assert(resolved === instance && resolved instanceof ContractService, "LayerBackend resolves the registered Service token", `expected token "${ContractService.serviceTag}" to resolve its registered instance`);
|
|
123
|
+
});
|
|
124
|
+
}),
|
|
125
|
+
layerScenario("LayerBackend caches successful acquisitions", async () => {
|
|
126
|
+
await withBackend(options, async (backend) => {
|
|
127
|
+
let acquisitions = 0;
|
|
128
|
+
await backend.register({
|
|
129
|
+
service: ContractService,
|
|
130
|
+
acquire: () => {
|
|
131
|
+
acquisitions++;
|
|
132
|
+
return new ContractService();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
const first = await backend.resolve(ContractService);
|
|
136
|
+
const second = await backend.resolve(ContractService);
|
|
137
|
+
assert(first === second && acquisitions === 1, "LayerBackend caches successful acquisitions", `expected one cached acquisition for tag "${ContractService.serviceTag}", received ${acquisitions}`);
|
|
138
|
+
});
|
|
139
|
+
}),
|
|
140
|
+
layerScenario("LayerBackend deduplicates concurrent acquisitions", async () => {
|
|
141
|
+
await withBackend(options, async (backend) => {
|
|
142
|
+
let acquisitions = 0;
|
|
143
|
+
let releaseAcquisition;
|
|
144
|
+
let markStarted;
|
|
145
|
+
const acquired = new Promise((resolve) => {
|
|
146
|
+
markStarted = resolve;
|
|
147
|
+
});
|
|
148
|
+
const release = new Promise((resolve) => {
|
|
149
|
+
releaseAcquisition = resolve;
|
|
150
|
+
});
|
|
151
|
+
const instance = new ContractService();
|
|
152
|
+
await backend.register({
|
|
153
|
+
service: ContractService,
|
|
154
|
+
acquire: async () => {
|
|
155
|
+
acquisitions++;
|
|
156
|
+
markStarted();
|
|
157
|
+
await release;
|
|
158
|
+
return instance;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
const first = Promise.resolve(backend.resolve(ContractService));
|
|
162
|
+
const second = Promise.resolve(backend.resolve(ContractService));
|
|
163
|
+
try {
|
|
164
|
+
await acquired;
|
|
165
|
+
assert(acquisitions === 1, "LayerBackend deduplicates concurrent acquisitions", `expected one in-flight acquisition for tag "${ContractService.serviceTag}", received ${acquisitions}`);
|
|
166
|
+
releaseAcquisition();
|
|
167
|
+
const [firstInstance, secondInstance] = await Promise.all([first, second]);
|
|
168
|
+
assert(firstInstance === instance && secondInstance === instance, "LayerBackend deduplicates concurrent acquisitions", `expected concurrent resolves for tag "${ContractService.serviceTag}" to share one instance`);
|
|
169
|
+
} finally {
|
|
170
|
+
releaseAcquisition();
|
|
171
|
+
await Promise.allSettled([first, second]);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}),
|
|
175
|
+
layerScenario("LayerBackend reports a missing Service tag", async () => {
|
|
176
|
+
await withBackend(options, async (backend) => {
|
|
177
|
+
const cause = await rejected(Promise.resolve().then(() => backend.resolve(MissingContractService)), "LayerBackend reports a missing Service tag", `resolution for tag "${MissingContractService.serviceTag}"`);
|
|
178
|
+
expectMissingService(cause, MissingContractService, "LayerBackend reports a missing Service tag");
|
|
179
|
+
});
|
|
180
|
+
}),
|
|
181
|
+
layerScenario("LayerBackend rejects exact duplicate registrations", async () => {
|
|
182
|
+
await withBackend(options, async (backend) => {
|
|
183
|
+
const registration = {
|
|
184
|
+
service: ContractService,
|
|
185
|
+
acquire: () => new ContractService()
|
|
186
|
+
};
|
|
187
|
+
await backend.register(registration);
|
|
188
|
+
const cause = await rejected(Promise.resolve().then(() => backend.register(registration)), "LayerBackend rejects exact duplicate registrations", `a duplicate registration for tag "${ContractService.serviceTag}"`);
|
|
189
|
+
expectDuplicate(cause, ContractService, "LayerBackend rejects exact duplicate registrations");
|
|
190
|
+
});
|
|
191
|
+
}),
|
|
192
|
+
layerScenario("LayerBackend rejects incompatible same-tag collisions", async () => {
|
|
193
|
+
await withBackend(options, async (backend) => {
|
|
194
|
+
await backend.register({
|
|
195
|
+
service: IncompatibleRegisteredService,
|
|
196
|
+
acquire: () => new IncompatibleRegisteredService()
|
|
197
|
+
});
|
|
198
|
+
const registrationCause = await rejected(Promise.resolve().then(() => backend.register({
|
|
199
|
+
service: IncompatibleRequestedService,
|
|
200
|
+
acquire: () => new IncompatibleRequestedService()
|
|
201
|
+
})), "LayerBackend rejects incompatible same-tag collisions", `a colliding registration for tag "${IncompatibleRequestedService.serviceTag}"`);
|
|
202
|
+
expectCollision(registrationCause, IncompatibleRequestedService, "LayerBackend rejects incompatible same-tag collisions");
|
|
203
|
+
const resolutionCause = await rejected(Promise.resolve().then(() => backend.resolve(IncompatibleRequestedService)), "LayerBackend rejects incompatible same-tag collisions", `an incompatible lookup for tag "${IncompatibleRequestedService.serviceTag}"`);
|
|
204
|
+
expectCollision(resolutionCause, IncompatibleRequestedService, "LayerBackend rejects incompatible same-tag collisions");
|
|
205
|
+
});
|
|
206
|
+
}),
|
|
207
|
+
layerScenario("LayerBackend preserves compatible same-tag registrations", async () => {
|
|
208
|
+
await withBackend(options, async (backend) => {
|
|
209
|
+
const registered = new CompatibleRegisteredService();
|
|
210
|
+
await backend.register({
|
|
211
|
+
service: CompatibleRegisteredService,
|
|
212
|
+
acquire: () => registered
|
|
213
|
+
});
|
|
214
|
+
const resolved = await backend.resolve(CompatibleRequestedService);
|
|
215
|
+
assert(resolved === registered && resolved.read() === "registered", "LayerBackend preserves compatible same-tag registrations", `expected compatible tag "${CompatibleRequestedService.serviceTag}" to resolve the registered contract`);
|
|
216
|
+
});
|
|
217
|
+
}),
|
|
218
|
+
layerScenario("LayerBackend applies declared acquisition-failure behavior", async () => {
|
|
219
|
+
await withBackend(options, async (backend) => {
|
|
220
|
+
const failure = /* @__PURE__ */ new Error("contract acquisition failure");
|
|
221
|
+
let acquisitions = 0;
|
|
222
|
+
await backend.register({
|
|
223
|
+
service: ContractService,
|
|
224
|
+
acquire: async () => {
|
|
225
|
+
acquisitions++;
|
|
226
|
+
throw failure;
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
const firstCause = await rejected(Promise.resolve().then(() => backend.resolve(ContractService)), "LayerBackend applies declared acquisition-failure behavior", `the first acquisition for tag "${ContractService.serviceTag}"`);
|
|
230
|
+
const secondCause = await rejected(Promise.resolve().then(() => backend.resolve(ContractService)), "LayerBackend applies declared acquisition-failure behavior", `the second acquisition for tag "${ContractService.serviceTag}"`);
|
|
231
|
+
const expectedAcquisitions = options.acquisitionFailure === "retry" ? 2 : 1;
|
|
232
|
+
assert(firstCause === failure && secondCause === failure && acquisitions === expectedAcquisitions, "LayerBackend applies declared acquisition-failure behavior", `expected ${options.acquisitionFailure} behavior with ${expectedAcquisitions} acquisition attempts for tag "${ContractService.serviceTag}", received ${acquisitions}`);
|
|
233
|
+
});
|
|
234
|
+
}),
|
|
235
|
+
layerScenario("LayerBackend disposal waits for in-flight acquisition", async () => {
|
|
236
|
+
await withBackend(options, async (backend) => {
|
|
237
|
+
let releaseAcquisition;
|
|
238
|
+
let releaseCallbackGate;
|
|
239
|
+
let markStarted;
|
|
240
|
+
let disposing;
|
|
241
|
+
let callbackCompleted;
|
|
242
|
+
let observedPendingAcquisitions;
|
|
243
|
+
let callbackInvoked = false;
|
|
244
|
+
let acquisitionCompleted = false;
|
|
245
|
+
let callbackCompletionReactionObserved = false;
|
|
246
|
+
let disposalSettled = false;
|
|
247
|
+
const acquired = new Promise((resolve) => {
|
|
248
|
+
markStarted = resolve;
|
|
249
|
+
});
|
|
250
|
+
const release = new Promise((resolve) => {
|
|
251
|
+
releaseAcquisition = resolve;
|
|
252
|
+
});
|
|
253
|
+
const callbackGate = new Promise((resolve) => {
|
|
254
|
+
releaseCallbackGate = resolve;
|
|
255
|
+
});
|
|
256
|
+
await backend.register({
|
|
257
|
+
service: ContractService,
|
|
258
|
+
acquire: async () => {
|
|
259
|
+
markStarted();
|
|
260
|
+
await release;
|
|
261
|
+
acquisitionCompleted = true;
|
|
262
|
+
return new ContractService();
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
const resolving = Promise.resolve(backend.resolve(ContractService));
|
|
266
|
+
try {
|
|
267
|
+
await acquired;
|
|
268
|
+
const disposalResult = backend.disposeAll({ onPendingAcquisitions: (acquisitions) => {
|
|
269
|
+
assert(acquisitions.length > 0, "LayerBackend disposal waits for in-flight acquisition", `disposeAll did not report a pending acquisition for tag "${ContractService.serviceTag}"`);
|
|
270
|
+
callbackInvoked = true;
|
|
271
|
+
observedPendingAcquisitions = Promise.allSettled(acquisitions).then(() => {
|
|
272
|
+
assert(acquisitionCompleted, "LayerBackend disposal waits for in-flight acquisition", `disposeAll reported an acquisition that settled before tag "${ContractService.serviceTag}" completed`);
|
|
273
|
+
});
|
|
274
|
+
callbackCompleted = callbackGate.then(() => void 0);
|
|
275
|
+
return callbackCompleted;
|
|
276
|
+
} });
|
|
277
|
+
assert(disposalResult !== void 0 && isPromiseLike(disposalResult), "LayerBackend disposal waits for in-flight acquisition", `disposeAll did not await the pending-acquisition callback for tag "${ContractService.serviceTag}"`);
|
|
278
|
+
disposing = Promise.resolve(disposalResult);
|
|
279
|
+
const disposalReaction = disposing.then(() => {
|
|
280
|
+
disposalSettled = true;
|
|
281
|
+
}, () => {
|
|
282
|
+
disposalSettled = true;
|
|
283
|
+
});
|
|
284
|
+
assert(callbackInvoked, "LayerBackend disposal waits for in-flight acquisition", `disposeAll did not synchronously invoke the pending-acquisition callback for tag "${ContractService.serviceTag}"`);
|
|
285
|
+
const callbackCompletionBarrier = (callbackCompleted ?? fail("LayerBackend disposal waits for in-flight acquisition", `the pending-acquisition callback for tag "${ContractService.serviceTag}" did not return a completion Promise`)).then(() => {
|
|
286
|
+
callbackCompletionReactionObserved = true;
|
|
287
|
+
});
|
|
288
|
+
releaseCallbackGate();
|
|
289
|
+
await callbackCompletionBarrier;
|
|
290
|
+
assert(callbackCompletionReactionObserved, "LayerBackend disposal waits for in-flight acquisition", `the pending-acquisition callback for tag "${ContractService.serviceTag}" did not complete`);
|
|
291
|
+
assert(!acquisitionCompleted, "LayerBackend disposal waits for in-flight acquisition", `the pending acquisition for tag "${ContractService.serviceTag}" completed before disposal released it`);
|
|
292
|
+
assert(!disposalSettled, "LayerBackend disposal waits for in-flight acquisition", `disposeAll settled after its pending-acquisition callback completed but before tag "${ContractService.serviceTag}" completed`);
|
|
293
|
+
releaseAcquisition();
|
|
294
|
+
if (observedPendingAcquisitions === void 0) fail("LayerBackend disposal waits for in-flight acquisition", `disposeAll did not observe a pending acquisition for tag "${ContractService.serviceTag}"`);
|
|
295
|
+
await Promise.all([
|
|
296
|
+
resolving,
|
|
297
|
+
observedPendingAcquisitions,
|
|
298
|
+
disposing,
|
|
299
|
+
disposalReaction
|
|
300
|
+
]);
|
|
301
|
+
} finally {
|
|
302
|
+
releaseCallbackGate();
|
|
303
|
+
releaseAcquisition();
|
|
304
|
+
const pending = [resolving];
|
|
305
|
+
if (observedPendingAcquisitions !== void 0) pending.push(observedPendingAcquisitions);
|
|
306
|
+
if (disposing !== void 0) pending.push(disposing);
|
|
307
|
+
await Promise.allSettled(pending);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
}),
|
|
311
|
+
layerScenario("LayerBackend disposal clears registrations and supports reuse", async () => {
|
|
312
|
+
await withBackend(options, async (backend) => {
|
|
313
|
+
const first = new ContractService();
|
|
314
|
+
await backend.register({
|
|
315
|
+
service: ContractService,
|
|
316
|
+
acquire: () => first
|
|
317
|
+
});
|
|
318
|
+
assert(await backend.resolve(ContractService) === first, "LayerBackend disposal clears registrations and supports reuse", `expected the first registration for tag "${ContractService.serviceTag}" to resolve before disposal`);
|
|
319
|
+
await backend.disposeAll();
|
|
320
|
+
const missingCause = await rejected(Promise.resolve().then(() => backend.resolve(ContractService)), "LayerBackend disposal clears registrations and supports reuse", `resolution after disposing tag "${ContractService.serviceTag}"`);
|
|
321
|
+
expectMissingService(missingCause, ContractService, "LayerBackend disposal clears registrations and supports reuse");
|
|
322
|
+
const second = new ContractService();
|
|
323
|
+
await backend.register({
|
|
324
|
+
service: ContractService,
|
|
325
|
+
acquire: () => second
|
|
326
|
+
});
|
|
327
|
+
assert(await backend.resolve(ContractService) === second, "LayerBackend disposal clears registrations and supports reuse", `expected a fresh instance for reused tag "${ContractService.serviceTag}" after disposal`);
|
|
328
|
+
});
|
|
329
|
+
}),
|
|
330
|
+
layerScenario("LayerBackend repeated disposal is safe", async () => {
|
|
331
|
+
await withBackend(options, async (backend) => {
|
|
332
|
+
await backend.disposeAll();
|
|
333
|
+
await backend.disposeAll();
|
|
334
|
+
});
|
|
335
|
+
}),
|
|
336
|
+
layerScenario("LayerBackend does not execute Layer release callbacks", async () => {
|
|
337
|
+
await withBackend(options, async (backend) => {
|
|
338
|
+
let releases = 0;
|
|
339
|
+
const registration = Layer.scoped(ContractService, () => new ContractService(), () => {
|
|
340
|
+
releases++;
|
|
341
|
+
}).providers[0] ?? fail("LayerBackend does not execute Layer release callbacks", `Layer did not retain the provider for tag "${ContractService.serviceTag}"`);
|
|
342
|
+
await backend.register(registration);
|
|
343
|
+
await backend.resolve(ContractService);
|
|
344
|
+
await backend.disposeAll();
|
|
345
|
+
assert(releases === 0, "LayerBackend does not execute Layer release callbacks", `backend disposal executed ${releases} Layer release callback(s) for tag "${ContractService.serviceTag}"`);
|
|
346
|
+
});
|
|
347
|
+
})
|
|
348
|
+
];
|
|
349
|
+
const makeResolver = () => {
|
|
350
|
+
const services = /* @__PURE__ */ new Map();
|
|
351
|
+
return { resolve(token) {
|
|
352
|
+
const service = services.get(token);
|
|
353
|
+
if (service === void 0) throw new Error(`No test Service is registered for "${token.serviceTag}"`);
|
|
354
|
+
return service;
|
|
355
|
+
} };
|
|
356
|
+
};
|
|
357
|
+
const makeContext = (scope, resolver) => ({
|
|
358
|
+
scope,
|
|
359
|
+
resolver,
|
|
360
|
+
resolutionPath: []
|
|
361
|
+
});
|
|
362
|
+
const withStorage = async (options, run) => {
|
|
363
|
+
const storage = options.makeStorage();
|
|
364
|
+
try {
|
|
365
|
+
return await run(storage);
|
|
366
|
+
} finally {
|
|
367
|
+
await options.cleanup?.(storage);
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
const expectMissingContext = (cause, options, scenario) => {
|
|
371
|
+
const isMissing = options.isMissingContextError ?? ((cause) => cause instanceof RuntimeContextNotConfiguredError);
|
|
372
|
+
assert(isMissing(cause), scenario, `expected the declared missing-context error for ${options.concurrency} storage, received ${describeCause(cause)}`);
|
|
373
|
+
};
|
|
374
|
+
const expectOverlap = (cause, options, scenario) => {
|
|
375
|
+
const isOverlap = options.isOverlapError ?? ((cause) => cause instanceof RuntimeContextOverlapError);
|
|
376
|
+
assert(isOverlap(cause), scenario, `expected the declared overlap error for sequential storage, received ${describeCause(cause)}`);
|
|
377
|
+
};
|
|
378
|
+
const closeScopes = async (...scopes) => {
|
|
379
|
+
const failures = [];
|
|
380
|
+
for (const scope of scopes) try {
|
|
381
|
+
await scope.close();
|
|
382
|
+
} catch (cause) {
|
|
383
|
+
failures.push(cause);
|
|
384
|
+
}
|
|
385
|
+
if (failures.length === 1) throw failures[0];
|
|
386
|
+
if (failures.length > 1) throw new AggregateError(failures, "Contract Scope cleanup failed");
|
|
387
|
+
};
|
|
388
|
+
/**
|
|
389
|
+
* Return runner-neutral scenarios for a RuntimeContextStorage implementation.
|
|
390
|
+
*
|
|
391
|
+
* Every scenario creates a fresh storage and invokes `cleanup` in `finally`,
|
|
392
|
+
* so consumers can register `scenario.run` directly with their test runner.
|
|
393
|
+
*/
|
|
394
|
+
const runtimeContextStorageContract = (options) => {
|
|
395
|
+
const scenarios = [
|
|
396
|
+
layerScenario("RuntimeContextStorage exposes context synchronously and after await", async () => {
|
|
397
|
+
await withStorage(options, async (storage) => {
|
|
398
|
+
const scope = Scope.make();
|
|
399
|
+
const context = makeContext(scope, makeResolver());
|
|
400
|
+
try {
|
|
401
|
+
await storage.run(context, async () => {
|
|
402
|
+
assert(storage.current() === context, "RuntimeContextStorage exposes context synchronously and after await", "current() did not return the context synchronously inside run()");
|
|
403
|
+
await Promise.resolve();
|
|
404
|
+
assert(storage.current() === context, "RuntimeContextStorage exposes context synchronously and after await", "current() did not retain the context after Promise suspension");
|
|
405
|
+
});
|
|
406
|
+
} finally {
|
|
407
|
+
await scope.close();
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
}),
|
|
411
|
+
layerScenario("RuntimeContextStorage restores parent context after nested execution", async () => {
|
|
412
|
+
await withStorage(options, async (storage) => {
|
|
413
|
+
const rootScope = Scope.make();
|
|
414
|
+
const childScope = rootScope.fork();
|
|
415
|
+
const resolver = makeResolver();
|
|
416
|
+
const root = makeContext(rootScope, resolver);
|
|
417
|
+
try {
|
|
418
|
+
await storage.run(root, async () => {
|
|
419
|
+
await ServiceRuntime.run(resolver, async () => {
|
|
420
|
+
const serviceContext = storage.current();
|
|
421
|
+
assert(serviceContext !== root && serviceContext.resolver === resolver, "RuntimeContextStorage restores parent context after nested execution", "ServiceRuntime.run did not preserve the parent resolver lineage");
|
|
422
|
+
await Scope.provide(childScope, async () => {
|
|
423
|
+
const scopeContext = storage.current();
|
|
424
|
+
assert(scopeContext.scope === childScope && scopeContext.resolver === resolver, "RuntimeContextStorage restores parent context after nested execution", "Scope.provide did not preserve the parent Service lineage");
|
|
425
|
+
await Promise.resolve();
|
|
426
|
+
assert(storage.current() === scopeContext, "RuntimeContextStorage restores parent context after nested execution", "derived Scope context changed after Promise suspension");
|
|
427
|
+
});
|
|
428
|
+
assert(storage.current() === serviceContext, "RuntimeContextStorage restores parent context after nested execution", "parent Service context was not restored after Scope.provide");
|
|
429
|
+
}, storage);
|
|
430
|
+
assert(storage.current() === root, "RuntimeContextStorage restores parent context after nested execution", "root context was not restored after ServiceRuntime.run");
|
|
431
|
+
});
|
|
432
|
+
} finally {
|
|
433
|
+
await closeScopes(childScope, rootScope);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
}),
|
|
437
|
+
layerScenario("RuntimeContextStorage restores context after synchronous throw", async () => {
|
|
438
|
+
await withStorage(options, async (storage) => {
|
|
439
|
+
const scope = Scope.make();
|
|
440
|
+
const context = makeContext(scope, makeResolver());
|
|
441
|
+
const failure = /* @__PURE__ */ new Error("contract synchronous failure");
|
|
442
|
+
try {
|
|
443
|
+
const cause = await rejected(Promise.resolve().then(() => storage.run(context, () => {
|
|
444
|
+
throw failure;
|
|
445
|
+
})), "RuntimeContextStorage restores context after synchronous throw", "the synchronous callback failure");
|
|
446
|
+
assert(cause === failure, "RuntimeContextStorage restores context after synchronous throw", `expected the original synchronous failure, received ${describeCause(cause)}`);
|
|
447
|
+
expectMissingContext(await rejected(Promise.resolve().then(() => storage.current()), "RuntimeContextStorage restores context after synchronous throw", "current() outside run()"), options, "RuntimeContextStorage restores context after synchronous throw");
|
|
448
|
+
} finally {
|
|
449
|
+
await scope.close();
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
}),
|
|
453
|
+
layerScenario("RuntimeContextStorage restores context after Promise rejection", async () => {
|
|
454
|
+
await withStorage(options, async (storage) => {
|
|
455
|
+
const scope = Scope.make();
|
|
456
|
+
const context = makeContext(scope, makeResolver());
|
|
457
|
+
const failure = /* @__PURE__ */ new Error("contract async failure");
|
|
458
|
+
try {
|
|
459
|
+
const cause = await rejected(storage.run(context, async () => {
|
|
460
|
+
await Promise.resolve();
|
|
461
|
+
throw failure;
|
|
462
|
+
}), "RuntimeContextStorage restores context after Promise rejection", "the asynchronous callback failure");
|
|
463
|
+
assert(cause === failure, "RuntimeContextStorage restores context after Promise rejection", `expected the original asynchronous failure, received ${describeCause(cause)}`);
|
|
464
|
+
expectMissingContext(await rejected(Promise.resolve().then(() => storage.current()), "RuntimeContextStorage restores context after Promise rejection", "current() outside run()"), options, "RuntimeContextStorage restores context after Promise rejection");
|
|
465
|
+
} finally {
|
|
466
|
+
await scope.close();
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
}),
|
|
470
|
+
layerScenario("RuntimeContextStorage preserves custom internal failures", async () => {
|
|
471
|
+
await withStorage(options, async (storage) => {
|
|
472
|
+
const failure = /* @__PURE__ */ new Error("contract storage failure");
|
|
473
|
+
const faultyStorage = {
|
|
474
|
+
run: (context, program) => storage.run(context, program),
|
|
475
|
+
current: () => {
|
|
476
|
+
throw failure;
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
const cause = await rejected(Promise.resolve().then(() => ServiceRuntime.run(makeResolver(), () => void 0, faultyStorage)), "RuntimeContextStorage preserves custom internal failures", "a custom storage failure");
|
|
480
|
+
assert(cause === failure, "RuntimeContextStorage preserves custom internal failures", `expected the original custom failure, received ${describeCause(cause)}`);
|
|
481
|
+
});
|
|
482
|
+
}),
|
|
483
|
+
layerScenario("Scope closes when RuntimeContextStorage.run throws synchronously", async () => {
|
|
484
|
+
await withStorage(options, async (storage) => {
|
|
485
|
+
const parent = Scope.make();
|
|
486
|
+
const child = parent.fork();
|
|
487
|
+
const storageFailure = /* @__PURE__ */ new Error("contract close storage failure");
|
|
488
|
+
const closeFailure = /* @__PURE__ */ new Error("contract close finalizer failure");
|
|
489
|
+
const outcome = {
|
|
490
|
+
status: "failure",
|
|
491
|
+
cause: closeFailure
|
|
492
|
+
};
|
|
493
|
+
const events = [];
|
|
494
|
+
const faultyStorage = {
|
|
495
|
+
run: () => {
|
|
496
|
+
throw storageFailure;
|
|
497
|
+
},
|
|
498
|
+
current: () => storage.current()
|
|
499
|
+
};
|
|
500
|
+
child.addFinalizer((childOutcome) => {
|
|
501
|
+
events.push(`child:${childOutcome.status}`);
|
|
502
|
+
});
|
|
503
|
+
parent.addFinalizer((parentOutcome) => {
|
|
504
|
+
events.push(`first:${parentOutcome.status}`);
|
|
505
|
+
throw closeFailure;
|
|
506
|
+
});
|
|
507
|
+
parent.addFinalizer((parentOutcome) => {
|
|
508
|
+
events.push(`second:${parentOutcome.status}`);
|
|
509
|
+
});
|
|
510
|
+
ScopeRuntime.bind(parent, faultyStorage);
|
|
511
|
+
const firstClose = parent.close(outcome);
|
|
512
|
+
const secondClose = parent.close();
|
|
513
|
+
assert(secondClose === firstClose, "Scope closes when RuntimeContextStorage.run throws synchronously", "later close did not share the original close Promise");
|
|
514
|
+
const cause = await rejected(firstClose, "Scope closes when RuntimeContextStorage.run throws synchronously", "the storage-safe close");
|
|
515
|
+
assert(cause instanceof ScopeCloseError && cause.causes.length === 2 && cause.causes[0] === storageFailure && cause.causes[1] === closeFailure, "Scope closes when RuntimeContextStorage.run throws synchronously", `expected storage then finalizer cleanup failures, received ${describeCause(cause)}`);
|
|
516
|
+
assert(events.join(",") === "child:failure,second:failure,first:failure", "Scope closes when RuntimeContextStorage.run throws synchronously", `expected child-first LIFO finalizers with the original outcome, received ${events.join(",")}`);
|
|
517
|
+
const closedCause = await rejected(Promise.resolve().then(() => parent.addFinalizer(() => void 0)), "Scope closes when RuntimeContextStorage.run throws synchronously", "registration after close");
|
|
518
|
+
assert(closedCause instanceof ScopeClosedError, "Scope closes when RuntimeContextStorage.run throws synchronously", `Scope remained open after storage failure (${describeCause(closedCause)})`);
|
|
519
|
+
});
|
|
520
|
+
})
|
|
521
|
+
];
|
|
522
|
+
if (options.concurrency === "concurrent") scenarios.push(layerScenario("RuntimeContextStorage isolates concurrent roots", async () => {
|
|
523
|
+
await withStorage(options, async (storage) => {
|
|
524
|
+
const firstScope = Scope.make();
|
|
525
|
+
const secondScope = Scope.make();
|
|
526
|
+
const first = makeContext(firstScope, makeResolver());
|
|
527
|
+
const second = makeContext(secondScope, makeResolver());
|
|
528
|
+
let releaseFirst;
|
|
529
|
+
let releaseSecond;
|
|
530
|
+
let firstStarted;
|
|
531
|
+
let secondStarted;
|
|
532
|
+
const firstMayFinish = new Promise((resolve) => {
|
|
533
|
+
releaseFirst = resolve;
|
|
534
|
+
});
|
|
535
|
+
const secondMayFinish = new Promise((resolve) => {
|
|
536
|
+
releaseSecond = resolve;
|
|
537
|
+
});
|
|
538
|
+
const firstStartedPromise = new Promise((resolve) => {
|
|
539
|
+
firstStarted = resolve;
|
|
540
|
+
});
|
|
541
|
+
const secondStartedPromise = new Promise((resolve) => {
|
|
542
|
+
secondStarted = resolve;
|
|
543
|
+
});
|
|
544
|
+
const runs = [];
|
|
545
|
+
try {
|
|
546
|
+
const firstRun = Promise.resolve(storage.run(first, async () => {
|
|
547
|
+
firstStarted();
|
|
548
|
+
await firstMayFinish;
|
|
549
|
+
return storage.current();
|
|
550
|
+
}));
|
|
551
|
+
runs.push(firstRun);
|
|
552
|
+
const secondRun = Promise.resolve(storage.run(second, async () => {
|
|
553
|
+
secondStarted();
|
|
554
|
+
await secondMayFinish;
|
|
555
|
+
return storage.current();
|
|
556
|
+
}));
|
|
557
|
+
runs.push(secondRun);
|
|
558
|
+
await Promise.all([firstStartedPromise, secondStartedPromise]);
|
|
559
|
+
releaseFirst();
|
|
560
|
+
releaseSecond();
|
|
561
|
+
const [firstCurrent, secondCurrent] = await Promise.all([firstRun, secondRun]);
|
|
562
|
+
assert(firstCurrent === first && secondCurrent === second, "RuntimeContextStorage isolates concurrent roots", "overlapping roots observed another context frame");
|
|
563
|
+
} finally {
|
|
564
|
+
releaseFirst();
|
|
565
|
+
releaseSecond();
|
|
566
|
+
await Promise.allSettled(runs);
|
|
567
|
+
await closeScopes(firstScope, secondScope);
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
}), layerScenario("RuntimeContextStorage isolates concurrent derived siblings", async () => {
|
|
571
|
+
await withStorage(options, async (storage) => {
|
|
572
|
+
const rootScope = Scope.make();
|
|
573
|
+
const firstScope = rootScope.fork();
|
|
574
|
+
const secondScope = rootScope.fork();
|
|
575
|
+
const root = makeContext(rootScope, makeResolver());
|
|
576
|
+
let releaseFirst;
|
|
577
|
+
let releaseSecond;
|
|
578
|
+
let firstStarted;
|
|
579
|
+
let secondStarted;
|
|
580
|
+
const firstMayFinish = new Promise((resolve) => {
|
|
581
|
+
releaseFirst = resolve;
|
|
582
|
+
});
|
|
583
|
+
const secondMayFinish = new Promise((resolve) => {
|
|
584
|
+
releaseSecond = resolve;
|
|
585
|
+
});
|
|
586
|
+
const firstStartedPromise = new Promise((resolve) => {
|
|
587
|
+
firstStarted = resolve;
|
|
588
|
+
});
|
|
589
|
+
const secondStartedPromise = new Promise((resolve) => {
|
|
590
|
+
secondStarted = resolve;
|
|
591
|
+
});
|
|
592
|
+
const runs = [];
|
|
593
|
+
try {
|
|
594
|
+
await storage.run(root, async () => {
|
|
595
|
+
try {
|
|
596
|
+
const firstRun = Promise.resolve(Scope.provide(firstScope, async () => {
|
|
597
|
+
firstStarted();
|
|
598
|
+
await firstMayFinish;
|
|
599
|
+
return storage.current();
|
|
600
|
+
}));
|
|
601
|
+
runs.push(firstRun);
|
|
602
|
+
const secondRun = Promise.resolve(Scope.provide(secondScope, async () => {
|
|
603
|
+
secondStarted();
|
|
604
|
+
await secondMayFinish;
|
|
605
|
+
return storage.current();
|
|
606
|
+
}));
|
|
607
|
+
runs.push(secondRun);
|
|
608
|
+
await Promise.all([firstStartedPromise, secondStartedPromise]);
|
|
609
|
+
releaseFirst();
|
|
610
|
+
releaseSecond();
|
|
611
|
+
const [firstContext, secondContext] = await Promise.all([firstRun, secondRun]);
|
|
612
|
+
assert(firstContext.scope === firstScope && secondContext.scope === secondScope, "RuntimeContextStorage isolates concurrent derived siblings", "overlapping derived siblings observed another Scope context");
|
|
613
|
+
assert(storage.current() === root, "RuntimeContextStorage isolates concurrent derived siblings", "root context was not restored after concurrent derived siblings settled");
|
|
614
|
+
} finally {
|
|
615
|
+
releaseFirst();
|
|
616
|
+
releaseSecond();
|
|
617
|
+
await Promise.allSettled(runs);
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
} finally {
|
|
621
|
+
releaseFirst();
|
|
622
|
+
releaseSecond();
|
|
623
|
+
await Promise.allSettled(runs);
|
|
624
|
+
await closeScopes(firstScope, secondScope, rootScope);
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
}));
|
|
628
|
+
else scenarios.push(layerScenario("RuntimeContextStorage rejects overlap and remains reusable", async () => {
|
|
629
|
+
await withStorage(options, async (storage) => {
|
|
630
|
+
const firstScope = Scope.make();
|
|
631
|
+
const secondScope = Scope.make();
|
|
632
|
+
const first = makeContext(firstScope, makeResolver());
|
|
633
|
+
const second = makeContext(secondScope, makeResolver());
|
|
634
|
+
let releaseFirst;
|
|
635
|
+
let firstStarted;
|
|
636
|
+
const firstMayFinish = new Promise((resolve) => {
|
|
637
|
+
releaseFirst = resolve;
|
|
638
|
+
});
|
|
639
|
+
const firstStartedPromise = new Promise((resolve) => {
|
|
640
|
+
firstStarted = resolve;
|
|
641
|
+
});
|
|
642
|
+
const runs = [];
|
|
643
|
+
try {
|
|
644
|
+
const firstRun = Promise.resolve(storage.run(first, async () => {
|
|
645
|
+
firstStarted();
|
|
646
|
+
await firstMayFinish;
|
|
647
|
+
return storage.current();
|
|
648
|
+
}));
|
|
649
|
+
runs.push(firstRun);
|
|
650
|
+
await firstStartedPromise;
|
|
651
|
+
const overlapCause = await rejected(Promise.resolve().then(() => storage.run(second, () => storage.current())), "RuntimeContextStorage rejects overlap and remains reusable", "an overlapping root run");
|
|
652
|
+
expectOverlap(overlapCause, options, "RuntimeContextStorage rejects overlap and remains reusable");
|
|
653
|
+
releaseFirst();
|
|
654
|
+
assert(await firstRun === first, "RuntimeContextStorage rejects overlap and remains reusable", "the first context did not remain intact after rejected overlap");
|
|
655
|
+
assert(storage.run(second, () => storage.current()) === second, "RuntimeContextStorage rejects overlap and remains reusable", "storage was not reusable after rejected overlap");
|
|
656
|
+
} finally {
|
|
657
|
+
releaseFirst();
|
|
658
|
+
await Promise.allSettled(runs);
|
|
659
|
+
await closeScopes(firstScope, secondScope);
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
}), layerScenario("RuntimeContextStorage rejects overlapping derived siblings and remains reusable", async () => {
|
|
663
|
+
await withStorage(options, async (storage) => {
|
|
664
|
+
const rootScope = Scope.make();
|
|
665
|
+
const firstScope = rootScope.fork();
|
|
666
|
+
const secondScope = rootScope.fork();
|
|
667
|
+
const root = makeContext(rootScope, makeResolver());
|
|
668
|
+
let releaseFirst;
|
|
669
|
+
let firstStarted;
|
|
670
|
+
const firstMayFinish = new Promise((resolve) => {
|
|
671
|
+
releaseFirst = resolve;
|
|
672
|
+
});
|
|
673
|
+
const firstStartedPromise = new Promise((resolve) => {
|
|
674
|
+
firstStarted = resolve;
|
|
675
|
+
});
|
|
676
|
+
const runs = [];
|
|
677
|
+
try {
|
|
678
|
+
await storage.run(root, async () => {
|
|
679
|
+
try {
|
|
680
|
+
const firstRun = Promise.resolve(Scope.provide(firstScope, async () => {
|
|
681
|
+
firstStarted();
|
|
682
|
+
await firstMayFinish;
|
|
683
|
+
return storage.current();
|
|
684
|
+
}));
|
|
685
|
+
runs.push(firstRun);
|
|
686
|
+
await firstStartedPromise;
|
|
687
|
+
let secondRan = false;
|
|
688
|
+
const overlapCause = await rejected(Promise.resolve().then(() => Scope.provide(secondScope, () => {
|
|
689
|
+
secondRan = true;
|
|
690
|
+
return storage.current();
|
|
691
|
+
})), "RuntimeContextStorage rejects overlapping derived siblings and remains reusable", "an overlapping derived sibling run");
|
|
692
|
+
expectOverlap(overlapCause, options, "RuntimeContextStorage rejects overlapping derived siblings and remains reusable");
|
|
693
|
+
assert(!secondRan, "RuntimeContextStorage rejects overlapping derived siblings and remains reusable", "the rejected derived sibling callback ran");
|
|
694
|
+
releaseFirst();
|
|
695
|
+
const firstContext = await firstRun;
|
|
696
|
+
assert(firstContext.scope === firstScope, "RuntimeContextStorage rejects overlapping derived siblings and remains reusable", "the first derived sibling context changed after rejected overlap");
|
|
697
|
+
assert(storage.current() === root, "RuntimeContextStorage rejects overlapping derived siblings and remains reusable", "root context was not restored after the first derived sibling settled");
|
|
698
|
+
} finally {
|
|
699
|
+
releaseFirst();
|
|
700
|
+
await Promise.allSettled(runs);
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
const reusable = makeContext(secondScope, makeResolver());
|
|
704
|
+
assert(storage.run(reusable, () => storage.current()) === reusable, "RuntimeContextStorage rejects overlapping derived siblings and remains reusable", "storage was not reusable after rejected derived sibling overlap");
|
|
705
|
+
} finally {
|
|
706
|
+
releaseFirst();
|
|
707
|
+
await Promise.allSettled(runs);
|
|
708
|
+
await closeScopes(firstScope, secondScope, rootScope);
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
}));
|
|
712
|
+
const makeCompanionStorage = options.makeCompanionStorage;
|
|
713
|
+
if (makeCompanionStorage) scenarios.push(layerScenario("RuntimeContextStorage interoperates without leaking frames", async () => {
|
|
714
|
+
await withStorage(options, async (storage) => {
|
|
715
|
+
const companion = makeCompanionStorage();
|
|
716
|
+
const outerScope = Scope.make();
|
|
717
|
+
const innerScope = Scope.make();
|
|
718
|
+
const outer = makeContext(outerScope, makeResolver());
|
|
719
|
+
const inner = makeContext(innerScope, makeResolver());
|
|
720
|
+
try {
|
|
721
|
+
await storage.run(outer, async () => {
|
|
722
|
+
await companion.run(inner, async () => {
|
|
723
|
+
assert(companion.current() === inner, "RuntimeContextStorage interoperates without leaking frames", "companion storage did not expose its own context");
|
|
724
|
+
await Promise.resolve();
|
|
725
|
+
assert(companion.current() === inner, "RuntimeContextStorage interoperates without leaking frames", "companion storage lost its context after Promise suspension");
|
|
726
|
+
});
|
|
727
|
+
assert(storage.current() === outer, "RuntimeContextStorage interoperates without leaking frames", "companion storage leaked its frame into the primary storage");
|
|
728
|
+
expectMissingContext(await rejected(Promise.resolve().then(() => companion.current()), "RuntimeContextStorage interoperates without leaking frames", "companion current() after its run() settled"), options, "RuntimeContextStorage interoperates without leaking frames");
|
|
729
|
+
});
|
|
730
|
+
expectMissingContext(await rejected(Promise.resolve().then(() => storage.current()), "RuntimeContextStorage interoperates without leaking frames", "primary current() after its run() settled"), options, "RuntimeContextStorage interoperates without leaking frames");
|
|
731
|
+
} finally {
|
|
732
|
+
try {
|
|
733
|
+
await closeScopes(outerScope, innerScope);
|
|
734
|
+
} finally {
|
|
735
|
+
await options.cleanup?.(companion);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
}));
|
|
740
|
+
return scenarios;
|
|
741
|
+
};
|
|
742
|
+
//#endregion
|
|
743
|
+
//#region src/testing/recorded-runtime-observer.ts
|
|
744
|
+
const immutableView = (events) => Object.freeze([...events]);
|
|
745
|
+
/** Records Runtime lifecycle events for assertions in tests. */
|
|
746
|
+
var RecordedRuntimeObserver = class RecordedRuntimeObserver {
|
|
747
|
+
serviceResolutionEvents = [];
|
|
748
|
+
serviceAcquisitionEvents = [];
|
|
749
|
+
executionStartEvents = [];
|
|
750
|
+
executionEndEvents = [];
|
|
751
|
+
resourceReleaseEvents = [];
|
|
752
|
+
timelineEvents = [];
|
|
753
|
+
static make() {
|
|
754
|
+
return new RecordedRuntimeObserver();
|
|
755
|
+
}
|
|
756
|
+
onServiceResolve = (event) => {
|
|
757
|
+
this.record(this.serviceResolutionEvents, event);
|
|
758
|
+
};
|
|
759
|
+
onServiceAcquire = (event) => {
|
|
760
|
+
this.record(this.serviceAcquisitionEvents, event);
|
|
761
|
+
};
|
|
762
|
+
onExecutionStart = (event) => {
|
|
763
|
+
this.record(this.executionStartEvents, event);
|
|
764
|
+
};
|
|
765
|
+
onExecutionEnd = (event) => {
|
|
766
|
+
this.record(this.executionEndEvents, event);
|
|
767
|
+
};
|
|
768
|
+
onResourceRelease = (event) => {
|
|
769
|
+
this.record(this.resourceReleaseEvents, event);
|
|
770
|
+
};
|
|
771
|
+
get serviceResolutions() {
|
|
772
|
+
return immutableView(this.serviceResolutionEvents);
|
|
773
|
+
}
|
|
774
|
+
get serviceAcquisitions() {
|
|
775
|
+
return immutableView(this.serviceAcquisitionEvents);
|
|
776
|
+
}
|
|
777
|
+
get executionStarts() {
|
|
778
|
+
return immutableView(this.executionStartEvents);
|
|
779
|
+
}
|
|
780
|
+
get executionEnds() {
|
|
781
|
+
return immutableView(this.executionEndEvents);
|
|
782
|
+
}
|
|
783
|
+
get resourceReleases() {
|
|
784
|
+
return immutableView(this.resourceReleaseEvents);
|
|
785
|
+
}
|
|
786
|
+
get timeline() {
|
|
787
|
+
return immutableView(this.timelineEvents);
|
|
788
|
+
}
|
|
789
|
+
clear() {
|
|
790
|
+
this.serviceResolutionEvents.length = 0;
|
|
791
|
+
this.serviceAcquisitionEvents.length = 0;
|
|
792
|
+
this.executionStartEvents.length = 0;
|
|
793
|
+
this.executionEndEvents.length = 0;
|
|
794
|
+
this.resourceReleaseEvents.length = 0;
|
|
795
|
+
this.timelineEvents.length = 0;
|
|
796
|
+
}
|
|
797
|
+
snapshot() {
|
|
798
|
+
return Object.freeze({
|
|
799
|
+
serviceResolutions: this.serviceResolutions,
|
|
800
|
+
serviceAcquisitions: this.serviceAcquisitions,
|
|
801
|
+
executionStarts: this.executionStarts,
|
|
802
|
+
executionEnds: this.executionEnds,
|
|
803
|
+
resourceReleases: this.resourceReleases,
|
|
804
|
+
timeline: this.timeline
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
record(events, event) {
|
|
808
|
+
events.push(event);
|
|
809
|
+
this.timelineEvents.push(event);
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
//#endregion
|
|
813
|
+
//#region src/testing/runtime-graph-observer.ts
|
|
814
|
+
const compareStrings = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
815
|
+
const compareEdges = (left, right) => compareStrings(left.from, right.from) || compareStrings(left.to, right.to);
|
|
816
|
+
const freezeNode = (node) => Object.freeze({ ...node });
|
|
817
|
+
const freezeEdge = (edge) => Object.freeze({ ...edge });
|
|
818
|
+
const serviceTags = (path, targetTag) => {
|
|
819
|
+
const tags = path.map((service) => service.serviceTag);
|
|
820
|
+
if (tags[tags.length - 1] !== targetTag) tags.push(targetTag);
|
|
821
|
+
return tags;
|
|
822
|
+
};
|
|
823
|
+
const mermaidLabelPunctuation = /* @__PURE__ */ new Set([
|
|
824
|
+
"&",
|
|
825
|
+
"\"",
|
|
826
|
+
"'",
|
|
827
|
+
"\\",
|
|
828
|
+
"[",
|
|
829
|
+
"]",
|
|
830
|
+
"(",
|
|
831
|
+
")",
|
|
832
|
+
"{",
|
|
833
|
+
"}",
|
|
834
|
+
"|",
|
|
835
|
+
"#",
|
|
836
|
+
";",
|
|
837
|
+
":",
|
|
838
|
+
"<",
|
|
839
|
+
">",
|
|
840
|
+
"=",
|
|
841
|
+
"`",
|
|
842
|
+
"*",
|
|
843
|
+
"_",
|
|
844
|
+
"~",
|
|
845
|
+
"!",
|
|
846
|
+
"^",
|
|
847
|
+
"$"
|
|
848
|
+
]);
|
|
849
|
+
const isMermaidLabelControl = (character, codePoint) => codePoint <= 31 || codePoint >= 127 && codePoint <= 159 || codePoint >= 55296 && codePoint <= 57343 || /\s/u.test(character) && character !== " " || /\p{Cf}/u.test(character);
|
|
850
|
+
/**
|
|
851
|
+
* Escape Mermaid syntax while keeping the decoded label readable.
|
|
852
|
+
*
|
|
853
|
+
* Mermaid's documented `#number;` form is a syntax-level escape. Encoding the
|
|
854
|
+
* entity introducers themselves prevents label text from creating a second
|
|
855
|
+
* entity, while encoding Markdown and HTML punctuation keeps it out of the
|
|
856
|
+
* renderer's interpretation paths.
|
|
857
|
+
*/
|
|
858
|
+
const escapeMermaidLabel = (label) => {
|
|
859
|
+
let escaped = "";
|
|
860
|
+
for (const character of label) {
|
|
861
|
+
const codePoint = character.codePointAt(0);
|
|
862
|
+
if (codePoint === void 0) continue;
|
|
863
|
+
if (isMermaidLabelControl(character, codePoint) || mermaidLabelPunctuation.has(character)) escaped += `#${codePoint};`;
|
|
864
|
+
else escaped += character;
|
|
865
|
+
}
|
|
866
|
+
return escaped;
|
|
867
|
+
};
|
|
868
|
+
/** Encode a string as a Mermaid-safe, injective and registration-order-independent ID. */
|
|
869
|
+
const mermaidId = (prefix, value) => {
|
|
870
|
+
const codeUnits = [];
|
|
871
|
+
for (let index = 0; index < value.length; index += 1) codeUnits.push(value.charCodeAt(index).toString(16).padStart(4, "0"));
|
|
872
|
+
return `${prefix}${codeUnits.join("_") || "empty"}`;
|
|
873
|
+
};
|
|
874
|
+
/**
|
|
875
|
+
* Observe the public Runtime resolution and acquisition events as a diagnostic graph.
|
|
876
|
+
*
|
|
877
|
+
* The graph is deliberately an observation of paths emitted by Runtime. It does
|
|
878
|
+
* not inspect Layer provenance, retain Service instances, or plan dependencies.
|
|
879
|
+
*/
|
|
880
|
+
var RuntimeGraphObserver = class RuntimeGraphObserver {
|
|
881
|
+
includeFailures;
|
|
882
|
+
rootLabel;
|
|
883
|
+
nodes = /* @__PURE__ */ new Map();
|
|
884
|
+
edges = /* @__PURE__ */ new Map();
|
|
885
|
+
rootEdges = /* @__PURE__ */ new Map();
|
|
886
|
+
constructor(includeFailures, rootLabel) {
|
|
887
|
+
this.includeFailures = includeFailures;
|
|
888
|
+
this.rootLabel = rootLabel;
|
|
889
|
+
}
|
|
890
|
+
/** Create an empty Runtime graph observer. */
|
|
891
|
+
static make(options = {}) {
|
|
892
|
+
if (typeof options !== "object" || options === null) throw new TypeError("RuntimeGraphObserver options must be an object");
|
|
893
|
+
if (options.includeFailures !== void 0 && typeof options.includeFailures !== "boolean") throw new TypeError("RuntimeGraphObserver includeFailures must be a boolean");
|
|
894
|
+
if (options.rootLabel !== void 0 && typeof options.rootLabel !== "string") throw new TypeError("RuntimeGraphObserver rootLabel must be a string");
|
|
895
|
+
return new RuntimeGraphObserver(options.includeFailures ?? true, options.rootLabel);
|
|
896
|
+
}
|
|
897
|
+
onServiceResolve = (event) => {
|
|
898
|
+
const targetTag = event.service.serviceTag;
|
|
899
|
+
const path = serviceTags(event.resolutionPath, targetTag);
|
|
900
|
+
const node = this.ensureNode(targetTag);
|
|
901
|
+
this.ensurePath(path);
|
|
902
|
+
node.resolutions += 1;
|
|
903
|
+
if (event.outcome.status === "failure" && this.includeFailures) node.failures += 1;
|
|
904
|
+
const parentTag = path[path.length - 2];
|
|
905
|
+
if (parentTag === void 0) {
|
|
906
|
+
if (this.rootLabel !== void 0) this.rootEdges.set(targetTag, (this.rootEdges.get(targetTag) ?? 0) + 1);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
const targets = this.edges.get(parentTag) ?? /* @__PURE__ */ new Map();
|
|
910
|
+
const edge = targets.get(targetTag) ?? (() => {
|
|
911
|
+
const created = {
|
|
912
|
+
from: parentTag,
|
|
913
|
+
to: targetTag,
|
|
914
|
+
resolutions: 0
|
|
915
|
+
};
|
|
916
|
+
targets.set(targetTag, created);
|
|
917
|
+
return created;
|
|
918
|
+
})();
|
|
919
|
+
edge.resolutions += 1;
|
|
920
|
+
this.edges.set(parentTag, targets);
|
|
921
|
+
};
|
|
922
|
+
onServiceAcquire = (event) => {
|
|
923
|
+
const targetTag = event.service.serviceTag;
|
|
924
|
+
const path = serviceTags(event.resolutionPath, targetTag);
|
|
925
|
+
const node = this.ensureNode(targetTag);
|
|
926
|
+
this.ensurePath(path);
|
|
927
|
+
node.acquisitions += 1;
|
|
928
|
+
if (event.outcome.status === "failure" && this.includeFailures) node.failures += 1;
|
|
929
|
+
};
|
|
930
|
+
/** Return a fresh, deeply detached and sorted graph snapshot. */
|
|
931
|
+
toJSON() {
|
|
932
|
+
const nodes = Object.freeze([...this.nodes.entries()].sort(([left], [right]) => compareStrings(left, right)).map(([tag, counters]) => freezeNode({
|
|
933
|
+
tag,
|
|
934
|
+
resolutions: counters.resolutions,
|
|
935
|
+
acquisitions: counters.acquisitions,
|
|
936
|
+
failures: counters.failures
|
|
937
|
+
})));
|
|
938
|
+
const edges = Object.freeze([...this.edges.values()].flatMap((targets) => [...targets.values()]).map((edge) => freezeEdge({ ...edge })).sort(compareEdges));
|
|
939
|
+
return Object.freeze({
|
|
940
|
+
nodes,
|
|
941
|
+
edges
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
/** Render the current graph as deterministic Mermaid flowchart source. */
|
|
945
|
+
toMermaid() {
|
|
946
|
+
const snapshot = this.toJSON();
|
|
947
|
+
const serviceIds = new Map(snapshot.nodes.map((node) => [node.tag, mermaidId("service_", node.tag)]));
|
|
948
|
+
const lines = ["flowchart TD"];
|
|
949
|
+
const rootTargets = [...this.rootEdges.entries()].sort(([left], [right]) => compareStrings(left, right));
|
|
950
|
+
const root = this.rootLabel !== void 0 && rootTargets.length > 0 ? {
|
|
951
|
+
id: mermaidId("root_", this.rootLabel),
|
|
952
|
+
label: this.rootLabel
|
|
953
|
+
} : void 0;
|
|
954
|
+
if (root !== void 0) lines.push(` ${root.id}["${escapeMermaidLabel(root.label)}"]`);
|
|
955
|
+
for (const node of snapshot.nodes) {
|
|
956
|
+
const id = serviceIds.get(node.tag);
|
|
957
|
+
if (id !== void 0) lines.push(` ${id}["${escapeMermaidLabel(node.tag)}"]`);
|
|
958
|
+
}
|
|
959
|
+
if (root !== void 0) for (const [targetTag, resolutions] of rootTargets) {
|
|
960
|
+
const targetId = serviceIds.get(targetTag);
|
|
961
|
+
if (targetId !== void 0) lines.push(` ${root.id} -->|${resolutions}| ${targetId}`);
|
|
962
|
+
}
|
|
963
|
+
for (const edge of snapshot.edges) {
|
|
964
|
+
const fromId = serviceIds.get(edge.from);
|
|
965
|
+
const toId = serviceIds.get(edge.to);
|
|
966
|
+
if (fromId !== void 0 && toId !== void 0) lines.push(` ${fromId} -->|${edge.resolutions}| ${toId}`);
|
|
967
|
+
}
|
|
968
|
+
return lines.join("\n");
|
|
969
|
+
}
|
|
970
|
+
/** Discard all observed graph data while keeping this observer reusable. */
|
|
971
|
+
clear() {
|
|
972
|
+
this.nodes.clear();
|
|
973
|
+
this.edges.clear();
|
|
974
|
+
this.rootEdges.clear();
|
|
975
|
+
}
|
|
976
|
+
ensureNode(tag) {
|
|
977
|
+
const existing = this.nodes.get(tag);
|
|
978
|
+
if (existing !== void 0) return existing;
|
|
979
|
+
const node = {
|
|
980
|
+
resolutions: 0,
|
|
981
|
+
acquisitions: 0,
|
|
982
|
+
failures: 0
|
|
983
|
+
};
|
|
984
|
+
this.nodes.set(tag, node);
|
|
985
|
+
return node;
|
|
986
|
+
}
|
|
987
|
+
ensurePath(path) {
|
|
988
|
+
for (const tag of path) this.ensureNode(tag);
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
//#endregion
|
|
992
|
+
//#region src/testing/test-runtime.ts
|
|
993
|
+
/**
|
|
994
|
+
* Thrown when the TestRuntime recorder contains an execution start or end
|
|
995
|
+
* event without its matching event at final disposal.
|
|
996
|
+
*/
|
|
997
|
+
var TestRuntimeObserverError = class extends Error {
|
|
998
|
+
unmatchedStarts;
|
|
999
|
+
unmatchedEnds;
|
|
1000
|
+
constructor(unmatchedStarts, unmatchedEnds) {
|
|
1001
|
+
const startLabel = `${unmatchedStarts.length} start${unmatchedStarts.length === 1 ? "" : "s"}`;
|
|
1002
|
+
const endLabel = `${unmatchedEnds.length} end${unmatchedEnds.length === 1 ? "" : "s"}`;
|
|
1003
|
+
super(`TestRuntime found unmatched execution events: ${startLabel} without an end, ${endLabel} without a start`);
|
|
1004
|
+
this.name = "TestRuntimeObserverError";
|
|
1005
|
+
this.unmatchedStarts = Object.freeze([...unmatchedStarts]);
|
|
1006
|
+
this.unmatchedEnds = Object.freeze([...unmatchedEnds]);
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
/**
|
|
1010
|
+
* A disposable test boundary backed by the real Layer and Runtime
|
|
1011
|
+
* implementations.
|
|
1012
|
+
*/
|
|
1013
|
+
var TestRuntime = class TestRuntime {
|
|
1014
|
+
runtime;
|
|
1015
|
+
observer;
|
|
1016
|
+
clock;
|
|
1017
|
+
logger;
|
|
1018
|
+
random;
|
|
1019
|
+
idGenerator;
|
|
1020
|
+
disposalPromise;
|
|
1021
|
+
constructor(runtime, observer, clock, logger, random, idGenerator) {
|
|
1022
|
+
this.runtime = runtime;
|
|
1023
|
+
this.observer = observer;
|
|
1024
|
+
this.clock = clock;
|
|
1025
|
+
this.logger = logger;
|
|
1026
|
+
this.random = random;
|
|
1027
|
+
this.idGenerator = idGenerator;
|
|
1028
|
+
}
|
|
1029
|
+
static async make(layer, options) {
|
|
1030
|
+
const resolvedOptions = options === void 0 ? {} : options;
|
|
1031
|
+
return TestRuntime.makeInternal(layer, resolvedOptions);
|
|
1032
|
+
}
|
|
1033
|
+
static async makeInternal(layer, options) {
|
|
1034
|
+
const observer = RecordedRuntimeObserver.make();
|
|
1035
|
+
const composedLayer = composeTestLayer(layer, options);
|
|
1036
|
+
const runtimeOptions = {
|
|
1037
|
+
...options,
|
|
1038
|
+
backend: options.backend ?? new MapLayerBackend(),
|
|
1039
|
+
observers: [observer, ...options.observers ?? []]
|
|
1040
|
+
};
|
|
1041
|
+
const typedLayer = composedLayer;
|
|
1042
|
+
const runtime = await Runtime.make(typedLayer, runtimeOptions);
|
|
1043
|
+
return new TestRuntime(runtime, observer, options.clock, options.logger, options.random, options.idGenerator);
|
|
1044
|
+
}
|
|
1045
|
+
/** Run a fully typechecked program in the underlying Runtime. */
|
|
1046
|
+
run(program, options) {
|
|
1047
|
+
return this.runtime.run(program, options);
|
|
1048
|
+
}
|
|
1049
|
+
/** Run a fully typechecked program with an execution-local Layer. */
|
|
1050
|
+
runWith(layer, program, options) {
|
|
1051
|
+
return this.runtime.runWith(layer, program, options);
|
|
1052
|
+
}
|
|
1053
|
+
/** Dispose the underlying Runtime and perform the final recorder consistency check. */
|
|
1054
|
+
dispose(options) {
|
|
1055
|
+
return this.disposeWithInput(options);
|
|
1056
|
+
}
|
|
1057
|
+
/** Release this TestRuntime through JavaScript's async disposal protocol. */
|
|
1058
|
+
[Symbol.asyncDispose]() {
|
|
1059
|
+
return this.dispose();
|
|
1060
|
+
}
|
|
1061
|
+
disposeWithOutcome(outcome) {
|
|
1062
|
+
return this.disposeWithInput(outcome);
|
|
1063
|
+
}
|
|
1064
|
+
disposeWithInput(input) {
|
|
1065
|
+
if (this.disposalPromise !== void 0) return this.disposalPromise;
|
|
1066
|
+
const disposal = input === void 0 ? this.runtime.dispose() : isScopeOutcome(input) ? this.runtime.dispose(input) : this.runtime.dispose(input);
|
|
1067
|
+
this.disposalPromise = disposal.then(() => this.assertObserverConsistency(), (cause) => {
|
|
1068
|
+
const observerFailure = this.observerConsistencyFailure();
|
|
1069
|
+
if (observerFailure === void 0) throw cause;
|
|
1070
|
+
const causes = cause instanceof LayerDisposeError ? cause.causes : [cause];
|
|
1071
|
+
throw new LayerDisposeError([...causes, observerFailure]);
|
|
1072
|
+
});
|
|
1073
|
+
return this.disposalPromise;
|
|
1074
|
+
}
|
|
1075
|
+
assertObserverConsistency() {
|
|
1076
|
+
const failure = this.observerConsistencyFailure();
|
|
1077
|
+
if (failure !== void 0) throw failure;
|
|
1078
|
+
}
|
|
1079
|
+
observerConsistencyFailure() {
|
|
1080
|
+
const unmatched = findUnmatchedExecutionEvents(this.observer.executionStarts, this.observer.executionEnds);
|
|
1081
|
+
if (unmatched.starts.length === 0 && unmatched.ends.length === 0) return;
|
|
1082
|
+
return new TestRuntimeObserverError(unmatched.starts, unmatched.ends);
|
|
1083
|
+
}
|
|
1084
|
+
static async use(layer, optionsOrUse, maybeUse) {
|
|
1085
|
+
let use;
|
|
1086
|
+
let resolvedOptions;
|
|
1087
|
+
if (typeof optionsOrUse === "function") {
|
|
1088
|
+
use = optionsOrUse;
|
|
1089
|
+
resolvedOptions = {};
|
|
1090
|
+
} else {
|
|
1091
|
+
use = maybeUse;
|
|
1092
|
+
resolvedOptions = optionsOrUse;
|
|
1093
|
+
}
|
|
1094
|
+
if (use === void 0) throw new TypeError("TestRuntime.use requires a callback");
|
|
1095
|
+
const test = await TestRuntime.makeInternal(layer, resolvedOptions);
|
|
1096
|
+
let value;
|
|
1097
|
+
let executionFailed = false;
|
|
1098
|
+
let executionFailure;
|
|
1099
|
+
let outcome;
|
|
1100
|
+
try {
|
|
1101
|
+
value = await use(test);
|
|
1102
|
+
outcome = classifyRuntimeOutcome(value);
|
|
1103
|
+
} catch (cause) {
|
|
1104
|
+
executionFailed = true;
|
|
1105
|
+
executionFailure = cause;
|
|
1106
|
+
outcome = {
|
|
1107
|
+
status: "failure",
|
|
1108
|
+
cause
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
try {
|
|
1112
|
+
await test.disposeWithOutcome(outcome);
|
|
1113
|
+
} catch (cleanupFailure) {
|
|
1114
|
+
if (!executionFailed && outcome.status === "success") throw cleanupFailure;
|
|
1115
|
+
}
|
|
1116
|
+
if (executionFailed) throw executionFailure;
|
|
1117
|
+
return value;
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
1120
|
+
const isScopeOutcome = (input) => "status" in input;
|
|
1121
|
+
const composeTestLayer = (layer, options) => {
|
|
1122
|
+
const overrides = [...options.overrides ?? []];
|
|
1123
|
+
if (options.clock !== void 0) overrides.push(Layer.succeed(Clock, options.clock));
|
|
1124
|
+
if (options.logger !== void 0) overrides.push(Layer.succeed(Logger, options.logger));
|
|
1125
|
+
if (options.random !== void 0) overrides.push(Layer.succeed(Random, options.random));
|
|
1126
|
+
if (options.idGenerator !== void 0) overrides.push(Layer.succeed(IdGenerator, options.idGenerator));
|
|
1127
|
+
const typedLayer = layer;
|
|
1128
|
+
const typedOverrides = overrides;
|
|
1129
|
+
return Layer.override(typedLayer, ...typedOverrides);
|
|
1130
|
+
};
|
|
1131
|
+
const findUnmatchedExecutionEvents = (starts, ends) => {
|
|
1132
|
+
const endCounts = countExecutionScopes(ends);
|
|
1133
|
+
const unmatchedStarts = [];
|
|
1134
|
+
for (const event of starts) {
|
|
1135
|
+
const count = endCounts.get(event.scope) ?? 0;
|
|
1136
|
+
if (count === 0) unmatchedStarts.push(event);
|
|
1137
|
+
else endCounts.set(event.scope, count - 1);
|
|
1138
|
+
}
|
|
1139
|
+
const startCounts = countExecutionScopes(starts);
|
|
1140
|
+
const unmatchedEnds = [];
|
|
1141
|
+
for (const event of ends) {
|
|
1142
|
+
const count = startCounts.get(event.scope) ?? 0;
|
|
1143
|
+
if (count === 0) unmatchedEnds.push(event);
|
|
1144
|
+
else startCounts.set(event.scope, count - 1);
|
|
1145
|
+
}
|
|
1146
|
+
return {
|
|
1147
|
+
starts: unmatchedStarts,
|
|
1148
|
+
ends: unmatchedEnds
|
|
1149
|
+
};
|
|
1150
|
+
};
|
|
1151
|
+
const countExecutionScopes = (events) => {
|
|
1152
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1153
|
+
for (const event of events) counts.set(event.scope, (counts.get(event.scope) ?? 0) + 1);
|
|
1154
|
+
return counts;
|
|
1155
|
+
};
|
|
1156
|
+
//#endregion
|
|
1157
|
+
export { ClockTest, ConformanceError, IdGeneratorTest, LoggerTest, MapLayerBackend as MemoryLayerBackend, RandomSeeded, RecordedRuntimeObserver, RuntimeGraphObserver, RuntimeObserver, TestRuntime, TestRuntimeObserverError, layerBackendContract, runtimeContextStorageContract };
|
|
1158
|
+
|
|
1159
|
+
//# sourceMappingURL=testing.mjs.map
|