@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,126 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { Result } from '@ontrails/core';
|
|
4
|
+
|
|
5
|
+
import { assertPartialMatch } from '../assertions.js';
|
|
6
|
+
|
|
7
|
+
describe('assertPartialMatch', () => {
|
|
8
|
+
describe('scalar values', () => {
|
|
9
|
+
test('passes with exact scalar match', () => {
|
|
10
|
+
const result = Result.ok('hello');
|
|
11
|
+
assertPartialMatch(result, 'hello');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('passes with numeric match', () => {
|
|
15
|
+
const result = Result.ok(42);
|
|
16
|
+
assertPartialMatch(result, 42);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('fails when scalar does not match', () => {
|
|
20
|
+
const result = Result.ok('hello');
|
|
21
|
+
expect(() => assertPartialMatch(result, 'world')).toThrow();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('object subset', () => {
|
|
26
|
+
test('passes with full match', () => {
|
|
27
|
+
const result = Result.ok({ id: '1', name: 'Alpha', type: 'concept' });
|
|
28
|
+
assertPartialMatch(result, { id: '1', name: 'Alpha', type: 'concept' });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('passes with partial match (extra keys in actual ignored)', () => {
|
|
32
|
+
const result = Result.ok({
|
|
33
|
+
createdAt: '2026-01-01',
|
|
34
|
+
id: '1',
|
|
35
|
+
name: 'Alpha',
|
|
36
|
+
type: 'concept',
|
|
37
|
+
});
|
|
38
|
+
assertPartialMatch(result, { name: 'Alpha', type: 'concept' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('fails when expected key is missing from actual', () => {
|
|
42
|
+
const result = Result.ok({ id: '1', name: 'Alpha' });
|
|
43
|
+
expect(() =>
|
|
44
|
+
assertPartialMatch(result, { missing: true, name: 'Alpha' })
|
|
45
|
+
).toThrow();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('fails when expected value does not match', () => {
|
|
49
|
+
const result = Result.ok({ id: '1', name: 'Alpha' });
|
|
50
|
+
expect(() => assertPartialMatch(result, { name: 'Beta' })).toThrow();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('nested object subset', () => {
|
|
55
|
+
test('passes with nested partial match', () => {
|
|
56
|
+
const result = Result.ok({
|
|
57
|
+
id: '1',
|
|
58
|
+
meta: { stars: 0, tags: ['a', 'b'], views: 5 },
|
|
59
|
+
});
|
|
60
|
+
assertPartialMatch(result, { meta: { stars: 0 } });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('fails when nested value does not match', () => {
|
|
64
|
+
const result = Result.ok({
|
|
65
|
+
id: '1',
|
|
66
|
+
meta: { stars: 0, views: 5 },
|
|
67
|
+
});
|
|
68
|
+
expect(() =>
|
|
69
|
+
assertPartialMatch(result, { meta: { stars: 3 } })
|
|
70
|
+
).toThrow();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('array subset', () => {
|
|
75
|
+
test('passes when actual contains all expected elements (order-independent)', () => {
|
|
76
|
+
const result = Result.ok({
|
|
77
|
+
tags: ['a', 'b', 'c'],
|
|
78
|
+
});
|
|
79
|
+
assertPartialMatch(result, { tags: ['c', 'a'] });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('fails when actual is missing expected array element', () => {
|
|
83
|
+
const result = Result.ok({
|
|
84
|
+
tags: ['a', 'b'],
|
|
85
|
+
});
|
|
86
|
+
expect(() => assertPartialMatch(result, { tags: ['a', 'z'] })).toThrow();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('fails when expected has duplicate but actual has only one match', () => {
|
|
90
|
+
const result = Result.ok({
|
|
91
|
+
tags: ['a', 'b'],
|
|
92
|
+
});
|
|
93
|
+
expect(() => assertPartialMatch(result, { tags: ['a', 'a'] })).toThrow();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('passes when expected has duplicate and actual has enough matches', () => {
|
|
97
|
+
const result = Result.ok({
|
|
98
|
+
tags: ['a', 'a', 'b'],
|
|
99
|
+
});
|
|
100
|
+
assertPartialMatch(result, { tags: ['a', 'a'] });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('fails when expected objects have duplicates but actual has only one', () => {
|
|
104
|
+
const result = Result.ok({
|
|
105
|
+
items: [{ id: 1 }],
|
|
106
|
+
});
|
|
107
|
+
expect(() =>
|
|
108
|
+
assertPartialMatch(result, { items: [{ id: 1 }, { id: 1 }] })
|
|
109
|
+
).toThrow();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('passes when expected objects have duplicates and actual has enough', () => {
|
|
113
|
+
const result = Result.ok({
|
|
114
|
+
items: [{ id: 1 }, { id: 1 }, { id: 2 }],
|
|
115
|
+
});
|
|
116
|
+
assertPartialMatch(result, { items: [{ id: 1 }, { id: 1 }] });
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('error on non-ok result', () => {
|
|
121
|
+
test('fails when result is an error', () => {
|
|
122
|
+
const result = Result.err(new Error('boom'));
|
|
123
|
+
expect(() => assertPartialMatch(result, { id: '1' })).toThrow();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import type { TrailContext } from '@ontrails/core';
|
|
4
|
+
import { resource, Result, trail, topo } from '@ontrails/core';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
|
|
7
|
+
import { errResultMatch, okResultMatch } from '../assertions.js';
|
|
8
|
+
import { executeScenarioSteps, ref, scenario } from '../scenario.js';
|
|
9
|
+
import type { ScenarioStep } from '../types.js';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Test trails
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
const createTrail = trail('item.create', {
|
|
16
|
+
blaze: (input: { name: string }) => Result.ok({ id: 'g1', name: input.name }),
|
|
17
|
+
description: 'Create an item',
|
|
18
|
+
input: z.object({ name: z.string() }),
|
|
19
|
+
output: z.object({ id: z.string(), name: z.string() }),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const showTrail = trail('item.show', {
|
|
23
|
+
blaze: (input: { id: string }) =>
|
|
24
|
+
Result.ok({ found: true, id: input.id, name: 'Test' }),
|
|
25
|
+
description: 'Show an item',
|
|
26
|
+
input: z.object({ id: z.string() }),
|
|
27
|
+
output: z.object({ found: z.boolean(), id: z.string(), name: z.string() }),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const failTrail = trail('item.fail', {
|
|
31
|
+
blaze: () => Result.err(new Error('intentional failure')),
|
|
32
|
+
description: 'Always fails',
|
|
33
|
+
input: z.object({}),
|
|
34
|
+
output: z.object({}),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** A trail that uses ctx.cross() to delegate to item.create. */
|
|
38
|
+
const createViaProxy = trail('item.create-via-proxy', {
|
|
39
|
+
blaze: (input: { name: string }, ctx: TrailContext) => {
|
|
40
|
+
const crossFn = ctx.cross;
|
|
41
|
+
if (!crossFn) {
|
|
42
|
+
return Promise.resolve(Result.err(new Error('ctx.cross is undefined')));
|
|
43
|
+
}
|
|
44
|
+
return crossFn(createTrail, input);
|
|
45
|
+
},
|
|
46
|
+
crosses: [createTrail],
|
|
47
|
+
description: 'Delegates to item.create via ctx.cross()',
|
|
48
|
+
input: z.object({ name: z.string() }),
|
|
49
|
+
output: z.object({ id: z.string(), name: z.string() }),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** A resource with a mock factory. */
|
|
53
|
+
const db = resource<{ query: (sql: string) => string }>('db', {
|
|
54
|
+
create: () => Result.err(new Error('not wired in tests')),
|
|
55
|
+
mock: () => ({ query: (sql: string) => `mock:${sql}` }),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** A trail that uses a resource via ctx.resource(). */
|
|
59
|
+
const queryTrail = trail('item.query', {
|
|
60
|
+
blaze: (_input: { sql: string }, ctx: TrailContext) => {
|
|
61
|
+
const instance = db.from(ctx);
|
|
62
|
+
return Result.ok({ result: instance.query(_input.sql) });
|
|
63
|
+
},
|
|
64
|
+
description: 'Run a query via the db resource',
|
|
65
|
+
input: z.object({ sql: z.string() }),
|
|
66
|
+
output: z.object({ result: z.string() }),
|
|
67
|
+
resources: [db],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const app = topo('scenario-test-app', {
|
|
71
|
+
createTrail,
|
|
72
|
+
createViaProxy,
|
|
73
|
+
db,
|
|
74
|
+
failTrail,
|
|
75
|
+
queryTrail,
|
|
76
|
+
showTrail,
|
|
77
|
+
} as Record<string, unknown>);
|
|
78
|
+
|
|
79
|
+
const readySignal = 'ready';
|
|
80
|
+
type ReadySignal = typeof readySignal;
|
|
81
|
+
|
|
82
|
+
const concurrentBatchOutput = z.object({ results: z.array(z.unknown()) });
|
|
83
|
+
|
|
84
|
+
const createReadyController = () => Promise.withResolvers<ReadySignal>();
|
|
85
|
+
|
|
86
|
+
const requireCrossFn = (
|
|
87
|
+
ctx: TrailContext
|
|
88
|
+
): NonNullable<TrailContext['cross']> => {
|
|
89
|
+
expect(ctx.cross).toBeDefined();
|
|
90
|
+
return ctx.cross as NonNullable<TrailContext['cross']>;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const waitForReadyPair = async (
|
|
94
|
+
first: Promise<ReadySignal>,
|
|
95
|
+
second: Promise<ReadySignal>
|
|
96
|
+
): Promise<void> => {
|
|
97
|
+
await first;
|
|
98
|
+
await second;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Tests
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
describe('ref()', () => {
|
|
106
|
+
test('creates a RefToken with the given path', () => {
|
|
107
|
+
const token = ref('create.id');
|
|
108
|
+
expect(token).toEqual({ __ref: true, path: 'create.id' });
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('scenario()', () => {
|
|
113
|
+
// oxlint-disable-next-line jest/require-hook -- scenario() registers describe/test blocks, not setup code
|
|
114
|
+
scenario('basic two-step flow', app, [
|
|
115
|
+
{
|
|
116
|
+
as: 'created',
|
|
117
|
+
cross: createTrail,
|
|
118
|
+
input: { name: 'Hello' },
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
cross: showTrail,
|
|
122
|
+
expectedMatch: { found: true, id: 'g1' },
|
|
123
|
+
input: { id: ref('created.id') },
|
|
124
|
+
},
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
// oxlint-disable-next-line jest/require-hook -- scenario() registers describe/test blocks, not setup code
|
|
128
|
+
scenario('ref resolves dot-path from prior step', app, [
|
|
129
|
+
{
|
|
130
|
+
as: 'original',
|
|
131
|
+
cross: createTrail,
|
|
132
|
+
expected: { id: 'g1', name: 'Test' },
|
|
133
|
+
input: { name: 'Test' },
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
cross: showTrail,
|
|
137
|
+
input: { id: ref('original.id') },
|
|
138
|
+
},
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
// oxlint-disable-next-line jest/require-hook -- scenario() registers describe/test blocks, not setup code
|
|
142
|
+
scenario('expectedMatch on a step works', app, [
|
|
143
|
+
{
|
|
144
|
+
cross: createTrail,
|
|
145
|
+
expectedMatch: { name: 'Partial' },
|
|
146
|
+
input: { name: 'Partial' },
|
|
147
|
+
},
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
// oxlint-disable-next-line jest/require-hook -- scenario() registers describe/test blocks, not setup code
|
|
151
|
+
scenario('step that uses ctx.cross() receives a bound cross function', app, [
|
|
152
|
+
{
|
|
153
|
+
as: 'proxied',
|
|
154
|
+
cross: createViaProxy,
|
|
155
|
+
expectedMatch: { id: 'g1', name: 'CrossTest' },
|
|
156
|
+
input: { name: 'CrossTest' },
|
|
157
|
+
},
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
// Resource mock forwarding
|
|
161
|
+
// oxlint-disable-next-line jest/require-hook -- scenario() registers describe/test blocks, not setup code
|
|
162
|
+
scenario('step with resource receives mock from topo', app, [
|
|
163
|
+
{
|
|
164
|
+
cross: queryTrail,
|
|
165
|
+
expected: { result: 'mock:SELECT 1' },
|
|
166
|
+
input: { sql: 'SELECT 1' },
|
|
167
|
+
},
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
// Step failure reporting
|
|
171
|
+
describe('step failure reporting', () => {
|
|
172
|
+
test('reports which step failed', async () => {
|
|
173
|
+
// We can't use scenario() directly here because it registers
|
|
174
|
+
// describe/test blocks. Instead, test the error message shape
|
|
175
|
+
// by importing the internals or checking that the scenario
|
|
176
|
+
// properly reports failures.
|
|
177
|
+
// For now, verify that a failing trail in a scenario produces
|
|
178
|
+
// an informative error.
|
|
179
|
+
const { executeTrail } = await import('@ontrails/core');
|
|
180
|
+
const result = await executeTrail(failTrail, {}, { topo: app });
|
|
181
|
+
expect(result.isErr()).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Duplicate alias guard
|
|
186
|
+
describe('duplicate alias guard', () => {
|
|
187
|
+
test('throws on duplicate step alias', async () => {
|
|
188
|
+
const steps: ScenarioStep[] = [
|
|
189
|
+
{ as: 'dup', cross: createTrail, input: { name: 'A' } },
|
|
190
|
+
{ as: 'dup', cross: createTrail, input: { name: 'B' } },
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
await expect(executeScenarioSteps(app, steps)).rejects.toThrow(
|
|
194
|
+
'duplicate step alias "dup"'
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('executeScenarioSteps concurrent crossing support', () => {
|
|
201
|
+
test('matches concurrent fan-out arrays with ok result helpers', async () => {
|
|
202
|
+
const alphaTrail = trail('scenario.batch.alpha', {
|
|
203
|
+
blaze: () => Result.ok({ label: 'alpha' }),
|
|
204
|
+
input: z.object({}),
|
|
205
|
+
output: z.object({ label: z.string() }),
|
|
206
|
+
visibility: 'internal',
|
|
207
|
+
});
|
|
208
|
+
const betaTrail = trail('scenario.batch.beta', {
|
|
209
|
+
blaze: () => Result.ok({ label: 'beta' }),
|
|
210
|
+
input: z.object({}),
|
|
211
|
+
output: z.object({ label: z.string() }),
|
|
212
|
+
visibility: 'internal',
|
|
213
|
+
});
|
|
214
|
+
const fanoutTrail = trail('scenario.batch.fanout', {
|
|
215
|
+
blaze: async (_input, ctx) => {
|
|
216
|
+
const results = await requireCrossFn(ctx)([
|
|
217
|
+
[alphaTrail, {}],
|
|
218
|
+
[betaTrail, {}],
|
|
219
|
+
] as const);
|
|
220
|
+
return Result.ok({ results });
|
|
221
|
+
},
|
|
222
|
+
crosses: [alphaTrail, betaTrail],
|
|
223
|
+
input: z.object({}),
|
|
224
|
+
output: concurrentBatchOutput,
|
|
225
|
+
});
|
|
226
|
+
const fanoutApp = topo('scenario-concurrent-fanout-app', {
|
|
227
|
+
alphaTrail,
|
|
228
|
+
betaTrail,
|
|
229
|
+
fanoutTrail,
|
|
230
|
+
} as Record<string, unknown>);
|
|
231
|
+
|
|
232
|
+
await executeScenarioSteps(fanoutApp, [
|
|
233
|
+
{
|
|
234
|
+
cross: fanoutTrail,
|
|
235
|
+
expectedMatch: {
|
|
236
|
+
results: [
|
|
237
|
+
okResultMatch({ label: 'alpha' }),
|
|
238
|
+
okResultMatch({ label: 'beta' }),
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
input: {},
|
|
242
|
+
},
|
|
243
|
+
]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('matches mixed ok/err batch results for partial failure scenarios', async () => {
|
|
247
|
+
const successTrail = trail('scenario.batch.partial.success', {
|
|
248
|
+
blaze: () => Result.ok({ label: 'success' }),
|
|
249
|
+
input: z.object({}),
|
|
250
|
+
output: z.object({ label: z.string() }),
|
|
251
|
+
visibility: 'internal',
|
|
252
|
+
});
|
|
253
|
+
const failureTrail = trail('scenario.batch.partial.failure', {
|
|
254
|
+
blaze: () => Result.err(new Error('branch failure')),
|
|
255
|
+
input: z.object({}),
|
|
256
|
+
output: z.object({}),
|
|
257
|
+
visibility: 'internal',
|
|
258
|
+
});
|
|
259
|
+
const partialTrail = trail('scenario.batch.partial.root', {
|
|
260
|
+
blaze: async (_input, ctx) => {
|
|
261
|
+
const results = await requireCrossFn(ctx)([
|
|
262
|
+
[successTrail, {}],
|
|
263
|
+
[failureTrail, {}],
|
|
264
|
+
] as const);
|
|
265
|
+
return Result.ok({ results });
|
|
266
|
+
},
|
|
267
|
+
crosses: [successTrail, failureTrail],
|
|
268
|
+
input: z.object({}),
|
|
269
|
+
output: concurrentBatchOutput,
|
|
270
|
+
});
|
|
271
|
+
const partialApp = topo('scenario-partial-failure-app', {
|
|
272
|
+
failureTrail,
|
|
273
|
+
partialTrail,
|
|
274
|
+
successTrail,
|
|
275
|
+
} as Record<string, unknown>);
|
|
276
|
+
|
|
277
|
+
await executeScenarioSteps(partialApp, [
|
|
278
|
+
{
|
|
279
|
+
cross: partialTrail,
|
|
280
|
+
expectedMatch: {
|
|
281
|
+
results: [
|
|
282
|
+
okResultMatch({ label: 'success' }),
|
|
283
|
+
errResultMatch({ message: 'branch failure' }),
|
|
284
|
+
],
|
|
285
|
+
},
|
|
286
|
+
input: {},
|
|
287
|
+
},
|
|
288
|
+
]);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('respects concurrency limits for scenario ctx.cross batch flows', async () => {
|
|
292
|
+
const slowStarted = createReadyController();
|
|
293
|
+
const fastStarted = createReadyController();
|
|
294
|
+
const releaseFirstBatch = createReadyController();
|
|
295
|
+
const startedIds: string[] = [];
|
|
296
|
+
const slowTrail = trail('scenario.batch.limited.slow', {
|
|
297
|
+
blaze: async () => {
|
|
298
|
+
startedIds.push('slow');
|
|
299
|
+
slowStarted.resolve(readySignal);
|
|
300
|
+
await releaseFirstBatch.promise;
|
|
301
|
+
await Bun.sleep(20);
|
|
302
|
+
return Result.ok({ id: 'slow' });
|
|
303
|
+
},
|
|
304
|
+
input: z.object({}),
|
|
305
|
+
output: z.object({ id: z.string() }),
|
|
306
|
+
visibility: 'internal',
|
|
307
|
+
});
|
|
308
|
+
const fastTrail = trail('scenario.batch.limited.fast', {
|
|
309
|
+
blaze: async () => {
|
|
310
|
+
startedIds.push('fast');
|
|
311
|
+
fastStarted.resolve(readySignal);
|
|
312
|
+
await releaseFirstBatch.promise;
|
|
313
|
+
await Bun.sleep(1);
|
|
314
|
+
return Result.ok({ id: 'fast' });
|
|
315
|
+
},
|
|
316
|
+
input: z.object({}),
|
|
317
|
+
output: z.object({ id: z.string() }),
|
|
318
|
+
visibility: 'internal',
|
|
319
|
+
});
|
|
320
|
+
const queuedTrail = trail('scenario.batch.limited.queued', {
|
|
321
|
+
blaze: () => {
|
|
322
|
+
startedIds.push('queued');
|
|
323
|
+
return Result.ok({ id: 'queued' });
|
|
324
|
+
},
|
|
325
|
+
input: z.object({}),
|
|
326
|
+
output: z.object({ id: z.string() }),
|
|
327
|
+
visibility: 'internal',
|
|
328
|
+
});
|
|
329
|
+
const limitedTrail = trail('scenario.batch.limited.root', {
|
|
330
|
+
blaze: async (_input, ctx) => {
|
|
331
|
+
const run = requireCrossFn(ctx)(
|
|
332
|
+
[
|
|
333
|
+
[slowTrail, {}],
|
|
334
|
+
[fastTrail, {}],
|
|
335
|
+
[queuedTrail, {}],
|
|
336
|
+
] as const,
|
|
337
|
+
{ concurrency: 2 }
|
|
338
|
+
);
|
|
339
|
+
await waitForReadyPair(slowStarted.promise, fastStarted.promise);
|
|
340
|
+
const startedBeforeRelease = [...startedIds];
|
|
341
|
+
releaseFirstBatch.resolve(readySignal);
|
|
342
|
+
const results = await run;
|
|
343
|
+
return Result.ok({
|
|
344
|
+
startedBeforeRelease,
|
|
345
|
+
startedOverall: [...startedIds],
|
|
346
|
+
successIds: results.flatMap((result) =>
|
|
347
|
+
result.match({
|
|
348
|
+
err: () => [] as string[],
|
|
349
|
+
ok: (value) => [value.id],
|
|
350
|
+
})
|
|
351
|
+
),
|
|
352
|
+
});
|
|
353
|
+
},
|
|
354
|
+
crosses: [slowTrail, fastTrail, queuedTrail],
|
|
355
|
+
input: z.object({}),
|
|
356
|
+
output: z.object({
|
|
357
|
+
startedBeforeRelease: z.array(z.string()),
|
|
358
|
+
startedOverall: z.array(z.string()),
|
|
359
|
+
successIds: z.array(z.string()),
|
|
360
|
+
}),
|
|
361
|
+
});
|
|
362
|
+
const limitedApp = topo('scenario-concurrency-limited-app', {
|
|
363
|
+
fastTrail,
|
|
364
|
+
limitedTrail,
|
|
365
|
+
queuedTrail,
|
|
366
|
+
slowTrail,
|
|
367
|
+
} as Record<string, unknown>);
|
|
368
|
+
|
|
369
|
+
await executeScenarioSteps(limitedApp, [
|
|
370
|
+
{
|
|
371
|
+
cross: limitedTrail,
|
|
372
|
+
expected: {
|
|
373
|
+
startedBeforeRelease: ['slow', 'fast'],
|
|
374
|
+
startedOverall: ['slow', 'fast', 'queued'],
|
|
375
|
+
successIds: ['slow', 'fast', 'queued'],
|
|
376
|
+
},
|
|
377
|
+
input: {},
|
|
378
|
+
},
|
|
379
|
+
]);
|
|
380
|
+
});
|
|
381
|
+
});
|
package/src/all.ts
CHANGED
|
@@ -2,18 +2,21 @@
|
|
|
2
2
|
* testAll — single-line governance suite for any Topo.
|
|
3
3
|
*
|
|
4
4
|
* Wraps topo validation, example execution, contract checks, and detour
|
|
5
|
-
*
|
|
5
|
+
* contract validation into one describe block.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, expect, test } from 'bun:test';
|
|
9
9
|
|
|
10
10
|
import type { Topo, TrailContext } from '@ontrails/core';
|
|
11
|
-
import { validateTopo } from '@ontrails/core';
|
|
11
|
+
import { validateEstablishedTopo, validateTopo } from '@ontrails/core';
|
|
12
12
|
|
|
13
|
+
import { createCliHarness } from './harness-cli.js';
|
|
14
|
+
import { createMcpHarness } from './harness-mcp.js';
|
|
13
15
|
import { testContracts } from './contracts.js';
|
|
14
16
|
import type { TestExecutionOptions } from './context.js';
|
|
15
17
|
import { testDetours } from './detours.js';
|
|
16
18
|
import { testExamples } from './examples.js';
|
|
19
|
+
import type { TestAllEstablishedOptions } from './types.js';
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
22
|
* Run the full governance test suite for a Topo.
|
|
@@ -22,7 +25,7 @@ import { testExamples } from './examples.js';
|
|
|
22
25
|
* - Structural validation via `validateTopo`
|
|
23
26
|
* - Example execution via `testExamples`
|
|
24
27
|
* - Output contract checks via `testContracts`
|
|
25
|
-
* - Detour
|
|
28
|
+
* - Detour contract validation via `testDetours`
|
|
26
29
|
*
|
|
27
30
|
* Accepts either a static context or a factory function that produces a
|
|
28
31
|
* fresh context per test (useful when the context contains mutable state
|
|
@@ -31,22 +34,53 @@ import { testExamples } from './examples.js';
|
|
|
31
34
|
* @example
|
|
32
35
|
* ```ts
|
|
33
36
|
* import { testAll } from '@ontrails/testing';
|
|
34
|
-
* import {
|
|
37
|
+
* import { graph } from '../src/app.js';
|
|
35
38
|
*
|
|
36
|
-
* testAll(
|
|
39
|
+
* testAll(graph);
|
|
37
40
|
* ```
|
|
38
41
|
*/
|
|
39
|
-
|
|
42
|
+
type TestAllInput =
|
|
43
|
+
| Partial<TrailContext>
|
|
44
|
+
| TestExecutionOptions
|
|
45
|
+
| (() => Partial<TrailContext> | TestExecutionOptions);
|
|
46
|
+
|
|
47
|
+
const formatValidationFailure = (error: Error): string => {
|
|
48
|
+
const issues = (
|
|
49
|
+
error as {
|
|
50
|
+
context?: { issues?: readonly Record<string, unknown>[] };
|
|
51
|
+
}
|
|
52
|
+
).context?.issues;
|
|
53
|
+
|
|
54
|
+
if (issues === undefined || issues.length === 0) {
|
|
55
|
+
return error.message;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const details = issues.map((issue) => {
|
|
59
|
+
const id = typeof issue['id'] === 'string' ? issue['id'] : undefined;
|
|
60
|
+
const message =
|
|
61
|
+
typeof issue['message'] === 'string' ? issue['message'] : undefined;
|
|
62
|
+
const rule = typeof issue['rule'] === 'string' ? issue['rule'] : undefined;
|
|
63
|
+
|
|
64
|
+
return [rule, id, message].filter(Boolean).join(': ');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return [error.message, ...details].join('\n');
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const assertValidTopo = (result: ReturnType<typeof validateTopo>): void => {
|
|
71
|
+
if (result.isErr()) {
|
|
72
|
+
throw new Error(formatValidationFailure(result.error));
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const registerGovernanceSuite = (
|
|
40
77
|
topo: Topo,
|
|
41
|
-
ctxOrFactory
|
|
42
|
-
|
|
43
|
-
| TestExecutionOptions
|
|
44
|
-
| (() => Partial<TrailContext> | TestExecutionOptions)
|
|
78
|
+
ctxOrFactory: TestAllInput | undefined,
|
|
79
|
+
validate: (topo: Topo) => ReturnType<typeof validateTopo>
|
|
45
80
|
): void => {
|
|
46
81
|
describe('governance', () => {
|
|
47
82
|
test('topo validates', () => {
|
|
48
|
-
|
|
49
|
-
expect(result.isOk()).toBe(true);
|
|
83
|
+
expect(() => assertValidTopo(validate(topo))).not.toThrow();
|
|
50
84
|
});
|
|
51
85
|
|
|
52
86
|
// oxlint-disable-next-line jest/require-hook -- these generate describe/test blocks, not setup code
|
|
@@ -57,3 +91,106 @@ export const testAll = (
|
|
|
57
91
|
testDetours(topo);
|
|
58
92
|
});
|
|
59
93
|
};
|
|
94
|
+
|
|
95
|
+
export const testAll = (topo: Topo, ctxOrFactory?: TestAllInput): void => {
|
|
96
|
+
registerGovernanceSuite(topo, ctxOrFactory, validateTopo);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
type EstablishedInput =
|
|
100
|
+
| Partial<TrailContext>
|
|
101
|
+
| TestAllEstablishedOptions
|
|
102
|
+
| (() => Partial<TrailContext> | TestAllEstablishedOptions);
|
|
103
|
+
|
|
104
|
+
const isEstablishedOptions = (
|
|
105
|
+
input: Partial<TrailContext> | TestAllEstablishedOptions | undefined
|
|
106
|
+
): input is TestAllEstablishedOptions =>
|
|
107
|
+
input !== undefined &&
|
|
108
|
+
(Object.hasOwn(input, 'cli') ||
|
|
109
|
+
Object.hasOwn(input, 'createPermit') ||
|
|
110
|
+
Object.hasOwn(input, 'ctx') ||
|
|
111
|
+
Object.hasOwn(input, 'mcp') ||
|
|
112
|
+
Object.hasOwn(input, 'resources') ||
|
|
113
|
+
Object.hasOwn(input, 'strictPermits'));
|
|
114
|
+
|
|
115
|
+
const normalizeEstablishedOptions = (
|
|
116
|
+
input?: Partial<TrailContext> | TestAllEstablishedOptions
|
|
117
|
+
): TestAllEstablishedOptions =>
|
|
118
|
+
isEstablishedOptions(input) ? input : { ctx: input };
|
|
119
|
+
|
|
120
|
+
const toExecutionOptions = (
|
|
121
|
+
options: TestAllEstablishedOptions
|
|
122
|
+
): TestExecutionOptions => ({
|
|
123
|
+
...(options.createPermit === undefined
|
|
124
|
+
? {}
|
|
125
|
+
: { createPermit: options.createPermit }),
|
|
126
|
+
...(options.ctx === undefined ? {} : { ctx: options.ctx }),
|
|
127
|
+
...(options.resources === undefined ? {} : { resources: options.resources }),
|
|
128
|
+
...(options.strictPermits === undefined
|
|
129
|
+
? {}
|
|
130
|
+
: { strictPermits: options.strictPermits }),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const toCliHarnessOptions = (
|
|
134
|
+
topo: Topo,
|
|
135
|
+
options: TestAllEstablishedOptions
|
|
136
|
+
) => {
|
|
137
|
+
const cliOptions = {
|
|
138
|
+
graph: topo,
|
|
139
|
+
...options.cli,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
if (options.ctx !== undefined) {
|
|
143
|
+
cliOptions.ctx = options.ctx;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return cliOptions;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const toMcpHarnessOptions = (
|
|
150
|
+
topo: Topo,
|
|
151
|
+
options: TestAllEstablishedOptions
|
|
152
|
+
) => ({
|
|
153
|
+
graph: topo,
|
|
154
|
+
...options.mcp,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const registerEstablishedSurfaceSuite = (
|
|
158
|
+
topo: Topo,
|
|
159
|
+
resolveInput: () =>
|
|
160
|
+
| Partial<TrailContext>
|
|
161
|
+
| TestAllEstablishedOptions
|
|
162
|
+
| undefined
|
|
163
|
+
): void => {
|
|
164
|
+
describe('surfaces', () => {
|
|
165
|
+
test('CLI projection validates established topo', () => {
|
|
166
|
+
const options = normalizeEstablishedOptions(resolveInput());
|
|
167
|
+
expect(() =>
|
|
168
|
+
createCliHarness(toCliHarnessOptions(topo, options))
|
|
169
|
+
).not.toThrow();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('MCP projection validates established topo', () => {
|
|
173
|
+
const options = normalizeEstablishedOptions(resolveInput());
|
|
174
|
+
expect(() =>
|
|
175
|
+
createMcpHarness(toMcpHarnessOptions(topo, options))
|
|
176
|
+
).not.toThrow();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export const testAllEstablished = (
|
|
182
|
+
topo: Topo,
|
|
183
|
+
optionsOrFactory?: EstablishedInput
|
|
184
|
+
): void => {
|
|
185
|
+
const resolveInput =
|
|
186
|
+
typeof optionsOrFactory === 'function'
|
|
187
|
+
? optionsOrFactory
|
|
188
|
+
: () => optionsOrFactory;
|
|
189
|
+
|
|
190
|
+
registerGovernanceSuite(
|
|
191
|
+
topo,
|
|
192
|
+
() => toExecutionOptions(normalizeEstablishedOptions(resolveInput())),
|
|
193
|
+
validateEstablishedTopo
|
|
194
|
+
);
|
|
195
|
+
registerEstablishedSurfaceSuite(topo, resolveInput);
|
|
196
|
+
};
|