| 1 | // @effect-diagnostics nodeBuiltinImport:off - the executed suite runs the generated install script through a real POSIX shell. |
| 2 | import { describe, it } from "@effect/vitest"; |
| 3 | import { afterAll, expect } from "vite-plus/test"; |
| 4 | import * as NodeChildProcess from "node:child_process"; |
| 5 | import * as Duration from "effect/Duration"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Fiber from "effect/Fiber"; |
| 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Sink from "effect/Sink"; |
| 10 | import * as Stream from "effect/Stream"; |
| 11 | import * as TestClock from "effect/testing/TestClock"; |
| 12 | import { ChildProcessSpawner } from "effect/process"; |
| 13 | |
| 14 | import { |
| 15 | buildWslRuntimeInstallScript, |
| 16 | buildWslRuntimeInvalidateScript, |
| 17 | buildWslRuntimePruneScript, |
| 18 | buildWslRuntimeProbeScript, |
| 19 | DesktopWslDistroListError, |
| 20 | formatMissingToolsReason, |
| 21 | parseNodePath, |
| 22 | parseNodeVersion, |
| 23 | parseResolvedPath, |
| 24 | parseToolchainReport, |
| 25 | parseWslRuntimeRoot, |
| 26 | probeWslDistros, |
| 27 | } from "./DesktopWslEnvironment.ts"; |
| 28 | |
| 29 | const encoder = new TextEncoder(); |
| 30 | |
| 31 | // The install script only fails the way this file cares about when a real shell |
| 32 | // runs it, so find one that has the tools it needs: bash directly on Linux, and |
| 33 | // the WSL distro on a Windows dev box, where Git Bash ships no flock. Anywhere |
| 34 | // else the executed suite skips and the generated-text assertions stand alone. |
| 35 | const REQUIRED_SHELL_TOOLS = ["flock", "sha256sum", "tar", "mktemp"] as const; |
| 36 | |
| 37 | const posixShellRunner = (() => { |
| 38 | // Candidates rather than a platform switch: wsl.exe simply fails to spawn |
| 39 | // where it does not exist, which is the same answer as a shell missing flock. |
| 40 | const candidates = [ |
| 41 | { file: "bash", args: [] as ReadonlyArray<string> }, |
| 42 | { file: "wsl.exe", args: ["-e", "bash"] as ReadonlyArray<string> }, |
| 43 | ]; |
| 44 | const probe = [ |
| 45 | "[ -d /proc/1 ] || exit 1", |
| 46 | ...REQUIRED_SHELL_TOOLS.map((tool) => `command -v ${tool} >/dev/null || exit 1`), |
| 47 | ].join("\n"); |
| 48 | return ( |
| 49 | candidates.find((candidate) => { |
| 50 | const result = NodeChildProcess.spawnSync(candidate.file, [...candidate.args, "-c", probe], { |
| 51 | encoding: "utf8", |
| 52 | }); |
| 53 | return result.status === 0; |
| 54 | }) ?? null |
| 55 | ); |
| 56 | })(); |
| 57 | |
| 58 | const runShell = (script: string) => { |
| 59 | if (posixShellRunner === null) throw new Error("no POSIX shell runner available"); |
| 60 | // The install script arrives on stdin in production too, which is what lets |
| 61 | // its own /proc scan not match itself. |
| 62 | const result = NodeChildProcess.spawnSync( |
| 63 | posixShellRunner.file, |
| 64 | [...posixShellRunner.args, "-s"], |
| 65 | { input: script, encoding: "utf8" }, |
| 66 | ); |
| 67 | return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; |
| 68 | }; |
| 69 | |
| 70 | const sh = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; |
| 71 | |
| 72 | // Polls for up to five seconds until a background holder has taken effect. |
| 73 | const awaitHolder = (condition: string) => |
| 74 | `i=0; until ${condition}; do i=$((i + 1)); [ "$i" -lt 500 ] || exit 1; sleep 0.01; done`; |
| 75 | |
| 76 | // Holds a cache busy the way a running server does: the holder's argv[0] is the |
| 77 | // runtime entry, which the prune and install scripts look for in /proc cmdline. |
| 78 | // It reads a pipe the script keeps open on fd 7, so it exits with the script |
| 79 | // instead of outliving it on a timer. |
| 80 | const holdRuntimeBusy = (entry: string) => |
| 81 | [ |
| 82 | `exec 7> >(exec -a ${entry} cat >/dev/null 2>&1)`, |
| 83 | awaitHolder(`grep -qF -- ${entry} /proc/[0-9]*/cmdline 2>/dev/null`), |
| 84 | ].join("\n"); |
| 85 | |
| 86 | // Holds a cache's install lock the way a concurrent install does, and likewise |
| 87 | // releases it when the script exits. |
| 88 | const holdInstallLock = (lock: string) => |
| 89 | [ |
| 90 | `exec 6> >(exec 9> ${lock}; flock -x 9; cat >/dev/null 2>&1)`, |
| 91 | awaitHolder(`! flock -n ${lock} true`), |
| 92 | ].join("\n"); |
| 93 | |
| 94 | const readField = (stdout: string, field: string) => { |
| 95 | const line = stdout.split("\n").find((candidate) => candidate.startsWith(`${field}:`)); |
| 96 | if (line === undefined) throw new Error(`missing ${field} in fixture output: ${stdout}`); |
| 97 | return line.slice(field.length + 1).trim(); |
| 98 | }; |
| 99 | |
| 100 | // Stands in for the release's self-contained `t3` executable: the install |
| 101 | // script only asks it for `--version`. |
| 102 | const SERVER_ENTRY_SOURCE = '#!/bin/sh\necho "t3code wsl runtime test server 0.0.0"\n'; |
| 103 | |
| 104 | const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) => |
| 105 | ChildProcessSpawner.make(() => |
| 106 | Effect.succeed( |
| 107 | ChildProcessSpawner.makeHandle({ |
| 108 | pid: ChildProcessSpawner.ProcessId(1), |
| 109 | exitCode: |
| 110 | result.exitCode === undefined |
| 111 | ? Effect.never |
| 112 | : Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), |
| 113 | isRunning: Effect.succeed(result.exitCode === undefined), |
| 114 | kill: () => Effect.void, |
| 115 | unref: Effect.succeed(Effect.void), |
| 116 | stdin: Sink.drain, |
| 117 | stdout: Stream.make(encoder.encode(result.stdout ?? "")), |
| 118 | stderr: Stream.empty, |
| 119 | all: Stream.empty, |
| 120 | getInputFd: () => Sink.drain, |
| 121 | getOutputFd: () => Stream.empty, |
| 122 | }), |
| 123 | ), |
| 124 | ); |
| 125 | |
| 126 | describe("probeWslDistros", () => { |
| 127 | it.effect("preserves a successful empty distro list", () => |
| 128 | Effect.gen(function* () { |
| 129 | const distros = yield* probeWslDistros; |
| 130 | expect(distros).toEqual([]); |
| 131 | }).pipe( |
| 132 | Effect.provideService( |
| 133 | ChildProcessSpawner.ChildProcessSpawner, |
| 134 | makeDistroListSpawner({ stdout: "", exitCode: 0 }), |
| 135 | ), |
| 136 | ), |
| 137 | ); |
| 138 | |
| 139 | it.effect("fails when the distro-list command exits unsuccessfully", () => |
| 140 | Effect.gen(function* () { |
| 141 | const error = yield* probeWslDistros.pipe(Effect.flip); |
| 142 | expect(error).toBeInstanceOf(DesktopWslDistroListError); |
| 143 | expect(error.message).toContain("exited with code 1"); |
| 144 | }).pipe( |
| 145 | Effect.provideService( |
| 146 | ChildProcessSpawner.ChildProcessSpawner, |
| 147 | makeDistroListSpawner({ exitCode: 1 }), |
| 148 | ), |
| 149 | ), |
| 150 | ); |
| 151 | |
| 152 | it.effect("fails when the distro-list command times out", () => { |
| 153 | const layer = Layer.merge( |
| 154 | TestClock.layer(), |
| 155 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, makeDistroListSpawner({})), |
| 156 | ); |
| 157 | return Effect.gen(function* () { |
| 158 | const fiber = yield* probeWslDistros.pipe(Effect.flip, Effect.forkScoped); |
| 159 | yield* Effect.yieldNow; |
| 160 | yield* TestClock.adjust(Duration.seconds(8)); |
| 161 | const error = yield* Fiber.join(fiber); |
| 162 | expect(error).toBeInstanceOf(DesktopWslDistroListError); |
| 163 | expect(error.message).toContain("timed out"); |
| 164 | }).pipe(Effect.provide(layer)); |
| 165 | }); |
| 166 | }); |
| 167 | |
| 168 | describe("WSL runtime cache", () => { |
| 169 | it.each([ |
| 170 | [ |
| 171 | "install", |
| 172 | (id: string) => buildWslRuntimeInstallScript("/runtime.tar.gz", id, "b".repeat(64)), |
| 173 | ], |
| 174 | ["prune", buildWslRuntimePruneScript], |
| 175 | ["invalidate", buildWslRuntimeInvalidateScript], |
| 176 | ] as const)("sanitizes cache ids in the %s script", (_, buildScript) => { |
| 177 | const runtimeId = "1.2.3/x64; touch /tmp/nope"; |
| 178 | const script = buildScript(runtimeId); |
| 179 | expect(script).toContain("/1.2.3_x64__touch__tmp_nope"); |
| 180 | expect(script).not.toContain(runtimeId); |
| 181 | }); |
| 182 | |
| 183 | it("installs through a temporary directory and only reuses valid completed caches", () => { |
| 184 | const script = buildWslRuntimeInstallScript( |
| 185 | "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", |
| 186 | "1.2.3-x64", |
| 187 | "b".repeat(64), |
| 188 | ); |
| 189 | |
| 190 | expect(script).toContain('runtime_parent="$HOME/.t3/wsl-runtime"'); |
| 191 | expect(script).toContain(' [ -f "$ready_marker" ] &&'); |
| 192 | expect(script).toContain(' runtime_entry_runs "$runtime_root" &&'); |
| 193 | expect(script).toContain("if runtime_is_ready; then"); |
| 194 | expect(script).not.toContain("bin.mjs"); |
| 195 | expect(script).not.toContain("node-pty"); |
| 196 | expect(script).toContain("trap 'exit 1' HUP INT TERM"); |
| 197 | expect(script).toContain('exec 9> "$runtime_lock"'); |
| 198 | expect(script).toContain("flock -x 9"); |
| 199 | expect(script).not.toContain('rm -rf "$runtime_lock"'); |
| 200 | expect(script).toContain('mv -T "$runtime_root" "$runtime_stale"'); |
| 201 | expect(script).toContain('mktemp -d "$runtime_parent/.1.2.3-x64.tmp.XXXXXX"'); |
| 202 | // The release archive wraps everything in one `t3-<version>-linux-x64/` |
| 203 | // directory; stripping it puts the executable at `$runtime_root/t3`. |
| 204 | expect(script).toContain( |
| 205 | "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\" --strip-components=1", |
| 206 | ); |
| 207 | expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then'); |
| 208 | expect(script).toContain('mv -T "$runtime_tmp" "$runtime_root"'); |
| 209 | expect(script).not.toContain('rm -rf "$runtime_root"'); |
| 210 | |
| 211 | const lockAcquired = script.indexOf("flock -x 9"); |
| 212 | const readinessAfterLock = script.indexOf("if runtime_is_ready; then", lockAcquired + 1); |
| 213 | const existingRuntimeMoved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"'); |
| 214 | expect(lockAcquired).toBeGreaterThan(-1); |
| 215 | expect(readinessAfterLock).toBeGreaterThan(lockAcquired); |
| 216 | expect(existingRuntimeMoved).toBeGreaterThan(readinessAfterLock); |
| 217 | }); |
| 218 | |
| 219 | it("verifies the archive digest before extracting, and only on a cache miss", () => { |
| 220 | const script = buildWslRuntimeInstallScript( |
| 221 | "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", |
| 222 | "1.2.3-x64", |
| 223 | "b".repeat(64), |
| 224 | ); |
| 225 | |
| 226 | const expected = "b".repeat(64); |
| 227 | expect(script).toContain( |
| 228 | "archive_sha=$(sha256sum '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' | cut -d ' ' -f 1)", |
| 229 | ); |
| 230 | expect(script).toContain(`if [ "$archive_sha" != '${expected}' ]; then`); |
| 231 | |
| 232 | // A warm cache exits before the hash, so reuse never pays for it, and the |
| 233 | // mismatch check runs before anything mutates the cache. |
| 234 | const readyShortCircuit = script.indexOf("if runtime_is_ready; then"); |
| 235 | const digestChecked = script.indexOf("archive_sha=$(sha256sum"); |
| 236 | const existingRuntimeMoved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"'); |
| 237 | const extracted = script.indexOf("tar -xzf"); |
| 238 | expect(digestChecked).toBeGreaterThan(readyShortCircuit); |
| 239 | expect(existingRuntimeMoved).toBeGreaterThan(digestChecked); |
| 240 | expect(extracted).toBeGreaterThan(digestChecked); |
| 241 | }); |
| 242 | |
| 243 | // Invalidation revokes the ready marker without stopping the backend that |
| 244 | // failed the probe, so the next install can find an unready tree that a live |
| 245 | // process is still running out of. Deleting it there unlinks node_modules |
| 246 | // under that process; the pruner already refuses to touch in-use caches, and |
| 247 | // the install path has to refuse too. |
| 248 | it("moves an in-use runtime aside instead of deleting it under a live backend", () => { |
| 249 | const script = buildWslRuntimeInstallScript( |
| 250 | "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", |
| 251 | "sha256-" + "c".repeat(64), |
| 252 | "b".repeat(64), |
| 253 | ); |
| 254 | |
| 255 | expect(script).toContain('grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null'); |
| 256 | // No /proc means no way to tell, and guessing wrong costs a backend its |
| 257 | // runtime, so an unknowable answer has to count as in use. |
| 258 | expect(script).toContain(" [ -d /proc/1 ] || return 0"); |
| 259 | expect(script).toContain(' if runtime_in_use "$runtime_root"; then'); |
| 260 | |
| 261 | // A process's cmdline keeps the pre-rename path, so the question is only |
| 262 | // answerable before the move. |
| 263 | const inUseChecked = script.indexOf('if runtime_in_use "$runtime_root"; then'); |
| 264 | const moved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"'); |
| 265 | expect(inUseChecked).toBeGreaterThan(-1); |
| 266 | expect(inUseChecked).toBeLessThan(moved); |
| 267 | |
| 268 | // In use: keep the tree and restart the sweep's clock, because renaming |
| 269 | // preserves the directory's mtime and a long-installed tree would otherwise |
| 270 | // already be past the age gate. Idle: delete it now, as before. |
| 271 | const kept = script.indexOf('touch "$runtime_stale"'); |
| 272 | const deleted = script.indexOf('rm -rf "$runtime_stale"'); |
| 273 | expect(kept).toBeGreaterThan(moved); |
| 274 | expect(deleted).toBeGreaterThan(kept); |
| 275 | }); |
| 276 | |
| 277 | it("treats a runtime whose executable no longer runs as a cache miss", () => { |
| 278 | const script = buildWslRuntimeInstallScript( |
| 279 | "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", |
| 280 | "1.2.3-x64", |
| 281 | "b".repeat(64), |
| 282 | ); |
| 283 | |
| 284 | // The same proof the SSH runner and CLI installers use: executable, and |
| 285 | // `--version` exits 0. That is what decides arch and loadability, so no |
| 286 | // separate native probe is needed. |
| 287 | expect(script).toContain(' [ -x "$1/t3" ] && "$1/t3" --version >/dev/null 2>&1'); |
| 288 | |
| 289 | // Readiness gates the short-circuit, so a cache whose executable broke |
| 290 | // reinstalls from the archive instead of being reused forever. |
| 291 | const entryCheckDefined = script.indexOf("runtime_entry_runs() {"); |
| 292 | const readinessDefined = script.indexOf("runtime_is_ready() {"); |
| 293 | const readyShortCircuit = script.indexOf("if runtime_is_ready; then"); |
| 294 | expect(entryCheckDefined).toBeGreaterThan(-1); |
| 295 | expect(entryCheckDefined).toBeLessThan(readinessDefined); |
| 296 | expect(readinessDefined).toBeLessThan(readyShortCircuit); |
| 297 | }); |
| 298 | |
| 299 | // A swapped or half-written `t3` can still exist and even still answer |
| 300 | // `--version`, and launch then runs something this install never verified. |
| 301 | // The digest the install records is what turns that into a miss. |
| 302 | it("re-hashes the executable against the digest the install recorded", () => { |
| 303 | const script = buildWslRuntimeInstallScript( |
| 304 | "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", |
| 305 | "1.2.3-x64", |
| 306 | "b".repeat(64), |
| 307 | ); |
| 308 | |
| 309 | expect(script).toContain(` sha256sum "$1/t3" 2>/dev/null | cut -d ' ' -f 1`); |
| 310 | expect(script).toContain( |
| 311 | ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]', |
| 312 | ); |
| 313 | // A runtime installed before the marker carried a digest reads as empty, |
| 314 | // which has to be a miss rather than a pass. |
| 315 | expect(script).toContain(' [ -n "$recorded_entry_digest" ] &&'); |
| 316 | expect(script).toContain( |
| 317 | `printf '%s\\n' "$installed_entry_digest" > "$runtime_tmp/.t3code-wsl-runtime-ready"`, |
| 318 | ); |
| 319 | |
| 320 | // The digest is recorded after extraction and before promotion. |
| 321 | const extracted = script.indexOf("tar -xzf"); |
| 322 | const digestRecorded = script.indexOf( |
| 323 | 'installed_entry_digest=$(runtime_server_entry_digest "$runtime_tmp")', |
| 324 | ); |
| 325 | const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"'); |
| 326 | const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"'); |
| 327 | expect(digestRecorded).toBeGreaterThan(extracted); |
| 328 | expect(markerWritten).toBeGreaterThan(digestRecorded); |
| 329 | expect(promoted).toBeGreaterThan(markerWritten); |
| 330 | }); |
| 331 | |
| 332 | it("refuses to mark an archive whose executable does not run as ready", () => { |
| 333 | const script = buildWslRuntimeInstallScript( |
| 334 | "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz", |
| 335 | "1.2.3-x64", |
| 336 | "b".repeat(64), |
| 337 | ); |
| 338 | |
| 339 | expect(script).toContain('if ! runtime_entry_runs "$runtime_tmp"; then'); |
| 340 | |
| 341 | // The extracted tree is rejected before the ready marker is written, so a |
| 342 | // defective archive falls back to the mounted tree instead of caching. |
| 343 | const payloadValidated = script.indexOf('runtime_entry_runs "$runtime_tmp"'); |
| 344 | const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"'); |
| 345 | const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"'); |
| 346 | expect(payloadValidated).toBeGreaterThan(-1); |
| 347 | expect(markerWritten).toBeGreaterThan(payloadValidated); |
| 348 | expect(promoted).toBeGreaterThan(payloadValidated); |
| 349 | }); |
| 350 | |
| 351 | it("parses only absolute Linux runtime paths", () => { |
| 352 | expect(parseWslRuntimeRoot("runtimeRoot:/home/josh/.t3/wsl-runtime/1.2.3-x64\n")).toBe( |
| 353 | "/home/josh/.t3/wsl-runtime/1.2.3-x64", |
| 354 | ); |
| 355 | expect(parseWslRuntimeRoot("runtimeRoot:relative/path\n")).toBeNull(); |
| 356 | expect(parseWslRuntimeRoot("noise\n")).toBeNull(); |
| 357 | }); |
| 358 | |
| 359 | it("prunes completed runtimes except the current and newest previous cache", () => { |
| 360 | const script = buildWslRuntimePruneScript("1.2.3/x64"); |
| 361 | |
| 362 | expect(script).toContain('current_runtime="$runtime_parent/1.2.3_x64"'); |
| 363 | expect(script).toContain('[ "$candidate" -nt "$previous_runtime" ]'); |
| 364 | expect(script).toContain('[ "$candidate" != "$current_runtime" ] || continue'); |
| 365 | expect(script).toContain('[ "$candidate" != "$previous_runtime" ] || continue'); |
| 366 | expect(script).toContain('[ -f "$candidate/.t3code-wsl-runtime-ready" ] || continue'); |
| 367 | expect(script).toContain('rm -rf -- "$candidate"'); |
| 368 | }); |
| 369 | |
| 370 | it("never deletes a runtime another backend is running from", () => { |
| 371 | const script = buildWslRuntimePruneScript("1.2.3/x64"); |
| 372 | |
| 373 | // The running backend's argv holds `<runtime>/t3`, so |
| 374 | // the process itself is the lease and exiting releases it. Nothing has to be |
| 375 | // registered up front, which is what makes this cover backends already |
| 376 | // running from an older version that knows nothing about pruning. |
| 377 | expect(script).toContain(' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null'); |
| 378 | expect(script).toContain(' ! runtime_in_use "$candidate" || continue'); |
| 379 | |
| 380 | // Without visible processes the retention rules cannot tell a live cache |
| 381 | // from an abandoned one, so the sweep is skipped rather than guessed at. |
| 382 | expect(script).toContain("[ -d /proc/1 ] || exit 0"); |
| 383 | |
| 384 | // The guard has to gate the delete, not just exist. |
| 385 | const inUseChecked = script.indexOf('! runtime_in_use "$candidate"'); |
| 386 | const removed = script.indexOf('rm -rf -- "$candidate"'); |
| 387 | expect(inUseChecked).toBeGreaterThan(-1); |
| 388 | expect(removed).toBeGreaterThan(inUseChecked); |
| 389 | }); |
| 390 | |
| 391 | it("sweeps orphaned install scratch directories the ready-marker loops cannot see", () => { |
| 392 | const script = buildWslRuntimePruneScript("1.2.3/x64"); |
| 393 | |
| 394 | // Dot-prefixed, so `"$runtime_parent"/*` never matches them, and they carry |
| 395 | // no ready marker either; without this pass a killed install leaks forever. |
| 396 | expect(script).toContain( |
| 397 | 'for scratch in "$runtime_parent"/.*.tmp.* "$runtime_parent"/.*.stale.*; do', |
| 398 | ); |
| 399 | // Age guard: a scratch directory younger than this belongs to a live install. |
| 400 | expect(script).toContain('find "$scratch" -maxdepth 0 -mmin +120'); |
| 401 | }); |
| 402 | |
| 403 | it("invalidates a cache by dropping its ready marker, not the tree", () => { |
| 404 | const script = buildWslRuntimeInvalidateScript("1.2.3/x64"); |
| 405 | |
| 406 | // Readiness is a presence check, so a tree whose pty.node is present but |
| 407 | // unloadable stays ready forever unless the probe can revoke the marker. |
| 408 | expect(script).toContain('rm -f "$HOME/.t3/wsl-runtime/1.2.3_x64/.t3code-wsl-runtime-ready"'); |
| 409 | // Deleting the tree here would pull it out from under any backend still |
| 410 | // running from it; the next install moves an unready root aside instead. |
| 411 | expect(script).not.toContain("rm -rf"); |
| 412 | }); |
| 413 | }); |
| 414 | |
| 415 | // Reading the generated script proves what it says, not what it does. A cache |
| 416 | // whose entry was truncated satisfied every assertion above and still got |
| 417 | // reused, so these run the real script against a real archive in a throwaway |
| 418 | // HOME and check the outcome. |
| 419 | describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed)", () => { |
| 420 | const fixtures: Array<string> = []; |
| 421 | |
| 422 | afterAll(() => { |
| 423 | for (const work of fixtures) runShell(`set -eu\nrm -rf ${sh(work)}`); |
| 424 | fixtures.length = 0; |
| 425 | }); |
| 426 | |
| 427 | const createFixture = () => { |
| 428 | const result = runShell( |
| 429 | [ |
| 430 | "set -eu", |
| 431 | "work=$(mktemp -d)", |
| 432 | // Mirrors the release archive: one top-level versioned directory that |
| 433 | // holds the executable and its native addons. |
| 434 | 'stage="$work/stage/t3-0.0.0-linux-x64"', |
| 435 | 'mkdir -p "$stage/node_modules/node-pty/build/Release" "$work/home"', |
| 436 | `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/t3"`, |
| 437 | 'chmod +x "$stage/t3"', |
| 438 | `printf '%s' 'pty-native-payload' > "$stage/node_modules/node-pty/build/Release/pty.node"`, |
| 439 | `tar -czf "$work/wsl-runtime.tar.gz" -C "$work/stage" t3-0.0.0-linux-x64`, |
| 440 | `printf 'work:%s\\n' "$work"`, |
| 441 | `printf 'archiveSha:%s\\n' "$(sha256sum "$work/wsl-runtime.tar.gz" | cut -d ' ' -f 1)"`, |
| 442 | ].join("\n"), |
| 443 | ); |
| 444 | expect(result.status, result.stderr).toBe(0); |
| 445 | |
| 446 | const work = readField(result.stdout, "work"); |
| 447 | fixtures.push(work); |
| 448 | const archivePath = `${work}/wsl-runtime.tar.gz`; |
| 449 | const archiveSha = readField(result.stdout, "archiveSha"); |
| 450 | const runtimeId = `sha256-${archiveSha}`; |
| 451 | // The script reads $HOME, and WSL does not inherit the parent process's |
| 452 | // environment, so the home override rides in the script itself. |
| 453 | const installScript = (archive = archivePath, sha = archiveSha) => |
| 454 | [ |
| 455 | `HOME=${sh(`${work}/home`)}`, |
| 456 | "export HOME", |
| 457 | buildWslRuntimeInstallScript(archive, runtimeId, sha), |
| 458 | ].join("\n"); |
| 459 | return { |
| 460 | work, |
| 461 | archivePath, |
| 462 | archiveSha, |
| 463 | runtimeId, |
| 464 | runtimeParent: `${work}/home/.t3/wsl-runtime`, |
| 465 | runtimeRoot: `${work}/home/.t3/wsl-runtime/${runtimeId}`, |
| 466 | serverEntry: `${work}/home/.t3/wsl-runtime/${runtimeId}/t3`, |
| 467 | installScript, |
| 468 | install: (archive?: string, sha?: string) => runShell(installScript(archive, sha)), |
| 469 | }; |
| 470 | }; |
| 471 | |
| 472 | const probeFixture = (fixture: ReturnType<typeof createFixture>) => |
| 473 | runShell( |
| 474 | [ |
| 475 | `export HOME=${sh(`${fixture.work}/home`)}`, |
| 476 | 'export NVM_DIR="$HOME/.nvm" FNM_DIR="$HOME/.fnm" VOLTA_HOME="$HOME/.volta"', |
| 477 | // Isolate login profiles and hide the host's Node/version managers. |
| 478 | // The resolver must discover the fixture's installation itself. |
| 479 | "bash() { (", |
| 480 | " command() {", |
| 481 | ' case "$*" in', |
| 482 | ' "-v node"|"-v mise"|"-v fnm"|"-v nodenv") return 1 ;;', |
| 483 | ' *) builtin command "$@" ;;', |
| 484 | " esac", |
| 485 | " }", |
| 486 | ' eval "$2"', |
| 487 | "); }", |
| 488 | buildWslRuntimeProbeScript(fixture.runtimeRoot), |
| 489 | ].join("\n"), |
| 490 | ); |
| 491 | |
| 492 | it("discovers version-managed Node for providers with a standalone runtime", () => { |
| 493 | const fixture = createFixture(); |
| 494 | expect(fixture.install().status).toBe(0); |
| 495 | const nodeBin = `${fixture.work}/home/.nvm/versions/node/v24.15.0/bin`; |
| 496 | const setup = runShell( |
| 497 | [ |
| 498 | "set -eu", |
| 499 | `mkdir -p ${sh(nodeBin)}`, |
| 500 | `printf '%s' ${sh('#!/bin/sh\nprintf "linux-node-provider\\n"\n')} > ${sh(`${nodeBin}/node`)}`, |
| 501 | `chmod +x ${sh(`${nodeBin}/node`)}`, |
| 502 | ].join("\n"), |
| 503 | ); |
| 504 | expect(setup.status, setup.stderr).toBe(0); |
| 505 | |
| 506 | const probe = probeFixture(fixture); |
| 507 | |
| 508 | expect(probe.status, probe.stderr).toBe(0); |
| 509 | const resolvedPath = parseResolvedPath(probe.stdout); |
| 510 | expect(resolvedPath?.split(":")).toContain(nodeBin); |
| 511 | const provider = runShell(`export PATH=${sh(resolvedPath ?? "")}\nnode provider.js`); |
| 512 | expect(provider.status, provider.stderr).toBe(0); |
| 513 | expect(provider.stdout).toBe("linux-node-provider\n"); |
| 514 | }); |
| 515 | |
| 516 | it("keeps standalone runtime readiness independent of Node availability", () => { |
| 517 | const fixture = createFixture(); |
| 518 | expect(fixture.install().status).toBe(0); |
| 519 | |
| 520 | const probe = probeFixture(fixture); |
| 521 | |
| 522 | expect(probe.status, probe.stderr).toBe(0); |
| 523 | expect(parseResolvedPath(probe.stdout)).not.toBeNull(); |
| 524 | }); |
| 525 | |
| 526 | it("keeps the inherited PATH when bash is unavailable", () => { |
| 527 | const fixture = createFixture(); |
| 528 | expect(fixture.install().status).toBe(0); |
| 529 | const probe = runShell( |
| 530 | [ |
| 531 | "bash() { return 127; }", |
| 532 | 'export PATH="/fixture/bin:/usr/bin:/bin"', |
| 533 | buildWslRuntimeProbeScript(fixture.runtimeRoot), |
| 534 | ].join("\n"), |
| 535 | ); |
| 536 | |
| 537 | expect(probe.status, probe.stderr).toBe(0); |
| 538 | expect(parseResolvedPath(probe.stdout)).toBe("/fixture/bin:/usr/bin:/bin"); |
| 539 | }); |
| 540 | |
| 541 | it("reuses a warm cache without touching the archive", () => { |
| 542 | const fixture = createFixture(); |
| 543 | expect(fixture.install().status).toBe(0); |
| 544 | // Deleting the archive is how the test tells reuse apart from a silent |
| 545 | // reinstall: only the warm path can succeed without it. |
| 546 | expect(runShell(`set -eu\nrm ${sh(fixture.archivePath)}`).status).toBe(0); |
| 547 | |
| 548 | const warm = fixture.install(); |
| 549 | |
| 550 | expect(warm.status, warm.stderr).toBe(0); |
| 551 | expect(parseWslRuntimeRoot(warm.stdout)).toBe(fixture.runtimeRoot); |
| 552 | }); |
| 553 | |
| 554 | it("reinstalls a cache whose executable was truncated", () => { |
| 555 | const fixture = createFixture(); |
| 556 | expect(fixture.install().status).toBe(0); |
| 557 | expect(runShell(`set -eu\n: > ${sh(fixture.serverEntry)}`).status).toBe(0); |
| 558 | |
| 559 | const repaired = fixture.install(); |
| 560 | |
| 561 | expect(repaired.status, repaired.stderr).toBe(0); |
| 562 | expect(parseWslRuntimeRoot(repaired.stdout)).toBe(fixture.runtimeRoot); |
| 563 | const restored = runShell(`set -eu\ncat ${sh(fixture.serverEntry)}`); |
| 564 | expect(restored.stdout).toBe(SERVER_ENTRY_SOURCE); |
| 565 | }); |
| 566 | |
| 567 | it("falls back instead of launching a corrupted cache it cannot reinstall", () => { |
| 568 | const fixture = createFixture(); |
| 569 | expect(fixture.install().status).toBe(0); |
| 570 | expect(runShell(`set -eu\n: > ${sh(fixture.serverEntry)}`).status).toBe(0); |
| 571 | expect(runShell(`set -eu\nrm ${sh(fixture.archivePath)}`).status).toBe(0); |
| 572 | |
| 573 | const broken = fixture.install(); |
| 574 | |
| 575 | // Non-zero with no runtimeRoot is what sends the backend to the mounted |
| 576 | // server tree. Exiting 0 here is the bug: launch would pick the zero-byte |
| 577 | // server, fail to become ready, and do it again on every restart. |
| 578 | expect(broken.status).not.toBe(0); |
| 579 | expect(parseWslRuntimeRoot(broken.stdout)).toBeNull(); |
| 580 | }); |
| 581 | |
| 582 | it("extracts once when two installs race for the same cache", () => { |
| 583 | const fixture = createFixture(); |
| 584 | // A tar shim counts extractions and holds the critical section open long |
| 585 | // enough that the second install is certain to arrive while the first is |
| 586 | // still inside it. One extraction is the answer either way the runs |
| 587 | // interleave: whoever waits for the lock re-checks readiness before |
| 588 | // spending an extract, so a broken lock shows up as two. |
| 589 | const raced = runShell( |
| 590 | [ |
| 591 | "set -eu", |
| 592 | `work=${sh(fixture.work)}`, |
| 593 | 'mkdir -p "$work/bin"', |
| 594 | "real_tar=$(command -v tar)", |
| 595 | `printf '#!/bin/sh\\nprintf x >> "%s/tar-calls"\\nsleep 1\\nexec %s "$@"\\n' "$work" "$real_tar" > "$work/bin/tar"`, |
| 596 | 'chmod +x "$work/bin/tar"', |
| 597 | ': > "$work/tar-calls"', |
| 598 | 'PATH="$work/bin:$PATH"', |
| 599 | "export PATH", |
| 600 | `cat > "$work/install.sh" <<'T3CODE_INSTALL_SCRIPT'`, |
| 601 | fixture.installScript(), |
| 602 | "T3CODE_INSTALL_SCRIPT", |
| 603 | // Both racers run the same file, and neither file path contains the |
| 604 | // runtime root, so the script's own /proc scan cannot see them. |
| 605 | 'sh "$work/install.sh" > "$work/first.out" 2>&1 &', |
| 606 | "first=$!", |
| 607 | 'sh "$work/install.sh" > "$work/second.out" 2>&1 &', |
| 608 | "second=$!", |
| 609 | "if wait $first; then first_status=0; else first_status=$?; fi", |
| 610 | "if wait $second; then second_status=0; else second_status=$?; fi", |
| 611 | `printf 'firstStatus:%s\\n' "$first_status"`, |
| 612 | `printf 'secondStatus:%s\\n' "$second_status"`, |
| 613 | `printf 'extractions:%s\\n' "$(wc -c < "$work/tar-calls" | tr -d ' ')"`, |
| 614 | `printf 'firstRoot:%s\\n' "$(sed -n 's/^runtimeRoot://p' "$work/first.out")"`, |
| 615 | `printf 'secondRoot:%s\\n' "$(sed -n 's/^runtimeRoot://p' "$work/second.out")"`, |
| 616 | ].join("\n"), |
| 617 | ); |
| 618 | |
| 619 | expect(raced.status, raced.stderr).toBe(0); |
| 620 | expect(readField(raced.stdout, "firstStatus")).toBe("0"); |
| 621 | expect(readField(raced.stdout, "secondStatus")).toBe("0"); |
| 622 | expect(readField(raced.stdout, "extractions")).toBe("1"); |
| 623 | expect(readField(raced.stdout, "firstRoot")).toBe(fixture.runtimeRoot); |
| 624 | expect(readField(raced.stdout, "secondRoot")).toBe(fixture.runtimeRoot); |
| 625 | }); |
| 626 | |
| 627 | it("leaves no half-built cache when extraction fails", () => { |
| 628 | const fixture = createFixture(); |
| 629 | // Truncating the archive and re-recording its digest gets the install past |
| 630 | // the digest gate and into a tar that dies mid-stream, which is what a full |
| 631 | // disk or an interrupted write looks like from inside the distro. |
| 632 | const truncated = runShell( |
| 633 | [ |
| 634 | "set -eu", |
| 635 | `work=${sh(fixture.work)}`, |
| 636 | 'size=$(wc -c < "$work/wsl-runtime.tar.gz")', |
| 637 | 'head -c $((size / 2)) "$work/wsl-runtime.tar.gz" > "$work/truncated.tar.gz"', |
| 638 | `printf 'sha:%s\\n' "$(sha256sum "$work/truncated.tar.gz" | cut -d ' ' -f 1)"`, |
| 639 | ].join("\n"), |
| 640 | ); |
| 641 | expect(truncated.status, truncated.stderr).toBe(0); |
| 642 | |
| 643 | const failed = fixture.install( |
| 644 | `${fixture.work}/truncated.tar.gz`, |
| 645 | readField(truncated.stdout, "sha"), |
| 646 | ); |
| 647 | |
| 648 | expect(failed.status).not.toBe(0); |
| 649 | expect(parseWslRuntimeRoot(failed.stdout)).toBeNull(); |
| 650 | // A partial extract that survived under the cache name would be promoted by |
| 651 | // the next launch's readiness check; scratch that survived would sit there |
| 652 | // until the pruner's age sweep. Neither is left behind. Only directories |
| 653 | // are counted: the empty flock file stays on purpose, which is what keeps |
| 654 | // the lock from carrying stale state across a killed install. |
| 655 | const leftovers = runShell( |
| 656 | `set -eu\nfind ${sh(fixture.runtimeParent)} -mindepth 1 -maxdepth 1 -type d`, |
| 657 | ); |
| 658 | expect(leftovers.stdout.trim()).toBe(""); |
| 659 | }); |
| 660 | |
| 661 | // The archive and the identity recorded beside it can diverge — a partial |
| 662 | // download, or a rebuilt archive dropped next to an older sidecar. Either |
| 663 | // gate firing means the bytes never reach the cache under a name that claims |
| 664 | // to describe something else. |
| 665 | it("refuses an archive whose bytes do not match the digest recorded for it", () => { |
| 666 | const fixture = createFixture(); |
| 667 | |
| 668 | const refused = fixture.install(fixture.archivePath, "e".repeat(64)); |
| 669 | |
| 670 | expect(refused.status).not.toBe(0); |
| 671 | expect(refused.stderr).toContain("does not match its recorded SHA-256"); |
| 672 | expect(parseWslRuntimeRoot(refused.stdout)).toBeNull(); |
| 673 | const leftovers = runShell( |
| 674 | `set -eu\nfind ${sh(fixture.runtimeParent)} -mindepth 1 -maxdepth 1 -type d`, |
| 675 | ); |
| 676 | expect(leftovers.stdout.trim()).toBe(""); |
| 677 | }); |
| 678 | |
| 679 | it("leases a warm cache across the prepare-to-spawn handoff", () => { |
| 680 | const fixture = createFixture(); |
| 681 | expect(fixture.install().status).toBe(0); |
| 682 | const selected = runShell( |
| 683 | [ |
| 684 | "set -eu", |
| 685 | `runtime_parent=${sh(fixture.runtimeParent)}`, |
| 686 | 'mkdir -p "$runtime_parent/sha256-current" "$runtime_parent/sha256-previous"', |
| 687 | 'printf ready > "$runtime_parent/sha256-current/.t3code-wsl-runtime-ready"', |
| 688 | 'printf ready > "$runtime_parent/sha256-previous/.t3code-wsl-runtime-ready"', |
| 689 | `touch -d "10 minutes ago" ${sh(fixture.runtimeRoot)}`, |
| 690 | 'touch -d "1 minute ago" "$runtime_parent/sha256-previous"', |
| 691 | `cat > ${sh(`${fixture.work}/select.sh`)} <<'T3CODE_SELECT_SCRIPT'`, |
| 692 | fixture.installScript(), |
| 693 | "T3CODE_SELECT_SCRIPT", |
| 694 | `sh ${sh(`${fixture.work}/select.sh`)}`, |
| 695 | `HOME=${sh(`${fixture.work}/home`)}`, |
| 696 | "export HOME", |
| 697 | buildWslRuntimePruneScript("sha256-current"), |
| 698 | `test -d ${sh(fixture.runtimeRoot)}`, |
| 699 | ].join("\n"), |
| 700 | ); |
| 701 | |
| 702 | expect(selected.status, `${selected.stdout}\n${selected.stderr}`).toBe(0); |
| 703 | }); |
| 704 | |
| 705 | it("prunes a selected cache after its prepare-to-spawn grace period expires", () => { |
| 706 | const fixture = createFixture(); |
| 707 | expect(fixture.install().status).toBe(0); |
| 708 | const result = runShell( |
| 709 | [ |
| 710 | "set -eu", |
| 711 | `runtime_parent=${sh(fixture.runtimeParent)}`, |
| 712 | 'mkdir -p "$runtime_parent/sha256-current" "$runtime_parent/sha256-previous"', |
| 713 | 'printf ready > "$runtime_parent/sha256-current/.t3code-wsl-runtime-ready"', |
| 714 | 'printf ready > "$runtime_parent/sha256-previous/.t3code-wsl-runtime-ready"', |
| 715 | `touch -d "10 minutes ago" ${sh(fixture.runtimeRoot)}`, |
| 716 | `touch -d "10 minutes ago" ${sh(`${fixture.runtimeRoot}/.t3code-wsl-runtime-selected`)}`, |
| 717 | 'touch -d "1 minute ago" "$runtime_parent/sha256-previous"', |
| 718 | `HOME=${sh(`${fixture.work}/home`)}`, |
| 719 | "export HOME", |
| 720 | buildWslRuntimePruneScript("sha256-current"), |
| 721 | `test ! -e ${sh(fixture.runtimeRoot)}`, |
| 722 | ].join("\n"), |
| 723 | ); |
| 724 | |
| 725 | expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); |
| 726 | }); |
| 727 | |
| 728 | it("removes an aged stale tree after replacing an active unready cache", () => { |
| 729 | const fixture = createFixture(); |
| 730 | expect(fixture.install().status).toBe(0); |
| 731 | const result = runShell( |
| 732 | [ |
| 733 | "set -eu", |
| 734 | `runtime_root=${sh(fixture.runtimeRoot)}`, |
| 735 | `runtime_parent=${sh(fixture.runtimeParent)}`, |
| 736 | 'rm "$runtime_root/.t3code-wsl-runtime-ready"', |
| 737 | holdRuntimeBusy('"$runtime_root/t3"'), |
| 738 | fixture.installScript(), |
| 739 | 'stale=$(find "$runtime_parent" -maxdepth 1 -type d -name ".sha256-*.stale.*" -print -quit)', |
| 740 | 'test -n "$stale"', |
| 741 | 'touch -d "180 minutes ago" "$stale"', |
| 742 | `HOME=${sh(`${fixture.work}/home`)}`, |
| 743 | "export HOME", |
| 744 | buildWslRuntimePruneScript(fixture.runtimeId), |
| 745 | 'test ! -e "$stale"', |
| 746 | ].join("\n"), |
| 747 | ); |
| 748 | |
| 749 | expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); |
| 750 | }); |
| 751 | |
| 752 | it("prunes old and markerless caches without touching retained, active, locked, or unrelated roots", () => { |
| 753 | const result = runShell( |
| 754 | [ |
| 755 | "set -eu", |
| 756 | "work=$(mktemp -d)", |
| 757 | 'home="$work/home"', |
| 758 | 'runtime_parent="$home/.t3/wsl-runtime"', |
| 759 | 'mkdir -p "$runtime_parent"', |
| 760 | 'make_ready() { mkdir -p "$runtime_parent/$1"; printf ready > "$runtime_parent/$1/.t3code-wsl-runtime-ready"; }', |
| 761 | "make_ready sha256-current", |
| 762 | "make_ready sha256-previous", |
| 763 | "make_ready sha256-active", |
| 764 | "make_ready sha256-old", |
| 765 | "make_ready sha256-locked", |
| 766 | 'mkdir -p "$runtime_parent/sha256-markerless" "$runtime_parent/versions"', |
| 767 | 'touch -d "1 minute ago" "$runtime_parent/sha256-previous"', |
| 768 | 'touch -d "4 minutes ago" "$runtime_parent/sha256-active"', |
| 769 | 'touch -d "3 minutes ago" "$runtime_parent/sha256-old"', |
| 770 | 'touch -d "2 minutes ago" "$runtime_parent/sha256-locked"', |
| 771 | holdRuntimeBusy('"$runtime_parent/sha256-active/t3"'), |
| 772 | holdInstallLock('"$runtime_parent/.sha256-locked.install.lock"'), |
| 773 | `HOME="$home"`, |
| 774 | "export HOME", |
| 775 | buildWslRuntimePruneScript("sha256-current"), |
| 776 | 'test -d "$runtime_parent/sha256-current"', |
| 777 | 'test -d "$runtime_parent/sha256-previous"', |
| 778 | 'test -d "$runtime_parent/sha256-active"', |
| 779 | 'test -d "$runtime_parent/sha256-locked"', |
| 780 | 'test -d "$runtime_parent/versions"', |
| 781 | 'test ! -e "$runtime_parent/sha256-old"', |
| 782 | 'test ! -e "$runtime_parent/sha256-markerless"', |
| 783 | 'rm -rf "$work"', |
| 784 | ].join("\n"), |
| 785 | ); |
| 786 | |
| 787 | expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); |
| 788 | }); |
| 789 | }); |
| 790 | |
| 791 | describe("parseToolchainReport", () => { |
| 792 | it("returns no missing tools and no node version on empty output", () => { |
| 793 | expect(parseToolchainReport("")).toEqual({ missingTools: [], nodeVersion: null }); |
| 794 | }); |
| 795 | |
| 796 | it("collects all missing: lines", () => { |
| 797 | const stdout = ["missing:make", "missing:g++", "nodeVersion:24.10.0"].join("\n"); |
| 798 | expect(parseToolchainReport(stdout)).toEqual({ |
| 799 | missingTools: ["make", "g++"], |
| 800 | nodeVersion: "24.10.0", |
| 801 | }); |
| 802 | }); |
| 803 | |
| 804 | it("ignores blank lines and trims whitespace", () => { |
| 805 | const stdout = [" missing:python3 ", "", " nodeVersion:v22.16.0 "].join("\n"); |
| 806 | expect(parseToolchainReport(stdout)).toEqual({ |
| 807 | missingTools: ["python3"], |
| 808 | nodeVersion: "v22.16.0", |
| 809 | }); |
| 810 | }); |
| 811 | |
| 812 | it("returns null node version when value after prefix is empty", () => { |
| 813 | expect(parseToolchainReport("nodeVersion:")).toEqual({ |
| 814 | missingTools: [], |
| 815 | nodeVersion: null, |
| 816 | }); |
| 817 | }); |
| 818 | }); |
| 819 | |
| 820 | describe("parseNodePath", () => { |
| 821 | it("extracts the absolute node path from a nodePath: line", () => { |
| 822 | const stdout = "nodePath:/home/josh/.nvm/versions/node/v22.16.0/bin/node"; |
| 823 | expect(parseNodePath(stdout)).toBe("/home/josh/.nvm/versions/node/v22.16.0/bin/node"); |
| 824 | }); |
| 825 | |
| 826 | it("returns null when node was not found (empty value after prefix)", () => { |
| 827 | expect(parseNodePath("nodePath:")).toBeNull(); |
| 828 | }); |
| 829 | |
| 830 | it("returns null when there is no nodePath line at all", () => { |
| 831 | expect(parseNodePath("missing:node\nnodeVersion:")).toBeNull(); |
| 832 | }); |
| 833 | |
| 834 | it("ignores surrounding noise and trims whitespace", () => { |
| 835 | const stdout = ["some preamble noise", " nodePath:/usr/bin/node ", "trailing"].join("\n"); |
| 836 | expect(parseNodePath(stdout)).toBe("/usr/bin/node"); |
| 837 | }); |
| 838 | }); |
| 839 | |
| 840 | describe("parseNodeVersion", () => { |
| 841 | it("extracts the node version from a nodeVersion: line", () => { |
| 842 | expect(parseNodeVersion("nodeVersion:24.10.0")).toBe("24.10.0"); |
| 843 | }); |
| 844 | |
| 845 | it("returns null when the version value is empty", () => { |
| 846 | expect(parseNodeVersion("nodeVersion:")).toBeNull(); |
| 847 | }); |
| 848 | |
| 849 | it("returns null when there is no nodeVersion line at all", () => { |
| 850 | expect(parseNodeVersion("nodePath:/usr/bin/node\nresolvedPath:/usr/bin")).toBeNull(); |
| 851 | }); |
| 852 | |
| 853 | it("ignores surrounding noise and trims whitespace", () => { |
| 854 | const stdout = [ |
| 855 | "some preamble noise", |
| 856 | " nodeVersion:22.16.0 ", |
| 857 | "nodePath:/usr/bin/node", |
| 858 | ].join("\n"); |
| 859 | expect(parseNodeVersion(stdout)).toBe("22.16.0"); |
| 860 | }); |
| 861 | }); |
| 862 | |
| 863 | describe("parseResolvedPath", () => { |
| 864 | it("preserves spaces and apostrophes in the resolved login-shell PATH", () => { |
| 865 | const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin"; |
| 866 | expect(parseResolvedPath(`nodePath:/usr/bin/node\nresolvedPath:${resolvedPath}\n`)).toBe( |
| 867 | resolvedPath, |
| 868 | ); |
| 869 | }); |
| 870 | |
| 871 | it("accepts CRLF output without retaining the carriage return", () => { |
| 872 | expect(parseResolvedPath("resolvedPath:/usr/local/bin:/usr/bin\r\n")).toBe( |
| 873 | "/usr/local/bin:/usr/bin", |
| 874 | ); |
| 875 | }); |
| 876 | |
| 877 | it("returns null when the resolved PATH is absent or empty", () => { |
| 878 | expect(parseResolvedPath("nodePath:/usr/bin/node\n")).toBeNull(); |
| 879 | expect(parseResolvedPath("resolvedPath:\n")).toBeNull(); |
| 880 | }); |
| 881 | }); |
| 882 | |
| 883 | describe("formatMissingToolsReason", () => { |
| 884 | it("returns null when everything is present and node is in range", () => { |
| 885 | expect( |
| 886 | formatMissingToolsReason({ missingTools: [], nodeVersion: "24.10.0" }, "^24.10"), |
| 887 | ).toBeNull(); |
| 888 | }); |
| 889 | |
| 890 | it("returns null when range is not specified and tools are present", () => { |
| 891 | expect(formatMissingToolsReason({ missingTools: [], nodeVersion: "18.0.0" }, null)).toBeNull(); |
| 892 | }); |
| 893 | |
| 894 | it("flags missing node first", () => { |
| 895 | const reason = formatMissingToolsReason( |
| 896 | { missingTools: ["node", "make"], nodeVersion: null }, |
| 897 | "^24.10", |
| 898 | ); |
| 899 | expect(reason).toContain("node"); |
| 900 | expect(reason).toContain("^24.10"); |
| 901 | expect(reason).toContain("make"); |
| 902 | expect(reason).toContain("nvm"); |
| 903 | }); |
| 904 | |
| 905 | it("flags an out-of-range node version with the actual version surfaced", () => { |
| 906 | const reason = formatMissingToolsReason( |
| 907 | { missingTools: [], nodeVersion: "20.0.0" }, |
| 908 | "^24.10 || ^22.16", |
| 909 | ); |
| 910 | expect(reason).toContain("node 20.0.0"); |
| 911 | expect(reason).toContain("requires ^24.10 || ^22.16"); |
| 912 | }); |
| 913 | |
| 914 | it("flags missing build tools without node when node is fine", () => { |
| 915 | const reason = formatMissingToolsReason( |
| 916 | { missingTools: ["g++", "python3"], nodeVersion: "24.10.0" }, |
| 917 | "^24.10", |
| 918 | ); |
| 919 | expect(reason).toContain("g++"); |
| 920 | expect(reason).toContain("python3"); |
| 921 | expect(reason).toContain("build-essential"); |
| 922 | expect(reason).not.toContain("nvm"); |
| 923 | }); |
| 924 | }); |