diff --git a/docs/llm-shared-packages.md b/docs/llm-shared-packages.md index 537f263e..a773fd90 100644 --- a/docs/llm-shared-packages.md +++ b/docs/llm-shared-packages.md @@ -343,13 +343,8 @@ Understanding the dependency chain helps prevent circular dependencies: All packages must be built before use: ```bash -# Build all packages -cd libs/types && npm run build -cd libs/utils && npm run build -cd libs/platform && npm run build -cd libs/model-resolver && npm run build -cd libs/dependency-resolver && npm run build -cd libs/git-utils && npm run build +# Build all packages from workspace +npm run build:packages # Or from root npm install # Installs and links workspace packages diff --git a/libs/dependency-resolver/package.json b/libs/dependency-resolver/package.json index 551b4494..f39140e9 100644 --- a/libs/dependency-resolver/package.json +++ b/libs/dependency-resolver/package.json @@ -14,7 +14,9 @@ }, "scripts": { "build": "tsc", - "watch": "tsc --watch" + "watch": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest" }, "keywords": ["automaker", "dependency", "resolver"], "author": "AutoMaker Team", @@ -24,6 +26,7 @@ }, "devDependencies": { "@types/node": "^22.10.5", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "vitest": "^4.0.16" } } diff --git a/libs/dependency-resolver/tests/resolver.test.ts b/libs/dependency-resolver/tests/resolver.test.ts new file mode 100644 index 00000000..54884f3c --- /dev/null +++ b/libs/dependency-resolver/tests/resolver.test.ts @@ -0,0 +1,360 @@ +import { describe, it, expect } from "vitest"; +import { + resolveDependencies, + areDependenciesSatisfied, + getBlockingDependencies, +} from "../src/resolver"; +import type { Feature } from "@automaker/types"; + +// Helper to create test features +function createFeature( + id: string, + options: { + dependencies?: string[]; + status?: string; + priority?: number; + } = {} +): Feature { + return { + id, + category: "test", + description: `Feature ${id}`, + dependencies: options.dependencies, + status: options.status || "pending", + priority: options.priority, + }; +} + +describe("resolver.ts", () => { + describe("resolveDependencies", () => { + it("should handle features with no dependencies", () => { + const features = [ + createFeature("A"), + createFeature("B"), + createFeature("C"), + ]; + + const result = resolveDependencies(features); + + expect(result.orderedFeatures).toHaveLength(3); + expect(result.circularDependencies).toEqual([]); + expect(result.missingDependencies.size).toBe(0); + expect(result.blockedFeatures.size).toBe(0); + }); + + it("should order features with linear dependencies", () => { + const features = [ + createFeature("C", { dependencies: ["B"] }), + createFeature("A"), + createFeature("B", { dependencies: ["A"] }), + ]; + + const result = resolveDependencies(features); + + const ids = result.orderedFeatures.map(f => f.id); + expect(ids.indexOf("A")).toBeLessThan(ids.indexOf("B")); + expect(ids.indexOf("B")).toBeLessThan(ids.indexOf("C")); + expect(result.circularDependencies).toEqual([]); + }); + + it("should respect priority within same dependency level", () => { + const features = [ + createFeature("Low", { priority: 3 }), + createFeature("High", { priority: 1 }), + createFeature("Medium", { priority: 2 }), + ]; + + const result = resolveDependencies(features); + + const ids = result.orderedFeatures.map(f => f.id); + expect(ids).toEqual(["High", "Medium", "Low"]); + }); + + it("should use default priority 2 when not specified", () => { + const features = [ + createFeature("NoPriority"), + createFeature("HighPriority", { priority: 1 }), + createFeature("LowPriority", { priority: 3 }), + ]; + + const result = resolveDependencies(features); + + const ids = result.orderedFeatures.map(f => f.id); + expect(ids.indexOf("HighPriority")).toBeLessThan(ids.indexOf("NoPriority")); + expect(ids.indexOf("NoPriority")).toBeLessThan(ids.indexOf("LowPriority")); + }); + + it("should respect dependencies over priority", () => { + const features = [ + createFeature("B", { dependencies: ["A"], priority: 1 }), // High priority but depends on A + createFeature("A", { priority: 3 }), // Low priority but no dependencies + ]; + + const result = resolveDependencies(features); + + const ids = result.orderedFeatures.map(f => f.id); + expect(ids.indexOf("A")).toBeLessThan(ids.indexOf("B")); + }); + + it("should detect circular dependencies (simple cycle)", () => { + const features = [ + createFeature("A", { dependencies: ["B"] }), + createFeature("B", { dependencies: ["A"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.circularDependencies).toHaveLength(1); + expect(result.circularDependencies[0]).toContain("A"); + expect(result.circularDependencies[0]).toContain("B"); + expect(result.orderedFeatures).toHaveLength(2); // All features still included + }); + + it("should detect circular dependencies (3-way cycle)", () => { + const features = [ + createFeature("A", { dependencies: ["C"] }), + createFeature("B", { dependencies: ["A"] }), + createFeature("C", { dependencies: ["B"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.circularDependencies.length).toBeGreaterThan(0); + const allCycleIds = result.circularDependencies.flat(); + expect(allCycleIds).toContain("A"); + expect(allCycleIds).toContain("B"); + expect(allCycleIds).toContain("C"); + }); + + it("should detect missing dependencies", () => { + const features = [ + createFeature("A", { dependencies: ["NonExistent"] }), + createFeature("B"), + ]; + + const result = resolveDependencies(features); + + expect(result.missingDependencies.has("A")).toBe(true); + expect(result.missingDependencies.get("A")).toContain("NonExistent"); + }); + + it("should detect blocked features (incomplete dependencies)", () => { + const features = [ + createFeature("A", { status: "pending" }), + createFeature("B", { dependencies: ["A"], status: "pending" }), + ]; + + const result = resolveDependencies(features); + + expect(result.blockedFeatures.has("B")).toBe(true); + expect(result.blockedFeatures.get("B")).toContain("A"); + }); + + it("should not mark features as blocked if dependencies are completed", () => { + const features = [ + createFeature("A", { status: "completed" }), + createFeature("B", { dependencies: ["A"], status: "pending" }), + ]; + + const result = resolveDependencies(features); + + expect(result.blockedFeatures.has("B")).toBe(false); + }); + + it("should not mark features as blocked if dependencies are verified", () => { + const features = [ + createFeature("A", { status: "verified" }), + createFeature("B", { dependencies: ["A"], status: "pending" }), + ]; + + const result = resolveDependencies(features); + + expect(result.blockedFeatures.has("B")).toBe(false); + }); + + it("should handle complex dependency graph", () => { + const features = [ + createFeature("E", { dependencies: ["C", "D"] }), + createFeature("D", { dependencies: ["B"] }), + createFeature("C", { dependencies: ["A", "B"] }), + createFeature("B"), + createFeature("A"), + ]; + + const result = resolveDependencies(features); + + const ids = result.orderedFeatures.map(f => f.id); + + // A and B have no dependencies - can be first or second + expect(ids.indexOf("A")).toBeLessThan(ids.indexOf("C")); + expect(ids.indexOf("B")).toBeLessThan(ids.indexOf("C")); + expect(ids.indexOf("B")).toBeLessThan(ids.indexOf("D")); + + // C depends on A and B + expect(ids.indexOf("C")).toBeLessThan(ids.indexOf("E")); + + // D depends on B + expect(ids.indexOf("D")).toBeLessThan(ids.indexOf("E")); + + expect(result.circularDependencies).toEqual([]); + }); + + it("should handle multiple missing dependencies", () => { + const features = [ + createFeature("A", { dependencies: ["X", "Y", "Z"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.missingDependencies.get("A")).toEqual(["X", "Y", "Z"]); + }); + + it("should handle empty feature list", () => { + const result = resolveDependencies([]); + + expect(result.orderedFeatures).toEqual([]); + expect(result.circularDependencies).toEqual([]); + expect(result.missingDependencies.size).toBe(0); + expect(result.blockedFeatures.size).toBe(0); + }); + + it("should handle features with both missing and existing dependencies", () => { + const features = [ + createFeature("A"), + createFeature("B", { dependencies: ["A", "NonExistent"] }), + ]; + + const result = resolveDependencies(features); + + expect(result.missingDependencies.get("B")).toContain("NonExistent"); + const ids = result.orderedFeatures.map(f => f.id); + expect(ids.indexOf("A")).toBeLessThan(ids.indexOf("B")); + }); + }); + + describe("areDependenciesSatisfied", () => { + it("should return true for feature with no dependencies", () => { + const feature = createFeature("A"); + const allFeatures = [feature]; + + expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true); + }); + + it("should return true for feature with empty dependencies array", () => { + const feature = createFeature("A", { dependencies: [] }); + const allFeatures = [feature]; + + expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true); + }); + + it("should return true when all dependencies are completed", () => { + const dep = createFeature("Dep", { status: "completed" }); + const feature = createFeature("A", { dependencies: ["Dep"] }); + const allFeatures = [dep, feature]; + + expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true); + }); + + it("should return true when all dependencies are verified", () => { + const dep = createFeature("Dep", { status: "verified" }); + const feature = createFeature("A", { dependencies: ["Dep"] }); + const allFeatures = [dep, feature]; + + expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true); + }); + + it("should return false when any dependency is pending", () => { + const dep = createFeature("Dep", { status: "pending" }); + const feature = createFeature("A", { dependencies: ["Dep"] }); + const allFeatures = [dep, feature]; + + expect(areDependenciesSatisfied(feature, allFeatures)).toBe(false); + }); + + it("should return false when any dependency is running", () => { + const dep = createFeature("Dep", { status: "running" }); + const feature = createFeature("A", { dependencies: ["Dep"] }); + const allFeatures = [dep, feature]; + + expect(areDependenciesSatisfied(feature, allFeatures)).toBe(false); + }); + + it("should return false when dependency is missing", () => { + const feature = createFeature("A", { dependencies: ["NonExistent"] }); + const allFeatures = [feature]; + + expect(areDependenciesSatisfied(feature, allFeatures)).toBe(false); + }); + + it("should check all dependencies", () => { + const dep1 = createFeature("Dep1", { status: "completed" }); + const dep2 = createFeature("Dep2", { status: "pending" }); + const feature = createFeature("A", { dependencies: ["Dep1", "Dep2"] }); + const allFeatures = [dep1, dep2, feature]; + + expect(areDependenciesSatisfied(feature, allFeatures)).toBe(false); + }); + }); + + describe("getBlockingDependencies", () => { + it("should return empty array for feature with no dependencies", () => { + const feature = createFeature("A"); + const allFeatures = [feature]; + + expect(getBlockingDependencies(feature, allFeatures)).toEqual([]); + }); + + it("should return empty array when all dependencies are completed", () => { + const dep = createFeature("Dep", { status: "completed" }); + const feature = createFeature("A", { dependencies: ["Dep"] }); + const allFeatures = [dep, feature]; + + expect(getBlockingDependencies(feature, allFeatures)).toEqual([]); + }); + + it("should return empty array when all dependencies are verified", () => { + const dep = createFeature("Dep", { status: "verified" }); + const feature = createFeature("A", { dependencies: ["Dep"] }); + const allFeatures = [dep, feature]; + + expect(getBlockingDependencies(feature, allFeatures)).toEqual([]); + }); + + it("should return pending dependencies", () => { + const dep = createFeature("Dep", { status: "pending" }); + const feature = createFeature("A", { dependencies: ["Dep"] }); + const allFeatures = [dep, feature]; + + expect(getBlockingDependencies(feature, allFeatures)).toEqual(["Dep"]); + }); + + it("should return running dependencies", () => { + const dep = createFeature("Dep", { status: "running" }); + const feature = createFeature("A", { dependencies: ["Dep"] }); + const allFeatures = [dep, feature]; + + expect(getBlockingDependencies(feature, allFeatures)).toEqual(["Dep"]); + }); + + it("should return failed dependencies", () => { + const dep = createFeature("Dep", { status: "failed" }); + const feature = createFeature("A", { dependencies: ["Dep"] }); + const allFeatures = [dep, feature]; + + expect(getBlockingDependencies(feature, allFeatures)).toEqual(["Dep"]); + }); + + it("should return all incomplete dependencies", () => { + const dep1 = createFeature("Dep1", { status: "pending" }); + const dep2 = createFeature("Dep2", { status: "completed" }); + const dep3 = createFeature("Dep3", { status: "running" }); + const feature = createFeature("A", { dependencies: ["Dep1", "Dep2", "Dep3"] }); + const allFeatures = [dep1, dep2, dep3, feature]; + + const blocking = getBlockingDependencies(feature, allFeatures); + expect(blocking).toContain("Dep1"); + expect(blocking).toContain("Dep3"); + expect(blocking).not.toContain("Dep2"); + }); + }); +}); diff --git a/libs/git-utils/package.json b/libs/git-utils/package.json index b1c6a81c..35145fd0 100644 --- a/libs/git-utils/package.json +++ b/libs/git-utils/package.json @@ -6,7 +6,9 @@ "types": "dist/index.d.ts", "scripts": { "build": "tsc", - "watch": "tsc --watch" + "watch": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest" }, "keywords": ["automaker", "git", "utils"], "author": "AutoMaker Team", @@ -17,6 +19,7 @@ }, "devDependencies": { "@types/node": "^22.10.5", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "vitest": "^4.0.16" } } diff --git a/libs/git-utils/tests/diff.test.ts b/libs/git-utils/tests/diff.test.ts new file mode 100644 index 00000000..6a5b810b --- /dev/null +++ b/libs/git-utils/tests/diff.test.ts @@ -0,0 +1,306 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + generateSyntheticDiffForNewFile, + appendUntrackedFileDiffs, + listAllFilesInDirectory, + generateDiffsForNonGitDirectory, + getGitRepositoryDiffs, +} from "../src/diff"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; + +describe("diff.ts", () => { + let tempDir: string; + + beforeEach(async () => { + // Create a temporary directory for each test + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "git-utils-test-")); + }); + + afterEach(async () => { + // Clean up temporary directory + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch (error) { + // Ignore cleanup errors + } + }); + + describe("generateSyntheticDiffForNewFile", () => { + it("should generate diff for binary file", async () => { + const fileName = "test.png"; + const filePath = path.join(tempDir, fileName); + await fs.writeFile(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + + const diff = await generateSyntheticDiffForNewFile(tempDir, fileName); + + expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`); + expect(diff).toContain("new file mode 100644"); + expect(diff).toContain(`Binary file ${fileName} added`); + }); + + it("should generate diff for large text file", async () => { + const fileName = "large.txt"; + const filePath = path.join(tempDir, fileName); + // Create a file > 1MB + const largeContent = "x".repeat(1024 * 1024 + 100); + await fs.writeFile(filePath, largeContent); + + const diff = await generateSyntheticDiffForNewFile(tempDir, fileName); + + expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`); + expect(diff).toContain("[File too large to display:"); + expect(diff).toMatch(/\d+KB\]/); + }); + + it("should generate diff for small text file with trailing newline", async () => { + const fileName = "test.txt"; + const filePath = path.join(tempDir, fileName); + const content = "line 1\nline 2\nline 3\n"; + await fs.writeFile(filePath, content); + + const diff = await generateSyntheticDiffForNewFile(tempDir, fileName); + + expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`); + expect(diff).toContain("new file mode 100644"); + expect(diff).toContain("--- /dev/null"); + expect(diff).toContain(`+++ b/${fileName}`); + expect(diff).toContain("@@ -0,0 +1,3 @@"); + expect(diff).toContain("+line 1"); + expect(diff).toContain("+line 2"); + expect(diff).toContain("+line 3"); + expect(diff).not.toContain("\\ No newline at end of file"); + }); + + it("should generate diff for text file without trailing newline", async () => { + const fileName = "no-newline.txt"; + const filePath = path.join(tempDir, fileName); + const content = "line 1\nline 2"; + await fs.writeFile(filePath, content); + + const diff = await generateSyntheticDiffForNewFile(tempDir, fileName); + + expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`); + expect(diff).toContain("+line 1"); + expect(diff).toContain("+line 2"); + expect(diff).toContain("\\ No newline at end of file"); + }); + + it("should generate diff for empty file", async () => { + const fileName = "empty.txt"; + const filePath = path.join(tempDir, fileName); + await fs.writeFile(filePath, ""); + + const diff = await generateSyntheticDiffForNewFile(tempDir, fileName); + + expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`); + expect(diff).toContain("@@ -0,0 +1,0 @@"); + }); + + it("should generate diff for single line file", async () => { + const fileName = "single.txt"; + const filePath = path.join(tempDir, fileName); + await fs.writeFile(filePath, "single line\n"); + + const diff = await generateSyntheticDiffForNewFile(tempDir, fileName); + + expect(diff).toContain("@@ -0,0 +1,1 @@"); + expect(diff).toContain("+single line"); + }); + + it("should handle file not found error", async () => { + const fileName = "nonexistent.txt"; + + const diff = await generateSyntheticDiffForNewFile(tempDir, fileName); + + expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`); + expect(diff).toContain("[Unable to read file content]"); + }); + }); + + describe("appendUntrackedFileDiffs", () => { + it("should return existing diff when no untracked files", async () => { + const existingDiff = "diff --git a/test.txt b/test.txt\n"; + const files = [ + { status: "M", path: "test.txt" }, + { status: "A", path: "new.txt" }, + ]; + + const result = await appendUntrackedFileDiffs(tempDir, existingDiff, files); + + expect(result).toBe(existingDiff); + }); + + it("should append synthetic diffs for untracked files", async () => { + const existingDiff = "existing diff\n"; + const untrackedFile = "untracked.txt"; + const filePath = path.join(tempDir, untrackedFile); + await fs.writeFile(filePath, "content\n"); + + const files = [ + { status: "M", path: "modified.txt" }, + { status: "?", path: untrackedFile }, + ]; + + const result = await appendUntrackedFileDiffs(tempDir, existingDiff, files); + + expect(result).toContain("existing diff"); + expect(result).toContain(`diff --git a/${untrackedFile} b/${untrackedFile}`); + expect(result).toContain("+content"); + }); + + it("should handle multiple untracked files", async () => { + const file1 = "file1.txt"; + const file2 = "file2.txt"; + await fs.writeFile(path.join(tempDir, file1), "file1\n"); + await fs.writeFile(path.join(tempDir, file2), "file2\n"); + + const files = [ + { status: "?", path: file1 }, + { status: "?", path: file2 }, + ]; + + const result = await appendUntrackedFileDiffs(tempDir, "", files); + + expect(result).toContain(`diff --git a/${file1} b/${file1}`); + expect(result).toContain(`diff --git a/${file2} b/${file2}`); + expect(result).toContain("+file1"); + expect(result).toContain("+file2"); + }); + }); + + describe("listAllFilesInDirectory", () => { + it("should list files in empty directory", async () => { + const files = await listAllFilesInDirectory(tempDir); + expect(files).toEqual([]); + }); + + it("should list files in flat directory", async () => { + await fs.writeFile(path.join(tempDir, "file1.txt"), "content"); + await fs.writeFile(path.join(tempDir, "file2.js"), "code"); + + const files = await listAllFilesInDirectory(tempDir); + + expect(files).toHaveLength(2); + expect(files).toContain("file1.txt"); + expect(files).toContain("file2.js"); + }); + + it("should list files in nested directories", async () => { + await fs.mkdir(path.join(tempDir, "subdir")); + await fs.writeFile(path.join(tempDir, "root.txt"), ""); + await fs.writeFile(path.join(tempDir, "subdir", "nested.txt"), ""); + + const files = await listAllFilesInDirectory(tempDir); + + expect(files).toHaveLength(2); + expect(files).toContain("root.txt"); + expect(files).toContain("subdir/nested.txt"); + }); + + it("should skip node_modules directory", async () => { + await fs.mkdir(path.join(tempDir, "node_modules")); + await fs.writeFile(path.join(tempDir, "app.js"), ""); + await fs.writeFile(path.join(tempDir, "node_modules", "package.js"), ""); + + const files = await listAllFilesInDirectory(tempDir); + + expect(files).toHaveLength(1); + expect(files).toContain("app.js"); + expect(files).not.toContain("node_modules/package.js"); + }); + + it("should skip common build directories", async () => { + await fs.mkdir(path.join(tempDir, "dist")); + await fs.mkdir(path.join(tempDir, "build")); + await fs.mkdir(path.join(tempDir, ".next")); + await fs.writeFile(path.join(tempDir, "source.ts"), ""); + await fs.writeFile(path.join(tempDir, "dist", "output.js"), ""); + await fs.writeFile(path.join(tempDir, "build", "output.js"), ""); + + const files = await listAllFilesInDirectory(tempDir); + + expect(files).toHaveLength(1); + expect(files).toContain("source.ts"); + }); + + it("should skip hidden files except .env", async () => { + await fs.writeFile(path.join(tempDir, ".hidden"), ""); + await fs.writeFile(path.join(tempDir, ".env"), ""); + await fs.writeFile(path.join(tempDir, "visible.txt"), ""); + + const files = await listAllFilesInDirectory(tempDir); + + expect(files).toHaveLength(2); + expect(files).toContain(".env"); + expect(files).toContain("visible.txt"); + expect(files).not.toContain(".hidden"); + }); + + it("should skip .git directory", async () => { + await fs.mkdir(path.join(tempDir, ".git")); + await fs.writeFile(path.join(tempDir, ".git", "config"), ""); + await fs.writeFile(path.join(tempDir, "README.md"), ""); + + const files = await listAllFilesInDirectory(tempDir); + + expect(files).toHaveLength(1); + expect(files).toContain("README.md"); + }); + }); + + describe("generateDiffsForNonGitDirectory", () => { + it("should generate diffs for all files in directory", async () => { + await fs.writeFile(path.join(tempDir, "file1.txt"), "content1\n"); + await fs.writeFile(path.join(tempDir, "file2.js"), "console.log('hi');\n"); + + const result = await generateDiffsForNonGitDirectory(tempDir); + + expect(result.files).toHaveLength(2); + expect(result.files.every(f => f.status === "?")).toBe(true); + expect(result.diff).toContain("diff --git a/file1.txt b/file1.txt"); + expect(result.diff).toContain("diff --git a/file2.js b/file2.js"); + expect(result.diff).toContain("+content1"); + expect(result.diff).toContain("+console.log('hi');"); + }); + + it("should return empty result for empty directory", async () => { + const result = await generateDiffsForNonGitDirectory(tempDir); + + expect(result.files).toEqual([]); + expect(result.diff).toBe(""); + }); + + it("should mark all files as untracked", async () => { + await fs.writeFile(path.join(tempDir, "test.txt"), "test"); + + const result = await generateDiffsForNonGitDirectory(tempDir); + + expect(result.files).toHaveLength(1); + expect(result.files[0].status).toBe("?"); + expect(result.files[0].statusText).toBe("New"); + }); + }); + + describe("getGitRepositoryDiffs", () => { + it("should treat non-git directory as all new files", async () => { + await fs.writeFile(path.join(tempDir, "file.txt"), "content\n"); + + const result = await getGitRepositoryDiffs(tempDir); + + expect(result.hasChanges).toBe(true); + expect(result.files).toHaveLength(1); + expect(result.files[0].status).toBe("?"); + expect(result.diff).toContain("diff --git a/file.txt b/file.txt"); + }); + + it("should return no changes for empty non-git directory", async () => { + const result = await getGitRepositoryDiffs(tempDir); + + expect(result.hasChanges).toBe(false); + expect(result.files).toEqual([]); + expect(result.diff).toBe(""); + }); + }); +}); diff --git a/libs/utils/package.json b/libs/utils/package.json index 72e2fb82..6f1cd182 100644 --- a/libs/utils/package.json +++ b/libs/utils/package.json @@ -6,7 +6,9 @@ "types": "dist/index.d.ts", "scripts": { "build": "tsc", - "watch": "tsc --watch" + "watch": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest" }, "keywords": ["automaker", "utils"], "author": "AutoMaker Team", @@ -16,6 +18,7 @@ }, "devDependencies": { "@types/node": "^22.10.5", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "vitest": "^4.0.16" } } diff --git a/libs/utils/tests/error-handler.test.ts b/libs/utils/tests/error-handler.test.ts new file mode 100644 index 00000000..e4592813 --- /dev/null +++ b/libs/utils/tests/error-handler.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect } from "vitest"; +import { + isAbortError, + isCancellationError, + isAuthenticationError, + classifyError, + getUserFriendlyErrorMessage, +} from "../src/error-handler"; + +describe("error-handler.ts", () => { + describe("isAbortError", () => { + it("should return true for Error with name 'AbortError'", () => { + const error = new Error("Operation aborted"); + error.name = "AbortError"; + expect(isAbortError(error)).toBe(true); + }); + + it("should return true for Error with message containing 'abort'", () => { + const error = new Error("Request was aborted"); + expect(isAbortError(error)).toBe(true); + }); + + it("should return false for regular Error", () => { + const error = new Error("Something went wrong"); + expect(isAbortError(error)).toBe(false); + }); + + it("should return false for non-Error values", () => { + expect(isAbortError("abort")).toBe(false); + expect(isAbortError(null)).toBe(false); + expect(isAbortError(undefined)).toBe(false); + expect(isAbortError({})).toBe(false); + }); + + it("should handle Error with both AbortError name and abort message", () => { + const error = new Error("abort"); + error.name = "AbortError"; + expect(isAbortError(error)).toBe(true); + }); + }); + + describe("isCancellationError", () => { + it("should return true for 'cancelled' message", () => { + expect(isCancellationError("Operation cancelled")).toBe(true); + expect(isCancellationError("CANCELLED")).toBe(true); + }); + + it("should return true for 'canceled' message (US spelling)", () => { + expect(isCancellationError("Operation canceled")).toBe(true); + expect(isCancellationError("CANCELED")).toBe(true); + }); + + it("should return true for 'stopped' message", () => { + expect(isCancellationError("Process stopped")).toBe(true); + expect(isCancellationError("STOPPED")).toBe(true); + }); + + it("should return true for 'aborted' message", () => { + expect(isCancellationError("Request aborted")).toBe(true); + expect(isCancellationError("ABORTED")).toBe(true); + }); + + it("should return false for non-cancellation messages", () => { + expect(isCancellationError("Something went wrong")).toBe(false); + expect(isCancellationError("Error occurred")).toBe(false); + expect(isCancellationError("")).toBe(false); + }); + + it("should be case-insensitive", () => { + expect(isCancellationError("CaNcElLeD")).toBe(true); + expect(isCancellationError("StOpPeD")).toBe(true); + }); + }); + + describe("isAuthenticationError", () => { + it("should return true for 'Authentication failed' message", () => { + expect(isAuthenticationError("Authentication failed")).toBe(true); + }); + + it("should return true for 'Invalid API key' message", () => { + expect(isAuthenticationError("Invalid API key provided")).toBe(true); + }); + + it("should return true for 'authentication_failed' message", () => { + expect(isAuthenticationError("Error: authentication_failed")).toBe(true); + }); + + it("should return true for 'Fix external API key' message", () => { + expect(isAuthenticationError("Fix external API key configuration")).toBe(true); + }); + + it("should return false for non-authentication errors", () => { + expect(isAuthenticationError("Something went wrong")).toBe(false); + expect(isAuthenticationError("Network error")).toBe(false); + expect(isAuthenticationError("")).toBe(false); + }); + + it("should be case-sensitive", () => { + expect(isAuthenticationError("authentication failed")).toBe(false); + expect(isAuthenticationError("AUTHENTICATION FAILED")).toBe(false); + }); + }); + + describe("classifyError", () => { + it("should classify authentication errors", () => { + const error = new Error("Authentication failed"); + const result = classifyError(error); + + expect(result.type).toBe("authentication"); + expect(result.isAuth).toBe(true); + expect(result.isAbort).toBe(false); + expect(result.isCancellation).toBe(false); + expect(result.message).toBe("Authentication failed"); + expect(result.originalError).toBe(error); + }); + + it("should classify abort errors", () => { + const error = new Error("aborted"); + const result = classifyError(error); + + expect(result.type).toBe("abort"); + expect(result.isAbort).toBe(true); + expect(result.isAuth).toBe(false); + expect(result.message).toBe("aborted"); + }); + + it("should classify AbortError by name", () => { + const error = new Error("Request cancelled"); + error.name = "AbortError"; + const result = classifyError(error); + + expect(result.type).toBe("abort"); + expect(result.isAbort).toBe(true); + }); + + it("should classify cancellation errors", () => { + const error = new Error("Operation cancelled"); + const result = classifyError(error); + + expect(result.type).toBe("cancellation"); + expect(result.isCancellation).toBe(true); + expect(result.isAbort).toBe(false); + }); + + it("should classify execution errors (regular Error)", () => { + const error = new Error("Something went wrong"); + const result = classifyError(error); + + expect(result.type).toBe("execution"); + expect(result.isAuth).toBe(false); + expect(result.isAbort).toBe(false); + expect(result.isCancellation).toBe(false); + }); + + it("should classify unknown errors (non-Error)", () => { + const result = classifyError("string error"); + + expect(result.type).toBe("unknown"); + expect(result.message).toBe("string error"); + }); + + it("should handle null/undefined errors", () => { + const result1 = classifyError(null); + expect(result1.type).toBe("unknown"); + expect(result1.message).toBe("Unknown error"); + + const result2 = classifyError(undefined); + expect(result2.type).toBe("unknown"); + expect(result2.message).toBe("Unknown error"); + }); + + it("should prioritize authentication over abort", () => { + const error = new Error("Authentication failed - aborted"); + const result = classifyError(error); + + expect(result.type).toBe("authentication"); + expect(result.isAuth).toBe(true); + expect(result.isAbort).toBe(true); // Both flags can be true + }); + + it("should prioritize abort over cancellation", () => { + const error = new Error("Request cancelled"); + error.name = "AbortError"; + const result = classifyError(error); + + expect(result.type).toBe("abort"); + expect(result.isAbort).toBe(true); + expect(result.isCancellation).toBe(true); // Both flags can be true + }); + + it("should convert object errors to string", () => { + const result = classifyError({ code: 500, message: "Server error" }); + expect(result.message).toContain("Object"); + }); + + it("should convert number errors to string", () => { + const result = classifyError(404); + expect(result.message).toBe("404"); + expect(result.type).toBe("unknown"); + }); + }); + + describe("getUserFriendlyErrorMessage", () => { + it("should return friendly message for abort errors", () => { + const error = new Error("abort"); + const message = getUserFriendlyErrorMessage(error); + + expect(message).toBe("Operation was cancelled"); + }); + + it("should return friendly message for AbortError by name", () => { + const error = new Error("Something"); + error.name = "AbortError"; + const message = getUserFriendlyErrorMessage(error); + + expect(message).toBe("Operation was cancelled"); + }); + + it("should return friendly message for authentication errors", () => { + const error = new Error("Authentication failed"); + const message = getUserFriendlyErrorMessage(error); + + expect(message).toBe("Authentication failed. Please check your API key."); + }); + + it("should prioritize abort message over auth", () => { + const error = new Error("Authentication failed - abort"); + const message = getUserFriendlyErrorMessage(error); + + // Auth is checked first in classifyError, but abort check happens before auth in getUserFriendlyErrorMessage + expect(message).toBe("Operation was cancelled"); + }); + + it("should return original message for other errors", () => { + const error = new Error("Network timeout"); + const message = getUserFriendlyErrorMessage(error); + + expect(message).toBe("Network timeout"); + }); + + it("should handle non-Error values", () => { + expect(getUserFriendlyErrorMessage("string error")).toBe("string error"); + expect(getUserFriendlyErrorMessage(null)).toBe("Unknown error"); + expect(getUserFriendlyErrorMessage(undefined)).toBe("Unknown error"); + }); + + it("should return original message for cancellation errors", () => { + const error = new Error("Operation cancelled by user"); + const message = getUserFriendlyErrorMessage(error); + + expect(message).toBe("Operation cancelled by user"); + }); + + it("should handle Error without message", () => { + const error = new Error(); + const message = getUserFriendlyErrorMessage(error); + + expect(message).toBe(""); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 98cc3a13..6405b2d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "apps/server": { "name": "@automaker/server", "version": "0.1.0", + "license": "SEE LICENSE IN LICENSE", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.1.72", "@automaker/dependency-resolver": "^1.0.0", @@ -62,7 +63,7 @@ "name": "@automaker/ui", "version": "0.1.0", "hasInstallScript": true, - "license": "Unlicense", + "license": "SEE LICENSE IN LICENSE", "dependencies": { "@automaker/dependency-resolver": "^1.0.0", "@automaker/types": "^1.0.0", @@ -162,12 +163,14 @@ "libs/dependency-resolver": { "name": "@automaker/dependency-resolver", "version": "1.0.0", + "license": "SEE LICENSE IN LICENSE", "dependencies": { "@automaker/types": "^1.0.0" }, "devDependencies": { "@types/node": "^22.10.5", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "vitest": "^4.0.16" } }, "libs/dependency-resolver/node_modules/@types/node": { @@ -183,13 +186,15 @@ "libs/git-utils": { "name": "@automaker/git-utils", "version": "1.0.0", + "license": "SEE LICENSE IN LICENSE", "dependencies": { "@automaker/types": "^1.0.0", "@automaker/utils": "^1.0.0" }, "devDependencies": { "@types/node": "^22.10.5", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "vitest": "^4.0.16" } }, "libs/git-utils/node_modules/@types/node": { @@ -205,6 +210,7 @@ "libs/model-resolver": { "name": "@automaker/model-resolver", "version": "1.0.0", + "license": "SEE LICENSE IN LICENSE", "dependencies": { "@automaker/types": "^1.0.0" }, @@ -226,6 +232,7 @@ "libs/platform": { "name": "@automaker/platform", "version": "1.0.0", + "license": "SEE LICENSE IN LICENSE", "dependencies": { "@automaker/types": "^1.0.0" }, @@ -507,6 +514,7 @@ "libs/types": { "name": "@automaker/types", "version": "1.0.0", + "license": "SEE LICENSE IN LICENSE", "devDependencies": { "@types/node": "^22.10.5", "typescript": "^5.7.3" @@ -525,12 +533,14 @@ "libs/utils": { "name": "@automaker/utils", "version": "1.0.0", + "license": "SEE LICENSE IN LICENSE", "dependencies": { "@automaker/types": "^1.0.0" }, "devDependencies": { "@types/node": "^22.10.5", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "vitest": "^4.0.16" } }, "libs/utils/node_modules/@types/node": {