@ontrails/testing 1.0.0-beta.10 → 1.0.0-beta.11
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/.turbo/turbo-lint.log +1 -1
- package/CHANGELOG.md +28 -0
- package/dist/all.d.ts +2 -1
- package/dist/all.d.ts.map +1 -1
- package/dist/all.js.map +1 -1
- package/dist/context.d.ts +9 -2
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +47 -13
- package/dist/context.js.map +1 -1
- package/dist/contracts.d.ts +2 -1
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +13 -8
- package/dist/contracts.js.map +1 -1
- package/dist/examples.d.ts +2 -1
- package/dist/examples.d.ts.map +1 -1
- package/dist/examples.js +28 -21
- package/dist/examples.js.map +1 -1
- package/dist/follows.d.ts +7 -1
- package/dist/follows.d.ts.map +1 -1
- package/dist/follows.js +60 -17
- package/dist/follows.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/all.test.ts +64 -0
- package/src/__tests__/context.test.ts +58 -3
- package/src/__tests__/contracts.test.ts +92 -1
- package/src/__tests__/examples.test.ts +265 -1
- package/src/__tests__/follows.test.ts +427 -1
- package/src/all.ts +5 -1
- package/src/context.ts +82 -15
- package/src/contracts.ts +28 -10
- package/src/examples.ts +63 -21
- package/src/follows.ts +112 -17
- package/src/index.ts +1 -0
package/src/examples.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { describe, expect, test } from 'bun:test';
|
|
|
11
11
|
|
|
12
12
|
import type {
|
|
13
13
|
FollowFn,
|
|
14
|
+
ServiceOverrideMap,
|
|
14
15
|
Topo,
|
|
15
16
|
TrailExample,
|
|
16
17
|
Trail,
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
NotFoundError,
|
|
30
31
|
PermissionError,
|
|
31
32
|
RateLimitError,
|
|
33
|
+
executeTrail,
|
|
32
34
|
Result,
|
|
33
35
|
TimeoutError,
|
|
34
36
|
TrailsError,
|
|
@@ -41,9 +43,14 @@ import {
|
|
|
41
43
|
assertErrorMatch,
|
|
42
44
|
assertFullMatch,
|
|
43
45
|
assertSchemaMatch,
|
|
44
|
-
expectOk,
|
|
45
46
|
} from './assertions.js';
|
|
46
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
mergeServiceOverrides,
|
|
49
|
+
mergeTestContext,
|
|
50
|
+
normalizeTestExecutionOptions,
|
|
51
|
+
resolveMockServices,
|
|
52
|
+
} from './context.js';
|
|
53
|
+
import type { TestExecutionOptions } from './context.js';
|
|
47
54
|
|
|
48
55
|
// ---------------------------------------------------------------------------
|
|
49
56
|
// Error class name -> constructor map
|
|
@@ -128,16 +135,19 @@ const runExample = async (
|
|
|
128
135
|
t: Trail<unknown, unknown>,
|
|
129
136
|
example: TrailExample<unknown, unknown>,
|
|
130
137
|
output: z.ZodType | undefined,
|
|
131
|
-
testCtx: TrailContext
|
|
138
|
+
testCtx: TrailContext,
|
|
139
|
+
services?: ServiceOverrideMap
|
|
132
140
|
): Promise<void> => {
|
|
133
141
|
const validated = validateInput(t.input, example.input);
|
|
134
142
|
|
|
135
143
|
if (handleValidationError(validated, example)) {
|
|
136
144
|
return;
|
|
137
145
|
}
|
|
138
|
-
const validatedInput = expectOk(validated);
|
|
139
146
|
|
|
140
|
-
const result = await t.
|
|
147
|
+
const result = await executeTrail(t, example.input, {
|
|
148
|
+
ctx: testCtx,
|
|
149
|
+
services,
|
|
150
|
+
});
|
|
141
151
|
assertProgressiveMatch(result, example, output);
|
|
142
152
|
};
|
|
143
153
|
|
|
@@ -156,7 +166,8 @@ const createCoverageFollow = (
|
|
|
156
166
|
called: Set<string>,
|
|
157
167
|
baseFollow: FollowFn | undefined,
|
|
158
168
|
topo: Topo,
|
|
159
|
-
ctx: TrailContext
|
|
169
|
+
ctx: TrailContext,
|
|
170
|
+
services?: ServiceOverrideMap
|
|
160
171
|
): FollowFn => {
|
|
161
172
|
const follow = (id: string, input: unknown) => {
|
|
162
173
|
called.add(id);
|
|
@@ -167,11 +178,10 @@ const createCoverageFollow = (
|
|
|
167
178
|
|
|
168
179
|
const trailDef = topo.get(id);
|
|
169
180
|
if (trailDef !== undefined) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
174
|
-
return Promise.resolve(trailDef.run(validated.value, ctx));
|
|
181
|
+
return executeTrail(trailDef, input, {
|
|
182
|
+
ctx: { ...ctx, follow },
|
|
183
|
+
services,
|
|
184
|
+
});
|
|
175
185
|
}
|
|
176
186
|
|
|
177
187
|
return Promise.resolve(Result.ok());
|
|
@@ -188,19 +198,28 @@ const runCompositionExample = async (
|
|
|
188
198
|
output: z.ZodType | undefined,
|
|
189
199
|
baseCtx: TrailContext,
|
|
190
200
|
called: Set<string>,
|
|
191
|
-
topo: Topo
|
|
201
|
+
topo: Topo,
|
|
202
|
+
services?: ServiceOverrideMap
|
|
192
203
|
): Promise<void> => {
|
|
193
204
|
const validated = validateInput(trailDef.input, example.input);
|
|
194
205
|
|
|
195
206
|
if (handleValidationError(validated, example)) {
|
|
196
207
|
return;
|
|
197
208
|
}
|
|
198
|
-
const validatedInput = expectOk(validated);
|
|
199
209
|
|
|
200
|
-
const follow = createCoverageFollow(
|
|
210
|
+
const follow = createCoverageFollow(
|
|
211
|
+
called,
|
|
212
|
+
baseCtx.follow,
|
|
213
|
+
topo,
|
|
214
|
+
baseCtx,
|
|
215
|
+
services
|
|
216
|
+
);
|
|
201
217
|
const testCtx: TrailContext = { ...baseCtx, follow };
|
|
202
218
|
|
|
203
|
-
const result = await trailDef.
|
|
219
|
+
const result = await executeTrail(trailDef, example.input, {
|
|
220
|
+
ctx: testCtx,
|
|
221
|
+
services,
|
|
222
|
+
});
|
|
204
223
|
assertProgressiveMatch(result, example, output);
|
|
205
224
|
};
|
|
206
225
|
|
|
@@ -221,9 +240,12 @@ const runCompositionExample = async (
|
|
|
221
240
|
*/
|
|
222
241
|
export const testExamples = (
|
|
223
242
|
app: Topo,
|
|
224
|
-
ctxOrFactory?:
|
|
243
|
+
ctxOrFactory?:
|
|
244
|
+
| Partial<TrailContext>
|
|
245
|
+
| TestExecutionOptions
|
|
246
|
+
| (() => Partial<TrailContext> | TestExecutionOptions)
|
|
225
247
|
): void => {
|
|
226
|
-
const
|
|
248
|
+
const resolveInput =
|
|
227
249
|
typeof ctxOrFactory === 'function' ? ctxOrFactory : () => ctxOrFactory;
|
|
228
250
|
const allTrails = app.list() as Trail<unknown, unknown>[];
|
|
229
251
|
|
|
@@ -244,8 +266,14 @@ export const testExamples = (
|
|
|
244
266
|
test.each([...examples])(
|
|
245
267
|
'example: $name',
|
|
246
268
|
async (example: TrailExample<unknown, unknown>) => {
|
|
247
|
-
const
|
|
248
|
-
|
|
269
|
+
const resolved = normalizeTestExecutionOptions(resolveInput());
|
|
270
|
+
const services = mergeServiceOverrides(
|
|
271
|
+
await resolveMockServices(app),
|
|
272
|
+
resolved.ctx,
|
|
273
|
+
resolved.services
|
|
274
|
+
);
|
|
275
|
+
const testCtx = mergeTestContext(resolved.ctx);
|
|
276
|
+
await runExample(t, example, output, testCtx, services);
|
|
249
277
|
}
|
|
250
278
|
);
|
|
251
279
|
});
|
|
@@ -264,8 +292,22 @@ export const testExamples = (
|
|
|
264
292
|
test.each([...examples])(
|
|
265
293
|
'example: $name',
|
|
266
294
|
async (example: TrailExample<unknown, unknown>) => {
|
|
267
|
-
const
|
|
268
|
-
|
|
295
|
+
const resolved = normalizeTestExecutionOptions(resolveInput());
|
|
296
|
+
const services = mergeServiceOverrides(
|
|
297
|
+
await resolveMockServices(app),
|
|
298
|
+
resolved.ctx,
|
|
299
|
+
resolved.services
|
|
300
|
+
);
|
|
301
|
+
const baseCtx = mergeTestContext(resolved.ctx);
|
|
302
|
+
await runCompositionExample(
|
|
303
|
+
t,
|
|
304
|
+
example,
|
|
305
|
+
output,
|
|
306
|
+
baseCtx,
|
|
307
|
+
called,
|
|
308
|
+
app,
|
|
309
|
+
services
|
|
310
|
+
);
|
|
269
311
|
}
|
|
270
312
|
);
|
|
271
313
|
|
package/src/follows.ts
CHANGED
|
@@ -7,8 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
import { describe, expect, test } from 'bun:test';
|
|
9
9
|
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
AnyTrail,
|
|
12
|
+
FollowFn,
|
|
13
|
+
ServiceOverrideMap,
|
|
14
|
+
TrailContext,
|
|
15
|
+
} from '@ontrails/core';
|
|
11
16
|
import {
|
|
17
|
+
executeTrail,
|
|
12
18
|
InternalError,
|
|
13
19
|
Result,
|
|
14
20
|
ValidationError,
|
|
@@ -19,9 +25,8 @@ import {
|
|
|
19
25
|
assertErrorMatch,
|
|
20
26
|
assertFullMatch,
|
|
21
27
|
assertSchemaMatch,
|
|
22
|
-
expectOk,
|
|
23
28
|
} from './assertions.js';
|
|
24
|
-
import { mergeTestContext } from './context.js';
|
|
29
|
+
import { mergeServiceOverrides, mergeTestContext } from './context.js';
|
|
25
30
|
import type { FollowScenario } from './types.js';
|
|
26
31
|
|
|
27
32
|
// ---------------------------------------------------------------------------
|
|
@@ -33,6 +38,58 @@ interface FollowRecord {
|
|
|
33
38
|
readonly input: unknown;
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
const collectDeclaredServices = (
|
|
42
|
+
trailDef: AnyTrail,
|
|
43
|
+
trailsMap: ReadonlyMap<string, AnyTrail> | undefined
|
|
44
|
+
): AnyTrail['services'] => {
|
|
45
|
+
const seenServiceIds = new Set<string>();
|
|
46
|
+
const seenTrailIds = new Set<string>();
|
|
47
|
+
const services: AnyTrail['services'][number][] = [];
|
|
48
|
+
|
|
49
|
+
const collect = (candidate: AnyTrail): void => {
|
|
50
|
+
for (const declaredService of candidate.services) {
|
|
51
|
+
if (seenServiceIds.has(declaredService.id)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
seenServiceIds.add(declaredService.id);
|
|
55
|
+
services.push(declaredService);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const visit = (candidate: AnyTrail): void => {
|
|
60
|
+
if (seenTrailIds.has(candidate.id)) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
seenTrailIds.add(candidate.id);
|
|
64
|
+
collect(candidate);
|
|
65
|
+
for (const followedId of candidate.follow) {
|
|
66
|
+
const followedTrail = trailsMap?.get(followedId);
|
|
67
|
+
if (followedTrail) {
|
|
68
|
+
visit(followedTrail);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
visit(trailDef);
|
|
74
|
+
return services;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const resolveMockServices = async (
|
|
78
|
+
trailDef: AnyTrail,
|
|
79
|
+
trailsMap: ReadonlyMap<string, AnyTrail> | undefined
|
|
80
|
+
): Promise<ServiceOverrideMap> => {
|
|
81
|
+
const services: Record<string, unknown> = {};
|
|
82
|
+
|
|
83
|
+
for (const declaredService of collectDeclaredServices(trailDef, trailsMap)) {
|
|
84
|
+
if (!declaredService.mock) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
services[declaredService.id] = await declaredService.mock();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return services;
|
|
91
|
+
};
|
|
92
|
+
|
|
36
93
|
// ---------------------------------------------------------------------------
|
|
37
94
|
// Injection helpers
|
|
38
95
|
// ---------------------------------------------------------------------------
|
|
@@ -90,18 +147,20 @@ const executeFromMap = (
|
|
|
90
147
|
id: string,
|
|
91
148
|
input: unknown,
|
|
92
149
|
trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
|
|
93
|
-
ctx: TrailContext
|
|
150
|
+
ctx: TrailContext,
|
|
151
|
+
services: ServiceOverrideMap | undefined,
|
|
152
|
+
follow?: FollowFn
|
|
94
153
|
): Result<unknown, Error> | Promise<Result<unknown, Error>> | undefined => {
|
|
95
154
|
const trailDef = trailsMap?.get(id);
|
|
96
155
|
if (trailDef === undefined) {
|
|
97
156
|
return undefined;
|
|
98
157
|
}
|
|
99
158
|
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
159
|
+
const nestedCtx = follow ? { ...ctx, follow } : ctx;
|
|
160
|
+
return executeTrail(trailDef, input, {
|
|
161
|
+
ctx: nestedCtx,
|
|
162
|
+
services,
|
|
163
|
+
});
|
|
105
164
|
};
|
|
106
165
|
|
|
107
166
|
// ---------------------------------------------------------------------------
|
|
@@ -116,7 +175,8 @@ const createRecordingFollow = (
|
|
|
116
175
|
scenario: FollowScenario,
|
|
117
176
|
trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
|
|
118
177
|
baseFollow: FollowFn | undefined,
|
|
119
|
-
ctx: TrailContext
|
|
178
|
+
ctx: TrailContext,
|
|
179
|
+
services: ServiceOverrideMap | undefined
|
|
120
180
|
): FollowFn => {
|
|
121
181
|
// The generic O on FollowFn is erased at runtime; the cast is safe
|
|
122
182
|
// because callers narrow via isOk/isErr before accessing the value.
|
|
@@ -132,7 +192,14 @@ const createRecordingFollow = (
|
|
|
132
192
|
return baseFollow(id, input);
|
|
133
193
|
}
|
|
134
194
|
|
|
135
|
-
const executed = executeFromMap(
|
|
195
|
+
const executed = executeFromMap(
|
|
196
|
+
id,
|
|
197
|
+
input,
|
|
198
|
+
trailsMap,
|
|
199
|
+
ctx,
|
|
200
|
+
services,
|
|
201
|
+
follow as FollowFn
|
|
202
|
+
);
|
|
136
203
|
if (executed !== undefined) {
|
|
137
204
|
return Promise.resolve(executed);
|
|
138
205
|
}
|
|
@@ -209,7 +276,8 @@ const handleValidationError = (
|
|
|
209
276
|
const buildTestContext = (
|
|
210
277
|
scenario: FollowScenario,
|
|
211
278
|
ctx: Partial<TrailContext> | undefined,
|
|
212
|
-
trailsMap: ReadonlyMap<string, AnyTrail> | undefined
|
|
279
|
+
trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
|
|
280
|
+
services: ServiceOverrideMap | undefined
|
|
213
281
|
): { trace: FollowRecord[]; testCtx: TrailContext } => {
|
|
214
282
|
const trace: FollowRecord[] = [];
|
|
215
283
|
const baseCtx = mergeTestContext(ctx);
|
|
@@ -218,7 +286,8 @@ const buildTestContext = (
|
|
|
218
286
|
scenario,
|
|
219
287
|
trailsMap,
|
|
220
288
|
baseCtx.follow,
|
|
221
|
-
baseCtx
|
|
289
|
+
baseCtx,
|
|
290
|
+
services
|
|
222
291
|
);
|
|
223
292
|
return { testCtx: { ...baseCtx, follow }, trace };
|
|
224
293
|
};
|
|
@@ -227,15 +296,24 @@ const runScenario = async (
|
|
|
227
296
|
trailDef: AnyTrail,
|
|
228
297
|
scenario: FollowScenario,
|
|
229
298
|
ctx: Partial<TrailContext> | undefined,
|
|
230
|
-
trailsMap: ReadonlyMap<string, AnyTrail> | undefined
|
|
299
|
+
trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
|
|
300
|
+
services: ServiceOverrideMap | undefined
|
|
231
301
|
): Promise<void> => {
|
|
232
302
|
const validated = validateInput(trailDef.input, scenario.input);
|
|
233
303
|
if (handleValidationError(validated, scenario)) {
|
|
234
304
|
return;
|
|
235
305
|
}
|
|
236
306
|
|
|
237
|
-
const { trace, testCtx } = buildTestContext(
|
|
238
|
-
|
|
307
|
+
const { trace, testCtx } = buildTestContext(
|
|
308
|
+
scenario,
|
|
309
|
+
ctx,
|
|
310
|
+
trailsMap,
|
|
311
|
+
services
|
|
312
|
+
);
|
|
313
|
+
const result = await executeTrail(trailDef, scenario.input, {
|
|
314
|
+
ctx: testCtx,
|
|
315
|
+
services,
|
|
316
|
+
});
|
|
239
317
|
assertFollowTrace(trace, scenario);
|
|
240
318
|
assertScenarioResult(result, scenario, trailDef);
|
|
241
319
|
};
|
|
@@ -248,6 +326,12 @@ const runScenario = async (
|
|
|
248
326
|
export interface TestFollowOptions {
|
|
249
327
|
/** Partial context overrides. */
|
|
250
328
|
readonly ctx?: Partial<TrailContext> | undefined;
|
|
329
|
+
/**
|
|
330
|
+
* Explicit service overrides merged on top of auto-resolved mocks for every
|
|
331
|
+
* scenario. Values are passed by reference — provide immutable objects, or
|
|
332
|
+
* use `mock()` on the service definition to get a fresh instance per run.
|
|
333
|
+
*/
|
|
334
|
+
readonly services?: ServiceOverrideMap | undefined;
|
|
251
335
|
/** Map of trail ID to trail definition, used for injectFromExample. */
|
|
252
336
|
readonly trails?: ReadonlyMap<string, AnyTrail> | undefined;
|
|
253
337
|
}
|
|
@@ -276,7 +360,18 @@ export const testFollows = (
|
|
|
276
360
|
test.each([...scenarios])(
|
|
277
361
|
'$description',
|
|
278
362
|
async (scenario: FollowScenario) => {
|
|
279
|
-
|
|
363
|
+
const services = mergeServiceOverrides(
|
|
364
|
+
await resolveMockServices(trailDef, options?.trails),
|
|
365
|
+
options?.ctx,
|
|
366
|
+
options?.services
|
|
367
|
+
);
|
|
368
|
+
await runScenario(
|
|
369
|
+
trailDef,
|
|
370
|
+
scenario,
|
|
371
|
+
options?.ctx,
|
|
372
|
+
options?.trails,
|
|
373
|
+
services
|
|
374
|
+
);
|
|
280
375
|
}
|
|
281
376
|
);
|
|
282
377
|
});
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ export { createMcpHarness } from './harness-mcp.js';
|
|
|
25
25
|
|
|
26
26
|
// Types
|
|
27
27
|
export type { CreateFollowContextOptions } from './context.js';
|
|
28
|
+
export type { TestExecutionOptions } from './context.js';
|
|
28
29
|
export type { TestFollowOptions } from './follows.js';
|
|
29
30
|
|
|
30
31
|
export type {
|