definitely-fine 0.1.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 +281 -0
- package/dist/index.d.ts +604 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +729 -0
- package/dist/index.js.map +1 -0
- package/package.json +29 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, parse, relative } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
//#region src/runtime-scenario-context.ts
|
|
9
|
+
const runtimeScenarioContextStorage = new AsyncLocalStorage();
|
|
10
|
+
/**
|
|
11
|
+
* Runs a callback inside a request-scoped runtime scenario context.
|
|
12
|
+
* @public
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* runWithRuntimeScenarioContext({ scenarioId: "checkout" }, () => {
|
|
17
|
+
* return getRuntimeScenarioId();
|
|
18
|
+
* });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
function runWithRuntimeScenarioContext(context, callback) {
|
|
22
|
+
return runtimeScenarioContextStorage.run(context, callback);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Returns the active runtime scenario id for the current async scope.
|
|
26
|
+
* @public
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```ts
|
|
30
|
+
* const scenarioId = getRuntimeScenarioId();
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
function getRuntimeScenarioId() {
|
|
34
|
+
return runtimeScenarioContextStorage.getStore()?.scenarioId;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/services/utils/get-scenario-file-path.ts
|
|
39
|
+
/**
|
|
40
|
+
* Builds the persisted JSON file path for a scenario id.
|
|
41
|
+
* @internal
|
|
42
|
+
*/
|
|
43
|
+
function getScenarioFilePath(directory, scenarioId) {
|
|
44
|
+
return join(directory, `${scenarioId}.json`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/services/impls/JsonScenarioStorageAdapter.ts
|
|
49
|
+
function createJsonScenarioStorageAdapterError(message, cause) {
|
|
50
|
+
return new Error(message, { cause });
|
|
51
|
+
}
|
|
52
|
+
function findNodeModulesDirectory(startPath) {
|
|
53
|
+
let currentPath = dirname(startPath);
|
|
54
|
+
const { root } = parse(currentPath);
|
|
55
|
+
while (currentPath !== root) {
|
|
56
|
+
if (basename(currentPath) === "node_modules") return currentPath;
|
|
57
|
+
currentPath = dirname(currentPath);
|
|
58
|
+
}
|
|
59
|
+
if (basename(currentPath) === "node_modules") return currentPath;
|
|
60
|
+
return void 0;
|
|
61
|
+
}
|
|
62
|
+
function isDirectory(path) {
|
|
63
|
+
try {
|
|
64
|
+
return statSync(path).isDirectory();
|
|
65
|
+
} catch (_error) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function findInstalledPackageNodeModulesDirectory(packageRootPath) {
|
|
70
|
+
let currentPath = process.cwd();
|
|
71
|
+
const { root } = parse(currentPath);
|
|
72
|
+
while (currentPath !== root) {
|
|
73
|
+
const nodeModulesDirectory$1 = join(currentPath, "node_modules");
|
|
74
|
+
const installedPackageDirectory$1 = join(nodeModulesDirectory$1, "definitely-fine");
|
|
75
|
+
if (isDirectory(installedPackageDirectory$1) && realpathSync(installedPackageDirectory$1) === packageRootPath) return nodeModulesDirectory$1;
|
|
76
|
+
currentPath = dirname(currentPath);
|
|
77
|
+
}
|
|
78
|
+
const nodeModulesDirectory = join(currentPath, "node_modules");
|
|
79
|
+
const installedPackageDirectory = join(nodeModulesDirectory, "definitely-fine");
|
|
80
|
+
if (isDirectory(installedPackageDirectory) && realpathSync(installedPackageDirectory) === packageRootPath) return nodeModulesDirectory;
|
|
81
|
+
return void 0;
|
|
82
|
+
}
|
|
83
|
+
function isPathInsideDirectory(path, directory) {
|
|
84
|
+
const relativePath = relative(directory, path);
|
|
85
|
+
return relativePath === "" || !relativePath.startsWith("..") && !isAbsolute(relativePath);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Resolves the default directory used by the built-in JSON scenario adapter.
|
|
89
|
+
* @public
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```ts
|
|
93
|
+
* import { resolveJsonScenarioStorageAdapterDirectory } from "definitely-fine";
|
|
94
|
+
*
|
|
95
|
+
* const directory = resolveJsonScenarioStorageAdapterDirectory({
|
|
96
|
+
* directory: ".definitely-fine",
|
|
97
|
+
* });
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
function resolveJsonScenarioStorageAdapterDirectory(options) {
|
|
101
|
+
if (options.directory !== void 0) return options.directory;
|
|
102
|
+
const unresolvedModuleFilePath = fileURLToPath(options.moduleUrl ?? import.meta.url);
|
|
103
|
+
const symlinkedNodeModulesDirectory = findNodeModulesDirectory(unresolvedModuleFilePath);
|
|
104
|
+
if (symlinkedNodeModulesDirectory !== void 0) return join(symlinkedNodeModulesDirectory, ".cache", "definitely-fine", "scenarios");
|
|
105
|
+
const moduleFilePath = realpathSync(unresolvedModuleFilePath);
|
|
106
|
+
const nodeModulesDirectory = findNodeModulesDirectory(moduleFilePath);
|
|
107
|
+
if (nodeModulesDirectory !== void 0) return join(nodeModulesDirectory, ".cache", "definitely-fine", "scenarios");
|
|
108
|
+
if (options.moduleUrl === void 0 && !isPathInsideDirectory(moduleFilePath, process.cwd())) {
|
|
109
|
+
const workingDirectoryNodeModulesDirectory = findInstalledPackageNodeModulesDirectory(dirname(dirname(moduleFilePath)));
|
|
110
|
+
if (workingDirectoryNodeModulesDirectory !== void 0) return join(workingDirectoryNodeModulesDirectory, ".cache", "definitely-fine", "scenarios");
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`Could not infer the default file scenario adapter directory from ${moduleFilePath}. Provide an explicit directory instead.`);
|
|
113
|
+
}
|
|
114
|
+
function isRecord(value) {
|
|
115
|
+
return typeof value === "object" && value !== null;
|
|
116
|
+
}
|
|
117
|
+
function isValidIsoTimestamp(value) {
|
|
118
|
+
if (typeof value !== "string") return false;
|
|
119
|
+
const parsed = new Date(value);
|
|
120
|
+
return !Number.isNaN(parsed.valueOf()) && parsed.toISOString() === value;
|
|
121
|
+
}
|
|
122
|
+
function isSerializedTarget(value) {
|
|
123
|
+
if (!isRecord(value) || typeof value.kind !== "string") return false;
|
|
124
|
+
if (value.kind === "function") return typeof value.function === "string";
|
|
125
|
+
if (value.kind === "service-method") return typeof value.service === "string" && typeof value.method === "string";
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
function isSerializedAction(value) {
|
|
129
|
+
if (!isRecord(value) || typeof value.kind !== "string") return false;
|
|
130
|
+
if (value.kind === "return") return "value" in value;
|
|
131
|
+
if (value.kind === "throw-message") return typeof value.message === "string";
|
|
132
|
+
if (value.kind === "throw-factory") return typeof value.factory === "string";
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
function isSerializedRule(value) {
|
|
136
|
+
return isRecord(value) && typeof value.callNumber === "number" && Number.isInteger(value.callNumber) && value.callNumber > 0 && isSerializedTarget(value.target) && isSerializedAction(value.action);
|
|
137
|
+
}
|
|
138
|
+
function isSerializedScenario(value) {
|
|
139
|
+
return isRecord(value) && typeof value.id === "string" && value.version === 1 && isValidIsoTimestamp(value.createdAt) && Array.isArray(value.rules) && value.rules.every((rule) => isSerializedRule(rule));
|
|
140
|
+
}
|
|
141
|
+
function loadSerializedScenario(scenarioId, scenarioFilePath) {
|
|
142
|
+
let serializedScenarioText;
|
|
143
|
+
try {
|
|
144
|
+
serializedScenarioText = readFileSync(scenarioFilePath, "utf8");
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (isRecord(error) && "code" in error && error.code === "ENOENT") return void 0;
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
const parsedValue = JSON.parse(serializedScenarioText);
|
|
151
|
+
if (!isSerializedScenario(parsedValue) || parsedValue.id !== scenarioId) throw new Error(`Invalid persisted scenario data in ${scenarioFilePath}.`);
|
|
152
|
+
return parsedValue;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (error instanceof Error && error.message === `Invalid persisted scenario data in ${scenarioFilePath}.`) throw error;
|
|
155
|
+
throw new Error(`Invalid persisted scenario data in ${scenarioFilePath}.`, { cause: error });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Persists scenarios as JSON files in a local directory.
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* ```ts
|
|
163
|
+
* import { JsonScenarioStorageAdapter } from "definitely-fine";
|
|
164
|
+
*
|
|
165
|
+
* const adapter = new JsonScenarioStorageAdapter({
|
|
166
|
+
* directory: ".definitely-fine",
|
|
167
|
+
* });
|
|
168
|
+
* ```
|
|
169
|
+
* @public
|
|
170
|
+
*/
|
|
171
|
+
var JsonScenarioStorageAdapter = class {
|
|
172
|
+
directory;
|
|
173
|
+
constructor(options = {}) {
|
|
174
|
+
this.directory = resolveJsonScenarioStorageAdapterDirectory(options);
|
|
175
|
+
}
|
|
176
|
+
async deleteScenario(scenarioId) {
|
|
177
|
+
await rm(getScenarioFilePath(this.directory, scenarioId), { force: true });
|
|
178
|
+
}
|
|
179
|
+
describeScenarioLocation(scenarioId) {
|
|
180
|
+
return getScenarioFilePath(this.directory, scenarioId);
|
|
181
|
+
}
|
|
182
|
+
loadScenario(scenarioId) {
|
|
183
|
+
return loadSerializedScenario(scenarioId, getScenarioFilePath(this.directory, scenarioId));
|
|
184
|
+
}
|
|
185
|
+
async saveScenario(scenario) {
|
|
186
|
+
try {
|
|
187
|
+
await mkdir(this.directory, { recursive: true });
|
|
188
|
+
} catch (error) {
|
|
189
|
+
throw createJsonScenarioStorageAdapterError(`Could not create the file scenario adapter directory at ${this.directory}.`, error);
|
|
190
|
+
}
|
|
191
|
+
const scenarioFilePath = getScenarioFilePath(this.directory, scenario.id);
|
|
192
|
+
try {
|
|
193
|
+
await writeFile(scenarioFilePath, JSON.stringify(scenario, null, 2));
|
|
194
|
+
} catch (error) {
|
|
195
|
+
throw createJsonScenarioStorageAdapterError(`Could not write the file scenario adapter payload to ${scenarioFilePath}.`, error);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region src/runtime-errors.ts
|
|
202
|
+
/**
|
|
203
|
+
* Signals that runtime setup is missing required configuration.
|
|
204
|
+
* @public
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* ```ts
|
|
208
|
+
* import { RuntimeConfigurationError } from "definitely-fine";
|
|
209
|
+
*
|
|
210
|
+
* throw new RuntimeConfigurationError("A scenario adapter is required.");
|
|
211
|
+
* ```
|
|
212
|
+
*/
|
|
213
|
+
var RuntimeConfigurationError = class extends Error {
|
|
214
|
+
constructor(message, options) {
|
|
215
|
+
super(message, options);
|
|
216
|
+
this.name = "RuntimeConfigurationError";
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
//#endregion
|
|
221
|
+
//#region src/adapter-resolution.ts
|
|
222
|
+
/**
|
|
223
|
+
* Normalizes storage options into an adapter, directory, or missing state.
|
|
224
|
+
* @internal
|
|
225
|
+
*/
|
|
226
|
+
function resolveScenarioStorageSelection(options) {
|
|
227
|
+
if (options.adapter !== void 0 && options.directory !== void 0) throw new RuntimeConfigurationError("A storage adapter and directory cannot be used together.");
|
|
228
|
+
if (options.adapter) return {
|
|
229
|
+
kind: "adapter",
|
|
230
|
+
adapter: options.adapter
|
|
231
|
+
};
|
|
232
|
+
if (options.directory !== void 0) return {
|
|
233
|
+
kind: "directory",
|
|
234
|
+
directory: options.directory
|
|
235
|
+
};
|
|
236
|
+
return { kind: "default" };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
//#endregion
|
|
240
|
+
//#region src/services/impls/RuntimeErrorFactory.ts
|
|
241
|
+
function hasRuntimeFactory(errorFactories, factoryName) {
|
|
242
|
+
return factoryName in errorFactories;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Default runtime factory-error resolver implementation.
|
|
246
|
+
* @internal
|
|
247
|
+
*/
|
|
248
|
+
var RuntimeErrorFactory = class {
|
|
249
|
+
errorFactories;
|
|
250
|
+
constructor(errorFactories) {
|
|
251
|
+
this.errorFactories = errorFactories ?? {};
|
|
252
|
+
}
|
|
253
|
+
create(factoryName, ...input) {
|
|
254
|
+
if (!hasRuntimeFactory(this.errorFactories, factoryName)) throw new RuntimeConfigurationError(`No runtime error factory registered for "${factoryName}".`);
|
|
255
|
+
const factory = this.errorFactories[factoryName];
|
|
256
|
+
if (typeof factory !== "function") throw new RuntimeConfigurationError(`No runtime error factory registered for "${factoryName}".`);
|
|
257
|
+
return Reflect.apply(factory, void 0, input);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
//#endregion
|
|
262
|
+
//#region src/services/impls/RuntimeScenarioCacheService.ts
|
|
263
|
+
const DEFAULT_RUNTIME_SCENARIO_CACHE_TTL_MS = 6e4;
|
|
264
|
+
function scheduleEntryEviction(cacheEntries, scenarioId, ttlMs) {
|
|
265
|
+
const evictionTimer = setTimeout(() => {
|
|
266
|
+
cacheEntries.delete(scenarioId);
|
|
267
|
+
}, ttlMs);
|
|
268
|
+
evictionTimer.unref?.();
|
|
269
|
+
return evictionTimer;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Default runtime scenario cache with inactivity-based eviction.
|
|
273
|
+
* @internal
|
|
274
|
+
*/
|
|
275
|
+
var RuntimeScenarioCacheService = class {
|
|
276
|
+
ttlMs;
|
|
277
|
+
cacheEntries = new Map();
|
|
278
|
+
constructor(options = {}) {
|
|
279
|
+
this.ttlMs = options.ttlMs ?? DEFAULT_RUNTIME_SCENARIO_CACHE_TTL_MS;
|
|
280
|
+
}
|
|
281
|
+
has(scenarioId) {
|
|
282
|
+
return this.cacheEntries.has(scenarioId);
|
|
283
|
+
}
|
|
284
|
+
get(scenarioId) {
|
|
285
|
+
const cacheEntry = this.cacheEntries.get(scenarioId);
|
|
286
|
+
if (cacheEntry) {
|
|
287
|
+
clearTimeout(cacheEntry.evictionTimer);
|
|
288
|
+
cacheEntry.evictionTimer = scheduleEntryEviction(this.cacheEntries, scenarioId, this.ttlMs);
|
|
289
|
+
return cacheEntry.runtimeScenario;
|
|
290
|
+
}
|
|
291
|
+
return void 0;
|
|
292
|
+
}
|
|
293
|
+
set(scenarioId, runtimeScenario) {
|
|
294
|
+
const cacheEntry = this.cacheEntries.get(scenarioId);
|
|
295
|
+
if (cacheEntry) clearTimeout(cacheEntry.evictionTimer);
|
|
296
|
+
this.cacheEntries.set(scenarioId, {
|
|
297
|
+
runtimeScenario,
|
|
298
|
+
evictionTimer: scheduleEntryEviction(this.cacheEntries, scenarioId, this.ttlMs)
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
//#endregion
|
|
304
|
+
//#region src/runtime-matcher.ts
|
|
305
|
+
/**
|
|
306
|
+
* Reusable runtime scenario state bound to a single scenario id.
|
|
307
|
+
* @internal
|
|
308
|
+
*/
|
|
309
|
+
var RuntimeScenario = class {
|
|
310
|
+
/**
|
|
311
|
+
* Stable identifier for the runtime scenario state.
|
|
312
|
+
*/
|
|
313
|
+
id;
|
|
314
|
+
#scenario;
|
|
315
|
+
#targetCounters = new Map();
|
|
316
|
+
constructor(scenarioId, scenario) {
|
|
317
|
+
this.id = scenarioId;
|
|
318
|
+
this.#scenario = scenario;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Replaces the currently loaded persisted scenario document.
|
|
322
|
+
* @internal
|
|
323
|
+
*/
|
|
324
|
+
setSerializedScenario(scenario) {
|
|
325
|
+
this.#scenario = scenario;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Resolves the next matching rule for a target call, if this scenario has any.
|
|
329
|
+
* @internal
|
|
330
|
+
*/
|
|
331
|
+
resolveRuleMatch(target) {
|
|
332
|
+
if (!this.#scenario || !hasScenarioRuleForTarget(this.#scenario, target)) return void 0;
|
|
333
|
+
return resolveRuntimeRuleMatch({
|
|
334
|
+
scenario: this.#scenario,
|
|
335
|
+
target,
|
|
336
|
+
targetCounters: this.#targetCounters
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
function getTargetKey(target) {
|
|
341
|
+
if (target.kind === "function") return `function:${target.function}`;
|
|
342
|
+
return `service:${target.service}:${target.method}`;
|
|
343
|
+
}
|
|
344
|
+
function isSameTarget(left, right) {
|
|
345
|
+
if (left.kind !== right.kind) return false;
|
|
346
|
+
if (left.kind === "function" && right.kind === "function") return left.function === right.function;
|
|
347
|
+
if (left.kind === "service-method" && right.kind === "service-method") return left.service === right.service && left.method === right.method;
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
function incrementTargetCounter(targetCounters, target) {
|
|
351
|
+
const targetKey = getTargetKey(target);
|
|
352
|
+
const nextCallNumber = (targetCounters.get(targetKey) ?? 0) + 1;
|
|
353
|
+
targetCounters.set(targetKey, nextCallNumber);
|
|
354
|
+
return nextCallNumber;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Checks whether a scenario contains any rule for a target.
|
|
358
|
+
* @internal
|
|
359
|
+
*/
|
|
360
|
+
function hasScenarioRuleForTarget(scenario, target) {
|
|
361
|
+
if (!scenario) return false;
|
|
362
|
+
return scenario.rules.some((rule) => isSameTarget(rule.target, target));
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Resolves the next matching rule for a target call.
|
|
366
|
+
* @internal
|
|
367
|
+
*/
|
|
368
|
+
function resolveRuntimeRuleMatch(options) {
|
|
369
|
+
const callNumber = incrementTargetCounter(options.targetCounters, options.target);
|
|
370
|
+
const rule = options.scenario.rules.find((candidate) => {
|
|
371
|
+
return candidate.callNumber === callNumber && isSameTarget(candidate.target, options.target);
|
|
372
|
+
});
|
|
373
|
+
return {
|
|
374
|
+
callNumber,
|
|
375
|
+
rule
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
//#endregion
|
|
380
|
+
//#region src/services/impls/RuntimeScenarioLoaderService.ts
|
|
381
|
+
/**
|
|
382
|
+
* Default runtime scenario loader implementation.
|
|
383
|
+
* @internal
|
|
384
|
+
*/
|
|
385
|
+
var RuntimeScenarioLoaderService = class {
|
|
386
|
+
adapter;
|
|
387
|
+
cacheService;
|
|
388
|
+
resolveScenarioId;
|
|
389
|
+
constructor(options) {
|
|
390
|
+
this.adapter = options.adapter;
|
|
391
|
+
this.cacheService = options.cacheService;
|
|
392
|
+
this.resolveScenarioId = options.resolveScenarioId;
|
|
393
|
+
}
|
|
394
|
+
resolve() {
|
|
395
|
+
const scenarioId = this.resolveScenarioId();
|
|
396
|
+
if (!scenarioId) return void 0;
|
|
397
|
+
const cachedScenario = this.cacheService.get(scenarioId);
|
|
398
|
+
if (cachedScenario) return cachedScenario;
|
|
399
|
+
const runtimeScenario = new RuntimeScenario(scenarioId, this.adapter.loadScenario(scenarioId));
|
|
400
|
+
this.cacheService.set(scenarioId, runtimeScenario);
|
|
401
|
+
return runtimeScenario;
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
//#endregion
|
|
406
|
+
//#region src/services/impls/ScenarioBuilderService.ts
|
|
407
|
+
function isSameSerializedTarget(left, right) {
|
|
408
|
+
if (left.kind !== right.kind) return false;
|
|
409
|
+
if (left.kind === "function" && right.kind === "function") return left.function === right.function;
|
|
410
|
+
if (left.kind === "service-method" && right.kind === "service-method") return left.service === right.service && left.method === right.method;
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
function formatSerializedTarget(target) {
|
|
414
|
+
if (target.kind === "function") return `function ${target.function}`;
|
|
415
|
+
return `service method ${target.service}.${target.method}`;
|
|
416
|
+
}
|
|
417
|
+
function createActionBuilder(context, target, callNumber) {
|
|
418
|
+
function pushRuleAndReturnBuilder(action) {
|
|
419
|
+
const duplicateRule = context.rules.find((rule) => {
|
|
420
|
+
return rule.callNumber === callNumber && isSameSerializedTarget(rule.target, target);
|
|
421
|
+
});
|
|
422
|
+
if (duplicateRule) throw new Error(`Duplicate scenario rule for ${formatSerializedTarget(target)} on call ${callNumber}.`);
|
|
423
|
+
context.rules.push({
|
|
424
|
+
action,
|
|
425
|
+
callNumber,
|
|
426
|
+
target
|
|
427
|
+
});
|
|
428
|
+
if (!context.scenario) throw new Error("scenario builder reference is not initialized");
|
|
429
|
+
return context.scenario;
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
returns(value) {
|
|
433
|
+
return pushRuleAndReturnBuilder({
|
|
434
|
+
kind: "return",
|
|
435
|
+
value
|
|
436
|
+
});
|
|
437
|
+
},
|
|
438
|
+
throwsMessage(message) {
|
|
439
|
+
return pushRuleAndReturnBuilder({
|
|
440
|
+
kind: "throw-message",
|
|
441
|
+
message
|
|
442
|
+
});
|
|
443
|
+
},
|
|
444
|
+
throwsFactory(factory, ...args) {
|
|
445
|
+
const [input] = args;
|
|
446
|
+
return pushRuleAndReturnBuilder({
|
|
447
|
+
kind: "throw-factory",
|
|
448
|
+
factory,
|
|
449
|
+
input
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function createScenarioServiceMethodRuleBuilder(context, service, method) {
|
|
455
|
+
return { onCall(callNumber) {
|
|
456
|
+
return createActionBuilder(context, {
|
|
457
|
+
kind: "service-method",
|
|
458
|
+
method,
|
|
459
|
+
service
|
|
460
|
+
}, callNumber);
|
|
461
|
+
} };
|
|
462
|
+
}
|
|
463
|
+
function createScenarioServiceRuleBuilder(context, service) {
|
|
464
|
+
return { method(method) {
|
|
465
|
+
return createScenarioServiceMethodRuleBuilder(context, service, method);
|
|
466
|
+
} };
|
|
467
|
+
}
|
|
468
|
+
function createScenarioFunctionRuleBuilder(context, fn) {
|
|
469
|
+
return { onCall(callNumber) {
|
|
470
|
+
return createActionBuilder(context, {
|
|
471
|
+
kind: "function",
|
|
472
|
+
function: fn
|
|
473
|
+
}, callNumber);
|
|
474
|
+
} };
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Default scenario builder implementation.
|
|
478
|
+
* @internal
|
|
479
|
+
*/
|
|
480
|
+
var ScenarioBuilderService = class {
|
|
481
|
+
id;
|
|
482
|
+
adapter;
|
|
483
|
+
createdAt;
|
|
484
|
+
context;
|
|
485
|
+
constructor(adapter) {
|
|
486
|
+
this.adapter = adapter;
|
|
487
|
+
this.id = randomUUID();
|
|
488
|
+
this.createdAt = new Date().toISOString();
|
|
489
|
+
this.context = {
|
|
490
|
+
rules: [],
|
|
491
|
+
scenario: this
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
service(service) {
|
|
495
|
+
return createScenarioServiceRuleBuilder(this.context, service);
|
|
496
|
+
}
|
|
497
|
+
fn(fn) {
|
|
498
|
+
return createScenarioFunctionRuleBuilder(this.context, fn);
|
|
499
|
+
}
|
|
500
|
+
async save() {
|
|
501
|
+
await this.adapter.saveScenario({
|
|
502
|
+
id: this.id,
|
|
503
|
+
version: 1,
|
|
504
|
+
createdAt: this.createdAt,
|
|
505
|
+
rules: this.context.rules
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
async dispose() {
|
|
509
|
+
await this.adapter.deleteScenario(this.id);
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
//#endregion
|
|
514
|
+
//#region src/runtime-wrappers.ts
|
|
515
|
+
/**
|
|
516
|
+
* Wraps a synchronous function with scenario interception behavior.
|
|
517
|
+
* @internal
|
|
518
|
+
*/
|
|
519
|
+
function createWrappedSyncFunction(implementation, options) {
|
|
520
|
+
return new Proxy(implementation, { apply(target, thisArg, args) {
|
|
521
|
+
const runtimeScenario = options.runtimeScenarioLoaderService.resolve();
|
|
522
|
+
if (!runtimeScenario) return Reflect.apply(target, thisArg, args);
|
|
523
|
+
const match = runtimeScenario.resolveRuleMatch(options.target);
|
|
524
|
+
if (match?.rule?.action.kind === "return") return match.rule.action.value;
|
|
525
|
+
if (match?.rule?.action.kind === "throw-message") throw new Error(match.rule.action.message);
|
|
526
|
+
if (match?.rule?.action.kind === "throw-factory") throw options.runtimeErrorFactory.create(match.rule.action.factory, match.rule.action.input);
|
|
527
|
+
return Reflect.apply(target, thisArg, args);
|
|
528
|
+
} });
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Wraps an async function with scenario interception behavior.
|
|
532
|
+
* @internal
|
|
533
|
+
*/
|
|
534
|
+
function createWrappedAsyncFunction(implementation, options) {
|
|
535
|
+
return new Proxy(implementation, { apply(target, thisArg, args) {
|
|
536
|
+
const runtimeScenario = options.runtimeScenarioLoaderService.resolve();
|
|
537
|
+
if (!runtimeScenario) return Reflect.apply(target, thisArg, args);
|
|
538
|
+
const match = runtimeScenario.resolveRuleMatch(options.target);
|
|
539
|
+
if (match?.rule?.action.kind === "return") return Promise.resolve(match.rule.action.value);
|
|
540
|
+
if (match?.rule?.action.kind === "throw-message") return Promise.reject(new Error(match.rule.action.message));
|
|
541
|
+
if (match?.rule?.action.kind === "throw-factory") try {
|
|
542
|
+
return Promise.reject(options.runtimeErrorFactory.create(match.rule.action.factory, match.rule.action.input));
|
|
543
|
+
} catch (error) {
|
|
544
|
+
return Promise.reject(error);
|
|
545
|
+
}
|
|
546
|
+
return Reflect.apply(target, thisArg, args);
|
|
547
|
+
} });
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Wraps each callable method on a service with scenario interception behavior.
|
|
551
|
+
* @internal
|
|
552
|
+
*/
|
|
553
|
+
function createWrappedService(options) {
|
|
554
|
+
const wrappedMethods = new Map();
|
|
555
|
+
return new Proxy(options.implementation, { get(target, property, _receiver) {
|
|
556
|
+
const value = Reflect.get(target, property, target);
|
|
557
|
+
if (typeof property !== "string" || typeof value !== "function") return value;
|
|
558
|
+
const targetRule = {
|
|
559
|
+
kind: "service-method",
|
|
560
|
+
method: property,
|
|
561
|
+
service: options.service
|
|
562
|
+
};
|
|
563
|
+
const wrappedMethod = wrappedMethods.get(property);
|
|
564
|
+
if (wrappedMethod) return wrappedMethod;
|
|
565
|
+
const nextWrappedMethod = options.createWrapper(value.bind(options.implementation), {
|
|
566
|
+
runtimeScenarioLoaderService: options.runtimeScenarioLoaderService,
|
|
567
|
+
target: targetRule,
|
|
568
|
+
runtimeErrorFactory: options.runtimeErrorFactory
|
|
569
|
+
});
|
|
570
|
+
wrappedMethods.set(property, nextWrappedMethod);
|
|
571
|
+
return nextWrappedMethod;
|
|
572
|
+
} });
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
//#endregion
|
|
576
|
+
//#region src/index.ts
|
|
577
|
+
function resolveScenarioStorageAdapter(options = {}) {
|
|
578
|
+
const storageSelection = resolveScenarioStorageSelection({
|
|
579
|
+
adapter: "adapter" in options ? options.adapter : void 0,
|
|
580
|
+
directory: "directory" in options ? options.directory : void 0
|
|
581
|
+
});
|
|
582
|
+
return storageSelection.kind === "adapter" ? storageSelection.adapter : new JsonScenarioStorageAdapter(storageSelection.kind === "directory" ? { directory: storageSelection.directory } : {});
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Creates a scenario builder that records interception rules and persists them as JSON.
|
|
586
|
+
*
|
|
587
|
+
* @public
|
|
588
|
+
*
|
|
589
|
+
* @example
|
|
590
|
+
* ```ts
|
|
591
|
+
* import { JsonScenarioStorageAdapter, createScenario } from "definitely-fine";
|
|
592
|
+
*
|
|
593
|
+
* const scenario = createScenario({
|
|
594
|
+
* adapter: new JsonScenarioStorageAdapter({}),
|
|
595
|
+
* });
|
|
596
|
+
*
|
|
597
|
+
* scenario.fn("fetchUser").onCall(1).returns({ id: "user-1" });
|
|
598
|
+
* await scenario.save();
|
|
599
|
+
* ```
|
|
600
|
+
*/
|
|
601
|
+
function createScenario(options = {}) {
|
|
602
|
+
const adapter = resolveScenarioStorageAdapter(options);
|
|
603
|
+
return new ScenarioBuilderService(adapter);
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Creates a runtime that loads a persisted scenario and wraps implementations.
|
|
607
|
+
*
|
|
608
|
+
* @public
|
|
609
|
+
*
|
|
610
|
+
* @example
|
|
611
|
+
* ```ts
|
|
612
|
+
* import { createRuntime, type IScenarioStorageAdapter } from "definitely-fine";
|
|
613
|
+
*
|
|
614
|
+
* const customAdapter: IScenarioStorageAdapter = {
|
|
615
|
+
* async deleteScenario(_scenarioId) {},
|
|
616
|
+
* loadScenario(_scenarioId) {
|
|
617
|
+
* return undefined;
|
|
618
|
+
* },
|
|
619
|
+
* async saveScenario(_scenario) {},
|
|
620
|
+
* };
|
|
621
|
+
*
|
|
622
|
+
* const runtime = createRuntime({
|
|
623
|
+
* adapter: customAdapter,
|
|
624
|
+
* errorFactories: {},
|
|
625
|
+
* });
|
|
626
|
+
*
|
|
627
|
+
* const wrappedFetchUser = runtime.wrapAsyncFunction("fetchUser", fetchUser);
|
|
628
|
+
* ```
|
|
629
|
+
*/
|
|
630
|
+
function createRuntime(options = {}) {
|
|
631
|
+
if (options.enabled === false) return {
|
|
632
|
+
wrapSyncFunction(_name, implementation) {
|
|
633
|
+
return implementation;
|
|
634
|
+
},
|
|
635
|
+
wrapAsyncFunction(_name, implementation) {
|
|
636
|
+
return implementation;
|
|
637
|
+
},
|
|
638
|
+
wrapSyncServiceMethod(_service, _method, implementation) {
|
|
639
|
+
return implementation;
|
|
640
|
+
},
|
|
641
|
+
wrapAsyncServiceMethod(_service, _method, implementation) {
|
|
642
|
+
return implementation;
|
|
643
|
+
},
|
|
644
|
+
wrapSyncService(_service, implementation) {
|
|
645
|
+
return implementation;
|
|
646
|
+
},
|
|
647
|
+
wrapAsyncService(_service, implementation) {
|
|
648
|
+
return implementation;
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
const storageSelection = resolveScenarioStorageSelection({
|
|
652
|
+
adapter: "adapter" in options ? options.adapter : void 0,
|
|
653
|
+
directory: "directory" in options ? options.directory : void 0
|
|
654
|
+
});
|
|
655
|
+
const adapter = storageSelection.kind === "adapter" ? storageSelection.adapter : new JsonScenarioStorageAdapter(storageSelection.kind === "directory" ? { directory: storageSelection.directory } : {});
|
|
656
|
+
const runtimeErrorFactory = new RuntimeErrorFactory(options.errorFactories);
|
|
657
|
+
const cacheService = new RuntimeScenarioCacheService(options.cache);
|
|
658
|
+
const runtimeScenarioLoaderService = new RuntimeScenarioLoaderService({
|
|
659
|
+
adapter,
|
|
660
|
+
cacheService,
|
|
661
|
+
resolveScenarioId: getRuntimeScenarioId
|
|
662
|
+
});
|
|
663
|
+
return {
|
|
664
|
+
wrapSyncFunction(name, implementation) {
|
|
665
|
+
return createWrappedSyncFunction(implementation, {
|
|
666
|
+
runtimeErrorFactory,
|
|
667
|
+
runtimeScenarioLoaderService,
|
|
668
|
+
target: {
|
|
669
|
+
kind: "function",
|
|
670
|
+
function: name
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
},
|
|
674
|
+
wrapAsyncFunction(name, implementation) {
|
|
675
|
+
return createWrappedAsyncFunction(implementation, {
|
|
676
|
+
runtimeErrorFactory,
|
|
677
|
+
runtimeScenarioLoaderService,
|
|
678
|
+
target: {
|
|
679
|
+
kind: "function",
|
|
680
|
+
function: name
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
},
|
|
684
|
+
wrapSyncServiceMethod(service, method, implementation) {
|
|
685
|
+
return createWrappedSyncFunction(implementation, {
|
|
686
|
+
runtimeErrorFactory,
|
|
687
|
+
runtimeScenarioLoaderService,
|
|
688
|
+
target: {
|
|
689
|
+
kind: "service-method",
|
|
690
|
+
method,
|
|
691
|
+
service
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
},
|
|
695
|
+
wrapAsyncServiceMethod(service, method, implementation) {
|
|
696
|
+
return createWrappedAsyncFunction(implementation, {
|
|
697
|
+
runtimeErrorFactory,
|
|
698
|
+
runtimeScenarioLoaderService,
|
|
699
|
+
target: {
|
|
700
|
+
kind: "service-method",
|
|
701
|
+
method,
|
|
702
|
+
service
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
},
|
|
706
|
+
wrapSyncService(service, implementation) {
|
|
707
|
+
return createWrappedService({
|
|
708
|
+
implementation,
|
|
709
|
+
createWrapper: createWrappedSyncFunction,
|
|
710
|
+
runtimeErrorFactory,
|
|
711
|
+
service,
|
|
712
|
+
runtimeScenarioLoaderService
|
|
713
|
+
});
|
|
714
|
+
},
|
|
715
|
+
wrapAsyncService(service, implementation) {
|
|
716
|
+
return createWrappedService({
|
|
717
|
+
implementation,
|
|
718
|
+
createWrapper: createWrappedAsyncFunction,
|
|
719
|
+
runtimeErrorFactory,
|
|
720
|
+
service,
|
|
721
|
+
runtimeScenarioLoaderService
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
//#endregion
|
|
728
|
+
export { JsonScenarioStorageAdapter, RuntimeConfigurationError, createRuntime, createScenario, getRuntimeScenarioId, resolveJsonScenarioStorageAdapterDirectory, runWithRuntimeScenarioContext };
|
|
729
|
+
//# sourceMappingURL=index.js.map
|