@ontrails/testing 0.2.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.
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
+ };
@@ -0,0 +1,439 @@
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 trails with `composes`
7
+ * declarations, checks that every declared composing was called at least once.
8
+ */
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+
12
+ import type {
13
+ ComposeFn,
14
+ ComposeOptions,
15
+ ExecuteTrailOptions,
16
+ ResourceOverrideMap,
17
+ Topo,
18
+ TrailExample,
19
+ Trail,
20
+ TrailContext,
21
+ } from '@ontrails/core';
22
+
23
+ import {
24
+ buildComposeValidationSchema,
25
+ executeTrail,
26
+ parseTrailIdVersionReference,
27
+ Result,
28
+ ValidationError,
29
+ validateInput,
30
+ } from '@ontrails/core';
31
+ import type { z } from 'zod';
32
+
33
+ import {
34
+ assertErrorMatch,
35
+ assertFullMatch,
36
+ assertPartialMatch,
37
+ assertSchemaMatch,
38
+ } from './assertions.js';
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>;
57
+ };
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Helpers
61
+ // ---------------------------------------------------------------------------
62
+
63
+ const assertProgressiveMatch = (
64
+ result: Result<unknown, Error>,
65
+ example: TrailExample<unknown, unknown>,
66
+ output: z.ZodType | undefined
67
+ ): void => {
68
+ if (example.expected !== undefined) {
69
+ return assertFullMatch(result, example.expected);
70
+ }
71
+ if (example.expectedMatch !== undefined) {
72
+ return assertPartialMatch(result, example.expectedMatch);
73
+ }
74
+ if (example.error !== undefined) {
75
+ return assertErrorMatch(result, resolveErrorClass(example.error));
76
+ }
77
+ assertSchemaMatch(result, output);
78
+ };
79
+
80
+ /**
81
+ * Handle input validation failure for an example.
82
+ * Returns true if the validation error was expected (and assertions passed).
83
+ * Throws if the validation error was unexpected.
84
+ */
85
+ const handleValidationError = (
86
+ validated: Result<unknown, Error>,
87
+ example: TrailExample<unknown, unknown>
88
+ ): boolean => {
89
+ if (!validated.isErr()) {
90
+ return false;
91
+ }
92
+
93
+ if (example.error !== undefined) {
94
+ const errorClass = resolveErrorClass(example.error);
95
+ expect(validated.error).toBeInstanceOf(errorClass);
96
+ return true;
97
+ }
98
+
99
+ throw new Error(
100
+ `Example "${example.name}" has invalid input: ${validated.error.message}`
101
+ );
102
+ };
103
+
104
+ /**
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.
107
+ */
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,
129
+ example: TrailExample<unknown, unknown>,
130
+ testCtx: TrailContext,
131
+ resources?: ResourceOverrideMap,
132
+ opts?: TestExecutionOptions
133
+ ): Promise<void> => {
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);
138
+
139
+ if (handleValidationError(validated, example)) {
140
+ signals.assert();
141
+ return;
142
+ }
143
+
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
+ });
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
+ );
180
+ };
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Composing coverage for trails with compositions
184
+ // ---------------------------------------------------------------------------
185
+
186
+ /**
187
+ * Build a recording compose function that tracks which trail IDs are called.
188
+ *
189
+ * Delegates to `baseCompose` when available, otherwise looks up the trail
190
+ * in the topo and executes it with validated input. Falls back to
191
+ * `Result.ok()` when neither is available.
192
+ */
193
+ const createCoverageCompose = (
194
+ called: Set<string>,
195
+ baseCompose: ComposeFn | undefined,
196
+ topo: Topo,
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
+ }
222
+
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);
233
+ }
234
+
235
+ const trailDef = topo.get(id);
236
+ if (trailDef !== undefined) {
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);
244
+ }
245
+
246
+ return Result.ok();
247
+ };
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;
276
+ };
277
+
278
+ /**
279
+ * Run a single example against a trail with compositions, recording compose calls.
280
+ */
281
+ const runCompositionExample = async (
282
+ target: TrailExampleTarget,
283
+ example: TrailExample<unknown, unknown>,
284
+ baseCtx: TrailContext,
285
+ called: Set<string>,
286
+ topo: Topo,
287
+ resources?: ResourceOverrideMap,
288
+ opts?: TestExecutionOptions
289
+ ): Promise<void> => {
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);
296
+
297
+ if (handleValidationError(validated, example)) {
298
+ signals.assert();
299
+ return;
300
+ }
301
+
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 }),
317
+ });
318
+ assertProgressiveMatch(result, example, output);
319
+ signals.assert();
320
+ };
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // testExamples
324
+ // ---------------------------------------------------------------------------
325
+
326
+ /**
327
+ * Generate describe/test blocks for every trail example in the app.
328
+ *
329
+ * For trails with `composes` declarations and examples, also verifies that
330
+ * every declared composed ID was called at least once across all examples.
331
+ *
332
+ * One line in your test file:
333
+ * ```ts
334
+ * testExamples(graph);
335
+ * ```
336
+ */
337
+ export const testExamples = (
338
+ app: Topo,
339
+ ctxOrFactory?:
340
+ | Partial<TrailContext>
341
+ | TestExecutionOptions
342
+ | (() => Partial<TrailContext> | TestExecutionOptions)
343
+ ): void => {
344
+ const resolveInput =
345
+ typeof ctxOrFactory === 'function' ? ctxOrFactory : () => ctxOrFactory;
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
+ }
359
+
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
+ }
375
+
376
+ // Composition trails: use recording compose and check coverage.
377
+ //
378
+ // Composing coverage only runs against AUTHORED examples. Entity-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;
390
+ }
391
+
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
+ }
439
+ };