@xiaozhi-client/cli 1.9.4-beta.5

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/fix-imports.js +32 -0
  4. package/package.json +26 -0
  5. package/project.json +75 -0
  6. package/src/Constants.ts +105 -0
  7. package/src/Container.ts +212 -0
  8. package/src/Types.ts +79 -0
  9. package/src/commands/CommandHandlerFactory.ts +98 -0
  10. package/src/commands/ConfigCommandHandler.ts +279 -0
  11. package/src/commands/EndpointCommandHandler.ts +158 -0
  12. package/src/commands/McpCommandHandler.ts +778 -0
  13. package/src/commands/ProjectCommandHandler.ts +254 -0
  14. package/src/commands/ServiceCommandHandler.ts +182 -0
  15. package/src/commands/__tests__/CommandHandlerFactory.test.ts +323 -0
  16. package/src/commands/__tests__/CommandRegistry.test.ts +287 -0
  17. package/src/commands/__tests__/ConfigCommandHandler.test.ts +844 -0
  18. package/src/commands/__tests__/EndpointCommandHandler.test.ts +426 -0
  19. package/src/commands/__tests__/McpCommandHandler.test.ts +753 -0
  20. package/src/commands/__tests__/ProjectCommandHandler.test.ts +230 -0
  21. package/src/commands/__tests__/ServiceCommands.integration.test.ts +408 -0
  22. package/src/commands/index.ts +351 -0
  23. package/src/errors/ErrorHandlers.ts +141 -0
  24. package/src/errors/ErrorMessages.ts +121 -0
  25. package/src/errors/__tests__/index.test.ts +186 -0
  26. package/src/errors/index.ts +163 -0
  27. package/src/global.d.ts +19 -0
  28. package/src/index.ts +53 -0
  29. package/src/interfaces/Command.ts +128 -0
  30. package/src/interfaces/CommandTypes.ts +95 -0
  31. package/src/interfaces/Config.ts +25 -0
  32. package/src/interfaces/Service.ts +99 -0
  33. package/src/services/DaemonManager.ts +318 -0
  34. package/src/services/ProcessManager.ts +235 -0
  35. package/src/services/ServiceManager.ts +319 -0
  36. package/src/services/TemplateManager.ts +382 -0
  37. package/src/services/__tests__/DaemonManager.test.ts +378 -0
  38. package/src/services/__tests__/DaemonMode.integration.test.ts +321 -0
  39. package/src/services/__tests__/ProcessManager.test.ts +296 -0
  40. package/src/services/__tests__/ServiceManager.test.ts +774 -0
  41. package/src/services/__tests__/TemplateManager.test.ts +337 -0
  42. package/src/types/backend.d.ts +48 -0
  43. package/src/utils/FileUtils.ts +320 -0
  44. package/src/utils/FormatUtils.ts +198 -0
  45. package/src/utils/PathUtils.ts +255 -0
  46. package/src/utils/PlatformUtils.ts +217 -0
  47. package/src/utils/Validation.ts +274 -0
  48. package/src/utils/VersionUtils.ts +141 -0
  49. package/src/utils/__tests__/FileUtils.test.ts +728 -0
  50. package/src/utils/__tests__/FormatUtils.test.ts +243 -0
  51. package/src/utils/__tests__/PathUtils.test.ts +1165 -0
  52. package/src/utils/__tests__/PlatformUtils.test.ts +723 -0
  53. package/src/utils/__tests__/Validation.test.ts +560 -0
  54. package/src/utils/__tests__/VersionUtils.test.ts +410 -0
  55. package/tsconfig.json +32 -0
  56. package/tsconfig.tsbuildinfo +1 -0
  57. package/tsup.config.ts +107 -0
  58. package/vitest.config.ts +97 -0
@@ -0,0 +1,774 @@
1
+ /**
2
+ * 服务管理服务单元测试
3
+ */
4
+
5
+ import { ConfigError, ServiceError } from "@cli/errors/index.js";
6
+ import type {
7
+ ProcessManager,
8
+ ServiceStartOptions,
9
+ } from "@cli/interfaces/Service.js";
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
+ import { ServiceManagerImpl } from "../ServiceManager";
12
+
13
+ // Mock 依赖
14
+ const mockProcessManager: ProcessManager = {
15
+ getServiceStatus: vi.fn(),
16
+ killProcess: vi.fn(),
17
+ cleanupPidFile: vi.fn(),
18
+ isXiaozhiProcess: vi.fn(),
19
+ savePidInfo: vi.fn(),
20
+ gracefulKillProcess: vi.fn(),
21
+ processExists: vi.fn(),
22
+ cleanupContainerState: vi.fn(),
23
+ getProcessInfo: vi.fn(),
24
+ validatePidFile: vi.fn(),
25
+ } as any;
26
+
27
+ const mockConfigManager = {
28
+ configExists: vi.fn(),
29
+ getConfig: vi.fn(),
30
+ } as any;
31
+
32
+ const mockWebServerInstance = {
33
+ start: vi.fn().mockResolvedValue(undefined),
34
+ stop: vi.fn().mockResolvedValue(undefined),
35
+ };
36
+
37
+ const mockMCPServerInstance = {
38
+ start: vi.fn().mockResolvedValue(undefined),
39
+ stop: vi.fn().mockResolvedValue(undefined),
40
+ };
41
+
42
+ vi.mock("@root/WebServer.js", () => ({
43
+ WebServer: vi.fn().mockImplementation(() => mockWebServerInstance),
44
+ }));
45
+
46
+ // Mock dynamic imports
47
+ vi.mock("node:child_process", () => ({
48
+ spawn: vi.fn().mockReturnValue({
49
+ pid: 1234,
50
+ stdout: { pipe: vi.fn() },
51
+ stderr: { pipe: vi.fn() },
52
+ on: vi.fn(),
53
+ unref: vi.fn(),
54
+ }),
55
+ exec: vi.fn().mockImplementation((cmd: string, callback: any) => {
56
+ callback(null, { stdout: "", stderr: "" });
57
+ }),
58
+ }));
59
+
60
+ vi.mock("@services/MCPServer.js", () => ({
61
+ MCPServer: vi.fn().mockImplementation(() => mockMCPServerInstance),
62
+ }));
63
+
64
+ // Mock PathUtils
65
+ vi.mock("@cli/utils/PathUtils.js", () => ({
66
+ PathUtils: {
67
+ getWebServerLauncherPath: vi
68
+ .fn()
69
+ .mockReturnValue("/mock/path/WebServerLauncher.js"),
70
+ getExecutablePath: vi.fn().mockReturnValue("/mock/path/cli.js"),
71
+ getConfigDir: vi.fn().mockReturnValue("/mock/config"),
72
+ getLogFile: vi.fn().mockReturnValue("/mock/logs/xiaozhi.log"),
73
+ },
74
+ }));
75
+
76
+ // Mock ConfigManager for WebServer
77
+ vi.mock("@/lib/config/manager.js", () => {
78
+ const mockConfig = {
79
+ mcpEndpoint: "ws://localhost:3000",
80
+ mcpServers: {},
81
+ webServer: { port: 9999 },
82
+ };
83
+ const mockConfigManager = {
84
+ configExists: vi.fn().mockReturnValue(true),
85
+ getConfig: vi.fn().mockReturnValue(mockConfig),
86
+ loadConfig: vi.fn().mockResolvedValue(mockConfig),
87
+ getToolCallLogConfig: vi.fn().mockReturnValue({ enabled: false }),
88
+ getMcpServers: vi.fn().mockReturnValue({}),
89
+ getMcpEndpoint: vi.fn().mockReturnValue("ws://localhost:3000"),
90
+ getConfigDir: vi.fn().mockReturnValue("/mock/config"),
91
+ };
92
+ return {
93
+ configManager: mockConfigManager,
94
+ ConfigManager: vi.fn().mockImplementation(() => mockConfigManager),
95
+ };
96
+ });
97
+
98
+ // Mock fs
99
+ vi.mock("node:fs", () => {
100
+ const mockExistsSync = vi.fn().mockReturnValue(true);
101
+ const mockReadFileSync = vi.fn().mockReturnValue(
102
+ JSON.stringify({
103
+ mcpEndpoint: "ws://localhost:3000",
104
+ mcpServers: {},
105
+ })
106
+ );
107
+ return {
108
+ default: {
109
+ existsSync: mockExistsSync,
110
+ readFileSync: mockReadFileSync,
111
+ writeFileSync: vi.fn(),
112
+ copyFileSync: vi.fn(),
113
+ mkdirSync: vi.fn(),
114
+ readdirSync: vi.fn().mockReturnValue([]),
115
+ statSync: vi
116
+ .fn()
117
+ .mockReturnValue({ isFile: () => true, isDirectory: () => false }),
118
+ createWriteStream: vi.fn().mockReturnValue({
119
+ write: vi.fn(),
120
+ }),
121
+ },
122
+ existsSync: mockExistsSync,
123
+ readFileSync: mockReadFileSync,
124
+ writeFileSync: vi.fn(),
125
+ copyFileSync: vi.fn(),
126
+ mkdirSync: vi.fn(),
127
+ readdirSync: vi.fn().mockReturnValue([]),
128
+ statSync: vi
129
+ .fn()
130
+ .mockReturnValue({ isFile: () => true, isDirectory: () => false }),
131
+ createWriteStream: vi.fn().mockReturnValue({
132
+ write: vi.fn(),
133
+ }),
134
+ promises: {
135
+ readFile: vi.fn().mockResolvedValue("mock content"),
136
+ writeFile: vi.fn().mockResolvedValue(undefined),
137
+ mkdir: vi.fn().mockResolvedValue(undefined),
138
+ },
139
+ };
140
+ });
141
+
142
+ // Mock process.exit
143
+ const mockProcessExit = vi.spyOn(process, "exit").mockImplementation(() => {
144
+ throw new Error("process.exit called");
145
+ });
146
+
147
+ describe("ServiceManagerImpl 服务管理器实现", () => {
148
+ let serviceManager: ServiceManagerImpl;
149
+
150
+ beforeEach(async () => {
151
+ serviceManager = new ServiceManagerImpl(
152
+ mockProcessManager,
153
+ mockConfigManager
154
+ );
155
+
156
+ // 重置所有 mock
157
+ vi.clearAllMocks();
158
+ mockProcessExit.mockClear();
159
+
160
+ // Reset PathUtils mocks
161
+ const { PathUtils } = await import("@cli/utils/PathUtils.js");
162
+ vi.mocked(PathUtils.getWebServerLauncherPath).mockReturnValue(
163
+ "/mock/path/WebServerLauncher.js"
164
+ );
165
+ vi.mocked(PathUtils.getExecutablePath).mockReturnValue("/mock/path/cli.js");
166
+ vi.mocked(PathUtils.getConfigDir).mockReturnValue("/mock/config");
167
+ vi.mocked(PathUtils.getLogFile).mockReturnValue("/mock/logs/xiaozhi.log");
168
+
169
+ // 重置 mock 实例
170
+ mockWebServerInstance.start.mockClear();
171
+ mockWebServerInstance.stop.mockClear();
172
+ mockMCPServerInstance.start.mockClear();
173
+ mockMCPServerInstance.stop.mockClear();
174
+
175
+ // 设置默认 mock 返回值
176
+ mockConfigManager.configExists.mockReturnValue(true);
177
+ mockConfigManager.getConfig.mockReturnValue({ webServer: { port: 9999 } });
178
+ (mockProcessManager.getServiceStatus as any).mockReturnValue({
179
+ running: false,
180
+ });
181
+ (mockProcessManager.gracefulKillProcess as any).mockResolvedValue(
182
+ undefined
183
+ );
184
+
185
+ // Mock dynamic import for WebServer
186
+ vi.doMock("@root/WebServer.js", () => ({
187
+ WebServer: vi.fn().mockImplementation(() => mockWebServerInstance),
188
+ }));
189
+ });
190
+
191
+ afterEach(() => {
192
+ vi.restoreAllMocks();
193
+ mockProcessExit.mockClear();
194
+ });
195
+
196
+ describe("start 启动服务", () => {
197
+ const defaultOptions: ServiceStartOptions = {
198
+ daemon: false,
199
+ ui: false,
200
+ mode: "normal",
201
+ };
202
+
203
+ it("如果服务已在运行应自动重启", async () => {
204
+ // 第一次调用返回服务正在运行
205
+ (mockProcessManager.getServiceStatus as any)
206
+ .mockReturnValueOnce({
207
+ running: true,
208
+ pid: 1234,
209
+ })
210
+ // 第二次调用(停止后)返回服务未运行
211
+ .mockReturnValueOnce({
212
+ running: false,
213
+ });
214
+
215
+ // Mock gracefulKillProcess 方法
216
+ mockProcessManager.gracefulKillProcess = vi
217
+ .fn()
218
+ .mockResolvedValue(undefined);
219
+ mockProcessManager.cleanupPidFile = vi.fn();
220
+
221
+ await serviceManager.start(defaultOptions);
222
+
223
+ // 验证调用了停止进程的方法
224
+ expect(mockProcessManager.gracefulKillProcess).toHaveBeenCalledWith(1234);
225
+ expect(mockProcessManager.cleanupPidFile).toHaveBeenCalled();
226
+
227
+ // 验证最终启动了服务
228
+ expect(mockWebServerInstance.start).toHaveBeenCalled();
229
+ });
230
+
231
+ it("如果停止现有服务失败应继续启动新服务", async () => {
232
+ // 第一次调用返回服务正在运行
233
+ (mockProcessManager.getServiceStatus as any)
234
+ .mockReturnValueOnce({
235
+ running: true,
236
+ pid: 1234,
237
+ })
238
+ // 第二次调用(停止后)返回服务未运行
239
+ .mockReturnValueOnce({
240
+ running: false,
241
+ });
242
+
243
+ // Mock gracefulKillProcess 抛出错误
244
+ const stopError = new Error("无法停止进程");
245
+ mockProcessManager.gracefulKillProcess = vi
246
+ .fn()
247
+ .mockRejectedValue(stopError);
248
+ mockProcessManager.cleanupPidFile = vi.fn();
249
+
250
+ // Mock console.warn 来验证警告信息
251
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
252
+
253
+ await serviceManager.start(defaultOptions);
254
+
255
+ // 验证调用了停止进程的方法
256
+ expect(mockProcessManager.gracefulKillProcess).toHaveBeenCalledWith(1234);
257
+ // 注意:当 gracefulKillProcess 失败时,cleanupPidFile 不会在 catch 块中被调用
258
+ // 这是当前实现的行为,所以我们不应该期望它被调用
259
+
260
+ // 验证输出了警告信息
261
+ expect(consoleSpy).toHaveBeenCalledWith(
262
+ "停止现有服务时出现警告: 无法停止进程"
263
+ );
264
+
265
+ // 验证最终仍然启动了服务
266
+ expect(mockWebServerInstance.start).toHaveBeenCalled();
267
+
268
+ consoleSpy.mockRestore();
269
+ });
270
+
271
+ it("如果配置不存在应抛出错误", async () => {
272
+ mockConfigManager.configExists.mockReturnValue(false);
273
+
274
+ await expect(serviceManager.start(defaultOptions)).rejects.toThrow(
275
+ ServiceError
276
+ );
277
+ });
278
+
279
+ it("应验证端口选项", async () => {
280
+ const invalidOptions: ServiceStartOptions = {
281
+ ...defaultOptions,
282
+ port: 99999, // Invalid port
283
+ };
284
+
285
+ await expect(serviceManager.start(invalidOptions)).rejects.toThrow();
286
+ });
287
+
288
+ it("应验证模式选项", async () => {
289
+ const invalidOptions: ServiceStartOptions = {
290
+ ...defaultOptions,
291
+ mode: "invalid" as any,
292
+ };
293
+
294
+ await expect(serviceManager.start(invalidOptions)).rejects.toThrow(
295
+ ServiceError
296
+ );
297
+ });
298
+
299
+ it("启动前应清理容器状态", async () => {
300
+ await serviceManager.start(defaultOptions);
301
+
302
+ expect(mockProcessManager.cleanupContainerState).toHaveBeenCalled();
303
+ });
304
+
305
+ it("默认应以普通模式启动", async () => {
306
+ await serviceManager.start(defaultOptions);
307
+
308
+ expect(mockWebServerInstance.start).toHaveBeenCalled();
309
+ expect(mockProcessManager.savePidInfo).toHaveBeenCalledWith(
310
+ process.pid,
311
+ "foreground"
312
+ );
313
+ });
314
+
315
+ it("应以 MCP 服务器模式启动", async () => {
316
+ const mcpOptions: ServiceStartOptions = {
317
+ ...defaultOptions,
318
+ mode: "mcp-server",
319
+ port: 3000,
320
+ };
321
+
322
+ await serviceManager.start(mcpOptions);
323
+
324
+ expect(mockWebServerInstance.start).toHaveBeenCalled();
325
+ });
326
+
327
+ describe("daemon 模式", () => {
328
+ it("应以 daemon 模式启动 WebServer 并退出父进程", async () => {
329
+ // Ensure file exists
330
+ const fs = await import("node:fs");
331
+ vi.mocked(fs.default.existsSync).mockReturnValue(true);
332
+
333
+ const { spawn } = await import("node:child_process");
334
+ const mockSpawn = vi.mocked(spawn);
335
+ const mockChild = {
336
+ pid: 1234,
337
+ unref: vi.fn(),
338
+ };
339
+ mockSpawn.mockReturnValue(mockChild as any);
340
+
341
+ const daemonOptions: ServiceStartOptions = {
342
+ ...defaultOptions,
343
+ daemon: true,
344
+ };
345
+
346
+ // 测试 daemon 模式启动
347
+ await expect(serviceManager.start(daemonOptions)).rejects.toThrow(
348
+ /process\.exit/
349
+ );
350
+
351
+ // 验证子进程正确启动
352
+ expect(mockSpawn).toHaveBeenCalledWith(
353
+ "node",
354
+ ["/mock/path/WebServerLauncher.js"],
355
+ {
356
+ detached: true,
357
+ stdio: ["ignore", "ignore", "ignore"],
358
+ env: expect.objectContaining({
359
+ XIAOZHI_CONFIG_DIR: "/mock/config",
360
+ XIAOZHI_DAEMON: "true",
361
+ }),
362
+ }
363
+ );
364
+
365
+ // 验证 PID 信息保存
366
+ expect(mockProcessManager.savePidInfo).toHaveBeenCalledWith(
367
+ 1234,
368
+ "daemon"
369
+ );
370
+
371
+ // 验证子进程分离
372
+ expect(mockChild.unref).toHaveBeenCalled();
373
+
374
+ // 验证父进程退出 (通过异常抛出验证)
375
+ // mockProcessExit 在测试中抛出异常,所以不直接检查调用
376
+ });
377
+
378
+ it("应以 daemon 模式启动 WebServer", async () => {
379
+ // Ensure file exists
380
+ const fs = await import("node:fs");
381
+ vi.mocked(fs.default.existsSync).mockReturnValue(true);
382
+
383
+ const { spawn } = await import("node:child_process");
384
+ const mockSpawn = vi.mocked(spawn);
385
+ const mockChild = {
386
+ pid: 1234,
387
+ unref: vi.fn(),
388
+ };
389
+ mockSpawn.mockReturnValue(mockChild as any);
390
+
391
+ const daemonOptions: ServiceStartOptions = {
392
+ ...defaultOptions,
393
+ daemon: true,
394
+ };
395
+
396
+ await expect(serviceManager.start(daemonOptions)).rejects.toThrow(
397
+ /process\.exit/
398
+ );
399
+
400
+ expect(mockSpawn).toHaveBeenCalledWith(
401
+ "node",
402
+ ["/mock/path/WebServerLauncher.js"],
403
+ {
404
+ detached: true,
405
+ stdio: ["ignore", "ignore", "ignore"],
406
+ env: expect.objectContaining({
407
+ XIAOZHI_CONFIG_DIR: "/mock/config",
408
+ XIAOZHI_DAEMON: "true",
409
+ }),
410
+ }
411
+ );
412
+ });
413
+
414
+ it("应以 daemon 模式启动 MCP Server 并退出父进程", async () => {
415
+ // Ensure file exists
416
+ const fs = await import("node:fs");
417
+ vi.mocked(fs.default.existsSync).mockReturnValue(true);
418
+
419
+ const { spawn } = await import("node:child_process");
420
+ const mockSpawn = vi.mocked(spawn);
421
+ const mockChild = {
422
+ pid: 5678,
423
+ unref: vi.fn(),
424
+ };
425
+ mockSpawn.mockReturnValue(mockChild as any);
426
+
427
+ const mcpDaemonOptions: ServiceStartOptions = {
428
+ ...defaultOptions,
429
+ daemon: true,
430
+ mode: "mcp-server",
431
+ port: 3000,
432
+ };
433
+
434
+ await expect(serviceManager.start(mcpDaemonOptions)).rejects.toThrow(
435
+ /process\.exit/
436
+ );
437
+
438
+ expect(mockSpawn).toHaveBeenCalledWith(
439
+ "node",
440
+ ["/mock/path/cli.js", "start", "--server", "3000"],
441
+ {
442
+ detached: true,
443
+ stdio: ["ignore", "ignore", "ignore"],
444
+ env: expect.objectContaining({
445
+ XIAOZHI_CONFIG_DIR: "/mock/config",
446
+ XIAOZHI_DAEMON: "true",
447
+ MCP_SERVER_MODE: "true",
448
+ }),
449
+ }
450
+ );
451
+
452
+ expect(mockProcessManager.savePidInfo).toHaveBeenCalledWith(
453
+ 5678,
454
+ "daemon"
455
+ );
456
+ expect(mockChild.unref).toHaveBeenCalled();
457
+ // 验证父进程退出 (通过异常抛出验证)
458
+ // mockProcessExit 在测试中抛出异常,所以不直接检查调用
459
+ });
460
+
461
+ it("如果 WebServer 文件不存在应抛出错误", async () => {
462
+ const fs = await import("node:fs");
463
+ vi.mocked(fs.default.existsSync).mockReturnValue(false);
464
+
465
+ const daemonOptions: ServiceStartOptions = {
466
+ ...defaultOptions,
467
+ daemon: true,
468
+ };
469
+
470
+ await expect(serviceManager.start(daemonOptions)).rejects.toThrow(
471
+ /WebServer 文件不存在/
472
+ );
473
+ });
474
+
475
+ it("应优雅地处理 spawn 错误", async () => {
476
+ // Ensure file exists first
477
+ const fs = await import("node:fs");
478
+ vi.mocked(fs.default.existsSync).mockReturnValue(true);
479
+
480
+ const { spawn } = await import("node:child_process");
481
+ const mockSpawn = vi.mocked(spawn);
482
+
483
+ mockSpawn.mockImplementation(() => {
484
+ throw new Error("Failed to spawn process");
485
+ });
486
+
487
+ const daemonOptions: ServiceStartOptions = {
488
+ ...defaultOptions,
489
+ daemon: true,
490
+ };
491
+
492
+ await expect(serviceManager.start(daemonOptions)).rejects.toThrow(
493
+ "Failed to spawn process"
494
+ );
495
+ expect(mockProcessExit).not.toHaveBeenCalled();
496
+ });
497
+
498
+ it("应处理没有 PID 的子进程", async () => {
499
+ // Ensure file exists
500
+ const fs = await import("node:fs");
501
+ vi.mocked(fs.default.existsSync).mockReturnValue(true);
502
+
503
+ const { spawn } = await import("node:child_process");
504
+ const mockSpawn = vi.mocked(spawn);
505
+ const mockChild = {
506
+ pid: undefined,
507
+ unref: vi.fn(),
508
+ };
509
+ mockSpawn.mockReturnValue(mockChild as any);
510
+
511
+ const daemonOptions: ServiceStartOptions = {
512
+ ...defaultOptions,
513
+ daemon: true,
514
+ };
515
+
516
+ // Should handle undefined PID gracefully
517
+ await expect(serviceManager.start(daemonOptions)).rejects.toThrow(
518
+ /process\.exit/
519
+ );
520
+ expect(mockProcessManager.savePidInfo).toHaveBeenCalledWith(
521
+ 0,
522
+ "daemon"
523
+ );
524
+ });
525
+
526
+ it("应传递正确的环境变量", async () => {
527
+ // Ensure file exists
528
+ const fs = await import("node:fs");
529
+ vi.mocked(fs.default.existsSync).mockReturnValue(true);
530
+
531
+ const { spawn } = await import("node:child_process");
532
+ const mockSpawn = vi.mocked(spawn);
533
+ const mockChild = {
534
+ pid: 1234,
535
+ unref: vi.fn(),
536
+ };
537
+ mockSpawn.mockReturnValue(mockChild as any);
538
+
539
+ // Set some existing environment variables
540
+ const originalEnv = process.env;
541
+ process.env = {
542
+ ...originalEnv,
543
+ EXISTING_VAR: "existing_value",
544
+ PATH: "/usr/bin:/bin",
545
+ };
546
+
547
+ const daemonOptions: ServiceStartOptions = {
548
+ ...defaultOptions,
549
+ daemon: true,
550
+ };
551
+
552
+ await expect(serviceManager.start(daemonOptions)).rejects.toThrow(
553
+ /process\.exit/
554
+ );
555
+
556
+ const spawnCall = mockSpawn.mock.calls[0];
557
+ expect(spawnCall[2]?.env).toEqual(
558
+ expect.objectContaining({
559
+ EXISTING_VAR: "existing_value",
560
+ PATH: "/usr/bin:/bin",
561
+ XIAOZHI_CONFIG_DIR: "/mock/config",
562
+ XIAOZHI_DAEMON: "true",
563
+ })
564
+ );
565
+
566
+ // Restore original environment
567
+ process.env = originalEnv;
568
+ });
569
+ });
570
+ });
571
+
572
+ describe("stop 停止服务", () => {
573
+ it("如果服务未运行应抛出错误", async () => {
574
+ (mockProcessManager.getServiceStatus as any).mockReturnValue({
575
+ running: false,
576
+ });
577
+
578
+ await expect(serviceManager.stop()).rejects.toThrow(ServiceError);
579
+ });
580
+
581
+ it("应优雅地终止进程并清理 PID 文件", async () => {
582
+ (mockProcessManager.getServiceStatus as any).mockReturnValue({
583
+ running: true,
584
+ pid: 1234,
585
+ });
586
+
587
+ await serviceManager.stop();
588
+
589
+ expect(mockProcessManager.gracefulKillProcess).toHaveBeenCalledWith(1234);
590
+ expect(mockProcessManager.cleanupPidFile).toHaveBeenCalled();
591
+ });
592
+
593
+ it("应处理终止进程错误", async () => {
594
+ (mockProcessManager.getServiceStatus as any).mockReturnValue({
595
+ running: true,
596
+ pid: 1234,
597
+ });
598
+ (mockProcessManager.gracefulKillProcess as any).mockRejectedValue(
599
+ new Error("Kill failed")
600
+ );
601
+
602
+ await expect(serviceManager.stop()).rejects.toThrow(ServiceError);
603
+ });
604
+ });
605
+
606
+ describe("restart 重启服务", () => {
607
+ it("应停止并启动服务", async () => {
608
+ const options: ServiceStartOptions = { daemon: false, ui: false };
609
+
610
+ // Mock running service - restart() calls getStatus(), then stop() calls getStatus() again
611
+ (mockProcessManager.getServiceStatus as any)
612
+ .mockReturnValueOnce({ running: true, pid: 1234 }) // restart() check
613
+ .mockReturnValueOnce({ running: true, pid: 1234 }) // stop() check
614
+ .mockReturnValueOnce({ running: false }); // after stop
615
+
616
+ // Mock gracefulKillProcess to resolve successfully
617
+ (mockProcessManager.gracefulKillProcess as any).mockResolvedValue(
618
+ undefined
619
+ );
620
+
621
+ await serviceManager.restart(options);
622
+
623
+ expect(mockProcessManager.gracefulKillProcess).toHaveBeenCalledWith(1234);
624
+ expect(mockProcessManager.cleanupPidFile).toHaveBeenCalled();
625
+ expect(mockWebServerInstance.start).toHaveBeenCalled();
626
+ });
627
+
628
+ it("如果服务未运行应启动服务", async () => {
629
+ const options: ServiceStartOptions = { daemon: false, ui: false };
630
+
631
+ (mockProcessManager.getServiceStatus as any).mockReturnValue({
632
+ running: false,
633
+ });
634
+
635
+ await serviceManager.restart(options);
636
+
637
+ expect(mockProcessManager.gracefulKillProcess).not.toHaveBeenCalled();
638
+ expect(mockWebServerInstance.start).toHaveBeenCalled();
639
+ });
640
+
641
+ it("应处理重启过程中的错误", async () => {
642
+ const options: ServiceStartOptions = { daemon: false, ui: false };
643
+
644
+ // Mock running service
645
+ (mockProcessManager.getServiceStatus as any)
646
+ .mockReturnValueOnce({ running: true, pid: 1234 }) // restart() check
647
+ .mockReturnValueOnce({ running: true, pid: 1234 }); // stop() check
648
+
649
+ // Mock gracefulKillProcess to throw error (this will cause stop() to throw)
650
+ const killError = new Error("无法停止进程");
651
+ mockProcessManager.gracefulKillProcess = vi
652
+ .fn()
653
+ .mockRejectedValue(killError);
654
+
655
+ await expect(serviceManager.restart(options)).rejects.toThrow(
656
+ ServiceError
657
+ );
658
+ });
659
+
660
+ it("应处理启动过程中的错误", async () => {
661
+ const options: ServiceStartOptions = { daemon: false, ui: false };
662
+
663
+ // Mock service not running
664
+ (mockProcessManager.getServiceStatus as any)
665
+ .mockReturnValueOnce({ running: false }) // restart() check
666
+ .mockReturnValueOnce({ running: false }); // start() check
667
+
668
+ // Mock WebServer start to throw error
669
+ const startError = new Error("启动失败");
670
+ mockWebServerInstance.start.mockRejectedValue(startError);
671
+
672
+ await expect(serviceManager.restart(options)).rejects.toThrow(
673
+ ServiceError
674
+ );
675
+ await expect(serviceManager.restart(options)).rejects.toThrow(
676
+ "重启服务失败: 服务启动失败: 启动失败"
677
+ );
678
+ });
679
+ });
680
+
681
+ describe("getStatus 获取状态", () => {
682
+ it("应委托给进程管理器", () => {
683
+ const expectedStatus = { running: true, pid: 1234, uptime: "1分钟" };
684
+ (mockProcessManager.getServiceStatus as any).mockReturnValue(
685
+ expectedStatus
686
+ );
687
+
688
+ const status = serviceManager.getStatus();
689
+
690
+ expect(status).toEqual(expectedStatus);
691
+ expect(mockProcessManager.getServiceStatus).toHaveBeenCalled();
692
+ });
693
+ });
694
+
695
+ describe("checkEnvironment 检查环境", () => {
696
+ it("如果配置不存在应抛出 ConfigError", () => {
697
+ mockConfigManager.configExists.mockReturnValue(false);
698
+
699
+ expect(() => (serviceManager as any).checkEnvironment()).toThrow(
700
+ ConfigError
701
+ );
702
+ });
703
+
704
+ it("如果配置无效应抛出 ConfigError", () => {
705
+ mockConfigManager.configExists.mockReturnValue(true);
706
+ mockConfigManager.getConfig.mockReturnValue(null);
707
+
708
+ expect(() => (serviceManager as any).checkEnvironment()).toThrow(
709
+ ConfigError
710
+ );
711
+ });
712
+
713
+ it("如果配置有效应通过", () => {
714
+ mockConfigManager.configExists.mockReturnValue(true);
715
+ mockConfigManager.getConfig.mockReturnValue({ valid: true });
716
+
717
+ expect(() => (serviceManager as any).checkEnvironment()).not.toThrow();
718
+ });
719
+
720
+ it("应处理配置获取时的异常", () => {
721
+ mockConfigManager.configExists.mockReturnValue(true);
722
+ mockConfigManager.getConfig.mockImplementation(() => {
723
+ throw new Error("配置解析失败");
724
+ });
725
+
726
+ expect(() => (serviceManager as any).checkEnvironment()).toThrow(
727
+ ConfigError
728
+ );
729
+ expect(() => (serviceManager as any).checkEnvironment()).toThrow(
730
+ "配置文件错误: 配置解析失败"
731
+ );
732
+ });
733
+
734
+ it("应处理非 Error 类型的异常", () => {
735
+ mockConfigManager.configExists.mockReturnValue(true);
736
+ mockConfigManager.getConfig.mockImplementation(() => {
737
+ throw "字符串错误";
738
+ });
739
+
740
+ expect(() => (serviceManager as any).checkEnvironment()).toThrow(
741
+ ConfigError
742
+ );
743
+ expect(() => (serviceManager as any).checkEnvironment()).toThrow(
744
+ "配置文件错误: 字符串错误"
745
+ );
746
+ });
747
+ });
748
+
749
+ describe("validateStartOptions 验证启动选项", () => {
750
+ it("应验证端口", () => {
751
+ const invalidOptions = { port: 99999 };
752
+
753
+ expect(() =>
754
+ (serviceManager as any).validateStartOptions(invalidOptions)
755
+ ).toThrow();
756
+ });
757
+
758
+ it("应验证模式", () => {
759
+ const invalidOptions = { mode: "invalid" };
760
+
761
+ expect(() =>
762
+ (serviceManager as any).validateStartOptions(invalidOptions)
763
+ ).toThrow(ServiceError);
764
+ });
765
+
766
+ it("应通过有效选项", () => {
767
+ const validOptions = { port: 3000, mode: "normal" };
768
+
769
+ expect(() =>
770
+ (serviceManager as any).validateStartOptions(validOptions)
771
+ ).not.toThrow();
772
+ });
773
+ });
774
+ });