Files
obsidian_ollama/tests/tool-executor.test.ts
T

784 lines
25 KiB
TypeScript
Executable File

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<any>;
getAbstractFileByPath: (path: string) => any;
cachedRead: (file: any) => Promise<string>;
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<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_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<file.md',
}),
},
};
await expect(executor.handleToolCall(call)).rejects.toThrow();
});
it('should throw error when file not found', async () => {
// 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' });
});
});
});
});