mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
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:
241
libs/utils/tests/conversation-utils.test.ts
Normal file
241
libs/utils/tests/conversation-utils.test.ts
Normal 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' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
261
libs/utils/tests/error-handler.test.ts
Normal file
261
libs/utils/tests/error-handler.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
246
libs/utils/tests/fs-utils.test.ts
Normal file
246
libs/utils/tests/fs-utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
244
libs/utils/tests/image-handler.test.ts
Normal file
244
libs/utils/tests/image-handler.test.ts
Normal 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:');
|
||||
});
|
||||
});
|
||||
});
|
||||
292
libs/utils/tests/logger.test.ts
Normal file
292
libs/utils/tests/logger.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
283
libs/utils/tests/prompt-builder.test.ts
Normal file
283
libs/utils/tests/prompt-builder.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user