@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/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 { mergeTestContext } from './context.js';
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.run(validatedInput, testCtx);
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
- const validated = validateInput(trailDef.input, input);
171
- if (validated.isErr()) {
172
- return Promise.resolve(validated);
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(called, baseCtx.follow, topo, baseCtx);
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.run(validatedInput, testCtx);
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?: Partial<TrailContext> | (() => Partial<TrailContext>)
243
+ ctxOrFactory?:
244
+ | Partial<TrailContext>
245
+ | TestExecutionOptions
246
+ | (() => Partial<TrailContext> | TestExecutionOptions)
225
247
  ): void => {
226
- const resolveCtx =
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 testCtx = mergeTestContext(resolveCtx());
248
- await runExample(t, example, output, testCtx);
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 baseCtx = mergeTestContext(resolveCtx());
268
- await runCompositionExample(t, example, output, baseCtx, called, app);
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 { AnyTrail, FollowFn, TrailContext } from '@ontrails/core';
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 validated = validateInput(trailDef.input, input);
101
- if (validated.isErr()) {
102
- return validated;
103
- }
104
- return trailDef.run(validated.value, ctx);
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(id, input, trailsMap, ctx);
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(scenario, ctx, trailsMap);
238
- const result = await trailDef.run(expectOk(validated), testCtx);
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
- await runScenario(trailDef, scenario, options?.ctx, options?.trails);
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 {