@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.
- package/CHANGELOG.md +396 -5
- package/README.md +86 -22
- package/package.json +34 -5
- package/src/all-established.ts +168 -0
- package/src/all.ts +51 -12
- package/src/assertions.ts +253 -0
- package/src/cli.ts +6 -0
- package/src/composes.ts +433 -0
- package/src/context.ts +200 -14
- package/src/contracts.ts +45 -21
- package/src/detours.ts +155 -18
- package/src/effective-examples.ts +414 -0
- package/src/errors.ts +47 -0
- package/src/examples.ts +302 -177
- package/src/harness-cli.ts +83 -58
- package/src/harness-http.ts +341 -0
- package/src/harness-mcp.ts +46 -16
- package/src/http.ts +10 -0
- package/src/index.ts +22 -14
- package/src/logger.ts +3 -1
- package/src/mcp.ts +6 -0
- package/src/scenario.ts +375 -0
- package/src/signals.ts +221 -0
- package/src/surface-parity.ts +389 -0
- package/src/trail.ts +1 -1
- package/src/types.ts +24 -52
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/dist/all.d.ts +0 -30
- package/dist/all.d.ts.map +0 -1
- package/dist/all.js +0 -47
- package/dist/all.js.map +0 -1
- package/dist/assertions.d.ts +0 -49
- package/dist/assertions.d.ts.map +0 -1
- package/dist/assertions.js +0 -84
- package/dist/assertions.js.map +0 -1
- package/dist/context.d.ts +0 -19
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js +0 -33
- package/dist/context.js.map +0 -1
- package/dist/contracts.d.ts +0 -16
- package/dist/contracts.d.ts.map +0 -1
- package/dist/contracts.js +0 -56
- package/dist/contracts.js.map +0 -1
- package/dist/detours.d.ts +0 -12
- package/dist/detours.d.ts.map +0 -1
- package/dist/detours.js +0 -30
- package/dist/detours.js.map +0 -1
- package/dist/examples.d.ts +0 -22
- package/dist/examples.d.ts.map +0 -1
- package/dist/examples.js +0 -187
- package/dist/examples.js.map +0 -1
- package/dist/harness-cli.d.ts +0 -21
- package/dist/harness-cli.d.ts.map +0 -1
- package/dist/harness-cli.js +0 -213
- package/dist/harness-cli.js.map +0 -1
- package/dist/harness-mcp.d.ts +0 -21
- package/dist/harness-mcp.d.ts.map +0 -1
- package/dist/harness-mcp.js +0 -50
- package/dist/harness-mcp.js.map +0 -1
- package/dist/hike.d.ts +0 -32
- package/dist/hike.d.ts.map +0 -1
- package/dist/hike.js +0 -169
- package/dist/hike.js.map +0 -1
- package/dist/index.d.ts +0 -14
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -16
- package/dist/index.js.map +0 -1
- package/dist/logger.d.ts +0 -15
- package/dist/logger.d.ts.map +0 -1
- package/dist/logger.js +0 -87
- package/dist/logger.js.map +0 -1
- package/dist/trail.d.ts +0 -20
- package/dist/trail.d.ts.map +0 -1
- package/dist/trail.js +0 -80
- package/dist/trail.js.map +0 -1
- package/dist/types.d.ts +0 -80
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -5
- package/dist/types.js.map +0 -1
- package/src/__tests__/context.test.ts +0 -60
- package/src/__tests__/contracts.test.ts +0 -68
- package/src/__tests__/detours.test.ts +0 -55
- package/src/__tests__/examples.test.ts +0 -176
- package/src/__tests__/hike.test.ts +0 -164
- package/src/__tests__/logger.test.ts +0 -136
- package/src/__tests__/trail.test.ts +0 -99
- package/src/hike.ts +0 -283
- package/tsconfig.json +0 -9
- 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
|
|
7
|
-
* declarations, checks that every declared
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
126
|
-
*
|
|
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
|
|
129
|
-
|
|
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
|
-
|
|
132
|
-
|
|
130
|
+
testCtx: TrailContext,
|
|
131
|
+
resources?: ResourceOverrideMap,
|
|
132
|
+
opts?: TestExecutionOptions
|
|
133
133
|
): Promise<void> => {
|
|
134
|
-
const
|
|
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.
|
|
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
|
-
//
|
|
183
|
+
// Composing coverage for trails with compositions
|
|
147
184
|
// ---------------------------------------------------------------------------
|
|
148
185
|
|
|
149
186
|
/**
|
|
150
|
-
* Build a recording
|
|
187
|
+
* Build a recording compose function that tracks which trail IDs are called.
|
|
151
188
|
*
|
|
152
|
-
* Delegates to `
|
|
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
|
|
193
|
+
const createCoverageCompose = (
|
|
157
194
|
called: Set<string>,
|
|
158
|
-
|
|
195
|
+
baseCompose: ComposeFn | undefined,
|
|
159
196
|
topo: Topo,
|
|
160
|
-
ctx: TrailContext
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
246
|
+
return Result.ok();
|
|
179
247
|
};
|
|
180
|
-
|
|
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
|
|
279
|
+
* Run a single example against a trail with compositions, recording compose calls.
|
|
185
280
|
*/
|
|
186
|
-
const
|
|
187
|
-
|
|
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
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
281
|
-
* every declared
|
|
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(
|
|
334
|
+
* testExamples(graph);
|
|
286
335
|
* ```
|
|
287
336
|
*/
|
|
288
337
|
export const testExamples = (
|
|
289
338
|
app: Topo,
|
|
290
|
-
ctxOrFactory?:
|
|
339
|
+
ctxOrFactory?:
|
|
340
|
+
| Partial<TrailContext>
|
|
341
|
+
| TestExecutionOptions
|
|
342
|
+
| (() => Partial<TrailContext> | TestExecutionOptions)
|
|
291
343
|
): void => {
|
|
292
|
-
const
|
|
344
|
+
const resolveInput =
|
|
293
345
|
typeof ctxOrFactory === 'function' ? ctxOrFactory : () => ctxOrFactory;
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
};
|