@outfitter/testing 0.2.4 → 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.
@@ -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 };
@@ -8,7 +8,7 @@ function createTestLogger(context = {}) {
8
8
  }
9
9
  function createTestLoggerWithContext(context, logs) {
10
10
  const write = (level, message, data) => {
11
- const merged = { ...context, ...data ?? {} };
11
+ const merged = { ...context, ...data };
12
12
  const entry = {
13
13
  level,
14
14
  message
@@ -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,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",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "dist"
@@ -45,32 +45,25 @@
45
45
  "default": "./dist/mock-factories.js"
46
46
  }
47
47
  },
48
- "./package.json": "./package.json"
49
- },
50
- "sideEffects": false,
51
- "scripts": {
52
- "build": "bunup --filter @outfitter/testing",
53
- "lint": "biome lint ./src",
54
- "lint:fix": "biome lint --write ./src",
55
- "test": "bun test",
56
- "typecheck": "tsc --noEmit",
57
- "clean": "rm -rf dist",
58
- "prepublishOnly": "bun ../../scripts/check-publish-manifest.ts"
59
- },
60
- "dependencies": {
61
- "@outfitter/contracts": "0.4.1",
62
- "@outfitter/mcp": "0.4.2",
63
- "zod": "^4.3.5"
64
- },
65
- "devDependencies": {
66
- "@types/bun": "latest",
67
- "typescript": "^5.8.0"
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
+ }
68
61
  },
69
62
  "keywords": [
70
- "outfitter",
71
- "testing",
72
63
  "fixtures",
73
64
  "harness",
65
+ "outfitter",
66
+ "testing",
74
67
  "typescript"
75
68
  ],
76
69
  "license": "MIT",
@@ -79,7 +72,27 @@
79
72
  "url": "https://github.com/outfitter-dev/outfitter.git",
80
73
  "directory": "packages/testing"
81
74
  },
75
+ "sideEffects": false,
82
76
  "publishConfig": {
83
77
  "access": "public"
78
+ },
79
+ "scripts": {
80
+ "build": "cd ../.. && bash ./scripts/run-bunup-with-lock.sh bunup --filter @outfitter/testing",
81
+ "lint": "oxlint ./src",
82
+ "lint:fix": "oxlint --fix ./src",
83
+ "test": "bun test",
84
+ "typecheck": "tsc --noEmit",
85
+ "clean": "rm -rf dist",
86
+ "prepublishOnly": "bun ../../scripts/check-publish-manifest.ts"
87
+ },
88
+ "dependencies": {
89
+ "@outfitter/cli": "1.0.0",
90
+ "@outfitter/contracts": "0.5.0",
91
+ "@outfitter/mcp": "0.5.0",
92
+ "zod": "^4.3.5"
93
+ },
94
+ "devDependencies": {
95
+ "@types/bun": "^1.3.9",
96
+ "typescript": "^5.9.3"
84
97
  }
85
98
  }
@@ -1,48 +0,0 @@
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 };
@@ -1,29 +0,0 @@
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 };