import { describe, it, expect, vi, beforeEach } from "vitest"; import { FeatureLoader } from "@/services/feature-loader.js"; import * as fs from "fs/promises"; import path from "path"; vi.mock("fs/promises"); describe("feature-loader.ts", () => { let loader: FeatureLoader; const testProjectPath = "/test/project"; beforeEach(() => { vi.clearAllMocks(); loader = new FeatureLoader(); }); describe("getFeaturesDir", () => { it("should return features directory path", () => { const result = loader.getFeaturesDir(testProjectPath); expect(result).toContain("test"); expect(result).toContain("project"); expect(result).toContain(".automaker"); expect(result).toContain("features"); }); }); describe("getFeatureImagesDir", () => { it("should return feature images directory path", () => { const result = loader.getFeatureImagesDir(testProjectPath, "feature-123"); expect(result).toContain("features"); expect(result).toContain("feature-123"); expect(result).toContain("images"); }); }); describe("getFeatureDir", () => { it("should return feature directory path", () => { const result = loader.getFeatureDir(testProjectPath, "feature-123"); expect(result).toContain("features"); expect(result).toContain("feature-123"); }); }); describe("getFeatureJsonPath", () => { it("should return feature.json path", () => { const result = loader.getFeatureJsonPath(testProjectPath, "feature-123"); expect(result).toContain("features"); expect(result).toContain("feature-123"); expect(result).toContain("feature.json"); }); }); describe("getAgentOutputPath", () => { it("should return agent-output.md path", () => { const result = loader.getAgentOutputPath(testProjectPath, "feature-123"); expect(result).toContain("features"); expect(result).toContain("feature-123"); expect(result).toContain("agent-output.md"); }); }); describe("generateFeatureId", () => { it("should generate unique feature ID with timestamp", () => { const id1 = loader.generateFeatureId(); const id2 = loader.generateFeatureId(); expect(id1).toMatch(/^feature-\d+-[a-z0-9]+$/); expect(id2).toMatch(/^feature-\d+-[a-z0-9]+$/); expect(id1).not.toBe(id2); }); it("should start with 'feature-'", () => { const id = loader.generateFeatureId(); expect(id).toMatch(/^feature-/); }); }); describe("getAll", () => { it("should return empty array when features directory doesn't exist", async () => { vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT")); const result = await loader.getAll(testProjectPath); expect(result).toEqual([]); }); it("should load all features from feature directories", async () => { vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readdir).mockResolvedValue([ { name: "feature-1", isDirectory: () => true } as any, { name: "feature-2", isDirectory: () => true } as any, { name: "file.txt", isDirectory: () => false } as any, ]); vi.mocked(fs.readFile) .mockResolvedValueOnce( JSON.stringify({ id: "feature-1", category: "ui", description: "Feature 1", }) ) .mockResolvedValueOnce( JSON.stringify({ id: "feature-2", category: "backend", description: "Feature 2", }) ); const result = await loader.getAll(testProjectPath); expect(result).toHaveLength(2); expect(result[0].id).toBe("feature-1"); expect(result[1].id).toBe("feature-2"); }); it("should skip features without id field", async () => { vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readdir).mockResolvedValue([ { name: "feature-1", isDirectory: () => true } as any, { name: "feature-2", isDirectory: () => true } as any, ]); const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); vi.mocked(fs.readFile) .mockResolvedValueOnce( JSON.stringify({ category: "ui", description: "Missing ID", }) ) .mockResolvedValueOnce( JSON.stringify({ id: "feature-2", category: "backend", description: "Feature 2", }) ); const result = await loader.getAll(testProjectPath); expect(result).toHaveLength(1); expect(result[0].id).toBe("feature-2"); expect(consoleSpy).toHaveBeenCalledWith( "[FeatureLoader]", expect.stringContaining("missing required 'id' field") ); consoleSpy.mockRestore(); }); it("should skip features with missing feature.json", async () => { vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readdir).mockResolvedValue([ { name: "feature-1", isDirectory: () => true } as any, { name: "feature-2", isDirectory: () => true } as any, ]); const error: any = new Error("File not found"); error.code = "ENOENT"; vi.mocked(fs.readFile) .mockRejectedValueOnce(error) .mockResolvedValueOnce( JSON.stringify({ id: "feature-2", category: "backend", description: "Feature 2", }) ); const result = await loader.getAll(testProjectPath); expect(result).toHaveLength(1); expect(result[0].id).toBe("feature-2"); }); it("should handle malformed JSON gracefully", async () => { vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readdir).mockResolvedValue([ { name: "feature-1", isDirectory: () => true } as any, ]); const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); vi.mocked(fs.readFile).mockResolvedValue("invalid json{"); const result = await loader.getAll(testProjectPath); expect(result).toEqual([]); expect(consoleSpy).toHaveBeenCalledWith( "[FeatureLoader]", expect.stringContaining("Failed to parse feature.json") ); consoleSpy.mockRestore(); }); it("should sort features by creation order (timestamp)", async () => { vi.mocked(fs.access).mockResolvedValue(undefined); vi.mocked(fs.readdir).mockResolvedValue([ { name: "feature-3", isDirectory: () => true } as any, { name: "feature-1", isDirectory: () => true } as any, { name: "feature-2", isDirectory: () => true } as any, ]); vi.mocked(fs.readFile) .mockResolvedValueOnce( JSON.stringify({ id: "feature-3000-xyz", category: "ui", }) ) .mockResolvedValueOnce( JSON.stringify({ id: "feature-1000-abc", category: "ui", }) ) .mockResolvedValueOnce( JSON.stringify({ id: "feature-2000-def", category: "ui", }) ); const result = await loader.getAll(testProjectPath); expect(result).toHaveLength(3); expect(result[0].id).toBe("feature-1000-abc"); expect(result[1].id).toBe("feature-2000-def"); expect(result[2].id).toBe("feature-3000-xyz"); }); }); describe("get", () => { it("should return feature by ID", async () => { const featureData = { id: "feature-123", category: "ui", description: "Test feature", }; vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(featureData)); const result = await loader.get(testProjectPath, "feature-123"); expect(result).toEqual(featureData); }); it("should return null when feature doesn't exist", async () => { const error: any = new Error("File not found"); error.code = "ENOENT"; vi.mocked(fs.readFile).mockRejectedValue(error); const result = await loader.get(testProjectPath, "feature-123"); expect(result).toBeNull(); }); it("should throw on other errors", async () => { vi.mocked(fs.readFile).mockRejectedValue(new Error("Permission denied")); await expect( loader.get(testProjectPath, "feature-123") ).rejects.toThrow("Permission denied"); }); }); describe("create", () => { it("should create new feature", async () => { vi.mocked(fs.mkdir).mockResolvedValue(undefined); vi.mocked(fs.writeFile).mockResolvedValue(undefined); const featureData = { category: "ui", description: "New feature", }; const result = await loader.create(testProjectPath, featureData); expect(result).toMatchObject({ category: "ui", description: "New feature", id: expect.stringMatching(/^feature-/), }); expect(fs.writeFile).toHaveBeenCalled(); }); it("should use provided ID if given", async () => { vi.mocked(fs.mkdir).mockResolvedValue(undefined); vi.mocked(fs.writeFile).mockResolvedValue(undefined); const result = await loader.create(testProjectPath, { id: "custom-id", category: "ui", description: "Test", }); expect(result.id).toBe("custom-id"); }); it("should set default category if not provided", async () => { vi.mocked(fs.mkdir).mockResolvedValue(undefined); vi.mocked(fs.writeFile).mockResolvedValue(undefined); const result = await loader.create(testProjectPath, { description: "Test", }); expect(result.category).toBe("Uncategorized"); }); }); describe("update", () => { it("should update existing feature", async () => { vi.mocked(fs.readFile).mockResolvedValue( JSON.stringify({ id: "feature-123", category: "ui", description: "Old description", }) ); vi.mocked(fs.writeFile).mockResolvedValue(undefined); const result = await loader.update(testProjectPath, "feature-123", { description: "New description", }); expect(result.description).toBe("New description"); expect(result.category).toBe("ui"); expect(fs.writeFile).toHaveBeenCalled(); }); it("should throw if feature doesn't exist", async () => { const error: any = new Error("File not found"); error.code = "ENOENT"; vi.mocked(fs.readFile).mockRejectedValue(error); await expect( loader.update(testProjectPath, "feature-123", {}) ).rejects.toThrow("not found"); }); }); describe("delete", () => { it("should delete feature directory", async () => { vi.mocked(fs.rm).mockResolvedValue(undefined); const result = await loader.delete(testProjectPath, "feature-123"); expect(result).toBe(true); expect(fs.rm).toHaveBeenCalledWith( expect.stringContaining("feature-123"), { recursive: true, force: true } ); }); it("should return false on error", async () => { vi.mocked(fs.rm).mockRejectedValue(new Error("Permission denied")); const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const result = await loader.delete(testProjectPath, "feature-123"); expect(result).toBe(false); expect(consoleSpy).toHaveBeenCalledWith( "[FeatureLoader]", expect.stringContaining("Failed to delete feature"), expect.objectContaining({ message: "Permission denied" }) ); consoleSpy.mockRestore(); }); }); describe("getAgentOutput", () => { it("should return agent output content", async () => { vi.mocked(fs.readFile).mockResolvedValue("Agent output content"); const result = await loader.getAgentOutput(testProjectPath, "feature-123"); expect(result).toBe("Agent output content"); }); it("should return null when file doesn't exist", async () => { const error: any = new Error("File not found"); error.code = "ENOENT"; vi.mocked(fs.readFile).mockRejectedValue(error); const result = await loader.getAgentOutput(testProjectPath, "feature-123"); expect(result).toBeNull(); }); it("should throw on other errors", async () => { vi.mocked(fs.readFile).mockRejectedValue(new Error("Permission denied")); await expect( loader.getAgentOutput(testProjectPath, "feature-123") ).rejects.toThrow("Permission denied"); }); }); describe("saveAgentOutput", () => { it("should save agent output to file", async () => { vi.mocked(fs.mkdir).mockResolvedValue(undefined); vi.mocked(fs.writeFile).mockResolvedValue(undefined); await loader.saveAgentOutput( testProjectPath, "feature-123", "Output content" ); expect(fs.writeFile).toHaveBeenCalledWith( expect.stringContaining("agent-output.md"), "Output content", "utf-8" ); }); }); describe("deleteAgentOutput", () => { it("should delete agent output file", async () => { vi.mocked(fs.unlink).mockResolvedValue(undefined); await loader.deleteAgentOutput(testProjectPath, "feature-123"); expect(fs.unlink).toHaveBeenCalledWith( expect.stringContaining("agent-output.md") ); }); it("should handle missing file gracefully", async () => { const error: any = new Error("File not found"); error.code = "ENOENT"; vi.mocked(fs.unlink).mockRejectedValue(error); // Should not throw await expect( loader.deleteAgentOutput(testProjectPath, "feature-123") ).resolves.toBeUndefined(); }); it("should throw on other errors", async () => { vi.mocked(fs.unlink).mockRejectedValue(new Error("Permission denied")); await expect( loader.deleteAgentOutput(testProjectPath, "feature-123") ).rejects.toThrow("Permission denied"); }); }); });