返回 CodeWhale
public-installer-contract.test.ts
根目录 / web / lib / public-installer-contract.test.ts
1 import { createHash } from "node:crypto";
2 import { spawnSync } from "node:child_process";
3 import {
4 chmodSync,
5 existsSync,
6 mkdirSync,
7 mkdtempSync,
8 readFileSync,
9 realpathSync,
10 rmSync,
11 writeFileSync,
12 } from "node:fs";
13 import { tmpdir } from "node:os";
14 import path from "node:path";
15 import { fileURLToPath } from "node:url";
16 import { afterEach, describe, expect, it } from "vitest";
17
18 const installer = fileURLToPath(new URL("../public/install.sh", import.meta.url));
19 const fixtureRoots: string[] = [];
20
21 function executable(contents: string): Buffer {
22 return Buffer.from(`#!/bin/sh\nprintf '%s\\n' '${contents}'\n`, "utf8");
23 }
24
25 function sha256(contents: Buffer): string {
26 return createHash("sha256").update(contents).digest("hex");
27 }
28
29 function installFixture(
30 withLegacyTui: boolean,
31 options: {
32 os?: "Darwin" | "Linux";
33 arch?: "x86_64" | "aarch64";
34 version?: string;
35 glibc?: string;
36 expectedStatus?: number;
37 shell?: string;
38 installUnderHome?: boolean;
39 installDirName?: string;
40 homeFiles?: string[];
41 onPath?: "first" | "shadowed";
42 } = {},
43 ) {
44 const {
45 os = "Darwin",
46 arch = "x86_64",
47 version,
48 glibc = "2.39",
49 expectedStatus = 0,
50 shell = "/bin/zsh",
51 installUnderHome = false,
52 installDirName = "install",
53 homeFiles = [],
54 onPath,
55 } = options;
56 const root = mkdtempSync(path.join(tmpdir(), "codewhale-web-installer-"));
57 fixtureRoots.push(root);
58 const releaseDir = path.join(root, "release");
59 const home = path.join(root, "home");
60 const installDir = installUnderHome
61 ? path.join(home, ".local", "bin")
62 : path.join(root, installDirName);
63 const fakeBin = path.join(root, "fake-bin");
64 mkdirSync(releaseDir, { recursive: true });
65 mkdirSync(installDir, { recursive: true });
66 mkdirSync(fakeBin, { recursive: true });
67
68 const runtime = executable("codewhale 0.9.5 (fixture)");
69 const target = `${os === "Darwin" ? "macos" : "linux"}-${arch === "aarch64" ? "arm64" : "x64"}`;
70 const assets = [`codewhale-${target}`, `codew-${target}`];
71 for (const asset of assets) {
72 writeFileSync(path.join(releaseDir, asset), runtime);
73 }
74 writeFileSync(
75 path.join(releaseDir, "codewhale-artifacts-sha256.txt"),
76 assets.map((asset) => `${sha256(runtime)} ${asset}`).join("\n") + "\n",
77 );
78
79 const fakeUname = [
80 "#!/bin/sh",
81 'case "${1:-}" in',
82 ` -s) printf '%s\\n' ${os} ;;`,
83 ` -m) printf '%s\\n' ${arch} ;;`,
84 ` *) printf '%s\\n' ${os} ;;`,
85 "esac",
86 "",
87 ].join("\n");
88 const fakeCurl = [
89 "#!/bin/sh",
90 'out=""',
91 'url=""',
92 'while [ "$#" -gt 0 ]; do',
93 ' case "$1" in',
94 ' -o) shift; out="$1" ;;',
95 " -*) ;;",
96 ' *) url="$1" ;;',
97 " esac",
98 " shift",
99 "done",
100 'cp "$FAKE_RELEASE_DIR/${url##*/}" "$out"',
101 "",
102 ].join("\n");
103 const fakeGetconf = [
104 "#!/bin/sh",
105 `printf '%s\\n' 'glibc ${glibc}'`,
106 "",
107 ].join("\n");
108 for (const [name, contents] of [
109 ["uname", fakeUname],
110 ["curl", fakeCurl],
111 ["getconf", fakeGetconf],
112 ]) {
113 const destination = path.join(fakeBin, name);
114 writeFileSync(destination, contents);
115 chmodSync(destination, 0o755);
116 }
117
118 for (const file of homeFiles) {
119 mkdirSync(home, { recursive: true });
120 writeFileSync(path.join(home, file), "# existing profile\n");
121 }
122 // The installer resolves its directory with `cd -P`, so PATH must carry the
123 // real path (macOS tmpdir sits behind a /var -> /private/var symlink).
124 const pathPrefix: string[] = [];
125 if (onPath === "shadowed") {
126 const shadow = path.join(root, "shadow");
127 mkdirSync(shadow, { recursive: true });
128 for (const name of ["codewhale", "codew"]) {
129 writeFileSync(path.join(shadow, name), executable(`${name} other`));
130 chmodSync(path.join(shadow, name), 0o755);
131 }
132 pathPrefix.push(realpathSync(shadow));
133 }
134 if (onPath) pathPrefix.push(realpathSync(installDir));
135
136 const legacyPath = path.join(installDir, "codewhale-tui");
137 if (withLegacyTui) {
138 writeFileSync(legacyPath, executable("codewhale-tui 0.9.4 (legacy fixture)"));
139 chmodSync(legacyPath, 0o755);
140 }
141
142 const result = spawnSync("/bin/sh", [installer], {
143 encoding: "utf8",
144 env: {
145 ...process.env,
146 CODEWHALE_INSTALL_DIR: installDir,
147 CODEWHALE_RELEASE_BASE_URL: "https://fixtures.invalid/download",
148 ...(version ? { CODEWHALE_VERSION: version } : {}),
149 FAKE_RELEASE_DIR: releaseDir,
150 HOME: home,
151 SHELL: shell,
152 PATH: [...pathPrefix, fakeBin, process.env.PATH ?? "/usr/bin:/bin"].join(":"),
153 },
154 });
155 expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(expectedStatus);
156
157 return { installDir, legacyPath, result, runtime };
158 }
159
160 afterEach(() => {
161 for (const root of fixtureRoots.splice(0)) {
162 rmSync(root, { recursive: true, force: true });
163 }
164 });
165
166 describe.skipIf(process.platform === "win32")("public installer compatibility contract", () => {
167 it("preserves a different legacy TUI command and directs migration to a fresh directory", () => {
168 const { installDir, legacyPath, result } = installFixture(true, { expectedStatus: 1 });
169
170 expect(existsSync(path.join(installDir, "codewhale"))).toBe(false);
171 expect(existsSync(path.join(installDir, "codew"))).toBe(false);
172 expect(readFileSync(legacyPath)).toEqual(executable("codewhale-tui 0.9.4 (legacy fixture)"));
173 expect(result.stderr).toContain(legacyPath);
174 expect(result.stderr).toContain("mktemp -d");
175 expect(result.stderr).toContain("No existing file was changed");
176 });
177
178 it("does not create the retired TUI command for a clean v0.9.5 install", () => {
179 const { installDir, legacyPath, result, runtime } = installFixture(false);
180
181 expect(readFileSync(path.join(installDir, "codewhale"))).toEqual(runtime);
182 expect(readFileSync(path.join(installDir, "codew"))).toEqual(runtime);
183 expect(existsSync(legacyPath)).toBe(false);
184 expect(result.stdout).not.toContain("Refreshed legacy compatibility command:");
185 });
186
187 it.each([
188 ["/bin/zsh", "Linux", `echo 'export PATH="INSTALL:$PATH"' >> ~/.zshrc`, ". ~/.zshrc"],
189 ["/bin/bash", "Linux", `echo 'export PATH="INSTALL:$PATH"' >> ~/.bashrc`, ". ~/.bashrc"],
190 ["/bin/bash", "Darwin", `echo 'export PATH="INSTALL:$PATH"' >> ~/.bash_profile`, ". ~/.bash_profile"],
191 ["/usr/bin/fish", "Linux", `fish_add_path "INSTALL"`, "this fish shell"],
192 ["/bin/dash", "Linux", `echo 'export PATH="INSTALL:$PATH"' >> ~/.profile`, ". ~/.profile"],
193 ] as const)(
194 "prints the persistent PATH line for %s on %s without editing a profile",
195 (shell, os, persist, reload) => {
196 const { installDir, result } = installFixture(false, { shell, os });
197
198 // The fixture install dir is never on PATH, so the hint always prints.
199 expect(result.stdout).toContain("PATH selects");
200 expect(result.stdout).toContain(persist.replace("INSTALL", realpathSync(installDir)));
201 expect(result.stdout).toContain(reload);
202 expect(result.stdout).toContain("docs/INSTALL.md#put-it-on-your-path");
203 // HOME (a sibling of the install dir) is never created: no profile was written.
204 expect(existsSync(path.join(installDir, "..", "home"))).toBe(false);
205 },
206 );
207
208 it.skipIf(!existsSync("/bin/dash"))("the printed profile and reload commands work in dash", () => {
209 const { installDir, result } = installFixture(false, { shell: "/bin/dash", os: "Linux" });
210 const persist = result.stdout.split("\n").find((line) => line.trim().startsWith("echo 'export PATH="));
211 const reload = result.stdout.match(/Then run: (.+?) \(or open a new terminal\)/)?.[1];
212 expect(persist).toBeDefined();
213 expect(reload).toBeDefined();
214 const home = path.join(installDir, "..", "home");
215 mkdirSync(home, { recursive: true });
216 const applied = spawnSync("/bin/dash", ["-c", `set -e\n${persist}\n${reload}\ncommand -v codewhale`], {
217 encoding: "utf8",
218 env: { ...process.env, HOME: home },
219 });
220 expect(applied.status, applied.stderr).toBe(0);
221 expect(applied.stdout.trim()).toBe(path.join(realpathSync(installDir), "codewhale"));
222 });
223
224 it("writes the default directory as $HOME/.local/bin in the persistent line", () => {
225 const { result } = installFixture(false, { installUnderHome: true });
226
227 expect(result.stdout).toContain(`echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc`);
228 });
229
230 it.each([
231 [[".profile"], "~/.profile"],
232 [[".bash_login", ".profile"], "~/.bash_login"],
233 [[".bash_profile", ".profile"], "~/.bash_profile"],
234 ] as const)("keeps macOS login bash reading %j by naming %s", (homeFiles, profile) => {
235 const { result } = installFixture(false, { shell: "/bin/bash", os: "Darwin", homeFiles: [...homeFiles] });
236
237 expect(result.stdout).toContain(`:$PATH"' >> ${profile}\n`);
238 });
239
240 it.each(["/bin/tcsh", "/usr/bin/nu"])("prints no POSIX line for the unrecognised shell %s", (shell) => {
241 const { result } = installFixture(false, { shell });
242
243 expect(result.stdout).toContain(`this installer has no PATH line for ${path.basename(shell)}`);
244 expect(result.stdout).not.toContain("export PATH=");
245 expect(result.stdout).toContain("docs/INSTALL.md#put-it-on-your-path");
246 });
247
248 it.each(["it's", "$(id)", "a`b`", 'q"x', "back\\slash"])(
249 "prints no pasteable PATH line for a directory named %s",
250 (installDirName) => {
251 const { result } = installFixture(false, { installDirName });
252
253 expect(result.stdout).toContain("contains shell-special characters");
254 expect(result.stdout).not.toContain("export PATH=");
255 expect(result.stdout).not.toContain(">> ~/");
256 },
257 );
258
259 it("prints no PATH hint when PATH already selects this install", () => {
260 const { result } = installFixture(false, { onPath: "first" });
261
262 expect(result.stdout).not.toContain("PATH selects");
263 expect(result.stdout).not.toContain("first on PATH in future shells");
264 expect(result.stdout).not.toContain("export PATH=");
265 });
266
267 it("prints the PATH hint when an earlier PATH entry shadows this install", () => {
268 const { result } = installFixture(false, { onPath: "shadowed" });
269
270 expect(result.stdout).toMatch(/PATH selects .*shadow\/codewhale; this install is/);
271 expect(result.stdout).toContain("first on PATH in future shells");
272 });
273
274 it.each([undefined, "v0.9.6", "v0.9.11"])(
275 "does not apply a glibc floor to static Linux arm64 version %s",
276 (version) => {
277 const { installDir } = installFixture(false, {
278 os: "Linux",
279 arch: "aarch64",
280 version,
281 glibc: "2.17",
282 });
283
284 expect(existsSync(path.join(installDir, "codewhale"))).toBe(true);
285 expect(existsSync(path.join(installDir, "codew"))).toBe(true);
286 },
287 );
288
289 it("keeps the truthful glibc preflight for an explicitly requested older Linux arm64 release", () => {
290 const { result } = installFixture(false, {
291 os: "Linux",
292 arch: "aarch64",
293 version: "v0.9.5",
294 glibc: "2.35",
295 expectedStatus: 1,
296 });
297
298 expect(result.stderr).toContain(
299 "Codewhale v0.9.5 linux-arm64 assets require glibc 2.39 or newer",
300 );
301 expect(result.stderr).toContain(
302 "Current v0.9.6+ assets are static musl builds",
303 );
304 });
305 });
306
306 lines TYPESCRIPT