From 19fd23c39c8ab31ae5ec2b8c1634f211ad59453b Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Fri, 19 Dec 2025 21:21:39 -0500 Subject: [PATCH] test: enhance error handling in fs-utils tests - Added tests to ensure mkdirSafe handles ELOOP and EEXIST errors gracefully. - Implemented checks for existsSafe to return true for ELOOP errors and throw for other errors. - Improved overall robustness of filesystem utility tests. --- apps/server/tests/unit/lib/fs-utils.test.ts | 60 +++ .../tests/unit/lib/worktree-metadata.test.ts | 368 ++++++++++++++++++ 2 files changed, 428 insertions(+) create mode 100644 apps/server/tests/unit/lib/worktree-metadata.test.ts diff --git a/apps/server/tests/unit/lib/fs-utils.test.ts b/apps/server/tests/unit/lib/fs-utils.test.ts index 9e7e9f22..c9040153 100644 --- a/apps/server/tests/unit/lib/fs-utils.test.ts +++ b/apps/server/tests/unit/lib/fs-utils.test.ts @@ -65,6 +65,47 @@ describe("fs-utils.ts", () => { // Should not throw await expect(mkdirSafe(symlinkPath)).resolves.toBeUndefined(); }); + + it("should handle ELOOP error gracefully when checking path", async () => { + // Mock lstat to throw ELOOP error + const originalLstat = fs.lstat; + const mkdirSafePath = path.join(testDir, "eloop-path"); + + vi.spyOn(fs, "lstat").mockRejectedValueOnce({ code: "ELOOP" }); + + // Should not throw, should return gracefully + await expect(mkdirSafe(mkdirSafePath)).resolves.toBeUndefined(); + + vi.restoreAllMocks(); + }); + + it("should handle EEXIST error gracefully when creating directory", async () => { + const newDir = path.join(testDir, "race-condition-dir"); + + // Mock lstat to return ENOENT (path doesn't exist) + // Then mock mkdir to throw EEXIST (race condition) + vi.spyOn(fs, "lstat").mockRejectedValueOnce({ code: "ENOENT" }); + vi.spyOn(fs, "mkdir").mockRejectedValueOnce({ code: "EEXIST" }); + + // Should not throw, should return gracefully + await expect(mkdirSafe(newDir)).resolves.toBeUndefined(); + + vi.restoreAllMocks(); + }); + + it("should handle ELOOP error gracefully when creating directory", async () => { + const newDir = path.join(testDir, "eloop-create-dir"); + + // Mock lstat to return ENOENT (path doesn't exist) + // Then mock mkdir to throw ELOOP + vi.spyOn(fs, "lstat").mockRejectedValueOnce({ code: "ENOENT" }); + vi.spyOn(fs, "mkdir").mockRejectedValueOnce({ code: "ELOOP" }); + + // Should not throw, should return gracefully + await expect(mkdirSafe(newDir)).resolves.toBeUndefined(); + + vi.restoreAllMocks(); + }); }); describe("existsSafe", () => { @@ -109,5 +150,24 @@ describe("fs-utils.ts", () => { const exists = await existsSafe(symlinkPath); expect(exists).toBe(true); }); + + it("should return true for ELOOP error (symlink loop)", async () => { + // Mock lstat to throw ELOOP error + vi.spyOn(fs, "lstat").mockRejectedValueOnce({ code: "ELOOP" }); + + const exists = await existsSafe("/some/path/with/loop"); + expect(exists).toBe(true); + + vi.restoreAllMocks(); + }); + + it("should throw for other errors", async () => { + // Mock lstat to throw a non-ENOENT, non-ELOOP error + vi.spyOn(fs, "lstat").mockRejectedValueOnce({ code: "EACCES" }); + + await expect(existsSafe("/some/path")).rejects.toMatchObject({ code: "EACCES" }); + + vi.restoreAllMocks(); + }); }); }); diff --git a/apps/server/tests/unit/lib/worktree-metadata.test.ts b/apps/server/tests/unit/lib/worktree-metadata.test.ts new file mode 100644 index 00000000..0071f207 --- /dev/null +++ b/apps/server/tests/unit/lib/worktree-metadata.test.ts @@ -0,0 +1,368 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + readWorktreeMetadata, + writeWorktreeMetadata, + updateWorktreePRInfo, + getWorktreePRInfo, + readAllWorktreeMetadata, + deleteWorktreeMetadata, + type WorktreeMetadata, + type WorktreePRInfo, +} from "@/lib/worktree-metadata.js"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; + +describe("worktree-metadata.ts", () => { + let testProjectPath: string; + + beforeEach(async () => { + testProjectPath = path.join(os.tmpdir(), `worktree-metadata-test-${Date.now()}`); + await fs.mkdir(testProjectPath, { recursive: true }); + }); + + afterEach(async () => { + try { + await fs.rm(testProjectPath, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe("sanitizeBranchName", () => { + // Test through readWorktreeMetadata and writeWorktreeMetadata + it("should sanitize branch names with invalid characters", async () => { + const branch = "feature/test-branch"; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).toEqual(metadata); + }); + + it("should sanitize branch names with Windows invalid characters", async () => { + const branch = "feature:test*branch?"; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).toEqual(metadata); + }); + + it("should sanitize Windows reserved names", async () => { + const branch = "CON"; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).toEqual(metadata); + }); + }); + + describe("readWorktreeMetadata", () => { + it("should return null when metadata file doesn't exist", async () => { + const result = await readWorktreeMetadata(testProjectPath, "nonexistent-branch"); + expect(result).toBeNull(); + }); + + it("should read existing metadata", async () => { + const branch = "test-branch"; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).toEqual(metadata); + }); + + it("should read metadata with PR info", async () => { + const branch = "pr-branch"; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + pr: { + number: 123, + url: "https://github.com/owner/repo/pull/123", + title: "Test PR", + state: "open", + createdAt: new Date().toISOString(), + }, + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).toEqual(metadata); + }); + }); + + describe("writeWorktreeMetadata", () => { + it("should create metadata directory if it doesn't exist", async () => { + const branch = "new-branch"; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).toEqual(metadata); + }); + + it("should overwrite existing metadata", async () => { + const branch = "existing-branch"; + const metadata1: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + const metadata2: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + pr: { + number: 456, + url: "https://github.com/owner/repo/pull/456", + title: "Updated PR", + state: "closed", + createdAt: new Date().toISOString(), + }, + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata1); + await writeWorktreeMetadata(testProjectPath, branch, metadata2); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).toEqual(metadata2); + }); + }); + + describe("updateWorktreePRInfo", () => { + it("should create new metadata if it doesn't exist", async () => { + const branch = "new-pr-branch"; + const prInfo: WorktreePRInfo = { + number: 789, + url: "https://github.com/owner/repo/pull/789", + title: "New PR", + state: "open", + createdAt: new Date().toISOString(), + }; + + await updateWorktreePRInfo(testProjectPath, branch, prInfo); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).not.toBeNull(); + expect(result?.branch).toBe(branch); + expect(result?.pr).toEqual(prInfo); + }); + + it("should update existing metadata with PR info", async () => { + const branch = "existing-pr-branch"; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata); + + const prInfo: WorktreePRInfo = { + number: 999, + url: "https://github.com/owner/repo/pull/999", + title: "Updated PR", + state: "merged", + createdAt: new Date().toISOString(), + }; + + await updateWorktreePRInfo(testProjectPath, branch, prInfo); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result?.pr).toEqual(prInfo); + }); + + it("should preserve existing metadata when updating PR info", async () => { + const branch = "preserve-branch"; + const originalCreatedAt = new Date().toISOString(); + const metadata: WorktreeMetadata = { + branch, + createdAt: originalCreatedAt, + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata); + + const prInfo: WorktreePRInfo = { + number: 111, + url: "https://github.com/owner/repo/pull/111", + title: "PR", + state: "open", + createdAt: new Date().toISOString(), + }; + + await updateWorktreePRInfo(testProjectPath, branch, prInfo); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result?.createdAt).toBe(originalCreatedAt); + expect(result?.pr).toEqual(prInfo); + }); + }); + + describe("getWorktreePRInfo", () => { + it("should return null when metadata doesn't exist", async () => { + const result = await getWorktreePRInfo(testProjectPath, "nonexistent"); + expect(result).toBeNull(); + }); + + it("should return null when metadata exists but has no PR info", async () => { + const branch = "no-pr-branch"; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata); + const result = await getWorktreePRInfo(testProjectPath, branch); + expect(result).toBeNull(); + }); + + it("should return PR info when it exists", async () => { + const branch = "has-pr-branch"; + const prInfo: WorktreePRInfo = { + number: 222, + url: "https://github.com/owner/repo/pull/222", + title: "Has PR", + state: "open", + createdAt: new Date().toISOString(), + }; + + await updateWorktreePRInfo(testProjectPath, branch, prInfo); + const result = await getWorktreePRInfo(testProjectPath, branch); + expect(result).toEqual(prInfo); + }); + }); + + describe("readAllWorktreeMetadata", () => { + it("should return empty map when worktrees directory doesn't exist", async () => { + const result = await readAllWorktreeMetadata(testProjectPath); + expect(result.size).toBe(0); + }); + + it("should return empty map when worktrees directory is empty", async () => { + const worktreesDir = path.join(testProjectPath, ".automaker", "worktrees"); + await fs.mkdir(worktreesDir, { recursive: true }); + + const result = await readAllWorktreeMetadata(testProjectPath); + expect(result.size).toBe(0); + }); + + it("should read all worktree metadata", async () => { + const branch1 = "branch-1"; + const branch2 = "branch-2"; + const metadata1: WorktreeMetadata = { + branch: branch1, + createdAt: new Date().toISOString(), + }; + const metadata2: WorktreeMetadata = { + branch: branch2, + createdAt: new Date().toISOString(), + pr: { + number: 333, + url: "https://github.com/owner/repo/pull/333", + title: "PR 3", + state: "open", + createdAt: new Date().toISOString(), + }, + }; + + await writeWorktreeMetadata(testProjectPath, branch1, metadata1); + await writeWorktreeMetadata(testProjectPath, branch2, metadata2); + + const result = await readAllWorktreeMetadata(testProjectPath); + expect(result.size).toBe(2); + expect(result.get(branch1)).toEqual(metadata1); + expect(result.get(branch2)).toEqual(metadata2); + }); + + it("should skip directories without worktree.json", async () => { + const worktreesDir = path.join(testProjectPath, ".automaker", "worktrees"); + const emptyDir = path.join(worktreesDir, "empty-dir"); + await fs.mkdir(emptyDir, { recursive: true }); + + const branch = "valid-branch"; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + await writeWorktreeMetadata(testProjectPath, branch, metadata); + + const result = await readAllWorktreeMetadata(testProjectPath); + expect(result.size).toBe(1); + expect(result.get(branch)).toEqual(metadata); + }); + + it("should skip files in worktrees directory", async () => { + const worktreesDir = path.join(testProjectPath, ".automaker", "worktrees"); + await fs.mkdir(worktreesDir, { recursive: true }); + const filePath = path.join(worktreesDir, "not-a-dir.txt"); + await fs.writeFile(filePath, "content"); + + const branch = "valid-branch"; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + await writeWorktreeMetadata(testProjectPath, branch, metadata); + + const result = await readAllWorktreeMetadata(testProjectPath); + expect(result.size).toBe(1); + expect(result.get(branch)).toEqual(metadata); + }); + + it("should skip directories with malformed JSON", async () => { + const worktreesDir = path.join(testProjectPath, ".automaker", "worktrees"); + const badDir = path.join(worktreesDir, "bad-dir"); + await fs.mkdir(badDir, { recursive: true }); + const badJsonPath = path.join(badDir, "worktree.json"); + await fs.writeFile(badJsonPath, "not valid json"); + + const branch = "valid-branch"; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + await writeWorktreeMetadata(testProjectPath, branch, metadata); + + const result = await readAllWorktreeMetadata(testProjectPath); + expect(result.size).toBe(1); + expect(result.get(branch)).toEqual(metadata); + }); + }); + + describe("deleteWorktreeMetadata", () => { + it("should delete worktree metadata directory", async () => { + const branch = "to-delete"; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata); + let result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).not.toBeNull(); + + await deleteWorktreeMetadata(testProjectPath, branch); + result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).toBeNull(); + }); + + it("should handle deletion when metadata doesn't exist", async () => { + // Should not throw + await expect( + deleteWorktreeMetadata(testProjectPath, "nonexistent") + ).resolves.toBeUndefined(); + }); + }); +}); +