diff --git a/packages/turbo-utils/__tests__/managers.test.ts b/packages/turbo-utils/__tests__/managers.test.ts new file mode 100644 index 0000000000000..ccd661c07d62f --- /dev/null +++ b/packages/turbo-utils/__tests__/managers.test.ts @@ -0,0 +1,139 @@ +import os from "node:os"; +import { describe, test, expect, beforeEach, jest } from "@jest/globals"; +import execa from "execa"; +import { + getAvailablePackageManagers, + getPackageManagersBinPaths, +} from "../src/managers"; + +// Mock dependencies +jest.mock("execa"); +jest.mock("node:os"); + +const mockExeca = execa as jest.MockedFunction; +const mockOs = os as jest.Mocked; + +describe("managers", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockOs.tmpdir.mockReturnValue("/tmp"); + }); + + describe("getAvailablePackageManagers", () => { + test("should return all available package managers", async () => { + mockExeca + .mockResolvedValueOnce({ stdout: "1.22.19" } as any) // yarn + .mockResolvedValueOnce({ stdout: "9.5.0" } as any) // npm + .mockResolvedValueOnce({ stdout: "8.6.7" } as any) // pnpm + .mockResolvedValueOnce({ stdout: "1.0.0" } as any); // bun + + const result = await getAvailablePackageManagers(); + + expect(result).toEqual({ + yarn: "1.22.19", + npm: "9.5.0", + pnpm: "8.6.7", + bun: "1.0.0", + }); + }); + + test("should return undefined for unavailable package managers", async () => { + mockExeca + .mockResolvedValueOnce({ stdout: "1.22.19" } as any) // yarn + .mockRejectedValueOnce(new Error("npm not found")) // npm + .mockResolvedValueOnce({ stdout: "8.6.7" } as any) // pnpm + .mockRejectedValueOnce(new Error("bun not found")); // bun + + const result = await getAvailablePackageManagers(); + + expect(result).toEqual({ + yarn: "1.22.19", + npm: undefined, + pnpm: "8.6.7", + bun: undefined, + }); + }); + + describe("getPackageManagersBinPaths", () => { + test("should return bin paths for all package managers", async () => { + mockExeca + .mockResolvedValueOnce({ stdout: "3.2.1" } as any) // yarn version (berry) + .mockResolvedValueOnce({ stdout: "/usr/local/bin" } as any) // npm prefix + .mockResolvedValueOnce({ stdout: "/usr/local/pnpm" } as any) // pnpm bin + .mockResolvedValueOnce({ stdout: "/usr/local/bun" } as any); // bun bin + + const result = await getPackageManagersBinPaths(); + + expect(result).toEqual({ + yarn: ".yarn/releases/yarn-3.2.1.cjs", + npm: "/usr/local/bin", + pnpm: "/usr/local/pnpm", + bun: "/usr/local/bun", + }); + }); + + test("should handle yarn v1 global bin path", async () => { + mockExeca + .mockResolvedValueOnce({ stdout: "1.22.19" } as any) // yarn version check + .mockResolvedValueOnce({ stdout: "/usr/local/bin" } as any) // npm prefix + .mockResolvedValueOnce({ stdout: "/usr/local/pnpm" } as any) // pnpm bin + .mockResolvedValueOnce({ stdout: "/usr/local/bun" } as any) // bun bin + .mockResolvedValueOnce({ stdout: "/usr/local/yarn" } as any); // yarn global bin + + const result = await getPackageManagersBinPaths(); + + expect(result.yarn).toBe("/usr/local/yarn"); + expect(result.npm).toBe("/usr/local/bin"); + expect(result.pnpm).toBe("/usr/local/pnpm"); + expect(result.bun).toBe("/usr/local/bun"); + }); + + test("should return undefined for failed package manager checks", async () => { + mockExeca + .mockRejectedValueOnce(new Error("yarn not found")) // yarn + .mockRejectedValueOnce(new Error("npm not found")) // npm + .mockResolvedValueOnce({ stdout: "/usr/local/pnpm" } as any) // pnpm + .mockRejectedValueOnce(new Error("bun not found")); // bun + + const result = await getPackageManagersBinPaths(); + + expect(result).toEqual({ + yarn: undefined, + npm: undefined, + pnpm: "/usr/local/pnpm", + bun: undefined, + }); + }); + + test("should call execa with correct commands for bin paths", async () => { + mockExeca.mockResolvedValue({ stdout: "1.0.0" } as any); + + await getPackageManagersBinPaths(); + + // Verify yarn version check + expect(mockExeca).toHaveBeenCalledWith("yarnpkg", ["--version"], { + cwd: ".", + env: { COREPACK_ENABLE_STRICT: "0" }, + }); + + // Verify other package manager bin path commands + expect(mockExeca).toHaveBeenCalledWith( + "npm", + ["config", "get", "prefix"], + { + cwd: "/tmp", + env: { COREPACK_ENABLE_STRICT: "0" }, + } + ); + expect(mockExeca).toHaveBeenCalledWith("pnpm", ["bin", "--global"], { + cwd: "/tmp", + env: { COREPACK_ENABLE_STRICT: "0" }, + }); + expect(mockExeca).toHaveBeenCalledWith("bun", ["pm", "--g", "bin"], { + cwd: "/tmp", + env: { COREPACK_ENABLE_STRICT: "0" }, + }); + }); + }); + }); +}); diff --git a/packages/turbo-utils/__tests__/searchUp.test.ts b/packages/turbo-utils/__tests__/searchUp.test.ts new file mode 100644 index 0000000000000..b8aca55c24b46 --- /dev/null +++ b/packages/turbo-utils/__tests__/searchUp.test.ts @@ -0,0 +1,60 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, test, expect, beforeEach, jest } from "@jest/globals"; +import { searchUp } from "../src/searchUp"; + +// Mock fs module +jest.mock("node:fs"); +const mockFs = fs as jest.Mocked; + +describe("searchUp", () => { + const mockCwd = "/path/to/project/src/components"; + const mockRoot = process.platform === "win32" ? "C:\\" : "/"; + + beforeEach(() => { + jest.clearAllMocks(); + // Mock path.parse to return consistent root + jest.spyOn(path, "parse").mockImplementation((pathString: string) => ({ + root: mockRoot, + dir: path.dirname(pathString), + base: path.basename(pathString), + ext: path.extname(pathString), + name: path.basename(pathString, path.extname(pathString)), + })); + }); + + test("should find file in current directory", () => { + const targetFile = "package.json"; + mockFs.existsSync.mockImplementation( + (filePath: any) => filePath === path.join(mockCwd, targetFile) + ); + + const result = searchUp({ target: targetFile, cwd: mockCwd }); + + expect(result).toBe(mockCwd); + expect(mockFs.existsSync).toHaveBeenCalledWith( + path.join(mockCwd, targetFile) + ); + }); + + test("should find file in parent directory", () => { + const targetFile = "turbo.json"; + const parentDir = "/path/to/project"; + + mockFs.existsSync.mockImplementation( + (filePath: any) => filePath === path.join(parentDir, targetFile) + ); + + const result = searchUp({ target: targetFile, cwd: mockCwd }); + + expect(result).toBe(parentDir); + }); + + test("should return null when file not found", () => { + mockFs.existsSync.mockReturnValue(false); + + const result = searchUp({ target: "nonexistent.json", cwd: mockCwd }); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/turbo-utils/__tests__/validateDirectory.test.ts b/packages/turbo-utils/__tests__/validateDirectory.test.ts new file mode 100644 index 0000000000000..11547b9e614b0 --- /dev/null +++ b/packages/turbo-utils/__tests__/validateDirectory.test.ts @@ -0,0 +1,146 @@ +import { describe, test, expect, beforeEach, jest } from "@jest/globals"; +import path from "node:path"; +import fs from "fs-extra"; +import { validateDirectory } from "../src/validateDirectory"; +import { isFolderEmpty } from "../src/isFolderEmpty"; + +// Mock dependencies +jest.mock("fs-extra"); +jest.mock("../src/isFolderEmpty"); + +const mockFs = fs as jest.Mocked; +const mockIsFolderEmpty = isFolderEmpty as jest.MockedFunction< + typeof isFolderEmpty +>; + +describe("validateDirectory", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("should return valid result for valid empty directory", () => { + const directory = "/path/to/project"; + const resolvedPath = path.resolve(directory); + + mockFs.existsSync.mockReturnValue(true); + mockFs.lstatSync.mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + mockIsFolderEmpty.mockReturnValue({ + isEmpty: true, + conflicts: [], + }); + + const result = validateDirectory(directory); + + expect(result).toEqual({ + valid: true, + root: resolvedPath, + projectName: "project", + }); + }); + + test("should return error when path points to a file", () => { + const directory = "/path/to/file.txt"; + const resolvedPath = path.resolve(directory); + + mockFs.lstatSync.mockReturnValue({ + isDirectory: () => false, + } as fs.Stats); + + const result = validateDirectory(directory); + + expect(result).toEqual({ + valid: false, + root: resolvedPath, + projectName: "file.txt", + error: expect.stringContaining("is not a directory"), + }); + }); + + test("should return error when directory has conflicts", () => { + const directory = "/path/to/existing"; + const resolvedPath = path.resolve(directory); + const conflicts = ["package.json", "src/"]; + + mockFs.existsSync.mockReturnValue(true); + mockFs.lstatSync.mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + mockIsFolderEmpty.mockReturnValue({ + isEmpty: false, + conflicts, + }); + + const result = validateDirectory(directory); + + expect(result).toEqual({ + valid: false, + root: resolvedPath, + projectName: "existing", + error: expect.stringContaining("has 2 conflicting files"), + }); + }); + + test("should return error with singular 'file' for single conflict", () => { + const directory = "/path/to/existing"; + const resolvedPath = path.resolve(directory); + const conflicts = ["package.json"]; + + mockFs.existsSync.mockReturnValue(true); + mockFs.lstatSync.mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + mockIsFolderEmpty.mockReturnValue({ + isEmpty: false, + conflicts, + }); + + const result = validateDirectory(directory); + + expect(result).toEqual({ + valid: false, + root: resolvedPath, + projectName: "existing", + error: expect.stringContaining("has 1 conflicting file"), + }); + }); + + test("should handle non-existent directory as valid", () => { + const directory = "/path/to/new-project"; + const resolvedPath = path.resolve(directory); + + mockFs.existsSync.mockReturnValue(false); + mockFs.lstatSync.mockReturnValue(undefined as any); + + const result = validateDirectory(directory); + + expect(result).toEqual({ + valid: true, + root: resolvedPath, + projectName: "new-project", + }); + }); + + test("should handle lstat errors gracefully", () => { + const directory = "/path/to/project"; + const resolvedPath = path.resolve(directory); + + mockFs.lstatSync.mockImplementation(() => { + const error = new Error("Permission denied"); + (error as any).code = "ENOENT"; + throw error; + }); + + // Since lstatSync is called with throwIfNoEntry: false, it should return null on error + mockFs.lstatSync.mockReturnValue(null as any); + + const result = validateDirectory(directory); + + expect(result).toEqual({ + valid: true, + root: resolvedPath, + projectName: "project", + }); + }); +});