@ontrails/testing 1.0.0-beta.14 → 1.0.0-beta.16
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 +69 -0
- package/README.md +12 -12
- package/package.json +15 -6
- package/src/all.ts +153 -16
- package/src/assertions.ts +253 -0
- package/src/context.ts +67 -38
- package/src/contracts.ts +15 -24
- package/src/crosses.ts +93 -51
- package/src/detours.ts +155 -18
- package/src/effective-examples.ts +350 -0
- package/src/examples.ts +139 -63
- package/src/harness-cli.ts +19 -11
- package/src/harness-mcp.ts +9 -8
- package/src/index.ts +14 -4
- package/src/logger.ts +3 -1
- package/src/scenario.ts +368 -0
- package/src/signals.ts +221 -0
- package/src/types.ts +64 -6
- 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 -31
- 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 -75
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js +0 -107
- package/dist/context.js.map +0 -1
- package/dist/contracts.d.ts +0 -17
- package/dist/contracts.d.ts.map +0 -1
- package/dist/contracts.js +0 -71
- package/dist/contracts.js.map +0 -1
- package/dist/crosses.d.ts +0 -38
- package/dist/crosses.d.ts.map +0 -1
- package/dist/crosses.js +0 -213
- package/dist/crosses.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 -23
- package/dist/examples.d.ts.map +0 -1
- package/dist/examples.js +0 -202
- package/dist/examples.js.map +0 -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
- package/dist/harness-cli.d.ts +0 -21
- package/dist/harness-cli.d.ts.map +0 -1
- package/dist/harness-cli.js +0 -200
- 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 -53
- package/dist/harness-mcp.js.map +0 -1
- package/dist/index.d.ts +0 -16
- 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__/all.test.ts +0 -135
- package/src/__tests__/context.test.ts +0 -150
- package/src/__tests__/contracts.test.ts +0 -185
- package/src/__tests__/crosses.test.ts +0 -587
- package/src/__tests__/detours.test.ts +0 -55
- package/src/__tests__/examples.test.ts +0 -534
- package/src/__tests__/harness-cli.test.ts +0 -66
- package/src/__tests__/logger.test.ts +0 -136
- package/src/__tests__/trail.test.ts +0 -99
- package/tsconfig.json +0 -9
- package/tsconfig.tsbuildinfo +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,74 @@
|
|
|
1
1
|
# @ontrails/testing
|
|
2
2
|
|
|
3
|
+
## 1.0.0-beta.16
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- bcdc484: Add an explicit `unmockable: { reason }` resource marker and have testing auto-mock resolution skip intentionally unmockable resources.
|
|
8
|
+
- bb1cadf: Rename the generated `testAll` suite from `governance` to `contract`.
|
|
9
|
+
|
|
10
|
+
### Patch Changes
|
|
11
|
+
|
|
12
|
+
- d40430d: Remove the retired `@ontrails/logging` workspace from the prerelease package set. Use `@ontrails/observe` for log and trace sink contracts and `@ontrails/logtape` for LogTape forwarding.
|
|
13
|
+
- 193ae78: Migrate testing logger types from the legacy `@ontrails/logging` package to `@ontrails/observe`.
|
|
14
|
+
- Updated dependencies [73622ae]
|
|
15
|
+
- Updated dependencies [e991a5b]
|
|
16
|
+
- Updated dependencies [25f3c5c]
|
|
17
|
+
- Updated dependencies [6300f70]
|
|
18
|
+
- Updated dependencies [d172013]
|
|
19
|
+
- Updated dependencies [c3fc5c3]
|
|
20
|
+
- Updated dependencies [20d7a5c]
|
|
21
|
+
- Updated dependencies [be5fb46]
|
|
22
|
+
- Updated dependencies [e898cc4]
|
|
23
|
+
- Updated dependencies [a8997ed]
|
|
24
|
+
- Updated dependencies [fe03945]
|
|
25
|
+
- Updated dependencies [3395234]
|
|
26
|
+
- Updated dependencies [d40430d]
|
|
27
|
+
- Updated dependencies [bcdc484]
|
|
28
|
+
- Updated dependencies [ed171d5]
|
|
29
|
+
- Updated dependencies [49c2e7d]
|
|
30
|
+
- Updated dependencies [331e3a9]
|
|
31
|
+
- Updated dependencies [4399fdb]
|
|
32
|
+
- Updated dependencies [4b8d13b]
|
|
33
|
+
- Updated dependencies [fbd42fc]
|
|
34
|
+
- Updated dependencies [63d1aef]
|
|
35
|
+
- Updated dependencies [112b9f2]
|
|
36
|
+
- Updated dependencies [893025e]
|
|
37
|
+
- Updated dependencies [ed888e2]
|
|
38
|
+
- Updated dependencies [2e05e27]
|
|
39
|
+
- Updated dependencies [9cdb0f2]
|
|
40
|
+
- Updated dependencies [c8caa5e]
|
|
41
|
+
- Updated dependencies [f4b90c9]
|
|
42
|
+
- Updated dependencies [eec5e9d]
|
|
43
|
+
- Updated dependencies [4e75129]
|
|
44
|
+
- Updated dependencies [47505fe]
|
|
45
|
+
- Updated dependencies [ebd4434]
|
|
46
|
+
- Updated dependencies [863d473]
|
|
47
|
+
- Updated dependencies [344f2f7]
|
|
48
|
+
- Updated dependencies [26f9ffd]
|
|
49
|
+
- Updated dependencies [66056ac]
|
|
50
|
+
- Updated dependencies [0bad534]
|
|
51
|
+
- Updated dependencies [10eae9a]
|
|
52
|
+
- Updated dependencies [22c6c06]
|
|
53
|
+
- @ontrails/core@1.0.0-beta.16
|
|
54
|
+
- @ontrails/cli@1.0.0-beta.16
|
|
55
|
+
- @ontrails/mcp@1.0.0-beta.16
|
|
56
|
+
- @ontrails/observe@1.0.0-beta.16
|
|
57
|
+
|
|
58
|
+
## 1.0.0-beta.15
|
|
59
|
+
|
|
60
|
+
### Minor Changes
|
|
61
|
+
|
|
62
|
+
- f511a3a: Rename `app` field to `graph` in `CliHarnessOptions` and `McpHarnessOptions`
|
|
63
|
+
|
|
64
|
+
### Patch Changes
|
|
65
|
+
|
|
66
|
+
- Updated dependencies [4ad6b25]
|
|
67
|
+
- @ontrails/core@1.0.0-beta.15
|
|
68
|
+
- @ontrails/cli@1.0.0-beta.15
|
|
69
|
+
- @ontrails/observe@1.0.0-beta.15
|
|
70
|
+
- @ontrails/mcp@1.0.0-beta.15
|
|
71
|
+
|
|
3
72
|
## 1.0.0-beta.14
|
|
4
73
|
|
|
5
74
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# @ontrails/testing
|
|
2
2
|
|
|
3
|
-
Contract-driven testing for Trails. Add examples to your trails, then `testAll(
|
|
3
|
+
Contract-driven testing for Trails. Add examples to your trails, then `testAll(graph)` runs them as assertions, validates output schemas, checks composition graphs, and verifies structural integrity. One line of test code, full contract coverage.
|
|
4
4
|
|
|
5
5
|
## Usage
|
|
6
6
|
|
|
7
7
|
```typescript
|
|
8
8
|
import { testAll } from '@ontrails/testing';
|
|
9
|
-
import {
|
|
9
|
+
import { graph } from '../app';
|
|
10
10
|
|
|
11
|
-
testAll(
|
|
11
|
+
testAll(graph);
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
That single call covers example execution, contract validation, detour checks, and topo validation. For most apps, this is all you need.
|
|
@@ -18,20 +18,20 @@ If you want finer control:
|
|
|
18
18
|
```typescript
|
|
19
19
|
import { testExamples, testContracts, testDetours } from '@ontrails/testing';
|
|
20
20
|
|
|
21
|
-
testExamples(
|
|
22
|
-
testContracts(
|
|
23
|
-
testDetours(
|
|
21
|
+
testExamples(graph); // Run every trail's examples as tests
|
|
22
|
+
testContracts(graph); // Validate outputs against declared schemas
|
|
23
|
+
testDetours(graph); // Validate detour constructor, recover, and ordering semantics
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
## API
|
|
27
27
|
|
|
28
28
|
| Export | What it does |
|
|
29
29
|
| --- | --- |
|
|
30
|
-
| `testAll(topo, ctx?)` | Single-line
|
|
30
|
+
| `testAll(topo, ctx?)` | Single-line contract suite: validation + examples + contracts + detours |
|
|
31
31
|
| `testExamples(topo, ctx?)` | Run trail examples as `describe`/`test` blocks |
|
|
32
32
|
| `testTrail(trail, scenarios)` | Custom scenarios for edge cases, error paths, and cross chains |
|
|
33
33
|
| `testContracts(topo, ctx?)` | Validate output against declared schemas |
|
|
34
|
-
| `testDetours(topo)` |
|
|
34
|
+
| `testDetours(topo)` | Validate detour constructor, recover, and shadowing semantics |
|
|
35
35
|
| `createCrossContext(options?)` | Mock `CrossFn` for testing composite trails; returns preconfigured `Result` values keyed by trail ID |
|
|
36
36
|
| `createTestContext(options?)` | `TrailContext` with sensible test defaults |
|
|
37
37
|
| `createTestLogger()` | Logger that captures entries in memory for assertions |
|
|
@@ -81,23 +81,23 @@ const cross = createCrossContext({
|
|
|
81
81
|
},
|
|
82
82
|
});
|
|
83
83
|
const ctx = { ...createTestContext(), cross };
|
|
84
|
-
const result = await onboardTrail.
|
|
84
|
+
const result = await onboardTrail.blaze({ name: 'Delta', type: 'tool' }, ctx);
|
|
85
85
|
```
|
|
86
86
|
|
|
87
87
|
Calls to unregistered trail IDs return `Result.err` with a descriptive message, so missing stubs fail loudly.
|
|
88
88
|
|
|
89
|
-
##
|
|
89
|
+
## Surface Harnesses
|
|
90
90
|
|
|
91
91
|
```typescript
|
|
92
92
|
import { createCliHarness, createMcpHarness } from '@ontrails/testing';
|
|
93
93
|
|
|
94
94
|
// CLI
|
|
95
|
-
const cli = createCliHarness({
|
|
95
|
+
const cli = createCliHarness({ graph });
|
|
96
96
|
const result = await cli.run('entity show --name Alpha --output json');
|
|
97
97
|
expect(result.exitCode).toBe(0);
|
|
98
98
|
|
|
99
99
|
// MCP
|
|
100
|
-
const mcp = createMcpHarness({
|
|
100
|
+
const mcp = createMcpHarness({ graph });
|
|
101
101
|
const tool = await mcp.callTool('myapp_entity_show', { name: 'Alpha' });
|
|
102
102
|
expect(tool.isError).toBe(false);
|
|
103
103
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ontrails/testing",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.16",
|
|
4
|
+
"files": [
|
|
5
|
+
"src/**/*.ts",
|
|
6
|
+
"!src/**/__tests__/**",
|
|
7
|
+
"!src/**/*.test.ts",
|
|
8
|
+
"!src/**/*.test-d.ts",
|
|
9
|
+
"README.md",
|
|
10
|
+
"CHANGELOG.md"
|
|
11
|
+
],
|
|
4
12
|
"type": "module",
|
|
5
13
|
"exports": {
|
|
6
14
|
".": "./src/index.ts",
|
|
@@ -14,13 +22,14 @@
|
|
|
14
22
|
"clean": "rm -rf dist *.tsbuildinfo"
|
|
15
23
|
},
|
|
16
24
|
"devDependencies": {
|
|
17
|
-
"@ontrails/
|
|
25
|
+
"@ontrails/drizzle": "^1.0.0-beta.15",
|
|
26
|
+
"@ontrails/store": "^1.0.0-beta.15"
|
|
18
27
|
},
|
|
19
28
|
"peerDependencies": {
|
|
20
|
-
"@ontrails/cli": "^1.0.0-beta.
|
|
21
|
-
"@ontrails/core": "^1.0.0-beta.
|
|
22
|
-
"@ontrails/
|
|
23
|
-
"@ontrails/
|
|
29
|
+
"@ontrails/cli": "^1.0.0-beta.15",
|
|
30
|
+
"@ontrails/core": "^1.0.0-beta.15",
|
|
31
|
+
"@ontrails/mcp": "^1.0.0-beta.15",
|
|
32
|
+
"@ontrails/observe": "^1.0.0-beta.15",
|
|
24
33
|
"zod": "^4.3.5"
|
|
25
34
|
}
|
|
26
35
|
}
|
package/src/all.ts
CHANGED
|
@@ -1,28 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* testAll — single-line
|
|
2
|
+
* testAll — single-line contract 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
|
-
* Run the full
|
|
22
|
+
* Run the full contract test suite for a Topo.
|
|
20
23
|
*
|
|
21
|
-
* Generates a `
|
|
24
|
+
* Generates a `contract` describe block containing:
|
|
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 registerContractSuite = (
|
|
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
|
-
describe('
|
|
81
|
+
describe('contract', () => {
|
|
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
|
+
registerContractSuite(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
|
+
registerContractSuite(
|
|
191
|
+
topo,
|
|
192
|
+
() => toExecutionOptions(normalizeEstablishedOptions(resolveInput())),
|
|
193
|
+
validateEstablishedTopo
|
|
194
|
+
);
|
|
195
|
+
registerEstablishedSurfaceSuite(topo, resolveInput);
|
|
196
|
+
};
|
package/src/assertions.ts
CHANGED
|
@@ -49,6 +49,40 @@ export const expectErr = <T, E>(result: Result<T, E>): E => {
|
|
|
49
49
|
return (result as unknown as { error: E }).error;
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Result Match Tokens
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
export interface OkResultMatch {
|
|
57
|
+
readonly __resultMatch: 'ok';
|
|
58
|
+
readonly value?: unknown | undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ErrResultMatch {
|
|
62
|
+
readonly __resultMatch: 'err';
|
|
63
|
+
readonly error?: unknown | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type ResultMatchToken = OkResultMatch | ErrResultMatch;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create a partial-match token for `Result.ok(...)` values nested inside
|
|
70
|
+
* arrays or objects, such as the `Result[]` returned by batch `ctx.cross()`.
|
|
71
|
+
*/
|
|
72
|
+
export const okResultMatch = (value?: unknown): OkResultMatch => ({
|
|
73
|
+
__resultMatch: 'ok',
|
|
74
|
+
value,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Create a partial-match token for `Result.err(...)` values nested inside
|
|
79
|
+
* arrays or objects, such as mixed-success batch `ctx.cross()`.
|
|
80
|
+
*/
|
|
81
|
+
export const errResultMatch = (error?: unknown): ErrResultMatch => ({
|
|
82
|
+
__resultMatch: 'err',
|
|
83
|
+
error,
|
|
84
|
+
});
|
|
85
|
+
|
|
52
86
|
// ---------------------------------------------------------------------------
|
|
53
87
|
// Full Match
|
|
54
88
|
// ---------------------------------------------------------------------------
|
|
@@ -87,6 +121,225 @@ export const assertSchemaMatch = (
|
|
|
87
121
|
}
|
|
88
122
|
};
|
|
89
123
|
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Partial Match
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/** Format a path for error messages. */
|
|
129
|
+
const formatLoc = (path: readonly string[]): string =>
|
|
130
|
+
path.length > 0 ? path.join('.') : 'root';
|
|
131
|
+
|
|
132
|
+
interface ResultLike {
|
|
133
|
+
readonly error?: unknown;
|
|
134
|
+
isErr(): boolean;
|
|
135
|
+
isOk(): boolean;
|
|
136
|
+
readonly value?: unknown;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const isResultMatchToken = (value: unknown): value is ResultMatchToken =>
|
|
140
|
+
typeof value === 'object' &&
|
|
141
|
+
value !== null &&
|
|
142
|
+
'__resultMatch' in value &&
|
|
143
|
+
((value as Record<string, unknown>)['__resultMatch'] === 'ok' ||
|
|
144
|
+
(value as Record<string, unknown>)['__resultMatch'] === 'err');
|
|
145
|
+
|
|
146
|
+
const isResultLike = (value: unknown): value is ResultLike =>
|
|
147
|
+
typeof value === 'object' &&
|
|
148
|
+
value !== null &&
|
|
149
|
+
'isOk' in value &&
|
|
150
|
+
typeof (value as Record<string, unknown>)['isOk'] === 'function' &&
|
|
151
|
+
'isErr' in value &&
|
|
152
|
+
typeof (value as Record<string, unknown>)['isErr'] === 'function';
|
|
153
|
+
|
|
154
|
+
/** Find an unconsumed actual element that deep-matches the expected object. */
|
|
155
|
+
const findObjectMatch = (
|
|
156
|
+
actual: unknown[],
|
|
157
|
+
elem: object,
|
|
158
|
+
consumed: ReadonlySet<number>,
|
|
159
|
+
path: readonly string[],
|
|
160
|
+
index: number
|
|
161
|
+
): number =>
|
|
162
|
+
actual.findIndex((a, idx) => {
|
|
163
|
+
if (consumed.has(idx)) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
// oxlint-disable-next-line no-use-before-define -- mutual recursion with assertSubset
|
|
168
|
+
assertSubset(a, elem, [...path, `[${String(index)}]`]);
|
|
169
|
+
return true;
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Assert that every element in `expected` exists in `actual` (order-independent).
|
|
177
|
+
*
|
|
178
|
+
* Tracks consumed indices so that duplicate expected elements each require a
|
|
179
|
+
* distinct actual element — `['a', 'a']` does not match `['a']`.
|
|
180
|
+
*/
|
|
181
|
+
const assertArraySubset = (
|
|
182
|
+
actual: unknown[],
|
|
183
|
+
expected: unknown[],
|
|
184
|
+
path: readonly string[],
|
|
185
|
+
loc: string
|
|
186
|
+
): void => {
|
|
187
|
+
const consumed = new Set<number>();
|
|
188
|
+
for (let i = 0; i < expected.length; i += 1) {
|
|
189
|
+
const elem = expected[i];
|
|
190
|
+
const matchIndex =
|
|
191
|
+
typeof elem === 'object' && elem !== null
|
|
192
|
+
? findObjectMatch(actual, elem, consumed, path, i)
|
|
193
|
+
: actual.findIndex((a, idx) => !consumed.has(idx) && a === elem);
|
|
194
|
+
|
|
195
|
+
if (matchIndex === -1) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`at ${loc}[${String(i)}]: expected array to contain ${JSON.stringify(elem)}`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
consumed.add(matchIndex);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** Assert that every key in `expected` exists in `actual` with a matching value. */
|
|
205
|
+
const assertObjectSubset = (
|
|
206
|
+
actual: Record<string, unknown>,
|
|
207
|
+
expected: Record<string, unknown>,
|
|
208
|
+
path: readonly string[]
|
|
209
|
+
): void => {
|
|
210
|
+
for (const key of Object.keys(expected)) {
|
|
211
|
+
if (!(key in actual)) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`at ${[...path, key].join('.')}: key not found in actual`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
// oxlint-disable-next-line no-use-before-define -- mutual recursion with assertSubset
|
|
217
|
+
assertSubset(actual[key], expected[key], [...path, key]);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Recursively assert that `actual` is a superset of `expected`.
|
|
223
|
+
*
|
|
224
|
+
* - **Scalars:** strict equality.
|
|
225
|
+
* - **Objects:** every key in `expected` must exist in `actual` with a matching
|
|
226
|
+
* value. Extra keys in `actual` are ignored.
|
|
227
|
+
* - **Arrays:** every element in `expected` must exist in `actual`
|
|
228
|
+
* (order-independent subset check).
|
|
229
|
+
* - **Nested objects:** recursive subset matching.
|
|
230
|
+
*/
|
|
231
|
+
// oxlint-disable-next-line max-statements -- recursive dispatch across four type branches
|
|
232
|
+
const assertSubset = (
|
|
233
|
+
actual: unknown,
|
|
234
|
+
expected: unknown,
|
|
235
|
+
path: readonly string[]
|
|
236
|
+
): void => {
|
|
237
|
+
const loc = formatLoc(path);
|
|
238
|
+
|
|
239
|
+
if (expected === null || expected === undefined) {
|
|
240
|
+
if (actual !== expected) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`at ${loc}: expected ${String(expected)}, got ${String(actual)}`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (isResultMatchToken(expected)) {
|
|
249
|
+
// oxlint-disable-next-line no-use-before-define -- result token matching delegates back into assertSubset
|
|
250
|
+
assertResultTokenMatch(actual, expected, path);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (Array.isArray(expected)) {
|
|
255
|
+
if (!Array.isArray(actual)) {
|
|
256
|
+
throw new TypeError(`at ${loc}: expected an array, got ${typeof actual}`);
|
|
257
|
+
}
|
|
258
|
+
assertArraySubset(actual, expected, path, loc);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (typeof expected === 'object') {
|
|
263
|
+
if (
|
|
264
|
+
typeof actual !== 'object' ||
|
|
265
|
+
actual === null ||
|
|
266
|
+
Array.isArray(actual)
|
|
267
|
+
) {
|
|
268
|
+
throw new Error(`at ${loc}: expected an object, got ${typeof actual}`);
|
|
269
|
+
}
|
|
270
|
+
assertObjectSubset(
|
|
271
|
+
actual as Record<string, unknown>,
|
|
272
|
+
expected as Record<string, unknown>,
|
|
273
|
+
path
|
|
274
|
+
);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (actual !== expected) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`at ${loc}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const assertOkResultTokenMatch = (
|
|
286
|
+
actual: ResultLike,
|
|
287
|
+
expected: OkResultMatch,
|
|
288
|
+
loc: string,
|
|
289
|
+
path: readonly string[]
|
|
290
|
+
): void => {
|
|
291
|
+
if (!actual.isOk()) {
|
|
292
|
+
throw new Error(`at ${loc}: expected Result.ok(...), got Result.err(...)`);
|
|
293
|
+
}
|
|
294
|
+
if (expected.value !== undefined) {
|
|
295
|
+
assertSubset(actual.value, expected.value, [...path, 'value']);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const assertErrResultTokenMatch = (
|
|
300
|
+
actual: ResultLike,
|
|
301
|
+
expected: ErrResultMatch,
|
|
302
|
+
loc: string,
|
|
303
|
+
path: readonly string[]
|
|
304
|
+
): void => {
|
|
305
|
+
if (!actual.isErr()) {
|
|
306
|
+
throw new Error(`at ${loc}: expected Result.err(...), got Result.ok(...)`);
|
|
307
|
+
}
|
|
308
|
+
if (expected.error !== undefined) {
|
|
309
|
+
assertSubset(actual.error, expected.error, [...path, 'error']);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const assertResultTokenMatch = (
|
|
314
|
+
actual: unknown,
|
|
315
|
+
expected: ResultMatchToken,
|
|
316
|
+
path: readonly string[]
|
|
317
|
+
): void => {
|
|
318
|
+
const loc = formatLoc(path);
|
|
319
|
+
if (!isResultLike(actual)) {
|
|
320
|
+
throw new TypeError(`at ${loc}: expected a Result-like value`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (expected.__resultMatch === 'ok') {
|
|
324
|
+
assertOkResultTokenMatch(actual, expected, loc, path);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
assertErrResultTokenMatch(actual, expected, loc, path);
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Assert that the result is ok and its value is a superset of the expected
|
|
333
|
+
* partial output. Declared fields must match; extra fields are ignored.
|
|
334
|
+
*/
|
|
335
|
+
export const assertPartialMatch = (
|
|
336
|
+
result: Result<unknown, Error>,
|
|
337
|
+
expectedMatch: unknown
|
|
338
|
+
): void => {
|
|
339
|
+
const value = expectOk(result);
|
|
340
|
+
assertSubset(value, expectedMatch, []);
|
|
341
|
+
};
|
|
342
|
+
|
|
90
343
|
// ---------------------------------------------------------------------------
|
|
91
344
|
// Error Match
|
|
92
345
|
// ---------------------------------------------------------------------------
|