@ontrails/testing 1.0.0-beta.13 → 1.0.0-beta.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +28 -0
  3. package/README.md +11 -11
  4. package/dist/all.d.ts +10 -5
  5. package/dist/all.d.ts.map +1 -1
  6. package/dist/all.js +78 -26
  7. package/dist/all.js.map +1 -1
  8. package/dist/assertions.d.ts +23 -0
  9. package/dist/assertions.d.ts.map +1 -1
  10. package/dist/assertions.js +154 -0
  11. package/dist/assertions.js.map +1 -1
  12. package/dist/context.d.ts +17 -16
  13. package/dist/context.d.ts.map +1 -1
  14. package/dist/context.js +31 -20
  15. package/dist/context.js.map +1 -1
  16. package/dist/contracts.d.ts.map +1 -1
  17. package/dist/contracts.js +9 -5
  18. package/dist/contracts.js.map +1 -1
  19. package/dist/crosses.d.ts +4 -4
  20. package/dist/crosses.d.ts.map +1 -1
  21. package/dist/crosses.js +49 -39
  22. package/dist/crosses.js.map +1 -1
  23. package/dist/detours.d.ts +5 -4
  24. package/dist/detours.d.ts.map +1 -1
  25. package/dist/detours.js +93 -14
  26. package/dist/detours.js.map +1 -1
  27. package/dist/effective-examples.d.ts +30 -0
  28. package/dist/effective-examples.d.ts.map +1 -0
  29. package/dist/effective-examples.js +227 -0
  30. package/dist/effective-examples.js.map +1 -0
  31. package/dist/examples.d.ts +1 -1
  32. package/dist/examples.d.ts.map +1 -1
  33. package/dist/examples.js +79 -41
  34. package/dist/examples.js.map +1 -1
  35. package/dist/harness-cli.d.ts +3 -3
  36. package/dist/harness-cli.d.ts.map +1 -1
  37. package/dist/harness-cli.js +25 -33
  38. package/dist/harness-cli.js.map +1 -1
  39. package/dist/harness-mcp.d.ts +3 -3
  40. package/dist/harness-mcp.d.ts.map +1 -1
  41. package/dist/harness-mcp.js +9 -8
  42. package/dist/harness-mcp.js.map +1 -1
  43. package/dist/index.d.ts +6 -4
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +5 -2
  46. package/dist/index.js.map +1 -1
  47. package/dist/scenario.d.ts +37 -0
  48. package/dist/scenario.d.ts.map +1 -0
  49. package/dist/scenario.js +235 -0
  50. package/dist/scenario.js.map +1 -0
  51. package/dist/types.d.ts +38 -5
  52. package/dist/types.d.ts.map +1 -1
  53. package/package.json +9 -5
  54. package/src/__tests__/all.test.ts +217 -29
  55. package/src/__tests__/context.test.ts +32 -12
  56. package/src/__tests__/contracts.test.ts +72 -18
  57. package/src/__tests__/crosses.test.ts +78 -78
  58. package/src/__tests__/detours.test.ts +176 -19
  59. package/src/__tests__/effective-examples.test.ts +203 -0
  60. package/src/__tests__/examples.test.ts +152 -50
  61. package/src/__tests__/harness-cli.test.ts +90 -0
  62. package/src/__tests__/harness-mcp.test.ts +37 -0
  63. package/src/__tests__/partial-match.test.ts +126 -0
  64. package/src/__tests__/scenario.test.ts +381 -0
  65. package/src/all.ts +149 -12
  66. package/src/assertions.ts +253 -0
  67. package/src/context.ts +64 -38
  68. package/src/contracts.ts +14 -8
  69. package/src/crosses.ts +93 -51
  70. package/src/detours.ts +155 -18
  71. package/src/effective-examples.ts +350 -0
  72. package/src/examples.ts +127 -59
  73. package/src/harness-cli.ts +33 -49
  74. package/src/harness-mcp.ts +9 -8
  75. package/src/index.ts +13 -3
  76. package/src/scenario.ts +370 -0
  77. package/src/types.ts +63 -5
  78. package/tsconfig.tests.json +10 -0
  79. package/tsconfig.tsbuildinfo +1 -1
  80. package/dist/follows.d.ts +0 -38
  81. package/dist/follows.d.ts.map +0 -1
  82. package/dist/follows.js +0 -212
  83. package/dist/follows.js.map +0 -1
@@ -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
- * verification into one describe block.
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 target verification via `testDetours`
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 { app } from '../src/app.js';
37
+ * import { graph } from '../src/app.js';
35
38
  *
36
- * testAll(app);
39
+ * testAll(graph);
37
40
  * ```
38
41
  */
39
- export const testAll = (
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
- | Partial<TrailContext>
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
- const result = validateTopo(topo);
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
+ };