@ontrails/testing 1.0.0-beta.5 → 1.0.0-beta.50
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/CHANGELOG.md +555 -12
- package/README.md +83 -18
- package/package.json +39 -5
- package/src/all-established.ts +168 -0
- package/src/all.ts +51 -12
- package/src/assertions.ts +253 -0
- package/src/cli.ts +6 -0
- package/src/composes.ts +433 -0
- package/src/context.ts +200 -14
- package/src/contracts.ts +44 -33
- package/src/detours.ts +155 -18
- package/src/effective-examples.ts +408 -0
- package/src/errors.ts +47 -0
- package/src/examples.ts +274 -113
- package/src/harness-cli.ts +83 -58
- package/src/harness-http.ts +341 -0
- package/src/harness-mcp.ts +49 -16
- package/src/http.ts +10 -0
- package/src/index.ts +22 -14
- package/src/logger.ts +3 -1
- package/src/mcp.ts +6 -0
- package/src/scenario.ts +375 -0
- package/src/signals.ts +221 -0
- package/src/surface-parity.ts +389 -0
- package/src/trail.ts +1 -1
- package/src/types.ts +24 -52
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/dist/all.d.ts +0 -30
- package/dist/all.d.ts.map +0 -1
- package/dist/all.js +0 -47
- package/dist/all.js.map +0 -1
- package/dist/assertions.d.ts +0 -49
- package/dist/assertions.d.ts.map +0 -1
- package/dist/assertions.js +0 -84
- package/dist/assertions.js.map +0 -1
- package/dist/context.d.ts +0 -19
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js +0 -33
- package/dist/context.js.map +0 -1
- package/dist/contracts.d.ts +0 -16
- package/dist/contracts.d.ts.map +0 -1
- package/dist/contracts.js +0 -66
- package/dist/contracts.js.map +0 -1
- package/dist/detours.d.ts +0 -12
- package/dist/detours.d.ts.map +0 -1
- package/dist/detours.js +0 -30
- package/dist/detours.js.map +0 -1
- package/dist/examples.d.ts +0 -22
- package/dist/examples.d.ts.map +0 -1
- package/dist/examples.js +0 -175
- package/dist/examples.js.map +0 -1
- package/dist/follows.d.ts +0 -32
- package/dist/follows.d.ts.map +0 -1
- package/dist/follows.js +0 -169
- package/dist/follows.js.map +0 -1
- package/dist/harness-cli.d.ts +0 -21
- package/dist/harness-cli.d.ts.map +0 -1
- package/dist/harness-cli.js +0 -213
- package/dist/harness-cli.js.map +0 -1
- package/dist/harness-mcp.d.ts +0 -21
- package/dist/harness-mcp.d.ts.map +0 -1
- package/dist/harness-mcp.js +0 -50
- package/dist/harness-mcp.js.map +0 -1
- package/dist/index.d.ts +0 -14
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -16
- package/dist/index.js.map +0 -1
- package/dist/logger.d.ts +0 -15
- package/dist/logger.d.ts.map +0 -1
- package/dist/logger.js +0 -87
- package/dist/logger.js.map +0 -1
- package/dist/trail.d.ts +0 -20
- package/dist/trail.d.ts.map +0 -1
- package/dist/trail.js +0 -80
- package/dist/trail.js.map +0 -1
- package/dist/types.d.ts +0 -80
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -5
- package/dist/types.js.map +0 -1
- package/src/__tests__/context.test.ts +0 -60
- package/src/__tests__/contracts.test.ts +0 -94
- package/src/__tests__/detours.test.ts +0 -55
- package/src/__tests__/examples.test.ts +0 -175
- package/src/__tests__/follows.test.ts +0 -163
- package/src/__tests__/logger.test.ts +0 -136
- package/src/__tests__/trail.test.ts +0 -99
- package/src/follows.ts +0 -283
- package/tsconfig.json +0 -9
- package/tsconfig.tsbuildinfo +0 -1
package/src/assertions.ts
CHANGED
|
@@ -49,6 +49,40 @@ export const expectErr = <T, E>(result: Result<T, E>): E => {
|
|
|
49
49
|
return (result as unknown as { error: E }).error;
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Result Match Tokens
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
export interface OkResultMatch {
|
|
57
|
+
readonly __resultMatch: 'ok';
|
|
58
|
+
readonly value?: unknown | undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ErrResultMatch {
|
|
62
|
+
readonly __resultMatch: 'err';
|
|
63
|
+
readonly error?: unknown | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type ResultMatchToken = OkResultMatch | ErrResultMatch;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create a partial-match token for `Result.ok(...)` values nested inside
|
|
70
|
+
* arrays or objects, such as the `Result[]` returned by batch `ctx.compose()`.
|
|
71
|
+
*/
|
|
72
|
+
export const okResultMatch = (value?: unknown): OkResultMatch => ({
|
|
73
|
+
__resultMatch: 'ok',
|
|
74
|
+
value,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Create a partial-match token for `Result.err(...)` values nested inside
|
|
79
|
+
* arrays or objects, such as mixed-success batch `ctx.compose()`.
|
|
80
|
+
*/
|
|
81
|
+
export const errResultMatch = (error?: unknown): ErrResultMatch => ({
|
|
82
|
+
__resultMatch: 'err',
|
|
83
|
+
error,
|
|
84
|
+
});
|
|
85
|
+
|
|
52
86
|
// ---------------------------------------------------------------------------
|
|
53
87
|
// Full Match
|
|
54
88
|
// ---------------------------------------------------------------------------
|
|
@@ -87,6 +121,225 @@ export const assertSchemaMatch = (
|
|
|
87
121
|
}
|
|
88
122
|
};
|
|
89
123
|
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Partial Match
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/** Format a path for error messages. */
|
|
129
|
+
const formatLoc = (path: readonly string[]): string =>
|
|
130
|
+
path.length > 0 ? path.join('.') : 'root';
|
|
131
|
+
|
|
132
|
+
interface ResultLike {
|
|
133
|
+
readonly error?: unknown;
|
|
134
|
+
isErr(): boolean;
|
|
135
|
+
isOk(): boolean;
|
|
136
|
+
readonly value?: unknown;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const isResultMatchToken = (value: unknown): value is ResultMatchToken =>
|
|
140
|
+
typeof value === 'object' &&
|
|
141
|
+
value !== null &&
|
|
142
|
+
'__resultMatch' in value &&
|
|
143
|
+
((value as Record<string, unknown>)['__resultMatch'] === 'ok' ||
|
|
144
|
+
(value as Record<string, unknown>)['__resultMatch'] === 'err');
|
|
145
|
+
|
|
146
|
+
const isResultLike = (value: unknown): value is ResultLike =>
|
|
147
|
+
typeof value === 'object' &&
|
|
148
|
+
value !== null &&
|
|
149
|
+
'isOk' in value &&
|
|
150
|
+
typeof (value as Record<string, unknown>)['isOk'] === 'function' &&
|
|
151
|
+
'isErr' in value &&
|
|
152
|
+
typeof (value as Record<string, unknown>)['isErr'] === 'function';
|
|
153
|
+
|
|
154
|
+
/** Find an unconsumed actual element that deep-matches the expected object. */
|
|
155
|
+
const findObjectMatch = (
|
|
156
|
+
actual: unknown[],
|
|
157
|
+
elem: object,
|
|
158
|
+
consumed: ReadonlySet<number>,
|
|
159
|
+
path: readonly string[],
|
|
160
|
+
index: number
|
|
161
|
+
): number =>
|
|
162
|
+
actual.findIndex((a, idx) => {
|
|
163
|
+
if (consumed.has(idx)) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
// oxlint-disable-next-line no-use-before-define -- mutual recursion with assertSubset
|
|
168
|
+
assertSubset(a, elem, [...path, `[${String(index)}]`]);
|
|
169
|
+
return true;
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Assert that every element in `expected` exists in `actual` (order-independent).
|
|
177
|
+
*
|
|
178
|
+
* Tracks consumed indices so that duplicate expected elements each require a
|
|
179
|
+
* distinct actual element — `['a', 'a']` does not match `['a']`.
|
|
180
|
+
*/
|
|
181
|
+
const assertArraySubset = (
|
|
182
|
+
actual: unknown[],
|
|
183
|
+
expected: unknown[],
|
|
184
|
+
path: readonly string[],
|
|
185
|
+
loc: string
|
|
186
|
+
): void => {
|
|
187
|
+
const consumed = new Set<number>();
|
|
188
|
+
for (let i = 0; i < expected.length; i += 1) {
|
|
189
|
+
const elem = expected[i];
|
|
190
|
+
const matchIndex =
|
|
191
|
+
typeof elem === 'object' && elem !== null
|
|
192
|
+
? findObjectMatch(actual, elem, consumed, path, i)
|
|
193
|
+
: actual.findIndex((a, idx) => !consumed.has(idx) && a === elem);
|
|
194
|
+
|
|
195
|
+
if (matchIndex === -1) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`at ${loc}[${String(i)}]: expected array to contain ${JSON.stringify(elem)}`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
consumed.add(matchIndex);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** Assert that every key in `expected` exists in `actual` with a matching value. */
|
|
205
|
+
const assertObjectSubset = (
|
|
206
|
+
actual: Record<string, unknown>,
|
|
207
|
+
expected: Record<string, unknown>,
|
|
208
|
+
path: readonly string[]
|
|
209
|
+
): void => {
|
|
210
|
+
for (const key of Object.keys(expected)) {
|
|
211
|
+
if (!(key in actual)) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`at ${[...path, key].join('.')}: key not found in actual`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
// oxlint-disable-next-line no-use-before-define -- mutual recursion with assertSubset
|
|
217
|
+
assertSubset(actual[key], expected[key], [...path, key]);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Recursively assert that `actual` is a superset of `expected`.
|
|
223
|
+
*
|
|
224
|
+
* - **Scalars:** strict equality.
|
|
225
|
+
* - **Objects:** every key in `expected` must exist in `actual` with a matching
|
|
226
|
+
* value. Extra keys in `actual` are ignored.
|
|
227
|
+
* - **Arrays:** every element in `expected` must exist in `actual`
|
|
228
|
+
* (order-independent subset check).
|
|
229
|
+
* - **Nested objects:** recursive subset matching.
|
|
230
|
+
*/
|
|
231
|
+
// oxlint-disable-next-line max-statements -- recursive dispatch across four type branches
|
|
232
|
+
const assertSubset = (
|
|
233
|
+
actual: unknown,
|
|
234
|
+
expected: unknown,
|
|
235
|
+
path: readonly string[]
|
|
236
|
+
): void => {
|
|
237
|
+
const loc = formatLoc(path);
|
|
238
|
+
|
|
239
|
+
if (expected === null || expected === undefined) {
|
|
240
|
+
if (actual !== expected) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`at ${loc}: expected ${String(expected)}, got ${String(actual)}`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (isResultMatchToken(expected)) {
|
|
249
|
+
// oxlint-disable-next-line no-use-before-define -- result token matching delegates back into assertSubset
|
|
250
|
+
assertResultTokenMatch(actual, expected, path);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (Array.isArray(expected)) {
|
|
255
|
+
if (!Array.isArray(actual)) {
|
|
256
|
+
throw new TypeError(`at ${loc}: expected an array, got ${typeof actual}`);
|
|
257
|
+
}
|
|
258
|
+
assertArraySubset(actual, expected, path, loc);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (typeof expected === 'object') {
|
|
263
|
+
if (
|
|
264
|
+
typeof actual !== 'object' ||
|
|
265
|
+
actual === null ||
|
|
266
|
+
Array.isArray(actual)
|
|
267
|
+
) {
|
|
268
|
+
throw new Error(`at ${loc}: expected an object, got ${typeof actual}`);
|
|
269
|
+
}
|
|
270
|
+
assertObjectSubset(
|
|
271
|
+
actual as Record<string, unknown>,
|
|
272
|
+
expected as Record<string, unknown>,
|
|
273
|
+
path
|
|
274
|
+
);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (actual !== expected) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`at ${loc}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const assertOkResultTokenMatch = (
|
|
286
|
+
actual: ResultLike,
|
|
287
|
+
expected: OkResultMatch,
|
|
288
|
+
loc: string,
|
|
289
|
+
path: readonly string[]
|
|
290
|
+
): void => {
|
|
291
|
+
if (!actual.isOk()) {
|
|
292
|
+
throw new Error(`at ${loc}: expected Result.ok(...), got Result.err(...)`);
|
|
293
|
+
}
|
|
294
|
+
if (expected.value !== undefined) {
|
|
295
|
+
assertSubset(actual.value, expected.value, [...path, 'value']);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const assertErrResultTokenMatch = (
|
|
300
|
+
actual: ResultLike,
|
|
301
|
+
expected: ErrResultMatch,
|
|
302
|
+
loc: string,
|
|
303
|
+
path: readonly string[]
|
|
304
|
+
): void => {
|
|
305
|
+
if (!actual.isErr()) {
|
|
306
|
+
throw new Error(`at ${loc}: expected Result.err(...), got Result.ok(...)`);
|
|
307
|
+
}
|
|
308
|
+
if (expected.error !== undefined) {
|
|
309
|
+
assertSubset(actual.error, expected.error, [...path, 'error']);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const assertResultTokenMatch = (
|
|
314
|
+
actual: unknown,
|
|
315
|
+
expected: ResultMatchToken,
|
|
316
|
+
path: readonly string[]
|
|
317
|
+
): void => {
|
|
318
|
+
const loc = formatLoc(path);
|
|
319
|
+
if (!isResultLike(actual)) {
|
|
320
|
+
throw new TypeError(`at ${loc}: expected a Result-like value`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (expected.__resultMatch === 'ok') {
|
|
324
|
+
assertOkResultTokenMatch(actual, expected, loc, path);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
assertErrResultTokenMatch(actual, expected, loc, path);
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Assert that the result is ok and its value is a superset of the expected
|
|
333
|
+
* partial output. Declared fields must match; extra fields are ignored.
|
|
334
|
+
*/
|
|
335
|
+
export const assertPartialMatch = (
|
|
336
|
+
result: Result<unknown, Error>,
|
|
337
|
+
expectedMatch: unknown
|
|
338
|
+
): void => {
|
|
339
|
+
const value = expectOk(result);
|
|
340
|
+
assertSubset(value, expectedMatch, []);
|
|
341
|
+
};
|
|
342
|
+
|
|
90
343
|
// ---------------------------------------------------------------------------
|
|
91
344
|
// Error Match
|
|
92
345
|
// ---------------------------------------------------------------------------
|
package/src/cli.ts
ADDED
package/src/composes.ts
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* testComposes — composing-aware scenario testing for trails with compositions.
|
|
3
|
+
*
|
|
4
|
+
* Tests the composing graph: which trails were composed, in what order,
|
|
5
|
+
* and supports failure injection from composed trail examples.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
AnyTrail,
|
|
12
|
+
ComposeFn,
|
|
13
|
+
ExecuteTrailOptions,
|
|
14
|
+
ResourceOverrideMap,
|
|
15
|
+
TrailContext,
|
|
16
|
+
} from '@ontrails/core';
|
|
17
|
+
import {
|
|
18
|
+
buildComposeValidationSchema,
|
|
19
|
+
executeTrail,
|
|
20
|
+
InternalError,
|
|
21
|
+
Result,
|
|
22
|
+
ValidationError,
|
|
23
|
+
validateInput,
|
|
24
|
+
} from '@ontrails/core';
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
assertErrorMatch,
|
|
28
|
+
assertFullMatch,
|
|
29
|
+
assertSchemaMatch,
|
|
30
|
+
} from './assertions.js';
|
|
31
|
+
import { mergeResourceOverrides, mergeTestContext } from './context.js';
|
|
32
|
+
import { createErrorFromName } from './errors.js';
|
|
33
|
+
import type { ComposeScenario } from './types.js';
|
|
34
|
+
|
|
35
|
+
type TestingExecuteTrailOptions = ExecuteTrailOptions & {
|
|
36
|
+
readonly validationSchema?: ReturnType<typeof buildComposeValidationSchema>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Compose trace
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
interface ComposeRecord {
|
|
44
|
+
readonly id: string;
|
|
45
|
+
readonly input: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const collectDeclaredResources = (
|
|
49
|
+
trailDef: AnyTrail,
|
|
50
|
+
trailsMap: ReadonlyMap<string, AnyTrail> | undefined
|
|
51
|
+
): AnyTrail['resources'] => {
|
|
52
|
+
const seenResourceIds = new Set<string>();
|
|
53
|
+
const seenTrailIds = new Set<string>();
|
|
54
|
+
const resources: AnyTrail['resources'][number][] = [];
|
|
55
|
+
|
|
56
|
+
const collect = (candidate: AnyTrail): void => {
|
|
57
|
+
for (const declaredResource of candidate.resources) {
|
|
58
|
+
if (seenResourceIds.has(declaredResource.id)) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
seenResourceIds.add(declaredResource.id);
|
|
62
|
+
resources.push(declaredResource);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const visit = (candidate: AnyTrail): void => {
|
|
67
|
+
if (seenTrailIds.has(candidate.id)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
seenTrailIds.add(candidate.id);
|
|
71
|
+
collect(candidate);
|
|
72
|
+
for (const composedId of candidate.composes) {
|
|
73
|
+
const composedTrail = trailsMap?.get(composedId);
|
|
74
|
+
if (composedTrail) {
|
|
75
|
+
visit(composedTrail);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
visit(trailDef);
|
|
81
|
+
return resources;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const resolveComposeMockResources = async (
|
|
85
|
+
trailDef: AnyTrail,
|
|
86
|
+
trailsMap: ReadonlyMap<string, AnyTrail> | undefined
|
|
87
|
+
): Promise<ResourceOverrideMap> => {
|
|
88
|
+
const resources: Record<string, unknown> = {};
|
|
89
|
+
|
|
90
|
+
for (const declaredResource of collectDeclaredResources(
|
|
91
|
+
trailDef,
|
|
92
|
+
trailsMap
|
|
93
|
+
)) {
|
|
94
|
+
if (!declaredResource.mock) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
resources[declaredResource.id] = await declaredResource.mock();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return resources;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Injection helpers
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Find an error example on a trail by name or description substring.
|
|
109
|
+
*/
|
|
110
|
+
const findErrorExample = (
|
|
111
|
+
trailDef: AnyTrail,
|
|
112
|
+
description: string
|
|
113
|
+
): string | undefined => {
|
|
114
|
+
const example = trailDef.examples?.find(
|
|
115
|
+
(ex) =>
|
|
116
|
+
ex.error !== undefined &&
|
|
117
|
+
(ex.description?.includes(description) || ex.name.includes(description))
|
|
118
|
+
);
|
|
119
|
+
return example?.error;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Try to inject an error from a composed trail's example.
|
|
124
|
+
* Returns undefined when no injection is configured for this trail ID.
|
|
125
|
+
*/
|
|
126
|
+
const tryInjectError = (
|
|
127
|
+
id: string,
|
|
128
|
+
scenario: ComposeScenario,
|
|
129
|
+
trailsMap: ReadonlyMap<string, AnyTrail> | undefined
|
|
130
|
+
): Result<unknown, Error> | undefined => {
|
|
131
|
+
const injection = scenario.injectFromExample?.[id];
|
|
132
|
+
if (injection === undefined) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const trailDef = trailsMap?.get(id);
|
|
137
|
+
if (trailDef === undefined) {
|
|
138
|
+
return Result.err(
|
|
139
|
+
new InternalError(`Cannot inject: trail "${id}" not in topo`)
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const errorName = findErrorExample(trailDef, injection);
|
|
143
|
+
if (errorName === undefined) {
|
|
144
|
+
return Result.err(
|
|
145
|
+
new InternalError(
|
|
146
|
+
`No error example matching "${injection}" on trail "${id}"`
|
|
147
|
+
)
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return Result.err(createErrorFromName(errorName));
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const executeFromMap = (
|
|
154
|
+
id: string,
|
|
155
|
+
input: unknown,
|
|
156
|
+
trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
|
|
157
|
+
ctx: TrailContext,
|
|
158
|
+
resources: ResourceOverrideMap | undefined,
|
|
159
|
+
compose?: ComposeFn
|
|
160
|
+
): Result<unknown, Error> | Promise<Result<unknown, Error>> | undefined => {
|
|
161
|
+
const trailDef = trailsMap?.get(id);
|
|
162
|
+
if (trailDef === undefined) {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const nestedCtx = compose ? { ...ctx, compose } : ctx;
|
|
167
|
+
const options: TestingExecuteTrailOptions = {
|
|
168
|
+
ctx: nestedCtx,
|
|
169
|
+
resources,
|
|
170
|
+
validationSchema: buildComposeValidationSchema(trailDef),
|
|
171
|
+
};
|
|
172
|
+
return executeTrail(trailDef, input, options);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** Extract trail ID from either a trail object or a string. */
|
|
176
|
+
const resolveComposeId = (
|
|
177
|
+
idOrTrail: string | { readonly id: string }
|
|
178
|
+
): string => (typeof idOrTrail === 'string' ? idOrTrail : idOrTrail.id);
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Compose factory
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/** Delegate to baseCompose, executeFromMap, or fall back to Result.ok(). */
|
|
185
|
+
const delegateCompose = (
|
|
186
|
+
id: string,
|
|
187
|
+
input: unknown,
|
|
188
|
+
baseCompose: ComposeFn | undefined,
|
|
189
|
+
trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
|
|
190
|
+
ctx: TrailContext,
|
|
191
|
+
resources: ResourceOverrideMap | undefined,
|
|
192
|
+
self: ComposeFn
|
|
193
|
+
): Promise<Result<unknown, Error>> => {
|
|
194
|
+
if (baseCompose !== undefined) {
|
|
195
|
+
return baseCompose(id, input);
|
|
196
|
+
}
|
|
197
|
+
const executed = executeFromMap(id, input, trailsMap, ctx, resources, self);
|
|
198
|
+
return Promise.resolve(executed ?? Result.ok());
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Build a recording compose function that optionally injects errors.
|
|
203
|
+
*/
|
|
204
|
+
const createRecordingCompose = (
|
|
205
|
+
trace: ComposeRecord[],
|
|
206
|
+
scenario: ComposeScenario,
|
|
207
|
+
trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
|
|
208
|
+
baseCompose: ComposeFn | undefined,
|
|
209
|
+
ctx: TrailContext,
|
|
210
|
+
resources: ResourceOverrideMap | undefined
|
|
211
|
+
): ComposeFn => {
|
|
212
|
+
// The generic O on ComposeFn is erased at runtime; the cast is safe
|
|
213
|
+
// because callers narrow via isOk/isErr before accessing the value.
|
|
214
|
+
const invokeCompose = async (
|
|
215
|
+
idOrTrail: string | { readonly id: string },
|
|
216
|
+
input: unknown,
|
|
217
|
+
self: ComposeFn
|
|
218
|
+
) => {
|
|
219
|
+
const id = resolveComposeId(idOrTrail);
|
|
220
|
+
trace.push({ id, input });
|
|
221
|
+
|
|
222
|
+
const injected = tryInjectError(id, scenario, trailsMap);
|
|
223
|
+
if (injected !== undefined) {
|
|
224
|
+
return injected;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return await delegateCompose(
|
|
228
|
+
id,
|
|
229
|
+
input,
|
|
230
|
+
baseCompose,
|
|
231
|
+
trailsMap,
|
|
232
|
+
ctx,
|
|
233
|
+
resources,
|
|
234
|
+
self
|
|
235
|
+
);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
// Accepts either a trail object (typed compose), a string id (untyped),
|
|
239
|
+
// or a batch of `[target, input]` tuples.
|
|
240
|
+
const compose = async function compose(
|
|
241
|
+
idOrTrail:
|
|
242
|
+
| string
|
|
243
|
+
| { readonly id: string }
|
|
244
|
+
| readonly (readonly [string | { readonly id: string }, unknown])[],
|
|
245
|
+
input?: unknown
|
|
246
|
+
) {
|
|
247
|
+
if (Array.isArray(idOrTrail)) {
|
|
248
|
+
return await Promise.all(
|
|
249
|
+
idOrTrail.map(([target, batchInput]) =>
|
|
250
|
+
invokeCompose(target, batchInput, compose as ComposeFn)
|
|
251
|
+
)
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return await invokeCompose(
|
|
256
|
+
idOrTrail as string | { readonly id: string },
|
|
257
|
+
input,
|
|
258
|
+
compose as ComposeFn
|
|
259
|
+
);
|
|
260
|
+
} as ComposeFn;
|
|
261
|
+
|
|
262
|
+
return compose;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Scenario assertions
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
const assertScenarioResult = (
|
|
270
|
+
result: Result<unknown, Error>,
|
|
271
|
+
scenario: ComposeScenario,
|
|
272
|
+
trailDef: AnyTrail
|
|
273
|
+
): void => {
|
|
274
|
+
if (scenario.expectValue !== undefined) {
|
|
275
|
+
assertFullMatch(result, scenario.expectValue);
|
|
276
|
+
} else if (scenario.expectErr !== undefined) {
|
|
277
|
+
assertErrorMatch(result, scenario.expectErr, scenario.expectErrMessage);
|
|
278
|
+
} else if (scenario.expectErrMessage !== undefined) {
|
|
279
|
+
expect(result.isErr()).toBe(true);
|
|
280
|
+
if (result.isErr()) {
|
|
281
|
+
expect(result.error.message).toContain(scenario.expectErrMessage);
|
|
282
|
+
}
|
|
283
|
+
} else if (scenario.expectOk === true) {
|
|
284
|
+
expect(result.isOk()).toBe(true);
|
|
285
|
+
assertSchemaMatch(result, trailDef.output);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const assertComposeTrace = (
|
|
290
|
+
trace: readonly ComposeRecord[],
|
|
291
|
+
scenario: ComposeScenario
|
|
292
|
+
): void => {
|
|
293
|
+
if (scenario.expectComposed !== undefined) {
|
|
294
|
+
const composedIds = trace.map((r) => r.id);
|
|
295
|
+
expect(composedIds).toEqual([...scenario.expectComposed]);
|
|
296
|
+
}
|
|
297
|
+
if (scenario.expectComposedCount !== undefined) {
|
|
298
|
+
const counts: Record<string, number> = {};
|
|
299
|
+
for (const record of trace) {
|
|
300
|
+
counts[record.id] = (counts[record.id] ?? 0) + 1;
|
|
301
|
+
}
|
|
302
|
+
expect(counts).toEqual({ ...scenario.expectComposedCount });
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const handleValidationError = (
|
|
307
|
+
validated: Result<unknown, Error>,
|
|
308
|
+
scenario: ComposeScenario
|
|
309
|
+
): boolean => {
|
|
310
|
+
if (!validated.isErr()) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
if (scenario.expectErr === ValidationError) {
|
|
314
|
+
expect(validated.error).toBeInstanceOf(ValidationError);
|
|
315
|
+
if (scenario.expectErrMessage !== undefined) {
|
|
316
|
+
expect(validated.error.message).toContain(scenario.expectErrMessage);
|
|
317
|
+
}
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
throw new Error(
|
|
321
|
+
`Input validation failed unexpectedly: ${validated.error.message}`
|
|
322
|
+
);
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Scenario runner
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
const buildTestContext = (
|
|
330
|
+
scenario: ComposeScenario,
|
|
331
|
+
ctx: Partial<TrailContext> | undefined,
|
|
332
|
+
trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
|
|
333
|
+
resources: ResourceOverrideMap | undefined
|
|
334
|
+
): { trace: ComposeRecord[]; testCtx: TrailContext } => {
|
|
335
|
+
const trace: ComposeRecord[] = [];
|
|
336
|
+
const baseCtx = mergeTestContext(ctx);
|
|
337
|
+
const compose = createRecordingCompose(
|
|
338
|
+
trace,
|
|
339
|
+
scenario,
|
|
340
|
+
trailsMap,
|
|
341
|
+
baseCtx.compose,
|
|
342
|
+
baseCtx,
|
|
343
|
+
resources
|
|
344
|
+
);
|
|
345
|
+
return { testCtx: { ...baseCtx, compose }, trace };
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const runScenario = async (
|
|
349
|
+
trailDef: AnyTrail,
|
|
350
|
+
scenario: ComposeScenario,
|
|
351
|
+
ctx: Partial<TrailContext> | undefined,
|
|
352
|
+
trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
|
|
353
|
+
resources: ResourceOverrideMap | undefined
|
|
354
|
+
): Promise<void> => {
|
|
355
|
+
const validated = validateInput(trailDef.input, scenario.input);
|
|
356
|
+
if (handleValidationError(validated, scenario)) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const { trace, testCtx } = buildTestContext(
|
|
361
|
+
scenario,
|
|
362
|
+
ctx,
|
|
363
|
+
trailsMap,
|
|
364
|
+
resources
|
|
365
|
+
);
|
|
366
|
+
const result = await executeTrail(trailDef, scenario.input, {
|
|
367
|
+
ctx: testCtx,
|
|
368
|
+
resources,
|
|
369
|
+
});
|
|
370
|
+
assertComposeTrace(trace, scenario);
|
|
371
|
+
assertScenarioResult(result, scenario, trailDef);
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// testComposes
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
/** Options for testComposes that provide trail definitions for injection. */
|
|
379
|
+
export interface TestComposeOptions {
|
|
380
|
+
/** Partial context overrides. */
|
|
381
|
+
readonly ctx?: Partial<TrailContext> | undefined;
|
|
382
|
+
/**
|
|
383
|
+
* Explicit resource overrides merged on top of auto-resolved mocks for every
|
|
384
|
+
* scenario. Values are passed by reference — provide immutable objects, or
|
|
385
|
+
* use `mock()` on the resource definition to get a fresh instance per run.
|
|
386
|
+
*/
|
|
387
|
+
readonly resources?: ResourceOverrideMap | undefined;
|
|
388
|
+
/** Map of trail ID to trail definition, used for injectFromExample. */
|
|
389
|
+
readonly trails?: ReadonlyMap<string, AnyTrail> | undefined;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Generate a describe block for a trail with compositions with one test per scenario.
|
|
394
|
+
*
|
|
395
|
+
* @example
|
|
396
|
+
* ```ts
|
|
397
|
+
* testComposes(onboardTrail, [
|
|
398
|
+
* {
|
|
399
|
+
* description: "composes add then relate",
|
|
400
|
+
* input: { name: "Alpha" },
|
|
401
|
+
* expectOk: true,
|
|
402
|
+
* expectComposed: ["entity.add", "entity.relate"],
|
|
403
|
+
* },
|
|
404
|
+
* ]);
|
|
405
|
+
* ```
|
|
406
|
+
*/
|
|
407
|
+
export const testComposes = (
|
|
408
|
+
trailDef: AnyTrail,
|
|
409
|
+
scenarios: readonly ComposeScenario[],
|
|
410
|
+
options?: TestComposeOptions
|
|
411
|
+
): void => {
|
|
412
|
+
const explicitResources = options?.resources;
|
|
413
|
+
|
|
414
|
+
describe(trailDef.id, () => {
|
|
415
|
+
test.each([...scenarios])(
|
|
416
|
+
'$description',
|
|
417
|
+
async (scenario: ComposeScenario) => {
|
|
418
|
+
const resources = mergeResourceOverrides(
|
|
419
|
+
await resolveComposeMockResources(trailDef, options?.trails),
|
|
420
|
+
options?.ctx,
|
|
421
|
+
explicitResources
|
|
422
|
+
);
|
|
423
|
+
await runScenario(
|
|
424
|
+
trailDef,
|
|
425
|
+
scenario,
|
|
426
|
+
options?.ctx,
|
|
427
|
+
options?.trails,
|
|
428
|
+
resources
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
);
|
|
432
|
+
});
|
|
433
|
+
};
|