chatccc 0.2.235 → 0.2.237

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.
@@ -1,285 +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
+ 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
+ });
@@ -128,6 +128,61 @@ describe("spawnRestartChild", () => {
128
128
  expect(failCall).toBeTruthy();
129
129
  expect(typeof (failCall![1] as { error: unknown }).error).toBe("string");
130
130
  });
131
+
132
+ it("inherits the full terminal stdio when launched from a TTY (visible window logs, no EPIPE)", async () => {
133
+ const logDir = await tmpLogDir();
134
+ const fake = new FakeChild();
135
+ const spawnImpl = vi.fn(() => fake as never);
136
+ const trace = vi.fn();
137
+
138
+ spawnRestartChild({
139
+ projectRoot: "F:/proj",
140
+ spawnImpl,
141
+ trace,
142
+ restartLogDir: logDir,
143
+ isTty: () => true,
144
+ });
145
+
146
+ const callArgs = spawnImpl.mock.calls[0] as unknown as Array<unknown>;
147
+ const stdio = (callArgs[2] as { stdio: unknown[] }).stdio;
148
+ // 全部 inherit(含 stdin):避免 detached + stdin=ignore 在 Windows 上
149
+ // 弹新控制台/丢失控制台关联,日志必须留在用户当前窗口
150
+ expect(stdio).toEqual(["inherit", "inherit", "inherit"]);
151
+ // TTY 场景 stderr 走终端,不再生成 restart-*.log 文件
152
+ const files = await readdir(logDir);
153
+ expect(files.filter((f) => f.startsWith("restart-") && f.endsWith(".log"))).toHaveLength(0);
154
+ // 运行时自检 trace:记录 isTty 判定与最终 stdio
155
+ const spawnCall = trace.mock.calls.find(([name]) => name === "restart: spawn child");
156
+ expect(spawnCall).toBeTruthy();
157
+ expect(spawnCall![1]).toEqual({
158
+ isTty: true,
159
+ stdio: JSON.stringify(["inherit", "inherit", "inherit"]),
160
+ });
161
+ });
162
+
163
+ it("still redirects stderr to a file when explicitly not a TTY", async () => {
164
+ const logDir = await tmpLogDir();
165
+ const fake = new FakeChild();
166
+ const spawnImpl = vi.fn(() => fake as never);
167
+ const trace = vi.fn();
168
+
169
+ spawnRestartChild({
170
+ projectRoot: "F:/proj",
171
+ spawnImpl,
172
+ trace,
173
+ restartLogDir: logDir,
174
+ isTty: () => false,
175
+ });
176
+
177
+ const callArgs = spawnImpl.mock.calls[0] as unknown as Array<unknown>;
178
+ const stdio = (callArgs[2] as { stdio: unknown[] }).stdio;
179
+ expect(stdio[0]).toBe("ignore");
180
+ expect(stdio[1]).toBe("ignore");
181
+ expect(typeof stdio[2]).toBe("number");
182
+ expect(stdio[2] as number).toBeGreaterThan(2);
183
+ const files = await readdir(logDir);
184
+ expect(files.some((f) => f.startsWith("restart-") && f.endsWith(".log"))).toBe(true);
185
+ });
131
186
  });
132
187
 
133
188
  describe("decideRestartParentExit", () => {