Increase test coverage across multiple modules (91.2% statements, 82.85% branches, 82.35% functions, 93.1% lines)

Update abort controller handling in OllamaClient to properly clean up previous controllers

Remove unused imports and constants: MODEL_NAME_REGEX, MouseEvent, DEFAULT_SETTINGS, convertMarkdownToHtml

Refactor event handler naming to be more consistent
```
This commit is contained in:
2026-05-06 22:30:29 +02:00
parent 649061a747
commit 579e60f5d5
21 changed files with 2392 additions and 2427 deletions
+25 -23
View File
@@ -31,9 +31,9 @@ class ChatView extends obsidian_1.ItemView {
this.sendButtonClickHandler = null;
this.inputKeyDownHandler = null;
this.newChatButtonClickHandler = null;
this.sendButtonEventHandler = null;
this.inputKeyDownEventHandler = null;
this.newChatButtonEventHandler = null;
this.sendButtonClickWrapper = null;
this.inputKeyDownWrapper = null;
this.newChatButtonClickWrapper = null;
this.listenersAttached = false;
this.settings = settings;
this.ollamaClient = new ollama_client_1.OllamaClient(settings.ollamaUrl, settings.model);
@@ -70,8 +70,9 @@ class ChatView extends obsidian_1.ItemView {
return Promise.resolve();
}
cleanupStreamingResources() {
// Ensure any ongoing streaming is properly cleaned up
if (this.lastMessageEl && this.lastMessageEl.parentElement) {
// Only cleanup if there's still an active streaming message
const streamingMessage = this.messages.find((msg) => msg.isStreaming);
if (streamingMessage && this.lastMessageEl && this.lastMessageEl.parentElement) {
this.lastMessageEl.parentElement.removeChild(this.lastMessageEl);
this.lastMessageEl = null;
}
@@ -155,39 +156,40 @@ class ChatView extends obsidian_1.ItemView {
this.inputEl.value = '';
};
}
// Add event listeners
this.sendButtonEventHandler = () => {
// Create wrapper functions for event listeners
this.sendButtonClickWrapper = () => {
void this.sendButtonClickHandler?.();
};
this.inputKeyDownEventHandler = (e) => {
this.inputKeyDownWrapper = (e) => {
void this.inputKeyDownHandler?.(e);
};
this.sendButton.addEventListener('click', this.sendButtonEventHandler);
this.inputEl.addEventListener('keydown', this.inputKeyDownEventHandler);
this.newChatButtonClickWrapper = () => {
void this.newChatButtonClickHandler?.();
};
// Add event listeners using wrappers
this.sendButton.addEventListener('click', this.sendButtonClickWrapper);
this.inputEl.addEventListener('keydown', this.inputKeyDownWrapper);
if (this.newChatButton) {
if (!this.newChatButtonClickHandler) {
this.newChatButtonClickHandler = () => this.clearConversation();
}
this.newChatButtonEventHandler = () => {
this.newChatButtonClickHandler?.();
};
this.newChatButton.addEventListener('click', this.newChatButtonEventHandler);
this.newChatButton.addEventListener('click', this.newChatButtonClickWrapper);
}
this.listenersAttached = true;
}
removeEventListeners() {
if (this.sendButton && this.sendButtonEventHandler) {
this.sendButton.removeEventListener('click', this.sendButtonEventHandler);
if (this.sendButton && this.sendButtonClickWrapper) {
this.sendButton.removeEventListener('click', this.sendButtonClickWrapper);
}
if (this.inputEl && this.inputKeyDownEventHandler) {
this.inputEl.removeEventListener('keydown', this.inputKeyDownEventHandler);
if (this.inputEl && this.inputKeyDownWrapper) {
this.inputEl.removeEventListener('keydown', this.inputKeyDownWrapper);
}
if (this.newChatButton && this.newChatButtonEventHandler) {
this.newChatButton.removeEventListener('click', this.newChatButtonEventHandler);
if (this.newChatButton && this.newChatButtonClickWrapper) {
this.newChatButton.removeEventListener('click', this.newChatButtonClickWrapper);
}
this.sendButtonEventHandler = null;
this.inputKeyDownEventHandler = null;
this.newChatButtonEventHandler = null;
this.sendButtonClickWrapper = null;
this.inputKeyDownWrapper = null;
this.newChatButtonClickWrapper = null;
this.listenersAttached = false;
}
clearConversation() {
+38 -51
View File
@@ -4,7 +4,6 @@ import { ItemView, WorkspaceLeaf, Notice } from 'obsidian';
type KeyboardEvent = globalThis.KeyboardEvent;
type HTMLTextAreaElement = globalThis.HTMLTextAreaElement;
type HTMLButtonElement = globalThis.HTMLButtonElement;
type MouseEvent = globalThis.MouseEvent;
const DEFAULT_VAULT_SEARCH_LIMIT = 3;
const MAX_MESSAGE_HISTORY = 50;
@@ -29,16 +28,16 @@ export class ChatView extends ItemView {
private vaultIndexer: VaultIndexer;
private toolExecutor: ToolExecutor;
private lastMessageEl: HTMLElement | null = null;
private newChatButton: HTMLElement | null = null;
private sendButton: HTMLElement | null = null;
private inputEl: HTMLElement | null = null;
private newChatButton: HTMLButtonElement | null = null;
private sendButton: HTMLButtonElement | null = null;
private inputEl: HTMLTextAreaElement | null = null;
private chatContainer: HTMLElement | null = null;
private sendButtonClickHandler: (() => Promise<void>) | null = null;
private inputKeyDownHandler: ((e: KeyboardEvent) => Promise<void>) | null = null;
private newChatButtonClickHandler: (() => void) | null = null;
private sendButtonEventHandler: ((e: MouseEvent) => void) | null = null;
private inputKeyDownEventHandler: ((e: KeyboardEvent) => void) | null = null;
private newChatButtonEventHandler: ((e: MouseEvent) => void) | null = null;
private sendButtonClickWrapper: (() => void) | null = null;
private inputKeyDownWrapper: ((e: KeyboardEvent) => void) | null = null;
private newChatButtonClickWrapper: (() => void) | null = null;
private listenersAttached = false;
// Getters for testing
@@ -98,8 +97,9 @@ export class ChatView extends ItemView {
}
private cleanupStreamingResources(): void {
// Ensure any ongoing streaming is properly cleaned up
if (this.lastMessageEl && this.lastMessageEl.parentElement) {
// Only cleanup if there's still an active streaming message
const streamingMessage = this.messages.find((msg) => msg.isStreaming);
if (streamingMessage && this.lastMessageEl && this.lastMessageEl.parentElement) {
this.lastMessageEl.parentElement.removeChild(this.lastMessageEl);
this.lastMessageEl = null;
}
@@ -120,7 +120,7 @@ export class ChatView extends ItemView {
this.sendButton = inputContainer.createEl('button', {
cls: 'ollama-send-button',
});
(this.sendButton as HTMLButtonElement).textContent = 'Send';
this.sendButton.textContent = 'Send';
}
if (!this.newChatButton) {
@@ -130,8 +130,8 @@ export class ChatView extends ItemView {
this.newChatButton = newChatContainer.createEl('button', {
cls: 'ollama-new-chat-button',
});
(this.newChatButton as HTMLButtonElement).textContent = '🔄 New Chat';
(this.newChatButton as HTMLButtonElement).title = 'Start a new conversation';
this.newChatButton.textContent = '🔄 New Chat';
this.newChatButton.title = 'Start a new conversation';
}
// Create immutable snapshot for rendering
@@ -183,8 +183,8 @@ export class ChatView extends ItemView {
if (!this.sendButtonClickHandler) {
this.sendButtonClickHandler = async () => {
if (!this.inputEl) return;
await this.handleUserInput((this.inputEl as HTMLTextAreaElement).value);
(this.inputEl as HTMLTextAreaElement).value = '';
await this.handleUserInput(this.inputEl.value);
this.inputEl.value = '';
};
}
@@ -192,60 +192,47 @@ export class ChatView extends ItemView {
this.inputKeyDownHandler = async (e: KeyboardEvent) => {
if (!this.inputEl || e.key !== 'Enter' || e.shiftKey) return;
e.preventDefault();
await this.handleUserInput((this.inputEl as HTMLTextAreaElement).value);
(this.inputEl as HTMLTextAreaElement).value = '';
await this.handleUserInput(this.inputEl.value);
this.inputEl.value = '';
};
}
// Add event listeners
this.sendButtonEventHandler = () => {
// Create wrapper functions for event listeners
this.sendButtonClickWrapper = () => {
void this.sendButtonClickHandler?.();
};
this.inputKeyDownEventHandler = (e: KeyboardEvent) => {
this.inputKeyDownWrapper = (e: KeyboardEvent) => {
void this.inputKeyDownHandler?.(e);
};
(this.sendButton as HTMLButtonElement).addEventListener('click', this.sendButtonEventHandler);
(this.inputEl as HTMLTextAreaElement).addEventListener(
'keydown',
this.inputKeyDownEventHandler
);
this.newChatButtonClickWrapper = () => {
void this.newChatButtonClickHandler?.();
};
// Add event listeners using wrappers
this.sendButton.addEventListener('click', this.sendButtonClickWrapper);
this.inputEl.addEventListener('keydown', this.inputKeyDownWrapper);
if (this.newChatButton) {
if (!this.newChatButtonClickHandler) {
this.newChatButtonClickHandler = () => this.clearConversation();
}
this.newChatButtonEventHandler = () => {
this.newChatButtonClickHandler?.();
};
(this.newChatButton as HTMLButtonElement).addEventListener(
'click',
this.newChatButtonEventHandler
);
this.newChatButton.addEventListener('click', this.newChatButtonClickWrapper);
}
this.listenersAttached = true;
}
private removeEventListeners(): void {
if (this.sendButton && this.sendButtonEventHandler) {
(this.sendButton as HTMLButtonElement).removeEventListener(
'click',
this.sendButtonEventHandler
);
if (this.sendButton && this.sendButtonClickWrapper) {
this.sendButton.removeEventListener('click', this.sendButtonClickWrapper);
}
if (this.inputEl && this.inputKeyDownEventHandler) {
(this.inputEl as HTMLTextAreaElement).removeEventListener(
'keydown',
this.inputKeyDownEventHandler
);
if (this.inputEl && this.inputKeyDownWrapper) {
this.inputEl.removeEventListener('keydown', this.inputKeyDownWrapper);
}
if (this.newChatButton && this.newChatButtonEventHandler) {
(this.newChatButton as HTMLButtonElement).removeEventListener(
'click',
this.newChatButtonEventHandler
);
if (this.newChatButton && this.newChatButtonClickWrapper) {
this.newChatButton.removeEventListener('click', this.newChatButtonClickWrapper);
}
this.sendButtonEventHandler = null;
this.inputKeyDownEventHandler = null;
this.newChatButtonEventHandler = null;
this.sendButtonClickWrapper = null;
this.inputKeyDownWrapper = null;
this.newChatButtonClickWrapper = null;
this.listenersAttached = false;
}
@@ -283,7 +270,7 @@ export class ChatView extends ItemView {
private async handleUserInput(content: string) {
if (!this.sendButton || !this.inputEl) return;
(this.sendButton as HTMLButtonElement).disabled = true;
this.sendButton.disabled = true;
try {
// Guard against empty messages
@@ -483,7 +470,7 @@ export class ChatView extends ItemView {
this.cleanupStreamingResources();
} finally {
if (this.sendButton) {
(this.sendButton as HTMLButtonElement).disabled = false;
this.sendButton.disabled = false;
}
}
}
+1 -2
View File
@@ -1,7 +1,7 @@
"use strict";
// Default plugin settings
Object.defineProperty(exports, "__esModule", { value: true });
exports.MODEL_NAME_REGEX = exports.DEFAULT_SETTINGS = void 0;
exports.DEFAULT_SETTINGS = void 0;
exports.DEFAULT_SETTINGS = {
ollamaUrl: 'http://localhost:11434',
model: 'llama3',
@@ -10,4 +10,3 @@ exports.DEFAULT_SETTINGS = {
lastIndexTime: 0,
};
// Model validation regex - lowercase letters, numbers, dashes, underscores only
exports.MODEL_NAME_REGEX = /^[a-z0-9-_]+$/;
-1
View File
@@ -9,4 +9,3 @@ export const DEFAULT_SETTINGS = {
};
// Model validation regex - lowercase letters, numbers, dashes, underscores only
export const MODEL_NAME_REGEX = /^[a-z0-9-_]+$/;
-1
View File
@@ -93,7 +93,6 @@ class OllamaSettingTab extends obsidian_1.PluginSettingTab {
this.containerEl.empty();
// Create container for settings
const container = this.containerEl.createDiv();
container.empty();
new obsidian_1.Setting(container)
.setName('Ollama URL')
.setDesc('URL of your Ollama instance')
-1
View File
@@ -105,7 +105,6 @@ class OllamaSettingTab extends PluginSettingTab {
// Create container for settings
const container = this.containerEl.createDiv() as HTMLElement;
container.empty();
new Setting(container)
.setName('Ollama URL')
+4 -2
View File
@@ -29,7 +29,11 @@ class OllamaClient {
return chunks;
}
async *streamChatWithRetry(messages, tools = [], attempt = 0) {
const prevController = this.abortController;
this.abortController = new AbortController();
if (prevController) {
prevController.abort();
}
try {
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
method: 'POST',
@@ -128,7 +132,6 @@ class OllamaClient {
return this.chatWithRetry(messages, tools, 0);
}
async chatWithRetry(messages, tools = [], attempt = 0) {
const controller = new AbortController();
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: {
@@ -140,7 +143,6 @@ class OllamaClient {
tools,
stream: false,
}),
signal: controller.signal,
});
if (!response.ok) {
if (response.status >= 500 && attempt < this.maxRetries) {
+4 -3
View File
@@ -51,7 +51,11 @@ export class OllamaClient {
tools: OllamaTool[] = [],
attempt: number = 0
): AsyncGenerator<OllamaMessage, void, unknown> {
const prevController = this.abortController;
this.abortController = new AbortController();
if (prevController) {
prevController.abort();
}
try {
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
@@ -175,8 +179,6 @@ export class OllamaClient {
tools: OllamaTool[] = [],
attempt: number = 0
): Promise<OllamaMessage> {
const controller = new AbortController();
const response = await this.fetchFn(`${this.baseURL}/api/chat`, {
method: 'POST',
headers: {
@@ -188,7 +190,6 @@ export class OllamaClient {
tools,
stream: false,
}),
signal: controller.signal,
});
if (!response.ok) {
+1 -8
View File
@@ -1,7 +1,7 @@
"use strict";
// src/types.ts
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_SETTINGS = exports.PathValidationError = exports.ToolExecutionError = exports.StreamingError = exports.ValidationError = exports.ApiError = exports.NetworkError = exports.OllamaError = exports.ErrorType = void 0;
exports.PathValidationError = exports.ToolExecutionError = exports.StreamingError = exports.ValidationError = exports.ApiError = exports.NetworkError = exports.OllamaError = exports.ErrorType = void 0;
// ============================================================
// Error Type Hierarchy
// ============================================================
@@ -70,10 +70,3 @@ class PathValidationError extends OllamaError {
}
}
exports.PathValidationError = PathValidationError;
exports.DEFAULT_SETTINGS = {
ollamaUrl: 'http://localhost:11434',
model: 'llama3',
vaultSearchLimit: 3,
maxMessageHistory: 50,
lastIndexTime: 0,
};
-8
View File
@@ -98,14 +98,6 @@ export interface PluginSettings {
lastIndexTime: number;
}
export const DEFAULT_SETTINGS: PluginSettings = {
ollamaUrl: 'http://localhost:11434',
model: 'llama3',
vaultSearchLimit: 3,
maxMessageHistory: 50,
lastIndexTime: 0,
};
// ============================================================
// Ollama Protocol Types
// ============================================================
-4
View File
@@ -8,7 +8,6 @@ exports.validatePluginSettings = validatePluginSettings;
exports.safeParseJson = safeParseJson;
exports.sanitizeFilePath = sanitizeFilePath;
exports.isValidHttpUrl = isValidHttpUrl;
exports.convertMarkdownToHtml = convertMarkdownToHtml;
// ==================== Logger ====================
var LogLevel;
(function (LogLevel) {
@@ -175,6 +174,3 @@ function isValidHttpUrl(url) {
}
}
// ==================== Markdown Utilities ====================
function convertMarkdownToHtml(markdown) {
return markdown.replace(/\n/g, '<br>').replace(/# (.+)/g, '<h1>$1</h1>');
}
-4
View File
@@ -199,7 +199,3 @@ export function isValidHttpUrl(url: string): boolean {
}
// ==================== Markdown Utilities ====================
export function convertMarkdownToHtml(markdown: string): string {
return markdown.replace(/\n/g, '<br>').replace(/# (.+)/g, '<h1>$1</h1>');
}