@outfitter/testing 0.2.5 → 0.3.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.
@@ -1,95 +1,8 @@
1
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
- }
2
+ import {
3
+ captureCLI,
4
+ mockStdin
5
+ } from "./shared/@outfitter/testing-xsfjh1n3.js";
93
6
  export {
94
7
  mockStdin,
95
8
  captureCLI
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
+ import { TestCommandOptions, TestCommandResult, getTestContext, testCommand } from "./shared/@outfitter/testing-s2fyaxm2.js";
1
2
  import { CliTestResult, captureCLI, mockStdin } from "./shared/@outfitter/testing-fyjbwn80.js";
3
+ import { TestToolOptions, TestToolResult, testTool } from "./shared/@outfitter/testing-136ebq11.js";
2
4
  import { createFixture, loadFixture, withEnv, withTempDir } from "./shared/@outfitter/testing-98fsks4n.js";
3
- import { McpHarness, McpTestHarnessOptions, McpToolResponse, createMCPTestHarness, createMcpHarness } from "./shared/@outfitter/testing-5gdrv3f5.js";
5
+ import { McpHarness, McpTestHarnessOptions, McpToolResponse, createMCPTestHarness, createMcpHarness } from "./shared/@outfitter/testing-jc0ppq2z.js";
4
6
  import { LogEntry, TestLogger, createTestConfig, createTestContext, createTestLogger } from "./shared/@outfitter/testing-jdfrrv33.js";
5
7
  import { CliHarness, CliResult, createCliHarness } from "./shared/@outfitter/testing-xaxkt6c9.js";
6
- export { withTempDir, withEnv, mockStdin, loadFixture, createTestLogger, createTestContext, createTestConfig, createMCPTestHarness as createMcpTestHarness, createMcpHarness, createMCPTestHarness, createFixture, createCliHarness, captureCLI, TestLogger, McpToolResponse, McpTestHarnessOptions, McpHarness, LogEntry, CliTestResult, CliResult, CliHarness };
8
+ export { withTempDir, withEnv, testTool, testCommand, mockStdin, loadFixture, getTestContext, createTestLogger, createTestContext, createTestConfig, createMCPTestHarness as createMcpTestHarness, createMcpHarness, createMCPTestHarness, createFixture, createCliHarness, captureCLI, TestToolResult, TestToolOptions, TestLogger, TestCommandResult, TestCommandOptions, McpToolResponse, McpTestHarnessOptions, McpHarness, LogEntry, CliTestResult, CliResult, CliHarness };
package/dist/index.js CHANGED
@@ -2,4 +2,6 @@ export { createFixture, loadFixture, withEnv, withTempDir } from "./fixtures.js"
2
2
  export { createCliHarness } from "./cli-harness.js";
3
3
  export { captureCLI, mockStdin } from "./cli-helpers.js";
4
4
  export { createMCPTestHarness, createMcpHarness, createMcpTestHarness } from "./mcp-harness.js";
5
+ export { getTestContext, testCommand } from "./test-command.js";
6
+ export { testTool } from "./test-tool.js";
5
7
  export { createTestConfig, createTestContext, createTestLogger } from "./mock-factories.js";
@@ -1,2 +1,2 @@
1
- import { McpHarness, McpHarnessOptions, McpTestHarnessOptions, McpToolResponse, createMCPTestHarness, createMcpHarness } from "./shared/@outfitter/testing-5gdrv3f5.js";
1
+ import { McpHarness, McpHarnessOptions, McpTestHarnessOptions, McpToolResponse, createMCPTestHarness, createMcpHarness } from "./shared/@outfitter/testing-jc0ppq2z.js";
2
2
  export { createMCPTestHarness as createMcpTestHarness, createMcpHarness, createMCPTestHarness, McpToolResponse, McpTestHarnessOptions, McpHarnessOptions, McpHarness };
@@ -15,6 +15,9 @@ function createMcpHarness(server, options = {}) {
15
15
  listTools() {
16
16
  return server.getTools();
17
17
  },
18
+ listResources() {
19
+ return server.getResources();
20
+ },
18
21
  searchTools(query) {
19
22
  const normalized = query.trim().toLowerCase();
20
23
  const tools = server.getTools();
@@ -1,112 +1,9 @@
1
1
  // @bun
2
- // packages/testing/src/mock-factories.ts
3
2
  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
- }
3
+ createTestConfig,
4
+ createTestContext,
5
+ createTestLogger
6
+ } from "./shared/@outfitter/testing-xcd5p1gm.js";
110
7
  export {
111
8
  createTestLogger,
112
9
  createTestContext,
@@ -0,0 +1,123 @@
1
+ import { HandlerContext, MCPHint, OutfitterError, Result, ValidationError } from "@outfitter/contracts";
2
+ import { ToolDefinition } from "@outfitter/mcp";
3
+ /**
4
+ * Options for testTool().
5
+ */
6
+ interface TestToolOptions {
7
+ /**
8
+ * Custom working directory for the handler context.
9
+ * @deprecated Use `context.cwd` instead.
10
+ */
11
+ readonly cwd?: string;
12
+ /**
13
+ * Custom environment variables for the handler context.
14
+ * @deprecated Use `context.env` instead.
15
+ */
16
+ readonly env?: Readonly<Record<string, string | undefined>>;
17
+ /**
18
+ * Custom request ID for the handler context.
19
+ * @deprecated Use `context.requestId` instead.
20
+ */
21
+ readonly requestId?: string;
22
+ /**
23
+ * Full HandlerContext overrides.
24
+ *
25
+ * When provided, these values are merged with the default test context.
26
+ * Takes priority over the individual `cwd`, `env`, and `requestId` options.
27
+ *
28
+ * @example
29
+ * ```typescript
30
+ * await testTool(tool, input, {
31
+ * context: {
32
+ * requestId: "test-req-001",
33
+ * cwd: "/test/dir",
34
+ * logger: createTestLogger(),
35
+ * },
36
+ * });
37
+ * ```
38
+ */
39
+ readonly context?: Partial<HandlerContext>;
40
+ /**
41
+ * Hint generation function for asserting on hints.
42
+ *
43
+ * When provided, called with the handler's success result and the result
44
+ * is attached to the returned `TestToolResult.hints` for assertion.
45
+ * Returns `MCPHint[]` for MCP tool testing.
46
+ *
47
+ * @example
48
+ * ```typescript
49
+ * const result = await testTool(tool, input, {
50
+ * hints: (result) => [{
51
+ * description: "View details",
52
+ * tool: "get-details",
53
+ * input: { id: result.id },
54
+ * }],
55
+ * });
56
+ * expect(result.hints).toHaveLength(1);
57
+ * ```
58
+ */
59
+ readonly hints?: (result: unknown) => MCPHint[];
60
+ }
61
+ /**
62
+ * Enhanced result from testTool() with optional hints.
63
+ *
64
+ * Extends the base `Result` with a `hints` field that contains
65
+ * generated hints when a `hints` function was provided in options.
66
+ */
67
+ type TestToolResult<
68
+ TOutput,
69
+ TError extends OutfitterError
70
+ > = Result<TOutput, TError | InstanceType<typeof ValidationError>> & {
71
+ /**
72
+ * Generated hints from the `hints` option function.
73
+ *
74
+ * Present when `hints` option is provided and the handler succeeds.
75
+ * Undefined when no hints option was given or when hints array is empty.
76
+ */
77
+ readonly hints?: MCPHint[] | undefined;
78
+ };
79
+ /**
80
+ * Execute an MCP tool definition with given input.
81
+ *
82
+ * Validates input against the tool's Zod schema. If validation fails,
83
+ * returns an `Err<ValidationError>` without invoking the handler.
84
+ * If validation succeeds, invokes the handler exactly once with a
85
+ * test `HandlerContext` and returns the handler's `Result`.
86
+ *
87
+ * ### v0.5 Enhancements
88
+ *
89
+ * - **`context`**: Full `HandlerContext` overrides (not just cwd/env/requestId).
90
+ * Takes priority over individual options.
91
+ * - **`hints`**: Hint generation function for asserting on hints.
92
+ * When provided, the function is called with the handler's success result
93
+ * and the generated hints are attached to `result.hints`.
94
+ *
95
+ * @param tool - An MCP tool definition with inputSchema and handler
96
+ * @param input - Raw input to validate and pass to the handler
97
+ * @param options - Optional context overrides and hint function
98
+ * @returns The handler's Result with optional hints, or Err<ValidationError> on schema failure
99
+ *
100
+ * @example
101
+ * ```typescript
102
+ * // Basic usage (backward compatible)
103
+ * const result = await testTool(myTool, { a: 2, b: 3 });
104
+ * expect(result.unwrap().sum).toBe(5);
105
+ *
106
+ * // With full context injection
107
+ * const result = await testTool(myTool, input, {
108
+ * context: { requestId: "test-001", logger: testLogger },
109
+ * });
110
+ *
111
+ * // With hints assertion
112
+ * const result = await testTool(myTool, input, {
113
+ * hints: (r) => [{ description: "Next step", tool: "other" }],
114
+ * });
115
+ * expect(result.hints).toHaveLength(1);
116
+ * ```
117
+ */
118
+ declare function testTool<
119
+ TInput,
120
+ TOutput,
121
+ TError extends OutfitterError
122
+ >(tool: ToolDefinition<TInput, TOutput, TError>, input: unknown, options?: TestToolOptions): Promise<TestToolResult<TOutput, TError>>;
123
+ export { TestToolOptions, TestToolResult, testTool };
@@ -1,5 +1,5 @@
1
1
  import { OutfitterError, Result } from "@outfitter/contracts";
2
- import { McpError, McpServer, SerializedTool, ToolDefinition } from "@outfitter/mcp";
2
+ import { McpError, McpServer, ResourceDefinition, SerializedTool, ToolDefinition } from "@outfitter/mcp";
3
3
  /**
4
4
  * MCP tool response content.
5
5
  * Matches the MCP protocol shape used in the spec.
@@ -18,9 +18,12 @@ interface McpToolResponse {
18
18
  interface McpHarness {
19
19
  /**
20
20
  * Call a tool by name with input parameters.
21
- * Returns the MCP-formatted response.
21
+ * Returns the raw handler output (not MCP-wrapped content).
22
+ *
23
+ * Callers who know the handler's return shape can pass a type parameter
24
+ * to avoid manual narrowing: `harness.callTool<MyOutput>("tool", input)`.
22
25
  */
23
- callTool(name: string, input: Record<string, unknown>): Promise<Result<McpToolResponse, InstanceType<typeof McpError>>>;
26
+ callTool<T = unknown>(name: string, input: Record<string, unknown>): Promise<Result<T, InstanceType<typeof McpError>>>;
24
27
  /**
25
28
  * List all registered tools with schemas.
26
29
  */
@@ -34,6 +37,10 @@ interface McpHarness {
34
37
  */
35
38
  reset(): void;
36
39
  /**
40
+ * List all registered resources.
41
+ */
42
+ listResources(): ResourceDefinition[];
43
+ /**
37
44
  * Search tools by name or description (case-insensitive).
38
45
  */
39
46
  searchTools(query: string): SerializedTool[];
@@ -0,0 +1,134 @@
1
+ import { CliTestResult } from "./testing-fyjbwn80.js";
2
+ import { CLI } from "@outfitter/cli/command";
3
+ import { CommandEnvelope } from "@outfitter/cli/envelope";
4
+ /**
5
+ * Options for testCommand().
6
+ */
7
+ interface TestCommandOptions {
8
+ /**
9
+ * Environment variables to set during CLI execution.
10
+ * Variables are restored to their original values after execution.
11
+ */
12
+ readonly env?: Readonly<Record<string, string>>;
13
+ /**
14
+ * Pre-parsed input object to convert to CLI arguments.
15
+ *
16
+ * Each key-value pair is converted to a `--key value` argument:
17
+ * - `string` → `--key value`
18
+ * - `number` → `--key value` (stringified)
19
+ * - `boolean` → `--key` (true) or omitted (false)
20
+ * - `string[]` → repeated `--key value1 --key value2`
21
+ *
22
+ * Input args are appended after the explicit `args` parameter,
23
+ * allowing both positional args and input overrides.
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * // Converts to: ["greet", "--name", "World"]
28
+ * await testCommand(cli, ["greet"], { input: { name: "World" } });
29
+ * ```
30
+ */
31
+ readonly input?: Readonly<Record<string, unknown>>;
32
+ /**
33
+ * Mock context object to inject into the command execution.
34
+ *
35
+ * When provided, the context is stored in a test injection point
36
+ * and can be retrieved by context factories via `getTestContext()`.
37
+ * The context is cleaned up after execution.
38
+ *
39
+ * @example
40
+ * ```typescript
41
+ * await testCommand(cli, ["status"], {
42
+ * context: { db: mockDb, config: testConfig },
43
+ * });
44
+ * ```
45
+ */
46
+ readonly context?: Readonly<Record<string, unknown>>;
47
+ /**
48
+ * Force JSON output mode by setting OUTFITTER_JSON=1.
49
+ *
50
+ * When true, the CLI produces JSON output which is automatically
51
+ * parsed into the `envelope` field of the result.
52
+ */
53
+ readonly json?: boolean;
54
+ }
55
+ /**
56
+ * Enhanced CLI test result with optional envelope parsing.
57
+ *
58
+ * Extends the base `CliTestResult` with an `envelope` field that
59
+ * contains the parsed `CommandEnvelope` when JSON output is detected.
60
+ */
61
+ interface TestCommandResult extends CliTestResult {
62
+ /**
63
+ * Parsed command envelope from JSON output.
64
+ *
65
+ * Present when the command outputs a JSON envelope (either via
66
+ * `runHandler()` with JSON format or when `json: true` is set).
67
+ * Undefined for non-JSON output.
68
+ */
69
+ readonly envelope?: CommandEnvelope | undefined;
70
+ }
71
+ /**
72
+ * Retrieve the injected test context, if any.
73
+ *
74
+ * Context factories in builder-pattern commands can call this
75
+ * to use the test-provided context instead of constructing a real one.
76
+ *
77
+ * @returns The injected context, or `undefined` if not in a test
78
+ *
79
+ * @example
80
+ * ```typescript
81
+ * // In your command definition:
82
+ * command("status")
83
+ * .context(async (input) => {
84
+ * // Use test context if available, otherwise create real one
85
+ * const testCtx = getTestContext();
86
+ * if (testCtx) return testCtx;
87
+ * return { db: await connectDb() };
88
+ * })
89
+ * ```
90
+ */
91
+ declare function getTestContext<T extends Record<string, unknown> = Record<string, unknown>>(): T | undefined;
92
+ /**
93
+ * Execute a CLI instance with given arguments and capture output.
94
+ *
95
+ * Creates a fresh CLI instance from the same program, configured with
96
+ * `onExit` to capture exit codes cleanly. Wraps `captureCLI()` to
97
+ * intercept stdout, stderr, and process.exit during execution.
98
+ * No side effects leak to real stdout, stderr, or process.exit.
99
+ * Global process state is fully restored after each call.
100
+ *
101
+ * ### v0.5 Enhancements
102
+ *
103
+ * - **`input`**: Pre-parsed input object auto-converted to CLI args.
104
+ * Keys become `--key value` flags, booleans become presence/absence flags.
105
+ * - **`context`**: Mock context object injectable via `getTestContext()`.
106
+ * Context factories can use this to skip real resource construction in tests.
107
+ * - **`json`**: Forces JSON output mode (OUTFITTER_JSON=1) so the result
108
+ * includes a parsed `envelope` field.
109
+ * - **`envelope`**: Parsed `CommandEnvelope` from JSON output, available on
110
+ * the result for structured assertion.
111
+ *
112
+ * @param cli - A CLI instance created by `createCLI()`
113
+ * @param args - Command-line arguments (without the program name prefix)
114
+ * @param options - Optional configuration for the test execution
115
+ * @returns Captured stdout, stderr, exitCode, and optional envelope
116
+ *
117
+ * @example
118
+ * ```typescript
119
+ * // Basic usage (backward compatible)
120
+ * const result = await testCommand(cli, ["hello"]);
121
+ * expect(result.stdout).toContain("Hello!");
122
+ *
123
+ * // With pre-parsed input
124
+ * const result = await testCommand(cli, ["greet"], {
125
+ * input: { name: "World" },
126
+ * });
127
+ *
128
+ * // With JSON envelope
129
+ * const result = await testCommand(cli, ["info"], { json: true });
130
+ * expect(result.envelope?.ok).toBe(true);
131
+ * ```
132
+ */
133
+ declare function testCommand(cli: CLI, args: string[], options?: TestCommandOptions): Promise<TestCommandResult>;
134
+ export { TestCommandOptions, TestCommandResult, getTestContext, testCommand };
@@ -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,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 };
@@ -0,0 +1,3 @@
1
+ import { TestCommandOptions, TestCommandResult, getTestContext, testCommand } from "./shared/@outfitter/testing-s2fyaxm2.js";
2
+ import "./shared/@outfitter/testing-fyjbwn80.js";
3
+ export { testCommand, getTestContext, TestCommandResult, TestCommandOptions };
@@ -0,0 +1,114 @@
1
+ // @bun
2
+ import {
3
+ captureCLI
4
+ } from "./shared/@outfitter/testing-xsfjh1n3.js";
5
+
6
+ // packages/testing/src/test-command.ts
7
+ var injectedTestContext;
8
+ var executionChain = Promise.resolve();
9
+ function getTestContext() {
10
+ return injectedTestContext;
11
+ }
12
+ async function withProcessLock(run) {
13
+ const prior = executionChain;
14
+ let release;
15
+ executionChain = new Promise((resolve) => {
16
+ release = resolve;
17
+ });
18
+ await prior;
19
+ try {
20
+ return await run();
21
+ } finally {
22
+ release?.();
23
+ }
24
+ }
25
+ function inputToArgs(input) {
26
+ const args = [];
27
+ for (const [key, value] of Object.entries(input)) {
28
+ const kebabKey = key.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").toLowerCase();
29
+ const flag = `--${kebabKey}`;
30
+ if (typeof value === "boolean") {
31
+ if (value) {
32
+ args.push(flag);
33
+ }
34
+ } else if (Array.isArray(value)) {
35
+ for (const item of value) {
36
+ args.push(flag, String(item));
37
+ }
38
+ } else if (value !== undefined && value !== null) {
39
+ args.push(flag, String(value));
40
+ }
41
+ }
42
+ return args;
43
+ }
44
+ function parseEnvelope(stdout, stderr) {
45
+ const stdoutEnvelope = tryParseEnvelope(stdout);
46
+ if (stdoutEnvelope)
47
+ return stdoutEnvelope;
48
+ const stderrEnvelope = tryParseEnvelope(stderr);
49
+ if (stderrEnvelope)
50
+ return stderrEnvelope;
51
+ return;
52
+ }
53
+ function tryParseEnvelope(text) {
54
+ const trimmed = text.trim();
55
+ if (!trimmed.startsWith("{"))
56
+ return;
57
+ try {
58
+ const parsed = JSON.parse(trimmed);
59
+ if (typeof parsed["ok"] === "boolean" && typeof parsed["command"] === "string") {
60
+ return parsed;
61
+ }
62
+ } catch {}
63
+ return;
64
+ }
65
+ async function testCommand(cli, args, options) {
66
+ return withProcessLock(async () => {
67
+ const originalEnv = { ...process.env };
68
+ if (options?.env) {
69
+ for (const [key, value] of Object.entries(options.env)) {
70
+ process.env[key] = value;
71
+ }
72
+ }
73
+ if (options?.json) {
74
+ process.env["OUTFITTER_JSON"] = "1";
75
+ }
76
+ if (options?.context) {
77
+ injectedTestContext = { ...options.context };
78
+ }
79
+ const inputArgs = options?.input ? inputToArgs(options.input) : [];
80
+ const fullArgs = [...args, ...inputArgs];
81
+ const savedArgv = process.argv;
82
+ const testArgv = ["node", "test", ...fullArgs];
83
+ process.argv = testArgv;
84
+ try {
85
+ const cliResult = await captureCLI(async () => {
86
+ await cli.parse(testArgv);
87
+ });
88
+ const envelope = parseEnvelope(cliResult.stdout, cliResult.stderr);
89
+ return {
90
+ ...cliResult,
91
+ envelope
92
+ };
93
+ } finally {
94
+ process.argv = savedArgv;
95
+ for (const key of Object.keys(process.env)) {
96
+ if (!(key in originalEnv)) {
97
+ delete process.env[key];
98
+ }
99
+ }
100
+ for (const [key, value] of Object.entries(originalEnv)) {
101
+ if (value === undefined) {
102
+ delete process.env[key];
103
+ } else {
104
+ process.env[key] = value;
105
+ }
106
+ }
107
+ injectedTestContext = undefined;
108
+ }
109
+ });
110
+ }
111
+ export {
112
+ testCommand,
113
+ getTestContext
114
+ };
@@ -0,0 +1,2 @@
1
+ import { TestToolOptions, TestToolResult, testTool } from "./shared/@outfitter/testing-136ebq11.js";
2
+ export { testTool, TestToolResult, TestToolOptions };
@@ -0,0 +1,58 @@
1
+ // @bun
2
+ import {
3
+ createTestContext
4
+ } from "./shared/@outfitter/testing-xcd5p1gm.js";
5
+
6
+ // packages/testing/src/test-tool.ts
7
+ import {
8
+ formatZodIssues,
9
+ Result,
10
+ ValidationError
11
+ } from "@outfitter/contracts";
12
+ async function testTool(tool, input, options) {
13
+ const parseResult = tool.inputSchema.safeParse(input);
14
+ if (!parseResult.success) {
15
+ const errorMessages = formatZodIssues(parseResult.error.issues);
16
+ return Result.err(new ValidationError({
17
+ message: `Invalid input: ${errorMessages}`
18
+ }));
19
+ }
20
+ const ctxOverrides = {};
21
+ if (options?.requestId !== undefined) {
22
+ ctxOverrides.requestId = options.requestId;
23
+ }
24
+ if (options?.cwd !== undefined) {
25
+ ctxOverrides.cwd = options.cwd;
26
+ }
27
+ if (options?.env !== undefined) {
28
+ ctxOverrides.env = options.env;
29
+ }
30
+ if (options?.context) {
31
+ Object.assign(ctxOverrides, options.context);
32
+ }
33
+ const ctx = createTestContext(ctxOverrides);
34
+ const result = await tool.handler(parseResult.data, ctx);
35
+ if (options?.hints && result.isOk()) {
36
+ const hints = options.hints(result.value);
37
+ if (hints.length > 0) {
38
+ const enhanced = Object.create(Object.getPrototypeOf(result));
39
+ for (const key of Reflect.ownKeys(result)) {
40
+ const descriptor = Object.getOwnPropertyDescriptor(result, key);
41
+ if (descriptor) {
42
+ Object.defineProperty(enhanced, key, descriptor);
43
+ }
44
+ }
45
+ Object.defineProperty(enhanced, "hints", {
46
+ value: hints,
47
+ enumerable: true,
48
+ configurable: true,
49
+ writable: false
50
+ });
51
+ return enhanced;
52
+ }
53
+ }
54
+ return result;
55
+ }
56
+ export {
57
+ testTool
58
+ };
package/package.json CHANGED
@@ -1,25 +1,11 @@
1
1
  {
2
2
  "name": "@outfitter/testing",
3
- "version": "0.2.5",
4
3
  "description": "Test harnesses, fixtures, and utilities for Outfitter packages",
5
- "keywords": [
6
- "fixtures",
7
- "harness",
8
- "outfitter",
9
- "testing",
10
- "typescript"
11
- ],
12
- "license": "MIT",
13
- "repository": {
14
- "type": "git",
15
- "url": "https://github.com/outfitter-dev/outfitter.git",
16
- "directory": "packages/testing"
17
- },
4
+ "version": "0.3.0",
5
+ "type": "module",
18
6
  "files": [
19
7
  "dist"
20
8
  ],
21
- "type": "module",
22
- "sideEffects": false,
23
9
  "module": "./dist/index.js",
24
10
  "types": "./dist/index.d.ts",
25
11
  "exports": {
@@ -59,13 +45,39 @@
59
45
  "default": "./dist/mock-factories.js"
60
46
  }
61
47
  },
62
- "./package.json": "./package.json"
48
+ "./package.json": "./package.json",
49
+ "./test-command": {
50
+ "import": {
51
+ "types": "./dist/test-command.d.ts",
52
+ "default": "./dist/test-command.js"
53
+ }
54
+ },
55
+ "./test-tool": {
56
+ "import": {
57
+ "types": "./dist/test-tool.d.ts",
58
+ "default": "./dist/test-tool.js"
59
+ }
60
+ }
63
61
  },
62
+ "keywords": [
63
+ "fixtures",
64
+ "harness",
65
+ "outfitter",
66
+ "testing",
67
+ "typescript"
68
+ ],
69
+ "license": "MIT",
70
+ "repository": {
71
+ "type": "git",
72
+ "url": "https://github.com/outfitter-dev/outfitter.git",
73
+ "directory": "packages/testing"
74
+ },
75
+ "sideEffects": false,
64
76
  "publishConfig": {
65
77
  "access": "public"
66
78
  },
67
79
  "scripts": {
68
- "build": "cd ../.. && bunup --filter @outfitter/testing",
80
+ "build": "cd ../.. && bash ./scripts/run-bunup-with-lock.sh bunup --filter @outfitter/testing",
69
81
  "lint": "oxlint ./src",
70
82
  "lint:fix": "oxlint --fix ./src",
71
83
  "test": "bun test",
@@ -74,8 +86,9 @@
74
86
  "prepublishOnly": "bun ../../scripts/check-publish-manifest.ts"
75
87
  },
76
88
  "dependencies": {
77
- "@outfitter/contracts": "0.4.2",
78
- "@outfitter/mcp": "0.4.3",
89
+ "@outfitter/cli": "1.0.0",
90
+ "@outfitter/contracts": "0.5.0",
91
+ "@outfitter/mcp": "0.5.0",
79
92
  "zod": "^4.3.5"
80
93
  },
81
94
  "devDependencies": {