@ontrails/testing 1.0.0-beta.13 → 1.0.0-beta.15
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/.turbo/turbo-lint.log +1 -1
- package/CHANGELOG.md +28 -0
- package/README.md +11 -11
- package/dist/all.d.ts +10 -5
- package/dist/all.d.ts.map +1 -1
- package/dist/all.js +78 -26
- package/dist/all.js.map +1 -1
- package/dist/assertions.d.ts +23 -0
- package/dist/assertions.d.ts.map +1 -1
- package/dist/assertions.js +154 -0
- package/dist/assertions.js.map +1 -1
- package/dist/context.d.ts +17 -16
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +31 -20
- package/dist/context.js.map +1 -1
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +9 -5
- package/dist/contracts.js.map +1 -1
- package/dist/crosses.d.ts +4 -4
- package/dist/crosses.d.ts.map +1 -1
- package/dist/crosses.js +49 -39
- package/dist/crosses.js.map +1 -1
- package/dist/detours.d.ts +5 -4
- package/dist/detours.d.ts.map +1 -1
- package/dist/detours.js +93 -14
- package/dist/detours.js.map +1 -1
- package/dist/effective-examples.d.ts +30 -0
- package/dist/effective-examples.d.ts.map +1 -0
- package/dist/effective-examples.js +227 -0
- package/dist/effective-examples.js.map +1 -0
- package/dist/examples.d.ts +1 -1
- package/dist/examples.d.ts.map +1 -1
- package/dist/examples.js +79 -41
- package/dist/examples.js.map +1 -1
- package/dist/harness-cli.d.ts +3 -3
- package/dist/harness-cli.d.ts.map +1 -1
- package/dist/harness-cli.js +25 -33
- package/dist/harness-cli.js.map +1 -1
- package/dist/harness-mcp.d.ts +3 -3
- package/dist/harness-mcp.d.ts.map +1 -1
- package/dist/harness-mcp.js +9 -8
- package/dist/harness-mcp.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/scenario.d.ts +37 -0
- package/dist/scenario.d.ts.map +1 -0
- package/dist/scenario.js +235 -0
- package/dist/scenario.js.map +1 -0
- package/dist/types.d.ts +38 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +9 -5
- package/src/__tests__/all.test.ts +217 -29
- package/src/__tests__/context.test.ts +32 -12
- package/src/__tests__/contracts.test.ts +72 -18
- package/src/__tests__/crosses.test.ts +78 -78
- package/src/__tests__/detours.test.ts +176 -19
- package/src/__tests__/effective-examples.test.ts +203 -0
- package/src/__tests__/examples.test.ts +152 -50
- package/src/__tests__/harness-cli.test.ts +90 -0
- package/src/__tests__/harness-mcp.test.ts +37 -0
- package/src/__tests__/partial-match.test.ts +126 -0
- package/src/__tests__/scenario.test.ts +381 -0
- package/src/all.ts +149 -12
- package/src/assertions.ts +253 -0
- package/src/context.ts +64 -38
- package/src/contracts.ts +14 -8
- package/src/crosses.ts +93 -51
- package/src/detours.ts +155 -18
- package/src/effective-examples.ts +350 -0
- package/src/examples.ts +127 -59
- package/src/harness-cli.ts +33 -49
- package/src/harness-mcp.ts +9 -8
- package/src/index.ts +13 -3
- package/src/scenario.ts +370 -0
- package/src/types.ts +63 -5
- package/tsconfig.tests.json +10 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/follows.d.ts +0 -38
- package/dist/follows.d.ts.map +0 -1
- package/dist/follows.js +0 -212
- package/dist/follows.js.map +0 -1
package/src/examples.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { describe, expect, test } from 'bun:test';
|
|
|
11
11
|
|
|
12
12
|
import type {
|
|
13
13
|
CrossFn,
|
|
14
|
-
|
|
14
|
+
ResourceOverrideMap,
|
|
15
15
|
Topo,
|
|
16
16
|
TrailExample,
|
|
17
17
|
Trail,
|
|
@@ -23,13 +23,16 @@ import {
|
|
|
23
23
|
AmbiguousError,
|
|
24
24
|
AssertionError,
|
|
25
25
|
AuthError,
|
|
26
|
+
buildCrossValidationSchema,
|
|
26
27
|
CancelledError,
|
|
27
28
|
ConflictError,
|
|
29
|
+
DerivationError,
|
|
28
30
|
InternalError,
|
|
29
31
|
NetworkError,
|
|
30
32
|
NotFoundError,
|
|
31
33
|
PermissionError,
|
|
32
34
|
RateLimitError,
|
|
35
|
+
RetryExhaustedError,
|
|
33
36
|
executeTrail,
|
|
34
37
|
Result,
|
|
35
38
|
TimeoutError,
|
|
@@ -42,16 +45,18 @@ import type { z } from 'zod';
|
|
|
42
45
|
import {
|
|
43
46
|
assertErrorMatch,
|
|
44
47
|
assertFullMatch,
|
|
48
|
+
assertPartialMatch,
|
|
45
49
|
assertSchemaMatch,
|
|
46
50
|
} from './assertions.js';
|
|
47
51
|
import {
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
defaultCreatePermit,
|
|
53
|
+
mergeResourceOverrides,
|
|
50
54
|
mergeTestContext,
|
|
51
55
|
normalizeTestExecutionOptions,
|
|
52
|
-
|
|
56
|
+
createMockResources,
|
|
53
57
|
} from './context.js';
|
|
54
|
-
import type {
|
|
58
|
+
import type { PermittedTrail, TestExecutionOptions } from './context.js';
|
|
59
|
+
import { isDerivedExample, deriveTrailExamples } from './effective-examples.js';
|
|
55
60
|
|
|
56
61
|
// ---------------------------------------------------------------------------
|
|
57
62
|
// Error class name -> constructor map
|
|
@@ -64,11 +69,15 @@ const ERROR_MAP: Record<string, new (...args: never[]) => Error> = {
|
|
|
64
69
|
AuthError: AuthError as new (...args: never[]) => Error,
|
|
65
70
|
CancelledError: CancelledError as new (...args: never[]) => Error,
|
|
66
71
|
ConflictError: ConflictError as new (...args: never[]) => Error,
|
|
72
|
+
DerivationError: DerivationError as new (...args: never[]) => Error,
|
|
67
73
|
InternalError: InternalError as new (...args: never[]) => Error,
|
|
68
74
|
NetworkError: NetworkError as new (...args: never[]) => Error,
|
|
69
75
|
NotFoundError: NotFoundError as new (...args: never[]) => Error,
|
|
70
76
|
PermissionError: PermissionError as new (...args: never[]) => Error,
|
|
71
77
|
RateLimitError: RateLimitError as new (...args: never[]) => Error,
|
|
78
|
+
RetryExhaustedError: RetryExhaustedError as unknown as new (
|
|
79
|
+
...args: never[]
|
|
80
|
+
) => Error,
|
|
72
81
|
TimeoutError: TimeoutError as new (...args: never[]) => Error,
|
|
73
82
|
TrailsError: TrailsError as unknown as new (...args: never[]) => Error,
|
|
74
83
|
ValidationError: ValidationError as new (...args: never[]) => Error,
|
|
@@ -91,16 +100,14 @@ const assertProgressiveMatch = (
|
|
|
91
100
|
output: z.ZodType | undefined
|
|
92
101
|
): void => {
|
|
93
102
|
if (example.expected !== undefined) {
|
|
94
|
-
assertFullMatch(result, example.expected);
|
|
95
|
-
|
|
103
|
+
return assertFullMatch(result, example.expected);
|
|
104
|
+
}
|
|
105
|
+
if (example.expectedMatch !== undefined) {
|
|
106
|
+
return assertPartialMatch(result, example.expectedMatch);
|
|
96
107
|
}
|
|
97
|
-
|
|
98
108
|
if (example.error !== undefined) {
|
|
99
|
-
|
|
100
|
-
assertErrorMatch(result, errorClass);
|
|
101
|
-
return;
|
|
109
|
+
return assertErrorMatch(result, resolveErrorClass(example.error));
|
|
102
110
|
}
|
|
103
|
-
|
|
104
111
|
assertSchemaMatch(result, output);
|
|
105
112
|
};
|
|
106
113
|
|
|
@@ -129,12 +136,12 @@ const handleValidationError = (
|
|
|
129
136
|
};
|
|
130
137
|
|
|
131
138
|
/**
|
|
132
|
-
* Apply auto-
|
|
133
|
-
* doesn't already have a permit,
|
|
139
|
+
* Apply auto-permit: if the trail declares scoped permits and the context
|
|
140
|
+
* doesn't already have a permit, create one and merge it into the context.
|
|
134
141
|
*/
|
|
135
|
-
const
|
|
142
|
+
const applyAutoPermit = (
|
|
136
143
|
ctx: TrailContext,
|
|
137
|
-
trailDef:
|
|
144
|
+
trailDef: PermittedTrail,
|
|
138
145
|
opts: TestExecutionOptions
|
|
139
146
|
): TrailContext => {
|
|
140
147
|
if (opts.strictPermits) {
|
|
@@ -143,8 +150,8 @@ const applyAutoMint = (
|
|
|
143
150
|
if (ctx.permit !== undefined) {
|
|
144
151
|
return ctx;
|
|
145
152
|
}
|
|
146
|
-
const
|
|
147
|
-
const permit =
|
|
153
|
+
const create = opts.createPermit ?? defaultCreatePermit;
|
|
154
|
+
const permit = create(trailDef);
|
|
148
155
|
if (!permit) {
|
|
149
156
|
return ctx;
|
|
150
157
|
}
|
|
@@ -156,11 +163,11 @@ const applyAutoMint = (
|
|
|
156
163
|
* Handles validation, execution, and assertions.
|
|
157
164
|
*/
|
|
158
165
|
const runExample = async (
|
|
159
|
-
t: Trail<unknown, unknown>,
|
|
166
|
+
t: Trail<unknown, unknown, unknown>,
|
|
160
167
|
example: TrailExample<unknown, unknown>,
|
|
161
168
|
output: z.ZodType | undefined,
|
|
162
169
|
testCtx: TrailContext,
|
|
163
|
-
|
|
170
|
+
resources?: ResourceOverrideMap,
|
|
164
171
|
opts?: TestExecutionOptions
|
|
165
172
|
): Promise<void> => {
|
|
166
173
|
const validated = validateInput(t.input, example.input);
|
|
@@ -169,11 +176,11 @@ const runExample = async (
|
|
|
169
176
|
return;
|
|
170
177
|
}
|
|
171
178
|
|
|
172
|
-
const ctx = opts ?
|
|
179
|
+
const ctx = opts ? applyAutoPermit(testCtx, t, opts) : testCtx;
|
|
173
180
|
|
|
174
181
|
const result = await executeTrail(t, example.input, {
|
|
175
182
|
ctx,
|
|
176
|
-
|
|
183
|
+
resources: resources ?? opts?.resources,
|
|
177
184
|
});
|
|
178
185
|
assertProgressiveMatch(result, example, output);
|
|
179
186
|
};
|
|
@@ -194,39 +201,70 @@ const createCoverageCross = (
|
|
|
194
201
|
baseCross: CrossFn | undefined,
|
|
195
202
|
topo: Topo,
|
|
196
203
|
ctx: TrailContext,
|
|
197
|
-
|
|
204
|
+
resources?: ResourceOverrideMap
|
|
198
205
|
): CrossFn => {
|
|
199
|
-
const
|
|
206
|
+
const invokeCross = async (
|
|
207
|
+
idOrTrail: string | { readonly id: string },
|
|
208
|
+
input: unknown,
|
|
209
|
+
self: CrossFn
|
|
210
|
+
) => {
|
|
211
|
+
const id = typeof idOrTrail === 'string' ? idOrTrail : idOrTrail.id;
|
|
200
212
|
called.add(id);
|
|
201
213
|
|
|
202
214
|
if (baseCross !== undefined) {
|
|
203
|
-
return baseCross(id, input);
|
|
215
|
+
return await baseCross(id, input);
|
|
204
216
|
}
|
|
205
217
|
|
|
206
218
|
const trailDef = topo.get(id);
|
|
207
219
|
if (trailDef !== undefined) {
|
|
208
|
-
return executeTrail(trailDef, input, {
|
|
209
|
-
ctx: { ...ctx, cross },
|
|
210
|
-
|
|
220
|
+
return await executeTrail(trailDef, input, {
|
|
221
|
+
ctx: { ...ctx, cross: self },
|
|
222
|
+
resources,
|
|
223
|
+
validationSchema: buildCrossValidationSchema(trailDef),
|
|
211
224
|
});
|
|
212
225
|
}
|
|
213
226
|
|
|
214
|
-
return
|
|
227
|
+
return Result.ok();
|
|
215
228
|
};
|
|
216
|
-
|
|
229
|
+
|
|
230
|
+
// Accepts either a trail object (typed cross), a string id (untyped),
|
|
231
|
+
// or a batch of `[target, input]` tuples.
|
|
232
|
+
const cross = async function cross(
|
|
233
|
+
idOrTrail:
|
|
234
|
+
| string
|
|
235
|
+
| { readonly id: string }
|
|
236
|
+
| readonly (readonly [string | { readonly id: string }, unknown])[],
|
|
237
|
+
input?: unknown
|
|
238
|
+
) {
|
|
239
|
+
if (Array.isArray(idOrTrail)) {
|
|
240
|
+
return await Promise.all(
|
|
241
|
+
idOrTrail.map(([target, batchInput]) =>
|
|
242
|
+
invokeCross(target, batchInput, cross as CrossFn)
|
|
243
|
+
)
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return await invokeCross(
|
|
248
|
+
idOrTrail as string | { readonly id: string },
|
|
249
|
+
input,
|
|
250
|
+
cross as CrossFn
|
|
251
|
+
);
|
|
252
|
+
} as CrossFn;
|
|
253
|
+
|
|
254
|
+
return cross;
|
|
217
255
|
};
|
|
218
256
|
|
|
219
257
|
/**
|
|
220
258
|
* Run a single example against a trail with crossings, recording cross calls.
|
|
221
259
|
*/
|
|
222
260
|
const runCompositionExample = async (
|
|
223
|
-
trailDef: Trail<unknown, unknown>,
|
|
261
|
+
trailDef: Trail<unknown, unknown, unknown>,
|
|
224
262
|
example: TrailExample<unknown, unknown>,
|
|
225
263
|
output: z.ZodType | undefined,
|
|
226
264
|
baseCtx: TrailContext,
|
|
227
265
|
called: Set<string>,
|
|
228
266
|
topo: Topo,
|
|
229
|
-
|
|
267
|
+
resources?: ResourceOverrideMap,
|
|
230
268
|
opts?: TestExecutionOptions
|
|
231
269
|
): Promise<void> => {
|
|
232
270
|
const validated = validateInput(trailDef.input, example.input);
|
|
@@ -235,19 +273,23 @@ const runCompositionExample = async (
|
|
|
235
273
|
return;
|
|
236
274
|
}
|
|
237
275
|
|
|
238
|
-
const
|
|
276
|
+
const permittedCtx = opts
|
|
277
|
+
? applyAutoPermit(baseCtx, trailDef, opts)
|
|
278
|
+
: baseCtx;
|
|
239
279
|
const cross = createCoverageCross(
|
|
240
280
|
called,
|
|
241
|
-
|
|
281
|
+
permittedCtx.cross,
|
|
242
282
|
topo,
|
|
243
|
-
|
|
244
|
-
|
|
283
|
+
permittedCtx,
|
|
284
|
+
resources
|
|
245
285
|
);
|
|
246
|
-
const testCtx: TrailContext = { ...
|
|
286
|
+
const testCtx: TrailContext = { ...permittedCtx, cross };
|
|
247
287
|
|
|
288
|
+
// Top-level trail validates against trail.input (not merged crossInput).
|
|
289
|
+
// Merged validation only applies to cross targets in executeFromMap/createCoverageCross.
|
|
248
290
|
const result = await executeTrail(trailDef, example.input, {
|
|
249
291
|
ctx: testCtx,
|
|
250
|
-
|
|
292
|
+
resources: resources ?? opts?.resources,
|
|
251
293
|
});
|
|
252
294
|
assertProgressiveMatch(result, example, output);
|
|
253
295
|
};
|
|
@@ -264,7 +306,7 @@ const runCompositionExample = async (
|
|
|
264
306
|
*
|
|
265
307
|
* One line in your test file:
|
|
266
308
|
* ```ts
|
|
267
|
-
* testExamples(
|
|
309
|
+
* testExamples(graph);
|
|
268
310
|
* ```
|
|
269
311
|
*/
|
|
270
312
|
export const testExamples = (
|
|
@@ -276,11 +318,12 @@ export const testExamples = (
|
|
|
276
318
|
): void => {
|
|
277
319
|
const resolveInput =
|
|
278
320
|
typeof ctxOrFactory === 'function' ? ctxOrFactory : () => ctxOrFactory;
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
321
|
+
const withExamples = (app.list() as Trail<unknown, unknown, unknown>[])
|
|
322
|
+
.map((trailDef) => ({
|
|
323
|
+
...trailDef,
|
|
324
|
+
examples: deriveTrailExamples(trailDef),
|
|
325
|
+
}))
|
|
326
|
+
.filter((trailDef) => trailDef.examples.length > 0);
|
|
284
327
|
const simpleTrails = withExamples.filter((t) => t.crosses.length === 0);
|
|
285
328
|
const compositionTrails = withExamples.filter((t) => t.crosses.length > 0);
|
|
286
329
|
|
|
@@ -296,19 +339,27 @@ export const testExamples = (
|
|
|
296
339
|
'example: $name',
|
|
297
340
|
async (example: TrailExample<unknown, unknown>) => {
|
|
298
341
|
const resolved = normalizeTestExecutionOptions(resolveInput());
|
|
299
|
-
const
|
|
300
|
-
await
|
|
342
|
+
const resources = mergeResourceOverrides(
|
|
343
|
+
await createMockResources(app),
|
|
301
344
|
resolved.ctx,
|
|
302
|
-
resolved.
|
|
345
|
+
resolved.resources
|
|
303
346
|
);
|
|
304
347
|
const testCtx = mergeTestContext(resolved.ctx);
|
|
305
|
-
await runExample(t, example, output, testCtx,
|
|
348
|
+
await runExample(t, example, output, testCtx, resources, resolved);
|
|
306
349
|
}
|
|
307
350
|
);
|
|
308
351
|
});
|
|
309
352
|
}
|
|
310
353
|
|
|
311
|
-
// Composition trails: use recording cross and check coverage
|
|
354
|
+
// Composition trails: use recording cross and check coverage.
|
|
355
|
+
//
|
|
356
|
+
// Crossing coverage only runs against AUTHORED examples. Contour-derived
|
|
357
|
+
// fixtures are opportunistic coverage that may not exercise every
|
|
358
|
+
// `ctx.cross()` branch in the trail, so asserting coverage against them
|
|
359
|
+
// would produce false failures for trails whose authored intent was a
|
|
360
|
+
// single path. When a trail has zero authored examples the coverage
|
|
361
|
+
// assertion is skipped entirely — the derived-example runs still
|
|
362
|
+
// execute, but they are not required to cover declared crossings.
|
|
312
363
|
if (compositionTrails.length > 0) {
|
|
313
364
|
describe.each(compositionTrails)('$id', (t) => {
|
|
314
365
|
const { examples, output } = t;
|
|
@@ -316,16 +367,29 @@ export const testExamples = (
|
|
|
316
367
|
return;
|
|
317
368
|
}
|
|
318
369
|
|
|
319
|
-
const
|
|
370
|
+
const calledFromAuthored = new Set<string>();
|
|
371
|
+
const hasAuthoredExamples = examples.some(
|
|
372
|
+
(example) => !isDerivedExample(example)
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
// Only record cross calls from authored examples. Derived fixtures
|
|
376
|
+
// execute normally but do not contribute to coverage — the sink map
|
|
377
|
+
// routes each example to the right bucket without an inline
|
|
378
|
+
// conditional inside the test body.
|
|
379
|
+
const discardSink = new Set<string>();
|
|
380
|
+
const pickCoverageSink = (
|
|
381
|
+
example: TrailExample<unknown, unknown>
|
|
382
|
+
): Set<string> =>
|
|
383
|
+
isDerivedExample(example) ? discardSink : calledFromAuthored;
|
|
320
384
|
|
|
321
385
|
test.each([...examples])(
|
|
322
386
|
'example: $name',
|
|
323
387
|
async (example: TrailExample<unknown, unknown>) => {
|
|
324
388
|
const resolved = normalizeTestExecutionOptions(resolveInput());
|
|
325
|
-
const
|
|
326
|
-
await
|
|
389
|
+
const resources = mergeResourceOverrides(
|
|
390
|
+
await createMockResources(app),
|
|
327
391
|
resolved.ctx,
|
|
328
|
-
resolved.
|
|
392
|
+
resolved.resources
|
|
329
393
|
);
|
|
330
394
|
const baseCtx = mergeTestContext(resolved.ctx);
|
|
331
395
|
await runCompositionExample(
|
|
@@ -333,18 +397,22 @@ export const testExamples = (
|
|
|
333
397
|
example,
|
|
334
398
|
output,
|
|
335
399
|
baseCtx,
|
|
336
|
-
|
|
400
|
+
pickCoverageSink(example),
|
|
337
401
|
app,
|
|
338
|
-
|
|
402
|
+
resources,
|
|
339
403
|
resolved
|
|
340
404
|
);
|
|
341
405
|
}
|
|
342
406
|
);
|
|
343
407
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
408
|
+
if (hasAuthoredExamples) {
|
|
409
|
+
test('crossing coverage', () => {
|
|
410
|
+
const uncovered = t.crosses.filter(
|
|
411
|
+
(id) => !calledFromAuthored.has(id)
|
|
412
|
+
);
|
|
413
|
+
expect(uncovered).toEqual([]);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
348
416
|
});
|
|
349
417
|
}
|
|
350
418
|
};
|
package/src/harness-cli.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CLI integration test harness.
|
|
3
3
|
*
|
|
4
|
-
* Builds CLI commands from
|
|
4
|
+
* Builds CLI commands from a graph, executes them in-process,
|
|
5
5
|
* and captures stdout/stderr.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { deriveCliCommands } from '@ontrails/cli';
|
|
9
9
|
import type { CliCommand } from '@ontrails/cli';
|
|
10
|
+
import type { TrailContext } from '@ontrails/core';
|
|
10
11
|
|
|
11
|
-
import {
|
|
12
|
+
import { mergeTestContext } from './context.js';
|
|
12
13
|
import type {
|
|
13
14
|
CliHarness,
|
|
14
15
|
CliHarnessOptions,
|
|
@@ -30,55 +31,31 @@ const parseCommandString = (input: string): string[] =>
|
|
|
30
31
|
// Command resolution
|
|
31
32
|
// ---------------------------------------------------------------------------
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
tokens
|
|
39
|
-
): { command: CliCommand; flagTokens: string[] } | undefined => {
|
|
40
|
-
if (secondToken === undefined || secondToken.startsWith('-')) {
|
|
41
|
-
return undefined;
|
|
42
|
-
}
|
|
43
|
-
const match = commands.find(
|
|
44
|
-
(c) => c.group === firstToken && c.name === secondToken
|
|
45
|
-
);
|
|
46
|
-
if (match === undefined) {
|
|
47
|
-
return undefined;
|
|
48
|
-
}
|
|
49
|
-
return { command: match, flagTokens: tokens.slice(2) };
|
|
50
|
-
};
|
|
34
|
+
const matchesPath = (
|
|
35
|
+
path: readonly string[],
|
|
36
|
+
tokens: readonly string[]
|
|
37
|
+
): boolean =>
|
|
38
|
+
path.length <= tokens.length &&
|
|
39
|
+
path.every((segment, index) => tokens[index] === segment);
|
|
51
40
|
|
|
52
|
-
/**
|
|
53
|
-
const
|
|
41
|
+
/** Resolve a command from tokens using the longest matching command path. */
|
|
42
|
+
const resolveCommand = (
|
|
54
43
|
commands: CliCommand[],
|
|
55
|
-
firstToken: string,
|
|
56
44
|
tokens: string[]
|
|
57
45
|
): { command: CliCommand; flagTokens: string[] } | undefined => {
|
|
58
|
-
|
|
59
|
-
(c) => c.name === firstToken && c.group === undefined
|
|
60
|
-
);
|
|
61
|
-
if (match === undefined) {
|
|
46
|
+
if (tokens.length === 0) {
|
|
62
47
|
return undefined;
|
|
63
48
|
}
|
|
64
|
-
return { command: match, flagTokens: tokens.slice(1) };
|
|
65
|
-
};
|
|
66
49
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
tokens: string[]
|
|
71
|
-
): { command: CliCommand; flagTokens: string[] } | undefined => {
|
|
72
|
-
const [firstToken, secondToken] = tokens;
|
|
50
|
+
const [match] = commands
|
|
51
|
+
.filter((command) => matchesPath(command.path, tokens))
|
|
52
|
+
.toSorted((a, b) => b.path.length - a.path.length);
|
|
73
53
|
|
|
74
|
-
if (
|
|
54
|
+
if (match === undefined) {
|
|
75
55
|
return undefined;
|
|
76
56
|
}
|
|
77
57
|
|
|
78
|
-
return (
|
|
79
|
-
tryGroupMatch(commands, firstToken, secondToken, tokens) ??
|
|
80
|
-
tryDirectMatch(commands, firstToken, tokens)
|
|
81
|
-
);
|
|
58
|
+
return { command: match, flagTokens: tokens.slice(match.path.length) };
|
|
82
59
|
};
|
|
83
60
|
|
|
84
61
|
// ---------------------------------------------------------------------------
|
|
@@ -242,9 +219,10 @@ const buildErrorResult = (
|
|
|
242
219
|
const executeCommand = async (
|
|
243
220
|
command: CliCommand,
|
|
244
221
|
flags: Record<string, unknown>,
|
|
245
|
-
streams: CapturedStreams
|
|
222
|
+
streams: CapturedStreams,
|
|
223
|
+
ctxOverrides?: Partial<TrailContext>
|
|
246
224
|
): Promise<CliHarnessResult> => {
|
|
247
|
-
const ctx =
|
|
225
|
+
const ctx = mergeTestContext(ctxOverrides);
|
|
248
226
|
const result = await command.execute({}, flags, ctx);
|
|
249
227
|
streams.restore();
|
|
250
228
|
|
|
@@ -262,7 +240,8 @@ const executeCommand = async (
|
|
|
262
240
|
/** Run the full command pipeline: resolve, parse, execute. */
|
|
263
241
|
const runCommand = async (
|
|
264
242
|
commands: CliCommand[],
|
|
265
|
-
commandString: string
|
|
243
|
+
commandString: string,
|
|
244
|
+
ctxOverrides?: Partial<TrailContext>
|
|
266
245
|
): Promise<CliHarnessResult> => {
|
|
267
246
|
const parts = parseCommandString(commandString);
|
|
268
247
|
const resolved = resolveCommand(commands, parts);
|
|
@@ -279,7 +258,7 @@ const runCommand = async (
|
|
|
279
258
|
const streams = captureStreams();
|
|
280
259
|
|
|
281
260
|
try {
|
|
282
|
-
return await executeCommand(command, flags, streams);
|
|
261
|
+
return await executeCommand(command, flags, streams, ctxOverrides);
|
|
283
262
|
} catch (error: unknown) {
|
|
284
263
|
return buildErrorResult(error, streams);
|
|
285
264
|
}
|
|
@@ -292,19 +271,24 @@ const runCommand = async (
|
|
|
292
271
|
/**
|
|
293
272
|
* Create a CLI harness for integration testing.
|
|
294
273
|
*
|
|
295
|
-
* Builds commands from the
|
|
274
|
+
* Builds commands from the graph's topo and provides a `run()` method
|
|
296
275
|
* that parses command strings and executes them in-process.
|
|
297
276
|
*
|
|
298
277
|
* ```ts
|
|
299
|
-
* const harness = createCliHarness({
|
|
278
|
+
* const harness = createCliHarness({ graph });
|
|
300
279
|
* const result = await harness.run("entity show --name Alpha --output json");
|
|
301
280
|
* expect(result.exitCode).toBe(0);
|
|
302
281
|
* ```
|
|
303
282
|
*/
|
|
304
283
|
export const createCliHarness = (options: CliHarnessOptions): CliHarness => {
|
|
305
|
-
const
|
|
284
|
+
const { ctx, graph, ...deriveOptions } = options;
|
|
285
|
+
const commandsResult = deriveCliCommands(graph, deriveOptions);
|
|
286
|
+
if (commandsResult.isErr()) {
|
|
287
|
+
throw commandsResult.error;
|
|
288
|
+
}
|
|
289
|
+
const commands = commandsResult.value;
|
|
306
290
|
|
|
307
291
|
return {
|
|
308
|
-
run: (commandString: string) => runCommand(commands, commandString),
|
|
292
|
+
run: (commandString: string) => runCommand(commands, commandString, ctx),
|
|
309
293
|
};
|
|
310
294
|
};
|
package/src/harness-mcp.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP integration test harness.
|
|
3
3
|
*
|
|
4
|
-
* Builds MCP tools from
|
|
4
|
+
* Builds MCP tools from a graph, invokes them directly (no transport),
|
|
5
5
|
* and returns the MCP tool response.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { deriveMcpTools } from '@ontrails/mcp';
|
|
9
9
|
import type { McpToolDefinition } from '@ontrails/mcp';
|
|
10
10
|
|
|
11
11
|
import type {
|
|
@@ -21,17 +21,18 @@ import type {
|
|
|
21
21
|
/**
|
|
22
22
|
* Create an MCP harness for integration testing.
|
|
23
23
|
*
|
|
24
|
-
* Builds MCP tools from the
|
|
24
|
+
* Builds MCP tools from the graph's topo and provides a `callTool()` method
|
|
25
25
|
* that invokes tools directly without any transport boundary.
|
|
26
26
|
*
|
|
27
27
|
* ```ts
|
|
28
|
-
* const harness = createMcpHarness({
|
|
28
|
+
* const harness = createMcpHarness({ graph });
|
|
29
29
|
* const result = await harness.callTool("myapp_entity_show", { name: "Alpha" });
|
|
30
30
|
* expect(result.isError).toBe(false);
|
|
31
31
|
* ```
|
|
32
32
|
*/
|
|
33
33
|
export const createMcpHarness = (options: McpHarnessOptions): McpHarness => {
|
|
34
|
-
const
|
|
34
|
+
const { extra, graph, ...deriveOptions } = options;
|
|
35
|
+
const toolsResult = deriveMcpTools(graph, deriveOptions);
|
|
35
36
|
if (toolsResult.isErr()) {
|
|
36
37
|
throw toolsResult.error;
|
|
37
38
|
}
|
|
@@ -54,9 +55,9 @@ export const createMcpHarness = (options: McpHarnessOptions): McpHarness => {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
const result = await tool.handler(args, {
|
|
57
|
-
abortSignal:
|
|
58
|
-
progressToken:
|
|
59
|
-
sendProgress:
|
|
58
|
+
abortSignal: extra?.abortSignal,
|
|
59
|
+
progressToken: extra?.progressToken,
|
|
60
|
+
sendProgress: extra?.sendProgress,
|
|
60
61
|
});
|
|
61
62
|
|
|
62
63
|
return {
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Contract-driven testing
|
|
2
2
|
export { testAll } from './all.js';
|
|
3
|
+
export { testAllEstablished } from './all.js';
|
|
3
4
|
export { testExamples } from './examples.js';
|
|
4
5
|
export { testCrosses } from './crosses.js';
|
|
5
6
|
export { testTrail } from './trail.js';
|
|
@@ -10,16 +11,22 @@ export { testDetours } from './detours.js';
|
|
|
10
11
|
export {
|
|
11
12
|
assertErrorMatch,
|
|
12
13
|
assertFullMatch,
|
|
14
|
+
assertPartialMatch,
|
|
13
15
|
assertSchemaMatch,
|
|
16
|
+
errResultMatch,
|
|
14
17
|
expectErr,
|
|
15
18
|
expectOk,
|
|
19
|
+
okResultMatch,
|
|
16
20
|
} from './assertions.js';
|
|
17
21
|
|
|
22
|
+
// Scenario testing
|
|
23
|
+
export { executeScenarioSteps, ref, scenario } from './scenario.js';
|
|
24
|
+
|
|
18
25
|
// Mock factories
|
|
19
26
|
export {
|
|
20
27
|
createCrossContext,
|
|
21
28
|
createTestContext,
|
|
22
|
-
|
|
29
|
+
defaultCreatePermit,
|
|
23
30
|
} from './context.js';
|
|
24
31
|
export { createTestLogger } from './logger.js';
|
|
25
32
|
|
|
@@ -30,14 +37,17 @@ export { createMcpHarness } from './harness-mcp.js';
|
|
|
30
37
|
// Types
|
|
31
38
|
export type { CreateCrossContextOptions } from './context.js';
|
|
32
39
|
export type {
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
PermittedTrail,
|
|
41
|
+
MinimalPermit,
|
|
35
42
|
TestExecutionOptions,
|
|
36
43
|
} from './context.js';
|
|
37
44
|
export type { TestCrossOptions } from './crosses.js';
|
|
38
45
|
|
|
39
46
|
export type {
|
|
40
47
|
CrossScenario,
|
|
48
|
+
RefToken,
|
|
49
|
+
ScenarioStep,
|
|
50
|
+
TestAllEstablishedOptions,
|
|
41
51
|
TestScenario,
|
|
42
52
|
TestLogger,
|
|
43
53
|
TestTrailContextOptions,
|