@ontrails/testing 1.0.0-beta.3 → 1.0.0-beta.32

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 (91) hide show
  1. package/CHANGELOG.md +507 -8
  2. package/README.md +86 -22
  3. package/package.json +34 -5
  4. package/src/all-established.ts +168 -0
  5. package/src/all.ts +51 -12
  6. package/src/assertions.ts +253 -0
  7. package/src/cli.ts +6 -0
  8. package/src/composes.ts +433 -0
  9. package/src/context.ts +200 -14
  10. package/src/contracts.ts +46 -35
  11. package/src/detours.ts +155 -18
  12. package/src/effective-examples.ts +414 -0
  13. package/src/errors.ts +47 -0
  14. package/src/examples.ts +302 -177
  15. package/src/harness-cli.ts +83 -58
  16. package/src/harness-http.ts +341 -0
  17. package/src/harness-mcp.ts +46 -16
  18. package/src/http.ts +10 -0
  19. package/src/index.ts +22 -14
  20. package/src/logger.ts +3 -1
  21. package/src/mcp.ts +6 -0
  22. package/src/scenario.ts +375 -0
  23. package/src/signals.ts +221 -0
  24. package/src/surface-parity.ts +389 -0
  25. package/src/trail.ts +1 -1
  26. package/src/types.ts +24 -52
  27. package/.turbo/turbo-build.log +0 -1
  28. package/.turbo/turbo-lint.log +0 -3
  29. package/.turbo/turbo-typecheck.log +0 -1
  30. package/dist/all.d.ts +0 -30
  31. package/dist/all.d.ts.map +0 -1
  32. package/dist/all.js +0 -47
  33. package/dist/all.js.map +0 -1
  34. package/dist/assertions.d.ts +0 -49
  35. package/dist/assertions.d.ts.map +0 -1
  36. package/dist/assertions.js +0 -84
  37. package/dist/assertions.js.map +0 -1
  38. package/dist/context.d.ts +0 -19
  39. package/dist/context.d.ts.map +0 -1
  40. package/dist/context.js +0 -33
  41. package/dist/context.js.map +0 -1
  42. package/dist/contracts.d.ts +0 -16
  43. package/dist/contracts.d.ts.map +0 -1
  44. package/dist/contracts.js +0 -66
  45. package/dist/contracts.js.map +0 -1
  46. package/dist/detours.d.ts +0 -12
  47. package/dist/detours.d.ts.map +0 -1
  48. package/dist/detours.js +0 -30
  49. package/dist/detours.js.map +0 -1
  50. package/dist/examples.d.ts +0 -22
  51. package/dist/examples.d.ts.map +0 -1
  52. package/dist/examples.js +0 -187
  53. package/dist/examples.js.map +0 -1
  54. package/dist/harness-cli.d.ts +0 -21
  55. package/dist/harness-cli.d.ts.map +0 -1
  56. package/dist/harness-cli.js +0 -213
  57. package/dist/harness-cli.js.map +0 -1
  58. package/dist/harness-mcp.d.ts +0 -21
  59. package/dist/harness-mcp.d.ts.map +0 -1
  60. package/dist/harness-mcp.js +0 -50
  61. package/dist/harness-mcp.js.map +0 -1
  62. package/dist/hike.d.ts +0 -32
  63. package/dist/hike.d.ts.map +0 -1
  64. package/dist/hike.js +0 -169
  65. package/dist/hike.js.map +0 -1
  66. package/dist/index.d.ts +0 -14
  67. package/dist/index.d.ts.map +0 -1
  68. package/dist/index.js +0 -16
  69. package/dist/index.js.map +0 -1
  70. package/dist/logger.d.ts +0 -15
  71. package/dist/logger.d.ts.map +0 -1
  72. package/dist/logger.js +0 -87
  73. package/dist/logger.js.map +0 -1
  74. package/dist/trail.d.ts +0 -20
  75. package/dist/trail.d.ts.map +0 -1
  76. package/dist/trail.js +0 -80
  77. package/dist/trail.js.map +0 -1
  78. package/dist/types.d.ts +0 -80
  79. package/dist/types.d.ts.map +0 -1
  80. package/dist/types.js +0 -5
  81. package/dist/types.js.map +0 -1
  82. package/src/__tests__/context.test.ts +0 -60
  83. package/src/__tests__/contracts.test.ts +0 -93
  84. package/src/__tests__/detours.test.ts +0 -55
  85. package/src/__tests__/examples.test.ts +0 -176
  86. package/src/__tests__/hike.test.ts +0 -164
  87. package/src/__tests__/logger.test.ts +0 -136
  88. package/src/__tests__/trail.test.ts +0 -99
  89. package/src/hike.ts +0 -283
  90. package/tsconfig.json +0 -9
  91. package/tsconfig.tsbuildinfo +0 -1
package/src/context.ts CHANGED
@@ -2,11 +2,26 @@
2
2
  * Test context factory for creating TrailContext instances suitable for testing.
3
3
  */
4
4
 
5
- import type { TrailContext } from '@ontrails/core';
5
+ import type {
6
+ ComposeFn,
7
+ ResourceOverrideMap,
8
+ Topo,
9
+ TrailContext,
10
+ } from '@ontrails/core';
11
+ import {
12
+ Result,
13
+ buildComposeValidationSchema,
14
+ createResourceLookup,
15
+ passthroughTrace,
16
+ } from '@ontrails/core';
6
17
 
7
18
  import { createTestLogger } from './logger.js';
8
19
  import type { TestTrailContextOptions } from './types.js';
9
20
 
21
+ type MutableTrailContext = {
22
+ -readonly [K in keyof TrailContext]: TrailContext[K];
23
+ };
24
+
10
25
  // ---------------------------------------------------------------------------
11
26
  // createTestContext
12
27
  // ---------------------------------------------------------------------------
@@ -16,27 +31,198 @@ import type { TestTrailContextOptions } from './types.js';
16
31
  *
17
32
  * - `requestId`: `"test-request-001"` (deterministic)
18
33
  * - `logger`: a `TestLogger` that captures entries
19
- * - `signal`: a non-aborted AbortController signal
34
+ * - `abortSignal`: a non-aborted AbortController signal
20
35
  */
21
36
  export const createTestContext = (
22
37
  overrides?: TestTrailContextOptions
23
- ): TrailContext => ({
24
- env: overrides?.env ?? { TRAILS_ENV: 'test' },
25
- logger: overrides?.logger ?? createTestLogger(),
26
- requestId: overrides?.requestId ?? 'test-request-001',
27
- signal: overrides?.signal ?? new AbortController().signal,
28
- workspaceRoot: overrides?.cwd ?? process.cwd(),
38
+ ): TrailContext => {
39
+ const cwd = overrides?.cwd ?? process.cwd();
40
+ const ctx = {
41
+ abortSignal: overrides?.abortSignal ?? new AbortController().signal,
42
+ cwd,
43
+ env: overrides?.env ?? { TRAILS_ENV: 'test' },
44
+ extensions: undefined,
45
+ logger: overrides?.logger ?? createTestLogger(),
46
+ requestId: overrides?.requestId ?? 'test-request-001',
47
+ trace: overrides?.trace ?? passthroughTrace,
48
+ workspaceRoot: cwd,
49
+ } as MutableTrailContext;
50
+ const lookup = createResourceLookup(() => ctx);
51
+ ctx.resource = lookup;
52
+ return ctx;
53
+ };
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // createComposeContext
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export interface CreateComposeContextOptions {
60
+ readonly responses?: Record<string, Result<unknown, Error>> | undefined;
61
+ }
62
+
63
+ /** Minimal permit shape returned by the create function. */
64
+ export interface MinimalPermit {
65
+ readonly id: string;
66
+ readonly scopes: readonly string[];
67
+ }
68
+
69
+ /** Trail shape consumed by the create function — avoids importing permits. */
70
+ export interface PermittedTrail {
71
+ readonly permit?:
72
+ | { readonly scopes: readonly string[] }
73
+ | 'public'
74
+ | undefined;
75
+ }
76
+
77
+ export interface TestExecutionOptions {
78
+ readonly ctx?: Partial<TrailContext> | undefined;
79
+ readonly resources?: ResourceOverrideMap | undefined;
80
+ /**
81
+ * When true, disables automatic permit creation. Tests must provide
82
+ * explicit permits.
83
+ */
84
+ readonly strictPermits?: boolean | undefined;
85
+ /**
86
+ * Optional function to create a test permit for a trail. When provided,
87
+ * called for each trail with a non-public `permit` requirement.
88
+ * Returning `undefined` skips creation for that trail.
89
+ *
90
+ * A default inline implementation is used when this is not provided,
91
+ * keeping the testing package free of a hard dependency on `@ontrails/permits`.
92
+ */
93
+ readonly createPermit?: (trail: PermittedTrail) => MinimalPermit | undefined;
94
+ }
95
+
96
+ /**
97
+ * Create a mock `ComposeFn` for testing composite trails.
98
+ *
99
+ * Returns preconfigured `Result` values keyed by trail ID. Calls to
100
+ * unregistered IDs return `Result.err` with a descriptive message.
101
+ *
102
+ * @example
103
+ * ```ts
104
+ * const compose = createComposeContext({
105
+ * responses: { 'entity.add': Result.ok({ id: '1', name: 'Alpha' }) },
106
+ * });
107
+ * const ctx = { ...createTestContext(), compose };
108
+ * ```
109
+ */
110
+ export const createComposeContext = (
111
+ options?: CreateComposeContextOptions
112
+ ): ComposeFn => {
113
+ const responses = options?.responses ?? {};
114
+ const respondToCompose = <O>(
115
+ idOrTrail: string | { readonly id: string }
116
+ ): Promise<Result<O, Error>> => {
117
+ const id = typeof idOrTrail === 'string' ? idOrTrail : idOrTrail.id;
118
+ const response = responses[id];
119
+ if (response === undefined) {
120
+ return Promise.resolve(
121
+ Result.err(
122
+ new Error(`No mock response for compose("${id}")`)
123
+ ) as Result<O, Error>
124
+ );
125
+ }
126
+ return Promise.resolve(response as Result<O, Error>);
127
+ };
128
+ const compose = (async (
129
+ idOrTrail:
130
+ | string
131
+ | { readonly id: string }
132
+ | readonly (readonly [string | { readonly id: string }, unknown])[],
133
+ _input?: unknown
134
+ ) => {
135
+ if (Array.isArray(idOrTrail)) {
136
+ return await Promise.all(
137
+ idOrTrail.map(([target]) => respondToCompose(target))
138
+ );
139
+ }
140
+
141
+ return await respondToCompose(
142
+ idOrTrail as string | { readonly id: string }
143
+ );
144
+ }) as ComposeFn;
145
+ return compose;
146
+ };
147
+
148
+ /**
149
+ * Default permit creator — reads `trail.permit.scopes` and produces a
150
+ * minimal permit object. No dependency on `@ontrails/permits`.
151
+ */
152
+ export const defaultCreatePermit = (
153
+ trail: PermittedTrail
154
+ ): MinimalPermit | undefined => {
155
+ if (!trail.permit || trail.permit === 'public') {
156
+ return undefined;
157
+ }
158
+ return { id: 'test-permit', scopes: trail.permit.scopes };
159
+ };
160
+
161
+ const isTestExecutionOptions = (
162
+ input: Partial<TrailContext> | TestExecutionOptions | undefined
163
+ ): input is TestExecutionOptions =>
164
+ input !== undefined &&
165
+ (Object.hasOwn(input, 'ctx') ||
166
+ Object.hasOwn(input, 'resources') ||
167
+ Object.hasOwn(input, 'strictPermits') ||
168
+ Object.hasOwn(input, 'createPermit'));
169
+
170
+ export const normalizeTestExecutionOptions = (
171
+ input?: Partial<TrailContext> | TestExecutionOptions
172
+ ): TestExecutionOptions =>
173
+ isTestExecutionOptions(input) ? input : { ctx: input };
174
+
175
+ export const mergeResourceOverrides = (
176
+ autoResolved: ResourceOverrideMap,
177
+ ctx: Partial<TrailContext> | undefined,
178
+ explicit: ResourceOverrideMap | undefined
179
+ ): ResourceOverrideMap => ({
180
+ ...autoResolved,
181
+ ...ctx?.extensions,
182
+ ...explicit,
29
183
  });
30
184
 
185
+ const buildMockResources = async (app: Topo): Promise<ResourceOverrideMap> => {
186
+ const resources: Record<string, unknown> = {};
187
+ for (const declaredResource of app.listResources()) {
188
+ if (declaredResource.unmockable !== undefined) {
189
+ continue;
190
+ }
191
+ if (!declaredResource.mock) {
192
+ continue;
193
+ }
194
+ resources[declaredResource.id] = await declaredResource.mock();
195
+ }
196
+ return resources;
197
+ };
198
+
199
+ export const createMockResources = async (
200
+ app: Topo
201
+ ): Promise<ResourceOverrideMap> => await buildMockResources(app);
202
+
203
+ // Re-export from core so existing consumers of this module continue to work.
204
+ export { buildComposeValidationSchema };
205
+
31
206
  /**
32
207
  * Merge a Partial<TrailContext> into a test context.
33
208
  * Used internally when the public API accepts Partial<TrailContext>.
34
209
  */
35
- export const mergeTestContext = (ctx?: Partial<TrailContext>): TrailContext => {
36
- if (ctx === undefined) {
37
- return createTestContext();
38
- }
39
-
210
+ export const mergeTestContext = (
211
+ ctx?: Partial<TrailContext>,
212
+ resources?: ResourceOverrideMap
213
+ ): TrailContext => {
40
214
  const base = createTestContext();
41
- return { ...base, ...ctx };
215
+ const extensions = {
216
+ ...base.extensions,
217
+ ...ctx?.extensions,
218
+ ...resources,
219
+ };
220
+ const merged = {
221
+ ...base,
222
+ ...ctx,
223
+ extensions: Object.keys(extensions).length === 0 ? undefined : extensions,
224
+ } as MutableTrailContext;
225
+ const lookup = createResourceLookup(() => merged);
226
+ merged.resource = lookup;
227
+ return merged;
42
228
  };
package/src/contracts.ts CHANGED
@@ -1,36 +1,32 @@
1
1
  /**
2
2
  * testContracts — output schema verification.
3
3
  *
4
- * For every trail and hike that has both examples and an output schema,
5
- * run each example and validate the implementation output against
4
+ * For every trail that has both examples and an output schema,
5
+ * run each example and validate the Result.ok value against
6
6
  * the declared schema.
7
7
  */
8
8
 
9
9
  import { describe, test } from 'bun:test';
10
10
 
11
11
  import type { Topo, TrailExample, Trail, TrailContext } from '@ontrails/core';
12
- import { formatZodIssues, validateInput } from '@ontrails/core';
12
+ import { executeTrail, formatZodIssues, validateInput } from '@ontrails/core';
13
13
  import type { z } from 'zod';
14
14
 
15
15
  import { expectOk } from './assertions.js';
16
- import { mergeTestContext } from './context.js';
16
+ import {
17
+ mergeResourceOverrides,
18
+ mergeTestContext,
19
+ normalizeTestExecutionOptions,
20
+ createMockResources,
21
+ } from './context.js';
22
+ import type { TestExecutionOptions } from './context.js';
23
+ import type { TrailExampleTarget } from './effective-examples.js';
24
+ import { deriveTrailExampleTargets } from './effective-examples.js';
17
25
 
18
26
  // ---------------------------------------------------------------------------
19
27
  // Helpers
20
28
  // ---------------------------------------------------------------------------
21
29
 
22
- /** Check if a trail/hike requires follow() but the context doesn't provide it. */
23
- const needsFollowContext = (
24
- t: unknown,
25
- resolveCtx: () => Partial<TrailContext> | undefined
26
- ): boolean => {
27
- const spec = t as { follows?: readonly string[] };
28
- if (!spec.follows || spec.follows.length === 0) {
29
- return false;
30
- }
31
- return !resolveCtx()?.follow;
32
- };
33
-
34
30
  const validateOutputSchema = (
35
31
  outputSchema: z.ZodType,
36
32
  value: unknown,
@@ -46,48 +42,63 @@ const validateOutputSchema = (
46
42
  }
47
43
  };
48
44
 
45
+ type ContractExampleTarget = TrailExampleTarget & {
46
+ readonly output: z.ZodType;
47
+ };
48
+
49
+ const hasContractExamples = (
50
+ target: TrailExampleTarget
51
+ ): target is ContractExampleTarget =>
52
+ target.output !== undefined && target.examples.length > 0;
53
+
49
54
  // ---------------------------------------------------------------------------
50
55
  // testContracts
51
56
  // ---------------------------------------------------------------------------
52
57
 
53
58
  /**
54
- * Verify that every trail and hike implementation output matches its declared
55
- * output schema. Catches implementation-schema drift.
59
+ * Verify that every successful trail result matches its declared
60
+ * output schema. Catches output-schema drift.
56
61
  *
57
- * Trails and hikes without output schemas or examples are skipped.
62
+ * Trails without output schemas or examples are skipped.
58
63
  */
59
64
  export const testContracts = (
60
65
  app: Topo,
61
- ctxOrFactory?: Partial<TrailContext> | (() => Partial<TrailContext>)
66
+ ctxOrFactory?:
67
+ | Partial<TrailContext>
68
+ | TestExecutionOptions
69
+ | (() => Partial<TrailContext> | TestExecutionOptions)
62
70
  ): void => {
63
- const resolveCtx =
71
+ const resolveInput =
64
72
  typeof ctxOrFactory === 'function' ? ctxOrFactory : () => ctxOrFactory;
65
- const allEntries = app.list() as Trail<unknown, unknown>[];
73
+ const allEntries = (app.list() as Trail<unknown, unknown, unknown>[])
74
+ .flatMap(deriveTrailExampleTargets)
75
+ .filter(hasContractExamples);
66
76
 
67
77
  describe('contracts', () => {
68
78
  describe.each(allEntries)('$id', (t) => {
69
- if (t.output === undefined) {
70
- return;
71
- }
72
- if (t.examples === undefined || t.examples.length === 0) {
73
- return;
74
- }
75
- if (needsFollowContext(t, resolveCtx)) {
76
- return;
77
- }
78
-
79
79
  const { examples, output: outputSchema } = t;
80
80
  const successExamples = examples.filter((e) => e.error === undefined);
81
81
 
82
82
  test.each(successExamples)(
83
83
  'contract: $name',
84
84
  async (example: TrailExample<unknown, unknown>) => {
85
- const testCtx = mergeTestContext(resolveCtx());
85
+ const resolved = normalizeTestExecutionOptions(resolveInput());
86
+ const resources = mergeResourceOverrides(
87
+ await createMockResources(app),
88
+ resolved.ctx,
89
+ resolved.resources
90
+ );
91
+ const testCtx = mergeTestContext(resolved.ctx);
86
92
 
87
93
  const validated = validateInput(t.input, example.input);
88
- const validatedInput = expectOk(validated);
94
+ expectOk(validated);
89
95
 
90
- const result = await t.implementation(validatedInput, testCtx);
96
+ const result = await executeTrail(t.trail, example.input, {
97
+ ctx: testCtx,
98
+ resources,
99
+ topo: app,
100
+ ...(t.version === undefined ? {} : { version: t.version }),
101
+ });
91
102
  const resultValue = expectOk(result);
92
103
 
93
104
  validateOutputSchema(outputSchema, resultValue, t.id, example.name);
package/src/detours.ts CHANGED
@@ -1,42 +1,179 @@
1
1
  /**
2
- * testDetours — verify that all detour targets exist in the topo.
2
+ * testDetours — validate the live detour contract for every trail.
3
3
  *
4
- * Pure structural validation. No implementation execution needed.
4
+ * Pure structural validation. No blaze or detour recovery execution needed.
5
5
  */
6
6
 
7
- import { describe, expect, test } from 'bun:test';
7
+ import { describe, test } from 'bun:test';
8
8
 
9
+ import { TrailsError } from '@ontrails/core';
9
10
  import type { Topo, Trail } from '@ontrails/core';
10
11
 
11
- // ---------------------------------------------------------------------------
12
- // testDetours
13
- // ---------------------------------------------------------------------------
12
+ interface RuntimeDetour {
13
+ readonly on?: unknown;
14
+ readonly recover?: unknown;
15
+ }
16
+
17
+ const isErrorConstructor = (
18
+ value: unknown
19
+ ): value is abstract new (...args: never[]) => Error => {
20
+ if (typeof value !== 'function') {
21
+ return false;
22
+ }
23
+
24
+ const { prototype } = value as { prototype?: unknown };
25
+ return prototype instanceof Error;
26
+ };
27
+
28
+ const detourLabel = (trailId: string, index: number, detour: RuntimeDetour) => {
29
+ if (typeof detour.on === 'function') {
30
+ const { name } = detour.on as { name?: unknown };
31
+ if (typeof name === 'string' && name.length > 0) {
32
+ return `${trailId} detour[${index}] on ${name}`;
33
+ }
34
+ }
35
+
36
+ return `${trailId} detour[${index}]`;
37
+ };
38
+
39
+ const assertValidOn = (
40
+ trailId: string,
41
+ index: number,
42
+ detour: RuntimeDetour
43
+ ): void => {
44
+ if (isErrorConstructor(detour.on)) {
45
+ return;
46
+ }
47
+
48
+ throw new Error(
49
+ `${detourLabel(trailId, index, detour)} must declare a real error constructor in on:`
50
+ );
51
+ };
52
+
53
+ const assertCallableRecover = (
54
+ trailId: string,
55
+ index: number,
56
+ detour: RuntimeDetour
57
+ ): void => {
58
+ if (typeof detour.recover === 'function') {
59
+ return;
60
+ }
61
+
62
+ throw new Error(
63
+ `${detourLabel(trailId, index, detour)} must declare a callable recover function`
64
+ );
65
+ };
66
+
67
+ const sameOrSubtype = (
68
+ candidate: abstract new (...args: never[]) => Error,
69
+ ancestor: abstract new (...args: never[]) => Error
70
+ ): boolean => {
71
+ if (candidate === ancestor) {
72
+ return true;
73
+ }
74
+
75
+ let current = Object.getPrototypeOf(candidate.prototype);
76
+ while (current && typeof current === 'object') {
77
+ const ctor = (current as { constructor?: unknown }).constructor;
78
+ if (ctor === ancestor) {
79
+ return true;
80
+ }
81
+ current = Object.getPrototypeOf(current);
82
+ }
83
+
84
+ return false;
85
+ };
86
+
87
+ const getShadowingDetour = (
88
+ detours: readonly RuntimeDetour[],
89
+ index: number
90
+ ):
91
+ | {
92
+ readonly index: number;
93
+ readonly on: abstract new (...args: never[]) => Error;
94
+ }
95
+ | undefined => {
96
+ const detour = detours[index];
97
+ if (!detour || !isErrorConstructor(detour.on)) {
98
+ return undefined;
99
+ }
100
+
101
+ for (let previousIndex = 0; previousIndex < index; previousIndex += 1) {
102
+ const previous = detours[previousIndex];
103
+ if (!previous || !isErrorConstructor(previous.on)) {
104
+ continue;
105
+ }
106
+
107
+ if (sameOrSubtype(detour.on, previous.on)) {
108
+ return { index: previousIndex, on: previous.on };
109
+ }
110
+ }
111
+
112
+ return undefined;
113
+ };
114
+
115
+ const assertNotShadowed = (
116
+ trailId: string,
117
+ detours: readonly RuntimeDetour[],
118
+ index: number
119
+ ): void => {
120
+ const detour = detours[index];
121
+ if (!detour || !isErrorConstructor(detour.on)) {
122
+ return;
123
+ }
124
+
125
+ const shadowing = getShadowingDetour(detours, index);
126
+ if (!shadowing) {
127
+ return;
128
+ }
129
+
130
+ const previousName = shadowing.on.name || TrailsError.name;
131
+ const currentName = detour.on.name || TrailsError.name;
132
+ throw new Error(
133
+ `${trailId} detour[${index}] on ${currentName} is shadowed by earlier detour[${shadowing.index}] on ${previousName}`
134
+ );
135
+ };
14
136
 
15
137
  /**
16
- * Verify that every trail's detour targets reference trails that
17
- * actually exist in the app's topo.
138
+ * Verify that every trail's detours match the live runtime contract:
139
+ * `on` must be an error constructor, `recover` must be callable, and
140
+ * later detours must not be shadowed by earlier broader `on` types.
18
141
  */
19
142
  export const testDetours = (app: Topo): void => {
20
143
  const trailEntries = [...app.trails];
21
144
 
22
145
  describe('detours', () => {
23
146
  describe.each(trailEntries)('%s', (_id, trailDef) => {
24
- const t = trailDef as Trail<unknown, unknown>;
147
+ const trail = trailDef as Trail<unknown, unknown, unknown>;
25
148
 
26
- if (t.detours === undefined) {
149
+ if (trail.detours.length === 0) {
27
150
  return;
28
151
  }
29
152
 
30
- const { detours } = t;
31
- const testCases = Object.entries(detours).flatMap(
32
- ([detourName, targets]) =>
33
- targets.map((targetId) => ({ detourName, targetId }))
153
+ const detourCases = trail.detours.map((detour, index) => ({
154
+ detour,
155
+ index,
156
+ trailId: trail.id,
157
+ }));
158
+
159
+ test.each(detourCases)(
160
+ '$trailId detour[$index] uses an error constructor',
161
+ ({ detour, index, trailId }) => {
162
+ assertValidOn(trailId, index, detour);
163
+ }
164
+ );
165
+
166
+ test.each(detourCases)(
167
+ '$trailId detour[$index] provides a callable recover',
168
+ ({ detour, index, trailId }) => {
169
+ assertCallableRecover(trailId, index, detour);
170
+ }
34
171
  );
35
172
 
36
- test.each(testCases)(
37
- 'detour "$detourName" -> "$targetId" exists',
38
- ({ targetId }) => {
39
- expect(app.has(targetId)).toBe(true);
173
+ test.each(detourCases)(
174
+ '$trailId detour[$index] is not shadowed by an earlier detour',
175
+ ({ index, trailId }) => {
176
+ assertNotShadowed(trailId, trail.detours, index);
40
177
  }
41
178
  );
42
179
  });