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