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