59df2f6856
Update error handling and improve streaming capabilities in the Ollama client and related components. Key changes include: - Simplify error types and improve error handling - Refactor streaming logic to use async generators - Update tool execution and vault indexing - Improve utility functions and types - Update test files to reflect changes
212 lines
7.1 KiB
TypeScript
Executable File
212 lines
7.1 KiB
TypeScript
Executable File
import { ChatView } from '../src/chat-view';
|
|
import { PluginSettings, OllamaMessage, ChatMessage, OllamaTool, ToolCall } from '../src/types';
|
|
|
|
// Mock Obsidian types
|
|
interface MockVault {
|
|
getMarkdownFiles: () => any[];
|
|
read: () => Promise<string>;
|
|
create: () => Promise<any>;
|
|
}
|
|
interface MockWorkspace {
|
|
getLeaf: () => any;
|
|
revealLeaf: () => void;
|
|
}
|
|
interface MockApp {
|
|
vault: MockVault;
|
|
workspace: MockWorkspace;
|
|
}
|
|
|
|
// Mock Obsidian module - ItemView must set this.app from the leaf
|
|
jest.mock('obsidian', () => ({
|
|
ItemView: jest.fn().mockImplementation(function (this: any, leaf: any) {
|
|
this.app = leaf.app;
|
|
}),
|
|
WorkspaceLeaf: jest.fn(),
|
|
Notice: jest.fn(),
|
|
}));
|
|
|
|
const mockSettings: PluginSettings = {
|
|
ollamaUrl: 'http://localhost:11434',
|
|
model: 'llama3',
|
|
lastIndexTime: 0,
|
|
};
|
|
|
|
describe('ChatView', () => {
|
|
let view: ChatView;
|
|
let mockLeaf: any;
|
|
let mockApp: MockApp;
|
|
|
|
beforeEach(() => {
|
|
mockApp = {
|
|
vault: {
|
|
getMarkdownFiles: jest.fn().mockReturnValue([]),
|
|
read: jest.fn().mockResolvedValue(''),
|
|
create: jest.fn().mockResolvedValue(null),
|
|
},
|
|
workspace: {
|
|
getLeaf: jest.fn(),
|
|
revealLeaf: jest.fn(),
|
|
},
|
|
};
|
|
|
|
mockLeaf = {
|
|
view: null,
|
|
setViewState: jest.fn(),
|
|
app: mockApp,
|
|
};
|
|
|
|
view = new ChatView(mockLeaf as unknown as any, mockSettings);
|
|
// Obsidian's contentEl has a createEl helper that standard DOM lacks
|
|
// Unlike standard DOM, Obsidian elements can create nested elements with createEl
|
|
const contentDiv = document.createElement('div') as any;
|
|
|
|
// Create a factory function that captures the parent element
|
|
const createElementWithCreateEl = function (parent: any) {
|
|
return function (tag: string, options?: { cls?: string }) {
|
|
const el = document.createElement(tag);
|
|
if (options?.cls) {
|
|
el.classList.add(...options.cls.split(' '));
|
|
}
|
|
parent.appendChild(el);
|
|
// Add createEl to the new element so it can create nested elements
|
|
(el as any).createEl = createElementWithCreateEl(el);
|
|
return el;
|
|
};
|
|
};
|
|
|
|
contentDiv.createEl = createElementWithCreateEl(contentDiv);
|
|
view.contentEl = contentDiv;
|
|
});
|
|
|
|
describe('getViewType', () => {
|
|
it('should return the correct view type', () => {
|
|
expect(view.getViewType()).toBe('ollama-chat-view');
|
|
});
|
|
});
|
|
|
|
describe('getDisplayText', () => {
|
|
it('should return the correct display text', () => {
|
|
expect(view.getDisplayText()).toBe('Ollama Chat');
|
|
});
|
|
});
|
|
|
|
describe('onOpen', () => {
|
|
it('should call render and setup event listeners', async () => {
|
|
const renderSpy = jest.spyOn(view, 'render');
|
|
const setupSpy = jest.spyOn(view, 'setupEventListeners' as any);
|
|
await view.onOpen();
|
|
expect(renderSpy).toHaveBeenCalled();
|
|
expect(setupSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('onClose', () => {
|
|
it('should clean up resources and remove event listeners', async () => {
|
|
view['lastMessageEl'] = document.createElement('div');
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
const cancelSpy = jest.spyOn(view['ollamaClient'], 'cancelStream');
|
|
const removeSpy = jest.spyOn(view, 'removeEventListeners' as any);
|
|
await view.onClose();
|
|
expect(view['lastMessageEl']).toBeNull();
|
|
expect(view['sendButton']).toBeNull();
|
|
expect(view['inputEl']).toBeNull();
|
|
expect(cancelSpy).toHaveBeenCalled();
|
|
expect(removeSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('render', () => {
|
|
it('should render the chat interface', async () => {
|
|
await view.render();
|
|
expect(view.contentEl.querySelector('.ollama-chat-container')).not.toBeNull();
|
|
expect(view.contentEl.querySelector('.ollama-input-container')).not.toBeNull();
|
|
});
|
|
|
|
it('should not duplicate elements on re-render', async () => {
|
|
// First render
|
|
await view.render();
|
|
const firstRenderCount = view.contentEl.querySelectorAll('.ollama-message').length;
|
|
|
|
// Second render with no changes
|
|
await view.render();
|
|
const secondRenderCount = view.contentEl.querySelectorAll('.ollama-message').length;
|
|
|
|
expect(secondRenderCount).toBe(firstRenderCount);
|
|
});
|
|
|
|
it('should only render non-streaming messages', async () => {
|
|
view['messages'] = [
|
|
{ id: '1', role: 'user', content: 'test', timestamp: Date.now() },
|
|
{
|
|
id: '2',
|
|
role: 'assistant',
|
|
content: 'response',
|
|
timestamp: Date.now(),
|
|
isStreaming: true,
|
|
},
|
|
];
|
|
await view.render();
|
|
const messages = view.contentEl.querySelectorAll('.ollama-message');
|
|
expect(messages.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('handleUserInput', () => {
|
|
it('should handle user input and call ollamaClient', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockResolvedValue({
|
|
[Symbol.asyncIterator]: async function* () {
|
|
yield { content: 'test' };
|
|
},
|
|
} as any);
|
|
await (view as any).handleUserInput('test');
|
|
expect(chatSpy).toHaveBeenCalled();
|
|
// Verify that messages were added to conversation history
|
|
expect((view as any).messages.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should process tool calls with follow-up context', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
(view['inputEl'] as HTMLTextAreaElement).value = 'test';
|
|
const chatSpy = jest.spyOn(view['ollamaClient'], 'streamChat').mockResolvedValue({
|
|
[Symbol.asyncIterator]: async function* () {
|
|
yield {
|
|
content: 'test',
|
|
tool_calls: [{ function: { name: 'create_file', arguments: '{}' } }],
|
|
};
|
|
},
|
|
} as any);
|
|
const followUpSpy = jest
|
|
.spyOn(view['ollamaClient'], 'chat')
|
|
.mockResolvedValue({ content: ' follow-up' });
|
|
await (view as any).handleUserInput('test');
|
|
expect(followUpSpy).toHaveBeenCalled();
|
|
// Verify that tool calls resulted in follow-up messages
|
|
expect((view as any).messages.length).toBeGreaterThan(1);
|
|
});
|
|
});
|
|
|
|
describe('event listeners', () => {
|
|
it('should setup event listeners on open', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
await view.onOpen();
|
|
expect(view['sendButtonClickHandler']).not.toBeNull();
|
|
expect(view['inputKeyDownHandler']).not.toBeNull();
|
|
});
|
|
|
|
it('should remove event listeners on close', async () => {
|
|
view['sendButton'] = document.createElement('button');
|
|
view['inputEl'] = document.createElement('textarea');
|
|
await view.onOpen();
|
|
const removeSpy = jest.spyOn(view, 'removeEventListeners' as any);
|
|
await view.onClose();
|
|
expect(removeSpy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|