@ontrails/testing 1.0.0-beta.18 → 1.0.0-beta.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/errors.ts ADDED
@@ -0,0 +1,47 @@
1
+ import {
2
+ errorClasses,
3
+ InternalError,
4
+ RetryExhaustedError,
5
+ TrailsError,
6
+ } from '@ontrails/core';
7
+
8
+ type ErrorConstructor = new (...args: never[]) => Error;
9
+ type MessageErrorConstructor = new (message: string) => Error;
10
+
11
+ const ERROR_CLASS_BY_NAME = new Map<string, ErrorConstructor>([
12
+ ...errorClasses.map(
13
+ (entry) => [entry.name, entry.ctor as ErrorConstructor] as const
14
+ ),
15
+ ['TrailsError', TrailsError as unknown as ErrorConstructor],
16
+ ]);
17
+
18
+ /**
19
+ * Resolve an error class name string to the actual constructor.
20
+ * Falls back to generic Error if the name is not in the core taxonomy.
21
+ */
22
+ export const resolveErrorClass = (name: string): ErrorConstructor =>
23
+ ERROR_CLASS_BY_NAME.get(name) ?? (Error as ErrorConstructor);
24
+
25
+ /**
26
+ * Create an error instance for an authored example error name.
27
+ */
28
+ export const createErrorFromName = (name: string): Error => {
29
+ if (name === 'TrailsError') {
30
+ return new InternalError(name);
31
+ }
32
+
33
+ const entry = errorClasses.find((candidate) => candidate.name === name);
34
+ if (entry === undefined) {
35
+ return new Error(name);
36
+ }
37
+
38
+ if (entry.name === 'RetryExhaustedError') {
39
+ return new RetryExhaustedError(new InternalError(name), {
40
+ attempts: 1,
41
+ detour: 'testComposes',
42
+ });
43
+ }
44
+
45
+ const ErrorClass = entry.ctor as unknown as MessageErrorConstructor;
46
+ return new ErrorClass(name);
47
+ };
package/src/examples.ts CHANGED
@@ -3,14 +3,16 @@
3
3
  *
4
4
  * Iterates every trail in the app's topo. For each trail with examples,
5
5
  * generates describe/test blocks using bun:test. Progressive assertion
6
- * determines which check to run per example. For trails with `crosses`
7
- * declarations, checks that every declared crossing was called at least once.
6
+ * determines which check to run per example. For trails with `composes`
7
+ * declarations, checks that every declared composing was called at least once.
8
8
  */
9
9
 
10
10
  import { describe, expect, test } from 'bun:test';
11
11
 
12
12
  import type {
13
- CrossFn,
13
+ ComposeFn,
14
+ ComposeOptions,
15
+ ExecuteTrailOptions,
14
16
  ResourceOverrideMap,
15
17
  Topo,
16
18
  TrailExample,
@@ -19,25 +21,10 @@ import type {
19
21
  } from '@ontrails/core';
20
22
 
21
23
  import {
22
- AlreadyExistsError,
23
- AmbiguousError,
24
- AssertionError,
25
- AuthError,
26
- buildCrossValidationSchema,
27
- CancelledError,
28
- ConflictError,
29
- DerivationError,
30
- InternalError,
31
- NetworkError,
32
- NotFoundError,
33
- PermissionError,
34
- PermitError,
35
- RateLimitError,
36
- RetryExhaustedError,
24
+ buildComposeValidationSchema,
37
25
  executeTrail,
26
+ parseTrailIdVersionReference,
38
27
  Result,
39
- TimeoutError,
40
- TrailsError,
41
28
  ValidationError,
42
29
  validateInput,
43
30
  } from '@ontrails/core';
@@ -57,42 +44,18 @@ import {
57
44
  createMockResources,
58
45
  } from './context.js';
59
46
  import type { PermittedTrail, TestExecutionOptions } from './context.js';
60
- import { isDerivedExample, deriveTrailExamples } from './effective-examples.js';
47
+ import {
48
+ deriveTrailExampleTargets,
49
+ isDerivedExample,
50
+ } from './effective-examples.js';
51
+ import type { TrailExampleTarget } from './effective-examples.js';
52
+ import { resolveErrorClass } from './errors.js';
61
53
  import { withSignalAssertions } from './signals.js';
62
54
 
63
- // ---------------------------------------------------------------------------
64
- // Error class name -> constructor map
65
- // ---------------------------------------------------------------------------
66
-
67
- const ERROR_MAP: Record<string, new (...args: never[]) => Error> = {
68
- AlreadyExistsError: AlreadyExistsError as new (...args: never[]) => Error,
69
- AmbiguousError: AmbiguousError as new (...args: never[]) => Error,
70
- AssertionError: AssertionError as new (...args: never[]) => Error,
71
- AuthError: AuthError as new (...args: never[]) => Error,
72
- CancelledError: CancelledError as new (...args: never[]) => Error,
73
- ConflictError: ConflictError as new (...args: never[]) => Error,
74
- DerivationError: DerivationError as new (...args: never[]) => Error,
75
- InternalError: InternalError as new (...args: never[]) => Error,
76
- NetworkError: NetworkError as new (...args: never[]) => Error,
77
- NotFoundError: NotFoundError as new (...args: never[]) => Error,
78
- PermissionError: PermissionError as new (...args: never[]) => Error,
79
- PermitError: PermitError as new (...args: never[]) => Error,
80
- RateLimitError: RateLimitError as new (...args: never[]) => Error,
81
- RetryExhaustedError: RetryExhaustedError as unknown as new (
82
- ...args: never[]
83
- ) => Error,
84
- TimeoutError: TimeoutError as new (...args: never[]) => Error,
85
- TrailsError: TrailsError as unknown as new (...args: never[]) => Error,
86
- ValidationError: ValidationError as new (...args: never[]) => Error,
55
+ type TestingExecuteTrailOptions = ExecuteTrailOptions & {
56
+ readonly validationSchema?: ReturnType<typeof buildComposeValidationSchema>;
87
57
  };
88
58
 
89
- /**
90
- * Resolve an error class name string to the actual constructor.
91
- * Falls back to generic Error if the name is not in the core taxonomy.
92
- */
93
- const resolveErrorClass = (name: string): new (...args: never[]) => Error =>
94
- ERROR_MAP[name] ?? (Error as new (...args: never[]) => Error);
95
-
96
59
  // ---------------------------------------------------------------------------
97
60
  // Helpers
98
61
  // ---------------------------------------------------------------------------
@@ -161,21 +124,17 @@ const applyAutoPermit = (
161
124
  return { ...ctx, permit };
162
125
  };
163
126
 
164
- /**
165
- * Run a single example against a trail.
166
- * Handles validation, execution, and assertions.
167
- */
168
- export const runExample = async (
169
- t: Trail<unknown, unknown, unknown>,
127
+ const runTargetExample = async (
128
+ target: TrailExampleTarget,
170
129
  example: TrailExample<unknown, unknown>,
171
- output: z.ZodType | undefined,
172
130
  testCtx: TrailContext,
173
131
  resources?: ResourceOverrideMap,
174
132
  opts?: TestExecutionOptions
175
133
  ): Promise<void> => {
134
+ const { output, trail: t } = target;
176
135
  const ctx = opts ? applyAutoPermit(testCtx, t, opts) : testCtx;
177
136
  const signals = withSignalAssertions(ctx, example);
178
- const validated = validateInput(t.input, example.input);
137
+ const validated = validateInput(target.input, example.input);
179
138
 
180
139
  if (handleValidationError(validated, example)) {
181
140
  signals.assert();
@@ -185,118 +144,176 @@ export const runExample = async (
185
144
  const result = await executeTrail(t, example.input, {
186
145
  ctx: signals.ctx,
187
146
  resources: resources ?? opts?.resources,
147
+ ...(target.version === undefined ? {} : { version: target.version }),
188
148
  });
189
149
  assertProgressiveMatch(result, example, output);
190
150
  signals.assert();
191
151
  };
192
152
 
153
+ /**
154
+ * Run a single example against a trail.
155
+ * Handles validation, execution, and assertions.
156
+ */
157
+ export const runExample = async (
158
+ t: Trail<unknown, unknown, unknown>,
159
+ example: TrailExample<unknown, unknown>,
160
+ output: z.ZodType | undefined,
161
+ testCtx: TrailContext,
162
+ resources?: ResourceOverrideMap,
163
+ opts?: TestExecutionOptions
164
+ ): Promise<void> => {
165
+ await runTargetExample(
166
+ {
167
+ composes: t.composes,
168
+ current: true,
169
+ examples: [example],
170
+ id: t.id,
171
+ input: t.input,
172
+ output,
173
+ trail: t,
174
+ },
175
+ example,
176
+ testCtx,
177
+ resources,
178
+ opts
179
+ );
180
+ };
181
+
193
182
  // ---------------------------------------------------------------------------
194
- // Crossing coverage for trails with crossings
183
+ // Composing coverage for trails with compositions
195
184
  // ---------------------------------------------------------------------------
196
185
 
197
186
  /**
198
- * Build a recording cross function that tracks which trail IDs are called.
187
+ * Build a recording compose function that tracks which trail IDs are called.
199
188
  *
200
- * Delegates to `baseCross` when available, otherwise looks up the trail
189
+ * Delegates to `baseCompose` when available, otherwise looks up the trail
201
190
  * in the topo and executes it with validated input. Falls back to
202
191
  * `Result.ok()` when neither is available.
203
192
  */
204
- const createCoverageCross = (
193
+ const createCoverageCompose = (
205
194
  called: Set<string>,
206
- baseCross: CrossFn | undefined,
195
+ baseCompose: ComposeFn | undefined,
207
196
  topo: Topo,
208
197
  ctx: TrailContext,
209
198
  resources?: ResourceOverrideMap
210
- ): CrossFn => {
211
- const invokeCross = async (
199
+ ): ComposeFn => {
200
+ const invokeCompose = async (
212
201
  idOrTrail: string | { readonly id: string },
213
202
  input: unknown,
214
- self: CrossFn
203
+ self: ComposeFn,
204
+ composeOptions?: ComposeOptions | undefined
215
205
  ) => {
216
- const id = typeof idOrTrail === 'string' ? idOrTrail : idOrTrail.id;
217
- called.add(id);
206
+ const parsed =
207
+ typeof idOrTrail === 'string'
208
+ ? parseTrailIdVersionReference(idOrTrail)
209
+ : Result.ok({ id: idOrTrail.id });
210
+ if (parsed.isErr()) {
211
+ return parsed;
212
+ }
213
+ const parsedVersion =
214
+ 'version' in parsed.value ? parsed.value.version : undefined;
215
+ if (parsedVersion !== undefined && composeOptions?.version !== undefined) {
216
+ return Result.err(
217
+ new ValidationError(
218
+ `Trail "${parsed.value.id}" version was provided both in the id reference and ctx.compose() options`
219
+ )
220
+ );
221
+ }
218
222
 
219
- if (baseCross !== undefined) {
220
- return await baseCross(id, input);
223
+ const { id } = parsed.value;
224
+ called.add(id);
225
+ const version = composeOptions?.version ?? parsedVersion;
226
+
227
+ if (baseCompose !== undefined) {
228
+ const forwardedOptions =
229
+ parsedVersion === undefined
230
+ ? composeOptions
231
+ : { ...composeOptions, version: parsedVersion };
232
+ return await baseCompose(id, input, forwardedOptions);
221
233
  }
222
234
 
223
235
  const trailDef = topo.get(id);
224
236
  if (trailDef !== undefined) {
225
- return await executeTrail(trailDef, input, {
226
- ctx: { ...ctx, cross: self },
237
+ const options: TestingExecuteTrailOptions = {
238
+ ctx: { ...ctx, compose: self },
227
239
  resources,
228
- validationSchema: buildCrossValidationSchema(trailDef),
229
- });
240
+ ...(version === undefined ? {} : { version }),
241
+ validationSchema: buildComposeValidationSchema(trailDef),
242
+ };
243
+ return await executeTrail(trailDef, input, options);
230
244
  }
231
245
 
232
246
  return Result.ok();
233
247
  };
234
248
 
235
- // Accepts either a trail object (typed cross), a string id (untyped),
249
+ // Accepts either a trail object (typed compose), a string id (untyped),
236
250
  // or a batch of `[target, input]` tuples.
237
- const cross = async function cross(
251
+ const compose = async function compose(
238
252
  idOrTrail:
239
253
  | string
240
254
  | { readonly id: string }
241
255
  | readonly (readonly [string | { readonly id: string }, unknown])[],
242
- input?: unknown
256
+ inputOrOptions?: unknown,
257
+ singleOptions?: ComposeOptions
243
258
  ) {
244
259
  if (Array.isArray(idOrTrail)) {
245
260
  return await Promise.all(
246
261
  idOrTrail.map(([target, batchInput]) =>
247
- invokeCross(target, batchInput, cross as CrossFn)
262
+ invokeCompose(target, batchInput, compose as ComposeFn)
248
263
  )
249
264
  );
250
265
  }
251
266
 
252
- return await invokeCross(
267
+ return await invokeCompose(
253
268
  idOrTrail as string | { readonly id: string },
254
- input,
255
- cross as CrossFn
269
+ inputOrOptions,
270
+ compose as ComposeFn,
271
+ singleOptions
256
272
  );
257
- } as CrossFn;
273
+ } as ComposeFn;
258
274
 
259
- return cross;
275
+ return compose;
260
276
  };
261
277
 
262
278
  /**
263
- * Run a single example against a trail with crossings, recording cross calls.
279
+ * Run a single example against a trail with compositions, recording compose calls.
264
280
  */
265
281
  const runCompositionExample = async (
266
- trailDef: Trail<unknown, unknown, unknown>,
282
+ target: TrailExampleTarget,
267
283
  example: TrailExample<unknown, unknown>,
268
- output: z.ZodType | undefined,
269
284
  baseCtx: TrailContext,
270
285
  called: Set<string>,
271
286
  topo: Topo,
272
287
  resources?: ResourceOverrideMap,
273
288
  opts?: TestExecutionOptions
274
289
  ): Promise<void> => {
290
+ const { output, trail: trailDef } = target;
275
291
  const permittedCtx = opts
276
292
  ? applyAutoPermit(baseCtx, trailDef, opts)
277
293
  : baseCtx;
278
294
  const signals = withSignalAssertions(permittedCtx, example);
279
- const validated = validateInput(trailDef.input, example.input);
295
+ const validated = validateInput(target.input, example.input);
280
296
 
281
297
  if (handleValidationError(validated, example)) {
282
298
  signals.assert();
283
299
  return;
284
300
  }
285
301
 
286
- const cross = createCoverageCross(
302
+ const compose = createCoverageCompose(
287
303
  called,
288
- signals.ctx.cross,
304
+ signals.ctx.compose,
289
305
  topo,
290
306
  signals.ctx,
291
307
  resources
292
308
  );
293
- const testCtx: TrailContext = { ...signals.ctx, cross };
309
+ const testCtx: TrailContext = { ...signals.ctx, compose };
294
310
 
295
- // Top-level trail validates against trail.input (not merged crossInput).
296
- // Merged validation only applies to cross targets in executeFromMap/createCoverageCross.
311
+ // Top-level trail validates against trail.input (not merged composeInput).
312
+ // Merged validation only applies to compose targets in executeFromMap/createCoverageCompose.
297
313
  const result = await executeTrail(trailDef, example.input, {
298
314
  ctx: testCtx,
299
315
  resources: resources ?? opts?.resources,
316
+ ...(target.version === undefined ? {} : { version: target.version }),
300
317
  });
301
318
  assertProgressiveMatch(result, example, output);
302
319
  signals.assert();
@@ -309,8 +326,8 @@ const runCompositionExample = async (
309
326
  /**
310
327
  * Generate describe/test blocks for every trail example in the app.
311
328
  *
312
- * For trails with `crosses` declarations and examples, also verifies that
313
- * every declared crossed ID was called at least once across all examples.
329
+ * For trails with `composes` declarations and examples, also verifies that
330
+ * every declared composed ID was called at least once across all examples.
314
331
  *
315
332
  * One line in your test file:
316
333
  * ```ts
@@ -327,18 +344,15 @@ export const testExamples = (
327
344
  const resolveInput =
328
345
  typeof ctxOrFactory === 'function' ? ctxOrFactory : () => ctxOrFactory;
329
346
  const withExamples = (app.list() as Trail<unknown, unknown, unknown>[])
330
- .map((trailDef) => ({
331
- ...trailDef,
332
- examples: deriveTrailExamples(trailDef),
333
- }))
334
- .filter((trailDef) => trailDef.examples.length > 0);
335
- const simpleTrails = withExamples.filter((t) => t.crosses.length === 0);
336
- const compositionTrails = withExamples.filter((t) => t.crosses.length > 0);
347
+ .flatMap(deriveTrailExampleTargets)
348
+ .filter((target) => target.examples.length > 0);
349
+ const simpleTrails = withExamples.filter((t) => t.composes.length === 0);
350
+ const compositionTrails = withExamples.filter((t) => t.composes.length > 0);
337
351
 
338
352
  // Simple trails: run examples directly
339
353
  if (simpleTrails.length > 0) {
340
354
  describe.each(simpleTrails)('$id', (t) => {
341
- const { examples, output } = t;
355
+ const { examples } = t;
342
356
  if (!examples) {
343
357
  return;
344
358
  }
@@ -353,42 +367,42 @@ export const testExamples = (
353
367
  resolved.resources
354
368
  );
355
369
  const testCtx = mergeTestContext(resolved.ctx);
356
- await runExample(t, example, output, testCtx, resources, resolved);
370
+ await runTargetExample(t, example, testCtx, resources, resolved);
357
371
  }
358
372
  );
359
373
  });
360
374
  }
361
375
 
362
- // Composition trails: use recording cross and check coverage.
376
+ // Composition trails: use recording compose and check coverage.
363
377
  //
364
- // Crossing coverage only runs against AUTHORED examples. Contour-derived
378
+ // Composing coverage only runs against AUTHORED examples. Contour-derived
365
379
  // fixtures are opportunistic coverage that may not exercise every
366
- // `ctx.cross()` branch in the trail, so asserting coverage against them
380
+ // `ctx.compose()` branch in the trail, so asserting coverage against them
367
381
  // would produce false failures for trails whose authored intent was a
368
382
  // single path. When a trail has zero authored examples the coverage
369
383
  // assertion is skipped entirely — the derived-example runs still
370
- // execute, but they are not required to cover declared crossings.
384
+ // execute, but they are not required to cover declared compositions.
371
385
  if (compositionTrails.length > 0) {
372
386
  describe.each(compositionTrails)('$id', (t) => {
373
- const { examples, output } = t;
387
+ const { examples } = t;
374
388
  if (!examples) {
375
389
  return;
376
390
  }
377
391
 
378
- const calledFromAuthored = new Set<string>();
392
+ const composedFromAuthored = new Set<string>();
379
393
  const hasAuthoredExamples = examples.some(
380
394
  (example) => !isDerivedExample(example)
381
395
  );
382
396
 
383
- // Only record cross calls from authored examples. Derived fixtures
397
+ // Only record compose calls from authored examples. Derived fixtures
384
398
  // execute normally but do not contribute to coverage — the sink map
385
- // routes each example to the right bucket without an inline
399
+ // puts each example in the right bucket without an inline
386
400
  // conditional inside the test body.
387
401
  const discardSink = new Set<string>();
388
402
  const pickCoverageSink = (
389
403
  example: TrailExample<unknown, unknown>
390
404
  ): Set<string> =>
391
- isDerivedExample(example) ? discardSink : calledFromAuthored;
405
+ isDerivedExample(example) ? discardSink : composedFromAuthored;
392
406
 
393
407
  test.each([...examples])(
394
408
  'example: $name',
@@ -403,7 +417,6 @@ export const testExamples = (
403
417
  await runCompositionExample(
404
418
  t,
405
419
  example,
406
- output,
407
420
  baseCtx,
408
421
  pickCoverageSink(example),
409
422
  app,
@@ -414,9 +427,9 @@ export const testExamples = (
414
427
  );
415
428
 
416
429
  if (hasAuthoredExamples) {
417
- test('crossing coverage', () => {
418
- const uncovered = t.crosses.filter(
419
- (id) => !calledFromAuthored.has(id)
430
+ test('composing coverage', () => {
431
+ const uncovered = t.composes.filter(
432
+ (id) => !composedFromAuthored.has(id)
420
433
  );
421
434
  expect(uncovered).toEqual([]);
422
435
  });
@@ -6,16 +6,42 @@
6
6
  */
7
7
 
8
8
  import { deriveCliCommands } from '@ontrails/cli';
9
- import type { CliCommand } from '@ontrails/cli';
10
- import type { TrailContext } from '@ontrails/core';
9
+ import type { CliCommand, DeriveCliCommandsOptions } from '@ontrails/cli';
10
+ import type { Topo, TrailContext } from '@ontrails/core';
11
11
  import { projectPublicSurfaceError } from '@ontrails/core';
12
12
 
13
13
  import { mergeTestContext } from './context.js';
14
- import type {
15
- CliHarness,
16
- CliHarnessOptions,
17
- CliHarnessResult,
18
- } from './types.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
+ }
19
45
 
20
46
  // ---------------------------------------------------------------------------
21
47
  // Tokenizer
@@ -288,6 +314,8 @@ const runCommand = async (
288
314
  * that parses command strings and executes them in-process.
289
315
  *
290
316
  * ```ts
317
+ * import { createCliHarness } from '@ontrails/testing/cli';
318
+ *
291
319
  * const harness = createCliHarness({ graph });
292
320
  * const result = await harness.run("entity show --name Alpha --output json");
293
321
  * expect(result.exitCode).toBe(0);
@@ -6,18 +6,96 @@
6
6
  */
7
7
 
8
8
  import { deriveHttpRoutes } from '@ontrails/http';
9
- import type { HttpMethod, HttpRouteDefinition } from '@ontrails/http';
10
- import type { TrailContext, TrailContextInit } from '@ontrails/core';
9
+ import type {
10
+ DeriveHttpRoutesOptions,
11
+ HttpHeaderSource,
12
+ HttpMethod,
13
+ HttpRouteDefinition,
14
+ } from '@ontrails/http';
15
+ import type { Topo, TrailContext, TrailContextInit } from '@ontrails/core';
11
16
  import { NotFoundError, projectPublicSurfaceError } from '@ontrails/core';
12
17
 
13
18
  import { mergeTestContext } from './context.js';
14
- import type {
15
- HttpHarness,
16
- HttpHarnessOptions,
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<
17
37
  HttpHarnessRequest,
18
- HttpHarnessRequestOptions,
19
- HttpHarnessResult,
20
- } from './types.js';
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
+ }
21
99
 
22
100
  const TEST_ORIGIN = 'http://ontrails.test';
23
101
 
@@ -163,7 +241,7 @@ const executeRoute = async (
163
241
  *
164
242
  * @example
165
243
  * ```ts
166
- * import { createHttpHarness } from '@ontrails/testing';
244
+ * import { createHttpHarness } from '@ontrails/testing/http';
167
245
  *
168
246
  * const http = createHttpHarness({ graph });
169
247
  * const result = await http.get('/entity/show', { name: 'Alpha' });