@ontrails/testing 1.0.0-beta.3 → 1.0.0-beta.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +507 -8
  2. package/README.md +86 -22
  3. package/package.json +34 -5
  4. package/src/all-established.ts +168 -0
  5. package/src/all.ts +51 -12
  6. package/src/assertions.ts +253 -0
  7. package/src/cli.ts +6 -0
  8. package/src/composes.ts +433 -0
  9. package/src/context.ts +200 -14
  10. package/src/contracts.ts +46 -35
  11. package/src/detours.ts +155 -18
  12. package/src/effective-examples.ts +414 -0
  13. package/src/errors.ts +47 -0
  14. package/src/examples.ts +302 -177
  15. package/src/harness-cli.ts +83 -58
  16. package/src/harness-http.ts +341 -0
  17. package/src/harness-mcp.ts +46 -16
  18. package/src/http.ts +10 -0
  19. package/src/index.ts +22 -14
  20. package/src/logger.ts +3 -1
  21. package/src/mcp.ts +6 -0
  22. package/src/scenario.ts +375 -0
  23. package/src/signals.ts +221 -0
  24. package/src/surface-parity.ts +389 -0
  25. package/src/trail.ts +1 -1
  26. package/src/types.ts +24 -52
  27. package/.turbo/turbo-build.log +0 -1
  28. package/.turbo/turbo-lint.log +0 -3
  29. package/.turbo/turbo-typecheck.log +0 -1
  30. package/dist/all.d.ts +0 -30
  31. package/dist/all.d.ts.map +0 -1
  32. package/dist/all.js +0 -47
  33. package/dist/all.js.map +0 -1
  34. package/dist/assertions.d.ts +0 -49
  35. package/dist/assertions.d.ts.map +0 -1
  36. package/dist/assertions.js +0 -84
  37. package/dist/assertions.js.map +0 -1
  38. package/dist/context.d.ts +0 -19
  39. package/dist/context.d.ts.map +0 -1
  40. package/dist/context.js +0 -33
  41. package/dist/context.js.map +0 -1
  42. package/dist/contracts.d.ts +0 -16
  43. package/dist/contracts.d.ts.map +0 -1
  44. package/dist/contracts.js +0 -66
  45. package/dist/contracts.js.map +0 -1
  46. package/dist/detours.d.ts +0 -12
  47. package/dist/detours.d.ts.map +0 -1
  48. package/dist/detours.js +0 -30
  49. package/dist/detours.js.map +0 -1
  50. package/dist/examples.d.ts +0 -22
  51. package/dist/examples.d.ts.map +0 -1
  52. package/dist/examples.js +0 -187
  53. package/dist/examples.js.map +0 -1
  54. package/dist/harness-cli.d.ts +0 -21
  55. package/dist/harness-cli.d.ts.map +0 -1
  56. package/dist/harness-cli.js +0 -213
  57. package/dist/harness-cli.js.map +0 -1
  58. package/dist/harness-mcp.d.ts +0 -21
  59. package/dist/harness-mcp.d.ts.map +0 -1
  60. package/dist/harness-mcp.js +0 -50
  61. package/dist/harness-mcp.js.map +0 -1
  62. package/dist/hike.d.ts +0 -32
  63. package/dist/hike.d.ts.map +0 -1
  64. package/dist/hike.js +0 -169
  65. package/dist/hike.js.map +0 -1
  66. package/dist/index.d.ts +0 -14
  67. package/dist/index.d.ts.map +0 -1
  68. package/dist/index.js +0 -16
  69. package/dist/index.js.map +0 -1
  70. package/dist/logger.d.ts +0 -15
  71. package/dist/logger.d.ts.map +0 -1
  72. package/dist/logger.js +0 -87
  73. package/dist/logger.js.map +0 -1
  74. package/dist/trail.d.ts +0 -20
  75. package/dist/trail.d.ts.map +0 -1
  76. package/dist/trail.js +0 -80
  77. package/dist/trail.js.map +0 -1
  78. package/dist/types.d.ts +0 -80
  79. package/dist/types.d.ts.map +0 -1
  80. package/dist/types.js +0 -5
  81. package/dist/types.js.map +0 -1
  82. package/src/__tests__/context.test.ts +0 -60
  83. package/src/__tests__/contracts.test.ts +0 -93
  84. package/src/__tests__/detours.test.ts +0 -55
  85. package/src/__tests__/examples.test.ts +0 -176
  86. package/src/__tests__/hike.test.ts +0 -164
  87. package/src/__tests__/logger.test.ts +0 -136
  88. package/src/__tests__/trail.test.ts +0 -99
  89. package/src/hike.ts +0 -283
  90. package/tsconfig.json +0 -9
  91. package/tsconfig.tsbuildinfo +0 -1
package/src/assertions.ts CHANGED
@@ -49,6 +49,40 @@ export const expectErr = <T, E>(result: Result<T, E>): E => {
49
49
  return (result as unknown as { error: E }).error;
50
50
  };
51
51
 
52
+ // ---------------------------------------------------------------------------
53
+ // Result Match Tokens
54
+ // ---------------------------------------------------------------------------
55
+
56
+ export interface OkResultMatch {
57
+ readonly __resultMatch: 'ok';
58
+ readonly value?: unknown | undefined;
59
+ }
60
+
61
+ export interface ErrResultMatch {
62
+ readonly __resultMatch: 'err';
63
+ readonly error?: unknown | undefined;
64
+ }
65
+
66
+ type ResultMatchToken = OkResultMatch | ErrResultMatch;
67
+
68
+ /**
69
+ * Create a partial-match token for `Result.ok(...)` values nested inside
70
+ * arrays or objects, such as the `Result[]` returned by batch `ctx.compose()`.
71
+ */
72
+ export const okResultMatch = (value?: unknown): OkResultMatch => ({
73
+ __resultMatch: 'ok',
74
+ value,
75
+ });
76
+
77
+ /**
78
+ * Create a partial-match token for `Result.err(...)` values nested inside
79
+ * arrays or objects, such as mixed-success batch `ctx.compose()`.
80
+ */
81
+ export const errResultMatch = (error?: unknown): ErrResultMatch => ({
82
+ __resultMatch: 'err',
83
+ error,
84
+ });
85
+
52
86
  // ---------------------------------------------------------------------------
53
87
  // Full Match
54
88
  // ---------------------------------------------------------------------------
@@ -87,6 +121,225 @@ export const assertSchemaMatch = (
87
121
  }
88
122
  };
89
123
 
124
+ // ---------------------------------------------------------------------------
125
+ // Partial Match
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /** Format a path for error messages. */
129
+ const formatLoc = (path: readonly string[]): string =>
130
+ path.length > 0 ? path.join('.') : 'root';
131
+
132
+ interface ResultLike {
133
+ readonly error?: unknown;
134
+ isErr(): boolean;
135
+ isOk(): boolean;
136
+ readonly value?: unknown;
137
+ }
138
+
139
+ const isResultMatchToken = (value: unknown): value is ResultMatchToken =>
140
+ typeof value === 'object' &&
141
+ value !== null &&
142
+ '__resultMatch' in value &&
143
+ ((value as Record<string, unknown>)['__resultMatch'] === 'ok' ||
144
+ (value as Record<string, unknown>)['__resultMatch'] === 'err');
145
+
146
+ const isResultLike = (value: unknown): value is ResultLike =>
147
+ typeof value === 'object' &&
148
+ value !== null &&
149
+ 'isOk' in value &&
150
+ typeof (value as Record<string, unknown>)['isOk'] === 'function' &&
151
+ 'isErr' in value &&
152
+ typeof (value as Record<string, unknown>)['isErr'] === 'function';
153
+
154
+ /** Find an unconsumed actual element that deep-matches the expected object. */
155
+ const findObjectMatch = (
156
+ actual: unknown[],
157
+ elem: object,
158
+ consumed: ReadonlySet<number>,
159
+ path: readonly string[],
160
+ index: number
161
+ ): number =>
162
+ actual.findIndex((a, idx) => {
163
+ if (consumed.has(idx)) {
164
+ return false;
165
+ }
166
+ try {
167
+ // oxlint-disable-next-line no-use-before-define -- mutual recursion with assertSubset
168
+ assertSubset(a, elem, [...path, `[${String(index)}]`]);
169
+ return true;
170
+ } catch {
171
+ return false;
172
+ }
173
+ });
174
+
175
+ /**
176
+ * Assert that every element in `expected` exists in `actual` (order-independent).
177
+ *
178
+ * Tracks consumed indices so that duplicate expected elements each require a
179
+ * distinct actual element — `['a', 'a']` does not match `['a']`.
180
+ */
181
+ const assertArraySubset = (
182
+ actual: unknown[],
183
+ expected: unknown[],
184
+ path: readonly string[],
185
+ loc: string
186
+ ): void => {
187
+ const consumed = new Set<number>();
188
+ for (let i = 0; i < expected.length; i += 1) {
189
+ const elem = expected[i];
190
+ const matchIndex =
191
+ typeof elem === 'object' && elem !== null
192
+ ? findObjectMatch(actual, elem, consumed, path, i)
193
+ : actual.findIndex((a, idx) => !consumed.has(idx) && a === elem);
194
+
195
+ if (matchIndex === -1) {
196
+ throw new Error(
197
+ `at ${loc}[${String(i)}]: expected array to contain ${JSON.stringify(elem)}`
198
+ );
199
+ }
200
+ consumed.add(matchIndex);
201
+ }
202
+ };
203
+
204
+ /** Assert that every key in `expected` exists in `actual` with a matching value. */
205
+ const assertObjectSubset = (
206
+ actual: Record<string, unknown>,
207
+ expected: Record<string, unknown>,
208
+ path: readonly string[]
209
+ ): void => {
210
+ for (const key of Object.keys(expected)) {
211
+ if (!(key in actual)) {
212
+ throw new Error(
213
+ `at ${[...path, key].join('.')}: key not found in actual`
214
+ );
215
+ }
216
+ // oxlint-disable-next-line no-use-before-define -- mutual recursion with assertSubset
217
+ assertSubset(actual[key], expected[key], [...path, key]);
218
+ }
219
+ };
220
+
221
+ /**
222
+ * Recursively assert that `actual` is a superset of `expected`.
223
+ *
224
+ * - **Scalars:** strict equality.
225
+ * - **Objects:** every key in `expected` must exist in `actual` with a matching
226
+ * value. Extra keys in `actual` are ignored.
227
+ * - **Arrays:** every element in `expected` must exist in `actual`
228
+ * (order-independent subset check).
229
+ * - **Nested objects:** recursive subset matching.
230
+ */
231
+ // oxlint-disable-next-line max-statements -- recursive dispatch across four type branches
232
+ const assertSubset = (
233
+ actual: unknown,
234
+ expected: unknown,
235
+ path: readonly string[]
236
+ ): void => {
237
+ const loc = formatLoc(path);
238
+
239
+ if (expected === null || expected === undefined) {
240
+ if (actual !== expected) {
241
+ throw new Error(
242
+ `at ${loc}: expected ${String(expected)}, got ${String(actual)}`
243
+ );
244
+ }
245
+ return;
246
+ }
247
+
248
+ if (isResultMatchToken(expected)) {
249
+ // oxlint-disable-next-line no-use-before-define -- result token matching delegates back into assertSubset
250
+ assertResultTokenMatch(actual, expected, path);
251
+ return;
252
+ }
253
+
254
+ if (Array.isArray(expected)) {
255
+ if (!Array.isArray(actual)) {
256
+ throw new TypeError(`at ${loc}: expected an array, got ${typeof actual}`);
257
+ }
258
+ assertArraySubset(actual, expected, path, loc);
259
+ return;
260
+ }
261
+
262
+ if (typeof expected === 'object') {
263
+ if (
264
+ typeof actual !== 'object' ||
265
+ actual === null ||
266
+ Array.isArray(actual)
267
+ ) {
268
+ throw new Error(`at ${loc}: expected an object, got ${typeof actual}`);
269
+ }
270
+ assertObjectSubset(
271
+ actual as Record<string, unknown>,
272
+ expected as Record<string, unknown>,
273
+ path
274
+ );
275
+ return;
276
+ }
277
+
278
+ if (actual !== expected) {
279
+ throw new Error(
280
+ `at ${loc}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
281
+ );
282
+ }
283
+ };
284
+
285
+ const assertOkResultTokenMatch = (
286
+ actual: ResultLike,
287
+ expected: OkResultMatch,
288
+ loc: string,
289
+ path: readonly string[]
290
+ ): void => {
291
+ if (!actual.isOk()) {
292
+ throw new Error(`at ${loc}: expected Result.ok(...), got Result.err(...)`);
293
+ }
294
+ if (expected.value !== undefined) {
295
+ assertSubset(actual.value, expected.value, [...path, 'value']);
296
+ }
297
+ };
298
+
299
+ const assertErrResultTokenMatch = (
300
+ actual: ResultLike,
301
+ expected: ErrResultMatch,
302
+ loc: string,
303
+ path: readonly string[]
304
+ ): void => {
305
+ if (!actual.isErr()) {
306
+ throw new Error(`at ${loc}: expected Result.err(...), got Result.ok(...)`);
307
+ }
308
+ if (expected.error !== undefined) {
309
+ assertSubset(actual.error, expected.error, [...path, 'error']);
310
+ }
311
+ };
312
+
313
+ const assertResultTokenMatch = (
314
+ actual: unknown,
315
+ expected: ResultMatchToken,
316
+ path: readonly string[]
317
+ ): void => {
318
+ const loc = formatLoc(path);
319
+ if (!isResultLike(actual)) {
320
+ throw new TypeError(`at ${loc}: expected a Result-like value`);
321
+ }
322
+
323
+ if (expected.__resultMatch === 'ok') {
324
+ assertOkResultTokenMatch(actual, expected, loc, path);
325
+ return;
326
+ }
327
+
328
+ assertErrResultTokenMatch(actual, expected, loc, path);
329
+ };
330
+
331
+ /**
332
+ * Assert that the result is ok and its value is a superset of the expected
333
+ * partial output. Declared fields must match; extra fields are ignored.
334
+ */
335
+ export const assertPartialMatch = (
336
+ result: Result<unknown, Error>,
337
+ expectedMatch: unknown
338
+ ): void => {
339
+ const value = expectOk(result);
340
+ assertSubset(value, expectedMatch, []);
341
+ };
342
+
90
343
  // ---------------------------------------------------------------------------
91
344
  // Error Match
92
345
  // ---------------------------------------------------------------------------
package/src/cli.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { createCliHarness } from './harness-cli.js';
2
+ export type {
3
+ CliHarness,
4
+ CliHarnessOptions,
5
+ CliHarnessResult,
6
+ } from './harness-cli.js';
@@ -0,0 +1,433 @@
1
+ /**
2
+ * testComposes — composing-aware scenario testing for trails with compositions.
3
+ *
4
+ * Tests the composing graph: which trails were composed, in what order,
5
+ * and supports failure injection from composed trail examples.
6
+ */
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+
10
+ import type {
11
+ AnyTrail,
12
+ ComposeFn,
13
+ ExecuteTrailOptions,
14
+ ResourceOverrideMap,
15
+ TrailContext,
16
+ } from '@ontrails/core';
17
+ import {
18
+ buildComposeValidationSchema,
19
+ executeTrail,
20
+ InternalError,
21
+ Result,
22
+ ValidationError,
23
+ validateInput,
24
+ } from '@ontrails/core';
25
+
26
+ import {
27
+ assertErrorMatch,
28
+ assertFullMatch,
29
+ assertSchemaMatch,
30
+ } from './assertions.js';
31
+ import { mergeResourceOverrides, mergeTestContext } from './context.js';
32
+ import { createErrorFromName } from './errors.js';
33
+ import type { ComposeScenario } from './types.js';
34
+
35
+ type TestingExecuteTrailOptions = ExecuteTrailOptions & {
36
+ readonly validationSchema?: ReturnType<typeof buildComposeValidationSchema>;
37
+ };
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Compose trace
41
+ // ---------------------------------------------------------------------------
42
+
43
+ interface ComposeRecord {
44
+ readonly id: string;
45
+ readonly input: unknown;
46
+ }
47
+
48
+ const collectDeclaredResources = (
49
+ trailDef: AnyTrail,
50
+ trailsMap: ReadonlyMap<string, AnyTrail> | undefined
51
+ ): AnyTrail['resources'] => {
52
+ const seenResourceIds = new Set<string>();
53
+ const seenTrailIds = new Set<string>();
54
+ const resources: AnyTrail['resources'][number][] = [];
55
+
56
+ const collect = (candidate: AnyTrail): void => {
57
+ for (const declaredResource of candidate.resources) {
58
+ if (seenResourceIds.has(declaredResource.id)) {
59
+ continue;
60
+ }
61
+ seenResourceIds.add(declaredResource.id);
62
+ resources.push(declaredResource);
63
+ }
64
+ };
65
+
66
+ const visit = (candidate: AnyTrail): void => {
67
+ if (seenTrailIds.has(candidate.id)) {
68
+ return;
69
+ }
70
+ seenTrailIds.add(candidate.id);
71
+ collect(candidate);
72
+ for (const composedId of candidate.composes) {
73
+ const composedTrail = trailsMap?.get(composedId);
74
+ if (composedTrail) {
75
+ visit(composedTrail);
76
+ }
77
+ }
78
+ };
79
+
80
+ visit(trailDef);
81
+ return resources;
82
+ };
83
+
84
+ const resolveComposeMockResources = async (
85
+ trailDef: AnyTrail,
86
+ trailsMap: ReadonlyMap<string, AnyTrail> | undefined
87
+ ): Promise<ResourceOverrideMap> => {
88
+ const resources: Record<string, unknown> = {};
89
+
90
+ for (const declaredResource of collectDeclaredResources(
91
+ trailDef,
92
+ trailsMap
93
+ )) {
94
+ if (!declaredResource.mock) {
95
+ continue;
96
+ }
97
+ resources[declaredResource.id] = await declaredResource.mock();
98
+ }
99
+
100
+ return resources;
101
+ };
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Injection helpers
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Find an error example on a trail by name or description substring.
109
+ */
110
+ const findErrorExample = (
111
+ trailDef: AnyTrail,
112
+ description: string
113
+ ): string | undefined => {
114
+ const example = trailDef.examples?.find(
115
+ (ex) =>
116
+ ex.error !== undefined &&
117
+ (ex.description?.includes(description) || ex.name.includes(description))
118
+ );
119
+ return example?.error;
120
+ };
121
+
122
+ /**
123
+ * Try to inject an error from a composed trail's example.
124
+ * Returns undefined when no injection is configured for this trail ID.
125
+ */
126
+ const tryInjectError = (
127
+ id: string,
128
+ scenario: ComposeScenario,
129
+ trailsMap: ReadonlyMap<string, AnyTrail> | undefined
130
+ ): Result<unknown, Error> | undefined => {
131
+ const injection = scenario.injectFromExample?.[id];
132
+ if (injection === undefined) {
133
+ return undefined;
134
+ }
135
+
136
+ const trailDef = trailsMap?.get(id);
137
+ if (trailDef === undefined) {
138
+ return Result.err(
139
+ new InternalError(`Cannot inject: trail "${id}" not in topo`)
140
+ );
141
+ }
142
+ const errorName = findErrorExample(trailDef, injection);
143
+ if (errorName === undefined) {
144
+ return Result.err(
145
+ new InternalError(
146
+ `No error example matching "${injection}" on trail "${id}"`
147
+ )
148
+ );
149
+ }
150
+ return Result.err(createErrorFromName(errorName));
151
+ };
152
+
153
+ const executeFromMap = (
154
+ id: string,
155
+ input: unknown,
156
+ trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
157
+ ctx: TrailContext,
158
+ resources: ResourceOverrideMap | undefined,
159
+ compose?: ComposeFn
160
+ ): Result<unknown, Error> | Promise<Result<unknown, Error>> | undefined => {
161
+ const trailDef = trailsMap?.get(id);
162
+ if (trailDef === undefined) {
163
+ return undefined;
164
+ }
165
+
166
+ const nestedCtx = compose ? { ...ctx, compose } : ctx;
167
+ const options: TestingExecuteTrailOptions = {
168
+ ctx: nestedCtx,
169
+ resources,
170
+ validationSchema: buildComposeValidationSchema(trailDef),
171
+ };
172
+ return executeTrail(trailDef, input, options);
173
+ };
174
+
175
+ /** Extract trail ID from either a trail object or a string. */
176
+ const resolveComposeId = (
177
+ idOrTrail: string | { readonly id: string }
178
+ ): string => (typeof idOrTrail === 'string' ? idOrTrail : idOrTrail.id);
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Compose factory
182
+ // ---------------------------------------------------------------------------
183
+
184
+ /** Delegate to baseCompose, executeFromMap, or fall back to Result.ok(). */
185
+ const delegateCompose = (
186
+ id: string,
187
+ input: unknown,
188
+ baseCompose: ComposeFn | undefined,
189
+ trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
190
+ ctx: TrailContext,
191
+ resources: ResourceOverrideMap | undefined,
192
+ self: ComposeFn
193
+ ): Promise<Result<unknown, Error>> => {
194
+ if (baseCompose !== undefined) {
195
+ return baseCompose(id, input);
196
+ }
197
+ const executed = executeFromMap(id, input, trailsMap, ctx, resources, self);
198
+ return Promise.resolve(executed ?? Result.ok());
199
+ };
200
+
201
+ /**
202
+ * Build a recording compose function that optionally injects errors.
203
+ */
204
+ const createRecordingCompose = (
205
+ trace: ComposeRecord[],
206
+ scenario: ComposeScenario,
207
+ trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
208
+ baseCompose: ComposeFn | undefined,
209
+ ctx: TrailContext,
210
+ resources: ResourceOverrideMap | undefined
211
+ ): ComposeFn => {
212
+ // The generic O on ComposeFn is erased at runtime; the cast is safe
213
+ // because callers narrow via isOk/isErr before accessing the value.
214
+ const invokeCompose = async (
215
+ idOrTrail: string | { readonly id: string },
216
+ input: unknown,
217
+ self: ComposeFn
218
+ ) => {
219
+ const id = resolveComposeId(idOrTrail);
220
+ trace.push({ id, input });
221
+
222
+ const injected = tryInjectError(id, scenario, trailsMap);
223
+ if (injected !== undefined) {
224
+ return injected;
225
+ }
226
+
227
+ return await delegateCompose(
228
+ id,
229
+ input,
230
+ baseCompose,
231
+ trailsMap,
232
+ ctx,
233
+ resources,
234
+ self
235
+ );
236
+ };
237
+
238
+ // Accepts either a trail object (typed compose), a string id (untyped),
239
+ // or a batch of `[target, input]` tuples.
240
+ const compose = async function compose(
241
+ idOrTrail:
242
+ | string
243
+ | { readonly id: string }
244
+ | readonly (readonly [string | { readonly id: string }, unknown])[],
245
+ input?: unknown
246
+ ) {
247
+ if (Array.isArray(idOrTrail)) {
248
+ return await Promise.all(
249
+ idOrTrail.map(([target, batchInput]) =>
250
+ invokeCompose(target, batchInput, compose as ComposeFn)
251
+ )
252
+ );
253
+ }
254
+
255
+ return await invokeCompose(
256
+ idOrTrail as string | { readonly id: string },
257
+ input,
258
+ compose as ComposeFn
259
+ );
260
+ } as ComposeFn;
261
+
262
+ return compose;
263
+ };
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // Scenario assertions
267
+ // ---------------------------------------------------------------------------
268
+
269
+ const assertScenarioResult = (
270
+ result: Result<unknown, Error>,
271
+ scenario: ComposeScenario,
272
+ trailDef: AnyTrail
273
+ ): void => {
274
+ if (scenario.expectValue !== undefined) {
275
+ assertFullMatch(result, scenario.expectValue);
276
+ } else if (scenario.expectErr !== undefined) {
277
+ assertErrorMatch(result, scenario.expectErr, scenario.expectErrMessage);
278
+ } else if (scenario.expectErrMessage !== undefined) {
279
+ expect(result.isErr()).toBe(true);
280
+ if (result.isErr()) {
281
+ expect(result.error.message).toContain(scenario.expectErrMessage);
282
+ }
283
+ } else if (scenario.expectOk === true) {
284
+ expect(result.isOk()).toBe(true);
285
+ assertSchemaMatch(result, trailDef.output);
286
+ }
287
+ };
288
+
289
+ const assertComposeTrace = (
290
+ trace: readonly ComposeRecord[],
291
+ scenario: ComposeScenario
292
+ ): void => {
293
+ if (scenario.expectComposed !== undefined) {
294
+ const composedIds = trace.map((r) => r.id);
295
+ expect(composedIds).toEqual([...scenario.expectComposed]);
296
+ }
297
+ if (scenario.expectComposedCount !== undefined) {
298
+ const counts: Record<string, number> = {};
299
+ for (const record of trace) {
300
+ counts[record.id] = (counts[record.id] ?? 0) + 1;
301
+ }
302
+ expect(counts).toEqual({ ...scenario.expectComposedCount });
303
+ }
304
+ };
305
+
306
+ const handleValidationError = (
307
+ validated: Result<unknown, Error>,
308
+ scenario: ComposeScenario
309
+ ): boolean => {
310
+ if (!validated.isErr()) {
311
+ return false;
312
+ }
313
+ if (scenario.expectErr === ValidationError) {
314
+ expect(validated.error).toBeInstanceOf(ValidationError);
315
+ if (scenario.expectErrMessage !== undefined) {
316
+ expect(validated.error.message).toContain(scenario.expectErrMessage);
317
+ }
318
+ return true;
319
+ }
320
+ throw new Error(
321
+ `Input validation failed unexpectedly: ${validated.error.message}`
322
+ );
323
+ };
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // Scenario runner
327
+ // ---------------------------------------------------------------------------
328
+
329
+ const buildTestContext = (
330
+ scenario: ComposeScenario,
331
+ ctx: Partial<TrailContext> | undefined,
332
+ trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
333
+ resources: ResourceOverrideMap | undefined
334
+ ): { trace: ComposeRecord[]; testCtx: TrailContext } => {
335
+ const trace: ComposeRecord[] = [];
336
+ const baseCtx = mergeTestContext(ctx);
337
+ const compose = createRecordingCompose(
338
+ trace,
339
+ scenario,
340
+ trailsMap,
341
+ baseCtx.compose,
342
+ baseCtx,
343
+ resources
344
+ );
345
+ return { testCtx: { ...baseCtx, compose }, trace };
346
+ };
347
+
348
+ const runScenario = async (
349
+ trailDef: AnyTrail,
350
+ scenario: ComposeScenario,
351
+ ctx: Partial<TrailContext> | undefined,
352
+ trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
353
+ resources: ResourceOverrideMap | undefined
354
+ ): Promise<void> => {
355
+ const validated = validateInput(trailDef.input, scenario.input);
356
+ if (handleValidationError(validated, scenario)) {
357
+ return;
358
+ }
359
+
360
+ const { trace, testCtx } = buildTestContext(
361
+ scenario,
362
+ ctx,
363
+ trailsMap,
364
+ resources
365
+ );
366
+ const result = await executeTrail(trailDef, scenario.input, {
367
+ ctx: testCtx,
368
+ resources,
369
+ });
370
+ assertComposeTrace(trace, scenario);
371
+ assertScenarioResult(result, scenario, trailDef);
372
+ };
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // testComposes
376
+ // ---------------------------------------------------------------------------
377
+
378
+ /** Options for testComposes that provide trail definitions for injection. */
379
+ export interface TestComposeOptions {
380
+ /** Partial context overrides. */
381
+ readonly ctx?: Partial<TrailContext> | undefined;
382
+ /**
383
+ * Explicit resource overrides merged on top of auto-resolved mocks for every
384
+ * scenario. Values are passed by reference — provide immutable objects, or
385
+ * use `mock()` on the resource definition to get a fresh instance per run.
386
+ */
387
+ readonly resources?: ResourceOverrideMap | undefined;
388
+ /** Map of trail ID to trail definition, used for injectFromExample. */
389
+ readonly trails?: ReadonlyMap<string, AnyTrail> | undefined;
390
+ }
391
+
392
+ /**
393
+ * Generate a describe block for a trail with compositions with one test per scenario.
394
+ *
395
+ * @example
396
+ * ```ts
397
+ * testComposes(onboardTrail, [
398
+ * {
399
+ * description: "composes add then relate",
400
+ * input: { name: "Alpha" },
401
+ * expectOk: true,
402
+ * expectComposed: ["entity.add", "entity.relate"],
403
+ * },
404
+ * ]);
405
+ * ```
406
+ */
407
+ export const testComposes = (
408
+ trailDef: AnyTrail,
409
+ scenarios: readonly ComposeScenario[],
410
+ options?: TestComposeOptions
411
+ ): void => {
412
+ const explicitResources = options?.resources;
413
+
414
+ describe(trailDef.id, () => {
415
+ test.each([...scenarios])(
416
+ '$description',
417
+ async (scenario: ComposeScenario) => {
418
+ const resources = mergeResourceOverrides(
419
+ await resolveComposeMockResources(trailDef, options?.trails),
420
+ options?.ctx,
421
+ explicitResources
422
+ );
423
+ await runScenario(
424
+ trailDef,
425
+ scenario,
426
+ options?.ctx,
427
+ options?.trails,
428
+ resources
429
+ );
430
+ }
431
+ );
432
+ });
433
+ };