@ontrails/testing 1.0.0-beta.2 → 1.0.0-beta.21

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 (91) hide show
  1. package/CHANGELOG.md +396 -5
  2. package/README.md +86 -22
  3. package/package.json +34 -5
  4. package/src/all-established.ts +168 -0
  5. package/src/all.ts +51 -12
  6. package/src/assertions.ts +253 -0
  7. package/src/cli.ts +6 -0
  8. package/src/composes.ts +433 -0
  9. package/src/context.ts +200 -14
  10. package/src/contracts.ts +45 -21
  11. package/src/detours.ts +155 -18
  12. package/src/effective-examples.ts +414 -0
  13. package/src/errors.ts +47 -0
  14. package/src/examples.ts +302 -177
  15. package/src/harness-cli.ts +83 -58
  16. package/src/harness-http.ts +341 -0
  17. package/src/harness-mcp.ts +46 -16
  18. package/src/http.ts +10 -0
  19. package/src/index.ts +22 -14
  20. package/src/logger.ts +3 -1
  21. package/src/mcp.ts +6 -0
  22. package/src/scenario.ts +375 -0
  23. package/src/signals.ts +221 -0
  24. package/src/surface-parity.ts +389 -0
  25. package/src/trail.ts +1 -1
  26. package/src/types.ts +24 -52
  27. package/.turbo/turbo-build.log +0 -1
  28. package/.turbo/turbo-lint.log +0 -3
  29. package/.turbo/turbo-typecheck.log +0 -1
  30. package/dist/all.d.ts +0 -30
  31. package/dist/all.d.ts.map +0 -1
  32. package/dist/all.js +0 -47
  33. package/dist/all.js.map +0 -1
  34. package/dist/assertions.d.ts +0 -49
  35. package/dist/assertions.d.ts.map +0 -1
  36. package/dist/assertions.js +0 -84
  37. package/dist/assertions.js.map +0 -1
  38. package/dist/context.d.ts +0 -19
  39. package/dist/context.d.ts.map +0 -1
  40. package/dist/context.js +0 -33
  41. package/dist/context.js.map +0 -1
  42. package/dist/contracts.d.ts +0 -16
  43. package/dist/contracts.d.ts.map +0 -1
  44. package/dist/contracts.js +0 -56
  45. package/dist/contracts.js.map +0 -1
  46. package/dist/detours.d.ts +0 -12
  47. package/dist/detours.d.ts.map +0 -1
  48. package/dist/detours.js +0 -30
  49. package/dist/detours.js.map +0 -1
  50. package/dist/examples.d.ts +0 -22
  51. package/dist/examples.d.ts.map +0 -1
  52. package/dist/examples.js +0 -187
  53. package/dist/examples.js.map +0 -1
  54. package/dist/harness-cli.d.ts +0 -21
  55. package/dist/harness-cli.d.ts.map +0 -1
  56. package/dist/harness-cli.js +0 -213
  57. package/dist/harness-cli.js.map +0 -1
  58. package/dist/harness-mcp.d.ts +0 -21
  59. package/dist/harness-mcp.d.ts.map +0 -1
  60. package/dist/harness-mcp.js +0 -50
  61. package/dist/harness-mcp.js.map +0 -1
  62. package/dist/hike.d.ts +0 -32
  63. package/dist/hike.d.ts.map +0 -1
  64. package/dist/hike.js +0 -169
  65. package/dist/hike.js.map +0 -1
  66. package/dist/index.d.ts +0 -14
  67. package/dist/index.d.ts.map +0 -1
  68. package/dist/index.js +0 -16
  69. package/dist/index.js.map +0 -1
  70. package/dist/logger.d.ts +0 -15
  71. package/dist/logger.d.ts.map +0 -1
  72. package/dist/logger.js +0 -87
  73. package/dist/logger.js.map +0 -1
  74. package/dist/trail.d.ts +0 -20
  75. package/dist/trail.d.ts.map +0 -1
  76. package/dist/trail.js +0 -80
  77. package/dist/trail.js.map +0 -1
  78. package/dist/types.d.ts +0 -80
  79. package/dist/types.d.ts.map +0 -1
  80. package/dist/types.js +0 -5
  81. package/dist/types.js.map +0 -1
  82. package/src/__tests__/context.test.ts +0 -60
  83. package/src/__tests__/contracts.test.ts +0 -68
  84. package/src/__tests__/detours.test.ts +0 -55
  85. package/src/__tests__/examples.test.ts +0 -176
  86. package/src/__tests__/hike.test.ts +0 -164
  87. package/src/__tests__/logger.test.ts +0 -136
  88. package/src/__tests__/trail.test.ts +0 -99
  89. package/src/hike.ts +0 -283
  90. package/tsconfig.json +0 -9
  91. package/tsconfig.tsbuildinfo +0 -1
@@ -1,19 +1,47 @@
1
1
  /**
2
2
  * CLI integration test harness.
3
3
  *
4
- * Builds CLI commands from an App, executes them in-process,
4
+ * Builds CLI commands from a graph, executes them in-process,
5
5
  * and captures stdout/stderr.
6
6
  */
7
7
 
8
- import { buildCliCommands } from '@ontrails/cli';
9
- import type { CliCommand } from '@ontrails/cli';
8
+ import { deriveCliCommands } from '@ontrails/cli';
9
+ import type { CliCommand, DeriveCliCommandsOptions } from '@ontrails/cli';
10
+ import type { Topo, TrailContext } from '@ontrails/core';
11
+ import { projectPublicSurfaceError } from '@ontrails/core';
10
12
 
11
- import { createTestContext } from './context.js';
12
- import type {
13
- CliHarness,
14
- CliHarnessOptions,
15
- CliHarnessResult,
16
- } from './types.js';
13
+ import { mergeTestContext } from './context.js';
14
+
15
+ /** Options for creating a CLI harness. */
16
+ export interface CliHarnessOptions extends Omit<
17
+ DeriveCliCommandsOptions,
18
+ 'onResult' | 'presets' | 'resolveInput'
19
+ > {
20
+ readonly ctx?: Partial<TrailContext> | undefined;
21
+ readonly graph: Topo;
22
+ }
23
+
24
+ /** A test harness for CLI commands. */
25
+ export interface CliHarness {
26
+ /** Execute a CLI command string and capture output. */
27
+ run(command: string): Promise<CliHarnessResult>;
28
+ }
29
+
30
+ /** The result of a CLI harness command execution. */
31
+ export interface CliHarnessResult {
32
+ readonly error?:
33
+ | {
34
+ readonly category: string;
35
+ readonly code: string;
36
+ readonly message: string;
37
+ }
38
+ | undefined;
39
+ readonly exitCode: number;
40
+ /** Parsed JSON output if --output json was used. */
41
+ readonly json?: unknown | undefined;
42
+ readonly stderr: string;
43
+ readonly stdout: string;
44
+ }
17
45
 
18
46
  // ---------------------------------------------------------------------------
19
47
  // Tokenizer
@@ -30,55 +58,31 @@ const parseCommandString = (input: string): string[] =>
30
58
  // Command resolution
31
59
  // ---------------------------------------------------------------------------
32
60
 
33
- /** Try to match group + name from tokens. */
34
- const tryGroupMatch = (
35
- commands: CliCommand[],
36
- firstToken: string,
37
- secondToken: string | undefined,
38
- tokens: string[]
39
- ): { command: CliCommand; flagTokens: string[] } | undefined => {
40
- if (secondToken === undefined || secondToken.startsWith('-')) {
41
- return undefined;
42
- }
43
- const match = commands.find(
44
- (c) => c.group === firstToken && c.name === secondToken
45
- );
46
- if (match === undefined) {
47
- return undefined;
48
- }
49
- return { command: match, flagTokens: tokens.slice(2) };
50
- };
61
+ const matchesPath = (
62
+ path: readonly string[],
63
+ tokens: readonly string[]
64
+ ): boolean =>
65
+ path.length <= tokens.length &&
66
+ path.every((segment, index) => tokens[index] === segment);
51
67
 
52
- /** Try to match a direct name from the first token. */
53
- const tryDirectMatch = (
68
+ /** Resolve a command from tokens using the longest matching command path. */
69
+ const resolveCommand = (
54
70
  commands: CliCommand[],
55
- firstToken: string,
56
71
  tokens: string[]
57
72
  ): { command: CliCommand; flagTokens: string[] } | undefined => {
58
- const match = commands.find(
59
- (c) => c.name === firstToken && c.group === undefined
60
- );
61
- if (match === undefined) {
73
+ if (tokens.length === 0) {
62
74
  return undefined;
63
75
  }
64
- return { command: match, flagTokens: tokens.slice(1) };
65
- };
66
76
 
67
- /** Resolve a command from tokens, handling group.name patterns. */
68
- const resolveCommand = (
69
- commands: CliCommand[],
70
- tokens: string[]
71
- ): { command: CliCommand; flagTokens: string[] } | undefined => {
72
- const [firstToken, secondToken] = tokens;
77
+ const [match] = commands
78
+ .filter((command) => matchesPath(command.path, tokens))
79
+ .toSorted((a, b) => b.path.length - a.path.length);
73
80
 
74
- if (firstToken === undefined) {
81
+ if (match === undefined) {
75
82
  return undefined;
76
83
  }
77
84
 
78
- return (
79
- tryGroupMatch(commands, firstToken, secondToken, tokens) ??
80
- tryDirectMatch(commands, firstToken, tokens)
81
- );
85
+ return { command: match, flagTokens: tokens.slice(match.path.length) };
82
86
  };
83
87
 
84
88
  // ---------------------------------------------------------------------------
@@ -230,10 +234,16 @@ const buildErrorResult = (
230
234
  streams: CapturedStreams
231
235
  ): CliHarnessResult => {
232
236
  streams.restore();
233
- const message = error instanceof Error ? error.message : String(error);
237
+ const actualError = error instanceof Error ? error : new Error(String(error));
238
+ const projection = projectPublicSurfaceError('cli', actualError);
234
239
  return {
240
+ error: {
241
+ category: projection.category,
242
+ code: projection.name,
243
+ message: projection.message,
244
+ },
235
245
  exitCode: 1,
236
- stderr: streams.getStderr() || message,
246
+ stderr: streams.getStderr() || projection.message,
237
247
  stdout: streams.getStdout(),
238
248
  };
239
249
  };
@@ -242,16 +252,23 @@ const buildErrorResult = (
242
252
  const executeCommand = async (
243
253
  command: CliCommand,
244
254
  flags: Record<string, unknown>,
245
- streams: CapturedStreams
255
+ streams: CapturedStreams,
256
+ ctxOverrides?: Partial<TrailContext>
246
257
  ): Promise<CliHarnessResult> => {
247
- const ctx = createTestContext();
258
+ const ctx = mergeTestContext(ctxOverrides);
248
259
  const result = await command.execute({}, flags, ctx);
249
260
  streams.restore();
250
261
 
251
262
  if (result.isErr()) {
263
+ const projection = projectPublicSurfaceError('cli', result.error);
252
264
  return {
265
+ error: {
266
+ category: projection.category,
267
+ code: projection.name,
268
+ message: projection.message,
269
+ },
253
270
  exitCode: 1,
254
- stderr: streams.getStderr() || result.error.message,
271
+ stderr: streams.getStderr() || projection.message,
255
272
  stdout: streams.getStdout(),
256
273
  };
257
274
  }
@@ -262,7 +279,8 @@ const executeCommand = async (
262
279
  /** Run the full command pipeline: resolve, parse, execute. */
263
280
  const runCommand = async (
264
281
  commands: CliCommand[],
265
- commandString: string
282
+ commandString: string,
283
+ ctxOverrides?: Partial<TrailContext>
266
284
  ): Promise<CliHarnessResult> => {
267
285
  const parts = parseCommandString(commandString);
268
286
  const resolved = resolveCommand(commands, parts);
@@ -279,7 +297,7 @@ const runCommand = async (
279
297
  const streams = captureStreams();
280
298
 
281
299
  try {
282
- return await executeCommand(command, flags, streams);
300
+ return await executeCommand(command, flags, streams, ctxOverrides);
283
301
  } catch (error: unknown) {
284
302
  return buildErrorResult(error, streams);
285
303
  }
@@ -292,19 +310,26 @@ const runCommand = async (
292
310
  /**
293
311
  * Create a CLI harness for integration testing.
294
312
  *
295
- * Builds commands from the app's topo and provides a `run()` method
313
+ * Builds commands from the graph's topo and provides a `run()` method
296
314
  * that parses command strings and executes them in-process.
297
315
  *
298
316
  * ```ts
299
- * const harness = createCliHarness({ app });
317
+ * import { createCliHarness } from '@ontrails/testing/cli';
318
+ *
319
+ * const harness = createCliHarness({ graph });
300
320
  * const result = await harness.run("entity show --name Alpha --output json");
301
321
  * expect(result.exitCode).toBe(0);
302
322
  * ```
303
323
  */
304
324
  export const createCliHarness = (options: CliHarnessOptions): CliHarness => {
305
- const commands = buildCliCommands(options.app);
325
+ const { ctx, graph, ...deriveOptions } = options;
326
+ const commandsResult = deriveCliCommands(graph, deriveOptions);
327
+ if (commandsResult.isErr()) {
328
+ throw commandsResult.error;
329
+ }
330
+ const commands = commandsResult.value;
306
331
 
307
332
  return {
308
- run: (commandString: string) => runCommand(commands, commandString),
333
+ run: (commandString: string) => runCommand(commands, commandString, ctx),
309
334
  };
310
335
  };
@@ -0,0 +1,341 @@
1
+ /**
2
+ * HTTP integration test harness.
3
+ *
4
+ * Builds framework-agnostic HTTP routes from a graph and executes them
5
+ * directly, without Hono or a listening server.
6
+ */
7
+
8
+ import { deriveHttpRoutes } from '@ontrails/http';
9
+ import type {
10
+ DeriveHttpRoutesOptions,
11
+ HttpHeaderSource,
12
+ HttpMethod,
13
+ HttpRouteDefinition,
14
+ } from '@ontrails/http';
15
+ import type { Topo, TrailContext, TrailContextInit } from '@ontrails/core';
16
+ import { NotFoundError, projectPublicSurfaceError } from '@ontrails/core';
17
+
18
+ import { mergeTestContext } from './context.js';
19
+
20
+ /** Options for creating an HTTP harness. */
21
+ export interface HttpHarnessOptions extends DeriveHttpRoutesOptions {
22
+ readonly ctx?: Partial<TrailContext> | undefined;
23
+ readonly graph: Topo;
24
+ }
25
+
26
+ export interface HttpHarnessRequest {
27
+ readonly abortSignal?: AbortSignal | undefined;
28
+ readonly body?: unknown | undefined;
29
+ readonly headers?: HttpHeaderSource | undefined;
30
+ readonly method: HttpMethod;
31
+ readonly path: string;
32
+ readonly query?: Record<string, unknown> | undefined;
33
+ readonly requestId?: string | undefined;
34
+ }
35
+
36
+ export interface HttpHarnessRequestOptions extends Omit<
37
+ HttpHarnessRequest,
38
+ 'body' | 'method' | 'path' | 'query'
39
+ > {
40
+ readonly query?: Record<string, unknown> | undefined;
41
+ }
42
+
43
+ /** A test harness for HTTP route projections. */
44
+ export interface HttpHarness {
45
+ /** Execute a raw HTTP-style harness request. */
46
+ request(request: HttpHarnessRequest): Promise<HttpHarnessResult>;
47
+ /** Execute a GET request, reading input from query params. */
48
+ get(
49
+ path: string,
50
+ query?: Record<string, unknown>,
51
+ options?: HttpHarnessRequestOptions
52
+ ): Promise<HttpHarnessResult>;
53
+ /** Execute a POST request, reading input from the JSON-like body value. */
54
+ post(
55
+ path: string,
56
+ body?: unknown,
57
+ options?: HttpHarnessRequestOptions
58
+ ): Promise<HttpHarnessResult>;
59
+ /** Execute a PUT request. */
60
+ put(
61
+ path: string,
62
+ body?: unknown,
63
+ options?: HttpHarnessRequestOptions
64
+ ): Promise<HttpHarnessResult>;
65
+ /** Execute a PATCH request. */
66
+ patch(
67
+ path: string,
68
+ body?: unknown,
69
+ options?: HttpHarnessRequestOptions
70
+ ): Promise<HttpHarnessResult>;
71
+ /** Execute a DELETE request. */
72
+ delete(
73
+ path: string,
74
+ body?: unknown,
75
+ options?: HttpHarnessRequestOptions
76
+ ): Promise<HttpHarnessResult>;
77
+ }
78
+
79
+ export interface HttpHarnessErrorBody {
80
+ readonly error: {
81
+ readonly category: string;
82
+ readonly code: string;
83
+ readonly message: string;
84
+ };
85
+ }
86
+
87
+ export interface HttpHarnessSuccessBody {
88
+ readonly data: unknown;
89
+ }
90
+
91
+ /** The result of an HTTP harness request. */
92
+ export interface HttpHarnessResult {
93
+ readonly body: HttpHarnessErrorBody | HttpHarnessSuccessBody;
94
+ readonly data?: unknown | undefined;
95
+ readonly error?: HttpHarnessErrorBody['error'] | undefined;
96
+ readonly ok: boolean;
97
+ readonly status: number;
98
+ }
99
+
100
+ const TEST_ORIGIN = 'http://ontrails.test';
101
+
102
+ const normalizeMethod = (method: HttpMethod): HttpMethod =>
103
+ method.toUpperCase() as HttpMethod;
104
+
105
+ const collectQueryParams = (url: URL): Record<string, unknown> => {
106
+ const query: Record<string, unknown> = {};
107
+ const seen = new Set<string>();
108
+
109
+ for (const key of url.searchParams.keys()) {
110
+ if (seen.has(key)) {
111
+ continue;
112
+ }
113
+ seen.add(key);
114
+ const values = url.searchParams.getAll(key);
115
+ query[key] = values.length > 1 ? values : values[0];
116
+ }
117
+
118
+ return query;
119
+ };
120
+
121
+ const findRoute = (
122
+ routes: readonly HttpRouteDefinition[],
123
+ method: HttpMethod,
124
+ path: string
125
+ ): HttpRouteDefinition | undefined =>
126
+ routes.find((route) => route.method === method && route.path === path);
127
+
128
+ const mapError = (error: Error): HttpHarnessResult => {
129
+ const projection = projectPublicSurfaceError('http', error);
130
+ const body = {
131
+ error: {
132
+ category: projection.category,
133
+ code: projection.name,
134
+ message: projection.message,
135
+ },
136
+ };
137
+ return {
138
+ body,
139
+ error: body.error,
140
+ ok: false,
141
+ status: projection.code,
142
+ };
143
+ };
144
+
145
+ const mapSuccess = (data: unknown): HttpHarnessResult => ({
146
+ body: { data },
147
+ data,
148
+ ok: true,
149
+ status: 200,
150
+ });
151
+
152
+ const isWebhookParseResult = (
153
+ input: unknown
154
+ ): input is {
155
+ readonly error?: Error | undefined;
156
+ isErr(): boolean;
157
+ readonly value?: unknown | undefined;
158
+ } =>
159
+ typeof input === 'object' &&
160
+ input !== null &&
161
+ 'isErr' in input &&
162
+ typeof input.isErr === 'function';
163
+
164
+ const mergeContextInit = (
165
+ base: TrailContextInit | undefined,
166
+ ctx: Partial<TrailContext> | undefined
167
+ ): TrailContextInit => ({
168
+ ...base,
169
+ ...mergeTestContext({ ...base, ...ctx }),
170
+ });
171
+
172
+ const createHarnessContextFactory = (
173
+ options: HttpHarnessOptions
174
+ ): (() => TrailContextInit | Promise<TrailContextInit>) => {
175
+ const { createContext, ctx } = options;
176
+ return async () => {
177
+ const base = await createContext?.();
178
+ return mergeContextInit(base, ctx);
179
+ };
180
+ };
181
+
182
+ const buildInput = (
183
+ route: HttpRouteDefinition,
184
+ url: URL,
185
+ request: HttpHarnessRequest
186
+ ): unknown => {
187
+ if (route.inputSource === 'query') {
188
+ return {
189
+ ...collectQueryParams(url),
190
+ ...request.query,
191
+ };
192
+ }
193
+ return request.body ?? {};
194
+ };
195
+
196
+ const executeRouteWithInput = async (
197
+ route: HttpRouteDefinition,
198
+ input: unknown,
199
+ request: HttpHarnessRequest
200
+ ): Promise<HttpHarnessResult> => {
201
+ const result = await route.execute(
202
+ input,
203
+ request.requestId,
204
+ request.abortSignal,
205
+ { headers: request.headers }
206
+ );
207
+
208
+ if (result.isErr()) {
209
+ return mapError(result.error);
210
+ }
211
+
212
+ return mapSuccess(result.value);
213
+ };
214
+
215
+ const executeRoute = async (
216
+ route: HttpRouteDefinition,
217
+ url: URL,
218
+ request: HttpHarnessRequest
219
+ ): Promise<HttpHarnessResult> => {
220
+ const parsedInput = buildInput(route, url, request);
221
+ if (route.inputSource === 'webhook' && route.parseWebhookInput) {
222
+ const parsed = route.parseWebhookInput(parsedInput);
223
+ if (isWebhookParseResult(parsed)) {
224
+ if (parsed.isErr()) {
225
+ return mapError(parsed.error ?? new Error('Invalid webhook input'));
226
+ }
227
+ return await executeRouteWithInput(route, parsed.value, request);
228
+ }
229
+ return await executeRouteWithInput(route, parsed, request);
230
+ }
231
+
232
+ return await executeRouteWithInput(route, parsedInput, request);
233
+ };
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // createHttpHarness
237
+ // ---------------------------------------------------------------------------
238
+
239
+ /**
240
+ * Create an HTTP harness for integration testing.
241
+ *
242
+ * @example
243
+ * ```ts
244
+ * import { createHttpHarness } from '@ontrails/testing/http';
245
+ *
246
+ * const http = createHttpHarness({ graph });
247
+ * const result = await http.get('/entity/show', { name: 'Alpha' });
248
+ * expect(result.status).toBe(200);
249
+ * ```
250
+ */
251
+ export const createHttpHarness = (
252
+ harnessOptions: HttpHarnessOptions
253
+ ): HttpHarness => {
254
+ const { ctx: _ctx, graph, ...deriveOptions } = harnessOptions;
255
+ const routesResult = deriveHttpRoutes(graph, {
256
+ ...deriveOptions,
257
+ createContext: createHarnessContextFactory(harnessOptions),
258
+ });
259
+ if (routesResult.isErr()) {
260
+ throw routesResult.error;
261
+ }
262
+ const routes = routesResult.value;
263
+
264
+ const request = async (
265
+ rawRequest: HttpHarnessRequest
266
+ ): Promise<HttpHarnessResult> => {
267
+ const method = normalizeMethod(rawRequest.method);
268
+ const url = new URL(rawRequest.path, TEST_ORIGIN);
269
+ const route = findRoute(routes, method, url.pathname);
270
+
271
+ if (!route) {
272
+ return mapError(
273
+ new NotFoundError(`No HTTP route found for ${method} ${url.pathname}`)
274
+ );
275
+ }
276
+
277
+ return await executeRoute(route, url, { ...rawRequest, method });
278
+ };
279
+
280
+ return {
281
+ delete: async (
282
+ path: string,
283
+ body?: unknown,
284
+ requestOptions?: HttpHarnessRequestOptions
285
+ ) =>
286
+ await request({
287
+ ...requestOptions,
288
+ body,
289
+ method: 'DELETE',
290
+ path,
291
+ }),
292
+ get: async (
293
+ path: string,
294
+ query?: Record<string, unknown>,
295
+ requestOptions?: HttpHarnessRequestOptions
296
+ ) =>
297
+ await request({
298
+ ...requestOptions,
299
+ method: 'GET',
300
+ path,
301
+ query: {
302
+ ...requestOptions?.query,
303
+ ...query,
304
+ },
305
+ }),
306
+ patch: async (
307
+ path: string,
308
+ body?: unknown,
309
+ requestOptions?: HttpHarnessRequestOptions
310
+ ) =>
311
+ await request({
312
+ ...requestOptions,
313
+ body,
314
+ method: 'PATCH',
315
+ path,
316
+ }),
317
+ post: async (
318
+ path: string,
319
+ body?: unknown,
320
+ requestOptions?: HttpHarnessRequestOptions
321
+ ) =>
322
+ await request({
323
+ ...requestOptions,
324
+ body,
325
+ method: 'POST',
326
+ path,
327
+ }),
328
+ put: async (
329
+ path: string,
330
+ body?: unknown,
331
+ requestOptions?: HttpHarnessRequestOptions
332
+ ) =>
333
+ await request({
334
+ ...requestOptions,
335
+ body,
336
+ method: 'PUT',
337
+ path,
338
+ }),
339
+ request,
340
+ };
341
+ };
@@ -1,18 +1,40 @@
1
1
  /**
2
2
  * MCP integration test harness.
3
3
  *
4
- * Builds MCP tools from an App, invokes them directly (no transport),
4
+ * Builds MCP tools from a graph, invokes them directly (no transport),
5
5
  * and returns the MCP tool response.
6
6
  */
7
7
 
8
- import { buildMcpTools } from '@ontrails/mcp';
9
- import type { McpToolDefinition } from '@ontrails/mcp';
10
-
8
+ import { deriveMcpTools } from '@ontrails/mcp';
11
9
  import type {
12
- McpHarness,
13
- McpHarnessOptions,
14
- McpHarnessResult,
15
- } from './types.js';
10
+ DeriveMcpToolsOptions,
11
+ McpExtra,
12
+ McpToolDefinition,
13
+ } from '@ontrails/mcp';
14
+ import type { Topo } from '@ontrails/core';
15
+
16
+ /** Options for creating an MCP harness. */
17
+ export interface McpHarnessOptions extends DeriveMcpToolsOptions {
18
+ readonly extra?: Partial<McpExtra> | undefined;
19
+ readonly graph: Topo;
20
+ }
21
+
22
+ /** A test harness for MCP tools. */
23
+ export interface McpHarness {
24
+ /** Call an MCP tool by name with arguments. */
25
+ callTool(
26
+ name: string,
27
+ args: Record<string, unknown>
28
+ ): Promise<McpHarnessResult>;
29
+ }
30
+
31
+ /** The result of an MCP harness tool invocation. */
32
+ export interface McpHarnessResult {
33
+ readonly content: unknown;
34
+ readonly isError: boolean;
35
+ readonly meta?: Record<string, unknown> | undefined;
36
+ readonly structuredContent?: Record<string, unknown> | undefined;
37
+ }
16
38
 
17
39
  // ---------------------------------------------------------------------------
18
40
  // createMcpHarness
@@ -21,19 +43,25 @@ import type {
21
43
  /**
22
44
  * Create an MCP harness for integration testing.
23
45
  *
24
- * Builds MCP tools from the app's topo and provides a `callTool()` method
25
- * that invokes tools directly without any transport layer.
46
+ * Builds MCP tools from the graph's topo and provides a `callTool()` method
47
+ * that invokes tools directly without any transport boundary.
26
48
  *
27
49
  * ```ts
28
- * const harness = createMcpHarness({ app });
50
+ * import { createMcpHarness } from '@ontrails/testing/mcp';
51
+ *
52
+ * const harness = createMcpHarness({ graph });
29
53
  * const result = await harness.callTool("myapp_entity_show", { name: "Alpha" });
30
54
  * expect(result.isError).toBe(false);
31
55
  * ```
32
56
  */
33
57
  export const createMcpHarness = (options: McpHarnessOptions): McpHarness => {
34
- const tools = buildMcpTools(options.app);
58
+ const { extra, graph, ...deriveOptions } = options;
59
+ const toolsResult = deriveMcpTools(graph, deriveOptions);
60
+ if (toolsResult.isErr()) {
61
+ throw toolsResult.error;
62
+ }
35
63
  const toolMap = new Map<string, McpToolDefinition>();
36
- for (const tool of tools) {
64
+ for (const tool of toolsResult.value) {
37
65
  toolMap.set(tool.name, tool);
38
66
  }
39
67
 
@@ -51,14 +79,16 @@ export const createMcpHarness = (options: McpHarnessOptions): McpHarness => {
51
79
  }
52
80
 
53
81
  const result = await tool.handler(args, {
54
- progressToken: undefined,
55
- sendProgress: undefined,
56
- signal: undefined,
82
+ abortSignal: extra?.abortSignal,
83
+ progressToken: extra?.progressToken,
84
+ sendProgress: extra?.sendProgress,
57
85
  });
58
86
 
59
87
  return {
60
88
  content: result.content,
61
89
  isError: result.isError ?? false,
90
+ meta: result._meta,
91
+ structuredContent: result.structuredContent,
62
92
  };
63
93
  },
64
94
  };
package/src/http.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { createHttpHarness } from './harness-http.js';
2
+ export type {
3
+ HttpHarness,
4
+ HttpHarnessErrorBody,
5
+ HttpHarnessOptions,
6
+ HttpHarnessRequest,
7
+ HttpHarnessRequestOptions,
8
+ HttpHarnessResult,
9
+ HttpHarnessSuccessBody,
10
+ } from './harness-http.js';