| 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 |