Merge main into massive-terminal-upgrade

Resolves merge conflicts:
- apps/server/src/routes/terminal/common.ts: Keep randomBytes import, use @automaker/utils for createLogger
- apps/ui/eslint.config.mjs: Use main's explicit globals list with XMLHttpRequest and MediaQueryListEvent additions
- apps/ui/src/components/views/terminal-view.tsx: Keep our terminal improvements (killAllSessions, beforeunload, better error handling)
- apps/ui/src/config/terminal-themes.ts: Keep our search highlight colors for all themes
- apps/ui/src/store/app-store.ts: Keep our terminal settings persistence improvements (merge function)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
SuperComboGamer
2025-12-21 20:27:44 -05:00
393 changed files with 32473 additions and 17974 deletions

155
libs/utils/README.md Normal file
View File

@@ -0,0 +1,155 @@
# @automaker/utils
Shared utility functions for AutoMaker.
## Overview
This package provides common utility functions used across AutoMaker's server and UI. It includes error handling, logging, conversation utilities, image handling, and prompt building.
## Installation
```bash
npm install @automaker/utils
```
## Exports
### Logger
Structured logging with context.
```typescript
import { createLogger, LogLevel } from '@automaker/utils';
const logger = createLogger('MyComponent');
logger.info('Processing request');
logger.error('Failed to process:', error);
logger.debug('Debug information', { data });
```
### Error Handler
Error classification and user-friendly messages.
```typescript
import {
isAbortError,
isCancellationError,
isAuthenticationError,
classifyError,
getUserFriendlyErrorMessage,
} from '@automaker/utils';
try {
await operation();
} catch (error) {
if (isAbortError(error)) {
console.log('Operation was aborted');
}
const errorInfo = classifyError(error);
const message = getUserFriendlyErrorMessage(error);
}
```
### Conversation Utils
Message formatting and conversion.
```typescript
import {
extractTextFromContent,
normalizeContentBlocks,
formatHistoryAsText,
convertHistoryToMessages,
} from '@automaker/utils';
const text = extractTextFromContent(contentBlocks);
const normalized = normalizeContentBlocks(content);
const formatted = formatHistoryAsText(messages);
const converted = convertHistoryToMessages(history);
```
### Image Handler
Image processing for Claude prompts.
```typescript
import {
getMimeTypeForImage,
readImageAsBase64,
convertImagesToContentBlocks,
formatImagePathsForPrompt,
} from '@automaker/utils';
const mimeType = getMimeTypeForImage('screenshot.png');
const base64 = await readImageAsBase64('/path/to/image.jpg');
const blocks = await convertImagesToContentBlocks(imagePaths, basePath);
const formatted = formatImagePathsForPrompt(imagePaths);
```
### Prompt Builder
Build prompts with images for Claude.
```typescript
import { buildPromptWithImages } from '@automaker/utils';
const result = await buildPromptWithImages({
basePrompt: 'Analyze this screenshot',
imagePaths: ['/path/to/screenshot.png'],
basePath: '/project/path',
});
console.log(result.prompt); // Prompt with image references
console.log(result.images); // Image data for Claude
```
### File System Utils
Common file system operations.
```typescript
import { ensureDir, fileExists, readJsonFile, writeJsonFile } from '@automaker/utils';
await ensureDir('/path/to/dir');
const exists = await fileExists('/path/to/file');
const data = await readJsonFile('/config.json');
await writeJsonFile('/config.json', data);
```
## Usage Example
```typescript
import { createLogger, classifyError, buildPromptWithImages } from '@automaker/utils';
const logger = createLogger('FeatureExecutor');
async function executeWithImages(prompt: string, images: string[]) {
try {
logger.info('Building prompt with images');
const result = await buildPromptWithImages({
basePrompt: prompt,
imagePaths: images,
basePath: process.cwd(),
});
logger.debug('Prompt built successfully', { imageCount: result.images.length });
return result;
} catch (error) {
const errorInfo = classifyError(error);
logger.error('Failed to build prompt:', errorInfo.message);
throw error;
}
}
```
## Dependencies
- `@automaker/types` - Type definitions
## Used By
- `@automaker/server`
- `@automaker/ui`

29
libs/utils/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "@automaker/utils",
"version": "1.0.0",
"type": "module",
"description": "Shared utility functions for AutoMaker",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": [
"automaker",
"utils"
],
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@automaker/platform": "^1.0.0",
"@automaker/types": "^1.0.0"
},
"devDependencies": {
"@types/node": "^22.10.5",
"typescript": "^5.7.3",
"vitest": "^4.0.16"
}
}

View File

@@ -0,0 +1,95 @@
/**
* Conversation history utilities for processing message history
*
* Provides standardized conversation history handling:
* - Extract text from content (string or array format)
* - Normalize content blocks to array format
* - Format history as plain text for CLI-based providers
* - Convert history to Claude SDK message format
*/
import type { ConversationMessage } from '@automaker/types';
/**
* Extract plain text from message content (handles both string and array formats)
*
* @param content - Message content (string or array of content blocks)
* @returns Extracted text content
*/
export function extractTextFromContent(
content: string | Array<{ type: string; text?: string; source?: object }>
): string {
if (typeof content === 'string') {
return content;
}
// Extract text blocks only
return content
.filter((block) => block.type === 'text')
.map((block) => block.text || '')
.join('\n');
}
/**
* Normalize message content to array format
*
* @param content - Message content (string or array)
* @returns Content as array of blocks
*/
export function normalizeContentBlocks(
content: string | Array<{ type: string; text?: string; source?: object }>
): Array<{ type: string; text?: string; source?: object }> {
if (Array.isArray(content)) {
return content;
}
return [{ type: 'text', text: content }];
}
/**
* Format conversation history as plain text for CLI-based providers
*
* @param history - Array of conversation messages
* @returns Formatted text with role labels
*/
export function formatHistoryAsText(history: ConversationMessage[]): string {
if (history.length === 0) {
return '';
}
let historyText = 'Previous conversation:\n\n';
for (const msg of history) {
const contentText = extractTextFromContent(msg.content);
const role = msg.role === 'user' ? 'User' : 'Assistant';
historyText += `${role}: ${contentText}\n\n`;
}
historyText += '---\n\n';
return historyText;
}
/**
* Convert conversation history to Claude SDK message format
*
* @param history - Array of conversation messages
* @returns Array of Claude SDK formatted messages
*/
export function convertHistoryToMessages(history: ConversationMessage[]): Array<{
type: 'user' | 'assistant';
session_id: string;
message: {
role: 'user' | 'assistant';
content: Array<{ type: string; text?: string; source?: object }>;
};
parent_tool_use_id: null;
}> {
return history.map((historyMsg) => ({
type: historyMsg.role,
session_id: '',
message: {
role: historyMsg.role,
content: normalizeContentBlocks(historyMsg.content),
},
parent_tool_use_id: null,
}));
}

View File

@@ -0,0 +1,131 @@
/**
* Error handling utilities for standardized error classification
*
* Provides utilities for:
* - Detecting abort/cancellation errors
* - Detecting authentication errors
* - Classifying errors by type
* - Generating user-friendly error messages
*/
import type { ErrorType, ErrorInfo } from '@automaker/types';
/**
* Check if an error is an abort/cancellation error
*
* @param error - The error to check
* @returns True if the error is an abort error
*/
export function isAbortError(error: unknown): boolean {
return error instanceof Error && (error.name === 'AbortError' || error.message.includes('abort'));
}
/**
* Check if an error is a user-initiated cancellation
*
* @param errorMessage - The error message to check
* @returns True if the error is a user-initiated cancellation
*/
export function isCancellationError(errorMessage: string): boolean {
const lowerMessage = errorMessage.toLowerCase();
return (
lowerMessage.includes('cancelled') ||
lowerMessage.includes('canceled') ||
lowerMessage.includes('stopped') ||
lowerMessage.includes('aborted')
);
}
/**
* Check if an error is an authentication/API key error
*
* @param errorMessage - The error message to check
* @returns True if the error is authentication-related
*/
export function isAuthenticationError(errorMessage: string): boolean {
return (
errorMessage.includes('Authentication failed') ||
errorMessage.includes('Invalid API key') ||
errorMessage.includes('authentication_failed') ||
errorMessage.includes('Fix external API key')
);
}
/**
* Classify an error into a specific type
*
* @param error - The error to classify
* @returns Classified error information
*/
export function classifyError(error: unknown): ErrorInfo {
const message = error instanceof Error ? error.message : String(error || 'Unknown error');
const isAbort = isAbortError(error);
const isAuth = isAuthenticationError(message);
const isCancellation = isCancellationError(message);
let type: ErrorType;
if (isAuth) {
type = 'authentication';
} else if (isAbort) {
type = 'abort';
} else if (isCancellation) {
type = 'cancellation';
} else if (error instanceof Error) {
type = 'execution';
} else {
type = 'unknown';
}
return {
type,
message,
isAbort,
isAuth,
isCancellation,
originalError: error,
};
}
/**
* Get a user-friendly error message
*
* @param error - The error to convert
* @returns User-friendly error message
*/
export function getUserFriendlyErrorMessage(error: unknown): string {
const info = classifyError(error);
if (info.isAbort) {
return 'Operation was cancelled';
}
if (info.isAuth) {
return 'Authentication failed. Please check your API key.';
}
return info.message;
}
/**
* Extract error message from an unknown error value
*
* Simple utility for getting a string error message from any error type.
* Returns the error's message property if it's an Error, otherwise
* converts to string. Used throughout the codebase for consistent
* error message extraction.
*
* @param error - The error value (Error object, string, or unknown)
* @returns Error message string
*
* @example
* ```typescript
* try {
* throw new Error("Something went wrong");
* } catch (error) {
* const message = getErrorMessage(error); // "Something went wrong"
* }
* ```
*/
export function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown error';
}

View File

@@ -0,0 +1,67 @@
/**
* File system utilities that handle symlinks safely
*/
import { secureFs } from '@automaker/platform';
import path from 'path';
/**
* Create a directory, handling symlinks safely to avoid ELOOP errors.
* If the path already exists as a directory or symlink, returns success.
*/
export async function mkdirSafe(dirPath: string): Promise<void> {
const resolvedPath = path.resolve(dirPath);
// Check if path already exists using lstat (doesn't follow symlinks)
try {
const stats = await secureFs.lstat(resolvedPath);
// Path exists - if it's a directory or symlink, consider it success
if (stats.isDirectory() || stats.isSymbolicLink()) {
return;
}
// It's a file - can't create directory
throw new Error(`Path exists and is not a directory: ${resolvedPath}`);
} catch (error: any) {
// ENOENT means path doesn't exist - we should create it
if (error.code !== 'ENOENT') {
// Some other error (could be ELOOP in parent path)
// If it's ELOOP, the path involves symlinks - don't try to create
if (error.code === 'ELOOP') {
console.warn(`[fs-utils] Symlink loop detected at ${resolvedPath}, skipping mkdir`);
return;
}
throw error;
}
}
// Path doesn't exist, create it
try {
await secureFs.mkdir(resolvedPath, { recursive: true });
} catch (error: any) {
// Handle race conditions and symlink issues
if (error.code === 'EEXIST' || error.code === 'ELOOP') {
return;
}
throw error;
}
}
/**
* Check if a path exists, handling symlinks safely.
* Returns true if the path exists as a file, directory, or symlink.
*/
export async function existsSafe(filePath: string): Promise<boolean> {
try {
await secureFs.lstat(filePath);
return true;
} catch (error: any) {
if (error.code === 'ENOENT') {
return false;
}
// ELOOP or other errors - path exists but is problematic
if (error.code === 'ELOOP') {
return true; // Symlink exists, even if looping
}
throw error;
}
}

View File

@@ -0,0 +1,113 @@
/**
* Image handling utilities for processing image files
*
* Provides utilities for:
* - MIME type detection based on file extensions
* - Base64 encoding of image files
* - Content block generation for Claude SDK format
* - Path resolution (relative/absolute)
*/
import { secureFs } from '@automaker/platform';
import path from 'path';
import type { ImageData, ImageContentBlock } from '@automaker/types';
/**
* MIME type mapping for image file extensions
*/
const IMAGE_MIME_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
} as const;
/**
* Get MIME type for an image file based on extension
*
* @param imagePath - Path to the image file
* @returns MIME type string (defaults to "image/png" for unknown extensions)
*/
export function getMimeTypeForImage(imagePath: string): string {
const ext = path.extname(imagePath).toLowerCase();
return IMAGE_MIME_TYPES[ext] || 'image/png';
}
/**
* Read an image file and convert to base64 with metadata
*
* @param imagePath - Path to the image file
* @returns Promise resolving to image data with base64 encoding
* @throws Error if file cannot be read
*/
export async function readImageAsBase64(imagePath: string): Promise<ImageData> {
const imageBuffer = (await secureFs.readFile(imagePath)) as Buffer;
const base64Data = imageBuffer.toString('base64');
const mimeType = getMimeTypeForImage(imagePath);
return {
base64: base64Data,
mimeType,
filename: path.basename(imagePath),
originalPath: imagePath,
};
}
/**
* Convert image paths to content blocks (Claude SDK format)
* Handles both relative and absolute paths
*
* @param imagePaths - Array of image file paths
* @param workDir - Optional working directory for resolving relative paths
* @returns Promise resolving to array of image content blocks
*/
export async function convertImagesToContentBlocks(
imagePaths: string[],
workDir?: string
): Promise<ImageContentBlock[]> {
const blocks: ImageContentBlock[] = [];
for (const imagePath of imagePaths) {
try {
// Resolve to absolute path if needed
const absolutePath =
workDir && !path.isAbsolute(imagePath) ? path.join(workDir, imagePath) : imagePath;
const imageData = await readImageAsBase64(absolutePath);
blocks.push({
type: 'image',
source: {
type: 'base64',
media_type: imageData.mimeType,
data: imageData.base64,
},
});
} catch (error) {
console.error(`[ImageHandler] Failed to load image ${imagePath}:`, error);
// Continue processing other images
}
}
return blocks;
}
/**
* Build a list of image paths for text prompts
* Formats image paths as a bulleted list for inclusion in text prompts
*
* @param imagePaths - Array of image file paths
* @returns Formatted string with image paths, or empty string if no images
*/
export function formatImagePathsForPrompt(imagePaths: string[]): string {
if (imagePaths.length === 0) {
return '';
}
let text = '\n\nAttached images:\n';
for (const imagePath of imagePaths) {
text += `- ${imagePath}\n`;
}
return text;
}

46
libs/utils/src/index.ts Normal file
View File

@@ -0,0 +1,46 @@
/**
* @automaker/utils
* Shared utility functions for AutoMaker
*/
// Error handling
export {
isAbortError,
isCancellationError,
isAuthenticationError,
classifyError,
getUserFriendlyErrorMessage,
getErrorMessage,
} from './error-handler.js';
// Conversation utilities
export {
extractTextFromContent,
normalizeContentBlocks,
formatHistoryAsText,
convertHistoryToMessages,
} from './conversation-utils.js';
// Image handling
export {
getMimeTypeForImage,
readImageAsBase64,
convertImagesToContentBlocks,
formatImagePathsForPrompt,
} from './image-handler.js';
// Prompt building
export {
buildPromptWithImages,
type PromptContent,
type PromptWithImages,
} from './prompt-builder.js';
// Logger
export { createLogger, getLogLevel, setLogLevel, LogLevel } from './logger.js';
// File system utilities
export { mkdirSafe, existsSafe } from './fs-utils.js';
// Path utilities
export { normalizePath, pathsEqual } from './path-utils.js';

74
libs/utils/src/logger.ts Normal file
View File

@@ -0,0 +1,74 @@
/**
* Simple logger utility with log levels
* Configure via LOG_LEVEL environment variable: error, warn, info, debug
* Defaults to 'info' if not set
*/
export enum LogLevel {
ERROR = 0,
WARN = 1,
INFO = 2,
DEBUG = 3,
}
const LOG_LEVEL_NAMES: Record<string, LogLevel> = {
error: LogLevel.ERROR,
warn: LogLevel.WARN,
info: LogLevel.INFO,
debug: LogLevel.DEBUG,
};
let currentLogLevel: LogLevel = LogLevel.INFO;
// Initialize log level from environment variable
const envLogLevel = process.env.LOG_LEVEL?.toLowerCase();
if (envLogLevel && LOG_LEVEL_NAMES[envLogLevel] !== undefined) {
currentLogLevel = LOG_LEVEL_NAMES[envLogLevel];
}
/**
* Create a logger instance with a context prefix
*/
export function createLogger(context: string) {
const prefix = `[${context}]`;
return {
error: (...args: unknown[]): void => {
if (currentLogLevel >= LogLevel.ERROR) {
console.error(prefix, ...args);
}
},
warn: (...args: unknown[]): void => {
if (currentLogLevel >= LogLevel.WARN) {
console.warn(prefix, ...args);
}
},
info: (...args: unknown[]): void => {
if (currentLogLevel >= LogLevel.INFO) {
console.log(prefix, ...args);
}
},
debug: (...args: unknown[]): void => {
if (currentLogLevel >= LogLevel.DEBUG) {
console.log(prefix, '[DEBUG]', ...args);
}
},
};
}
/**
* Get the current log level
*/
export function getLogLevel(): LogLevel {
return currentLogLevel;
}
/**
* Set the log level programmatically (useful for testing)
*/
export function setLogLevel(level: LogLevel): void {
currentLogLevel = level;
}

View File

@@ -0,0 +1,51 @@
/**
* Path Utilities - Cross-platform path manipulation helpers
*
* Provides functions for normalizing and comparing file system paths
* across different operating systems (Windows, macOS, Linux).
*/
/**
* Normalize a path by converting backslashes to forward slashes
*
* This ensures consistent path representation across platforms:
* - Windows: C:\Users\foo\bar -> C:/Users/foo/bar
* - Unix: /home/foo/bar -> /home/foo/bar (unchanged)
*
* @param p - Path string to normalize
* @returns Normalized path with forward slashes
*
* @example
* ```typescript
* normalizePath("C:\\Users\\foo\\bar"); // "C:/Users/foo/bar"
* normalizePath("/home/foo/bar"); // "/home/foo/bar"
* ```
*/
export function normalizePath(p: string): string {
return p.replace(/\\/g, '/');
}
/**
* Compare two paths for equality after normalization
*
* Handles null/undefined values and normalizes paths before comparison.
* Useful for checking if two paths refer to the same location regardless
* of platform-specific path separators.
*
* @param p1 - First path to compare (or null/undefined)
* @param p2 - Second path to compare (or null/undefined)
* @returns true if paths are equal (or both null/undefined), false otherwise
*
* @example
* ```typescript
* pathsEqual("C:\\foo\\bar", "C:/foo/bar"); // true
* pathsEqual("/home/user", "/home/user"); // true
* pathsEqual("/home/user", "/home/other"); // false
* pathsEqual(null, undefined); // false
* pathsEqual(null, null); // true
* ```
*/
export function pathsEqual(p1: string | undefined | null, p2: string | undefined | null): boolean {
if (!p1 || !p2) return p1 === p2;
return normalizePath(p1) === normalizePath(p2);
}

View File

@@ -0,0 +1,79 @@
/**
* Prompt building utilities for constructing prompts with images
*
* Provides standardized prompt building that:
* - Combines text prompts with image attachments
* - Handles content block array generation
* - Optionally includes image paths in text
* - Supports both vision and non-vision models
*/
import { convertImagesToContentBlocks, formatImagePathsForPrompt } from './image-handler.js';
/**
* Content that can be either simple text or structured blocks
*/
export type PromptContent =
| string
| Array<{
type: string;
text?: string;
source?: object;
}>;
/**
* Result of building a prompt with optional images
*/
export interface PromptWithImages {
content: PromptContent;
hasImages: boolean;
}
/**
* Build a prompt with optional image attachments
*
* @param basePrompt - The text prompt
* @param imagePaths - Optional array of image file paths
* @param workDir - Optional working directory for resolving relative paths
* @param includeImagePaths - Whether to append image paths to the text (default: false)
* @returns Promise resolving to prompt content and metadata
*/
export async function buildPromptWithImages(
basePrompt: string,
imagePaths?: string[],
workDir?: string,
includeImagePaths: boolean = false
): Promise<PromptWithImages> {
// No images - return plain text
if (!imagePaths || imagePaths.length === 0) {
return { content: basePrompt, hasImages: false };
}
// Build text content with optional image path listing
let textContent = basePrompt;
if (includeImagePaths) {
textContent += formatImagePathsForPrompt(imagePaths);
}
// Build content blocks array
const contentBlocks: Array<{
type: string;
text?: string;
source?: object;
}> = [];
// Add text block if we have text
if (textContent.trim()) {
contentBlocks.push({ type: 'text', text: textContent });
}
// Add image blocks
const imageBlocks = await convertImagesToContentBlocks(imagePaths, workDir);
contentBlocks.push(...imageBlocks);
// Return appropriate format
const content: PromptContent =
contentBlocks.length > 1 || contentBlocks[0]?.type === 'image' ? contentBlocks : textContent;
return { content, hasImages: true };
}

View File

@@ -0,0 +1,241 @@
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,261 @@
import { describe, it, expect } from 'vitest';
import {
isAbortError,
isCancellationError,
isAuthenticationError,
classifyError,
getUserFriendlyErrorMessage,
} from '../src/error-handler';
describe('error-handler.ts', () => {
describe('isAbortError', () => {
it("should return true for Error with name 'AbortError'", () => {
const error = new Error('Operation aborted');
error.name = 'AbortError';
expect(isAbortError(error)).toBe(true);
});
it("should return true for Error with message containing 'abort'", () => {
const error = new Error('Request was aborted');
expect(isAbortError(error)).toBe(true);
});
it('should return false for regular Error', () => {
const error = new Error('Something went wrong');
expect(isAbortError(error)).toBe(false);
});
it('should return false for non-Error values', () => {
expect(isAbortError('abort')).toBe(false);
expect(isAbortError(null)).toBe(false);
expect(isAbortError(undefined)).toBe(false);
expect(isAbortError({})).toBe(false);
});
it('should handle Error with both AbortError name and abort message', () => {
const error = new Error('abort');
error.name = 'AbortError';
expect(isAbortError(error)).toBe(true);
});
});
describe('isCancellationError', () => {
it("should return true for 'cancelled' message", () => {
expect(isCancellationError('Operation cancelled')).toBe(true);
expect(isCancellationError('CANCELLED')).toBe(true);
});
it("should return true for 'canceled' message (US spelling)", () => {
expect(isCancellationError('Operation canceled')).toBe(true);
expect(isCancellationError('CANCELED')).toBe(true);
});
it("should return true for 'stopped' message", () => {
expect(isCancellationError('Process stopped')).toBe(true);
expect(isCancellationError('STOPPED')).toBe(true);
});
it("should return true for 'aborted' message", () => {
expect(isCancellationError('Request aborted')).toBe(true);
expect(isCancellationError('ABORTED')).toBe(true);
});
it('should return false for non-cancellation messages', () => {
expect(isCancellationError('Something went wrong')).toBe(false);
expect(isCancellationError('Error occurred')).toBe(false);
expect(isCancellationError('')).toBe(false);
});
it('should be case-insensitive', () => {
expect(isCancellationError('CaNcElLeD')).toBe(true);
expect(isCancellationError('StOpPeD')).toBe(true);
});
});
describe('isAuthenticationError', () => {
it("should return true for 'Authentication failed' message", () => {
expect(isAuthenticationError('Authentication failed')).toBe(true);
});
it("should return true for 'Invalid API key' message", () => {
expect(isAuthenticationError('Invalid API key provided')).toBe(true);
});
it("should return true for 'authentication_failed' message", () => {
expect(isAuthenticationError('Error: authentication_failed')).toBe(true);
});
it("should return true for 'Fix external API key' message", () => {
expect(isAuthenticationError('Fix external API key configuration')).toBe(true);
});
it('should return false for non-authentication errors', () => {
expect(isAuthenticationError('Something went wrong')).toBe(false);
expect(isAuthenticationError('Network error')).toBe(false);
expect(isAuthenticationError('')).toBe(false);
});
it('should be case-sensitive', () => {
expect(isAuthenticationError('authentication failed')).toBe(false);
expect(isAuthenticationError('AUTHENTICATION FAILED')).toBe(false);
});
});
describe('classifyError', () => {
it('should classify authentication errors', () => {
const error = new Error('Authentication failed');
const result = classifyError(error);
expect(result.type).toBe('authentication');
expect(result.isAuth).toBe(true);
expect(result.isAbort).toBe(false);
expect(result.isCancellation).toBe(false);
expect(result.message).toBe('Authentication failed');
expect(result.originalError).toBe(error);
});
it('should classify abort errors', () => {
const error = new Error('aborted');
const result = classifyError(error);
expect(result.type).toBe('abort');
expect(result.isAbort).toBe(true);
expect(result.isAuth).toBe(false);
expect(result.message).toBe('aborted');
});
it('should classify AbortError by name', () => {
const error = new Error('Request cancelled');
error.name = 'AbortError';
const result = classifyError(error);
expect(result.type).toBe('abort');
expect(result.isAbort).toBe(true);
});
it('should classify cancellation errors', () => {
const error = new Error('Operation cancelled');
const result = classifyError(error);
expect(result.type).toBe('cancellation');
expect(result.isCancellation).toBe(true);
expect(result.isAbort).toBe(false);
});
it('should classify execution errors (regular Error)', () => {
const error = new Error('Something went wrong');
const result = classifyError(error);
expect(result.type).toBe('execution');
expect(result.isAuth).toBe(false);
expect(result.isAbort).toBe(false);
expect(result.isCancellation).toBe(false);
});
it('should classify unknown errors (non-Error)', () => {
const result = classifyError('string error');
expect(result.type).toBe('unknown');
expect(result.message).toBe('string error');
});
it('should handle null/undefined errors', () => {
const result1 = classifyError(null);
expect(result1.type).toBe('unknown');
expect(result1.message).toBe('Unknown error');
const result2 = classifyError(undefined);
expect(result2.type).toBe('unknown');
expect(result2.message).toBe('Unknown error');
});
it('should prioritize authentication over abort', () => {
const error = new Error('Authentication failed - aborted');
const result = classifyError(error);
expect(result.type).toBe('authentication');
expect(result.isAuth).toBe(true);
expect(result.isAbort).toBe(true); // Both flags can be true
});
it('should prioritize abort over cancellation', () => {
const error = new Error('Request cancelled');
error.name = 'AbortError';
const result = classifyError(error);
expect(result.type).toBe('abort');
expect(result.isAbort).toBe(true);
expect(result.isCancellation).toBe(true); // Both flags can be true
});
it('should convert object errors to string', () => {
const result = classifyError({ code: 500, message: 'Server error' });
expect(result.message).toContain('Object');
});
it('should convert number errors to string', () => {
const result = classifyError(404);
expect(result.message).toBe('404');
expect(result.type).toBe('unknown');
});
});
describe('getUserFriendlyErrorMessage', () => {
it('should return friendly message for abort errors', () => {
const error = new Error('abort');
const message = getUserFriendlyErrorMessage(error);
expect(message).toBe('Operation was cancelled');
});
it('should return friendly message for AbortError by name', () => {
const error = new Error('Something');
error.name = 'AbortError';
const message = getUserFriendlyErrorMessage(error);
expect(message).toBe('Operation was cancelled');
});
it('should return friendly message for authentication errors', () => {
const error = new Error('Authentication failed');
const message = getUserFriendlyErrorMessage(error);
expect(message).toBe('Authentication failed. Please check your API key.');
});
it('should prioritize abort message over auth', () => {
const error = new Error('Authentication failed - abort');
const message = getUserFriendlyErrorMessage(error);
// Auth is checked first in classifyError, but abort check happens before auth in getUserFriendlyErrorMessage
expect(message).toBe('Operation was cancelled');
});
it('should return original message for other errors', () => {
const error = new Error('Network timeout');
const message = getUserFriendlyErrorMessage(error);
expect(message).toBe('Network timeout');
});
it('should handle non-Error values', () => {
expect(getUserFriendlyErrorMessage('string error')).toBe('string error');
expect(getUserFriendlyErrorMessage(null)).toBe('Unknown error');
expect(getUserFriendlyErrorMessage(undefined)).toBe('Unknown error');
});
it('should return original message for cancellation errors', () => {
const error = new Error('Operation cancelled by user');
const message = getUserFriendlyErrorMessage(error);
expect(message).toBe('Operation cancelled by user');
});
it('should handle Error without message', () => {
const error = new Error();
const message = getUserFriendlyErrorMessage(error);
expect(message).toBe('');
});
});
});

View File

@@ -0,0 +1,246 @@
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,244 @@
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,292 @@
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,283 @@
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);
});
});
});

9
libs/utils/tsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.d.ts', 'src/index.ts'],
thresholds: {
// 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,
},
},
},
});