| 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 previousTui = process.env.DEEPSEEK_TUI_FORCE_DOWNLOAD; |
| 34 | const previousLegacy = process.env.DEEPSEEK_FORCE_DOWNLOAD; |
| 35 | delete process.env.DEEPSEEK_TUI_FORCE_DOWNLOAD; |
| 36 | delete process.env.DEEPSEEK_FORCE_DOWNLOAD; |
| 37 | try { |
| 38 | return await callback(); |
| 39 | } finally { |
| 40 | if (previousTui === undefined) { |
| 41 | delete process.env.DEEPSEEK_TUI_FORCE_DOWNLOAD; |
| 42 | } else { |
| 43 | process.env.DEEPSEEK_TUI_FORCE_DOWNLOAD = previousTui; |
| 44 | } |
| 45 | if (previousLegacy === undefined) { |
| 46 | delete process.env.DEEPSEEK_FORCE_DOWNLOAD; |
| 47 | } else { |
| 48 | process.env.DEEPSEEK_FORCE_DOWNLOAD = previousLegacy; |
| 49 | } |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | test("install script checks Node support before loading helpers", () => { |
| 54 | const guardIndex = installScript.indexOf("assertSupportedNode();"); |
| 55 | const firstRequireIndex = installScript.indexOf("require("); |
| 56 | |
| 57 | assert.notEqual(guardIndex, -1); |
| 58 | assert.notEqual(firstRequireIndex, -1); |
| 59 | assert.ok(guardIndex < firstRequireIndex); |
| 60 | }); |
| 61 | |
| 62 | test("install script remains parseable before the Node support guard runs", () => { |
| 63 | assert.equal(installScript.includes("??"), false); |
| 64 | assert.equal(installScript.includes("?."), false); |
| 65 | }); |
| 66 | |
| 67 | test("native shortcut has its own platform asset and installed path", () => { |
| 68 | const paths = _internal.binaryPaths(); |
| 69 | assert.match(paths.codew.asset, /^codew-/); |
| 70 | assert.equal(path.basename(paths.codew.target), process.platform === "win32" ? "codew.exe" : "codew"); |
| 71 | assert.notEqual(paths.codew.target, paths.codewhale.target); |
| 72 | }); |
| 73 | |
| 74 | test("install failure hint explains release base override for blocked GitHub downloads", () => { |
| 75 | const previous = process.env.DEEPSEEK_TUI_RELEASE_BASE_URL; |
| 76 | delete process.env.DEEPSEEK_TUI_RELEASE_BASE_URL; |
| 77 | try { |
| 78 | const error = Object.assign( |
| 79 | new Error( |
| 80 | "fetch https://github.com/Hmbown/CodeWhale/releases/download/v0.8.19/codewhale-artifacts-sha256.txt failed after 5 attempts:\ngetaddrinfo ENOTFOUND github.com", |
| 81 | ), |
| 82 | { code: "ENOTFOUND" }, |
| 83 | ); |
| 84 | |
| 85 | const hint = installFailureHint(error); |
| 86 | |
| 87 | assert.match(hint, /DEEPSEEK_TUI_RELEASE_BASE_URL/); |
| 88 | assert.match(hint, /codewhale-artifacts-sha256\.txt/); |
| 89 | assert.match(hint, /platform binaries/); |
| 90 | assert.match(hint, /#npm-binary-download-times-out/); |
| 91 | } finally { |
| 92 | if (previous === undefined) { |
| 93 | delete process.env.DEEPSEEK_TUI_RELEASE_BASE_URL; |
| 94 | } else { |
| 95 | process.env.DEEPSEEK_TUI_RELEASE_BASE_URL = previous; |
| 96 | } |
| 97 | } |
| 98 | }); |
| 99 | |
| 100 | test("install failure hint checks configured release base when override is already set", () => { |
| 101 | const previous = process.env.DEEPSEEK_TUI_RELEASE_BASE_URL; |
| 102 | process.env.DEEPSEEK_TUI_RELEASE_BASE_URL = "https://mirror.example/deepseek/"; |
| 103 | try { |
| 104 | const error = Object.assign(new Error("download stalled"), { |
| 105 | code: "EDOWNLOADTIMEOUT", |
| 106 | }); |
| 107 | |
| 108 | const hint = installFailureHint(error); |
| 109 | |
| 110 | assert.match(hint, /is set to https:\/\/mirror\.example\/deepseek\//); |
| 111 | assert.match(hint, /codewhale-artifacts-sha256\.txt/); |
| 112 | assert.doesNotMatch(hint, /If GitHub is unavailable/); |
| 113 | } finally { |
| 114 | if (previous === undefined) { |
| 115 | delete process.env.DEEPSEEK_TUI_RELEASE_BASE_URL; |
| 116 | } else { |
| 117 | process.env.DEEPSEEK_TUI_RELEASE_BASE_URL = previous; |
| 118 | } |
| 119 | } |
| 120 | }); |
| 121 | |
| 122 | test("glibc preflight message is Codewhale-branded and actionable", () => { |
| 123 | const message = glibcInternal.glibcCompatibilityMessage([2, 39, 0], [2, 35, 0]); |
| 124 | |
| 125 | assert.match(message, /Prebuilt Codewhale Linux binaries require GLIBC_2\.39/); |
| 126 | assert.match(message, /this system has glibc 2\.35/); |
| 127 | assert.match(message, /cargo install codewhale-cli --locked/); |
| 128 | assert.match(message, /Linux x64 release asset is a static \(musl\) build/); |
| 129 | assert.match(message, /Linux arm64 asset is a GNU libc build/); |
| 130 | assert.match(message, /CODEWHALE_SKIP_GLIBC_CHECK=1/); |
| 131 | }); |
| 132 | |
| 133 | test("glibc preflight accepts canonical and legacy skip env vars", () => { |
| 134 | const previousCodewhale = process.env.CODEWHALE_SKIP_GLIBC_CHECK; |
| 135 | const previousTui = process.env.DEEPSEEK_TUI_SKIP_GLIBC_CHECK; |
| 136 | const previousLegacy = process.env.DEEPSEEK_SKIP_GLIBC_CHECK; |
| 137 | delete process.env.CODEWHALE_SKIP_GLIBC_CHECK; |
| 138 | delete process.env.DEEPSEEK_TUI_SKIP_GLIBC_CHECK; |
| 139 | delete process.env.DEEPSEEK_SKIP_GLIBC_CHECK; |
| 140 | try { |
| 141 | assert.equal(glibcInternal.skipGlibcCheck(), false); |
| 142 | process.env.CODEWHALE_SKIP_GLIBC_CHECK = "1"; |
| 143 | assert.equal(glibcInternal.skipGlibcCheck(), true); |
| 144 | delete process.env.CODEWHALE_SKIP_GLIBC_CHECK; |
| 145 | process.env.DEEPSEEK_TUI_SKIP_GLIBC_CHECK = "1"; |
| 146 | assert.equal(glibcInternal.skipGlibcCheck(), true); |
| 147 | } finally { |
| 148 | if (previousCodewhale === undefined) { |
| 149 | delete process.env.CODEWHALE_SKIP_GLIBC_CHECK; |
| 150 | } else { |
| 151 | process.env.CODEWHALE_SKIP_GLIBC_CHECK = previousCodewhale; |
| 152 | } |
| 153 | if (previousTui === undefined) { |
| 154 | delete process.env.DEEPSEEK_TUI_SKIP_GLIBC_CHECK; |
| 155 | } else { |
| 156 | process.env.DEEPSEEK_TUI_SKIP_GLIBC_CHECK = previousTui; |
| 157 | } |
| 158 | if (previousLegacy === undefined) { |
| 159 | delete process.env.DEEPSEEK_SKIP_GLIBC_CHECK; |
| 160 | } else { |
| 161 | process.env.DEEPSEEK_SKIP_GLIBC_CHECK = previousLegacy; |
| 162 | } |
| 163 | } |
| 164 | }); |
| 165 | |
| 166 | test("ensureBinary adopts a manually placed target binary after checksum validation", async (t) => { |
| 167 | const dir = await makeTempDir(t); |
| 168 | const target = path.join(dir, process.platform === "win32" ? "codewhale.exe" : "codewhale"); |
| 169 | const assetName = process.platform === "win32" ? "codewhale-windows-x64.exe" : "codewhale-linux-x64"; |
| 170 | const version = "0.8.25"; |
| 171 | const content = Buffer.from("manual codewhale binary"); |
| 172 | let checksumLoads = 0; |
| 173 | |
| 174 | await fs.promises.writeFile(target, content, { mode: 0o600 }); |
| 175 | await fs.promises.writeFile(`${target}.version`, "0.8.24", "utf8"); |
| 176 | |
| 177 | const result = await withoutForcedDownload(() => |
| 178 | _internal.ensureBinary(target, assetName, version, "Hmbown/CodeWhale", async () => { |
| 179 | checksumLoads += 1; |
| 180 | return new Map([[assetName, sha256(content)]]); |
| 181 | }), |
| 182 | ); |
| 183 | |
| 184 | assert.equal(result, target); |
| 185 | assert.equal(checksumLoads, 1); |
| 186 | assert.equal(await fs.promises.readFile(`${target}.version`, "utf8"), version); |
| 187 | if (process.platform !== "win32") { |
| 188 | assert.notEqual((await fs.promises.stat(target)).mode & 0o111, 0); |
| 189 | } |
| 190 | }); |
| 191 | |
| 192 | test("ensureBinary adopts an official release-named binary placed in downloads", async (t) => { |
| 193 | const dir = await makeTempDir(t); |
| 194 | const target = path.join(dir, process.platform === "win32" ? "codewhale.exe" : "codewhale"); |
| 195 | const assetName = process.platform === "win32" ? "codewhale-windows-x64.exe" : "codewhale-linux-x64"; |
| 196 | const assetPath = path.join(dir, assetName); |
| 197 | const version = "0.8.25"; |
| 198 | const content = Buffer.from("official release binary"); |
| 199 | |
| 200 | await fs.promises.writeFile(assetPath, content); |
| 201 | |
| 202 | const result = await withoutForcedDownload(() => |
| 203 | _internal.ensureBinary(target, assetName, version, "Hmbown/CodeWhale", async () => |
| 204 | new Map([[assetName, sha256(content)]]), |
| 205 | ), |
| 206 | ); |
| 207 | |
| 208 | assert.equal(result, target); |
| 209 | assert.equal(await exists(target), true); |
| 210 | assert.equal(await exists(assetPath), false); |
| 211 | assert.equal(await fs.promises.readFile(`${target}.version`, "utf8"), version); |
| 212 | }); |
| 213 | |
| 214 | test("manual binaries with mismatched checksums are not adopted", async (t) => { |
| 215 | const dir = await makeTempDir(t); |
| 216 | const target = path.join(dir, process.platform === "win32" ? "codewhale.exe" : "codewhale"); |
| 217 | const assetName = process.platform === "win32" ? "codewhale-windows-x64.exe" : "codewhale-linux-x64"; |
| 218 | const content = Buffer.from("wrong binary bytes"); |
| 219 | |
| 220 | await fs.promises.writeFile(target, content); |
| 221 | |
| 222 | const adopted = await _internal.adoptExistingBinaryIfValid( |
| 223 | target, |
| 224 | assetName, |
| 225 | "0.8.25", |
| 226 | async () => new Map([[assetName, sha256(Buffer.from("different bytes"))]]), |
| 227 | `${target}.version`, |
| 228 | ); |
| 229 | |
| 230 | assert.equal(adopted, false); |
| 231 | assert.equal(await exists(`${target}.version`), false); |
| 232 | }); |
| 233 | |
| 234 | test("resolvePackageVersion honors codewhaleBinaryVersion precedence (#3769)", () => { |
| 235 | const { resolvePackageVersion } = _internal; |
| 236 | |
| 237 | // codewhaleBinaryVersion wins over deepseekBinaryVersion and pkg.version. |
| 238 | assert.equal( |
| 239 | resolvePackageVersion( |
| 240 | { |
| 241 | codewhaleBinaryVersion: "1.2.3", |
| 242 | deepseekBinaryVersion: "0.0.1", |
| 243 | version: "9.9.9", |
| 244 | }, |
| 245 | {}, |
| 246 | ), |
| 247 | "1.2.3", |
| 248 | ); |
| 249 | |
| 250 | // Falls back to deepseekBinaryVersion, then pkg.version. |
| 251 | assert.equal( |
| 252 | resolvePackageVersion({ deepseekBinaryVersion: "0.0.1", version: "9.9.9" }, {}), |
| 253 | "0.0.1", |
| 254 | ); |
| 255 | assert.equal(resolvePackageVersion({ version: "9.9.9" }, {}), "9.9.9"); |
| 256 | |
| 257 | // Legacy env vars still take precedence over package fields, unchanged. |
| 258 | assert.equal( |
| 259 | resolvePackageVersion( |
| 260 | { codewhaleBinaryVersion: "1.2.3", version: "9.9.9" }, |
| 261 | { DEEPSEEK_TUI_VERSION: "7.7.7" }, |
| 262 | ), |
| 263 | "7.7.7", |
| 264 | ); |
| 265 | assert.equal( |
| 266 | resolvePackageVersion( |
| 267 | { codewhaleBinaryVersion: "1.2.3" }, |
| 268 | { DEEPSEEK_VERSION: "8.8.8" }, |
| 269 | ), |
| 270 | "8.8.8", |
| 271 | ); |
| 272 | }); |
| 273 | |
| 274 | test("httpRequest handles invalid URL parsing errors", async () => { |
| 275 | const { httpRequest } = _internal; |
| 276 | const invalidUrl = "not-a-valid-url"; |
| 277 | try { |
| 278 | await httpRequest(invalidUrl); |
| 279 | assert.fail("httpRequest should throw for an invalid URL"); |
| 280 | } catch (err) { |
| 281 | assert.equal(err.name, "NonRetryableError"); |
| 282 | assert.match(err.message, /Invalid URL: not-a-valid-url/); |
| 283 | } |
| 284 | }); |
| 285 |