@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/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
+ };