返回 CodeWhale
install.test.js
根目录 / npm / codewhale / test / install.test.js
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
455 lines JAVASCRIPT