@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,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 守护进程管理服务单元测试
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { ServiceError } from "@cli/errors/index.js";
|
|
6
|
+
import type { ProcessManager } from "@cli/interfaces/Service.js";
|
|
7
|
+
import consola from "consola";
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import type { DaemonOptions } from "../DaemonManager";
|
|
10
|
+
import { DaemonManagerImpl } from "../DaemonManager";
|
|
11
|
+
|
|
12
|
+
// Mock 依赖
|
|
13
|
+
const mockProcessManager: ProcessManager = {
|
|
14
|
+
getServiceStatus: vi.fn(),
|
|
15
|
+
killProcess: vi.fn(),
|
|
16
|
+
cleanupPidFile: vi.fn(),
|
|
17
|
+
isXiaozhiProcess: vi.fn(),
|
|
18
|
+
savePidInfo: vi.fn(),
|
|
19
|
+
gracefulKillProcess: vi.fn(),
|
|
20
|
+
processExists: vi.fn(),
|
|
21
|
+
cleanupContainerState: vi.fn(),
|
|
22
|
+
getProcessInfo: vi.fn(),
|
|
23
|
+
validatePidFile: vi.fn(),
|
|
24
|
+
} as any;
|
|
25
|
+
|
|
26
|
+
// Mock child_process
|
|
27
|
+
const mockChild = {
|
|
28
|
+
pid: 1234,
|
|
29
|
+
stdout: { pipe: vi.fn() },
|
|
30
|
+
stderr: { pipe: vi.fn() },
|
|
31
|
+
on: vi.fn(),
|
|
32
|
+
unref: vi.fn(),
|
|
33
|
+
kill: vi.fn(),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
vi.mock("node:child_process", () => ({
|
|
37
|
+
spawn: vi.fn(() => mockChild),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
// Mock fs
|
|
41
|
+
vi.mock("node:fs", () => ({
|
|
42
|
+
default: {
|
|
43
|
+
existsSync: vi.fn(),
|
|
44
|
+
mkdirSync: vi.fn(),
|
|
45
|
+
createWriteStream: vi.fn(() => ({
|
|
46
|
+
write: vi.fn(),
|
|
47
|
+
})),
|
|
48
|
+
},
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
// Mock utils
|
|
52
|
+
vi.mock("@cli/utils/PathUtils.js", () => ({
|
|
53
|
+
PathUtils: {
|
|
54
|
+
getWebServerLauncherPath: vi.fn(() => "/path/to/webserver.js"),
|
|
55
|
+
getConfigDir: vi.fn(() => "/config"),
|
|
56
|
+
getLogFile: vi.fn(() => "/logs/xiaozhi.log"),
|
|
57
|
+
},
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
vi.mock("@cli/utils/PlatformUtils.js", () => ({
|
|
61
|
+
PlatformUtils: {
|
|
62
|
+
getTailCommand: vi.fn(() => ({
|
|
63
|
+
command: "tail",
|
|
64
|
+
args: ["-f", "/logs/xiaozhi.log"],
|
|
65
|
+
})),
|
|
66
|
+
},
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
describe("DaemonManagerImpl", () => {
|
|
70
|
+
let daemonManager: DaemonManagerImpl;
|
|
71
|
+
const mockServerFactory = vi.fn();
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
daemonManager = new DaemonManagerImpl(mockProcessManager);
|
|
75
|
+
|
|
76
|
+
// Mock consola
|
|
77
|
+
vi.spyOn(consola, "info").mockImplementation(() => consola);
|
|
78
|
+
vi.spyOn(consola, "warn").mockImplementation(() => consola);
|
|
79
|
+
vi.spyOn(consola, "error").mockImplementation(() => consola);
|
|
80
|
+
|
|
81
|
+
// 重置所有 mock
|
|
82
|
+
vi.clearAllMocks();
|
|
83
|
+
|
|
84
|
+
// 设置默认 mock 返回值
|
|
85
|
+
(mockProcessManager.getServiceStatus as any).mockReturnValue({
|
|
86
|
+
running: false,
|
|
87
|
+
});
|
|
88
|
+
mockChild.on.mockImplementation((event, callback) => {
|
|
89
|
+
// 不立即调用回调,让测试控制
|
|
90
|
+
return mockChild;
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
vi.restoreAllMocks();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("startDaemon", () => {
|
|
99
|
+
it("should throw error if daemon is already running", async () => {
|
|
100
|
+
(mockProcessManager.getServiceStatus as any).mockReturnValue({
|
|
101
|
+
running: true,
|
|
102
|
+
pid: 1234,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await expect(
|
|
106
|
+
daemonManager.startDaemon(mockServerFactory)
|
|
107
|
+
).rejects.toThrow(ServiceError);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should start daemon successfully", async () => {
|
|
111
|
+
const { spawn } = await import("node:child_process");
|
|
112
|
+
|
|
113
|
+
await daemonManager.startDaemon(mockServerFactory);
|
|
114
|
+
|
|
115
|
+
expect(spawn).toHaveBeenCalledWith("node", ["/path/to/webserver.js"], {
|
|
116
|
+
detached: true,
|
|
117
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
118
|
+
env: expect.objectContaining({
|
|
119
|
+
XIAOZHI_CONFIG_DIR: "/config",
|
|
120
|
+
XIAOZHI_DAEMON: "true",
|
|
121
|
+
}),
|
|
122
|
+
cwd: process.cwd(),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(mockProcessManager.savePidInfo).toHaveBeenCalledWith(
|
|
126
|
+
1234,
|
|
127
|
+
"daemon"
|
|
128
|
+
);
|
|
129
|
+
expect(mockChild.unref).toHaveBeenCalled();
|
|
130
|
+
expect(consola.info).toHaveBeenCalledWith("守护进程已启动 (PID: 1234)");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("should start daemon with options", async () => {
|
|
134
|
+
const options: DaemonOptions = {
|
|
135
|
+
openBrowser: true,
|
|
136
|
+
env: { CUSTOM_VAR: "value" },
|
|
137
|
+
cwd: "/custom/cwd",
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const { spawn } = await import("node:child_process");
|
|
141
|
+
|
|
142
|
+
await daemonManager.startDaemon(mockServerFactory, options);
|
|
143
|
+
|
|
144
|
+
expect(spawn).toHaveBeenCalledWith(
|
|
145
|
+
"node",
|
|
146
|
+
["/path/to/webserver.js", "--open-browser"],
|
|
147
|
+
{
|
|
148
|
+
detached: true,
|
|
149
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
150
|
+
env: expect.objectContaining({
|
|
151
|
+
XIAOZHI_CONFIG_DIR: "/config",
|
|
152
|
+
XIAOZHI_DAEMON: "true",
|
|
153
|
+
CUSTOM_VAR: "value",
|
|
154
|
+
}),
|
|
155
|
+
cwd: "/custom/cwd",
|
|
156
|
+
}
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("should setup logging", async () => {
|
|
161
|
+
const fs = await import("node:fs");
|
|
162
|
+
const mockLogStream = { write: vi.fn() };
|
|
163
|
+
(fs.default.createWriteStream as any).mockReturnValue(mockLogStream);
|
|
164
|
+
|
|
165
|
+
await daemonManager.startDaemon(mockServerFactory);
|
|
166
|
+
|
|
167
|
+
expect(fs.default.createWriteStream).toHaveBeenCalledWith(
|
|
168
|
+
"/logs/xiaozhi.log",
|
|
169
|
+
{ flags: "a" }
|
|
170
|
+
);
|
|
171
|
+
expect(mockChild.stdout.pipe).toHaveBeenCalledWith(mockLogStream);
|
|
172
|
+
expect(mockChild.stderr.pipe).toHaveBeenCalledWith(mockLogStream);
|
|
173
|
+
expect(mockLogStream.write).toHaveBeenCalledWith(
|
|
174
|
+
expect.stringContaining("守护进程启动 (PID: 1234)")
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("should setup event handlers", async () => {
|
|
179
|
+
await daemonManager.startDaemon(mockServerFactory);
|
|
180
|
+
|
|
181
|
+
expect(mockChild.on).toHaveBeenCalledWith("exit", expect.any(Function));
|
|
182
|
+
expect(mockChild.on).toHaveBeenCalledWith("error", expect.any(Function));
|
|
183
|
+
expect(mockChild.on).toHaveBeenCalledWith(
|
|
184
|
+
"disconnect",
|
|
185
|
+
expect.any(Function)
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("stopDaemon", () => {
|
|
191
|
+
it("should throw error if daemon is not running", async () => {
|
|
192
|
+
(mockProcessManager.getServiceStatus as any).mockReturnValue({
|
|
193
|
+
running: false,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await expect(daemonManager.stopDaemon()).rejects.toThrow(ServiceError);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("should stop daemon successfully", async () => {
|
|
200
|
+
(mockProcessManager.getServiceStatus as any).mockReturnValue({
|
|
201
|
+
running: true,
|
|
202
|
+
pid: 1234,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await daemonManager.stopDaemon();
|
|
206
|
+
|
|
207
|
+
expect(mockProcessManager.gracefulKillProcess).toHaveBeenCalledWith(1234);
|
|
208
|
+
expect(mockProcessManager.cleanupPidFile).toHaveBeenCalled();
|
|
209
|
+
expect(consola.info).toHaveBeenCalledWith("守护进程已停止");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("should handle stop errors", async () => {
|
|
213
|
+
(mockProcessManager.getServiceStatus as any).mockReturnValue({
|
|
214
|
+
running: true,
|
|
215
|
+
pid: 1234,
|
|
216
|
+
});
|
|
217
|
+
(mockProcessManager.gracefulKillProcess as any).mockRejectedValue(
|
|
218
|
+
new Error("Kill failed")
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
await expect(daemonManager.stopDaemon()).rejects.toThrow(ServiceError);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("restartDaemon", () => {
|
|
226
|
+
it("should restart daemon", async () => {
|
|
227
|
+
// Mock running daemon - restartDaemon() calls getServiceStatus(), then stopDaemon() calls getServiceStatus() again
|
|
228
|
+
(mockProcessManager.getServiceStatus as any)
|
|
229
|
+
.mockReturnValueOnce({ running: true, pid: 1234 }) // restartDaemon() check
|
|
230
|
+
.mockReturnValueOnce({ running: true, pid: 1234 }) // stopDaemon() check
|
|
231
|
+
.mockReturnValueOnce({ running: false }); // after stop
|
|
232
|
+
|
|
233
|
+
// Mock gracefulKillProcess to resolve successfully
|
|
234
|
+
(mockProcessManager.gracefulKillProcess as any).mockResolvedValue(
|
|
235
|
+
undefined
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
await daemonManager.restartDaemon(mockServerFactory);
|
|
239
|
+
|
|
240
|
+
expect(mockProcessManager.gracefulKillProcess).toHaveBeenCalledWith(1234);
|
|
241
|
+
expect(mockProcessManager.cleanupPidFile).toHaveBeenCalled();
|
|
242
|
+
expect(mockProcessManager.savePidInfo).toHaveBeenCalledWith(
|
|
243
|
+
1234,
|
|
244
|
+
"daemon"
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("should start daemon if not running", async () => {
|
|
249
|
+
(mockProcessManager.getServiceStatus as any).mockReturnValue({
|
|
250
|
+
running: false,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
await daemonManager.restartDaemon(mockServerFactory);
|
|
254
|
+
|
|
255
|
+
expect(mockProcessManager.gracefulKillProcess).not.toHaveBeenCalled();
|
|
256
|
+
expect(mockProcessManager.savePidInfo).toHaveBeenCalledWith(
|
|
257
|
+
1234,
|
|
258
|
+
"daemon"
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("getDaemonStatus", () => {
|
|
264
|
+
it("should delegate to process manager", () => {
|
|
265
|
+
const expectedStatus = { running: true, pid: 1234, uptime: "1分钟" };
|
|
266
|
+
(mockProcessManager.getServiceStatus as any).mockReturnValue(
|
|
267
|
+
expectedStatus
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const status = daemonManager.getDaemonStatus();
|
|
271
|
+
|
|
272
|
+
expect(status).toEqual(expectedStatus);
|
|
273
|
+
expect(mockProcessManager.getServiceStatus).toHaveBeenCalled();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("attachToLogs", () => {
|
|
278
|
+
it("should throw error if log file does not exist", async () => {
|
|
279
|
+
const fs = await import("node:fs");
|
|
280
|
+
(fs.default.existsSync as any).mockReturnValue(false);
|
|
281
|
+
|
|
282
|
+
await expect(daemonManager.attachToLogs()).rejects.toThrow(ServiceError);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("should attach to logs successfully", async () => {
|
|
286
|
+
const fs = await import("node:fs");
|
|
287
|
+
const { spawn } = await import("node:child_process");
|
|
288
|
+
|
|
289
|
+
(fs.default.existsSync as any).mockReturnValue(true);
|
|
290
|
+
|
|
291
|
+
await daemonManager.attachToLogs();
|
|
292
|
+
|
|
293
|
+
expect(spawn).toHaveBeenCalledWith("tail", ["-f", "/logs/xiaozhi.log"], {
|
|
294
|
+
stdio: "inherit",
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("checkHealth", () => {
|
|
300
|
+
it("should return false if daemon is not running", async () => {
|
|
301
|
+
(mockProcessManager.getServiceStatus as any).mockReturnValue({
|
|
302
|
+
running: false,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const isHealthy = await daemonManager.checkHealth();
|
|
306
|
+
|
|
307
|
+
expect(isHealthy).toBe(false);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("should return true if daemon is healthy", async () => {
|
|
311
|
+
(mockProcessManager.getServiceStatus as any).mockReturnValue({
|
|
312
|
+
running: true,
|
|
313
|
+
pid: 1234,
|
|
314
|
+
});
|
|
315
|
+
(mockProcessManager.getProcessInfo as any).mockReturnValue({
|
|
316
|
+
exists: true,
|
|
317
|
+
isXiaozhi: true,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const isHealthy = await daemonManager.checkHealth();
|
|
321
|
+
|
|
322
|
+
expect(isHealthy).toBe(true);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("should return false if process is not xiaozhi", async () => {
|
|
326
|
+
(mockProcessManager.getServiceStatus as any).mockReturnValue({
|
|
327
|
+
running: true,
|
|
328
|
+
pid: 1234,
|
|
329
|
+
});
|
|
330
|
+
(mockProcessManager.getProcessInfo as any).mockReturnValue({
|
|
331
|
+
exists: true,
|
|
332
|
+
isXiaozhi: false,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const isHealthy = await daemonManager.checkHealth();
|
|
336
|
+
|
|
337
|
+
expect(isHealthy).toBe(false);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe("cleanup", () => {
|
|
342
|
+
it("should cleanup current daemon", async () => {
|
|
343
|
+
// Start a daemon first
|
|
344
|
+
await daemonManager.startDaemon(mockServerFactory);
|
|
345
|
+
|
|
346
|
+
daemonManager.cleanup();
|
|
347
|
+
|
|
348
|
+
expect(mockChild.kill).toHaveBeenCalledWith("SIGTERM");
|
|
349
|
+
expect(daemonManager.getCurrentDaemon()).toBeNull();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("should handle cleanup errors gracefully", async () => {
|
|
353
|
+
await daemonManager.startDaemon(mockServerFactory);
|
|
354
|
+
mockChild.kill.mockImplementation(() => {
|
|
355
|
+
throw new Error("Kill failed");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
expect(() => daemonManager.cleanup()).not.toThrow();
|
|
359
|
+
expect(consola.warn).toHaveBeenCalled();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("should do nothing if no current daemon", () => {
|
|
363
|
+
expect(() => daemonManager.cleanup()).not.toThrow();
|
|
364
|
+
expect(mockChild.kill).not.toHaveBeenCalled();
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe("getCurrentDaemon", () => {
|
|
369
|
+
it("should return null initially", () => {
|
|
370
|
+
expect(daemonManager.getCurrentDaemon()).toBeNull();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("should return current daemon after start", async () => {
|
|
374
|
+
await daemonManager.startDaemon(mockServerFactory);
|
|
375
|
+
expect(daemonManager.getCurrentDaemon()).toBe(mockChild);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
});
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import type { ServiceStartOptions } from "@cli/interfaces/Service.js";
|
|
2
|
+
import { PathUtils } from "@cli/utils/PathUtils.js";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { ServiceManagerImpl } from "../ServiceManager";
|
|
5
|
+
|
|
6
|
+
// Mock external dependencies
|
|
7
|
+
vi.mock("node:child_process", () => ({
|
|
8
|
+
spawn: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("node:fs", () => ({
|
|
12
|
+
default: {
|
|
13
|
+
existsSync: vi.fn(),
|
|
14
|
+
createWriteStream: vi.fn(),
|
|
15
|
+
mkdirSync: vi.fn(),
|
|
16
|
+
},
|
|
17
|
+
existsSync: vi.fn(),
|
|
18
|
+
createWriteStream: vi.fn(),
|
|
19
|
+
mkdirSync: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("@cli/utils/PathUtils.js", () => ({
|
|
23
|
+
PathUtils: {
|
|
24
|
+
getWebServerLauncherPath: vi.fn(),
|
|
25
|
+
getExecutablePath: vi.fn(),
|
|
26
|
+
getConfigDir: vi.fn(),
|
|
27
|
+
getLogFile: vi.fn(),
|
|
28
|
+
getMcpServerProxyPath: vi.fn(),
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// Mock process.exit
|
|
33
|
+
const mockProcessExit = vi.spyOn(process, "exit").mockImplementation((code) => {
|
|
34
|
+
throw new Error(`process.exit unexpectedly called with "${code}"`);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Mock console methods
|
|
38
|
+
const mockConsoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
39
|
+
|
|
40
|
+
describe("Daemon 模式集成测试", () => {
|
|
41
|
+
let serviceManager: ServiceManagerImpl;
|
|
42
|
+
let mockProcessManager: any;
|
|
43
|
+
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
// Reset all mocks but preserve implementations
|
|
46
|
+
mockProcessExit.mockClear();
|
|
47
|
+
mockConsoleLog.mockClear();
|
|
48
|
+
|
|
49
|
+
// Re-setup mock implementations
|
|
50
|
+
mockProcessExit.mockImplementation((code) => {
|
|
51
|
+
throw new Error(`process.exit unexpectedly called with "${code}"`);
|
|
52
|
+
});
|
|
53
|
+
mockConsoleLog.mockImplementation(() => {});
|
|
54
|
+
|
|
55
|
+
// Setup PathUtils mocks
|
|
56
|
+
vi.mocked(PathUtils.getWebServerLauncherPath).mockReturnValue(
|
|
57
|
+
"/test/WebServerLauncher.js"
|
|
58
|
+
);
|
|
59
|
+
vi.mocked(PathUtils.getExecutablePath).mockImplementation(
|
|
60
|
+
(name: string) => {
|
|
61
|
+
return `/test/${name}.js`;
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
vi.mocked(PathUtils.getConfigDir).mockReturnValue("/test/config");
|
|
65
|
+
vi.mocked(PathUtils.getLogFile).mockReturnValue("/test/logs/xiaozhi.log");
|
|
66
|
+
|
|
67
|
+
// Setup fs mocks
|
|
68
|
+
const fs = await import("node:fs");
|
|
69
|
+
vi.mocked(fs.default.existsSync).mockReturnValue(true);
|
|
70
|
+
|
|
71
|
+
// Setup ProcessManager mock
|
|
72
|
+
mockProcessManager = {
|
|
73
|
+
savePidInfo: vi.fn(),
|
|
74
|
+
cleanupPidFile: vi.fn(),
|
|
75
|
+
isServiceRunning: vi.fn().mockReturnValue(false),
|
|
76
|
+
getServiceStatus: vi.fn().mockReturnValue({ running: false }),
|
|
77
|
+
stopService: vi.fn(),
|
|
78
|
+
cleanupContainerState: vi.fn(),
|
|
79
|
+
killProcess: vi.fn(),
|
|
80
|
+
isXiaozhiProcess: vi.fn(),
|
|
81
|
+
gracefulKillProcess: vi.fn(),
|
|
82
|
+
processExists: vi.fn(),
|
|
83
|
+
getProcessInfo: vi.fn(),
|
|
84
|
+
validatePidFile: vi.fn(),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Setup ConfigManager mock
|
|
88
|
+
const mockConfigManager = {
|
|
89
|
+
configExists: vi.fn().mockReturnValue(true),
|
|
90
|
+
getConfig: vi.fn().mockReturnValue({ webServer: { port: 9999 } }),
|
|
91
|
+
} as any;
|
|
92
|
+
|
|
93
|
+
serviceManager = new ServiceManagerImpl(
|
|
94
|
+
mockProcessManager,
|
|
95
|
+
mockConfigManager
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterEach(() => {
|
|
100
|
+
// Don't restore mocks to preserve spy functionality
|
|
101
|
+
// vi.restoreAllMocks();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("WebServer Daemon 模式", () => {
|
|
105
|
+
it("应完成完整的 daemon 启动工作流程", async () => {
|
|
106
|
+
const { spawn } = await import("node:child_process");
|
|
107
|
+
const mockSpawn = vi.mocked(spawn);
|
|
108
|
+
|
|
109
|
+
const mockChild = {
|
|
110
|
+
pid: 12345,
|
|
111
|
+
unref: vi.fn(),
|
|
112
|
+
stdout: null,
|
|
113
|
+
stderr: null,
|
|
114
|
+
};
|
|
115
|
+
mockSpawn.mockReturnValue(mockChild as any);
|
|
116
|
+
|
|
117
|
+
const options: ServiceStartOptions = {
|
|
118
|
+
daemon: true,
|
|
119
|
+
mode: "normal",
|
|
120
|
+
port: 3000,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Execute daemon start
|
|
124
|
+
await expect(serviceManager.start(options)).rejects.toThrow(
|
|
125
|
+
"process.exit unexpectedly called"
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// Verify complete workflow
|
|
129
|
+
expect(PathUtils.getWebServerLauncherPath).toHaveBeenCalled();
|
|
130
|
+
expect(mockSpawn).toHaveBeenCalledWith(
|
|
131
|
+
"node",
|
|
132
|
+
["/test/WebServerLauncher.js"],
|
|
133
|
+
{
|
|
134
|
+
detached: true,
|
|
135
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
136
|
+
env: expect.objectContaining({
|
|
137
|
+
XIAOZHI_CONFIG_DIR: "/test/config",
|
|
138
|
+
XIAOZHI_DAEMON: "true",
|
|
139
|
+
}),
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
expect(mockProcessManager.savePidInfo).toHaveBeenCalledWith(
|
|
143
|
+
12345,
|
|
144
|
+
"daemon"
|
|
145
|
+
);
|
|
146
|
+
expect(mockChild.unref).toHaveBeenCalled();
|
|
147
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
148
|
+
"✅ 后台服务已启动 (PID: 12345)"
|
|
149
|
+
);
|
|
150
|
+
expect(mockProcessExit).toHaveBeenCalledWith(0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("应处理默认 daemon 启动", async () => {
|
|
154
|
+
const { spawn } = await import("node:child_process");
|
|
155
|
+
const mockSpawn = vi.mocked(spawn);
|
|
156
|
+
|
|
157
|
+
const mockChild = {
|
|
158
|
+
pid: 12346,
|
|
159
|
+
unref: vi.fn(),
|
|
160
|
+
};
|
|
161
|
+
mockSpawn.mockReturnValue(mockChild as any);
|
|
162
|
+
|
|
163
|
+
const options: ServiceStartOptions = {
|
|
164
|
+
daemon: true,
|
|
165
|
+
mode: "normal",
|
|
166
|
+
port: 3000,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
await expect(serviceManager.start(options)).rejects.toThrow(
|
|
170
|
+
"process.exit unexpectedly called"
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
expect(mockSpawn).toHaveBeenCalledWith(
|
|
174
|
+
"node",
|
|
175
|
+
["/test/WebServerLauncher.js"],
|
|
176
|
+
{
|
|
177
|
+
detached: true,
|
|
178
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
179
|
+
env: expect.objectContaining({
|
|
180
|
+
XIAOZHI_CONFIG_DIR: "/test/config",
|
|
181
|
+
XIAOZHI_DAEMON: "true",
|
|
182
|
+
}),
|
|
183
|
+
}
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("MCP Server Daemon 模式", () => {
|
|
189
|
+
it("应完成完整的 MCP daemon 启动工作流程", async () => {
|
|
190
|
+
// Re-setup console.log mock for this specific test
|
|
191
|
+
mockConsoleLog.mockClear();
|
|
192
|
+
mockConsoleLog.mockImplementation(() => {});
|
|
193
|
+
|
|
194
|
+
const { spawn } = await import("node:child_process");
|
|
195
|
+
const mockSpawn = vi.mocked(spawn);
|
|
196
|
+
|
|
197
|
+
const mockChild = {
|
|
198
|
+
pid: 54321,
|
|
199
|
+
unref: vi.fn(),
|
|
200
|
+
};
|
|
201
|
+
mockSpawn.mockReturnValue(mockChild as any);
|
|
202
|
+
|
|
203
|
+
const options: ServiceStartOptions = {
|
|
204
|
+
daemon: true,
|
|
205
|
+
mode: "mcp-server",
|
|
206
|
+
port: 4000,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Execute daemon start
|
|
210
|
+
await expect(serviceManager.start(options)).rejects.toThrow(
|
|
211
|
+
"process.exit unexpectedly called"
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
expect(mockSpawn).toHaveBeenCalledWith(
|
|
215
|
+
"node",
|
|
216
|
+
["/test/cli.js", "start", "--server", "4000"],
|
|
217
|
+
{
|
|
218
|
+
detached: true,
|
|
219
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
220
|
+
env: expect.objectContaining({
|
|
221
|
+
XIAOZHI_CONFIG_DIR: "/test/config",
|
|
222
|
+
XIAOZHI_DAEMON: "true",
|
|
223
|
+
MCP_SERVER_MODE: "true",
|
|
224
|
+
}),
|
|
225
|
+
}
|
|
226
|
+
);
|
|
227
|
+
expect(mockProcessManager.savePidInfo).toHaveBeenCalledWith(
|
|
228
|
+
54321,
|
|
229
|
+
"daemon"
|
|
230
|
+
);
|
|
231
|
+
expect(mockChild.unref).toHaveBeenCalled();
|
|
232
|
+
|
|
233
|
+
// Verify console.log was called with the success message
|
|
234
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
235
|
+
"✅ MCP Server 已在后台启动 (PID: 54321, Port: 4000)"
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("错误处理", () => {
|
|
241
|
+
it("应处理缺失的 WebServer 文件", async () => {
|
|
242
|
+
const fs = await import("node:fs");
|
|
243
|
+
vi.mocked(fs.default.existsSync).mockReturnValue(false);
|
|
244
|
+
|
|
245
|
+
const options: ServiceStartOptions = {
|
|
246
|
+
daemon: true,
|
|
247
|
+
mode: "normal",
|
|
248
|
+
port: 3000,
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
await expect(serviceManager.start(options)).rejects.toThrow(
|
|
252
|
+
"WebServer 文件不存在: /test/WebServerLauncher.js"
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// Should not attempt to spawn or exit
|
|
256
|
+
expect(mockProcessExit).not.toHaveBeenCalled();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("应处理 spawn 错误", async () => {
|
|
260
|
+
const { spawn } = await import("node:child_process");
|
|
261
|
+
const mockSpawn = vi.mocked(spawn);
|
|
262
|
+
|
|
263
|
+
mockSpawn.mockImplementation(() => {
|
|
264
|
+
throw new Error("Spawn failed");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const options: ServiceStartOptions = {
|
|
268
|
+
daemon: true,
|
|
269
|
+
mode: "normal",
|
|
270
|
+
port: 3000,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
await expect(serviceManager.start(options)).rejects.toThrow(
|
|
274
|
+
"Spawn failed"
|
|
275
|
+
);
|
|
276
|
+
expect(mockProcessExit).not.toHaveBeenCalled();
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("进程分离", () => {
|
|
281
|
+
it("应确保完全的进程分离", async () => {
|
|
282
|
+
const { spawn } = await import("node:child_process");
|
|
283
|
+
const mockSpawn = vi.mocked(spawn);
|
|
284
|
+
|
|
285
|
+
const mockChild = {
|
|
286
|
+
pid: 99999,
|
|
287
|
+
unref: vi.fn(),
|
|
288
|
+
stdout: { pipe: vi.fn() },
|
|
289
|
+
stderr: { pipe: vi.fn() },
|
|
290
|
+
};
|
|
291
|
+
mockSpawn.mockReturnValue(mockChild as any);
|
|
292
|
+
|
|
293
|
+
const options: ServiceStartOptions = {
|
|
294
|
+
daemon: true,
|
|
295
|
+
mode: "normal",
|
|
296
|
+
port: 3000,
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
await expect(serviceManager.start(options)).rejects.toThrow(
|
|
300
|
+
"process.exit unexpectedly called"
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
// Verify stdio is completely ignored (no piping)
|
|
304
|
+
expect(mockChild.stdout.pipe).not.toHaveBeenCalled();
|
|
305
|
+
expect(mockChild.stderr.pipe).not.toHaveBeenCalled();
|
|
306
|
+
|
|
307
|
+
// Verify process is detached and unreferenced
|
|
308
|
+
const spawnCall = mockSpawn.mock.calls[0];
|
|
309
|
+
expect(spawnCall[2]).toEqual(
|
|
310
|
+
expect.objectContaining({
|
|
311
|
+
detached: true,
|
|
312
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
313
|
+
})
|
|
314
|
+
);
|
|
315
|
+
expect(mockChild.unref).toHaveBeenCalled();
|
|
316
|
+
|
|
317
|
+
// Note: process.exit is called but the test catches the error
|
|
318
|
+
// The fact that we reach this point means the daemon setup worked correctly
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
});
|