| 1 | const assert = require("node:assert/strict"); |
| 2 | const crypto = require("node:crypto"); |
| 3 | const fs = require("node:fs"); |
| 4 | const os = require("node:os"); |
| 5 | const path = require("node:path"); |
| 6 | const test = require("node:test"); |
| 7 | |
| 8 | const installScript = fs.readFileSync( |
| 9 | path.join(__dirname, "..", "scripts", "install.js"), |
| 10 | "utf8", |
| 11 | ); |
| 12 | const { installFailureHint, _internal } = require("../scripts/install"); |
| 13 | const { _internal: glibcInternal } = require("../scripts/preflight-glibc"); |
| 14 | |
| 15 | function sha256(content) { |
| 16 | return crypto.createHash("sha256").update(content).digest("hex"); |
| 17 | } |
| 18 | |
| 19 | async function makeTempDir(t) { |
| 20 | const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "codewhale-install-test-")); |
| 21 | t.after(() => fs.promises.rm(dir, { force: true, recursive: true })); |
| 22 | return dir; |
| 23 | } |
| 24 | |
| 25 | async function exists(file) { |
| 26 | return fs.promises.access(file).then( |
| 27 | () => true, |
| 28 | () => false, |
| 29 | ); |
| 30 | } |
| 31 | |
| 32 | async function withoutForcedDownload(callback) { |
| 33 | const previousCodewhale = process.env.CODEWHALE_FORCE_DOWNLOAD; |
| 34 | const previousTui = process.env.DEEPSEEK_TUI_FORCE_DOWNLOAD; |
| 35 | const previousLegacy = process.env.DEEPSEEK_FORCE_DOWNLOAD; |
| 36 | delete process.env.CODEWHALE_FORCE_DOWNLOAD; |
| 37 | delete process.env.DEEPSEEK_TUI_FORCE_DOWNLOAD; |
| 38 | delete process.env.DEEPSEEK_FORCE_DOWNLOAD; |
| 39 | try { |
| 40 | return await callback(); |
| 41 | } finally { |
| 42 | if (previousCodewhale === undefined) { |
| 43 | delete process.env.CODEWHALE_FORCE_DOWNLOAD; |
| 44 | } else { |
| 45 | process.env.CODEWHALE_FORCE_DOWNLOAD = previousCodewhale; |
| 46 | } |
| 47 | if (previousTui === undefined) { |
| 48 | delete process.env.DEEPSEEK_TUI_FORCE_DOWNLOAD; |
| 49 | } else { |
| 50 | process.env.DEEPSEEK_TUI_FORCE_DOWNLOAD = previousTui; |
| 51 | } |
| 52 | if (previousLegacy === undefined) { |
| 53 | delete process.env.DEEPSEEK_FORCE_DOWNLOAD; |
| 54 | } else { |
| 55 | process.env.DEEPSEEK_FORCE_DOWNLOAD = previousLegacy; |
| 56 | } |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | test("install script checks Node support before loading helpers", () => { |
| 61 | const guardIndex = installScript.indexOf("assertSupportedNode();"); |
| 62 | const firstRequireIndex = installScript.indexOf("require("); |
| 63 | |
| 64 | assert.notEqual(guardIndex, -1); |
| 65 | assert.notEqual(firstRequireIndex, -1); |
| 66 | assert.ok(guardIndex < firstRequireIndex); |
| 67 | }); |
| 68 | |
| 69 | test("install script remains parseable before the Node support guard runs", () => { |
| 70 | assert.equal(installScript.includes("??"), false); |
| 71 | assert.equal(installScript.includes("?."), false); |
| 72 | }); |
| 73 | |
| 74 | test("native shortcut has its own platform asset and installed path", () => { |
| 75 | const paths = _internal.binaryPaths(); |
| 76 | assert.match(paths.codew.asset, /^codew-/); |
| 77 | assert.equal(path.basename(paths.codew.target), process.platform === "win32" ? "codew.exe" : "codew"); |
| 78 | assert.notEqual(paths.codew.target, paths.codewhale.target); |
| 79 | }); |
| 80 | |
| 81 | test("install failure hint explains release base override for blocked GitHub downloads", () => { |
| 82 | const previous = process.env.DEEPSEEK_TUI_RELEASE_BASE_URL; |
| 83 | delete process.env.DEEPSEEK_TUI_RELEASE_BASE_URL; |
| 84 | try { |
| 85 | const error = Object.assign( |
| 86 | new Error( |
| 87 | "fetch https://github.com/codewhale-hq/CodeWhale/releases/download/v0.8.19/codewhale-artifacts-sha256.txt failed after 5 attempts:\ngetaddrinfo ENOTFOUND github.com", |
| 88 | ), |
| 89 | { code: "ENOTFOUND" }, |
| 90 | ); |
| 91 | |
| 92 | const hint = installFailureHint(error); |
| 93 | |
| 94 | assert.match(hint, /CODEWHALE_RELEASE_BASE_URL/); |
| 95 | assert.match(hint, /codewhale-artifacts-sha256\.txt/); |
| 96 | assert.match(hint, /platform binaries/); |
| 97 | assert.match(hint, /#npm-binary-download-times-out/); |
| 98 | } finally { |
| 99 | if (previous === undefined) { |
| 100 | delete process.env.DEEPSEEK_TUI_RELEASE_BASE_URL; |
| 101 | } else { |
| 102 | process.env.DEEPSEEK_TUI_RELEASE_BASE_URL = previous; |
| 103 | } |
| 104 | } |
| 105 | }); |
| 106 | |
| 107 | test("install failure hint checks configured release base when override is already set", () => { |
| 108 | const previous = process.env.DEEPSEEK_TUI_RELEASE_BASE_URL; |
| 109 | process.env.DEEPSEEK_TUI_RELEASE_BASE_URL = "https://mirror.example/deepseek/"; |
| 110 | try { |
| 111 | const error = Object.assign(new Error("download stalled"), { |
| 112 | code: "EDOWNLOADTIMEOUT", |
| 113 | }); |
| 114 | |
| 115 | const hint = installFailureHint(error); |
| 116 | |
| 117 | assert.match(hint, /resolves to https:\/\/mirror\.example\/deepseek\//); |
| 118 | assert.match(hint, /codewhale-artifacts-sha256\.txt/); |
| 119 | assert.doesNotMatch(hint, /If GitHub is unavailable/); |
| 120 | } finally { |
| 121 | if (previous === undefined) { |
| 122 | delete process.env.DEEPSEEK_TUI_RELEASE_BASE_URL; |
| 123 | } else { |
| 124 | process.env.DEEPSEEK_TUI_RELEASE_BASE_URL = previous; |
| 125 | } |
| 126 | } |
| 127 | }); |
| 128 | |
| 129 | test("glibc preflight message is Codewhale-branded and actionable", () => { |
| 130 | const message = glibcInternal.glibcCompatibilityMessage([2, 39, 0], [2, 35, 0]); |
| 131 | |
| 132 | assert.match(message, /This Codewhale binary requires GLIBC_2\.39/); |
| 133 | assert.match(message, /this system has glibc 2\.35/); |
| 134 | assert.match(message, /cargo install codewhale-cli --locked/); |
| 135 | assert.match(message, /ln -sf .*codewhale.*codew/); |
| 136 | assert.doesNotMatch(message, /cargo install codewhale-tui/); |
| 137 | assert.match(message, /Linux release assets \(x64 and arm64\) are static musl builds/); |
| 138 | assert.doesNotMatch(message, /GNU libc/); |
| 139 | assert.match(message, /CODEWHALE_SKIP_GLIBC_CHECK=1/); |
| 140 | }); |
| 141 | |
| 142 | test("glibc preflight accepts canonical and legacy skip env vars", () => { |
| 143 | const previousCodewhale = process.env.CODEWHALE_SKIP_GLIBC_CHECK; |
| 144 | const previousTui = process.env.DEEPSEEK_TUI_SKIP_GLIBC_CHECK; |
| 145 | const previousLegacy = process.env.DEEPSEEK_SKIP_GLIBC_CHECK; |
| 146 | delete process.env.CODEWHALE_SKIP_GLIBC_CHECK; |
| 147 | delete process.env.DEEPSEEK_TUI_SKIP_GLIBC_CHECK; |
| 148 | delete process.env.DEEPSEEK_SKIP_GLIBC_CHECK; |
| 149 | try { |
| 150 | assert.equal(glibcInternal.skipGlibcCheck(), false); |
| 151 | process.env.CODEWHALE_SKIP_GLIBC_CHECK = "1"; |
| 152 | assert.equal(glibcInternal.skipGlibcCheck(), true); |
| 153 | delete process.env.CODEWHALE_SKIP_GLIBC_CHECK; |
| 154 | process.env.DEEPSEEK_TUI_SKIP_GLIBC_CHECK = "1"; |
| 155 | assert.equal(glibcInternal.skipGlibcCheck(), true); |
| 156 | } finally { |
| 157 | if (previousCodewhale === undefined) { |
| 158 | delete process.env.CODEWHALE_SKIP_GLIBC_CHECK; |
| 159 | } else { |
| 160 | process.env.CODEWHALE_SKIP_GLIBC_CHECK = previousCodewhale; |
| 161 | } |
| 162 | if (previousTui === undefined) { |
| 163 | delete process.env.DEEPSEEK_TUI_SKIP_GLIBC_CHECK; |
| 164 | } else { |
| 165 | process.env.DEEPSEEK_TUI_SKIP_GLIBC_CHECK = previousTui; |
| 166 | } |
| 167 | if (previousLegacy === undefined) { |
| 168 | delete process.env.DEEPSEEK_SKIP_GLIBC_CHECK; |
| 169 | } else { |
| 170 | process.env.DEEPSEEK_SKIP_GLIBC_CHECK = previousLegacy; |
| 171 | } |
| 172 | } |
| 173 | }); |
| 174 | |
| 175 | test("ensureBinary adopts a manually placed target binary after checksum validation", async (t) => { |
| 176 | const dir = await makeTempDir(t); |
| 177 | const target = path.join(dir, process.platform === "win32" ? "codewhale.exe" : "codewhale"); |
| 178 | const assetName = process.platform === "win32" ? "codewhale-windows-x64.exe" : "codewhale-linux-x64"; |
| 179 | const version = "0.8.25"; |
| 180 | const content = Buffer.from("manual codewhale binary"); |
| 181 | let checksumLoads = 0; |
| 182 | |
| 183 | await fs.promises.writeFile(target, content, { mode: 0o600 }); |
| 184 | await fs.promises.writeFile(`${target}.version`, "0.8.24", "utf8"); |
| 185 | |
| 186 | const result = await withoutForcedDownload(() => |
| 187 | _internal.ensureBinary(target, assetName, version, "codewhale-hq/CodeWhale", async () => { |
| 188 | checksumLoads += 1; |
| 189 | return new Map([[assetName, sha256(content)]]); |
| 190 | }), |
| 191 | ); |
| 192 | |
| 193 | assert.equal(result, target); |
| 194 | assert.equal(checksumLoads, 1); |
| 195 | assert.equal(await fs.promises.readFile(`${target}.version`, "utf8"), version); |
| 196 | if (process.platform !== "win32") { |
| 197 | assert.notEqual((await fs.promises.stat(target)).mode & 0o111, 0); |
| 198 | } |
| 199 | }); |
| 200 | |
| 201 | test("ensureBinary adopts an official release-named binary placed in downloads", async (t) => { |
| 202 | const dir = await makeTempDir(t); |
| 203 | const target = path.join(dir, process.platform === "win32" ? "codewhale.exe" : "codewhale"); |
| 204 | const assetName = process.platform === "win32" ? "codewhale-windows-x64.exe" : "codewhale-linux-x64"; |
| 205 | const assetPath = path.join(dir, assetName); |
| 206 | const version = "0.8.25"; |
| 207 | const content = Buffer.from("official release binary"); |
| 208 | |
| 209 | await fs.promises.writeFile(assetPath, content); |
| 210 | |
| 211 | const result = await withoutForcedDownload(() => |
| 212 | _internal.ensureBinary(target, assetName, version, "codewhale-hq/CodeWhale", async () => |
| 213 | new Map([[assetName, sha256(content)]]), |
| 214 | ), |
| 215 | ); |
| 216 | |
| 217 | assert.equal(result, target); |
| 218 | assert.equal(await exists(target), true); |
| 219 | assert.equal(await exists(assetPath), false); |
| 220 | assert.equal(await fs.promises.readFile(`${target}.version`, "utf8"), version); |
| 221 | }); |
| 222 | |
| 223 | test("manual binaries with mismatched checksums are not adopted", async (t) => { |
| 224 | const dir = await makeTempDir(t); |
| 225 | const target = path.join(dir, process.platform === "win32" ? "codewhale.exe" : "codewhale"); |
| 226 | const assetName = process.platform === "win32" ? "codewhale-windows-x64.exe" : "codewhale-linux-x64"; |
| 227 | const content = Buffer.from("wrong binary bytes"); |
| 228 | |
| 229 | await fs.promises.writeFile(target, content); |
| 230 | |
| 231 | const adopted = await _internal.adoptExistingBinaryIfValid( |
| 232 | target, |
| 233 | assetName, |
| 234 | "0.8.25", |
| 235 | async () => new Map([[assetName, sha256(Buffer.from("different bytes"))]]), |
| 236 | `${target}.version`, |
| 237 | ); |
| 238 | |
| 239 | assert.equal(adopted, false); |
| 240 | assert.equal(await exists(`${target}.version`), false); |
| 241 | }); |
| 242 | |
| 243 | test("resolvePackageVersion honors codewhaleBinaryVersion precedence (#3769)", () => { |
| 244 | const { resolvePackageVersion } = _internal; |
| 245 | |
| 246 | // codewhaleBinaryVersion wins over deepseekBinaryVersion and pkg.version. |
| 247 | assert.equal( |
| 248 | resolvePackageVersion( |
| 249 | { |
| 250 | codewhaleBinaryVersion: "1.2.3", |
| 251 | deepseekBinaryVersion: "0.0.1", |
| 252 | version: "9.9.9", |
| 253 | }, |
| 254 | {}, |
| 255 | ), |
| 256 | "1.2.3", |
| 257 | ); |
| 258 | |
| 259 | // Falls back to deepseekBinaryVersion, then pkg.version. |
| 260 | assert.equal( |
| 261 | resolvePackageVersion({ deepseekBinaryVersion: "0.0.1", version: "9.9.9" }, {}), |
| 262 | "0.0.1", |
| 263 | ); |
| 264 | assert.equal(resolvePackageVersion({ version: "9.9.9" }, {}), "9.9.9"); |
| 265 | |
| 266 | assert.equal( |
| 267 | resolvePackageVersion( |
| 268 | { codewhaleBinaryVersion: "1.2.3" }, |
| 269 | { |
| 270 | CODEWHALE_VERSION: "6.6.6", |
| 271 | DEEPSEEK_TUI_VERSION: "7.7.7", |
| 272 | DEEPSEEK_VERSION: "8.8.8", |
| 273 | }, |
| 274 | ), |
| 275 | "6.6.6", |
| 276 | ); |
| 277 | |
| 278 | // Legacy env vars still take precedence over package fields, unchanged. |
| 279 | assert.equal( |
| 280 | resolvePackageVersion( |
| 281 | { codewhaleBinaryVersion: "1.2.3", version: "9.9.9" }, |
| 282 | { DEEPSEEK_TUI_VERSION: "7.7.7" }, |
| 283 | ), |
| 284 | "7.7.7", |
| 285 | ); |
| 286 | assert.equal( |
| 287 | resolvePackageVersion( |
| 288 | { codewhaleBinaryVersion: "1.2.3" }, |
| 289 | { DEEPSEEK_VERSION: "8.8.8" }, |
| 290 | ), |
| 291 | "8.8.8", |
| 292 | ); |
| 293 | }); |
| 294 | |
| 295 | test("canonical Codewhale installer variables outrank legacy aliases", () => { |
| 296 | assert.equal( |
| 297 | _internal.resolveRepo({ |
| 298 | CODEWHALE_GITHUB_REPO: "example/codewhale", |
| 299 | DEEPSEEK_TUI_GITHUB_REPO: "legacy/tui", |
| 300 | DEEPSEEK_GITHUB_REPO: "legacy/root", |
| 301 | }), |
| 302 | "example/codewhale", |
| 303 | ); |
| 304 | assert.equal( |
| 305 | _internal.isOptionalInstall([], { |
| 306 | CODEWHALE_OPTIONAL_INSTALL: "1", |
| 307 | }), |
| 308 | true, |
| 309 | ); |
| 310 | assert.equal( |
| 311 | _internal.isQuietInstall({ CODEWHALE_QUIET_INSTALL: "1" }), |
| 312 | true, |
| 313 | ); |
| 314 | assert.equal( |
| 315 | _internal.shouldForceDownload({ CODEWHALE_FORCE_DOWNLOAD: "1" }), |
| 316 | true, |
| 317 | ); |
| 318 | assert.equal( |
| 319 | _internal.shouldDisableInstall({ CODEWHALE_DISABLE_INSTALL: "1" }), |
| 320 | true, |
| 321 | ); |
| 322 | assert.equal( |
| 323 | _internal.downloadTimeoutMs("runtime", { |
| 324 | CODEWHALE_DOWNLOAD_TIMEOUT_MS: "1234", |
| 325 | DEEPSEEK_TUI_DOWNLOAD_TIMEOUT_MS: "9999", |
| 326 | }), |
| 327 | 1234, |
| 328 | ); |
| 329 | assert.equal( |
| 330 | _internal.downloadStallMs("runtime", { |
| 331 | CODEWHALE_DOWNLOAD_STALL_MS: "5678", |
| 332 | DEEPSEEK_TUI_DOWNLOAD_STALL_MS: "9999", |
| 333 | }), |
| 334 | 5678, |
| 335 | ); |
| 336 | }); |
| 337 | |
| 338 | test("httpRequest handles invalid URL parsing errors", async () => { |
| 339 | const { httpRequest } = _internal; |
| 340 | const invalidUrl = "not-a-valid-url"; |
| 341 | try { |
| 342 | await httpRequest(invalidUrl); |
| 343 | assert.fail("httpRequest should throw for an invalid URL"); |
| 344 | } catch (err) { |
| 345 | assert.equal(err.name, "NonRetryableError"); |
| 346 | assert.match(err.message, /Invalid URL: not-a-valid-url/); |
| 347 | } |
| 348 | }); |
| 349 | |
| 350 | // A server that sends headers and part of a body, then either |
| 351 | // goes silent or keeps trickling bytes, and never ends the response. |
| 352 | async function withStallingServer(t, { trickleMs = 0 } = {}) { |
| 353 | const http = require("node:http"); |
| 354 | const timers = []; |
| 355 | const server = http.createServer((req, res) => { |
| 356 | res.writeHead(200, { "Content-Type": "application/octet-stream" }); |
| 357 | res.write("partial body"); |
| 358 | if (trickleMs > 0) { |
| 359 | timers.push(setInterval(() => res.write("."), trickleMs)); |
| 360 | } |
| 361 | }); |
| 362 | await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); |
| 363 | t.after(() => { |
| 364 | for (const timer of timers) clearInterval(timer); |
| 365 | server.closeAllConnections(); |
| 366 | return new Promise((resolve) => server.close(resolve)); |
| 367 | }); |
| 368 | // Keep a developer's proxy variables from routing the loopback request. |
| 369 | const noProxy = process.env.NO_PROXY; |
| 370 | process.env.NO_PROXY = "*"; |
| 371 | t.after(() => { |
| 372 | if (noProxy === undefined) delete process.env.NO_PROXY; |
| 373 | else process.env.NO_PROXY = noProxy; |
| 374 | }); |
| 375 | return `http://127.0.0.1:${server.address().port}/asset`; |
| 376 | } |
| 377 | |
| 378 | // Consume a handed-off body the way `downloadText` and `streamToFile` do: |
| 379 | // resolve on `end`, reject on `error`. |
| 380 | function consumeBody(response) { |
| 381 | return new Promise((resolve, reject) => { |
| 382 | response.on("data", () => {}); |
| 383 | response.on("end", resolve); |
| 384 | response.on("error", reject); |
| 385 | response.resume(); |
| 386 | }); |
| 387 | } |
| 388 | |
| 389 | test("a body that stalls after the headers fails with the stall timeout", { timeout: 5000 }, async (t) => { |
| 390 | const url = await withStallingServer(t); |
| 391 | const { response } = await _internal.httpRequest(url, { |
| 392 | stallMs: 150, |
| 393 | totalTimeoutMs: 30000, |
| 394 | }); |
| 395 | await assert.rejects(consumeBody(response), (error) => { |
| 396 | assert.equal(error.code, "EDOWNLOADTIMEOUT"); |
| 397 | assert.match(error.message, /stalled/); |
| 398 | return true; |
| 399 | }); |
| 400 | }); |
| 401 | |
| 402 | test("a body that trickles past the total budget fails with the total timeout", { timeout: 5000 }, async (t) => { |
| 403 | const url = await withStallingServer(t, { trickleMs: 40 }); |
| 404 | const { response } = await _internal.httpRequest(url, { |
| 405 | stallMs: 1000, |
| 406 | totalTimeoutMs: 400, |
| 407 | }); |
| 408 | await assert.rejects(consumeBody(response), (error) => { |
| 409 | assert.equal(error.code, "EDOWNLOADTIMEOUT"); |
| 410 | assert.match(error.message, /total timeout/); |
| 411 | return true; |
| 412 | }); |
| 413 | }); |
| 414 | |
| 415 | test("a body paused by a slow consumer is not reported as a stall", { timeout: 5000 }, async (t) => { |
| 416 | const http = require("node:http"); |
| 417 | const server = http.createServer((req, res) => { |
| 418 | res.writeHead(200, { "Content-Type": "application/octet-stream" }); |
| 419 | res.write("first chunk "); |
| 420 | setTimeout(() => res.end("rest of the body"), 50); |
| 421 | }); |
| 422 | await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); |
| 423 | t.after(() => { |
| 424 | server.closeAllConnections(); |
| 425 | return new Promise((resolve) => server.close(resolve)); |
| 426 | }); |
| 427 | const noProxy = process.env.NO_PROXY; |
| 428 | process.env.NO_PROXY = "*"; |
| 429 | t.after(() => { |
| 430 | if (noProxy === undefined) delete process.env.NO_PROXY; |
| 431 | else process.env.NO_PROXY = noProxy; |
| 432 | }); |
| 433 | const { response } = await _internal.httpRequest( |
| 434 | `http://127.0.0.1:${server.address().port}/asset`, |
| 435 | { stallMs: 150, totalTimeoutMs: 30000 }, |
| 436 | ); |
| 437 | // Hold the stream paused for several stall budgets, as `pipe` does while a |
| 438 | // slow disk drains, then let it flow again. |
| 439 | let body = ""; |
| 440 | const done = new Promise((resolve, reject) => { |
| 441 | response.on("data", (chunk) => { |
| 442 | body += chunk; |
| 443 | }); |
| 444 | response.on("end", resolve); |
| 445 | response.on("error", reject); |
| 446 | }); |
| 447 | response.resume(); |
| 448 | response.once("data", () => { |
| 449 | response.pause(); |
| 450 | setTimeout(() => response.resume(), 600); |
| 451 | }); |
| 452 | await done; |
| 453 | assert.equal(body, "first chunk rest of the body"); |
| 454 | }); |
| 455 |