@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.
Files changed (83) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +28 -0
  3. package/README.md +11 -11
  4. package/dist/all.d.ts +10 -5
  5. package/dist/all.d.ts.map +1 -1
  6. package/dist/all.js +78 -26
  7. package/dist/all.js.map +1 -1
  8. package/dist/assertions.d.ts +23 -0
  9. package/dist/assertions.d.ts.map +1 -1
  10. package/dist/assertions.js +154 -0
  11. package/dist/assertions.js.map +1 -1
  12. package/dist/context.d.ts +17 -16
  13. package/dist/context.d.ts.map +1 -1
  14. package/dist/context.js +31 -20
  15. package/dist/context.js.map +1 -1
  16. package/dist/contracts.d.ts.map +1 -1
  17. package/dist/contracts.js +9 -5
  18. package/dist/contracts.js.map +1 -1
  19. package/dist/crosses.d.ts +4 -4
  20. package/dist/crosses.d.ts.map +1 -1
  21. package/dist/crosses.js +49 -39
  22. package/dist/crosses.js.map +1 -1
  23. package/dist/detours.d.ts +5 -4
  24. package/dist/detours.d.ts.map +1 -1
  25. package/dist/detours.js +93 -14
  26. package/dist/detours.js.map +1 -1
  27. package/dist/effective-examples.d.ts +30 -0
  28. package/dist/effective-examples.d.ts.map +1 -0
  29. package/dist/effective-examples.js +227 -0
  30. package/dist/effective-examples.js.map +1 -0
  31. package/dist/examples.d.ts +1 -1
  32. package/dist/examples.d.ts.map +1 -1
  33. package/dist/examples.js +79 -41
  34. package/dist/examples.js.map +1 -1
  35. package/dist/harness-cli.d.ts +3 -3
  36. package/dist/harness-cli.d.ts.map +1 -1
  37. package/dist/harness-cli.js +25 -33
  38. package/dist/harness-cli.js.map +1 -1
  39. package/dist/harness-mcp.d.ts +3 -3
  40. package/dist/harness-mcp.d.ts.map +1 -1
  41. package/dist/harness-mcp.js +9 -8
  42. package/dist/harness-mcp.js.map +1 -1
  43. package/dist/index.d.ts +6 -4
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +5 -2
  46. package/dist/index.js.map +1 -1
  47. package/dist/scenario.d.ts +37 -0
  48. package/dist/scenario.d.ts.map +1 -0
  49. package/dist/scenario.js +235 -0
  50. package/dist/scenario.js.map +1 -0
  51. package/dist/types.d.ts +38 -5
  52. package/dist/types.d.ts.map +1 -1
  53. package/package.json +9 -5
  54. package/src/__tests__/all.test.ts +217 -29
  55. package/src/__tests__/context.test.ts +32 -12
  56. package/src/__tests__/contracts.test.ts +72 -18
  57. package/src/__tests__/crosses.test.ts +78 -78
  58. package/src/__tests__/detours.test.ts +176 -19
  59. package/src/__tests__/effective-examples.test.ts +203 -0
  60. package/src/__tests__/examples.test.ts +152 -50
  61. package/src/__tests__/harness-cli.test.ts +90 -0
  62. package/src/__tests__/harness-mcp.test.ts +37 -0
  63. package/src/__tests__/partial-match.test.ts +126 -0
  64. package/src/__tests__/scenario.test.ts +381 -0
  65. package/src/all.ts +149 -12
  66. package/src/assertions.ts +253 -0
  67. package/src/context.ts +64 -38
  68. package/src/contracts.ts +14 -8
  69. package/src/crosses.ts +93 -51
  70. package/src/detours.ts +155 -18
  71. package/src/effective-examples.ts +350 -0
  72. package/src/examples.ts +127 -59
  73. package/src/harness-cli.ts +33 -49
  74. package/src/harness-mcp.ts +9 -8
  75. package/src/index.ts +13 -3
  76. package/src/scenario.ts +370 -0
  77. package/src/types.ts +63 -5
  78. package/tsconfig.tests.json +10 -0
  79. package/tsconfig.tsbuildinfo +1 -1
  80. package/dist/follows.d.ts +0 -38
  81. package/dist/follows.d.ts.map +0 -1
  82. package/dist/follows.js +0 -212
  83. 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, provision, trail } from '@ontrails/core';
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 mockDbProvision = provision('db.mock.crosses', {
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('provision.leaf', {
236
+ const provisionLeafTrail = trail('resource.leaf', {
237
237
  blaze: (_input, ctx) =>
238
- Result.ok({ childSource: mockDbProvision.from(ctx).source }),
239
- description: 'Leaf trail that reads from a provision',
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
- provisions: [mockDbProvision],
242
+ resources: [mockDbResource],
243
243
  });
244
244
 
245
- const provisionRootTrail = trail('provision.root', {
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
- 'provision.leaf',
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: mockDbProvision.from(ctx).source,
260
+ rootSource: mockDbResource.from(ctx).source,
261
261
  });
262
262
  },
263
- crosses: ['provision.leaf'],
263
+ crosses: ['resource.leaf'],
264
264
  description:
265
- 'Root trail that reads from a provision and crosses a child trail',
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
- provisions: [mockDbProvision],
268
+ resources: [mockDbResource],
269
269
  });
270
270
 
271
271
  const provisionTrailsMap = new Map<string, AnyTrail>([
272
- ['provision.leaf', provisionLeafTrail],
272
+ ['resource.leaf', provisionLeafTrail],
273
273
  ]);
274
274
 
275
- const statefulMockDbProvision = provision('db.mock.crosses.stateful', {
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 statefulProvisionLeafTrail = trail('provision.stateful.leaf', {
280
+ const statefulResourceLeafTrail = trail('resource.stateful.leaf', {
281
281
  blaze: (_input, ctx) =>
282
- Result.ok({ childCount: statefulMockDbProvision.from(ctx).count }),
283
- description: 'Leaf trail that observes the current mock provision state',
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
- provisions: [statefulMockDbProvision],
286
+ resources: [statefulMockDbResource],
287
287
  });
288
288
 
289
- const statefulProvisionRootTrail = trail('provision.stateful.root', {
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 statefulProvision = statefulMockDbProvision.from(ctx);
296
- statefulProvision.count += 1;
295
+ const statefulResource = statefulMockDbResource.from(ctx);
296
+ statefulResource.count += 1;
297
297
 
298
298
  const childResult = await ctx.cross<{ childCount: number }>(
299
- 'provision.stateful.leaf',
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: statefulProvision.count,
308
+ rootCount: statefulResource.count,
309
309
  });
310
310
  },
311
- crosses: ['provision.stateful.leaf'],
311
+ crosses: ['resource.stateful.leaf'],
312
312
  description:
313
- 'Root trail that mutates a mocked provision and crosses a child trail',
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
- provisions: [statefulMockDbProvision],
316
+ resources: [statefulMockDbResource],
317
317
  });
318
318
 
319
- const statefulProvisionTrailsMap = new Map<string, AnyTrail>([
320
- ['provision.stateful.leaf', statefulProvisionLeafTrail],
319
+ const statefulResourceTrailsMap = new Map<string, AnyTrail>([
320
+ ['resource.stateful.leaf', statefulResourceLeafTrail],
321
321
  ]);
322
322
 
323
- const scenarioStateProvision = provision('db.mock.scenarios', {
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('provision.scenario.leaf', {
328
+ const scenarioLeafTrail = trail('resource.scenario.leaf', {
329
329
  blaze: (_input, ctx) =>
330
- Result.ok({ count: scenarioStateProvision.from(ctx).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
- provisions: [scenarioStateProvision],
334
+ resources: [scenarioStateResource],
335
335
  });
336
336
 
337
- const scenarioRootTrail = trail('provision.scenario.root', {
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 = scenarioStateProvision.from(ctx);
343
+ const state = scenarioStateResource.from(ctx);
344
344
  state.count += 1;
345
345
 
346
346
  const leafResult = await ctx.cross<{ count: number }>(
347
- 'provision.scenario.leaf',
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: ['provision.scenario.leaf'],
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
- provisions: [scenarioStateProvision],
360
+ resources: [scenarioStateResource],
361
361
  });
362
362
 
363
- const scenarioProvisionTrailsMap = new Map<string, AnyTrail>([
364
- ['provision.scenario.leaf', scenarioLeafTrail],
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 undeclaredCrossProvision = provision('db.undeclared.crosses', {
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 undeclaredProvisionLeafTrail = trail('provision.undeclared.leaf', {
407
+ const undeclaredResourceLeafTrail = trail('resource.undeclared.leaf', {
408
408
  blaze: (_input, ctx) =>
409
- Result.ok({ childSource: undeclaredCrossProvision.from(ctx).source }),
410
- description: 'Leaf trail that declares the shared provision',
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
- provisions: [undeclaredCrossProvision],
413
+ resources: [undeclaredCrossResource],
414
414
  });
415
415
 
416
- const undeclaredProvisionRootTrail = trail('provision.undeclared.root', {
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
- 'provision.undeclared.leaf',
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: undeclaredCrossProvision.from(ctx).source,
432
+ rootSource: undeclaredCrossResource.from(ctx).source,
433
433
  });
434
434
  },
435
- crosses: ['provision.undeclared.leaf'],
436
- description: 'Root trail that uses a provision without declaring it',
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 undeclaredProvisionTrailsMap = new Map<string, AnyTrail>([
442
- ['provision.undeclared.leaf', undeclaredProvisionLeafTrail],
441
+ const undeclaredResourceTrailsMap = new Map<string, AnyTrail>([
442
+ ['resource.undeclared.leaf', undeclaredResourceLeafTrail],
443
443
  ]);
444
444
 
445
- const unrelatedCrossProvision = provision('db.unrelated.crosses', {
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 unrelatedProvisionTrail = trail('provision.unrelated', {
452
+ const unrelatedResourceTrail = trail('resource.unrelated', {
453
453
  blaze: (_input, ctx) =>
454
- Result.ok({ source: unrelatedCrossProvision.from(ctx).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
- provisions: [unrelatedCrossProvision],
458
+ resources: [unrelatedCrossResource],
459
459
  });
460
460
 
461
- const unrelatedProvisionTrailsMap = new Map<string, AnyTrail>([
462
- ['provision.unrelated', unrelatedProvisionTrail],
461
+ const unrelatedResourceTrailsMap = new Map<string, AnyTrail>([
462
+ ['resource.unrelated', unrelatedResourceTrail],
463
463
  ]);
464
464
 
465
- describe('testCrosses provision mocks', () => {
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 provision mocks through cross',
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 provision mocks are fresh per scenario', () => {
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 provision',
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 provision',
491
+ description: 'second scenario also sees a fresh mutable resource',
492
492
  expectValue: { count: 1 },
493
493
  input: {},
494
494
  },
495
495
  ],
496
- { trails: scenarioProvisionTrailsMap }
496
+ { trails: scenarioResourceTrailsMap }
497
497
  );
498
498
  });
499
499
 
500
- describe('testCrosses explicit provision overrides', () => {
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 provision overrides through cross',
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
- provisions: { 'db.mock.crosses': { source: 'override' } },
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 provision declarations', () => {
533
+ describe('testCrosses resource declarations', () => {
534
534
  // eslint-disable-next-line jest/require-hook
535
535
  testCrosses(
536
- undeclaredProvisionRootTrail,
536
+ undeclaredResourceRootTrail,
537
537
  [
538
538
  {
539
- description: 'fails when the root trail omits a required provision',
539
+ description: 'fails when the root trail omits a required resource',
540
540
  expectErr: InternalError,
541
- expectErrMessage: undeclaredCrossProvision.id,
541
+ expectErrMessage: undeclaredCrossResource.id,
542
542
  input: {},
543
543
  },
544
544
  ],
545
- { trails: undeclaredProvisionTrailsMap }
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 provision mocks are not resolved',
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
- ...unrelatedProvisionTrailsMap.entries(),
563
+ ...unrelatedResourceTrailsMap.entries(),
564
564
  ]),
565
565
  }
566
566
  );
567
567
  });
568
568
 
569
- describe('testCrosses fresh provision mocks per scenario', () => {
569
+ describe('testCrosses fresh resource mocks per scenario', () => {
570
570
  // eslint-disable-next-line jest/require-hook
571
571
  testCrosses(
572
- statefulProvisionRootTrail,
572
+ statefulResourceRootTrail,
573
573
  [
574
574
  {
575
- description: 'first scenario gets a fresh provision mock',
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 provision mock',
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: statefulProvisionTrailsMap }
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
- related: ['entity.list'],
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 listTrail = trail('entity.list', {
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 noDetoursTrail = trail('entity.plain', {
26
- blaze: () => Result.ok('ok'),
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
- // Tests
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
- describe('testDetours: all targets exist', () => {
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: skips trails without detours', () => {
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
+ });