@ontrails/testing 1.0.0-beta.18 → 1.0.0-beta.19
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 +51 -0
- package/README.md +31 -29
- package/package.json +24 -8
- package/src/all-established.ts +168 -0
- package/src/all.ts +3 -130
- package/src/assertions.ts +2 -2
- package/src/cli.ts +6 -0
- package/src/{crosses.ts → composes.ts} +87 -79
- package/src/context.ts +22 -21
- package/src/contracts.ts +19 -17
- package/src/effective-examples.ts +67 -3
- package/src/errors.ts +47 -0
- package/src/examples.ts +131 -118
- package/src/harness-cli.ts +35 -7
- package/src/harness-http.ts +87 -9
- package/src/harness-mcp.ts +30 -6
- package/src/http.ts +10 -0
- package/src/index.ts +5 -34
- package/src/mcp.ts +6 -0
- package/src/scenario.ts +67 -60
- package/src/surface-parity.ts +43 -10
- package/src/types.ts +11 -232
package/src/errors.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {
|
|
2
|
+
errorClasses,
|
|
3
|
+
InternalError,
|
|
4
|
+
RetryExhaustedError,
|
|
5
|
+
TrailsError,
|
|
6
|
+
} from '@ontrails/core';
|
|
7
|
+
|
|
8
|
+
type ErrorConstructor = new (...args: never[]) => Error;
|
|
9
|
+
type MessageErrorConstructor = new (message: string) => Error;
|
|
10
|
+
|
|
11
|
+
const ERROR_CLASS_BY_NAME = new Map<string, ErrorConstructor>([
|
|
12
|
+
...errorClasses.map(
|
|
13
|
+
(entry) => [entry.name, entry.ctor as ErrorConstructor] as const
|
|
14
|
+
),
|
|
15
|
+
['TrailsError', TrailsError as unknown as ErrorConstructor],
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve an error class name string to the actual constructor.
|
|
20
|
+
* Falls back to generic Error if the name is not in the core taxonomy.
|
|
21
|
+
*/
|
|
22
|
+
export const resolveErrorClass = (name: string): ErrorConstructor =>
|
|
23
|
+
ERROR_CLASS_BY_NAME.get(name) ?? (Error as ErrorConstructor);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create an error instance for an authored example error name.
|
|
27
|
+
*/
|
|
28
|
+
export const createErrorFromName = (name: string): Error => {
|
|
29
|
+
if (name === 'TrailsError') {
|
|
30
|
+
return new InternalError(name);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const entry = errorClasses.find((candidate) => candidate.name === name);
|
|
34
|
+
if (entry === undefined) {
|
|
35
|
+
return new Error(name);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (entry.name === 'RetryExhaustedError') {
|
|
39
|
+
return new RetryExhaustedError(new InternalError(name), {
|
|
40
|
+
attempts: 1,
|
|
41
|
+
detour: 'testComposes',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ErrorClass = entry.ctor as unknown as MessageErrorConstructor;
|
|
46
|
+
return new ErrorClass(name);
|
|
47
|
+
};
|
package/src/examples.ts
CHANGED
|
@@ -3,14 +3,16 @@
|
|
|
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 trails with `
|
|
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
|
-
|
|
13
|
+
ComposeFn,
|
|
14
|
+
ComposeOptions,
|
|
15
|
+
ExecuteTrailOptions,
|
|
14
16
|
ResourceOverrideMap,
|
|
15
17
|
Topo,
|
|
16
18
|
TrailExample,
|
|
@@ -19,25 +21,10 @@ import type {
|
|
|
19
21
|
} from '@ontrails/core';
|
|
20
22
|
|
|
21
23
|
import {
|
|
22
|
-
|
|
23
|
-
AmbiguousError,
|
|
24
|
-
AssertionError,
|
|
25
|
-
AuthError,
|
|
26
|
-
buildCrossValidationSchema,
|
|
27
|
-
CancelledError,
|
|
28
|
-
ConflictError,
|
|
29
|
-
DerivationError,
|
|
30
|
-
InternalError,
|
|
31
|
-
NetworkError,
|
|
32
|
-
NotFoundError,
|
|
33
|
-
PermissionError,
|
|
34
|
-
PermitError,
|
|
35
|
-
RateLimitError,
|
|
36
|
-
RetryExhaustedError,
|
|
24
|
+
buildComposeValidationSchema,
|
|
37
25
|
executeTrail,
|
|
26
|
+
parseTrailIdVersionReference,
|
|
38
27
|
Result,
|
|
39
|
-
TimeoutError,
|
|
40
|
-
TrailsError,
|
|
41
28
|
ValidationError,
|
|
42
29
|
validateInput,
|
|
43
30
|
} from '@ontrails/core';
|
|
@@ -57,42 +44,18 @@ import {
|
|
|
57
44
|
createMockResources,
|
|
58
45
|
} from './context.js';
|
|
59
46
|
import type { PermittedTrail, TestExecutionOptions } from './context.js';
|
|
60
|
-
import {
|
|
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';
|
|
61
53
|
import { withSignalAssertions } from './signals.js';
|
|
62
54
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
// ---------------------------------------------------------------------------
|
|
66
|
-
|
|
67
|
-
const ERROR_MAP: Record<string, new (...args: never[]) => Error> = {
|
|
68
|
-
AlreadyExistsError: AlreadyExistsError as new (...args: never[]) => Error,
|
|
69
|
-
AmbiguousError: AmbiguousError as new (...args: never[]) => Error,
|
|
70
|
-
AssertionError: AssertionError as new (...args: never[]) => Error,
|
|
71
|
-
AuthError: AuthError as new (...args: never[]) => Error,
|
|
72
|
-
CancelledError: CancelledError as new (...args: never[]) => Error,
|
|
73
|
-
ConflictError: ConflictError as new (...args: never[]) => Error,
|
|
74
|
-
DerivationError: DerivationError as new (...args: never[]) => Error,
|
|
75
|
-
InternalError: InternalError as new (...args: never[]) => Error,
|
|
76
|
-
NetworkError: NetworkError as new (...args: never[]) => Error,
|
|
77
|
-
NotFoundError: NotFoundError as new (...args: never[]) => Error,
|
|
78
|
-
PermissionError: PermissionError as new (...args: never[]) => Error,
|
|
79
|
-
PermitError: PermitError as new (...args: never[]) => Error,
|
|
80
|
-
RateLimitError: RateLimitError as new (...args: never[]) => Error,
|
|
81
|
-
RetryExhaustedError: RetryExhaustedError as unknown as new (
|
|
82
|
-
...args: never[]
|
|
83
|
-
) => Error,
|
|
84
|
-
TimeoutError: TimeoutError as new (...args: never[]) => Error,
|
|
85
|
-
TrailsError: TrailsError as unknown as new (...args: never[]) => Error,
|
|
86
|
-
ValidationError: ValidationError as new (...args: never[]) => Error,
|
|
55
|
+
type TestingExecuteTrailOptions = ExecuteTrailOptions & {
|
|
56
|
+
readonly validationSchema?: ReturnType<typeof buildComposeValidationSchema>;
|
|
87
57
|
};
|
|
88
58
|
|
|
89
|
-
/**
|
|
90
|
-
* Resolve an error class name string to the actual constructor.
|
|
91
|
-
* Falls back to generic Error if the name is not in the core taxonomy.
|
|
92
|
-
*/
|
|
93
|
-
const resolveErrorClass = (name: string): new (...args: never[]) => Error =>
|
|
94
|
-
ERROR_MAP[name] ?? (Error as new (...args: never[]) => Error);
|
|
95
|
-
|
|
96
59
|
// ---------------------------------------------------------------------------
|
|
97
60
|
// Helpers
|
|
98
61
|
// ---------------------------------------------------------------------------
|
|
@@ -161,21 +124,17 @@ const applyAutoPermit = (
|
|
|
161
124
|
return { ...ctx, permit };
|
|
162
125
|
};
|
|
163
126
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
* Handles validation, execution, and assertions.
|
|
167
|
-
*/
|
|
168
|
-
export const runExample = async (
|
|
169
|
-
t: Trail<unknown, unknown, unknown>,
|
|
127
|
+
const runTargetExample = async (
|
|
128
|
+
target: TrailExampleTarget,
|
|
170
129
|
example: TrailExample<unknown, unknown>,
|
|
171
|
-
output: z.ZodType | undefined,
|
|
172
130
|
testCtx: TrailContext,
|
|
173
131
|
resources?: ResourceOverrideMap,
|
|
174
132
|
opts?: TestExecutionOptions
|
|
175
133
|
): Promise<void> => {
|
|
134
|
+
const { output, trail: t } = target;
|
|
176
135
|
const ctx = opts ? applyAutoPermit(testCtx, t, opts) : testCtx;
|
|
177
136
|
const signals = withSignalAssertions(ctx, example);
|
|
178
|
-
const validated = validateInput(
|
|
137
|
+
const validated = validateInput(target.input, example.input);
|
|
179
138
|
|
|
180
139
|
if (handleValidationError(validated, example)) {
|
|
181
140
|
signals.assert();
|
|
@@ -185,118 +144,176 @@ export const runExample = async (
|
|
|
185
144
|
const result = await executeTrail(t, example.input, {
|
|
186
145
|
ctx: signals.ctx,
|
|
187
146
|
resources: resources ?? opts?.resources,
|
|
147
|
+
...(target.version === undefined ? {} : { version: target.version }),
|
|
188
148
|
});
|
|
189
149
|
assertProgressiveMatch(result, example, output);
|
|
190
150
|
signals.assert();
|
|
191
151
|
};
|
|
192
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
|
+
);
|
|
180
|
+
};
|
|
181
|
+
|
|
193
182
|
// ---------------------------------------------------------------------------
|
|
194
|
-
//
|
|
183
|
+
// Composing coverage for trails with compositions
|
|
195
184
|
// ---------------------------------------------------------------------------
|
|
196
185
|
|
|
197
186
|
/**
|
|
198
|
-
* Build a recording
|
|
187
|
+
* Build a recording compose function that tracks which trail IDs are called.
|
|
199
188
|
*
|
|
200
|
-
* Delegates to `
|
|
189
|
+
* Delegates to `baseCompose` when available, otherwise looks up the trail
|
|
201
190
|
* in the topo and executes it with validated input. Falls back to
|
|
202
191
|
* `Result.ok()` when neither is available.
|
|
203
192
|
*/
|
|
204
|
-
const
|
|
193
|
+
const createCoverageCompose = (
|
|
205
194
|
called: Set<string>,
|
|
206
|
-
|
|
195
|
+
baseCompose: ComposeFn | undefined,
|
|
207
196
|
topo: Topo,
|
|
208
197
|
ctx: TrailContext,
|
|
209
198
|
resources?: ResourceOverrideMap
|
|
210
|
-
):
|
|
211
|
-
const
|
|
199
|
+
): ComposeFn => {
|
|
200
|
+
const invokeCompose = async (
|
|
212
201
|
idOrTrail: string | { readonly id: string },
|
|
213
202
|
input: unknown,
|
|
214
|
-
self:
|
|
203
|
+
self: ComposeFn,
|
|
204
|
+
composeOptions?: ComposeOptions | undefined
|
|
215
205
|
) => {
|
|
216
|
-
const
|
|
217
|
-
|
|
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
|
+
}
|
|
218
222
|
|
|
219
|
-
|
|
220
|
-
|
|
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);
|
|
221
233
|
}
|
|
222
234
|
|
|
223
235
|
const trailDef = topo.get(id);
|
|
224
236
|
if (trailDef !== undefined) {
|
|
225
|
-
|
|
226
|
-
ctx: { ...ctx,
|
|
237
|
+
const options: TestingExecuteTrailOptions = {
|
|
238
|
+
ctx: { ...ctx, compose: self },
|
|
227
239
|
resources,
|
|
228
|
-
|
|
229
|
-
|
|
240
|
+
...(version === undefined ? {} : { version }),
|
|
241
|
+
validationSchema: buildComposeValidationSchema(trailDef),
|
|
242
|
+
};
|
|
243
|
+
return await executeTrail(trailDef, input, options);
|
|
230
244
|
}
|
|
231
245
|
|
|
232
246
|
return Result.ok();
|
|
233
247
|
};
|
|
234
248
|
|
|
235
|
-
// Accepts either a trail object (typed
|
|
249
|
+
// Accepts either a trail object (typed compose), a string id (untyped),
|
|
236
250
|
// or a batch of `[target, input]` tuples.
|
|
237
|
-
const
|
|
251
|
+
const compose = async function compose(
|
|
238
252
|
idOrTrail:
|
|
239
253
|
| string
|
|
240
254
|
| { readonly id: string }
|
|
241
255
|
| readonly (readonly [string | { readonly id: string }, unknown])[],
|
|
242
|
-
|
|
256
|
+
inputOrOptions?: unknown,
|
|
257
|
+
singleOptions?: ComposeOptions
|
|
243
258
|
) {
|
|
244
259
|
if (Array.isArray(idOrTrail)) {
|
|
245
260
|
return await Promise.all(
|
|
246
261
|
idOrTrail.map(([target, batchInput]) =>
|
|
247
|
-
|
|
262
|
+
invokeCompose(target, batchInput, compose as ComposeFn)
|
|
248
263
|
)
|
|
249
264
|
);
|
|
250
265
|
}
|
|
251
266
|
|
|
252
|
-
return await
|
|
267
|
+
return await invokeCompose(
|
|
253
268
|
idOrTrail as string | { readonly id: string },
|
|
254
|
-
|
|
255
|
-
|
|
269
|
+
inputOrOptions,
|
|
270
|
+
compose as ComposeFn,
|
|
271
|
+
singleOptions
|
|
256
272
|
);
|
|
257
|
-
} as
|
|
273
|
+
} as ComposeFn;
|
|
258
274
|
|
|
259
|
-
return
|
|
275
|
+
return compose;
|
|
260
276
|
};
|
|
261
277
|
|
|
262
278
|
/**
|
|
263
|
-
* Run a single example against a trail with
|
|
279
|
+
* Run a single example against a trail with compositions, recording compose calls.
|
|
264
280
|
*/
|
|
265
281
|
const runCompositionExample = async (
|
|
266
|
-
|
|
282
|
+
target: TrailExampleTarget,
|
|
267
283
|
example: TrailExample<unknown, unknown>,
|
|
268
|
-
output: z.ZodType | undefined,
|
|
269
284
|
baseCtx: TrailContext,
|
|
270
285
|
called: Set<string>,
|
|
271
286
|
topo: Topo,
|
|
272
287
|
resources?: ResourceOverrideMap,
|
|
273
288
|
opts?: TestExecutionOptions
|
|
274
289
|
): Promise<void> => {
|
|
290
|
+
const { output, trail: trailDef } = target;
|
|
275
291
|
const permittedCtx = opts
|
|
276
292
|
? applyAutoPermit(baseCtx, trailDef, opts)
|
|
277
293
|
: baseCtx;
|
|
278
294
|
const signals = withSignalAssertions(permittedCtx, example);
|
|
279
|
-
const validated = validateInput(
|
|
295
|
+
const validated = validateInput(target.input, example.input);
|
|
280
296
|
|
|
281
297
|
if (handleValidationError(validated, example)) {
|
|
282
298
|
signals.assert();
|
|
283
299
|
return;
|
|
284
300
|
}
|
|
285
301
|
|
|
286
|
-
const
|
|
302
|
+
const compose = createCoverageCompose(
|
|
287
303
|
called,
|
|
288
|
-
signals.ctx.
|
|
304
|
+
signals.ctx.compose,
|
|
289
305
|
topo,
|
|
290
306
|
signals.ctx,
|
|
291
307
|
resources
|
|
292
308
|
);
|
|
293
|
-
const testCtx: TrailContext = { ...signals.ctx,
|
|
309
|
+
const testCtx: TrailContext = { ...signals.ctx, compose };
|
|
294
310
|
|
|
295
|
-
// Top-level trail validates against trail.input (not merged
|
|
296
|
-
// Merged validation only applies to
|
|
311
|
+
// Top-level trail validates against trail.input (not merged composeInput).
|
|
312
|
+
// Merged validation only applies to compose targets in executeFromMap/createCoverageCompose.
|
|
297
313
|
const result = await executeTrail(trailDef, example.input, {
|
|
298
314
|
ctx: testCtx,
|
|
299
315
|
resources: resources ?? opts?.resources,
|
|
316
|
+
...(target.version === undefined ? {} : { version: target.version }),
|
|
300
317
|
});
|
|
301
318
|
assertProgressiveMatch(result, example, output);
|
|
302
319
|
signals.assert();
|
|
@@ -309,8 +326,8 @@ const runCompositionExample = async (
|
|
|
309
326
|
/**
|
|
310
327
|
* Generate describe/test blocks for every trail example in the app.
|
|
311
328
|
*
|
|
312
|
-
* For trails with `
|
|
313
|
-
* 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.
|
|
314
331
|
*
|
|
315
332
|
* One line in your test file:
|
|
316
333
|
* ```ts
|
|
@@ -327,18 +344,15 @@ export const testExamples = (
|
|
|
327
344
|
const resolveInput =
|
|
328
345
|
typeof ctxOrFactory === 'function' ? ctxOrFactory : () => ctxOrFactory;
|
|
329
346
|
const withExamples = (app.list() as Trail<unknown, unknown, unknown>[])
|
|
330
|
-
.
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
.filter((trailDef) => trailDef.examples.length > 0);
|
|
335
|
-
const simpleTrails = withExamples.filter((t) => t.crosses.length === 0);
|
|
336
|
-
const compositionTrails = withExamples.filter((t) => t.crosses.length > 0);
|
|
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);
|
|
337
351
|
|
|
338
352
|
// Simple trails: run examples directly
|
|
339
353
|
if (simpleTrails.length > 0) {
|
|
340
354
|
describe.each(simpleTrails)('$id', (t) => {
|
|
341
|
-
const { examples
|
|
355
|
+
const { examples } = t;
|
|
342
356
|
if (!examples) {
|
|
343
357
|
return;
|
|
344
358
|
}
|
|
@@ -353,42 +367,42 @@ export const testExamples = (
|
|
|
353
367
|
resolved.resources
|
|
354
368
|
);
|
|
355
369
|
const testCtx = mergeTestContext(resolved.ctx);
|
|
356
|
-
await
|
|
370
|
+
await runTargetExample(t, example, testCtx, resources, resolved);
|
|
357
371
|
}
|
|
358
372
|
);
|
|
359
373
|
});
|
|
360
374
|
}
|
|
361
375
|
|
|
362
|
-
// Composition trails: use recording
|
|
376
|
+
// Composition trails: use recording compose and check coverage.
|
|
363
377
|
//
|
|
364
|
-
//
|
|
378
|
+
// Composing coverage only runs against AUTHORED examples. Contour-derived
|
|
365
379
|
// fixtures are opportunistic coverage that may not exercise every
|
|
366
|
-
// `ctx.
|
|
380
|
+
// `ctx.compose()` branch in the trail, so asserting coverage against them
|
|
367
381
|
// would produce false failures for trails whose authored intent was a
|
|
368
382
|
// single path. When a trail has zero authored examples the coverage
|
|
369
383
|
// assertion is skipped entirely — the derived-example runs still
|
|
370
|
-
// execute, but they are not required to cover declared
|
|
384
|
+
// execute, but they are not required to cover declared compositions.
|
|
371
385
|
if (compositionTrails.length > 0) {
|
|
372
386
|
describe.each(compositionTrails)('$id', (t) => {
|
|
373
|
-
const { examples
|
|
387
|
+
const { examples } = t;
|
|
374
388
|
if (!examples) {
|
|
375
389
|
return;
|
|
376
390
|
}
|
|
377
391
|
|
|
378
|
-
const
|
|
392
|
+
const composedFromAuthored = new Set<string>();
|
|
379
393
|
const hasAuthoredExamples = examples.some(
|
|
380
394
|
(example) => !isDerivedExample(example)
|
|
381
395
|
);
|
|
382
396
|
|
|
383
|
-
// Only record
|
|
397
|
+
// Only record compose calls from authored examples. Derived fixtures
|
|
384
398
|
// execute normally but do not contribute to coverage — the sink map
|
|
385
|
-
//
|
|
399
|
+
// puts each example in the right bucket without an inline
|
|
386
400
|
// conditional inside the test body.
|
|
387
401
|
const discardSink = new Set<string>();
|
|
388
402
|
const pickCoverageSink = (
|
|
389
403
|
example: TrailExample<unknown, unknown>
|
|
390
404
|
): Set<string> =>
|
|
391
|
-
isDerivedExample(example) ? discardSink :
|
|
405
|
+
isDerivedExample(example) ? discardSink : composedFromAuthored;
|
|
392
406
|
|
|
393
407
|
test.each([...examples])(
|
|
394
408
|
'example: $name',
|
|
@@ -403,7 +417,6 @@ export const testExamples = (
|
|
|
403
417
|
await runCompositionExample(
|
|
404
418
|
t,
|
|
405
419
|
example,
|
|
406
|
-
output,
|
|
407
420
|
baseCtx,
|
|
408
421
|
pickCoverageSink(example),
|
|
409
422
|
app,
|
|
@@ -414,9 +427,9 @@ export const testExamples = (
|
|
|
414
427
|
);
|
|
415
428
|
|
|
416
429
|
if (hasAuthoredExamples) {
|
|
417
|
-
test('
|
|
418
|
-
const uncovered = t.
|
|
419
|
-
(id) => !
|
|
430
|
+
test('composing coverage', () => {
|
|
431
|
+
const uncovered = t.composes.filter(
|
|
432
|
+
(id) => !composedFromAuthored.has(id)
|
|
420
433
|
);
|
|
421
434
|
expect(uncovered).toEqual([]);
|
|
422
435
|
});
|
package/src/harness-cli.ts
CHANGED
|
@@ -6,16 +6,42 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { deriveCliCommands } from '@ontrails/cli';
|
|
9
|
-
import type { CliCommand } from '@ontrails/cli';
|
|
10
|
-
import type { TrailContext } from '@ontrails/core';
|
|
9
|
+
import type { CliCommand, DeriveCliCommandsOptions } from '@ontrails/cli';
|
|
10
|
+
import type { Topo, TrailContext } from '@ontrails/core';
|
|
11
11
|
import { projectPublicSurfaceError } from '@ontrails/core';
|
|
12
12
|
|
|
13
13
|
import { mergeTestContext } from './context.js';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
|
|
15
|
+
/** Options for creating a CLI harness. */
|
|
16
|
+
export interface CliHarnessOptions extends Omit<
|
|
17
|
+
DeriveCliCommandsOptions,
|
|
18
|
+
'onResult' | 'presets' | 'resolveInput'
|
|
19
|
+
> {
|
|
20
|
+
readonly ctx?: Partial<TrailContext> | undefined;
|
|
21
|
+
readonly graph: Topo;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A test harness for CLI commands. */
|
|
25
|
+
export interface CliHarness {
|
|
26
|
+
/** Execute a CLI command string and capture output. */
|
|
27
|
+
run(command: string): Promise<CliHarnessResult>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The result of a CLI harness command execution. */
|
|
31
|
+
export interface CliHarnessResult {
|
|
32
|
+
readonly error?:
|
|
33
|
+
| {
|
|
34
|
+
readonly category: string;
|
|
35
|
+
readonly code: string;
|
|
36
|
+
readonly message: string;
|
|
37
|
+
}
|
|
38
|
+
| undefined;
|
|
39
|
+
readonly exitCode: number;
|
|
40
|
+
/** Parsed JSON output if --output json was used. */
|
|
41
|
+
readonly json?: unknown | undefined;
|
|
42
|
+
readonly stderr: string;
|
|
43
|
+
readonly stdout: string;
|
|
44
|
+
}
|
|
19
45
|
|
|
20
46
|
// ---------------------------------------------------------------------------
|
|
21
47
|
// Tokenizer
|
|
@@ -288,6 +314,8 @@ const runCommand = async (
|
|
|
288
314
|
* that parses command strings and executes them in-process.
|
|
289
315
|
*
|
|
290
316
|
* ```ts
|
|
317
|
+
* import { createCliHarness } from '@ontrails/testing/cli';
|
|
318
|
+
*
|
|
291
319
|
* const harness = createCliHarness({ graph });
|
|
292
320
|
* const result = await harness.run("entity show --name Alpha --output json");
|
|
293
321
|
* expect(result.exitCode).toBe(0);
|
package/src/harness-http.ts
CHANGED
|
@@ -6,18 +6,96 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { deriveHttpRoutes } from '@ontrails/http';
|
|
9
|
-
import type {
|
|
10
|
-
|
|
9
|
+
import type {
|
|
10
|
+
DeriveHttpRoutesOptions,
|
|
11
|
+
HttpHeaderSource,
|
|
12
|
+
HttpMethod,
|
|
13
|
+
HttpRouteDefinition,
|
|
14
|
+
} from '@ontrails/http';
|
|
15
|
+
import type { Topo, TrailContext, TrailContextInit } from '@ontrails/core';
|
|
11
16
|
import { NotFoundError, projectPublicSurfaceError } from '@ontrails/core';
|
|
12
17
|
|
|
13
18
|
import { mergeTestContext } from './context.js';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
19
|
+
|
|
20
|
+
/** Options for creating an HTTP harness. */
|
|
21
|
+
export interface HttpHarnessOptions extends DeriveHttpRoutesOptions {
|
|
22
|
+
readonly ctx?: Partial<TrailContext> | undefined;
|
|
23
|
+
readonly graph: Topo;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface HttpHarnessRequest {
|
|
27
|
+
readonly abortSignal?: AbortSignal | undefined;
|
|
28
|
+
readonly body?: unknown | undefined;
|
|
29
|
+
readonly headers?: HttpHeaderSource | undefined;
|
|
30
|
+
readonly method: HttpMethod;
|
|
31
|
+
readonly path: string;
|
|
32
|
+
readonly query?: Record<string, unknown> | undefined;
|
|
33
|
+
readonly requestId?: string | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface HttpHarnessRequestOptions extends Omit<
|
|
17
37
|
HttpHarnessRequest,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
38
|
+
'body' | 'method' | 'path' | 'query'
|
|
39
|
+
> {
|
|
40
|
+
readonly query?: Record<string, unknown> | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A test harness for HTTP route projections. */
|
|
44
|
+
export interface HttpHarness {
|
|
45
|
+
/** Execute a raw HTTP-style harness request. */
|
|
46
|
+
request(request: HttpHarnessRequest): Promise<HttpHarnessResult>;
|
|
47
|
+
/** Execute a GET request, reading input from query params. */
|
|
48
|
+
get(
|
|
49
|
+
path: string,
|
|
50
|
+
query?: Record<string, unknown>,
|
|
51
|
+
options?: HttpHarnessRequestOptions
|
|
52
|
+
): Promise<HttpHarnessResult>;
|
|
53
|
+
/** Execute a POST request, reading input from the JSON-like body value. */
|
|
54
|
+
post(
|
|
55
|
+
path: string,
|
|
56
|
+
body?: unknown,
|
|
57
|
+
options?: HttpHarnessRequestOptions
|
|
58
|
+
): Promise<HttpHarnessResult>;
|
|
59
|
+
/** Execute a PUT request. */
|
|
60
|
+
put(
|
|
61
|
+
path: string,
|
|
62
|
+
body?: unknown,
|
|
63
|
+
options?: HttpHarnessRequestOptions
|
|
64
|
+
): Promise<HttpHarnessResult>;
|
|
65
|
+
/** Execute a PATCH request. */
|
|
66
|
+
patch(
|
|
67
|
+
path: string,
|
|
68
|
+
body?: unknown,
|
|
69
|
+
options?: HttpHarnessRequestOptions
|
|
70
|
+
): Promise<HttpHarnessResult>;
|
|
71
|
+
/** Execute a DELETE request. */
|
|
72
|
+
delete(
|
|
73
|
+
path: string,
|
|
74
|
+
body?: unknown,
|
|
75
|
+
options?: HttpHarnessRequestOptions
|
|
76
|
+
): Promise<HttpHarnessResult>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface HttpHarnessErrorBody {
|
|
80
|
+
readonly error: {
|
|
81
|
+
readonly category: string;
|
|
82
|
+
readonly code: string;
|
|
83
|
+
readonly message: string;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface HttpHarnessSuccessBody {
|
|
88
|
+
readonly data: unknown;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The result of an HTTP harness request. */
|
|
92
|
+
export interface HttpHarnessResult {
|
|
93
|
+
readonly body: HttpHarnessErrorBody | HttpHarnessSuccessBody;
|
|
94
|
+
readonly data?: unknown | undefined;
|
|
95
|
+
readonly error?: HttpHarnessErrorBody['error'] | undefined;
|
|
96
|
+
readonly ok: boolean;
|
|
97
|
+
readonly status: number;
|
|
98
|
+
}
|
|
21
99
|
|
|
22
100
|
const TEST_ORIGIN = 'http://ontrails.test';
|
|
23
101
|
|
|
@@ -163,7 +241,7 @@ const executeRoute = async (
|
|
|
163
241
|
*
|
|
164
242
|
* @example
|
|
165
243
|
* ```ts
|
|
166
|
-
* import { createHttpHarness } from '@ontrails/testing';
|
|
244
|
+
* import { createHttpHarness } from '@ontrails/testing/http';
|
|
167
245
|
*
|
|
168
246
|
* const http = createHttpHarness({ graph });
|
|
169
247
|
* const result = await http.get('/entity/show', { name: 'Alpha' });
|