@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.
- package/README.md +10 -36
- package/package.json +5 -9
- package/skills/pi-cw/SKILL.md +33 -71
- package/src/__tests__/cw-tool.test.ts +239 -639
- package/src/cw-runner.ts +79 -292
- package/src/cw-spawn.ts +3 -3
- package/src/index.ts +56 -147
- package/agents/dev-agent.md +0 -92
- package/agents/merge-agent.md +0 -55
- package/agents/planning-agent.md +0 -133
- package/agents/review-agent.md +0 -95
- package/agents/wave-agent.md +0 -115
- package/skills/pi-cw/design-v4.md +0 -226
- package/src/__tests__/detect-repo-workspace.test.ts +0 -163
- package/src/__tests__/workspace-gate.test.ts +0 -261
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
45
|
+
return response;
|
|
69
46
|
});
|
|
70
47
|
return { spawner, calls };
|
|
71
48
|
}
|
|
72
49
|
|
|
73
|
-
/**
|
|
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("
|
|
53
|
+
throw new Error("forbidden: 拒绝路径不应 spawn cw");
|
|
78
54
|
});
|
|
79
55
|
return spawner;
|
|
80
56
|
}
|
|
81
57
|
|
|
82
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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("
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
"
|
|
207
|
-
|
|
208
|
-
{},
|
|
209
|
-
|
|
210
|
-
|
|
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("
|
|
220
|
-
|
|
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("
|
|
239
|
-
|
|
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("
|
|
255
|
-
|
|
256
|
-
|
|
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("
|
|
273
|
-
|
|
274
|
-
|
|
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("
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
"
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
"u1",
|
|
304
|
-
|
|
305
|
-
|
|
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("
|
|
321
|
-
expect(buildCwArgs("status",
|
|
145
|
+
it("status 无参数 → 裸命令", () => {
|
|
146
|
+
expect(buildCwArgs("status", {})).toEqual(["status"]);
|
|
322
147
|
});
|
|
323
148
|
|
|
324
|
-
it("
|
|
325
|
-
expect(buildCwArgs("
|
|
326
|
-
"
|
|
327
|
-
"--
|
|
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
|
-
"--
|
|
350
|
-
"abc123",
|
|
154
|
+
"--json",
|
|
351
155
|
]);
|
|
352
156
|
});
|
|
353
157
|
|
|
354
|
-
it("
|
|
355
|
-
expect(buildCwArgs("
|
|
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("
|
|
367
|
-
expect(buildCwArgs("
|
|
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("
|
|
379
|
-
expect(buildCwArgs("
|
|
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
|
|
383
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
{},
|
|
192
|
+
CW_ACTIONS,
|
|
193
|
+
TOOL_NAME,
|
|
194
|
+
{ json: true },
|
|
412
195
|
spawner,
|
|
413
|
-
|
|
196
|
+
CWD,
|
|
414
197
|
);
|
|
415
|
-
expect(
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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("
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
221
|
+
CWD,
|
|
470
222
|
);
|
|
471
|
-
expect(
|
|
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("
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
"
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
undefined,
|
|
253
|
+
"status",
|
|
254
|
+
CW_ACTIONS,
|
|
255
|
+
TOOL_NAME,
|
|
504
256
|
{},
|
|
505
|
-
|
|
506
|
-
|
|
257
|
+
hangingSpawner,
|
|
258
|
+
CWD,
|
|
259
|
+
undefined,
|
|
260
|
+
20,
|
|
507
261
|
);
|
|
508
|
-
expect(details
|
|
509
|
-
if (details.ok)
|
|
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("
|
|
516
|
-
const
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
undefined,
|
|
272
|
+
CW_ACTIONS,
|
|
273
|
+
TOOL_NAME,
|
|
522
274
|
{},
|
|
523
275
|
spawner,
|
|
524
|
-
|
|
276
|
+
CWD,
|
|
277
|
+
controller.signal,
|
|
525
278
|
);
|
|
526
|
-
|
|
527
|
-
expect(
|
|
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
|
-
// ──
|
|
284
|
+
// ── 工具注册与 execute 闭包 ─────────────────────────────────────
|
|
557
285
|
|
|
558
|
-
describe("
|
|
559
|
-
it("
|
|
560
|
-
const {
|
|
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
|
|
566
|
-
|
|
567
|
-
registered.push({ name: tool.name, actionEnum: enumVal ?? [] });
|
|
290
|
+
registerTool: (tool: { name: string }) => {
|
|
291
|
+
registered.push(tool);
|
|
568
292
|
},
|
|
569
|
-
}
|
|
570
|
-
cwToolExtension(
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
expect(
|
|
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("
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
type
|
|
594
|
-
|
|
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("
|
|
605
|
-
const { spawner
|
|
606
|
-
const tool =
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
expect(
|
|
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("
|
|
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
|
-
|
|
634
|
-
const params: Params = { action: "status", unitId: "u1" };
|
|
335
|
+
const tool = buildQueryTool(forbiddenSpawner());
|
|
635
336
|
const result = await tool.execute(
|
|
636
|
-
"call-
|
|
637
|
-
|
|
337
|
+
"call-1",
|
|
338
|
+
{ action: "status" },
|
|
638
339
|
controller.signal,
|
|
639
340
|
undefined,
|
|
640
341
|
fakeCtx,
|
|
641
342
|
);
|
|
642
|
-
expect(
|
|
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("
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
stdin
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
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
|
|
368
|
+
const realCwd = process.cwd(); // defaultCwSpawner 有 existsSync(cwd) 前置检查,用真实存在目录
|
|
369
|
+
const promise = defaultCwSpawner(["status"], undefined, realCwd, controller.signal);
|
|
733
370
|
controller.abort();
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
});
|
|
371
|
+
child.emit("close", null);
|
|
372
|
+
const result = await promise;
|
|
737
373
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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).
|
|
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
|
});
|