@ontrails/testing 1.0.0-beta.12 → 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/CHANGELOG.md +58 -27
- package/README.md +9 -9
- package/dist/context.d.ts +11 -11
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +23 -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.js +31 -31
- 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 +5 -5
- 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 +1 -1
- 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 +145 -144
- package/src/__tests__/trail.test.ts +4 -4
- package/src/context.ts +36 -32
- package/src/contracts.ts +12 -12
- package/src/{follows.ts → crosses.ts} +97 -92
- package/src/examples.ts +43 -43
- package/src/harness-mcp.ts +2 -2
- package/src/index.ts +6 -6
- 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,30 +26,31 @@ 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
|
|
|
@@ -69,7 +70,7 @@ export interface MintableTrail {
|
|
|
69
70
|
|
|
70
71
|
export interface TestExecutionOptions {
|
|
71
72
|
readonly ctx?: Partial<TrailContext> | undefined;
|
|
72
|
-
readonly
|
|
73
|
+
readonly provisions?: ProvisionOverrideMap | undefined;
|
|
73
74
|
/**
|
|
74
75
|
* When true, disables automatic permit minting. Tests must provide
|
|
75
76
|
* explicit permits.
|
|
@@ -87,28 +88,28 @@ export interface TestExecutionOptions {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
/**
|
|
90
|
-
* Create a mock `
|
|
91
|
+
* Create a mock `CrossFn` for testing composite trails.
|
|
91
92
|
*
|
|
92
93
|
* Returns preconfigured `Result` values keyed by trail ID. Calls to
|
|
93
94
|
* unregistered IDs return `Result.err` with a descriptive message.
|
|
94
95
|
*
|
|
95
96
|
* @example
|
|
96
97
|
* ```ts
|
|
97
|
-
* const
|
|
98
|
+
* const cross = createCrossContext({
|
|
98
99
|
* responses: { 'entity.add': Result.ok({ id: '1', name: 'Alpha' }) },
|
|
99
100
|
* });
|
|
100
|
-
* const ctx = { ...createTestContext(),
|
|
101
|
+
* const ctx = { ...createTestContext(), cross };
|
|
101
102
|
* ```
|
|
102
103
|
*/
|
|
103
|
-
export const
|
|
104
|
-
options?:
|
|
105
|
-
):
|
|
104
|
+
export const createCrossContext = (
|
|
105
|
+
options?: CreateCrossContextOptions
|
|
106
|
+
): CrossFn => {
|
|
106
107
|
const responses = options?.responses ?? {};
|
|
107
108
|
return <O>(id: string, _input: unknown): Promise<Result<O, Error>> => {
|
|
108
109
|
const response = responses[id];
|
|
109
110
|
if (response === undefined) {
|
|
110
111
|
return Promise.resolve(
|
|
111
|
-
Result.err(new Error(`No mock response for
|
|
112
|
+
Result.err(new Error(`No mock response for cross("${id}")`)) as Result<
|
|
112
113
|
O,
|
|
113
114
|
Error
|
|
114
115
|
>
|
|
@@ -136,7 +137,7 @@ const isTestExecutionOptions = (
|
|
|
136
137
|
): input is TestExecutionOptions =>
|
|
137
138
|
input !== undefined &&
|
|
138
139
|
(Object.hasOwn(input, 'ctx') ||
|
|
139
|
-
Object.hasOwn(input, '
|
|
140
|
+
Object.hasOwn(input, 'provisions') ||
|
|
140
141
|
Object.hasOwn(input, 'strictPermits') ||
|
|
141
142
|
Object.hasOwn(input, 'mintPermit'));
|
|
142
143
|
|
|
@@ -145,30 +146,32 @@ export const normalizeTestExecutionOptions = (
|
|
|
145
146
|
): TestExecutionOptions =>
|
|
146
147
|
isTestExecutionOptions(input) ? input : { ctx: input };
|
|
147
148
|
|
|
148
|
-
export const
|
|
149
|
-
autoResolved:
|
|
149
|
+
export const mergeProvisionOverrides = (
|
|
150
|
+
autoResolved: ProvisionOverrideMap,
|
|
150
151
|
ctx: Partial<TrailContext> | undefined,
|
|
151
|
-
explicit:
|
|
152
|
-
):
|
|
152
|
+
explicit: ProvisionOverrideMap | undefined
|
|
153
|
+
): ProvisionOverrideMap => ({
|
|
153
154
|
...autoResolved,
|
|
154
155
|
...ctx?.extensions,
|
|
155
156
|
...explicit,
|
|
156
157
|
});
|
|
157
158
|
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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) {
|
|
162
165
|
continue;
|
|
163
166
|
}
|
|
164
|
-
|
|
167
|
+
provisions[declaredProvision.id] = await declaredProvision.mock();
|
|
165
168
|
}
|
|
166
|
-
return
|
|
169
|
+
return provisions;
|
|
167
170
|
};
|
|
168
171
|
|
|
169
|
-
export const
|
|
172
|
+
export const resolveMockProvisions = async (
|
|
170
173
|
app: Topo
|
|
171
|
-
): Promise<
|
|
174
|
+
): Promise<ProvisionOverrideMap> => await buildMockProvisions(app);
|
|
172
175
|
|
|
173
176
|
/**
|
|
174
177
|
* Merge a Partial<TrailContext> into a test context.
|
|
@@ -176,19 +179,20 @@ export const resolveMockServices = async (
|
|
|
176
179
|
*/
|
|
177
180
|
export const mergeTestContext = (
|
|
178
181
|
ctx?: Partial<TrailContext>,
|
|
179
|
-
|
|
182
|
+
provisions?: ProvisionOverrideMap
|
|
180
183
|
): TrailContext => {
|
|
181
184
|
const base = createTestContext();
|
|
182
185
|
const extensions = {
|
|
183
186
|
...base.extensions,
|
|
184
187
|
...ctx?.extensions,
|
|
185
|
-
...
|
|
188
|
+
...provisions,
|
|
186
189
|
};
|
|
187
190
|
const merged = {
|
|
188
191
|
...base,
|
|
189
192
|
...ctx,
|
|
190
193
|
extensions: Object.keys(extensions).length === 0 ? undefined : extensions,
|
|
191
194
|
} as MutableTrailContext;
|
|
192
|
-
|
|
195
|
+
const lookup = createProvisionLookup(() => merged);
|
|
196
|
+
merged.provision = lookup;
|
|
193
197
|
return merged;
|
|
194
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
|
);
|