@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
package/src/context.ts CHANGED
@@ -2,11 +2,21 @@
2
2
  * Test context factory for creating TrailContext instances suitable for testing.
3
3
  */
4
4
 
5
- import type { TrailContext } from '@ontrails/core';
5
+ import type {
6
+ FollowFn,
7
+ ServiceOverrideMap,
8
+ Topo,
9
+ TrailContext,
10
+ } from '@ontrails/core';
11
+ import { Result, createServiceLookup } from '@ontrails/core';
6
12
 
7
13
  import { createTestLogger } from './logger.js';
8
14
  import type { TestTrailContextOptions } from './types.js';
9
15
 
16
+ type MutableTrailContext = {
17
+ -readonly [K in keyof TrailContext]: TrailContext[K];
18
+ };
19
+
10
20
  // ---------------------------------------------------------------------------
11
21
  // createTestContext
12
22
  // ---------------------------------------------------------------------------
@@ -20,23 +30,121 @@ import type { TestTrailContextOptions } from './types.js';
20
30
  */
21
31
  export const createTestContext = (
22
32
  overrides?: TestTrailContextOptions
23
- ): TrailContext => ({
24
- env: overrides?.env ?? { TRAILS_ENV: 'test' },
25
- logger: overrides?.logger ?? createTestLogger(),
26
- requestId: overrides?.requestId ?? 'test-request-001',
27
- signal: overrides?.signal ?? new AbortController().signal,
28
- workspaceRoot: overrides?.cwd ?? process.cwd(),
33
+ ): TrailContext => {
34
+ const cwd = overrides?.cwd ?? process.cwd();
35
+ const ctx = {
36
+ cwd,
37
+ env: overrides?.env ?? { TRAILS_ENV: 'test' },
38
+ extensions: undefined,
39
+ logger: overrides?.logger ?? createTestLogger(),
40
+ requestId: overrides?.requestId ?? 'test-request-001',
41
+ signal: overrides?.signal ?? new AbortController().signal,
42
+ workspaceRoot: cwd,
43
+ } as MutableTrailContext;
44
+ ctx.service = createServiceLookup(() => ctx);
45
+ return ctx;
46
+ };
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // createFollowContext
50
+ // ---------------------------------------------------------------------------
51
+
52
+ export interface CreateFollowContextOptions {
53
+ readonly responses?: Record<string, Result<unknown, Error>> | undefined;
54
+ }
55
+
56
+ export interface TestExecutionOptions {
57
+ readonly ctx?: Partial<TrailContext> | undefined;
58
+ readonly services?: ServiceOverrideMap | undefined;
59
+ }
60
+
61
+ /**
62
+ * Create a mock `FollowFn` for testing composite trails.
63
+ *
64
+ * Returns preconfigured `Result` values keyed by trail ID. Calls to
65
+ * unregistered IDs return `Result.err` with a descriptive message.
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * const follow = createFollowContext({
70
+ * responses: { 'entity.add': Result.ok({ id: '1', name: 'Alpha' }) },
71
+ * });
72
+ * const ctx = { ...createTestContext(), follow };
73
+ * ```
74
+ */
75
+ export const createFollowContext = (
76
+ options?: CreateFollowContextOptions
77
+ ): FollowFn => {
78
+ const responses = options?.responses ?? {};
79
+ return <O>(id: string, _input: unknown): Promise<Result<O, Error>> => {
80
+ const response = responses[id];
81
+ if (response === undefined) {
82
+ return Promise.resolve(
83
+ Result.err(new Error(`No mock response for follow("${id}")`)) as Result<
84
+ O,
85
+ Error
86
+ >
87
+ );
88
+ }
89
+ return Promise.resolve(response as Result<O, Error>);
90
+ };
91
+ };
92
+
93
+ const isTestExecutionOptions = (
94
+ input: Partial<TrailContext> | TestExecutionOptions | undefined
95
+ ): input is TestExecutionOptions =>
96
+ input !== undefined &&
97
+ (Object.hasOwn(input, 'ctx') || Object.hasOwn(input, 'services'));
98
+
99
+ export const normalizeTestExecutionOptions = (
100
+ input?: Partial<TrailContext> | TestExecutionOptions
101
+ ): TestExecutionOptions =>
102
+ isTestExecutionOptions(input) ? input : { ctx: input };
103
+
104
+ export const mergeServiceOverrides = (
105
+ autoResolved: ServiceOverrideMap,
106
+ ctx: Partial<TrailContext> | undefined,
107
+ explicit: ServiceOverrideMap | undefined
108
+ ): ServiceOverrideMap => ({
109
+ ...autoResolved,
110
+ ...ctx?.extensions,
111
+ ...explicit,
29
112
  });
30
113
 
114
+ const buildMockServices = async (app: Topo): Promise<ServiceOverrideMap> => {
115
+ const services: Record<string, unknown> = {};
116
+ for (const declaredService of app.listServices()) {
117
+ if (!declaredService.mock) {
118
+ continue;
119
+ }
120
+ services[declaredService.id] = await declaredService.mock();
121
+ }
122
+ return services;
123
+ };
124
+
125
+ export const resolveMockServices = async (
126
+ app: Topo
127
+ ): Promise<ServiceOverrideMap> => await buildMockServices(app);
128
+
31
129
  /**
32
130
  * Merge a Partial<TrailContext> into a test context.
33
131
  * Used internally when the public API accepts Partial<TrailContext>.
34
132
  */
35
- export const mergeTestContext = (ctx?: Partial<TrailContext>): TrailContext => {
36
- if (ctx === undefined) {
37
- return createTestContext();
38
- }
39
-
133
+ export const mergeTestContext = (
134
+ ctx?: Partial<TrailContext>,
135
+ services?: ServiceOverrideMap
136
+ ): TrailContext => {
40
137
  const base = createTestContext();
41
- return { ...base, ...ctx };
138
+ const extensions = {
139
+ ...base.extensions,
140
+ ...ctx?.extensions,
141
+ ...services,
142
+ };
143
+ const merged = {
144
+ ...base,
145
+ ...ctx,
146
+ extensions: Object.keys(extensions).length === 0 ? undefined : extensions,
147
+ } as MutableTrailContext;
148
+ merged.service = createServiceLookup(() => merged);
149
+ return merged;
42
150
  };
package/src/contracts.ts CHANGED
@@ -9,16 +9,34 @@
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
20
26
  // ---------------------------------------------------------------------------
21
27
 
28
+ /** Check if a trail requires follow() but the context doesn't provide it. */
29
+ const needsFollowContext = (
30
+ t: unknown,
31
+ resolveCtx: () => Partial<TrailContext> | TestExecutionOptions | undefined
32
+ ): boolean => {
33
+ const spec = t as { follow?: readonly string[] };
34
+ if (!spec.follow || spec.follow.length === 0) {
35
+ return false;
36
+ }
37
+ return !normalizeTestExecutionOptions(resolveCtx()).ctx?.follow;
38
+ };
39
+
22
40
  const validateOutputSchema = (
23
41
  outputSchema: z.ZodType,
24
42
  value: unknown,
@@ -39,29 +57,33 @@ const validateOutputSchema = (
39
57
  // ---------------------------------------------------------------------------
40
58
 
41
59
  /**
42
- * Verify that every trail's implementation output matches its declared
60
+ * Verify that every trail implementation output matches its declared
43
61
  * output schema. Catches implementation-schema drift.
44
62
  *
45
63
  * Trails without output schemas or examples are skipped.
46
64
  */
47
65
  export const testContracts = (
48
66
  app: Topo,
49
- ctxOrFactory?: Partial<TrailContext> | (() => Partial<TrailContext>)
67
+ ctxOrFactory?:
68
+ | Partial<TrailContext>
69
+ | TestExecutionOptions
70
+ | (() => Partial<TrailContext> | TestExecutionOptions)
50
71
  ): void => {
51
- const resolveCtx =
72
+ const resolveInput =
52
73
  typeof ctxOrFactory === 'function' ? ctxOrFactory : () => ctxOrFactory;
53
- const trailEntries = [...app.trails];
74
+ const allEntries = app.list() as Trail<unknown, unknown>[];
54
75
 
55
76
  describe('contracts', () => {
56
- describe.each(trailEntries)('%s', (_id, trailDef) => {
57
- const t = trailDef as Trail<unknown, unknown>;
58
-
77
+ describe.each(allEntries)('$id', (t) => {
59
78
  if (t.output === undefined) {
60
79
  return;
61
80
  }
62
81
  if (t.examples === undefined || t.examples.length === 0) {
63
82
  return;
64
83
  }
84
+ if (needsFollowContext(t, resolveInput)) {
85
+ return;
86
+ }
65
87
 
66
88
  const { examples, output: outputSchema } = t;
67
89
  const successExamples = examples.filter((e) => e.error === undefined);
@@ -69,12 +91,21 @@ export const testContracts = (
69
91
  test.each(successExamples)(
70
92
  'contract: $name',
71
93
  async (example: TrailExample<unknown, unknown>) => {
72
- 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);
73
101
 
74
102
  const validated = validateInput(t.input, example.input);
75
- const validatedInput = expectOk(validated);
103
+ expectOk(validated);
76
104
 
77
- const result = await t.implementation(validatedInput, testCtx);
105
+ const result = await executeTrail(t, example.input, {
106
+ ctx: testCtx,
107
+ services,
108
+ });
78
109
  const resultValue = expectOk(result);
79
110
 
80
111
  validateOutputSchema(outputSchema, resultValue, t.id, example.name);
package/src/examples.ts CHANGED
@@ -3,15 +3,15 @@
3
3
  *
4
4
  * Iterates every trail in the app's topo. For each trail with examples,
5
5
  * generates describe/test blocks using bun:test. Progressive assertion
6
- * determines which check to run per example. For hikes with `follows`
6
+ * determines which check to run per example. For trails with `follow`
7
7
  * declarations, checks that every declared follow was called at least once.
8
8
  */
9
9
 
10
10
  import { describe, expect, test } from 'bun:test';
11
11
 
12
12
  import type {
13
- AnyHike,
14
13
  FollowFn,
14
+ ServiceOverrideMap,
15
15
  Topo,
16
16
  TrailExample,
17
17
  Trail,
@@ -30,6 +30,7 @@ import {
30
30
  NotFoundError,
31
31
  PermissionError,
32
32
  RateLimitError,
33
+ executeTrail,
33
34
  Result,
34
35
  TimeoutError,
35
36
  TrailsError,
@@ -42,9 +43,14 @@ import {
42
43
  assertErrorMatch,
43
44
  assertFullMatch,
44
45
  assertSchemaMatch,
45
- expectOk,
46
46
  } from './assertions.js';
47
- 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';
48
54
 
49
55
  // ---------------------------------------------------------------------------
50
56
  // Error class name -> constructor map
@@ -129,21 +135,24 @@ const runExample = async (
129
135
  t: Trail<unknown, unknown>,
130
136
  example: TrailExample<unknown, unknown>,
131
137
  output: z.ZodType | undefined,
132
- testCtx: TrailContext
138
+ testCtx: TrailContext,
139
+ services?: ServiceOverrideMap
133
140
  ): Promise<void> => {
134
141
  const validated = validateInput(t.input, example.input);
135
142
 
136
143
  if (handleValidationError(validated, example)) {
137
144
  return;
138
145
  }
139
- const validatedInput = expectOk(validated);
140
146
 
141
- const result = await t.implementation(validatedInput, testCtx);
147
+ const result = await executeTrail(t, example.input, {
148
+ ctx: testCtx,
149
+ services,
150
+ });
142
151
  assertProgressiveMatch(result, example, output);
143
152
  };
144
153
 
145
154
  // ---------------------------------------------------------------------------
146
- // Follows coverage for hikes
155
+ // Follow coverage for composition trails
147
156
  // ---------------------------------------------------------------------------
148
157
 
149
158
  /**
@@ -157,7 +166,8 @@ const createCoverageFollow = (
157
166
  called: Set<string>,
158
167
  baseFollow: FollowFn | undefined,
159
168
  topo: Topo,
160
- ctx: TrailContext
169
+ ctx: TrailContext,
170
+ services?: ServiceOverrideMap
161
171
  ): FollowFn => {
162
172
  const follow = (id: string, input: unknown) => {
163
173
  called.add(id);
@@ -168,11 +178,10 @@ const createCoverageFollow = (
168
178
 
169
179
  const trailDef = topo.get(id);
170
180
  if (trailDef !== undefined) {
171
- const validated = validateInput(trailDef.input, input);
172
- if (validated.isErr()) {
173
- return Promise.resolve(validated);
174
- }
175
- return Promise.resolve(trailDef.implementation(validated.value, ctx));
181
+ return executeTrail(trailDef, input, {
182
+ ctx: { ...ctx, follow },
183
+ services,
184
+ });
176
185
  }
177
186
 
178
187
  return Promise.resolve(Result.ok());
@@ -181,93 +190,37 @@ const createCoverageFollow = (
181
190
  };
182
191
 
183
192
  /**
184
- * Run a single example against a hike, recording follow calls.
193
+ * Run a single example against a composition trail, recording follow calls.
185
194
  */
186
- const runHikeExample = async (
187
- hikeDef: AnyHike,
195
+ const runCompositionExample = async (
196
+ trailDef: Trail<unknown, unknown>,
188
197
  example: TrailExample<unknown, unknown>,
189
198
  output: z.ZodType | undefined,
190
199
  baseCtx: TrailContext,
191
200
  called: Set<string>,
192
- topo: Topo
201
+ topo: Topo,
202
+ services?: ServiceOverrideMap
193
203
  ): Promise<void> => {
194
- const validated = validateInput(hikeDef.input, example.input);
204
+ const validated = validateInput(trailDef.input, example.input);
195
205
 
196
206
  if (handleValidationError(validated, example)) {
197
207
  return;
198
208
  }
199
- const validatedInput = expectOk(validated);
200
209
 
201
- const follow = createCoverageFollow(called, baseCtx.follow, topo, baseCtx);
210
+ const follow = createCoverageFollow(
211
+ called,
212
+ baseCtx.follow,
213
+ topo,
214
+ baseCtx,
215
+ services
216
+ );
202
217
  const testCtx: TrailContext = { ...baseCtx, follow };
203
218
 
204
- const result = await hikeDef.implementation(validatedInput, testCtx);
205
- assertProgressiveMatch(result, example, output);
206
- };
207
-
208
- // ---------------------------------------------------------------------------
209
- // Hike entry with examples pre-validated
210
- // ---------------------------------------------------------------------------
211
-
212
- interface HikeWithExamples {
213
- readonly hikeDef: AnyHike;
214
- readonly hikeId: string;
215
- readonly examples: readonly TrailExample<unknown, unknown>[];
216
- }
217
-
218
- const collectHikesWithExamples = (app: Topo): readonly HikeWithExamples[] =>
219
- [...app.hikes]
220
- .filter(([, h]) => h.examples !== undefined && h.examples.length > 0)
221
- .map(([hikeId, hikeDef]) => ({
222
- examples: hikeDef.examples as readonly TrailExample<unknown, unknown>[],
223
- hikeDef,
224
- hikeId,
225
- }));
226
-
227
- // ---------------------------------------------------------------------------
228
- // Hike example describe blocks
229
- // ---------------------------------------------------------------------------
230
-
231
- /**
232
- * Generate describe/test blocks for hikes with follows coverage.
233
- *
234
- * Always uses a recording follow so that follows coverage can be checked.
235
- * Hikes without `follows` still run their examples but skip the coverage test.
236
- */
237
- const describeHikeExamples = (
238
- hikesWithExamples: readonly HikeWithExamples[],
239
- resolveCtx: () => Partial<TrailContext> | undefined,
240
- topo: Topo
241
- ): void => {
242
- if (hikesWithExamples.length === 0) {
243
- return;
244
- }
245
-
246
- describe.each([...hikesWithExamples])('$hikeId', ({ hikeDef, examples }) => {
247
- const called = new Set<string>();
248
-
249
- test.each([...examples])(
250
- 'example: $name',
251
- async (example: TrailExample<unknown, unknown>) => {
252
- const baseCtx = mergeTestContext(resolveCtx());
253
- await runHikeExample(
254
- hikeDef,
255
- example,
256
- hikeDef.output,
257
- baseCtx,
258
- called,
259
- topo
260
- );
261
- }
262
- );
263
-
264
- if (hikeDef.follows.length > 0) {
265
- test('follows coverage', () => {
266
- const uncovered = hikeDef.follows.filter((id) => !called.has(id));
267
- expect(uncovered).toEqual([]);
268
- });
269
- }
219
+ const result = await executeTrail(trailDef, example.input, {
220
+ ctx: testCtx,
221
+ services,
270
222
  });
223
+ assertProgressiveMatch(result, example, output);
271
224
  };
272
225
 
273
226
  // ---------------------------------------------------------------------------
@@ -277,7 +230,7 @@ const describeHikeExamples = (
277
230
  /**
278
231
  * Generate describe/test blocks for every trail example in the app.
279
232
  *
280
- * For hikes with `follows` declarations and examples, also verifies that
233
+ * For trails with `follow` declarations and examples, also verifies that
281
234
  * every declared follow ID was called at least once across all examples.
282
235
  *
283
236
  * One line in your test file:
@@ -287,28 +240,81 @@ const describeHikeExamples = (
287
240
  */
288
241
  export const testExamples = (
289
242
  app: Topo,
290
- ctxOrFactory?: Partial<TrailContext> | (() => Partial<TrailContext>)
243
+ ctxOrFactory?:
244
+ | Partial<TrailContext>
245
+ | TestExecutionOptions
246
+ | (() => Partial<TrailContext> | TestExecutionOptions)
291
247
  ): void => {
292
- const resolveCtx =
248
+ const resolveInput =
293
249
  typeof ctxOrFactory === 'function' ? ctxOrFactory : () => ctxOrFactory;
294
- const trailEntries = [...app.trails];
250
+ const allTrails = app.list() as Trail<unknown, unknown>[];
295
251
 
296
- describe.each(trailEntries)('%s', (_id, trailDef) => {
297
- const t = trailDef as Trail<unknown, unknown>;
298
- if (t.examples === undefined || t.examples.length === 0) {
299
- return;
300
- }
252
+ const withExamples = allTrails.filter(
253
+ (t) => t.examples !== undefined && t.examples.length > 0
254
+ );
255
+ const simpleTrails = withExamples.filter((t) => t.follow.length === 0);
256
+ const compositionTrails = withExamples.filter((t) => t.follow.length > 0);
257
+
258
+ // Simple trails: run examples directly
259
+ if (simpleTrails.length > 0) {
260
+ describe.each(simpleTrails)('$id', (t) => {
261
+ const { examples, output } = t;
262
+ if (!examples) {
263
+ return;
264
+ }
301
265
 
302
- const { examples, output } = t;
266
+ test.each([...examples])(
267
+ 'example: $name',
268
+ async (example: TrailExample<unknown, unknown>) => {
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);
277
+ }
278
+ );
279
+ });
280
+ }
303
281
 
304
- test.each([...examples])(
305
- 'example: $name',
306
- async (example: TrailExample<unknown, unknown>) => {
307
- const testCtx = mergeTestContext(resolveCtx());
308
- await runExample(t, example, output, testCtx);
282
+ // Composition trails: use recording follow and check coverage
283
+ if (compositionTrails.length > 0) {
284
+ describe.each(compositionTrails)('$id', (t) => {
285
+ const { examples, output } = t;
286
+ if (!examples) {
287
+ return;
309
288
  }
310
- );
311
- });
312
289
 
313
- describeHikeExamples(collectHikesWithExamples(app), resolveCtx, app);
290
+ const called = new Set<string>();
291
+
292
+ test.each([...examples])(
293
+ 'example: $name',
294
+ async (example: TrailExample<unknown, unknown>) => {
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
+ );
311
+ }
312
+ );
313
+
314
+ test('follow coverage', () => {
315
+ const uncovered = t.follow.filter((id) => !called.has(id));
316
+ expect(uncovered).toEqual([]);
317
+ });
318
+ });
319
+ }
314
320
  };