From 30f4315c17b7aef1299d387e14ceadedd1fc6045 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 20 Dec 2025 23:35:31 +0100 Subject: [PATCH] test: Add comprehensive tests for platform and utils packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added extensive test coverage for previously untested files: Platform package (94.69% coverage, +47 tests): - paths.test.ts: 22 tests for path construction and directory creation - security.test.ts: 25 tests for path validation and security Utils package (94.3% coverage, +109 tests): - logger.test.ts: 23 tests for logging with levels - fs-utils.test.ts: 20 tests for safe file operations - conversation-utils.test.ts: 24 tests for message formatting - image-handler.test.ts: 25 tests for image processing - prompt-builder.test.ts: 17 tests for prompt construction Coverage improvements: - Platform: 63.71% → 94.69% stmts, 40% → 97.14% funcs - Utils: 19.51% → 94.3% stmts, 18.51% → 100% funcs Updated thresholds to enforce high quality: - Platform: 90% lines/stmts, 95% funcs, 75% branches - Utils: 90% lines/stmts, 95% funcs, 85% branches Total new tests: 156 (platform: 47, utils: 109) All tests passing with new coverage thresholds. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- libs/platform/tests/paths.test.ts | 227 ++++++++++++++ libs/platform/tests/security.test.ts | 238 +++++++++++++++ libs/platform/vitest.config.ts | 12 +- libs/utils/tests/conversation-utils.test.ts | 261 ++++++++++++++++ libs/utils/tests/fs-utils.test.ts | 254 +++++++++++++++ libs/utils/tests/image-handler.test.ts | 250 +++++++++++++++ libs/utils/tests/logger.test.ts | 323 ++++++++++++++++++++ libs/utils/tests/prompt-builder.test.ts | 316 +++++++++++++++++++ libs/utils/vitest.config.ts | 12 +- 9 files changed, 1881 insertions(+), 12 deletions(-) create mode 100644 libs/platform/tests/paths.test.ts create mode 100644 libs/platform/tests/security.test.ts create mode 100644 libs/utils/tests/conversation-utils.test.ts create mode 100644 libs/utils/tests/fs-utils.test.ts create mode 100644 libs/utils/tests/image-handler.test.ts create mode 100644 libs/utils/tests/logger.test.ts create mode 100644 libs/utils/tests/prompt-builder.test.ts diff --git a/libs/platform/tests/paths.test.ts b/libs/platform/tests/paths.test.ts new file mode 100644 index 00000000..a38b995b --- /dev/null +++ b/libs/platform/tests/paths.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; +import { + getAutomakerDir, + getFeaturesDir, + getFeatureDir, + getFeatureImagesDir, + getBoardDir, + getImagesDir, + getContextDir, + getWorktreesDir, + getAppSpecPath, + getBranchTrackingPath, + ensureAutomakerDir, + getGlobalSettingsPath, + getCredentialsPath, + getProjectSettingsPath, + ensureDataDir, +} from "../src/paths"; + +describe("paths.ts", () => { + let tempDir: string; + let projectPath: string; + let dataDir: string; + + beforeEach(async () => { + // Create a temporary directory for testing + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "platform-paths-test-")); + projectPath = path.join(tempDir, "test-project"); + dataDir = path.join(tempDir, "user-data"); + await fs.mkdir(projectPath, { recursive: true }); + }); + + afterEach(async () => { + // Clean up temporary directory + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch (error) { + // Ignore cleanup errors + } + }); + + describe("Project-level path construction", () => { + it("should return automaker directory path", () => { + const result = getAutomakerDir(projectPath); + expect(result).toBe(path.join(projectPath, ".automaker")); + }); + + it("should return features directory path", () => { + const result = getFeaturesDir(projectPath); + expect(result).toBe(path.join(projectPath, ".automaker", "features")); + }); + + it("should return feature directory path", () => { + const featureId = "auth-feature"; + const result = getFeatureDir(projectPath, featureId); + expect(result).toBe( + path.join(projectPath, ".automaker", "features", featureId) + ); + }); + + it("should return feature images directory path", () => { + const featureId = "auth-feature"; + const result = getFeatureImagesDir(projectPath, featureId); + expect(result).toBe( + path.join(projectPath, ".automaker", "features", featureId, "images") + ); + }); + + it("should return board directory path", () => { + const result = getBoardDir(projectPath); + expect(result).toBe(path.join(projectPath, ".automaker", "board")); + }); + + it("should return images directory path", () => { + const result = getImagesDir(projectPath); + expect(result).toBe(path.join(projectPath, ".automaker", "images")); + }); + + it("should return context directory path", () => { + const result = getContextDir(projectPath); + expect(result).toBe(path.join(projectPath, ".automaker", "context")); + }); + + it("should return worktrees directory path", () => { + const result = getWorktreesDir(projectPath); + expect(result).toBe(path.join(projectPath, ".automaker", "worktrees")); + }); + + it("should return app spec file path", () => { + const result = getAppSpecPath(projectPath); + expect(result).toBe( + path.join(projectPath, ".automaker", "app_spec.txt") + ); + }); + + it("should return branch tracking file path", () => { + const result = getBranchTrackingPath(projectPath); + expect(result).toBe( + path.join(projectPath, ".automaker", "active-branches.json") + ); + }); + + it("should return project settings file path", () => { + const result = getProjectSettingsPath(projectPath); + expect(result).toBe( + path.join(projectPath, ".automaker", "settings.json") + ); + }); + }); + + describe("Global settings path construction", () => { + it("should return global settings path", () => { + const result = getGlobalSettingsPath(dataDir); + expect(result).toBe(path.join(dataDir, "settings.json")); + }); + + it("should return credentials path", () => { + const result = getCredentialsPath(dataDir); + expect(result).toBe(path.join(dataDir, "credentials.json")); + }); + }); + + describe("Directory creation", () => { + it("should create automaker directory", async () => { + const automakerDir = await ensureAutomakerDir(projectPath); + + expect(automakerDir).toBe(path.join(projectPath, ".automaker")); + + const stats = await fs.stat(automakerDir); + expect(stats.isDirectory()).toBe(true); + }); + + it("should be idempotent when creating automaker directory", async () => { + // Create directory first time + const firstResult = await ensureAutomakerDir(projectPath); + + // Create directory second time + const secondResult = await ensureAutomakerDir(projectPath); + + expect(firstResult).toBe(secondResult); + + const stats = await fs.stat(firstResult); + expect(stats.isDirectory()).toBe(true); + }); + + it("should create data directory", async () => { + const result = await ensureDataDir(dataDir); + + expect(result).toBe(dataDir); + + const stats = await fs.stat(dataDir); + expect(stats.isDirectory()).toBe(true); + }); + + it("should be idempotent when creating data directory", async () => { + // Create directory first time + const firstResult = await ensureDataDir(dataDir); + + // Create directory second time + const secondResult = await ensureDataDir(dataDir); + + expect(firstResult).toBe(secondResult); + + const stats = await fs.stat(firstResult); + expect(stats.isDirectory()).toBe(true); + }); + + it("should create nested directories recursively", async () => { + const deepProjectPath = path.join( + tempDir, + "nested", + "deep", + "project" + ); + await fs.mkdir(deepProjectPath, { recursive: true }); + + const automakerDir = await ensureAutomakerDir(deepProjectPath); + + const stats = await fs.stat(automakerDir); + expect(stats.isDirectory()).toBe(true); + }); + }); + + describe("Path handling with special characters", () => { + it("should handle feature IDs with special characters", () => { + const featureId = "feature-with-dashes_and_underscores"; + const result = getFeatureDir(projectPath, featureId); + expect(result).toContain(featureId); + }); + + it("should handle paths with spaces", () => { + const pathWithSpaces = path.join(tempDir, "path with spaces"); + const result = getAutomakerDir(pathWithSpaces); + expect(result).toBe(path.join(pathWithSpaces, ".automaker")); + }); + }); + + describe("Path relationships", () => { + it("should have feature dir as child of features dir", () => { + const featuresDir = getFeaturesDir(projectPath); + const featureDir = getFeatureDir(projectPath, "test-feature"); + + expect(featureDir.startsWith(featuresDir)).toBe(true); + }); + + it("should have all project paths under automaker dir", () => { + const automakerDir = getAutomakerDir(projectPath); + const paths = [ + getFeaturesDir(projectPath), + getBoardDir(projectPath), + getImagesDir(projectPath), + getContextDir(projectPath), + getWorktreesDir(projectPath), + getAppSpecPath(projectPath), + getBranchTrackingPath(projectPath), + getProjectSettingsPath(projectPath), + ]; + + paths.forEach((p) => { + expect(p.startsWith(automakerDir)).toBe(true); + }); + }); + }); +}); diff --git a/libs/platform/tests/security.test.ts b/libs/platform/tests/security.test.ts new file mode 100644 index 00000000..e38edad3 --- /dev/null +++ b/libs/platform/tests/security.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import path from "path"; +import { + initAllowedPaths, + addAllowedPath, + isPathAllowed, + validatePath, + getAllowedPaths, +} from "../src/security"; + +describe("security.ts", () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + // Save original environment + originalEnv = { ...process.env }; + }); + + afterEach(() => { + // Restore original environment + process.env = originalEnv; + }); + + describe("initAllowedPaths", () => { + it("should initialize from ALLOWED_PROJECT_DIRS environment variable", () => { + process.env.ALLOWED_PROJECT_DIRS = "/path/one,/path/two,/path/three"; + + initAllowedPaths(); + + const allowed = getAllowedPaths(); + expect(allowed).toContain(path.resolve("/path/one")); + expect(allowed).toContain(path.resolve("/path/two")); + expect(allowed).toContain(path.resolve("/path/three")); + }); + + it("should trim whitespace from paths", () => { + process.env.ALLOWED_PROJECT_DIRS = " /path/one , /path/two , /path/three "; + + initAllowedPaths(); + + const allowed = getAllowedPaths(); + expect(allowed).toContain(path.resolve("/path/one")); + expect(allowed).toContain(path.resolve("/path/two")); + expect(allowed).toContain(path.resolve("/path/three")); + }); + + it("should skip empty paths", () => { + process.env.ALLOWED_PROJECT_DIRS = "/path/one,,/path/two, ,/path/three"; + + initAllowedPaths(); + + const allowed = getAllowedPaths(); + expect(allowed.length).toBeLessThanOrEqual(3); + expect(allowed).toContain(path.resolve("/path/one")); + }); + + it("should initialize from DATA_DIR environment variable", () => { + process.env.DATA_DIR = "/data/directory"; + + initAllowedPaths(); + + const allowed = getAllowedPaths(); + expect(allowed).toContain(path.resolve("/data/directory")); + }); + + it("should initialize from WORKSPACE_DIR environment variable", () => { + process.env.WORKSPACE_DIR = "/workspace/directory"; + + initAllowedPaths(); + + const allowed = getAllowedPaths(); + expect(allowed).toContain(path.resolve("/workspace/directory")); + }); + + it("should handle all environment variables together", () => { + process.env.ALLOWED_PROJECT_DIRS = "/projects/one,/projects/two"; + process.env.DATA_DIR = "/app/data"; + process.env.WORKSPACE_DIR = "/app/workspace"; + + initAllowedPaths(); + + const allowed = getAllowedPaths(); + expect(allowed).toContain(path.resolve("/projects/one")); + expect(allowed).toContain(path.resolve("/projects/two")); + expect(allowed).toContain(path.resolve("/app/data")); + expect(allowed).toContain(path.resolve("/app/workspace")); + }); + + it("should handle missing environment variables gracefully", () => { + delete process.env.ALLOWED_PROJECT_DIRS; + delete process.env.DATA_DIR; + delete process.env.WORKSPACE_DIR; + + expect(() => initAllowedPaths()).not.toThrow(); + }); + }); + + describe("addAllowedPath", () => { + it("should add a path to allowed list", () => { + const testPath = "/new/allowed/path"; + + addAllowedPath(testPath); + + const allowed = getAllowedPaths(); + expect(allowed).toContain(path.resolve(testPath)); + }); + + it("should resolve relative paths to absolute", () => { + const relativePath = "relative/path"; + + addAllowedPath(relativePath); + + const allowed = getAllowedPaths(); + expect(allowed).toContain(path.resolve(relativePath)); + }); + + it("should handle duplicate paths", () => { + const testPath = "/duplicate/path"; + + addAllowedPath(testPath); + addAllowedPath(testPath); + + const allowed = getAllowedPaths(); + const count = allowed.filter((p) => p === path.resolve(testPath)).length; + expect(count).toBe(1); + }); + }); + + describe("isPathAllowed", () => { + it("should always return true (all paths allowed)", () => { + expect(isPathAllowed("/any/path")).toBe(true); + expect(isPathAllowed("/another/path")).toBe(true); + expect(isPathAllowed("relative/path")).toBe(true); + expect(isPathAllowed("/etc/passwd")).toBe(true); + expect(isPathAllowed("../../../dangerous/path")).toBe(true); + }); + + it("should return true even for non-existent paths", () => { + expect(isPathAllowed("/nonexistent/path/12345")).toBe(true); + }); + + it("should return true for empty string", () => { + expect(isPathAllowed("")).toBe(true); + }); + }); + + describe("validatePath", () => { + it("should resolve absolute paths", () => { + const absPath = "/absolute/path/to/file.txt"; + const result = validatePath(absPath); + expect(result).toBe(path.resolve(absPath)); + }); + + it("should resolve relative paths", () => { + const relPath = "relative/path/file.txt"; + const result = validatePath(relPath); + expect(result).toBe(path.resolve(relPath)); + }); + + it("should handle current directory", () => { + const result = validatePath("."); + expect(result).toBe(path.resolve(".")); + }); + + it("should handle parent directory", () => { + const result = validatePath(".."); + expect(result).toBe(path.resolve("..")); + }); + + it("should handle complex relative paths", () => { + const complexPath = "../../some/nested/../path/./file.txt"; + const result = validatePath(complexPath); + expect(result).toBe(path.resolve(complexPath)); + }); + + it("should handle paths with spaces", () => { + const pathWithSpaces = "/path with spaces/file.txt"; + const result = validatePath(pathWithSpaces); + expect(result).toBe(path.resolve(pathWithSpaces)); + }); + + it("should handle home directory expansion on Unix", () => { + if (process.platform !== "win32") { + const homePath = "~/documents/file.txt"; + const result = validatePath(homePath); + expect(result).toBe(path.resolve(homePath)); + } + }); + }); + + describe("getAllowedPaths", () => { + it("should return empty array initially", () => { + const allowed = getAllowedPaths(); + expect(Array.isArray(allowed)).toBe(true); + }); + + it("should return array of added paths", () => { + addAllowedPath("/path/one"); + addAllowedPath("/path/two"); + + const allowed = getAllowedPaths(); + expect(allowed).toContain(path.resolve("/path/one")); + expect(allowed).toContain(path.resolve("/path/two")); + }); + + it("should return copy of internal set", () => { + addAllowedPath("/test/path"); + + const allowed1 = getAllowedPaths(); + const allowed2 = getAllowedPaths(); + + expect(allowed1).not.toBe(allowed2); + expect(allowed1).toEqual(allowed2); + }); + }); + + describe("Path security disabled behavior", () => { + it("should allow unrestricted access despite allowed paths list", () => { + process.env.ALLOWED_PROJECT_DIRS = "/only/this/path"; + initAllowedPaths(); + + // Should return true even for paths not in allowed list + expect(isPathAllowed("/some/other/path")).toBe(true); + expect(isPathAllowed("/completely/different/path")).toBe(true); + }); + + it("should validate paths without permission checks", () => { + process.env.ALLOWED_PROJECT_DIRS = "/only/this/path"; + initAllowedPaths(); + + // Should validate any path without throwing + expect(() => validatePath("/some/other/path")).not.toThrow(); + expect(validatePath("/some/other/path")).toBe( + path.resolve("/some/other/path") + ); + }); + }); +}); diff --git a/libs/platform/vitest.config.ts b/libs/platform/vitest.config.ts index 2a441396..2b6ac168 100644 --- a/libs/platform/vitest.config.ts +++ b/libs/platform/vitest.config.ts @@ -11,12 +11,12 @@ export default defineConfig({ include: ["src/**/*.ts"], exclude: ["src/**/*.d.ts", "src/index.ts"], thresholds: { - // Current overall coverage: ~64% (only subprocess.ts well tested) - // Set realistic thresholds until more files are tested - lines: 60, - functions: 40, - branches: 60, - statements: 60, + // Excellent coverage: 94.69% stmts, 80.48% branches, 97.14% funcs, 94.64% lines + // All files now have comprehensive tests + lines: 90, + functions: 95, + branches: 75, + statements: 90, }, }, }, diff --git a/libs/utils/tests/conversation-utils.test.ts b/libs/utils/tests/conversation-utils.test.ts new file mode 100644 index 00000000..bbb1b13e --- /dev/null +++ b/libs/utils/tests/conversation-utils.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect } from "vitest"; +import type { ConversationMessage } from "@automaker/types"; +import { + extractTextFromContent, + normalizeContentBlocks, + formatHistoryAsText, + convertHistoryToMessages, +} from "../src/conversation-utils"; + +describe("conversation-utils.ts", () => { + describe("extractTextFromContent", () => { + it("should extract text from string content", () => { + const content = "Hello, world!"; + const result = extractTextFromContent(content); + expect(result).toBe("Hello, world!"); + }); + + it("should extract text from array content with text blocks", () => { + const content = [ + { type: "text", text: "First block" }, + { type: "text", text: "Second block" }, + ]; + const result = extractTextFromContent(content); + expect(result).toBe("First block\nSecond block"); + }); + + it("should filter out non-text blocks", () => { + const content = [ + { type: "text", text: "Text block" }, + { type: "image", source: { data: "..." } }, + { type: "text", text: "Another text" }, + ]; + const result = extractTextFromContent(content); + expect(result).toBe("Text block\nAnother text"); + }); + + it("should handle empty text blocks", () => { + const content = [ + { type: "text", text: "First" }, + { type: "text" }, + { type: "text", text: "Third" }, + ]; + const result = extractTextFromContent(content); + expect(result).toBe("First\n\nThird"); + }); + + it("should return empty string for array with only non-text blocks", () => { + const content = [ + { type: "image", source: {} }, + { type: "tool_use", source: {} }, + ]; + const result = extractTextFromContent(content); + expect(result).toBe(""); + }); + + it("should return empty string for empty array", () => { + const content: Array<{ type: string; text?: string }> = []; + const result = extractTextFromContent(content); + expect(result).toBe(""); + }); + }); + + describe("normalizeContentBlocks", () => { + it("should convert string to array of text blocks", () => { + const content = "Simple text"; + const result = normalizeContentBlocks(content); + expect(result).toEqual([{ type: "text", text: "Simple text" }]); + }); + + it("should return array as-is", () => { + const content = [ + { type: "text", text: "First" }, + { type: "image", source: {} }, + ]; + const result = normalizeContentBlocks(content); + expect(result).toBe(content); + expect(result).toEqual(content); + }); + + it("should handle empty string", () => { + const content = ""; + const result = normalizeContentBlocks(content); + expect(result).toEqual([{ type: "text", text: "" }]); + }); + + it("should handle multiline string", () => { + const content = "Line 1\nLine 2\nLine 3"; + const result = normalizeContentBlocks(content); + expect(result).toEqual([{ type: "text", text: "Line 1\nLine 2\nLine 3" }]); + }); + }); + + describe("formatHistoryAsText", () => { + it("should format empty history as empty string", () => { + const history: ConversationMessage[] = []; + const result = formatHistoryAsText(history); + expect(result).toBe(""); + }); + + it("should format single user message", () => { + const history: ConversationMessage[] = [ + { role: "user", content: "Hello!" }, + ]; + const result = formatHistoryAsText(history); + expect(result).toBe("Previous conversation:\n\nUser: Hello!\n\n---\n\n"); + }); + + it("should format single assistant message", () => { + const history: ConversationMessage[] = [ + { role: "assistant", content: "Hi there!" }, + ]; + const result = formatHistoryAsText(history); + expect(result).toBe( + "Previous conversation:\n\nAssistant: Hi there!\n\n---\n\n" + ); + }); + + it("should format conversation with multiple messages", () => { + const history: ConversationMessage[] = [ + { role: "user", content: "What's 2+2?" }, + { role: "assistant", content: "The answer is 4." }, + { role: "user", content: "Thanks!" }, + ]; + const result = formatHistoryAsText(history); + expect(result).toBe( + "Previous conversation:\n\n" + + "User: What's 2+2?\n\n" + + "Assistant: The answer is 4.\n\n" + + "User: Thanks!\n\n" + + "---\n\n" + ); + }); + + it("should handle array content by extracting text", () => { + const history: ConversationMessage[] = [ + { + role: "user", + content: [ + { type: "text", text: "First part" }, + { type: "text", text: "Second part" }, + ], + }, + ]; + const result = formatHistoryAsText(history); + expect(result).toBe( + "Previous conversation:\n\nUser: First part\nSecond part\n\n---\n\n" + ); + }); + + it("should handle mixed string and array content", () => { + const history: ConversationMessage[] = [ + { role: "user", content: "String message" }, + { + role: "assistant", + content: [{ type: "text", text: "Array message" }], + }, + ]; + const result = formatHistoryAsText(history); + expect(result).toContain("User: String message"); + expect(result).toContain("Assistant: Array message"); + }); + }); + + describe("convertHistoryToMessages", () => { + it("should convert empty history", () => { + const history: ConversationMessage[] = []; + const result = convertHistoryToMessages(history); + expect(result).toEqual([]); + }); + + it("should convert single user message", () => { + const history: ConversationMessage[] = [ + { role: "user", content: "Hello!" }, + ]; + const result = convertHistoryToMessages(history); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "user", + session_id: "", + message: { + role: "user", + content: [{ type: "text", text: "Hello!" }], + }, + parent_tool_use_id: null, + }); + }); + + it("should convert single assistant message", () => { + const history: ConversationMessage[] = [ + { role: "assistant", content: "Hi there!" }, + ]; + const result = convertHistoryToMessages(history); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "assistant", + session_id: "", + message: { + role: "assistant", + content: [{ type: "text", text: "Hi there!" }], + }, + parent_tool_use_id: null, + }); + }); + + it("should preserve array content as-is", () => { + const content = [ + { type: "text", text: "Text" }, + { type: "image", source: { data: "..." } }, + ]; + const history: ConversationMessage[] = [{ role: "user", content }]; + const result = convertHistoryToMessages(history); + + expect(result[0].message.content).toEqual(content); + }); + + it("should convert multiple messages", () => { + const history: ConversationMessage[] = [ + { role: "user", content: "First" }, + { role: "assistant", content: "Second" }, + { role: "user", content: "Third" }, + ]; + const result = convertHistoryToMessages(history); + + expect(result).toHaveLength(3); + expect(result[0].type).toBe("user"); + expect(result[1].type).toBe("assistant"); + expect(result[2].type).toBe("user"); + }); + + it("should set session_id to empty string", () => { + const history: ConversationMessage[] = [ + { role: "user", content: "Test" }, + ]; + const result = convertHistoryToMessages(history); + + expect(result[0].session_id).toBe(""); + }); + + it("should set parent_tool_use_id to null", () => { + const history: ConversationMessage[] = [ + { role: "user", content: "Test" }, + ]; + const result = convertHistoryToMessages(history); + + expect(result[0].parent_tool_use_id).toBeNull(); + }); + + it("should normalize string content to blocks", () => { + const history: ConversationMessage[] = [ + { role: "user", content: "String content" }, + ]; + const result = convertHistoryToMessages(history); + + expect(result[0].message.content).toEqual([ + { type: "text", text: "String content" }, + ]); + }); + }); +}); diff --git a/libs/utils/tests/fs-utils.test.ts b/libs/utils/tests/fs-utils.test.ts new file mode 100644 index 00000000..23df1003 --- /dev/null +++ b/libs/utils/tests/fs-utils.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; +import { mkdirSafe, existsSafe } from "../src/fs-utils"; + +describe("fs-utils.ts", () => { + let tempDir: string; + + beforeEach(async () => { + // Create a temporary directory for testing + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "fs-utils-test-")); + }); + + afterEach(async () => { + // Clean up temporary directory + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch (error) { + // Ignore cleanup errors + } + }); + + describe("mkdirSafe", () => { + it("should create a new directory", async () => { + const newDir = path.join(tempDir, "new-directory"); + + await mkdirSafe(newDir); + + const stats = await fs.stat(newDir); + expect(stats.isDirectory()).toBe(true); + }); + + it("should create nested directories recursively", async () => { + const nestedDir = path.join(tempDir, "level1", "level2", "level3"); + + await mkdirSafe(nestedDir); + + const stats = await fs.stat(nestedDir); + expect(stats.isDirectory()).toBe(true); + }); + + it("should succeed when directory already exists", async () => { + const existingDir = path.join(tempDir, "existing"); + await fs.mkdir(existingDir); + + await expect(mkdirSafe(existingDir)).resolves.not.toThrow(); + }); + + it("should succeed when path is a symlink to a directory", async () => { + const targetDir = path.join(tempDir, "target"); + const symlinkPath = path.join(tempDir, "symlink"); + + await fs.mkdir(targetDir); + await fs.symlink(targetDir, symlinkPath, "dir"); + + await expect(mkdirSafe(symlinkPath)).resolves.not.toThrow(); + }); + + it("should throw when path exists as a file", async () => { + const filePath = path.join(tempDir, "existing-file.txt"); + await fs.writeFile(filePath, "content"); + + await expect(mkdirSafe(filePath)).rejects.toThrow( + "Path exists and is not a directory" + ); + }); + + it("should resolve relative paths", async () => { + const originalCwd = process.cwd(); + try { + process.chdir(tempDir); + + await mkdirSafe("relative-dir"); + + const stats = await fs.stat(path.join(tempDir, "relative-dir")); + expect(stats.isDirectory()).toBe(true); + } finally { + process.chdir(originalCwd); + } + }); + + it("should handle concurrent creation gracefully", async () => { + const newDir = path.join(tempDir, "concurrent"); + + const promises = [ + mkdirSafe(newDir), + mkdirSafe(newDir), + mkdirSafe(newDir), + ]; + + await expect(Promise.all(promises)).resolves.not.toThrow(); + + const stats = await fs.stat(newDir); + expect(stats.isDirectory()).toBe(true); + }); + + it("should handle paths with special characters", async () => { + const specialDir = path.join(tempDir, "dir with spaces & special-chars"); + + await mkdirSafe(specialDir); + + const stats = await fs.stat(specialDir); + expect(stats.isDirectory()).toBe(true); + }); + }); + + describe("existsSafe", () => { + it("should return true for existing directory", async () => { + const existingDir = path.join(tempDir, "exists"); + await fs.mkdir(existingDir); + + const result = await existsSafe(existingDir); + + expect(result).toBe(true); + }); + + it("should return true for existing file", async () => { + const filePath = path.join(tempDir, "file.txt"); + await fs.writeFile(filePath, "content"); + + const result = await existsSafe(filePath); + + expect(result).toBe(true); + }); + + it("should return false for non-existent path", async () => { + const nonExistent = path.join(tempDir, "does-not-exist"); + + const result = await existsSafe(nonExistent); + + expect(result).toBe(false); + }); + + it("should return true for symlink", async () => { + const target = path.join(tempDir, "target.txt"); + const symlink = path.join(tempDir, "link.txt"); + + await fs.writeFile(target, "content"); + await fs.symlink(target, symlink); + + const result = await existsSafe(symlink); + + expect(result).toBe(true); + }); + + it("should return true for broken symlink", async () => { + const symlink = path.join(tempDir, "broken-link"); + + // Create symlink to non-existent target + await fs.symlink("/non/existent/path", symlink); + + const result = await existsSafe(symlink); + + // lstat succeeds on broken symlinks + expect(result).toBe(true); + }); + + it("should handle relative paths", async () => { + const originalCwd = process.cwd(); + try { + process.chdir(tempDir); + + await fs.writeFile("test.txt", "content"); + + const result = await existsSafe("test.txt"); + + expect(result).toBe(true); + } finally { + process.chdir(originalCwd); + } + }); + + it("should handle paths with special characters", async () => { + const specialFile = path.join(tempDir, "file with spaces & chars.txt"); + await fs.writeFile(specialFile, "content"); + + const result = await existsSafe(specialFile); + + expect(result).toBe(true); + }); + + it("should return false for parent of non-existent nested path", async () => { + const nonExistent = path.join(tempDir, "does", "not", "exist"); + + const result = await existsSafe(nonExistent); + + expect(result).toBe(false); + }); + }); + + describe("Error handling", () => { + it("should handle permission errors in mkdirSafe", async () => { + // Skip on Windows where permissions work differently + if (process.platform === "win32") { + return; + } + + const restrictedDir = path.join(tempDir, "restricted"); + await fs.mkdir(restrictedDir); + + // Make directory read-only + await fs.chmod(restrictedDir, 0o444); + + const newDir = path.join(restrictedDir, "new"); + + try { + await expect(mkdirSafe(newDir)).rejects.toThrow(); + } finally { + // Restore permissions for cleanup + await fs.chmod(restrictedDir, 0o755); + } + }); + + it("should propagate unexpected errors in existsSafe", async () => { + const mockError = new Error("Unexpected error"); + (mockError as any).code = "EACCES"; + + const spy = vi.spyOn(fs, "lstat").mockRejectedValueOnce(mockError); + + await expect(existsSafe("/some/path")).rejects.toThrow( + "Unexpected error" + ); + + spy.mockRestore(); + }); + }); + + describe("Integration scenarios", () => { + it("should work together: check existence then create if missing", async () => { + const dirPath = path.join(tempDir, "check-then-create"); + + const existsBefore = await existsSafe(dirPath); + expect(existsBefore).toBe(false); + + await mkdirSafe(dirPath); + + const existsAfter = await existsSafe(dirPath); + expect(existsAfter).toBe(true); + }); + + it("should handle nested directory creation with existence checks", async () => { + const level1 = path.join(tempDir, "level1"); + const level2 = path.join(level1, "level2"); + const level3 = path.join(level2, "level3"); + + await mkdirSafe(level3); + + expect(await existsSafe(level1)).toBe(true); + expect(await existsSafe(level2)).toBe(true); + expect(await existsSafe(level3)).toBe(true); + }); + }); +}); diff --git a/libs/utils/tests/image-handler.test.ts b/libs/utils/tests/image-handler.test.ts new file mode 100644 index 00000000..665e2e01 --- /dev/null +++ b/libs/utils/tests/image-handler.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; +import { + getMimeTypeForImage, + readImageAsBase64, + convertImagesToContentBlocks, + formatImagePathsForPrompt, +} from "../src/image-handler"; + +describe("image-handler.ts", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "image-handler-test-")); + }); + + afterEach(async () => { + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch (error) { + // Ignore cleanup errors + } + }); + + describe("getMimeTypeForImage", () => { + it("should return correct MIME type for .jpg", () => { + expect(getMimeTypeForImage("image.jpg")).toBe("image/jpeg"); + expect(getMimeTypeForImage("/path/to/image.jpg")).toBe("image/jpeg"); + }); + + it("should return correct MIME type for .jpeg", () => { + expect(getMimeTypeForImage("image.jpeg")).toBe("image/jpeg"); + }); + + it("should return correct MIME type for .png", () => { + expect(getMimeTypeForImage("image.png")).toBe("image/png"); + }); + + it("should return correct MIME type for .gif", () => { + expect(getMimeTypeForImage("image.gif")).toBe("image/gif"); + }); + + it("should return correct MIME type for .webp", () => { + expect(getMimeTypeForImage("image.webp")).toBe("image/webp"); + }); + + it("should be case-insensitive", () => { + expect(getMimeTypeForImage("image.JPG")).toBe("image/jpeg"); + expect(getMimeTypeForImage("image.PNG")).toBe("image/png"); + expect(getMimeTypeForImage("image.GIF")).toBe("image/gif"); + }); + + it("should default to image/png for unknown extensions", () => { + expect(getMimeTypeForImage("file.xyz")).toBe("image/png"); + expect(getMimeTypeForImage("file.txt")).toBe("image/png"); + expect(getMimeTypeForImage("file")).toBe("image/png"); + }); + + it("should handle filenames with multiple dots", () => { + expect(getMimeTypeForImage("my.file.name.jpg")).toBe("image/jpeg"); + }); + }); + + describe("readImageAsBase64", () => { + it("should read image and return base64 data", async () => { + const imagePath = path.join(tempDir, "test.png"); + const imageContent = Buffer.from("fake png data"); + await fs.writeFile(imagePath, imageContent); + + const result = await readImageAsBase64(imagePath); + + expect(result.base64).toBe(imageContent.toString("base64")); + expect(result.mimeType).toBe("image/png"); + expect(result.filename).toBe("test.png"); + expect(result.originalPath).toBe(imagePath); + }); + + it("should handle different image formats", async () => { + const formats = [ + { ext: "jpg", mime: "image/jpeg" }, + { ext: "png", mime: "image/png" }, + { ext: "gif", mime: "image/gif" }, + { ext: "webp", mime: "image/webp" }, + ]; + + for (const format of formats) { + const imagePath = path.join(tempDir, `image.${format.ext}`); + await fs.writeFile(imagePath, Buffer.from("data")); + + const result = await readImageAsBase64(imagePath); + + expect(result.mimeType).toBe(format.mime); + expect(result.filename).toBe(`image.${format.ext}`); + } + }); + + it("should throw error if file doesn't exist", async () => { + const imagePath = path.join(tempDir, "nonexistent.png"); + + await expect(readImageAsBase64(imagePath)).rejects.toThrow(); + }); + + it("should handle binary image data correctly", async () => { + const imagePath = path.join(tempDir, "binary.png"); + const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); + await fs.writeFile(imagePath, binaryData); + + const result = await readImageAsBase64(imagePath); + + expect(result.base64).toBe(binaryData.toString("base64")); + }); + }); + + describe("convertImagesToContentBlocks", () => { + it("should convert single image to content block", async () => { + const imagePath = path.join(tempDir, "test.png"); + await fs.writeFile(imagePath, Buffer.from("image data")); + + const result = await convertImagesToContentBlocks([imagePath]); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: "image", + source: { + type: "base64", + media_type: "image/png", + }, + }); + expect(result[0].source.data).toBeTruthy(); + }); + + it("should convert multiple images", async () => { + const image1 = path.join(tempDir, "image1.jpg"); + const image2 = path.join(tempDir, "image2.png"); + + await fs.writeFile(image1, Buffer.from("jpg data")); + await fs.writeFile(image2, Buffer.from("png data")); + + const result = await convertImagesToContentBlocks([image1, image2]); + + expect(result).toHaveLength(2); + expect(result[0].source.media_type).toBe("image/jpeg"); + expect(result[1].source.media_type).toBe("image/png"); + }); + + it("should resolve relative paths with workDir", async () => { + const image = "test.png"; + const imagePath = path.join(tempDir, image); + await fs.writeFile(imagePath, Buffer.from("data")); + + const result = await convertImagesToContentBlocks([image], tempDir); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe("image"); + }); + + it("should handle absolute paths without workDir", async () => { + const imagePath = path.join(tempDir, "absolute.png"); + await fs.writeFile(imagePath, Buffer.from("data")); + + const result = await convertImagesToContentBlocks([imagePath]); + + expect(result).toHaveLength(1); + }); + + it("should skip images that fail to load", async () => { + const validImage = path.join(tempDir, "valid.png"); + const invalidImage = path.join(tempDir, "nonexistent.png"); + + await fs.writeFile(validImage, Buffer.from("data")); + + const result = await convertImagesToContentBlocks([ + validImage, + invalidImage, + ]); + + expect(result).toHaveLength(1); + expect(result[0].source.media_type).toBe("image/png"); + }); + + it("should return empty array for empty input", async () => { + const result = await convertImagesToContentBlocks([]); + expect(result).toEqual([]); + }); + + it("should preserve order of images", async () => { + const images = ["img1.jpg", "img2.png", "img3.gif"]; + + for (const img of images) { + await fs.writeFile(path.join(tempDir, img), Buffer.from("data")); + } + + const result = await convertImagesToContentBlocks(images, tempDir); + + expect(result).toHaveLength(3); + expect(result[0].source.media_type).toBe("image/jpeg"); + expect(result[1].source.media_type).toBe("image/png"); + expect(result[2].source.media_type).toBe("image/gif"); + }); + }); + + describe("formatImagePathsForPrompt", () => { + it("should return empty string for empty array", () => { + const result = formatImagePathsForPrompt([]); + expect(result).toBe(""); + }); + + it("should format single image path", () => { + const result = formatImagePathsForPrompt(["/path/to/image.png"]); + expect(result).toBe("\n\nAttached images:\n- /path/to/image.png\n"); + }); + + it("should format multiple image paths", () => { + const result = formatImagePathsForPrompt([ + "/path/image1.png", + "/path/image2.jpg", + "/path/image3.gif", + ]); + + expect(result).toBe( + "\n\nAttached images:\n" + + "- /path/image1.png\n" + + "- /path/image2.jpg\n" + + "- /path/image3.gif\n" + ); + }); + + it("should handle relative paths", () => { + const result = formatImagePathsForPrompt([ + "relative/path/image.png", + "another/image.jpg", + ]); + + expect(result).toContain("- relative/path/image.png"); + expect(result).toContain("- another/image.jpg"); + }); + + it("should start with newlines", () => { + const result = formatImagePathsForPrompt(["/image.png"]); + expect(result.startsWith("\n\n")).toBe(true); + }); + + it("should include header text", () => { + const result = formatImagePathsForPrompt(["/image.png"]); + expect(result).toContain("Attached images:"); + }); + }); +}); diff --git a/libs/utils/tests/logger.test.ts b/libs/utils/tests/logger.test.ts new file mode 100644 index 00000000..9a50d7c2 --- /dev/null +++ b/libs/utils/tests/logger.test.ts @@ -0,0 +1,323 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + createLogger, + LogLevel, + getLogLevel, + setLogLevel, +} from "../src/logger"; + +describe("logger.ts", () => { + let originalConsoleError: typeof console.error; + let originalConsoleWarn: typeof console.warn; + let originalConsoleLog: typeof console.log; + let originalLogLevel: LogLevel; + + beforeEach(() => { + // Save original console methods and log level + originalConsoleError = console.error; + originalConsoleWarn = console.warn; + originalConsoleLog = console.log; + originalLogLevel = getLogLevel(); + + // Mock console methods + console.error = vi.fn(); + console.warn = vi.fn(); + console.log = vi.fn(); + }); + + afterEach(() => { + // Restore original console methods and log level + console.error = originalConsoleError; + console.warn = originalConsoleWarn; + console.log = originalConsoleLog; + setLogLevel(originalLogLevel); + }); + + describe("createLogger", () => { + it("should create logger with context prefix", () => { + const logger = createLogger("TestContext"); + setLogLevel(LogLevel.INFO); + + logger.info("test message"); + + expect(console.log).toHaveBeenCalledWith( + "[TestContext]", + "test message" + ); + }); + + it("should handle multiple arguments", () => { + const logger = createLogger("Test"); + setLogLevel(LogLevel.INFO); + + logger.info("message", { data: 123 }, [1, 2, 3]); + + expect(console.log).toHaveBeenCalledWith( + "[Test]", + "message", + { data: 123 }, + [1, 2, 3] + ); + }); + }); + + describe("Log levels", () => { + it("should log error at ERROR level", () => { + const logger = createLogger("Test"); + setLogLevel(LogLevel.ERROR); + + logger.error("error message"); + logger.warn("warn message"); + logger.info("info message"); + logger.debug("debug message"); + + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.warn).not.toHaveBeenCalled(); + expect(console.log).not.toHaveBeenCalled(); + }); + + it("should log error and warn at WARN level", () => { + const logger = createLogger("Test"); + setLogLevel(LogLevel.WARN); + + logger.error("error message"); + logger.warn("warn message"); + logger.info("info message"); + logger.debug("debug message"); + + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.log).not.toHaveBeenCalled(); + }); + + it("should log error, warn, and info at INFO level", () => { + const logger = createLogger("Test"); + setLogLevel(LogLevel.INFO); + + logger.error("error message"); + logger.warn("warn message"); + logger.info("info message"); + logger.debug("debug message"); + + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledTimes(1); // Only info, not debug + }); + + it("should log all messages at DEBUG level", () => { + const logger = createLogger("Test"); + setLogLevel(LogLevel.DEBUG); + + logger.error("error message"); + logger.warn("warn message"); + logger.info("info message"); + logger.debug("debug message"); + + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledTimes(2); // info + debug + }); + }); + + describe("error method", () => { + it("should use console.error", () => { + const logger = createLogger("ErrorTest"); + setLogLevel(LogLevel.ERROR); + + logger.error("error occurred", { code: 500 }); + + expect(console.error).toHaveBeenCalledWith( + "[ErrorTest]", + "error occurred", + { code: 500 } + ); + }); + + it("should not log when level is below ERROR", () => { + const logger = createLogger("Test"); + setLogLevel(LogLevel.ERROR - 1 as LogLevel); + + logger.error("should not appear"); + + expect(console.error).not.toHaveBeenCalled(); + }); + }); + + describe("warn method", () => { + it("should use console.warn", () => { + const logger = createLogger("WarnTest"); + setLogLevel(LogLevel.WARN); + + logger.warn("warning message"); + + expect(console.warn).toHaveBeenCalledWith("[WarnTest]", "warning message"); + }); + + it("should not log when level is below WARN", () => { + const logger = createLogger("Test"); + setLogLevel(LogLevel.ERROR); + + logger.warn("should not appear"); + + expect(console.warn).not.toHaveBeenCalled(); + }); + }); + + describe("info method", () => { + it("should use console.log", () => { + const logger = createLogger("InfoTest"); + setLogLevel(LogLevel.INFO); + + logger.info("info message"); + + expect(console.log).toHaveBeenCalledWith("[InfoTest]", "info message"); + }); + + it("should not log when level is below INFO", () => { + const logger = createLogger("Test"); + setLogLevel(LogLevel.WARN); + + logger.info("should not appear"); + + expect(console.log).not.toHaveBeenCalled(); + }); + }); + + describe("debug method", () => { + it("should use console.log with DEBUG prefix", () => { + const logger = createLogger("DebugTest"); + setLogLevel(LogLevel.DEBUG); + + logger.debug("debug details", { trace: "..." }); + + expect(console.log).toHaveBeenCalledWith( + "[DebugTest]", + "[DEBUG]", + "debug details", + { trace: "..." } + ); + }); + + it("should not log when level is below DEBUG", () => { + const logger = createLogger("Test"); + setLogLevel(LogLevel.INFO); + + logger.debug("should not appear"); + + expect(console.log).not.toHaveBeenCalled(); + }); + }); + + describe("getLogLevel", () => { + it("should return current log level", () => { + setLogLevel(LogLevel.DEBUG); + expect(getLogLevel()).toBe(LogLevel.DEBUG); + + setLogLevel(LogLevel.ERROR); + expect(getLogLevel()).toBe(LogLevel.ERROR); + }); + }); + + describe("setLogLevel", () => { + it("should change log level", () => { + setLogLevel(LogLevel.WARN); + expect(getLogLevel()).toBe(LogLevel.WARN); + + setLogLevel(LogLevel.DEBUG); + expect(getLogLevel()).toBe(LogLevel.DEBUG); + }); + + it("should affect subsequent logging", () => { + const logger = createLogger("Test"); + + setLogLevel(LogLevel.ERROR); + logger.info("should not log"); + expect(console.log).not.toHaveBeenCalled(); + + setLogLevel(LogLevel.INFO); + logger.info("should log"); + expect(console.log).toHaveBeenCalledWith("[Test]", "should log"); + }); + }); + + describe("Multiple logger instances", () => { + it("should maintain separate contexts", () => { + const logger1 = createLogger("Service1"); + const logger2 = createLogger("Service2"); + setLogLevel(LogLevel.INFO); + + logger1.info("from service 1"); + logger2.info("from service 2"); + + expect(console.log).toHaveBeenNthCalledWith( + 1, + "[Service1]", + "from service 1" + ); + expect(console.log).toHaveBeenNthCalledWith( + 2, + "[Service2]", + "from service 2" + ); + }); + + it("should share log level setting", () => { + const logger1 = createLogger("Service1"); + const logger2 = createLogger("Service2"); + + setLogLevel(LogLevel.ERROR); + + logger1.info("should not log"); + logger2.info("should not log"); + + expect(console.log).not.toHaveBeenCalled(); + }); + }); + + describe("Edge cases", () => { + it("should handle empty context string", () => { + const logger = createLogger(""); + setLogLevel(LogLevel.INFO); + + logger.info("message"); + + expect(console.log).toHaveBeenCalledWith("[]", "message"); + }); + + it("should handle context with special characters", () => { + const logger = createLogger("Test-Service_v2.0"); + setLogLevel(LogLevel.INFO); + + logger.info("message"); + + expect(console.log).toHaveBeenCalledWith( + "[Test-Service_v2.0]", + "message" + ); + }); + + it("should handle no arguments to log methods", () => { + const logger = createLogger("Test"); + setLogLevel(LogLevel.INFO); + + logger.info(); + + expect(console.log).toHaveBeenCalledWith("[Test]"); + }); + + it("should handle complex object arguments", () => { + const logger = createLogger("Test"); + setLogLevel(LogLevel.INFO); + + const complexObj = { + nested: { deep: { value: 123 } }, + array: [1, 2, 3], + fn: () => {}, + }; + + logger.info("complex", complexObj); + + expect(console.log).toHaveBeenCalledWith("[Test]", "complex", complexObj); + }); + }); +}); diff --git a/libs/utils/tests/prompt-builder.test.ts b/libs/utils/tests/prompt-builder.test.ts new file mode 100644 index 00000000..2ea5f357 --- /dev/null +++ b/libs/utils/tests/prompt-builder.test.ts @@ -0,0 +1,316 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; +import { buildPromptWithImages } from "../src/prompt-builder"; + +describe("prompt-builder.ts", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "prompt-builder-test-")); + }); + + afterEach(async () => { + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch (error) { + // Ignore cleanup errors + } + }); + + describe("buildPromptWithImages - no images", () => { + it("should return plain text when no images provided", async () => { + const basePrompt = "Hello, world!"; + + const result = await buildPromptWithImages(basePrompt); + + expect(result.content).toBe("Hello, world!"); + expect(result.hasImages).toBe(false); + }); + + it("should return plain text when empty image array provided", async () => { + const basePrompt = "Test prompt"; + + const result = await buildPromptWithImages(basePrompt, []); + + expect(result.content).toBe("Test prompt"); + expect(result.hasImages).toBe(false); + }); + + it("should handle multiline prompts", async () => { + const basePrompt = "Line 1\nLine 2\nLine 3"; + + const result = await buildPromptWithImages(basePrompt); + + expect(result.content).toBe("Line 1\nLine 2\nLine 3"); + }); + }); + + describe("buildPromptWithImages - with images", () => { + it("should build content blocks with single image", async () => { + const imagePath = path.join(tempDir, "test.png"); + await fs.writeFile(imagePath, Buffer.from("image data")); + + const result = await buildPromptWithImages( + "Check this image", + [imagePath] + ); + + expect(result.hasImages).toBe(true); + expect(Array.isArray(result.content)).toBe(true); + + const blocks = result.content as Array<{ + type: string; + text?: string; + source?: object; + }>; + expect(blocks).toHaveLength(2); + expect(blocks[0]).toMatchObject({ + type: "text", + text: "Check this image", + }); + expect(blocks[1]).toMatchObject({ + type: "image", + }); + }); + + it("should build content blocks with multiple images", async () => { + const image1 = path.join(tempDir, "img1.jpg"); + const image2 = path.join(tempDir, "img2.png"); + + await fs.writeFile(image1, Buffer.from("jpg data")); + await fs.writeFile(image2, Buffer.from("png data")); + + const result = await buildPromptWithImages("Two images", [ + image1, + image2, + ]); + + expect(result.hasImages).toBe(true); + + const blocks = result.content as Array<{ + type: string; + text?: string; + source?: object; + }>; + expect(blocks).toHaveLength(3); // 1 text + 2 images + expect(blocks[0].type).toBe("text"); + expect(blocks[1].type).toBe("image"); + expect(blocks[2].type).toBe("image"); + }); + + it("should resolve relative paths with workDir", async () => { + const imagePath = "test.png"; + const fullPath = path.join(tempDir, imagePath); + await fs.writeFile(fullPath, Buffer.from("data")); + + const result = await buildPromptWithImages( + "Test", + [imagePath], + tempDir + ); + + expect(result.hasImages).toBe(true); + expect(Array.isArray(result.content)).toBe(true); + }); + + it("should handle absolute paths without workDir", async () => { + const imagePath = path.join(tempDir, "absolute.png"); + await fs.writeFile(imagePath, Buffer.from("data")); + + const result = await buildPromptWithImages("Test", [imagePath]); + + expect(result.hasImages).toBe(true); + }); + }); + + describe("buildPromptWithImages - includeImagePaths option", () => { + it("should not include image paths by default", async () => { + const imagePath = path.join(tempDir, "test.png"); + await fs.writeFile(imagePath, Buffer.from("data")); + + const result = await buildPromptWithImages("Prompt", [imagePath]); + + const blocks = result.content as Array<{ + type: string; + text?: string; + }>; + const textBlock = blocks.find((b) => b.type === "text"); + + expect(textBlock?.text).not.toContain("Attached images:"); + expect(textBlock?.text).toBe("Prompt"); + }); + + it("should include image paths when requested", async () => { + const imagePath = path.join(tempDir, "test.png"); + await fs.writeFile(imagePath, Buffer.from("data")); + + const result = await buildPromptWithImages( + "Prompt", + [imagePath], + undefined, + true + ); + + const blocks = result.content as Array<{ + type: string; + text?: string; + }>; + const textBlock = blocks.find((b) => b.type === "text"); + + expect(textBlock?.text).toContain("Prompt"); + expect(textBlock?.text).toContain("Attached images:"); + expect(textBlock?.text).toContain(imagePath); + }); + + it("should format multiple image paths when included", async () => { + const img1 = path.join(tempDir, "img1.png"); + const img2 = path.join(tempDir, "img2.jpg"); + + await fs.writeFile(img1, Buffer.from("data1")); + await fs.writeFile(img2, Buffer.from("data2")); + + const result = await buildPromptWithImages( + "Test", + [img1, img2], + undefined, + true + ); + + const blocks = result.content as Array<{ + type: string; + text?: string; + }>; + const textBlock = blocks.find((b) => b.type === "text"); + + expect(textBlock?.text).toContain("Attached images:"); + expect(textBlock?.text).toContain(img1); + expect(textBlock?.text).toContain(img2); + }); + }); + + describe("buildPromptWithImages - edge cases", () => { + it("should handle empty prompt with images", async () => { + const imagePath = path.join(tempDir, "test.png"); + await fs.writeFile(imagePath, Buffer.from("data")); + + const result = await buildPromptWithImages("", [imagePath]); + + expect(result.hasImages).toBe(true); + + const blocks = result.content as Array<{ + type: string; + text?: string; + source?: object; + }>; + // Should only have image block, no text block for empty string + expect(blocks.length).toBeGreaterThan(0); + expect(blocks.some((b) => b.type === "image")).toBe(true); + }); + + it("should handle whitespace-only prompt with images", async () => { + const imagePath = path.join(tempDir, "test.png"); + await fs.writeFile(imagePath, Buffer.from("data")); + + const result = await buildPromptWithImages(" ", [imagePath]); + + expect(result.hasImages).toBe(true); + + const blocks = result.content as Array<{ + type: string; + text?: string; + source?: object; + }>; + // Whitespace-only is trimmed, so no text block should be added + expect(blocks.every((b) => b.type !== "text")).toBe(true); + }); + + it("should skip failed image loads", async () => { + const validImage = path.join(tempDir, "valid.png"); + const invalidImage = path.join(tempDir, "nonexistent.png"); + + await fs.writeFile(validImage, Buffer.from("data")); + + const result = await buildPromptWithImages("Test", [ + validImage, + invalidImage, + ]); + + expect(result.hasImages).toBe(true); + + const blocks = result.content as Array<{ + type: string; + text?: string; + source?: object; + }>; + const imageBlocks = blocks.filter((b) => b.type === "image"); + + // Only valid image should be included + expect(imageBlocks).toHaveLength(1); + }); + + it("should handle mixed case in includeImagePaths parameter", async () => { + const imagePath = path.join(tempDir, "test.png"); + await fs.writeFile(imagePath, Buffer.from("data")); + + const resultFalse = await buildPromptWithImages( + "Test", + [imagePath], + undefined, + false + ); + const resultTrue = await buildPromptWithImages( + "Test", + [imagePath], + undefined, + true + ); + + const blocksFalse = resultFalse.content as Array<{ + type: string; + text?: string; + }>; + const blocksTrue = resultTrue.content as Array<{ + type: string; + text?: string; + }>; + + expect(blocksFalse[0].text).not.toContain("Attached images:"); + expect(blocksTrue[0].text).toContain("Attached images:"); + }); + }); + + describe("buildPromptWithImages - content format", () => { + it("should return string when only text and includeImagePaths false", async () => { + const result = await buildPromptWithImages("Just text", undefined); + + expect(typeof result.content).toBe("string"); + }); + + it("should return array when has images", async () => { + const imagePath = path.join(tempDir, "test.png"); + await fs.writeFile(imagePath, Buffer.from("data")); + + const result = await buildPromptWithImages("Text", [imagePath]); + + expect(Array.isArray(result.content)).toBe(true); + }); + + it("should preserve prompt formatting", async () => { + const basePrompt = "Line 1\n\nLine 2\n Indented line"; + const imagePath = path.join(tempDir, "test.png"); + await fs.writeFile(imagePath, Buffer.from("data")); + + const result = await buildPromptWithImages(basePrompt, [imagePath]); + + const blocks = result.content as Array<{ + type: string; + text?: string; + }>; + const textBlock = blocks.find((b) => b.type === "text"); + + expect(textBlock?.text).toBe(basePrompt); + }); + }); +}); diff --git a/libs/utils/vitest.config.ts b/libs/utils/vitest.config.ts index 62681b2d..ecc209f8 100644 --- a/libs/utils/vitest.config.ts +++ b/libs/utils/vitest.config.ts @@ -11,12 +11,12 @@ export default defineConfig({ include: ["src/**/*.ts"], exclude: ["src/**/*.d.ts", "src/index.ts"], thresholds: { - // Current overall coverage: ~19% (only error-handler.ts tested) - // Set modest thresholds until more files are tested - lines: 15, - functions: 15, - branches: 25, - statements: 15, + // Excellent coverage: 94.3% stmts, 89.77% branches, 100% funcs, 94.21% lines + // All files now have comprehensive tests + lines: 90, + functions: 95, + branches: 85, + statements: 90, }, }, },