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