chatccc 0.2.175 → 0.2.176

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.
@@ -6,8 +6,8 @@ import { join } from "node:path";
6
6
  import type { PlatformAdapter } from "../platform-adapter.ts";
7
7
  import type { SessionInfo, ToolAdapter } from "../adapters/adapter-interface.ts";
8
8
 
9
- const mockStreamStates = new Map<string, { status: "running" | "done" | "stopped"; finalReply: string }>();
10
- const mockGetCodexUsageSummary = vi.hoisted(() => vi.fn());
9
+ const mockStreamStates = new Map<string, { status: "running" | "done" | "stopped"; finalReply: string }>();
10
+ const mockGetCodexUsageSummary = vi.hoisted(() => vi.fn());
11
11
 
12
12
  vi.mock("../im-skills.ts", () => ({
13
13
  buildImSkillsPrompt: async () => "",
@@ -15,7 +15,7 @@ vi.mock("../im-skills.ts", () => ({
15
15
  exportSkillSubDocs: async () => {},
16
16
  }));
17
17
 
18
- vi.mock("../stream-state.ts", () => ({
18
+ vi.mock("../stream-state.ts", () => ({
19
19
  readStreamState: async (sessionId: string) => {
20
20
  const state = mockStreamStates.get(sessionId);
21
21
  if (!state) return null;
@@ -50,14 +50,14 @@ vi.mock("../stream-state.ts", () => ({
50
50
  cwd,
51
51
  tool,
52
52
  }),
53
- fixStaleStreamStates: async () => {},
54
- }));
55
-
56
- vi.mock("../feishu-platform.ts", () => ({
57
- getCodexUsageSummary: mockGetCodexUsageSummary,
58
- getTenantAccessToken: vi.fn(async () => "tenant-token"),
59
- sendPostMessage: vi.fn(async () => true),
60
- }));
53
+ fixStaleStreamStates: async () => {},
54
+ }));
55
+
56
+ vi.mock("../feishu-platform.ts", () => ({
57
+ getCodexUsageSummary: mockGetCodexUsageSummary,
58
+ getTenantAccessToken: vi.fn(async () => "tenant-token"),
59
+ sendPostMessage: vi.fn(async () => true),
60
+ }));
61
61
 
62
62
  import { handleCommand } from "../orchestrator.ts";
63
63
  import {
@@ -120,14 +120,14 @@ describe("handleCommand WeChat processing ack", () => {
120
120
  _setSessionToolsFileForTest(join(tempDir, "sessions.json"));
121
121
  resetState();
122
122
  resetBindingState();
123
- mockStreamStates.clear();
124
- mockGetCodexUsageSummary.mockReset();
125
- mockGetCodexUsageSummary.mockResolvedValue({
126
- fiveHour: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
127
- weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
128
- });
129
- _setAdapterForToolForTest("claude", mockAdapter());
130
- });
123
+ mockStreamStates.clear();
124
+ mockGetCodexUsageSummary.mockReset();
125
+ mockGetCodexUsageSummary.mockResolvedValue({
126
+ fiveHour: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
127
+ weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
128
+ });
129
+ _setAdapterForToolForTest("claude", mockAdapter());
130
+ });
131
131
 
132
132
  afterEach(async () => {
133
133
  resetState();
@@ -249,7 +249,7 @@ describe("handleCommand WeChat processing ack", () => {
249
249
  expect(registry["feishu-group"]?.sessionId).toBe("sid-feishu-new");
250
250
  });
251
251
 
252
- it("cleans stale Feishu p2p binding but keeps valid commands from auto-creating a group", async () => {
252
+ it("cleans stale Feishu p2p binding but keeps valid commands from auto-creating a group", async () => {
253
253
  const platform = mockPlatform("feishu");
254
254
  await recordSessionRegistry({
255
255
  chatId: "feishu-p2p",
@@ -264,76 +264,76 @@ describe("handleCommand WeChat processing ack", () => {
264
264
  expect(platform.createGroup).not.toHaveBeenCalled();
265
265
  expect(platform.sendRawCard).toHaveBeenCalled();
266
266
  const registry = await loadSessionRegistryForBinding();
267
- expect(registry["feishu-p2p"]).toBeUndefined();
268
- });
269
-
270
- it("handles /usage without creating a new Feishu group", async () => {
271
- const platform = mockPlatform("feishu");
272
- mockGetCodexUsageSummary.mockResolvedValue({
273
- fiveHour: { usedPercent: 37, remainingPercent: 63, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
274
- weekly: { usedPercent: 12, remainingPercent: 88, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
275
- });
276
-
277
- await handleCommand(platform, "/usage", "feishu-p2p", "ou-user", Date.now(), "p2p");
278
-
279
- expect(platform.createGroup).not.toHaveBeenCalled();
280
- expect(platform.sendCard).toHaveBeenCalledWith(
281
- "feishu-p2p",
282
- "Codex Usage",
283
- expect.stringContaining("**5h:** 已用 37%,剩余 63%,重置:"),
284
- "blue",
285
- );
286
- expect(platform.sendCard).toHaveBeenCalledWith(
287
- "feishu-p2p",
288
- "Codex Usage",
289
- expect.stringContaining("约 2小时52分钟后"),
290
- "blue",
291
- );
292
- expect(platform.sendCard).toHaveBeenCalledWith(
293
- "feishu-p2p",
294
- "Codex Usage",
295
- expect.stringContaining("[███████░░░░░░░░░░░░░]"),
296
- "blue",
297
- );
298
- expect(platform.sendCard).toHaveBeenCalledWith(
299
- "feishu-p2p",
300
- "Codex Usage",
301
- expect.stringContaining("**周:** 已用 12%,剩余 88%,重置:"),
302
- "blue",
303
- );
304
- expect(platform.sendCard).toHaveBeenCalledWith(
305
- "feishu-p2p",
306
- "Codex Usage",
307
- expect.stringContaining("约 3天18小时17分钟后"),
308
- "blue",
309
- );
310
- expect(platform.sendCard).toHaveBeenCalledWith(
311
- "feishu-p2p",
312
- "Codex Usage",
313
- expect.stringContaining("[██░░░░░░░░░░░░░░░░░░]"),
314
- "blue",
315
- );
316
- });
317
-
318
- it("only advertises /usage in new Codex session ready messages", async () => {
319
- const codexPlatform = mockPlatform("feishu");
320
- _setAdapterForToolForTest("codex", mockAdapter("sid-codex"));
321
-
322
- await handleCommand(codexPlatform, "/new codex", "feishu-p2p", "ou-user", Date.now(), "p2p");
323
-
324
- expect(codexPlatform.sendCard).toHaveBeenCalledWith(
325
- "feishu-group",
326
- "Codex Session Ready",
327
- expect.stringContaining("发送 **/usage** 查看 Codex 5h 和周用量。"),
328
- "green",
329
- );
330
-
331
- const claudePlatform = mockPlatform("feishu");
332
- await handleCommand(claudePlatform, "/new claude", "feishu-p2p-2", "ou-user", Date.now(), "p2p");
333
-
334
- const claudeReadyCall = vi.mocked(claudePlatform.sendCard).mock.calls.find(
335
- ([chatId, title]) => chatId === "feishu-group" && title === "Claude Code Session Ready",
336
- );
337
- expect(claudeReadyCall?.[2]).not.toContain("/usage");
338
- });
339
- });
267
+ expect(registry["feishu-p2p"]).toBeUndefined();
268
+ });
269
+
270
+ it("handles /usage without creating a new Feishu group", async () => {
271
+ const platform = mockPlatform("feishu");
272
+ mockGetCodexUsageSummary.mockResolvedValue({
273
+ fiveHour: { usedPercent: 37, remainingPercent: 63, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
274
+ weekly: { usedPercent: 12, remainingPercent: 88, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
275
+ });
276
+
277
+ await handleCommand(platform, "/usage", "feishu-p2p", "ou-user", Date.now(), "p2p");
278
+
279
+ expect(platform.createGroup).not.toHaveBeenCalled();
280
+ expect(platform.sendCard).toHaveBeenCalledWith(
281
+ "feishu-p2p",
282
+ "Codex Usage",
283
+ expect.stringContaining("**5h:** 已用 37%,剩余 63%,重置:"),
284
+ "blue",
285
+ );
286
+ expect(platform.sendCard).toHaveBeenCalledWith(
287
+ "feishu-p2p",
288
+ "Codex Usage",
289
+ expect.stringContaining("约 2小时52分钟后"),
290
+ "blue",
291
+ );
292
+ expect(platform.sendCard).toHaveBeenCalledWith(
293
+ "feishu-p2p",
294
+ "Codex Usage",
295
+ expect.stringContaining("[███████░░░░░░░░░░░░░]"),
296
+ "blue",
297
+ );
298
+ expect(platform.sendCard).toHaveBeenCalledWith(
299
+ "feishu-p2p",
300
+ "Codex Usage",
301
+ expect.stringContaining("**周:** 已用 12%,剩余 88%,重置:"),
302
+ "blue",
303
+ );
304
+ expect(platform.sendCard).toHaveBeenCalledWith(
305
+ "feishu-p2p",
306
+ "Codex Usage",
307
+ expect.stringContaining("约 3天18小时17分钟后"),
308
+ "blue",
309
+ );
310
+ expect(platform.sendCard).toHaveBeenCalledWith(
311
+ "feishu-p2p",
312
+ "Codex Usage",
313
+ expect.stringContaining("[██░░░░░░░░░░░░░░░░░░]"),
314
+ "blue",
315
+ );
316
+ });
317
+
318
+ it("only advertises /usage in new Codex session ready messages", async () => {
319
+ const codexPlatform = mockPlatform("feishu");
320
+ _setAdapterForToolForTest("codex", mockAdapter("sid-codex"));
321
+
322
+ await handleCommand(codexPlatform, "/new codex", "feishu-p2p", "ou-user", Date.now(), "p2p");
323
+
324
+ expect(codexPlatform.sendCard).toHaveBeenCalledWith(
325
+ "feishu-group",
326
+ "Codex Session Ready",
327
+ expect.stringContaining("发送 **/usage** 查看 Codex 5h 和周用量。"),
328
+ "green",
329
+ );
330
+
331
+ const claudePlatform = mockPlatform("feishu");
332
+ await handleCommand(claudePlatform, "/new claude", "feishu-p2p-2", "ou-user", Date.now(), "p2p");
333
+
334
+ const claudeReadyCall = vi.mocked(claudePlatform.sendCard).mock.calls.find(
335
+ ([chatId, title]) => chatId === "feishu-group" && title === "Claude Code Session Ready",
336
+ );
337
+ expect(claudeReadyCall?.[2]).not.toContain("/usage");
338
+ });
339
+ });
@@ -3,7 +3,6 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
- // 在 import privacy 之前 mock config,让 USER_DATA_DIR 指向临时目录
7
6
  const TEST_DATA_DIR = await mkdtemp(join(tmpdir(), "chatccc-privacy-test-"));
8
7
  vi.mock("../config.ts", async () => {
9
8
  const actual = await vi.importActual<typeof import("../config.ts")>("../config.ts");
@@ -17,10 +16,10 @@ vi.mock("../config.ts", async () => {
17
16
  let applyPrivacy: (text: string) => string;
18
17
  let reloadPrivacyRules: () => void;
19
18
  let getPrivacyRules: () => Record<string, string>;
19
+ let getPrivacyConfig: () => { enabled: boolean; rules: Record<string, string> };
20
20
 
21
21
  beforeEach(async () => {
22
22
  vi.resetModules();
23
- // 清理临时目录中的 privacy.json
24
23
  try {
25
24
  await rm(join(TEST_DATA_DIR, "privacy.json"), { force: true });
26
25
  } catch {}
@@ -28,6 +27,7 @@ beforeEach(async () => {
28
27
  applyPrivacy = mod.applyPrivacy;
29
28
  reloadPrivacyRules = mod.reloadPrivacyRules;
30
29
  getPrivacyRules = mod.getPrivacyRules;
30
+ getPrivacyConfig = mod.getPrivacyConfig;
31
31
  });
32
32
 
33
33
  afterEach(async () => {
@@ -43,12 +43,12 @@ afterAll(async () => {
43
43
  });
44
44
 
45
45
  describe("applyPrivacy", () => {
46
- it(" privacy.json 时返回原文", () => {
46
+ it("returns original text when privacy.json is missing", () => {
47
47
  reloadPrivacyRules();
48
48
  expect(applyPrivacy("hello weizhangjian")).toBe("hello weizhangjian");
49
49
  });
50
50
 
51
- it("privacy.json 存在时按规则替换", async () => {
51
+ it("supports legacy flat privacy rules", async () => {
52
52
  await writeFile(
53
53
  join(TEST_DATA_DIR, "privacy.json"),
54
54
  JSON.stringify({ weizhangjian: "wzj", secret: "***" }),
@@ -61,7 +61,63 @@ describe("applyPrivacy", () => {
61
61
  expect(applyPrivacy("weizhangjian and secret")).toBe("wzj and ***");
62
62
  });
63
63
 
64
- it("多规则替换多次出现", async () => {
64
+ it("supports privacy.json schema with enabled=false", async () => {
65
+ await writeFile(
66
+ join(TEST_DATA_DIR, "privacy.json"),
67
+ JSON.stringify({ enabled: false, rules: { weizhangjian: "wzj" } }),
68
+ "utf-8",
69
+ );
70
+ reloadPrivacyRules();
71
+
72
+ expect(getPrivacyConfig()).toEqual({ enabled: false, rules: { weizhangjian: "wzj" } });
73
+ expect(getPrivacyRules()).toEqual({ weizhangjian: "wzj" });
74
+ expect(applyPrivacy("hello weizhangjian")).toBe("hello weizhangjian");
75
+ });
76
+
77
+ it("supports privacy.json schema with enabled=true", async () => {
78
+ await writeFile(
79
+ join(TEST_DATA_DIR, "privacy.json"),
80
+ JSON.stringify({ enabled: true, rules: { weizhangjian: "wzj" } }),
81
+ "utf-8",
82
+ );
83
+ reloadPrivacyRules();
84
+
85
+ expect(applyPrivacy("hello weizhangjian")).toBe("hello wzj");
86
+ });
87
+
88
+ it("accepts UTF-8 BOM in privacy.json", async () => {
89
+ await writeFile(
90
+ join(TEST_DATA_DIR, "privacy.json"),
91
+ `\uFEFF${JSON.stringify({ enabled: false, rules: { weizhangjian: "wzj" } })}`,
92
+ "utf-8",
93
+ );
94
+ reloadPrivacyRules();
95
+
96
+ expect(getPrivacyConfig()).toEqual({ enabled: false, rules: { weizhangjian: "wzj" } });
97
+ expect(applyPrivacy("hello weizhangjian")).toBe("hello weizhangjian");
98
+ });
99
+
100
+ it("auto reloads privacy.json changes without explicit reload", async () => {
101
+ await writeFile(
102
+ join(TEST_DATA_DIR, "privacy.json"),
103
+ JSON.stringify({ weizhangjian: "wzj" }),
104
+ "utf-8",
105
+ );
106
+ reloadPrivacyRules();
107
+
108
+ expect(applyPrivacy("hello weizhangjian")).toBe("hello wzj");
109
+
110
+ await writeFile(
111
+ join(TEST_DATA_DIR, "privacy.json"),
112
+ JSON.stringify({ enabled: false, rules: { weizhangjian: "wzj-disabled" } }),
113
+ "utf-8",
114
+ );
115
+
116
+ expect(applyPrivacy("hello weizhangjian")).toBe("hello weizhangjian");
117
+ expect(getPrivacyConfig()).toEqual({ enabled: false, rules: { weizhangjian: "wzj-disabled" } });
118
+ });
119
+
120
+ it("replaces multiple rules and repeated occurrences", async () => {
65
121
  await writeFile(
66
122
  join(TEST_DATA_DIR, "privacy.json"),
67
123
  JSON.stringify({ a: "A", b: "B" }),
@@ -72,7 +128,7 @@ describe("applyPrivacy", () => {
72
128
  expect(applyPrivacy("a b a b")).toBe("A B A B");
73
129
  });
74
130
 
75
- it("空文本直接返回", async () => {
131
+ it("returns empty text directly", async () => {
76
132
  await writeFile(
77
133
  join(TEST_DATA_DIR, "privacy.json"),
78
134
  JSON.stringify({ x: "y" }),
@@ -83,7 +139,7 @@ describe("applyPrivacy", () => {
83
139
  expect(applyPrivacy("")).toBe("");
84
140
  });
85
141
 
86
- it("规则中的特殊字符不会被当作正则", async () => {
142
+ it("treats special characters in rule keys literally", async () => {
87
143
  await writeFile(
88
144
  join(TEST_DATA_DIR, "privacy.json"),
89
145
  JSON.stringify({ "a.b": "X", "(test)": "Y", "*star": "Z" }),
@@ -96,7 +152,7 @@ describe("applyPrivacy", () => {
96
152
  expect(applyPrivacy("a *star shines")).toBe("a Z shines");
97
153
  });
98
154
 
99
- it("reloadPrivacyRules 强制重新加载", async () => {
155
+ it("reloadPrivacyRules forces a reload", async () => {
100
156
  await writeFile(
101
157
  join(TEST_DATA_DIR, "privacy.json"),
102
158
  JSON.stringify({ old: "OLD" }),
@@ -107,7 +163,6 @@ describe("applyPrivacy", () => {
107
163
  expect(applyPrivacy("old")).toBe("OLD");
108
164
  expect(getPrivacyRules()).toEqual({ old: "OLD" });
109
165
 
110
- // 变更磁盘内容后 reload
111
166
  await writeFile(
112
167
  join(TEST_DATA_DIR, "privacy.json"),
113
168
  JSON.stringify({ new: "NEW" }),
@@ -119,7 +174,7 @@ describe("applyPrivacy", () => {
119
174
  expect(getPrivacyRules()).toEqual({ new: "NEW" });
120
175
  });
121
176
 
122
- it("格式错误的 JSON 不抛异常,返回原文", async () => {
177
+ it("returns original text for malformed JSON", async () => {
123
178
  await writeFile(
124
179
  join(TEST_DATA_DIR, "privacy.json"),
125
180
  "not json",
@@ -130,7 +185,7 @@ describe("applyPrivacy", () => {
130
185
  expect(applyPrivacy("hello")).toBe("hello");
131
186
  });
132
187
 
133
- it("数组格式的 JSON 不抛异常,返回原文", async () => {
188
+ it("returns original text for array JSON", async () => {
134
189
  await writeFile(
135
190
  join(TEST_DATA_DIR, "privacy.json"),
136
191
  JSON.stringify(["a", "b"]),
@@ -140,4 +195,4 @@ describe("applyPrivacy", () => {
140
195
 
141
196
  expect(applyPrivacy("hello")).toBe("hello");
142
197
  });
143
- });
198
+ });
@@ -1,92 +1,92 @@
1
- import { describe, it, expect } from "vitest";
2
- import {
3
- PAGE_HTML,
4
- chooseStartPath,
5
- unflattenConfig,
6
- } from "../web-ui.ts";
7
-
8
- describe("unflattenConfig", () => {
9
- it("maps Claude subagent model into claude.subagentModel", () => {
10
- expect(
11
- unflattenConfig({
12
- CHATCCC_ANTHROPIC_MODEL: "claude-sonnet-4-6",
13
- CHATCCC_ANTHROPIC_SUBAGENT_MODEL: "claude-haiku-4-5-20251001",
14
- }),
15
- ).toEqual({
16
- claude: {
17
- model: "claude-sonnet-4-6",
18
- subagentModel: "claude-haiku-4-5-20251001",
19
- },
20
- });
21
- });
22
-
23
- it("maps Claude apiKey and baseUrl into claude config", () => {
24
- expect(
25
- unflattenConfig({
26
- CHATCCC_ANTHROPIC_API_KEY: "sk-test-key",
27
- CHATCCC_ANTHROPIC_BASE_URL: "https://api.example.com",
28
- }),
29
- ).toEqual({
30
- claude: {
31
- apiKey: "sk-test-key",
32
- baseUrl: "https://api.example.com",
33
- },
34
- });
35
- });
36
- });
37
-
38
- describe("dashboard edit modal", () => {
39
- it("shows the edit modal and overlay when a section edit button is clicked", () => {
40
- expect(PAGE_HTML).toContain("function editSection(section)");
41
- expect(PAGE_HTML).toContain("document.getElementById('edit-modal').classList.remove('hidden');");
42
- expect(PAGE_HTML).toContain("document.getElementById('edit-overlay').classList.remove('hidden');");
43
- });
44
- });
45
-
46
- // ---------------------------------------------------------------------------
47
- // chooseStartPath — /api/start 的路径选择
48
- // 关键护栏:
49
- // - setup 模式(hasInplaceActivateHook=true)下 isServiceRunning 永远为 true
50
- // (setup 进程自己占着 PID 文件),必须无条件走 inplace;否则用户点
51
- // "保存并启动"将永远拿到 "Service is already running"。
52
- // - dashboard 模式 + service 已运行(通常就是当前进程自己)→ "reload":
53
- // 用户点"保存并启动"想让新 config 生效,但服务正在跑——不真重启,仅
54
- // 调用 reloadConfigFromDisk() 刷新进程内 export let 常量。绝不能再
55
- // 返回"already running"挡用户路。
56
- // - dashboard 模式 + service 未运行 → spawn 一个新的(旧 service 退出后场景)。
57
- // ---------------------------------------------------------------------------
58
-
59
- describe("chooseStartPath", () => {
60
- it("setup 模式(注入 inplace hook)→ inplace(不管 PID 文件状态)", () => {
61
- expect(
62
- chooseStartPath({
63
- hasInplaceActivateHook: true,
64
- isServiceRunning: true,
65
- }),
66
- ).toBe("inplace");
67
- expect(
68
- chooseStartPath({
69
- hasInplaceActivateHook: true,
70
- isServiceRunning: false,
71
- }),
72
- ).toBe("inplace");
73
- });
74
-
75
- it("dashboard 模式 + service 已运行 → reload(仅刷新 config,不真重启)", () => {
76
- expect(
77
- chooseStartPath({
78
- hasInplaceActivateHook: false,
79
- isServiceRunning: true,
80
- }),
81
- ).toBe("reload");
82
- });
83
-
84
- it("dashboard 模式 + service 未运行 → spawn", () => {
85
- expect(
86
- chooseStartPath({
87
- hasInplaceActivateHook: false,
88
- isServiceRunning: false,
89
- }),
90
- ).toBe("spawn");
91
- });
92
- });
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ PAGE_HTML,
4
+ chooseStartPath,
5
+ unflattenConfig,
6
+ } from "../web-ui.ts";
7
+
8
+ describe("unflattenConfig", () => {
9
+ it("maps Claude subagent model into claude.subagentModel", () => {
10
+ expect(
11
+ unflattenConfig({
12
+ CHATCCC_ANTHROPIC_MODEL: "claude-sonnet-4-6",
13
+ CHATCCC_ANTHROPIC_SUBAGENT_MODEL: "claude-haiku-4-5-20251001",
14
+ }),
15
+ ).toEqual({
16
+ claude: {
17
+ model: "claude-sonnet-4-6",
18
+ subagentModel: "claude-haiku-4-5-20251001",
19
+ },
20
+ });
21
+ });
22
+
23
+ it("maps Claude apiKey and baseUrl into claude config", () => {
24
+ expect(
25
+ unflattenConfig({
26
+ CHATCCC_ANTHROPIC_API_KEY: "sk-test-key",
27
+ CHATCCC_ANTHROPIC_BASE_URL: "https://api.example.com",
28
+ }),
29
+ ).toEqual({
30
+ claude: {
31
+ apiKey: "sk-test-key",
32
+ baseUrl: "https://api.example.com",
33
+ },
34
+ });
35
+ });
36
+ });
37
+
38
+ describe("dashboard edit modal", () => {
39
+ it("shows the edit modal and overlay when a section edit button is clicked", () => {
40
+ expect(PAGE_HTML).toContain("function editSection(section)");
41
+ expect(PAGE_HTML).toContain("document.getElementById('edit-modal').classList.remove('hidden');");
42
+ expect(PAGE_HTML).toContain("document.getElementById('edit-overlay').classList.remove('hidden');");
43
+ });
44
+ });
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // chooseStartPath — /api/start 的路径选择
48
+ // 关键护栏:
49
+ // - setup 模式(hasInplaceActivateHook=true)下 isServiceRunning 永远为 true
50
+ // (setup 进程自己占着 PID 文件),必须无条件走 inplace;否则用户点
51
+ // "保存并启动"将永远拿到 "Service is already running"。
52
+ // - dashboard 模式 + service 已运行(通常就是当前进程自己)→ "reload":
53
+ // 用户点"保存并启动"想让新 config 生效,但服务正在跑——不真重启,仅
54
+ // 调用 reloadConfigFromDisk() 刷新进程内 export let 常量。绝不能再
55
+ // 返回"already running"挡用户路。
56
+ // - dashboard 模式 + service 未运行 → spawn 一个新的(旧 service 退出后场景)。
57
+ // ---------------------------------------------------------------------------
58
+
59
+ describe("chooseStartPath", () => {
60
+ it("setup 模式(注入 inplace hook)→ inplace(不管 PID 文件状态)", () => {
61
+ expect(
62
+ chooseStartPath({
63
+ hasInplaceActivateHook: true,
64
+ isServiceRunning: true,
65
+ }),
66
+ ).toBe("inplace");
67
+ expect(
68
+ chooseStartPath({
69
+ hasInplaceActivateHook: true,
70
+ isServiceRunning: false,
71
+ }),
72
+ ).toBe("inplace");
73
+ });
74
+
75
+ it("dashboard 模式 + service 已运行 → reload(仅刷新 config,不真重启)", () => {
76
+ expect(
77
+ chooseStartPath({
78
+ hasInplaceActivateHook: false,
79
+ isServiceRunning: true,
80
+ }),
81
+ ).toBe("reload");
82
+ });
83
+
84
+ it("dashboard 模式 + service 未运行 → spawn", () => {
85
+ expect(
86
+ chooseStartPath({
87
+ hasInplaceActivateHook: false,
88
+ isServiceRunning: false,
89
+ }),
90
+ ).toBe("spawn");
91
+ });
92
+ });