@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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe } from 'bun:test';
|
|
2
2
|
|
|
3
3
|
import type { AnyTrail, TrailContext } from '@ontrails/core';
|
|
4
|
-
import { InternalError, Result,
|
|
4
|
+
import { InternalError, Result, resource, trail } from '@ontrails/core';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
|
|
7
7
|
import { testCrosses } from '../crosses.js';
|
|
@@ -228,27 +228,27 @@ describe('testCrosses: nested cross chain (A → B → C)', () => {
|
|
|
228
228
|
);
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
-
const
|
|
231
|
+
const mockDbResource = resource('db.mock.crosses', {
|
|
232
232
|
create: () => Result.ok({ source: 'factory' }),
|
|
233
233
|
mock: () => ({ source: 'mock' }),
|
|
234
234
|
});
|
|
235
235
|
|
|
236
|
-
const provisionLeafTrail = trail('
|
|
236
|
+
const provisionLeafTrail = trail('resource.leaf', {
|
|
237
237
|
blaze: (_input, ctx) =>
|
|
238
|
-
Result.ok({ childSource:
|
|
239
|
-
description: 'Leaf trail that reads from a
|
|
238
|
+
Result.ok({ childSource: mockDbResource.from(ctx).source }),
|
|
239
|
+
description: 'Leaf trail that reads from a resource',
|
|
240
240
|
input: z.object({}),
|
|
241
241
|
output: z.object({ childSource: z.string() }),
|
|
242
|
-
|
|
242
|
+
resources: [mockDbResource],
|
|
243
243
|
});
|
|
244
244
|
|
|
245
|
-
const provisionRootTrail = trail('
|
|
245
|
+
const provisionRootTrail = trail('resource.root', {
|
|
246
246
|
blaze: async (_input, ctx: TrailContext) => {
|
|
247
247
|
if (!ctx.cross) {
|
|
248
248
|
return Result.err(new Error('cross not available'));
|
|
249
249
|
}
|
|
250
250
|
const childResult = await ctx.cross<{ childSource: string }>(
|
|
251
|
-
'
|
|
251
|
+
'resource.leaf',
|
|
252
252
|
{}
|
|
253
253
|
);
|
|
254
254
|
if (childResult.isErr()) {
|
|
@@ -257,46 +257,46 @@ const provisionRootTrail = trail('provision.root', {
|
|
|
257
257
|
|
|
258
258
|
return Result.ok({
|
|
259
259
|
childSource: childResult.value.childSource,
|
|
260
|
-
rootSource:
|
|
260
|
+
rootSource: mockDbResource.from(ctx).source,
|
|
261
261
|
});
|
|
262
262
|
},
|
|
263
|
-
crosses: ['
|
|
263
|
+
crosses: ['resource.leaf'],
|
|
264
264
|
description:
|
|
265
|
-
'Root trail that reads from a
|
|
265
|
+
'Root trail that reads from a resource and crosses a child trail',
|
|
266
266
|
input: z.object({}),
|
|
267
267
|
output: z.object({ childSource: z.string(), rootSource: z.string() }),
|
|
268
|
-
|
|
268
|
+
resources: [mockDbResource],
|
|
269
269
|
});
|
|
270
270
|
|
|
271
271
|
const provisionTrailsMap = new Map<string, AnyTrail>([
|
|
272
|
-
['
|
|
272
|
+
['resource.leaf', provisionLeafTrail],
|
|
273
273
|
]);
|
|
274
274
|
|
|
275
|
-
const
|
|
275
|
+
const statefulMockDbResource = resource('db.mock.crosses.stateful', {
|
|
276
276
|
create: () => Result.ok({ count: 0 }),
|
|
277
277
|
mock: () => ({ count: 0 }),
|
|
278
278
|
});
|
|
279
279
|
|
|
280
|
-
const
|
|
280
|
+
const statefulResourceLeafTrail = trail('resource.stateful.leaf', {
|
|
281
281
|
blaze: (_input, ctx) =>
|
|
282
|
-
Result.ok({ childCount:
|
|
283
|
-
description: 'Leaf trail that observes the current mock
|
|
282
|
+
Result.ok({ childCount: statefulMockDbResource.from(ctx).count }),
|
|
283
|
+
description: 'Leaf trail that observes the current mock resource state',
|
|
284
284
|
input: z.object({}),
|
|
285
285
|
output: z.object({ childCount: z.number() }),
|
|
286
|
-
|
|
286
|
+
resources: [statefulMockDbResource],
|
|
287
287
|
});
|
|
288
288
|
|
|
289
|
-
const
|
|
289
|
+
const statefulResourceRootTrail = trail('resource.stateful.root', {
|
|
290
290
|
blaze: async (_input, ctx: TrailContext) => {
|
|
291
291
|
if (!ctx.cross) {
|
|
292
292
|
return Result.err(new Error('cross not available'));
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
-
const
|
|
296
|
-
|
|
295
|
+
const statefulResource = statefulMockDbResource.from(ctx);
|
|
296
|
+
statefulResource.count += 1;
|
|
297
297
|
|
|
298
298
|
const childResult = await ctx.cross<{ childCount: number }>(
|
|
299
|
-
'
|
|
299
|
+
'resource.stateful.leaf',
|
|
300
300
|
{}
|
|
301
301
|
);
|
|
302
302
|
if (childResult.isErr()) {
|
|
@@ -305,46 +305,46 @@ const statefulProvisionRootTrail = trail('provision.stateful.root', {
|
|
|
305
305
|
|
|
306
306
|
return Result.ok({
|
|
307
307
|
childCount: childResult.value.childCount,
|
|
308
|
-
rootCount:
|
|
308
|
+
rootCount: statefulResource.count,
|
|
309
309
|
});
|
|
310
310
|
},
|
|
311
|
-
crosses: ['
|
|
311
|
+
crosses: ['resource.stateful.leaf'],
|
|
312
312
|
description:
|
|
313
|
-
'Root trail that mutates a mocked
|
|
313
|
+
'Root trail that mutates a mocked resource and crosses a child trail',
|
|
314
314
|
input: z.object({}),
|
|
315
315
|
output: z.object({ childCount: z.number(), rootCount: z.number() }),
|
|
316
|
-
|
|
316
|
+
resources: [statefulMockDbResource],
|
|
317
317
|
});
|
|
318
318
|
|
|
319
|
-
const
|
|
320
|
-
['
|
|
319
|
+
const statefulResourceTrailsMap = new Map<string, AnyTrail>([
|
|
320
|
+
['resource.stateful.leaf', statefulResourceLeafTrail],
|
|
321
321
|
]);
|
|
322
322
|
|
|
323
|
-
const
|
|
323
|
+
const scenarioStateResource = resource('db.mock.scenarios', {
|
|
324
324
|
create: () => Result.ok({ count: 0 }),
|
|
325
325
|
mock: () => ({ count: 0 }),
|
|
326
326
|
});
|
|
327
327
|
|
|
328
|
-
const scenarioLeafTrail = trail('
|
|
328
|
+
const scenarioLeafTrail = trail('resource.scenario.leaf', {
|
|
329
329
|
blaze: (_input, ctx) =>
|
|
330
|
-
Result.ok({ count:
|
|
330
|
+
Result.ok({ count: scenarioStateResource.from(ctx).count }),
|
|
331
331
|
description: 'Leaf trail that reads mutable scenario state',
|
|
332
332
|
input: z.object({}),
|
|
333
333
|
output: z.object({ count: z.number() }),
|
|
334
|
-
|
|
334
|
+
resources: [scenarioStateResource],
|
|
335
335
|
});
|
|
336
336
|
|
|
337
|
-
const scenarioRootTrail = trail('
|
|
337
|
+
const scenarioRootTrail = trail('resource.scenario.root', {
|
|
338
338
|
blaze: async (_input, ctx: TrailContext) => {
|
|
339
339
|
if (!ctx.cross) {
|
|
340
340
|
return Result.err(new Error('cross not available'));
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
-
const state =
|
|
343
|
+
const state = scenarioStateResource.from(ctx);
|
|
344
344
|
state.count += 1;
|
|
345
345
|
|
|
346
346
|
const leafResult = await ctx.cross<{ count: number }>(
|
|
347
|
-
'
|
|
347
|
+
'resource.scenario.leaf',
|
|
348
348
|
{}
|
|
349
349
|
);
|
|
350
350
|
if (leafResult.isErr()) {
|
|
@@ -353,15 +353,15 @@ const scenarioRootTrail = trail('provision.scenario.root', {
|
|
|
353
353
|
|
|
354
354
|
return Result.ok({ count: leafResult.value.count });
|
|
355
355
|
},
|
|
356
|
-
crosses: ['
|
|
356
|
+
crosses: ['resource.scenario.leaf'],
|
|
357
357
|
description: 'Root trail that mutates scenario state and crosses a leaf',
|
|
358
358
|
input: z.object({}),
|
|
359
359
|
output: z.object({ count: z.number() }),
|
|
360
|
-
|
|
360
|
+
resources: [scenarioStateResource],
|
|
361
361
|
});
|
|
362
362
|
|
|
363
|
-
const
|
|
364
|
-
['
|
|
363
|
+
const scenarioResourceTrailsMap = new Map<string, AnyTrail>([
|
|
364
|
+
['resource.scenario.leaf', scenarioLeafTrail],
|
|
365
365
|
]);
|
|
366
366
|
|
|
367
367
|
const transformedCrossLeafTrail = trail('cross.transformed.leaf', {
|
|
@@ -399,28 +399,28 @@ const transformedCrossTrailsMap = new Map<string, AnyTrail>([
|
|
|
399
399
|
['cross.transformed.leaf', transformedCrossLeafTrail],
|
|
400
400
|
]);
|
|
401
401
|
|
|
402
|
-
const
|
|
402
|
+
const undeclaredCrossResource = resource('db.undeclared.crosses', {
|
|
403
403
|
create: () => Result.ok({ source: 'factory' }),
|
|
404
404
|
mock: () => ({ source: 'mock' }),
|
|
405
405
|
});
|
|
406
406
|
|
|
407
|
-
const
|
|
407
|
+
const undeclaredResourceLeafTrail = trail('resource.undeclared.leaf', {
|
|
408
408
|
blaze: (_input, ctx) =>
|
|
409
|
-
Result.ok({ childSource:
|
|
410
|
-
description: 'Leaf trail that declares the shared
|
|
409
|
+
Result.ok({ childSource: undeclaredCrossResource.from(ctx).source }),
|
|
410
|
+
description: 'Leaf trail that declares the shared resource',
|
|
411
411
|
input: z.object({}),
|
|
412
412
|
output: z.object({ childSource: z.string() }),
|
|
413
|
-
|
|
413
|
+
resources: [undeclaredCrossResource],
|
|
414
414
|
});
|
|
415
415
|
|
|
416
|
-
const
|
|
416
|
+
const undeclaredResourceRootTrail = trail('resource.undeclared.root', {
|
|
417
417
|
blaze: async (_input, ctx: TrailContext) => {
|
|
418
418
|
if (!ctx.cross) {
|
|
419
419
|
return Result.err(new Error('cross not available'));
|
|
420
420
|
}
|
|
421
421
|
|
|
422
422
|
const childResult = await ctx.cross<{ childSource: string }>(
|
|
423
|
-
'
|
|
423
|
+
'resource.undeclared.leaf',
|
|
424
424
|
{}
|
|
425
425
|
);
|
|
426
426
|
if (childResult.isErr()) {
|
|
@@ -429,46 +429,46 @@ const undeclaredProvisionRootTrail = trail('provision.undeclared.root', {
|
|
|
429
429
|
|
|
430
430
|
return Result.ok({
|
|
431
431
|
childSource: childResult.value.childSource,
|
|
432
|
-
rootSource:
|
|
432
|
+
rootSource: undeclaredCrossResource.from(ctx).source,
|
|
433
433
|
});
|
|
434
434
|
},
|
|
435
|
-
crosses: ['
|
|
436
|
-
description: 'Root trail that uses a
|
|
435
|
+
crosses: ['resource.undeclared.leaf'],
|
|
436
|
+
description: 'Root trail that uses a resource without declaring it',
|
|
437
437
|
input: z.object({}),
|
|
438
438
|
output: z.object({ childSource: z.string(), rootSource: z.string() }),
|
|
439
439
|
});
|
|
440
440
|
|
|
441
|
-
const
|
|
442
|
-
['
|
|
441
|
+
const undeclaredResourceTrailsMap = new Map<string, AnyTrail>([
|
|
442
|
+
['resource.undeclared.leaf', undeclaredResourceLeafTrail],
|
|
443
443
|
]);
|
|
444
444
|
|
|
445
|
-
const
|
|
445
|
+
const unrelatedCrossResource = resource('db.unrelated.crosses', {
|
|
446
446
|
create: () => Result.ok({ source: 'factory' }),
|
|
447
447
|
mock: () => {
|
|
448
448
|
throw new Error('unrelated mock should not be resolved');
|
|
449
449
|
},
|
|
450
450
|
});
|
|
451
451
|
|
|
452
|
-
const
|
|
452
|
+
const unrelatedResourceTrail = trail('resource.unrelated', {
|
|
453
453
|
blaze: (_input, ctx) =>
|
|
454
|
-
Result.ok({ source:
|
|
454
|
+
Result.ok({ source: unrelatedCrossResource.from(ctx).source }),
|
|
455
455
|
description: 'Trail that should not be traversed or mocked',
|
|
456
456
|
input: z.object({}),
|
|
457
457
|
output: z.object({ source: z.string() }),
|
|
458
|
-
|
|
458
|
+
resources: [unrelatedCrossResource],
|
|
459
459
|
});
|
|
460
460
|
|
|
461
|
-
const
|
|
462
|
-
['
|
|
461
|
+
const unrelatedResourceTrailsMap = new Map<string, AnyTrail>([
|
|
462
|
+
['resource.unrelated', unrelatedResourceTrail],
|
|
463
463
|
]);
|
|
464
464
|
|
|
465
|
-
describe('testCrosses
|
|
465
|
+
describe('testCrosses resource mocks', () => {
|
|
466
466
|
// eslint-disable-next-line jest/require-hook
|
|
467
467
|
testCrosses(
|
|
468
468
|
provisionRootTrail,
|
|
469
469
|
[
|
|
470
470
|
{
|
|
471
|
-
description: 'propagates auto-resolved
|
|
471
|
+
description: 'propagates auto-resolved resource mocks through cross',
|
|
472
472
|
expectValue: { childSource: 'mock', rootSource: 'mock' },
|
|
473
473
|
input: {},
|
|
474
474
|
},
|
|
@@ -477,39 +477,39 @@ describe('testCrosses provision mocks', () => {
|
|
|
477
477
|
);
|
|
478
478
|
});
|
|
479
479
|
|
|
480
|
-
describe('testCrosses
|
|
480
|
+
describe('testCrosses resource mocks are fresh per scenario', () => {
|
|
481
481
|
// eslint-disable-next-line jest/require-hook
|
|
482
482
|
testCrosses(
|
|
483
483
|
scenarioRootTrail,
|
|
484
484
|
[
|
|
485
485
|
{
|
|
486
|
-
description: 'first scenario sees a fresh mutable
|
|
486
|
+
description: 'first scenario sees a fresh mutable resource',
|
|
487
487
|
expectValue: { count: 1 },
|
|
488
488
|
input: {},
|
|
489
489
|
},
|
|
490
490
|
{
|
|
491
|
-
description: 'second scenario also sees a fresh mutable
|
|
491
|
+
description: 'second scenario also sees a fresh mutable resource',
|
|
492
492
|
expectValue: { count: 1 },
|
|
493
493
|
input: {},
|
|
494
494
|
},
|
|
495
495
|
],
|
|
496
|
-
{ trails:
|
|
496
|
+
{ trails: scenarioResourceTrailsMap }
|
|
497
497
|
);
|
|
498
498
|
});
|
|
499
499
|
|
|
500
|
-
describe('testCrosses explicit
|
|
500
|
+
describe('testCrosses explicit resource overrides', () => {
|
|
501
501
|
// eslint-disable-next-line jest/require-hook
|
|
502
502
|
testCrosses(
|
|
503
503
|
provisionRootTrail,
|
|
504
504
|
[
|
|
505
505
|
{
|
|
506
|
-
description: 'propagates explicit
|
|
506
|
+
description: 'propagates explicit resource overrides through cross',
|
|
507
507
|
expectValue: { childSource: 'override', rootSource: 'override' },
|
|
508
508
|
input: {},
|
|
509
509
|
},
|
|
510
510
|
],
|
|
511
511
|
{
|
|
512
|
-
|
|
512
|
+
resources: { 'db.mock.crosses': { source: 'override' } },
|
|
513
513
|
trails: provisionTrailsMap,
|
|
514
514
|
}
|
|
515
515
|
);
|
|
@@ -530,19 +530,19 @@ describe('testCrosses raw transformed input', () => {
|
|
|
530
530
|
);
|
|
531
531
|
});
|
|
532
532
|
|
|
533
|
-
describe('testCrosses
|
|
533
|
+
describe('testCrosses resource declarations', () => {
|
|
534
534
|
// eslint-disable-next-line jest/require-hook
|
|
535
535
|
testCrosses(
|
|
536
|
-
|
|
536
|
+
undeclaredResourceRootTrail,
|
|
537
537
|
[
|
|
538
538
|
{
|
|
539
|
-
description: 'fails when the root trail omits a required
|
|
539
|
+
description: 'fails when the root trail omits a required resource',
|
|
540
540
|
expectErr: InternalError,
|
|
541
|
-
expectErrMessage:
|
|
541
|
+
expectErrMessage: undeclaredCrossResource.id,
|
|
542
542
|
input: {},
|
|
543
543
|
},
|
|
544
544
|
],
|
|
545
|
-
{ trails:
|
|
545
|
+
{ trails: undeclaredResourceTrailsMap }
|
|
546
546
|
);
|
|
547
547
|
});
|
|
548
548
|
|
|
@@ -552,7 +552,7 @@ describe('testCrosses only resolves mocks for trails under test', () => {
|
|
|
552
552
|
provisionRootTrail,
|
|
553
553
|
[
|
|
554
554
|
{
|
|
555
|
-
description: 'unrelated
|
|
555
|
+
description: 'unrelated resource mocks are not resolved',
|
|
556
556
|
expectValue: { childSource: 'mock', rootSource: 'mock' },
|
|
557
557
|
input: {},
|
|
558
558
|
},
|
|
@@ -560,28 +560,28 @@ describe('testCrosses only resolves mocks for trails under test', () => {
|
|
|
560
560
|
{
|
|
561
561
|
trails: new Map<string, AnyTrail>([
|
|
562
562
|
...provisionTrailsMap.entries(),
|
|
563
|
-
...
|
|
563
|
+
...unrelatedResourceTrailsMap.entries(),
|
|
564
564
|
]),
|
|
565
565
|
}
|
|
566
566
|
);
|
|
567
567
|
});
|
|
568
568
|
|
|
569
|
-
describe('testCrosses fresh
|
|
569
|
+
describe('testCrosses fresh resource mocks per scenario', () => {
|
|
570
570
|
// eslint-disable-next-line jest/require-hook
|
|
571
571
|
testCrosses(
|
|
572
|
-
|
|
572
|
+
statefulResourceRootTrail,
|
|
573
573
|
[
|
|
574
574
|
{
|
|
575
|
-
description: 'first scenario gets a fresh
|
|
575
|
+
description: 'first scenario gets a fresh resource mock',
|
|
576
576
|
expectValue: { childCount: 1, rootCount: 1 },
|
|
577
577
|
input: {},
|
|
578
578
|
},
|
|
579
579
|
{
|
|
580
|
-
description: 'second scenario also gets a fresh
|
|
580
|
+
description: 'second scenario also gets a fresh resource mock',
|
|
581
581
|
expectValue: { childCount: 1, rootCount: 1 },
|
|
582
582
|
input: {},
|
|
583
583
|
},
|
|
584
584
|
],
|
|
585
|
-
{ trails:
|
|
585
|
+
{ trails: statefulResourceTrailsMap }
|
|
586
586
|
);
|
|
587
587
|
});
|
|
@@ -1,47 +1,163 @@
|
|
|
1
|
-
import { describe, test } from 'bun:test';
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
2
4
|
|
|
3
|
-
import { Result, trail, topo } from '@ontrails/core';
|
|
5
|
+
import { ConflictError, Result, trail, topo } from '@ontrails/core';
|
|
4
6
|
import { z } from 'zod';
|
|
5
7
|
|
|
6
8
|
import { testDetours } from '../detours.js';
|
|
7
9
|
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
// Test trails
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
|
|
12
10
|
const showTrail = trail('entity.show', {
|
|
13
11
|
blaze: (input: { id: string }) => Result.ok({ id: input.id }),
|
|
14
|
-
detours:
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
detours: [
|
|
13
|
+
{
|
|
14
|
+
on: ConflictError,
|
|
15
|
+
/* oxlint-disable-next-line require-await -- test stub */
|
|
16
|
+
recover: async () => Result.ok({ id: 'recovered' }),
|
|
17
|
+
},
|
|
18
|
+
],
|
|
17
19
|
input: z.object({ id: z.string() }),
|
|
18
20
|
});
|
|
19
21
|
|
|
20
|
-
const
|
|
21
|
-
blaze: () => Result.ok(
|
|
22
|
+
const noDetoursTrail = trail('entity.plain', {
|
|
23
|
+
blaze: () => Result.ok('ok'),
|
|
22
24
|
input: z.object({}),
|
|
23
25
|
});
|
|
24
26
|
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
+
const createBlankNamedDetourTrail = () => {
|
|
28
|
+
class BlankNamedConflictError extends ConflictError {}
|
|
29
|
+
|
|
30
|
+
Object.defineProperty(BlankNamedConflictError, 'name', { value: '' });
|
|
31
|
+
|
|
32
|
+
return trail('entity.blank-name', {
|
|
33
|
+
blaze: (input: { id: string }) => Result.ok({ id: input.id }),
|
|
34
|
+
detours: [
|
|
35
|
+
{
|
|
36
|
+
on: BlankNamedConflictError,
|
|
37
|
+
/* oxlint-disable-next-line require-await -- test stub */
|
|
38
|
+
recover: async () => Result.ok({ id: 'recovered' }),
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
input: z.object({ id: z.string() }),
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const repoTempDir = (): string =>
|
|
46
|
+
join(
|
|
47
|
+
resolve(import.meta.dir, '../..'),
|
|
48
|
+
'.tmp-tests',
|
|
49
|
+
`test-detours-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const generatedSuiteSource = (
|
|
53
|
+
variant: 'invalidOn' | 'invalidRecover' | 'shadowed'
|
|
54
|
+
): string => {
|
|
55
|
+
const shared = `import { ConflictError, Result, TrailsError, trail, topo } from '@ontrails/core';
|
|
56
|
+
import { testDetours } from '../../src/index.ts';
|
|
57
|
+
import { z } from 'zod';
|
|
58
|
+
|
|
59
|
+
const baseTrail = trail('entity.save', {
|
|
60
|
+
blaze: () => Result.err(new ConflictError('conflict')),
|
|
61
|
+
detours: [
|
|
62
|
+
{
|
|
63
|
+
on: ConflictError,
|
|
64
|
+
recover: async () => Result.ok({ recovered: true }),
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
input: z.object({}),
|
|
68
|
+
output: z.object({ recovered: z.boolean() }),
|
|
69
|
+
});
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
if (variant === 'invalidOn') {
|
|
73
|
+
return `${shared}
|
|
74
|
+
const candidate = {
|
|
75
|
+
...baseTrail,
|
|
76
|
+
detours: [
|
|
77
|
+
{
|
|
78
|
+
on: 'ConflictError',
|
|
79
|
+
recover: async () => Result.ok({ recovered: true }),
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
} as typeof baseTrail;
|
|
83
|
+
|
|
84
|
+
testDetours(topo('detour-invalid-on', { candidate } as Record<string, unknown>));
|
|
85
|
+
`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (variant === 'invalidRecover') {
|
|
89
|
+
return `${shared}
|
|
90
|
+
const candidate = {
|
|
91
|
+
...baseTrail,
|
|
92
|
+
detours: [
|
|
93
|
+
{
|
|
94
|
+
on: ConflictError,
|
|
95
|
+
recover: 'not callable',
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
} as typeof baseTrail;
|
|
99
|
+
|
|
100
|
+
testDetours(topo('detour-invalid-recover', { candidate } as Record<string, unknown>));
|
|
101
|
+
`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return `${shared}
|
|
105
|
+
const candidate = trail('entity.shadowed', {
|
|
106
|
+
blaze: () => Result.err(new ConflictError('conflict')),
|
|
107
|
+
detours: [
|
|
108
|
+
{
|
|
109
|
+
on: TrailsError,
|
|
110
|
+
recover: async () => Result.ok({ recovered: 'broad' }),
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
on: ConflictError,
|
|
114
|
+
recover: async () => Result.ok({ recovered: 'specific' }),
|
|
115
|
+
},
|
|
116
|
+
],
|
|
27
117
|
input: z.object({}),
|
|
118
|
+
output: z.object({ recovered: z.string() }),
|
|
28
119
|
});
|
|
29
120
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
121
|
+
testDetours(topo('detour-shadowed', { candidate }));
|
|
122
|
+
`;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const runGeneratedDetourSuite = (
|
|
126
|
+
variant: 'invalidOn' | 'invalidRecover' | 'shadowed'
|
|
127
|
+
): { readonly exitCode: number; readonly output: string } => {
|
|
128
|
+
const dir = repoTempDir();
|
|
129
|
+
const testFile = join(dir, `${variant}.test.ts`);
|
|
33
130
|
|
|
34
|
-
|
|
131
|
+
mkdirSync(dir, { recursive: true });
|
|
132
|
+
writeFileSync(testFile, generatedSuiteSource(variant));
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const proc = Bun.spawnSync({
|
|
136
|
+
cmd: ['bun', 'test', testFile, '--bail'],
|
|
137
|
+
cwd: resolve(import.meta.dir, '..', '..', '..'),
|
|
138
|
+
stderr: 'pipe',
|
|
139
|
+
stdout: 'pipe',
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
exitCode: proc.exitCode,
|
|
144
|
+
output: `${proc.stdout.toString()}\n${proc.stderr.toString()}`,
|
|
145
|
+
};
|
|
146
|
+
} finally {
|
|
147
|
+
rmSync(dir, { force: true, recursive: true });
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
describe('testDetours valid contracts', () => {
|
|
35
152
|
// eslint-disable-next-line jest/require-hook
|
|
36
153
|
testDetours(
|
|
37
154
|
topo('test-app', {
|
|
38
|
-
listTrail,
|
|
39
155
|
showTrail,
|
|
40
156
|
} as Record<string, unknown>)
|
|
41
157
|
);
|
|
42
158
|
});
|
|
43
159
|
|
|
44
|
-
describe('testDetours
|
|
160
|
+
describe('testDetours skips trails without detours', () => {
|
|
45
161
|
// eslint-disable-next-line jest/require-hook
|
|
46
162
|
testDetours(
|
|
47
163
|
topo('test-app', {
|
|
@@ -53,3 +169,44 @@ describe('testDetours: skips trails without detours', () => {
|
|
|
53
169
|
// Trail without detours is skipped -- no detour tests generated
|
|
54
170
|
});
|
|
55
171
|
});
|
|
172
|
+
|
|
173
|
+
describe('testDetours constructor handling', () => {
|
|
174
|
+
// eslint-disable-next-line jest/require-hook
|
|
175
|
+
testDetours(
|
|
176
|
+
topo('test-app', {
|
|
177
|
+
blankNamedDetourTrail: createBlankNamedDetourTrail(),
|
|
178
|
+
} as Record<string, unknown>)
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('testDetours invalid contract detection', () => {
|
|
183
|
+
test('fails when on is not an error constructor', () => {
|
|
184
|
+
const result = runGeneratedDetourSuite('invalidOn');
|
|
185
|
+
|
|
186
|
+
expect(result.exitCode).toBe(1);
|
|
187
|
+
expect(result.output).toContain(
|
|
188
|
+
'entity.save detour[0] must declare a real error constructor in on:'
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('fails when recover is not callable', () => {
|
|
193
|
+
const result = runGeneratedDetourSuite('invalidRecover');
|
|
194
|
+
|
|
195
|
+
expect(result.exitCode).toBe(1);
|
|
196
|
+
expect(result.output).toContain(
|
|
197
|
+
'entity.save detour[0] on ConflictError must declare a callable recover function'
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('fails when a later detour is shadowed by an earlier broader detour', () => {
|
|
202
|
+
const result = runGeneratedDetourSuite('shadowed');
|
|
203
|
+
|
|
204
|
+
expect(result.exitCode).toBe(1);
|
|
205
|
+
expect(result.output).toContain(
|
|
206
|
+
'entity.shadowed detour[1] on ConflictError'
|
|
207
|
+
);
|
|
208
|
+
expect(result.output).toContain(
|
|
209
|
+
'shadowed by earlier detour[0] on TrailsError'
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
});
|