@xiaozhi-client/cli 1.9.4-beta.10
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 +98 -0
- package/package.json +31 -0
- package/project.json +75 -0
- package/src/Constants.ts +105 -0
- package/src/Container.ts +212 -0
- package/src/Types.ts +79 -0
- package/src/commands/CommandHandlerFactory.ts +98 -0
- package/src/commands/ConfigCommandHandler.ts +279 -0
- package/src/commands/EndpointCommandHandler.ts +158 -0
- package/src/commands/McpCommandHandler.ts +778 -0
- package/src/commands/ProjectCommandHandler.ts +254 -0
- package/src/commands/ServiceCommandHandler.ts +182 -0
- package/src/commands/__tests__/CommandHandlerFactory.test.ts +323 -0
- package/src/commands/__tests__/CommandRegistry.test.ts +287 -0
- package/src/commands/__tests__/ConfigCommandHandler.test.ts +844 -0
- package/src/commands/__tests__/EndpointCommandHandler.test.ts +426 -0
- package/src/commands/__tests__/McpCommandHandler.test.ts +753 -0
- package/src/commands/__tests__/ProjectCommandHandler.test.ts +230 -0
- package/src/commands/__tests__/ServiceCommands.integration.test.ts +408 -0
- package/src/commands/index.ts +351 -0
- package/src/errors/ErrorHandlers.ts +141 -0
- package/src/errors/ErrorMessages.ts +121 -0
- package/src/errors/__tests__/index.test.ts +186 -0
- package/src/errors/index.ts +163 -0
- package/src/global.d.ts +19 -0
- package/src/index.ts +53 -0
- package/src/interfaces/Command.ts +128 -0
- package/src/interfaces/CommandTypes.ts +95 -0
- package/src/interfaces/Config.ts +25 -0
- package/src/interfaces/Service.ts +99 -0
- package/src/services/DaemonManager.ts +318 -0
- package/src/services/ProcessManager.ts +235 -0
- package/src/services/ServiceManager.ts +319 -0
- package/src/services/TemplateManager.ts +382 -0
- package/src/services/__tests__/DaemonManager.test.ts +378 -0
- package/src/services/__tests__/DaemonMode.integration.test.ts +321 -0
- package/src/services/__tests__/ProcessManager.test.ts +296 -0
- package/src/services/__tests__/ServiceManager.test.ts +774 -0
- package/src/services/__tests__/TemplateManager.test.ts +337 -0
- package/src/types/backend.d.ts +48 -0
- package/src/utils/FileUtils.ts +320 -0
- package/src/utils/FormatUtils.ts +198 -0
- package/src/utils/PathUtils.ts +255 -0
- package/src/utils/PlatformUtils.ts +217 -0
- package/src/utils/Validation.ts +274 -0
- package/src/utils/VersionUtils.ts +141 -0
- package/src/utils/__tests__/FileUtils.test.ts +728 -0
- package/src/utils/__tests__/FormatUtils.test.ts +243 -0
- package/src/utils/__tests__/PathUtils.test.ts +1165 -0
- package/src/utils/__tests__/PlatformUtils.test.ts +723 -0
- package/src/utils/__tests__/Validation.test.ts +560 -0
- package/src/utils/__tests__/VersionUtils.test.ts +410 -0
- package/tsconfig.json +32 -0
- package/tsup.config.ts +100 -0
- package/vitest.config.ts +97 -0
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
import type { IDIContainer } from "@cli/interfaces/Config.js";
|
|
2
|
+
import { configManager } from "@xiaozhi-client/config";
|
|
3
|
+
import type {
|
|
4
|
+
MCPServerConfig,
|
|
5
|
+
MCPServerToolsConfig,
|
|
6
|
+
MCPToolConfig,
|
|
7
|
+
} from "@xiaozhi-client/config";
|
|
8
|
+
import ora from "ora";
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
import { McpCommandHandler } from "../McpCommandHandler.js";
|
|
11
|
+
|
|
12
|
+
// 测试专用类型定义
|
|
13
|
+
interface MockedOra {
|
|
14
|
+
start: ReturnType<typeof vi.fn>;
|
|
15
|
+
succeed: ReturnType<typeof vi.fn>;
|
|
16
|
+
fail: ReturnType<typeof vi.fn>;
|
|
17
|
+
warn: ReturnType<typeof vi.fn>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 简化 mock spinner 类型
|
|
21
|
+
interface MockSpinner extends MockedOra {
|
|
22
|
+
// 只包含我们实际需要的方法
|
|
23
|
+
text?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface MockServerConfig {
|
|
27
|
+
[serverName: string]: MCPServerConfig;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface MockServerToolsConfig {
|
|
31
|
+
[serverName: string]: MCPServerToolsConfig;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ListCommandOptions {
|
|
35
|
+
tools?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 为测试创建可访问私有方法的扩展类
|
|
39
|
+
class McpCommandHandlerTest extends McpCommandHandler {
|
|
40
|
+
// 公开静态私有方法用于测试
|
|
41
|
+
public static testGetDisplayWidth(str: string): number {
|
|
42
|
+
return (
|
|
43
|
+
McpCommandHandler as unknown as {
|
|
44
|
+
getDisplayWidth: (str: string) => number;
|
|
45
|
+
}
|
|
46
|
+
).getDisplayWidth(str);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public static testTruncateToWidth(str: string, maxWidth: number): string {
|
|
50
|
+
return (
|
|
51
|
+
McpCommandHandler as unknown as {
|
|
52
|
+
truncateToWidth: (str: string, maxWidth: number) => string;
|
|
53
|
+
}
|
|
54
|
+
).truncateToWidth(str, maxWidth);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 公开实例私有方法用于测试
|
|
58
|
+
public async testHandleListInternal(
|
|
59
|
+
options: ListCommandOptions = {}
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
return (
|
|
62
|
+
this as unknown as {
|
|
63
|
+
handleListInternal: (options: ListCommandOptions) => Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
).handleListInternal(options);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public async testHandleServerInternal(serverName: string): Promise<void> {
|
|
69
|
+
return (
|
|
70
|
+
this as unknown as {
|
|
71
|
+
handleServerInternal: (serverName: string) => Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
).handleServerInternal(serverName);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public async testHandleToolInternal(
|
|
77
|
+
serverName: string,
|
|
78
|
+
toolName: string,
|
|
79
|
+
enabled: boolean
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
return (
|
|
82
|
+
this as unknown as {
|
|
83
|
+
handleToolInternal: (
|
|
84
|
+
serverName: string,
|
|
85
|
+
toolName: string,
|
|
86
|
+
enabled: boolean
|
|
87
|
+
) => Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
).handleToolInternal(serverName, toolName, enabled);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
public async testHandleCall(
|
|
93
|
+
serviceName: string,
|
|
94
|
+
toolName: string,
|
|
95
|
+
argsString: string
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
return (
|
|
98
|
+
this as unknown as {
|
|
99
|
+
handleCall: (
|
|
100
|
+
serviceName: string,
|
|
101
|
+
toolName: string,
|
|
102
|
+
argsString: string
|
|
103
|
+
) => Promise<void>;
|
|
104
|
+
}
|
|
105
|
+
).handleCall(serviceName, toolName, argsString);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Mock dependencies
|
|
110
|
+
vi.mock("chalk", () => ({
|
|
111
|
+
default: {
|
|
112
|
+
cyan: vi.fn((text) => text),
|
|
113
|
+
bold: vi.fn((text) => text),
|
|
114
|
+
green: vi.fn((text) => text),
|
|
115
|
+
red: vi.fn((text) => text),
|
|
116
|
+
yellow: vi.fn((text) => text),
|
|
117
|
+
gray: vi.fn((text) => text),
|
|
118
|
+
},
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
vi.mock("ora", () => ({
|
|
122
|
+
default: vi.fn(() => ({
|
|
123
|
+
start: vi.fn().mockReturnThis(),
|
|
124
|
+
succeed: vi.fn().mockReturnThis(),
|
|
125
|
+
fail: vi.fn().mockReturnThis(),
|
|
126
|
+
warn: vi.fn().mockReturnThis(),
|
|
127
|
+
})),
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
vi.mock("@xiaozhi-client/config", () => ({
|
|
131
|
+
configManager: {
|
|
132
|
+
getMcpServers: vi.fn(),
|
|
133
|
+
getMcpServerConfig: vi.fn(),
|
|
134
|
+
getServerToolsConfig: vi.fn(),
|
|
135
|
+
setToolEnabled: vi.fn(),
|
|
136
|
+
getCustomMCPTools: vi.fn(),
|
|
137
|
+
getWebUIPort: vi.fn(),
|
|
138
|
+
},
|
|
139
|
+
}));
|
|
140
|
+
|
|
141
|
+
// Mock ProcessManager
|
|
142
|
+
const mockGetServiceStatus = vi
|
|
143
|
+
.fn()
|
|
144
|
+
.mockReturnValue({ running: false, pid: null });
|
|
145
|
+
|
|
146
|
+
vi.mock("@cli/services/ProcessManager.js", () => ({
|
|
147
|
+
ProcessManagerImpl: vi.fn().mockImplementation(() => ({
|
|
148
|
+
getServiceStatus: mockGetServiceStatus,
|
|
149
|
+
})),
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
// Mock fetch for HTTP API calls
|
|
153
|
+
global.fetch = vi.fn();
|
|
154
|
+
|
|
155
|
+
// Mock console methods
|
|
156
|
+
const mockConsoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
157
|
+
const mockConsoleError = vi
|
|
158
|
+
.spyOn(console, "error")
|
|
159
|
+
.mockImplementation(() => {});
|
|
160
|
+
// 设置测试环境变量
|
|
161
|
+
process.env.NODE_ENV = "test";
|
|
162
|
+
const mockProcessExit = vi.spyOn(process, "exit").mockImplementation(() => {
|
|
163
|
+
throw new Error("process.exit called");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("McpCommandHandler", () => {
|
|
167
|
+
const mockSpinner: MockSpinner = {
|
|
168
|
+
start: vi.fn().mockReturnThis(),
|
|
169
|
+
succeed: vi.fn().mockReturnThis(),
|
|
170
|
+
fail: vi.fn().mockReturnThis(),
|
|
171
|
+
warn: vi.fn().mockReturnThis(),
|
|
172
|
+
text: "",
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const mockContainer: IDIContainer = {
|
|
176
|
+
get: vi.fn(),
|
|
177
|
+
has: vi.fn(),
|
|
178
|
+
register: vi.fn(),
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
let handler: McpCommandHandlerTest;
|
|
182
|
+
|
|
183
|
+
beforeEach(() => {
|
|
184
|
+
vi.clearAllMocks();
|
|
185
|
+
vi.mocked(ora).mockReturnValue(
|
|
186
|
+
mockSpinner as unknown as ReturnType<typeof ora>
|
|
187
|
+
);
|
|
188
|
+
handler = new McpCommandHandlerTest(mockContainer);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
afterEach(() => {
|
|
192
|
+
vi.clearAllMocks();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("静态工具函数", () => {
|
|
196
|
+
describe("getDisplayWidth", () => {
|
|
197
|
+
it("应该正确计算英文字符的宽度", () => {
|
|
198
|
+
expect(McpCommandHandlerTest.testGetDisplayWidth("hello")).toBe(5);
|
|
199
|
+
expect(McpCommandHandlerTest.testGetDisplayWidth("Hello World!")).toBe(
|
|
200
|
+
12
|
|
201
|
+
);
|
|
202
|
+
expect(McpCommandHandlerTest.testGetDisplayWidth("")).toBe(0);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("应该正确计算中文字符的宽度", () => {
|
|
206
|
+
expect(McpCommandHandlerTest.testGetDisplayWidth("你好")).toBe(4); // 2个中文字符 = 4宽度
|
|
207
|
+
expect(McpCommandHandlerTest.testGetDisplayWidth("中文测试")).toBe(8); // 4个中文字符 = 8宽度
|
|
208
|
+
expect(McpCommandHandlerTest.testGetDisplayWidth("测试")).toBe(4); // 2个中文字符 = 4宽度
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("应该正确计算混合字符的宽度", () => {
|
|
212
|
+
expect(McpCommandHandlerTest.testGetDisplayWidth("Hello你好")).toBe(9); // 5个英文 + 2个中文 = 5 + 4 = 9
|
|
213
|
+
expect(McpCommandHandlerTest.testGetDisplayWidth("测试Test")).toBe(8); // 2个中文 + 4个英文 = 4 + 4 = 8
|
|
214
|
+
expect(
|
|
215
|
+
McpCommandHandlerTest.testGetDisplayWidth("中文English混合")
|
|
216
|
+
).toBe(15); // 2个中文 + 7个英文 + 2个中文 = 4 + 7 + 4 = 15
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("应该正确处理中文标点符号", () => {
|
|
220
|
+
expect(McpCommandHandlerTest.testGetDisplayWidth("你好,世界!")).toBe(
|
|
221
|
+
12
|
|
222
|
+
); // 4个中文字符 + 2个中文标点 = 12
|
|
223
|
+
expect(McpCommandHandlerTest.testGetDisplayWidth("测试:成功")).toBe(
|
|
224
|
+
10
|
|
225
|
+
); // 4个中文字符 + 1个中文冒号 = 10
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("应该处理特殊字符", () => {
|
|
229
|
+
expect(
|
|
230
|
+
McpCommandHandlerTest.testGetDisplayWidth("test@example.com")
|
|
231
|
+
).toBe(16);
|
|
232
|
+
expect(McpCommandHandlerTest.testGetDisplayWidth("123-456-789")).toBe(
|
|
233
|
+
11
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("truncateToWidth", () => {
|
|
239
|
+
it("应该不截断宽度限制内的字符串", () => {
|
|
240
|
+
expect(McpCommandHandlerTest.testTruncateToWidth("hello", 10)).toBe(
|
|
241
|
+
"hello"
|
|
242
|
+
);
|
|
243
|
+
expect(McpCommandHandlerTest.testTruncateToWidth("你好", 10)).toBe(
|
|
244
|
+
"你好"
|
|
245
|
+
);
|
|
246
|
+
expect(McpCommandHandlerTest.testTruncateToWidth("Hello你好", 10)).toBe(
|
|
247
|
+
"Hello你好"
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("应该正确截断英文字符串", () => {
|
|
252
|
+
expect(
|
|
253
|
+
McpCommandHandlerTest.testTruncateToWidth("Hello World", 8)
|
|
254
|
+
).toBe("Hello...");
|
|
255
|
+
expect(
|
|
256
|
+
McpCommandHandlerTest.testTruncateToWidth(
|
|
257
|
+
"This is a very long description",
|
|
258
|
+
15
|
|
259
|
+
)
|
|
260
|
+
).toBe("This is a ve...");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("应该正确截断中文字符串", () => {
|
|
264
|
+
// "这是一个很长的描述文本" = 16宽度, maxWidth=10, 所以 "这是一..." = 7宽度
|
|
265
|
+
expect(
|
|
266
|
+
McpCommandHandlerTest.testTruncateToWidth(
|
|
267
|
+
"这是一个很长的描述文本",
|
|
268
|
+
10
|
|
269
|
+
)
|
|
270
|
+
).toBe("这是一...");
|
|
271
|
+
// "中文测试内容" = 10宽度, maxWidth=6, 所以 "中..." = 5宽度
|
|
272
|
+
expect(
|
|
273
|
+
McpCommandHandlerTest.testTruncateToWidth("中文测试内容", 6)
|
|
274
|
+
).toBe("中...");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("应该正确截断混合字符串", () => {
|
|
278
|
+
// "Hello你好World" = 13宽度, maxWidth=10, 所以 "Hello你..." = 10宽度
|
|
279
|
+
expect(
|
|
280
|
+
McpCommandHandlerTest.testTruncateToWidth("Hello你好World", 10)
|
|
281
|
+
).toBe("Hello你...");
|
|
282
|
+
// "测试Test内容" = 12宽度, maxWidth=8, 所以 "测试T..." = 8宽度
|
|
283
|
+
expect(
|
|
284
|
+
McpCommandHandlerTest.testTruncateToWidth("测试Test内容", 8)
|
|
285
|
+
).toBe("测试T...");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("应该处理边界情况", () => {
|
|
289
|
+
expect(McpCommandHandlerTest.testTruncateToWidth("", 10)).toBe("");
|
|
290
|
+
expect(McpCommandHandlerTest.testTruncateToWidth("a", 1)).toBe("a");
|
|
291
|
+
expect(McpCommandHandlerTest.testTruncateToWidth("ab", 1)).toBe(""); // 连一个字符 + "..." 都放不下
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("应该处理非常短的宽度限制", () => {
|
|
295
|
+
expect(McpCommandHandlerTest.testTruncateToWidth("hello", 3)).toBe(""); // maxWidth <= 3, 返回空字符串
|
|
296
|
+
expect(McpCommandHandlerTest.testTruncateToWidth("hello", 4)).toBe(
|
|
297
|
+
"h..."
|
|
298
|
+
);
|
|
299
|
+
expect(McpCommandHandlerTest.testTruncateToWidth("你好", 4)).toBe(
|
|
300
|
+
"你好"
|
|
301
|
+
); // "你好" 宽度=4, 正好符合 maxWidth=4
|
|
302
|
+
expect(McpCommandHandlerTest.testTruncateToWidth("你好世界", 4)).toBe(
|
|
303
|
+
""
|
|
304
|
+
); // "你好世界" 宽度=8 > 4, 但连一个字符 + "..." 都放不下
|
|
305
|
+
expect(McpCommandHandlerTest.testTruncateToWidth("你好", 5)).toBe(
|
|
306
|
+
"你好"
|
|
307
|
+
); // "你好" 宽度=4 <= maxWidth=5, 不需要截断
|
|
308
|
+
expect(McpCommandHandlerTest.testTruncateToWidth("你好世界", 5)).toBe(
|
|
309
|
+
"你..."
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe("handleListInternal", () => {
|
|
316
|
+
const mockServers: MockServerConfig = {
|
|
317
|
+
calculator: {
|
|
318
|
+
command: "node",
|
|
319
|
+
args: ["./mcpServers/calculator.js"],
|
|
320
|
+
},
|
|
321
|
+
datetime: {
|
|
322
|
+
command: "node",
|
|
323
|
+
args: ["./mcpServers/datetime.js"],
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const mockServerConfig: MockServerToolsConfig = {
|
|
328
|
+
calculator: {
|
|
329
|
+
tools: {
|
|
330
|
+
calculator: {
|
|
331
|
+
description: "数学计算工具",
|
|
332
|
+
enable: true,
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
datetime: {
|
|
337
|
+
tools: {
|
|
338
|
+
get_current_time: {
|
|
339
|
+
description: "获取当前时间",
|
|
340
|
+
enable: true,
|
|
341
|
+
},
|
|
342
|
+
get_current_date: {
|
|
343
|
+
description: "获取当前日期",
|
|
344
|
+
enable: false,
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
beforeEach(() => {
|
|
351
|
+
vi.mocked(configManager.getMcpServers).mockReturnValue(mockServers);
|
|
352
|
+
vi.mocked(configManager.getMcpServerConfig).mockReturnValue(
|
|
353
|
+
mockServerConfig
|
|
354
|
+
);
|
|
355
|
+
vi.mocked(configManager.getServerToolsConfig).mockImplementation(
|
|
356
|
+
(serverName: string) => {
|
|
357
|
+
return mockServerConfig[serverName]?.tools || {};
|
|
358
|
+
}
|
|
359
|
+
);
|
|
360
|
+
vi.mocked(configManager.getCustomMCPTools).mockReturnValue([]);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("应该列出不带工具选项的服务", async () => {
|
|
364
|
+
await handler.testHandleListInternal();
|
|
365
|
+
|
|
366
|
+
expect(mockSpinner.succeed).toHaveBeenCalledWith("找到 2 个 MCP 服务");
|
|
367
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
368
|
+
expect.stringContaining("MCP 服务列表:")
|
|
369
|
+
);
|
|
370
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
371
|
+
expect.stringContaining("calculator")
|
|
372
|
+
);
|
|
373
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
374
|
+
expect.stringContaining("datetime")
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("应该使用cli-table3列出带工具选项的服务", async () => {
|
|
379
|
+
await handler.testHandleListInternal({ tools: true });
|
|
380
|
+
|
|
381
|
+
expect(mockSpinner.succeed).toHaveBeenCalledWith("找到 2 个 MCP 服务");
|
|
382
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
383
|
+
expect.stringContaining("MCP 服务工具列表:")
|
|
384
|
+
);
|
|
385
|
+
// 检查表格输出中的工具名称(现在只显示工具名,不包含服务名前缀)
|
|
386
|
+
const tableOutput = mockConsoleLog.mock.calls.find(
|
|
387
|
+
(call) =>
|
|
388
|
+
call[0] &&
|
|
389
|
+
typeof call[0] === "string" &&
|
|
390
|
+
call[0].includes("calculator")
|
|
391
|
+
);
|
|
392
|
+
expect(tableOutput).toBeDefined();
|
|
393
|
+
|
|
394
|
+
const timeToolOutput = mockConsoleLog.mock.calls.find(
|
|
395
|
+
(call) =>
|
|
396
|
+
call[0] &&
|
|
397
|
+
typeof call[0] === "string" &&
|
|
398
|
+
call[0].includes("get_current_time")
|
|
399
|
+
);
|
|
400
|
+
expect(timeToolOutput).toBeDefined();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("应该处理空服务列表", async () => {
|
|
404
|
+
vi.mocked(configManager.getMcpServers).mockReturnValue({});
|
|
405
|
+
|
|
406
|
+
await handler.testHandleListInternal();
|
|
407
|
+
|
|
408
|
+
expect(mockSpinner.warn).toHaveBeenCalledWith(
|
|
409
|
+
"未配置任何 MCP 服务或 customMCP 工具"
|
|
410
|
+
);
|
|
411
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
412
|
+
expect.stringContaining("提示: 使用 'xiaozhi config' 命令配置 MCP 服务")
|
|
413
|
+
);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("应该优雅地处理错误", async () => {
|
|
417
|
+
vi.mocked(configManager.getMcpServers).mockImplementation(() => {
|
|
418
|
+
throw new Error("Config error");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
await expect(async () => {
|
|
422
|
+
await handler.testHandleListInternal();
|
|
423
|
+
}).rejects.toThrow("process.exit called");
|
|
424
|
+
|
|
425
|
+
expect(mockSpinner.fail).toHaveBeenCalledWith("获取 MCP 服务列表失败");
|
|
426
|
+
expect(mockConsoleError).toHaveBeenCalledWith(
|
|
427
|
+
expect.stringContaining("错误: Config error")
|
|
428
|
+
);
|
|
429
|
+
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
describe("handleServerInternal", () => {
|
|
434
|
+
const mockServers: MockServerConfig = {
|
|
435
|
+
datetime: {
|
|
436
|
+
command: "node",
|
|
437
|
+
args: ["./mcpServers/datetime.js"],
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const mockToolsConfig: Record<string, MCPToolConfig> = {
|
|
442
|
+
get_current_time: {
|
|
443
|
+
description: "获取当前时间",
|
|
444
|
+
enable: true,
|
|
445
|
+
},
|
|
446
|
+
get_current_date: {
|
|
447
|
+
description: "获取当前日期",
|
|
448
|
+
enable: false,
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
beforeEach(() => {
|
|
453
|
+
vi.mocked(configManager.getMcpServers).mockReturnValue(mockServers);
|
|
454
|
+
vi.mocked(configManager.getServerToolsConfig).mockReturnValue(
|
|
455
|
+
mockToolsConfig
|
|
456
|
+
);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("应该列出现有服务的工具", async () => {
|
|
460
|
+
await handler.testHandleServerInternal("datetime");
|
|
461
|
+
|
|
462
|
+
expect(mockSpinner.succeed).toHaveBeenCalledWith(
|
|
463
|
+
"服务 'datetime' 共有 2 个工具"
|
|
464
|
+
);
|
|
465
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
466
|
+
expect.stringContaining("datetime 服务工具列表:")
|
|
467
|
+
);
|
|
468
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
469
|
+
expect.stringContaining("get_current_time")
|
|
470
|
+
);
|
|
471
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
472
|
+
expect.stringContaining("get_current_date")
|
|
473
|
+
);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("应该处理不存在的服务", async () => {
|
|
477
|
+
vi.mocked(configManager.getMcpServers).mockReturnValue({});
|
|
478
|
+
|
|
479
|
+
await handler.testHandleServerInternal("non-existent");
|
|
480
|
+
|
|
481
|
+
expect(mockSpinner.fail).toHaveBeenCalledWith(
|
|
482
|
+
"服务 'non-existent' 不存在"
|
|
483
|
+
);
|
|
484
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
485
|
+
expect.stringContaining(
|
|
486
|
+
"提示: 使用 'xiaozhi mcp list' 查看所有可用服务"
|
|
487
|
+
)
|
|
488
|
+
);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("应该优雅地处理错误", async () => {
|
|
492
|
+
vi.mocked(configManager.getMcpServers).mockImplementation(() => {
|
|
493
|
+
throw new Error("Config error");
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
await expect(async () => {
|
|
497
|
+
await handler.testHandleServerInternal("datetime");
|
|
498
|
+
}).rejects.toThrow("process.exit called");
|
|
499
|
+
|
|
500
|
+
expect(mockSpinner.fail).toHaveBeenCalledWith("获取工具列表失败");
|
|
501
|
+
expect(mockConsoleError).toHaveBeenCalledWith(
|
|
502
|
+
expect.stringContaining("错误: Config error")
|
|
503
|
+
);
|
|
504
|
+
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
describe("handleToolInternal", () => {
|
|
509
|
+
const mockServers: MockServerConfig = {
|
|
510
|
+
datetime: {
|
|
511
|
+
command: "node",
|
|
512
|
+
args: ["./mcpServers/datetime.js"],
|
|
513
|
+
},
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const mockToolsConfig: Record<string, MCPToolConfig> = {
|
|
517
|
+
get_current_time: {
|
|
518
|
+
description: "获取当前时间",
|
|
519
|
+
enable: true,
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
beforeEach(() => {
|
|
524
|
+
vi.mocked(configManager.getMcpServers).mockReturnValue(mockServers);
|
|
525
|
+
vi.mocked(configManager.getServerToolsConfig).mockReturnValue(
|
|
526
|
+
mockToolsConfig
|
|
527
|
+
);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("应该成功启用工具", async () => {
|
|
531
|
+
await handler.testHandleToolInternal(
|
|
532
|
+
"datetime",
|
|
533
|
+
"get_current_time",
|
|
534
|
+
true
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
expect(mockSpinner.succeed).toHaveBeenCalledWith(
|
|
538
|
+
expect.stringContaining("成功启用工具")
|
|
539
|
+
);
|
|
540
|
+
expect(configManager.setToolEnabled).toHaveBeenCalledWith(
|
|
541
|
+
"datetime",
|
|
542
|
+
"get_current_time",
|
|
543
|
+
true,
|
|
544
|
+
"获取当前时间"
|
|
545
|
+
);
|
|
546
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
547
|
+
expect.stringContaining("提示: 工具状态更改将在下次启动服务时生效")
|
|
548
|
+
);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("应该成功禁用工具", async () => {
|
|
552
|
+
await handler.testHandleToolInternal(
|
|
553
|
+
"datetime",
|
|
554
|
+
"get_current_time",
|
|
555
|
+
false
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
expect(mockSpinner.succeed).toHaveBeenCalledWith(
|
|
559
|
+
expect.stringContaining("成功禁用工具")
|
|
560
|
+
);
|
|
561
|
+
expect(configManager.setToolEnabled).toHaveBeenCalledWith(
|
|
562
|
+
"datetime",
|
|
563
|
+
"get_current_time",
|
|
564
|
+
false,
|
|
565
|
+
"获取当前时间"
|
|
566
|
+
);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it("应该处理不存在的服务", async () => {
|
|
570
|
+
vi.mocked(configManager.getMcpServers).mockReturnValue({});
|
|
571
|
+
|
|
572
|
+
await handler.testHandleToolInternal("non-existent", "tool", true);
|
|
573
|
+
|
|
574
|
+
expect(mockSpinner.fail).toHaveBeenCalledWith(
|
|
575
|
+
"服务 'non-existent' 不存在"
|
|
576
|
+
);
|
|
577
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
578
|
+
expect.stringContaining(
|
|
579
|
+
"提示: 使用 'xiaozhi mcp list' 查看所有可用服务"
|
|
580
|
+
)
|
|
581
|
+
);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("应该处理不存在的工具", async () => {
|
|
585
|
+
vi.mocked(configManager.getServerToolsConfig).mockReturnValue({});
|
|
586
|
+
|
|
587
|
+
await handler.testHandleToolInternal(
|
|
588
|
+
"datetime",
|
|
589
|
+
"non-existent-tool",
|
|
590
|
+
true
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
expect(mockSpinner.fail).toHaveBeenCalledWith(
|
|
594
|
+
"工具 'non-existent-tool' 在服务 'datetime' 中不存在"
|
|
595
|
+
);
|
|
596
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
597
|
+
expect.stringContaining(
|
|
598
|
+
"提示: 使用 'xiaozhi mcp datetime list' 查看该服务的所有工具"
|
|
599
|
+
)
|
|
600
|
+
);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("应该优雅地处理启用工具时的错误", async () => {
|
|
604
|
+
vi.mocked(configManager.getMcpServers).mockImplementation(() => {
|
|
605
|
+
throw new Error("Config error");
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
await expect(async () => {
|
|
609
|
+
await handler.testHandleToolInternal("datetime", "tool", true);
|
|
610
|
+
}).rejects.toThrow("process.exit called");
|
|
611
|
+
|
|
612
|
+
expect(mockSpinner.fail).toHaveBeenCalledWith("启用工具失败");
|
|
613
|
+
expect(mockConsoleError).toHaveBeenCalledWith(
|
|
614
|
+
expect.stringContaining("错误: Config error")
|
|
615
|
+
);
|
|
616
|
+
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
describe("handleCall", () => {
|
|
621
|
+
beforeEach(() => {
|
|
622
|
+
// 默认 mock Web 端口
|
|
623
|
+
vi.mocked(configManager.getWebUIPort).mockReturnValue(9999);
|
|
624
|
+
// 默认 mock 服务未运行状态
|
|
625
|
+
mockGetServiceStatus.mockReturnValue({ running: false, pid: null });
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it("应该成功调用工具并返回结果", async () => {
|
|
629
|
+
// Mock ProcessManager 返回服务运行中
|
|
630
|
+
mockGetServiceStatus.mockReturnValue({
|
|
631
|
+
running: true,
|
|
632
|
+
pid: 12345,
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// Mock fetch 返回成功响应
|
|
636
|
+
const mockFetch = vi.mocked(fetch);
|
|
637
|
+
mockFetch
|
|
638
|
+
.mockResolvedValueOnce({
|
|
639
|
+
ok: true,
|
|
640
|
+
json: async () => ({ success: true }),
|
|
641
|
+
} as Response)
|
|
642
|
+
.mockResolvedValueOnce({
|
|
643
|
+
ok: true,
|
|
644
|
+
json: async () => ({
|
|
645
|
+
success: true,
|
|
646
|
+
data: {
|
|
647
|
+
content: [{ type: "text", text: "3" }],
|
|
648
|
+
},
|
|
649
|
+
}),
|
|
650
|
+
} as Response);
|
|
651
|
+
|
|
652
|
+
await handler.testHandleCall("calculator", "calculator", '{"a": 1}');
|
|
653
|
+
|
|
654
|
+
// 验证调用了正确的 API
|
|
655
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
656
|
+
"http://localhost:9999/api/tools/call",
|
|
657
|
+
{
|
|
658
|
+
method: "POST",
|
|
659
|
+
headers: {
|
|
660
|
+
"Content-Type": "application/json",
|
|
661
|
+
},
|
|
662
|
+
body: JSON.stringify({
|
|
663
|
+
serviceName: "calculator",
|
|
664
|
+
toolName: "calculator",
|
|
665
|
+
args: { a: 1 },
|
|
666
|
+
}),
|
|
667
|
+
}
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
// 验证输出结果
|
|
671
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
672
|
+
'{"content":[{"type":"text","text":"3"}]}'
|
|
673
|
+
);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it("应该在参数格式错误时抛出错误", async () => {
|
|
677
|
+
await expect(async () => {
|
|
678
|
+
await handler.testHandleCall(
|
|
679
|
+
"calculator",
|
|
680
|
+
"calculator",
|
|
681
|
+
"invalid-json"
|
|
682
|
+
);
|
|
683
|
+
}).rejects.toThrow("process.exit called");
|
|
684
|
+
|
|
685
|
+
expect(mockConsoleError).toHaveBeenCalledWith(
|
|
686
|
+
expect.stringContaining("错误:"),
|
|
687
|
+
expect.stringContaining("参数格式错误")
|
|
688
|
+
);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it("应该在服务未启动时显示提示", async () => {
|
|
692
|
+
// 重置 ProcessManager mock 返回服务未运行
|
|
693
|
+
mockGetServiceStatus.mockReturnValue({ running: false, pid: null });
|
|
694
|
+
|
|
695
|
+
// 确保 fetch 返回一个有效的 Response,但由于服务未启动,不应该调用到 fetch
|
|
696
|
+
const mockFetch = vi.mocked(fetch);
|
|
697
|
+
mockFetch.mockResolvedValue({
|
|
698
|
+
ok: true,
|
|
699
|
+
json: async () => ({ success: true }),
|
|
700
|
+
} as Response);
|
|
701
|
+
|
|
702
|
+
await expect(async () => {
|
|
703
|
+
await handler.testHandleCall("calculator", "calculator", '{"a": 1}');
|
|
704
|
+
}).rejects.toThrow();
|
|
705
|
+
|
|
706
|
+
// 验证错误处理流程 - 服务未启动的错误应该在调用 fetch 之前被抛出
|
|
707
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
708
|
+
expect.stringContaining("工具调用失败:")
|
|
709
|
+
);
|
|
710
|
+
expect(mockConsoleError).toHaveBeenCalledWith(
|
|
711
|
+
"错误:",
|
|
712
|
+
expect.stringContaining("服务未启动")
|
|
713
|
+
);
|
|
714
|
+
// 测试环境中会直接 throw,不调用 process.exit(1)
|
|
715
|
+
|
|
716
|
+
// fetch 不应该被调用,因为在服务状态检查时就失败了
|
|
717
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it("应该在 HTTP API 调用失败时显示错误", async () => {
|
|
721
|
+
// Mock ProcessManager 返回服务运行中
|
|
722
|
+
mockGetServiceStatus.mockReturnValue({
|
|
723
|
+
running: true,
|
|
724
|
+
pid: 12345,
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// Mock fetch 返回失败响应
|
|
728
|
+
const mockFetch = vi.mocked(fetch);
|
|
729
|
+
mockFetch
|
|
730
|
+
.mockResolvedValueOnce({
|
|
731
|
+
ok: true,
|
|
732
|
+
json: async () => ({ success: true }),
|
|
733
|
+
} as Response)
|
|
734
|
+
.mockResolvedValueOnce({
|
|
735
|
+
ok: false,
|
|
736
|
+
status: 500,
|
|
737
|
+
statusText: "Internal Server Error",
|
|
738
|
+
json: async () => ({
|
|
739
|
+
success: false,
|
|
740
|
+
error: { message: "工具调用失败" },
|
|
741
|
+
}),
|
|
742
|
+
} as Response);
|
|
743
|
+
|
|
744
|
+
await expect(async () => {
|
|
745
|
+
await handler.testHandleCall("calculator", "calculator", '{"a": 1}');
|
|
746
|
+
}).rejects.toThrow("process.exit called");
|
|
747
|
+
|
|
748
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
749
|
+
expect.stringContaining("工具调用失败:")
|
|
750
|
+
);
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
});
|