@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.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/fix-imports.js +32 -0
- package/package.json +26 -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/tsconfig.tsbuildinfo +1 -0
- package/tsup.config.ts +107 -0
- 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
|
+
});
|