@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.
- package/CHANGELOG.md +396 -5
- package/README.md +86 -22
- package/package.json +34 -5
- package/src/all-established.ts +168 -0
- package/src/all.ts +51 -12
- package/src/assertions.ts +253 -0
- package/src/cli.ts +6 -0
- package/src/composes.ts +433 -0
- package/src/context.ts +200 -14
- package/src/contracts.ts +45 -21
- package/src/detours.ts +155 -18
- package/src/effective-examples.ts +414 -0
- package/src/errors.ts +47 -0
- package/src/examples.ts +302 -177
- package/src/harness-cli.ts +83 -58
- package/src/harness-http.ts +341 -0
- package/src/harness-mcp.ts +46 -16
- package/src/http.ts +10 -0
- package/src/index.ts +22 -14
- package/src/logger.ts +3 -1
- 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 +1 -1
- package/src/types.ts +24 -52
- 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 -30
- 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 -19
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js +0 -33
- package/dist/context.js.map +0 -1
- package/dist/contracts.d.ts +0 -16
- package/dist/contracts.d.ts.map +0 -1
- package/dist/contracts.js +0 -56
- package/dist/contracts.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 -22
- package/dist/examples.d.ts.map +0 -1
- package/dist/examples.js +0 -187
- package/dist/examples.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 -213
- 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 -50
- package/dist/harness-mcp.js.map +0 -1
- package/dist/hike.d.ts +0 -32
- package/dist/hike.d.ts.map +0 -1
- package/dist/hike.js +0 -169
- package/dist/hike.js.map +0 -1
- package/dist/index.d.ts +0 -14
- 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__/context.test.ts +0 -60
- package/src/__tests__/contracts.test.ts +0 -68
- package/src/__tests__/detours.test.ts +0 -55
- package/src/__tests__/examples.test.ts +0 -176
- package/src/__tests__/hike.test.ts +0 -164
- package/src/__tests__/logger.test.ts +0 -136
- package/src/__tests__/trail.test.ts +0 -99
- package/src/hike.ts +0 -283
- package/tsconfig.json +0 -9
- package/tsconfig.tsbuildinfo +0 -1
package/src/harness-cli.ts
CHANGED
|
@@ -1,19 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CLI integration test harness.
|
|
3
3
|
*
|
|
4
|
-
* Builds CLI commands from
|
|
4
|
+
* Builds CLI commands from a graph, executes them in-process,
|
|
5
5
|
* and captures stdout/stderr.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
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 {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
tokens
|
|
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
|
-
/**
|
|
53
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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 (
|
|
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
|
|
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 =
|
|
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() ||
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
+
};
|
package/src/harness-mcp.ts
CHANGED
|
@@ -1,18 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP integration test harness.
|
|
3
3
|
*
|
|
4
|
-
* Builds MCP tools from
|
|
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 {
|
|
9
|
-
import type { McpToolDefinition } from '@ontrails/mcp';
|
|
10
|
-
|
|
8
|
+
import { deriveMcpTools } from '@ontrails/mcp';
|
|
11
9
|
import type {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
} from '
|
|
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
|
|
25
|
-
* that invokes tools directly without any transport
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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