@ontrails/testing 1.0.0-beta.13 → 1.0.0-beta.15
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 +28 -0
- package/README.md +11 -11
- package/dist/all.d.ts +10 -5
- package/dist/all.d.ts.map +1 -1
- package/dist/all.js +78 -26
- package/dist/all.js.map +1 -1
- package/dist/assertions.d.ts +23 -0
- package/dist/assertions.d.ts.map +1 -1
- package/dist/assertions.js +154 -0
- package/dist/assertions.js.map +1 -1
- package/dist/context.d.ts +17 -16
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +31 -20
- package/dist/context.js.map +1 -1
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +9 -5
- package/dist/contracts.js.map +1 -1
- package/dist/crosses.d.ts +4 -4
- package/dist/crosses.d.ts.map +1 -1
- package/dist/crosses.js +49 -39
- package/dist/crosses.js.map +1 -1
- package/dist/detours.d.ts +5 -4
- package/dist/detours.d.ts.map +1 -1
- package/dist/detours.js +93 -14
- package/dist/detours.js.map +1 -1
- package/dist/effective-examples.d.ts +30 -0
- package/dist/effective-examples.d.ts.map +1 -0
- package/dist/effective-examples.js +227 -0
- package/dist/effective-examples.js.map +1 -0
- package/dist/examples.d.ts +1 -1
- package/dist/examples.d.ts.map +1 -1
- package/dist/examples.js +79 -41
- package/dist/examples.js.map +1 -1
- package/dist/harness-cli.d.ts +3 -3
- package/dist/harness-cli.d.ts.map +1 -1
- package/dist/harness-cli.js +25 -33
- package/dist/harness-cli.js.map +1 -1
- package/dist/harness-mcp.d.ts +3 -3
- package/dist/harness-mcp.d.ts.map +1 -1
- package/dist/harness-mcp.js +9 -8
- package/dist/harness-mcp.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/scenario.d.ts +37 -0
- package/dist/scenario.d.ts.map +1 -0
- package/dist/scenario.js +235 -0
- package/dist/scenario.js.map +1 -0
- package/dist/types.d.ts +38 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +9 -5
- package/src/__tests__/all.test.ts +217 -29
- package/src/__tests__/context.test.ts +32 -12
- package/src/__tests__/contracts.test.ts +72 -18
- package/src/__tests__/crosses.test.ts +78 -78
- package/src/__tests__/detours.test.ts +176 -19
- package/src/__tests__/effective-examples.test.ts +203 -0
- package/src/__tests__/examples.test.ts +152 -50
- package/src/__tests__/harness-cli.test.ts +90 -0
- package/src/__tests__/harness-mcp.test.ts +37 -0
- package/src/__tests__/partial-match.test.ts +126 -0
- package/src/__tests__/scenario.test.ts +381 -0
- package/src/all.ts +149 -12
- package/src/assertions.ts +253 -0
- package/src/context.ts +64 -38
- package/src/contracts.ts +14 -8
- package/src/crosses.ts +93 -51
- package/src/detours.ts +155 -18
- package/src/effective-examples.ts +350 -0
- package/src/examples.ts +127 -59
- package/src/harness-cli.ts +33 -49
- package/src/harness-mcp.ts +9 -8
- package/src/index.ts +13 -3
- package/src/scenario.ts +370 -0
- package/src/types.ts +63 -5
- package/tsconfig.tests.json +10 -0
- package/tsconfig.tsbuildinfo +1 -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/src/assertions.ts
CHANGED
|
@@ -49,6 +49,40 @@ export const expectErr = <T, E>(result: Result<T, E>): E => {
|
|
|
49
49
|
return (result as unknown as { error: E }).error;
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Result Match Tokens
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
export interface OkResultMatch {
|
|
57
|
+
readonly __resultMatch: 'ok';
|
|
58
|
+
readonly value?: unknown | undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ErrResultMatch {
|
|
62
|
+
readonly __resultMatch: 'err';
|
|
63
|
+
readonly error?: unknown | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type ResultMatchToken = OkResultMatch | ErrResultMatch;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create a partial-match token for `Result.ok(...)` values nested inside
|
|
70
|
+
* arrays or objects, such as the `Result[]` returned by batch `ctx.cross()`.
|
|
71
|
+
*/
|
|
72
|
+
export const okResultMatch = (value?: unknown): OkResultMatch => ({
|
|
73
|
+
__resultMatch: 'ok',
|
|
74
|
+
value,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Create a partial-match token for `Result.err(...)` values nested inside
|
|
79
|
+
* arrays or objects, such as mixed-success batch `ctx.cross()`.
|
|
80
|
+
*/
|
|
81
|
+
export const errResultMatch = (error?: unknown): ErrResultMatch => ({
|
|
82
|
+
__resultMatch: 'err',
|
|
83
|
+
error,
|
|
84
|
+
});
|
|
85
|
+
|
|
52
86
|
// ---------------------------------------------------------------------------
|
|
53
87
|
// Full Match
|
|
54
88
|
// ---------------------------------------------------------------------------
|
|
@@ -87,6 +121,225 @@ export const assertSchemaMatch = (
|
|
|
87
121
|
}
|
|
88
122
|
};
|
|
89
123
|
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Partial Match
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/** Format a path for error messages. */
|
|
129
|
+
const formatLoc = (path: readonly string[]): string =>
|
|
130
|
+
path.length > 0 ? path.join('.') : 'root';
|
|
131
|
+
|
|
132
|
+
interface ResultLike {
|
|
133
|
+
readonly error?: unknown;
|
|
134
|
+
isErr(): boolean;
|
|
135
|
+
isOk(): boolean;
|
|
136
|
+
readonly value?: unknown;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const isResultMatchToken = (value: unknown): value is ResultMatchToken =>
|
|
140
|
+
typeof value === 'object' &&
|
|
141
|
+
value !== null &&
|
|
142
|
+
'__resultMatch' in value &&
|
|
143
|
+
((value as Record<string, unknown>)['__resultMatch'] === 'ok' ||
|
|
144
|
+
(value as Record<string, unknown>)['__resultMatch'] === 'err');
|
|
145
|
+
|
|
146
|
+
const isResultLike = (value: unknown): value is ResultLike =>
|
|
147
|
+
typeof value === 'object' &&
|
|
148
|
+
value !== null &&
|
|
149
|
+
'isOk' in value &&
|
|
150
|
+
typeof (value as Record<string, unknown>)['isOk'] === 'function' &&
|
|
151
|
+
'isErr' in value &&
|
|
152
|
+
typeof (value as Record<string, unknown>)['isErr'] === 'function';
|
|
153
|
+
|
|
154
|
+
/** Find an unconsumed actual element that deep-matches the expected object. */
|
|
155
|
+
const findObjectMatch = (
|
|
156
|
+
actual: unknown[],
|
|
157
|
+
elem: object,
|
|
158
|
+
consumed: ReadonlySet<number>,
|
|
159
|
+
path: readonly string[],
|
|
160
|
+
index: number
|
|
161
|
+
): number =>
|
|
162
|
+
actual.findIndex((a, idx) => {
|
|
163
|
+
if (consumed.has(idx)) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
// oxlint-disable-next-line no-use-before-define -- mutual recursion with assertSubset
|
|
168
|
+
assertSubset(a, elem, [...path, `[${String(index)}]`]);
|
|
169
|
+
return true;
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Assert that every element in `expected` exists in `actual` (order-independent).
|
|
177
|
+
*
|
|
178
|
+
* Tracks consumed indices so that duplicate expected elements each require a
|
|
179
|
+
* distinct actual element — `['a', 'a']` does not match `['a']`.
|
|
180
|
+
*/
|
|
181
|
+
const assertArraySubset = (
|
|
182
|
+
actual: unknown[],
|
|
183
|
+
expected: unknown[],
|
|
184
|
+
path: readonly string[],
|
|
185
|
+
loc: string
|
|
186
|
+
): void => {
|
|
187
|
+
const consumed = new Set<number>();
|
|
188
|
+
for (let i = 0; i < expected.length; i += 1) {
|
|
189
|
+
const elem = expected[i];
|
|
190
|
+
const matchIndex =
|
|
191
|
+
typeof elem === 'object' && elem !== null
|
|
192
|
+
? findObjectMatch(actual, elem, consumed, path, i)
|
|
193
|
+
: actual.findIndex((a, idx) => !consumed.has(idx) && a === elem);
|
|
194
|
+
|
|
195
|
+
if (matchIndex === -1) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`at ${loc}[${String(i)}]: expected array to contain ${JSON.stringify(elem)}`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
consumed.add(matchIndex);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** Assert that every key in `expected` exists in `actual` with a matching value. */
|
|
205
|
+
const assertObjectSubset = (
|
|
206
|
+
actual: Record<string, unknown>,
|
|
207
|
+
expected: Record<string, unknown>,
|
|
208
|
+
path: readonly string[]
|
|
209
|
+
): void => {
|
|
210
|
+
for (const key of Object.keys(expected)) {
|
|
211
|
+
if (!(key in actual)) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`at ${[...path, key].join('.')}: key not found in actual`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
// oxlint-disable-next-line no-use-before-define -- mutual recursion with assertSubset
|
|
217
|
+
assertSubset(actual[key], expected[key], [...path, key]);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Recursively assert that `actual` is a superset of `expected`.
|
|
223
|
+
*
|
|
224
|
+
* - **Scalars:** strict equality.
|
|
225
|
+
* - **Objects:** every key in `expected` must exist in `actual` with a matching
|
|
226
|
+
* value. Extra keys in `actual` are ignored.
|
|
227
|
+
* - **Arrays:** every element in `expected` must exist in `actual`
|
|
228
|
+
* (order-independent subset check).
|
|
229
|
+
* - **Nested objects:** recursive subset matching.
|
|
230
|
+
*/
|
|
231
|
+
// oxlint-disable-next-line max-statements -- recursive dispatch across four type branches
|
|
232
|
+
const assertSubset = (
|
|
233
|
+
actual: unknown,
|
|
234
|
+
expected: unknown,
|
|
235
|
+
path: readonly string[]
|
|
236
|
+
): void => {
|
|
237
|
+
const loc = formatLoc(path);
|
|
238
|
+
|
|
239
|
+
if (expected === null || expected === undefined) {
|
|
240
|
+
if (actual !== expected) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`at ${loc}: expected ${String(expected)}, got ${String(actual)}`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (isResultMatchToken(expected)) {
|
|
249
|
+
// oxlint-disable-next-line no-use-before-define -- result token matching delegates back into assertSubset
|
|
250
|
+
assertResultTokenMatch(actual, expected, path);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (Array.isArray(expected)) {
|
|
255
|
+
if (!Array.isArray(actual)) {
|
|
256
|
+
throw new TypeError(`at ${loc}: expected an array, got ${typeof actual}`);
|
|
257
|
+
}
|
|
258
|
+
assertArraySubset(actual, expected, path, loc);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (typeof expected === 'object') {
|
|
263
|
+
if (
|
|
264
|
+
typeof actual !== 'object' ||
|
|
265
|
+
actual === null ||
|
|
266
|
+
Array.isArray(actual)
|
|
267
|
+
) {
|
|
268
|
+
throw new Error(`at ${loc}: expected an object, got ${typeof actual}`);
|
|
269
|
+
}
|
|
270
|
+
assertObjectSubset(
|
|
271
|
+
actual as Record<string, unknown>,
|
|
272
|
+
expected as Record<string, unknown>,
|
|
273
|
+
path
|
|
274
|
+
);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (actual !== expected) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`at ${loc}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const assertOkResultTokenMatch = (
|
|
286
|
+
actual: ResultLike,
|
|
287
|
+
expected: OkResultMatch,
|
|
288
|
+
loc: string,
|
|
289
|
+
path: readonly string[]
|
|
290
|
+
): void => {
|
|
291
|
+
if (!actual.isOk()) {
|
|
292
|
+
throw new Error(`at ${loc}: expected Result.ok(...), got Result.err(...)`);
|
|
293
|
+
}
|
|
294
|
+
if (expected.value !== undefined) {
|
|
295
|
+
assertSubset(actual.value, expected.value, [...path, 'value']);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const assertErrResultTokenMatch = (
|
|
300
|
+
actual: ResultLike,
|
|
301
|
+
expected: ErrResultMatch,
|
|
302
|
+
loc: string,
|
|
303
|
+
path: readonly string[]
|
|
304
|
+
): void => {
|
|
305
|
+
if (!actual.isErr()) {
|
|
306
|
+
throw new Error(`at ${loc}: expected Result.err(...), got Result.ok(...)`);
|
|
307
|
+
}
|
|
308
|
+
if (expected.error !== undefined) {
|
|
309
|
+
assertSubset(actual.error, expected.error, [...path, 'error']);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const assertResultTokenMatch = (
|
|
314
|
+
actual: unknown,
|
|
315
|
+
expected: ResultMatchToken,
|
|
316
|
+
path: readonly string[]
|
|
317
|
+
): void => {
|
|
318
|
+
const loc = formatLoc(path);
|
|
319
|
+
if (!isResultLike(actual)) {
|
|
320
|
+
throw new TypeError(`at ${loc}: expected a Result-like value`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (expected.__resultMatch === 'ok') {
|
|
324
|
+
assertOkResultTokenMatch(actual, expected, loc, path);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
assertErrResultTokenMatch(actual, expected, loc, path);
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Assert that the result is ok and its value is a superset of the expected
|
|
333
|
+
* partial output. Declared fields must match; extra fields are ignored.
|
|
334
|
+
*/
|
|
335
|
+
export const assertPartialMatch = (
|
|
336
|
+
result: Result<unknown, Error>,
|
|
337
|
+
expectedMatch: unknown
|
|
338
|
+
): void => {
|
|
339
|
+
const value = expectOk(result);
|
|
340
|
+
assertSubset(value, expectedMatch, []);
|
|
341
|
+
};
|
|
342
|
+
|
|
90
343
|
// ---------------------------------------------------------------------------
|
|
91
344
|
// Error Match
|
|
92
345
|
// ---------------------------------------------------------------------------
|
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,42 @@ 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
|
-
for (const declaredProvision of app.listProvisions()) {
|
|
164
|
-
if (!declaredProvision.mock) {
|
|
184
|
+
const buildMockResources = async (app: Topo): Promise<ResourceOverrideMap> => {
|
|
185
|
+
const resources: Record<string, unknown> = {};
|
|
186
|
+
for (const declaredResource of app.listResources()) {
|
|
187
|
+
if (!declaredResource.mock) {
|
|
165
188
|
continue;
|
|
166
189
|
}
|
|
167
|
-
|
|
190
|
+
resources[declaredResource.id] = await declaredResource.mock();
|
|
168
191
|
}
|
|
169
|
-
return
|
|
192
|
+
return resources;
|
|
170
193
|
};
|
|
171
194
|
|
|
172
|
-
export const
|
|
195
|
+
export const createMockResources = async (
|
|
173
196
|
app: Topo
|
|
174
|
-
): Promise<
|
|
197
|
+
): Promise<ResourceOverrideMap> => await buildMockResources(app);
|
|
198
|
+
|
|
199
|
+
// Re-export from core so existing consumers of this module continue to work.
|
|
200
|
+
export { buildCrossValidationSchema };
|
|
175
201
|
|
|
176
202
|
/**
|
|
177
203
|
* Merge a Partial<TrailContext> into a test context.
|
|
@@ -179,20 +205,20 @@ export const resolveMockProvisions = async (
|
|
|
179
205
|
*/
|
|
180
206
|
export const mergeTestContext = (
|
|
181
207
|
ctx?: Partial<TrailContext>,
|
|
182
|
-
|
|
208
|
+
resources?: ResourceOverrideMap
|
|
183
209
|
): TrailContext => {
|
|
184
210
|
const base = createTestContext();
|
|
185
211
|
const extensions = {
|
|
186
212
|
...base.extensions,
|
|
187
213
|
...ctx?.extensions,
|
|
188
|
-
...
|
|
214
|
+
...resources,
|
|
189
215
|
};
|
|
190
216
|
const merged = {
|
|
191
217
|
...base,
|
|
192
218
|
...ctx,
|
|
193
219
|
extensions: Object.keys(extensions).length === 0 ? undefined : extensions,
|
|
194
220
|
} as MutableTrailContext;
|
|
195
|
-
const lookup =
|
|
196
|
-
merged.
|
|
221
|
+
const lookup = createResourceLookup(() => merged);
|
|
222
|
+
merged.resource = lookup;
|
|
197
223
|
return merged;
|
|
198
224
|
};
|
package/src/contracts.ts
CHANGED
|
@@ -14,12 +14,13 @@ 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
|
|
@@ -71,14 +72,19 @@ export const testContracts = (
|
|
|
71
72
|
): void => {
|
|
72
73
|
const resolveInput =
|
|
73
74
|
typeof ctxOrFactory === 'function' ? ctxOrFactory : () => ctxOrFactory;
|
|
74
|
-
const allEntries = app.list() as Trail<unknown, unknown>[]
|
|
75
|
+
const allEntries = (app.list() as Trail<unknown, unknown, unknown>[]).map(
|
|
76
|
+
(trailDef) => ({
|
|
77
|
+
...trailDef,
|
|
78
|
+
examples: deriveTrailExamples(trailDef),
|
|
79
|
+
})
|
|
80
|
+
);
|
|
75
81
|
|
|
76
82
|
describe('contracts', () => {
|
|
77
83
|
describe.each(allEntries)('$id', (t) => {
|
|
78
84
|
if (t.output === undefined) {
|
|
79
85
|
return;
|
|
80
86
|
}
|
|
81
|
-
if (t.examples
|
|
87
|
+
if (t.examples.length === 0) {
|
|
82
88
|
return;
|
|
83
89
|
}
|
|
84
90
|
if (needsCrossContext(t, resolveInput)) {
|
|
@@ -92,10 +98,10 @@ export const testContracts = (
|
|
|
92
98
|
'contract: $name',
|
|
93
99
|
async (example: TrailExample<unknown, unknown>) => {
|
|
94
100
|
const resolved = normalizeTestExecutionOptions(resolveInput());
|
|
95
|
-
const
|
|
96
|
-
await
|
|
101
|
+
const resources = mergeResourceOverrides(
|
|
102
|
+
await createMockResources(app),
|
|
97
103
|
resolved.ctx,
|
|
98
|
-
resolved.
|
|
104
|
+
resolved.resources
|
|
99
105
|
);
|
|
100
106
|
const testCtx = mergeTestContext(resolved.ctx);
|
|
101
107
|
|
|
@@ -104,7 +110,7 @@ export const testContracts = (
|
|
|
104
110
|
|
|
105
111
|
const result = await executeTrail(t, example.input, {
|
|
106
112
|
ctx: testCtx,
|
|
107
|
-
|
|
113
|
+
resources,
|
|
108
114
|
});
|
|
109
115
|
const resultValue = expectOk(result);
|
|
110
116
|
|