@ontrails/testing 0.2.0
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 +807 -0
- package/README.md +157 -0
- package/package.json +57 -0
- package/src/all-established.ts +168 -0
- package/src/all.ts +94 -0
- package/src/assertions.ts +361 -0
- package/src/cli.ts +6 -0
- package/src/composes.ts +433 -0
- package/src/context.ts +228 -0
- package/src/contracts.ts +109 -0
- package/src/detours.ts +181 -0
- package/src/effective-examples.ts +408 -0
- package/src/errors.ts +47 -0
- package/src/examples.ts +439 -0
- package/src/harness-cli.ts +335 -0
- package/src/harness-http.ts +341 -0
- package/src/harness-mcp.ts +98 -0
- package/src/http.ts +10 -0
- package/src/index.ts +48 -0
- package/src/logger.ts +127 -0
- package/src/mcp.ts +6 -0
- package/src/scenario.ts +375 -0
- package/src/signals.ts +221 -0
- package/src/surface-parity.ts +389 -0
- package/src/trail.ts +116 -0
- package/src/types.ts +89 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progressive assertion logic for example-driven testing.
|
|
3
|
+
*
|
|
4
|
+
* Three tiers:
|
|
5
|
+
* 1. Full match — example has `expected` output
|
|
6
|
+
* 2. Schema-only — no expected output, no error
|
|
7
|
+
* 3. Error match — example declares an error class name
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { expect } from 'bun:test';
|
|
11
|
+
|
|
12
|
+
import type { Result } from '@ontrails/core';
|
|
13
|
+
import { formatZodIssues } from '@ontrails/core';
|
|
14
|
+
import type { z } from 'zod';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Result narrowing helpers
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Assert that a Result is Ok and return its value.
|
|
22
|
+
*
|
|
23
|
+
* Eliminates the `if (result.isOk())` / `as unknown as` dance in tests.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* const value = expectOk(result);
|
|
28
|
+
* expect(value.name).toBe('Alice');
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export const expectOk = <T, E>(result: Result<T, E>): T => {
|
|
32
|
+
expect(result.isOk()).toBe(true);
|
|
33
|
+
return (result as unknown as { value: T }).value;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Assert that a Result is Err and return its error.
|
|
38
|
+
*
|
|
39
|
+
* Eliminates the `if (result.isErr())` / `as unknown as` dance in tests.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```typescript
|
|
43
|
+
* const error = expectErr(result);
|
|
44
|
+
* expect(error).toBeInstanceOf(ValidationError);
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export const expectErr = <T, E>(result: Result<T, E>): E => {
|
|
48
|
+
expect(result.isErr()).toBe(true);
|
|
49
|
+
return (result as unknown as { error: E }).error;
|
|
50
|
+
};
|
|
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.compose()`.
|
|
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.compose()`.
|
|
80
|
+
*/
|
|
81
|
+
export const errResultMatch = (error?: unknown): ErrResultMatch => ({
|
|
82
|
+
__resultMatch: 'err',
|
|
83
|
+
error,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Full Match
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Assert that the result is ok and its value deep-equals the expected output.
|
|
92
|
+
*/
|
|
93
|
+
export const assertFullMatch = (
|
|
94
|
+
result: Result<unknown, Error>,
|
|
95
|
+
expected: unknown
|
|
96
|
+
): void => {
|
|
97
|
+
const value = expectOk(result);
|
|
98
|
+
expect(value).toEqual(expected);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Schema-Only Match
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Assert that the result is ok and, if an output schema is provided,
|
|
107
|
+
* the value parses against it.
|
|
108
|
+
*/
|
|
109
|
+
export const assertSchemaMatch = (
|
|
110
|
+
result: Result<unknown, Error>,
|
|
111
|
+
outputSchema: z.ZodType | undefined
|
|
112
|
+
): void => {
|
|
113
|
+
const value = expectOk(result);
|
|
114
|
+
if (outputSchema !== undefined) {
|
|
115
|
+
const parsed = outputSchema.safeParse(value);
|
|
116
|
+
if (!parsed.success) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Output does not match schema: ${formatZodIssues(parsed.error.issues).join('; ')}`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
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
|
+
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
// Error Match
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Assert that the result is an error of the specified type, with optional
|
|
349
|
+
* message substring matching.
|
|
350
|
+
*/
|
|
351
|
+
export const assertErrorMatch = (
|
|
352
|
+
result: Result<unknown, Error>,
|
|
353
|
+
expectedError: new (...args: never[]) => Error,
|
|
354
|
+
expectedMessage?: string
|
|
355
|
+
): void => {
|
|
356
|
+
const error = expectErr(result);
|
|
357
|
+
expect(error).toBeInstanceOf(expectedError);
|
|
358
|
+
if (expectedMessage !== undefined) {
|
|
359
|
+
expect(error.message).toContain(expectedMessage);
|
|
360
|
+
}
|
|
361
|
+
};
|