@ontrails/testing 1.0.0-beta.10 → 1.0.0-beta.12
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 +47 -0
- package/dist/all.d.ts +2 -1
- package/dist/all.d.ts.map +1 -1
- package/dist/all.js.map +1 -1
- package/dist/context.d.ts +39 -2
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +60 -13
- package/dist/context.js.map +1 -1
- package/dist/contracts.d.ts +2 -1
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +13 -8
- package/dist/contracts.js.map +1 -1
- package/dist/examples.d.ts +2 -1
- package/dist/examples.d.ts.map +1 -1
- package/dist/examples.js +49 -22
- package/dist/examples.js.map +1 -1
- package/dist/follows.d.ts +7 -1
- package/dist/follows.d.ts.map +1 -1
- package/dist/follows.js +60 -17
- package/dist/follows.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/all.test.ts +64 -0
- package/src/__tests__/context.test.ts +58 -3
- package/src/__tests__/contracts.test.ts +92 -1
- package/src/__tests__/examples.test.ts +359 -1
- package/src/__tests__/follows.test.ts +427 -1
- package/src/all.ts +5 -1
- package/src/context.ts +126 -15
- package/src/contracts.ts +28 -10
- package/src/examples.ts +94 -22
- package/src/follows.ts +112 -17
- package/src/index.ts +10 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, test } from 'bun:test';
|
|
2
2
|
|
|
3
|
-
import { NotFoundError, Result, trail, topo } from '@ontrails/core';
|
|
3
|
+
import { NotFoundError, Result, service, trail, topo } from '@ontrails/core';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
|
|
6
6
|
import { testExamples } from '../examples.js';
|
|
@@ -67,6 +67,136 @@ const noExamplesTrail = trail('noexamples', {
|
|
|
67
67
|
run: (input: { x: number }) => Result.ok(input.x * 2),
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
const mockDbService = service('db.mock.examples', {
|
|
71
|
+
create: () => Result.ok({ source: 'factory' }),
|
|
72
|
+
mock: () => ({ source: 'mock' }),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const mockServiceTrail = trail('service.mocked', {
|
|
76
|
+
description: 'Trail that reads from a mocked service',
|
|
77
|
+
examples: [
|
|
78
|
+
{
|
|
79
|
+
expected: { source: 'mock' },
|
|
80
|
+
input: {},
|
|
81
|
+
name: 'Uses auto-resolved service mock',
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
input: z.object({}),
|
|
85
|
+
output: z.object({ source: z.string() }),
|
|
86
|
+
run: (_input, ctx) => Result.ok({ source: mockDbService.from(ctx).source }),
|
|
87
|
+
services: [mockDbService],
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const explicitOverrideTrail = trail('service.override', {
|
|
91
|
+
description: 'Trail whose service mock can be overridden explicitly',
|
|
92
|
+
examples: [
|
|
93
|
+
{
|
|
94
|
+
expected: { source: 'override' },
|
|
95
|
+
input: {},
|
|
96
|
+
name: 'Explicit service override wins over mock factory',
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
input: z.object({}),
|
|
100
|
+
output: z.object({ source: z.string() }),
|
|
101
|
+
run: (_input, ctx) => Result.ok({ source: mockDbService.from(ctx).source }),
|
|
102
|
+
services: [mockDbService],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const transformedInputTrail = trail('example.transformed', {
|
|
106
|
+
description: 'Trail whose input schema transforms once',
|
|
107
|
+
examples: [
|
|
108
|
+
{
|
|
109
|
+
expected: { value: 2 },
|
|
110
|
+
input: { value: '1' },
|
|
111
|
+
name: 'Raw example input is only transformed once',
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
input: z
|
|
115
|
+
.object({ value: z.string() })
|
|
116
|
+
.transform(({ value }) => ({ value: Number(value) + 1 })),
|
|
117
|
+
output: z.object({ value: z.number() }),
|
|
118
|
+
run: (input: { value: number }) => Result.ok({ value: input.value }),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const ctxOverrideTrail = trail('service.ctx-override', {
|
|
122
|
+
description: 'Trail whose service mock can be overridden by ctx.extensions',
|
|
123
|
+
examples: [
|
|
124
|
+
{
|
|
125
|
+
expected: { source: 'ctx' },
|
|
126
|
+
input: {},
|
|
127
|
+
name: 'Context extensions beat auto-resolved mock services',
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
input: z.object({}),
|
|
131
|
+
output: z.object({ source: z.string() }),
|
|
132
|
+
run: (_input, ctx) => Result.ok({ source: mockDbService.from(ctx).source }),
|
|
133
|
+
services: [mockDbService],
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const undeclaredDbService = service('db.undeclared.examples', {
|
|
137
|
+
create: () => Result.ok({ source: 'factory' }),
|
|
138
|
+
mock: () => ({ source: 'mock' }),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const undeclaredServiceTrail = trail('service.undeclared.examples', {
|
|
142
|
+
description: 'Trail that uses a service without declaring it',
|
|
143
|
+
examples: [
|
|
144
|
+
{
|
|
145
|
+
error: 'InternalError',
|
|
146
|
+
input: {},
|
|
147
|
+
name: 'Undeclared services stay unavailable during example execution',
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
input: z.object({}),
|
|
151
|
+
output: z.object({ source: z.string() }),
|
|
152
|
+
run: (_input, ctx) =>
|
|
153
|
+
Result.ok({ source: undeclaredDbService.from(ctx).source }),
|
|
154
|
+
});
|
|
155
|
+
const followedDbService = service('db.mock.examples.follow', {
|
|
156
|
+
create: () => Result.ok({ source: 'factory' }),
|
|
157
|
+
mock: () => ({ source: 'mock' }),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const followedLeafTrail = trail('service.follow.leaf', {
|
|
161
|
+
description: 'Leaf trail that resolves a service inside a follow chain',
|
|
162
|
+
input: z.object({}),
|
|
163
|
+
output: z.object({ childSource: z.string() }),
|
|
164
|
+
run: (_input, ctx) =>
|
|
165
|
+
Result.ok({ childSource: followedDbService.from(ctx).source }),
|
|
166
|
+
services: [followedDbService],
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const followedRootTrail = trail('service.follow.root', {
|
|
170
|
+
description: 'Root trail that follows a child trail using services',
|
|
171
|
+
examples: [
|
|
172
|
+
{
|
|
173
|
+
expected: { childSource: 'mock', rootSource: 'mock' },
|
|
174
|
+
input: {},
|
|
175
|
+
name: 'Propagates service mocks through follow execution',
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
follow: ['service.follow.leaf'],
|
|
179
|
+
input: z.object({}),
|
|
180
|
+
output: z.object({ childSource: z.string(), rootSource: z.string() }),
|
|
181
|
+
run: async (_input, ctx) => {
|
|
182
|
+
if (!ctx.follow) {
|
|
183
|
+
return Result.err(new Error('follow not available'));
|
|
184
|
+
}
|
|
185
|
+
const childResult = await ctx.follow<{ childSource: string }>(
|
|
186
|
+
'service.follow.leaf',
|
|
187
|
+
{}
|
|
188
|
+
);
|
|
189
|
+
if (childResult.isErr()) {
|
|
190
|
+
return childResult;
|
|
191
|
+
}
|
|
192
|
+
return Result.ok({
|
|
193
|
+
childSource: childResult.value.childSource,
|
|
194
|
+
rootSource: followedDbService.from(ctx).source,
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
services: [followedDbService],
|
|
198
|
+
});
|
|
199
|
+
|
|
70
200
|
// ---------------------------------------------------------------------------
|
|
71
201
|
// Composition trails (for follow coverage)
|
|
72
202
|
// ---------------------------------------------------------------------------
|
|
@@ -163,6 +293,63 @@ describe('testExamples skips trails with no examples', () => {
|
|
|
163
293
|
});
|
|
164
294
|
});
|
|
165
295
|
|
|
296
|
+
describe('testExamples service mocks', () => {
|
|
297
|
+
// eslint-disable-next-line jest/require-hook
|
|
298
|
+
testExamples(
|
|
299
|
+
topo('service-mock-app', {
|
|
300
|
+
mockDbService,
|
|
301
|
+
mockServiceTrail,
|
|
302
|
+
} as Record<string, unknown>)
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe('testExamples explicit service overrides', () => {
|
|
307
|
+
// eslint-disable-next-line jest/require-hook
|
|
308
|
+
testExamples(
|
|
309
|
+
topo('service-override-app', {
|
|
310
|
+
explicitOverrideTrail,
|
|
311
|
+
mockDbService,
|
|
312
|
+
} as Record<string, unknown>),
|
|
313
|
+
{
|
|
314
|
+
services: { 'db.mock.examples': { source: 'override' } },
|
|
315
|
+
}
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe('testExamples raw transformed input', () => {
|
|
320
|
+
// eslint-disable-next-line jest/require-hook
|
|
321
|
+
testExamples(
|
|
322
|
+
topo('transformed-input-app', {
|
|
323
|
+
transformedInputTrail,
|
|
324
|
+
} as Record<string, unknown>)
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe('testExamples context extension overrides', () => {
|
|
329
|
+
// eslint-disable-next-line jest/require-hook
|
|
330
|
+
testExamples(
|
|
331
|
+
topo('ctx-override-app', {
|
|
332
|
+
ctxOverrideTrail,
|
|
333
|
+
mockDbService,
|
|
334
|
+
} as Record<string, unknown>),
|
|
335
|
+
{
|
|
336
|
+
ctx: {
|
|
337
|
+
extensions: { 'db.mock.examples': { source: 'ctx' } },
|
|
338
|
+
},
|
|
339
|
+
}
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe('testExamples service declarations', () => {
|
|
344
|
+
// eslint-disable-next-line jest/require-hook
|
|
345
|
+
testExamples(
|
|
346
|
+
topo('undeclared-service-app', {
|
|
347
|
+
undeclaredDbService,
|
|
348
|
+
undeclaredServiceTrail,
|
|
349
|
+
} as Record<string, unknown>)
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
|
|
166
353
|
describe('testExamples follow coverage for composition trails', () => {
|
|
167
354
|
// eslint-disable-next-line jest/require-hook
|
|
168
355
|
testExamples(
|
|
@@ -173,3 +360,174 @@ describe('testExamples follow coverage for composition trails', () => {
|
|
|
173
360
|
} as Record<string, unknown>)
|
|
174
361
|
);
|
|
175
362
|
});
|
|
363
|
+
|
|
364
|
+
describe('testExamples service mocks through follow', () => {
|
|
365
|
+
// eslint-disable-next-line jest/require-hook
|
|
366
|
+
testExamples(
|
|
367
|
+
topo('service-follow-app', {
|
|
368
|
+
followedDbService,
|
|
369
|
+
followedLeafTrail,
|
|
370
|
+
followedRootTrail,
|
|
371
|
+
} as Record<string, unknown>)
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// Nested follow chain: A → B → C
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
const leafTrail = trail('step.leaf', {
|
|
380
|
+
description: 'Leaf trail in a nested chain',
|
|
381
|
+
input: z.object({ value: z.string() }),
|
|
382
|
+
output: z.object({ leaf: z.string() }),
|
|
383
|
+
run: (input: { value: string }) => Result.ok({ leaf: input.value }),
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const middleTrail = trail('step.middle', {
|
|
387
|
+
description: 'Middle trail that follows the leaf',
|
|
388
|
+
follow: ['step.leaf'],
|
|
389
|
+
input: z.object({ value: z.string() }),
|
|
390
|
+
output: z.object({ middle: z.string() }),
|
|
391
|
+
run: async (input: { value: string }, ctx) => {
|
|
392
|
+
if (!ctx.follow) {
|
|
393
|
+
return Result.err(new Error('follow not available'));
|
|
394
|
+
}
|
|
395
|
+
const leafResult = await ctx.follow<{ leaf: string }>('step.leaf', input);
|
|
396
|
+
if (leafResult.isErr()) {
|
|
397
|
+
return leafResult;
|
|
398
|
+
}
|
|
399
|
+
return Result.ok({ middle: leafResult.value.leaf });
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const rootTrail = trail('step.root', {
|
|
404
|
+
description: 'Root trail that follows the middle trail',
|
|
405
|
+
examples: [
|
|
406
|
+
{
|
|
407
|
+
expected: { root: 'hello' },
|
|
408
|
+
input: { value: 'hello' },
|
|
409
|
+
name: 'Nested follow chain A→B→C',
|
|
410
|
+
},
|
|
411
|
+
],
|
|
412
|
+
follow: ['step.middle'],
|
|
413
|
+
input: z.object({ value: z.string() }),
|
|
414
|
+
output: z.object({ root: z.string() }),
|
|
415
|
+
run: async (input: { value: string }, ctx) => {
|
|
416
|
+
if (!ctx.follow) {
|
|
417
|
+
return Result.err(new Error('follow not available'));
|
|
418
|
+
}
|
|
419
|
+
const midResult = await ctx.follow<{ middle: string }>(
|
|
420
|
+
'step.middle',
|
|
421
|
+
input
|
|
422
|
+
);
|
|
423
|
+
if (midResult.isErr()) {
|
|
424
|
+
return midResult;
|
|
425
|
+
}
|
|
426
|
+
return Result.ok({ root: midResult.value.middle });
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
describe('testExamples nested follow chain (A → B → C)', () => {
|
|
431
|
+
// eslint-disable-next-line jest/require-hook
|
|
432
|
+
testExamples(
|
|
433
|
+
topo('nested-chain-app', {
|
|
434
|
+
leafTrail,
|
|
435
|
+
middleTrail,
|
|
436
|
+
rootTrail,
|
|
437
|
+
} as Record<string, unknown>)
|
|
438
|
+
);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
// Auto-minting permit tests (B3)
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
const scopedTrail = trail('scoped.trail', {
|
|
446
|
+
description: 'Trail requiring admin scope',
|
|
447
|
+
examples: [
|
|
448
|
+
{
|
|
449
|
+
expected: { ok: true },
|
|
450
|
+
input: {},
|
|
451
|
+
name: 'Runs with auto-minted permit',
|
|
452
|
+
},
|
|
453
|
+
],
|
|
454
|
+
input: z.object({}),
|
|
455
|
+
output: z.object({ ok: z.boolean() }),
|
|
456
|
+
permit: { scopes: ['admin'] },
|
|
457
|
+
run: (_input, ctx) => {
|
|
458
|
+
// Verify the permit was auto-minted with declared scopes
|
|
459
|
+
const permit = ctx.permit as
|
|
460
|
+
| { id: string; scopes: readonly string[] }
|
|
461
|
+
| undefined;
|
|
462
|
+
if (!permit || !permit.scopes.includes('admin')) {
|
|
463
|
+
return Result.err(new Error('Missing permit or scopes'));
|
|
464
|
+
}
|
|
465
|
+
return Result.ok({ ok: true });
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const publicTrail = trail('public.trail', {
|
|
470
|
+
description: 'Public trail — no permit needed',
|
|
471
|
+
examples: [
|
|
472
|
+
{
|
|
473
|
+
expected: { ok: true },
|
|
474
|
+
input: {},
|
|
475
|
+
name: 'Runs without a permit',
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
input: z.object({}),
|
|
479
|
+
output: z.object({ ok: z.boolean() }),
|
|
480
|
+
permit: 'public',
|
|
481
|
+
run: (_input, ctx) => {
|
|
482
|
+
// Public trail should NOT get a permit
|
|
483
|
+
if (ctx.permit !== undefined) {
|
|
484
|
+
return Result.err(new Error('Unexpected permit on public trail'));
|
|
485
|
+
}
|
|
486
|
+
return Result.ok({ ok: true });
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
describe('testExamples auto-minting permits', () => {
|
|
491
|
+
describe('scoped trail gets auto-minted permit', () => {
|
|
492
|
+
// eslint-disable-next-line jest/require-hook
|
|
493
|
+
testExamples(
|
|
494
|
+
topo('mint-scoped-app', {
|
|
495
|
+
scopedTrail,
|
|
496
|
+
} as Record<string, unknown>)
|
|
497
|
+
);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
describe('public trail does NOT get a permit', () => {
|
|
501
|
+
// eslint-disable-next-line jest/require-hook
|
|
502
|
+
testExamples(
|
|
503
|
+
topo('mint-public-app', {
|
|
504
|
+
publicTrail,
|
|
505
|
+
} as Record<string, unknown>)
|
|
506
|
+
);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
describe('strictPermits skips auto-minting', () => {
|
|
510
|
+
const strictScopedTrail = trail('strict.scoped', {
|
|
511
|
+
description: 'Trail that expects no permit under strictPermits',
|
|
512
|
+
examples: [
|
|
513
|
+
{
|
|
514
|
+
expected: { hasPermit: false },
|
|
515
|
+
input: {},
|
|
516
|
+
name: 'No auto-minted permit when strictPermits is true',
|
|
517
|
+
},
|
|
518
|
+
],
|
|
519
|
+
input: z.object({}),
|
|
520
|
+
output: z.object({ hasPermit: z.boolean() }),
|
|
521
|
+
permit: { scopes: ['admin'] },
|
|
522
|
+
run: (_input, ctx) => Result.ok({ hasPermit: ctx.permit !== undefined }),
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// eslint-disable-next-line jest/require-hook
|
|
526
|
+
testExamples(
|
|
527
|
+
topo('strict-app', {
|
|
528
|
+
strictScopedTrail,
|
|
529
|
+
} as Record<string, unknown>),
|
|
530
|
+
{ strictPermits: true }
|
|
531
|
+
);
|
|
532
|
+
});
|
|
533
|
+
});
|