chatccc 0.2.233 → 0.2.235
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 +105 -91
- package/config.sample.json +9 -9
- package/package.json +2 -2
- package/src/__tests__/claude-raw-stream-log.test.ts +96 -87
- package/src/__tests__/claude-sdk-installer.test.ts +285 -0
- package/src/__tests__/restart.test.ts +177 -177
- package/src/__tests__/web-ui.test.ts +411 -326
- package/src/adapters/claude-adapter.ts +673 -620
- package/src/claude-sdk-installer.ts +324 -0
- package/src/orchestrator.ts +2517 -2517
- package/src/web-ui.ts +171 -46
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
CLAUDE_SDK_VERSION,
|
|
15
|
+
getClaudeSdkEntryPath,
|
|
16
|
+
getClaudeSdkInstalledVersion,
|
|
17
|
+
installClaudeSdk,
|
|
18
|
+
isClaudeSdkInstalled,
|
|
19
|
+
isClaudeSdkInstalling,
|
|
20
|
+
type SdkInstallProgress,
|
|
21
|
+
} from "../claude-sdk-installer.ts";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// 工具:fake npm(可执行脚本,模拟安装过程并伪造产物)
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const tempDirs: string[] = [];
|
|
28
|
+
|
|
29
|
+
function makeTempDir(prefix = "claude-sdk-test-"): string {
|
|
30
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
31
|
+
tempDirs.push(dir);
|
|
32
|
+
return dir;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 伪造 SDK 产物:node_modules/@anthropic-ai/claude-agent-sdk/package.json */
|
|
36
|
+
function fakeInstallArtifact(dir: string, version: string = CLAUDE_SDK_VERSION): void {
|
|
37
|
+
const pkgDir = join(dir, "node_modules", "@anthropic-ai", "claude-agent-sdk");
|
|
38
|
+
mkdirSync(pkgDir, { recursive: true });
|
|
39
|
+
writeFileSync(
|
|
40
|
+
join(pkgDir, "package.json"),
|
|
41
|
+
JSON.stringify({ name: "@anthropic-ai/claude-agent-sdk", version }),
|
|
42
|
+
"utf8",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 生成 fake npm 可执行脚本,行为由环境变量控制(FAKE_NPM_FAIL / FAKE_NPM_SLEEP) */
|
|
47
|
+
function makeFakeNpmCommand(workDir: string): string {
|
|
48
|
+
const mjs = join(workDir, "fake-npm.mjs");
|
|
49
|
+
writeFileSync(
|
|
50
|
+
mjs,
|
|
51
|
+
[
|
|
52
|
+
'import fs from "node:fs";',
|
|
53
|
+
'const args = process.argv.slice(2);',
|
|
54
|
+
'const prefixIdx = args.indexOf("--prefix");',
|
|
55
|
+
'const dir = prefixIdx >= 0 ? args[prefixIdx + 1] : null;',
|
|
56
|
+
'const pkg = args.find((a) => a.startsWith("@anthropic-ai/claude-agent-sdk@"));',
|
|
57
|
+
'const version = pkg ? pkg.split("@").pop() : "0.0.0";',
|
|
58
|
+
'if (dir) {',
|
|
59
|
+
' fs.mkdirSync(dir + "/node_modules/@anthropic-ai/claude-agent-sdk", { recursive: true });',
|
|
60
|
+
' fs.writeFileSync(dir + "/node_modules/@anthropic-ai/claude-agent-sdk/package.json", JSON.stringify({ name: "@anthropic-ai/claude-agent-sdk", version }));',
|
|
61
|
+
"}",
|
|
62
|
+
'console.log("npm http fetch GET 200 https://registry.npmjs.org/fake");',
|
|
63
|
+
'console.log("npm http fetch GET 200 https://registry.npmjs.org/fake2");',
|
|
64
|
+
'if (process.env.FAKE_NPM_SLEEP) {',
|
|
65
|
+
" await new Promise((r) => setTimeout(r, Number(process.env.FAKE_NPM_SLEEP)));",
|
|
66
|
+
"}",
|
|
67
|
+
'if (process.env.FAKE_NPM_FAIL === "1") process.exit(1);',
|
|
68
|
+
"process.exit(0);",
|
|
69
|
+
"",
|
|
70
|
+
].join("\n"),
|
|
71
|
+
"utf8",
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
if (process.platform === "win32") {
|
|
75
|
+
const cmd = join(workDir, "fake-npm.cmd");
|
|
76
|
+
const winPath = mjs.replace(/\//g, "\\");
|
|
77
|
+
writeFileSync(
|
|
78
|
+
cmd,
|
|
79
|
+
["@echo off", `node "${winPath}" %*`, ""].join("\r\n"),
|
|
80
|
+
"utf8",
|
|
81
|
+
);
|
|
82
|
+
return cmd;
|
|
83
|
+
}
|
|
84
|
+
const sh = join(workDir, "fake-npm.sh");
|
|
85
|
+
writeFileSync(sh, `#!/bin/sh\nexec node "${mjs}" "$@"\n`, "utf8");
|
|
86
|
+
// chmod +x
|
|
87
|
+
try {
|
|
88
|
+
chmodSync(sh, 0o755);
|
|
89
|
+
} catch {
|
|
90
|
+
// POSIX only
|
|
91
|
+
}
|
|
92
|
+
return sh;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
for (const dir of tempDirs.splice(0)) {
|
|
99
|
+
try {
|
|
100
|
+
rmSync(dir, { recursive: true, force: true });
|
|
101
|
+
} catch {
|
|
102
|
+
// 清理失败不阻塞
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// 状态查询
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
describe("claude-sdk-installer 状态查询", () => {
|
|
112
|
+
it("getClaudeSdkEntryPath 指向 sdk.mjs 入口", () => {
|
|
113
|
+
const dir = makeTempDir();
|
|
114
|
+
const entry = getClaudeSdkEntryPath(dir);
|
|
115
|
+
expect(entry).toContain(join("node_modules", "@anthropic-ai", "claude-agent-sdk"));
|
|
116
|
+
expect(entry.endsWith("sdk.mjs")).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("isClaudeSdkInstalled:空目录为 false,有 package.json 为 true", () => {
|
|
120
|
+
const dir = makeTempDir();
|
|
121
|
+
expect(isClaudeSdkInstalled(dir)).toBe(false);
|
|
122
|
+
fakeInstallArtifact(dir);
|
|
123
|
+
expect(isClaudeSdkInstalled(dir)).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("getClaudeSdkInstalledVersion:读取版本号", () => {
|
|
127
|
+
const dir = makeTempDir();
|
|
128
|
+
expect(getClaudeSdkInstalledVersion(dir)).toBeNull();
|
|
129
|
+
fakeInstallArtifact(dir, "9.9.9");
|
|
130
|
+
expect(getClaudeSdkInstalledVersion(dir)).toBe("9.9.9");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("getClaudeSdkInstalledVersion:损坏 JSON 返回 null", () => {
|
|
134
|
+
const dir = makeTempDir();
|
|
135
|
+
const pkgDir = join(dir, "node_modules", "@anthropic-ai", "claude-agent-sdk");
|
|
136
|
+
mkdirSync(pkgDir, { recursive: true });
|
|
137
|
+
writeFileSync(join(pkgDir, "package.json"), "{ not json", "utf8");
|
|
138
|
+
expect(getClaudeSdkInstalledVersion(dir)).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("isClaudeSdkInstalling:无锁为 false", () => {
|
|
142
|
+
const dir = makeTempDir();
|
|
143
|
+
expect(isClaudeSdkInstalling(dir)).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("isClaudeSdkInstalling:活 pid 锁为 true", () => {
|
|
147
|
+
const dir = makeTempDir();
|
|
148
|
+
writeFileSync(join(dir, ".installing"), String(process.pid), "utf8");
|
|
149
|
+
expect(isClaudeSdkInstalling(dir)).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("isClaudeSdkInstalling:死 pid 锁自动清除并返回 false", () => {
|
|
153
|
+
const dir = makeTempDir();
|
|
154
|
+
writeFileSync(join(dir, ".installing"), "999999999", "utf8");
|
|
155
|
+
expect(isClaudeSdkInstalling(dir)).toBe(false);
|
|
156
|
+
expect(existsSync(join(dir, ".installing"))).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("isClaudeSdkInstalling:损坏锁自动清除并返回 false", () => {
|
|
160
|
+
const dir = makeTempDir();
|
|
161
|
+
writeFileSync(join(dir, ".installing"), "abc", "utf8");
|
|
162
|
+
expect(isClaudeSdkInstalling(dir)).toBe(false);
|
|
163
|
+
expect(existsSync(join(dir, ".installing"))).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// 安装流程
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
describe("installClaudeSdk", () => {
|
|
172
|
+
it("已装同版本 → 直接 done,不 spawn", async () => {
|
|
173
|
+
const dir = makeTempDir();
|
|
174
|
+
fakeInstallArtifact(dir, CLAUDE_SDK_VERSION);
|
|
175
|
+
const phases: string[] = [];
|
|
176
|
+
await installClaudeSdk({
|
|
177
|
+
dir,
|
|
178
|
+
npmCommand: "definitely-not-exists",
|
|
179
|
+
onProgress: (p) => phases.push(p.phase),
|
|
180
|
+
});
|
|
181
|
+
expect(phases).toEqual(["done"]);
|
|
182
|
+
expect(getClaudeSdkInstalledVersion(dir)).toBe(CLAUDE_SDK_VERSION);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("未安装 → fake npm 成功 → done + 锁清除 + 进度序列", async () => {
|
|
186
|
+
const dir = makeTempDir();
|
|
187
|
+
const npmCommand = makeFakeNpmCommand(makeTempDir());
|
|
188
|
+
const phases: string[] = [];
|
|
189
|
+
const messages: string[] = [];
|
|
190
|
+
await installClaudeSdk({
|
|
191
|
+
dir,
|
|
192
|
+
npmCommand,
|
|
193
|
+
onProgress: (p) => {
|
|
194
|
+
phases.push(p.phase);
|
|
195
|
+
messages.push(p.message);
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
expect(phases[0]).toBe("detecting");
|
|
199
|
+
expect(phases).toContain("downloading");
|
|
200
|
+
expect(phases[phases.length - 1]).toBe("done");
|
|
201
|
+
expect(isClaudeSdkInstalled(dir)).toBe(true);
|
|
202
|
+
expect(getClaudeSdkInstalledVersion(dir)).toBe(CLAUDE_SDK_VERSION);
|
|
203
|
+
expect(existsSync(join(dir, ".installing"))).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("已装不同版本 → 触发重装并成功", async () => {
|
|
207
|
+
const dir = makeTempDir();
|
|
208
|
+
fakeInstallArtifact(dir, "0.1.0");
|
|
209
|
+
const npmCommand = makeFakeNpmCommand(makeTempDir());
|
|
210
|
+
await installClaudeSdk({ dir, npmCommand });
|
|
211
|
+
expect(getClaudeSdkInstalledVersion(dir)).toBe(CLAUDE_SDK_VERSION);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("安装失败(fake npm exit 1)→ error + 目录清理 + reject", async () => {
|
|
215
|
+
const dir = makeTempDir();
|
|
216
|
+
const npmCommand = makeFakeNpmCommand(makeTempDir());
|
|
217
|
+
const phases: string[] = [];
|
|
218
|
+
process.env.FAKE_NPM_FAIL = "1";
|
|
219
|
+
try {
|
|
220
|
+
await expect(
|
|
221
|
+
installClaudeSdk({
|
|
222
|
+
dir,
|
|
223
|
+
npmCommand,
|
|
224
|
+
onProgress: (p) => phases.push(p.phase),
|
|
225
|
+
}).then(() => {
|
|
226
|
+
throw new Error("should have rejected");
|
|
227
|
+
}),
|
|
228
|
+
).rejects.toThrow(/退出码 1/);
|
|
229
|
+
} finally {
|
|
230
|
+
delete process.env.FAKE_NPM_FAIL;
|
|
231
|
+
}
|
|
232
|
+
expect(phases[phases.length - 1]).toBe("error");
|
|
233
|
+
expect(existsSync(dir)).toBe(false);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("安装失败(spawn error)→ reject 且不残留锁", async () => {
|
|
237
|
+
const dir = makeTempDir();
|
|
238
|
+
await expect(
|
|
239
|
+
installClaudeSdk({ dir, npmCommand: join(makeTempDir(), "no-such-npm") }),
|
|
240
|
+
).rejects.toThrow();
|
|
241
|
+
expect(existsSync(dir)).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("锁存在(活 pid)→ reject 且不启动安装", async () => {
|
|
245
|
+
const dir = makeTempDir();
|
|
246
|
+
writeFileSync(join(dir, ".installing"), String(process.pid), "utf8");
|
|
247
|
+
await expect(
|
|
248
|
+
installClaudeSdk({ dir, npmCommand: "definitely-not-exists" }),
|
|
249
|
+
).rejects.toThrow(/另一个安装任务/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("并发:安装进行中再次调用 → reject", async () => {
|
|
253
|
+
const dir = makeTempDir();
|
|
254
|
+
const npmCommand = makeFakeNpmCommand(makeTempDir());
|
|
255
|
+
process.env.FAKE_NPM_SLEEP = "400";
|
|
256
|
+
try {
|
|
257
|
+
const p1 = installClaudeSdk({
|
|
258
|
+
dir,
|
|
259
|
+
npmCommand,
|
|
260
|
+
expectedVersion: CLAUDE_SDK_VERSION,
|
|
261
|
+
});
|
|
262
|
+
await sleep(80); // 等 installInProgress = true
|
|
263
|
+
await expect(
|
|
264
|
+
installClaudeSdk({ dir, npmCommand }),
|
|
265
|
+
).rejects.toThrow(/正在安装/);
|
|
266
|
+
await p1;
|
|
267
|
+
} finally {
|
|
268
|
+
delete process.env.FAKE_NPM_SLEEP;
|
|
269
|
+
}
|
|
270
|
+
expect(getClaudeSdkInstalledVersion(dir)).toBe(CLAUDE_SDK_VERSION);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("进度 percent 单调且封顶 100", async () => {
|
|
274
|
+
const dir = makeTempDir();
|
|
275
|
+
const npmCommand = makeFakeNpmCommand(makeTempDir());
|
|
276
|
+
const progress: SdkInstallProgress[] = [];
|
|
277
|
+
await installClaudeSdk({ dir, npmCommand, onProgress: (p) => progress.push(p) });
|
|
278
|
+
expect(progress.length).toBeGreaterThan(1);
|
|
279
|
+
for (const p of progress) {
|
|
280
|
+
expect(p.percent).toBeGreaterThanOrEqual(0);
|
|
281
|
+
expect(p.percent).toBeLessThanOrEqual(100);
|
|
282
|
+
}
|
|
283
|
+
expect(progress[progress.length - 1].percent).toBe(100);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -1,177 +1,177 @@
|
|
|
1
|
-
import { EventEmitter } from "node:events";
|
|
2
|
-
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
|
|
6
|
-
import { describe, expect, it, vi, afterEach } from "vitest";
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
buildRestartSpawnSpec,
|
|
10
|
-
decideRestartParentExit,
|
|
11
|
-
spawnRestartChild,
|
|
12
|
-
RESTART_CHILD_READY_MS,
|
|
13
|
-
} from "../orchestrator.ts";
|
|
14
|
-
import { INTERNAL_RESTART_ENV_VAR } from "../startup-lifecycle.ts";
|
|
15
|
-
|
|
16
|
-
describe("buildRestartSpawnSpec", () => {
|
|
17
|
-
it("spawns node directly with the local tsx CLI (never via npx/npm)", () => {
|
|
18
|
-
const spec = buildRestartSpawnSpec("F:/proj");
|
|
19
|
-
expect(spec.command).toBe(process.execPath);
|
|
20
|
-
expect(spec.args[0]).toBe(join("F:/proj", "node_modules", "tsx", "dist", "cli.mjs"));
|
|
21
|
-
expect(spec.args[1]).toBe("src/index.ts");
|
|
22
|
-
expect([spec.command, ...spec.args].join(" ")).not.toMatch(/npx|npm/i);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("accepts the default project root", () => {
|
|
26
|
-
const spec = buildRestartSpawnSpec();
|
|
27
|
-
expect(spec.command).toBe(process.execPath);
|
|
28
|
-
expect(spec.args[0]).toContain("tsx");
|
|
29
|
-
});
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
describe("spawnRestartChild", () => {
|
|
33
|
-
class FakeChild extends EventEmitter {
|
|
34
|
-
pid = 12345;
|
|
35
|
-
exitCode: number | null = null;
|
|
36
|
-
signalCode: number | string | null = null;
|
|
37
|
-
unref = vi.fn();
|
|
38
|
-
stderr = new EventEmitter();
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
let tmpDirs: string[] = [];
|
|
42
|
-
afterEach(async () => {
|
|
43
|
-
for (const dir of tmpDirs) await rm(dir, { recursive: true, force: true });
|
|
44
|
-
tmpDirs = [];
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
async function tmpLogDir(): Promise<string> {
|
|
48
|
-
const dir = await mkdtemp(join(tmpdir(), "chatccc-restart-"));
|
|
49
|
-
tmpDirs.push(dir);
|
|
50
|
-
return dir;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
it("spawns without a shell, redirects stderr to a restart log file, and marks the internal restart env", async () => {
|
|
54
|
-
const logDir = await tmpLogDir();
|
|
55
|
-
const fake = new FakeChild();
|
|
56
|
-
const spawnImpl = vi.fn(() => fake as never);
|
|
57
|
-
const trace = vi.fn();
|
|
58
|
-
|
|
59
|
-
const child = spawnRestartChild({ projectRoot: "F:/proj", spawnImpl, trace, restartLogDir: logDir });
|
|
60
|
-
|
|
61
|
-
expect(child).toBe(fake);
|
|
62
|
-
expect(spawnImpl).toHaveBeenCalledWith(
|
|
63
|
-
process.execPath,
|
|
64
|
-
[join("F:/proj", "node_modules", "tsx", "dist", "cli.mjs"), "src/index.ts"],
|
|
65
|
-
expect.objectContaining({
|
|
66
|
-
detached: true,
|
|
67
|
-
env: expect.objectContaining({ [INTERNAL_RESTART_ENV_VAR]: "1" }),
|
|
68
|
-
// 不使用 shell:避免 npm/npx 的 PATH 注入问题
|
|
69
|
-
shell: false,
|
|
70
|
-
}),
|
|
71
|
-
);
|
|
72
|
-
// spawnImpl 收到的 command/args 中不得出现 npx/npm(env 里的 npm_* 变量不算)
|
|
73
|
-
const firstCall = spawnImpl.mock.calls[0] as unknown as [string, string[]];
|
|
74
|
-
const [cmd, args] = firstCall;
|
|
75
|
-
expect([cmd, ...args].join(" ")).not.toMatch(/npx|npm/i);
|
|
76
|
-
|
|
77
|
-
// stderr 指向磁盘日志文件(fd),而不是 pipe:pipe 读端随父进程退出关闭后,
|
|
78
|
-
// 子进程写 stderr 会 EPIPE 崩溃(飞书 SDK console.warn 崩溃根因)。
|
|
79
|
-
const callArgs = spawnImpl.mock.calls[0] as unknown as Array<unknown>;
|
|
80
|
-
const stdio = (callArgs[2] as { stdio: unknown[] }).stdio;
|
|
81
|
-
expect(stdio[0]).toBe("ignore");
|
|
82
|
-
expect(stdio[1]).toBe("ignore");
|
|
83
|
-
expect(typeof stdio[2]).toBe("number");
|
|
84
|
-
expect(stdio[2] as number).toBeGreaterThan(2);
|
|
85
|
-
|
|
86
|
-
const files = await readdir(logDir);
|
|
87
|
-
expect(files.some((f) => f.startsWith("restart-") && f.endsWith(".log"))).toBe(true);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("records child exit code/signal in trace without pipe capture", async () => {
|
|
91
|
-
const logDir = await tmpLogDir();
|
|
92
|
-
const fake = new FakeChild();
|
|
93
|
-
const spawnImpl = vi.fn(() => fake as never);
|
|
94
|
-
const trace = vi.fn();
|
|
95
|
-
|
|
96
|
-
spawnRestartChild({ projectRoot: "F:/proj", spawnImpl, trace, restartLogDir: logDir });
|
|
97
|
-
|
|
98
|
-
fake.exitCode = 1;
|
|
99
|
-
fake.emit("exit", 1, null);
|
|
100
|
-
|
|
101
|
-
const exitCall = trace.mock.calls.find(([name]) => name === "restart: child exit");
|
|
102
|
-
expect(exitCall).toBeTruthy();
|
|
103
|
-
expect(exitCall![1]).toEqual(expect.objectContaining({
|
|
104
|
-
childPid: 12345,
|
|
105
|
-
code: 1,
|
|
106
|
-
signal: null,
|
|
107
|
-
}));
|
|
108
|
-
// 不再用 pipe 收集 stderr,trace 里不再有 stderr 字段
|
|
109
|
-
expect(exitCall![1]).not.toHaveProperty("stderr");
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it("falls back to pipe capture when the stderr log file cannot be opened", async () => {
|
|
113
|
-
const dir = await tmpLogDir();
|
|
114
|
-
// 用普通文件顶替目录:mkdir/open 日志文件必然失败
|
|
115
|
-
const blocker = join(dir, "blocker");
|
|
116
|
-
await writeFile(blocker, "x");
|
|
117
|
-
const fake = new FakeChild();
|
|
118
|
-
const spawnImpl = vi.fn(() => fake as never);
|
|
119
|
-
const trace = vi.fn();
|
|
120
|
-
|
|
121
|
-
spawnRestartChild({ projectRoot: "F:/proj", spawnImpl, trace, restartLogDir: blocker });
|
|
122
|
-
|
|
123
|
-
const callArgs = spawnImpl.mock.calls[0] as unknown as Array<unknown>;
|
|
124
|
-
expect((callArgs[2] as { stdio: unknown[] }).stdio[2]).toBe("pipe");
|
|
125
|
-
const failCall = trace.mock.calls.find(
|
|
126
|
-
([name]) => name === "restart: stderr log open failed, falling back to pipe",
|
|
127
|
-
);
|
|
128
|
-
expect(failCall).toBeTruthy();
|
|
129
|
-
expect(typeof (failCall![1] as { error: unknown }).error).toBe("string");
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
describe("decideRestartParentExit", () => {
|
|
134
|
-
it("returns false (parent stays alive) when the child dies during the window", async () => {
|
|
135
|
-
const child = { exitCode: 1, signalCode: null, pid: 42 } as never;
|
|
136
|
-
const trace = vi.fn();
|
|
137
|
-
|
|
138
|
-
const shouldExit = await decideRestartParentExit(child, 200, 50, trace);
|
|
139
|
-
|
|
140
|
-
expect(shouldExit).toBe(false);
|
|
141
|
-
expect(trace).toHaveBeenCalledWith(
|
|
142
|
-
"restart: child died during window, keeping parent",
|
|
143
|
-
expect.objectContaining({ childPid: 42, exitCode: 1 }),
|
|
144
|
-
);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it("returns true (parent exits) when the child stays alive through the window", async () => {
|
|
148
|
-
const child = { exitCode: null, signalCode: null, pid: 42 } as never;
|
|
149
|
-
const trace = vi.fn();
|
|
150
|
-
|
|
151
|
-
const shouldExit = await decideRestartParentExit(child, 150, 30, trace);
|
|
152
|
-
|
|
153
|
-
expect(shouldExit).toBe(true);
|
|
154
|
-
expect(trace).toHaveBeenCalledWith(
|
|
155
|
-
"restart: child alive after window, parent exiting",
|
|
156
|
-
expect.objectContaining({ childPid: 42 }),
|
|
157
|
-
);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it("surfaces a child that died with a signal", async () => {
|
|
161
|
-
const child = { exitCode: null, signalCode: "SIGKILL", pid: 42 } as never;
|
|
162
|
-
const trace = vi.fn();
|
|
163
|
-
|
|
164
|
-
const shouldExit = await decideRestartParentExit(child, 100, 25, trace);
|
|
165
|
-
|
|
166
|
-
expect(shouldExit).toBe(false);
|
|
167
|
-
expect(trace).toHaveBeenCalledWith(
|
|
168
|
-
"restart: child died during window, keeping parent",
|
|
169
|
-
expect.objectContaining({ signalCode: "SIGKILL" }),
|
|
170
|
-
);
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it("exports a sane default readiness window", () => {
|
|
174
|
-
expect(RESTART_CHILD_READY_MS).toBeGreaterThan(0);
|
|
175
|
-
expect(RESTART_CHILD_READY_MS).toBeLessThan(60_000);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it, vi, afterEach } from "vitest";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
buildRestartSpawnSpec,
|
|
10
|
+
decideRestartParentExit,
|
|
11
|
+
spawnRestartChild,
|
|
12
|
+
RESTART_CHILD_READY_MS,
|
|
13
|
+
} from "../orchestrator.ts";
|
|
14
|
+
import { INTERNAL_RESTART_ENV_VAR } from "../startup-lifecycle.ts";
|
|
15
|
+
|
|
16
|
+
describe("buildRestartSpawnSpec", () => {
|
|
17
|
+
it("spawns node directly with the local tsx CLI (never via npx/npm)", () => {
|
|
18
|
+
const spec = buildRestartSpawnSpec("F:/proj");
|
|
19
|
+
expect(spec.command).toBe(process.execPath);
|
|
20
|
+
expect(spec.args[0]).toBe(join("F:/proj", "node_modules", "tsx", "dist", "cli.mjs"));
|
|
21
|
+
expect(spec.args[1]).toBe("src/index.ts");
|
|
22
|
+
expect([spec.command, ...spec.args].join(" ")).not.toMatch(/npx|npm/i);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("accepts the default project root", () => {
|
|
26
|
+
const spec = buildRestartSpawnSpec();
|
|
27
|
+
expect(spec.command).toBe(process.execPath);
|
|
28
|
+
expect(spec.args[0]).toContain("tsx");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("spawnRestartChild", () => {
|
|
33
|
+
class FakeChild extends EventEmitter {
|
|
34
|
+
pid = 12345;
|
|
35
|
+
exitCode: number | null = null;
|
|
36
|
+
signalCode: number | string | null = null;
|
|
37
|
+
unref = vi.fn();
|
|
38
|
+
stderr = new EventEmitter();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let tmpDirs: string[] = [];
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
for (const dir of tmpDirs) await rm(dir, { recursive: true, force: true });
|
|
44
|
+
tmpDirs = [];
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
async function tmpLogDir(): Promise<string> {
|
|
48
|
+
const dir = await mkdtemp(join(tmpdir(), "chatccc-restart-"));
|
|
49
|
+
tmpDirs.push(dir);
|
|
50
|
+
return dir;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
it("spawns without a shell, redirects stderr to a restart log file, and marks the internal restart env", async () => {
|
|
54
|
+
const logDir = await tmpLogDir();
|
|
55
|
+
const fake = new FakeChild();
|
|
56
|
+
const spawnImpl = vi.fn(() => fake as never);
|
|
57
|
+
const trace = vi.fn();
|
|
58
|
+
|
|
59
|
+
const child = spawnRestartChild({ projectRoot: "F:/proj", spawnImpl, trace, restartLogDir: logDir });
|
|
60
|
+
|
|
61
|
+
expect(child).toBe(fake);
|
|
62
|
+
expect(spawnImpl).toHaveBeenCalledWith(
|
|
63
|
+
process.execPath,
|
|
64
|
+
[join("F:/proj", "node_modules", "tsx", "dist", "cli.mjs"), "src/index.ts"],
|
|
65
|
+
expect.objectContaining({
|
|
66
|
+
detached: true,
|
|
67
|
+
env: expect.objectContaining({ [INTERNAL_RESTART_ENV_VAR]: "1" }),
|
|
68
|
+
// 不使用 shell:避免 npm/npx 的 PATH 注入问题
|
|
69
|
+
shell: false,
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
// spawnImpl 收到的 command/args 中不得出现 npx/npm(env 里的 npm_* 变量不算)
|
|
73
|
+
const firstCall = spawnImpl.mock.calls[0] as unknown as [string, string[]];
|
|
74
|
+
const [cmd, args] = firstCall;
|
|
75
|
+
expect([cmd, ...args].join(" ")).not.toMatch(/npx|npm/i);
|
|
76
|
+
|
|
77
|
+
// stderr 指向磁盘日志文件(fd),而不是 pipe:pipe 读端随父进程退出关闭后,
|
|
78
|
+
// 子进程写 stderr 会 EPIPE 崩溃(飞书 SDK console.warn 崩溃根因)。
|
|
79
|
+
const callArgs = spawnImpl.mock.calls[0] as unknown as Array<unknown>;
|
|
80
|
+
const stdio = (callArgs[2] as { stdio: unknown[] }).stdio;
|
|
81
|
+
expect(stdio[0]).toBe("ignore");
|
|
82
|
+
expect(stdio[1]).toBe("ignore");
|
|
83
|
+
expect(typeof stdio[2]).toBe("number");
|
|
84
|
+
expect(stdio[2] as number).toBeGreaterThan(2);
|
|
85
|
+
|
|
86
|
+
const files = await readdir(logDir);
|
|
87
|
+
expect(files.some((f) => f.startsWith("restart-") && f.endsWith(".log"))).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("records child exit code/signal in trace without pipe capture", async () => {
|
|
91
|
+
const logDir = await tmpLogDir();
|
|
92
|
+
const fake = new FakeChild();
|
|
93
|
+
const spawnImpl = vi.fn(() => fake as never);
|
|
94
|
+
const trace = vi.fn();
|
|
95
|
+
|
|
96
|
+
spawnRestartChild({ projectRoot: "F:/proj", spawnImpl, trace, restartLogDir: logDir });
|
|
97
|
+
|
|
98
|
+
fake.exitCode = 1;
|
|
99
|
+
fake.emit("exit", 1, null);
|
|
100
|
+
|
|
101
|
+
const exitCall = trace.mock.calls.find(([name]) => name === "restart: child exit");
|
|
102
|
+
expect(exitCall).toBeTruthy();
|
|
103
|
+
expect(exitCall![1]).toEqual(expect.objectContaining({
|
|
104
|
+
childPid: 12345,
|
|
105
|
+
code: 1,
|
|
106
|
+
signal: null,
|
|
107
|
+
}));
|
|
108
|
+
// 不再用 pipe 收集 stderr,trace 里不再有 stderr 字段
|
|
109
|
+
expect(exitCall![1]).not.toHaveProperty("stderr");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("falls back to pipe capture when the stderr log file cannot be opened", async () => {
|
|
113
|
+
const dir = await tmpLogDir();
|
|
114
|
+
// 用普通文件顶替目录:mkdir/open 日志文件必然失败
|
|
115
|
+
const blocker = join(dir, "blocker");
|
|
116
|
+
await writeFile(blocker, "x");
|
|
117
|
+
const fake = new FakeChild();
|
|
118
|
+
const spawnImpl = vi.fn(() => fake as never);
|
|
119
|
+
const trace = vi.fn();
|
|
120
|
+
|
|
121
|
+
spawnRestartChild({ projectRoot: "F:/proj", spawnImpl, trace, restartLogDir: blocker });
|
|
122
|
+
|
|
123
|
+
const callArgs = spawnImpl.mock.calls[0] as unknown as Array<unknown>;
|
|
124
|
+
expect((callArgs[2] as { stdio: unknown[] }).stdio[2]).toBe("pipe");
|
|
125
|
+
const failCall = trace.mock.calls.find(
|
|
126
|
+
([name]) => name === "restart: stderr log open failed, falling back to pipe",
|
|
127
|
+
);
|
|
128
|
+
expect(failCall).toBeTruthy();
|
|
129
|
+
expect(typeof (failCall![1] as { error: unknown }).error).toBe("string");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("decideRestartParentExit", () => {
|
|
134
|
+
it("returns false (parent stays alive) when the child dies during the window", async () => {
|
|
135
|
+
const child = { exitCode: 1, signalCode: null, pid: 42 } as never;
|
|
136
|
+
const trace = vi.fn();
|
|
137
|
+
|
|
138
|
+
const shouldExit = await decideRestartParentExit(child, 200, 50, trace);
|
|
139
|
+
|
|
140
|
+
expect(shouldExit).toBe(false);
|
|
141
|
+
expect(trace).toHaveBeenCalledWith(
|
|
142
|
+
"restart: child died during window, keeping parent",
|
|
143
|
+
expect.objectContaining({ childPid: 42, exitCode: 1 }),
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("returns true (parent exits) when the child stays alive through the window", async () => {
|
|
148
|
+
const child = { exitCode: null, signalCode: null, pid: 42 } as never;
|
|
149
|
+
const trace = vi.fn();
|
|
150
|
+
|
|
151
|
+
const shouldExit = await decideRestartParentExit(child, 150, 30, trace);
|
|
152
|
+
|
|
153
|
+
expect(shouldExit).toBe(true);
|
|
154
|
+
expect(trace).toHaveBeenCalledWith(
|
|
155
|
+
"restart: child alive after window, parent exiting",
|
|
156
|
+
expect.objectContaining({ childPid: 42 }),
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("surfaces a child that died with a signal", async () => {
|
|
161
|
+
const child = { exitCode: null, signalCode: "SIGKILL", pid: 42 } as never;
|
|
162
|
+
const trace = vi.fn();
|
|
163
|
+
|
|
164
|
+
const shouldExit = await decideRestartParentExit(child, 100, 25, trace);
|
|
165
|
+
|
|
166
|
+
expect(shouldExit).toBe(false);
|
|
167
|
+
expect(trace).toHaveBeenCalledWith(
|
|
168
|
+
"restart: child died during window, keeping parent",
|
|
169
|
+
expect.objectContaining({ signalCode: "SIGKILL" }),
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("exports a sane default readiness window", () => {
|
|
174
|
+
expect(RESTART_CHILD_READY_MS).toBeGreaterThan(0);
|
|
175
|
+
expect(RESTART_CHILD_READY_MS).toBeLessThan(60_000);
|
|
176
|
+
});
|
|
177
|
+
});
|