@ontrails/testing 0.2.0
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 +807 -0
- package/README.md +157 -0
- package/package.json +57 -0
- package/src/all-established.ts +168 -0
- package/src/all.ts +94 -0
- package/src/assertions.ts +361 -0
- package/src/cli.ts +6 -0
- package/src/composes.ts +433 -0
- package/src/context.ts +228 -0
- package/src/contracts.ts +109 -0
- package/src/detours.ts +181 -0
- package/src/effective-examples.ts +408 -0
- package/src/errors.ts +47 -0
- package/src/examples.ts +439 -0
- package/src/harness-cli.ts +335 -0
- package/src/harness-http.ts +341 -0
- package/src/harness-mcp.ts +98 -0
- package/src/http.ts +10 -0
- package/src/index.ts +48 -0
- package/src/logger.ts +127 -0
- package/src/mcp.ts +6 -0
- package/src/scenario.ts +375 -0
- package/src/signals.ts +221 -0
- package/src/surface-parity.ts +389 -0
- package/src/trail.ts +116 -0
- package/src/types.ts +89 -0
package/src/signals.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
FireFn,
|
|
5
|
+
TrailContext,
|
|
6
|
+
TrailExample,
|
|
7
|
+
TrailExampleSignalAssertion,
|
|
8
|
+
} from '@ontrails/core';
|
|
9
|
+
import { summarizeSignalPayload } from '@ontrails/core';
|
|
10
|
+
|
|
11
|
+
export interface RecordedSignal {
|
|
12
|
+
readonly payload: unknown;
|
|
13
|
+
readonly signalId: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SignalAssertionHarness {
|
|
17
|
+
readonly assert: () => void;
|
|
18
|
+
readonly ctx: TrailContext;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const noop = (): void => undefined;
|
|
22
|
+
|
|
23
|
+
const resolveSignalId = (signal: unknown): string => {
|
|
24
|
+
if (typeof signal === 'string') {
|
|
25
|
+
return signal;
|
|
26
|
+
}
|
|
27
|
+
if (
|
|
28
|
+
typeof signal === 'object' &&
|
|
29
|
+
signal !== null &&
|
|
30
|
+
'id' in signal &&
|
|
31
|
+
typeof (signal as { readonly id: unknown }).id === 'string'
|
|
32
|
+
) {
|
|
33
|
+
return (signal as { readonly id: string }).id;
|
|
34
|
+
}
|
|
35
|
+
return '<unknown signal>';
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const assertionSignalId = (assertion: TrailExampleSignalAssertion): string =>
|
|
39
|
+
resolveSignalId(assertion.signal);
|
|
40
|
+
|
|
41
|
+
const formatPayloadSummary = (payload: unknown): string => {
|
|
42
|
+
const summary = summarizeSignalPayload(payload);
|
|
43
|
+
const parts = [
|
|
44
|
+
`redacted=${summary.redacted}`,
|
|
45
|
+
`shape=${summary.shape}`,
|
|
46
|
+
`digest=${summary.digest}`,
|
|
47
|
+
];
|
|
48
|
+
if (summary.topLevelEntryCount !== undefined) {
|
|
49
|
+
parts.push(`topLevelEntryCount=${summary.topLevelEntryCount}`);
|
|
50
|
+
}
|
|
51
|
+
return `{${parts.join(' ')}}`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const formatAssertion = (assertion: TrailExampleSignalAssertion): string => {
|
|
55
|
+
const parts = [`signal=${assertionSignalId(assertion)}`];
|
|
56
|
+
if (assertion.payload !== undefined) {
|
|
57
|
+
parts.push(`payloadSummary=${formatPayloadSummary(assertion.payload)}`);
|
|
58
|
+
}
|
|
59
|
+
if (assertion.payloadMatch !== undefined) {
|
|
60
|
+
parts.push(
|
|
61
|
+
`payloadMatchSummary=${formatPayloadSummary(assertion.payloadMatch)}`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (assertion.times !== undefined) {
|
|
65
|
+
parts.push(`times=${assertion.times}`);
|
|
66
|
+
}
|
|
67
|
+
return parts.join(' ');
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const formatObserved = (observed: readonly RecordedSignal[]): string => {
|
|
71
|
+
if (observed.length === 0) {
|
|
72
|
+
return '<none>';
|
|
73
|
+
}
|
|
74
|
+
return observed
|
|
75
|
+
.map(
|
|
76
|
+
(record) =>
|
|
77
|
+
`${record.signalId} payloadSummary=${formatPayloadSummary(record.payload)}`
|
|
78
|
+
)
|
|
79
|
+
.join('; ');
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const subsetArrayMatches = (
|
|
83
|
+
actual: readonly unknown[],
|
|
84
|
+
expected: readonly unknown[]
|
|
85
|
+
): boolean => {
|
|
86
|
+
const consumed = new Set<number>();
|
|
87
|
+
for (const expectedItem of expected) {
|
|
88
|
+
const matchIndex = actual.findIndex(
|
|
89
|
+
(actualItem, index) =>
|
|
90
|
+
!consumed.has(index) &&
|
|
91
|
+
// oxlint-disable-next-line no-use-before-define -- mutual recursion with subsetMatches
|
|
92
|
+
subsetMatches(actualItem, expectedItem)
|
|
93
|
+
);
|
|
94
|
+
if (matchIndex === -1) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
consumed.add(matchIndex);
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const subsetObjectMatches = (
|
|
103
|
+
actual: Record<string, unknown>,
|
|
104
|
+
expected: Record<string, unknown>
|
|
105
|
+
): boolean =>
|
|
106
|
+
Object.keys(expected).every(
|
|
107
|
+
(key) =>
|
|
108
|
+
key in actual &&
|
|
109
|
+
// oxlint-disable-next-line no-use-before-define -- mutual recursion with subsetMatches
|
|
110
|
+
subsetMatches(actual[key], expected[key])
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const subsetMatches = (actual: unknown, expected: unknown): boolean => {
|
|
114
|
+
if (Array.isArray(expected)) {
|
|
115
|
+
return Array.isArray(actual) && subsetArrayMatches(actual, expected);
|
|
116
|
+
}
|
|
117
|
+
if (expected !== null && typeof expected === 'object') {
|
|
118
|
+
return (
|
|
119
|
+
actual !== null &&
|
|
120
|
+
typeof actual === 'object' &&
|
|
121
|
+
!Array.isArray(actual) &&
|
|
122
|
+
subsetObjectMatches(
|
|
123
|
+
actual as Record<string, unknown>,
|
|
124
|
+
expected as Record<string, unknown>
|
|
125
|
+
)
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return isDeepStrictEqual(actual, expected);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const payloadMatches = (
|
|
132
|
+
payload: unknown,
|
|
133
|
+
assertion: TrailExampleSignalAssertion
|
|
134
|
+
): boolean => {
|
|
135
|
+
if (
|
|
136
|
+
assertion.payload !== undefined &&
|
|
137
|
+
!isDeepStrictEqual(payload, assertion.payload)
|
|
138
|
+
) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
if (
|
|
142
|
+
assertion.payloadMatch !== undefined &&
|
|
143
|
+
!subsetMatches(payload, assertion.payloadMatch)
|
|
144
|
+
) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
return true;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const signalMatches = (
|
|
151
|
+
record: RecordedSignal,
|
|
152
|
+
assertion: TrailExampleSignalAssertion
|
|
153
|
+
): boolean =>
|
|
154
|
+
record.signalId === assertionSignalId(assertion) &&
|
|
155
|
+
payloadMatches(record.payload, assertion);
|
|
156
|
+
|
|
157
|
+
const assertValidTimes = (assertion: TrailExampleSignalAssertion): number => {
|
|
158
|
+
const times = assertion.times ?? 1;
|
|
159
|
+
if (!Number.isInteger(times) || times < 1) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`Signal assertion has invalid times value: ${formatAssertion(assertion)}`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
return times;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const assertSignalAssertion = (
|
|
168
|
+
example: TrailExample<unknown, unknown>,
|
|
169
|
+
assertion: TrailExampleSignalAssertion,
|
|
170
|
+
observed: readonly RecordedSignal[],
|
|
171
|
+
consumed: Set<number>
|
|
172
|
+
): void => {
|
|
173
|
+
const times = assertValidTimes(assertion);
|
|
174
|
+
for (let count = 0; count < times; count += 1) {
|
|
175
|
+
const matchIndex = observed.findIndex(
|
|
176
|
+
(record, index) =>
|
|
177
|
+
!consumed.has(index) && signalMatches(record, assertion)
|
|
178
|
+
);
|
|
179
|
+
if (matchIndex === -1) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`Example "${example.name}" expected signal ${formatAssertion(
|
|
182
|
+
assertion
|
|
183
|
+
)}; observed ${formatObserved(observed)}`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
consumed.add(matchIndex);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export const assertSignalAssertions = (
|
|
191
|
+
example: TrailExample<unknown, unknown>,
|
|
192
|
+
observed: readonly RecordedSignal[]
|
|
193
|
+
): void => {
|
|
194
|
+
const consumed = new Set<number>();
|
|
195
|
+
for (const assertion of example.signals ?? []) {
|
|
196
|
+
assertSignalAssertion(example, assertion, observed, consumed);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export const withSignalAssertions = (
|
|
201
|
+
ctx: TrailContext,
|
|
202
|
+
example: TrailExample<unknown, unknown>
|
|
203
|
+
): SignalAssertionHarness => {
|
|
204
|
+
if (example.signals === undefined || example.signals.length === 0) {
|
|
205
|
+
return { assert: noop, ctx };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const observed: RecordedSignal[] = [];
|
|
209
|
+
const baseFire = ctx.fire as
|
|
210
|
+
| ((signal: unknown, payload: unknown) => Promise<void>)
|
|
211
|
+
| undefined;
|
|
212
|
+
const fire = (async (signal: unknown, payload: unknown): Promise<void> => {
|
|
213
|
+
observed.push({ payload, signalId: resolveSignalId(signal) });
|
|
214
|
+
await baseFire?.(signal, payload);
|
|
215
|
+
}) as FireFn;
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
assert: () => assertSignalAssertions(example, observed),
|
|
219
|
+
ctx: { ...ctx, fire },
|
|
220
|
+
};
|
|
221
|
+
};
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Example-driven parity checks across shipped surfaces.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
|
|
7
|
+
import { deriveCliPath, filterSurfaceTrails } from '@ontrails/core';
|
|
8
|
+
import type {
|
|
9
|
+
ResourceOverrideMap,
|
|
10
|
+
Topo,
|
|
11
|
+
Trail,
|
|
12
|
+
TrailContext,
|
|
13
|
+
TrailExample,
|
|
14
|
+
} from '@ontrails/core';
|
|
15
|
+
import { deriveHttpInputSource, deriveHttpMethod } from '@ontrails/http';
|
|
16
|
+
import type { HttpMethod } from '@ontrails/http';
|
|
17
|
+
import { MCP_TOOL_ERROR_META_KEY, deriveToolName } from '@ontrails/mcp';
|
|
18
|
+
|
|
19
|
+
import type { TestAllEstablishedOptions } from './all-established.js';
|
|
20
|
+
import {
|
|
21
|
+
createMockResources,
|
|
22
|
+
defaultCreatePermit,
|
|
23
|
+
mergeResourceOverrides,
|
|
24
|
+
mergeTestContext,
|
|
25
|
+
} from './context.js';
|
|
26
|
+
import { deriveTrailExamples } from './effective-examples.js';
|
|
27
|
+
import { createCliHarness } from './harness-cli.js';
|
|
28
|
+
import type { CliHarnessResult } from './harness-cli.js';
|
|
29
|
+
import { createHttpHarness } from './harness-http.js';
|
|
30
|
+
import type { HttpHarnessResult } from './harness-http.js';
|
|
31
|
+
import { createMcpHarness } from './harness-mcp.js';
|
|
32
|
+
import type { McpHarnessResult } from './harness-mcp.js';
|
|
33
|
+
|
|
34
|
+
type ParityTrail = Trail<unknown, unknown, unknown>;
|
|
35
|
+
|
|
36
|
+
export type SurfaceParitySurface = 'cli' | 'mcp' | 'http';
|
|
37
|
+
|
|
38
|
+
export interface SurfaceParityExclusion {
|
|
39
|
+
/** Optional example name. Omit to exclude every example for the trail. */
|
|
40
|
+
readonly example?: string | undefined;
|
|
41
|
+
/** Human-readable reason shown in the skipped test name. */
|
|
42
|
+
readonly reason: string;
|
|
43
|
+
/** Trail ID to exclude. */
|
|
44
|
+
readonly trailId: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SurfaceParityOptions extends TestAllEstablishedOptions {
|
|
48
|
+
readonly createResources?:
|
|
49
|
+
| (() => ResourceOverrideMap | Promise<ResourceOverrideMap>)
|
|
50
|
+
| undefined;
|
|
51
|
+
readonly exclusions?: readonly SurfaceParityExclusion[] | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type NormalizedSurfaceParityResult =
|
|
55
|
+
| {
|
|
56
|
+
readonly ok: true;
|
|
57
|
+
readonly value: unknown;
|
|
58
|
+
}
|
|
59
|
+
| {
|
|
60
|
+
readonly error: {
|
|
61
|
+
readonly category: string;
|
|
62
|
+
readonly code: string;
|
|
63
|
+
};
|
|
64
|
+
readonly ok: false;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export interface SurfaceParityComparison {
|
|
68
|
+
readonly cli: NormalizedSurfaceParityResult;
|
|
69
|
+
readonly http: NormalizedSurfaceParityResult;
|
|
70
|
+
readonly mcp: NormalizedSurfaceParityResult;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const httpPathForTrail = (trailId: string): string =>
|
|
74
|
+
`/${trailId.replaceAll('.', '/')}`;
|
|
75
|
+
|
|
76
|
+
const escapeWhitespaceForCliToken = (value: string): string =>
|
|
77
|
+
value.replaceAll(/\s/gu, (char) => {
|
|
78
|
+
let escaped = '';
|
|
79
|
+
|
|
80
|
+
for (let index = 0; index < char.length; index += 1) {
|
|
81
|
+
const codePoint = char.codePointAt(index);
|
|
82
|
+
|
|
83
|
+
if (codePoint !== undefined) {
|
|
84
|
+
escaped += `\\u${codePoint.toString(16).padStart(4, '0')}`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return escaped;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const cliCommandForExample = (
|
|
92
|
+
trail: ParityTrail,
|
|
93
|
+
example: TrailExample<unknown, unknown>
|
|
94
|
+
): string => {
|
|
95
|
+
const path = deriveCliPath(trail.id).join(' ');
|
|
96
|
+
const inputJson = escapeWhitespaceForCliToken(
|
|
97
|
+
JSON.stringify(example.input ?? {})
|
|
98
|
+
);
|
|
99
|
+
return `${path} --input-json ${inputJson} --output json`;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
|
103
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
104
|
+
|
|
105
|
+
const readTextContent = (content: unknown): string | undefined => {
|
|
106
|
+
if (!Array.isArray(content)) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
const firstText = content.find(
|
|
110
|
+
(item): item is { readonly text: string; readonly type: string } =>
|
|
111
|
+
isObjectRecord(item) &&
|
|
112
|
+
item['type'] === 'text' &&
|
|
113
|
+
typeof item['text'] === 'string'
|
|
114
|
+
);
|
|
115
|
+
return firstText?.text;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const parseJsonText = (text: string | undefined): unknown => {
|
|
119
|
+
if (text === undefined) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(text);
|
|
124
|
+
} catch {
|
|
125
|
+
return text;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const structuredContentValue = (
|
|
130
|
+
structuredContent: Record<string, unknown> | undefined
|
|
131
|
+
): unknown => {
|
|
132
|
+
if (structuredContent === undefined) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
const keys = Object.keys(structuredContent);
|
|
136
|
+
return keys.length === 1 && keys[0] === 'data'
|
|
137
|
+
? structuredContent['data']
|
|
138
|
+
: structuredContent;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const normalizeCliResult = (
|
|
142
|
+
result: CliHarnessResult
|
|
143
|
+
): NormalizedSurfaceParityResult =>
|
|
144
|
+
result.exitCode === 0
|
|
145
|
+
? { ok: true, value: result.json }
|
|
146
|
+
: {
|
|
147
|
+
error: {
|
|
148
|
+
category: result.error?.category ?? 'internal',
|
|
149
|
+
code: result.error?.code ?? 'InternalError',
|
|
150
|
+
},
|
|
151
|
+
ok: false,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const normalizeHttpResult = (
|
|
155
|
+
result: HttpHarnessResult
|
|
156
|
+
): NormalizedSurfaceParityResult =>
|
|
157
|
+
result.ok
|
|
158
|
+
? { ok: true, value: result.data }
|
|
159
|
+
: {
|
|
160
|
+
error: {
|
|
161
|
+
category: result.error?.category ?? 'internal',
|
|
162
|
+
code: result.error?.code ?? 'InternalError',
|
|
163
|
+
},
|
|
164
|
+
ok: false,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const readMcpError = (
|
|
168
|
+
result: McpHarnessResult
|
|
169
|
+
): { readonly category: string; readonly code: string } => {
|
|
170
|
+
const errorMeta = result.meta?.[MCP_TOOL_ERROR_META_KEY];
|
|
171
|
+
if (isObjectRecord(errorMeta)) {
|
|
172
|
+
return {
|
|
173
|
+
category:
|
|
174
|
+
typeof errorMeta['category'] === 'string'
|
|
175
|
+
? errorMeta['category']
|
|
176
|
+
: 'internal',
|
|
177
|
+
code:
|
|
178
|
+
typeof errorMeta['name'] === 'string'
|
|
179
|
+
? errorMeta['name']
|
|
180
|
+
: 'InternalError',
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return { category: 'internal', code: 'InternalError' };
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const normalizeMcpResult = (
|
|
187
|
+
result: McpHarnessResult
|
|
188
|
+
): NormalizedSurfaceParityResult =>
|
|
189
|
+
result.isError
|
|
190
|
+
? { error: readMcpError(result), ok: false }
|
|
191
|
+
: {
|
|
192
|
+
ok: true,
|
|
193
|
+
value:
|
|
194
|
+
result.structuredContent === undefined
|
|
195
|
+
? parseJsonText(readTextContent(result.content))
|
|
196
|
+
: structuredContentValue(result.structuredContent),
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const applyAutoPermit = (
|
|
200
|
+
ctx: TrailContext,
|
|
201
|
+
trail: ParityTrail,
|
|
202
|
+
options: SurfaceParityOptions
|
|
203
|
+
): TrailContext => {
|
|
204
|
+
if (options.strictPermits || ctx.permit !== undefined) {
|
|
205
|
+
return ctx;
|
|
206
|
+
}
|
|
207
|
+
const permit = (options.createPermit ?? defaultCreatePermit)(trail);
|
|
208
|
+
return permit === undefined ? ctx : { ...ctx, permit };
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const createInvocationContext = async (
|
|
212
|
+
app: Topo,
|
|
213
|
+
trail: ParityTrail,
|
|
214
|
+
options: SurfaceParityOptions
|
|
215
|
+
) => {
|
|
216
|
+
const autoResources =
|
|
217
|
+
options.createResources === undefined
|
|
218
|
+
? await createMockResources(app)
|
|
219
|
+
: await options.createResources();
|
|
220
|
+
const resources = mergeResourceOverrides(
|
|
221
|
+
autoResources,
|
|
222
|
+
options.ctx,
|
|
223
|
+
options.resources
|
|
224
|
+
);
|
|
225
|
+
const ctx = applyAutoPermit(mergeTestContext(options.ctx), trail, options);
|
|
226
|
+
return { ctx, resources };
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const mergeSurfaceContext = (
|
|
230
|
+
ctx: TrailContext,
|
|
231
|
+
surfaceCtx: Partial<TrailContext> | undefined
|
|
232
|
+
): TrailContext =>
|
|
233
|
+
surfaceCtx === undefined ? ctx : mergeTestContext({ ...ctx, ...surfaceCtx });
|
|
234
|
+
|
|
235
|
+
const runCliExample = async (
|
|
236
|
+
app: Topo,
|
|
237
|
+
trail: ParityTrail,
|
|
238
|
+
example: TrailExample<unknown, unknown>,
|
|
239
|
+
options: SurfaceParityOptions
|
|
240
|
+
): Promise<NormalizedSurfaceParityResult> => {
|
|
241
|
+
const { ctx, resources } = await createInvocationContext(app, trail, options);
|
|
242
|
+
const cliOptions = options.cli;
|
|
243
|
+
const harness = createCliHarness({
|
|
244
|
+
graph: app,
|
|
245
|
+
...cliOptions,
|
|
246
|
+
ctx: mergeSurfaceContext(ctx, cliOptions?.ctx),
|
|
247
|
+
resources,
|
|
248
|
+
});
|
|
249
|
+
return normalizeCliResult(
|
|
250
|
+
await harness.run(cliCommandForExample(trail, example))
|
|
251
|
+
);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const runMcpExample = async (
|
|
255
|
+
app: Topo,
|
|
256
|
+
trail: ParityTrail,
|
|
257
|
+
example: TrailExample<unknown, unknown>,
|
|
258
|
+
options: SurfaceParityOptions
|
|
259
|
+
): Promise<NormalizedSurfaceParityResult> => {
|
|
260
|
+
const { ctx, resources } = await createInvocationContext(app, trail, options);
|
|
261
|
+
const mcpOptions = options.mcp;
|
|
262
|
+
const harness = createMcpHarness({
|
|
263
|
+
graph: app,
|
|
264
|
+
...mcpOptions,
|
|
265
|
+
createContext: async () =>
|
|
266
|
+
mergeSurfaceContext(ctx, await mcpOptions?.createContext?.()),
|
|
267
|
+
resources,
|
|
268
|
+
});
|
|
269
|
+
const toolName = deriveToolName(app.name, trail.id);
|
|
270
|
+
return normalizeMcpResult(
|
|
271
|
+
await harness.callTool(
|
|
272
|
+
toolName,
|
|
273
|
+
isObjectRecord(example.input) ? example.input : {}
|
|
274
|
+
)
|
|
275
|
+
);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const httpRequestForExample = (
|
|
279
|
+
method: HttpMethod,
|
|
280
|
+
trailId: string,
|
|
281
|
+
example: TrailExample<unknown, unknown>
|
|
282
|
+
) => {
|
|
283
|
+
const input = isObjectRecord(example.input) ? example.input : {};
|
|
284
|
+
return deriveHttpInputSource(method) === 'query'
|
|
285
|
+
? { method, path: httpPathForTrail(trailId), query: input }
|
|
286
|
+
: { body: input, method, path: httpPathForTrail(trailId) };
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const runHttpExample = async (
|
|
290
|
+
app: Topo,
|
|
291
|
+
trail: ParityTrail,
|
|
292
|
+
example: TrailExample<unknown, unknown>,
|
|
293
|
+
options: SurfaceParityOptions
|
|
294
|
+
): Promise<NormalizedSurfaceParityResult> => {
|
|
295
|
+
const { ctx, resources } = await createInvocationContext(app, trail, options);
|
|
296
|
+
const httpOptions = options.http;
|
|
297
|
+
const harness = createHttpHarness({
|
|
298
|
+
graph: app,
|
|
299
|
+
...httpOptions,
|
|
300
|
+
ctx: mergeSurfaceContext(ctx, httpOptions?.ctx),
|
|
301
|
+
resources,
|
|
302
|
+
});
|
|
303
|
+
const method = deriveHttpMethod(trail.intent);
|
|
304
|
+
return normalizeHttpResult(
|
|
305
|
+
await harness.request(httpRequestForExample(method, trail.id, example))
|
|
306
|
+
);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
export const runSurfaceParityExample = async (
|
|
310
|
+
app: Topo,
|
|
311
|
+
trail: ParityTrail,
|
|
312
|
+
example: TrailExample<unknown, unknown>,
|
|
313
|
+
options: SurfaceParityOptions = {}
|
|
314
|
+
): Promise<SurfaceParityComparison> => {
|
|
315
|
+
// CLI harness output capture is process-scoped, so keep surface execution
|
|
316
|
+
// ordered even though MCP and HTTP do not share that constraint.
|
|
317
|
+
const cli = await runCliExample(app, trail, example, options);
|
|
318
|
+
const mcp = await runMcpExample(app, trail, example, options);
|
|
319
|
+
const http = await runHttpExample(app, trail, example, options);
|
|
320
|
+
return { cli, http, mcp };
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const findExclusion = (
|
|
324
|
+
exclusions: readonly SurfaceParityExclusion[] | undefined,
|
|
325
|
+
trail: ParityTrail,
|
|
326
|
+
example: TrailExample<unknown, unknown>
|
|
327
|
+
): SurfaceParityExclusion | undefined =>
|
|
328
|
+
exclusions?.find(
|
|
329
|
+
(exclusion) =>
|
|
330
|
+
exclusion.trailId === trail.id &&
|
|
331
|
+
(exclusion.example === undefined || exclusion.example === example.name)
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const parityTrails = (app: Topo): readonly ParityTrail[] =>
|
|
335
|
+
filterSurfaceTrails(app.list()).filter(
|
|
336
|
+
(trail) => deriveTrailExamples(trail).length > 0
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Register example-driven parity tests for CLI, MCP, and HTTP.
|
|
341
|
+
*
|
|
342
|
+
* @example
|
|
343
|
+
* ```ts
|
|
344
|
+
* import { testSurfaceParity } from '@ontrails/testing/surface-parity';
|
|
345
|
+
* import { graph } from '../src/app.js';
|
|
346
|
+
*
|
|
347
|
+
* testSurfaceParity(graph);
|
|
348
|
+
* ```
|
|
349
|
+
*/
|
|
350
|
+
export const testSurfaceParity = (
|
|
351
|
+
app: Topo,
|
|
352
|
+
optionsOrFactory?:
|
|
353
|
+
| SurfaceParityOptions
|
|
354
|
+
| (() => SurfaceParityOptions | undefined)
|
|
355
|
+
): void => {
|
|
356
|
+
const resolveOptions =
|
|
357
|
+
typeof optionsOrFactory === 'function'
|
|
358
|
+
? optionsOrFactory
|
|
359
|
+
: () => optionsOrFactory;
|
|
360
|
+
|
|
361
|
+
describe('surface parity', () => {
|
|
362
|
+
for (const trail of parityTrails(app)) {
|
|
363
|
+
describe(trail.id, () => {
|
|
364
|
+
for (const example of deriveTrailExamples(trail)) {
|
|
365
|
+
const options = resolveOptions() ?? {};
|
|
366
|
+
const exclusion = findExclusion(options.exclusions, trail, example);
|
|
367
|
+
const testName = `example: ${example.name}`;
|
|
368
|
+
if (exclusion !== undefined) {
|
|
369
|
+
test.skip(`${testName} (excluded: ${exclusion.reason})`, () => {
|
|
370
|
+
throw new Error('Skipped parity exclusion should not execute');
|
|
371
|
+
});
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
test(testName, async () => {
|
|
376
|
+
const comparison = await runSurfaceParityExample(
|
|
377
|
+
app,
|
|
378
|
+
trail,
|
|
379
|
+
example,
|
|
380
|
+
resolveOptions() ?? {}
|
|
381
|
+
);
|
|
382
|
+
expect(comparison.mcp).toEqual(comparison.cli);
|
|
383
|
+
expect(comparison.http).toEqual(comparison.cli);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
};
|