@outfitter/testing 0.2.3 → 0.2.4
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/dist/cli-harness.d.ts +2 -0
- package/dist/cli-harness.js +7 -0
- package/dist/cli-helpers.d.ts +2 -0
- package/dist/cli-helpers.js +9 -0
- package/dist/fixtures.d.ts +2 -0
- package/dist/fixtures.js +13 -0
- package/dist/index.d.ts +17 -17
- package/dist/mcp-harness.d.ts +2 -0
- package/dist/mcp-harness.js +11 -0
- package/dist/mock-factories.d.ts +2 -0
- package/dist/mock-factories.js +11 -0
- package/dist/shared/@outfitter/testing-05hzzr8n.js +48 -0
- package/dist/shared/@outfitter/testing-1wd76sr8.js +126 -0
- package/dist/shared/@outfitter/testing-5gdrv3f5.d.ts +66 -0
- package/dist/shared/@outfitter/testing-98fsks4n.d.ts +97 -0
- package/dist/shared/@outfitter/testing-fyjbwn80.d.ts +28 -0
- package/dist/shared/@outfitter/testing-jdfrrv33.d.ts +17 -0
- package/dist/shared/@outfitter/testing-kdwa417a.js +111 -0
- package/dist/shared/@outfitter/testing-wfp5f7pq.js +29 -0
- package/dist/shared/@outfitter/testing-xaxkt6c9.d.ts +64 -0
- package/dist/shared/@outfitter/testing-xsfjh1n3.js +94 -0
- package/package.json +5 -4
package/dist/fixtures.js
ADDED
package/dist/index.d.ts
CHANGED
|
@@ -109,12 +109,12 @@ declare function loadFixture2<T = string>(name: string, options?: LoadFixtureOpt
|
|
|
109
109
|
* Contains the captured output streams and exit code from the command.
|
|
110
110
|
*/
|
|
111
111
|
interface CliResult {
|
|
112
|
-
/** Standard output from the command */
|
|
113
|
-
stdout: string;
|
|
114
|
-
/** Standard error output from the command */
|
|
115
|
-
stderr: string;
|
|
116
112
|
/** Exit code from the command (0 typically indicates success) */
|
|
117
113
|
exitCode: number;
|
|
114
|
+
/** Standard error output from the command */
|
|
115
|
+
stderr: string;
|
|
116
|
+
/** Standard output from the command */
|
|
117
|
+
stdout: string;
|
|
118
118
|
}
|
|
119
119
|
/**
|
|
120
120
|
* Harness for executing CLI commands in tests.
|
|
@@ -165,12 +165,12 @@ declare function createCliHarness(command: string): CliHarness;
|
|
|
165
165
|
* @packageDocumentation
|
|
166
166
|
*/
|
|
167
167
|
interface CliTestResult {
|
|
168
|
-
/** Captured stdout */
|
|
169
|
-
stdout: string;
|
|
170
|
-
/** Captured stderr */
|
|
171
|
-
stderr: string;
|
|
172
168
|
/** Process exit code */
|
|
173
169
|
exitCode: number;
|
|
170
|
+
/** Captured stderr */
|
|
171
|
+
stderr: string;
|
|
172
|
+
/** Captured stdout */
|
|
173
|
+
stdout: string;
|
|
174
174
|
}
|
|
175
175
|
/**
|
|
176
176
|
* Capture stdout/stderr and exit code from an async CLI function.
|
|
@@ -212,10 +212,6 @@ interface McpHarness {
|
|
|
212
212
|
*/
|
|
213
213
|
listTools(): SerializedTool[];
|
|
214
214
|
/**
|
|
215
|
-
* Search tools by name or description (case-insensitive).
|
|
216
|
-
*/
|
|
217
|
-
searchTools(query: string): SerializedTool[];
|
|
218
|
-
/**
|
|
219
215
|
* Load fixture data by name (relative to __fixtures__).
|
|
220
216
|
*/
|
|
221
217
|
loadFixture<T = string>(name: string): T;
|
|
@@ -223,18 +219,22 @@ interface McpHarness {
|
|
|
223
219
|
* Reset harness state between tests.
|
|
224
220
|
*/
|
|
225
221
|
reset(): void;
|
|
222
|
+
/**
|
|
223
|
+
* Search tools by name or description (case-insensitive).
|
|
224
|
+
*/
|
|
225
|
+
searchTools(query: string): SerializedTool[];
|
|
226
226
|
}
|
|
227
227
|
interface McpHarnessOptions {
|
|
228
228
|
/** Base fixtures directory (defaults to `${process.cwd()}/__fixtures__`). */
|
|
229
229
|
readonly fixturesDir?: string;
|
|
230
230
|
}
|
|
231
231
|
interface McpTestHarnessOptions {
|
|
232
|
-
/** Tools to register on the test MCP server. */
|
|
233
|
-
readonly tools: ToolDefinition<unknown, unknown, OutfitterError>[];
|
|
234
232
|
/** Base fixtures directory (defaults to `${process.cwd()}/__fixtures__`). */
|
|
235
233
|
readonly fixturesDir?: string;
|
|
236
234
|
/** Optional server name for diagnostics. */
|
|
237
235
|
readonly name?: string;
|
|
236
|
+
/** Tools to register on the test MCP server. */
|
|
237
|
+
readonly tools: ToolDefinition<unknown, unknown, OutfitterError>[];
|
|
238
238
|
/** Optional server version for diagnostics. */
|
|
239
239
|
readonly version?: string;
|
|
240
240
|
}
|
|
@@ -252,15 +252,15 @@ declare function createMCPTestHarness(options: McpTestHarnessOptions): McpHarnes
|
|
|
252
252
|
import { HandlerContext, Logger, ResolvedConfig } from "@outfitter/contracts";
|
|
253
253
|
import { z } from "zod";
|
|
254
254
|
interface LogEntry {
|
|
255
|
+
data?: Record<string, unknown>;
|
|
255
256
|
level: "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
|
256
257
|
message: string;
|
|
257
|
-
data?: Record<string, unknown>;
|
|
258
258
|
}
|
|
259
259
|
interface TestLogger extends Logger {
|
|
260
|
-
/** Captured log entries for assertions */
|
|
261
|
-
logs: LogEntry[];
|
|
262
260
|
/** Clear captured logs */
|
|
263
261
|
clear(): void;
|
|
262
|
+
/** Captured log entries for assertions */
|
|
263
|
+
logs: LogEntry[];
|
|
264
264
|
}
|
|
265
265
|
declare function createTestLogger(context?: Record<string, unknown>): TestLogger;
|
|
266
266
|
declare function createTestConfig<T>(schema: z.ZodType<T>, values: Partial<T>): ResolvedConfig;
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { McpHarness, McpHarnessOptions, McpTestHarnessOptions, McpToolResponse, createMCPTestHarness, createMcpHarness } from "./shared/@outfitter/testing-5gdrv3f5.js";
|
|
2
|
+
export { createMCPTestHarness as createMcpTestHarness, createMcpHarness, createMCPTestHarness, McpToolResponse, McpTestHarnessOptions, McpHarnessOptions, McpHarness };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import {
|
|
3
|
+
createMCPTestHarness,
|
|
4
|
+
createMcpHarness
|
|
5
|
+
} from "./shared/@outfitter/testing-05hzzr8n.js";
|
|
6
|
+
import"./shared/@outfitter/testing-1wd76sr8.js";
|
|
7
|
+
export {
|
|
8
|
+
createMCPTestHarness as createMcpTestHarness,
|
|
9
|
+
createMcpHarness,
|
|
10
|
+
createMCPTestHarness
|
|
11
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import {
|
|
3
|
+
loadFixture
|
|
4
|
+
} from "./testing-1wd76sr8.js";
|
|
5
|
+
|
|
6
|
+
// packages/testing/src/mcp-harness.ts
|
|
7
|
+
import {
|
|
8
|
+
createMcpServer
|
|
9
|
+
} from "@outfitter/mcp";
|
|
10
|
+
function createMcpHarness(server, options = {}) {
|
|
11
|
+
return {
|
|
12
|
+
callTool(name, input) {
|
|
13
|
+
return server.invokeTool(name, input);
|
|
14
|
+
},
|
|
15
|
+
listTools() {
|
|
16
|
+
return server.getTools();
|
|
17
|
+
},
|
|
18
|
+
searchTools(query) {
|
|
19
|
+
const normalized = query.trim().toLowerCase();
|
|
20
|
+
const tools = server.getTools();
|
|
21
|
+
if (normalized.length === 0) {
|
|
22
|
+
return tools;
|
|
23
|
+
}
|
|
24
|
+
return tools.filter((tool) => {
|
|
25
|
+
const nameMatch = tool.name.toLowerCase().includes(normalized);
|
|
26
|
+
const descriptionMatch = tool.description.toLowerCase().includes(normalized);
|
|
27
|
+
return nameMatch || descriptionMatch;
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
loadFixture(name) {
|
|
31
|
+
return loadFixture(name, options.fixturesDir ? { fixturesDir: options.fixturesDir } : undefined);
|
|
32
|
+
},
|
|
33
|
+
reset() {}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function createMCPTestHarness(options) {
|
|
37
|
+
const server = createMcpServer({
|
|
38
|
+
name: options.name ?? "mcp-test",
|
|
39
|
+
version: options.version ?? "0.0.0"
|
|
40
|
+
});
|
|
41
|
+
for (const tool of options.tools) {
|
|
42
|
+
server.registerTool(tool);
|
|
43
|
+
}
|
|
44
|
+
return createMcpHarness(server, {
|
|
45
|
+
...options.fixturesDir !== undefined ? { fixturesDir: options.fixturesDir } : {}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
export { createMcpHarness, createMCPTestHarness };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/testing/src/fixtures.ts
|
|
3
|
+
var cachedRequire;
|
|
4
|
+
function getNodeRequire() {
|
|
5
|
+
if (cachedRequire !== undefined) {
|
|
6
|
+
if (cachedRequire === null) {
|
|
7
|
+
throw new Error("Node.js built-ins are unavailable in this runtime.");
|
|
8
|
+
}
|
|
9
|
+
return cachedRequire;
|
|
10
|
+
}
|
|
11
|
+
const metaRequire = import.meta.require;
|
|
12
|
+
if (typeof metaRequire === "function") {
|
|
13
|
+
cachedRequire = metaRequire;
|
|
14
|
+
return metaRequire;
|
|
15
|
+
}
|
|
16
|
+
const globalRequire = globalThis.require;
|
|
17
|
+
if (typeof globalRequire === "function") {
|
|
18
|
+
cachedRequire = globalRequire;
|
|
19
|
+
return globalRequire;
|
|
20
|
+
}
|
|
21
|
+
cachedRequire = null;
|
|
22
|
+
throw new Error("Node.js built-ins are unavailable in this runtime.");
|
|
23
|
+
}
|
|
24
|
+
function getNodeFs() {
|
|
25
|
+
return getNodeRequire()("node:fs");
|
|
26
|
+
}
|
|
27
|
+
function getNodeFsPromises() {
|
|
28
|
+
return getNodeRequire()("node:fs/promises");
|
|
29
|
+
}
|
|
30
|
+
function getNodeOs() {
|
|
31
|
+
return getNodeRequire()("node:os");
|
|
32
|
+
}
|
|
33
|
+
function getNodePath() {
|
|
34
|
+
return getNodeRequire()("node:path");
|
|
35
|
+
}
|
|
36
|
+
function isPlainObject(value) {
|
|
37
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
function deepMerge(target, source) {
|
|
40
|
+
const result = { ...target };
|
|
41
|
+
for (const key of Object.keys(source)) {
|
|
42
|
+
const sourceValue = source[key];
|
|
43
|
+
const targetValue = target[key];
|
|
44
|
+
if (sourceValue === undefined) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (isPlainObject(sourceValue) && isPlainObject(targetValue)) {
|
|
48
|
+
result[key] = deepMerge(targetValue, sourceValue);
|
|
49
|
+
} else {
|
|
50
|
+
result[key] = sourceValue;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
function deepClone(obj) {
|
|
56
|
+
if (obj === null || typeof obj !== "object") {
|
|
57
|
+
return obj;
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(obj)) {
|
|
60
|
+
return obj.map((item) => deepClone(item));
|
|
61
|
+
}
|
|
62
|
+
const cloned = {};
|
|
63
|
+
for (const key of Object.keys(obj)) {
|
|
64
|
+
cloned[key] = deepClone(obj[key]);
|
|
65
|
+
}
|
|
66
|
+
return cloned;
|
|
67
|
+
}
|
|
68
|
+
function createFixture(defaults) {
|
|
69
|
+
return (overrides) => {
|
|
70
|
+
const cloned = deepClone(defaults);
|
|
71
|
+
if (overrides === undefined) {
|
|
72
|
+
return cloned;
|
|
73
|
+
}
|
|
74
|
+
return deepMerge(cloned, overrides);
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function generateTempDirPath() {
|
|
78
|
+
const { tmpdir } = getNodeOs();
|
|
79
|
+
const { join } = getNodePath();
|
|
80
|
+
const timestamp = Date.now();
|
|
81
|
+
const random = Math.random().toString(36).slice(2, 10);
|
|
82
|
+
return join(tmpdir(), `outfitter-test-${timestamp}-${random}`);
|
|
83
|
+
}
|
|
84
|
+
async function withTempDir(fn) {
|
|
85
|
+
const { mkdir, rm } = getNodeFsPromises();
|
|
86
|
+
const dir = generateTempDirPath();
|
|
87
|
+
await mkdir(dir, { recursive: true });
|
|
88
|
+
try {
|
|
89
|
+
return await fn(dir);
|
|
90
|
+
} finally {
|
|
91
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function withEnv(vars, fn) {
|
|
95
|
+
const originalValues = new Map;
|
|
96
|
+
for (const key of Object.keys(vars)) {
|
|
97
|
+
originalValues.set(key, process.env[key]);
|
|
98
|
+
}
|
|
99
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
100
|
+
process.env[key] = value;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
return await fn();
|
|
104
|
+
} finally {
|
|
105
|
+
for (const [key, originalValue] of originalValues) {
|
|
106
|
+
if (originalValue === undefined) {
|
|
107
|
+
delete process.env[key];
|
|
108
|
+
} else {
|
|
109
|
+
process.env[key] = originalValue;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function loadFixture(name, options) {
|
|
115
|
+
const { readFileSync } = getNodeFs();
|
|
116
|
+
const { extname, join } = getNodePath();
|
|
117
|
+
const baseDir = options?.fixturesDir ?? join(process.cwd(), "__fixtures__");
|
|
118
|
+
const filePath = join(baseDir, name);
|
|
119
|
+
const content = readFileSync(filePath, "utf-8");
|
|
120
|
+
if (extname(filePath) === ".json") {
|
|
121
|
+
return JSON.parse(content);
|
|
122
|
+
}
|
|
123
|
+
return content;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export { createFixture, withTempDir, withEnv, loadFixture };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { OutfitterError, Result } from "@outfitter/contracts";
|
|
2
|
+
import { McpError, McpServer, SerializedTool, ToolDefinition } from "@outfitter/mcp";
|
|
3
|
+
/**
|
|
4
|
+
* MCP tool response content.
|
|
5
|
+
* Matches the MCP protocol shape used in the spec.
|
|
6
|
+
*/
|
|
7
|
+
interface McpToolResponse {
|
|
8
|
+
content: Array<{
|
|
9
|
+
type: "text" | "image";
|
|
10
|
+
text?: string;
|
|
11
|
+
data?: string;
|
|
12
|
+
}>;
|
|
13
|
+
isError?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Test harness for MCP servers.
|
|
17
|
+
*/
|
|
18
|
+
interface McpHarness {
|
|
19
|
+
/**
|
|
20
|
+
* Call a tool by name with input parameters.
|
|
21
|
+
* Returns the MCP-formatted response.
|
|
22
|
+
*/
|
|
23
|
+
callTool(name: string, input: Record<string, unknown>): Promise<Result<McpToolResponse, InstanceType<typeof McpError>>>;
|
|
24
|
+
/**
|
|
25
|
+
* List all registered tools with schemas.
|
|
26
|
+
*/
|
|
27
|
+
listTools(): SerializedTool[];
|
|
28
|
+
/**
|
|
29
|
+
* Load fixture data by name (relative to __fixtures__).
|
|
30
|
+
*/
|
|
31
|
+
loadFixture<T = string>(name: string): T;
|
|
32
|
+
/**
|
|
33
|
+
* Reset harness state between tests.
|
|
34
|
+
*/
|
|
35
|
+
reset(): void;
|
|
36
|
+
/**
|
|
37
|
+
* Search tools by name or description (case-insensitive).
|
|
38
|
+
*/
|
|
39
|
+
searchTools(query: string): SerializedTool[];
|
|
40
|
+
}
|
|
41
|
+
interface McpHarnessOptions {
|
|
42
|
+
/** Base fixtures directory (defaults to `${process.cwd()}/__fixtures__`). */
|
|
43
|
+
readonly fixturesDir?: string;
|
|
44
|
+
}
|
|
45
|
+
interface McpTestHarnessOptions {
|
|
46
|
+
/** Base fixtures directory (defaults to `${process.cwd()}/__fixtures__`). */
|
|
47
|
+
readonly fixturesDir?: string;
|
|
48
|
+
/** Optional server name for diagnostics. */
|
|
49
|
+
readonly name?: string;
|
|
50
|
+
/** Tools to register on the test MCP server. */
|
|
51
|
+
readonly tools: ToolDefinition<unknown, unknown, OutfitterError>[];
|
|
52
|
+
/** Optional server version for diagnostics. */
|
|
53
|
+
readonly version?: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Creates an MCP test harness from an MCP server.
|
|
57
|
+
*/
|
|
58
|
+
declare function createMcpHarness(server: McpServer, options?: McpHarnessOptions): McpHarness;
|
|
59
|
+
/**
|
|
60
|
+
* Creates an MCP test harness from tool definitions.
|
|
61
|
+
*
|
|
62
|
+
* This is a spec-compatible wrapper that builds a test server,
|
|
63
|
+
* registers tools, and returns the standard MCP harness.
|
|
64
|
+
*/
|
|
65
|
+
declare function createMCPTestHarness(options: McpTestHarnessOptions): McpHarness;
|
|
66
|
+
export { McpToolResponse, McpHarness, McpHarnessOptions, McpTestHarnessOptions, createMcpHarness, createMCPTestHarness };
|
|
@@ -0,0 +1,97 @@
|
|
|
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 loadFixture<T = string>(name: string, options?: LoadFixtureOptions): T;
|
|
97
|
+
export { createFixture, withTempDir, withEnv, loadFixture };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @outfitter/testing - CLI Helpers
|
|
3
|
+
*
|
|
4
|
+
* Utilities for capturing CLI output and mocking stdin in tests.
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
*/
|
|
8
|
+
interface CliTestResult {
|
|
9
|
+
/** Process exit code */
|
|
10
|
+
exitCode: number;
|
|
11
|
+
/** Captured stderr */
|
|
12
|
+
stderr: string;
|
|
13
|
+
/** Captured stdout */
|
|
14
|
+
stdout: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Capture stdout/stderr and exit code from an async CLI function.
|
|
18
|
+
*/
|
|
19
|
+
declare function captureCLI(fn: () => Promise<void> | void): Promise<CliTestResult>;
|
|
20
|
+
/**
|
|
21
|
+
* Mock stdin with provided input.
|
|
22
|
+
*
|
|
23
|
+
* Returns a restore function for convenience.
|
|
24
|
+
*/
|
|
25
|
+
declare function mockStdin(input: string): {
|
|
26
|
+
restore: () => void;
|
|
27
|
+
};
|
|
28
|
+
export { CliTestResult, captureCLI, mockStdin };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { HandlerContext, Logger, ResolvedConfig } from "@outfitter/contracts";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
interface LogEntry {
|
|
4
|
+
data?: Record<string, unknown>;
|
|
5
|
+
level: "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
interface TestLogger extends Logger {
|
|
9
|
+
/** Clear captured logs */
|
|
10
|
+
clear(): void;
|
|
11
|
+
/** Captured log entries for assertions */
|
|
12
|
+
logs: LogEntry[];
|
|
13
|
+
}
|
|
14
|
+
declare function createTestLogger(context?: Record<string, unknown>): TestLogger;
|
|
15
|
+
declare function createTestConfig<T>(schema: z.ZodType<T>, values: Partial<T>): ResolvedConfig;
|
|
16
|
+
declare function createTestContext(overrides?: Partial<HandlerContext>): HandlerContext;
|
|
17
|
+
export { LogEntry, TestLogger, createTestLogger, createTestConfig, createTestContext };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/testing/src/mock-factories.ts
|
|
3
|
+
import {
|
|
4
|
+
generateRequestId
|
|
5
|
+
} from "@outfitter/contracts";
|
|
6
|
+
function createTestLogger(context = {}) {
|
|
7
|
+
return createTestLoggerWithContext(context, []);
|
|
8
|
+
}
|
|
9
|
+
function createTestLoggerWithContext(context, logs) {
|
|
10
|
+
const write = (level, message, data) => {
|
|
11
|
+
const merged = { ...context, ...data ?? {} };
|
|
12
|
+
const entry = {
|
|
13
|
+
level,
|
|
14
|
+
message
|
|
15
|
+
};
|
|
16
|
+
if (Object.keys(merged).length > 0) {
|
|
17
|
+
entry.data = merged;
|
|
18
|
+
}
|
|
19
|
+
logs.push(entry);
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
logs,
|
|
23
|
+
clear() {
|
|
24
|
+
logs.length = 0;
|
|
25
|
+
},
|
|
26
|
+
trace: (message, metadata) => {
|
|
27
|
+
write("trace", message, metadata);
|
|
28
|
+
},
|
|
29
|
+
debug: (message, metadata) => {
|
|
30
|
+
write("debug", message, metadata);
|
|
31
|
+
},
|
|
32
|
+
info: (message, metadata) => {
|
|
33
|
+
write("info", message, metadata);
|
|
34
|
+
},
|
|
35
|
+
warn: (message, metadata) => {
|
|
36
|
+
write("warn", message, metadata);
|
|
37
|
+
},
|
|
38
|
+
error: (message, metadata) => {
|
|
39
|
+
write("error", message, metadata);
|
|
40
|
+
},
|
|
41
|
+
fatal: (message, metadata) => {
|
|
42
|
+
write("fatal", message, metadata);
|
|
43
|
+
},
|
|
44
|
+
child(childContext) {
|
|
45
|
+
return createTestLoggerWithContext({ ...context, ...childContext }, logs);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function createTestConfig(schema, values) {
|
|
50
|
+
const parsed = schema.safeParse(values);
|
|
51
|
+
let data;
|
|
52
|
+
if (parsed.success) {
|
|
53
|
+
data = parsed.data;
|
|
54
|
+
} else {
|
|
55
|
+
const maybePartial = schema.partial;
|
|
56
|
+
if (typeof maybePartial !== "function") {
|
|
57
|
+
throw parsed.error;
|
|
58
|
+
}
|
|
59
|
+
const partialSchema = maybePartial.call(schema);
|
|
60
|
+
const partialParsed = partialSchema.safeParse(values);
|
|
61
|
+
if (!partialParsed.success) {
|
|
62
|
+
throw partialParsed.error;
|
|
63
|
+
}
|
|
64
|
+
data = partialParsed.data;
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
get(key) {
|
|
68
|
+
return getPath(data, key);
|
|
69
|
+
},
|
|
70
|
+
getRequired(key) {
|
|
71
|
+
const value = getPath(data, key);
|
|
72
|
+
if (value === undefined) {
|
|
73
|
+
throw new Error(`Missing required config value: ${key}`);
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function createTestContext(overrides = {}) {
|
|
80
|
+
const logger = overrides.logger ?? createTestLogger();
|
|
81
|
+
const requestId = overrides.requestId ?? generateRequestId();
|
|
82
|
+
const context = {
|
|
83
|
+
requestId,
|
|
84
|
+
logger,
|
|
85
|
+
cwd: overrides.cwd ?? process.cwd(),
|
|
86
|
+
env: overrides.env ?? { ...process.env }
|
|
87
|
+
};
|
|
88
|
+
if (overrides.config !== undefined) {
|
|
89
|
+
context.config = overrides.config;
|
|
90
|
+
}
|
|
91
|
+
if (overrides.signal !== undefined) {
|
|
92
|
+
context.signal = overrides.signal;
|
|
93
|
+
}
|
|
94
|
+
if (overrides.workspaceRoot !== undefined) {
|
|
95
|
+
context.workspaceRoot = overrides.workspaceRoot;
|
|
96
|
+
}
|
|
97
|
+
return context;
|
|
98
|
+
}
|
|
99
|
+
function getPath(obj, key) {
|
|
100
|
+
const parts = key.split(".").filter((part) => part.length > 0);
|
|
101
|
+
let current = obj;
|
|
102
|
+
for (const part of parts) {
|
|
103
|
+
if (current === null || typeof current !== "object") {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
current = current[part];
|
|
107
|
+
}
|
|
108
|
+
return current;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export { createTestLogger, createTestConfig, createTestContext };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/testing/src/cli-harness.ts
|
|
3
|
+
function createCliHarness(command) {
|
|
4
|
+
return {
|
|
5
|
+
async run(args) {
|
|
6
|
+
const child = Bun.spawn([command, ...args], {
|
|
7
|
+
stdin: "pipe",
|
|
8
|
+
stdout: "pipe",
|
|
9
|
+
stderr: "pipe"
|
|
10
|
+
});
|
|
11
|
+
child.stdin?.end();
|
|
12
|
+
const stdoutPromise = child.stdout ? new Response(child.stdout).text() : Promise.resolve("");
|
|
13
|
+
const stderrPromise = child.stderr ? new Response(child.stderr).text() : Promise.resolve("");
|
|
14
|
+
const exitCodePromise = child.exited;
|
|
15
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
16
|
+
stdoutPromise,
|
|
17
|
+
stderrPromise,
|
|
18
|
+
exitCodePromise
|
|
19
|
+
]);
|
|
20
|
+
return {
|
|
21
|
+
stdout,
|
|
22
|
+
stderr,
|
|
23
|
+
exitCode: typeof exitCode === "number" ? exitCode : 1
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { createCliHarness };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @outfitter/testing - CLI Harness
|
|
3
|
+
*
|
|
4
|
+
* Test harness for executing and capturing CLI command output.
|
|
5
|
+
* Provides a simple interface for running CLI commands in tests
|
|
6
|
+
* and capturing their stdout, stderr, and exit code.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Result of a CLI command execution.
|
|
12
|
+
*
|
|
13
|
+
* Contains the captured output streams and exit code from the command.
|
|
14
|
+
*/
|
|
15
|
+
interface CliResult {
|
|
16
|
+
/** Exit code from the command (0 typically indicates success) */
|
|
17
|
+
exitCode: number;
|
|
18
|
+
/** Standard error output from the command */
|
|
19
|
+
stderr: string;
|
|
20
|
+
/** Standard output from the command */
|
|
21
|
+
stdout: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Harness for executing CLI commands in tests.
|
|
25
|
+
*
|
|
26
|
+
* Provides a simple interface to run a pre-configured command
|
|
27
|
+
* with various arguments and capture the results.
|
|
28
|
+
*/
|
|
29
|
+
interface CliHarness {
|
|
30
|
+
/**
|
|
31
|
+
* Runs the command with the given arguments.
|
|
32
|
+
*
|
|
33
|
+
* @param args - Command-line arguments to pass to the command
|
|
34
|
+
* @returns Promise resolving to the execution result
|
|
35
|
+
*/
|
|
36
|
+
run(args: string[]): Promise<CliResult>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Creates a CLI harness for testing command-line tools.
|
|
40
|
+
*
|
|
41
|
+
* The harness wraps a command and provides a simple interface for
|
|
42
|
+
* executing it with different arguments, capturing stdout, stderr,
|
|
43
|
+
* and exit code for assertions.
|
|
44
|
+
*
|
|
45
|
+
* @param command - The command to execute (e.g., "echo", "node", "./bin/cli")
|
|
46
|
+
* @returns A CliHarness instance for running the command
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```typescript
|
|
50
|
+
* const harness = createCliHarness("./bin/my-cli");
|
|
51
|
+
*
|
|
52
|
+
* // Test help output
|
|
53
|
+
* const helpResult = await harness.run(["--help"]);
|
|
54
|
+
* expect(helpResult.stdout).toContain("Usage:");
|
|
55
|
+
* expect(helpResult.exitCode).toBe(0);
|
|
56
|
+
*
|
|
57
|
+
* // Test error case
|
|
58
|
+
* const errorResult = await harness.run(["--invalid-flag"]);
|
|
59
|
+
* expect(errorResult.stderr).toContain("Unknown option");
|
|
60
|
+
* expect(errorResult.exitCode).toBe(1);
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
declare function createCliHarness(command: string): CliHarness;
|
|
64
|
+
export { CliResult, CliHarness, createCliHarness };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// packages/testing/src/cli-helpers.ts
|
|
3
|
+
class ExitError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(code) {
|
|
6
|
+
super(`Process exited with code ${code}`);
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
async function captureCLI(fn) {
|
|
11
|
+
const stdoutChunks = [];
|
|
12
|
+
const stderrChunks = [];
|
|
13
|
+
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
14
|
+
const originalStderrWrite = process.stderr.write.bind(process.stderr);
|
|
15
|
+
const originalExit = process.exit.bind(process);
|
|
16
|
+
const originalConsoleLog = console.log;
|
|
17
|
+
const originalConsoleError = console.error;
|
|
18
|
+
process.stdout.write = (chunk, _encoding, cb) => {
|
|
19
|
+
stdoutChunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk);
|
|
20
|
+
if (typeof cb === "function")
|
|
21
|
+
cb();
|
|
22
|
+
return true;
|
|
23
|
+
};
|
|
24
|
+
process.stderr.write = (chunk, _encoding, cb) => {
|
|
25
|
+
stderrChunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk);
|
|
26
|
+
if (typeof cb === "function")
|
|
27
|
+
cb();
|
|
28
|
+
return true;
|
|
29
|
+
};
|
|
30
|
+
console.log = (...args) => {
|
|
31
|
+
const line = `${args.map(String).join(" ")}
|
|
32
|
+
`;
|
|
33
|
+
stdoutChunks.push(new TextEncoder().encode(line));
|
|
34
|
+
};
|
|
35
|
+
console.error = (...args) => {
|
|
36
|
+
const line = `${args.map(String).join(" ")}
|
|
37
|
+
`;
|
|
38
|
+
stderrChunks.push(new TextEncoder().encode(line));
|
|
39
|
+
};
|
|
40
|
+
process.exit = (code) => {
|
|
41
|
+
throw new ExitError(code ?? 0);
|
|
42
|
+
};
|
|
43
|
+
let exitCode = 0;
|
|
44
|
+
try {
|
|
45
|
+
await fn();
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error instanceof ExitError) {
|
|
48
|
+
exitCode = error.code;
|
|
49
|
+
} else {
|
|
50
|
+
exitCode = 1;
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
process.stdout.write = originalStdoutWrite;
|
|
54
|
+
process.stderr.write = originalStderrWrite;
|
|
55
|
+
process.exit = originalExit;
|
|
56
|
+
console.log = originalConsoleLog;
|
|
57
|
+
console.error = originalConsoleError;
|
|
58
|
+
}
|
|
59
|
+
const decoder = new TextDecoder("utf-8");
|
|
60
|
+
return {
|
|
61
|
+
stdout: decoder.decode(concatChunks(stdoutChunks)),
|
|
62
|
+
stderr: decoder.decode(concatChunks(stderrChunks)),
|
|
63
|
+
exitCode
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function mockStdin(input) {
|
|
67
|
+
const originalStdin = process.stdin;
|
|
68
|
+
const encoded = new TextEncoder().encode(input);
|
|
69
|
+
const mockStream = {
|
|
70
|
+
async* [Symbol.asyncIterator]() {
|
|
71
|
+
yield encoded;
|
|
72
|
+
},
|
|
73
|
+
fd: 0,
|
|
74
|
+
isTTY: false
|
|
75
|
+
};
|
|
76
|
+
process.stdin = mockStream;
|
|
77
|
+
return {
|
|
78
|
+
restore: () => {
|
|
79
|
+
process.stdin = originalStdin;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function concatChunks(chunks) {
|
|
84
|
+
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
85
|
+
const result = new Uint8Array(totalLength);
|
|
86
|
+
let offset = 0;
|
|
87
|
+
for (const chunk of chunks) {
|
|
88
|
+
result.set(chunk, offset);
|
|
89
|
+
offset += chunk.length;
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export { captureCLI, mockStdin };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outfitter/testing",
|
|
3
3
|
"description": "Test harnesses, fixtures, and utilities for Outfitter packages",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
7
|
"dist"
|
|
@@ -54,11 +54,12 @@
|
|
|
54
54
|
"lint:fix": "biome lint --write ./src",
|
|
55
55
|
"test": "bun test",
|
|
56
56
|
"typecheck": "tsc --noEmit",
|
|
57
|
-
"clean": "rm -rf dist"
|
|
57
|
+
"clean": "rm -rf dist",
|
|
58
|
+
"prepublishOnly": "bun ../../scripts/check-publish-manifest.ts"
|
|
58
59
|
},
|
|
59
60
|
"dependencies": {
|
|
60
|
-
"@outfitter/contracts": "0.4.
|
|
61
|
-
"@outfitter/mcp": "0.4.
|
|
61
|
+
"@outfitter/contracts": "0.4.1",
|
|
62
|
+
"@outfitter/mcp": "0.4.2",
|
|
62
63
|
"zod": "^4.3.5"
|
|
63
64
|
},
|
|
64
65
|
"devDependencies": {
|