@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.
- package/CHANGELOG.md +69 -0
- package/README.md +12 -12
- package/package.json +15 -6
- package/src/all.ts +153 -16
- package/src/assertions.ts +253 -0
- package/src/context.ts +67 -38
- package/src/contracts.ts +15 -24
- package/src/crosses.ts +93 -51
- package/src/detours.ts +155 -18
- package/src/effective-examples.ts +350 -0
- package/src/examples.ts +139 -63
- package/src/harness-cli.ts +19 -11
- package/src/harness-mcp.ts +9 -8
- package/src/index.ts +14 -4
- package/src/logger.ts +3 -1
- package/src/scenario.ts +368 -0
- package/src/signals.ts +221 -0
- package/src/types.ts +64 -6
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/dist/all.d.ts +0 -31
- package/dist/all.d.ts.map +0 -1
- package/dist/all.js +0 -47
- package/dist/all.js.map +0 -1
- package/dist/assertions.d.ts +0 -49
- package/dist/assertions.d.ts.map +0 -1
- package/dist/assertions.js +0 -84
- package/dist/assertions.js.map +0 -1
- package/dist/context.d.ts +0 -75
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js +0 -107
- package/dist/context.js.map +0 -1
- package/dist/contracts.d.ts +0 -17
- package/dist/contracts.d.ts.map +0 -1
- package/dist/contracts.js +0 -71
- package/dist/contracts.js.map +0 -1
- package/dist/crosses.d.ts +0 -38
- package/dist/crosses.d.ts.map +0 -1
- package/dist/crosses.js +0 -213
- package/dist/crosses.js.map +0 -1
- package/dist/detours.d.ts +0 -12
- package/dist/detours.d.ts.map +0 -1
- package/dist/detours.js +0 -30
- package/dist/detours.js.map +0 -1
- package/dist/examples.d.ts +0 -23
- package/dist/examples.d.ts.map +0 -1
- package/dist/examples.js +0 -202
- package/dist/examples.js.map +0 -1
- package/dist/follows.d.ts +0 -38
- package/dist/follows.d.ts.map +0 -1
- package/dist/follows.js +0 -212
- package/dist/follows.js.map +0 -1
- package/dist/harness-cli.d.ts +0 -21
- package/dist/harness-cli.d.ts.map +0 -1
- package/dist/harness-cli.js +0 -200
- package/dist/harness-cli.js.map +0 -1
- package/dist/harness-mcp.d.ts +0 -21
- package/dist/harness-mcp.d.ts.map +0 -1
- package/dist/harness-mcp.js +0 -53
- package/dist/harness-mcp.js.map +0 -1
- package/dist/index.d.ts +0 -16
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -16
- package/dist/index.js.map +0 -1
- package/dist/logger.d.ts +0 -15
- package/dist/logger.d.ts.map +0 -1
- package/dist/logger.js +0 -87
- package/dist/logger.js.map +0 -1
- package/dist/trail.d.ts +0 -20
- package/dist/trail.d.ts.map +0 -1
- package/dist/trail.js +0 -80
- package/dist/trail.js.map +0 -1
- package/dist/types.d.ts +0 -80
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -5
- package/dist/types.js.map +0 -1
- package/src/__tests__/all.test.ts +0 -135
- package/src/__tests__/context.test.ts +0 -150
- package/src/__tests__/contracts.test.ts +0 -185
- package/src/__tests__/crosses.test.ts +0 -587
- package/src/__tests__/detours.test.ts +0 -55
- package/src/__tests__/examples.test.ts +0 -534
- package/src/__tests__/harness-cli.test.ts +0 -66
- package/src/__tests__/logger.test.ts +0 -136
- package/src/__tests__/trail.test.ts +0 -99
- package/tsconfig.json +0 -9
- package/tsconfig.tsbuildinfo +0 -1
package/src/context.ts
CHANGED
|
@@ -4,11 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
import type {
|
|
6
6
|
CrossFn,
|
|
7
|
-
|
|
7
|
+
ResourceOverrideMap,
|
|
8
8
|
Topo,
|
|
9
9
|
TrailContext,
|
|
10
10
|
} from '@ontrails/core';
|
|
11
|
-
import {
|
|
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 =
|
|
45
|
-
ctx.
|
|
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
|
|
58
|
-
export interface
|
|
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
|
|
64
|
-
export interface
|
|
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
|
|
79
|
+
readonly resources?: ResourceOverrideMap | undefined;
|
|
74
80
|
/**
|
|
75
|
-
* When true, disables automatic permit
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
127
|
-
trail:
|
|
128
|
-
):
|
|
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, '
|
|
165
|
+
Object.hasOwn(input, 'resources') ||
|
|
141
166
|
Object.hasOwn(input, 'strictPermits') ||
|
|
142
|
-
Object.hasOwn(input, '
|
|
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
|
|
150
|
-
autoResolved:
|
|
174
|
+
export const mergeResourceOverrides = (
|
|
175
|
+
autoResolved: ResourceOverrideMap,
|
|
151
176
|
ctx: Partial<TrailContext> | undefined,
|
|
152
|
-
explicit:
|
|
153
|
-
):
|
|
177
|
+
explicit: ResourceOverrideMap | undefined
|
|
178
|
+
): ResourceOverrideMap => ({
|
|
154
179
|
...autoResolved,
|
|
155
180
|
...ctx?.extensions,
|
|
156
181
|
...explicit,
|
|
157
182
|
});
|
|
158
183
|
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
193
|
+
resources[declaredResource.id] = await declaredResource.mock();
|
|
168
194
|
}
|
|
169
|
-
return
|
|
195
|
+
return resources;
|
|
170
196
|
};
|
|
171
197
|
|
|
172
|
-
export const
|
|
198
|
+
export const createMockResources = async (
|
|
173
199
|
app: Topo
|
|
174
|
-
): Promise<
|
|
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
|
-
|
|
211
|
+
resources?: ResourceOverrideMap
|
|
183
212
|
): TrailContext => {
|
|
184
213
|
const base = createTestContext();
|
|
185
214
|
const extensions = {
|
|
186
215
|
...base.extensions,
|
|
187
216
|
...ctx?.extensions,
|
|
188
|
-
...
|
|
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 =
|
|
196
|
-
merged.
|
|
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
|
-
|
|
17
|
+
mergeResourceOverrides,
|
|
18
18
|
mergeTestContext,
|
|
19
19
|
normalizeTestExecutionOptions,
|
|
20
|
-
|
|
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
|
|
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
|
|
96
|
-
await
|
|
85
|
+
const resources = mergeResourceOverrides(
|
|
86
|
+
await createMockResources(app),
|
|
97
87
|
resolved.ctx,
|
|
98
|
-
resolved.
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
42
|
+
const collectDeclaredResources = (
|
|
42
43
|
trailDef: AnyTrail,
|
|
43
44
|
trailsMap: ReadonlyMap<string, AnyTrail> | undefined
|
|
44
|
-
): AnyTrail['
|
|
45
|
-
const
|
|
45
|
+
): AnyTrail['resources'] => {
|
|
46
|
+
const seenResourceIds = new Set<string>();
|
|
46
47
|
const seenTrailIds = new Set<string>();
|
|
47
|
-
const
|
|
48
|
+
const resources: AnyTrail['resources'][number][] = [];
|
|
48
49
|
|
|
49
50
|
const collect = (candidate: AnyTrail): void => {
|
|
50
|
-
for (const
|
|
51
|
-
if (
|
|
51
|
+
for (const declaredResource of candidate.resources) {
|
|
52
|
+
if (seenResourceIds.has(declaredResource.id)) {
|
|
52
53
|
continue;
|
|
53
54
|
}
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
75
|
+
return resources;
|
|
75
76
|
};
|
|
76
77
|
|
|
77
|
-
const
|
|
78
|
+
const resolveCrossMockResources = async (
|
|
78
79
|
trailDef: AnyTrail,
|
|
79
80
|
trailsMap: ReadonlyMap<string, AnyTrail> | undefined
|
|
80
|
-
): Promise<
|
|
81
|
-
const
|
|
81
|
+
): Promise<ResourceOverrideMap> => {
|
|
82
|
+
const resources: Record<string, unknown> = {};
|
|
82
83
|
|
|
83
|
-
for (const
|
|
84
|
+
for (const declaredResource of collectDeclaredResources(
|
|
84
85
|
trailDef,
|
|
85
86
|
trailsMap
|
|
86
87
|
)) {
|
|
87
|
-
if (!
|
|
88
|
+
if (!declaredResource.mock) {
|
|
88
89
|
continue;
|
|
89
90
|
}
|
|
90
|
-
|
|
91
|
+
resources[declaredResource.id] = await declaredResource.mock();
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
216
|
+
return injected;
|
|
192
217
|
}
|
|
193
218
|
|
|
194
|
-
|
|
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
|
-
|
|
204
|
-
|
|
225
|
+
resources,
|
|
226
|
+
self
|
|
205
227
|
);
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
356
|
+
resources
|
|
315
357
|
);
|
|
316
358
|
const result = await executeTrail(trailDef, scenario.input, {
|
|
317
359
|
ctx: testCtx,
|
|
318
|
-
|
|
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
|
|
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
|
|
377
|
+
* use `mock()` on the resource definition to get a fresh instance per run.
|
|
336
378
|
*/
|
|
337
|
-
readonly
|
|
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
|
|
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
|
|
369
|
-
await
|
|
410
|
+
const resources = mergeResourceOverrides(
|
|
411
|
+
await resolveCrossMockResources(trailDef, options?.trails),
|
|
370
412
|
options?.ctx,
|
|
371
|
-
|
|
413
|
+
explicitResources
|
|
372
414
|
);
|
|
373
415
|
await runScenario(
|
|
374
416
|
trailDef,
|
|
375
417
|
scenario,
|
|
376
418
|
options?.ctx,
|
|
377
419
|
options?.trails,
|
|
378
|
-
|
|
420
|
+
resources
|
|
379
421
|
);
|
|
380
422
|
}
|
|
381
423
|
);
|