@ontrails/testing 1.0.0-beta.2 → 1.0.0-beta.21

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 (91) hide show
  1. package/CHANGELOG.md +396 -5
  2. package/README.md +86 -22
  3. package/package.json +34 -5
  4. package/src/all-established.ts +168 -0
  5. package/src/all.ts +51 -12
  6. package/src/assertions.ts +253 -0
  7. package/src/cli.ts +6 -0
  8. package/src/composes.ts +433 -0
  9. package/src/context.ts +200 -14
  10. package/src/contracts.ts +45 -21
  11. package/src/detours.ts +155 -18
  12. package/src/effective-examples.ts +414 -0
  13. package/src/errors.ts +47 -0
  14. package/src/examples.ts +302 -177
  15. package/src/harness-cli.ts +83 -58
  16. package/src/harness-http.ts +341 -0
  17. package/src/harness-mcp.ts +46 -16
  18. package/src/http.ts +10 -0
  19. package/src/index.ts +22 -14
  20. package/src/logger.ts +3 -1
  21. package/src/mcp.ts +6 -0
  22. package/src/scenario.ts +375 -0
  23. package/src/signals.ts +221 -0
  24. package/src/surface-parity.ts +389 -0
  25. package/src/trail.ts +1 -1
  26. package/src/types.ts +24 -52
  27. package/.turbo/turbo-build.log +0 -1
  28. package/.turbo/turbo-lint.log +0 -3
  29. package/.turbo/turbo-typecheck.log +0 -1
  30. package/dist/all.d.ts +0 -30
  31. package/dist/all.d.ts.map +0 -1
  32. package/dist/all.js +0 -47
  33. package/dist/all.js.map +0 -1
  34. package/dist/assertions.d.ts +0 -49
  35. package/dist/assertions.d.ts.map +0 -1
  36. package/dist/assertions.js +0 -84
  37. package/dist/assertions.js.map +0 -1
  38. package/dist/context.d.ts +0 -19
  39. package/dist/context.d.ts.map +0 -1
  40. package/dist/context.js +0 -33
  41. package/dist/context.js.map +0 -1
  42. package/dist/contracts.d.ts +0 -16
  43. package/dist/contracts.d.ts.map +0 -1
  44. package/dist/contracts.js +0 -56
  45. package/dist/contracts.js.map +0 -1
  46. package/dist/detours.d.ts +0 -12
  47. package/dist/detours.d.ts.map +0 -1
  48. package/dist/detours.js +0 -30
  49. package/dist/detours.js.map +0 -1
  50. package/dist/examples.d.ts +0 -22
  51. package/dist/examples.d.ts.map +0 -1
  52. package/dist/examples.js +0 -187
  53. package/dist/examples.js.map +0 -1
  54. package/dist/harness-cli.d.ts +0 -21
  55. package/dist/harness-cli.d.ts.map +0 -1
  56. package/dist/harness-cli.js +0 -213
  57. package/dist/harness-cli.js.map +0 -1
  58. package/dist/harness-mcp.d.ts +0 -21
  59. package/dist/harness-mcp.d.ts.map +0 -1
  60. package/dist/harness-mcp.js +0 -50
  61. package/dist/harness-mcp.js.map +0 -1
  62. package/dist/hike.d.ts +0 -32
  63. package/dist/hike.d.ts.map +0 -1
  64. package/dist/hike.js +0 -169
  65. package/dist/hike.js.map +0 -1
  66. package/dist/index.d.ts +0 -14
  67. package/dist/index.d.ts.map +0 -1
  68. package/dist/index.js +0 -16
  69. package/dist/index.js.map +0 -1
  70. package/dist/logger.d.ts +0 -15
  71. package/dist/logger.d.ts.map +0 -1
  72. package/dist/logger.js +0 -87
  73. package/dist/logger.js.map +0 -1
  74. package/dist/trail.d.ts +0 -20
  75. package/dist/trail.d.ts.map +0 -1
  76. package/dist/trail.js +0 -80
  77. package/dist/trail.js.map +0 -1
  78. package/dist/types.d.ts +0 -80
  79. package/dist/types.d.ts.map +0 -1
  80. package/dist/types.js +0 -5
  81. package/dist/types.js.map +0 -1
  82. package/src/__tests__/context.test.ts +0 -60
  83. package/src/__tests__/contracts.test.ts +0 -68
  84. package/src/__tests__/detours.test.ts +0 -55
  85. package/src/__tests__/examples.test.ts +0 -176
  86. package/src/__tests__/hike.test.ts +0 -164
  87. package/src/__tests__/logger.test.ts +0 -136
  88. package/src/__tests__/trail.test.ts +0 -99
  89. package/src/hike.ts +0 -283
  90. package/tsconfig.json +0 -9
  91. package/tsconfig.tsbuildinfo +0 -1
package/src/examples.ts CHANGED
@@ -3,15 +3,17 @@
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`
7
- * declarations, checks that every declared follow was called at least once.
6
+ * determines which check to run per example. For trails with `composes`
7
+ * declarations, checks that every declared composing 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
- FollowFn,
13
+ ComposeFn,
14
+ ComposeOptions,
15
+ ExecuteTrailOptions,
16
+ ResourceOverrideMap,
15
17
  Topo,
16
18
  TrailExample,
17
19
  Trail,
@@ -19,20 +21,10 @@ import type {
19
21
  } from '@ontrails/core';
20
22
 
21
23
  import {
22
- AlreadyExistsError,
23
- AmbiguousError,
24
- AssertionError,
25
- AuthError,
26
- CancelledError,
27
- ConflictError,
28
- InternalError,
29
- NetworkError,
30
- NotFoundError,
31
- PermissionError,
32
- RateLimitError,
24
+ buildComposeValidationSchema,
25
+ executeTrail,
26
+ parseTrailIdVersionReference,
33
27
  Result,
34
- TimeoutError,
35
- TrailsError,
36
28
  ValidationError,
37
29
  validateInput,
38
30
  } from '@ontrails/core';
@@ -41,39 +33,29 @@ import type { z } from 'zod';
41
33
  import {
42
34
  assertErrorMatch,
43
35
  assertFullMatch,
36
+ assertPartialMatch,
44
37
  assertSchemaMatch,
45
- expectOk,
46
38
  } from './assertions.js';
47
- import { mergeTestContext } from './context.js';
48
-
49
- // ---------------------------------------------------------------------------
50
- // Error class name -> constructor map
51
- // ---------------------------------------------------------------------------
52
-
53
- const ERROR_MAP: Record<string, new (...args: never[]) => Error> = {
54
- AlreadyExistsError: AlreadyExistsError as new (...args: never[]) => Error,
55
- AmbiguousError: AmbiguousError as new (...args: never[]) => Error,
56
- AssertionError: AssertionError as new (...args: never[]) => Error,
57
- AuthError: AuthError as new (...args: never[]) => Error,
58
- CancelledError: CancelledError as new (...args: never[]) => Error,
59
- ConflictError: ConflictError as new (...args: never[]) => Error,
60
- InternalError: InternalError as new (...args: never[]) => Error,
61
- NetworkError: NetworkError as new (...args: never[]) => Error,
62
- NotFoundError: NotFoundError as new (...args: never[]) => Error,
63
- PermissionError: PermissionError as new (...args: never[]) => Error,
64
- RateLimitError: RateLimitError as new (...args: never[]) => Error,
65
- TimeoutError: TimeoutError as new (...args: never[]) => Error,
66
- TrailsError: TrailsError as unknown as new (...args: never[]) => Error,
67
- ValidationError: ValidationError as new (...args: never[]) => Error,
39
+ import {
40
+ defaultCreatePermit,
41
+ mergeResourceOverrides,
42
+ mergeTestContext,
43
+ normalizeTestExecutionOptions,
44
+ createMockResources,
45
+ } from './context.js';
46
+ import type { PermittedTrail, TestExecutionOptions } from './context.js';
47
+ import {
48
+ deriveTrailExampleTargets,
49
+ isDerivedExample,
50
+ } from './effective-examples.js';
51
+ import type { TrailExampleTarget } from './effective-examples.js';
52
+ import { resolveErrorClass } from './errors.js';
53
+ import { withSignalAssertions } from './signals.js';
54
+
55
+ type TestingExecuteTrailOptions = ExecuteTrailOptions & {
56
+ readonly validationSchema?: ReturnType<typeof buildComposeValidationSchema>;
68
57
  };
69
58
 
70
- /**
71
- * Resolve an error class name string to the actual constructor.
72
- * Falls back to generic Error if the name is not in the core taxonomy.
73
- */
74
- const resolveErrorClass = (name: string): (new (...args: never[]) => Error) =>
75
- ERROR_MAP[name] ?? (Error as new (...args: never[]) => Error);
76
-
77
59
  // ---------------------------------------------------------------------------
78
60
  // Helpers
79
61
  // ---------------------------------------------------------------------------
@@ -84,16 +66,14 @@ const assertProgressiveMatch = (
84
66
  output: z.ZodType | undefined
85
67
  ): void => {
86
68
  if (example.expected !== undefined) {
87
- assertFullMatch(result, example.expected);
88
- return;
69
+ return assertFullMatch(result, example.expected);
70
+ }
71
+ if (example.expectedMatch !== undefined) {
72
+ return assertPartialMatch(result, example.expectedMatch);
89
73
  }
90
-
91
74
  if (example.error !== undefined) {
92
- const errorClass = resolveErrorClass(example.error);
93
- assertErrorMatch(result, errorClass);
94
- return;
75
+ return assertErrorMatch(result, resolveErrorClass(example.error));
95
76
  }
96
-
97
77
  assertSchemaMatch(result, output);
98
78
  };
99
79
 
@@ -122,152 +102,221 @@ const handleValidationError = (
122
102
  };
123
103
 
124
104
  /**
125
- * Run a single example against a trail.
126
- * Handles validation, execution, and assertions.
105
+ * Apply auto-permit: if the trail declares scoped permits and the context
106
+ * doesn't already have a permit, create one and merge it into the context.
127
107
  */
128
- const runExample = async (
129
- t: Trail<unknown, unknown>,
108
+ const applyAutoPermit = (
109
+ ctx: TrailContext,
110
+ trailDef: PermittedTrail,
111
+ opts: TestExecutionOptions
112
+ ): TrailContext => {
113
+ if (opts.strictPermits) {
114
+ return ctx;
115
+ }
116
+ if (ctx.permit !== undefined) {
117
+ return ctx;
118
+ }
119
+ const create = opts.createPermit ?? defaultCreatePermit;
120
+ const permit = create(trailDef);
121
+ if (!permit) {
122
+ return ctx;
123
+ }
124
+ return { ...ctx, permit };
125
+ };
126
+
127
+ const runTargetExample = async (
128
+ target: TrailExampleTarget,
130
129
  example: TrailExample<unknown, unknown>,
131
- output: z.ZodType | undefined,
132
- testCtx: TrailContext
130
+ testCtx: TrailContext,
131
+ resources?: ResourceOverrideMap,
132
+ opts?: TestExecutionOptions
133
133
  ): Promise<void> => {
134
- const validated = validateInput(t.input, example.input);
134
+ const { output, trail: t } = target;
135
+ const ctx = opts ? applyAutoPermit(testCtx, t, opts) : testCtx;
136
+ const signals = withSignalAssertions(ctx, example);
137
+ const validated = validateInput(target.input, example.input);
135
138
 
136
139
  if (handleValidationError(validated, example)) {
140
+ signals.assert();
137
141
  return;
138
142
  }
139
- const validatedInput = expectOk(validated);
140
143
 
141
- const result = await t.implementation(validatedInput, testCtx);
144
+ const result = await executeTrail(t, example.input, {
145
+ ctx: signals.ctx,
146
+ resources: resources ?? opts?.resources,
147
+ ...(target.version === undefined ? {} : { version: target.version }),
148
+ });
142
149
  assertProgressiveMatch(result, example, output);
150
+ signals.assert();
151
+ };
152
+
153
+ /**
154
+ * Run a single example against a trail.
155
+ * Handles validation, execution, and assertions.
156
+ */
157
+ export const runExample = async (
158
+ t: Trail<unknown, unknown, unknown>,
159
+ example: TrailExample<unknown, unknown>,
160
+ output: z.ZodType | undefined,
161
+ testCtx: TrailContext,
162
+ resources?: ResourceOverrideMap,
163
+ opts?: TestExecutionOptions
164
+ ): Promise<void> => {
165
+ await runTargetExample(
166
+ {
167
+ composes: t.composes,
168
+ current: true,
169
+ examples: [example],
170
+ id: t.id,
171
+ input: t.input,
172
+ output,
173
+ trail: t,
174
+ },
175
+ example,
176
+ testCtx,
177
+ resources,
178
+ opts
179
+ );
143
180
  };
144
181
 
145
182
  // ---------------------------------------------------------------------------
146
- // Follows coverage for hikes
183
+ // Composing coverage for trails with compositions
147
184
  // ---------------------------------------------------------------------------
148
185
 
149
186
  /**
150
- * Build a recording follow function that tracks which trail IDs are called.
187
+ * Build a recording compose function that tracks which trail IDs are called.
151
188
  *
152
- * Delegates to `baseFollow` when available, otherwise looks up the trail
189
+ * Delegates to `baseCompose` when available, otherwise looks up the trail
153
190
  * in the topo and executes it with validated input. Falls back to
154
191
  * `Result.ok()` when neither is available.
155
192
  */
156
- const createCoverageFollow = (
193
+ const createCoverageCompose = (
157
194
  called: Set<string>,
158
- baseFollow: FollowFn | undefined,
195
+ baseCompose: ComposeFn | undefined,
159
196
  topo: Topo,
160
- ctx: TrailContext
161
- ): FollowFn => {
162
- const follow = (id: string, input: unknown) => {
163
- called.add(id);
197
+ ctx: TrailContext,
198
+ resources?: ResourceOverrideMap
199
+ ): ComposeFn => {
200
+ const invokeCompose = async (
201
+ idOrTrail: string | { readonly id: string },
202
+ input: unknown,
203
+ self: ComposeFn,
204
+ composeOptions?: ComposeOptions | undefined
205
+ ) => {
206
+ const parsed =
207
+ typeof idOrTrail === 'string'
208
+ ? parseTrailIdVersionReference(idOrTrail)
209
+ : Result.ok({ id: idOrTrail.id });
210
+ if (parsed.isErr()) {
211
+ return parsed;
212
+ }
213
+ const parsedVersion =
214
+ 'version' in parsed.value ? parsed.value.version : undefined;
215
+ if (parsedVersion !== undefined && composeOptions?.version !== undefined) {
216
+ return Result.err(
217
+ new ValidationError(
218
+ `Trail "${parsed.value.id}" version was provided both in the id reference and ctx.compose() options`
219
+ )
220
+ );
221
+ }
164
222
 
165
- if (baseFollow !== undefined) {
166
- return baseFollow(id, input);
223
+ const { id } = parsed.value;
224
+ called.add(id);
225
+ const version = composeOptions?.version ?? parsedVersion;
226
+
227
+ if (baseCompose !== undefined) {
228
+ const forwardedOptions =
229
+ parsedVersion === undefined
230
+ ? composeOptions
231
+ : { ...composeOptions, version: parsedVersion };
232
+ return await baseCompose(id, input, forwardedOptions);
167
233
  }
168
234
 
169
235
  const trailDef = topo.get(id);
170
236
  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));
237
+ const options: TestingExecuteTrailOptions = {
238
+ ctx: { ...ctx, compose: self },
239
+ resources,
240
+ ...(version === undefined ? {} : { version }),
241
+ validationSchema: buildComposeValidationSchema(trailDef),
242
+ };
243
+ return await executeTrail(trailDef, input, options);
176
244
  }
177
245
 
178
- return Promise.resolve(Result.ok());
246
+ return Result.ok();
179
247
  };
180
- return follow as FollowFn;
248
+
249
+ // Accepts either a trail object (typed compose), a string id (untyped),
250
+ // or a batch of `[target, input]` tuples.
251
+ const compose = async function compose(
252
+ idOrTrail:
253
+ | string
254
+ | { readonly id: string }
255
+ | readonly (readonly [string | { readonly id: string }, unknown])[],
256
+ inputOrOptions?: unknown,
257
+ singleOptions?: ComposeOptions
258
+ ) {
259
+ if (Array.isArray(idOrTrail)) {
260
+ return await Promise.all(
261
+ idOrTrail.map(([target, batchInput]) =>
262
+ invokeCompose(target, batchInput, compose as ComposeFn)
263
+ )
264
+ );
265
+ }
266
+
267
+ return await invokeCompose(
268
+ idOrTrail as string | { readonly id: string },
269
+ inputOrOptions,
270
+ compose as ComposeFn,
271
+ singleOptions
272
+ );
273
+ } as ComposeFn;
274
+
275
+ return compose;
181
276
  };
182
277
 
183
278
  /**
184
- * Run a single example against a hike, recording follow calls.
279
+ * Run a single example against a trail with compositions, recording compose calls.
185
280
  */
186
- const runHikeExample = async (
187
- hikeDef: AnyHike,
281
+ const runCompositionExample = async (
282
+ target: TrailExampleTarget,
188
283
  example: TrailExample<unknown, unknown>,
189
- output: z.ZodType | undefined,
190
284
  baseCtx: TrailContext,
191
285
  called: Set<string>,
192
- topo: Topo
286
+ topo: Topo,
287
+ resources?: ResourceOverrideMap,
288
+ opts?: TestExecutionOptions
193
289
  ): Promise<void> => {
194
- const validated = validateInput(hikeDef.input, example.input);
290
+ const { output, trail: trailDef } = target;
291
+ const permittedCtx = opts
292
+ ? applyAutoPermit(baseCtx, trailDef, opts)
293
+ : baseCtx;
294
+ const signals = withSignalAssertions(permittedCtx, example);
295
+ const validated = validateInput(target.input, example.input);
195
296
 
196
297
  if (handleValidationError(validated, example)) {
298
+ signals.assert();
197
299
  return;
198
300
  }
199
- const validatedInput = expectOk(validated);
200
-
201
- const follow = createCoverageFollow(called, baseCtx.follow, topo, baseCtx);
202
- const testCtx: TrailContext = { ...baseCtx, follow };
203
-
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
301
 
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
- }
302
+ const compose = createCoverageCompose(
303
+ called,
304
+ signals.ctx.compose,
305
+ topo,
306
+ signals.ctx,
307
+ resources
308
+ );
309
+ const testCtx: TrailContext = { ...signals.ctx, compose };
310
+
311
+ // Top-level trail validates against trail.input (not merged composeInput).
312
+ // Merged validation only applies to compose targets in executeFromMap/createCoverageCompose.
313
+ const result = await executeTrail(trailDef, example.input, {
314
+ ctx: testCtx,
315
+ resources: resources ?? opts?.resources,
316
+ ...(target.version === undefined ? {} : { version: target.version }),
270
317
  });
318
+ assertProgressiveMatch(result, example, output);
319
+ signals.assert();
271
320
  };
272
321
 
273
322
  // ---------------------------------------------------------------------------
@@ -277,38 +326,114 @@ const describeHikeExamples = (
277
326
  /**
278
327
  * Generate describe/test blocks for every trail example in the app.
279
328
  *
280
- * For hikes with `follows` declarations and examples, also verifies that
281
- * every declared follow ID was called at least once across all examples.
329
+ * For trails with `composes` declarations and examples, also verifies that
330
+ * every declared composed ID was called at least once across all examples.
282
331
  *
283
332
  * One line in your test file:
284
333
  * ```ts
285
- * testExamples(app);
334
+ * testExamples(graph);
286
335
  * ```
287
336
  */
288
337
  export const testExamples = (
289
338
  app: Topo,
290
- ctxOrFactory?: Partial<TrailContext> | (() => Partial<TrailContext>)
339
+ ctxOrFactory?:
340
+ | Partial<TrailContext>
341
+ | TestExecutionOptions
342
+ | (() => Partial<TrailContext> | TestExecutionOptions)
291
343
  ): void => {
292
- const resolveCtx =
344
+ const resolveInput =
293
345
  typeof ctxOrFactory === 'function' ? ctxOrFactory : () => ctxOrFactory;
294
- const trailEntries = [...app.trails];
295
-
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
- }
346
+ const withExamples = (app.list() as Trail<unknown, unknown, unknown>[])
347
+ .flatMap(deriveTrailExampleTargets)
348
+ .filter((target) => target.examples.length > 0);
349
+ const simpleTrails = withExamples.filter((t) => t.composes.length === 0);
350
+ const compositionTrails = withExamples.filter((t) => t.composes.length > 0);
351
+
352
+ // Simple trails: run examples directly
353
+ if (simpleTrails.length > 0) {
354
+ describe.each(simpleTrails)('$id', (t) => {
355
+ const { examples } = t;
356
+ if (!examples) {
357
+ return;
358
+ }
301
359
 
302
- const { examples, output } = t;
360
+ test.each([...examples])(
361
+ 'example: $name',
362
+ async (example: TrailExample<unknown, unknown>) => {
363
+ const resolved = normalizeTestExecutionOptions(resolveInput());
364
+ const resources = mergeResourceOverrides(
365
+ await createMockResources(app),
366
+ resolved.ctx,
367
+ resolved.resources
368
+ );
369
+ const testCtx = mergeTestContext(resolved.ctx);
370
+ await runTargetExample(t, example, testCtx, resources, resolved);
371
+ }
372
+ );
373
+ });
374
+ }
303
375
 
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);
376
+ // Composition trails: use recording compose and check coverage.
377
+ //
378
+ // Composing coverage only runs against AUTHORED examples. Contour-derived
379
+ // fixtures are opportunistic coverage that may not exercise every
380
+ // `ctx.compose()` branch in the trail, so asserting coverage against them
381
+ // would produce false failures for trails whose authored intent was a
382
+ // single path. When a trail has zero authored examples the coverage
383
+ // assertion is skipped entirely — the derived-example runs still
384
+ // execute, but they are not required to cover declared compositions.
385
+ if (compositionTrails.length > 0) {
386
+ describe.each(compositionTrails)('$id', (t) => {
387
+ const { examples } = t;
388
+ if (!examples) {
389
+ return;
309
390
  }
310
- );
311
- });
312
391
 
313
- describeHikeExamples(collectHikesWithExamples(app), resolveCtx, app);
392
+ const composedFromAuthored = new Set<string>();
393
+ const hasAuthoredExamples = examples.some(
394
+ (example) => !isDerivedExample(example)
395
+ );
396
+
397
+ // Only record compose calls from authored examples. Derived fixtures
398
+ // execute normally but do not contribute to coverage — the sink map
399
+ // puts each example in the right bucket without an inline
400
+ // conditional inside the test body.
401
+ const discardSink = new Set<string>();
402
+ const pickCoverageSink = (
403
+ example: TrailExample<unknown, unknown>
404
+ ): Set<string> =>
405
+ isDerivedExample(example) ? discardSink : composedFromAuthored;
406
+
407
+ test.each([...examples])(
408
+ 'example: $name',
409
+ async (example: TrailExample<unknown, unknown>) => {
410
+ const resolved = normalizeTestExecutionOptions(resolveInput());
411
+ const resources = mergeResourceOverrides(
412
+ await createMockResources(app),
413
+ resolved.ctx,
414
+ resolved.resources
415
+ );
416
+ const baseCtx = mergeTestContext(resolved.ctx);
417
+ await runCompositionExample(
418
+ t,
419
+ example,
420
+ baseCtx,
421
+ pickCoverageSink(example),
422
+ app,
423
+ resources,
424
+ resolved
425
+ );
426
+ }
427
+ );
428
+
429
+ if (hasAuthoredExamples) {
430
+ test('composing coverage', () => {
431
+ const uncovered = t.composes.filter(
432
+ (id) => !composedFromAuthored.has(id)
433
+ );
434
+ expect(uncovered).toEqual([]);
435
+ });
436
+ }
437
+ });
438
+ }
314
439
  };