@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/CHANGELOG.md +807 -0
- package/README.md +157 -0
- package/package.json +57 -0
- package/src/all-established.ts +168 -0
- package/src/all.ts +94 -0
- package/src/assertions.ts +361 -0
- package/src/cli.ts +6 -0
- package/src/composes.ts +433 -0
- package/src/context.ts +228 -0
- package/src/contracts.ts +109 -0
- package/src/detours.ts +181 -0
- package/src/effective-examples.ts +408 -0
- package/src/errors.ts +47 -0
- package/src/examples.ts +439 -0
- package/src/harness-cli.ts +335 -0
- package/src/harness-http.ts +341 -0
- package/src/harness-mcp.ts +98 -0
- package/src/http.ts +10 -0
- package/src/index.ts +48 -0
- package/src/logger.ts +127 -0
- package/src/mcp.ts +6 -0
- package/src/scenario.ts +375 -0
- package/src/signals.ts +221 -0
- package/src/surface-parity.ts +389 -0
- package/src/trail.ts +116 -0
- package/src/types.ts +89 -0
package/src/composes.ts
ADDED
|
@@ -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
|
+
};
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test context factory for creating TrailContext instances suitable for testing.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
ComposeFn,
|
|
7
|
+
ResourceOverrideMap,
|
|
8
|
+
Topo,
|
|
9
|
+
TrailContext,
|
|
10
|
+
} from '@ontrails/core';
|
|
11
|
+
import {
|
|
12
|
+
Result,
|
|
13
|
+
buildComposeValidationSchema,
|
|
14
|
+
createResourceLookup,
|
|
15
|
+
passthroughTrace,
|
|
16
|
+
} from '@ontrails/core';
|
|
17
|
+
|
|
18
|
+
import { createTestLogger } from './logger.js';
|
|
19
|
+
import type { TestTrailContextOptions } from './types.js';
|
|
20
|
+
|
|
21
|
+
type MutableTrailContext = {
|
|
22
|
+
-readonly [K in keyof TrailContext]: TrailContext[K];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// createTestContext
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create a TrailContext with deterministic, test-friendly defaults.
|
|
31
|
+
*
|
|
32
|
+
* - `requestId`: `"test-request-001"` (deterministic)
|
|
33
|
+
* - `logger`: a `TestLogger` that captures entries
|
|
34
|
+
* - `abortSignal`: a non-aborted AbortController signal
|
|
35
|
+
*/
|
|
36
|
+
export const createTestContext = (
|
|
37
|
+
overrides?: TestTrailContextOptions
|
|
38
|
+
): TrailContext => {
|
|
39
|
+
const cwd = overrides?.cwd ?? process.cwd();
|
|
40
|
+
const ctx = {
|
|
41
|
+
abortSignal: overrides?.abortSignal ?? new AbortController().signal,
|
|
42
|
+
cwd,
|
|
43
|
+
env: overrides?.env ?? { TRAILS_ENV: 'test' },
|
|
44
|
+
extensions: undefined,
|
|
45
|
+
logger: overrides?.logger ?? createTestLogger(),
|
|
46
|
+
requestId: overrides?.requestId ?? 'test-request-001',
|
|
47
|
+
trace: overrides?.trace ?? passthroughTrace,
|
|
48
|
+
workspaceRoot: cwd,
|
|
49
|
+
} as MutableTrailContext;
|
|
50
|
+
const lookup = createResourceLookup(() => ctx);
|
|
51
|
+
ctx.resource = lookup;
|
|
52
|
+
return ctx;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// createComposeContext
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
export interface CreateComposeContextOptions {
|
|
60
|
+
readonly responses?: Record<string, Result<unknown, Error>> | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Minimal permit shape returned by the create function. */
|
|
64
|
+
export interface MinimalPermit {
|
|
65
|
+
readonly id: string;
|
|
66
|
+
readonly scopes: readonly string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Trail shape consumed by the create function — avoids importing permits. */
|
|
70
|
+
export interface PermittedTrail {
|
|
71
|
+
readonly permit?:
|
|
72
|
+
| { readonly scopes: readonly string[] }
|
|
73
|
+
| 'public'
|
|
74
|
+
| undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface TestExecutionOptions {
|
|
78
|
+
readonly ctx?: Partial<TrailContext> | undefined;
|
|
79
|
+
readonly resources?: ResourceOverrideMap | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* When true, disables automatic permit creation. Tests must provide
|
|
82
|
+
* explicit permits.
|
|
83
|
+
*/
|
|
84
|
+
readonly strictPermits?: boolean | undefined;
|
|
85
|
+
/**
|
|
86
|
+
* Optional function to create a test permit for a trail. When provided,
|
|
87
|
+
* called for each trail with a non-public `permit` requirement.
|
|
88
|
+
* Returning `undefined` skips creation for that trail.
|
|
89
|
+
*
|
|
90
|
+
* A default inline implementation is used when this is not provided,
|
|
91
|
+
* keeping the testing package free of a hard dependency on `@ontrails/permits`.
|
|
92
|
+
*/
|
|
93
|
+
readonly createPermit?: (trail: PermittedTrail) => MinimalPermit | undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Create a mock `ComposeFn` for testing composite trails.
|
|
98
|
+
*
|
|
99
|
+
* Returns preconfigured `Result` values keyed by trail ID. Calls to
|
|
100
|
+
* unregistered IDs return `Result.err` with a descriptive message.
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* ```ts
|
|
104
|
+
* const compose = createComposeContext({
|
|
105
|
+
* responses: { 'entity.add': Result.ok({ id: '1', name: 'Alpha' }) },
|
|
106
|
+
* });
|
|
107
|
+
* const ctx = { ...createTestContext(), compose };
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
export const createComposeContext = (
|
|
111
|
+
options?: CreateComposeContextOptions
|
|
112
|
+
): ComposeFn => {
|
|
113
|
+
const responses = options?.responses ?? {};
|
|
114
|
+
const respondToCompose = <O>(
|
|
115
|
+
idOrTrail: string | { readonly id: string }
|
|
116
|
+
): Promise<Result<O, Error>> => {
|
|
117
|
+
const id = typeof idOrTrail === 'string' ? idOrTrail : idOrTrail.id;
|
|
118
|
+
const response = responses[id];
|
|
119
|
+
if (response === undefined) {
|
|
120
|
+
return Promise.resolve(
|
|
121
|
+
Result.err(
|
|
122
|
+
new Error(`No mock response for compose("${id}")`)
|
|
123
|
+
) as Result<O, Error>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return Promise.resolve(response as Result<O, Error>);
|
|
127
|
+
};
|
|
128
|
+
const compose = (async (
|
|
129
|
+
idOrTrail:
|
|
130
|
+
| string
|
|
131
|
+
| { readonly id: string }
|
|
132
|
+
| readonly (readonly [string | { readonly id: string }, unknown])[],
|
|
133
|
+
_input?: unknown
|
|
134
|
+
) => {
|
|
135
|
+
if (Array.isArray(idOrTrail)) {
|
|
136
|
+
return await Promise.all(
|
|
137
|
+
idOrTrail.map(([target]) => respondToCompose(target))
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return await respondToCompose(
|
|
142
|
+
idOrTrail as string | { readonly id: string }
|
|
143
|
+
);
|
|
144
|
+
}) as ComposeFn;
|
|
145
|
+
return compose;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Default permit creator — reads `trail.permit.scopes` and produces a
|
|
150
|
+
* minimal permit object. No dependency on `@ontrails/permits`.
|
|
151
|
+
*/
|
|
152
|
+
export const defaultCreatePermit = (
|
|
153
|
+
trail: PermittedTrail
|
|
154
|
+
): MinimalPermit | undefined => {
|
|
155
|
+
if (!trail.permit || trail.permit === 'public') {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
return { id: 'test-permit', scopes: trail.permit.scopes };
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const isTestExecutionOptions = (
|
|
162
|
+
input: Partial<TrailContext> | TestExecutionOptions | undefined
|
|
163
|
+
): input is TestExecutionOptions =>
|
|
164
|
+
input !== undefined &&
|
|
165
|
+
(Object.hasOwn(input, 'ctx') ||
|
|
166
|
+
Object.hasOwn(input, 'resources') ||
|
|
167
|
+
Object.hasOwn(input, 'strictPermits') ||
|
|
168
|
+
Object.hasOwn(input, 'createPermit'));
|
|
169
|
+
|
|
170
|
+
export const normalizeTestExecutionOptions = (
|
|
171
|
+
input?: Partial<TrailContext> | TestExecutionOptions
|
|
172
|
+
): TestExecutionOptions =>
|
|
173
|
+
isTestExecutionOptions(input) ? input : { ctx: input };
|
|
174
|
+
|
|
175
|
+
export const mergeResourceOverrides = (
|
|
176
|
+
autoResolved: ResourceOverrideMap,
|
|
177
|
+
ctx: Partial<TrailContext> | undefined,
|
|
178
|
+
explicit: ResourceOverrideMap | undefined
|
|
179
|
+
): ResourceOverrideMap => ({
|
|
180
|
+
...autoResolved,
|
|
181
|
+
...ctx?.extensions,
|
|
182
|
+
...explicit,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const buildMockResources = async (app: Topo): Promise<ResourceOverrideMap> => {
|
|
186
|
+
const resources: Record<string, unknown> = {};
|
|
187
|
+
for (const declaredResource of app.listResources()) {
|
|
188
|
+
if (declaredResource.unmockable !== undefined) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (!declaredResource.mock) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
resources[declaredResource.id] = await declaredResource.mock();
|
|
195
|
+
}
|
|
196
|
+
return resources;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export const createMockResources = async (
|
|
200
|
+
app: Topo
|
|
201
|
+
): Promise<ResourceOverrideMap> => await buildMockResources(app);
|
|
202
|
+
|
|
203
|
+
// Re-export from core so existing consumers of this module continue to work.
|
|
204
|
+
export { buildComposeValidationSchema };
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Merge a Partial<TrailContext> into a test context.
|
|
208
|
+
* Used internally when the public API accepts Partial<TrailContext>.
|
|
209
|
+
*/
|
|
210
|
+
export const mergeTestContext = (
|
|
211
|
+
ctx?: Partial<TrailContext>,
|
|
212
|
+
resources?: ResourceOverrideMap
|
|
213
|
+
): TrailContext => {
|
|
214
|
+
const base = createTestContext();
|
|
215
|
+
const extensions = {
|
|
216
|
+
...base.extensions,
|
|
217
|
+
...ctx?.extensions,
|
|
218
|
+
...resources,
|
|
219
|
+
};
|
|
220
|
+
const merged = {
|
|
221
|
+
...base,
|
|
222
|
+
...ctx,
|
|
223
|
+
extensions: Object.keys(extensions).length === 0 ? undefined : extensions,
|
|
224
|
+
} as MutableTrailContext;
|
|
225
|
+
const lookup = createResourceLookup(() => merged);
|
|
226
|
+
merged.resource = lookup;
|
|
227
|
+
return merged;
|
|
228
|
+
};
|