@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.
- package/CHANGELOG.md +507 -8
- package/README.md +86 -22
- package/package.json +34 -5
- package/src/all-established.ts +168 -0
- package/src/all.ts +51 -12
- package/src/assertions.ts +253 -0
- package/src/cli.ts +6 -0
- package/src/composes.ts +433 -0
- package/src/context.ts +200 -14
- package/src/contracts.ts +46 -35
- package/src/detours.ts +155 -18
- package/src/effective-examples.ts +414 -0
- package/src/errors.ts +47 -0
- package/src/examples.ts +302 -177
- package/src/harness-cli.ts +83 -58
- package/src/harness-http.ts +341 -0
- package/src/harness-mcp.ts +46 -16
- package/src/http.ts +10 -0
- package/src/index.ts +22 -14
- package/src/logger.ts +3 -1
- package/src/mcp.ts +6 -0
- package/src/scenario.ts +375 -0
- package/src/signals.ts +221 -0
- package/src/surface-parity.ts +389 -0
- package/src/trail.ts +1 -1
- package/src/types.ts +24 -52
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/dist/all.d.ts +0 -30
- package/dist/all.d.ts.map +0 -1
- package/dist/all.js +0 -47
- package/dist/all.js.map +0 -1
- package/dist/assertions.d.ts +0 -49
- package/dist/assertions.d.ts.map +0 -1
- package/dist/assertions.js +0 -84
- package/dist/assertions.js.map +0 -1
- package/dist/context.d.ts +0 -19
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js +0 -33
- package/dist/context.js.map +0 -1
- package/dist/contracts.d.ts +0 -16
- package/dist/contracts.d.ts.map +0 -1
- package/dist/contracts.js +0 -66
- package/dist/contracts.js.map +0 -1
- package/dist/detours.d.ts +0 -12
- package/dist/detours.d.ts.map +0 -1
- package/dist/detours.js +0 -30
- package/dist/detours.js.map +0 -1
- package/dist/examples.d.ts +0 -22
- package/dist/examples.d.ts.map +0 -1
- package/dist/examples.js +0 -187
- package/dist/examples.js.map +0 -1
- package/dist/harness-cli.d.ts +0 -21
- package/dist/harness-cli.d.ts.map +0 -1
- package/dist/harness-cli.js +0 -213
- package/dist/harness-cli.js.map +0 -1
- package/dist/harness-mcp.d.ts +0 -21
- package/dist/harness-mcp.d.ts.map +0 -1
- package/dist/harness-mcp.js +0 -50
- package/dist/harness-mcp.js.map +0 -1
- package/dist/hike.d.ts +0 -32
- package/dist/hike.d.ts.map +0 -1
- package/dist/hike.js +0 -169
- package/dist/hike.js.map +0 -1
- package/dist/index.d.ts +0 -14
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -16
- package/dist/index.js.map +0 -1
- package/dist/logger.d.ts +0 -15
- package/dist/logger.d.ts.map +0 -1
- package/dist/logger.js +0 -87
- package/dist/logger.js.map +0 -1
- package/dist/trail.d.ts +0 -20
- package/dist/trail.d.ts.map +0 -1
- package/dist/trail.js +0 -80
- package/dist/trail.js.map +0 -1
- package/dist/types.d.ts +0 -80
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -5
- package/dist/types.js.map +0 -1
- package/src/__tests__/context.test.ts +0 -60
- package/src/__tests__/contracts.test.ts +0 -93
- package/src/__tests__/detours.test.ts +0 -55
- package/src/__tests__/examples.test.ts +0 -176
- package/src/__tests__/hike.test.ts +0 -164
- package/src/__tests__/logger.test.ts +0 -136
- package/src/__tests__/trail.test.ts +0 -99
- package/src/hike.ts +0 -283
- package/tsconfig.json +0 -9
- 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 {
|
|
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
|
-
* - `
|
|
34
|
+
* - `abortSignal`: a non-aborted AbortController signal
|
|
20
35
|
*/
|
|
21
36
|
export const createTestContext = (
|
|
22
37
|
overrides?: TestTrailContextOptions
|
|
23
|
-
): TrailContext =>
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 = (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
210
|
+
export const mergeTestContext = (
|
|
211
|
+
ctx?: Partial<TrailContext>,
|
|
212
|
+
resources?: ResourceOverrideMap
|
|
213
|
+
): TrailContext => {
|
|
40
214
|
const base = createTestContext();
|
|
41
|
-
|
|
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
|
|
5
|
-
* run each example and validate the
|
|
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 {
|
|
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
|
|
55
|
-
* output schema. Catches
|
|
59
|
+
* Verify that every successful trail result matches its declared
|
|
60
|
+
* output schema. Catches output-schema drift.
|
|
56
61
|
*
|
|
57
|
-
* Trails
|
|
62
|
+
* Trails without output schemas or examples are skipped.
|
|
58
63
|
*/
|
|
59
64
|
export const testContracts = (
|
|
60
65
|
app: Topo,
|
|
61
|
-
ctxOrFactory?:
|
|
66
|
+
ctxOrFactory?:
|
|
67
|
+
| Partial<TrailContext>
|
|
68
|
+
| TestExecutionOptions
|
|
69
|
+
| (() => Partial<TrailContext> | TestExecutionOptions)
|
|
62
70
|
): void => {
|
|
63
|
-
const
|
|
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
|
|
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
|
-
|
|
94
|
+
expectOk(validated);
|
|
89
95
|
|
|
90
|
-
const result = await t.
|
|
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 —
|
|
2
|
+
* testDetours — validate the live detour contract for every trail.
|
|
3
3
|
*
|
|
4
|
-
* Pure structural validation. No
|
|
4
|
+
* Pure structural validation. No blaze or detour recovery execution needed.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { describe,
|
|
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
|
-
|
|
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
|
|
17
|
-
*
|
|
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
|
|
147
|
+
const trail = trailDef as Trail<unknown, unknown, unknown>;
|
|
25
148
|
|
|
26
|
-
if (
|
|
149
|
+
if (trail.detours.length === 0) {
|
|
27
150
|
return;
|
|
28
151
|
}
|
|
29
152
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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(
|
|
37
|
-
'detour
|
|
38
|
-
({
|
|
39
|
-
|
|
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
|
});
|