test: Add comprehensive tests for platform and utils packages

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 <noreply@anthropic.com>
This commit is contained in:
Kacper
2025-12-20 23:35:31 +01:00
parent 8cccf74ace
commit 30f4315c17
9 changed files with 1881 additions and 12 deletions

View File

@@ -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" },
]);
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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:");
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});
});