@outfitter/testing 0.1.0-rc.1

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/README.md ADDED
@@ -0,0 +1,281 @@
1
+ # @outfitter/testing
2
+
3
+ Test harnesses, fixtures, and utilities for Outfitter packages.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add -d @outfitter/testing
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import {
15
+ createFixture,
16
+ createCliHarness,
17
+ createMcpHarness,
18
+ withTempDir,
19
+ withEnv,
20
+ } from "@outfitter/testing";
21
+
22
+ // Create reusable test fixtures
23
+ const createUser = createFixture({
24
+ id: 1,
25
+ name: "Test User",
26
+ email: "test@example.com",
27
+ });
28
+
29
+ // Test CLI commands
30
+ const cli = createCliHarness("./bin/my-cli");
31
+ const result = await cli.run(["--help"]);
32
+ expect(result.exitCode).toBe(0);
33
+
34
+ // Test MCP tools
35
+ const harness = createMcpHarness(myMcpServer);
36
+ const tools = await harness.listTools();
37
+ const output = await harness.invoke("my-tool", { input: "value" });
38
+ ```
39
+
40
+ ## Fixtures
41
+
42
+ Factory functions for creating test data with sensible defaults.
43
+
44
+ ### createFixture
45
+
46
+ Creates a fixture factory that returns new objects with optional overrides.
47
+
48
+ ```typescript
49
+ import { createFixture } from "@outfitter/testing";
50
+
51
+ // Define defaults
52
+ const createUser = createFixture({
53
+ id: 1,
54
+ name: "John Doe",
55
+ email: "john@example.com",
56
+ settings: { theme: "dark", notifications: true },
57
+ });
58
+
59
+ // Use defaults
60
+ const user1 = createUser();
61
+
62
+ // Override specific fields (supports deep merge)
63
+ const user2 = createUser({
64
+ name: "Jane Doe",
65
+ settings: { theme: "light" },
66
+ });
67
+ // Result: { id: 1, name: "Jane Doe", email: "john@example.com", settings: { theme: "light", notifications: true } }
68
+ ```
69
+
70
+ Each call returns a fresh copy, preventing test pollution from shared mutable state.
71
+
72
+ ### withTempDir
73
+
74
+ Runs a function with an isolated temporary directory that is automatically cleaned up.
75
+
76
+ ```typescript
77
+ import { withTempDir } from "@outfitter/testing";
78
+ import { join } from "node:path";
79
+
80
+ const result = await withTempDir(async (dir) => {
81
+ // Write test files
82
+ await Bun.write(join(dir, "config.json"), JSON.stringify({ key: "value" }));
83
+
84
+ // Run code that operates on the directory
85
+ return await processDirectory(dir);
86
+ });
87
+ // Directory is automatically removed after the callback
88
+ ```
89
+
90
+ Cleanup occurs even if the callback throws an error.
91
+
92
+ ### withEnv
93
+
94
+ Runs a function with temporary environment variables, restoring originals after.
95
+
96
+ ```typescript
97
+ import { withEnv } from "@outfitter/testing";
98
+
99
+ await withEnv({ API_KEY: "test-key", DEBUG: "true" }, async () => {
100
+ // process.env.API_KEY is "test-key"
101
+ // process.env.DEBUG is "true"
102
+ await runTests();
103
+ });
104
+ // Original environment is restored
105
+ ```
106
+
107
+ ## CLI Test Harness
108
+
109
+ Execute CLI commands and capture their output for assertions.
110
+
111
+ ### createCliHarness
112
+
113
+ Creates a harness for testing command-line tools.
114
+
115
+ ```typescript
116
+ import { createCliHarness } from "@outfitter/testing";
117
+
118
+ const harness = createCliHarness("./bin/my-cli");
119
+
120
+ // Test help output
121
+ const helpResult = await harness.run(["--help"]);
122
+ expect(helpResult.stdout).toContain("Usage:");
123
+ expect(helpResult.exitCode).toBe(0);
124
+
125
+ // Test error handling
126
+ const errorResult = await harness.run(["--invalid-flag"]);
127
+ expect(errorResult.stderr).toContain("Unknown option");
128
+ expect(errorResult.exitCode).toBe(1);
129
+
130
+ // Test with arguments
131
+ const result = await harness.run(["process", "--input", "data.json"]);
132
+ expect(result.stdout).toContain("Processed successfully");
133
+ ```
134
+
135
+ ### CliResult Interface
136
+
137
+ ```typescript
138
+ interface CliResult {
139
+ /** Standard output from the command */
140
+ stdout: string;
141
+ /** Standard error output from the command */
142
+ stderr: string;
143
+ /** Exit code (0 typically indicates success) */
144
+ exitCode: number;
145
+ }
146
+ ```
147
+
148
+ ## MCP Test Harness
149
+
150
+ Test MCP (Model Context Protocol) server tool invocations.
151
+
152
+ ### createMcpHarness
153
+
154
+ Creates a test harness from an MCP server for invoking and inspecting tools.
155
+
156
+ ```typescript
157
+ import { createMcpHarness, type McpServer } from "@outfitter/testing";
158
+
159
+ // Create or mock an MCP server
160
+ const server: McpServer = {
161
+ tools: [
162
+ {
163
+ name: "add",
164
+ description: "Add two numbers",
165
+ handler: ({ a, b }) => a + b,
166
+ },
167
+ {
168
+ name: "greet",
169
+ description: "Generate a greeting",
170
+ handler: ({ name }) => `Hello, ${name}!`,
171
+ },
172
+ ],
173
+ async invoke(toolName, input) {
174
+ const tool = this.tools.find(t => t.name === toolName);
175
+ if (!tool) throw new Error(`Tool not found: ${toolName}`);
176
+ return tool.handler(input);
177
+ },
178
+ };
179
+
180
+ // Create harness
181
+ const harness = createMcpHarness(server);
182
+
183
+ // List available tools
184
+ const tools = await harness.listTools();
185
+ expect(tools).toEqual(["add", "greet"]);
186
+
187
+ // Invoke tools with type inference
188
+ const sum = await harness.invoke<number>("add", { a: 2, b: 3 });
189
+ expect(sum).toBe(5);
190
+
191
+ const greeting = await harness.invoke<string>("greet", { name: "World" });
192
+ expect(greeting).toBe("Hello, World!");
193
+ ```
194
+
195
+ ### McpHarness Interface
196
+
197
+ ```typescript
198
+ interface McpHarness {
199
+ /** Invoke a tool by name with input parameters */
200
+ invoke<T>(toolName: string, input: unknown): Promise<T>;
201
+ /** List all available tool names */
202
+ listTools(): Promise<string[]>;
203
+ }
204
+ ```
205
+
206
+ ### McpServer Interface
207
+
208
+ Minimal interface for MCP servers (can be real or mocked).
209
+
210
+ ```typescript
211
+ interface McpServer {
212
+ /** Array of tools registered on the server */
213
+ tools: McpTool[];
214
+ /** Invoke a tool by name with input */
215
+ invoke(toolName: string, input: unknown): Promise<unknown>;
216
+ }
217
+
218
+ interface McpTool {
219
+ /** Unique name of the tool */
220
+ name: string;
221
+ /** Human-readable description */
222
+ description: string;
223
+ /** Handler function for the tool */
224
+ handler: McpToolHandler;
225
+ }
226
+
227
+ type McpToolHandler = (input: unknown) => unknown | Promise<unknown>;
228
+ ```
229
+
230
+ ## Subpath Exports
231
+
232
+ Import specific modules directly for smaller bundles:
233
+
234
+ ```typescript
235
+ // Just fixtures
236
+ import { createFixture, withTempDir, withEnv } from "@outfitter/testing/fixtures";
237
+
238
+ // Just CLI harness
239
+ import { createCliHarness } from "@outfitter/testing/cli-harness";
240
+
241
+ // Just MCP harness
242
+ import { createMcpHarness } from "@outfitter/testing/mcp-harness";
243
+ ```
244
+
245
+ ## API Reference
246
+
247
+ ### Fixtures
248
+
249
+ | Export | Description |
250
+ |--------|-------------|
251
+ | `createFixture<T>(defaults)` | Create a fixture factory with deep merge support |
252
+ | `withTempDir<T>(fn)` | Run callback with auto-cleaned temp directory |
253
+ | `withEnv<T>(vars, fn)` | Run callback with temporary environment variables |
254
+
255
+ ### CLI Harness
256
+
257
+ | Export | Description |
258
+ |--------|-------------|
259
+ | `createCliHarness(command)` | Create a CLI test harness |
260
+ | `CliHarness` | Interface for CLI harness |
261
+ | `CliResult` | Interface for command execution result |
262
+
263
+ ### MCP Harness
264
+
265
+ | Export | Description |
266
+ |--------|-------------|
267
+ | `createMcpHarness(server)` | Create an MCP test harness |
268
+ | `McpHarness` | Interface for MCP harness |
269
+ | `McpServer` | Interface for MCP server |
270
+ | `McpTool` | Interface for tool definition |
271
+ | `McpToolHandler` | Type for tool handler functions |
272
+
273
+ ## Related Packages
274
+
275
+ - `@outfitter/cli` - CLI framework for building command-line tools
276
+ - `@outfitter/mcp` - MCP server framework with typed tools
277
+ - `@outfitter/contracts` - Result types and error patterns
278
+
279
+ ## License
280
+
281
+ MIT
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Deep partial type that makes all nested properties optional.
3
+ * Used for fixture overrides.
4
+ */
5
+ type DeepPartial<T> = T extends object ? { [P in keyof T]? : DeepPartial<T[P]> } : T;
6
+ /**
7
+ * Creates a fixture factory for generating test data with defaults.
8
+ *
9
+ * The factory returns a new object each time it's called, preventing
10
+ * test pollution from shared mutable state. Supports deep merging
11
+ * for nested objects.
12
+ *
13
+ * @typeParam T - The shape of the fixture object
14
+ * @param defaults - Default values for the fixture
15
+ * @returns A factory function that creates fixtures with optional overrides
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * const createUser = createFixture({
20
+ * id: 1,
21
+ * name: "John Doe",
22
+ * email: "john@example.com",
23
+ * settings: { theme: "dark", notifications: true }
24
+ * });
25
+ *
26
+ * // Use defaults
27
+ * const user1 = createUser();
28
+ *
29
+ * // Override specific fields
30
+ * const user2 = createUser({ name: "Jane Doe", settings: { theme: "light" } });
31
+ * ```
32
+ */
33
+ declare function createFixture<T extends object>(defaults: T): (overrides?: DeepPartial<T>) => T;
34
+ /**
35
+ * Runs a function with a temporary directory, cleaning up after.
36
+ *
37
+ * Creates a unique temporary directory before invoking the callback,
38
+ * and removes it (including all contents) after the callback completes.
39
+ * Cleanup occurs even if the callback throws an error.
40
+ *
41
+ * @typeParam T - Return type of the callback
42
+ * @param fn - Async function that receives the temp directory path
43
+ * @returns The value returned by the callback
44
+ *
45
+ * @example
46
+ * ```typescript
47
+ * const result = await withTempDir(async (dir) => {
48
+ * await Bun.write(join(dir, "test.txt"), "content");
49
+ * return await processFiles(dir);
50
+ * });
51
+ * // Directory is automatically cleaned up
52
+ * ```
53
+ */
54
+ declare function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T>;
55
+ /**
56
+ * Runs a function with temporary environment variables, restoring after.
57
+ *
58
+ * Sets the specified environment variables before invoking the callback,
59
+ * then restores the original values after the callback completes.
60
+ * Restoration occurs even if the callback throws an error.
61
+ *
62
+ * @typeParam T - Return type of the callback
63
+ * @param vars - Environment variables to set
64
+ * @param fn - Async function to run with the modified environment
65
+ * @returns The value returned by the callback
66
+ *
67
+ * @example
68
+ * ```typescript
69
+ * await withEnv({ API_KEY: "test-key", DEBUG: "true" }, async () => {
70
+ * // process.env.API_KEY is "test-key"
71
+ * // process.env.DEBUG is "true"
72
+ * await runTests();
73
+ * });
74
+ * // Original environment is restored
75
+ * ```
76
+ */
77
+ declare function withEnv<T>(vars: Record<string, string>, fn: () => Promise<T>): Promise<T>;
78
+ interface LoadFixtureOptions {
79
+ /**
80
+ * Base fixtures directory.
81
+ * Defaults to `${process.cwd()}/__fixtures__`.
82
+ */
83
+ readonly fixturesDir?: string;
84
+ }
85
+ /**
86
+ * Load a fixture from the fixtures directory.
87
+ *
88
+ * JSON fixtures are parsed automatically; all other file types are returned as strings.
89
+ *
90
+ * @example
91
+ * ```typescript
92
+ * const note = loadFixture<{ id: string }>("mcp/notes.json");
93
+ * const config = loadFixture("mcp/config.toml");
94
+ * ```
95
+ */
96
+ declare function loadFixture2<T = string>(name: string, options?: LoadFixtureOptions): T;
97
+ /**
98
+ * Result of a CLI command execution.
99
+ *
100
+ * Contains the captured output streams and exit code from the command.
101
+ */
102
+ interface CliResult {
103
+ /** Standard output from the command */
104
+ stdout: string;
105
+ /** Standard error output from the command */
106
+ stderr: string;
107
+ /** Exit code from the command (0 typically indicates success) */
108
+ exitCode: number;
109
+ }
110
+ /**
111
+ * Harness for executing CLI commands in tests.
112
+ *
113
+ * Provides a simple interface to run a pre-configured command
114
+ * with various arguments and capture the results.
115
+ */
116
+ interface CliHarness {
117
+ /**
118
+ * Runs the command with the given arguments.
119
+ *
120
+ * @param args - Command-line arguments to pass to the command
121
+ * @returns Promise resolving to the execution result
122
+ */
123
+ run(args: string[]): Promise<CliResult>;
124
+ }
125
+ /**
126
+ * Creates a CLI harness for testing command-line tools.
127
+ *
128
+ * The harness wraps a command and provides a simple interface for
129
+ * executing it with different arguments, capturing stdout, stderr,
130
+ * and exit code for assertions.
131
+ *
132
+ * @param command - The command to execute (e.g., "echo", "node", "./bin/cli")
133
+ * @returns A CliHarness instance for running the command
134
+ *
135
+ * @example
136
+ * ```typescript
137
+ * const harness = createCliHarness("./bin/my-cli");
138
+ *
139
+ * // Test help output
140
+ * const helpResult = await harness.run(["--help"]);
141
+ * expect(helpResult.stdout).toContain("Usage:");
142
+ * expect(helpResult.exitCode).toBe(0);
143
+ *
144
+ * // Test error case
145
+ * const errorResult = await harness.run(["--invalid-flag"]);
146
+ * expect(errorResult.stderr).toContain("Unknown option");
147
+ * expect(errorResult.exitCode).toBe(1);
148
+ * ```
149
+ */
150
+ declare function createCliHarness(command: string): CliHarness;
151
+ /**
152
+ * @outfitter/testing - CLI Helpers
153
+ *
154
+ * Utilities for capturing CLI output and mocking stdin in tests.
155
+ *
156
+ * @packageDocumentation
157
+ */
158
+ interface CliTestResult {
159
+ /** Captured stdout */
160
+ stdout: string;
161
+ /** Captured stderr */
162
+ stderr: string;
163
+ /** Process exit code */
164
+ exitCode: number;
165
+ }
166
+ /**
167
+ * Capture stdout/stderr and exit code from an async CLI function.
168
+ */
169
+ declare function captureCLI(fn: () => Promise<void> | void): Promise<CliTestResult>;
170
+ /**
171
+ * Mock stdin with provided input.
172
+ *
173
+ * Returns a restore function for convenience.
174
+ */
175
+ declare function mockStdin(input: string): {
176
+ restore: () => void;
177
+ };
178
+ import { OutfitterError, Result } from "@outfitter/contracts";
179
+ import { McpError, McpServer, SerializedTool, ToolDefinition } from "@outfitter/mcp";
180
+ /**
181
+ * MCP tool response content.
182
+ * Matches the MCP protocol shape used in the spec.
183
+ */
184
+ interface McpToolResponse {
185
+ content: Array<{
186
+ type: "text" | "image";
187
+ text?: string;
188
+ data?: string;
189
+ }>;
190
+ isError?: boolean;
191
+ }
192
+ /**
193
+ * Test harness for MCP servers.
194
+ */
195
+ interface McpHarness {
196
+ /**
197
+ * Call a tool by name with input parameters.
198
+ * Returns the MCP-formatted response.
199
+ */
200
+ callTool(name: string, input: Record<string, unknown>): Promise<Result<McpToolResponse, InstanceType<typeof McpError>>>;
201
+ /**
202
+ * List all registered tools with schemas.
203
+ */
204
+ listTools(): SerializedTool[];
205
+ /**
206
+ * Search tools by name or description (case-insensitive).
207
+ */
208
+ searchTools(query: string): SerializedTool[];
209
+ /**
210
+ * Load fixture data by name (relative to __fixtures__).
211
+ */
212
+ loadFixture<T = string>(name: string): T;
213
+ /**
214
+ * Reset harness state between tests.
215
+ */
216
+ reset(): void;
217
+ }
218
+ interface McpHarnessOptions {
219
+ /** Base fixtures directory (defaults to `${process.cwd()}/__fixtures__`). */
220
+ readonly fixturesDir?: string;
221
+ }
222
+ interface McpTestHarnessOptions {
223
+ /** Tools to register on the test MCP server. */
224
+ readonly tools: ToolDefinition<unknown, unknown, OutfitterError>[];
225
+ /** Base fixtures directory (defaults to `${process.cwd()}/__fixtures__`). */
226
+ readonly fixturesDir?: string;
227
+ /** Optional server name for diagnostics. */
228
+ readonly name?: string;
229
+ /** Optional server version for diagnostics. */
230
+ readonly version?: string;
231
+ }
232
+ /**
233
+ * Creates an MCP test harness from an MCP server.
234
+ */
235
+ declare function createMcpHarness(server: McpServer, options?: McpHarnessOptions): McpHarness;
236
+ /**
237
+ * Creates an MCP test harness from tool definitions.
238
+ *
239
+ * This is a spec-compatible wrapper that builds a test server,
240
+ * registers tools, and returns the standard MCP harness.
241
+ */
242
+ declare function createMCPTestHarness(options: McpTestHarnessOptions): McpHarness;
243
+ import { HandlerContext, Logger, ResolvedConfig } from "@outfitter/contracts";
244
+ import { z } from "zod";
245
+ interface LogEntry {
246
+ level: "trace" | "debug" | "info" | "warn" | "error" | "fatal";
247
+ message: string;
248
+ data?: Record<string, unknown>;
249
+ }
250
+ interface TestLogger extends Logger {
251
+ /** Captured log entries for assertions */
252
+ logs: LogEntry[];
253
+ /** Clear captured logs */
254
+ clear(): void;
255
+ }
256
+ declare function createTestLogger(context?: Record<string, unknown>): TestLogger;
257
+ declare function createTestConfig<T>(schema: z.ZodType<T>, values: Partial<T>): ResolvedConfig;
258
+ declare function createTestContext(overrides?: Partial<HandlerContext>): HandlerContext;
259
+ export { withTempDir, withEnv, mockStdin, loadFixture2 as loadFixture, createTestLogger, createTestContext, createTestConfig, createMCPTestHarness as createMcpTestHarness, createMcpHarness, createMCPTestHarness, createFixture, createCliHarness, captureCLI, TestLogger, McpToolResponse, McpTestHarnessOptions, McpHarness, LogEntry, CliTestResult, CliResult, CliHarness };
package/dist/index.js ADDED
@@ -0,0 +1,399 @@
1
+ // src/fixtures.ts
2
+ import { readFileSync } from "node:fs";
3
+ import { mkdir, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { extname, join } from "node:path";
6
+ function isPlainObject(value) {
7
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8
+ }
9
+ function deepMerge(target, source) {
10
+ const result = { ...target };
11
+ for (const key of Object.keys(source)) {
12
+ const sourceValue = source[key];
13
+ const targetValue = target[key];
14
+ if (sourceValue === undefined) {
15
+ continue;
16
+ }
17
+ if (isPlainObject(sourceValue) && isPlainObject(targetValue)) {
18
+ result[key] = deepMerge(targetValue, sourceValue);
19
+ } else {
20
+ result[key] = sourceValue;
21
+ }
22
+ }
23
+ return result;
24
+ }
25
+ function deepClone(obj) {
26
+ if (obj === null || typeof obj !== "object") {
27
+ return obj;
28
+ }
29
+ if (Array.isArray(obj)) {
30
+ return obj.map((item) => deepClone(item));
31
+ }
32
+ const cloned = {};
33
+ for (const key of Object.keys(obj)) {
34
+ cloned[key] = deepClone(obj[key]);
35
+ }
36
+ return cloned;
37
+ }
38
+ function createFixture(defaults) {
39
+ return (overrides) => {
40
+ const cloned = deepClone(defaults);
41
+ if (overrides === undefined) {
42
+ return cloned;
43
+ }
44
+ return deepMerge(cloned, overrides);
45
+ };
46
+ }
47
+ function generateTempDirPath() {
48
+ const timestamp = Date.now();
49
+ const random = Math.random().toString(36).slice(2, 10);
50
+ return join(tmpdir(), `outfitter-test-${timestamp}-${random}`);
51
+ }
52
+ async function withTempDir(fn) {
53
+ const dir = generateTempDirPath();
54
+ await mkdir(dir, { recursive: true });
55
+ try {
56
+ return await fn(dir);
57
+ } finally {
58
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
59
+ }
60
+ }
61
+ async function withEnv(vars, fn) {
62
+ const originalValues = new Map;
63
+ for (const key of Object.keys(vars)) {
64
+ originalValues.set(key, process.env[key]);
65
+ }
66
+ for (const [key, value] of Object.entries(vars)) {
67
+ process.env[key] = value;
68
+ }
69
+ try {
70
+ return await fn();
71
+ } finally {
72
+ for (const [key, originalValue] of originalValues) {
73
+ if (originalValue === undefined) {
74
+ delete process.env[key];
75
+ } else {
76
+ process.env[key] = originalValue;
77
+ }
78
+ }
79
+ }
80
+ }
81
+ function loadFixture(name, options) {
82
+ const baseDir = options?.fixturesDir ?? join(process.cwd(), "__fixtures__");
83
+ const filePath = join(baseDir, name);
84
+ const content = readFileSync(filePath, "utf-8");
85
+ if (extname(filePath) === ".json") {
86
+ return JSON.parse(content);
87
+ }
88
+ return content;
89
+ }
90
+ // src/cli-harness.ts
91
+ import { spawn } from "node:child_process";
92
+ import { constants } from "node:os";
93
+ function createCliHarness(command) {
94
+ return {
95
+ run(args) {
96
+ return new Promise((resolve, reject) => {
97
+ const child = spawn(command, args, {
98
+ shell: false,
99
+ stdio: ["pipe", "pipe", "pipe"]
100
+ });
101
+ child.stdin.end();
102
+ const stdoutChunks = [];
103
+ const stderrChunks = [];
104
+ child.stdout.on("data", (chunk) => {
105
+ stdoutChunks.push(chunk);
106
+ });
107
+ child.stderr.on("data", (chunk) => {
108
+ stderrChunks.push(chunk);
109
+ });
110
+ child.on("error", (err) => {
111
+ reject(err);
112
+ });
113
+ child.on("close", (exitCode, signal) => {
114
+ const decoder = new TextDecoder("utf-8");
115
+ let finalExitCode;
116
+ if (exitCode !== null) {
117
+ finalExitCode = exitCode;
118
+ } else if (signal !== null) {
119
+ const signalNumber = constants.signals[signal];
120
+ finalExitCode = signalNumber !== undefined ? 128 + signalNumber : 1;
121
+ } else {
122
+ finalExitCode = 1;
123
+ }
124
+ resolve({
125
+ stdout: decoder.decode(concatUint8Arrays(stdoutChunks)),
126
+ stderr: decoder.decode(concatUint8Arrays(stderrChunks)),
127
+ exitCode: finalExitCode
128
+ });
129
+ });
130
+ });
131
+ }
132
+ };
133
+ }
134
+ function concatUint8Arrays(chunks) {
135
+ const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
136
+ const result = new Uint8Array(totalLength);
137
+ let offset = 0;
138
+ for (const chunk of chunks) {
139
+ result.set(chunk, offset);
140
+ offset += chunk.length;
141
+ }
142
+ return result;
143
+ }
144
+ // src/cli-helpers.ts
145
+ class ExitError extends Error {
146
+ code;
147
+ constructor(code) {
148
+ super(`Process exited with code ${code}`);
149
+ this.code = code;
150
+ }
151
+ }
152
+ async function captureCLI(fn) {
153
+ const stdoutChunks = [];
154
+ const stderrChunks = [];
155
+ const originalStdoutWrite = process.stdout.write.bind(process.stdout);
156
+ const originalStderrWrite = process.stderr.write.bind(process.stderr);
157
+ const originalExit = process.exit.bind(process);
158
+ const originalConsoleLog = console.log;
159
+ const originalConsoleError = console.error;
160
+ process.stdout.write = (chunk, _encoding, cb) => {
161
+ stdoutChunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk);
162
+ if (typeof cb === "function")
163
+ cb();
164
+ return true;
165
+ };
166
+ process.stderr.write = (chunk, _encoding, cb) => {
167
+ stderrChunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk);
168
+ if (typeof cb === "function")
169
+ cb();
170
+ return true;
171
+ };
172
+ console.log = (...args) => {
173
+ const line = `${args.map(String).join(" ")}
174
+ `;
175
+ stdoutChunks.push(new TextEncoder().encode(line));
176
+ };
177
+ console.error = (...args) => {
178
+ const line = `${args.map(String).join(" ")}
179
+ `;
180
+ stderrChunks.push(new TextEncoder().encode(line));
181
+ };
182
+ process.exit = (code) => {
183
+ throw new ExitError(code ?? 0);
184
+ };
185
+ let exitCode = 0;
186
+ try {
187
+ await fn();
188
+ } catch (error) {
189
+ if (error instanceof ExitError) {
190
+ exitCode = error.code;
191
+ } else {
192
+ exitCode = 1;
193
+ }
194
+ } finally {
195
+ process.stdout.write = originalStdoutWrite;
196
+ process.stderr.write = originalStderrWrite;
197
+ process.exit = originalExit;
198
+ console.log = originalConsoleLog;
199
+ console.error = originalConsoleError;
200
+ }
201
+ const decoder = new TextDecoder("utf-8");
202
+ return {
203
+ stdout: decoder.decode(concatChunks(stdoutChunks)),
204
+ stderr: decoder.decode(concatChunks(stderrChunks)),
205
+ exitCode
206
+ };
207
+ }
208
+ function mockStdin(input) {
209
+ const originalStdin = process.stdin;
210
+ const encoded = new TextEncoder().encode(input);
211
+ const mockStream = {
212
+ async* [Symbol.asyncIterator]() {
213
+ yield encoded;
214
+ },
215
+ fd: 0,
216
+ isTTY: false
217
+ };
218
+ process.stdin = mockStream;
219
+ return {
220
+ restore: () => {
221
+ process.stdin = originalStdin;
222
+ }
223
+ };
224
+ }
225
+ function concatChunks(chunks) {
226
+ const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
227
+ const result = new Uint8Array(totalLength);
228
+ let offset = 0;
229
+ for (const chunk of chunks) {
230
+ result.set(chunk, offset);
231
+ offset += chunk.length;
232
+ }
233
+ return result;
234
+ }
235
+ // src/mcp-harness.ts
236
+ import {
237
+ createMcpServer
238
+ } from "@outfitter/mcp";
239
+ function createMcpHarness(server, options = {}) {
240
+ return {
241
+ callTool(name, input) {
242
+ return server.invokeTool(name, input);
243
+ },
244
+ listTools() {
245
+ return server.getTools();
246
+ },
247
+ searchTools(query) {
248
+ const normalized = query.trim().toLowerCase();
249
+ const tools = server.getTools();
250
+ if (normalized.length === 0) {
251
+ return tools;
252
+ }
253
+ return tools.filter((tool) => {
254
+ const nameMatch = tool.name.toLowerCase().includes(normalized);
255
+ const descriptionMatch = tool.description.toLowerCase().includes(normalized);
256
+ return nameMatch || descriptionMatch;
257
+ });
258
+ },
259
+ loadFixture(name) {
260
+ return loadFixture(name, options.fixturesDir ? { fixturesDir: options.fixturesDir } : undefined);
261
+ },
262
+ reset() {}
263
+ };
264
+ }
265
+ function createMCPTestHarness(options) {
266
+ const server = createMcpServer({
267
+ name: options.name ?? "mcp-test",
268
+ version: options.version ?? "0.0.0"
269
+ });
270
+ for (const tool of options.tools) {
271
+ server.registerTool(tool);
272
+ }
273
+ return createMcpHarness(server, {
274
+ ...options.fixturesDir !== undefined ? { fixturesDir: options.fixturesDir } : {}
275
+ });
276
+ }
277
+ // src/mock-factories.ts
278
+ import {
279
+ generateRequestId
280
+ } from "@outfitter/contracts";
281
+ function createTestLogger(context = {}) {
282
+ return createTestLoggerWithContext(context, []);
283
+ }
284
+ function createTestLoggerWithContext(context, logs) {
285
+ const write = (level, message, data) => {
286
+ const merged = { ...context, ...data ?? {} };
287
+ const entry = {
288
+ level,
289
+ message
290
+ };
291
+ if (Object.keys(merged).length > 0) {
292
+ entry.data = merged;
293
+ }
294
+ logs.push(entry);
295
+ };
296
+ return {
297
+ logs,
298
+ clear() {
299
+ logs.length = 0;
300
+ },
301
+ trace(message, metadata) {
302
+ write("trace", message, metadata);
303
+ },
304
+ debug(message, metadata) {
305
+ write("debug", message, metadata);
306
+ },
307
+ info(message, metadata) {
308
+ write("info", message, metadata);
309
+ },
310
+ warn(message, metadata) {
311
+ write("warn", message, metadata);
312
+ },
313
+ error(message, metadata) {
314
+ write("error", message, metadata);
315
+ },
316
+ fatal(message, metadata) {
317
+ write("fatal", message, metadata);
318
+ },
319
+ child(childContext) {
320
+ return createTestLoggerWithContext({ ...context, ...childContext }, logs);
321
+ }
322
+ };
323
+ }
324
+ function createTestConfig(schema, values) {
325
+ const parsed = schema.safeParse(values);
326
+ let data;
327
+ if (parsed.success) {
328
+ data = parsed.data;
329
+ } else {
330
+ const maybePartial = schema.partial;
331
+ if (typeof maybePartial !== "function") {
332
+ throw parsed.error;
333
+ }
334
+ const partialSchema = maybePartial.call(schema);
335
+ const partialParsed = partialSchema.safeParse(values);
336
+ if (!partialParsed.success) {
337
+ throw partialParsed.error;
338
+ }
339
+ data = partialParsed.data;
340
+ }
341
+ return {
342
+ get(key) {
343
+ return getPath(data, key);
344
+ },
345
+ getRequired(key) {
346
+ const value = getPath(data, key);
347
+ if (value === undefined) {
348
+ throw new Error(`Missing required config value: ${key}`);
349
+ }
350
+ return value;
351
+ }
352
+ };
353
+ }
354
+ function createTestContext(overrides = {}) {
355
+ const logger = overrides.logger ?? createTestLogger();
356
+ const requestId = overrides.requestId ?? generateRequestId();
357
+ const context = {
358
+ requestId,
359
+ logger,
360
+ cwd: overrides.cwd ?? process.cwd(),
361
+ env: overrides.env ?? { ...process.env }
362
+ };
363
+ if (overrides.config !== undefined) {
364
+ context.config = overrides.config;
365
+ }
366
+ if (overrides.signal !== undefined) {
367
+ context.signal = overrides.signal;
368
+ }
369
+ if (overrides.workspaceRoot !== undefined) {
370
+ context.workspaceRoot = overrides.workspaceRoot;
371
+ }
372
+ return context;
373
+ }
374
+ function getPath(obj, key) {
375
+ const parts = key.split(".").filter((part) => part.length > 0);
376
+ let current = obj;
377
+ for (const part of parts) {
378
+ if (current === null || typeof current !== "object") {
379
+ return;
380
+ }
381
+ current = current[part];
382
+ }
383
+ return current;
384
+ }
385
+ export {
386
+ withTempDir,
387
+ withEnv,
388
+ mockStdin,
389
+ loadFixture,
390
+ createTestLogger,
391
+ createTestContext,
392
+ createTestConfig,
393
+ createMCPTestHarness as createMcpTestHarness,
394
+ createMcpHarness,
395
+ createMCPTestHarness,
396
+ createFixture,
397
+ createCliHarness,
398
+ captureCLI
399
+ };
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@outfitter/testing",
3
+ "description": "Test harnesses, fixtures, and utilities for Outfitter packages",
4
+ "version": "0.1.0-rc.1",
5
+ "type": "module",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "module": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "import": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ }
17
+ },
18
+ "./fixtures": {
19
+ "import": {
20
+ "types": "./dist/fixtures.d.ts",
21
+ "default": "./dist/fixtures.js"
22
+ }
23
+ },
24
+ "./package.json": "./package.json"
25
+ },
26
+ "sideEffects": false,
27
+ "scripts": {
28
+ "build": "bunup --filter @outfitter/testing",
29
+ "lint": "biome lint ./src",
30
+ "lint:fix": "biome lint --write ./src",
31
+ "test": "bun test",
32
+ "typecheck": "tsc --noEmit",
33
+ "clean": "rm -rf dist"
34
+ },
35
+ "dependencies": {
36
+ "@outfitter/contracts": "workspace:*",
37
+ "@outfitter/mcp": "workspace:*",
38
+ "zod": "^4.3.5"
39
+ },
40
+ "devDependencies": {
41
+ "@types/bun": "latest",
42
+ "typescript": "^5.8.0"
43
+ },
44
+ "keywords": [
45
+ "outfitter",
46
+ "testing",
47
+ "fixtures",
48
+ "harness",
49
+ "typescript"
50
+ ],
51
+ "license": "MIT",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "https://github.com/outfitter-dev/outfitter.git",
55
+ "directory": "packages/testing"
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ }
60
+ }