@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
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { contour, Result, trail } from '@ontrails/core';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
import { deriveTrailExamples } from '../effective-examples.js';
|
|
7
|
+
|
|
8
|
+
const requireContourExample = (
|
|
9
|
+
contourDef: { examples?: readonly Record<string, unknown>[] },
|
|
10
|
+
index: number
|
|
11
|
+
) => {
|
|
12
|
+
const example = contourDef.examples?.[index];
|
|
13
|
+
expect(example).toBeDefined();
|
|
14
|
+
if (!example) {
|
|
15
|
+
throw new Error(`Expected contour example at index ${index}`);
|
|
16
|
+
}
|
|
17
|
+
return example;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const userContour = contour(
|
|
21
|
+
'user',
|
|
22
|
+
{
|
|
23
|
+
email: z.string().email(),
|
|
24
|
+
id: z.string().uuid(),
|
|
25
|
+
name: z.string(),
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
examples: [
|
|
29
|
+
{
|
|
30
|
+
email: 'ada@example.com',
|
|
31
|
+
id: '550e8400-e29b-41d4-a716-446655440000',
|
|
32
|
+
name: 'Ada',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
email: 'grace@example.com',
|
|
36
|
+
id: '0f31f6ba-6ff0-41ce-9f6b-8d132b6c4b81',
|
|
37
|
+
name: 'Grace',
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
identity: 'id',
|
|
41
|
+
}
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const gistContour = contour(
|
|
45
|
+
'gist',
|
|
46
|
+
{
|
|
47
|
+
id: z.string().uuid(),
|
|
48
|
+
ownerId: userContour.id(),
|
|
49
|
+
title: z.string(),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
examples: [
|
|
53
|
+
{
|
|
54
|
+
id: '8f7ef40d-8234-4f73-8de8-4bb8366cf5c0',
|
|
55
|
+
ownerId: '550e8400-e29b-41d4-a716-446655440000',
|
|
56
|
+
title: 'Ada Gist',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'f104f457-b3fd-4643-87b9-d872c54b8a79',
|
|
60
|
+
ownerId: '0f31f6ba-6ff0-41ce-9f6b-8d132b6c4b81',
|
|
61
|
+
title: 'Grace Gist',
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
identity: 'id',
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
describe('deriveTrailExamples', () => {
|
|
69
|
+
test('prefers authored trail examples over contour-derived fixtures', () => {
|
|
70
|
+
const authoredExample = {
|
|
71
|
+
expected: { email: 'manual@example.com', name: 'Manual' },
|
|
72
|
+
input: { email: 'manual@example.com', name: 'Manual' },
|
|
73
|
+
name: 'Manual example',
|
|
74
|
+
} as const;
|
|
75
|
+
|
|
76
|
+
const trailDef = trail('user.manual', {
|
|
77
|
+
blaze: (input: { email: string; name: string }) => Result.ok(input),
|
|
78
|
+
contours: [userContour],
|
|
79
|
+
examples: [authoredExample],
|
|
80
|
+
input: z.object({ email: z.string().email(), name: z.string() }),
|
|
81
|
+
output: z.object({ email: z.string().email(), name: z.string() }),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(deriveTrailExamples(trailDef)).toEqual([authoredExample]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('derives single-contour fixtures and preserves full contour output', () => {
|
|
88
|
+
const firstUserExample = requireContourExample(userContour, 0);
|
|
89
|
+
|
|
90
|
+
const trailDef = trail('user.create', {
|
|
91
|
+
blaze: () => Result.ok(firstUserExample),
|
|
92
|
+
contours: [userContour],
|
|
93
|
+
input: userContour.pick({ email: true, name: true }),
|
|
94
|
+
output: userContour,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const examples = deriveTrailExamples(trailDef);
|
|
98
|
+
expect(examples).toHaveLength(2);
|
|
99
|
+
const firstRecord = firstUserExample as Record<string, unknown>;
|
|
100
|
+
expect(examples[0]).toEqual(
|
|
101
|
+
expect.objectContaining({
|
|
102
|
+
expected: firstUserExample,
|
|
103
|
+
// Input is projected down to the keys `trail.input` declares, so
|
|
104
|
+
// only `email` and `name` (from `.pick`) survive.
|
|
105
|
+
input: {
|
|
106
|
+
email: firstRecord.email,
|
|
107
|
+
name: firstRecord.name,
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('filters contour fixtures that do not satisfy the trail input schema', () => {
|
|
114
|
+
const trailDef = trail('user.slug-only', {
|
|
115
|
+
blaze: () => Result.ok({ slug: 'unused' }),
|
|
116
|
+
contours: [userContour],
|
|
117
|
+
input: z.object({ slug: z.string() }),
|
|
118
|
+
output: z.object({ slug: z.string() }),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(deriveTrailExamples(trailDef)).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('matches cross-contour references and exposes contour-prefixed aliases', () => {
|
|
125
|
+
const trailDef = trail('gist.star', {
|
|
126
|
+
blaze: (input: { gistId: string; userId: string }) => Result.ok(input),
|
|
127
|
+
contours: [userContour, gistContour],
|
|
128
|
+
input: z.object({
|
|
129
|
+
gistId: gistContour.id(),
|
|
130
|
+
userId: userContour.id(),
|
|
131
|
+
}),
|
|
132
|
+
output: z.object({
|
|
133
|
+
gistId: gistContour.shape.id,
|
|
134
|
+
userId: userContour.shape.id,
|
|
135
|
+
}),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const examples = deriveTrailExamples(trailDef);
|
|
139
|
+
expect(examples).toHaveLength(2);
|
|
140
|
+
// No contour fixture parses as the output schema on its own (the
|
|
141
|
+
// output expects `gistId` and `userId`, but the fixtures expose
|
|
142
|
+
// `id`, `ownerId`, etc.), so derived examples are left without an
|
|
143
|
+
// `expected` and fall back to schema-only validation at runtime.
|
|
144
|
+
expect(examples).toEqual([
|
|
145
|
+
{
|
|
146
|
+
input: {
|
|
147
|
+
gistId: '8f7ef40d-8234-4f73-8de8-4bb8366cf5c0',
|
|
148
|
+
userId: '550e8400-e29b-41d4-a716-446655440000',
|
|
149
|
+
},
|
|
150
|
+
name: expect.stringContaining('Derived fixture 1'),
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
input: {
|
|
154
|
+
gistId: 'f104f457-b3fd-4643-87b9-d872c54b8a79',
|
|
155
|
+
userId: '0f31f6ba-6ff0-41ce-9f6b-8d132b6c4b81',
|
|
156
|
+
},
|
|
157
|
+
name: expect.stringContaining('Derived fixture 2'),
|
|
158
|
+
},
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('derives fixtures for strict input schemas by projecting to known keys', () => {
|
|
163
|
+
const firstUserExample = requireContourExample(userContour, 0);
|
|
164
|
+
|
|
165
|
+
const trailDef = trail('user.strict-create', {
|
|
166
|
+
blaze: (input: { email: string; name: string }) => Result.ok(input),
|
|
167
|
+
contours: [userContour],
|
|
168
|
+
input: z.object({ email: z.string().email(), name: z.string() }).strict(),
|
|
169
|
+
output: z.object({ email: z.string().email(), name: z.string() }),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const examples = deriveTrailExamples(trailDef);
|
|
173
|
+
expect(examples).toHaveLength(2);
|
|
174
|
+
expect(examples[0]?.input).toEqual({
|
|
175
|
+
email: (firstUserExample as { email: string }).email,
|
|
176
|
+
name: (firstUserExample as { name: string }).name,
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('does not infer expected from merged input when no contour fixture matches the output', () => {
|
|
181
|
+
// The output schema is a subset of the input shape, so the merged
|
|
182
|
+
// derived input would parse as the output if we tried to infer from
|
|
183
|
+
// it — but that inference is semantically wrong because input and
|
|
184
|
+
// output have distinct meanings. Since no contour fixture matches
|
|
185
|
+
// the output schema (userContour fixtures carry id+email+name, and
|
|
186
|
+
// the strict output only accepts email+name), `expected` must be
|
|
187
|
+
// omitted entirely.
|
|
188
|
+
const trailDef = trail('user.create-strict-output', {
|
|
189
|
+
blaze: (input: { email: string; name: string }) => Result.ok(input),
|
|
190
|
+
contours: [userContour],
|
|
191
|
+
input: z.object({ email: z.string().email(), name: z.string() }),
|
|
192
|
+
output: z
|
|
193
|
+
.object({ email: z.string().email(), name: z.string() })
|
|
194
|
+
.strict(),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const examples = deriveTrailExamples(trailDef);
|
|
198
|
+
expect(examples.length).toBeGreaterThan(0);
|
|
199
|
+
for (const example of examples) {
|
|
200
|
+
expect(example.expected).toBeUndefined();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { describe, test } from 'bun:test';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
ConflictError,
|
|
5
|
+
contour,
|
|
6
|
+
DerivationError,
|
|
7
|
+
NotFoundError,
|
|
8
|
+
Result,
|
|
9
|
+
RetryExhaustedError,
|
|
10
|
+
resource,
|
|
11
|
+
trail,
|
|
12
|
+
topo,
|
|
13
|
+
} from '@ontrails/core';
|
|
4
14
|
import { z } from 'zod';
|
|
5
15
|
|
|
6
16
|
import { testExamples } from '../examples.js';
|
|
@@ -67,41 +77,68 @@ const noExamplesTrail = trail('noexamples', {
|
|
|
67
77
|
input: z.object({ x: z.number() }),
|
|
68
78
|
});
|
|
69
79
|
|
|
70
|
-
const
|
|
80
|
+
const taxonomyErrorTrail = trail('taxonomy.errors', {
|
|
81
|
+
blaze: (input: { type: 'derivation' | 'retry' }) => {
|
|
82
|
+
if (input.type === 'derivation') {
|
|
83
|
+
return Result.err(new DerivationError('could not derive projection'));
|
|
84
|
+
}
|
|
85
|
+
return Result.err(
|
|
86
|
+
new RetryExhaustedError(new ConflictError('version mismatch'), {
|
|
87
|
+
attempts: 3,
|
|
88
|
+
detour: 'ConflictError',
|
|
89
|
+
})
|
|
90
|
+
);
|
|
91
|
+
},
|
|
92
|
+
examples: [
|
|
93
|
+
{
|
|
94
|
+
error: 'DerivationError',
|
|
95
|
+
input: { type: 'derivation' },
|
|
96
|
+
name: 'Derivation failure returns DerivationError',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
error: 'RetryExhaustedError',
|
|
100
|
+
input: { type: 'retry' },
|
|
101
|
+
name: 'Exhausted detour returns RetryExhaustedError',
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
input: z.object({ type: z.enum(['derivation', 'retry']) }),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const mockDbResource = resource('db.mock.examples', {
|
|
71
108
|
create: () => Result.ok({ source: 'factory' }),
|
|
72
109
|
mock: () => ({ source: 'mock' }),
|
|
73
110
|
});
|
|
74
111
|
|
|
75
|
-
const
|
|
112
|
+
const mockResourceTrail = trail('resource.mocked', {
|
|
76
113
|
blaze: (_input, ctx) =>
|
|
77
|
-
Result.ok({ source:
|
|
78
|
-
description: 'Trail that reads from a mocked
|
|
114
|
+
Result.ok({ source: mockDbResource.from(ctx).source }),
|
|
115
|
+
description: 'Trail that reads from a mocked resource',
|
|
79
116
|
examples: [
|
|
80
117
|
{
|
|
81
118
|
expected: { source: 'mock' },
|
|
82
119
|
input: {},
|
|
83
|
-
name: 'Uses auto-resolved
|
|
120
|
+
name: 'Uses auto-resolved resource mock',
|
|
84
121
|
},
|
|
85
122
|
],
|
|
86
123
|
input: z.object({}),
|
|
87
124
|
output: z.object({ source: z.string() }),
|
|
88
|
-
|
|
125
|
+
resources: [mockDbResource],
|
|
89
126
|
});
|
|
90
127
|
|
|
91
|
-
const explicitOverrideTrail = trail('
|
|
128
|
+
const explicitOverrideTrail = trail('resource.override', {
|
|
92
129
|
blaze: (_input, ctx) =>
|
|
93
|
-
Result.ok({ source:
|
|
94
|
-
description: 'Trail whose
|
|
130
|
+
Result.ok({ source: mockDbResource.from(ctx).source }),
|
|
131
|
+
description: 'Trail whose resource mock can be overridden explicitly',
|
|
95
132
|
examples: [
|
|
96
133
|
{
|
|
97
134
|
expected: { source: 'override' },
|
|
98
135
|
input: {},
|
|
99
|
-
name: 'Explicit
|
|
136
|
+
name: 'Explicit resource override wins over mock factory',
|
|
100
137
|
},
|
|
101
138
|
],
|
|
102
139
|
input: z.object({}),
|
|
103
140
|
output: z.object({ source: z.string() }),
|
|
104
|
-
|
|
141
|
+
resources: [mockDbResource],
|
|
105
142
|
});
|
|
106
143
|
|
|
107
144
|
const transformedInputTrail = trail('example.transformed', {
|
|
@@ -120,62 +157,62 @@ const transformedInputTrail = trail('example.transformed', {
|
|
|
120
157
|
output: z.object({ value: z.number() }),
|
|
121
158
|
});
|
|
122
159
|
|
|
123
|
-
const ctxOverrideTrail = trail('
|
|
160
|
+
const ctxOverrideTrail = trail('resource.ctx-override', {
|
|
124
161
|
blaze: (_input, ctx) =>
|
|
125
|
-
Result.ok({ source:
|
|
126
|
-
description: 'Trail whose
|
|
162
|
+
Result.ok({ source: mockDbResource.from(ctx).source }),
|
|
163
|
+
description: 'Trail whose resource mock can be overridden by ctx.extensions',
|
|
127
164
|
examples: [
|
|
128
165
|
{
|
|
129
166
|
expected: { source: 'ctx' },
|
|
130
167
|
input: {},
|
|
131
|
-
name: 'Context extensions beat auto-resolved mock
|
|
168
|
+
name: 'Context extensions beat auto-resolved mock resources',
|
|
132
169
|
},
|
|
133
170
|
],
|
|
134
171
|
input: z.object({}),
|
|
135
172
|
output: z.object({ source: z.string() }),
|
|
136
|
-
|
|
173
|
+
resources: [mockDbResource],
|
|
137
174
|
});
|
|
138
175
|
|
|
139
|
-
const
|
|
176
|
+
const undeclaredDbResource = resource('db.undeclared.examples', {
|
|
140
177
|
create: () => Result.ok({ source: 'factory' }),
|
|
141
178
|
mock: () => ({ source: 'mock' }),
|
|
142
179
|
});
|
|
143
180
|
|
|
144
|
-
const
|
|
181
|
+
const undeclaredResourceTrail = trail('resource.undeclared.examples', {
|
|
145
182
|
blaze: (_input, ctx) =>
|
|
146
|
-
Result.ok({ source:
|
|
147
|
-
description: 'Trail that uses a
|
|
183
|
+
Result.ok({ source: undeclaredDbResource.from(ctx).source }),
|
|
184
|
+
description: 'Trail that uses a resource without declaring it',
|
|
148
185
|
examples: [
|
|
149
186
|
{
|
|
150
187
|
error: 'InternalError',
|
|
151
188
|
input: {},
|
|
152
|
-
name: 'Undeclared
|
|
189
|
+
name: 'Undeclared resources stay unavailable during example execution',
|
|
153
190
|
},
|
|
154
191
|
],
|
|
155
192
|
input: z.object({}),
|
|
156
193
|
output: z.object({ source: z.string() }),
|
|
157
194
|
});
|
|
158
|
-
const
|
|
195
|
+
const crossDbResource = resource('db.mock.examples.crosses', {
|
|
159
196
|
create: () => Result.ok({ source: 'factory' }),
|
|
160
197
|
mock: () => ({ source: 'mock' }),
|
|
161
198
|
});
|
|
162
199
|
|
|
163
|
-
const crossLeafTrail = trail('
|
|
200
|
+
const crossLeafTrail = trail('resource.crosses.leaf', {
|
|
164
201
|
blaze: (_input, ctx) =>
|
|
165
|
-
Result.ok({ childSource:
|
|
166
|
-
description: 'Leaf trail that resolves a
|
|
202
|
+
Result.ok({ childSource: crossDbResource.from(ctx).source }),
|
|
203
|
+
description: 'Leaf trail that resolves a resource inside a cross chain',
|
|
167
204
|
input: z.object({}),
|
|
168
205
|
output: z.object({ childSource: z.string() }),
|
|
169
|
-
|
|
206
|
+
resources: [crossDbResource],
|
|
170
207
|
});
|
|
171
208
|
|
|
172
|
-
const crossRootTrail = trail('
|
|
209
|
+
const crossRootTrail = trail('resource.crosses.root', {
|
|
173
210
|
blaze: async (_input, ctx) => {
|
|
174
211
|
if (!ctx.cross) {
|
|
175
212
|
return Result.err(new Error('cross not available'));
|
|
176
213
|
}
|
|
177
214
|
const childResult = await ctx.cross<{ childSource: string }>(
|
|
178
|
-
'
|
|
215
|
+
'resource.crosses.leaf',
|
|
179
216
|
{}
|
|
180
217
|
);
|
|
181
218
|
if (childResult.isErr()) {
|
|
@@ -183,21 +220,21 @@ const crossRootTrail = trail('provision.crosses.root', {
|
|
|
183
220
|
}
|
|
184
221
|
return Result.ok({
|
|
185
222
|
childSource: childResult.value.childSource,
|
|
186
|
-
rootSource:
|
|
223
|
+
rootSource: crossDbResource.from(ctx).source,
|
|
187
224
|
});
|
|
188
225
|
},
|
|
189
|
-
crosses: ['
|
|
190
|
-
description: 'Root trail that crosses a child trail using
|
|
226
|
+
crosses: ['resource.crosses.leaf'],
|
|
227
|
+
description: 'Root trail that crosses a child trail using resources',
|
|
191
228
|
examples: [
|
|
192
229
|
{
|
|
193
230
|
expected: { childSource: 'mock', rootSource: 'mock' },
|
|
194
231
|
input: {},
|
|
195
|
-
name: 'Propagates
|
|
232
|
+
name: 'Propagates resource mocks through cross execution',
|
|
196
233
|
},
|
|
197
234
|
],
|
|
198
235
|
input: z.object({}),
|
|
199
236
|
output: z.object({ childSource: z.string(), rootSource: z.string() }),
|
|
200
|
-
|
|
237
|
+
resources: [crossDbResource],
|
|
201
238
|
});
|
|
202
239
|
|
|
203
240
|
// ---------------------------------------------------------------------------
|
|
@@ -262,6 +299,7 @@ describe('testExamples', () => {
|
|
|
262
299
|
greetTrail,
|
|
263
300
|
noExamplesTrail,
|
|
264
301
|
searchTrail,
|
|
302
|
+
taxonomyErrorTrail,
|
|
265
303
|
} as Record<string, unknown>)
|
|
266
304
|
);
|
|
267
305
|
|
|
@@ -296,25 +334,25 @@ describe('testExamples skips trails with no examples', () => {
|
|
|
296
334
|
});
|
|
297
335
|
});
|
|
298
336
|
|
|
299
|
-
describe('testExamples
|
|
337
|
+
describe('testExamples resource mocks', () => {
|
|
300
338
|
// eslint-disable-next-line jest/require-hook
|
|
301
339
|
testExamples(
|
|
302
|
-
topo('
|
|
303
|
-
|
|
304
|
-
|
|
340
|
+
topo('resource-mock-app', {
|
|
341
|
+
mockDbResource,
|
|
342
|
+
mockResourceTrail,
|
|
305
343
|
} as Record<string, unknown>)
|
|
306
344
|
);
|
|
307
345
|
});
|
|
308
346
|
|
|
309
|
-
describe('testExamples explicit
|
|
347
|
+
describe('testExamples explicit resource overrides', () => {
|
|
310
348
|
// eslint-disable-next-line jest/require-hook
|
|
311
349
|
testExamples(
|
|
312
|
-
topo('
|
|
350
|
+
topo('resource-override-app', {
|
|
313
351
|
explicitOverrideTrail,
|
|
314
|
-
|
|
352
|
+
mockDbResource,
|
|
315
353
|
} as Record<string, unknown>),
|
|
316
354
|
{
|
|
317
|
-
|
|
355
|
+
resources: { 'db.mock.examples': { source: 'override' } },
|
|
318
356
|
}
|
|
319
357
|
);
|
|
320
358
|
});
|
|
@@ -333,7 +371,7 @@ describe('testExamples context extension overrides', () => {
|
|
|
333
371
|
testExamples(
|
|
334
372
|
topo('ctx-override-app', {
|
|
335
373
|
ctxOverrideTrail,
|
|
336
|
-
|
|
374
|
+
mockDbResource,
|
|
337
375
|
} as Record<string, unknown>),
|
|
338
376
|
{
|
|
339
377
|
ctx: {
|
|
@@ -343,12 +381,12 @@ describe('testExamples context extension overrides', () => {
|
|
|
343
381
|
);
|
|
344
382
|
});
|
|
345
383
|
|
|
346
|
-
describe('testExamples
|
|
384
|
+
describe('testExamples resource declarations', () => {
|
|
347
385
|
// eslint-disable-next-line jest/require-hook
|
|
348
386
|
testExamples(
|
|
349
|
-
topo('undeclared-
|
|
350
|
-
|
|
351
|
-
|
|
387
|
+
topo('undeclared-resource-app', {
|
|
388
|
+
undeclaredDbResource,
|
|
389
|
+
undeclaredResourceTrail,
|
|
352
390
|
} as Record<string, unknown>)
|
|
353
391
|
);
|
|
354
392
|
});
|
|
@@ -364,11 +402,11 @@ describe('testExamples crossing coverage for trails with crossings', () => {
|
|
|
364
402
|
);
|
|
365
403
|
});
|
|
366
404
|
|
|
367
|
-
describe('testExamples
|
|
405
|
+
describe('testExamples resource mocks through cross', () => {
|
|
368
406
|
// eslint-disable-next-line jest/require-hook
|
|
369
407
|
testExamples(
|
|
370
|
-
topo('
|
|
371
|
-
|
|
408
|
+
topo('resource-cross-app', {
|
|
409
|
+
crossDbResource,
|
|
372
410
|
crossLeafTrail,
|
|
373
411
|
crossRootTrail,
|
|
374
412
|
} as Record<string, unknown>)
|
|
@@ -532,3 +570,67 @@ describe('testExamples auto-minting permits', () => {
|
|
|
532
570
|
);
|
|
533
571
|
});
|
|
534
572
|
});
|
|
573
|
+
|
|
574
|
+
// ---------------------------------------------------------------------------
|
|
575
|
+
// Derived-fixture crossing coverage regression
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
//
|
|
578
|
+
// A composition trail whose only examples come from contour-derived
|
|
579
|
+
// fixtures must not fail crossing-coverage — derived inputs are not
|
|
580
|
+
// guaranteed to exercise every declared cross.
|
|
581
|
+
|
|
582
|
+
const itemContour = contour(
|
|
583
|
+
'item',
|
|
584
|
+
{
|
|
585
|
+
id: z.string(),
|
|
586
|
+
name: z.string(),
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
examples: [{ id: 'abc', name: 'Widget' }],
|
|
590
|
+
identity: 'id',
|
|
591
|
+
}
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
const helperTrail = trail('derived.helper', {
|
|
595
|
+
blaze: (input: { id: string }) => Result.ok({ id: input.id, ok: true }),
|
|
596
|
+
description: 'Helper referenced by a conditional cross',
|
|
597
|
+
input: z.object({ id: z.string() }),
|
|
598
|
+
output: z.object({ id: z.string(), ok: z.boolean() }),
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
const conditionalCrossTrail = trail('derived.conditional', {
|
|
602
|
+
blaze: async (input: { id: string; name: string }, ctx) => {
|
|
603
|
+
// The conditional cross is never taken for derived fixtures because
|
|
604
|
+
// `shouldCross` is always false in the synthesized input. This is
|
|
605
|
+
// exactly the case the provenance gate exists to protect: if we
|
|
606
|
+
// asserted crossing coverage against derived examples, this trail
|
|
607
|
+
// would fail even though its declaration is accurate for authored
|
|
608
|
+
// use.
|
|
609
|
+
const { shouldCross } = input as { shouldCross?: boolean };
|
|
610
|
+
if (shouldCross && ctx.cross) {
|
|
611
|
+
const result = await ctx.cross<{ id: string; ok: boolean }>(
|
|
612
|
+
'derived.helper',
|
|
613
|
+
{ id: input.id }
|
|
614
|
+
);
|
|
615
|
+
if (result.isErr()) {
|
|
616
|
+
return result;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return Result.ok({ id: input.id, name: input.name });
|
|
620
|
+
},
|
|
621
|
+
contours: [itemContour],
|
|
622
|
+
crosses: ['derived.helper'],
|
|
623
|
+
description: 'Composition trail with a cross that derived fixtures skip',
|
|
624
|
+
input: z.object({ id: z.string(), name: z.string() }),
|
|
625
|
+
output: z.object({ id: z.string(), name: z.string() }),
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
describe('testExamples derived-fixture crossing coverage is gated', () => {
|
|
629
|
+
// eslint-disable-next-line jest/require-hook
|
|
630
|
+
testExamples(
|
|
631
|
+
topo('derived-coverage-app', {
|
|
632
|
+
conditionalCrossTrail,
|
|
633
|
+
helperTrail,
|
|
634
|
+
} as Record<string, unknown>)
|
|
635
|
+
);
|
|
636
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { resource, Result, topo, trail } from '@ontrails/core';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
import { createCliHarness } from '../harness-cli.js';
|
|
7
|
+
|
|
8
|
+
describe('createCliHarness', () => {
|
|
9
|
+
test('runs top-level commands', async () => {
|
|
10
|
+
const greet = trail('greet', {
|
|
11
|
+
blaze: (input: { name: string }) => Result.ok(`Hello, ${input.name}!`),
|
|
12
|
+
input: z.object({ name: z.string() }),
|
|
13
|
+
});
|
|
14
|
+
const harness = createCliHarness({
|
|
15
|
+
graph: topo('test-app', { greet }),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const result = await harness.run('greet --name Trails');
|
|
19
|
+
|
|
20
|
+
expect(result.exitCode).toBe(0);
|
|
21
|
+
expect(result.stdout).toContain('Hello, Trails!');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('runs nested commands using the full ordered path', async () => {
|
|
25
|
+
const pin = trail('topo.pin', {
|
|
26
|
+
blaze: (input: { name: string }) => Result.ok(`Pinned ${input.name}`),
|
|
27
|
+
input: z.object({ name: z.string() }),
|
|
28
|
+
});
|
|
29
|
+
const harness = createCliHarness({
|
|
30
|
+
graph: topo('test-app', { pin }),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const result = await harness.run('topo pin --name before-auth');
|
|
34
|
+
|
|
35
|
+
expect(result.exitCode).toBe(0);
|
|
36
|
+
expect(result.stdout).toContain('Pinned before-auth');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('prefers the deepest matching executable path', async () => {
|
|
40
|
+
const calls: string[] = [];
|
|
41
|
+
const topoShow = trail('topo', {
|
|
42
|
+
blaze: () => {
|
|
43
|
+
calls.push('topo');
|
|
44
|
+
return Result.ok('topo');
|
|
45
|
+
},
|
|
46
|
+
input: z.object({}),
|
|
47
|
+
});
|
|
48
|
+
const topoPin = trail('topo.pin', {
|
|
49
|
+
blaze: () => {
|
|
50
|
+
calls.push('topo.pin');
|
|
51
|
+
return Result.ok('topo.pin');
|
|
52
|
+
},
|
|
53
|
+
input: z.object({}),
|
|
54
|
+
});
|
|
55
|
+
const harness = createCliHarness({
|
|
56
|
+
graph: topo('test-app', { topoPin, topoShow }),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const child = await harness.run('topo pin');
|
|
60
|
+
const parent = await harness.run('topo');
|
|
61
|
+
|
|
62
|
+
expect(child.exitCode).toBe(0);
|
|
63
|
+
expect(parent.exitCode).toBe(0);
|
|
64
|
+
expect(calls).toEqual(['topo.pin', 'topo']);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('threads resource overrides through CLI projection options', async () => {
|
|
68
|
+
const dbResource = resource('db.main', {
|
|
69
|
+
create: () => Result.ok({ source: 'factory' }),
|
|
70
|
+
});
|
|
71
|
+
const readResource = trail('resource.read', {
|
|
72
|
+
blaze: (_input, ctx) =>
|
|
73
|
+
Result.ok({ source: dbResource.from(ctx).source as string }),
|
|
74
|
+
input: z.object({}),
|
|
75
|
+
output: z.object({ source: z.string() }),
|
|
76
|
+
resources: [dbResource],
|
|
77
|
+
});
|
|
78
|
+
const harness = createCliHarness({
|
|
79
|
+
graph: topo('test-app', { dbResource, readResource }),
|
|
80
|
+
resources: {
|
|
81
|
+
'db.main': { source: 'override' },
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const result = await harness.run('resource read --output json');
|
|
86
|
+
|
|
87
|
+
expect(result.exitCode).toBe(0);
|
|
88
|
+
expect(result.stdout).toContain('"override"');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { resource, Result, topo, trail } from '@ontrails/core';
|
|
4
|
+
import { deriveToolName } from '@ontrails/mcp';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
|
|
7
|
+
import { createMcpHarness } from '../harness-mcp.js';
|
|
8
|
+
|
|
9
|
+
describe('createMcpHarness', () => {
|
|
10
|
+
test('threads resource overrides through MCP projection options', async () => {
|
|
11
|
+
const dbResource = resource('db.main', {
|
|
12
|
+
create: () => Result.ok({ source: 'factory' }),
|
|
13
|
+
});
|
|
14
|
+
const readResource = trail('resource.read', {
|
|
15
|
+
blaze: (_input, ctx) =>
|
|
16
|
+
Result.ok({ source: dbResource.from(ctx).source as string }),
|
|
17
|
+
input: z.object({}),
|
|
18
|
+
output: z.object({ source: z.string() }),
|
|
19
|
+
resources: [dbResource],
|
|
20
|
+
});
|
|
21
|
+
const graph = topo('test-app', { dbResource, readResource });
|
|
22
|
+
const harness = createMcpHarness({
|
|
23
|
+
graph,
|
|
24
|
+
resources: {
|
|
25
|
+
'db.main': { source: 'override' },
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const result = await harness.callTool(
|
|
30
|
+
deriveToolName(graph.name, readResource.id),
|
|
31
|
+
{}
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(result.isError).toBe(false);
|
|
35
|
+
expect(JSON.stringify(result.content)).toContain('override');
|
|
36
|
+
});
|
|
37
|
+
});
|