chatccc 0.2.16 → 0.2.18
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 -111
- package/bin/chatccc.mjs +2 -14
- package/config.sample.json +26 -0
- package/images/avatars/status_busy.png +0 -0
- package/images/avatars/status_idle.png +0 -0
- package/images/avatars/status_new.png +0 -0
- package/images/img_readme_0.jpg +0 -0
- package/images/img_readme_1.jpg +0 -0
- package/images/img_readme_callback.png +0 -0
- package/images/img_readme_event.png +0 -0
- package/images/img_readme_permission.png +0 -0
- package/package.json +13 -12
- package/src/__tests__/cards.test.ts +3 -3
- package/src/__tests__/claude-adapter.test.ts +301 -10
- package/src/__tests__/config-reload.test.ts +171 -0
- package/src/__tests__/config.test.ts +259 -2
- package/src/__tests__/crash-logging.test.ts +210 -0
- package/src/__tests__/session.test.ts +2 -2
- package/src/__tests__/web-ui.test.ts +178 -0
- package/src/adapters/claude-adapter.ts +57 -8
- package/src/adapters/codex-adapter.ts +294 -293
- package/src/adapters/cursor-adapter.ts +1 -1
- package/src/config-utils.ts +177 -0
- package/src/config.ts +412 -178
- package/src/exit-banner.ts +12 -2
- package/src/feishu-api.ts +4 -15
- package/src/index.ts +178 -64
- package/src/session.ts +27 -12
- package/src/shared.ts +189 -16
- package/src/web-ui.ts +1591 -0
- package/.env.example +0 -44
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// 注意:这里特意 import 自 `../config-utils.ts` 而不是 `../config.ts`。
|
|
4
|
+
// `../config.ts` 在模块顶层会执行 loadConfig()——在生产环境下,当 config.json
|
|
5
|
+
// 不存在时它会从 config.sample.json 自动复制一份过去。我们额外靠 VITEST 环境
|
|
6
|
+
// 变量在 loadConfig() 里跳过这次写文件,但能不触发就尽量不触发。
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
2
9
|
|
|
3
10
|
import {
|
|
4
11
|
DEFAULT_GIT_TIMEOUT_SECONDS,
|
|
5
12
|
MAX_GIT_TIMEOUT_SECONDS,
|
|
6
13
|
MIN_GIT_TIMEOUT_SECONDS,
|
|
14
|
+
autoDetectCodexPath,
|
|
15
|
+
autoDetectCursorPath,
|
|
16
|
+
normalizeOptionalConfigField,
|
|
7
17
|
parseGitTimeoutSeconds,
|
|
8
|
-
|
|
18
|
+
readToolCliPath,
|
|
19
|
+
} from "../config-utils.ts";
|
|
9
20
|
|
|
10
21
|
describe("parseGitTimeoutSeconds", () => {
|
|
11
22
|
it("returns default when raw is undefined", () => {
|
|
@@ -110,6 +121,95 @@ describe("parseGitTimeoutSeconds", () => {
|
|
|
110
121
|
});
|
|
111
122
|
});
|
|
112
123
|
|
|
124
|
+
describe("normalizeOptionalConfigField", () => {
|
|
125
|
+
it("returns fallback (default empty string) when value is undefined", () => {
|
|
126
|
+
expect(normalizeOptionalConfigField(undefined, { label: "x" })).toBe("");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("returns fallback (default empty string) when value is null", () => {
|
|
130
|
+
expect(normalizeOptionalConfigField(null, { label: "x" })).toBe("");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("returns fallback when value is not a string (number / boolean / object)", () => {
|
|
134
|
+
expect(normalizeOptionalConfigField(123, { label: "x" })).toBe("");
|
|
135
|
+
expect(normalizeOptionalConfigField(true, { label: "x" })).toBe("");
|
|
136
|
+
expect(normalizeOptionalConfigField({}, { label: "x" })).toBe("");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("uses explicit fallback when value is missing", () => {
|
|
140
|
+
expect(
|
|
141
|
+
normalizeOptionalConfigField(undefined, { label: "x", fallback: "FB" }),
|
|
142
|
+
).toBe("FB");
|
|
143
|
+
expect(
|
|
144
|
+
normalizeOptionalConfigField(42, { label: "x", fallback: "FB" }),
|
|
145
|
+
).toBe("FB");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("returns empty string as-is (treated as 'do not pass to SDK/CLI')", () => {
|
|
149
|
+
expect(normalizeOptionalConfigField("", { label: "x" })).toBe("");
|
|
150
|
+
expect(
|
|
151
|
+
normalizeOptionalConfigField("", { label: "x", fallback: "FB" }),
|
|
152
|
+
).toBe("");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("returns value as-is when it is a normal non-default string", () => {
|
|
156
|
+
expect(normalizeOptionalConfigField("high", { label: "x" })).toBe("high");
|
|
157
|
+
expect(
|
|
158
|
+
normalizeOptionalConfigField("claude-sonnet-4-6", { label: "x" }),
|
|
159
|
+
).toBe("claude-sonnet-4-6");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("preserves surrounding whitespace (trim handled by callers)", () => {
|
|
163
|
+
expect(normalizeOptionalConfigField(" high ", { label: "x" })).toBe(
|
|
164
|
+
" high ",
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("treats 'default' (any case, with whitespace) as empty string and warns", () => {
|
|
169
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
170
|
+
try {
|
|
171
|
+
expect(normalizeOptionalConfigField("default", { label: "x" })).toBe("");
|
|
172
|
+
expect(normalizeOptionalConfigField("DEFAULT", { label: "x" })).toBe("");
|
|
173
|
+
expect(normalizeOptionalConfigField("Default", { label: "x" })).toBe("");
|
|
174
|
+
expect(normalizeOptionalConfigField(" default ", { label: "x" })).toBe(
|
|
175
|
+
"",
|
|
176
|
+
);
|
|
177
|
+
expect(warn).toHaveBeenCalledTimes(4);
|
|
178
|
+
const msg = String(warn.mock.calls[0]?.[0] ?? "");
|
|
179
|
+
expect(msg).toContain("已废弃");
|
|
180
|
+
expect(msg).toContain("x");
|
|
181
|
+
} finally {
|
|
182
|
+
warn.mockRestore();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("ignores fallback for 'default' (still returns empty string)", () => {
|
|
187
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
188
|
+
try {
|
|
189
|
+
expect(
|
|
190
|
+
normalizeOptionalConfigField("default", {
|
|
191
|
+
label: "x",
|
|
192
|
+
fallback: "FB",
|
|
193
|
+
}),
|
|
194
|
+
).toBe("");
|
|
195
|
+
} finally {
|
|
196
|
+
warn.mockRestore();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("does NOT treat substrings like 'default-foo' as the deprecated literal", () => {
|
|
201
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
202
|
+
try {
|
|
203
|
+
expect(
|
|
204
|
+
normalizeOptionalConfigField("default-foo", { label: "x" }),
|
|
205
|
+
).toBe("default-foo");
|
|
206
|
+
expect(warn).not.toHaveBeenCalled();
|
|
207
|
+
} finally {
|
|
208
|
+
warn.mockRestore();
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
113
213
|
describe("GIT timeout constants", () => {
|
|
114
214
|
it("DEFAULT_GIT_TIMEOUT_SECONDS is 180", () => {
|
|
115
215
|
expect(DEFAULT_GIT_TIMEOUT_SECONDS).toBe(180);
|
|
@@ -121,3 +221,160 @@ describe("GIT timeout constants", () => {
|
|
|
121
221
|
expect(DEFAULT_GIT_TIMEOUT_SECONDS).toBeLessThan(MAX_GIT_TIMEOUT_SECONDS);
|
|
122
222
|
});
|
|
123
223
|
});
|
|
224
|
+
|
|
225
|
+
describe("autoDetectCursorPath", () => {
|
|
226
|
+
it("Windows 优先返回 LocalAppData 默认安装路径(agent.cmd 存在)", () => {
|
|
227
|
+
const calls: string[] = [];
|
|
228
|
+
const result = autoDetectCursorPath({
|
|
229
|
+
platform: "win32",
|
|
230
|
+
localAppData: "C:\\Users\\u\\AppData\\Local",
|
|
231
|
+
existsSync: (p) => {
|
|
232
|
+
calls.push(p);
|
|
233
|
+
return p.endsWith("agent.cmd");
|
|
234
|
+
},
|
|
235
|
+
whichSync: () => {
|
|
236
|
+
throw new Error("不该走 PATH 查找");
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
expect(result).toMatch(/cursor-agent[\\/]agent\.cmd$/);
|
|
240
|
+
// 至少检查过一次默认安装路径
|
|
241
|
+
expect(calls.some((c) => c.endsWith("agent.cmd"))).toBe(true);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("Windows 但 LocalAppData 不存在 → 退回 whichSync(cursor-agent)", () => {
|
|
245
|
+
const result = autoDetectCursorPath({
|
|
246
|
+
platform: "win32",
|
|
247
|
+
localAppData: "C:\\Users\\u\\AppData\\Local",
|
|
248
|
+
existsSync: () => false,
|
|
249
|
+
whichSync: (cmd) => (cmd === "cursor-agent" ? "C:\\tools\\cursor-agent.exe" : null),
|
|
250
|
+
});
|
|
251
|
+
expect(result).toBe("C:\\tools\\cursor-agent.exe");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("非 Windows 跳过 LocalAppData,直接用 whichSync 查 cursor-agent → agent", () => {
|
|
255
|
+
const tried: string[] = [];
|
|
256
|
+
const result = autoDetectCursorPath({
|
|
257
|
+
platform: "linux",
|
|
258
|
+
localAppData: undefined,
|
|
259
|
+
existsSync: () => {
|
|
260
|
+
throw new Error("非 Windows 不应该 stat LocalAppData");
|
|
261
|
+
},
|
|
262
|
+
whichSync: (cmd) => {
|
|
263
|
+
tried.push(cmd);
|
|
264
|
+
return cmd === "agent" ? "/usr/local/bin/agent" : null;
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
expect(result).toBe("/usr/local/bin/agent");
|
|
268
|
+
expect(tried).toEqual(["cursor-agent", "agent"]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("全部命中失败时返回 null(不抛、由调用方决定留空)", () => {
|
|
272
|
+
const result = autoDetectCursorPath({
|
|
273
|
+
platform: "darwin",
|
|
274
|
+
localAppData: undefined,
|
|
275
|
+
existsSync: () => false,
|
|
276
|
+
whichSync: () => null,
|
|
277
|
+
});
|
|
278
|
+
expect(result).toBeNull();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("autoDetectCodexPath", () => {
|
|
283
|
+
it("命中 PATH 中的 codex → 返回绝对路径", () => {
|
|
284
|
+
const result = autoDetectCodexPath({
|
|
285
|
+
whichSync: (cmd) => (cmd === "codex" ? "/usr/local/bin/codex" : null),
|
|
286
|
+
});
|
|
287
|
+
expect(result).toBe("/usr/local/bin/codex");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("PATH 中没有 codex → 返回 null", () => {
|
|
291
|
+
const result = autoDetectCodexPath({ whichSync: () => null });
|
|
292
|
+
expect(result).toBeNull();
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("readToolCliPath", () => {
|
|
297
|
+
it("优先返回新字段 path(command 同时存在也忽略)", () => {
|
|
298
|
+
const onLegacy = vi.fn();
|
|
299
|
+
expect(
|
|
300
|
+
readToolCliPath(
|
|
301
|
+
{ path: "/new/path", command: "/old/cmd" },
|
|
302
|
+
{ label: "cursor", onLegacyField: onLegacy },
|
|
303
|
+
),
|
|
304
|
+
).toBe("/new/path");
|
|
305
|
+
expect(onLegacy).not.toHaveBeenCalled();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("仅有旧字段 command 时 → 用 command 的值并触发 onLegacyField 回调", () => {
|
|
309
|
+
const onLegacy = vi.fn();
|
|
310
|
+
expect(
|
|
311
|
+
readToolCliPath(
|
|
312
|
+
{ command: "/old/cmd" },
|
|
313
|
+
{ label: "codex", onLegacyField: onLegacy },
|
|
314
|
+
),
|
|
315
|
+
).toBe("/old/cmd");
|
|
316
|
+
expect(onLegacy).toHaveBeenCalledWith("codex", "/old/cmd");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("path 为空字符串 / 全空白 → 视作未填,回退到 command", () => {
|
|
320
|
+
const onLegacy = vi.fn();
|
|
321
|
+
expect(
|
|
322
|
+
readToolCliPath(
|
|
323
|
+
{ path: " ", command: "/fallback" },
|
|
324
|
+
{ label: "cursor", onLegacyField: onLegacy },
|
|
325
|
+
),
|
|
326
|
+
).toBe("/fallback");
|
|
327
|
+
expect(onLegacy).toHaveBeenCalledTimes(1);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("两边都空 / undefined → 返回 '',不触发回调", () => {
|
|
331
|
+
const onLegacy = vi.fn();
|
|
332
|
+
expect(readToolCliPath(undefined, { label: "x", onLegacyField: onLegacy })).toBe("");
|
|
333
|
+
expect(readToolCliPath({}, { label: "x", onLegacyField: onLegacy })).toBe("");
|
|
334
|
+
expect(
|
|
335
|
+
readToolCliPath({ path: "", command: "" }, { label: "x", onLegacyField: onLegacy }),
|
|
336
|
+
).toBe("");
|
|
337
|
+
expect(onLegacy).not.toHaveBeenCalled();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("非字符串字段 → 当作未填", () => {
|
|
341
|
+
expect(
|
|
342
|
+
readToolCliPath({ path: 42, command: null } as unknown as { path?: unknown }, {
|
|
343
|
+
label: "cursor",
|
|
344
|
+
}),
|
|
345
|
+
).toBe("");
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
describe("test environment side-effects (regression guard)", () => {
|
|
350
|
+
// 历史 bug:跑单测会让仓库根目录"自己长出" config.json——
|
|
351
|
+
// src/config.ts 顶层执行的 loadConfig() 在生产环境下会自动把
|
|
352
|
+
// config.sample.json 复制成 config.json。这一行为不应在测试环境发生:
|
|
353
|
+
// (1) 单测不应改动工作区文件;
|
|
354
|
+
// (2) 误生成的 config.json 会污染 git status,干扰真实改动的提交。
|
|
355
|
+
it("仅 import config 模块不会让仓库根目录自动生成 config.json", async () => {
|
|
356
|
+
// 这里通过 import.meta.url 反推项目根目录,避免依赖 PROJECT_ROOT(后者来自 config.ts)。
|
|
357
|
+
const projectRoot = join(
|
|
358
|
+
new URL(".", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"),
|
|
359
|
+
"..",
|
|
360
|
+
"..",
|
|
361
|
+
);
|
|
362
|
+
const configJsonPath = join(projectRoot, "config.json");
|
|
363
|
+
const sampleExists = existsSync(join(projectRoot, "config.sample.json"));
|
|
364
|
+
expect(sampleExists).toBe(true);
|
|
365
|
+
|
|
366
|
+
const existedBefore = existsSync(configJsonPath);
|
|
367
|
+
|
|
368
|
+
// 触发 config.ts 顶层副作用(其它单测也会通过 session.ts/adapters/* 间接 import 它,
|
|
369
|
+
// 这里直接 import 模拟最坏情况)。
|
|
370
|
+
await import("../config.ts");
|
|
371
|
+
|
|
372
|
+
const existsAfter = existsSync(configJsonPath);
|
|
373
|
+
// 如果 import 之前就不存在,import 之后也不应该被创建出来。
|
|
374
|
+
if (!existedBefore) {
|
|
375
|
+
expect(existsAfter).toBe(false);
|
|
376
|
+
} else {
|
|
377
|
+
expect(existsAfter).toBe(true);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { buildCrashLoggingHandlers, installCrashLogging } from "../shared.ts";
|
|
4
|
+
|
|
5
|
+
describe("buildCrashLoggingHandlers", () => {
|
|
6
|
+
it("uncaughtException: 写诊断、刷新日志、调用 onFatal", () => {
|
|
7
|
+
const tracer = vi.fn();
|
|
8
|
+
const flush = vi.fn();
|
|
9
|
+
const onFatal = vi.fn();
|
|
10
|
+
const handlers = buildCrashLoggingHandlers({ tracer, flush, onFatal });
|
|
11
|
+
|
|
12
|
+
const err = new Error("boom");
|
|
13
|
+
handlers.uncaughtException(err);
|
|
14
|
+
|
|
15
|
+
expect(tracer).toHaveBeenCalledWith(
|
|
16
|
+
"FATAL uncaughtException",
|
|
17
|
+
expect.objectContaining({
|
|
18
|
+
message: "boom",
|
|
19
|
+
stack: expect.stringContaining("Error: boom"),
|
|
20
|
+
})
|
|
21
|
+
);
|
|
22
|
+
expect(flush).toHaveBeenCalledTimes(1);
|
|
23
|
+
expect(onFatal).toHaveBeenCalledWith("uncaughtException", err);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("unhandledRejection: 字符串 reason 也能转成 Error", () => {
|
|
27
|
+
const tracer = vi.fn();
|
|
28
|
+
const onFatal = vi.fn();
|
|
29
|
+
const handlers = buildCrashLoggingHandlers({ tracer, onFatal });
|
|
30
|
+
|
|
31
|
+
handlers.unhandledRejection("string-reason");
|
|
32
|
+
|
|
33
|
+
expect(tracer).toHaveBeenCalledWith(
|
|
34
|
+
"FATAL unhandledRejection",
|
|
35
|
+
expect.objectContaining({ message: "string-reason" })
|
|
36
|
+
);
|
|
37
|
+
const fatalArgs = onFatal.mock.calls[0];
|
|
38
|
+
expect(fatalArgs[0]).toBe("unhandledRejection");
|
|
39
|
+
expect(fatalArgs[1]).toBeInstanceOf(Error);
|
|
40
|
+
expect((fatalArgs[1] as Error).message).toBe("string-reason");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("unhandledRejection: undefined reason 不会爆炸", () => {
|
|
44
|
+
const tracer = vi.fn();
|
|
45
|
+
const onFatal = vi.fn();
|
|
46
|
+
const handlers = buildCrashLoggingHandlers({ tracer, onFatal });
|
|
47
|
+
|
|
48
|
+
handlers.unhandledRejection(undefined);
|
|
49
|
+
|
|
50
|
+
expect(tracer).toHaveBeenCalledTimes(1);
|
|
51
|
+
expect(onFatal).toHaveBeenCalledTimes(1);
|
|
52
|
+
expect((onFatal.mock.calls[0][1] as Error).message.length).toBeGreaterThan(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("unhandledRejection: 对象 reason 序列化为 JSON 文本", () => {
|
|
56
|
+
const tracer = vi.fn();
|
|
57
|
+
const onFatal = vi.fn();
|
|
58
|
+
const handlers = buildCrashLoggingHandlers({ tracer, onFatal });
|
|
59
|
+
|
|
60
|
+
handlers.unhandledRejection({ code: 500, msg: "internal" });
|
|
61
|
+
|
|
62
|
+
const traceExtra = tracer.mock.calls[0][1] as { message: string };
|
|
63
|
+
expect(traceExtra.message).toContain("500");
|
|
64
|
+
expect(traceExtra.message).toContain("internal");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("过长堆栈会被截断并打上 truncated 标记", () => {
|
|
68
|
+
const tracer = vi.fn();
|
|
69
|
+
const longStack = "x".repeat(8000);
|
|
70
|
+
const err = Object.assign(new Error("long"), { stack: longStack });
|
|
71
|
+
const handlers = buildCrashLoggingHandlers({ tracer, onFatal: () => {} });
|
|
72
|
+
|
|
73
|
+
handlers.uncaughtException(err);
|
|
74
|
+
|
|
75
|
+
const extra = tracer.mock.calls[0][1] as { stack: string };
|
|
76
|
+
expect(extra.stack.length).toBeLessThanOrEqual(4100);
|
|
77
|
+
expect(extra.stack).toContain("...(truncated)");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("signalLogger: 写入信号名并调用 onSignal", () => {
|
|
81
|
+
const tracer = vi.fn();
|
|
82
|
+
const onSignal = vi.fn();
|
|
83
|
+
const handlers = buildCrashLoggingHandlers({ tracer, onSignal });
|
|
84
|
+
|
|
85
|
+
handlers.signalLogger("SIGINT");
|
|
86
|
+
|
|
87
|
+
expect(tracer).toHaveBeenCalledWith("signal received", { signal: "SIGINT" });
|
|
88
|
+
expect(onSignal).toHaveBeenCalledWith("SIGINT");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("beforeExit: 写入退出码", () => {
|
|
92
|
+
const tracer = vi.fn();
|
|
93
|
+
const onBeforeExit = vi.fn();
|
|
94
|
+
const handlers = buildCrashLoggingHandlers({ tracer, onBeforeExit });
|
|
95
|
+
|
|
96
|
+
handlers.beforeExit(0);
|
|
97
|
+
|
|
98
|
+
expect(tracer).toHaveBeenCalledWith("beforeExit", { code: 0 });
|
|
99
|
+
expect(onBeforeExit).toHaveBeenCalledWith(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("tracer 本身抛错也不会阻断 onFatal", () => {
|
|
103
|
+
const tracer = vi.fn(() => {
|
|
104
|
+
throw new Error("tracer broken");
|
|
105
|
+
});
|
|
106
|
+
const onFatal = vi.fn();
|
|
107
|
+
const handlers = buildCrashLoggingHandlers({ tracer, onFatal });
|
|
108
|
+
|
|
109
|
+
expect(() => handlers.uncaughtException(new Error("boom"))).not.toThrow();
|
|
110
|
+
expect(onFatal).toHaveBeenCalledTimes(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("flush 本身抛错也不会阻断 onFatal", () => {
|
|
114
|
+
const tracer = vi.fn();
|
|
115
|
+
const flush = vi.fn(() => {
|
|
116
|
+
throw new Error("flush broken");
|
|
117
|
+
});
|
|
118
|
+
const onFatal = vi.fn();
|
|
119
|
+
const handlers = buildCrashLoggingHandlers({ tracer, flush, onFatal });
|
|
120
|
+
|
|
121
|
+
expect(() => handlers.uncaughtException(new Error("boom"))).not.toThrow();
|
|
122
|
+
expect(onFatal).toHaveBeenCalledTimes(1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("默认 onFatal 行为:调用 console.error 然后 process.exit(1)", () => {
|
|
126
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
127
|
+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
128
|
+
throw new Error(`__exit_${code}__`);
|
|
129
|
+
}) as never);
|
|
130
|
+
|
|
131
|
+
const handlers = buildCrashLoggingHandlers({ tracer: () => {} });
|
|
132
|
+
|
|
133
|
+
expect(() => handlers.uncaughtException(new Error("boom"))).toThrow("__exit_1__");
|
|
134
|
+
expect(errSpy).toHaveBeenCalled();
|
|
135
|
+
const message = (errSpy.mock.calls[0] ?? []).join(" ");
|
|
136
|
+
expect(message).toContain("uncaughtException");
|
|
137
|
+
expect(message).toContain("boom");
|
|
138
|
+
|
|
139
|
+
errSpy.mockRestore();
|
|
140
|
+
exitSpy.mockRestore();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("installCrashLogging", () => {
|
|
145
|
+
it("注册所有相关事件监听器", () => {
|
|
146
|
+
const before = {
|
|
147
|
+
uncaught: process.listenerCount("uncaughtException"),
|
|
148
|
+
rejection: process.listenerCount("unhandledRejection"),
|
|
149
|
+
beforeExit: process.listenerCount("beforeExit"),
|
|
150
|
+
sigint: process.listenerCount("SIGINT"),
|
|
151
|
+
sigterm: process.listenerCount("SIGTERM"),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const { cleanup } = installCrashLogging({
|
|
155
|
+
tracer: () => {},
|
|
156
|
+
onFatal: () => {},
|
|
157
|
+
onSignal: () => {},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(process.listenerCount("uncaughtException")).toBe(before.uncaught + 1);
|
|
161
|
+
expect(process.listenerCount("unhandledRejection")).toBe(before.rejection + 1);
|
|
162
|
+
expect(process.listenerCount("beforeExit")).toBe(before.beforeExit + 1);
|
|
163
|
+
expect(process.listenerCount("SIGINT")).toBe(before.sigint + 1);
|
|
164
|
+
expect(process.listenerCount("SIGTERM")).toBe(before.sigterm + 1);
|
|
165
|
+
|
|
166
|
+
cleanup();
|
|
167
|
+
|
|
168
|
+
expect(process.listenerCount("uncaughtException")).toBe(before.uncaught);
|
|
169
|
+
expect(process.listenerCount("unhandledRejection")).toBe(before.rejection);
|
|
170
|
+
expect(process.listenerCount("beforeExit")).toBe(before.beforeExit);
|
|
171
|
+
expect(process.listenerCount("SIGINT")).toBe(before.sigint);
|
|
172
|
+
expect(process.listenerCount("SIGTERM")).toBe(before.sigterm);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("cleanup 之后再触发不会再调用 tracer", () => {
|
|
176
|
+
const tracer = vi.fn();
|
|
177
|
+
const { handlers, cleanup } = installCrashLogging({
|
|
178
|
+
tracer,
|
|
179
|
+
onFatal: () => {},
|
|
180
|
+
onSignal: () => {},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
handlers.signalLogger("SIGTERM");
|
|
184
|
+
expect(tracer).toHaveBeenCalledTimes(1);
|
|
185
|
+
|
|
186
|
+
cleanup();
|
|
187
|
+
tracer.mockClear();
|
|
188
|
+
|
|
189
|
+
process.emit("SIGTERM");
|
|
190
|
+
expect(tracer).not.toHaveBeenCalled();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("通过 process.emit 触发 SIGINT 会走到 tracer 与 onSignal", () => {
|
|
194
|
+
const tracer = vi.fn();
|
|
195
|
+
const onSignal = vi.fn();
|
|
196
|
+
const { cleanup } = installCrashLogging({
|
|
197
|
+
tracer,
|
|
198
|
+
onFatal: () => {},
|
|
199
|
+
onSignal,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
process.emit("SIGINT");
|
|
204
|
+
expect(tracer).toHaveBeenCalledWith("signal received", { signal: "SIGINT" });
|
|
205
|
+
expect(onSignal).toHaveBeenCalledWith("SIGINT");
|
|
206
|
+
} finally {
|
|
207
|
+
cleanup();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -142,7 +142,7 @@ describe("getSessionStatus", () => {
|
|
|
142
142
|
mockSessionInfo("chat-claude", { tool: "claude" });
|
|
143
143
|
const status = await getSessionStatus("chat-claude");
|
|
144
144
|
expect(status!.effort).not.toBeNull();
|
|
145
|
-
// model
|
|
145
|
+
// model 必为字符串(留空时显示 '(留空)',否则为环境变量值);不应是占位符
|
|
146
146
|
expect(typeof status!.model).toBe("string");
|
|
147
147
|
expect(status!.model.length).toBeGreaterThan(0);
|
|
148
148
|
});
|
|
@@ -171,7 +171,7 @@ describe("getSessionStatus", () => {
|
|
|
171
171
|
expect(status!.model).toBe("Composer 2 Fast");
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
-
it("Cursor 会话:adapter 没返回 model
|
|
174
|
+
it("Cursor 会话:adapter 没返回 model 时使用占位符(不应硬塞任何模型字面量)", async () => {
|
|
175
175
|
mockSessionInfo("chat-cursor", { sessionId: "sid-cur", tool: "cursor" });
|
|
176
176
|
_setAdapterForToolForTest(
|
|
177
177
|
"cursor",
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
applyClaudeApiMode,
|
|
4
|
+
chooseStartPath,
|
|
5
|
+
detectClaudeApiMode,
|
|
6
|
+
} from "../web-ui.ts";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// detectClaudeApiMode — 加载已有 config 时如何判定 UI 初始模式
|
|
10
|
+
// 契约:apiKey 或 baseUrl 任一非空(trim 后) → "thirdparty",否则 "official"。
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
describe("detectClaudeApiMode", () => {
|
|
14
|
+
it("两者都缺失 → official", () => {
|
|
15
|
+
expect(detectClaudeApiMode(undefined)).toBe("official");
|
|
16
|
+
expect(detectClaudeApiMode({})).toBe("official");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("两者都为空字符串 → official", () => {
|
|
20
|
+
expect(detectClaudeApiMode({ apiKey: "", baseUrl: "" })).toBe("official");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("两者都全空白 → official", () => {
|
|
24
|
+
expect(detectClaudeApiMode({ apiKey: " ", baseUrl: "\t\n" })).toBe(
|
|
25
|
+
"official",
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("apiKey 非空 → thirdparty(即使 baseUrl 为空)", () => {
|
|
30
|
+
expect(detectClaudeApiMode({ apiKey: "sk-x", baseUrl: "" })).toBe(
|
|
31
|
+
"thirdparty",
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("baseUrl 非空 → thirdparty(即使 apiKey 为空)", () => {
|
|
36
|
+
expect(
|
|
37
|
+
detectClaudeApiMode({ apiKey: "", baseUrl: "https://gw/anthropic" }),
|
|
38
|
+
).toBe("thirdparty");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("两者都非空 → thirdparty", () => {
|
|
42
|
+
expect(
|
|
43
|
+
detectClaudeApiMode({
|
|
44
|
+
apiKey: "sk-x",
|
|
45
|
+
baseUrl: "https://gw/anthropic",
|
|
46
|
+
}),
|
|
47
|
+
).toBe("thirdparty");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// applyClaudeApiMode — 服务端写入前归一化扁平 vars
|
|
53
|
+
// 关键契约:
|
|
54
|
+
// - mode=official 时即使 vars 不含这两个键,也要写入 ""(覆盖原 config.json)
|
|
55
|
+
// - mode=thirdparty 时原样保留(包括前端传 "")
|
|
56
|
+
// - mode 未传 默认按 official 兌底(不保留可能误填的密钥)
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
describe("applyClaudeApiMode", () => {
|
|
60
|
+
it("mode=official 时清空 CLAUDE_API_KEY / CLAUDE_BASE_URL(即使 vars 没传)", () => {
|
|
61
|
+
const out = applyClaudeApiMode({ CHATCCC_APP_ID: "x" }, "official");
|
|
62
|
+
expect(out).toEqual({
|
|
63
|
+
CHATCCC_APP_ID: "x",
|
|
64
|
+
CLAUDE_API_KEY: "",
|
|
65
|
+
CLAUDE_BASE_URL: "",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("mode=official 时覆盖前端误传的非空 apiKey / baseUrl", () => {
|
|
70
|
+
const out = applyClaudeApiMode(
|
|
71
|
+
{
|
|
72
|
+
CLAUDE_API_KEY: "sk-leftover",
|
|
73
|
+
CLAUDE_BASE_URL: "https://leftover/anthropic",
|
|
74
|
+
CHATCCC_APP_ID: "x",
|
|
75
|
+
},
|
|
76
|
+
"official",
|
|
77
|
+
);
|
|
78
|
+
expect(out.CLAUDE_API_KEY).toBe("");
|
|
79
|
+
expect(out.CLAUDE_BASE_URL).toBe("");
|
|
80
|
+
expect(out.CHATCCC_APP_ID).toBe("x");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("mode=thirdparty 时原样保留前端值", () => {
|
|
84
|
+
const out = applyClaudeApiMode(
|
|
85
|
+
{
|
|
86
|
+
CLAUDE_API_KEY: "sk-test",
|
|
87
|
+
CLAUDE_BASE_URL: "https://gw/anthropic",
|
|
88
|
+
},
|
|
89
|
+
"thirdparty",
|
|
90
|
+
);
|
|
91
|
+
expect(out).toEqual({
|
|
92
|
+
CLAUDE_API_KEY: "sk-test",
|
|
93
|
+
CLAUDE_BASE_URL: "https://gw/anthropic",
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("mode=thirdparty 时保留前端主动提交的 ''(允许局部清空)", () => {
|
|
98
|
+
const out = applyClaudeApiMode(
|
|
99
|
+
{ CLAUDE_API_KEY: "", CLAUDE_BASE_URL: "https://gw/anthropic" },
|
|
100
|
+
"thirdparty",
|
|
101
|
+
);
|
|
102
|
+
expect(out.CLAUDE_API_KEY).toBe("");
|
|
103
|
+
expect(out.CLAUDE_BASE_URL).toBe("https://gw/anthropic");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("mode 未传(undefined) → 按 official 兌底清空", () => {
|
|
107
|
+
const out = applyClaudeApiMode(
|
|
108
|
+
{ CLAUDE_API_KEY: "sk-x" },
|
|
109
|
+
undefined,
|
|
110
|
+
);
|
|
111
|
+
expect(out.CLAUDE_API_KEY).toBe("");
|
|
112
|
+
expect(out.CLAUDE_BASE_URL).toBe("");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("mode 是未知字符串 → 按 official 兌底清空", () => {
|
|
116
|
+
const out = applyClaudeApiMode(
|
|
117
|
+
{ CLAUDE_API_KEY: "sk-x" },
|
|
118
|
+
"garbage-mode",
|
|
119
|
+
);
|
|
120
|
+
expect(out.CLAUDE_API_KEY).toBe("");
|
|
121
|
+
expect(out.CLAUDE_BASE_URL).toBe("");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("不修改入参对象(返回新对象)", () => {
|
|
125
|
+
const input = { CLAUDE_API_KEY: "sk-x" };
|
|
126
|
+
const out = applyClaudeApiMode(input, "official");
|
|
127
|
+
expect(input).toEqual({ CLAUDE_API_KEY: "sk-x" }); // 原对象未变
|
|
128
|
+
expect(out).not.toBe(input);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// chooseStartPath — /api/start 的路径选择
|
|
134
|
+
// 关键护栏:
|
|
135
|
+
// - setup 模式(hasInplaceActivateHook=true)下 isServiceRunning 永远为 true
|
|
136
|
+
// (setup 进程自己占着 PID 文件),必须无条件走 inplace;否则用户点
|
|
137
|
+
// "保存并启动"将永远拿到 "Service is already running"。
|
|
138
|
+
// - dashboard 模式 + service 已运行(通常就是当前进程自己)→ "reload":
|
|
139
|
+
// 用户点"保存并启动"想让新 config 生效,但服务正在跑——不真重启,仅
|
|
140
|
+
// 调用 reloadConfigFromDisk() 刷新进程内 export let 常量。绝不能再
|
|
141
|
+
// 返回"already running"挡用户路。
|
|
142
|
+
// - dashboard 模式 + service 未运行 → spawn 一个新的(旧 service 退出后场景)。
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
describe("chooseStartPath", () => {
|
|
146
|
+
it("setup 模式(注入 inplace hook)→ inplace(不管 PID 文件状态)", () => {
|
|
147
|
+
expect(
|
|
148
|
+
chooseStartPath({
|
|
149
|
+
hasInplaceActivateHook: true,
|
|
150
|
+
isServiceRunning: true,
|
|
151
|
+
}),
|
|
152
|
+
).toBe("inplace");
|
|
153
|
+
expect(
|
|
154
|
+
chooseStartPath({
|
|
155
|
+
hasInplaceActivateHook: true,
|
|
156
|
+
isServiceRunning: false,
|
|
157
|
+
}),
|
|
158
|
+
).toBe("inplace");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("dashboard 模式 + service 已运行 → reload(仅刷新 config,不真重启)", () => {
|
|
162
|
+
expect(
|
|
163
|
+
chooseStartPath({
|
|
164
|
+
hasInplaceActivateHook: false,
|
|
165
|
+
isServiceRunning: true,
|
|
166
|
+
}),
|
|
167
|
+
).toBe("reload");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("dashboard 模式 + service 未运行 → spawn", () => {
|
|
171
|
+
expect(
|
|
172
|
+
chooseStartPath({
|
|
173
|
+
hasInplaceActivateHook: false,
|
|
174
|
+
isServiceRunning: false,
|
|
175
|
+
}),
|
|
176
|
+
).toBe("spawn");
|
|
177
|
+
});
|
|
178
|
+
});
|