@ontrails/testing 1.0.0-beta.10 → 1.0.0-beta.12

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/contracts.ts CHANGED
@@ -9,11 +9,17 @@
9
9
  import { describe, test } from 'bun:test';
10
10
 
11
11
  import type { Topo, TrailExample, Trail, TrailContext } from '@ontrails/core';
12
- import { formatZodIssues, validateInput } from '@ontrails/core';
12
+ import { executeTrail, formatZodIssues, validateInput } from '@ontrails/core';
13
13
  import type { z } from 'zod';
14
14
 
15
15
  import { expectOk } from './assertions.js';
16
- import { mergeTestContext } from './context.js';
16
+ import {
17
+ mergeServiceOverrides,
18
+ mergeTestContext,
19
+ normalizeTestExecutionOptions,
20
+ resolveMockServices,
21
+ } from './context.js';
22
+ import type { TestExecutionOptions } from './context.js';
17
23
 
18
24
  // ---------------------------------------------------------------------------
19
25
  // Helpers
@@ -22,13 +28,13 @@ import { mergeTestContext } from './context.js';
22
28
  /** Check if a trail requires follow() but the context doesn't provide it. */
23
29
  const needsFollowContext = (
24
30
  t: unknown,
25
- resolveCtx: () => Partial<TrailContext> | undefined
31
+ resolveCtx: () => Partial<TrailContext> | TestExecutionOptions | undefined
26
32
  ): boolean => {
27
33
  const spec = t as { follow?: readonly string[] };
28
34
  if (!spec.follow || spec.follow.length === 0) {
29
35
  return false;
30
36
  }
31
- return !resolveCtx()?.follow;
37
+ return !normalizeTestExecutionOptions(resolveCtx()).ctx?.follow;
32
38
  };
33
39
 
34
40
  const validateOutputSchema = (
@@ -58,9 +64,12 @@ const validateOutputSchema = (
58
64
  */
59
65
  export const testContracts = (
60
66
  app: Topo,
61
- ctxOrFactory?: Partial<TrailContext> | (() => Partial<TrailContext>)
67
+ ctxOrFactory?:
68
+ | Partial<TrailContext>
69
+ | TestExecutionOptions
70
+ | (() => Partial<TrailContext> | TestExecutionOptions)
62
71
  ): void => {
63
- const resolveCtx =
72
+ const resolveInput =
64
73
  typeof ctxOrFactory === 'function' ? ctxOrFactory : () => ctxOrFactory;
65
74
  const allEntries = app.list() as Trail<unknown, unknown>[];
66
75
 
@@ -72,7 +81,7 @@ export const testContracts = (
72
81
  if (t.examples === undefined || t.examples.length === 0) {
73
82
  return;
74
83
  }
75
- if (needsFollowContext(t, resolveCtx)) {
84
+ if (needsFollowContext(t, resolveInput)) {
76
85
  return;
77
86
  }
78
87
 
@@ -82,12 +91,21 @@ export const testContracts = (
82
91
  test.each(successExamples)(
83
92
  'contract: $name',
84
93
  async (example: TrailExample<unknown, unknown>) => {
85
- const testCtx = mergeTestContext(resolveCtx());
94
+ const resolved = normalizeTestExecutionOptions(resolveInput());
95
+ const services = mergeServiceOverrides(
96
+ await resolveMockServices(app),
97
+ resolved.ctx,
98
+ resolved.services
99
+ );
100
+ const testCtx = mergeTestContext(resolved.ctx);
86
101
 
87
102
  const validated = validateInput(t.input, example.input);
88
- const validatedInput = expectOk(validated);
103
+ expectOk(validated);
89
104
 
90
- const result = await t.run(validatedInput, testCtx);
105
+ const result = await executeTrail(t, example.input, {
106
+ ctx: testCtx,
107
+ services,
108
+ });
91
109
  const resultValue = expectOk(result);
92
110
 
93
111
  validateOutputSchema(outputSchema, resultValue, t.id, example.name);
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,15 @@ 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
+ defaultMintPermit,
49
+ mergeServiceOverrides,
50
+ mergeTestContext,
51
+ normalizeTestExecutionOptions,
52
+ resolveMockServices,
53
+ } from './context.js';
54
+ import type { MintableTrail, TestExecutionOptions } from './context.js';
47
55
 
48
56
  // ---------------------------------------------------------------------------
49
57
  // Error class name -> constructor map
@@ -120,6 +128,29 @@ const handleValidationError = (
120
128
  );
121
129
  };
122
130
 
131
+ /**
132
+ * Apply auto-minting: if the trail declares scoped permits and the context
133
+ * doesn't already have a permit, mint one and merge it into the context.
134
+ */
135
+ const applyAutoMint = (
136
+ ctx: TrailContext,
137
+ trailDef: MintableTrail,
138
+ opts: TestExecutionOptions
139
+ ): TrailContext => {
140
+ if (opts.strictPermits) {
141
+ return ctx;
142
+ }
143
+ if (ctx.permit !== undefined) {
144
+ return ctx;
145
+ }
146
+ const mint = opts.mintPermit ?? defaultMintPermit;
147
+ const permit = mint(trailDef);
148
+ if (!permit) {
149
+ return ctx;
150
+ }
151
+ return { ...ctx, permit };
152
+ };
153
+
123
154
  /**
124
155
  * Run a single example against a trail.
125
156
  * Handles validation, execution, and assertions.
@@ -128,16 +159,22 @@ const runExample = async (
128
159
  t: Trail<unknown, unknown>,
129
160
  example: TrailExample<unknown, unknown>,
130
161
  output: z.ZodType | undefined,
131
- testCtx: TrailContext
162
+ testCtx: TrailContext,
163
+ services?: ServiceOverrideMap,
164
+ opts?: TestExecutionOptions
132
165
  ): Promise<void> => {
133
166
  const validated = validateInput(t.input, example.input);
134
167
 
135
168
  if (handleValidationError(validated, example)) {
136
169
  return;
137
170
  }
138
- const validatedInput = expectOk(validated);
139
171
 
140
- const result = await t.run(validatedInput, testCtx);
172
+ const ctx = opts ? applyAutoMint(testCtx, t, opts) : testCtx;
173
+
174
+ const result = await executeTrail(t, example.input, {
175
+ ctx,
176
+ services,
177
+ });
141
178
  assertProgressiveMatch(result, example, output);
142
179
  };
143
180
 
@@ -156,7 +193,8 @@ const createCoverageFollow = (
156
193
  called: Set<string>,
157
194
  baseFollow: FollowFn | undefined,
158
195
  topo: Topo,
159
- ctx: TrailContext
196
+ ctx: TrailContext,
197
+ services?: ServiceOverrideMap
160
198
  ): FollowFn => {
161
199
  const follow = (id: string, input: unknown) => {
162
200
  called.add(id);
@@ -167,11 +205,10 @@ const createCoverageFollow = (
167
205
 
168
206
  const trailDef = topo.get(id);
169
207
  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));
208
+ return executeTrail(trailDef, input, {
209
+ ctx: { ...ctx, follow },
210
+ services,
211
+ });
175
212
  }
176
213
 
177
214
  return Promise.resolve(Result.ok());
@@ -188,19 +225,30 @@ const runCompositionExample = async (
188
225
  output: z.ZodType | undefined,
189
226
  baseCtx: TrailContext,
190
227
  called: Set<string>,
191
- topo: Topo
228
+ topo: Topo,
229
+ services?: ServiceOverrideMap,
230
+ opts?: TestExecutionOptions
192
231
  ): Promise<void> => {
193
232
  const validated = validateInput(trailDef.input, example.input);
194
233
 
195
234
  if (handleValidationError(validated, example)) {
196
235
  return;
197
236
  }
198
- const validatedInput = expectOk(validated);
199
237
 
200
- const follow = createCoverageFollow(called, baseCtx.follow, topo, baseCtx);
201
- const testCtx: TrailContext = { ...baseCtx, follow };
238
+ const mintedCtx = opts ? applyAutoMint(baseCtx, trailDef, opts) : baseCtx;
239
+ const follow = createCoverageFollow(
240
+ called,
241
+ mintedCtx.follow,
242
+ topo,
243
+ mintedCtx,
244
+ services
245
+ );
246
+ const testCtx: TrailContext = { ...mintedCtx, follow };
202
247
 
203
- const result = await trailDef.run(validatedInput, testCtx);
248
+ const result = await executeTrail(trailDef, example.input, {
249
+ ctx: testCtx,
250
+ services,
251
+ });
204
252
  assertProgressiveMatch(result, example, output);
205
253
  };
206
254
 
@@ -221,9 +269,12 @@ const runCompositionExample = async (
221
269
  */
222
270
  export const testExamples = (
223
271
  app: Topo,
224
- ctxOrFactory?: Partial<TrailContext> | (() => Partial<TrailContext>)
272
+ ctxOrFactory?:
273
+ | Partial<TrailContext>
274
+ | TestExecutionOptions
275
+ | (() => Partial<TrailContext> | TestExecutionOptions)
225
276
  ): void => {
226
- const resolveCtx =
277
+ const resolveInput =
227
278
  typeof ctxOrFactory === 'function' ? ctxOrFactory : () => ctxOrFactory;
228
279
  const allTrails = app.list() as Trail<unknown, unknown>[];
229
280
 
@@ -244,8 +295,14 @@ export const testExamples = (
244
295
  test.each([...examples])(
245
296
  'example: $name',
246
297
  async (example: TrailExample<unknown, unknown>) => {
247
- const testCtx = mergeTestContext(resolveCtx());
248
- await runExample(t, example, output, testCtx);
298
+ const resolved = normalizeTestExecutionOptions(resolveInput());
299
+ const services = mergeServiceOverrides(
300
+ await resolveMockServices(app),
301
+ resolved.ctx,
302
+ resolved.services
303
+ );
304
+ const testCtx = mergeTestContext(resolved.ctx);
305
+ await runExample(t, example, output, testCtx, services, resolved);
249
306
  }
250
307
  );
251
308
  });
@@ -264,8 +321,23 @@ export const testExamples = (
264
321
  test.each([...examples])(
265
322
  'example: $name',
266
323
  async (example: TrailExample<unknown, unknown>) => {
267
- const baseCtx = mergeTestContext(resolveCtx());
268
- await runCompositionExample(t, example, output, baseCtx, called, app);
324
+ const resolved = normalizeTestExecutionOptions(resolveInput());
325
+ const services = mergeServiceOverrides(
326
+ await resolveMockServices(app),
327
+ resolved.ctx,
328
+ resolved.services
329
+ );
330
+ const baseCtx = mergeTestContext(resolved.ctx);
331
+ await runCompositionExample(
332
+ t,
333
+ example,
334
+ output,
335
+ baseCtx,
336
+ called,
337
+ app,
338
+ services,
339
+ resolved
340
+ );
269
341
  }
270
342
  );
271
343
 
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
@@ -16,7 +16,11 @@ export {
16
16
  } from './assertions.js';
17
17
 
18
18
  // Mock factories
19
- export { createFollowContext, createTestContext } from './context.js';
19
+ export {
20
+ createFollowContext,
21
+ createTestContext,
22
+ defaultMintPermit,
23
+ } from './context.js';
20
24
  export { createTestLogger } from './logger.js';
21
25
 
22
26
  // Surface harnesses
@@ -25,6 +29,11 @@ export { createMcpHarness } from './harness-mcp.js';
25
29
 
26
30
  // Types
27
31
  export type { CreateFollowContextOptions } from './context.js';
32
+ export type {
33
+ MintableTrail,
34
+ MintedPermit,
35
+ TestExecutionOptions,
36
+ } from './context.js';
28
37
  export type { TestFollowOptions } from './follows.js';
29
38
 
30
39
  export type {