| import { afterEach, describe, expect, it, vi } from "vitest"; |
|
|
| const fsMocks = vi.hoisted(() => ({ |
| access: vi.fn(), |
| })); |
|
|
| vi.mock("node:fs/promises", () => ({ |
| default: { access: fsMocks.access }, |
| access: fsMocks.access, |
| })); |
|
|
| import { |
| renderSystemNodeWarning, |
| resolvePreferredNodePath, |
| resolveStableNodePath, |
| resolveSystemNodeInfo, |
| } from "./runtime-paths.js"; |
|
|
| afterEach(() => { |
| vi.resetAllMocks(); |
| }); |
|
|
| function mockNodePathPresent(...nodePaths: string[]) { |
| fsMocks.access.mockImplementation(async (target: string) => { |
| if (nodePaths.includes(target)) { |
| return; |
| } |
| throw new Error("missing"); |
| }); |
| } |
|
|
| describe("resolvePreferredNodePath", () => { |
| const darwinNode = "/opt/homebrew/bin/node"; |
| const fnmNode = "/Users/test/.fnm/node-versions/v24.11.1/installation/bin/node"; |
|
|
| it("prefers execPath (version manager node) over system node", async () => { |
| mockNodePathPresent(darwinNode); |
|
|
| const execFile = vi.fn().mockResolvedValue({ stdout: "24.11.1\n", stderr: "" }); |
|
|
| const result = await resolvePreferredNodePath({ |
| env: {}, |
| runtime: "node", |
| platform: "darwin", |
| execFile, |
| execPath: fnmNode, |
| }); |
|
|
| expect(result).toBe(fnmNode); |
| expect(execFile).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it("falls back to system node when execPath version is unsupported", async () => { |
| mockNodePathPresent(darwinNode); |
|
|
| const execFile = vi |
| .fn() |
| .mockResolvedValueOnce({ stdout: "18.0.0\n", stderr: "" }) |
| .mockResolvedValueOnce({ stdout: "22.16.0\n", stderr: "" }); |
|
|
| const result = await resolvePreferredNodePath({ |
| env: {}, |
| runtime: "node", |
| platform: "darwin", |
| execFile, |
| execPath: "/some/old/node", |
| }); |
|
|
| expect(result).toBe(darwinNode); |
| expect(execFile).toHaveBeenCalledTimes(2); |
| }); |
|
|
| it("ignores execPath when it is not node", async () => { |
| mockNodePathPresent(darwinNode); |
|
|
| const execFile = vi.fn().mockResolvedValue({ stdout: "22.16.0\n", stderr: "" }); |
|
|
| const result = await resolvePreferredNodePath({ |
| env: {}, |
| runtime: "node", |
| platform: "darwin", |
| execFile, |
| execPath: "/Users/test/.bun/bin/bun", |
| }); |
|
|
| expect(result).toBe(darwinNode); |
| expect(execFile).toHaveBeenCalledTimes(1); |
| expect(execFile).toHaveBeenCalledWith(darwinNode, ["-p", "process.versions.node"], { |
| encoding: "utf8", |
| }); |
| }); |
|
|
| it("uses system node when it meets the minimum version", async () => { |
| mockNodePathPresent(darwinNode); |
|
|
| |
| const execFile = vi.fn().mockResolvedValue({ stdout: "22.16.0\n", stderr: "" }); |
|
|
| const result = await resolvePreferredNodePath({ |
| env: {}, |
| runtime: "node", |
| platform: "darwin", |
| execFile, |
| execPath: darwinNode, |
| }); |
|
|
| expect(result).toBe(darwinNode); |
| expect(execFile).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it("skips system node when it is too old", async () => { |
| mockNodePathPresent(darwinNode); |
|
|
| |
| const execFile = vi.fn().mockResolvedValue({ stdout: "22.15.0\n", stderr: "" }); |
|
|
| const result = await resolvePreferredNodePath({ |
| env: {}, |
| runtime: "node", |
| platform: "darwin", |
| execFile, |
| execPath: "", |
| }); |
|
|
| expect(result).toBeUndefined(); |
| expect(execFile).toHaveBeenCalledTimes(1); |
| }); |
|
|
| it("returns undefined when no system node is found", async () => { |
| fsMocks.access.mockRejectedValue(new Error("missing")); |
|
|
| const execFile = vi.fn().mockRejectedValue(new Error("not found")); |
|
|
| const result = await resolvePreferredNodePath({ |
| env: {}, |
| runtime: "node", |
| platform: "darwin", |
| execFile, |
| execPath: "", |
| }); |
|
|
| expect(result).toBeUndefined(); |
| }); |
| }); |
|
|
| describe("resolveStableNodePath", () => { |
| it("resolves Homebrew Cellar path to opt symlink", async () => { |
| mockNodePathPresent("/opt/homebrew/opt/node/bin/node"); |
|
|
| const result = await resolveStableNodePath("/opt/homebrew/Cellar/node/25.7.0/bin/node"); |
| expect(result).toBe("/opt/homebrew/opt/node/bin/node"); |
| }); |
|
|
| it("falls back to bin symlink for default node formula", async () => { |
| mockNodePathPresent("/opt/homebrew/bin/node"); |
|
|
| const result = await resolveStableNodePath("/opt/homebrew/Cellar/node/25.7.0/bin/node"); |
| expect(result).toBe("/opt/homebrew/bin/node"); |
| }); |
|
|
| it("resolves Intel Mac Cellar path to opt symlink", async () => { |
| mockNodePathPresent("/usr/local/opt/node/bin/node"); |
|
|
| const result = await resolveStableNodePath("/usr/local/Cellar/node/25.7.0/bin/node"); |
| expect(result).toBe("/usr/local/opt/node/bin/node"); |
| }); |
|
|
| it("resolves versioned node@22 formula to opt symlink", async () => { |
| mockNodePathPresent("/opt/homebrew/opt/node@22/bin/node"); |
|
|
| const result = await resolveStableNodePath("/opt/homebrew/Cellar/node@22/22.16.0/bin/node"); |
| expect(result).toBe("/opt/homebrew/opt/node@22/bin/node"); |
| }); |
|
|
| it("returns original path when no stable symlink exists", async () => { |
| fsMocks.access.mockRejectedValue(new Error("missing")); |
|
|
| const cellarPath = "/opt/homebrew/Cellar/node/25.7.0/bin/node"; |
| const result = await resolveStableNodePath(cellarPath); |
| expect(result).toBe(cellarPath); |
| }); |
|
|
| it("returns non-Cellar paths unchanged", async () => { |
| const fnmPath = "/Users/test/.fnm/node-versions/v24.11.1/installation/bin/node"; |
| const result = await resolveStableNodePath(fnmPath); |
| expect(result).toBe(fnmPath); |
| }); |
|
|
| it("returns system paths unchanged", async () => { |
| const result = await resolveStableNodePath("/opt/homebrew/bin/node"); |
| expect(result).toBe("/opt/homebrew/bin/node"); |
| }); |
| }); |
|
|
| describe("resolvePreferredNodePath — Homebrew Cellar", () => { |
| it("resolves Cellar execPath to stable Homebrew symlink", async () => { |
| const cellarNode = "/opt/homebrew/Cellar/node/25.7.0/bin/node"; |
| const stableNode = "/opt/homebrew/opt/node/bin/node"; |
| mockNodePathPresent(stableNode); |
|
|
| const execFile = vi.fn().mockResolvedValue({ stdout: "25.7.0\n", stderr: "" }); |
|
|
| const result = await resolvePreferredNodePath({ |
| env: {}, |
| runtime: "node", |
| platform: "darwin", |
| execFile, |
| execPath: cellarNode, |
| }); |
|
|
| expect(result).toBe(stableNode); |
| }); |
| }); |
|
|
| describe("resolveSystemNodeInfo", () => { |
| const darwinNode = "/opt/homebrew/bin/node"; |
|
|
| it("returns supported info when version is new enough", async () => { |
| mockNodePathPresent(darwinNode); |
|
|
| |
| const execFile = vi.fn().mockResolvedValue({ stdout: "22.16.0\n", stderr: "" }); |
|
|
| const result = await resolveSystemNodeInfo({ |
| env: {}, |
| platform: "darwin", |
| execFile, |
| }); |
|
|
| expect(result).toEqual({ |
| path: darwinNode, |
| version: "22.16.0", |
| supported: true, |
| }); |
| }); |
|
|
| it("returns undefined when system node is missing", async () => { |
| fsMocks.access.mockRejectedValue(new Error("missing")); |
| const execFile = vi.fn(); |
| const result = await resolveSystemNodeInfo({ env: {}, platform: "darwin", execFile }); |
| expect(result).toBeNull(); |
| }); |
|
|
| it("renders a warning when system node is too old", () => { |
| const warning = renderSystemNodeWarning( |
| { |
| path: darwinNode, |
| version: "18.19.0", |
| supported: false, |
| }, |
| "/Users/me/.fnm/node-22/bin/node", |
| ); |
|
|
| expect(warning).toContain("below the required Node 22.16+"); |
| expect(warning).toContain(darwinNode); |
| }); |
| }); |
|
|