@ontrails/testing 1.0.0-beta.14 → 1.0.0-beta.16

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 (88) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +12 -12
  3. package/package.json +15 -6
  4. package/src/all.ts +153 -16
  5. package/src/assertions.ts +253 -0
  6. package/src/context.ts +67 -38
  7. package/src/contracts.ts +15 -24
  8. package/src/crosses.ts +93 -51
  9. package/src/detours.ts +155 -18
  10. package/src/effective-examples.ts +350 -0
  11. package/src/examples.ts +139 -63
  12. package/src/harness-cli.ts +19 -11
  13. package/src/harness-mcp.ts +9 -8
  14. package/src/index.ts +14 -4
  15. package/src/logger.ts +3 -1
  16. package/src/scenario.ts +368 -0
  17. package/src/signals.ts +221 -0
  18. package/src/types.ts +64 -6
  19. package/.turbo/turbo-build.log +0 -1
  20. package/.turbo/turbo-lint.log +0 -3
  21. package/.turbo/turbo-typecheck.log +0 -1
  22. package/dist/all.d.ts +0 -31
  23. package/dist/all.d.ts.map +0 -1
  24. package/dist/all.js +0 -47
  25. package/dist/all.js.map +0 -1
  26. package/dist/assertions.d.ts +0 -49
  27. package/dist/assertions.d.ts.map +0 -1
  28. package/dist/assertions.js +0 -84
  29. package/dist/assertions.js.map +0 -1
  30. package/dist/context.d.ts +0 -75
  31. package/dist/context.d.ts.map +0 -1
  32. package/dist/context.js +0 -107
  33. package/dist/context.js.map +0 -1
  34. package/dist/contracts.d.ts +0 -17
  35. package/dist/contracts.d.ts.map +0 -1
  36. package/dist/contracts.js +0 -71
  37. package/dist/contracts.js.map +0 -1
  38. package/dist/crosses.d.ts +0 -38
  39. package/dist/crosses.d.ts.map +0 -1
  40. package/dist/crosses.js +0 -213
  41. package/dist/crosses.js.map +0 -1
  42. package/dist/detours.d.ts +0 -12
  43. package/dist/detours.d.ts.map +0 -1
  44. package/dist/detours.js +0 -30
  45. package/dist/detours.js.map +0 -1
  46. package/dist/examples.d.ts +0 -23
  47. package/dist/examples.d.ts.map +0 -1
  48. package/dist/examples.js +0 -202
  49. package/dist/examples.js.map +0 -1
  50. package/dist/follows.d.ts +0 -38
  51. package/dist/follows.d.ts.map +0 -1
  52. package/dist/follows.js +0 -212
  53. package/dist/follows.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 -200
  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 -53
  61. package/dist/harness-mcp.js.map +0 -1
  62. package/dist/index.d.ts +0 -16
  63. package/dist/index.d.ts.map +0 -1
  64. package/dist/index.js +0 -16
  65. package/dist/index.js.map +0 -1
  66. package/dist/logger.d.ts +0 -15
  67. package/dist/logger.d.ts.map +0 -1
  68. package/dist/logger.js +0 -87
  69. package/dist/logger.js.map +0 -1
  70. package/dist/trail.d.ts +0 -20
  71. package/dist/trail.d.ts.map +0 -1
  72. package/dist/trail.js +0 -80
  73. package/dist/trail.js.map +0 -1
  74. package/dist/types.d.ts +0 -80
  75. package/dist/types.d.ts.map +0 -1
  76. package/dist/types.js +0 -5
  77. package/dist/types.js.map +0 -1
  78. package/src/__tests__/all.test.ts +0 -135
  79. package/src/__tests__/context.test.ts +0 -150
  80. package/src/__tests__/contracts.test.ts +0 -185
  81. package/src/__tests__/crosses.test.ts +0 -587
  82. package/src/__tests__/detours.test.ts +0 -55
  83. package/src/__tests__/examples.test.ts +0 -534
  84. package/src/__tests__/harness-cli.test.ts +0 -66
  85. package/src/__tests__/logger.test.ts +0 -136
  86. package/src/__tests__/trail.test.ts +0 -99
  87. package/tsconfig.json +0 -9
  88. package/tsconfig.tsbuildinfo +0 -1
package/src/context.ts CHANGED
@@ -4,11 +4,16 @@
4
4
 
5
5
  import type {
6
6
  CrossFn,
7
- ProvisionOverrideMap,
7
+ ResourceOverrideMap,
8
8
  Topo,
9
9
  TrailContext,
10
10
  } from '@ontrails/core';
11
- import { Result, createProvisionLookup } from '@ontrails/core';
11
+ import {
12
+ Result,
13
+ buildCrossValidationSchema,
14
+ createResourceLookup,
15
+ passthroughTrace,
16
+ } from '@ontrails/core';
12
17
 
13
18
  import { createTestLogger } from './logger.js';
14
19
  import type { TestTrailContextOptions } from './types.js';
@@ -39,10 +44,11 @@ export const createTestContext = (
39
44
  extensions: undefined,
40
45
  logger: overrides?.logger ?? createTestLogger(),
41
46
  requestId: overrides?.requestId ?? 'test-request-001',
47
+ trace: overrides?.trace ?? passthroughTrace,
42
48
  workspaceRoot: cwd,
43
49
  } as MutableTrailContext;
44
- const lookup = createProvisionLookup(() => ctx);
45
- ctx.provision = lookup;
50
+ const lookup = createResourceLookup(() => ctx);
51
+ ctx.resource = lookup;
46
52
  return ctx;
47
53
  };
48
54
 
@@ -54,14 +60,14 @@ export interface CreateCrossContextOptions {
54
60
  readonly responses?: Record<string, Result<unknown, Error>> | undefined;
55
61
  }
56
62
 
57
- /** Minimal permit shape returned by the mint function. */
58
- export interface MintedPermit {
63
+ /** Minimal permit shape returned by the create function. */
64
+ export interface MinimalPermit {
59
65
  readonly id: string;
60
66
  readonly scopes: readonly string[];
61
67
  }
62
68
 
63
- /** Trail shape consumed by the mint function — avoids importing permits. */
64
- export interface MintableTrail {
69
+ /** Trail shape consumed by the create function — avoids importing permits. */
70
+ export interface PermittedTrail {
65
71
  readonly permit?:
66
72
  | { readonly scopes: readonly string[] }
67
73
  | 'public'
@@ -70,21 +76,21 @@ export interface MintableTrail {
70
76
 
71
77
  export interface TestExecutionOptions {
72
78
  readonly ctx?: Partial<TrailContext> | undefined;
73
- readonly provisions?: ProvisionOverrideMap | undefined;
79
+ readonly resources?: ResourceOverrideMap | undefined;
74
80
  /**
75
- * When true, disables automatic permit minting. Tests must provide
81
+ * When true, disables automatic permit creation. Tests must provide
76
82
  * explicit permits.
77
83
  */
78
84
  readonly strictPermits?: boolean | undefined;
79
85
  /**
80
- * Optional function to mint a test permit for a trail. When provided,
86
+ * Optional function to create a test permit for a trail. When provided,
81
87
  * called for each trail with a non-public `permit` requirement.
82
- * Returning `undefined` skips minting for that trail.
88
+ * Returning `undefined` skips creation for that trail.
83
89
  *
84
90
  * A default inline implementation is used when this is not provided,
85
91
  * keeping the testing package free of a hard dependency on `@ontrails/permits`.
86
92
  */
87
- readonly mintPermit?: (trail: MintableTrail) => MintedPermit | undefined;
93
+ readonly createPermit?: (trail: PermittedTrail) => MinimalPermit | undefined;
88
94
  }
89
95
 
90
96
  /**
@@ -105,7 +111,10 @@ export const createCrossContext = (
105
111
  options?: CreateCrossContextOptions
106
112
  ): CrossFn => {
107
113
  const responses = options?.responses ?? {};
108
- return <O>(id: string, _input: unknown): Promise<Result<O, Error>> => {
114
+ const respondToCross = <O>(
115
+ idOrTrail: string | { readonly id: string }
116
+ ): Promise<Result<O, Error>> => {
117
+ const id = typeof idOrTrail === 'string' ? idOrTrail : idOrTrail.id;
109
118
  const response = responses[id];
110
119
  if (response === undefined) {
111
120
  return Promise.resolve(
@@ -117,15 +126,31 @@ export const createCrossContext = (
117
126
  }
118
127
  return Promise.resolve(response as Result<O, Error>);
119
128
  };
129
+ const cross = (async (
130
+ idOrTrail:
131
+ | string
132
+ | { readonly id: string }
133
+ | readonly (readonly [string | { readonly id: string }, unknown])[],
134
+ _input?: unknown
135
+ ) => {
136
+ if (Array.isArray(idOrTrail)) {
137
+ return await Promise.all(
138
+ idOrTrail.map(([target]) => respondToCross(target))
139
+ );
140
+ }
141
+
142
+ return await respondToCross(idOrTrail as string | { readonly id: string });
143
+ }) as CrossFn;
144
+ return cross;
120
145
  };
121
146
 
122
147
  /**
123
- * Default permit minter — reads `trail.permit.scopes` and produces a
148
+ * Default permit creator — reads `trail.permit.scopes` and produces a
124
149
  * minimal permit object. No dependency on `@ontrails/permits`.
125
150
  */
126
- export const defaultMintPermit = (
127
- trail: MintableTrail
128
- ): MintedPermit | undefined => {
151
+ export const defaultCreatePermit = (
152
+ trail: PermittedTrail
153
+ ): MinimalPermit | undefined => {
129
154
  if (!trail.permit || trail.permit === 'public') {
130
155
  return undefined;
131
156
  }
@@ -137,41 +162,45 @@ const isTestExecutionOptions = (
137
162
  ): input is TestExecutionOptions =>
138
163
  input !== undefined &&
139
164
  (Object.hasOwn(input, 'ctx') ||
140
- Object.hasOwn(input, 'provisions') ||
165
+ Object.hasOwn(input, 'resources') ||
141
166
  Object.hasOwn(input, 'strictPermits') ||
142
- Object.hasOwn(input, 'mintPermit'));
167
+ Object.hasOwn(input, 'createPermit'));
143
168
 
144
169
  export const normalizeTestExecutionOptions = (
145
170
  input?: Partial<TrailContext> | TestExecutionOptions
146
171
  ): TestExecutionOptions =>
147
172
  isTestExecutionOptions(input) ? input : { ctx: input };
148
173
 
149
- export const mergeProvisionOverrides = (
150
- autoResolved: ProvisionOverrideMap,
174
+ export const mergeResourceOverrides = (
175
+ autoResolved: ResourceOverrideMap,
151
176
  ctx: Partial<TrailContext> | undefined,
152
- explicit: ProvisionOverrideMap | undefined
153
- ): ProvisionOverrideMap => ({
177
+ explicit: ResourceOverrideMap | undefined
178
+ ): ResourceOverrideMap => ({
154
179
  ...autoResolved,
155
180
  ...ctx?.extensions,
156
181
  ...explicit,
157
182
  });
158
183
 
159
- const buildMockProvisions = async (
160
- app: Topo
161
- ): Promise<ProvisionOverrideMap> => {
162
- const provisions: Record<string, unknown> = {};
163
- for (const declaredProvision of app.listProvisions()) {
164
- if (!declaredProvision.mock) {
184
+ const buildMockResources = async (app: Topo): Promise<ResourceOverrideMap> => {
185
+ const resources: Record<string, unknown> = {};
186
+ for (const declaredResource of app.listResources()) {
187
+ if (declaredResource.unmockable !== undefined) {
188
+ continue;
189
+ }
190
+ if (!declaredResource.mock) {
165
191
  continue;
166
192
  }
167
- provisions[declaredProvision.id] = await declaredProvision.mock();
193
+ resources[declaredResource.id] = await declaredResource.mock();
168
194
  }
169
- return provisions;
195
+ return resources;
170
196
  };
171
197
 
172
- export const resolveMockProvisions = async (
198
+ export const createMockResources = async (
173
199
  app: Topo
174
- ): Promise<ProvisionOverrideMap> => await buildMockProvisions(app);
200
+ ): Promise<ResourceOverrideMap> => await buildMockResources(app);
201
+
202
+ // Re-export from core so existing consumers of this module continue to work.
203
+ export { buildCrossValidationSchema };
175
204
 
176
205
  /**
177
206
  * Merge a Partial<TrailContext> into a test context.
@@ -179,20 +208,20 @@ export const resolveMockProvisions = async (
179
208
  */
180
209
  export const mergeTestContext = (
181
210
  ctx?: Partial<TrailContext>,
182
- provisions?: ProvisionOverrideMap
211
+ resources?: ResourceOverrideMap
183
212
  ): TrailContext => {
184
213
  const base = createTestContext();
185
214
  const extensions = {
186
215
  ...base.extensions,
187
216
  ...ctx?.extensions,
188
- ...provisions,
217
+ ...resources,
189
218
  };
190
219
  const merged = {
191
220
  ...base,
192
221
  ...ctx,
193
222
  extensions: Object.keys(extensions).length === 0 ? undefined : extensions,
194
223
  } as MutableTrailContext;
195
- const lookup = createProvisionLookup(() => merged);
196
- merged.provision = lookup;
224
+ const lookup = createResourceLookup(() => merged);
225
+ merged.resource = lookup;
197
226
  return merged;
198
227
  };
package/src/contracts.ts CHANGED
@@ -14,29 +14,18 @@ import type { z } from 'zod';
14
14
 
15
15
  import { expectOk } from './assertions.js';
16
16
  import {
17
- mergeProvisionOverrides,
17
+ mergeResourceOverrides,
18
18
  mergeTestContext,
19
19
  normalizeTestExecutionOptions,
20
- resolveMockProvisions,
20
+ createMockResources,
21
21
  } from './context.js';
22
22
  import type { TestExecutionOptions } from './context.js';
23
+ import { deriveTrailExamples } from './effective-examples.js';
23
24
 
24
25
  // ---------------------------------------------------------------------------
25
26
  // Helpers
26
27
  // ---------------------------------------------------------------------------
27
28
 
28
- /** Check if a trail requires cross() but the context doesn't provide it. */
29
- const needsCrossContext = (
30
- t: unknown,
31
- resolveCtx: () => Partial<TrailContext> | TestExecutionOptions | undefined
32
- ): boolean => {
33
- const spec = t as { crosses?: readonly string[] };
34
- if (!spec.crosses || spec.crosses.length === 0) {
35
- return false;
36
- }
37
- return !normalizeTestExecutionOptions(resolveCtx()).ctx?.cross;
38
- };
39
-
40
29
  const validateOutputSchema = (
41
30
  outputSchema: z.ZodType,
42
31
  value: unknown,
@@ -71,20 +60,21 @@ export const testContracts = (
71
60
  ): void => {
72
61
  const resolveInput =
73
62
  typeof ctxOrFactory === 'function' ? ctxOrFactory : () => ctxOrFactory;
74
- const allEntries = app.list() as Trail<unknown, unknown>[];
63
+ const allEntries = (app.list() as Trail<unknown, unknown, unknown>[]).map(
64
+ (trailDef) => ({
65
+ ...trailDef,
66
+ examples: deriveTrailExamples(trailDef),
67
+ })
68
+ );
75
69
 
76
70
  describe('contracts', () => {
77
71
  describe.each(allEntries)('$id', (t) => {
78
72
  if (t.output === undefined) {
79
73
  return;
80
74
  }
81
- if (t.examples === undefined || t.examples.length === 0) {
75
+ if (t.examples.length === 0) {
82
76
  return;
83
77
  }
84
- if (needsCrossContext(t, resolveInput)) {
85
- return;
86
- }
87
-
88
78
  const { examples, output: outputSchema } = t;
89
79
  const successExamples = examples.filter((e) => e.error === undefined);
90
80
 
@@ -92,10 +82,10 @@ export const testContracts = (
92
82
  'contract: $name',
93
83
  async (example: TrailExample<unknown, unknown>) => {
94
84
  const resolved = normalizeTestExecutionOptions(resolveInput());
95
- const provisions = mergeProvisionOverrides(
96
- await resolveMockProvisions(app),
85
+ const resources = mergeResourceOverrides(
86
+ await createMockResources(app),
97
87
  resolved.ctx,
98
- resolved.provisions
88
+ resolved.resources
99
89
  );
100
90
  const testCtx = mergeTestContext(resolved.ctx);
101
91
 
@@ -104,7 +94,8 @@ export const testContracts = (
104
94
 
105
95
  const result = await executeTrail(t, example.input, {
106
96
  ctx: testCtx,
107
- provisions,
97
+ resources,
98
+ topo: app,
108
99
  });
109
100
  const resultValue = expectOk(result);
110
101
 
package/src/crosses.ts CHANGED
@@ -10,10 +10,11 @@ import { describe, expect, test } from 'bun:test';
10
10
  import type {
11
11
  AnyTrail,
12
12
  CrossFn,
13
- ProvisionOverrideMap,
13
+ ResourceOverrideMap,
14
14
  TrailContext,
15
15
  } from '@ontrails/core';
16
16
  import {
17
+ buildCrossValidationSchema,
17
18
  executeTrail,
18
19
  InternalError,
19
20
  Result,
@@ -26,7 +27,7 @@ import {
26
27
  assertFullMatch,
27
28
  assertSchemaMatch,
28
29
  } from './assertions.js';
29
- import { mergeProvisionOverrides, mergeTestContext } from './context.js';
30
+ import { mergeResourceOverrides, mergeTestContext } from './context.js';
30
31
  import type { CrossScenario } from './types.js';
31
32
 
32
33
  // ---------------------------------------------------------------------------
@@ -38,21 +39,21 @@ interface CrossRecord {
38
39
  readonly input: unknown;
39
40
  }
40
41
 
41
- const collectDeclaredProvisions = (
42
+ const collectDeclaredResources = (
42
43
  trailDef: AnyTrail,
43
44
  trailsMap: ReadonlyMap<string, AnyTrail> | undefined
44
- ): AnyTrail['provisions'] => {
45
- const seenProvisionIds = new Set<string>();
45
+ ): AnyTrail['resources'] => {
46
+ const seenResourceIds = new Set<string>();
46
47
  const seenTrailIds = new Set<string>();
47
- const provisions: AnyTrail['provisions'][number][] = [];
48
+ const resources: AnyTrail['resources'][number][] = [];
48
49
 
49
50
  const collect = (candidate: AnyTrail): void => {
50
- for (const declaredProvision of candidate.provisions) {
51
- if (seenProvisionIds.has(declaredProvision.id)) {
51
+ for (const declaredResource of candidate.resources) {
52
+ if (seenResourceIds.has(declaredResource.id)) {
52
53
  continue;
53
54
  }
54
- seenProvisionIds.add(declaredProvision.id);
55
- provisions.push(declaredProvision);
55
+ seenResourceIds.add(declaredResource.id);
56
+ resources.push(declaredResource);
56
57
  }
57
58
  };
58
59
 
@@ -71,26 +72,26 @@ const collectDeclaredProvisions = (
71
72
  };
72
73
 
73
74
  visit(trailDef);
74
- return provisions;
75
+ return resources;
75
76
  };
76
77
 
77
- const resolveCrossMockProvisions = async (
78
+ const resolveCrossMockResources = async (
78
79
  trailDef: AnyTrail,
79
80
  trailsMap: ReadonlyMap<string, AnyTrail> | undefined
80
- ): Promise<ProvisionOverrideMap> => {
81
- const provisions: Record<string, unknown> = {};
81
+ ): Promise<ResourceOverrideMap> => {
82
+ const resources: Record<string, unknown> = {};
82
83
 
83
- for (const declaredProvision of collectDeclaredProvisions(
84
+ for (const declaredResource of collectDeclaredResources(
84
85
  trailDef,
85
86
  trailsMap
86
87
  )) {
87
- if (!declaredProvision.mock) {
88
+ if (!declaredResource.mock) {
88
89
  continue;
89
90
  }
90
- provisions[declaredProvision.id] = await declaredProvision.mock();
91
+ resources[declaredResource.id] = await declaredResource.mock();
91
92
  }
92
93
 
93
- return provisions;
94
+ return resources;
94
95
  };
95
96
 
96
97
  // ---------------------------------------------------------------------------
@@ -143,15 +144,12 @@ const tryInjectError = (
143
144
  return Result.err(new Error(errorName));
144
145
  };
145
146
 
146
- /**
147
- * Execute a trail from the map, validating input first.
148
- */
149
147
  const executeFromMap = (
150
148
  id: string,
151
149
  input: unknown,
152
150
  trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
153
151
  ctx: TrailContext,
154
- provisions: ProvisionOverrideMap | undefined,
152
+ resources: ResourceOverrideMap | undefined,
155
153
  cross?: CrossFn
156
154
  ): Result<unknown, Error> | Promise<Result<unknown, Error>> | undefined => {
157
155
  const trailDef = trailsMap?.get(id);
@@ -162,14 +160,36 @@ const executeFromMap = (
162
160
  const nestedCtx = cross ? { ...ctx, cross } : ctx;
163
161
  return executeTrail(trailDef, input, {
164
162
  ctx: nestedCtx,
165
- provisions,
163
+ resources,
164
+ validationSchema: buildCrossValidationSchema(trailDef),
166
165
  });
167
166
  };
168
167
 
168
+ /** Extract trail ID from either a trail object or a string. */
169
+ const resolveCrossId = (idOrTrail: string | { readonly id: string }): string =>
170
+ typeof idOrTrail === 'string' ? idOrTrail : idOrTrail.id;
171
+
169
172
  // ---------------------------------------------------------------------------
170
173
  // Cross factory
171
174
  // ---------------------------------------------------------------------------
172
175
 
176
+ /** Delegate to baseCross, executeFromMap, or fall back to Result.ok(). */
177
+ const delegateCross = (
178
+ id: string,
179
+ input: unknown,
180
+ baseCross: CrossFn | undefined,
181
+ trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
182
+ ctx: TrailContext,
183
+ resources: ResourceOverrideMap | undefined,
184
+ self: CrossFn
185
+ ): Promise<Result<unknown, Error>> => {
186
+ if (baseCross !== undefined) {
187
+ return baseCross(id, input);
188
+ }
189
+ const executed = executeFromMap(id, input, trailsMap, ctx, resources, self);
190
+ return Promise.resolve(executed ?? Result.ok());
191
+ };
192
+
173
193
  /**
174
194
  * Build a recording cross function that optionally injects errors.
175
195
  */
@@ -179,37 +199,59 @@ const createRecordingCross = (
179
199
  trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
180
200
  baseCross: CrossFn | undefined,
181
201
  ctx: TrailContext,
182
- provisions: ProvisionOverrideMap | undefined
202
+ resources: ResourceOverrideMap | undefined
183
203
  ): CrossFn => {
184
204
  // The generic O on CrossFn is erased at runtime; the cast is safe
185
205
  // because callers narrow via isOk/isErr before accessing the value.
186
- const cross = (id: string, input: unknown) => {
206
+ const invokeCross = async (
207
+ idOrTrail: string | { readonly id: string },
208
+ input: unknown,
209
+ self: CrossFn
210
+ ) => {
211
+ const id = resolveCrossId(idOrTrail);
187
212
  trace.push({ id, input });
188
213
 
189
214
  const injected = tryInjectError(id, scenario, trailsMap);
190
215
  if (injected !== undefined) {
191
- return Promise.resolve(injected);
216
+ return injected;
192
217
  }
193
218
 
194
- if (baseCross !== undefined) {
195
- return baseCross(id, input);
196
- }
197
-
198
- const executed = executeFromMap(
219
+ return await delegateCross(
199
220
  id,
200
221
  input,
222
+ baseCross,
201
223
  trailsMap,
202
224
  ctx,
203
- provisions,
204
- cross as CrossFn
225
+ resources,
226
+ self
205
227
  );
206
- if (executed !== undefined) {
207
- return Promise.resolve(executed);
228
+ };
229
+
230
+ // Accepts either a trail object (typed cross), a string id (untyped),
231
+ // or a batch of `[target, input]` tuples.
232
+ const cross = async function cross(
233
+ idOrTrail:
234
+ | string
235
+ | { readonly id: string }
236
+ | readonly (readonly [string | { readonly id: string }, unknown])[],
237
+ input?: unknown
238
+ ) {
239
+ if (Array.isArray(idOrTrail)) {
240
+ return await Promise.all(
241
+ idOrTrail.map(([target, batchInput]) =>
242
+ invokeCross(target, batchInput, cross as CrossFn)
243
+ )
244
+ );
208
245
  }
209
246
 
210
- return Promise.resolve(Result.ok());
211
- };
212
- return cross as CrossFn;
247
+ return await invokeCross(
248
+ idOrTrail as string | { readonly id: string },
249
+ input,
250
+ cross as CrossFn
251
+ );
252
+ } as CrossFn;
253
+
254
+ return cross;
213
255
  };
214
256
 
215
257
  // ---------------------------------------------------------------------------
@@ -280,7 +322,7 @@ const buildTestContext = (
280
322
  scenario: CrossScenario,
281
323
  ctx: Partial<TrailContext> | undefined,
282
324
  trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
283
- provisions: ProvisionOverrideMap | undefined
325
+ resources: ResourceOverrideMap | undefined
284
326
  ): { trace: CrossRecord[]; testCtx: TrailContext } => {
285
327
  const trace: CrossRecord[] = [];
286
328
  const baseCtx = mergeTestContext(ctx);
@@ -290,7 +332,7 @@ const buildTestContext = (
290
332
  trailsMap,
291
333
  baseCtx.cross,
292
334
  baseCtx,
293
- provisions
335
+ resources
294
336
  );
295
337
  return { testCtx: { ...baseCtx, cross }, trace };
296
338
  };
@@ -300,7 +342,7 @@ const runScenario = async (
300
342
  scenario: CrossScenario,
301
343
  ctx: Partial<TrailContext> | undefined,
302
344
  trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
303
- provisions: ProvisionOverrideMap | undefined
345
+ resources: ResourceOverrideMap | undefined
304
346
  ): Promise<void> => {
305
347
  const validated = validateInput(trailDef.input, scenario.input);
306
348
  if (handleValidationError(validated, scenario)) {
@@ -311,11 +353,11 @@ const runScenario = async (
311
353
  scenario,
312
354
  ctx,
313
355
  trailsMap,
314
- provisions
356
+ resources
315
357
  );
316
358
  const result = await executeTrail(trailDef, scenario.input, {
317
359
  ctx: testCtx,
318
- provisions,
360
+ resources,
319
361
  });
320
362
  assertCrossTrace(trace, scenario);
321
363
  assertScenarioResult(result, scenario, trailDef);
@@ -330,11 +372,11 @@ export interface TestCrossOptions {
330
372
  /** Partial context overrides. */
331
373
  readonly ctx?: Partial<TrailContext> | undefined;
332
374
  /**
333
- * Explicit provision overrides merged on top of auto-resolved mocks for every
375
+ * Explicit resource overrides merged on top of auto-resolved mocks for every
334
376
  * scenario. Values are passed by reference — provide immutable objects, or
335
- * use `mock()` on the provision definition to get a fresh instance per run.
377
+ * use `mock()` on the resource definition to get a fresh instance per run.
336
378
  */
337
- readonly provisions?: ProvisionOverrideMap | undefined;
379
+ readonly resources?: ResourceOverrideMap | undefined;
338
380
  /** Map of trail ID to trail definition, used for injectFromExample. */
339
381
  readonly trails?: ReadonlyMap<string, AnyTrail> | undefined;
340
382
  }
@@ -359,23 +401,23 @@ export const testCrosses = (
359
401
  scenarios: readonly CrossScenario[],
360
402
  options?: TestCrossOptions
361
403
  ): void => {
362
- const explicitProvisions = options?.provisions;
404
+ const explicitResources = options?.resources;
363
405
 
364
406
  describe(trailDef.id, () => {
365
407
  test.each([...scenarios])(
366
408
  '$description',
367
409
  async (scenario: CrossScenario) => {
368
- const provisions = mergeProvisionOverrides(
369
- await resolveCrossMockProvisions(trailDef, options?.trails),
410
+ const resources = mergeResourceOverrides(
411
+ await resolveCrossMockResources(trailDef, options?.trails),
370
412
  options?.ctx,
371
- explicitProvisions
413
+ explicitResources
372
414
  );
373
415
  await runScenario(
374
416
  trailDef,
375
417
  scenario,
376
418
  options?.ctx,
377
419
  options?.trails,
378
- provisions
420
+ resources
379
421
  );
380
422
  }
381
423
  );