@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.
- package/.turbo/turbo-lint.log +1 -1
- package/CHANGELOG.md +72 -22
- package/README.md +9 -9
- package/dist/context.d.ts +41 -11
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +36 -21
- package/dist/context.js.map +1 -1
- package/dist/contracts.js +8 -8
- package/dist/contracts.js.map +1 -1
- package/dist/crosses.d.ts +38 -0
- package/dist/crosses.d.ts.map +1 -0
- package/dist/crosses.js +213 -0
- package/dist/crosses.js.map +1 -0
- package/dist/examples.d.ts +4 -4
- package/dist/examples.d.ts.map +1 -1
- package/dist/examples.js +52 -32
- package/dist/examples.js.map +1 -1
- package/dist/harness-mcp.d.ts +1 -1
- package/dist/harness-mcp.js +2 -2
- package/dist/harness-mcp.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/trail.js +1 -1
- package/dist/trail.js.map +1 -1
- package/dist/types.d.ts +8 -8
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/all.test.ts +20 -18
- package/src/__tests__/context.test.ts +27 -27
- package/src/__tests__/contracts.test.ts +29 -29
- package/src/__tests__/{follows.test.ts → crosses.test.ts} +193 -195
- package/src/__tests__/detours.test.ts +3 -3
- package/src/__tests__/examples.test.ts +221 -126
- package/src/__tests__/trail.test.ts +4 -4
- package/src/context.ts +80 -32
- package/src/contracts.ts +12 -12
- package/src/{follows.ts → crosses.ts} +97 -92
- package/src/examples.ts +76 -46
- package/src/harness-mcp.ts +2 -2
- package/src/index.ts +15 -7
- package/src/trail.ts +1 -1
- package/src/types.ts +9 -9
- package/tsconfig.tsbuildinfo +1 -1
package/src/context.ts
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
CrossFn,
|
|
7
|
+
ProvisionOverrideMap,
|
|
8
8
|
Topo,
|
|
9
9
|
TrailContext,
|
|
10
10
|
} from '@ontrails/core';
|
|
11
|
-
import { Result,
|
|
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
|
-
* - `
|
|
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
|
-
|
|
44
|
+
const lookup = createProvisionLookup(() => ctx);
|
|
45
|
+
ctx.provision = lookup;
|
|
45
46
|
return ctx;
|
|
46
47
|
};
|
|
47
48
|
|
|
48
49
|
// ---------------------------------------------------------------------------
|
|
49
|
-
//
|
|
50
|
+
// createCrossContext
|
|
50
51
|
// ---------------------------------------------------------------------------
|
|
51
52
|
|
|
52
|
-
export interface
|
|
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
|
|
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 `
|
|
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
|
|
98
|
+
* const cross = createCrossContext({
|
|
70
99
|
* responses: { 'entity.add': Result.ok({ id: '1', name: 'Alpha' }) },
|
|
71
100
|
* });
|
|
72
|
-
* const ctx = { ...createTestContext(),
|
|
101
|
+
* const ctx = { ...createTestContext(), cross };
|
|
73
102
|
* ```
|
|
74
103
|
*/
|
|
75
|
-
export const
|
|
76
|
-
options?:
|
|
77
|
-
):
|
|
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
|
|
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') ||
|
|
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
|
|
105
|
-
autoResolved:
|
|
149
|
+
export const mergeProvisionOverrides = (
|
|
150
|
+
autoResolved: ProvisionOverrideMap,
|
|
106
151
|
ctx: Partial<TrailContext> | undefined,
|
|
107
|
-
explicit:
|
|
108
|
-
):
|
|
152
|
+
explicit: ProvisionOverrideMap | undefined
|
|
153
|
+
): ProvisionOverrideMap => ({
|
|
109
154
|
...autoResolved,
|
|
110
155
|
...ctx?.extensions,
|
|
111
156
|
...explicit,
|
|
112
157
|
});
|
|
113
158
|
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
167
|
+
provisions[declaredProvision.id] = await declaredProvision.mock();
|
|
121
168
|
}
|
|
122
|
-
return
|
|
169
|
+
return provisions;
|
|
123
170
|
};
|
|
124
171
|
|
|
125
|
-
export const
|
|
172
|
+
export const resolveMockProvisions = async (
|
|
126
173
|
app: Topo
|
|
127
|
-
): Promise<
|
|
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
|
-
|
|
182
|
+
provisions?: ProvisionOverrideMap
|
|
136
183
|
): TrailContext => {
|
|
137
184
|
const base = createTestContext();
|
|
138
185
|
const extensions = {
|
|
139
186
|
...base.extensions,
|
|
140
187
|
...ctx?.extensions,
|
|
141
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
17
|
+
mergeProvisionOverrides,
|
|
18
18
|
mergeTestContext,
|
|
19
19
|
normalizeTestExecutionOptions,
|
|
20
|
-
|
|
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
|
|
29
|
-
const
|
|
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 {
|
|
34
|
-
if (!spec.
|
|
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?.
|
|
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 (
|
|
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
|
|
96
|
-
await
|
|
95
|
+
const provisions = mergeProvisionOverrides(
|
|
96
|
+
await resolveMockProvisions(app),
|
|
97
97
|
resolved.ctx,
|
|
98
|
-
resolved.
|
|
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
|
-
|
|
107
|
+
provisions,
|
|
108
108
|
});
|
|
109
109
|
const resultValue = expectOk(result);
|
|
110
110
|
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* testCrosses — crossing-aware scenario testing for trails with crossings.
|
|
3
3
|
*
|
|
4
|
-
* Tests the
|
|
5
|
-
* and supports failure injection from
|
|
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
|
-
|
|
13
|
-
|
|
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 {
|
|
30
|
-
import type {
|
|
29
|
+
import { mergeProvisionOverrides, mergeTestContext } from './context.js';
|
|
30
|
+
import type { CrossScenario } from './types.js';
|
|
31
31
|
|
|
32
32
|
// ---------------------------------------------------------------------------
|
|
33
|
-
//
|
|
33
|
+
// Cross trace
|
|
34
34
|
// ---------------------------------------------------------------------------
|
|
35
35
|
|
|
36
|
-
interface
|
|
36
|
+
interface CrossRecord {
|
|
37
37
|
readonly id: string;
|
|
38
38
|
readonly input: unknown;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
const
|
|
41
|
+
const collectDeclaredProvisions = (
|
|
42
42
|
trailDef: AnyTrail,
|
|
43
43
|
trailsMap: ReadonlyMap<string, AnyTrail> | undefined
|
|
44
|
-
): AnyTrail['
|
|
45
|
-
const
|
|
44
|
+
): AnyTrail['provisions'] => {
|
|
45
|
+
const seenProvisionIds = new Set<string>();
|
|
46
46
|
const seenTrailIds = new Set<string>();
|
|
47
|
-
const
|
|
47
|
+
const provisions: AnyTrail['provisions'][number][] = [];
|
|
48
48
|
|
|
49
49
|
const collect = (candidate: AnyTrail): void => {
|
|
50
|
-
for (const
|
|
51
|
-
if (
|
|
50
|
+
for (const declaredProvision of candidate.provisions) {
|
|
51
|
+
if (seenProvisionIds.has(declaredProvision.id)) {
|
|
52
52
|
continue;
|
|
53
53
|
}
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
66
|
-
const
|
|
67
|
-
if (
|
|
68
|
-
visit(
|
|
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
|
|
74
|
+
return provisions;
|
|
75
75
|
};
|
|
76
76
|
|
|
77
|
-
const
|
|
77
|
+
const resolveCrossMockProvisions = async (
|
|
78
78
|
trailDef: AnyTrail,
|
|
79
79
|
trailsMap: ReadonlyMap<string, AnyTrail> | undefined
|
|
80
|
-
): Promise<
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
for (const
|
|
84
|
-
|
|
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
|
-
|
|
90
|
+
provisions[declaredProvision.id] = await declaredProvision.mock();
|
|
88
91
|
}
|
|
89
92
|
|
|
90
|
-
return
|
|
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
|
|
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:
|
|
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
|
-
|
|
152
|
-
|
|
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 =
|
|
162
|
+
const nestedCtx = cross ? { ...ctx, cross } : ctx;
|
|
160
163
|
return executeTrail(trailDef, input, {
|
|
161
164
|
ctx: nestedCtx,
|
|
162
|
-
|
|
165
|
+
provisions,
|
|
163
166
|
});
|
|
164
167
|
};
|
|
165
168
|
|
|
166
169
|
// ---------------------------------------------------------------------------
|
|
167
|
-
//
|
|
170
|
+
// Cross factory
|
|
168
171
|
// ---------------------------------------------------------------------------
|
|
169
172
|
|
|
170
173
|
/**
|
|
171
|
-
* Build a recording
|
|
174
|
+
* Build a recording cross function that optionally injects errors.
|
|
172
175
|
*/
|
|
173
|
-
const
|
|
174
|
-
trace:
|
|
175
|
-
scenario:
|
|
176
|
+
const createRecordingCross = (
|
|
177
|
+
trace: CrossRecord[],
|
|
178
|
+
scenario: CrossScenario,
|
|
176
179
|
trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
|
|
177
|
-
|
|
180
|
+
baseCross: CrossFn | undefined,
|
|
178
181
|
ctx: TrailContext,
|
|
179
|
-
|
|
180
|
-
):
|
|
181
|
-
// The generic O on
|
|
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
|
|
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 (
|
|
192
|
-
return
|
|
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
|
-
|
|
201
|
-
|
|
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
|
|
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:
|
|
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
|
|
237
|
-
trace: readonly
|
|
238
|
-
scenario:
|
|
239
|
+
const assertCrossTrace = (
|
|
240
|
+
trace: readonly CrossRecord[],
|
|
241
|
+
scenario: CrossScenario
|
|
239
242
|
): void => {
|
|
240
|
-
if (scenario.
|
|
241
|
-
const
|
|
242
|
-
expect(
|
|
243
|
+
if (scenario.expectCrossed !== undefined) {
|
|
244
|
+
const crossedIds = trace.map((r) => r.id);
|
|
245
|
+
expect(crossedIds).toEqual([...scenario.expectCrossed]);
|
|
243
246
|
}
|
|
244
|
-
if (scenario.
|
|
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.
|
|
252
|
+
expect(counts).toEqual({ ...scenario.expectCrossedCount });
|
|
250
253
|
}
|
|
251
254
|
};
|
|
252
255
|
|
|
253
256
|
const handleValidationError = (
|
|
254
257
|
validated: Result<unknown, Error>,
|
|
255
|
-
scenario:
|
|
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:
|
|
280
|
+
scenario: CrossScenario,
|
|
278
281
|
ctx: Partial<TrailContext> | undefined,
|
|
279
282
|
trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
|
|
280
|
-
|
|
281
|
-
): { trace:
|
|
282
|
-
const trace:
|
|
283
|
+
provisions: ProvisionOverrideMap | undefined
|
|
284
|
+
): { trace: CrossRecord[]; testCtx: TrailContext } => {
|
|
285
|
+
const trace: CrossRecord[] = [];
|
|
283
286
|
const baseCtx = mergeTestContext(ctx);
|
|
284
|
-
const
|
|
287
|
+
const cross = createRecordingCross(
|
|
285
288
|
trace,
|
|
286
289
|
scenario,
|
|
287
290
|
trailsMap,
|
|
288
|
-
baseCtx.
|
|
291
|
+
baseCtx.cross,
|
|
289
292
|
baseCtx,
|
|
290
|
-
|
|
293
|
+
provisions
|
|
291
294
|
);
|
|
292
|
-
return { testCtx: { ...baseCtx,
|
|
295
|
+
return { testCtx: { ...baseCtx, cross }, trace };
|
|
293
296
|
};
|
|
294
297
|
|
|
295
298
|
const runScenario = async (
|
|
296
299
|
trailDef: AnyTrail,
|
|
297
|
-
scenario:
|
|
300
|
+
scenario: CrossScenario,
|
|
298
301
|
ctx: Partial<TrailContext> | undefined,
|
|
299
302
|
trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
|
|
300
|
-
|
|
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
|
-
|
|
314
|
+
provisions
|
|
312
315
|
);
|
|
313
316
|
const result = await executeTrail(trailDef, scenario.input, {
|
|
314
317
|
ctx: testCtx,
|
|
315
|
-
|
|
318
|
+
provisions,
|
|
316
319
|
});
|
|
317
|
-
|
|
320
|
+
assertCrossTrace(trace, scenario);
|
|
318
321
|
assertScenarioResult(result, scenario, trailDef);
|
|
319
322
|
};
|
|
320
323
|
|
|
321
324
|
// ---------------------------------------------------------------------------
|
|
322
|
-
//
|
|
325
|
+
// testCrosses
|
|
323
326
|
// ---------------------------------------------------------------------------
|
|
324
327
|
|
|
325
|
-
/** Options for
|
|
326
|
-
export interface
|
|
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
|
|
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
|
|
335
|
+
* use `mock()` on the provision definition to get a fresh instance per run.
|
|
333
336
|
*/
|
|
334
|
-
readonly
|
|
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
|
|
343
|
+
* Generate a describe block for a trail with crossings with one test per scenario.
|
|
341
344
|
*
|
|
342
345
|
* @example
|
|
343
346
|
* ```ts
|
|
344
|
-
*
|
|
347
|
+
* testCrosses(onboardTrail, [
|
|
345
348
|
* {
|
|
346
|
-
* description: "
|
|
349
|
+
* description: "crosses add then relate",
|
|
347
350
|
* input: { name: "Alpha" },
|
|
348
351
|
* expectOk: true,
|
|
349
|
-
*
|
|
352
|
+
* expectCrossed: ["entity.add", "entity.relate"],
|
|
350
353
|
* },
|
|
351
354
|
* ]);
|
|
352
355
|
* ```
|
|
353
356
|
*/
|
|
354
|
-
export const
|
|
357
|
+
export const testCrosses = (
|
|
355
358
|
trailDef: AnyTrail,
|
|
356
|
-
scenarios: readonly
|
|
357
|
-
options?:
|
|
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:
|
|
363
|
-
const
|
|
364
|
-
await
|
|
367
|
+
async (scenario: CrossScenario) => {
|
|
368
|
+
const provisions = mergeProvisionOverrides(
|
|
369
|
+
await resolveCrossMockProvisions(trailDef, options?.trails),
|
|
365
370
|
options?.ctx,
|
|
366
|
-
|
|
371
|
+
explicitProvisions
|
|
367
372
|
);
|
|
368
373
|
await runScenario(
|
|
369
374
|
trailDef,
|
|
370
375
|
scenario,
|
|
371
376
|
options?.ctx,
|
|
372
377
|
options?.trails,
|
|
373
|
-
|
|
378
|
+
provisions
|
|
374
379
|
);
|
|
375
380
|
}
|
|
376
381
|
);
|