@ontrails/testing 1.0.0-beta.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.
Files changed (80) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/.turbo/turbo-lint.log +3 -0
  3. package/.turbo/turbo-typecheck.log +1 -0
  4. package/CHANGELOG.md +23 -0
  5. package/README.md +221 -0
  6. package/dist/all.d.ts +30 -0
  7. package/dist/all.d.ts.map +1 -0
  8. package/dist/all.js +47 -0
  9. package/dist/all.js.map +1 -0
  10. package/dist/assertions.d.ts +49 -0
  11. package/dist/assertions.d.ts.map +1 -0
  12. package/dist/assertions.js +84 -0
  13. package/dist/assertions.js.map +1 -0
  14. package/dist/context.d.ts +19 -0
  15. package/dist/context.d.ts.map +1 -0
  16. package/dist/context.js +33 -0
  17. package/dist/context.js.map +1 -0
  18. package/dist/contracts.d.ts +16 -0
  19. package/dist/contracts.d.ts.map +1 -0
  20. package/dist/contracts.js +56 -0
  21. package/dist/contracts.js.map +1 -0
  22. package/dist/detours.d.ts +12 -0
  23. package/dist/detours.d.ts.map +1 -0
  24. package/dist/detours.js +30 -0
  25. package/dist/detours.js.map +1 -0
  26. package/dist/examples.d.ts +22 -0
  27. package/dist/examples.d.ts.map +1 -0
  28. package/dist/examples.js +187 -0
  29. package/dist/examples.js.map +1 -0
  30. package/dist/harness-cli.d.ts +21 -0
  31. package/dist/harness-cli.d.ts.map +1 -0
  32. package/dist/harness-cli.js +213 -0
  33. package/dist/harness-cli.js.map +1 -0
  34. package/dist/harness-mcp.d.ts +21 -0
  35. package/dist/harness-mcp.d.ts.map +1 -0
  36. package/dist/harness-mcp.js +50 -0
  37. package/dist/harness-mcp.js.map +1 -0
  38. package/dist/hike.d.ts +32 -0
  39. package/dist/hike.d.ts.map +1 -0
  40. package/dist/hike.js +169 -0
  41. package/dist/hike.js.map +1 -0
  42. package/dist/index.d.ts +14 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +16 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/logger.d.ts +15 -0
  47. package/dist/logger.d.ts.map +1 -0
  48. package/dist/logger.js +87 -0
  49. package/dist/logger.js.map +1 -0
  50. package/dist/trail.d.ts +20 -0
  51. package/dist/trail.d.ts.map +1 -0
  52. package/dist/trail.js +80 -0
  53. package/dist/trail.js.map +1 -0
  54. package/dist/types.d.ts +80 -0
  55. package/dist/types.d.ts.map +1 -0
  56. package/dist/types.js +5 -0
  57. package/dist/types.js.map +1 -0
  58. package/package.json +23 -0
  59. package/src/__tests__/context.test.ts +60 -0
  60. package/src/__tests__/contracts.test.ts +68 -0
  61. package/src/__tests__/detours.test.ts +55 -0
  62. package/src/__tests__/examples.test.ts +176 -0
  63. package/src/__tests__/hike.test.ts +164 -0
  64. package/src/__tests__/logger.test.ts +136 -0
  65. package/src/__tests__/trail.test.ts +99 -0
  66. package/src/all.ts +55 -0
  67. package/src/assertions.ts +108 -0
  68. package/src/context.ts +42 -0
  69. package/src/contracts.ts +85 -0
  70. package/src/detours.ts +44 -0
  71. package/src/examples.ts +314 -0
  72. package/src/harness-cli.ts +310 -0
  73. package/src/harness-mcp.ts +65 -0
  74. package/src/hike.ts +283 -0
  75. package/src/index.ts +40 -0
  76. package/src/logger.ts +125 -0
  77. package/src/trail.ts +116 -0
  78. package/src/types.ts +117 -0
  79. package/tsconfig.json +9 -0
  80. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,310 @@
1
+ /**
2
+ * CLI integration test harness.
3
+ *
4
+ * Builds CLI commands from an App, executes them in-process,
5
+ * and captures stdout/stderr.
6
+ */
7
+
8
+ import { buildCliCommands } from '@ontrails/cli';
9
+ import type { CliCommand } from '@ontrails/cli';
10
+
11
+ import { createTestContext } from './context.js';
12
+ import type {
13
+ CliHarness,
14
+ CliHarnessOptions,
15
+ CliHarnessResult,
16
+ } from './types.js';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Tokenizer
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /** Parse a command string into tokens (simple split, no quoting support). */
23
+ const parseCommandString = (input: string): string[] =>
24
+ input
25
+ .trim()
26
+ .split(/\s+/)
27
+ .filter((s) => s.length > 0);
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Command resolution
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /** Try to match group + name from tokens. */
34
+ const tryGroupMatch = (
35
+ commands: CliCommand[],
36
+ firstToken: string,
37
+ secondToken: string | undefined,
38
+ tokens: string[]
39
+ ): { command: CliCommand; flagTokens: string[] } | undefined => {
40
+ if (secondToken === undefined || secondToken.startsWith('-')) {
41
+ return undefined;
42
+ }
43
+ const match = commands.find(
44
+ (c) => c.group === firstToken && c.name === secondToken
45
+ );
46
+ if (match === undefined) {
47
+ return undefined;
48
+ }
49
+ return { command: match, flagTokens: tokens.slice(2) };
50
+ };
51
+
52
+ /** Try to match a direct name from the first token. */
53
+ const tryDirectMatch = (
54
+ commands: CliCommand[],
55
+ firstToken: string,
56
+ tokens: string[]
57
+ ): { command: CliCommand; flagTokens: string[] } | undefined => {
58
+ const match = commands.find(
59
+ (c) => c.name === firstToken && c.group === undefined
60
+ );
61
+ if (match === undefined) {
62
+ return undefined;
63
+ }
64
+ return { command: match, flagTokens: tokens.slice(1) };
65
+ };
66
+
67
+ /** Resolve a command from tokens, handling group.name patterns. */
68
+ const resolveCommand = (
69
+ commands: CliCommand[],
70
+ tokens: string[]
71
+ ): { command: CliCommand; flagTokens: string[] } | undefined => {
72
+ const [firstToken, secondToken] = tokens;
73
+
74
+ if (firstToken === undefined) {
75
+ return undefined;
76
+ }
77
+
78
+ return (
79
+ tryGroupMatch(commands, firstToken, secondToken, tokens) ??
80
+ tryDirectMatch(commands, firstToken, tokens)
81
+ );
82
+ };
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Flag parsing
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /** Parse a value flag (--key value) and return new index. */
89
+ const parseValueFlag = (
90
+ key: string,
91
+ next: string,
92
+ flags: Record<string, unknown>
93
+ ): void => {
94
+ const num = Number(next);
95
+ flags[key] = Number.isNaN(num) ? next : num;
96
+ };
97
+
98
+ /** Parse a single flag token and advance the index. */
99
+ const parseSingleFlag = (
100
+ tokens: string[],
101
+ i: number,
102
+ flags: Record<string, unknown>
103
+ ): number => {
104
+ const token = tokens[i];
105
+ if (token === undefined || !token.startsWith('--')) {
106
+ return i + 1;
107
+ }
108
+
109
+ const key = token.slice(2);
110
+ const next = tokens[i + 1];
111
+
112
+ if (next !== undefined && !next.startsWith('-')) {
113
+ parseValueFlag(key, next, flags);
114
+ return i + 2;
115
+ }
116
+
117
+ flags[key] = true;
118
+ return i + 1;
119
+ };
120
+
121
+ /** Parse flag tokens into a record. */
122
+ const parseFlagTokens = (tokens: string[]): Record<string, unknown> => {
123
+ const flags: Record<string, unknown> = {};
124
+ let i = 0;
125
+
126
+ while (i < tokens.length) {
127
+ i = parseSingleFlag(tokens, i, flags);
128
+ }
129
+
130
+ return flags;
131
+ };
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Stream capture
135
+ // ---------------------------------------------------------------------------
136
+
137
+ interface CapturedStreams {
138
+ readonly getStderr: () => string;
139
+ readonly getStdout: () => string;
140
+ readonly restore: () => void;
141
+ readonly writeStdout: (text: string) => void;
142
+ }
143
+
144
+ /** Create interceptors for stdout/stderr capture. */
145
+ const captureStreams = (): CapturedStreams => {
146
+ let stdout = '';
147
+ let stderr = '';
148
+ const origStdoutWrite = process.stdout.write;
149
+ const origStderrWrite = process.stderr.write;
150
+
151
+ process.stdout.write = ((chunk: string | Uint8Array): boolean => {
152
+ stdout +=
153
+ typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk);
154
+ return true;
155
+ }) as typeof process.stdout.write;
156
+
157
+ process.stderr.write = ((chunk: string | Uint8Array): boolean => {
158
+ stderr +=
159
+ typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk);
160
+ return true;
161
+ }) as typeof process.stderr.write;
162
+
163
+ return {
164
+ getStderr: () => stderr,
165
+ getStdout: () => stdout,
166
+ restore: () => {
167
+ process.stdout.write = origStdoutWrite;
168
+ process.stderr.write = origStderrWrite;
169
+ },
170
+ writeStdout: (text: string) => {
171
+ stdout += text;
172
+ },
173
+ };
174
+ };
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Output formatting
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /** Try to parse a string as JSON, returning undefined on failure. */
181
+ const tryParseJson = (text: string): unknown => {
182
+ try {
183
+ return JSON.parse(text);
184
+ } catch {
185
+ return undefined;
186
+ }
187
+ };
188
+
189
+ /** Format result value into stdout and build the CLI result. */
190
+ const formatSuccessResult = (
191
+ value: unknown,
192
+ flags: Record<string, unknown>,
193
+ streams: CapturedStreams
194
+ ): CliHarnessResult => {
195
+ const outputMode =
196
+ flags['output'] ?? (flags['json'] === true ? 'json' : 'text');
197
+
198
+ if (outputMode === 'json') {
199
+ const jsonStr = `${JSON.stringify(value, null, 2)}\n`;
200
+ streams.writeStdout(jsonStr);
201
+ return {
202
+ exitCode: 0,
203
+ json: tryParseJson(jsonStr),
204
+ stderr: streams.getStderr(),
205
+ stdout: streams.getStdout(),
206
+ };
207
+ }
208
+
209
+ const formatted =
210
+ typeof value === 'string'
211
+ ? `${value}\n`
212
+ : `${JSON.stringify(value, null, 2)}\n`;
213
+ streams.writeStdout(formatted);
214
+
215
+ return {
216
+ exitCode: 0,
217
+ json: tryParseJson(streams.getStdout().trim()),
218
+ stderr: streams.getStderr(),
219
+ stdout: streams.getStdout(),
220
+ };
221
+ };
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Execute command
225
+ // ---------------------------------------------------------------------------
226
+
227
+ /** Build an error result from a caught exception. */
228
+ const buildErrorResult = (
229
+ error: unknown,
230
+ streams: CapturedStreams
231
+ ): CliHarnessResult => {
232
+ streams.restore();
233
+ const message = error instanceof Error ? error.message : String(error);
234
+ return {
235
+ exitCode: 1,
236
+ stderr: streams.getStderr() || message,
237
+ stdout: streams.getStdout(),
238
+ };
239
+ };
240
+
241
+ /** Execute a resolved command and return the result. */
242
+ const executeCommand = async (
243
+ command: CliCommand,
244
+ flags: Record<string, unknown>,
245
+ streams: CapturedStreams
246
+ ): Promise<CliHarnessResult> => {
247
+ const ctx = createTestContext();
248
+ const result = await command.execute({}, flags, ctx);
249
+ streams.restore();
250
+
251
+ if (result.isErr()) {
252
+ return {
253
+ exitCode: 1,
254
+ stderr: streams.getStderr() || result.error.message,
255
+ stdout: streams.getStdout(),
256
+ };
257
+ }
258
+
259
+ return formatSuccessResult(result.value, flags, streams);
260
+ };
261
+
262
+ /** Run the full command pipeline: resolve, parse, execute. */
263
+ const runCommand = async (
264
+ commands: CliCommand[],
265
+ commandString: string
266
+ ): Promise<CliHarnessResult> => {
267
+ const parts = parseCommandString(commandString);
268
+ const resolved = resolveCommand(commands, parts);
269
+ if (resolved === undefined) {
270
+ return {
271
+ exitCode: 1,
272
+ stderr: `Unknown command: ${commandString}`,
273
+ stdout: '',
274
+ };
275
+ }
276
+
277
+ const { command, flagTokens } = resolved;
278
+ const flags = parseFlagTokens(flagTokens);
279
+ const streams = captureStreams();
280
+
281
+ try {
282
+ return await executeCommand(command, flags, streams);
283
+ } catch (error: unknown) {
284
+ return buildErrorResult(error, streams);
285
+ }
286
+ };
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // createCliHarness
290
+ // ---------------------------------------------------------------------------
291
+
292
+ /**
293
+ * Create a CLI harness for integration testing.
294
+ *
295
+ * Builds commands from the app's topo and provides a `run()` method
296
+ * that parses command strings and executes them in-process.
297
+ *
298
+ * ```ts
299
+ * const harness = createCliHarness({ app });
300
+ * const result = await harness.run("entity show --name Alpha --output json");
301
+ * expect(result.exitCode).toBe(0);
302
+ * ```
303
+ */
304
+ export const createCliHarness = (options: CliHarnessOptions): CliHarness => {
305
+ const commands = buildCliCommands(options.app);
306
+
307
+ return {
308
+ run: (commandString: string) => runCommand(commands, commandString),
309
+ };
310
+ };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * MCP integration test harness.
3
+ *
4
+ * Builds MCP tools from an App, invokes them directly (no transport),
5
+ * and returns the MCP tool response.
6
+ */
7
+
8
+ import { buildMcpTools } from '@ontrails/mcp';
9
+ import type { McpToolDefinition } from '@ontrails/mcp';
10
+
11
+ import type {
12
+ McpHarness,
13
+ McpHarnessOptions,
14
+ McpHarnessResult,
15
+ } from './types.js';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // createMcpHarness
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /**
22
+ * Create an MCP harness for integration testing.
23
+ *
24
+ * Builds MCP tools from the app's topo and provides a `callTool()` method
25
+ * that invokes tools directly without any transport layer.
26
+ *
27
+ * ```ts
28
+ * const harness = createMcpHarness({ app });
29
+ * const result = await harness.callTool("myapp_entity_show", { name: "Alpha" });
30
+ * expect(result.isError).toBe(false);
31
+ * ```
32
+ */
33
+ export const createMcpHarness = (options: McpHarnessOptions): McpHarness => {
34
+ const tools = buildMcpTools(options.app);
35
+ const toolMap = new Map<string, McpToolDefinition>();
36
+ for (const tool of tools) {
37
+ toolMap.set(tool.name, tool);
38
+ }
39
+
40
+ return {
41
+ async callTool(
42
+ name: string,
43
+ args: Record<string, unknown>
44
+ ): Promise<McpHarnessResult> {
45
+ const tool = toolMap.get(name);
46
+ if (tool === undefined) {
47
+ return {
48
+ content: [{ text: `Unknown tool: ${name}`, type: 'text' }],
49
+ isError: true,
50
+ };
51
+ }
52
+
53
+ const result = await tool.handler(args, {
54
+ progressToken: undefined,
55
+ sendProgress: undefined,
56
+ signal: undefined,
57
+ });
58
+
59
+ return {
60
+ content: result.content,
61
+ isError: result.isError ?? false,
62
+ };
63
+ },
64
+ };
65
+ };
package/src/hike.ts ADDED
@@ -0,0 +1,283 @@
1
+ /**
2
+ * testHike — composition-aware scenario testing for hikes.
3
+ *
4
+ * Tests the composition graph: which trails were followed, in what order,
5
+ * and supports failure injection from followed trail examples.
6
+ */
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+
10
+ import type { AnyHike, AnyTrail, FollowFn, TrailContext } from '@ontrails/core';
11
+ import {
12
+ InternalError,
13
+ Result,
14
+ ValidationError,
15
+ validateInput,
16
+ } from '@ontrails/core';
17
+
18
+ import {
19
+ assertErrorMatch,
20
+ assertFullMatch,
21
+ assertSchemaMatch,
22
+ expectOk,
23
+ } from './assertions.js';
24
+ import { mergeTestContext } from './context.js';
25
+ import type { HikeScenario } from './types.js';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Follow trace
29
+ // ---------------------------------------------------------------------------
30
+
31
+ interface FollowRecord {
32
+ readonly id: string;
33
+ readonly input: unknown;
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Injection helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * Find an error example on a trail by name or description substring.
42
+ */
43
+ const findErrorExample = (
44
+ trailDef: AnyTrail,
45
+ description: string
46
+ ): string | undefined => {
47
+ const example = trailDef.examples?.find(
48
+ (ex) =>
49
+ ex.error !== undefined &&
50
+ (ex.description?.includes(description) || ex.name.includes(description))
51
+ );
52
+ return example?.error;
53
+ };
54
+
55
+ /**
56
+ * Try to inject an error from a followed trail's example.
57
+ * Returns undefined when no injection is configured for this trail ID.
58
+ */
59
+ const tryInjectError = (
60
+ id: string,
61
+ scenario: HikeScenario,
62
+ trailsMap: ReadonlyMap<string, AnyTrail> | undefined
63
+ ): Result<unknown, Error> | undefined => {
64
+ const injection = scenario.injectFromExample?.[id];
65
+ if (injection === undefined) {
66
+ return undefined;
67
+ }
68
+
69
+ const trailDef = trailsMap?.get(id);
70
+ if (trailDef === undefined) {
71
+ return Result.err(
72
+ new InternalError(`Cannot inject: trail "${id}" not in topo`)
73
+ );
74
+ }
75
+ const errorName = findErrorExample(trailDef, injection);
76
+ if (errorName === undefined) {
77
+ return Result.err(
78
+ new InternalError(
79
+ `No error example matching "${injection}" on trail "${id}"`
80
+ )
81
+ );
82
+ }
83
+ return Result.err(new Error(errorName));
84
+ };
85
+
86
+ /**
87
+ * Execute a trail from the map, validating input first.
88
+ */
89
+ const executeFromMap = (
90
+ id: string,
91
+ input: unknown,
92
+ trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
93
+ ctx: TrailContext
94
+ ): Result<unknown, Error> | Promise<Result<unknown, Error>> | undefined => {
95
+ const trailDef = trailsMap?.get(id);
96
+ if (trailDef === undefined) {
97
+ return undefined;
98
+ }
99
+
100
+ const validated = validateInput(trailDef.input, input);
101
+ if (validated.isErr()) {
102
+ return validated;
103
+ }
104
+ return trailDef.implementation(validated.value, ctx);
105
+ };
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Follow factory
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /**
112
+ * Build a recording follow function that optionally injects errors.
113
+ */
114
+ const createRecordingFollow = (
115
+ trace: FollowRecord[],
116
+ scenario: HikeScenario,
117
+ trailsMap: ReadonlyMap<string, AnyTrail> | undefined,
118
+ baseFollow: FollowFn | undefined,
119
+ ctx: TrailContext
120
+ ): FollowFn => {
121
+ // The generic O on FollowFn is erased at runtime; the cast is safe
122
+ // because callers narrow via isOk/isErr before accessing the value.
123
+ const follow = (id: string, input: unknown) => {
124
+ trace.push({ id, input });
125
+
126
+ const injected = tryInjectError(id, scenario, trailsMap);
127
+ if (injected !== undefined) {
128
+ return Promise.resolve(injected);
129
+ }
130
+
131
+ if (baseFollow !== undefined) {
132
+ return baseFollow(id, input);
133
+ }
134
+
135
+ const executed = executeFromMap(id, input, trailsMap, ctx);
136
+ if (executed !== undefined) {
137
+ return Promise.resolve(executed);
138
+ }
139
+
140
+ return Promise.resolve(Result.ok());
141
+ };
142
+ return follow as FollowFn;
143
+ };
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Scenario assertions
147
+ // ---------------------------------------------------------------------------
148
+
149
+ const assertScenarioResult = (
150
+ result: Result<unknown, Error>,
151
+ scenario: HikeScenario,
152
+ hikeDef: AnyHike
153
+ ): void => {
154
+ if (scenario.expectValue !== undefined) {
155
+ assertFullMatch(result, scenario.expectValue);
156
+ } else if (scenario.expectErr !== undefined) {
157
+ assertErrorMatch(result, scenario.expectErr, scenario.expectErrMessage);
158
+ } else if (scenario.expectErrMessage !== undefined) {
159
+ expect(result.isErr()).toBe(true);
160
+ if (result.isErr()) {
161
+ expect(result.error.message).toContain(scenario.expectErrMessage);
162
+ }
163
+ } else if (scenario.expectOk === true) {
164
+ expect(result.isOk()).toBe(true);
165
+ assertSchemaMatch(result, hikeDef.output);
166
+ }
167
+ };
168
+
169
+ const assertFollowTrace = (
170
+ trace: readonly FollowRecord[],
171
+ scenario: HikeScenario
172
+ ): void => {
173
+ if (scenario.expectFollowed !== undefined) {
174
+ const followedIds = trace.map((r) => r.id);
175
+ expect(followedIds).toEqual([...scenario.expectFollowed]);
176
+ }
177
+ if (scenario.expectFollowedCount !== undefined) {
178
+ const counts: Record<string, number> = {};
179
+ for (const record of trace) {
180
+ counts[record.id] = (counts[record.id] ?? 0) + 1;
181
+ }
182
+ expect(counts).toEqual({ ...scenario.expectFollowedCount });
183
+ }
184
+ };
185
+
186
+ const handleValidationError = (
187
+ validated: Result<unknown, Error>,
188
+ scenario: HikeScenario
189
+ ): boolean => {
190
+ if (!validated.isErr()) {
191
+ return false;
192
+ }
193
+ if (scenario.expectErr === ValidationError) {
194
+ expect(validated.error).toBeInstanceOf(ValidationError);
195
+ if (scenario.expectErrMessage !== undefined) {
196
+ expect(validated.error.message).toContain(scenario.expectErrMessage);
197
+ }
198
+ return true;
199
+ }
200
+ throw new Error(
201
+ `Input validation failed unexpectedly: ${validated.error.message}`
202
+ );
203
+ };
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Scenario runner
207
+ // ---------------------------------------------------------------------------
208
+
209
+ const buildTestContext = (
210
+ scenario: HikeScenario,
211
+ ctx: Partial<TrailContext> | undefined,
212
+ trailsMap: ReadonlyMap<string, AnyTrail> | undefined
213
+ ): { trace: FollowRecord[]; testCtx: TrailContext } => {
214
+ const trace: FollowRecord[] = [];
215
+ const baseCtx = mergeTestContext(ctx);
216
+ const follow = createRecordingFollow(
217
+ trace,
218
+ scenario,
219
+ trailsMap,
220
+ baseCtx.follow,
221
+ baseCtx
222
+ );
223
+ return { testCtx: { ...baseCtx, follow }, trace };
224
+ };
225
+
226
+ const runScenario = async (
227
+ hikeDef: AnyHike,
228
+ scenario: HikeScenario,
229
+ ctx: Partial<TrailContext> | undefined,
230
+ trailsMap: ReadonlyMap<string, AnyTrail> | undefined
231
+ ): Promise<void> => {
232
+ const validated = validateInput(hikeDef.input, scenario.input);
233
+ if (handleValidationError(validated, scenario)) {
234
+ return;
235
+ }
236
+
237
+ const { trace, testCtx } = buildTestContext(scenario, ctx, trailsMap);
238
+ const result = await hikeDef.implementation(expectOk(validated), testCtx);
239
+ assertFollowTrace(trace, scenario);
240
+ assertScenarioResult(result, scenario, hikeDef);
241
+ };
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // testHike
245
+ // ---------------------------------------------------------------------------
246
+
247
+ /** Options for testHike that provide trail definitions for injection. */
248
+ export interface TestHikeOptions {
249
+ /** Partial context overrides. */
250
+ readonly ctx?: Partial<TrailContext> | undefined;
251
+ /** Map of trail ID to trail definition, used for injectFromExample. */
252
+ readonly trails?: ReadonlyMap<string, AnyTrail> | undefined;
253
+ }
254
+
255
+ /**
256
+ * Generate a describe block for a hike with one test per scenario.
257
+ *
258
+ * @example
259
+ * ```ts
260
+ * testHike(onboardHike, [
261
+ * {
262
+ * description: "follows add then relate",
263
+ * input: { name: "Alpha" },
264
+ * expectOk: true,
265
+ * expectFollowed: ["entity.add", "entity.relate"],
266
+ * },
267
+ * ]);
268
+ * ```
269
+ */
270
+ export const testHike = (
271
+ hikeDef: AnyHike,
272
+ scenarios: readonly HikeScenario[],
273
+ options?: TestHikeOptions
274
+ ): void => {
275
+ describe(hikeDef.id, () => {
276
+ test.each([...scenarios])(
277
+ '$description',
278
+ async (scenario: HikeScenario) => {
279
+ await runScenario(hikeDef, scenario, options?.ctx, options?.trails);
280
+ }
281
+ );
282
+ });
283
+ };
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ // Contract-driven testing
2
+ export { testAll } from './all.js';
3
+ export { testExamples } from './examples.js';
4
+ export { testHike } from './hike.js';
5
+ export { testTrail } from './trail.js';
6
+ export { testContracts } from './contracts.js';
7
+ export { testDetours } from './detours.js';
8
+
9
+ // Assertions
10
+ export {
11
+ assertErrorMatch,
12
+ assertFullMatch,
13
+ assertSchemaMatch,
14
+ expectErr,
15
+ expectOk,
16
+ } from './assertions.js';
17
+
18
+ // Mock factories
19
+ export { createTestContext } from './context.js';
20
+ export { createTestLogger } from './logger.js';
21
+
22
+ // Surface harnesses
23
+ export { createCliHarness } from './harness-cli.js';
24
+ export { createMcpHarness } from './harness-mcp.js';
25
+
26
+ // Types
27
+ export type { TestHikeOptions } from './hike.js';
28
+
29
+ export type {
30
+ HikeScenario,
31
+ TestScenario,
32
+ TestLogger,
33
+ TestTrailContextOptions,
34
+ CliHarness,
35
+ CliHarnessOptions,
36
+ CliHarnessResult,
37
+ McpHarness,
38
+ McpHarnessOptions,
39
+ McpHarnessResult,
40
+ } from './types.js';