@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.
- package/README.md +24 -19
- package/dist/cli-harness.js +26 -3
- package/dist/index.d.ts +8 -268
- package/dist/index.js +7 -405
- package/dist/mcp-harness.d.ts +1 -1
- package/dist/mcp-harness.js +46 -4
- package/dist/mock-factories.js +1 -1
- package/dist/shared/@outfitter/testing-136ebq11.d.ts +123 -0
- package/dist/shared/@outfitter/{testing-5gdrv3f5.d.ts → testing-jc0ppq2z.d.ts} +10 -3
- package/dist/shared/@outfitter/testing-s2fyaxm2.d.ts +134 -0
- package/dist/shared/@outfitter/{testing-kdwa417a.js → testing-xcd5p1gm.js} +1 -1
- package/dist/test-command.d.ts +3 -0
- package/dist/test-command.js +114 -0
- package/dist/test-tool.d.ts +2 -0
- package/dist/test-tool.js +58 -0
- package/package.json +36 -23
- package/dist/shared/@outfitter/testing-05hzzr8n.js +0 -48
- package/dist/shared/@outfitter/testing-wfp5f7pq.js +0 -29
package/dist/index.js
CHANGED
|
@@ -1,405 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return cachedRequire;
|
|
9
|
-
}
|
|
10
|
-
const metaRequire = import.meta.require;
|
|
11
|
-
if (typeof metaRequire === "function") {
|
|
12
|
-
cachedRequire = metaRequire;
|
|
13
|
-
return metaRequire;
|
|
14
|
-
}
|
|
15
|
-
const globalRequire = globalThis.require;
|
|
16
|
-
if (typeof globalRequire === "function") {
|
|
17
|
-
cachedRequire = globalRequire;
|
|
18
|
-
return globalRequire;
|
|
19
|
-
}
|
|
20
|
-
cachedRequire = null;
|
|
21
|
-
throw new Error("Node.js built-ins are unavailable in this runtime.");
|
|
22
|
-
}
|
|
23
|
-
function getNodeFs() {
|
|
24
|
-
return getNodeRequire()("node:fs");
|
|
25
|
-
}
|
|
26
|
-
function getNodeFsPromises() {
|
|
27
|
-
return getNodeRequire()("node:fs/promises");
|
|
28
|
-
}
|
|
29
|
-
function getNodeOs() {
|
|
30
|
-
return getNodeRequire()("node:os");
|
|
31
|
-
}
|
|
32
|
-
function getNodePath() {
|
|
33
|
-
return getNodeRequire()("node:path");
|
|
34
|
-
}
|
|
35
|
-
function isPlainObject(value) {
|
|
36
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
37
|
-
}
|
|
38
|
-
function deepMerge(target, source) {
|
|
39
|
-
const result = { ...target };
|
|
40
|
-
for (const key of Object.keys(source)) {
|
|
41
|
-
const sourceValue = source[key];
|
|
42
|
-
const targetValue = target[key];
|
|
43
|
-
if (sourceValue === undefined) {
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
if (isPlainObject(sourceValue) && isPlainObject(targetValue)) {
|
|
47
|
-
result[key] = deepMerge(targetValue, sourceValue);
|
|
48
|
-
} else {
|
|
49
|
-
result[key] = sourceValue;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
return result;
|
|
53
|
-
}
|
|
54
|
-
function deepClone(obj) {
|
|
55
|
-
if (obj === null || typeof obj !== "object") {
|
|
56
|
-
return obj;
|
|
57
|
-
}
|
|
58
|
-
if (Array.isArray(obj)) {
|
|
59
|
-
return obj.map((item) => deepClone(item));
|
|
60
|
-
}
|
|
61
|
-
const cloned = {};
|
|
62
|
-
for (const key of Object.keys(obj)) {
|
|
63
|
-
cloned[key] = deepClone(obj[key]);
|
|
64
|
-
}
|
|
65
|
-
return cloned;
|
|
66
|
-
}
|
|
67
|
-
function createFixture(defaults) {
|
|
68
|
-
return (overrides) => {
|
|
69
|
-
const cloned = deepClone(defaults);
|
|
70
|
-
if (overrides === undefined) {
|
|
71
|
-
return cloned;
|
|
72
|
-
}
|
|
73
|
-
return deepMerge(cloned, overrides);
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
function generateTempDirPath() {
|
|
77
|
-
const { tmpdir } = getNodeOs();
|
|
78
|
-
const { join } = getNodePath();
|
|
79
|
-
const timestamp = Date.now();
|
|
80
|
-
const random = Math.random().toString(36).slice(2, 10);
|
|
81
|
-
return join(tmpdir(), `outfitter-test-${timestamp}-${random}`);
|
|
82
|
-
}
|
|
83
|
-
async function withTempDir(fn) {
|
|
84
|
-
const { mkdir, rm } = getNodeFsPromises();
|
|
85
|
-
const dir = generateTempDirPath();
|
|
86
|
-
await mkdir(dir, { recursive: true });
|
|
87
|
-
try {
|
|
88
|
-
return await fn(dir);
|
|
89
|
-
} finally {
|
|
90
|
-
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
async function withEnv(vars, fn) {
|
|
94
|
-
const originalValues = new Map;
|
|
95
|
-
for (const key of Object.keys(vars)) {
|
|
96
|
-
originalValues.set(key, process.env[key]);
|
|
97
|
-
}
|
|
98
|
-
for (const [key, value] of Object.entries(vars)) {
|
|
99
|
-
process.env[key] = value;
|
|
100
|
-
}
|
|
101
|
-
try {
|
|
102
|
-
return await fn();
|
|
103
|
-
} finally {
|
|
104
|
-
for (const [key, originalValue] of originalValues) {
|
|
105
|
-
if (originalValue === undefined) {
|
|
106
|
-
delete process.env[key];
|
|
107
|
-
} else {
|
|
108
|
-
process.env[key] = originalValue;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
function loadFixture(name, options) {
|
|
114
|
-
const { readFileSync } = getNodeFs();
|
|
115
|
-
const { extname, join } = getNodePath();
|
|
116
|
-
const baseDir = options?.fixturesDir ?? join(process.cwd(), "__fixtures__");
|
|
117
|
-
const filePath = join(baseDir, name);
|
|
118
|
-
const content = readFileSync(filePath, "utf-8");
|
|
119
|
-
if (extname(filePath) === ".json") {
|
|
120
|
-
return JSON.parse(content);
|
|
121
|
-
}
|
|
122
|
-
return content;
|
|
123
|
-
}
|
|
124
|
-
// src/cli-harness.ts
|
|
125
|
-
function createCliHarness(command) {
|
|
126
|
-
return {
|
|
127
|
-
async run(args) {
|
|
128
|
-
const child = Bun.spawn([command, ...args], {
|
|
129
|
-
stdin: "pipe",
|
|
130
|
-
stdout: "pipe",
|
|
131
|
-
stderr: "pipe"
|
|
132
|
-
});
|
|
133
|
-
child.stdin?.end();
|
|
134
|
-
const stdoutPromise = child.stdout ? new Response(child.stdout).text() : Promise.resolve("");
|
|
135
|
-
const stderrPromise = child.stderr ? new Response(child.stderr).text() : Promise.resolve("");
|
|
136
|
-
const exitCodePromise = child.exited;
|
|
137
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
138
|
-
stdoutPromise,
|
|
139
|
-
stderrPromise,
|
|
140
|
-
exitCodePromise
|
|
141
|
-
]);
|
|
142
|
-
return {
|
|
143
|
-
stdout,
|
|
144
|
-
stderr,
|
|
145
|
-
exitCode: typeof exitCode === "number" ? exitCode : 1
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
// src/cli-helpers.ts
|
|
151
|
-
class ExitError extends Error {
|
|
152
|
-
code;
|
|
153
|
-
constructor(code) {
|
|
154
|
-
super(`Process exited with code ${code}`);
|
|
155
|
-
this.code = code;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
async function captureCLI(fn) {
|
|
159
|
-
const stdoutChunks = [];
|
|
160
|
-
const stderrChunks = [];
|
|
161
|
-
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
162
|
-
const originalStderrWrite = process.stderr.write.bind(process.stderr);
|
|
163
|
-
const originalExit = process.exit.bind(process);
|
|
164
|
-
const originalConsoleLog = console.log;
|
|
165
|
-
const originalConsoleError = console.error;
|
|
166
|
-
process.stdout.write = (chunk, _encoding, cb) => {
|
|
167
|
-
stdoutChunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk);
|
|
168
|
-
if (typeof cb === "function")
|
|
169
|
-
cb();
|
|
170
|
-
return true;
|
|
171
|
-
};
|
|
172
|
-
process.stderr.write = (chunk, _encoding, cb) => {
|
|
173
|
-
stderrChunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk);
|
|
174
|
-
if (typeof cb === "function")
|
|
175
|
-
cb();
|
|
176
|
-
return true;
|
|
177
|
-
};
|
|
178
|
-
console.log = (...args) => {
|
|
179
|
-
const line = `${args.map(String).join(" ")}
|
|
180
|
-
`;
|
|
181
|
-
stdoutChunks.push(new TextEncoder().encode(line));
|
|
182
|
-
};
|
|
183
|
-
console.error = (...args) => {
|
|
184
|
-
const line = `${args.map(String).join(" ")}
|
|
185
|
-
`;
|
|
186
|
-
stderrChunks.push(new TextEncoder().encode(line));
|
|
187
|
-
};
|
|
188
|
-
process.exit = (code) => {
|
|
189
|
-
throw new ExitError(code ?? 0);
|
|
190
|
-
};
|
|
191
|
-
let exitCode = 0;
|
|
192
|
-
try {
|
|
193
|
-
await fn();
|
|
194
|
-
} catch (error) {
|
|
195
|
-
if (error instanceof ExitError) {
|
|
196
|
-
exitCode = error.code;
|
|
197
|
-
} else {
|
|
198
|
-
exitCode = 1;
|
|
199
|
-
}
|
|
200
|
-
} finally {
|
|
201
|
-
process.stdout.write = originalStdoutWrite;
|
|
202
|
-
process.stderr.write = originalStderrWrite;
|
|
203
|
-
process.exit = originalExit;
|
|
204
|
-
console.log = originalConsoleLog;
|
|
205
|
-
console.error = originalConsoleError;
|
|
206
|
-
}
|
|
207
|
-
const decoder = new TextDecoder("utf-8");
|
|
208
|
-
return {
|
|
209
|
-
stdout: decoder.decode(concatChunks(stdoutChunks)),
|
|
210
|
-
stderr: decoder.decode(concatChunks(stderrChunks)),
|
|
211
|
-
exitCode
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
function mockStdin(input) {
|
|
215
|
-
const originalStdin = process.stdin;
|
|
216
|
-
const encoded = new TextEncoder().encode(input);
|
|
217
|
-
const mockStream = {
|
|
218
|
-
async* [Symbol.asyncIterator]() {
|
|
219
|
-
yield encoded;
|
|
220
|
-
},
|
|
221
|
-
fd: 0,
|
|
222
|
-
isTTY: false
|
|
223
|
-
};
|
|
224
|
-
process.stdin = mockStream;
|
|
225
|
-
return {
|
|
226
|
-
restore: () => {
|
|
227
|
-
process.stdin = originalStdin;
|
|
228
|
-
}
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
function concatChunks(chunks) {
|
|
232
|
-
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
233
|
-
const result = new Uint8Array(totalLength);
|
|
234
|
-
let offset = 0;
|
|
235
|
-
for (const chunk of chunks) {
|
|
236
|
-
result.set(chunk, offset);
|
|
237
|
-
offset += chunk.length;
|
|
238
|
-
}
|
|
239
|
-
return result;
|
|
240
|
-
}
|
|
241
|
-
// src/mcp-harness.ts
|
|
242
|
-
import {
|
|
243
|
-
createMcpServer
|
|
244
|
-
} from "@outfitter/mcp";
|
|
245
|
-
function createMcpHarness(server, options = {}) {
|
|
246
|
-
return {
|
|
247
|
-
callTool(name, input) {
|
|
248
|
-
return server.invokeTool(name, input);
|
|
249
|
-
},
|
|
250
|
-
listTools() {
|
|
251
|
-
return server.getTools();
|
|
252
|
-
},
|
|
253
|
-
searchTools(query) {
|
|
254
|
-
const normalized = query.trim().toLowerCase();
|
|
255
|
-
const tools = server.getTools();
|
|
256
|
-
if (normalized.length === 0) {
|
|
257
|
-
return tools;
|
|
258
|
-
}
|
|
259
|
-
return tools.filter((tool) => {
|
|
260
|
-
const nameMatch = tool.name.toLowerCase().includes(normalized);
|
|
261
|
-
const descriptionMatch = tool.description.toLowerCase().includes(normalized);
|
|
262
|
-
return nameMatch || descriptionMatch;
|
|
263
|
-
});
|
|
264
|
-
},
|
|
265
|
-
loadFixture(name) {
|
|
266
|
-
return loadFixture(name, options.fixturesDir ? { fixturesDir: options.fixturesDir } : undefined);
|
|
267
|
-
},
|
|
268
|
-
reset() {}
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
function createMCPTestHarness(options) {
|
|
272
|
-
const server = createMcpServer({
|
|
273
|
-
name: options.name ?? "mcp-test",
|
|
274
|
-
version: options.version ?? "0.0.0"
|
|
275
|
-
});
|
|
276
|
-
for (const tool of options.tools) {
|
|
277
|
-
server.registerTool(tool);
|
|
278
|
-
}
|
|
279
|
-
return createMcpHarness(server, {
|
|
280
|
-
...options.fixturesDir !== undefined ? { fixturesDir: options.fixturesDir } : {}
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
// src/mock-factories.ts
|
|
284
|
-
import {
|
|
285
|
-
generateRequestId
|
|
286
|
-
} from "@outfitter/contracts";
|
|
287
|
-
function createTestLogger(context = {}) {
|
|
288
|
-
return createTestLoggerWithContext(context, []);
|
|
289
|
-
}
|
|
290
|
-
function createTestLoggerWithContext(context, logs) {
|
|
291
|
-
const write = (level, message, data) => {
|
|
292
|
-
const merged = { ...context, ...data ?? {} };
|
|
293
|
-
const entry = {
|
|
294
|
-
level,
|
|
295
|
-
message
|
|
296
|
-
};
|
|
297
|
-
if (Object.keys(merged).length > 0) {
|
|
298
|
-
entry.data = merged;
|
|
299
|
-
}
|
|
300
|
-
logs.push(entry);
|
|
301
|
-
};
|
|
302
|
-
return {
|
|
303
|
-
logs,
|
|
304
|
-
clear() {
|
|
305
|
-
logs.length = 0;
|
|
306
|
-
},
|
|
307
|
-
trace: (message, metadata) => {
|
|
308
|
-
write("trace", message, metadata);
|
|
309
|
-
},
|
|
310
|
-
debug: (message, metadata) => {
|
|
311
|
-
write("debug", message, metadata);
|
|
312
|
-
},
|
|
313
|
-
info: (message, metadata) => {
|
|
314
|
-
write("info", message, metadata);
|
|
315
|
-
},
|
|
316
|
-
warn: (message, metadata) => {
|
|
317
|
-
write("warn", message, metadata);
|
|
318
|
-
},
|
|
319
|
-
error: (message, metadata) => {
|
|
320
|
-
write("error", message, metadata);
|
|
321
|
-
},
|
|
322
|
-
fatal: (message, metadata) => {
|
|
323
|
-
write("fatal", message, metadata);
|
|
324
|
-
},
|
|
325
|
-
child(childContext) {
|
|
326
|
-
return createTestLoggerWithContext({ ...context, ...childContext }, logs);
|
|
327
|
-
}
|
|
328
|
-
};
|
|
329
|
-
}
|
|
330
|
-
function createTestConfig(schema, values) {
|
|
331
|
-
const parsed = schema.safeParse(values);
|
|
332
|
-
let data;
|
|
333
|
-
if (parsed.success) {
|
|
334
|
-
data = parsed.data;
|
|
335
|
-
} else {
|
|
336
|
-
const maybePartial = schema.partial;
|
|
337
|
-
if (typeof maybePartial !== "function") {
|
|
338
|
-
throw parsed.error;
|
|
339
|
-
}
|
|
340
|
-
const partialSchema = maybePartial.call(schema);
|
|
341
|
-
const partialParsed = partialSchema.safeParse(values);
|
|
342
|
-
if (!partialParsed.success) {
|
|
343
|
-
throw partialParsed.error;
|
|
344
|
-
}
|
|
345
|
-
data = partialParsed.data;
|
|
346
|
-
}
|
|
347
|
-
return {
|
|
348
|
-
get(key) {
|
|
349
|
-
return getPath(data, key);
|
|
350
|
-
},
|
|
351
|
-
getRequired(key) {
|
|
352
|
-
const value = getPath(data, key);
|
|
353
|
-
if (value === undefined) {
|
|
354
|
-
throw new Error(`Missing required config value: ${key}`);
|
|
355
|
-
}
|
|
356
|
-
return value;
|
|
357
|
-
}
|
|
358
|
-
};
|
|
359
|
-
}
|
|
360
|
-
function createTestContext(overrides = {}) {
|
|
361
|
-
const logger = overrides.logger ?? createTestLogger();
|
|
362
|
-
const requestId = overrides.requestId ?? generateRequestId();
|
|
363
|
-
const context = {
|
|
364
|
-
requestId,
|
|
365
|
-
logger,
|
|
366
|
-
cwd: overrides.cwd ?? process.cwd(),
|
|
367
|
-
env: overrides.env ?? { ...process.env }
|
|
368
|
-
};
|
|
369
|
-
if (overrides.config !== undefined) {
|
|
370
|
-
context.config = overrides.config;
|
|
371
|
-
}
|
|
372
|
-
if (overrides.signal !== undefined) {
|
|
373
|
-
context.signal = overrides.signal;
|
|
374
|
-
}
|
|
375
|
-
if (overrides.workspaceRoot !== undefined) {
|
|
376
|
-
context.workspaceRoot = overrides.workspaceRoot;
|
|
377
|
-
}
|
|
378
|
-
return context;
|
|
379
|
-
}
|
|
380
|
-
function getPath(obj, key) {
|
|
381
|
-
const parts = key.split(".").filter((part) => part.length > 0);
|
|
382
|
-
let current = obj;
|
|
383
|
-
for (const part of parts) {
|
|
384
|
-
if (current === null || typeof current !== "object") {
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
current = current[part];
|
|
388
|
-
}
|
|
389
|
-
return current;
|
|
390
|
-
}
|
|
391
|
-
export {
|
|
392
|
-
withTempDir,
|
|
393
|
-
withEnv,
|
|
394
|
-
mockStdin,
|
|
395
|
-
loadFixture,
|
|
396
|
-
createTestLogger,
|
|
397
|
-
createTestContext,
|
|
398
|
-
createTestConfig,
|
|
399
|
-
createMCPTestHarness as createMcpTestHarness,
|
|
400
|
-
createMcpHarness,
|
|
401
|
-
createMCPTestHarness,
|
|
402
|
-
createFixture,
|
|
403
|
-
createCliHarness,
|
|
404
|
-
captureCLI
|
|
405
|
-
};
|
|
1
|
+
export { createFixture, loadFixture, withEnv, withTempDir } from "./fixtures.js";
|
|
2
|
+
export { createCliHarness } from "./cli-harness.js";
|
|
3
|
+
export { captureCLI, mockStdin } from "./cli-helpers.js";
|
|
4
|
+
export { createMCPTestHarness, createMcpHarness, createMcpTestHarness } from "./mcp-harness.js";
|
|
5
|
+
export { getTestContext, testCommand } from "./test-command.js";
|
|
6
|
+
export { testTool } from "./test-tool.js";
|
|
7
|
+
export { createTestConfig, createTestContext, createTestLogger } from "./mock-factories.js";
|
package/dist/mcp-harness.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { McpHarness, McpHarnessOptions, McpTestHarnessOptions, McpToolResponse, createMCPTestHarness, createMcpHarness } from "./shared/@outfitter/testing-
|
|
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 };
|
package/dist/mcp-harness.js
CHANGED
|
@@ -1,9 +1,51 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
loadFixture
|
|
4
|
+
} from "./shared/@outfitter/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
|
+
listResources() {
|
|
19
|
+
return server.getResources();
|
|
20
|
+
},
|
|
21
|
+
searchTools(query) {
|
|
22
|
+
const normalized = query.trim().toLowerCase();
|
|
23
|
+
const tools = server.getTools();
|
|
24
|
+
if (normalized.length === 0) {
|
|
25
|
+
return tools;
|
|
26
|
+
}
|
|
27
|
+
return tools.filter((tool) => {
|
|
28
|
+
const nameMatch = tool.name.toLowerCase().includes(normalized);
|
|
29
|
+
const descriptionMatch = tool.description.toLowerCase().includes(normalized);
|
|
30
|
+
return nameMatch || descriptionMatch;
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
loadFixture(name) {
|
|
34
|
+
return loadFixture(name, options.fixturesDir ? { fixturesDir: options.fixturesDir } : undefined);
|
|
35
|
+
},
|
|
36
|
+
reset() {}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function createMCPTestHarness(options) {
|
|
40
|
+
const server = createMcpServer({
|
|
41
|
+
name: options.name ?? "mcp-test",
|
|
42
|
+
version: options.version ?? "0.0.0"
|
|
43
|
+
});
|
|
44
|
+
for (const tool of options.tools) {
|
|
45
|
+
server.registerTool(tool);
|
|
46
|
+
}
|
|
47
|
+
return createMcpHarness(server, options.fixturesDir !== undefined ? { fixturesDir: options.fixturesDir } : {});
|
|
48
|
+
}
|
|
7
49
|
export {
|
|
8
50
|
createMCPTestHarness as createMcpTestHarness,
|
|
9
51
|
createMcpHarness,
|
package/dist/mock-factories.js
CHANGED
|
@@ -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-
|
|
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<
|
|
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[];
|