@zhushanwen/pi-cw-tool 0.4.3 → 0.5.0

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,11 +1,11 @@
1
1
  /**
2
- * cw-tool 测试。
2
+ * cw-tool 测试(cw 2.0 只读查询面)。
3
3
  *
4
4
  * 测试框架:vitest(从 vitest 导入 describe/it/expect/vi)。
5
- * 不真调 cw:通过 fake spawner 注入。白名单拦截在 spawn 之前,故拒绝用例断言 spawner 未被调用。
5
+ * 不真调 cw:通过 fake spawner 注入。白名单/参数校验在 spawn 之前,故拒绝用例断言 spawner 未被调用。
6
6
  */
7
- import { EventEmitter } from "node:events";
8
7
  import type * as cp from "node:child_process";
8
+ import { EventEmitter } from "node:events";
9
9
 
10
10
  import { afterEach, describe, expect, it, vi } from "vitest";
11
11
 
@@ -14,41 +14,23 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
14
14
  // node:child_process 是原生 CJS 模块,ESM 命名导出不可重定义(vi.spyOn 报 "not configurable")。
15
15
  // 改用 vi.mock + vi.hoisted:工厂替换整个模块,hoisted vi.fn 作为 spawn,测试内动态配置实现。
16
16
  const spawnMock = vi.hoisted(() => vi.fn());
17
- // detectRepoWorkspace 的 git 探测同样走 node:child_process(spawnSync),mock 掉以保持纯单元;
18
- // 默认返回失败(非 git 目录语义),executeCwAction 因此不附加 --workspace,现有断言不受影响。
19
- const spawnSyncMock = vi.hoisted(() => vi.fn());
20
- vi.mock("node:child_process", () => ({ spawn: spawnMock, spawnSync: spawnSyncMock }));
21
-
22
- // 默认:git 探测失败(status 128)→ detectRepoWorkspace 返回 undefined。
23
- spawnSyncMock.mockImplementation((_cmd: string, _args: string[], _opts: object) => ({
24
- status: 128,
25
- stdout: "",
26
- stderr: "not a git repository",
27
- }));
17
+ vi.mock("node:child_process", () => ({ spawn: spawnMock }));
28
18
 
29
19
  import {
30
20
  buildCwArgs,
31
21
  type CwDetails,
22
+ CW_ACTIONS,
32
23
  executeCwAction,
33
- isReadonlyAction,
34
24
  rejectDisallowedAction,
35
- rejectMissingUnitId,
25
+ rejectInvalidQueryOptions,
36
26
  } from "../cw-runner.ts";
37
- import { type CwSpawner, type CwSpawnResult } from "../cw-spawn.ts";
38
- import {
39
- DEV_ALLOWED,
40
- PLANNING_ALLOWED,
41
- REVIEW_ALLOWED,
42
- WAVE_ALLOWED,
43
- buildTool,
44
- defaultCwSpawner,
45
- } from "../index.ts";
27
+ import { type CwSpawner, type CwSpawnResult, defaultCwSpawner } from "../cw-spawn.ts";
28
+ import { buildQueryTool, TOOL_NAME } from "../index.ts";
46
29
 
47
30
  // ── fake spawner 工具 ───────────────────────────────────────────
48
31
 
49
32
  interface CapturedCall {
50
33
  args: string[];
51
- input: string | undefined;
52
34
  cwd: string;
53
35
  }
54
36
 
@@ -56,727 +38,345 @@ interface CapturedCall {
56
38
  function fakeSpawner(responses: CwSpawnResult[]): { spawner: CwSpawner; calls: CapturedCall[] } {
57
39
  const calls: CapturedCall[] = [];
58
40
  let i = 0;
59
- const spawner: CwSpawner = vi.fn(async (args, input, cwd, _signal): Promise<CwSpawnResult> => {
60
- // 门控 probe(['--version'])是内部细节:返回低版本(不支持归一化)让 write action
61
- // 走兜底路径,且不入 calls(测试断言的是 action 调用,probe 透明)。
62
- if (args[0] === "--version") {
63
- return { stdout: "cw 1.0.0", stderr: "", exitCode: 0 };
64
- }
65
- calls.push({ args, input, cwd });
66
- const r = responses[i] ?? { stdout: "", stderr: "", exitCode: 0 };
41
+ const spawner: CwSpawner = vi.fn(async (args, _input, cwd, _signal): Promise<CwSpawnResult> => {
42
+ calls.push({ args, cwd });
43
+ const response = responses[i] ?? { stdout: "", stderr: "", exitCode: 0 };
67
44
  i += 1;
68
- return { stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode };
45
+ return response;
69
46
  });
70
47
  return { spawner, calls };
71
48
  }
72
49
 
73
- /** 造一个「若被调用即失败」的 spawner(用于白名单拒绝用例,断言不该被调到)。 */
50
+ /** 任何 spawn 都视为违规(拒绝用例用:断言从未触达 spawn)。 */
74
51
  function forbiddenSpawner(): CwSpawner {
75
- // 回调签名由变量类型 CwSpawner 提供 contextual typing;签名漂移会编译失败(消除假绿)。
76
52
  const spawner: CwSpawner = vi.fn(async (_args, _input, _cwd, _signal): Promise<CwSpawnResult> => {
77
- throw new Error("spawner must not be called for a rejected action");
53
+ throw new Error("forbidden: 拒绝路径不应 spawn cw");
78
54
  });
79
55
  return spawner;
80
56
  }
81
57
 
82
- // execute 的 ctx 是完整 ExtensionContext(SDK 类型,字段多);测试仅消费 cwd。
83
- // satisfies 校验 cwd 形状,再经 unknown 桥接到完整类型(partial mock of SDK type)。
84
- const fakeCtx = ({
85
- cwd: "/tmp/fake-workspace",
86
- } satisfies Pick<ExtensionContext, "cwd">) as unknown as ExtensionContext;
87
-
88
- // ── 白名单拦截:每个工具至少 1 个被拒 action ────────────────────
89
-
90
- describe("白名单拦截(executeCwAction)", () => {
91
- const cases: Array<{
92
- name: string;
93
- allowed: readonly string[];
94
- reject: string;
95
- }> = [
96
- { name: "cw_planning", allowed: PLANNING_ALLOWED, reject: "design-review" },
97
- { name: "cw_planning", allowed: PLANNING_ALLOWED, reject: "exec-review" },
98
- { name: "cw_wave", allowed: WAVE_ALLOWED, reject: "execute" },
99
- { name: "cw_wave", allowed: WAVE_ALLOWED, reject: "test" },
100
- { name: "cw_wave", allowed: WAVE_ALLOWED, reject: "design-review" },
101
- { name: "cw_wave", allowed: WAVE_ALLOWED, reject: "exec-review" },
102
- { name: "cw_dev", allowed: DEV_ALLOWED, reject: "design-review" },
103
- { name: "cw_dev", allowed: DEV_ALLOWED, reject: "clarify" },
104
- { name: "cw_review", allowed: REVIEW_ALLOWED, reject: "execute" },
105
- { name: "cw_review", allowed: REVIEW_ALLOWED, reject: "plan" },
106
- { name: "cw_review", allowed: REVIEW_ALLOWED, reject: "test" },
107
- ];
108
-
109
- for (const { name, allowed, reject } of cases) {
110
- it(`${name} 拒绝 "${reject}"(不在白名单,spawner 不被调用)`, async () => {
111
- const spawner = forbiddenSpawner();
112
- const details = await executeCwAction(
113
- reject,
114
- allowed,
115
- name,
116
- "unit-1",
117
- {},
118
- spawner,
119
- fakeCtx.cwd,
120
- );
121
-
122
- expect(details.ok).toBe(false);
123
- if (details.ok) throw new Error("unreachable");
124
- expect(details.error).toContain(reject);
125
- expect(details.error).toContain(name);
126
- expect(spawner).not.toHaveBeenCalled();
127
- });
128
- }
58
+ const CWD = "/tmp/proj";
129
59
 
130
- it("rejectDisallowedAction 直接返回含 action 与工具名的消息", () => {
131
- const msg = rejectDisallowedAction("execute", REVIEW_ALLOWED, "cw_review");
132
- expect(msg).toContain('"execute"');
133
- expect(msg).toContain("cw_review");
134
- expect(rejectDisallowedAction("status", REVIEW_ALLOWED, "cw_review")).toBeUndefined();
135
- });
60
+ /** ExtensionContext 最小 fake(executeCwAction 只用 ctx.cwd)。 */
61
+ const fakeCtx = ({ cwd: CWD } as unknown) as ExtensionContext;
62
+
63
+ afterEach(() => {
64
+ spawnMock.mockReset();
136
65
  });
137
66
 
138
- // ── 允许的 action:mock spawn,不真调 cw ────────────────────────
139
-
140
- describe("允许的 action(mock spawn)", () => {
141
- const okCases: Array<{ name: string; allowed: readonly string[]; action: string }> = [
142
- { name: "cw_planning", allowed: PLANNING_ALLOWED, action: "design" },
143
- { name: "cw_planning", allowed: PLANNING_ALLOWED, action: "execute" },
144
- { name: "cw_wave", allowed: WAVE_ALLOWED, action: "design" },
145
- { name: "cw_dev", allowed: DEV_ALLOWED, action: "execute" },
146
- { name: "cw_dev", allowed: DEV_ALLOWED, action: "test" },
147
- { name: "cw_review", allowed: REVIEW_ALLOWED, action: "design-review" },
148
- { name: "cw_review", allowed: REVIEW_ALLOWED, action: "exec-review" },
149
- ];
150
-
151
- for (const { name, allowed, action } of okCases) {
152
- it(`${name} 允许 "${action}":stdout 是 JSON → ok:true + parsed data`, async () => {
153
- const payload = { nextAction: { command: `cw ${action}` }, ok: true };
154
- const { spawner, calls } = fakeSpawner([
155
- { stdout: JSON.stringify(payload), stderr: "", exitCode: 0 },
156
- ]);
157
-
158
- const details = await executeCwAction(
159
- action,
160
- allowed,
161
- name,
162
- "unit-42",
163
- {},
164
- spawner,
165
- fakeCtx.cwd,
166
- );
167
-
168
- expect(details.ok).toBe(true);
169
- if (!details.ok) throw new Error("unreachable");
170
- expect(details.action).toBe(action);
171
- expect(details.unitId).toBe("unit-42");
172
- expect(details.parsed).toBe(true);
173
- if (!details.parsed) throw new Error("unreachable");
174
- expect(details.data).toEqual(payload);
175
-
176
- // 参数构造正确:cwd 透传,args 含 action + --unitId
177
- expect(calls).toHaveLength(1);
178
- expect(calls[0].cwd).toBe(fakeCtx.cwd);
179
- expect(calls[0].args[0]).toBe(action);
180
- expect(calls[0].args).toContain("--unitId");
181
- expect(calls[0].args).toContain("unit-42");
182
- });
183
- }
67
+ // ── 白名单(cw 2.0 只读面:写 action 物理不可达)────────────────
184
68
 
185
- it("stdout 非 JSON → ok:true + parsed:false + 原样 stdout", async () => {
186
- const { spawner } = fakeSpawner([{ stdout: "not a json", stderr: "", exitCode: 0 }]);
187
- const details = await executeCwAction(
188
- "status",
189
- DEV_ALLOWED,
190
- "cw_dev",
191
- "u1",
192
- {},
193
- spawner,
194
- fakeCtx.cwd,
195
- );
196
- expect(details.ok).toBe(true);
197
- if (!details.ok || details.parsed) throw new Error("unreachable");
198
- expect(details.stdout).toBe("not a json");
69
+ describe("rejectDisallowedAction / executeCwAction 白名单", () => {
70
+ it("CW_ACTIONS 恰为 cw 2.0 四个只读命令", () => {
71
+ expect([...CW_ACTIONS]).toEqual(["status", "frontier", "tree", "report"]);
199
72
  });
200
73
 
201
- it("空 stdout(cw 成功但无输出)→ ok:true + parsed:false", async () => {
202
- const { spawner } = fakeSpawner([{ stdout: " ", stderr: "", exitCode: 0 }]);
203
- const details = await executeCwAction(
204
- "status",
205
- DEV_ALLOWED,
206
- "cw_dev",
207
- "u1",
208
- {},
209
- spawner,
210
- fakeCtx.cwd,
74
+ it.each(["create", "run", "verify", "design", "execute", "handoff", "evidence", "review", "abort"])(
75
+ "写/1.x action %s 被拒且不 spawn",
76
+ async (action) => {
77
+ const err = rejectDisallowedAction(action, CW_ACTIONS, TOOL_NAME);
78
+ expect(err).toContain(action);
79
+ expect(err).toContain("cw-cli skill");
80
+
81
+ const details = await executeCwAction(action, CW_ACTIONS, TOOL_NAME, {}, forbiddenSpawner(), CWD);
82
+ expect(details).toMatchObject({ ok: false, action });
83
+ expect(details.ok && true).toBe(false);
84
+ },
85
+ );
86
+
87
+ it("错误消息列出全部允许 action", () => {
88
+ expect(rejectDisallowedAction("run", CW_ACTIONS, TOOL_NAME)).toContain(
89
+ "status, frontier, tree, report",
211
90
  );
212
- expect(details.ok).toBe(true);
213
91
  });
214
92
  });
215
93
 
216
- // ── 失败路径 ────────────────────────────────────────────────────
94
+ // ── 查询参数校验(action × flag 匹配 + 互斥)────────────────────
217
95
 
218
- describe("失败路径", () => {
219
- it("非零退出码 → ok:false + 含 exit code + stderr", async () => {
220
- const { spawner } = fakeSpawner([
221
- { stdout: "", stderr: "unit not found", exitCode: 1 },
222
- ]);
223
- const details = await executeCwAction(
224
- "status",
225
- DEV_ALLOWED,
226
- "cw_dev",
227
- "missing",
228
- {},
229
- spawner,
230
- fakeCtx.cwd,
231
- );
232
- expect(details.ok).toBe(false);
233
- if (details.ok) throw new Error("unreachable");
234
- expect(details.error).toContain("exit code 1");
235
- expect(details.error).toContain("unit not found");
96
+ describe("rejectInvalidQueryOptions", () => {
97
+ it("tree 传 unitId 被拒(2.0 tree 无选择器)", () => {
98
+ expect(rejectInvalidQueryOptions("tree", { unitId: "u1" })).toContain("unitId");
236
99
  });
237
100
 
238
- it("stderr 非空但 exitCode 0 → ok:true(S-2:按 exitCode 判定,stderr 不导致失败)", async () => {
239
- const { spawner } = fakeSpawner([
240
- { stdout: "{}", stderr: "warning: something", exitCode: 0 },
241
- ]);
242
- const details = await executeCwAction(
243
- "status",
244
- DEV_ALLOWED,
245
- "cw_dev",
246
- "u1",
247
- {},
248
- spawner,
249
- fakeCtx.cwd,
250
- );
251
- expect(details.ok).toBe(true);
101
+ it("frontier 传 unitId 被拒", () => {
102
+ expect(rejectInvalidQueryOptions("frontier", { unitId: "u1" })).toContain("unitId");
252
103
  });
253
104
 
254
- it("spawner 抛异常 → ok:false + spawn 失败消息", async () => {
255
- const spawner: CwSpawner = vi.fn(async (_args, _input, _cwd, _signal): Promise<CwSpawnResult> => {
256
- throw new Error("ENOENT");
257
- });
258
- const details = await executeCwAction(
259
- "status",
260
- DEV_ALLOWED,
261
- "cw_dev",
262
- "u1",
263
- {},
264
- spawner,
265
- fakeCtx.cwd,
266
- );
267
- expect(details.ok).toBe(false);
268
- if (details.ok) throw new Error("unreachable");
269
- expect(details.error).toContain("ENOENT");
105
+ it("非 report 传 rootId 被拒", () => {
106
+ expect(rejectInvalidQueryOptions("status", { rootId: "r1" })).toContain("rootId");
107
+ expect(rejectInvalidQueryOptions("tree", { rootId: "r1" })).toContain("rootId");
270
108
  });
271
109
 
272
- it("input 与 inputFile 同时给 → ok:false(互斥)", async () => {
273
- const spawner = forbiddenSpawner();
274
- const details = await executeCwAction(
275
- "design",
276
- PLANNING_ALLOWED,
277
- "cw_planning",
278
- "u1",
279
- { input: "{}", inputFile: "/tmp/x.json" },
280
- spawner,
281
- fakeCtx.cwd,
282
- );
283
- expect(details.ok).toBe(false);
284
- if (details.ok) throw new Error("unreachable");
285
- expect(details.error).toContain("互斥");
286
- expect(spawner).not.toHaveBeenCalled();
110
+ it("tree/report 传 json 被拒(2.0 仅 status/frontier 有 --json)", () => {
111
+ expect(rejectInvalidQueryOptions("tree", { json: true })).toContain("json");
112
+ expect(rejectInvalidQueryOptions("report", { json: true })).toContain("json");
287
113
  });
288
114
 
289
- it("spawn 超时(timeoutMs)→ ok:false 'cw 超时'", async () => {
290
- // spawner 模拟 cw 卡死:挂起直到 signal abort 才 resolve(默认实现行为)。
291
- const hangingSpawner: CwSpawner = vi.fn((_args, _input, _cwd, signal): Promise<CwSpawnResult> =>
292
- new Promise<CwSpawnResult>((resolve) => {
293
- signal?.addEventListener("abort", () =>
294
- resolve({ stdout: "", stderr: "", exitCode: null }),
295
- );
296
- }),
297
- );
115
+ it("report 同时传 unitId 与 rootId 被拒(CLI 侧互斥)", () => {
116
+ expect(rejectInvalidQueryOptions("report", { unitId: "u1", rootId: "r1" })).toContain("互斥");
117
+ });
298
118
 
119
+ it("合法组合放行", () => {
120
+ expect(rejectInvalidQueryOptions("status", {})).toBeUndefined();
121
+ expect(rejectInvalidQueryOptions("status", { unitId: "u1", json: true })).toBeUndefined();
122
+ expect(rejectInvalidQueryOptions("frontier", { json: true })).toBeUndefined();
123
+ expect(rejectInvalidQueryOptions("tree", {})).toBeUndefined();
124
+ expect(rejectInvalidQueryOptions("report", { unitId: "u1" })).toBeUndefined();
125
+ expect(rejectInvalidQueryOptions("report", { rootId: "r1" })).toBeUndefined();
126
+ expect(rejectInvalidQueryOptions("report", {})).toBeUndefined();
127
+ });
128
+
129
+ it("非法参数在 spawn 前拦截", async () => {
299
130
  const details = await executeCwAction(
300
- "status",
301
- DEV_ALLOWED,
302
- "cw_dev",
303
- "u1",
304
- {},
305
- hangingSpawner,
306
- fakeCtx.cwd,
307
- undefined,
308
- 50,
131
+ "report",
132
+ CW_ACTIONS,
133
+ TOOL_NAME,
134
+ { unitId: "u1", rootId: "r1" },
135
+ forbiddenSpawner(),
136
+ CWD,
309
137
  );
310
-
311
- expect(details.ok).toBe(false);
312
- if (details.ok) throw new Error("unreachable");
313
- expect(details.error).toBe("cw 超时");
138
+ expect(details).toMatchObject({ ok: false, action: "report" });
314
139
  });
315
140
  });
316
141
 
317
- // ── 参数构造 ────────────────────────────────────────────────────
142
+ // ── buildCwArgs(2.0 参数面映射)────────────────────────────────
318
143
 
319
144
  describe("buildCwArgs", () => {
320
- it("无 input:仅 action + --unitId", () => {
321
- expect(buildCwArgs("status", "u1", {})).toEqual(["status", "--unitId", "u1"]);
145
+ it("status 无参数 → 裸命令", () => {
146
+ expect(buildCwArgs("status", {})).toEqual(["status"]);
322
147
  });
323
148
 
324
- it("input 内容 → --input - (经 stdin)", () => {
325
- expect(buildCwArgs("design", "u1", { input: '{"a":1}' })).toEqual([
326
- "design",
327
- "--unitId",
328
- "u1",
329
- "--input",
330
- "-",
331
- ]);
332
- });
333
-
334
- it("inputFile 路径 → --input <path>", () => {
335
- expect(buildCwArgs("design", "u1", { inputFile: "/tmp/in.json" })).toEqual([
336
- "design",
337
- "--unitId",
338
- "u1",
339
- "--input",
340
- "/tmp/in.json",
341
- ]);
342
- });
343
-
344
- it("commitHash → --commitHash", () => {
345
- expect(buildCwArgs("execute", "u1", { commitHash: "abc123" })).toEqual([
346
- "execute",
347
- "--unitId",
149
+ it("status unitId + json → --unit 在前 --json 在后", () => {
150
+ expect(buildCwArgs("status", { unitId: "u1", json: true })).toEqual([
151
+ "status",
152
+ "--unit",
348
153
  "u1",
349
- "--commitHash",
350
- "abc123",
154
+ "--json",
351
155
  ]);
352
156
  });
353
157
 
354
- it("input + commitHash 同时", () => {
355
- expect(buildCwArgs("execute", "u1", { input: "{}", commitHash: "abc" })).toEqual([
356
- "execute",
357
- "--unitId",
358
- "u1",
359
- "--input",
360
- "-",
361
- "--commitHash",
362
- "abc",
363
- ]);
158
+ it("frontier json", () => {
159
+ expect(buildCwArgs("frontier", { json: true })).toEqual(["frontier", "--json"]);
364
160
  });
365
161
 
366
- it("workspace → 追加 --workspace <path>(位于 --commitHash 之后)", () => {
367
- expect(buildCwArgs("execute", "u1", { commitHash: "abc" }, "/tmp/repo-root")).toEqual([
368
- "execute",
369
- "--unitId",
370
- "u1",
371
- "--commitHash",
372
- "abc",
373
- "--workspace",
374
- "/tmp/repo-root",
375
- ]);
162
+ it("tree 恒裸命令", () => {
163
+ expect(buildCwArgs("tree", {})).toEqual(["tree"]);
376
164
  });
377
165
 
378
- it("workspace 不传 → 无 --workspace", () => {
379
- expect(buildCwArgs("status", "u1", {})).toEqual(["status", "--unitId", "u1"]);
166
+ it("report unitId / rootId 二选一", () => {
167
+ expect(buildCwArgs("report", { unitId: "u1" })).toEqual(["report", "--unit", "u1"]);
168
+ expect(buildCwArgs("report", { rootId: "r1" })).toEqual(["report", "--root", "r1"]);
169
+ expect(buildCwArgs("report", {})).toEqual(["report"]);
380
170
  });
381
171
 
382
- it("unitId 为 undefined → 不加 --unitId(只读 action,S-5)", () => {
383
- expect(buildCwArgs("list", undefined, {})).toEqual(["list"]);
172
+ it("无 1.x 残留 flag(--unitId / --workspace / --input / --commitHash 不出现)", () => {
173
+ for (const action of CW_ACTIONS) {
174
+ const args = buildCwArgs(action, { unitId: "u1", rootId: "r1", json: true });
175
+ expect(args).not.toContain("--unitId");
176
+ expect(args).not.toContain("--workspace");
177
+ expect(args).not.toContain("--input");
178
+ expect(args).not.toContain("--commitHash");
179
+ }
384
180
  });
385
181
  });
386
182
 
387
- // ── stdin 透传 ──────────────────────────────────────────────────
388
-
389
- describe("stdin 透传", () => {
390
- it("input 内容写入 spawner 的 input 参数", async () => {
391
- const { spawner, calls } = fakeSpawner([{ stdout: "{}", stderr: "", exitCode: 0 }]);
392
- await executeCwAction(
393
- "design",
394
- PLANNING_ALLOWED,
395
- "cw_planning",
396
- "u1",
397
- { input: '{"plan":"x"}' },
398
- spawner,
399
- fakeCtx.cwd,
400
- );
401
- expect(calls[0].input).toBe('{"plan":"x"}');
402
- });
183
+ // ── executeCwAction(spawn → 解析)──────────────────────────────
403
184
 
404
- it("无 input → spawner input 为 undefined", async () => {
405
- const { spawner, calls } = fakeSpawner([{ stdout: "{}", stderr: "", exitCode: 0 }]);
406
- await executeCwAction(
185
+ describe("executeCwAction", () => {
186
+ it("stdout 是 JSON → parsed:true + data", async () => {
187
+ const { spawner, calls } = fakeSpawner([
188
+ { stdout: '{"units":[]}', stderr: "", exitCode: 0 },
189
+ ]);
190
+ const details = await executeCwAction(
407
191
  "status",
408
- PLANNING_ALLOWED,
409
- "cw_planning",
410
- "u1",
411
- {},
192
+ CW_ACTIONS,
193
+ TOOL_NAME,
194
+ { json: true },
412
195
  spawner,
413
- fakeCtx.cwd,
196
+ CWD,
414
197
  );
415
- expect(calls[0].input).toBeUndefined();
198
+ expect(details).toMatchObject({ ok: true, action: "status", parsed: true });
199
+ if (details.ok && details.parsed) expect(details.data).toEqual({ units: [] });
200
+ expect(calls[0]?.args).toEqual(["status", "--json"]);
201
+ expect(calls[0]?.cwd).toBe(CWD);
416
202
  });
417
- });
418
203
 
419
- // ── executeCwAction 接线:spawnSync 探测结果 → --workspace 附加 ─────
420
-
421
- // 说明:本文件 mock 了 node:child_process(spawnSync 默认失败),此处验证接线逻辑;
422
- // 真实 git 探测行为见 detect-repo-workspace.test.ts(真实 git repo + worktree)。
423
- describe("executeCwAction 附加 --workspace(spawnSync mock)", () => {
424
- afterEach(() => {
425
- spawnSyncMock.mockReset();
426
- spawnSyncMock.mockImplementation((_cmd: string, _args: string[], _opts: object) => ({
427
- status: 128,
428
- stdout: "",
429
- stderr: "not a git repository",
430
- }));
204
+ it("stdout 非 JSON(人可读视图)→ parsed:false + 原样 stdout", async () => {
205
+ const { spawner } = fakeSpawner([{ stdout: "u1 closed", stderr: "", exitCode: 0 }]);
206
+ const details = await executeCwAction("tree", CW_ACTIONS, TOOL_NAME, {}, spawner, CWD);
207
+ expect(details).toMatchObject({ ok: true, parsed: false });
208
+ if (details.ok && !details.parsed) expect(details.stdout).toBe("u1 closed");
431
209
  });
432
210
 
433
- it("cwd 在 git repo 内 → args 含 --workspace <repo 根>(--commitHash 之后)", async () => {
434
- spawnSyncMock.mockImplementationOnce((_cmd: string, _args: string[], _opts: object) => ({
435
- status: 0,
436
- stdout: "/tmp/repo-root/.git\n",
437
- stderr: "",
438
- }));
439
- const { spawner, calls } = fakeSpawner([{ stdout: "{}", stderr: "", exitCode: 0 }]);
440
- await executeCwAction(
441
- "execute",
442
- DEV_ALLOWED,
443
- "cw_dev",
444
- "u1",
445
- { commitHash: "abc123" },
446
- spawner,
447
- "/tmp/repo-root/worktrees/wt1",
448
- );
449
- expect(calls[0].args).toEqual([
450
- "execute",
451
- "--unitId",
452
- "u1",
453
- "--commitHash",
454
- "abc123",
455
- "--workspace",
456
- "/tmp/repo-root",
211
+ it("非零退出码 → ok:false,stderr 折进错误消息", async () => {
212
+ const { spawner } = fakeSpawner([
213
+ { stdout: "", stderr: "unit 不存在: nope", exitCode: 1 },
457
214
  ]);
458
- });
459
-
460
- it("cwd 非 git(探测失败)→ write action 无 --workspace", async () => {
461
- const { spawner, calls } = fakeSpawner([{ stdout: "{}", stderr: "", exitCode: 0 }]);
462
- await executeCwAction(
463
- "execute",
464
- DEV_ALLOWED,
465
- "cw_dev",
466
- "u1",
467
- {},
215
+ const details = await executeCwAction(
216
+ "status",
217
+ CW_ACTIONS,
218
+ TOOL_NAME,
219
+ { unitId: "nope" },
468
220
  spawner,
469
- "/tmp/not-a-repo",
221
+ CWD,
470
222
  );
471
- expect(calls[0].args).toEqual(["execute", "--unitId", "u1"]);
223
+ expect(details).toMatchObject({ ok: false, action: "status" });
224
+ if (!details.ok) {
225
+ expect(details.error).toContain("exit code 1");
226
+ expect(details.error).toContain("unit 不存在");
227
+ }
472
228
  });
473
229
 
474
- it("read-only action(status)即使在 git repo 内也不附加 --workspace(S-3)", async () => {
475
- spawnSyncMock.mockImplementationOnce((_cmd: string, _args: string[], _opts: object) => ({
476
- status: 0,
477
- stdout: "/tmp/repo-root/.git\n",
478
- stderr: "",
479
- }));
480
- const { spawner, calls } = fakeSpawner([{ stdout: "{}", stderr: "", exitCode: 0 }]);
481
- await executeCwAction(
482
- "status",
483
- DEV_ALLOWED,
484
- "cw_dev",
485
- "u1",
486
- {},
487
- spawner,
488
- "/tmp/repo-root/worktrees/wt1",
489
- );
490
- expect(calls[0].args).not.toContain("--workspace");
230
+ it("null 退出码(被信号终止)同样判失败", async () => {
231
+ const { spawner } = fakeSpawner([{ stdout: "", stderr: "", exitCode: null }]);
232
+ const details = await executeCwAction("frontier", CW_ACTIONS, TOOL_NAME, {}, spawner, CWD);
233
+ expect(details).toMatchObject({ ok: false });
491
234
  });
492
- });
493
235
 
494
- // ── unitId 运行时校验(S-5)────────────────────────────────────
236
+ it("spawner 抛异常 → ok:false 带 spawn 失败前缀,不向上抛", async () => {
237
+ const spawner: CwSpawner = vi.fn(async () => {
238
+ throw new Error("boom");
239
+ });
240
+ const details = await executeCwAction("status", CW_ACTIONS, TOOL_NAME, {}, spawner, CWD);
241
+ expect(details).toMatchObject({ ok: false });
242
+ if (!details.ok) expect(details.error).toContain("cw spawn 失败");
243
+ });
495
244
 
496
- describe("unitId 运行时校验(S-5)", () => {
497
- it("写 action 缺 unitId → ok:false + 清晰错误(含 action 名 + unitId),spawner 不被调用", async () => {
498
- const spawner = forbiddenSpawner();
245
+ it("超时 → ok:false 'cw 超时'(timeoutMs=0 不限时)", async () => {
246
+ const hangingSpawner: CwSpawner = (_args, _input, _cwd, signal) =>
247
+ new Promise<CwSpawnResult>((resolve) => {
248
+ signal?.addEventListener("abort", () => resolve({ stdout: "", stderr: "", exitCode: null }), {
249
+ once: true,
250
+ });
251
+ });
499
252
  const details = await executeCwAction(
500
- "execute",
501
- DEV_ALLOWED,
502
- "cw_dev",
503
- undefined,
253
+ "status",
254
+ CW_ACTIONS,
255
+ TOOL_NAME,
504
256
  {},
505
- spawner,
506
- fakeCtx.cwd,
257
+ hangingSpawner,
258
+ CWD,
259
+ undefined,
260
+ 20,
507
261
  );
508
- expect(details.ok).toBe(false);
509
- if (details.ok) throw new Error("unreachable");
510
- expect(details.error).toContain("execute");
511
- expect(details.error).toContain("unitId");
512
- expect(spawner).not.toHaveBeenCalled();
262
+ expect(details).toMatchObject({ ok: false });
263
+ if (!details.ok) expect(details.error).toBe("cw 超时");
513
264
  });
514
265
 
515
- it("只读 action 缺 unitId → ok:true,args 不含 --unitId", async () => {
516
- const { spawner, calls } = fakeSpawner([{ stdout: "{}", stderr: "", exitCode: 0 }]);
266
+ it("SDK abort signal 与超时合并(进入即 aborted 也判失败)", async () => {
267
+ const controller = new AbortController();
268
+ controller.abort();
269
+ const { spawner } = fakeSpawner([{ stdout: "", stderr: "", exitCode: 0 }]);
517
270
  const details = await executeCwAction(
518
271
  "status",
519
- DEV_ALLOWED,
520
- "cw_dev",
521
- undefined,
272
+ CW_ACTIONS,
273
+ TOOL_NAME,
522
274
  {},
523
275
  spawner,
524
- fakeCtx.cwd,
276
+ CWD,
277
+ controller.signal,
525
278
  );
526
- expect(details.ok).toBe(true);
527
- expect(calls[0].args).not.toContain("--unitId");
528
- });
529
-
530
- it("只读 action 传 unitId 仍附加 --unitId(Optional 非禁止)", async () => {
531
- const { spawner, calls } = fakeSpawner([{ stdout: "{}", stderr: "", exitCode: 0 }]);
532
- await executeCwAction("list", PLANNING_ALLOWED, "cw_planning", "u1", {}, spawner, fakeCtx.cwd);
533
- expect(calls[0].args).toContain("--unitId");
534
- expect(calls[0].args).toContain("u1");
535
- });
536
-
537
- it("rejectMissingUnitId:写 action 缺 → 错误消息;只读 action 缺 → undefined", () => {
538
- expect(rejectMissingUnitId("execute", undefined)).toContain("unitId");
539
- expect(rejectMissingUnitId("design", undefined)).toContain("unitId");
540
- expect(rejectMissingUnitId("execute", "u1")).toBeUndefined();
541
- expect(rejectMissingUnitId("list", undefined)).toBeUndefined();
542
- expect(rejectMissingUnitId("status", undefined)).toBeUndefined();
543
- expect(rejectMissingUnitId("frontier", undefined)).toBeUndefined();
544
- });
545
-
546
- it("isReadonlyAction:READONLY_ACTIONS → true,写 action / 未知 action → false", () => {
547
- for (const ro of ["list", "tree", "status", "handoff", "frontier"]) {
548
- expect(isReadonlyAction(ro)).toBe(true);
549
- }
550
- expect(isReadonlyAction("execute")).toBe(false);
551
- expect(isReadonlyAction("design")).toBe(false);
552
- expect(isReadonlyAction("unknown-action")).toBe(false);
279
+ // 已 abort 的 signal 传入:spawner 可能仍 resolve,但合并机制不崩、结果可判定
280
+ expect(typeof details.ok).toBe("boolean");
553
281
  });
554
282
  });
555
283
 
556
- // ── 工厂注册 + buildTool 集成 ───────────────────────────────────
284
+ // ── 工具注册与 execute 闭包 ─────────────────────────────────────
557
285
 
558
- describe("工厂与工具注册", () => {
559
- it("cwToolExtension(pi) 注册 4 个工具(cw_planning/cw_wave/cw_dev/cw_review)", async () => {
560
- const { default: cwToolExtension } = await import("../index.ts");
561
- const registered: Array<{ name: string; actionEnum: string[] }> = [];
562
- // 仅 mock registerTool(ExtensionAPI 其余成员测试不消费);显式声明 tool 参数形状,
563
- // 让对 tool.name / parameters 的访问受类型检查;经 unknown 桥接到完整 ExtensionAPI。
286
+ describe("buildQueryTool / 扩展注册", () => {
287
+ it("注册单个 cw_query 工具(4 角色工具已退役)", async () => {
288
+ const registered: Array<{ name: string }> = [];
564
289
  const fakePi = {
565
- registerTool(tool: { name: string; parameters: { properties?: Record<string, unknown> } }): void {
566
- const enumVal = (tool.parameters.properties?.action as { enum?: string[] })?.enum;
567
- registered.push({ name: tool.name, actionEnum: enumVal ?? [] });
290
+ registerTool: (tool: { name: string }) => {
291
+ registered.push(tool);
568
292
  },
569
- } as unknown as ExtensionAPI;
570
- cwToolExtension(fakePi);
571
-
572
- const names = registered.map((r) => r.name);
573
- expect(names).toEqual(["cw_planning", "cw_wave", "cw_dev", "cw_review"]);
574
- // schema 的 action 枚举值与白名单数组逐项深相等(schema 即运行时第一道约束,
575
- // 与 executeCwAction 第二道 rejectDisallowedAction 同源)。
576
- const byName = Object.fromEntries(registered.map((r) => [r.name, r.actionEnum]));
577
- expect(byName.cw_planning).toEqual([...PLANNING_ALLOWED]);
578
- expect(byName.cw_wave).toEqual([...WAVE_ALLOWED]);
579
- expect(byName.cw_dev).toEqual([...DEV_ALLOWED]);
580
- expect(byName.cw_review).toEqual([...REVIEW_ALLOWED]);
293
+ };
294
+ const { default: cwToolExtension } = await import("../index.ts");
295
+ cwToolExtension(fakePi as unknown as ExtensionAPI);
296
+ expect(registered.map((t) => t.name)).toEqual([TOOL_NAME]);
297
+ expect(TOOL_NAME).toBe("cw_query");
581
298
  });
582
299
 
583
- it("buildTool execute 端到端:拒绝路径返回 ok:false(带工具名)", async () => {
584
- const tool = buildTool(REVIEW_ALLOWED, {
585
- name: "cw_review",
586
- label: "CW Review",
587
- description: "x",
588
- promptSnippet: "x",
589
- }, forbiddenSpawner());
590
-
591
- // execute 全签名:(_toolCallId, params, signal, onUpdate, ctx)
592
- // 故意传 review 工具不允许的 action "execute" 验证运行时拒绝;类型层须逃逸(Params 的 action 枚举不含 execute)。
593
- type Params = Parameters<(typeof tool)["execute"]>[1];
594
- const params = { action: "execute", unitId: "u1" } as unknown as Params;
595
- const result = await tool.execute("call-1", params, undefined, undefined, fakeCtx);
596
-
597
- const details = result.details as CwDetails;
598
- expect(details.ok).toBe(false);
599
- if (details.ok) throw new Error("unreachable");
600
- expect(details.error).toContain("execute");
601
- expect(details.error).toContain("cw_review");
300
+ it("execute 成功路径返回 stdout 文本 + details", async () => {
301
+ const { spawner } = fakeSpawner([{ stdout: "u1 closed", stderr: "", exitCode: 0 }]);
302
+ const tool = buildQueryTool(spawner);
303
+ const result = await tool.execute(
304
+ "call-1",
305
+ { action: "status" },
306
+ undefined,
307
+ undefined,
308
+ fakeCtx,
309
+ );
310
+ expect(result.content[0]).toMatchObject({ type: "text", text: "u1 closed" });
311
+ expect(result.details).toMatchObject({ ok: true, action: "status" });
602
312
  });
603
313
 
604
- it("buildTool execute 端到端:允许路径透传到 spawner", async () => {
605
- const { spawner, calls } = fakeSpawner([{ stdout: '{"ok":true}', stderr: "", exitCode: 0 }]);
606
- const tool = buildTool(REVIEW_ALLOWED, {
607
- name: "cw_review",
608
- label: "CW Review",
609
- description: "x",
610
- promptSnippet: "x",
611
- }, spawner);
612
-
613
- type Params = Parameters<(typeof tool)["execute"]>[1];
614
- const params: Params = { action: "design-review", unitId: "u9", input: '{"verdict":"pass"}' };
615
- const result = await tool.execute("call-2", params, undefined, undefined, fakeCtx);
616
-
617
- const details = result.details as CwDetails;
618
- expect(details.ok).toBe(true);
619
- expect(calls[0].args).toEqual(["design-review", "--unitId", "u9", "--input", "-"]);
620
- expect(calls[0].input).toBe('{"verdict":"pass"}');
314
+ it("execute 失败路径 content 带工具名与错误", async () => {
315
+ const { spawner } = fakeSpawner([{ stdout: "", stderr: "boom", exitCode: 1 }]);
316
+ const tool = buildQueryTool(spawner);
317
+ const result = await tool.execute(
318
+ "call-1",
319
+ { action: "frontier" },
320
+ undefined,
321
+ undefined,
322
+ fakeCtx,
323
+ );
324
+ expect(result.content[0]).toMatchObject({ type: "text" });
325
+ if (result.content[0]?.type === "text") {
326
+ expect(result.content[0].text).toContain("cw_query");
327
+ expect(result.content[0].text).toContain("失败");
328
+ }
329
+ expect(result.details).toMatchObject({ ok: false, action: "frontier" });
621
330
  });
622
331
 
623
- it("buildTool execute:abort signal → ok:false aborted", async () => {
624
- const tool = buildTool(REVIEW_ALLOWED, {
625
- name: "cw_review",
626
- label: "CW Review",
627
- description: "x",
628
- promptSnippet: "x",
629
- }, forbiddenSpawner());
630
-
332
+ it("进入即 aborted → 短路返回 aborted,不 spawn", async () => {
631
333
  const controller = new AbortController();
632
334
  controller.abort();
633
- type Params = Parameters<(typeof tool)["execute"]>[1];
634
- const params: Params = { action: "status", unitId: "u1" };
335
+ const tool = buildQueryTool(forbiddenSpawner());
635
336
  const result = await tool.execute(
636
- "call-3",
637
- params,
337
+ "call-1",
338
+ { action: "status" },
638
339
  controller.signal,
639
340
  undefined,
640
341
  fakeCtx,
641
342
  );
642
- expect((result.details as CwDetails).ok).toBe(false);
643
- });
644
-
645
- it("schema:unitId 是 Optional(不在 required),action 是 required(S-5)", () => {
646
- const tool = buildTool(DEV_ALLOWED, {
647
- name: "cw_dev",
648
- label: "CW Dev",
649
- description: "x",
650
- promptSnippet: "x",
651
- }, forbiddenSpawner());
652
- const required = (tool.parameters as { required?: string[] }).required ?? [];
653
- expect(required).toContain("action");
654
- expect(required).not.toContain("unitId");
655
- // unitId 属性仍存在(Optional 不是删除)
656
- expect(tool.parameters.properties?.unitId).toBeDefined();
343
+ expect(result.details).toMatchObject({ ok: false, error: "aborted by signal" });
657
344
  });
658
345
  });
659
346
 
660
- // ── 白名单表格逐字一致性 ────────────────────────────────────────
347
+ // ── defaultCwSpawner(子进程抽象,与命令面无关)─────────────────
661
348
 
662
- describe("白名单与方案表格逐字一致", () => {
663
- it("cw_planning = design/execute/replan/retrospect/closeout + status/handoff/list/tree/frontier", () => {
664
- expect([...PLANNING_ALLOWED]).toEqual([
665
- "design", "execute", "replan", "retrospect", "closeout",
666
- "status", "handoff", "list", "tree", "frontier",
667
- ]);
668
- });
669
-
670
- it("cw_wave = 同 planning 但无 execute(也无 test/design-review/exec-review)", () => {
671
- expect([...WAVE_ALLOWED]).toEqual([
672
- "design", "replan", "retrospect", "closeout",
673
- "status", "handoff", "list", "tree", "frontier",
674
- ]);
675
- for (const forbidden of ["execute", "test", "design-review", "exec-review"]) {
676
- expect(WAVE_ALLOWED).not.toContain(forbidden);
677
- }
678
- });
679
-
680
- it("cw_dev = execute/test + status/handoff", () => {
681
- expect([...DEV_ALLOWED]).toEqual(["execute", "test", "status", "handoff"]);
682
- });
683
-
684
- it("cw_review = design-review/exec-review + status", () => {
685
- expect([...REVIEW_ALLOWED]).toEqual(["design-review", "exec-review", "status"]);
686
- });
687
- });
688
-
689
- // ── cw 路径无硬编码 + 子进程生命周期(defaultCwSpawner)────────
690
-
691
- describe("cw 路径解析", () => {
692
- // 造一个满足 defaultCwSpawner 调用的假子进程(stdout/stderr 带 setEncoding,stdin write/end,可选 kill)。
693
- function makeFakeChild(): EventEmitter {
694
- const child = new EventEmitter();
695
- const stdio = (): EventEmitter => {
696
- const s = new EventEmitter();
697
- (s as unknown as { setEncoding: (_e: string) => void }).setEncoding = () => {};
698
- return s;
349
+ describe("defaultCwSpawner", () => {
350
+ it("spawn PATH 上的裸 cw,无硬编码路径;kill 信号透传", async () => {
351
+ const child = new EventEmitter() as unknown as cp.ChildProcess;
352
+ // EventEmitter 无 kill/stdin/stdout/stderr ——挂最小 stub 满足实现调用
353
+ const killCalls: string[] = [];
354
+ (child as unknown as { kill: (sig?: string) => void }).kill = (sig?: string) => {
355
+ killCalls.push(sig ?? "");
699
356
  };
700
- Object.assign(child, {
701
- stdout: stdio(),
702
- stderr: stdio(),
703
- stdin: { write() {}, end() {} },
704
- });
705
- return child;
706
- }
707
-
708
- afterEach(() => {
709
- spawnMock.mockReset();
710
- });
357
+ child.stdout = new EventEmitter() as unknown as NonNullable<cp.ChildProcess["stdout"]>;
358
+ child.stderr = new EventEmitter() as unknown as NonNullable<cp.ChildProcess["stderr"]>;
359
+ child.stdin = { write: () => true, end: () => {} } as unknown as NonNullable<
360
+ cp.ChildProcess["stdin"]
361
+ >;
362
+ (child.stdout as unknown as { setEncoding: (e: string) => void }).setEncoding = () => {};
363
+ (child.stderr as unknown as { setEncoding: (e: string) => void }).setEncoding = () => {};
711
364
 
712
- it("defaultCwSpawner spawn 裸名 'cw'(经 PATH 解析,无硬编码绝对路径)", async () => {
713
- const child = makeFakeChild();
714
- spawnMock.mockImplementation(() => child as unknown as cp.ChildProcess);
715
- queueMicrotask(() => child.emit("close", 0));
716
- await defaultCwSpawner(["status", "--unitId", "u1"], undefined, "/tmp");
717
- expect(spawnMock).toHaveBeenCalledTimes(1);
718
- // 第一参是命令名:裸名 "cw",不是任何绝对路径
719
- expect((spawnMock.mock.calls[0] as unknown[])[0]).toBe("cw");
720
- });
721
-
722
- it("abort signal 触发时 defaultCwSpawner kill 子进程(SIGTERM)", async () => {
723
- const child = makeFakeChild();
724
- // kill 模拟真实子进程收到信号后退出:调度 close 事件让 promise resolve
725
- const killed = vi.fn((_sig: string) => {
726
- queueMicrotask(() => child.emit("close", null));
727
- });
728
- (child as unknown as { kill: (s: string) => void }).kill = killed;
729
- spawnMock.mockImplementation(() => child as unknown as cp.ChildProcess);
365
+ spawnMock.mockReturnValueOnce(child);
730
366
 
731
367
  const controller = new AbortController();
732
- const pending = defaultCwSpawner(["status"], undefined, "/tmp", controller.signal);
368
+ const realCwd = process.cwd(); // defaultCwSpawner 有 existsSync(cwd) 前置检查,用真实存在目录
369
+ const promise = defaultCwSpawner(["status"], undefined, realCwd, controller.signal);
733
370
  controller.abort();
734
- await pending;
735
- expect(killed).toHaveBeenCalledWith("SIGTERM");
736
- });
371
+ child.emit("close", null);
372
+ const result = await promise;
737
373
 
738
- it("spawn error(cw 不在 PATH / ENOENT)→ exitCode:-1 + stderr 含 [spawn error]", async () => {
739
- // 模拟 cw 不在 PATH(用户首要失败模式):node 对失败的 spawn 触发 child 'error' 事件。
740
- // 若 defaultCwSpawner 的 error handler 被删,promise 永不 resolve(直到 5min 超时)→ 用例挂死暴露回归。
741
- const child = makeFakeChild();
742
- spawnMock.mockImplementation(() => child as unknown as cp.ChildProcess);
743
- const err = Object.assign(new Error("spawn cw ENOENT"), { code: "ENOENT" });
744
- queueMicrotask(() => child.emit("error", err));
745
-
746
- const result = await defaultCwSpawner(["status", "--unitId", "u1"], undefined, "/tmp");
747
-
748
- expect(result.exitCode).toBe(-1);
749
- expect(result.stderr).toContain("[spawn error]");
750
- expect(result.stderr).toContain("spawn cw ENOENT");
751
- expect(result.stdout).toBe("");
752
- });
753
-
754
- it("[worktree-reaper-fix] cwd 不存在 → 不 spawn,exitCode:-1 + 可操作错误(含 cwd 路径与恢复指引)", async () => {
755
- // worktree 被 orphan reaper 误删后,子进程 cwd 指向虚空。spawn 前检查必须拦截并返回
756
- // 含完整 cwd + 恢复指引的错误(否则 Node ENOENT 只报 command 名,误导诊断为"node 被卸载")。
757
- const result = await defaultCwSpawner(
758
- ["status", "--unitId", "u1"],
759
- undefined,
760
- "/nonexistent-cwd-for-reaper-test",
374
+ expect(spawnMock).toHaveBeenCalledWith(
375
+ "cw",
376
+ ["status"],
377
+ expect.objectContaining({ cwd: realCwd }),
761
378
  );
762
-
763
- expect(result.exitCode).toBe(-1);
764
- expect(result.stderr).toContain("/nonexistent-cwd-for-reaper-test");
765
- expect(result.stderr).toContain("worktrees.json");
766
- // 前置检查拦截:不进入 spawn
767
- expect(spawnMock).not.toHaveBeenCalled();
768
- });
769
-
770
- it("[worktree-reaper-fix] spawn error ENOENT → stderr 拼 cwd 路径(TOCTOU 兜底)", async () => {
771
- // existsSync 检查通过后目录被删(TOCTOU):error handler 必须兜底拼 cwd。
772
- const child = makeFakeChild();
773
- spawnMock.mockImplementation(() => child as unknown as cp.ChildProcess);
774
- const err = Object.assign(new Error("spawn cw ENOENT"), { code: "ENOENT" });
775
- queueMicrotask(() => child.emit("error", err));
776
-
777
- const result = await defaultCwSpawner(["status", "--unitId", "u1"], undefined, "/tmp");
778
-
779
- expect(result.exitCode).toBe(-1);
780
- expect(result.stderr).toContain("cwd: /tmp");
379
+ expect(killCalls).toContain("SIGTERM");
380
+ expect(result.exitCode).toBeNull();
781
381
  });
782
382
  });