import { ToolExecutor } from '../src/tool-executor'; import { TFile } from 'obsidian'; import { ToolCall, ToolResult } from '../src/types'; import { ErrorHandler } from '../src/error-handler'; // Mock Obsidian types interface MockVault { create: (path: string, content: string) => Promise; getAbstractFileByPath: (path: string) => any; cachedRead: (file: any) => Promise; getMarkdownFiles: () => any[]; } interface MockApp { // Mock app properties if needed } interface MockNotice { (message: string): void; } // Mock Obsidian module jest.mock('obsidian', () => { class TFile {} return { Vault: jest.fn(), App: jest.fn(), Notice: jest.fn(), TFile, }; }); // 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), getAbstractFileByPath: jest.fn(), cachedRead: jest.fn().mockResolvedValue(''), getMarkdownFiles: jest.fn().mockReturnValue([]), }; 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('read_vault_file tool', () => { it('should successfully read an existing file', async () => { const mockFile = { path: 'test-file.md', basename: 'test-file.md', }; // Create a proper mock TFile class for instanceof checks class MockTFile extends TFile { path: string; basename: string; extension: string; constructor(path: string) { super(); this.path = path; this.basename = path.split('/').pop() || path; this.extension = this.basename.split('.').pop() || ''; } } mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('test-file.md')); mockVault.cachedRead = jest.fn().mockResolvedValue('File content'); mockVault.getMarkdownFiles = jest.fn().mockReturnValue([]); const call: ToolCall = { id: 'call_28', type: 'function', function: { name: 'read_vault_file', arguments: JSON.stringify({ path: 'test-file.md', }), }, }; const result = await executor.handleToolCall(call); expect(result.success).toBe(true); expect(result.message).toBe('File read successfully'); expect(result.data).toEqual({ path: 'test-file.md', content: 'File content' }); }); it('should reject path traversal attempts', async () => { const call: ToolCall = { id: 'call_29', type: 'function', function: { name: 'read_vault_file', arguments: JSON.stringify({ path: '../test-file.md', }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow(); }); it('should reject invalid characters in path', async () => { const call: ToolCall = { id: 'call_30', type: 'function', function: { name: 'read_vault_file', arguments: JSON.stringify({ path: 'test { // Return null to simulate file not found mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(null); mockVault.cachedRead = jest.fn().mockResolvedValue(''); mockVault.getMarkdownFiles = jest.fn().mockReturnValue([]); const call: ToolCall = { id: 'call_31', type: 'function', function: { name: 'read_vault_file', arguments: JSON.stringify({ path: 'nonexistent.md', }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow( 'File not found: nonexistent.md' ); }); it('should reject non-string path', async () => { const call: ToolCall = { id: 'call_32', type: 'function', function: { name: 'read_vault_file', arguments: JSON.stringify({ path: 123, }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow('Path must be a string'); }); }); describe('search_vault_files tool', () => { it('should successfully search vault files', async () => { const mockFiles = [ { path: 'file1.md', basename: 'file1.md' }, { path: 'file2.md', basename: 'file2.md' }, ]; mockVault.getMarkdownFiles = jest.fn().mockReturnValue([ { path: 'query.md', basename: 'query.md' }, { path: 'other.md', basename: 'other.md' }, { path: 'query2.md', basename: 'query2.md' }, { path: 'unrelated.md', basename: 'unrelated.md' }, ] as unknown as any[]); const call: ToolCall = { id: 'call_33', type: 'function', function: { name: 'search_vault_files', arguments: JSON.stringify({ query: 'query', }), }, }; const result = await executor.handleToolCall(call); expect(result.success).toBe(true); expect(result.message).toContain('Found 2 matching files'); expect(result.data).toHaveLength(2); expect(result.data).toContainEqual({ path: 'query.md', basename: 'query.md' }); expect(result.data).toContainEqual({ path: 'query2.md', basename: 'query2.md' }); }); it('should limit results based on limit parameter', async () => { mockVault.getMarkdownFiles = jest.fn().mockReturnValue([ { path: 'result1.md', basename: 'result1.md' }, { path: 'result2.md', basename: 'result2.md' }, { path: 'result3.md', basename: 'result3.md' }, { path: 'result4.md', basename: 'result4.md' }, { path: 'result5.md', basename: 'result5.md' }, ] as unknown as any[]); const call: ToolCall = { id: 'call_34', type: 'function', function: { name: 'search_vault_files', arguments: JSON.stringify({ query: 'result', limit: 3, }), }, }; const result = await executor.handleToolCall(call); expect(result.data).toHaveLength(3); }); it('should use default limit of 10 when no limit specified', async () => { const mockFiles = Array.from({ length: 15 }, (_, i) => ({ path: `match${i}.md`, basename: `match${i}.md`, })); mockVault.getMarkdownFiles = jest.fn().mockReturnValue(mockFiles as unknown as any[]); const call: ToolCall = { id: 'call_35', type: 'function', function: { name: 'search_vault_files', arguments: JSON.stringify({ query: 'match', }), }, }; const result = await executor.handleToolCall(call); expect(result.data).toHaveLength(10); }); it('should handle case-insensitive search', async () => { mockVault.getMarkdownFiles = jest.fn().mockReturnValue([ { path: 'QUERY.md', basename: 'QUERY.md' }, { path: 'Query.md', basename: 'Query.md' }, { path: 'query.md', basename: 'query.md' }, { path: 'other.md', basename: 'other.md' }, ] as unknown as any[]); const call: ToolCall = { id: 'call_36', type: 'function', function: { name: 'search_vault_files', arguments: JSON.stringify({ query: 'query', }), }, }; const result = await executor.handleToolCall(call); expect(result.data).toHaveLength(3); }); it('should reject non-string query', async () => { const call: ToolCall = { id: 'call_37', type: 'function', function: { name: 'search_vault_files', arguments: JSON.stringify({ query: 123, }), }, }; await expect(executor.handleToolCall(call)).rejects.toThrow('Query must be a string'); }); it('should return empty array when no matches found', async () => { mockVault.getMarkdownFiles = jest.fn().mockReturnValue([ { path: 'unrelated1.md', basename: 'unrelated1.md' }, { path: 'unrelated2.md', basename: 'unrelated2.md' }, ] as unknown as any[]); const call: ToolCall = { id: 'call_38', type: 'function', function: { name: 'search_vault_files', arguments: JSON.stringify({ query: 'nomatches', }), }, }; const result = await executor.handleToolCall(call); expect(result.success).toBe(true); expect(result.data).toHaveLength(0); expect(result.message).toContain('Found 0 matching files'); }); }); describe('executeTool method', () => { it('should execute create_file tool successfully', async () => { const result = await executor.executeTool('create_file', { path: 'test-file.md', content: 'Test content', }); expect(result.success).toBe(true); expect(mockVault.create).toHaveBeenCalledWith('test-file.md', 'Test content'); }); it('should execute read_vault_file tool successfully', async () => { // Create a proper mock TFile class for instanceof checks class MockTFile extends TFile { path: string; basename: string; extension: string; constructor(path: string) { super(); this.path = path; this.basename = path.split('/').pop() || path; this.extension = this.basename.split('.').pop() || ''; } } mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new MockTFile('test-file.md')); mockVault.cachedRead = jest.fn().mockResolvedValue('File content'); mockVault.getMarkdownFiles = jest.fn().mockReturnValue([]); const result = await executor.executeTool('read_vault_file', { path: 'test-file.md', }); expect(result.success).toBe(true); }); it('should execute search_vault_files tool successfully', async () => { mockVault.getMarkdownFiles = jest .fn() .mockReturnValue([{ path: 'match.md', basename: 'match.md' }] as unknown as any[]); const result = await executor.executeTool('search_vault_files', { query: 'match', }); expect(result.success).toBe(true); expect(result.data).toHaveLength(1); }); it('should handle unknown tool', async () => { const result = await executor.executeTool('unknown_tool', {}); expect(result.success).toBe(false); expect(result.message).toContain('Unknown tool: unknown_tool'); }); }); describe('unknown tool', () => { it('should return failure for unknown tool', async () => { const call: ToolCall = { id: 'call_39', 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' }); }); }); }); });