@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
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI integration test harness.
|
|
3
|
+
*
|
|
4
|
+
* Builds CLI commands from a graph, executes them in-process,
|
|
5
|
+
* and captures stdout/stderr.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { deriveCliCommands } from '@ontrails/cli';
|
|
9
|
+
import type { CliCommand, DeriveCliCommandsOptions } from '@ontrails/cli';
|
|
10
|
+
import type { Topo, TrailContext } from '@ontrails/core';
|
|
11
|
+
import { renderPublicSurfaceError } from '@ontrails/core';
|
|
12
|
+
|
|
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
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Tokenizer
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/** Parse a command string into tokens (simple split, no quoting support). */
|
|
51
|
+
const parseCommandString = (input: string): string[] =>
|
|
52
|
+
input
|
|
53
|
+
.trim()
|
|
54
|
+
.split(/\s+/)
|
|
55
|
+
.filter((s) => s.length > 0);
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Command resolution
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
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);
|
|
67
|
+
|
|
68
|
+
/** Resolve a command from tokens using the longest matching command path. */
|
|
69
|
+
const resolveCommand = (
|
|
70
|
+
commands: CliCommand[],
|
|
71
|
+
tokens: string[]
|
|
72
|
+
): { command: CliCommand; flagTokens: string[] } | undefined => {
|
|
73
|
+
if (tokens.length === 0) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const [match] = commands
|
|
78
|
+
.filter((command) => matchesPath(command.path, tokens))
|
|
79
|
+
.toSorted((a, b) => b.path.length - a.path.length);
|
|
80
|
+
|
|
81
|
+
if (match === undefined) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { command: match, flagTokens: tokens.slice(match.path.length) };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Flag parsing
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/** Parse a value flag (--key value) and return new index. */
|
|
93
|
+
const parseValueFlag = (
|
|
94
|
+
key: string,
|
|
95
|
+
next: string,
|
|
96
|
+
flags: Record<string, unknown>
|
|
97
|
+
): void => {
|
|
98
|
+
const num = Number(next);
|
|
99
|
+
flags[key] = Number.isNaN(num) ? next : num;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Parse a single flag token and advance the index. */
|
|
103
|
+
const parseSingleFlag = (
|
|
104
|
+
tokens: string[],
|
|
105
|
+
i: number,
|
|
106
|
+
flags: Record<string, unknown>
|
|
107
|
+
): number => {
|
|
108
|
+
const token = tokens[i];
|
|
109
|
+
if (token === undefined || !token.startsWith('--')) {
|
|
110
|
+
return i + 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const key = token.slice(2);
|
|
114
|
+
const next = tokens[i + 1];
|
|
115
|
+
|
|
116
|
+
if (next !== undefined && !next.startsWith('-')) {
|
|
117
|
+
parseValueFlag(key, next, flags);
|
|
118
|
+
return i + 2;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
flags[key] = true;
|
|
122
|
+
return i + 1;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Parse flag tokens into a record. */
|
|
126
|
+
const parseFlagTokens = (tokens: string[]): Record<string, unknown> => {
|
|
127
|
+
const flags: Record<string, unknown> = {};
|
|
128
|
+
let i = 0;
|
|
129
|
+
|
|
130
|
+
while (i < tokens.length) {
|
|
131
|
+
i = parseSingleFlag(tokens, i, flags);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return flags;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Stream capture
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
interface CapturedStreams {
|
|
142
|
+
readonly getStderr: () => string;
|
|
143
|
+
readonly getStdout: () => string;
|
|
144
|
+
readonly restore: () => void;
|
|
145
|
+
readonly writeStdout: (text: string) => void;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Create interceptors for stdout/stderr capture. */
|
|
149
|
+
const captureStreams = (): CapturedStreams => {
|
|
150
|
+
let stdout = '';
|
|
151
|
+
let stderr = '';
|
|
152
|
+
const origStdoutWrite = process.stdout.write;
|
|
153
|
+
const origStderrWrite = process.stderr.write;
|
|
154
|
+
|
|
155
|
+
process.stdout.write = ((chunk: string | Uint8Array): boolean => {
|
|
156
|
+
stdout +=
|
|
157
|
+
typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk);
|
|
158
|
+
return true;
|
|
159
|
+
}) as typeof process.stdout.write;
|
|
160
|
+
|
|
161
|
+
process.stderr.write = ((chunk: string | Uint8Array): boolean => {
|
|
162
|
+
stderr +=
|
|
163
|
+
typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk);
|
|
164
|
+
return true;
|
|
165
|
+
}) as typeof process.stderr.write;
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
getStderr: () => stderr,
|
|
169
|
+
getStdout: () => stdout,
|
|
170
|
+
restore: () => {
|
|
171
|
+
process.stdout.write = origStdoutWrite;
|
|
172
|
+
process.stderr.write = origStderrWrite;
|
|
173
|
+
},
|
|
174
|
+
writeStdout: (text: string) => {
|
|
175
|
+
stdout += text;
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Output formatting
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/** Try to parse a string as JSON, returning undefined on failure. */
|
|
185
|
+
const tryParseJson = (text: string): unknown => {
|
|
186
|
+
try {
|
|
187
|
+
return JSON.parse(text);
|
|
188
|
+
} catch {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** Format result value into stdout and build the CLI result. */
|
|
194
|
+
const formatSuccessResult = (
|
|
195
|
+
value: unknown,
|
|
196
|
+
flags: Record<string, unknown>,
|
|
197
|
+
streams: CapturedStreams
|
|
198
|
+
): CliHarnessResult => {
|
|
199
|
+
const outputMode =
|
|
200
|
+
flags['output'] ?? (flags['json'] === true ? 'json' : 'text');
|
|
201
|
+
|
|
202
|
+
if (outputMode === 'json') {
|
|
203
|
+
const jsonStr = `${JSON.stringify(value, null, 2)}\n`;
|
|
204
|
+
streams.writeStdout(jsonStr);
|
|
205
|
+
return {
|
|
206
|
+
exitCode: 0,
|
|
207
|
+
json: tryParseJson(jsonStr),
|
|
208
|
+
stderr: streams.getStderr(),
|
|
209
|
+
stdout: streams.getStdout(),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const formatted =
|
|
214
|
+
typeof value === 'string'
|
|
215
|
+
? `${value}\n`
|
|
216
|
+
: `${JSON.stringify(value, null, 2)}\n`;
|
|
217
|
+
streams.writeStdout(formatted);
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
exitCode: 0,
|
|
221
|
+
json: tryParseJson(streams.getStdout().trim()),
|
|
222
|
+
stderr: streams.getStderr(),
|
|
223
|
+
stdout: streams.getStdout(),
|
|
224
|
+
};
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// Execute command
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
/** Build an error result from a caught exception. */
|
|
232
|
+
const buildErrorResult = (
|
|
233
|
+
error: unknown,
|
|
234
|
+
streams: CapturedStreams
|
|
235
|
+
): CliHarnessResult => {
|
|
236
|
+
streams.restore();
|
|
237
|
+
const actualError = error instanceof Error ? error : new Error(String(error));
|
|
238
|
+
const rendering = renderPublicSurfaceError('cli', actualError);
|
|
239
|
+
return {
|
|
240
|
+
error: {
|
|
241
|
+
category: rendering.category,
|
|
242
|
+
code: rendering.name,
|
|
243
|
+
message: rendering.message,
|
|
244
|
+
},
|
|
245
|
+
exitCode: 1,
|
|
246
|
+
stderr: streams.getStderr() || rendering.message,
|
|
247
|
+
stdout: streams.getStdout(),
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
/** Execute a resolved command and return the result. */
|
|
252
|
+
const executeCommand = async (
|
|
253
|
+
command: CliCommand,
|
|
254
|
+
flags: Record<string, unknown>,
|
|
255
|
+
streams: CapturedStreams,
|
|
256
|
+
ctxOverrides?: Partial<TrailContext>
|
|
257
|
+
): Promise<CliHarnessResult> => {
|
|
258
|
+
const ctx = mergeTestContext(ctxOverrides);
|
|
259
|
+
const result = await command.execute({}, flags, ctx);
|
|
260
|
+
streams.restore();
|
|
261
|
+
|
|
262
|
+
if (result.isErr()) {
|
|
263
|
+
const rendering = renderPublicSurfaceError('cli', result.error);
|
|
264
|
+
return {
|
|
265
|
+
error: {
|
|
266
|
+
category: rendering.category,
|
|
267
|
+
code: rendering.name,
|
|
268
|
+
message: rendering.message,
|
|
269
|
+
},
|
|
270
|
+
exitCode: 1,
|
|
271
|
+
stderr: streams.getStderr() || rendering.message,
|
|
272
|
+
stdout: streams.getStdout(),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return formatSuccessResult(result.value, flags, streams);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/** Run the full command pipeline: resolve, parse, execute. */
|
|
280
|
+
const runCommand = async (
|
|
281
|
+
commands: CliCommand[],
|
|
282
|
+
commandString: string,
|
|
283
|
+
ctxOverrides?: Partial<TrailContext>
|
|
284
|
+
): Promise<CliHarnessResult> => {
|
|
285
|
+
const parts = parseCommandString(commandString);
|
|
286
|
+
const resolved = resolveCommand(commands, parts);
|
|
287
|
+
if (resolved === undefined) {
|
|
288
|
+
return {
|
|
289
|
+
exitCode: 1,
|
|
290
|
+
stderr: `Unknown command: ${commandString}`,
|
|
291
|
+
stdout: '',
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const { command, flagTokens } = resolved;
|
|
296
|
+
const flags = parseFlagTokens(flagTokens);
|
|
297
|
+
const streams = captureStreams();
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
return await executeCommand(command, flags, streams, ctxOverrides);
|
|
301
|
+
} catch (error: unknown) {
|
|
302
|
+
return buildErrorResult(error, streams);
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// createCliHarness
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Create a CLI harness for integration testing.
|
|
312
|
+
*
|
|
313
|
+
* Builds commands from the graph's topo and provides a `run()` method
|
|
314
|
+
* that parses command strings and executes them in-process.
|
|
315
|
+
*
|
|
316
|
+
* ```ts
|
|
317
|
+
* import { createCliHarness } from '@ontrails/testing/cli';
|
|
318
|
+
*
|
|
319
|
+
* const harness = createCliHarness({ graph });
|
|
320
|
+
* const result = await harness.run("entity show --name Alpha --output json");
|
|
321
|
+
* expect(result.exitCode).toBe(0);
|
|
322
|
+
* ```
|
|
323
|
+
*/
|
|
324
|
+
export const createCliHarness = (options: CliHarnessOptions): CliHarness => {
|
|
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;
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
run: (commandString: string) => runCommand(commands, commandString, ctx),
|
|
334
|
+
};
|
|
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, renderPublicSurfaceError } 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 renderings. */
|
|
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 rendering = renderPublicSurfaceError('http', error);
|
|
130
|
+
const body = {
|
|
131
|
+
error: {
|
|
132
|
+
category: rendering.category,
|
|
133
|
+
code: rendering.name,
|
|
134
|
+
message: rendering.message,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
body,
|
|
139
|
+
error: body.error,
|
|
140
|
+
ok: false,
|
|
141
|
+
status: rendering.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
|
+
};
|