@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.
@@ -6,13 +6,35 @@
6
6
  */
7
7
 
8
8
  import { deriveMcpTools } from '@ontrails/mcp';
9
- import type { McpToolDefinition } from '@ontrails/mcp';
10
-
11
9
  import type {
12
- McpHarness,
13
- McpHarnessOptions,
14
- McpHarnessResult,
15
- } from './types.js';
10
+ DeriveMcpToolsOptions,
11
+ McpExtra,
12
+ McpToolDefinition,
13
+ } from '@ontrails/mcp';
14
+ import type { Topo } from '@ontrails/core';
15
+
16
+ /** Options for creating an MCP harness. */
17
+ export interface McpHarnessOptions extends DeriveMcpToolsOptions {
18
+ readonly extra?: Partial<McpExtra> | undefined;
19
+ readonly graph: Topo;
20
+ }
21
+
22
+ /** A test harness for MCP tools. */
23
+ export interface McpHarness {
24
+ /** Call an MCP tool by name with arguments. */
25
+ callTool(
26
+ name: string,
27
+ args: Record<string, unknown>
28
+ ): Promise<McpHarnessResult>;
29
+ }
30
+
31
+ /** The result of an MCP harness tool invocation. */
32
+ export interface McpHarnessResult {
33
+ readonly content: unknown;
34
+ readonly isError: boolean;
35
+ readonly meta?: Record<string, unknown> | undefined;
36
+ readonly structuredContent?: Record<string, unknown> | undefined;
37
+ }
16
38
 
17
39
  // ---------------------------------------------------------------------------
18
40
  // createMcpHarness
@@ -25,6 +47,8 @@ import type {
25
47
  * that invokes tools directly without any transport boundary.
26
48
  *
27
49
  * ```ts
50
+ * import { createMcpHarness } from '@ontrails/testing/mcp';
51
+ *
28
52
  * const harness = createMcpHarness({ graph });
29
53
  * const result = await harness.callTool("myapp_entity_show", { name: "Alpha" });
30
54
  * expect(result.isError).toBe(false);
package/src/http.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { createHttpHarness } from './harness-http.js';
2
+ export type {
3
+ HttpHarness,
4
+ HttpHarnessErrorBody,
5
+ HttpHarnessOptions,
6
+ HttpHarnessRequest,
7
+ HttpHarnessRequestOptions,
8
+ HttpHarnessResult,
9
+ HttpHarnessSuccessBody,
10
+ } from './harness-http.js';
package/src/index.ts CHANGED
@@ -1,15 +1,10 @@
1
1
  // Contract-driven testing
2
2
  export { testAll } from './all.js';
3
- export { testAllEstablished } from './all.js';
4
3
  export { testExamples } from './examples.js';
5
- export { testCrosses } from './crosses.js';
4
+ export { testComposes } from './composes.js';
6
5
  export { testTrail } from './trail.js';
7
6
  export { testContracts } from './contracts.js';
8
7
  export { testDetours } from './detours.js';
9
- export {
10
- runSurfaceParityExample,
11
- testSurfaceParity,
12
- } from './surface-parity.js';
13
8
 
14
9
  // Assertions
15
10
  export {
@@ -28,50 +23,26 @@ export { executeScenarioSteps, ref, scenario } from './scenario.js';
28
23
 
29
24
  // Mock factories
30
25
  export {
31
- createCrossContext,
26
+ createComposeContext,
32
27
  createTestContext,
33
28
  defaultCreatePermit,
34
29
  } from './context.js';
35
30
  export { createTestLogger } from './logger.js';
36
31
 
37
- // Surface harnesses
38
- export { createCliHarness } from './harness-cli.js';
39
- export { createHttpHarness } from './harness-http.js';
40
- export { createMcpHarness } from './harness-mcp.js';
41
-
42
32
  // Types
43
- export type { CreateCrossContextOptions } from './context.js';
33
+ export type { CreateComposeContextOptions } from './context.js';
44
34
  export type {
45
35
  PermittedTrail,
46
36
  MinimalPermit,
47
37
  TestExecutionOptions,
48
38
  } from './context.js';
49
- export type { TestCrossOptions } from './crosses.js';
39
+ export type { TestComposeOptions } from './composes.js';
50
40
 
51
41
  export type {
52
- CrossScenario,
42
+ ComposeScenario,
53
43
  RefToken,
54
44
  ScenarioStep,
55
- TestAllEstablishedOptions,
56
45
  TestScenario,
57
46
  TestLogger,
58
47
  TestTrailContextOptions,
59
- CliHarness,
60
- CliHarnessOptions,
61
- CliHarnessResult,
62
- HttpHarness,
63
- HttpHarnessErrorBody,
64
- HttpHarnessOptions,
65
- HttpHarnessRequest,
66
- HttpHarnessRequestOptions,
67
- HttpHarnessResult,
68
- HttpHarnessSuccessBody,
69
- McpHarness,
70
- McpHarnessOptions,
71
- McpHarnessResult,
72
- NormalizedSurfaceParityResult,
73
- SurfaceParityExclusion,
74
- SurfaceParityOptions,
75
- SurfaceParitySurface,
76
48
  } from './types.js';
77
- export type { SurfaceParityComparison } from './surface-parity.js';
package/src/mcp.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { createMcpHarness } from './harness-mcp.js';
2
+ export type {
3
+ McpHarness,
4
+ McpHarnessOptions,
5
+ McpHarnessResult,
6
+ } from './harness-mcp.js';
package/src/scenario.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Multi-step scenario runner for composition testing.
3
3
  *
4
4
  * Scenarios express multi-trail flows as structured data — arrays of steps
5
- * with cross-step references via `ref()`. Each step invokes a trail through
5
+ * with compose-step references via `ref()`. Each step invokes a trail through
6
6
  * the normal execution pipeline (validation, layers, blaze, Result).
7
7
  */
8
8
 
@@ -10,19 +10,20 @@ import { describe, test } from 'bun:test';
10
10
 
11
11
  import type {
12
12
  AnyTrail,
13
- CrossBatchOptions,
14
- CrossFn,
13
+ ComposeBatchOptions,
14
+ ComposeFn,
15
+ ExecuteTrailOptions,
15
16
  ResourceOverrideMap,
16
17
  Result,
17
18
  Topo,
18
19
  } from '@ontrails/core';
19
20
  import {
20
- buildCrossValidationSchema,
21
- claimNextCrossBatchIndex,
22
- createCrossBatchValidationResults,
21
+ buildComposeValidationSchema,
22
+ claimNextComposeBatchIndex,
23
+ createComposeBatchValidationResults,
23
24
  executeTrail,
24
25
  InternalError,
25
- normalizeCrossBatchConcurrency,
26
+ normalizeComposeBatchConcurrency,
26
27
  Result as R,
27
28
  } from '@ontrails/core';
28
29
 
@@ -30,15 +31,18 @@ import { assertPartialMatch, expectOk } from './assertions.js';
30
31
  import { createTestContext, createMockResources } from './context.js';
31
32
  import type { RefToken, ScenarioStep } from './types.js';
32
33
 
33
- type ScenarioCrossTarget = string | { readonly id: string };
34
- type ScenarioCrossCall = readonly [ScenarioCrossTarget, unknown];
34
+ type ScenarioComposeTarget = string | { readonly id: string };
35
+ type ScenarioComposeCall = readonly [ScenarioComposeTarget, unknown];
36
+ type TestingExecuteTrailOptions = ExecuteTrailOptions & {
37
+ readonly validationSchema?: ReturnType<typeof buildComposeValidationSchema>;
38
+ };
35
39
 
36
40
  // ---------------------------------------------------------------------------
37
- // ref() — cross-step reference marker
41
+ // ref() — compose-step reference marker
38
42
  // ---------------------------------------------------------------------------
39
43
 
40
44
  /**
41
- * Create a reference marker for cross-step data in scenario inputs.
45
+ * Create a reference marker for compose-step data in scenario inputs.
42
46
  *
43
47
  * `ref('create.id')` resolves to the `id` field of the step aliased as
44
48
  * `create`. Dot-paths are supported for nested access.
@@ -46,8 +50,8 @@ type ScenarioCrossCall = readonly [ScenarioCrossTarget, unknown];
46
50
  * @example
47
51
  * ```typescript
48
52
  * scenario('Fork flow', app, [
49
- * { cross: createGist, input: { name: 'Hello' }, as: 'original' },
50
- * { cross: forkGist, input: { id: ref('original.id') } },
53
+ * { compose: createGist, input: { name: 'Hello' }, as: 'original' },
54
+ * { compose: forkGist, input: { id: ref('original.id') } },
51
55
  * ]);
52
56
  * ```
53
57
  */
@@ -152,8 +156,8 @@ export const deriveRefs = (
152
156
  * @example
153
157
  * ```typescript
154
158
  * scenario('Create and show', app, [
155
- * { cross: createItem, input: { name: 'Test' }, as: 'created' },
156
- * { cross: showItem, input: { id: ref('created.id') },
159
+ * { compose: createItem, input: { name: 'Test' }, as: 'created' },
160
+ * { compose: showItem, input: { id: ref('created.id') },
157
161
  * expectedMatch: { found: true } },
158
162
  * ]);
159
163
  * ```
@@ -176,10 +180,10 @@ const assertStepExpectations = async (
176
180
  }
177
181
  };
178
182
 
179
- const executeUnlimitedCrossBatch = async (
180
- calls: readonly ScenarioCrossCall[],
183
+ const executeUnlimitedComposeBatch = async (
184
+ calls: readonly ScenarioComposeCall[],
181
185
  runCall: (
182
- call: ScenarioCrossCall,
186
+ call: ScenarioComposeCall,
183
187
  branchIndex: number
184
188
  ) => Promise<Result<unknown, Error>>
185
189
  ): Promise<Result<unknown, Error>[]> =>
@@ -187,10 +191,10 @@ const executeUnlimitedCrossBatch = async (
187
191
  calls.map((call, branchIndex) => runCall(call, branchIndex))
188
192
  );
189
193
 
190
- const executeLimitedCrossBatch = async (
191
- calls: readonly ScenarioCrossCall[],
194
+ const executeLimitedComposeBatch = async (
195
+ calls: readonly ScenarioComposeCall[],
192
196
  runCall: (
193
- call: ScenarioCrossCall,
197
+ call: ScenarioComposeCall,
194
198
  branchIndex: number
195
199
  ) => Promise<Result<unknown, Error>>,
196
200
  limit: number
@@ -200,20 +204,20 @@ const executeLimitedCrossBatch = async (
200
204
 
201
205
  const runWorker = async () => {
202
206
  while (true) {
203
- const branchIndex = claimNextCrossBatchIndex(nextIndex, calls);
207
+ const branchIndex = claimNextComposeBatchIndex(nextIndex, calls);
204
208
  if (branchIndex === undefined) {
205
209
  return;
206
210
  }
207
211
 
208
212
  const call = calls[branchIndex];
209
213
  if (call === undefined) {
210
- // Defensive: `claimNextCrossBatchIndex` only returns indices within
214
+ // Defensive: `claimNextComposeBatchIndex` only returns indices within
211
215
  // bounds, so this slot should always be populated. If it ever isn't,
212
216
  // surface a clear InternalError in place of the missing slot and keep
213
217
  // the worker loop running so sibling branches still get processed.
214
218
  results[branchIndex] = R.err(
215
219
  new InternalError(
216
- `unreachable: concurrent cross batch call missing at index ${branchIndex}`
220
+ `unreachable: concurrent compose batch call missing at index ${branchIndex}`
217
221
  )
218
222
  );
219
223
  continue;
@@ -228,78 +232,81 @@ const executeLimitedCrossBatch = async (
228
232
  };
229
233
 
230
234
  /**
231
- * Build a cross function that resolves trails from the topo and executes
232
- * them through the standard pipeline. Mirrors the pattern in crosses.ts
235
+ * Build a compose function that resolves trails from the topo and executes
236
+ * them through the standard pipeline. Mirrors the pattern in composes.ts
233
237
  * `executeFromMap` but without recording or injection.
234
238
  */
235
- const createScenarioCross = (
239
+ const createScenarioCompose = (
236
240
  app: Topo,
237
241
  resources?: ResourceOverrideMap
238
- ): CrossFn => {
239
- const invokeCross = async (
240
- idOrTrail: ScenarioCrossTarget,
242
+ ): ComposeFn => {
243
+ const invokeCompose = async (
244
+ idOrTrail: ScenarioComposeTarget,
241
245
  input: unknown,
242
- self: CrossFn
246
+ self: ComposeFn
243
247
  ) => {
244
248
  const id = typeof idOrTrail === 'string' ? idOrTrail : idOrTrail.id;
245
249
  const trailDef: AnyTrail | undefined = app.get(id);
246
250
  if (trailDef === undefined) {
247
- return R.err(new InternalError(`cross: trail "${id}" not found in topo`));
251
+ return R.err(
252
+ new InternalError(`compose: trail "${id}" not found in topo`)
253
+ );
248
254
  }
249
255
  const baseCtx = createTestContext();
250
- return await executeTrail(trailDef, input, {
251
- ctx: { ...baseCtx, cross: self },
256
+ const options: TestingExecuteTrailOptions = {
257
+ ctx: { ...baseCtx, compose: self },
252
258
  resources,
253
259
  topo: app,
254
- validationSchema: buildCrossValidationSchema(trailDef),
255
- });
260
+ validationSchema: buildComposeValidationSchema(trailDef),
261
+ };
262
+ return await executeTrail(trailDef, input, options);
256
263
  };
257
264
 
258
- const executeCrossBatch = async (
259
- calls: readonly ScenarioCrossCall[],
260
- self: CrossFn,
261
- options?: CrossBatchOptions
265
+ const executeComposeBatch = async (
266
+ calls: readonly ScenarioComposeCall[],
267
+ self: ComposeFn,
268
+ options?: ComposeBatchOptions
262
269
  ): Promise<Result<unknown, Error>[]> => {
263
270
  if (calls.length === 0) {
264
271
  return [];
265
272
  }
266
273
 
267
- const concurrency = normalizeCrossBatchConcurrency(options);
274
+ const concurrency = normalizeComposeBatchConcurrency(options);
268
275
  if (concurrency.isErr()) {
269
- return createCrossBatchValidationResults(calls, concurrency.error);
276
+ return createComposeBatchValidationResults(calls, concurrency.error);
270
277
  }
271
278
 
272
279
  const runCall = async (
273
- [target, batchInput]: ScenarioCrossCall,
280
+ [target, batchInput]: ScenarioComposeCall,
274
281
  _branchIndex: number
275
- ) => await invokeCross(target, batchInput, self);
282
+ ) => await invokeCompose(target, batchInput, self);
276
283
 
277
284
  const limit = concurrency.value ?? calls.length;
278
285
  return limit >= calls.length
279
- ? await executeUnlimitedCrossBatch(calls, runCall)
280
- : await executeLimitedCrossBatch(calls, runCall, limit);
286
+ ? await executeUnlimitedComposeBatch(calls, runCall)
287
+ : await executeLimitedComposeBatch(calls, runCall, limit);
281
288
  };
282
289
 
283
- const cross = async function cross(
284
- idOrTrail: ScenarioCrossTarget | readonly ScenarioCrossCall[],
290
+ const compose = async function compose(
291
+ idOrTrail: ScenarioComposeTarget | readonly ScenarioComposeCall[],
285
292
  inputOrOptions?: unknown
286
293
  ) {
287
294
  if (Array.isArray(idOrTrail)) {
288
- return await executeCrossBatch(
295
+ return await executeComposeBatch(
289
296
  idOrTrail,
290
- cross as CrossFn,
291
- inputOrOptions as CrossBatchOptions | undefined
297
+ compose as ComposeFn,
298
+ inputOrOptions as ComposeBatchOptions | undefined
292
299
  );
293
300
  }
294
301
 
295
- return await invokeCross(
296
- idOrTrail as ScenarioCrossTarget,
302
+ return await invokeCompose(
303
+ idOrTrail as ScenarioComposeTarget,
297
304
  inputOrOptions,
298
- cross as CrossFn
305
+ compose as ComposeFn
299
306
  );
300
- } as CrossFn;
307
+ } as ComposeFn;
301
308
 
302
- return cross;
309
+ return compose;
303
310
  };
304
311
 
305
312
  /**
@@ -319,18 +326,18 @@ const executeStep = async (
319
326
  );
320
327
  }
321
328
 
322
- const scenarioCross = createScenarioCross(app, resources);
329
+ const scenarioCompose = createScenarioCompose(app, resources);
323
330
  const baseCtx = createTestContext();
324
331
  const resolvedInput = deriveRefs(step.input, outputs);
325
- const result = await executeTrail(step.cross, resolvedInput, {
326
- ctx: { ...baseCtx, cross: scenarioCross },
332
+ const result = await executeTrail(step.compose, resolvedInput, {
333
+ ctx: { ...baseCtx, compose: scenarioCompose },
327
334
  resources,
328
335
  topo: app,
329
336
  });
330
337
 
331
338
  if (result.isErr()) {
332
339
  throw new Error(
333
- `Step ${String(index + 1)} ("${step.as ?? step.cross.id}") failed: ${result.error.message}`
340
+ `Step ${String(index + 1)} ("${step.as ?? step.compose.id}") failed: ${result.error.message}`
334
341
  );
335
342
  }
336
343
 
@@ -5,11 +5,18 @@
5
5
  import { describe, expect, test } from 'bun:test';
6
6
 
7
7
  import { deriveCliPath, filterSurfaceTrails } from '@ontrails/core';
8
- import type { Topo, Trail, TrailContext, TrailExample } from '@ontrails/core';
8
+ import type {
9
+ ResourceOverrideMap,
10
+ Topo,
11
+ Trail,
12
+ TrailContext,
13
+ TrailExample,
14
+ } from '@ontrails/core';
9
15
  import { deriveHttpInputSource, deriveHttpMethod } from '@ontrails/http';
10
16
  import type { HttpMethod } from '@ontrails/http';
11
17
  import { MCP_TOOL_ERROR_META_KEY, deriveToolName } from '@ontrails/mcp';
12
18
 
19
+ import type { TestAllEstablishedOptions } from './all-established.js';
13
20
  import {
14
21
  createMockResources,
15
22
  defaultCreatePermit,
@@ -18,19 +25,45 @@ import {
18
25
  } from './context.js';
19
26
  import { deriveTrailExamples } from './effective-examples.js';
20
27
  import { createCliHarness } from './harness-cli.js';
28
+ import type { CliHarnessResult } from './harness-cli.js';
21
29
  import { createHttpHarness } from './harness-http.js';
30
+ import type { HttpHarnessResult } from './harness-http.js';
22
31
  import { createMcpHarness } from './harness-mcp.js';
23
- import type {
24
- CliHarnessResult,
25
- HttpHarnessResult,
26
- McpHarnessResult,
27
- NormalizedSurfaceParityResult,
28
- SurfaceParityExclusion,
29
- SurfaceParityOptions,
30
- } from './types.js';
32
+ import type { McpHarnessResult } from './harness-mcp.js';
31
33
 
32
34
  type ParityTrail = Trail<unknown, unknown, unknown>;
33
35
 
36
+ export type SurfaceParitySurface = 'cli' | 'mcp' | 'http';
37
+
38
+ export interface SurfaceParityExclusion {
39
+ /** Optional example name. Omit to exclude every example for the trail. */
40
+ readonly example?: string | undefined;
41
+ /** Human-readable reason shown in the skipped test name. */
42
+ readonly reason: string;
43
+ /** Trail ID to exclude. */
44
+ readonly trailId: string;
45
+ }
46
+
47
+ export interface SurfaceParityOptions extends TestAllEstablishedOptions {
48
+ readonly createResources?:
49
+ | (() => ResourceOverrideMap | Promise<ResourceOverrideMap>)
50
+ | undefined;
51
+ readonly exclusions?: readonly SurfaceParityExclusion[] | undefined;
52
+ }
53
+
54
+ export type NormalizedSurfaceParityResult =
55
+ | {
56
+ readonly ok: true;
57
+ readonly value: unknown;
58
+ }
59
+ | {
60
+ readonly error: {
61
+ readonly category: string;
62
+ readonly code: string;
63
+ };
64
+ readonly ok: false;
65
+ };
66
+
34
67
  export interface SurfaceParityComparison {
35
68
  readonly cli: NormalizedSurfaceParityResult;
36
69
  readonly http: NormalizedSurfaceParityResult;
@@ -308,7 +341,7 @@ const parityTrails = (app: Topo): readonly ParityTrail[] =>
308
341
  *
309
342
  * @example
310
343
  * ```ts
311
- * import { testSurfaceParity } from '@ontrails/testing';
344
+ * import { testSurfaceParity } from '@ontrails/testing/surface-parity';
312
345
  * import { graph } from '../src/app.js';
313
346
  *
314
347
  * testSurfaceParity(graph);