返回 CodeWhale
run.test.js
根目录 / npm / codewhale / test / run.test.js
1 const assert = require("node:assert/strict");
2 const { spawn } = require("node:child_process");
3 const { EventEmitter } = require("node:events");
4 const fs = require("node:fs");
5 const os = require("node:os");
6 const path = require("node:path");
7 const test = require("node:test");
8
9 const { run, reportStartFailure, _internal } = require("../scripts/run");
10
11 // A stand-in for a spawned child that exits on the next tick.
12 function exitingChild(status, signal = null) {
13 const child = new EventEmitter();
14 child.kill = () => true;
15 process.nextTick(() => child.emit("exit", status, signal));
16 return child;
17 }
18
19 test("version fallback handles only version flags", () => {
20 assert.equal(_internal.isVersionFlag(["--version"]), true);
21 assert.equal(_internal.isVersionFlag(["-V"]), true);
22 assert.equal(_internal.isVersionFlag(["-v"]), false);
23 assert.equal(_internal.isVersionFlag(["--verbose"]), false);
24 });
25
26 test("version flags prefer the installed binary over package metadata", async () => {
27 let spawned = false;
28 const exits = [];
29
30 await run("codewhale", {
31 args: ["--version"],
32 getBinaryPath: async () => "/tmp/codewhale-test-binary",
33 spawn: (binary, args, options) => {
34 spawned = true;
35 assert.equal(binary, "/tmp/codewhale-test-binary");
36 assert.deepEqual(args, ["--version"]);
37 assert.deepEqual(options, { stdio: "inherit" });
38 return exitingChild(0);
39 },
40 exit: (status) => {
41 exits.push(status);
42 },
43 });
44
45 assert.equal(spawned, true);
46 assert.deepEqual(exits, [0]);
47 });
48
49 test("codew wrapper dispatches the native shortcut binary", async () => {
50 const resolvedNames = [];
51 const spawned = [];
52
53 await run("codew", {
54 args: ["--version"],
55 getBinaryPath: async (name) => {
56 resolvedNames.push(name);
57 return "/tmp/codew-test-binary";
58 },
59 spawn: (binary, args) => {
60 spawned.push({ binary, args });
61 return exitingChild(0);
62 },
63 exit: () => {},
64 });
65
66 assert.deepEqual(resolvedNames, ["codew"]);
67 assert.deepEqual(spawned, [
68 { binary: "/tmp/codew-test-binary", args: ["--version"] },
69 ]);
70 });
71
72 test("version flags fall back to package metadata when the binary is unavailable", async () => {
73 const originalLog = console.log;
74 const originalError = console.error;
75 const lines = [];
76 const errors = [];
77 const exits = [];
78 console.log = (line) => lines.push(line);
79 console.error = (...parts) => errors.push(parts.join(" "));
80 try {
81 await run("codewhale", {
82 args: ["--version"],
83 getBinaryPath: async () => {
84 throw Object.assign(new Error("getaddrinfo ENOTFOUND github.com"), {
85 code: "ENOTFOUND",
86 });
87 },
88 spawn: () => {
89 throw new Error("spawn should not run without a binary");
90 },
91 exit: (status) => {
92 exits.push(status);
93 },
94 });
95 } finally {
96 console.log = originalLog;
97 console.error = originalError;
98 }
99
100 assert.deepEqual(exits, [0]);
101 assert.match(lines.join("\n"), /codewhale \(npm wrapper\) v/);
102 // The fallback must not claim a binary version that is not installed.
103 assert.doesNotMatch(lines.join("\n"), /binary version: v/);
104 assert.match(lines.join("\n"), /binary: not installed \(expected v[^)]+\)/);
105 const stderr = errors.join("\n");
106 assert.match(stderr, /ENOTFOUND github\.com/);
107 assert.match(stderr, /codewhale install hint:/);
108 });
109
110 test("start failures print the install hint for download errors", () => {
111 const logged = [];
112 const log = (...parts) => logged.push(parts.join(" "));
113
114 reportStartFailure(
115 "codew",
116 Object.assign(new Error("download stalled"), { code: "EDOWNLOADTIMEOUT" }),
117 log,
118 );
119 const output = logged.join("\n");
120 assert.match(output, /^Failed to start codew: download stalled/);
121 assert.match(output, /codewhale install hint:/);
122 assert.match(
123 output,
124 /https:\/\/github\.com\/codewhale-hq\/CodeWhale\/blob\/main\/docs\/INSTALL\.md#npm-binary-download-times-out/,
125 );
126
127 logged.length = 0;
128 reportStartFailure("codewhale", new Error("permission denied"), log);
129 assert.deepEqual(logged, ["Failed to start codewhale: permission denied"]);
130 });
131
132 test("termination signals reach the native child and its status is kept", async () => {
133 const proc = new EventEmitter();
134 const child = new EventEmitter();
135 const killed = [];
136 child.kill = (signal) => {
137 killed.push(signal);
138 process.nextTick(() => child.emit("exit", 143, null));
139 return true;
140 };
141 const exits = [];
142 const running = run("codewhale", {
143 args: ["exec", "--auto", "task"],
144 getBinaryPath: async () => "/tmp/codewhale-test-binary",
145 spawn: () => child,
146 exit: (status) => exits.push(status),
147 process: proc,
148 });
149 await new Promise((resolve) => setImmediate(resolve));
150 // Terminal signals are outlived, never forwarded: the terminal already
151 // delivered them to the child through the process group.
152 for (const signal of ["SIGINT", "SIGHUP"]) {
153 assert.equal(proc.listenerCount(signal), 1, signal);
154 proc.emit(signal);
155 }
156 assert.deepEqual(killed, []);
157 const forwarded = _internal.FORWARDED_SIGNALS;
158 assert.deepEqual(forwarded, process.platform === "win32" ? [] : ["SIGTERM"]);
159 if (forwarded.length === 0) {
160 child.emit("exit", 0, null);
161 await running;
162 assert.deepEqual(exits, [0]);
163 return;
164 }
165 assert.equal(proc.listenerCount("SIGTERM"), 1);
166 proc.emit("SIGTERM");
167 await running;
168
169 assert.deepEqual(killed, ["SIGTERM"]);
170 assert.deepEqual(exits, [143]);
171 for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
172 assert.equal(proc.listenerCount(signal), 0, signal);
173 }
174 });
175
176 test("a child killed by a signal is reported as that signal", async () => {
177 const proc = new EventEmitter();
178 const raised = [];
179 proc.pid = 4242;
180 proc.kill = (pid, signal) => raised.push({ pid, signal });
181 const exits = [];
182 await run("codewhale", {
183 args: [],
184 getBinaryPath: async () => "/tmp/codewhale-test-binary",
185 spawn: () => exitingChild(null, "SIGTERM"),
186 exit: (status) => exits.push(status),
187 process: proc,
188 });
189 assert.deepEqual(raised, [{ pid: 4242, signal: "SIGTERM" }]);
190 assert.deepEqual(exits, [128 + os.constants.signals.SIGTERM]);
191 });
192
193 test(
194 "SIGTERM to the wrapper process does not leave the native child running",
195 { skip: process.platform === "win32" && "POSIX signals only", timeout: 20000 },
196 async () => {
197 const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "codewhale-run-signal-"));
198 const pidFile = path.join(dir, "child.pid");
199 const childScript =
200 `require("fs").writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));` +
201 "setInterval(() => {}, 1000);";
202 const wrapperScript =
203 `require(${JSON.stringify(path.join(__dirname, "..", "scripts", "run.js"))})` +
204 `.run("codewhale", { getBinaryPath: async () => process.execPath, ` +
205 `args: ["-e", ${JSON.stringify(childScript)}] });`;
206 const wrapper = spawn(process.execPath, ["-e", wrapperScript], { stdio: "ignore" });
207 let childPid = null;
208 const alive = (pid) => {
209 try {
210 process.kill(pid, 0);
211 return true;
212 } catch {
213 return false;
214 }
215 };
216 try {
217 const deadline = Date.now() + 10000;
218 while (childPid === null && Date.now() < deadline) {
219 try {
220 childPid = Number.parseInt(fs.readFileSync(pidFile, "utf8"), 10) || null;
221 } catch {
222 await new Promise((resolve) => setTimeout(resolve, 25));
223 }
224 }
225 assert.ok(childPid, "native child should have started");
226 const wrapperExit = new Promise((resolve) => wrapper.once("exit", resolve));
227 wrapper.kill("SIGTERM");
228 await wrapperExit;
229 const settle = Date.now() + 5000;
230 while (alive(childPid) && Date.now() < settle) {
231 await new Promise((resolve) => setTimeout(resolve, 25));
232 }
233 assert.equal(alive(childPid), false, "native child outlived the wrapper");
234 } finally {
235 if (childPid && alive(childPid)) process.kill(childPid, "SIGKILL");
236 if (wrapper.exitCode === null && wrapper.signalCode === null) wrapper.kill("SIGKILL");
237 await fs.promises.rm(dir, { recursive: true, force: true });
238 }
239 },
240 );
241
242 test(
243 "Ctrl-C to the terminal's process group reaches the native child once",
244 { skip: process.platform === "win32" && "POSIX process groups only", timeout: 20000 },
245 async () => {
246 const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "codewhale-run-sigint-"));
247 const pidFile = path.join(dir, "child.pid");
248 const logFile = path.join(dir, "signals.log");
249 // Count every SIGINT, then exit the way a Ctrl-C'd CLI does.
250 const childScript =
251 `const fs = require("fs");` +
252 `process.on("SIGINT", () => { fs.appendFileSync(${JSON.stringify(logFile)}, "INT\\n");` +
253 ` setTimeout(() => process.exit(130), 300); });` +
254 `fs.writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));` +
255 "setInterval(() => {}, 1000);";
256 const wrapperScript =
257 `require(${JSON.stringify(path.join(__dirname, "..", "scripts", "run.js"))})` +
258 `.run("codewhale", { getBinaryPath: async () => process.execPath, ` +
259 `args: ["-e", ${JSON.stringify(childScript)}] });`;
260 // `detached` makes the wrapper a process-group leader, standing in for the
261 // terminal's foreground job; the native child joins that group.
262 const wrapper = spawn(process.execPath, ["-e", wrapperScript], {
263 stdio: "ignore",
264 detached: true,
265 });
266 let childPid = null;
267 try {
268 const deadline = Date.now() + 10000;
269 while (childPid === null && Date.now() < deadline) {
270 try {
271 childPid = Number.parseInt(fs.readFileSync(pidFile, "utf8"), 10) || null;
272 } catch {
273 await new Promise((resolve) => setTimeout(resolve, 25));
274 }
275 }
276 assert.ok(childPid, "native child should have started");
277 const wrapperExit = new Promise((resolve) =>
278 wrapper.once("exit", (code, signal) => resolve({ code, signal })),
279 );
280 process.kill(-wrapper.pid, "SIGINT");
281 const ended = await wrapperExit;
282 const received = fs.readFileSync(logFile, "utf8").trim().split("\n");
283 assert.deepEqual(received, ["INT"], "the child must see one SIGINT, not a forwarded copy");
284 assert.deepEqual(ended, { code: 130, signal: null });
285 } finally {
286 try {
287 process.kill(-wrapper.pid, "SIGKILL");
288 } catch {
289 // already gone
290 }
291 await fs.promises.rm(dir, { recursive: true, force: true });
292 }
293 },
294 );
295
295 lines JAVASCRIPT