import { ToolExecutor } from '../src/tool-executor'; import { ToolCall, ToolResult } from '../src/types'; import { ErrorHandler } from '../src/error-handler'; // Mock Obsidian types interface MockVault { create: (path: string, content: string) => Promise; } interface MockApp { // Mock app properties if needed } interface MockNotice { (message: string): void; } // Mock Obsidian module jest.mock('obsidian', () => ({ Vault: jest.fn(), App: jest.fn(), Notice: jest.fn(), })); // Mock ErrorHandler jest.mock('../src/error-handler', () => ({ ErrorHandler: { handleError: jest.fn(), }, })); describe('ToolExecutor', () => { let executor: ToolExecutor; let mockVault: MockVault; let mockApp: MockApp; beforeEach(() => { mockVault = { create: jest.fn().mockResolvedValue(null), }; mockApp = {} as MockApp; executor = new ToolExecutor(mockVault as unknown as any, mockApp as unknown as any); jest.clearAllMocks(); }); describe('handleToolCall', () => { describe('create_file tool', () => { it('should successfully create a file with valid arguments', async () => { const call: ToolCall = { id: 'call_1', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: 'test-file.md', content: 'Test content', }), }, }; const result = await executor.handleToolCall(call); expect(result).toEqual({ success: true, message: 'File created successfully' }); expect(mockVault.create).toHaveBeenCalledWith('test-file.md', 'Test content'); }); it('should handle object arguments directly', async () => { const call: ToolCall = { id: 'call_2', type: 'function', function: { name: 'create_file', arguments: { path: 'obj-args-file.md', content: 'Object args content', } as unknown as string, }, }; const result = await executor.handleToolCall(call); expect(result).toEqual({ success: true, message: 'File created successfully' }); expect(mockVault.create).toHaveBeenCalledWith('obj-args-file.md', 'Object args content'); }); it('should successfully create a file in a subdirectory', async () => { const call: ToolCall = { id: 'call_3', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: 'subdirectory/test-file.md', content: 'Subdir content', }), }, }; const result = await executor.handleToolCall(call); expect(result).toEqual({ success: true, message: 'File created successfully' }); expect(mockVault.create).toHaveBeenCalledWith( 'subdirectory/test-file.md', 'Subdir content' ); }); it('should handle multiple slashes gracefully by normalizing path', async () => { const call: ToolCall = { id: 'call_4', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: 'test//file.md', content: 'Test content', }), }, }; const result = await executor.handleToolCall(call); expect(result).toEqual({ success: true, message: 'File created successfully' }); expect(mockVault.create).toHaveBeenCalledWith('test//file.md', 'Test content'); }); it('should handle empty content gracefully', async () => { const call: ToolCall = { id: 'call_5', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: 'empty-file.md', content: '', }), }, }; const result = await executor.handleToolCall(call); expect(result).toEqual({ success: true, message: 'File created successfully' }); expect(mockVault.create).toHaveBeenCalledWith('empty-file.md', ''); }); it('should allow filenames with consecutive dots', async () => { const call: ToolCall = { id: 'call_6', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: 'project..notes.md', content: 'Test content', }), }, }; const result = await executor.handleToolCall(call); expect(result).toEqual({ success: true, message: 'File created successfully' }); expect(mockVault.create).toHaveBeenCalledWith('project..notes.md', 'Test content'); }); it('should reject path traversal attempts with ..', async () => { const call: ToolCall = { id: 'call_7', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: '../test-file.md', content: 'Test content', }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow(); expect(mockVault.create).not.toHaveBeenCalled(); }); it('should reject path traversal attempts with .\\', async () => { const call: ToolCall = { id: 'call_8', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: '.\\test-file.md', content: 'Test content', }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow(); expect(mockVault.create).not.toHaveBeenCalled(); }); it('should reject path traversal attempts with /..', async () => { const call: ToolCall = { id: 'call_9', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: '/../test-file.md', content: 'Test content', }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow(); expect(mockVault.create).not.toHaveBeenCalled(); }); it('should reject absolute paths starting with /', async () => { const call: ToolCall = { id: 'call_10', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: '/var/test-file.md', content: 'Test content', }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow(); expect(mockVault.create).not.toHaveBeenCalled(); }); it('should reject absolute paths starting with \\', async () => { const call: ToolCall = { id: 'call_11', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: '\\var\\test-file.md', content: 'Test content', }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow(); expect(mockVault.create).not.toHaveBeenCalled(); }); it('should reject Windows drive letters', async () => { const call: ToolCall = { id: 'call_12', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: 'C:\\test-file.md', content: 'Test content', }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow(); expect(mockVault.create).not.toHaveBeenCalled(); }); it('should reject empty path', async () => { const call: ToolCall = { id: 'call_13', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: '', content: 'Test content', }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow(); expect(mockVault.create).not.toHaveBeenCalled(); }); it('should reject undefined path', async () => { const call: ToolCall = { id: 'call_14', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ content: 'Test content', }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow(); expect(mockVault.create).not.toHaveBeenCalled(); }); it('should reject path with invalid characters <', async () => { const call: ToolCall = { id: 'call_15', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: 'test', async () => { const call: ToolCall = { id: 'call_16', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: 'test>file.md', content: 'Test content', }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow(); expect(mockVault.create).not.toHaveBeenCalled(); }); it('should reject path with invalid characters :', async () => { const call: ToolCall = { id: 'call_17', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: 'test:file.md', content: 'Test content', }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow(); expect(mockVault.create).not.toHaveBeenCalled(); }); it('should reject path with invalid characters |', async () => { const call: ToolCall = { id: 'call_18', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: 'test|file.md', content: 'Test content', }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow(); expect(mockVault.create).not.toHaveBeenCalled(); }); it('should reject path with invalid characters ?', async () => { const call: ToolCall = { id: 'call_19', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: 'test?file.md', content: 'Test content', }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow(); expect(mockVault.create).not.toHaveBeenCalled(); }); it('should reject path with invalid characters *', async () => { const call: ToolCall = { id: 'call_20', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: 'test*file.md', content: 'Test content', }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow(); expect(mockVault.create).not.toHaveBeenCalled(); }); it('should reject path longer than 200 characters', async () => { const call: ToolCall = { id: 'call_21', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: 'a'.repeat(201) + '.md', content: 'Test content', }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow(); expect(mockVault.create).not.toHaveBeenCalled(); }); it('should reject path with ~ character', async () => { const call: ToolCall = { id: 'call_22', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: 'test~file.md', content: 'Test content', }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow(); expect(mockVault.create).not.toHaveBeenCalled(); }); it('should reject non-string content', async () => { const call: ToolCall = { id: 'call_23', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: 'test-file.md', content: 123, }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow(); expect(mockVault.create).not.toHaveBeenCalled(); }); it('should reject non-string path', async () => { const call: ToolCall = { id: 'call_24', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: 123, content: 'Test content', }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow(); expect(mockVault.create).not.toHaveBeenCalled(); }); it('should handle vault.create rejection gracefully', async () => { mockVault.create = jest.fn().mockRejectedValue(new Error('Permission denied')); const call: ToolCall = { id: 'call_25', type: 'function', function: { name: 'create_file', arguments: JSON.stringify({ path: 'test-file.md', content: 'Test content', }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow(); }); it('should handle invalid JSON in arguments', async () => { const call: ToolCall = { id: 'call_26', type: 'function', function: { name: 'create_file', arguments: 'invalid json', }, }; await expect(executor.handleToolCall(call)).rejects.toThrow(); expect(mockVault.create).not.toHaveBeenCalled(); }); }); describe('unknown tool', () => { it('should return failure for unknown tool', async () => { const call: ToolCall = { id: 'call_27', type: 'function', function: { name: 'unknown_tool', arguments: JSON.stringify({}), }, }; const result = await executor.handleToolCall(call); expect(result).toEqual({ success: false, message: 'Unknown tool: unknown_tool' }); }); }); }); });