@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.
Files changed (55) hide show
  1. package/README.md +98 -0
  2. package/package.json +31 -0
  3. package/project.json +75 -0
  4. package/src/Constants.ts +105 -0
  5. package/src/Container.ts +212 -0
  6. package/src/Types.ts +79 -0
  7. package/src/commands/CommandHandlerFactory.ts +98 -0
  8. package/src/commands/ConfigCommandHandler.ts +279 -0
  9. package/src/commands/EndpointCommandHandler.ts +158 -0
  10. package/src/commands/McpCommandHandler.ts +778 -0
  11. package/src/commands/ProjectCommandHandler.ts +254 -0
  12. package/src/commands/ServiceCommandHandler.ts +182 -0
  13. package/src/commands/__tests__/CommandHandlerFactory.test.ts +323 -0
  14. package/src/commands/__tests__/CommandRegistry.test.ts +287 -0
  15. package/src/commands/__tests__/ConfigCommandHandler.test.ts +844 -0
  16. package/src/commands/__tests__/EndpointCommandHandler.test.ts +426 -0
  17. package/src/commands/__tests__/McpCommandHandler.test.ts +753 -0
  18. package/src/commands/__tests__/ProjectCommandHandler.test.ts +230 -0
  19. package/src/commands/__tests__/ServiceCommands.integration.test.ts +408 -0
  20. package/src/commands/index.ts +351 -0
  21. package/src/errors/ErrorHandlers.ts +141 -0
  22. package/src/errors/ErrorMessages.ts +121 -0
  23. package/src/errors/__tests__/index.test.ts +186 -0
  24. package/src/errors/index.ts +163 -0
  25. package/src/global.d.ts +19 -0
  26. package/src/index.ts +53 -0
  27. package/src/interfaces/Command.ts +128 -0
  28. package/src/interfaces/CommandTypes.ts +95 -0
  29. package/src/interfaces/Config.ts +25 -0
  30. package/src/interfaces/Service.ts +99 -0
  31. package/src/services/DaemonManager.ts +318 -0
  32. package/src/services/ProcessManager.ts +235 -0
  33. package/src/services/ServiceManager.ts +319 -0
  34. package/src/services/TemplateManager.ts +382 -0
  35. package/src/services/__tests__/DaemonManager.test.ts +378 -0
  36. package/src/services/__tests__/DaemonMode.integration.test.ts +321 -0
  37. package/src/services/__tests__/ProcessManager.test.ts +296 -0
  38. package/src/services/__tests__/ServiceManager.test.ts +774 -0
  39. package/src/services/__tests__/TemplateManager.test.ts +337 -0
  40. package/src/types/backend.d.ts +48 -0
  41. package/src/utils/FileUtils.ts +320 -0
  42. package/src/utils/FormatUtils.ts +198 -0
  43. package/src/utils/PathUtils.ts +255 -0
  44. package/src/utils/PlatformUtils.ts +217 -0
  45. package/src/utils/Validation.ts +274 -0
  46. package/src/utils/VersionUtils.ts +141 -0
  47. package/src/utils/__tests__/FileUtils.test.ts +728 -0
  48. package/src/utils/__tests__/FormatUtils.test.ts +243 -0
  49. package/src/utils/__tests__/PathUtils.test.ts +1165 -0
  50. package/src/utils/__tests__/PlatformUtils.test.ts +723 -0
  51. package/src/utils/__tests__/Validation.test.ts +560 -0
  52. package/src/utils/__tests__/VersionUtils.test.ts +410 -0
  53. package/tsconfig.json +32 -0
  54. package/tsup.config.ts +100 -0
  55. 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
+ });