@ontrails/testing 1.0.0-beta.0

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 (80) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/.turbo/turbo-lint.log +3 -0
  3. package/.turbo/turbo-typecheck.log +1 -0
  4. package/CHANGELOG.md +23 -0
  5. package/README.md +221 -0
  6. package/dist/all.d.ts +30 -0
  7. package/dist/all.d.ts.map +1 -0
  8. package/dist/all.js +47 -0
  9. package/dist/all.js.map +1 -0
  10. package/dist/assertions.d.ts +49 -0
  11. package/dist/assertions.d.ts.map +1 -0
  12. package/dist/assertions.js +84 -0
  13. package/dist/assertions.js.map +1 -0
  14. package/dist/context.d.ts +19 -0
  15. package/dist/context.d.ts.map +1 -0
  16. package/dist/context.js +33 -0
  17. package/dist/context.js.map +1 -0
  18. package/dist/contracts.d.ts +16 -0
  19. package/dist/contracts.d.ts.map +1 -0
  20. package/dist/contracts.js +56 -0
  21. package/dist/contracts.js.map +1 -0
  22. package/dist/detours.d.ts +12 -0
  23. package/dist/detours.d.ts.map +1 -0
  24. package/dist/detours.js +30 -0
  25. package/dist/detours.js.map +1 -0
  26. package/dist/examples.d.ts +22 -0
  27. package/dist/examples.d.ts.map +1 -0
  28. package/dist/examples.js +187 -0
  29. package/dist/examples.js.map +1 -0
  30. package/dist/harness-cli.d.ts +21 -0
  31. package/dist/harness-cli.d.ts.map +1 -0
  32. package/dist/harness-cli.js +213 -0
  33. package/dist/harness-cli.js.map +1 -0
  34. package/dist/harness-mcp.d.ts +21 -0
  35. package/dist/harness-mcp.d.ts.map +1 -0
  36. package/dist/harness-mcp.js +50 -0
  37. package/dist/harness-mcp.js.map +1 -0
  38. package/dist/hike.d.ts +32 -0
  39. package/dist/hike.d.ts.map +1 -0
  40. package/dist/hike.js +169 -0
  41. package/dist/hike.js.map +1 -0
  42. package/dist/index.d.ts +14 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +16 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/logger.d.ts +15 -0
  47. package/dist/logger.d.ts.map +1 -0
  48. package/dist/logger.js +87 -0
  49. package/dist/logger.js.map +1 -0
  50. package/dist/trail.d.ts +20 -0
  51. package/dist/trail.d.ts.map +1 -0
  52. package/dist/trail.js +80 -0
  53. package/dist/trail.js.map +1 -0
  54. package/dist/types.d.ts +80 -0
  55. package/dist/types.d.ts.map +1 -0
  56. package/dist/types.js +5 -0
  57. package/dist/types.js.map +1 -0
  58. package/package.json +23 -0
  59. package/src/__tests__/context.test.ts +60 -0
  60. package/src/__tests__/contracts.test.ts +68 -0
  61. package/src/__tests__/detours.test.ts +55 -0
  62. package/src/__tests__/examples.test.ts +176 -0
  63. package/src/__tests__/hike.test.ts +164 -0
  64. package/src/__tests__/logger.test.ts +136 -0
  65. package/src/__tests__/trail.test.ts +99 -0
  66. package/src/all.ts +55 -0
  67. package/src/assertions.ts +108 -0
  68. package/src/context.ts +42 -0
  69. package/src/contracts.ts +85 -0
  70. package/src/detours.ts +44 -0
  71. package/src/examples.ts +314 -0
  72. package/src/harness-cli.ts +310 -0
  73. package/src/harness-mcp.ts +65 -0
  74. package/src/hike.ts +283 -0
  75. package/src/index.ts +40 -0
  76. package/src/logger.ts +125 -0
  77. package/src/trail.ts +116 -0
  78. package/src/types.ts +117 -0
  79. package/tsconfig.json +9 -0
  80. package/tsconfig.tsbuildinfo +1 -0
package/src/all.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * testAll — single-line governance suite for any Topo.
3
+ *
4
+ * Wraps topo validation, example execution, contract checks, and detour
5
+ * verification into one describe block.
6
+ */
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+
10
+ import type { Topo, TrailContext } from '@ontrails/core';
11
+ import { validateTopo } from '@ontrails/core';
12
+
13
+ import { testContracts } from './contracts.js';
14
+ import { testDetours } from './detours.js';
15
+ import { testExamples } from './examples.js';
16
+
17
+ /**
18
+ * Run the full governance test suite for a Topo.
19
+ *
20
+ * Generates a `governance` describe block containing:
21
+ * - Structural validation via `validateTopo`
22
+ * - Example execution via `testExamples`
23
+ * - Output contract checks via `testContracts`
24
+ * - Detour target verification via `testDetours`
25
+ *
26
+ * Accepts either a static context or a factory function that produces a
27
+ * fresh context per test (useful when the context contains mutable state
28
+ * like an in-memory store).
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * import { testAll } from '@ontrails/testing';
33
+ * import { app } from '../src/app.js';
34
+ *
35
+ * testAll(app);
36
+ * ```
37
+ */
38
+ export const testAll = (
39
+ topo: Topo,
40
+ ctxOrFactory?: Partial<TrailContext> | (() => Partial<TrailContext>)
41
+ ): void => {
42
+ describe('governance', () => {
43
+ test('topo validates', () => {
44
+ const result = validateTopo(topo);
45
+ expect(result.isOk()).toBe(true);
46
+ });
47
+
48
+ // oxlint-disable-next-line jest/require-hook -- these generate describe/test blocks, not setup code
49
+ testExamples(topo, ctxOrFactory);
50
+ // oxlint-disable-next-line jest/require-hook -- these generate describe/test blocks, not setup code
51
+ testContracts(topo, ctxOrFactory);
52
+ // oxlint-disable-next-line jest/require-hook -- these generate describe/test blocks, not setup code
53
+ testDetours(topo);
54
+ });
55
+ };
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Progressive assertion logic for example-driven testing.
3
+ *
4
+ * Three tiers:
5
+ * 1. Full match — example has `expected` output
6
+ * 2. Schema-only — no expected output, no error
7
+ * 3. Error match — example declares an error class name
8
+ */
9
+
10
+ import { expect } from 'bun:test';
11
+
12
+ import type { Result } from '@ontrails/core';
13
+ import { formatZodIssues } from '@ontrails/core';
14
+ import type { z } from 'zod';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Result narrowing helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Assert that a Result is Ok and return its value.
22
+ *
23
+ * Eliminates the `if (result.isOk())` / `as unknown as` dance in tests.
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * const value = expectOk(result);
28
+ * expect(value.name).toBe('Alice');
29
+ * ```
30
+ */
31
+ export const expectOk = <T, E>(result: Result<T, E>): T => {
32
+ expect(result.isOk()).toBe(true);
33
+ return (result as unknown as { value: T }).value;
34
+ };
35
+
36
+ /**
37
+ * Assert that a Result is Err and return its error.
38
+ *
39
+ * Eliminates the `if (result.isErr())` / `as unknown as` dance in tests.
40
+ *
41
+ * @example
42
+ * ```typescript
43
+ * const error = expectErr(result);
44
+ * expect(error).toBeInstanceOf(ValidationError);
45
+ * ```
46
+ */
47
+ export const expectErr = <T, E>(result: Result<T, E>): E => {
48
+ expect(result.isErr()).toBe(true);
49
+ return (result as unknown as { error: E }).error;
50
+ };
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Full Match
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Assert that the result is ok and its value deep-equals the expected output.
58
+ */
59
+ export const assertFullMatch = (
60
+ result: Result<unknown, Error>,
61
+ expected: unknown
62
+ ): void => {
63
+ const value = expectOk(result);
64
+ expect(value).toEqual(expected);
65
+ };
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Schema-Only Match
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * Assert that the result is ok and, if an output schema is provided,
73
+ * the value parses against it.
74
+ */
75
+ export const assertSchemaMatch = (
76
+ result: Result<unknown, Error>,
77
+ outputSchema: z.ZodType | undefined
78
+ ): void => {
79
+ const value = expectOk(result);
80
+ if (outputSchema !== undefined) {
81
+ const parsed = outputSchema.safeParse(value);
82
+ if (!parsed.success) {
83
+ throw new Error(
84
+ `Output does not match schema: ${formatZodIssues(parsed.error.issues).join('; ')}`
85
+ );
86
+ }
87
+ }
88
+ };
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Error Match
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /**
95
+ * Assert that the result is an error of the specified type, with optional
96
+ * message substring matching.
97
+ */
98
+ export const assertErrorMatch = (
99
+ result: Result<unknown, Error>,
100
+ expectedError: new (...args: never[]) => Error,
101
+ expectedMessage?: string
102
+ ): void => {
103
+ const error = expectErr(result);
104
+ expect(error).toBeInstanceOf(expectedError);
105
+ if (expectedMessage !== undefined) {
106
+ expect(error.message).toContain(expectedMessage);
107
+ }
108
+ };
package/src/context.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Test context factory for creating TrailContext instances suitable for testing.
3
+ */
4
+
5
+ import type { TrailContext } from '@ontrails/core';
6
+
7
+ import { createTestLogger } from './logger.js';
8
+ import type { TestTrailContextOptions } from './types.js';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // createTestContext
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /**
15
+ * Create a TrailContext with deterministic, test-friendly defaults.
16
+ *
17
+ * - `requestId`: `"test-request-001"` (deterministic)
18
+ * - `logger`: a `TestLogger` that captures entries
19
+ * - `signal`: a non-aborted AbortController signal
20
+ */
21
+ export const createTestContext = (
22
+ 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(),
29
+ });
30
+
31
+ /**
32
+ * Merge a Partial<TrailContext> into a test context.
33
+ * Used internally when the public API accepts Partial<TrailContext>.
34
+ */
35
+ export const mergeTestContext = (ctx?: Partial<TrailContext>): TrailContext => {
36
+ if (ctx === undefined) {
37
+ return createTestContext();
38
+ }
39
+
40
+ const base = createTestContext();
41
+ return { ...base, ...ctx };
42
+ };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * testContracts — output schema verification.
3
+ *
4
+ * For every trail that has both examples and an output schema,
5
+ * run each example and validate the implementation output against
6
+ * the declared schema.
7
+ */
8
+
9
+ import { describe, test } from 'bun:test';
10
+
11
+ import type { Topo, TrailExample, Trail, TrailContext } from '@ontrails/core';
12
+ import { formatZodIssues, validateInput } from '@ontrails/core';
13
+ import type { z } from 'zod';
14
+
15
+ import { expectOk } from './assertions.js';
16
+ import { mergeTestContext } from './context.js';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Helpers
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const validateOutputSchema = (
23
+ outputSchema: z.ZodType,
24
+ value: unknown,
25
+ trailId: string,
26
+ exampleName: string
27
+ ): void => {
28
+ const parsed = outputSchema.safeParse(value);
29
+ if (!parsed.success) {
30
+ const issues = formatZodIssues(parsed.error.issues);
31
+ throw new Error(
32
+ `Output schema violation for trail "${trailId}", example "${exampleName}":\n${issues.map((i) => ` - ${i}`).join('\n')}\n\nActual output: ${JSON.stringify(value, null, 2)}`
33
+ );
34
+ }
35
+ };
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // testContracts
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Verify that every trail's implementation output matches its declared
43
+ * output schema. Catches implementation-schema drift.
44
+ *
45
+ * Trails without output schemas or examples are skipped.
46
+ */
47
+ export const testContracts = (
48
+ app: Topo,
49
+ ctxOrFactory?: Partial<TrailContext> | (() => Partial<TrailContext>)
50
+ ): void => {
51
+ const resolveCtx =
52
+ typeof ctxOrFactory === 'function' ? ctxOrFactory : () => ctxOrFactory;
53
+ const trailEntries = [...app.trails];
54
+
55
+ describe('contracts', () => {
56
+ describe.each(trailEntries)('%s', (_id, trailDef) => {
57
+ const t = trailDef as Trail<unknown, unknown>;
58
+
59
+ if (t.output === undefined) {
60
+ return;
61
+ }
62
+ if (t.examples === undefined || t.examples.length === 0) {
63
+ return;
64
+ }
65
+
66
+ const { examples, output: outputSchema } = t;
67
+ const successExamples = examples.filter((e) => e.error === undefined);
68
+
69
+ test.each(successExamples)(
70
+ 'contract: $name',
71
+ async (example: TrailExample<unknown, unknown>) => {
72
+ const testCtx = mergeTestContext(resolveCtx());
73
+
74
+ const validated = validateInput(t.input, example.input);
75
+ const validatedInput = expectOk(validated);
76
+
77
+ const result = await t.implementation(validatedInput, testCtx);
78
+ const resultValue = expectOk(result);
79
+
80
+ validateOutputSchema(outputSchema, resultValue, t.id, example.name);
81
+ }
82
+ );
83
+ });
84
+ });
85
+ };
package/src/detours.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * testDetours — verify that all detour targets exist in the topo.
3
+ *
4
+ * Pure structural validation. No implementation execution needed.
5
+ */
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+
9
+ import type { Topo, Trail } from '@ontrails/core';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // testDetours
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /**
16
+ * Verify that every trail's detour targets reference trails that
17
+ * actually exist in the app's topo.
18
+ */
19
+ export const testDetours = (app: Topo): void => {
20
+ const trailEntries = [...app.trails];
21
+
22
+ describe('detours', () => {
23
+ describe.each(trailEntries)('%s', (_id, trailDef) => {
24
+ const t = trailDef as Trail<unknown, unknown>;
25
+
26
+ if (t.detours === undefined) {
27
+ return;
28
+ }
29
+
30
+ const { detours } = t;
31
+ const testCases = Object.entries(detours).flatMap(
32
+ ([detourName, targets]) =>
33
+ targets.map((targetId) => ({ detourName, targetId }))
34
+ );
35
+
36
+ test.each(testCases)(
37
+ 'detour "$detourName" -> "$targetId" exists',
38
+ ({ targetId }) => {
39
+ expect(app.has(targetId)).toBe(true);
40
+ }
41
+ );
42
+ });
43
+ });
44
+ };
@@ -0,0 +1,314 @@
1
+ /**
2
+ * testExamples — the headline one-liner.
3
+ *
4
+ * Iterates every trail in the app's topo. For each trail with examples,
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.
8
+ */
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+
12
+ import type {
13
+ AnyHike,
14
+ FollowFn,
15
+ Topo,
16
+ TrailExample,
17
+ Trail,
18
+ TrailContext,
19
+ } from '@ontrails/core';
20
+
21
+ import {
22
+ AlreadyExistsError,
23
+ AmbiguousError,
24
+ AssertionError,
25
+ AuthError,
26
+ CancelledError,
27
+ ConflictError,
28
+ InternalError,
29
+ NetworkError,
30
+ NotFoundError,
31
+ PermissionError,
32
+ RateLimitError,
33
+ Result,
34
+ TimeoutError,
35
+ TrailsError,
36
+ ValidationError,
37
+ validateInput,
38
+ } from '@ontrails/core';
39
+ import type { z } from 'zod';
40
+
41
+ import {
42
+ assertErrorMatch,
43
+ assertFullMatch,
44
+ assertSchemaMatch,
45
+ expectOk,
46
+ } 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,
68
+ };
69
+
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
+ // ---------------------------------------------------------------------------
78
+ // Helpers
79
+ // ---------------------------------------------------------------------------
80
+
81
+ const assertProgressiveMatch = (
82
+ result: Result<unknown, Error>,
83
+ example: TrailExample<unknown, unknown>,
84
+ output: z.ZodType | undefined
85
+ ): void => {
86
+ if (example.expected !== undefined) {
87
+ assertFullMatch(result, example.expected);
88
+ return;
89
+ }
90
+
91
+ if (example.error !== undefined) {
92
+ const errorClass = resolveErrorClass(example.error);
93
+ assertErrorMatch(result, errorClass);
94
+ return;
95
+ }
96
+
97
+ assertSchemaMatch(result, output);
98
+ };
99
+
100
+ /**
101
+ * Handle input validation failure for an example.
102
+ * Returns true if the validation error was expected (and assertions passed).
103
+ * Throws if the validation error was unexpected.
104
+ */
105
+ const handleValidationError = (
106
+ validated: Result<unknown, Error>,
107
+ example: TrailExample<unknown, unknown>
108
+ ): boolean => {
109
+ if (!validated.isErr()) {
110
+ return false;
111
+ }
112
+
113
+ if (example.error !== undefined) {
114
+ const errorClass = resolveErrorClass(example.error);
115
+ expect(validated.error).toBeInstanceOf(errorClass);
116
+ return true;
117
+ }
118
+
119
+ throw new Error(
120
+ `Example "${example.name}" has invalid input: ${validated.error.message}`
121
+ );
122
+ };
123
+
124
+ /**
125
+ * Run a single example against a trail.
126
+ * Handles validation, execution, and assertions.
127
+ */
128
+ const runExample = async (
129
+ t: Trail<unknown, unknown>,
130
+ example: TrailExample<unknown, unknown>,
131
+ output: z.ZodType | undefined,
132
+ testCtx: TrailContext
133
+ ): Promise<void> => {
134
+ const validated = validateInput(t.input, example.input);
135
+
136
+ if (handleValidationError(validated, example)) {
137
+ return;
138
+ }
139
+ const validatedInput = expectOk(validated);
140
+
141
+ const result = await t.implementation(validatedInput, testCtx);
142
+ assertProgressiveMatch(result, example, output);
143
+ };
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Follows coverage for hikes
147
+ // ---------------------------------------------------------------------------
148
+
149
+ /**
150
+ * Build a recording follow function that tracks which trail IDs are called.
151
+ *
152
+ * Delegates to `baseFollow` when available, otherwise looks up the trail
153
+ * in the topo and executes it with validated input. Falls back to
154
+ * `Result.ok()` when neither is available.
155
+ */
156
+ const createCoverageFollow = (
157
+ called: Set<string>,
158
+ baseFollow: FollowFn | undefined,
159
+ topo: Topo,
160
+ ctx: TrailContext
161
+ ): FollowFn => {
162
+ const follow = (id: string, input: unknown) => {
163
+ called.add(id);
164
+
165
+ if (baseFollow !== undefined) {
166
+ return baseFollow(id, input);
167
+ }
168
+
169
+ const trailDef = topo.get(id);
170
+ 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));
176
+ }
177
+
178
+ return Promise.resolve(Result.ok());
179
+ };
180
+ return follow as FollowFn;
181
+ };
182
+
183
+ /**
184
+ * Run a single example against a hike, recording follow calls.
185
+ */
186
+ const runHikeExample = async (
187
+ hikeDef: AnyHike,
188
+ example: TrailExample<unknown, unknown>,
189
+ output: z.ZodType | undefined,
190
+ baseCtx: TrailContext,
191
+ called: Set<string>,
192
+ topo: Topo
193
+ ): Promise<void> => {
194
+ const validated = validateInput(hikeDef.input, example.input);
195
+
196
+ if (handleValidationError(validated, example)) {
197
+ return;
198
+ }
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
+
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
+ }
270
+ });
271
+ };
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // testExamples
275
+ // ---------------------------------------------------------------------------
276
+
277
+ /**
278
+ * Generate describe/test blocks for every trail example in the app.
279
+ *
280
+ * For hikes with `follows` declarations and examples, also verifies that
281
+ * every declared follow ID was called at least once across all examples.
282
+ *
283
+ * One line in your test file:
284
+ * ```ts
285
+ * testExamples(app);
286
+ * ```
287
+ */
288
+ export const testExamples = (
289
+ app: Topo,
290
+ ctxOrFactory?: Partial<TrailContext> | (() => Partial<TrailContext>)
291
+ ): void => {
292
+ const resolveCtx =
293
+ 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
+ }
301
+
302
+ const { examples, output } = t;
303
+
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);
309
+ }
310
+ );
311
+ });
312
+
313
+ describeHikeExamples(collectHikesWithExamples(app), resolveCtx, app);
314
+ };