@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.
Files changed (55) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +166 -0
  3. package/README.md +25 -7
  4. package/dist/all.d.ts +2 -1
  5. package/dist/all.d.ts.map +1 -1
  6. package/dist/all.js.map +1 -1
  7. package/dist/context.d.ts +28 -2
  8. package/dist/context.d.ts.map +1 -1
  9. package/dist/context.js +70 -11
  10. package/dist/context.js.map +1 -1
  11. package/dist/contracts.d.ts +3 -2
  12. package/dist/contracts.d.ts.map +1 -1
  13. package/dist/contracts.js +25 -10
  14. package/dist/contracts.js.map +1 -1
  15. package/dist/examples.d.ts +4 -3
  16. package/dist/examples.d.ts.map +1 -1
  17. package/dist/examples.js +63 -68
  18. package/dist/examples.js.map +1 -1
  19. package/dist/follows.d.ts +38 -0
  20. package/dist/follows.d.ts.map +1 -0
  21. package/dist/{hike.js → follows.js} +71 -28
  22. package/dist/follows.js.map +1 -0
  23. package/dist/harness-mcp.d.ts.map +1 -1
  24. package/dist/harness-mcp.js +5 -2
  25. package/dist/harness-mcp.js.map +1 -1
  26. package/dist/index.d.ts +6 -4
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +2 -2
  29. package/dist/index.js.map +1 -1
  30. package/dist/trail.js +1 -1
  31. package/dist/trail.js.map +1 -1
  32. package/dist/types.d.ts +2 -2
  33. package/dist/types.d.ts.map +1 -1
  34. package/package.json +6 -6
  35. package/src/__tests__/all.test.ts +64 -0
  36. package/src/__tests__/context.test.ts +92 -2
  37. package/src/__tests__/contracts.test.ts +122 -5
  38. package/src/__tests__/detours.test.ts +3 -3
  39. package/src/__tests__/examples.test.ts +285 -22
  40. package/src/__tests__/follows.test.ts +589 -0
  41. package/src/__tests__/trail.test.ts +4 -4
  42. package/src/all.ts +5 -1
  43. package/src/context.ts +121 -13
  44. package/src/contracts.ts +43 -12
  45. package/src/examples.ts +111 -105
  46. package/src/{hike.ts → follows.ts} +138 -43
  47. package/src/harness-mcp.ts +5 -2
  48. package/src/index.ts +6 -4
  49. package/src/trail.ts +1 -1
  50. package/src/types.ts +3 -3
  51. package/tsconfig.tsbuildinfo +1 -1
  52. package/dist/hike.d.ts +0 -32
  53. package/dist/hike.d.ts.map +0 -1
  54. package/dist/hike.js.map +0 -1
  55. package/src/__tests__/hike.test.ts +0 -164
@@ -1,6 +1,6 @@
1
1
  import { describe, test } from 'bun:test';
2
2
 
3
- import { NotFoundError, Result, hike, trail, topo } from '@ontrails/core';
3
+ import { NotFoundError, Result, service, trail, topo } from '@ontrails/core';
4
4
  import { z } from 'zod';
5
5
 
6
6
  import { testExamples } from '../examples.js';
@@ -18,10 +18,10 @@ const greetTrail = trail('greet', {
18
18
  name: 'Greet Alice',
19
19
  },
20
20
  ],
21
- implementation: (input: { name: string }) =>
22
- Result.ok({ greeting: `Hello, ${input.name}` }),
23
21
  input: z.object({ name: z.string() }),
24
22
  output: z.object({ greeting: z.string() }),
23
+ run: (input: { name: string }) =>
24
+ Result.ok({ greeting: `Hello, ${input.name}` }),
25
25
  });
26
26
 
27
27
  const searchTrail = trail('search', {
@@ -32,10 +32,10 @@ const searchTrail = trail('search', {
32
32
  name: 'Schema-only search',
33
33
  },
34
34
  ],
35
- implementation: (input: { query: string }) =>
36
- Result.ok({ results: [`result for ${input.query}`] }),
37
35
  input: z.object({ query: z.string() }),
38
36
  output: z.object({ results: z.array(z.string()) }),
37
+ run: (input: { query: string }) =>
38
+ Result.ok({ results: [`result for ${input.query}`] }),
39
39
  });
40
40
 
41
41
  const entityTrail = trail('entity.show', {
@@ -52,42 +52,171 @@ const entityTrail = trail('entity.show', {
52
52
  name: 'Entity not found returns NotFoundError',
53
53
  },
54
54
  ],
55
- implementation: (input: { name: string }) => {
55
+ input: z.object({ name: z.string() }),
56
+ output: z.object({ id: z.number(), name: z.string() }),
57
+ run: (input: { name: string }) => {
56
58
  if (input.name === 'missing') {
57
59
  return Result.err(new NotFoundError('Entity not found'));
58
60
  }
59
61
  return Result.ok({ id: 1, name: input.name });
60
62
  },
61
- input: z.object({ name: z.string() }),
62
- output: z.object({ id: z.number(), name: z.string() }),
63
63
  });
64
64
 
65
65
  const noExamplesTrail = trail('noexamples', {
66
- implementation: (input: { x: number }) => Result.ok(input.x * 2),
67
66
  input: z.object({ x: z.number() }),
67
+ run: (input: { x: number }) => Result.ok(input.x * 2),
68
+ });
69
+
70
+ const mockDbService = service('db.mock.examples', {
71
+ create: () => Result.ok({ source: 'factory' }),
72
+ mock: () => ({ source: 'mock' }),
73
+ });
74
+
75
+ const mockServiceTrail = trail('service.mocked', {
76
+ description: 'Trail that reads from a mocked service',
77
+ examples: [
78
+ {
79
+ expected: { source: 'mock' },
80
+ input: {},
81
+ name: 'Uses auto-resolved service mock',
82
+ },
83
+ ],
84
+ input: z.object({}),
85
+ output: z.object({ source: z.string() }),
86
+ run: (_input, ctx) => Result.ok({ source: mockDbService.from(ctx).source }),
87
+ services: [mockDbService],
88
+ });
89
+
90
+ const explicitOverrideTrail = trail('service.override', {
91
+ description: 'Trail whose service mock can be overridden explicitly',
92
+ examples: [
93
+ {
94
+ expected: { source: 'override' },
95
+ input: {},
96
+ name: 'Explicit service override wins over mock factory',
97
+ },
98
+ ],
99
+ input: z.object({}),
100
+ output: z.object({ source: z.string() }),
101
+ run: (_input, ctx) => Result.ok({ source: mockDbService.from(ctx).source }),
102
+ services: [mockDbService],
103
+ });
104
+
105
+ const transformedInputTrail = trail('example.transformed', {
106
+ description: 'Trail whose input schema transforms once',
107
+ examples: [
108
+ {
109
+ expected: { value: 2 },
110
+ input: { value: '1' },
111
+ name: 'Raw example input is only transformed once',
112
+ },
113
+ ],
114
+ input: z
115
+ .object({ value: z.string() })
116
+ .transform(({ value }) => ({ value: Number(value) + 1 })),
117
+ output: z.object({ value: z.number() }),
118
+ run: (input: { value: number }) => Result.ok({ value: input.value }),
119
+ });
120
+
121
+ const ctxOverrideTrail = trail('service.ctx-override', {
122
+ description: 'Trail whose service mock can be overridden by ctx.extensions',
123
+ examples: [
124
+ {
125
+ expected: { source: 'ctx' },
126
+ input: {},
127
+ name: 'Context extensions beat auto-resolved mock services',
128
+ },
129
+ ],
130
+ input: z.object({}),
131
+ output: z.object({ source: z.string() }),
132
+ run: (_input, ctx) => Result.ok({ source: mockDbService.from(ctx).source }),
133
+ services: [mockDbService],
134
+ });
135
+
136
+ const undeclaredDbService = service('db.undeclared.examples', {
137
+ create: () => Result.ok({ source: 'factory' }),
138
+ mock: () => ({ source: 'mock' }),
139
+ });
140
+
141
+ const undeclaredServiceTrail = trail('service.undeclared.examples', {
142
+ description: 'Trail that uses a service without declaring it',
143
+ examples: [
144
+ {
145
+ error: 'InternalError',
146
+ input: {},
147
+ name: 'Undeclared services stay unavailable during example execution',
148
+ },
149
+ ],
150
+ input: z.object({}),
151
+ output: z.object({ source: z.string() }),
152
+ run: (_input, ctx) =>
153
+ Result.ok({ source: undeclaredDbService.from(ctx).source }),
154
+ });
155
+ const followedDbService = service('db.mock.examples.follow', {
156
+ create: () => Result.ok({ source: 'factory' }),
157
+ mock: () => ({ source: 'mock' }),
158
+ });
159
+
160
+ const followedLeafTrail = trail('service.follow.leaf', {
161
+ description: 'Leaf trail that resolves a service inside a follow chain',
162
+ input: z.object({}),
163
+ output: z.object({ childSource: z.string() }),
164
+ run: (_input, ctx) =>
165
+ Result.ok({ childSource: followedDbService.from(ctx).source }),
166
+ services: [followedDbService],
167
+ });
168
+
169
+ const followedRootTrail = trail('service.follow.root', {
170
+ description: 'Root trail that follows a child trail using services',
171
+ examples: [
172
+ {
173
+ expected: { childSource: 'mock', rootSource: 'mock' },
174
+ input: {},
175
+ name: 'Propagates service mocks through follow execution',
176
+ },
177
+ ],
178
+ follow: ['service.follow.leaf'],
179
+ input: z.object({}),
180
+ output: z.object({ childSource: z.string(), rootSource: z.string() }),
181
+ run: async (_input, ctx) => {
182
+ if (!ctx.follow) {
183
+ return Result.err(new Error('follow not available'));
184
+ }
185
+ const childResult = await ctx.follow<{ childSource: string }>(
186
+ 'service.follow.leaf',
187
+ {}
188
+ );
189
+ if (childResult.isErr()) {
190
+ return childResult;
191
+ }
192
+ return Result.ok({
193
+ childSource: childResult.value.childSource,
194
+ rootSource: followedDbService.from(ctx).source,
195
+ });
196
+ },
197
+ services: [followedDbService],
68
198
  });
69
199
 
70
200
  // ---------------------------------------------------------------------------
71
- // Test hikes (for follows coverage)
201
+ // Composition trails (for follow coverage)
72
202
  // ---------------------------------------------------------------------------
73
203
 
74
204
  const addTrail = trail('entity.add', {
75
205
  description: 'Add an entity',
76
- implementation: (input: { name: string }) =>
77
- Result.ok({ id: '1', name: input.name }),
78
206
  input: z.object({ name: z.string() }),
79
207
  output: z.object({ id: z.string(), name: z.string() }),
208
+ run: (input: { name: string }) => Result.ok({ id: '1', name: input.name }),
80
209
  });
81
210
 
82
211
  const relateTrail = trail('entity.relate', {
83
212
  description: 'Relate entities',
84
- implementation: (input: { from: string; to: string }) =>
85
- Result.ok({ from: input.from, to: input.to }),
86
213
  input: z.object({ from: z.string(), to: z.string() }),
87
214
  output: z.object({ from: z.string(), to: z.string() }),
215
+ run: (input: { from: string; to: string }) =>
216
+ Result.ok({ from: input.from, to: input.to }),
88
217
  });
89
218
 
90
- const onboardHike = hike('entity.onboard', {
219
+ const onboardTrail = trail('entity.onboard', {
91
220
  description: 'Onboard a new entity',
92
221
  examples: [
93
222
  {
@@ -96,8 +225,10 @@ const onboardHike = hike('entity.onboard', {
96
225
  name: 'Onboard Alpha',
97
226
  },
98
227
  ],
99
- follows: ['entity.add', 'entity.relate'],
100
- implementation: async (input: { name: string }, ctx) => {
228
+ follow: ['entity.add', 'entity.relate'],
229
+ input: z.object({ name: z.string() }),
230
+ output: z.object({ id: z.string(), name: z.string() }),
231
+ run: async (input: { name: string }, ctx) => {
101
232
  if (!ctx.follow) {
102
233
  return Result.err(new Error('follow not available'));
103
234
  }
@@ -114,8 +245,6 @@ const onboardHike = hike('entity.onboard', {
114
245
  }
115
246
  return Result.ok({ id: '1', name: input.name });
116
247
  },
117
- input: z.object({ name: z.string() }),
118
- output: z.object({ id: z.string(), name: z.string() }),
119
248
  });
120
249
 
121
250
  // ---------------------------------------------------------------------------
@@ -164,13 +293,147 @@ describe('testExamples skips trails with no examples', () => {
164
293
  });
165
294
  });
166
295
 
167
- describe('testExamples follows coverage for hikes', () => {
296
+ describe('testExamples service mocks', () => {
297
+ // eslint-disable-next-line jest/require-hook
298
+ testExamples(
299
+ topo('service-mock-app', {
300
+ mockDbService,
301
+ mockServiceTrail,
302
+ } as Record<string, unknown>)
303
+ );
304
+ });
305
+
306
+ describe('testExamples explicit service overrides', () => {
307
+ // eslint-disable-next-line jest/require-hook
308
+ testExamples(
309
+ topo('service-override-app', {
310
+ explicitOverrideTrail,
311
+ mockDbService,
312
+ } as Record<string, unknown>),
313
+ {
314
+ services: { 'db.mock.examples': { source: 'override' } },
315
+ }
316
+ );
317
+ });
318
+
319
+ describe('testExamples raw transformed input', () => {
320
+ // eslint-disable-next-line jest/require-hook
321
+ testExamples(
322
+ topo('transformed-input-app', {
323
+ transformedInputTrail,
324
+ } as Record<string, unknown>)
325
+ );
326
+ });
327
+
328
+ describe('testExamples context extension overrides', () => {
168
329
  // eslint-disable-next-line jest/require-hook
169
330
  testExamples(
170
- topo('hike-app', {
331
+ topo('ctx-override-app', {
332
+ ctxOverrideTrail,
333
+ mockDbService,
334
+ } as Record<string, unknown>),
335
+ {
336
+ ctx: {
337
+ extensions: { 'db.mock.examples': { source: 'ctx' } },
338
+ },
339
+ }
340
+ );
341
+ });
342
+
343
+ describe('testExamples service declarations', () => {
344
+ // eslint-disable-next-line jest/require-hook
345
+ testExamples(
346
+ topo('undeclared-service-app', {
347
+ undeclaredDbService,
348
+ undeclaredServiceTrail,
349
+ } as Record<string, unknown>)
350
+ );
351
+ });
352
+
353
+ describe('testExamples follow coverage for composition trails', () => {
354
+ // eslint-disable-next-line jest/require-hook
355
+ testExamples(
356
+ topo('composition-app', {
171
357
  addTrail,
172
- onboardHike,
358
+ onboardTrail,
173
359
  relateTrail,
174
360
  } as Record<string, unknown>)
175
361
  );
176
362
  });
363
+
364
+ describe('testExamples service mocks through follow', () => {
365
+ // eslint-disable-next-line jest/require-hook
366
+ testExamples(
367
+ topo('service-follow-app', {
368
+ followedDbService,
369
+ followedLeafTrail,
370
+ followedRootTrail,
371
+ } as Record<string, unknown>)
372
+ );
373
+ });
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // Nested follow chain: A → B → C
377
+ // ---------------------------------------------------------------------------
378
+
379
+ const leafTrail = trail('step.leaf', {
380
+ description: 'Leaf trail in a nested chain',
381
+ input: z.object({ value: z.string() }),
382
+ output: z.object({ leaf: z.string() }),
383
+ run: (input: { value: string }) => Result.ok({ leaf: input.value }),
384
+ });
385
+
386
+ const middleTrail = trail('step.middle', {
387
+ description: 'Middle trail that follows the leaf',
388
+ follow: ['step.leaf'],
389
+ input: z.object({ value: z.string() }),
390
+ output: z.object({ middle: z.string() }),
391
+ run: async (input: { value: string }, ctx) => {
392
+ if (!ctx.follow) {
393
+ return Result.err(new Error('follow not available'));
394
+ }
395
+ const leafResult = await ctx.follow<{ leaf: string }>('step.leaf', input);
396
+ if (leafResult.isErr()) {
397
+ return leafResult;
398
+ }
399
+ return Result.ok({ middle: leafResult.value.leaf });
400
+ },
401
+ });
402
+
403
+ const rootTrail = trail('step.root', {
404
+ description: 'Root trail that follows the middle trail',
405
+ examples: [
406
+ {
407
+ expected: { root: 'hello' },
408
+ input: { value: 'hello' },
409
+ name: 'Nested follow chain A→B→C',
410
+ },
411
+ ],
412
+ follow: ['step.middle'],
413
+ input: z.object({ value: z.string() }),
414
+ output: z.object({ root: z.string() }),
415
+ run: async (input: { value: string }, ctx) => {
416
+ if (!ctx.follow) {
417
+ return Result.err(new Error('follow not available'));
418
+ }
419
+ const midResult = await ctx.follow<{ middle: string }>(
420
+ 'step.middle',
421
+ input
422
+ );
423
+ if (midResult.isErr()) {
424
+ return midResult;
425
+ }
426
+ return Result.ok({ root: midResult.value.middle });
427
+ },
428
+ });
429
+
430
+ describe('testExamples nested follow chain (A → B → C)', () => {
431
+ // eslint-disable-next-line jest/require-hook
432
+ testExamples(
433
+ topo('nested-chain-app', {
434
+ leafTrail,
435
+ middleTrail,
436
+ rootTrail,
437
+ } as Record<string, unknown>)
438
+ );
439
+ });