@ontrails/testing 1.0.0-beta.1 → 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 +166 -0
- package/README.md +25 -7
- 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 +28 -2
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +70 -11
- package/dist/context.js.map +1 -1
- package/dist/contracts.d.ts +3 -2
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +25 -10
- package/dist/contracts.js.map +1 -1
- package/dist/examples.d.ts +4 -3
- package/dist/examples.d.ts.map +1 -1
- package/dist/examples.js +63 -68
- package/dist/examples.js.map +1 -1
- package/dist/follows.d.ts +38 -0
- package/dist/follows.d.ts.map +1 -0
- package/dist/{hike.js → follows.js} +71 -28
- package/dist/follows.js.map +1 -0
- package/dist/harness-mcp.d.ts.map +1 -1
- package/dist/harness-mcp.js +5 -2
- package/dist/harness-mcp.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/trail.js +1 -1
- package/dist/trail.js.map +1 -1
- package/dist/types.d.ts +2 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/__tests__/all.test.ts +64 -0
- package/src/__tests__/context.test.ts +92 -2
- package/src/__tests__/contracts.test.ts +122 -5
- package/src/__tests__/detours.test.ts +3 -3
- package/src/__tests__/examples.test.ts +285 -22
- package/src/__tests__/follows.test.ts +589 -0
- package/src/__tests__/trail.test.ts +4 -4
- package/src/all.ts +5 -1
- package/src/context.ts +121 -13
- package/src/contracts.ts +43 -12
- package/src/examples.ts +111 -105
- package/src/{hike.ts → follows.ts} +138 -43
- package/src/harness-mcp.ts +5 -2
- package/src/index.ts +6 -4
- package/src/trail.ts +1 -1
- package/src/types.ts +3 -3
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/hike.d.ts +0 -32
- package/dist/hike.d.ts.map +0 -1
- package/dist/hike.js.map +0 -1
- package/src/__tests__/hike.test.ts +0 -164
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
import { describe } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import type { AnyTrail, TrailContext } from '@ontrails/core';
|
|
4
|
+
import { InternalError, Result, service, trail } from '@ontrails/core';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
|
|
7
|
+
import { testFollows } from '../follows.js';
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Test trails (followed by composition trail)
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
const addTrail = trail('entity.add', {
|
|
14
|
+
description: 'Add an entity',
|
|
15
|
+
examples: [
|
|
16
|
+
{ input: { name: 'Alpha' }, name: 'success' },
|
|
17
|
+
{
|
|
18
|
+
description: 'duplicate name',
|
|
19
|
+
error: 'AlreadyExistsError',
|
|
20
|
+
input: { name: '' },
|
|
21
|
+
name: 'duplicate',
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
input: z.object({ name: z.string() }),
|
|
25
|
+
output: z.object({ id: z.string(), name: z.string() }),
|
|
26
|
+
run: (input: { name: string }) => Result.ok({ id: '1', name: input.name }),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const relateTrail = trail('entity.relate', {
|
|
30
|
+
description: 'Relate two entities',
|
|
31
|
+
input: z.object({ from: z.string(), to: z.string() }),
|
|
32
|
+
output: z.object({ from: z.string(), to: z.string() }),
|
|
33
|
+
run: (input: { from: string; to: string }) =>
|
|
34
|
+
Result.ok({ from: input.from, to: input.to }),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Composition trail
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
const onboardTrail = trail('entity.onboard', {
|
|
42
|
+
follow: ['entity.add', 'entity.relate'],
|
|
43
|
+
input: z.object({ name: z.string(), relatedTo: z.string() }),
|
|
44
|
+
output: z.object({ name: z.string(), relatedTo: z.string() }),
|
|
45
|
+
run: async (
|
|
46
|
+
input: { name: string; relatedTo: string },
|
|
47
|
+
ctx: TrailContext
|
|
48
|
+
) => {
|
|
49
|
+
if (!ctx.follow) {
|
|
50
|
+
return Result.err(new Error('follow not available'));
|
|
51
|
+
}
|
|
52
|
+
const addResult = await ctx.follow<{ id: string; name: string }>(
|
|
53
|
+
'entity.add',
|
|
54
|
+
{ name: input.name }
|
|
55
|
+
);
|
|
56
|
+
if (addResult.isErr()) {
|
|
57
|
+
return Result.err(addResult.error);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const relateResult = await ctx.follow<{ from: string; to: string }>(
|
|
61
|
+
'entity.relate',
|
|
62
|
+
{ from: addResult.value.name, to: input.relatedTo }
|
|
63
|
+
);
|
|
64
|
+
if (relateResult.isErr()) {
|
|
65
|
+
return Result.err(relateResult.error);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return Result.ok({
|
|
69
|
+
name: addResult.value.name,
|
|
70
|
+
relatedTo: relateResult.value.to,
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const trailsMap = new Map<string, AnyTrail>([
|
|
76
|
+
['entity.add', addTrail],
|
|
77
|
+
['entity.relate', relateTrail],
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Tests
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
const opts = { trails: trailsMap };
|
|
85
|
+
|
|
86
|
+
describe('testFollows: expectOk', () => {
|
|
87
|
+
// eslint-disable-next-line jest/require-hook
|
|
88
|
+
testFollows(
|
|
89
|
+
onboardTrail,
|
|
90
|
+
[
|
|
91
|
+
{
|
|
92
|
+
description: 'basic onboard succeeds',
|
|
93
|
+
expectOk: true,
|
|
94
|
+
input: { name: 'Alpha', relatedTo: 'Beta' },
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
opts
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('testFollows: expectFollowed', () => {
|
|
102
|
+
// eslint-disable-next-line jest/require-hook
|
|
103
|
+
testFollows(
|
|
104
|
+
onboardTrail,
|
|
105
|
+
[
|
|
106
|
+
{
|
|
107
|
+
description: 'follows add then relate in order',
|
|
108
|
+
expectFollowed: ['entity.add', 'entity.relate'],
|
|
109
|
+
expectOk: true,
|
|
110
|
+
input: { name: 'Alpha', relatedTo: 'Beta' },
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
opts
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('testFollows: expectFollowedCount', () => {
|
|
118
|
+
// eslint-disable-next-line jest/require-hook
|
|
119
|
+
testFollows(
|
|
120
|
+
onboardTrail,
|
|
121
|
+
[
|
|
122
|
+
{
|
|
123
|
+
description: 'each trail followed exactly once',
|
|
124
|
+
expectFollowedCount: { 'entity.add': 1, 'entity.relate': 1 },
|
|
125
|
+
expectOk: true,
|
|
126
|
+
input: { name: 'Alpha', relatedTo: 'Beta' },
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
opts
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('testFollows: injectFromExample', () => {
|
|
134
|
+
// eslint-disable-next-line jest/require-hook
|
|
135
|
+
testFollows(
|
|
136
|
+
onboardTrail,
|
|
137
|
+
[
|
|
138
|
+
{
|
|
139
|
+
description: 'inject duplicate error from add trail example',
|
|
140
|
+
expectErr: Error,
|
|
141
|
+
expectErrMessage: 'AlreadyExistsError',
|
|
142
|
+
injectFromExample: { 'entity.add': 'duplicate' },
|
|
143
|
+
input: { name: 'Alpha', relatedTo: 'Beta' },
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
opts
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('testFollows: expectValue', () => {
|
|
151
|
+
// eslint-disable-next-line jest/require-hook
|
|
152
|
+
testFollows(
|
|
153
|
+
onboardTrail,
|
|
154
|
+
[
|
|
155
|
+
{
|
|
156
|
+
description: 'exact value match',
|
|
157
|
+
expectValue: { name: 'Alpha', relatedTo: 'Beta' },
|
|
158
|
+
input: { name: 'Alpha', relatedTo: 'Beta' },
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
opts
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Nested follow chain: A → B → C
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
const leafTrail = trail('step.leaf', {
|
|
170
|
+
description: 'Leaf trail in a nested chain',
|
|
171
|
+
input: z.object({ value: z.string() }),
|
|
172
|
+
output: z.object({ leaf: z.string() }),
|
|
173
|
+
run: (input: { value: string }) => Result.ok({ leaf: input.value }),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const middleTrail = trail('step.middle', {
|
|
177
|
+
description: 'Middle trail that follows the leaf',
|
|
178
|
+
follow: ['step.leaf'],
|
|
179
|
+
input: z.object({ value: z.string() }),
|
|
180
|
+
output: z.object({ middle: z.string() }),
|
|
181
|
+
run: async (input: { value: string }, ctx: TrailContext) => {
|
|
182
|
+
if (!ctx.follow) {
|
|
183
|
+
return Result.err(new Error('follow not available'));
|
|
184
|
+
}
|
|
185
|
+
const leafResult = await ctx.follow<{ leaf: string }>('step.leaf', input);
|
|
186
|
+
if (leafResult.isErr()) {
|
|
187
|
+
return leafResult;
|
|
188
|
+
}
|
|
189
|
+
return Result.ok({ middle: leafResult.value.leaf });
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const nestedRootTrail = trail('step.root', {
|
|
194
|
+
description: 'Root trail that follows the middle trail',
|
|
195
|
+
follow: ['step.middle'],
|
|
196
|
+
input: z.object({ value: z.string() }),
|
|
197
|
+
output: z.object({ root: z.string() }),
|
|
198
|
+
run: async (input: { value: string }, ctx: TrailContext) => {
|
|
199
|
+
if (!ctx.follow) {
|
|
200
|
+
return Result.err(new Error('follow not available'));
|
|
201
|
+
}
|
|
202
|
+
const midResult = await ctx.follow<{ middle: string }>(
|
|
203
|
+
'step.middle',
|
|
204
|
+
input
|
|
205
|
+
);
|
|
206
|
+
if (midResult.isErr()) {
|
|
207
|
+
return midResult;
|
|
208
|
+
}
|
|
209
|
+
return Result.ok({ root: midResult.value.middle });
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const nestedTrailsMap = new Map<string, AnyTrail>([
|
|
214
|
+
['step.leaf', leafTrail],
|
|
215
|
+
['step.middle', middleTrail],
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
describe('testFollows: nested follow chain (A → B → C)', () => {
|
|
219
|
+
// eslint-disable-next-line jest/require-hook
|
|
220
|
+
testFollows(
|
|
221
|
+
nestedRootTrail,
|
|
222
|
+
[
|
|
223
|
+
{
|
|
224
|
+
description: 'nested ctx.follow works through A → B → C',
|
|
225
|
+
expectOk: true,
|
|
226
|
+
expectValue: { root: 'hello' },
|
|
227
|
+
input: { value: 'hello' },
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
{ trails: nestedTrailsMap }
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const mockDbService = service('db.mock.follows', {
|
|
235
|
+
create: () => Result.ok({ source: 'factory' }),
|
|
236
|
+
mock: () => ({ source: 'mock' }),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const serviceLeafTrail = trail('service.leaf', {
|
|
240
|
+
description: 'Leaf trail that reads from a service',
|
|
241
|
+
input: z.object({}),
|
|
242
|
+
output: z.object({ childSource: z.string() }),
|
|
243
|
+
run: (_input, ctx) =>
|
|
244
|
+
Result.ok({ childSource: mockDbService.from(ctx).source }),
|
|
245
|
+
services: [mockDbService],
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const serviceRootTrail = trail('service.root', {
|
|
249
|
+
description: 'Root trail that reads from a service and follows a child trail',
|
|
250
|
+
follow: ['service.leaf'],
|
|
251
|
+
input: z.object({}),
|
|
252
|
+
output: z.object({ childSource: z.string(), rootSource: z.string() }),
|
|
253
|
+
run: async (_input, ctx: TrailContext) => {
|
|
254
|
+
if (!ctx.follow) {
|
|
255
|
+
return Result.err(new Error('follow not available'));
|
|
256
|
+
}
|
|
257
|
+
const childResult = await ctx.follow<{ childSource: string }>(
|
|
258
|
+
'service.leaf',
|
|
259
|
+
{}
|
|
260
|
+
);
|
|
261
|
+
if (childResult.isErr()) {
|
|
262
|
+
return childResult;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return Result.ok({
|
|
266
|
+
childSource: childResult.value.childSource,
|
|
267
|
+
rootSource: mockDbService.from(ctx).source,
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
services: [mockDbService],
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const serviceTrailsMap = new Map<string, AnyTrail>([
|
|
274
|
+
['service.leaf', serviceLeafTrail],
|
|
275
|
+
]);
|
|
276
|
+
|
|
277
|
+
const statefulMockDbService = service('db.mock.follows.stateful', {
|
|
278
|
+
create: () => Result.ok({ count: 0 }),
|
|
279
|
+
mock: () => ({ count: 0 }),
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const statefulServiceLeafTrail = trail('service.stateful.leaf', {
|
|
283
|
+
description: 'Leaf trail that observes the current mock service state',
|
|
284
|
+
input: z.object({}),
|
|
285
|
+
output: z.object({ childCount: z.number() }),
|
|
286
|
+
run: (_input, ctx) =>
|
|
287
|
+
Result.ok({ childCount: statefulMockDbService.from(ctx).count }),
|
|
288
|
+
services: [statefulMockDbService],
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const statefulServiceRootTrail = trail('service.stateful.root', {
|
|
292
|
+
description:
|
|
293
|
+
'Root trail that mutates a mocked service and follows a child trail',
|
|
294
|
+
follow: ['service.stateful.leaf'],
|
|
295
|
+
input: z.object({}),
|
|
296
|
+
output: z.object({ childCount: z.number(), rootCount: z.number() }),
|
|
297
|
+
run: async (_input, ctx: TrailContext) => {
|
|
298
|
+
if (!ctx.follow) {
|
|
299
|
+
return Result.err(new Error('follow not available'));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const statefulService = statefulMockDbService.from(ctx);
|
|
303
|
+
statefulService.count += 1;
|
|
304
|
+
|
|
305
|
+
const childResult = await ctx.follow<{ childCount: number }>(
|
|
306
|
+
'service.stateful.leaf',
|
|
307
|
+
{}
|
|
308
|
+
);
|
|
309
|
+
if (childResult.isErr()) {
|
|
310
|
+
return childResult;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return Result.ok({
|
|
314
|
+
childCount: childResult.value.childCount,
|
|
315
|
+
rootCount: statefulService.count,
|
|
316
|
+
});
|
|
317
|
+
},
|
|
318
|
+
services: [statefulMockDbService],
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const statefulServiceTrailsMap = new Map<string, AnyTrail>([
|
|
322
|
+
['service.stateful.leaf', statefulServiceLeafTrail],
|
|
323
|
+
]);
|
|
324
|
+
|
|
325
|
+
const scenarioStateService = service('db.mock.scenarios', {
|
|
326
|
+
create: () => Result.ok({ count: 0 }),
|
|
327
|
+
mock: () => ({ count: 0 }),
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const scenarioLeafTrail = trail('service.scenario.leaf', {
|
|
331
|
+
description: 'Leaf trail that reads mutable scenario state',
|
|
332
|
+
input: z.object({}),
|
|
333
|
+
output: z.object({ count: z.number() }),
|
|
334
|
+
run: (_input, ctx) =>
|
|
335
|
+
Result.ok({ count: scenarioStateService.from(ctx).count }),
|
|
336
|
+
services: [scenarioStateService],
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const scenarioRootTrail = trail('service.scenario.root', {
|
|
340
|
+
description: 'Root trail that mutates scenario state and follows a leaf',
|
|
341
|
+
follow: ['service.scenario.leaf'],
|
|
342
|
+
input: z.object({}),
|
|
343
|
+
output: z.object({ count: z.number() }),
|
|
344
|
+
run: async (_input, ctx: TrailContext) => {
|
|
345
|
+
if (!ctx.follow) {
|
|
346
|
+
return Result.err(new Error('follow not available'));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const state = scenarioStateService.from(ctx);
|
|
350
|
+
state.count += 1;
|
|
351
|
+
|
|
352
|
+
const leafResult = await ctx.follow<{ count: number }>(
|
|
353
|
+
'service.scenario.leaf',
|
|
354
|
+
{}
|
|
355
|
+
);
|
|
356
|
+
if (leafResult.isErr()) {
|
|
357
|
+
return leafResult;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return Result.ok({ count: leafResult.value.count });
|
|
361
|
+
},
|
|
362
|
+
services: [scenarioStateService],
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const scenarioTrailsMap = new Map<string, AnyTrail>([
|
|
366
|
+
['service.scenario.leaf', scenarioLeafTrail],
|
|
367
|
+
]);
|
|
368
|
+
|
|
369
|
+
const transformedFollowLeafTrail = trail('follow.transformed.leaf', {
|
|
370
|
+
description: 'Leaf trail in a transformed follow chain',
|
|
371
|
+
input: z.object({ value: z.number() }),
|
|
372
|
+
output: z.object({ value: z.number() }),
|
|
373
|
+
run: (input: { value: number }) => Result.ok({ value: input.value }),
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const transformedFollowRootTrail = trail('follow.transformed.root', {
|
|
377
|
+
description: 'Root trail that transforms input once before following',
|
|
378
|
+
follow: ['follow.transformed.leaf'],
|
|
379
|
+
input: z
|
|
380
|
+
.object({ value: z.string() })
|
|
381
|
+
.transform(({ value }) => ({ value: Number(value) + 1 })),
|
|
382
|
+
output: z.object({ root: z.number() }),
|
|
383
|
+
run: async (input: { value: number }, ctx: TrailContext) => {
|
|
384
|
+
if (!ctx.follow) {
|
|
385
|
+
return Result.err(new Error('follow not available'));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const leafResult = await ctx.follow<{ value: number }>(
|
|
389
|
+
'follow.transformed.leaf',
|
|
390
|
+
{ value: input.value }
|
|
391
|
+
);
|
|
392
|
+
if (leafResult.isErr()) {
|
|
393
|
+
return leafResult;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return Result.ok({ root: leafResult.value.value });
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const transformedFollowTrailsMap = new Map<string, AnyTrail>([
|
|
401
|
+
['follow.transformed.leaf', transformedFollowLeafTrail],
|
|
402
|
+
]);
|
|
403
|
+
|
|
404
|
+
const undeclaredFollowService = service('db.undeclared.follows', {
|
|
405
|
+
create: () => Result.ok({ source: 'factory' }),
|
|
406
|
+
mock: () => ({ source: 'mock' }),
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const undeclaredServiceLeafTrail = trail('service.undeclared.leaf', {
|
|
410
|
+
description: 'Leaf trail that declares the shared service',
|
|
411
|
+
input: z.object({}),
|
|
412
|
+
output: z.object({ childSource: z.string() }),
|
|
413
|
+
run: (_input, ctx) =>
|
|
414
|
+
Result.ok({ childSource: undeclaredFollowService.from(ctx).source }),
|
|
415
|
+
services: [undeclaredFollowService],
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const undeclaredServiceRootTrail = trail('service.undeclared.root', {
|
|
419
|
+
description: 'Root trail that uses a service without declaring it',
|
|
420
|
+
follow: ['service.undeclared.leaf'],
|
|
421
|
+
input: z.object({}),
|
|
422
|
+
output: z.object({ childSource: z.string(), rootSource: z.string() }),
|
|
423
|
+
run: async (_input, ctx: TrailContext) => {
|
|
424
|
+
if (!ctx.follow) {
|
|
425
|
+
return Result.err(new Error('follow not available'));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const childResult = await ctx.follow<{ childSource: string }>(
|
|
429
|
+
'service.undeclared.leaf',
|
|
430
|
+
{}
|
|
431
|
+
);
|
|
432
|
+
if (childResult.isErr()) {
|
|
433
|
+
return childResult;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return Result.ok({
|
|
437
|
+
childSource: childResult.value.childSource,
|
|
438
|
+
rootSource: undeclaredFollowService.from(ctx).source,
|
|
439
|
+
});
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
const undeclaredServiceTrailsMap = new Map<string, AnyTrail>([
|
|
444
|
+
['service.undeclared.leaf', undeclaredServiceLeafTrail],
|
|
445
|
+
]);
|
|
446
|
+
|
|
447
|
+
const unrelatedFollowService = service('db.unrelated.follows', {
|
|
448
|
+
create: () => Result.ok({ source: 'factory' }),
|
|
449
|
+
mock: () => {
|
|
450
|
+
throw new Error('unrelated mock should not be resolved');
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const unrelatedServiceTrail = trail('service.unrelated', {
|
|
455
|
+
description: 'Trail that should not be traversed or mocked',
|
|
456
|
+
input: z.object({}),
|
|
457
|
+
output: z.object({ source: z.string() }),
|
|
458
|
+
run: (_input, ctx) =>
|
|
459
|
+
Result.ok({ source: unrelatedFollowService.from(ctx).source }),
|
|
460
|
+
services: [unrelatedFollowService],
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const unrelatedServiceTrailsMap = new Map<string, AnyTrail>([
|
|
464
|
+
['service.unrelated', unrelatedServiceTrail],
|
|
465
|
+
]);
|
|
466
|
+
|
|
467
|
+
describe('testFollows service mocks', () => {
|
|
468
|
+
// eslint-disable-next-line jest/require-hook
|
|
469
|
+
testFollows(
|
|
470
|
+
serviceRootTrail,
|
|
471
|
+
[
|
|
472
|
+
{
|
|
473
|
+
description: 'propagates auto-resolved service mocks through follow',
|
|
474
|
+
expectValue: { childSource: 'mock', rootSource: 'mock' },
|
|
475
|
+
input: {},
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
{ trails: serviceTrailsMap }
|
|
479
|
+
);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
describe('testFollows service mocks are fresh per scenario', () => {
|
|
483
|
+
// eslint-disable-next-line jest/require-hook
|
|
484
|
+
testFollows(
|
|
485
|
+
scenarioRootTrail,
|
|
486
|
+
[
|
|
487
|
+
{
|
|
488
|
+
description: 'first scenario sees a fresh mutable service',
|
|
489
|
+
expectValue: { count: 1 },
|
|
490
|
+
input: {},
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
description: 'second scenario also sees a fresh mutable service',
|
|
494
|
+
expectValue: { count: 1 },
|
|
495
|
+
input: {},
|
|
496
|
+
},
|
|
497
|
+
],
|
|
498
|
+
{ trails: scenarioTrailsMap }
|
|
499
|
+
);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
describe('testFollows explicit service overrides', () => {
|
|
503
|
+
// eslint-disable-next-line jest/require-hook
|
|
504
|
+
testFollows(
|
|
505
|
+
serviceRootTrail,
|
|
506
|
+
[
|
|
507
|
+
{
|
|
508
|
+
description: 'propagates explicit service overrides through follow',
|
|
509
|
+
expectValue: { childSource: 'override', rootSource: 'override' },
|
|
510
|
+
input: {},
|
|
511
|
+
},
|
|
512
|
+
],
|
|
513
|
+
{
|
|
514
|
+
services: { 'db.mock.follows': { source: 'override' } },
|
|
515
|
+
trails: serviceTrailsMap,
|
|
516
|
+
}
|
|
517
|
+
);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
describe('testFollows raw transformed input', () => {
|
|
521
|
+
// eslint-disable-next-line jest/require-hook
|
|
522
|
+
testFollows(
|
|
523
|
+
transformedFollowRootTrail,
|
|
524
|
+
[
|
|
525
|
+
{
|
|
526
|
+
description: 'raw scenario input is only transformed once',
|
|
527
|
+
expectValue: { root: 2 },
|
|
528
|
+
input: { value: '1' },
|
|
529
|
+
},
|
|
530
|
+
],
|
|
531
|
+
{ trails: transformedFollowTrailsMap }
|
|
532
|
+
);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
describe('testFollows service declarations', () => {
|
|
536
|
+
// eslint-disable-next-line jest/require-hook
|
|
537
|
+
testFollows(
|
|
538
|
+
undeclaredServiceRootTrail,
|
|
539
|
+
[
|
|
540
|
+
{
|
|
541
|
+
description: 'fails when the root trail omits a required service',
|
|
542
|
+
expectErr: InternalError,
|
|
543
|
+
expectErrMessage: undeclaredFollowService.id,
|
|
544
|
+
input: {},
|
|
545
|
+
},
|
|
546
|
+
],
|
|
547
|
+
{ trails: undeclaredServiceTrailsMap }
|
|
548
|
+
);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
describe('testFollows only resolves mocks for trails under test', () => {
|
|
552
|
+
// eslint-disable-next-line jest/require-hook
|
|
553
|
+
testFollows(
|
|
554
|
+
serviceRootTrail,
|
|
555
|
+
[
|
|
556
|
+
{
|
|
557
|
+
description: 'unrelated service mocks are not resolved',
|
|
558
|
+
expectValue: { childSource: 'mock', rootSource: 'mock' },
|
|
559
|
+
input: {},
|
|
560
|
+
},
|
|
561
|
+
],
|
|
562
|
+
{
|
|
563
|
+
trails: new Map<string, AnyTrail>([
|
|
564
|
+
...serviceTrailsMap.entries(),
|
|
565
|
+
...unrelatedServiceTrailsMap.entries(),
|
|
566
|
+
]),
|
|
567
|
+
}
|
|
568
|
+
);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
describe('testFollows fresh service mocks per scenario', () => {
|
|
572
|
+
// eslint-disable-next-line jest/require-hook
|
|
573
|
+
testFollows(
|
|
574
|
+
statefulServiceRootTrail,
|
|
575
|
+
[
|
|
576
|
+
{
|
|
577
|
+
description: 'first scenario gets a fresh service mock',
|
|
578
|
+
expectValue: { childCount: 1, rootCount: 1 },
|
|
579
|
+
input: {},
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
description: 'second scenario also gets a fresh service mock',
|
|
583
|
+
expectValue: { childCount: 1, rootCount: 1 },
|
|
584
|
+
input: {},
|
|
585
|
+
},
|
|
586
|
+
],
|
|
587
|
+
{ trails: statefulServiceTrailsMap }
|
|
588
|
+
);
|
|
589
|
+
});
|
|
@@ -10,20 +10,20 @@ import { testTrail } from '../trail.js';
|
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
11
|
|
|
12
12
|
const greetTrail = trail('greet', {
|
|
13
|
-
implementation: (input: { name: string }) =>
|
|
14
|
-
Result.ok({ greeting: `Hello, ${input.name}` }),
|
|
15
13
|
input: z.object({ name: z.string() }),
|
|
16
14
|
output: z.object({ greeting: z.string() }),
|
|
15
|
+
run: (input: { name: string }) =>
|
|
16
|
+
Result.ok({ greeting: `Hello, ${input.name}` }),
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
const failTrail = trail('fail', {
|
|
20
|
-
|
|
20
|
+
input: z.object({ id: z.string() }),
|
|
21
|
+
run: (input: { id: string }) => {
|
|
21
22
|
if (input.id === 'missing') {
|
|
22
23
|
return Result.err(new NotFoundError('Not found: missing'));
|
|
23
24
|
}
|
|
24
25
|
return Result.ok({ id: input.id });
|
|
25
26
|
},
|
|
26
|
-
input: z.object({ id: z.string() }),
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
// ---------------------------------------------------------------------------
|
package/src/all.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { Topo, TrailContext } from '@ontrails/core';
|
|
|
11
11
|
import { validateTopo } from '@ontrails/core';
|
|
12
12
|
|
|
13
13
|
import { testContracts } from './contracts.js';
|
|
14
|
+
import type { TestExecutionOptions } from './context.js';
|
|
14
15
|
import { testDetours } from './detours.js';
|
|
15
16
|
import { testExamples } from './examples.js';
|
|
16
17
|
|
|
@@ -37,7 +38,10 @@ import { testExamples } from './examples.js';
|
|
|
37
38
|
*/
|
|
38
39
|
export const testAll = (
|
|
39
40
|
topo: Topo,
|
|
40
|
-
ctxOrFactory?:
|
|
41
|
+
ctxOrFactory?:
|
|
42
|
+
| Partial<TrailContext>
|
|
43
|
+
| TestExecutionOptions
|
|
44
|
+
| (() => Partial<TrailContext> | TestExecutionOptions)
|
|
41
45
|
): void => {
|
|
42
46
|
describe('governance', () => {
|
|
43
47
|
test('topo validates', () => {
|