@ontrails/testing 1.0.0-beta.11 → 1.0.0-beta.13

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 (45) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +72 -22
  3. package/README.md +9 -9
  4. package/dist/context.d.ts +41 -11
  5. package/dist/context.d.ts.map +1 -1
  6. package/dist/context.js +36 -21
  7. package/dist/context.js.map +1 -1
  8. package/dist/contracts.js +8 -8
  9. package/dist/contracts.js.map +1 -1
  10. package/dist/crosses.d.ts +38 -0
  11. package/dist/crosses.d.ts.map +1 -0
  12. package/dist/crosses.js +213 -0
  13. package/dist/crosses.js.map +1 -0
  14. package/dist/examples.d.ts +4 -4
  15. package/dist/examples.d.ts.map +1 -1
  16. package/dist/examples.js +52 -32
  17. package/dist/examples.js.map +1 -1
  18. package/dist/harness-mcp.d.ts +1 -1
  19. package/dist/harness-mcp.js +2 -2
  20. package/dist/harness-mcp.js.map +1 -1
  21. package/dist/index.d.ts +6 -6
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +3 -3
  24. package/dist/index.js.map +1 -1
  25. package/dist/trail.js +1 -1
  26. package/dist/trail.js.map +1 -1
  27. package/dist/types.d.ts +8 -8
  28. package/dist/types.d.ts.map +1 -1
  29. package/package.json +5 -5
  30. package/src/__tests__/all.test.ts +20 -18
  31. package/src/__tests__/context.test.ts +27 -27
  32. package/src/__tests__/contracts.test.ts +29 -29
  33. package/src/__tests__/{follows.test.ts → crosses.test.ts} +193 -195
  34. package/src/__tests__/detours.test.ts +3 -3
  35. package/src/__tests__/examples.test.ts +221 -126
  36. package/src/__tests__/trail.test.ts +4 -4
  37. package/src/context.ts +80 -32
  38. package/src/contracts.ts +12 -12
  39. package/src/{follows.ts → crosses.ts} +97 -92
  40. package/src/examples.ts +76 -46
  41. package/src/harness-mcp.ts +2 -2
  42. package/src/index.ts +15 -7
  43. package/src/trail.ts +1 -1
  44. package/src/types.ts +9 -9
  45. package/tsconfig.tsbuildinfo +1 -1
package/src/context.ts CHANGED
@@ -3,12 +3,12 @@
3
3
  */
4
4
 
5
5
  import type {
6
- FollowFn,
7
- ServiceOverrideMap,
6
+ CrossFn,
7
+ ProvisionOverrideMap,
8
8
  Topo,
9
9
  TrailContext,
10
10
  } from '@ontrails/core';
11
- import { Result, createServiceLookup } from '@ontrails/core';
11
+ import { Result, createProvisionLookup } from '@ontrails/core';
12
12
 
13
13
  import { createTestLogger } from './logger.js';
14
14
  import type { TestTrailContextOptions } from './types.js';
@@ -26,61 +26,90 @@ type MutableTrailContext = {
26
26
  *
27
27
  * - `requestId`: `"test-request-001"` (deterministic)
28
28
  * - `logger`: a `TestLogger` that captures entries
29
- * - `signal`: a non-aborted AbortController signal
29
+ * - `abortSignal`: a non-aborted AbortController signal
30
30
  */
31
31
  export const createTestContext = (
32
32
  overrides?: TestTrailContextOptions
33
33
  ): TrailContext => {
34
34
  const cwd = overrides?.cwd ?? process.cwd();
35
35
  const ctx = {
36
+ abortSignal: overrides?.abortSignal ?? new AbortController().signal,
36
37
  cwd,
37
38
  env: overrides?.env ?? { TRAILS_ENV: 'test' },
38
39
  extensions: undefined,
39
40
  logger: overrides?.logger ?? createTestLogger(),
40
41
  requestId: overrides?.requestId ?? 'test-request-001',
41
- signal: overrides?.signal ?? new AbortController().signal,
42
42
  workspaceRoot: cwd,
43
43
  } as MutableTrailContext;
44
- ctx.service = createServiceLookup(() => ctx);
44
+ const lookup = createProvisionLookup(() => ctx);
45
+ ctx.provision = lookup;
45
46
  return ctx;
46
47
  };
47
48
 
48
49
  // ---------------------------------------------------------------------------
49
- // createFollowContext
50
+ // createCrossContext
50
51
  // ---------------------------------------------------------------------------
51
52
 
52
- export interface CreateFollowContextOptions {
53
+ export interface CreateCrossContextOptions {
53
54
  readonly responses?: Record<string, Result<unknown, Error>> | undefined;
54
55
  }
55
56
 
57
+ /** Minimal permit shape returned by the mint function. */
58
+ export interface MintedPermit {
59
+ readonly id: string;
60
+ readonly scopes: readonly string[];
61
+ }
62
+
63
+ /** Trail shape consumed by the mint function — avoids importing permits. */
64
+ export interface MintableTrail {
65
+ readonly permit?:
66
+ | { readonly scopes: readonly string[] }
67
+ | 'public'
68
+ | undefined;
69
+ }
70
+
56
71
  export interface TestExecutionOptions {
57
72
  readonly ctx?: Partial<TrailContext> | undefined;
58
- readonly services?: ServiceOverrideMap | undefined;
73
+ readonly provisions?: ProvisionOverrideMap | undefined;
74
+ /**
75
+ * When true, disables automatic permit minting. Tests must provide
76
+ * explicit permits.
77
+ */
78
+ readonly strictPermits?: boolean | undefined;
79
+ /**
80
+ * Optional function to mint a test permit for a trail. When provided,
81
+ * called for each trail with a non-public `permit` requirement.
82
+ * Returning `undefined` skips minting for that trail.
83
+ *
84
+ * A default inline implementation is used when this is not provided,
85
+ * keeping the testing package free of a hard dependency on `@ontrails/permits`.
86
+ */
87
+ readonly mintPermit?: (trail: MintableTrail) => MintedPermit | undefined;
59
88
  }
60
89
 
61
90
  /**
62
- * Create a mock `FollowFn` for testing composite trails.
91
+ * Create a mock `CrossFn` for testing composite trails.
63
92
  *
64
93
  * Returns preconfigured `Result` values keyed by trail ID. Calls to
65
94
  * unregistered IDs return `Result.err` with a descriptive message.
66
95
  *
67
96
  * @example
68
97
  * ```ts
69
- * const follow = createFollowContext({
98
+ * const cross = createCrossContext({
70
99
  * responses: { 'entity.add': Result.ok({ id: '1', name: 'Alpha' }) },
71
100
  * });
72
- * const ctx = { ...createTestContext(), follow };
101
+ * const ctx = { ...createTestContext(), cross };
73
102
  * ```
74
103
  */
75
- export const createFollowContext = (
76
- options?: CreateFollowContextOptions
77
- ): FollowFn => {
104
+ export const createCrossContext = (
105
+ options?: CreateCrossContextOptions
106
+ ): CrossFn => {
78
107
  const responses = options?.responses ?? {};
79
108
  return <O>(id: string, _input: unknown): Promise<Result<O, Error>> => {
80
109
  const response = responses[id];
81
110
  if (response === undefined) {
82
111
  return Promise.resolve(
83
- Result.err(new Error(`No mock response for follow("${id}")`)) as Result<
112
+ Result.err(new Error(`No mock response for cross("${id}")`)) as Result<
84
113
  O,
85
114
  Error
86
115
  >
@@ -90,41 +119,59 @@ export const createFollowContext = (
90
119
  };
91
120
  };
92
121
 
122
+ /**
123
+ * Default permit minter — reads `trail.permit.scopes` and produces a
124
+ * minimal permit object. No dependency on `@ontrails/permits`.
125
+ */
126
+ export const defaultMintPermit = (
127
+ trail: MintableTrail
128
+ ): MintedPermit | undefined => {
129
+ if (!trail.permit || trail.permit === 'public') {
130
+ return undefined;
131
+ }
132
+ return { id: 'test-permit', scopes: trail.permit.scopes };
133
+ };
134
+
93
135
  const isTestExecutionOptions = (
94
136
  input: Partial<TrailContext> | TestExecutionOptions | undefined
95
137
  ): input is TestExecutionOptions =>
96
138
  input !== undefined &&
97
- (Object.hasOwn(input, 'ctx') || Object.hasOwn(input, 'services'));
139
+ (Object.hasOwn(input, 'ctx') ||
140
+ Object.hasOwn(input, 'provisions') ||
141
+ Object.hasOwn(input, 'strictPermits') ||
142
+ Object.hasOwn(input, 'mintPermit'));
98
143
 
99
144
  export const normalizeTestExecutionOptions = (
100
145
  input?: Partial<TrailContext> | TestExecutionOptions
101
146
  ): TestExecutionOptions =>
102
147
  isTestExecutionOptions(input) ? input : { ctx: input };
103
148
 
104
- export const mergeServiceOverrides = (
105
- autoResolved: ServiceOverrideMap,
149
+ export const mergeProvisionOverrides = (
150
+ autoResolved: ProvisionOverrideMap,
106
151
  ctx: Partial<TrailContext> | undefined,
107
- explicit: ServiceOverrideMap | undefined
108
- ): ServiceOverrideMap => ({
152
+ explicit: ProvisionOverrideMap | undefined
153
+ ): ProvisionOverrideMap => ({
109
154
  ...autoResolved,
110
155
  ...ctx?.extensions,
111
156
  ...explicit,
112
157
  });
113
158
 
114
- const buildMockServices = async (app: Topo): Promise<ServiceOverrideMap> => {
115
- const services: Record<string, unknown> = {};
116
- for (const declaredService of app.listServices()) {
117
- if (!declaredService.mock) {
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) {
118
165
  continue;
119
166
  }
120
- services[declaredService.id] = await declaredService.mock();
167
+ provisions[declaredProvision.id] = await declaredProvision.mock();
121
168
  }
122
- return services;
169
+ return provisions;
123
170
  };
124
171
 
125
- export const resolveMockServices = async (
172
+ export const resolveMockProvisions = async (
126
173
  app: Topo
127
- ): Promise<ServiceOverrideMap> => await buildMockServices(app);
174
+ ): Promise<ProvisionOverrideMap> => await buildMockProvisions(app);
128
175
 
129
176
  /**
130
177
  * Merge a Partial<TrailContext> into a test context.
@@ -132,19 +179,20 @@ export const resolveMockServices = async (
132
179
  */
133
180
  export const mergeTestContext = (
134
181
  ctx?: Partial<TrailContext>,
135
- services?: ServiceOverrideMap
182
+ provisions?: ProvisionOverrideMap
136
183
  ): TrailContext => {
137
184
  const base = createTestContext();
138
185
  const extensions = {
139
186
  ...base.extensions,
140
187
  ...ctx?.extensions,
141
- ...services,
188
+ ...provisions,
142
189
  };
143
190
  const merged = {
144
191
  ...base,
145
192
  ...ctx,
146
193
  extensions: Object.keys(extensions).length === 0 ? undefined : extensions,
147
194
  } as MutableTrailContext;
148
- merged.service = createServiceLookup(() => merged);
195
+ const lookup = createProvisionLookup(() => merged);
196
+ merged.provision = lookup;
149
197
  return merged;
150
198
  };
package/src/contracts.ts CHANGED
@@ -14,10 +14,10 @@ import type { z } from 'zod';
14
14
 
15
15
  import { expectOk } from './assertions.js';
16
16
  import {
17
- mergeServiceOverrides,
17
+ mergeProvisionOverrides,
18
18
  mergeTestContext,
19
19
  normalizeTestExecutionOptions,
20
- resolveMockServices,
20
+ resolveMockProvisions,
21
21
  } from './context.js';
22
22
  import type { TestExecutionOptions } from './context.js';
23
23
 
@@ -25,16 +25,16 @@ import type { TestExecutionOptions } from './context.js';
25
25
  // Helpers
26
26
  // ---------------------------------------------------------------------------
27
27
 
28
- /** Check if a trail requires follow() but the context doesn't provide it. */
29
- const needsFollowContext = (
28
+ /** Check if a trail requires cross() but the context doesn't provide it. */
29
+ const needsCrossContext = (
30
30
  t: unknown,
31
31
  resolveCtx: () => Partial<TrailContext> | TestExecutionOptions | undefined
32
32
  ): boolean => {
33
- const spec = t as { follow?: readonly string[] };
34
- if (!spec.follow || spec.follow.length === 0) {
33
+ const spec = t as { crosses?: readonly string[] };
34
+ if (!spec.crosses || spec.crosses.length === 0) {
35
35
  return false;
36
36
  }
37
- return !normalizeTestExecutionOptions(resolveCtx()).ctx?.follow;
37
+ return !normalizeTestExecutionOptions(resolveCtx()).ctx?.cross;
38
38
  };
39
39
 
40
40
  const validateOutputSchema = (
@@ -81,7 +81,7 @@ export const testContracts = (
81
81
  if (t.examples === undefined || t.examples.length === 0) {
82
82
  return;
83
83
  }
84
- if (needsFollowContext(t, resolveInput)) {
84
+ if (needsCrossContext(t, resolveInput)) {
85
85
  return;
86
86
  }
87
87
 
@@ -92,10 +92,10 @@ export const testContracts = (
92
92
  'contract: $name',
93
93
  async (example: TrailExample<unknown, unknown>) => {
94
94
  const resolved = normalizeTestExecutionOptions(resolveInput());
95
- const services = mergeServiceOverrides(
96
- await resolveMockServices(app),
95
+ const provisions = mergeProvisionOverrides(
96
+ await resolveMockProvisions(app),
97
97
  resolved.ctx,
98
- resolved.services
98
+ resolved.provisions
99
99
  );
100
100
  const testCtx = mergeTestContext(resolved.ctx);
101
101
 
@@ -104,7 +104,7 @@ export const testContracts = (
104
104
 
105
105
  const result = await executeTrail(t, example.input, {
106
106
  ctx: testCtx,
107
- services,
107
+ provisions,
108
108
  });
109
109
  const resultValue = expectOk(result);
110
110
 
@@ -1,16 +1,16 @@
1
1
  /**
2
- * testFollowscomposition-aware scenario testing for trails with follow.
2
+ * testCrossescrossing-aware scenario testing for trails with crossings.
3
3
  *
4
- * Tests the follow graph: which trails were followed, in what order,
5
- * and supports failure injection from followed trail examples.
4
+ * Tests the crossing graph: which trails were crossed, in what order,
5
+ * and supports failure injection from crossed trail examples.
6
6
  */
7
7
 
8
8
  import { describe, expect, test } from 'bun:test';
9
9
 
10
10
  import type {
11
11
  AnyTrail,
12
- FollowFn,
13
- ServiceOverrideMap,
12
+ CrossFn,
13
+ ProvisionOverrideMap,
14
14
  TrailContext,
15
15
  } from '@ontrails/core';
16
16
  import {
@@ -26,33 +26,33 @@ import {
26
26
  assertFullMatch,
27
27
  assertSchemaMatch,
28
28
  } from './assertions.js';
29
- import { mergeServiceOverrides, mergeTestContext } from './context.js';
30
- import type { FollowScenario } from './types.js';
29
+ import { mergeProvisionOverrides, mergeTestContext } from './context.js';
30
+ import type { CrossScenario } from './types.js';
31
31
 
32
32
  // ---------------------------------------------------------------------------
33
- // Follow trace
33
+ // Cross trace
34
34
  // ---------------------------------------------------------------------------
35
35
 
36
- interface FollowRecord {
36
+ interface CrossRecord {
37
37
  readonly id: string;
38
38
  readonly input: unknown;
39
39
  }
40
40
 
41
- const collectDeclaredServices = (
41
+ const collectDeclaredProvisions = (
42
42
  trailDef: AnyTrail,
43
43
  trailsMap: ReadonlyMap<string, AnyTrail> | undefined
44
- ): AnyTrail['services'] => {
45
- const seenServiceIds = new Set<string>();
44
+ ): AnyTrail['provisions'] => {
45
+ const seenProvisionIds = new Set<string>();
46
46
  const seenTrailIds = new Set<string>();
47
- const services: AnyTrail['services'][number][] = [];
47
+ const provisions: AnyTrail['provisions'][number][] = [];
48
48
 
49
49
  const collect = (candidate: AnyTrail): void => {
50
- for (const declaredService of candidate.services) {
51
- if (seenServiceIds.has(declaredService.id)) {
50
+ for (const declaredProvision of candidate.provisions) {
51
+ if (seenProvisionIds.has(declaredProvision.id)) {
52
52
  continue;
53
53
  }
54
- seenServiceIds.add(declaredService.id);
55
- services.push(declaredService);
54
+ seenProvisionIds.add(declaredProvision.id);
55
+ provisions.push(declaredProvision);
56
56
  }
57
57
  };
58
58
 
@@ -62,32 +62,35 @@ const collectDeclaredServices = (
62
62
  }
63
63
  seenTrailIds.add(candidate.id);
64
64
  collect(candidate);
65
- for (const followedId of candidate.follow) {
66
- const followedTrail = trailsMap?.get(followedId);
67
- if (followedTrail) {
68
- visit(followedTrail);
65
+ for (const crossedId of candidate.crosses) {
66
+ const crossedTrail = trailsMap?.get(crossedId);
67
+ if (crossedTrail) {
68
+ visit(crossedTrail);
69
69
  }
70
70
  }
71
71
  };
72
72
 
73
73
  visit(trailDef);
74
- return services;
74
+ return provisions;
75
75
  };
76
76
 
77
- const resolveMockServices = async (
77
+ const resolveCrossMockProvisions = async (
78
78
  trailDef: AnyTrail,
79
79
  trailsMap: ReadonlyMap<string, AnyTrail> | undefined
80
- ): Promise<ServiceOverrideMap> => {
81
- const services: Record<string, unknown> = {};
82
-
83
- for (const declaredService of collectDeclaredServices(trailDef, trailsMap)) {
84
- if (!declaredService.mock) {
80
+ ): Promise<ProvisionOverrideMap> => {
81
+ const provisions: Record<string, unknown> = {};
82
+
83
+ for (const declaredProvision of collectDeclaredProvisions(
84
+ trailDef,
85
+ trailsMap
86
+ )) {
87
+ if (!declaredProvision.mock) {
85
88
  continue;
86
89
  }
87
- services[declaredService.id] = await declaredService.mock();
90
+ provisions[declaredProvision.id] = await declaredProvision.mock();
88
91
  }
89
92
 
90
- return services;
93
+ return provisions;
91
94
  };
92
95
 
93
96
  // ---------------------------------------------------------------------------
@@ -110,12 +113,12 @@ const findErrorExample = (
110
113
  };
111
114
 
112
115
  /**
113
- * Try to inject an error from a followed trail's example.
116
+ * Try to inject an error from a crossed trail's example.
114
117
  * Returns undefined when no injection is configured for this trail ID.
115
118
  */
116
119
  const tryInjectError = (
117
120
  id: string,
118
- scenario: FollowScenario,
121
+ scenario: CrossScenario,
119
122
  trailsMap: ReadonlyMap<string, AnyTrail> | undefined
120
123
  ): Result<unknown, Error> | undefined => {
121
124
  const injection = scenario.injectFromExample?.[id];
@@ -148,39 +151,39 @@ const executeFromMap = (
148
151
  input: unknown,
149
152
  trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
150
153
  ctx: TrailContext,
151
- services: ServiceOverrideMap | undefined,
152
- follow?: FollowFn
154
+ provisions: ProvisionOverrideMap | undefined,
155
+ cross?: CrossFn
153
156
  ): Result<unknown, Error> | Promise<Result<unknown, Error>> | undefined => {
154
157
  const trailDef = trailsMap?.get(id);
155
158
  if (trailDef === undefined) {
156
159
  return undefined;
157
160
  }
158
161
 
159
- const nestedCtx = follow ? { ...ctx, follow } : ctx;
162
+ const nestedCtx = cross ? { ...ctx, cross } : ctx;
160
163
  return executeTrail(trailDef, input, {
161
164
  ctx: nestedCtx,
162
- services,
165
+ provisions,
163
166
  });
164
167
  };
165
168
 
166
169
  // ---------------------------------------------------------------------------
167
- // Follow factory
170
+ // Cross factory
168
171
  // ---------------------------------------------------------------------------
169
172
 
170
173
  /**
171
- * Build a recording follow function that optionally injects errors.
174
+ * Build a recording cross function that optionally injects errors.
172
175
  */
173
- const createRecordingFollow = (
174
- trace: FollowRecord[],
175
- scenario: FollowScenario,
176
+ const createRecordingCross = (
177
+ trace: CrossRecord[],
178
+ scenario: CrossScenario,
176
179
  trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
177
- baseFollow: FollowFn | undefined,
180
+ baseCross: CrossFn | undefined,
178
181
  ctx: TrailContext,
179
- services: ServiceOverrideMap | undefined
180
- ): FollowFn => {
181
- // The generic O on FollowFn is erased at runtime; the cast is safe
182
+ provisions: ProvisionOverrideMap | undefined
183
+ ): CrossFn => {
184
+ // The generic O on CrossFn is erased at runtime; the cast is safe
182
185
  // because callers narrow via isOk/isErr before accessing the value.
183
- const follow = (id: string, input: unknown) => {
186
+ const cross = (id: string, input: unknown) => {
184
187
  trace.push({ id, input });
185
188
 
186
189
  const injected = tryInjectError(id, scenario, trailsMap);
@@ -188,8 +191,8 @@ const createRecordingFollow = (
188
191
  return Promise.resolve(injected);
189
192
  }
190
193
 
191
- if (baseFollow !== undefined) {
192
- return baseFollow(id, input);
194
+ if (baseCross !== undefined) {
195
+ return baseCross(id, input);
193
196
  }
194
197
 
195
198
  const executed = executeFromMap(
@@ -197,8 +200,8 @@ const createRecordingFollow = (
197
200
  input,
198
201
  trailsMap,
199
202
  ctx,
200
- services,
201
- follow as FollowFn
203
+ provisions,
204
+ cross as CrossFn
202
205
  );
203
206
  if (executed !== undefined) {
204
207
  return Promise.resolve(executed);
@@ -206,7 +209,7 @@ const createRecordingFollow = (
206
209
 
207
210
  return Promise.resolve(Result.ok());
208
211
  };
209
- return follow as FollowFn;
212
+ return cross as CrossFn;
210
213
  };
211
214
 
212
215
  // ---------------------------------------------------------------------------
@@ -215,7 +218,7 @@ const createRecordingFollow = (
215
218
 
216
219
  const assertScenarioResult = (
217
220
  result: Result<unknown, Error>,
218
- scenario: FollowScenario,
221
+ scenario: CrossScenario,
219
222
  trailDef: AnyTrail
220
223
  ): void => {
221
224
  if (scenario.expectValue !== undefined) {
@@ -233,26 +236,26 @@ const assertScenarioResult = (
233
236
  }
234
237
  };
235
238
 
236
- const assertFollowTrace = (
237
- trace: readonly FollowRecord[],
238
- scenario: FollowScenario
239
+ const assertCrossTrace = (
240
+ trace: readonly CrossRecord[],
241
+ scenario: CrossScenario
239
242
  ): void => {
240
- if (scenario.expectFollowed !== undefined) {
241
- const followedIds = trace.map((r) => r.id);
242
- expect(followedIds).toEqual([...scenario.expectFollowed]);
243
+ if (scenario.expectCrossed !== undefined) {
244
+ const crossedIds = trace.map((r) => r.id);
245
+ expect(crossedIds).toEqual([...scenario.expectCrossed]);
243
246
  }
244
- if (scenario.expectFollowedCount !== undefined) {
247
+ if (scenario.expectCrossedCount !== undefined) {
245
248
  const counts: Record<string, number> = {};
246
249
  for (const record of trace) {
247
250
  counts[record.id] = (counts[record.id] ?? 0) + 1;
248
251
  }
249
- expect(counts).toEqual({ ...scenario.expectFollowedCount });
252
+ expect(counts).toEqual({ ...scenario.expectCrossedCount });
250
253
  }
251
254
  };
252
255
 
253
256
  const handleValidationError = (
254
257
  validated: Result<unknown, Error>,
255
- scenario: FollowScenario
258
+ scenario: CrossScenario
256
259
  ): boolean => {
257
260
  if (!validated.isErr()) {
258
261
  return false;
@@ -274,30 +277,30 @@ const handleValidationError = (
274
277
  // ---------------------------------------------------------------------------
275
278
 
276
279
  const buildTestContext = (
277
- scenario: FollowScenario,
280
+ scenario: CrossScenario,
278
281
  ctx: Partial<TrailContext> | undefined,
279
282
  trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
280
- services: ServiceOverrideMap | undefined
281
- ): { trace: FollowRecord[]; testCtx: TrailContext } => {
282
- const trace: FollowRecord[] = [];
283
+ provisions: ProvisionOverrideMap | undefined
284
+ ): { trace: CrossRecord[]; testCtx: TrailContext } => {
285
+ const trace: CrossRecord[] = [];
283
286
  const baseCtx = mergeTestContext(ctx);
284
- const follow = createRecordingFollow(
287
+ const cross = createRecordingCross(
285
288
  trace,
286
289
  scenario,
287
290
  trailsMap,
288
- baseCtx.follow,
291
+ baseCtx.cross,
289
292
  baseCtx,
290
- services
293
+ provisions
291
294
  );
292
- return { testCtx: { ...baseCtx, follow }, trace };
295
+ return { testCtx: { ...baseCtx, cross }, trace };
293
296
  };
294
297
 
295
298
  const runScenario = async (
296
299
  trailDef: AnyTrail,
297
- scenario: FollowScenario,
300
+ scenario: CrossScenario,
298
301
  ctx: Partial<TrailContext> | undefined,
299
302
  trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
300
- services: ServiceOverrideMap | undefined
303
+ provisions: ProvisionOverrideMap | undefined
301
304
  ): Promise<void> => {
302
305
  const validated = validateInput(trailDef.input, scenario.input);
303
306
  if (handleValidationError(validated, scenario)) {
@@ -308,69 +311,71 @@ const runScenario = async (
308
311
  scenario,
309
312
  ctx,
310
313
  trailsMap,
311
- services
314
+ provisions
312
315
  );
313
316
  const result = await executeTrail(trailDef, scenario.input, {
314
317
  ctx: testCtx,
315
- services,
318
+ provisions,
316
319
  });
317
- assertFollowTrace(trace, scenario);
320
+ assertCrossTrace(trace, scenario);
318
321
  assertScenarioResult(result, scenario, trailDef);
319
322
  };
320
323
 
321
324
  // ---------------------------------------------------------------------------
322
- // testFollows
325
+ // testCrosses
323
326
  // ---------------------------------------------------------------------------
324
327
 
325
- /** Options for testFollows that provide trail definitions for injection. */
326
- export interface TestFollowOptions {
328
+ /** Options for testCrosses that provide trail definitions for injection. */
329
+ export interface TestCrossOptions {
327
330
  /** Partial context overrides. */
328
331
  readonly ctx?: Partial<TrailContext> | undefined;
329
332
  /**
330
- * Explicit service overrides merged on top of auto-resolved mocks for every
333
+ * Explicit provision overrides merged on top of auto-resolved mocks for every
331
334
  * scenario. Values are passed by reference — provide immutable objects, or
332
- * use `mock()` on the service definition to get a fresh instance per run.
335
+ * use `mock()` on the provision definition to get a fresh instance per run.
333
336
  */
334
- readonly services?: ServiceOverrideMap | undefined;
337
+ readonly provisions?: ProvisionOverrideMap | undefined;
335
338
  /** Map of trail ID to trail definition, used for injectFromExample. */
336
339
  readonly trails?: ReadonlyMap<string, AnyTrail> | undefined;
337
340
  }
338
341
 
339
342
  /**
340
- * Generate a describe block for a composition trail with one test per scenario.
343
+ * Generate a describe block for a trail with crossings with one test per scenario.
341
344
  *
342
345
  * @example
343
346
  * ```ts
344
- * testFollows(onboardTrail, [
347
+ * testCrosses(onboardTrail, [
345
348
  * {
346
- * description: "follows add then relate",
349
+ * description: "crosses add then relate",
347
350
  * input: { name: "Alpha" },
348
351
  * expectOk: true,
349
- * expectFollowed: ["entity.add", "entity.relate"],
352
+ * expectCrossed: ["entity.add", "entity.relate"],
350
353
  * },
351
354
  * ]);
352
355
  * ```
353
356
  */
354
- export const testFollows = (
357
+ export const testCrosses = (
355
358
  trailDef: AnyTrail,
356
- scenarios: readonly FollowScenario[],
357
- options?: TestFollowOptions
359
+ scenarios: readonly CrossScenario[],
360
+ options?: TestCrossOptions
358
361
  ): void => {
362
+ const explicitProvisions = options?.provisions;
363
+
359
364
  describe(trailDef.id, () => {
360
365
  test.each([...scenarios])(
361
366
  '$description',
362
- async (scenario: FollowScenario) => {
363
- const services = mergeServiceOverrides(
364
- await resolveMockServices(trailDef, options?.trails),
367
+ async (scenario: CrossScenario) => {
368
+ const provisions = mergeProvisionOverrides(
369
+ await resolveCrossMockProvisions(trailDef, options?.trails),
365
370
  options?.ctx,
366
- options?.services
371
+ explicitProvisions
367
372
  );
368
373
  await runScenario(
369
374
  trailDef,
370
375
  scenario,
371
376
  options?.ctx,
372
377
  options?.trails,
373
- services
378
+ provisions
374
379
  );
375
380
  }
376
381
  );