diff --git a/manifest.json b/manifest.json index 1cd33f0..dc42ae3 100644 --- a/manifest.json +++ b/manifest.json @@ -8,7 +8,5 @@ "authorUrl": "https://github.com/Epistemic-Technology", "fundingUrl": "https://ko-fi.com/epistemictechnology", "helpUrl": "https://github.com/Epistemic-Technology/co-intelligence#readme", - "main": "main.js", - "css": "styles.css", "isDesktopOnly": false } diff --git a/src/__tests__/ChatView.test.tsx b/src/__tests__/ChatView.test.tsx new file mode 100644 index 0000000..4f02d98 --- /dev/null +++ b/src/__tests__/ChatView.test.tsx @@ -0,0 +1,395 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { ChatView, VIEW_TYPE_COI_CHAT } from "../ChatView"; +import type { WorkspaceLeaf, App, TFile } from "obsidian"; +import type CoIntelligencePlugin from "@/CoIntelligencePlugin"; +import type { ModelChatMessage, ContextItems } from "@/types"; + +vi.mock("obsidian", () => ({ + TextFileView: class TextFileView { + leaf: any; + app: any; + constructor(leaf: any) { + this.leaf = leaf; + } + registerEvent = vi.fn(); + requestSave = vi.fn(); + }, + Notice: vi.fn(), + TFile: class TFile { + path: string; + basename: string; + constructor(path: string, basename: string) { + this.path = path; + this.basename = basename; + } + }, +})); + +vi.mock("solid-js/web", () => ({ + render: vi.fn(() => ({ dispose: vi.fn() })), +})); + +vi.mock("@/CoiChatApp", () => ({ + CoiChatApp: vi.fn(), +})); + +vi.mock("@/utils/notes", () => ({ + serializeCoiNote: vi.fn(), + deserializeCoiNote: vi.fn(), + renameNote: vi.fn(), + isActiveCoiNote: vi.fn(), + deserializeCoiNoteContent: vi.fn(), + serializeCoiNoteContent: vi.fn(), +})); + +describe("ChatView", () => { + let app: App; + let plugin: CoIntelligencePlugin; + let leaf: WorkspaceLeaf; + let chatView: ChatView; + let mockFile: TFile; + + beforeEach(() => { + vi.useFakeTimers(); + + mockFile = { + path: "test.md", + basename: "test", + } as TFile; + + app = { + workspace: { + getActiveFile: vi.fn().mockReturnValue(mockFile), + on: vi.fn(), + }, + vault: { + cachedRead: vi.fn().mockResolvedValue("# Test Content"), + process: vi.fn(), + }, + fileManager: { + processFrontMatter: vi.fn(), + }, + } as any; + + plugin = { + app, + registry: { + availableModels: [], + }, + settings: { + defaultModel: "", + systemPromptFolder: "", + defaultSystemPromptNote: "", + }, + } as any; + + leaf = {} as WorkspaceLeaf; + + chatView = new ChatView(leaf, plugin, app); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("constructor", () => { + it("should initialize with correct properties", () => { + expect(chatView.plugin).toBe(plugin); + expect(chatView.app).toBe(app); + expect(chatView.file).toBe(mockFile); + expect(chatView.icon).toBe("bot-message-square"); + expect(chatView.messages).toEqual([]); + expect(chatView.contextItems).toEqual({ notes: [], tags: [], sources: [] }); + expect(chatView.sources).toEqual([]); + }); + + it("should register settings change event", () => { + expect(chatView.registerEvent).toHaveBeenCalled(); + expect(app.workspace.on).toHaveBeenCalledWith( + "co-intelligence:settings-changed", + expect.any(Function) + ); + }); + }); + + describe("getViewType", () => { + it("should return correct view type", () => { + expect(chatView.getViewType()).toBe(VIEW_TYPE_COI_CHAT); + }); + }); + + describe("getDisplayText", () => { + it("should return file basename when file exists", () => { + expect(chatView.getDisplayText()).toBe("test"); + }); + + it("should return default text when no file", () => { + chatView.file = null; + expect(chatView.getDisplayText()).toBe("Co-Intelligence Chat"); + }); + }); + + describe("debounceUpdateViewData", () => { + it("should debounce multiple calls", async () => { + const updateSpy = vi.spyOn(chatView, "updateViewData"); + + await chatView.debounceUpdateViewData(); + await chatView.debounceUpdateViewData(); + await chatView.debounceUpdateViewData(); + + expect(updateSpy).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(500); + await vi.runAllTimersAsync(); + + expect(updateSpy).toHaveBeenCalledTimes(1); + }); + + it("should clear previous timeout", async () => { + const clearTimeoutSpy = vi.spyOn(window, "clearTimeout"); + + await chatView.debounceUpdateViewData(); + const firstTimeout = chatView["debounceTimeout"]; + + await chatView.debounceUpdateViewData(); + + expect(clearTimeoutSpy).toHaveBeenCalledWith(firstTimeout); + }); + }); + + describe("updateViewData", () => { + it("should update view data with serialized content", async () => { + const { serializeCoiNoteContent } = await import("@/utils/notes"); + const mockSerializedContent = "Serialized content"; + vi.mocked(serializeCoiNoteContent).mockResolvedValue(mockSerializedContent); + + chatView.messages = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there!" }, + ] as ModelChatMessage[]; + + await chatView.updateViewData(); + + expect(app.vault.cachedRead).toHaveBeenCalledWith(mockFile); + expect(serializeCoiNoteContent).toHaveBeenCalledWith( + "# Test Content", + app, + chatView.messages, + chatView.contextItems + ); + expect(chatView.data).toBe(mockSerializedContent); + expect(chatView.requestSave).toHaveBeenCalled(); + }); + + it("should handle no file gracefully", async () => { + chatView.file = null; + + const result = await chatView.updateViewData(); + + expect(result).toBe(""); + expect(app.vault.cachedRead).not.toHaveBeenCalled(); + }); + + }); + + describe("getViewData", () => { + it("should return current data", () => { + chatView.data = "test data"; + expect(chatView.getViewData()).toBe("test data"); + }); + }); + + describe("setViewData", () => { + it("should set data and deserialize content", async () => { + const { deserializeCoiNoteContent } = await import("@/utils/notes"); + const mockDeserializedData = { + messages: [{ role: "user", content: "Deserialized" }], + contextItems: { notes: ["note.md"], tags: [], sources: [] }, + sources: [], + }; + vi.mocked(deserializeCoiNoteContent).mockResolvedValue(mockDeserializedData); + + app.metadataCache = { + getFileCache: vi.fn().mockReturnValue({}), + } as any; + + await chatView.setViewData("new data", false); + + expect(chatView.data).toBe("new data"); + expect(chatView.messages).toEqual(mockDeserializedData.messages); + expect(chatView.contextItems).toEqual(mockDeserializedData.contextItems); + }); + + it("should handle null file gracefully", async () => { + const { deserializeCoiNoteContent } = await import("@/utils/notes"); + const Notice = vi.mocked((await import("obsidian")).Notice); + chatView.file = null; + + vi.mocked(deserializeCoiNoteContent).mockResolvedValue({ + messages: [], + contextItems: { notes: [], tags: [], sources: [] }, + sources: [] + }); + + await chatView.setViewData("data", false); + + expect(Notice).toHaveBeenCalledWith("Error: file is null while trying to set view data"); + }); + + it("should clear when clear parameter is true", async () => { + const clearSpy = vi.spyOn(chatView, "clear"); + app.metadataCache = { + getFileCache: vi.fn().mockReturnValue({}), + } as any; + + await chatView.setViewData("data", true); + + expect(clearSpy).toHaveBeenCalled(); + }); + }); + + describe("handleChatChange", () => { + it("should serialize note with new data", async () => { + const { serializeCoiNote } = await import("@/utils/notes"); + const newMessages: ModelChatMessage[] = [ + { role: "user", content: "New message" }, + ]; + const newContextItems: ContextItems = { + notes: ["note1.md"], + tags: ["#tag1"], + sources: [], + }; + + await chatView.handleChatChange({ + newMessages, + newTitle: "test", + contextItems: newContextItems, + sources: [], + lastModelId: "gpt-4", + }); + + expect(serializeCoiNote).toHaveBeenCalledWith( + mockFile, + app, + newMessages, + newContextItems, + "gpt-4", + [] + ); + }); + + it("should rename note when title changes", async () => { + const { renameNote } = await import("@/utils/notes"); + + await chatView.handleChatChange({ + newMessages: [], + newTitle: "New Title", + contextItems: null, + lastModelId: null, + }); + + expect(renameNote).toHaveBeenCalledWith(mockFile, "New Title", app, plugin); + }); + + it("should not rename when title is same as basename", async () => { + const { renameNote } = await import("@/utils/notes"); + + await chatView.handleChatChange({ + newMessages: [], + newTitle: "test", + contextItems: null, + lastModelId: null, + }); + + expect(renameNote).not.toHaveBeenCalled(); + }); + + it("should handle errors during serialization", async () => { + const { serializeCoiNote } = await import("@/utils/notes"); + const Notice = vi.mocked((await import("obsidian")).Notice); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(serializeCoiNote).mockRejectedValue(new Error("Serialization failed")); + + await chatView.handleChatChange({ + newMessages: [], + newTitle: "", + contextItems: null, + lastModelId: null, + }); + + expect(Notice).toHaveBeenCalledWith("Error serializing CoiNote: Error: Serialization failed"); + expect(chatView["updating"]).toBe(false); + + consoleSpy.mockRestore(); + }); + + it("should handle null file", async () => { + const Notice = vi.mocked((await import("obsidian")).Notice); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + chatView.file = null; + + await chatView.handleChatChange({ + newMessages: [], + newTitle: "", + contextItems: null, + lastModelId: null, + }); + + expect(Notice).toHaveBeenCalledWith("Error: file is null while trying to handle chat change"); + + consoleSpy.mockRestore(); + }); + }); + + describe("refresh", () => { + it("should refresh view when active", async () => { + const { isActiveCoiNote, deserializeCoiNote } = await import("@/utils/notes"); + vi.mocked(isActiveCoiNote).mockReturnValue(true); + vi.mocked(deserializeCoiNote).mockResolvedValue({ + messages: [], + contextItems: { notes: [], tags: [], sources: [] }, + sources: [] + }); + + app.metadataCache = { + getFileCache: vi.fn().mockReturnValue({}), + } as any; + + chatView.containerEl = { + children: [null, document.createElement("div")], + } as any; + + const onOpenSpy = vi.spyOn(chatView, "onOpen"); + + await chatView.refresh(); + + expect(isActiveCoiNote).toHaveBeenCalledWith(mockFile, app); + expect(onOpenSpy).toHaveBeenCalled(); + }); + + + it("should handle no file", async () => { + chatView.file = null; + + await expect(chatView.refresh()).resolves.not.toThrow(); + }); + }); + + describe("clear", () => { + it("should reset all data", () => { + chatView.messages = [{ role: "user", content: "test" }] as ModelChatMessage[]; + chatView.contextItems = { notes: ["note.md"], tags: [], sources: [] }; + chatView.sources = [{ title: "Source", url: "https://example.com" }]; + chatView.dispose = vi.fn(); + + chatView.clear(); + + expect(chatView.messages).toEqual([]); + expect(chatView.contextItems).toEqual({ notes: [], tags: [], sources: [] }); + expect(chatView.sources).toEqual([]); + expect(chatView.dispose).toHaveBeenCalled(); + }); + }); + + +}); \ No newline at end of file diff --git a/src/__tests__/CoIntelligencePlugin.test.tsx b/src/__tests__/CoIntelligencePlugin.test.tsx new file mode 100644 index 0000000..441dfe5 --- /dev/null +++ b/src/__tests__/CoIntelligencePlugin.test.tsx @@ -0,0 +1,315 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { CoIntelligencePlugin } from "../CoIntelligencePlugin"; +import { CoIntelligenceSettingsTab, DEFAULT_SETTINGS } from "../settings"; +import { ModelRegistry } from "@/services/model-registry"; +import { VIEW_TYPE_COI_CHAT } from "@/ChatView"; +import type { App, PluginManifest, WorkspaceLeaf, TFile, Menu, ViewState } from "obsidian"; + +vi.mock("obsidian", () => ({ + Plugin: class Plugin { + app: any; + manifest: any; + constructor(app: any, manifest: any) { + this.app = app; + this.manifest = manifest; + } + addSettingTab = vi.fn(); + addCommand = vi.fn(); + registerView = vi.fn(); + addRibbonIcon = vi.fn(); + registerEvent = vi.fn(); + register = vi.fn(); + loadData = vi.fn(); + saveData = vi.fn(); + }, + TFile: class TFile { + path: string; + basename: string; + constructor(path: string, basename: string) { + this.path = path; + this.basename = basename; + } + }, + around: vi.fn((target, patches) => { + return () => {}; + }), +})); + +vi.mock("../settings", () => ({ + CoIntelligenceSettingsTab: vi.fn(), + DEFAULT_SETTINGS: { + openaiApiKey: "", + anthropicApiKey: "", + googleApiKey: "", + perplexityApiKey: "", + defaultFolder: "coi", + defaultModel: "", + renamingModel: "", + systemPromptFolder: "coi/prompts", + defaultSystemPromptNote: "", + }, +})); + +vi.mock("@/services/model-registry", () => ({ + ModelRegistry: { + getInstance: vi.fn(), + }, +})); + +vi.mock("@/commands/new-chat", () => ({ + NewChatCommand: vi.fn(), +})); + +vi.mock("@/commands/toggle-chat-view", () => ({ + ToggleChatViewCommand: vi.fn(), +})); + +vi.mock("@/ChatView", () => ({ + ChatView: vi.fn(), + VIEW_TYPE_COI_CHAT: "co-intelligence-chat", +})); + +vi.mock("@/utils/notes", () => ({ + isCoiNote: vi.fn(), + isPathActiveCoiNote: vi.fn(), + isActiveCoiNote: vi.fn(), + createCOINote: vi.fn(), + waitForMetadataCache: vi.fn(), +})); + +describe("CoIntelligencePlugin", () => { + let app: App; + let manifest: PluginManifest; + let plugin: CoIntelligencePlugin; + let mockRegistry: ModelRegistry; + + beforeEach(() => { + vi.clearAllMocks(); + + app = { + workspace: { + on: vi.fn(), + onLayoutReady: vi.fn((callback) => callback()), + getActiveFile: vi.fn(), + }, + vault: { + on: vi.fn(), + }, + fileManager: { + processFrontMatter: vi.fn(), + }, + commands: { + executeCommandById: vi.fn(), + }, + } as any; + + manifest = { + id: "co-intelligence", + name: "Co-Intelligence", + version: "1.0.0", + } as PluginManifest; + + mockRegistry = {} as ModelRegistry; + vi.mocked(ModelRegistry.getInstance).mockReturnValue(mockRegistry); + + plugin = new CoIntelligencePlugin(app, manifest); + }); + + describe("constructor", () => { + it("should initialize with default settings", () => { + expect(plugin.settings).toEqual(DEFAULT_SETTINGS); + expect(plugin.isPerformingAutomaticRename).toBe(false); + }); + }); + + describe("onload", () => { + it("should initialize plugin components", async () => { + plugin.loadData = vi.fn().mockResolvedValue({}); + await plugin.onload(); + + expect(plugin.registry).toBe(mockRegistry); + expect(plugin.addSettingTab).toHaveBeenCalled(); + expect(plugin.addCommand).toHaveBeenCalledTimes(2); + expect(plugin.registerView).toHaveBeenCalledWith( + VIEW_TYPE_COI_CHAT, + expect.any(Function) + ); + expect(plugin.addRibbonIcon).toHaveBeenCalledWith( + "bot-message-square", + "New COI chat", + expect.any(Function) + ); + }); + + it("should register event handlers", async () => { + await plugin.onload(); + + expect(plugin.registerEvent).toHaveBeenCalledWith( + app.workspace.on("file-open", expect.any(Function)) + ); + expect(plugin.registerEvent).toHaveBeenCalledWith( + app.vault.on("rename", expect.any(Function)) + ); + expect(plugin.registerEvent).toHaveBeenCalledWith( + app.workspace.on("file-menu", expect.any(Function)) + ); + }); + + it("should call onLayoutReady", async () => { + await plugin.onload(); + + expect(app.workspace.onLayoutReady).toHaveBeenCalledWith(expect.any(Function)); + }); + }); + + describe("loadSettings", () => { + it("should merge loaded data with default settings", async () => { + const customSettings = { + openaiApiKey: "test-key", + defaultFolder: "custom-folder", + }; + plugin.loadData = vi.fn().mockResolvedValue(customSettings); + + await plugin.loadSettings(); + + expect(plugin.settings).toEqual({ + ...DEFAULT_SETTINGS, + ...customSettings, + }); + }); + + it("should use default settings when no data is loaded", async () => { + plugin.loadData = vi.fn().mockResolvedValue(null); + + await plugin.loadSettings(); + + expect(plugin.settings).toEqual(DEFAULT_SETTINGS); + }); + }); + + describe("saveSettings", () => { + it("should save current settings", async () => { + plugin.saveData = vi.fn(); + plugin.settings = { ...DEFAULT_SETTINGS, openaiApiKey: "test-key" }; + + await plugin.saveSettings(); + + expect(plugin.saveData).toHaveBeenCalledWith(plugin.settings); + }); + }); + + describe("handleFileRename", () => { + let mockFile: TFile; + + beforeEach(async () => { + const { TFile } = await import("obsidian"); + mockFile = new TFile("test.md", "test"); + }); + + it("should mark COI notes as renamed when not automatic", async () => { + const { isCoiNote, waitForMetadataCache } = await import("@/utils/notes"); + vi.mocked(isCoiNote).mockReturnValue(true); + + plugin.isPerformingAutomaticRename = false; + const processFrontMatterCallback = vi.fn(); + + app.fileManager.processFrontMatter = vi.fn(async (file, callback) => { + const frontmatter = {}; + callback(frontmatter); + processFrontMatterCallback(frontmatter); + }); + + await plugin["handleFileRename"](mockFile, "old-path.md"); + + expect(waitForMetadataCache).toHaveBeenCalledWith(app, mockFile); + expect(processFrontMatterCallback).toHaveBeenCalledWith( + expect.objectContaining({ "note-renamed": true }) + ); + }); + + it("should not mark as renamed when automatic rename", async () => { + const { isCoiNote, waitForMetadataCache } = await import("@/utils/notes"); + vi.mocked(isCoiNote).mockReturnValue(true); + + plugin.isPerformingAutomaticRename = true; + + await plugin["handleFileRename"](mockFile, "old-path.md"); + + expect(waitForMetadataCache).toHaveBeenCalledWith(app, mockFile); + expect(app.fileManager.processFrontMatter).not.toHaveBeenCalled(); + expect(plugin.isPerformingAutomaticRename).toBe(false); + }); + + it("should ignore non-TFile objects", async () => { + const notAFile = { path: "not-a-file" } as any; + + await plugin["handleFileRename"](notAFile, "old-path"); + + expect(app.fileManager.processFrontMatter).not.toHaveBeenCalled(); + }); + + it("should ignore non-COI notes", async () => { + const { isCoiNote } = await import("@/utils/notes"); + vi.mocked(isCoiNote).mockReturnValue(false); + + await plugin["handleFileRename"](mockFile, "old-path.md"); + + expect(app.fileManager.processFrontMatter).not.toHaveBeenCalled(); + }); + }); + + describe("onFileMenuHandler", () => { + let mockMenu: Menu; + let mockFile: TFile; + let mockLeaf: WorkspaceLeaf; + + beforeEach(async () => { + mockMenu = { + addItem: vi.fn(), + } as any; + + const { TFile } = await import("obsidian"); + mockFile = new TFile("test.md", "test"); + + mockLeaf = {} as WorkspaceLeaf; + }); + + + it("should not add menu item for non-COI notes", async () => { + const { isCoiNote } = await import("@/utils/notes"); + vi.mocked(isCoiNote).mockReturnValue(false); + + await plugin["onFileMenuHandler"](mockMenu, mockFile, "file-explorer", mockLeaf); + + expect(mockMenu.addItem).not.toHaveBeenCalled(); + }); + + it("should not add menu item for active COI notes", async () => { + const { isCoiNote, isActiveCoiNote } = await import("@/utils/notes"); + vi.mocked(isCoiNote).mockReturnValue(true); + vi.mocked(isActiveCoiNote).mockReturnValue(true); + + await plugin["onFileMenuHandler"](mockMenu, mockFile, "file-explorer", mockLeaf); + + expect(mockMenu.addItem).not.toHaveBeenCalled(); + }); + }); + + + describe("ribbon icon", () => { + it("should create COI note when ribbon icon is clicked", async () => { + const { createCOINote } = await import("@/utils/notes"); + let ribbonClickHandler: () => void; + + plugin.addRibbonIcon = vi.fn((icon, title, callback) => { + ribbonClickHandler = callback; + }); + + await plugin.onload(); + + ribbonClickHandler!(); + + expect(createCOINote).toHaveBeenCalledWith(app, plugin); + }); + }); +}); \ No newline at end of file diff --git a/src/__tests__/CoiChatApp.test.tsx b/src/__tests__/CoiChatApp.test.tsx new file mode 100644 index 0000000..77880ac --- /dev/null +++ b/src/__tests__/CoiChatApp.test.tsx @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@solidjs/testing-library"; +import { + CoiChatApp, + PluginContext, + AppContext, + FileContext, +} from "../CoiChatApp"; +import type { App, TFile } from "obsidian"; +import type CoIntelligencePlugin from "@/CoIntelligencePlugin"; +import type { ModelChatMessage, ContextItems, Source } from "@/types"; +import { useContext } from "solid-js"; + +vi.mock("@/components/ChatInterface", () => ({ + ChatInterface: (props: any) => { + // Test component that verifies props and context + const plugin = useContext(PluginContext); + const app = useContext(AppContext); + const file = useContext(FileContext); + + return ( +
+
+ {JSON.stringify(props.initialMessages)} +
+
+ {JSON.stringify(props.initialContext)} +
+
+ {JSON.stringify(props.initialSources)} +
+
{plugin ? "true" : "false"}
+
{app ? "true" : "false"}
+
{file ? "true" : "false"}
+ +
+ ); + }, +})); + +describe("CoiChatApp", () => { + let app: App; + let file: TFile; + let plugin: CoIntelligencePlugin; + let onChange: vi.Mock; + let initialMessages: ModelChatMessage[]; + let initialContext: ContextItems; + let initialSources: Source[]; + + beforeEach(() => { + app = { name: "MockApp" } as App; + file = { path: "test.md", basename: "test" } as TFile; + plugin = { name: "MockPlugin" } as CoIntelligencePlugin; + onChange = vi.fn(); + initialMessages = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there!" }, + ]; + initialContext = { + notes: ["note1.md"], + tags: ["#tag1"], + sources: [], + }; + initialSources = [{ title: "Source 1", url: "https://example.com" }]; + }); + + it("should render ChatInterface with correct props", () => { + render(() => ( + + )); + + expect(screen.getByTestId("chat-interface")).toBeInTheDocument(); + expect(screen.getByTestId("initial-messages").textContent).toBe( + JSON.stringify(initialMessages), + ); + expect(screen.getByTestId("initial-context").textContent).toBe( + JSON.stringify(initialContext), + ); + expect(screen.getByTestId("initial-sources").textContent).toBe( + JSON.stringify(initialSources), + ); + }); + + it("should provide contexts to child components", () => { + render(() => ( + + )); + + expect(screen.getByTestId("has-plugin").textContent).toBe("true"); + expect(screen.getByTestId("has-app").textContent).toBe("true"); + expect(screen.getByTestId("has-file").textContent).toBe("true"); + }); + + it("should pass onChange callback to ChatInterface", () => { + render(() => ( + + )); + + const button = screen.getByText("Trigger Change"); + button.click(); + + expect(onChange).toHaveBeenCalledWith({ + newMessages: [{ role: "user", content: "test" }], + newTitle: "Test Title", + contextItems: null, + lastModelId: "test-model", + }); + }); + + it("should handle undefined initialSources with default value", () => { + render(() => ( + + )); + + expect(screen.getByTestId("initial-sources").textContent).toBe( + JSON.stringify([]), + ); + }); + + it("should wrap content in coi-app div", () => { + render(() => ( + + )); + + const wrapper = screen.getByTestId("chat-interface").parentElement; + expect(wrapper).toHaveClass("coi-app"); + }); + + it("should handle empty initial messages", () => { + render(() => ( + + )); + + expect(screen.getByTestId("initial-messages").textContent).toBe("[]"); + }); + + it("should handle complex context items", () => { + const complexContext: ContextItems = { + notes: ["note1.md", "note2.md", "folder/note3.md"], + tags: ["#tag1", "#tag2", "#nested/tag"], + sources: ["https://source1.com", "https://source2.com"], + }; + + render(() => ( + + )); + + expect(screen.getByTestId("initial-context").textContent).toBe( + JSON.stringify(complexContext), + ); + }); +}); diff --git a/src/__tests__/settings.test.ts b/src/__tests__/settings.test.ts new file mode 100644 index 0000000..0a7bf88 --- /dev/null +++ b/src/__tests__/settings.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { CoIntelligenceSettingsTab, DEFAULT_SETTINGS } from "../settings"; +import type { App, Setting } from "obsidian"; +import type CoIntelligencePlugin from "@/CoIntelligencePlugin"; + +vi.mock("obsidian", () => ({ + PluginSettingTab: class PluginSettingTab { + app: any; + plugin: any; + containerEl: any; + constructor(app: any, plugin: any) { + this.app = app; + this.plugin = plugin; + this.containerEl = { + empty: vi.fn(), + createEl: vi.fn(), + createDiv: vi.fn(), + querySelectorAll: vi.fn(), + }; + } + }, + Setting: vi.fn().mockImplementation(() => ({ + setName: vi.fn().mockReturnThis(), + setDesc: vi.fn().mockReturnThis(), + addText: vi.fn().mockReturnThis(), + addDropdown: vi.fn().mockReturnThis(), + })), + normalizePath: vi.fn((path: string) => + path.replace(/\/+/g, "/").replace(/\/$/, ""), + ), +})); + +describe("settings", () => { + describe("DEFAULT_SETTINGS", () => { + it("should have all required fields with default values", () => { + expect(DEFAULT_SETTINGS).toEqual({ + openaiApiKey: "", + anthropicApiKey: "", + googleApiKey: "", + perplexityApiKey: "", + defaultFolder: "coi", + defaultModel: "", + renamingModel: "", + systemPromptFolder: "coi/prompts", + defaultSystemPromptNote: "", + }); + }); + }); + + describe("CoIntelligenceSettingsTab", () => { + let app: App; + let plugin: CoIntelligencePlugin; + let settingsTab: CoIntelligenceSettingsTab; + let mockElement: any; + + beforeEach(() => { + vi.useFakeTimers(); + + mockElement = { + empty: vi.fn(), + createEl: vi.fn().mockReturnValue({ + createEl: vi.fn().mockReturnValue({ + createEl: vi.fn(), + appendChild: vi.fn(), + setAttribute: vi.fn(), + }), + }), + createDiv: vi.fn().mockReturnValue({ + createDiv: vi.fn().mockReturnValue({ + createEl: vi.fn().mockReturnValue({ + createEl: vi.fn(), + }), + }), + createEl: vi.fn().mockReturnValue({ + createEl: vi.fn(), + appendChild: vi.fn(), + }), + }), + querySelectorAll: vi.fn().mockReturnValue([]), + }; + + app = { + vault: { + getMarkdownFiles: vi.fn().mockReturnValue([ + { path: "coi/prompts/prompt1.md", basename: "prompt1" }, + { path: "coi/prompts/prompt2.md", basename: "prompt2" }, + ]), + }, + workspace: { + trigger: vi.fn(), + }, + } as any; + + plugin = { + settings: { ...DEFAULT_SETTINGS }, + saveSettings: vi.fn().mockResolvedValue(undefined), + registry: { + availableModels: [ + { id: "gpt-4", name: "GPT-4", renaming: true }, + { id: "claude-3", name: "Claude 3", renaming: true }, + { id: "gemini-pro", name: "Gemini Pro", renaming: false }, + ], + reinitialize: vi.fn(), + }, + } as any; + + settingsTab = new CoIntelligenceSettingsTab(app, plugin); + settingsTab.containerEl = mockElement; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("createDebouncedChangeHandler", () => { + it("should debounce multiple changes", async () => { + const handler = settingsTab["createDebouncedChangeHandler"]( + "openaiApiKey", + true, + false, + ); + + handler("key1"); + handler("key2"); + handler("key3"); + + expect(plugin.saveSettings).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(500); + await vi.runAllTimersAsync(); + + expect(plugin.saveSettings).toHaveBeenCalledTimes(1); + expect(plugin.settings.openaiApiKey).toBe("key3"); + }); + + it("should reinitialize registry when required", async () => { + const handler = settingsTab["createDebouncedChangeHandler"]( + "anthropicApiKey", + true, + false, + ); + + handler("new-key"); + + vi.advanceTimersByTime(500); + await vi.runAllTimersAsync(); + + expect(plugin.registry.reinitialize).toHaveBeenCalled(); + }); + + it("should trigger settings-changed event", async () => { + const handler = settingsTab["createDebouncedChangeHandler"]( + "defaultFolder", + false, + false, + ); + + handler("new-folder"); + + vi.advanceTimersByTime(500); + await vi.runAllTimersAsync(); + + expect(app.workspace.trigger).toHaveBeenCalledWith( + "co-intelligence:settings-changed", + ); + }); + + it("should normalize folder paths", async () => { + const handler = settingsTab["createDebouncedChangeHandler"]( + "defaultFolder", + false, + false, + ); + + handler("folder//with///slashes/"); + + vi.advanceTimersByTime(500); + await vi.runAllTimersAsync(); + + expect(plugin.settings.defaultFolder).toBe("folder/with/slashes"); + }); + + it("should handle multiple pending changes for different settings", async () => { + const handler1 = settingsTab["createDebouncedChangeHandler"]( + "openaiApiKey", + true, + false, + ); + const handler2 = settingsTab["createDebouncedChangeHandler"]( + "anthropicApiKey", + true, + false, + ); + + handler1("openai-key"); + handler2("anthropic-key"); + + vi.advanceTimersByTime(500); + await vi.runAllTimersAsync(); + + expect(plugin.settings.openaiApiKey).toBe("openai-key"); + expect(plugin.settings.anthropicApiKey).toBe("anthropic-key"); + expect(plugin.saveSettings).toHaveBeenCalledTimes(1); + expect(plugin.registry.reinitialize).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/src/commands/__tests__/new-chat.test.ts b/src/commands/__tests__/new-chat.test.ts new file mode 100644 index 0000000..3d882a9 --- /dev/null +++ b/src/commands/__tests__/new-chat.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NewChatCommand } from "../new-chat"; +import type CoIntelligencePlugin from "@/CoIntelligencePlugin"; +import type { App } from "obsidian"; + +vi.mock("@/utils/notes", () => ({ + createCOINote: vi.fn(), +})); + +describe("NewChatCommand", () => { + let app: App; + let plugin: CoIntelligencePlugin; + let command: NewChatCommand; + + beforeEach(() => { + app = {} as App; + plugin = { app } as CoIntelligencePlugin; + command = new NewChatCommand(plugin); + }); + + it("should have correct id and name", () => { + expect(command.id).toBe("new-chat"); + expect(command.name).toBe("New chat"); + }); + + it("should call createCOINote when callback is executed", async () => { + const { createCOINote } = await import("@/utils/notes"); + const mockedCreateCOINote = vi.mocked(createCOINote); + + await command.callback(); + + expect(mockedCreateCOINote).toHaveBeenCalledWith(app, plugin); + expect(mockedCreateCOINote).toHaveBeenCalledTimes(1); + }); + + it("should store app and plugin references", () => { + expect(command["app"]).toBe(app); + expect(command["plugin"]).toBe(plugin); + }); +}); \ No newline at end of file diff --git a/src/commands/__tests__/toggle-chat-view.test.ts b/src/commands/__tests__/toggle-chat-view.test.ts new file mode 100644 index 0000000..16c14ee --- /dev/null +++ b/src/commands/__tests__/toggle-chat-view.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { ToggleChatViewCommand } from "../toggle-chat-view"; +import type CoIntelligencePlugin from "@/CoIntelligencePlugin"; +import type { App, TFile, WorkspaceLeaf, View, Notice } from "obsidian"; +import { VIEW_TYPE_COI_CHAT } from "@/ChatView"; + +vi.mock("@/utils/notes", () => ({ + isCoiNote: vi.fn(), + isActiveCoiNote: vi.fn(), +})); + +vi.mock("obsidian", async (importOriginal) => { + const actual = await importOriginal() as any; + return { + ...actual, + Notice: vi.fn(), + SuggestModal: vi.fn().mockImplementation(() => ({ + open: vi.fn(), + close: vi.fn(), + })), + }; +}); + +describe("ToggleChatViewCommand", () => { + let app: App; + let plugin: CoIntelligencePlugin; + let command: ToggleChatViewCommand; + let mockFile: TFile; + let mockLeaf: WorkspaceLeaf; + let mockView: View; + + beforeEach(() => { + vi.useFakeTimers(); + + mockView = { + getState: vi.fn().mockReturnValue({ mode: "source" }), + } as any; + + mockLeaf = { + view: mockView, + setViewState: vi.fn().mockResolvedValue(undefined), + } as any; + + mockFile = { + path: "test.md", + basename: "test", + } as TFile; + + app = { + workspace: { + getActiveFile: vi.fn(), + getMostRecentLeaf: vi.fn().mockReturnValue(mockLeaf), + }, + fileManager: { + processFrontMatter: vi.fn(), + }, + } as any; + + plugin = { app } as CoIntelligencePlugin; + command = new ToggleChatViewCommand(plugin); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should have correct id and name", () => { + expect(command.id).toBe("toggle-chat-view"); + expect(command.name).toBe("Toggle chat view"); + }); + + describe("checkCallback", () => { + it("should return false when checking with no active file", () => { + app.workspace.getActiveFile = vi.fn().mockReturnValue(null); + + const result = command.checkCallback(true); + + expect(result).toBe(false); + }); + + it("should return false when checking non-COI note", async () => { + const { isCoiNote } = await import("@/utils/notes"); + const mockedIsCoiNote = vi.mocked(isCoiNote); + + app.workspace.getActiveFile = vi.fn().mockReturnValue(mockFile); + mockedIsCoiNote.mockReturnValue(false); + + const result = command.checkCallback(true); + + expect(result).toBe(false); + expect(mockedIsCoiNote).toHaveBeenCalledWith(mockFile, app); + }); + + it("should return true when checking COI note", async () => { + const { isCoiNote } = await import("@/utils/notes"); + const mockedIsCoiNote = vi.mocked(isCoiNote); + + app.workspace.getActiveFile = vi.fn().mockReturnValue(mockFile); + mockedIsCoiNote.mockReturnValue(true); + + const result = command.checkCallback(true); + + expect(result).toBe(true); + }); + + it("should perform toggle when not checking and file is COI note", async () => { + const { isCoiNote } = await import("@/utils/notes"); + const mockedIsCoiNote = vi.mocked(isCoiNote); + const performToggleSpy = vi.spyOn(command as any, "performToggle"); + + app.workspace.getActiveFile = vi.fn().mockReturnValue(mockFile); + mockedIsCoiNote.mockReturnValue(true); + + command.checkCallback(false); + + expect(performToggleSpy).toHaveBeenCalledWith(mockFile); + }); + + it("should not perform toggle when file is not COI note", async () => { + const { isCoiNote } = await import("@/utils/notes"); + const mockedIsCoiNote = vi.mocked(isCoiNote); + const performToggleSpy = vi.spyOn(command as any, "performToggle"); + + app.workspace.getActiveFile = vi.fn().mockReturnValue(mockFile); + mockedIsCoiNote.mockReturnValue(false); + + command.checkCallback(false); + + expect(performToggleSpy).not.toHaveBeenCalled(); + }); + }); + + describe("performToggle", () => { + it("should toggle from active to source view", async () => { + const { isActiveCoiNote } = await import("@/utils/notes"); + const mockedIsActiveCoiNote = vi.mocked(isActiveCoiNote); + mockedIsActiveCoiNote.mockReturnValue(true); + + const processFrontMatterCallback = vi.fn(); + app.fileManager.processFrontMatter = vi.fn().mockImplementation(async (file, callback) => { + const frontmatter = { "coi-chat-view": true }; + callback(frontmatter); + processFrontMatterCallback(frontmatter); + return Promise.resolve(); + }); + + const togglePromise = command["performToggle"](mockFile); + await vi.runAllTimersAsync(); + await togglePromise; + + expect(processFrontMatterCallback).toHaveBeenCalledWith( + expect.objectContaining({ "coi-chat-view": false }) + ); + + expect(mockLeaf.setViewState).toHaveBeenCalledWith( + { + type: "markdown", + state: { mode: "source" }, + popstate: true, + }, + { focus: true } + ); + }); + + it("should toggle from source to chat view", async () => { + const { isActiveCoiNote } = await import("@/utils/notes"); + const mockedIsActiveCoiNote = vi.mocked(isActiveCoiNote); + mockedIsActiveCoiNote.mockReturnValue(false); + + const processFrontMatterCallback = vi.fn(); + app.fileManager.processFrontMatter = vi.fn().mockImplementation(async (file, callback) => { + const frontmatter = { "coi-chat-view": false }; + callback(frontmatter); + processFrontMatterCallback(frontmatter); + return Promise.resolve(); + }); + + const togglePromise = command["performToggle"](mockFile); + await vi.runAllTimersAsync(); + await togglePromise; + + expect(processFrontMatterCallback).toHaveBeenCalledWith( + expect.objectContaining({ "coi-chat-view": true }) + ); + + expect(mockLeaf.setViewState).toHaveBeenCalledWith( + { + type: VIEW_TYPE_COI_CHAT, + state: { mode: "source" }, + popstate: true, + }, + { focus: true } + ); + }); + }); + + describe("error handling", () => { + it("should show notice when no leaf found for default editor", async () => { + const Notice = vi.mocked((await import("obsidian")).Notice); + app.workspace.getMostRecentLeaf = vi.fn().mockReturnValue(null); + + await command["openInDefaultEditor"](mockFile); + + expect(Notice).toHaveBeenCalledWith( + "Error: no leaf found while opening chat in default editor" + ); + }); + + it("should show notice when no leaf found for chat view", async () => { + const Notice = vi.mocked((await import("obsidian")).Notice); + app.workspace.getMostRecentLeaf = vi.fn().mockReturnValue(null); + + await command["openInChatView"](mockFile); + + expect(Notice).toHaveBeenCalledWith( + "Error: no leaf found while opening chat in chat view" + ); + }); + + it("should log error to console when no leaf found", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(); + app.workspace.getMostRecentLeaf = vi.fn().mockReturnValue(null); + + await command["openInDefaultEditor"](mockFile); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + "No leaf found while opening chat in default editor" + ); + + consoleErrorSpy.mockRestore(); + }); + }); +}); \ No newline at end of file diff --git a/src/components/ChatInterface.tsx b/src/components/ChatInterface.tsx index e131a4f..073ca36 100644 --- a/src/components/ChatInterface.tsx +++ b/src/components/ChatInterface.tsx @@ -1,6 +1,6 @@ import { createSignal, useContext, Show, createEffect } from "solid-js"; import { ModelChatMessage } from "@/types"; -import { TFile, Notice } from "obsidian"; +import { TFile, Notice, debounce } from "obsidian"; import { ModelRegistry } from "@/services/model-registry"; import { @@ -17,7 +17,6 @@ import { ContextItems, Tag, } from "@/types"; -import { debounce } from "@/utils/debounce"; import { PluginContext, AppContext } from "@/CoiChatApp"; import { ChatHistory } from "@/components/ChatHistory"; import { UserInput } from "@/components/UserInput"; diff --git a/src/styles.css b/src/styles.css index d4fcdb0..3ab15ea 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3,6 +3,7 @@ height: 100%; display: flex; flex-direction: column; + container-type: inline-size; } .coi-app > * { @@ -477,10 +478,6 @@ li button.coi-add-context-menu-option:focus-visible { border: 0; } -/* Enable container queries for workspace tab container */ -.workspace-tab-container { - container-type: inline-size; -} /* Compact view for narrow containers */ @container (max-width: 545px) { diff --git a/src/utils/__tests__/debounce.test.ts b/src/utils/__tests__/debounce.test.ts deleted file mode 100644 index 01cae59..0000000 --- a/src/utils/__tests__/debounce.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { debounce } from '../debounce'; - -describe('debounce', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('should delay function execution', async () => { - const mockFn = vi.fn().mockResolvedValue(undefined); - const debouncedFn = debounce(mockFn, 100); - - debouncedFn('test'); - expect(mockFn).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(50); - expect(mockFn).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(50); - await vi.runAllTimersAsync(); - expect(mockFn).toHaveBeenCalledWith('test'); - }); - - it('should cancel previous execution when called multiple times', async () => { - const mockFn = vi.fn().mockResolvedValue(undefined); - const debouncedFn = debounce(mockFn, 100); - - debouncedFn('first'); - vi.advanceTimersByTime(50); - debouncedFn('second'); - vi.advanceTimersByTime(50); - debouncedFn('third'); - - vi.advanceTimersByTime(100); - await vi.runAllTimersAsync(); - - expect(mockFn).toHaveBeenCalledTimes(1); - expect(mockFn).toHaveBeenCalledWith('third'); - }); - - it('should handle multiple arguments', async () => { - const mockFn = vi.fn().mockResolvedValue(undefined); - const debouncedFn = debounce(mockFn, 100); - - debouncedFn('arg1', 'arg2', 'arg3'); - - vi.advanceTimersByTime(100); - await vi.runAllTimersAsync(); - - expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2', 'arg3'); - }); - - it('should handle async function execution', async () => { - const mockFn = vi.fn().mockImplementation(async (value: string) => { - await new Promise(resolve => setTimeout(resolve, 10)); - return value.toUpperCase(); - }); - const debouncedFn = debounce(mockFn, 100); - - debouncedFn('test'); - vi.advanceTimersByTime(100); - await vi.runAllTimersAsync(); - - expect(mockFn).toHaveBeenCalledWith('test'); - }); -}); \ No newline at end of file diff --git a/src/utils/debounce.ts b/src/utils/debounce.ts deleted file mode 100644 index 2527239..0000000 --- a/src/utils/debounce.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Debounces a function by delaying its execution until the specified delay - * has elapsed since the last time it was invoked. - * - * @param func The function to debounce - * @param wait The number of milliseconds to delay - * @returns A debounced version of the function - */ -export function debounce( - func: (...args: TArgs) => Promise, - wait: number, -): (...args: TArgs) => void { - let timeout: number | null = null; - - return function (...args: TArgs): void { - const later = async () => { - timeout = null; - await func(...args); - }; - - if (timeout !== null) { - window.clearTimeout(timeout); - } - - timeout = window.setTimeout(later, wait); - }; -} diff --git a/src/utils/notes.ts b/src/utils/notes.ts index 4d72cc8..a915773 100644 --- a/src/utils/notes.ts +++ b/src/utils/notes.ts @@ -5,6 +5,7 @@ import { TFolder, CachedMetadata, MetadataCache, + getAllTags, } from "obsidian"; import { ModelChatMessage } from "@/types"; @@ -257,7 +258,7 @@ export async function serializeCoiNote( lastModelId: string | null, sources?: Source[], ) { - const currentNoteContent = await app.vault.cachedRead(note); + const currentNoteContent = await app.vault.read(note); if ( !currentNoteContent.includes(CHAT_START) || @@ -409,6 +410,7 @@ export const getFilesWithTag = (tag: string, app: App): TFile[] => { const allFiles = app.vault.getMarkdownFiles(); for (const file of allFiles) { const cache = app.metadataCache.getCache(file.path); + if (!cache) continue; const tags = getAllTags(cache); if (tags?.includes(tag)) { @@ -419,27 +421,6 @@ export const getFilesWithTag = (tag: string, app: App): TFile[] => { return filesWithTag; }; -function getAllTags(cache: CachedMetadata | null): string[] { - if (!cache) return []; - - const tags: Set = new Set(); - - // Inline tags (e.g., #mytag) - cache.tags?.forEach((tagObj) => tags.add(tagObj.tag)); - - // Frontmatter tags (array or string) - if (cache.frontmatter?.tags) { - const fmTags = cache.frontmatter.tags; - if (typeof fmTags === "string") { - tags.add(fmTags.startsWith("#") ? fmTags : `#${fmTags}`); - } else if (Array.isArray(fmTags)) { - fmTags.forEach((t) => tags.add(t.startsWith("#") ? t : `#${t}`)); - } - } - - return Array.from(tags); -} - /** * Waits for the metadata cache to be available for a file. */ diff --git a/testing-implementation.md b/testing-implementation.md deleted file mode 100644 index fd81ca1..0000000 --- a/testing-implementation.md +++ /dev/null @@ -1,615 +0,0 @@ -# Testing Implementation Plan for Co-Intelligence AI - -## Executive Summary - -This document outlines a comprehensive testing strategy for the Co-Intelligence AI Obsidian plugin. The plugin presents unique testing challenges due to its architecture: a Solid.js UI framework, integration with Obsidian's API, AI SDK streaming capabilities, and the constraint that Obsidian plugins must compile to a single `main.js` file. - -Our approach uses Vitest as the primary testing framework (leveraging existing Vite configuration), the AI SDK's built-in testing utilities for mocking AI responses, and minimal Obsidian API mocks to enable unit and integration testing without requiring a full Obsidian environment. - -## Technology Stack - -### Core Testing Framework -- **Vitest** - Primary test runner (chosen over Jest for seamless Vite integration) -- **@vitest/ui** - Interactive UI for exploring test results -- **@vitest/coverage-v8** - Code coverage reporting -- **jsdom** - DOM environment for testing Solid.js components - -### Solid.js Testing -- **@solidjs/testing-library** - Solid-specific testing utilities -- **@testing-library/user-event** - Realistic user interaction simulation - -### AI SDK Testing -- **Built-in test utilities from 'ai/test'**: - - `MockLanguageModelV2` - Mock language model - - `simulateReadableStream` - Stream simulation with realistic delays - - No additional mocking libraries needed for AI responses - -### Existing Dependencies (Already in project) -- **typescript** - Type checking -- **vite** - Build tool -- **solid-js** - UI framework - -## Project Setup - -### Step 1: Install Testing Dependencies - -```bash -npm install -D vitest @vitest/ui @vitest/coverage-v8 jsdom @solidjs/testing-library @testing-library/user-event -``` - -### Step 2: Configure Vitest - -Create `vitest.config.ts` in the project root: - -```typescript -import { defineConfig } from 'vitest/config'; -import { loadEnv } from 'vite'; -import solid from 'vite-plugin-solid'; -import path from 'path'; - -export default defineConfig(({ mode }) => { - const env = loadEnv(mode, process.cwd(), ''); - - return { - plugins: [solid()], - resolve: { - alias: { - '@': path.resolve(__dirname, './src'), - '@assets': path.resolve(__dirname, './assets'), - 'obsidian': path.resolve(__dirname, './test/mocks/obsidian.ts') - } - }, - test: { - environment: 'jsdom', - globals: true, - setupFiles: ['./test/setup.ts'], - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: [ - 'node_modules/', - 'test/', - '**/*.d.ts', - '**/*.config.*', - '**/dist/**' - ] - } - } - }; -}); -``` - -### Step 3: Update package.json Scripts - -```json -{ - "scripts": { - "dev": "vite build --watch", - "build": "vite build", - "test": "vitest", - "test:ui": "vitest --ui", - "test:run": "vitest run", - "test:coverage": "vitest run --coverage" - } -} -``` - -### Step 4: Create Test Infrastructure - -``` -test/ -├── setup.ts # Global test setup and configuration -├── mocks/ -│ ├── obsidian.ts # Minimal Obsidian API mocks -│ └── solid-context.tsx # Mock providers for Solid.js components -├── fixtures/ -│ ├── chat-data.ts # Sample chat messages and threads -│ ├── ai-responses.ts # Predefined AI response patterns -│ └── notes.ts # Sample Obsidian notes -└── utils/ - ├── test-helpers.ts # Common test utilities - └── render.tsx # Custom render with providers -``` - -## Testing Architecture - -### Test Categories - -#### 1. Unit Tests (Priority: High) -- **Location**: `src/**/__tests__/*.test.ts` -- **Targets**: Pure functions, utilities, business logic -- **Examples**: - - URL validation in `utils/url.ts` - - Debounce logic in `utils/debounce.ts` - - Note serialization in `utils/notes.ts` - -#### 2. Service Tests (Priority: High) -- **Location**: `src/services/__tests__/*.test.ts` -- **Targets**: Core services with mocked dependencies -- **Examples**: - - Model registry singleton behavior - - AI streaming responses with abort handling - - Context preparation for prompts - -#### 3. Component Tests (Priority: Medium) -- **Location**: `src/components/__tests__/*.test.tsx` -- **Targets**: Solid.js components with mocked contexts -- **Examples**: - - User input handling and keyboard shortcuts - - Message rendering and interactions - - Model selector behavior - -#### 4. Integration Tests (Priority: Low) -- **Location**: `test/integration/*.test.ts` -- **Targets**: Multi-component workflows -- **Examples**: - - Complete chat creation flow - - Settings persistence - - View switching - -### Mocking Strategy - -#### Obsidian API Mocks - -Create minimal mocks that only implement the methods actually used: - -```typescript -// test/mocks/obsidian.ts -export class TFile { - path: string; - basename: string; - extension: string; - - constructor(path: string) { - this.path = path; - this.basename = path.split('/').pop()?.replace(/\.[^/.]+$/, '') || ''; - this.extension = path.split('.').pop() || ''; - } -} - -export class Plugin { - app: App; - manifest: any; - - constructor(app: App, manifest: any) { - this.app = app; - this.manifest = manifest; - } - - addCommand() { return this; } - addSettingTab() { return this; } - registerView() { return this; } - saveData() { return Promise.resolve(); } - loadData() { return Promise.resolve({}); } -} - -export class App { - vault = { - create: jest.fn(), - modify: jest.fn(), - read: jest.fn(), - getMarkdownFiles: jest.fn(() => []), - }; - - workspace = { - getLeaf: jest.fn(), - activeLeaf: null, - }; - - metadataCache = { - getFileCache: jest.fn(), - }; -} -``` - -#### AI SDK Mock Patterns - -```typescript -// test/fixtures/ai-responses.ts -import { MockLanguageModelV2, simulateReadableStream } from 'ai/test'; - -export const createMockModel = (response: string) => { - return new MockLanguageModelV2({ - doStream: async () => ({ - stream: simulateReadableStream({ - chunks: [ - { type: 'text-delta', textDelta: response }, - { type: 'finish', finishReason: 'stop' } - ] - }) - }) - }); -}; - -export const createMockStreamingModel = (chunks: string[]) => { - return new MockLanguageModelV2({ - doStream: async () => ({ - stream: simulateReadableStream({ - chunks: [ - ...chunks.map(chunk => ({ type: 'text-delta', textDelta: chunk })), - { type: 'finish', finishReason: 'stop' } - ] - }) - }) - }); -}; -``` - -## Implementation Phases - -### Phase 1: Foundation (Week 1) -1. **Day 1-2**: Set up Vitest configuration and test infrastructure -2. **Day 3-4**: Create minimal Obsidian mocks -3. **Day 5**: Write first utility function tests (debounce, url validation) -4. **Day 6-7**: Establish testing patterns and documentation - -**Deliverables**: -- Working test environment -- 5-10 utility function tests -- Testing guidelines document - -### Phase 2: Core Services (Week 2) -1. **Day 1-2**: Test `model-registry.ts` - - Singleton pattern - - Provider initialization - - Model availability checks -2. **Day 3-4**: Test `model-service.ts` - - Stream handling with AI SDK mocks - - Abort controller behavior - - Context injection -3. **Day 5-7**: Test note utilities - - COI note creation - - Serialization/deserialization - - Markdown parsing - -**Deliverables**: -- 90%+ coverage for services -- Documentation of service testing patterns - -### Phase 3: Component Testing (Week 3) -1. **Day 1-2**: Set up Solid.js testing utilities -2. **Day 3-4**: Test simple components (buttons, icons) -3. **Day 5-7**: Test complex components - - ChatInterface - - UserInput - - ModelSelector - -**Deliverables**: -- Component test suite -- Custom render utilities -- Interaction testing examples - -### Phase 4: Integration & Polish (Week 4) -1. **Day 1-2**: Plugin lifecycle tests -2. **Day 3-4**: Chat workflow integration tests -3. **Day 5-6**: CI/CD setup -4. **Day 7**: Documentation and cleanup - -**Deliverables**: -- Integration test suite -- GitHub Actions workflow -- Final documentation - -## Code Examples - -### Example 1: Testing the Model Service - -```typescript -// src/services/__tests__/model-service.test.ts -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { MockLanguageModelV2, simulateReadableStream } from 'ai/test'; -import { generateChatResponse } from '@/services/model-service'; -import { ModelRegistry } from '@/services/model-registry'; - -describe('model-service', () => { - let mockRegistry: ModelRegistry; - - beforeEach(() => { - mockRegistry = { - getLanguageModel: vi.fn(), - getModel: vi.fn(() => ({ toggleWebSearch: true })) - } as any; - }); - - it('should stream a chat response', async () => { - const mockModel = new MockLanguageModelV2({ - doStream: async () => ({ - stream: simulateReadableStream({ - chunks: [ - { type: 'text-delta', textDelta: 'Hello' }, - { type: 'text-delta', textDelta: ' world' }, - { type: 'finish', finishReason: 'stop', usage: { - promptTokens: 10, - completionTokens: 2, - totalTokens: 12 - }} - ] - }) - }) - }); - - mockRegistry.getLanguageModel.mockReturnValue(mockModel); - - const request = { - modelId: 'test-model', - messages: [{ role: 'user', content: 'Hi' }], - requestID: 'test-123', - webSearch: false - }; - - const result = await generateChatResponse(request, mockRegistry); - - // Collect the streamed text - let fullText = ''; - for await (const part of result.textStream) { - fullText += part; - } - - expect(fullText).toBe('Hello world'); - expect(mockRegistry.getLanguageModel).toHaveBeenCalledWith('test-model'); - }); - - it('should handle abort signals', async () => { - // Test abort controller functionality - }); - - it('should inject context into system prompt', async () => { - // Test context preparation - }); -}); -``` - -### Example 2: Testing a Solid.js Component - -```typescript -// src/components/__tests__/UserInput.test.tsx -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@solidjs/testing-library'; -import userEvent from '@testing-library/user-event'; -import { UserInput } from '@/components/UserInput'; - -describe('UserInput', () => { - const defaultProps = { - onSendMessage: vi.fn(), - isLoading: false, - onStopGenerating: vi.fn() - }; - - it('should send message on Enter key', async () => { - const user = userEvent.setup(); - render(() => ); - - const input = screen.getByPlaceholderText('Type your message...'); - await user.type(input, 'Test message{Enter}'); - - expect(defaultProps.onSendMessage).toHaveBeenCalledWith('Test message'); - expect(input).toHaveValue(''); - }); - - it('should not send empty messages', async () => { - const user = userEvent.setup(); - render(() => ); - - const input = screen.getByPlaceholderText('Type your message...'); - await user.type(input, '{Enter}'); - - expect(defaultProps.onSendMessage).not.toHaveBeenCalled(); - }); - - it('should show stop button when loading', () => { - render(() => ); - - const stopButton = screen.getByLabelText('Stop generating'); - expect(stopButton).toBeInTheDocument(); - }); -}); -``` - -### Example 3: Testing Note Utilities - -```typescript -// src/utils/__tests__/notes.test.ts -import { describe, it, expect } from 'vitest'; -import { createCOINote, deserializeChatThread } from '@/utils/notes'; -import { TFile } from 'obsidian'; - -describe('notes utilities', () => { - describe('createCOINote', () => { - it('should create a valid COI note structure', () => { - const note = createCOINote('Test Chat', [ - { role: 'user', content: 'Hello' }, - { role: 'assistant', content: 'Hi there!' } - ]); - - expect(note).toContain('---'); - expect(note).toContain('is-coi-chat: true'); - expect(note).toContain('# Test Chat'); - expect(note).toContain(''); - expect(note).toContain(''); - }); - }); - - describe('deserializeChatThread', () => { - it('should parse messages from note content', () => { - const content = ` ---- -is-coi-chat: true ---- -# Chat - -[{"role":"user","content":"Hello"},{"role":"assistant","content":"Hi!"}] - - `; - - const messages = deserializeChatThread(content); - - expect(messages).toHaveLength(2); - expect(messages[0]).toEqual({ role: 'user', content: 'Hello' }); - expect(messages[1]).toEqual({ role: 'assistant', content: 'Hi!' }); - }); - }); -}); -``` - -## CI/CD Integration - -### GitHub Actions Workflow - -Create `.github/workflows/test.yml`: - -```yaml -name: Tests - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - test: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: '18' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Run tests - run: npm run test:coverage - - - name: Upload coverage reports - uses: codecov/codecov-action@v3 - with: - file: ./coverage/coverage-final.json -``` - -## Best Practices - -### General Testing Guidelines - -1. **Test Behavior, Not Implementation** - - Focus on public APIs and user-facing behavior - - Avoid testing internal implementation details - -2. **Keep Tests Fast** - - Mock external dependencies (Obsidian API, AI providers) - - Use in-memory operations instead of file I/O - -3. **Write Descriptive Test Names** - ```typescript - // Good - it('should display error message when model API key is missing') - - // Bad - it('test error') - ``` - -4. **Follow AAA Pattern** - - **Arrange**: Set up test data and dependencies - - **Act**: Execute the code under test - - **Assert**: Verify the results - -### Obsidian Plugin-Specific Considerations - -1. **Mock Minimally** - - Only mock the Obsidian APIs your code actually uses - - Keep mocks simple and focused - -2. **Handle Async Operations** - - Most Obsidian APIs are async - - Use `async/await` in tests consistently - -3. **Test File Operations Carefully** - ```typescript - // Mock file operations instead of actual I/O - vault.create = vi.fn().mockResolvedValue(new TFile('test.md')); - ``` - -4. **Settings Management** - - Use in-memory settings for tests - - Reset settings between tests - -### AI SDK Testing Patterns - -1. **Test Different Response Scenarios** - - Successful completion - - Partial responses - - Error handling - - Abort/cancellation - -2. **Simulate Realistic Streaming** - ```typescript - simulateReadableStream({ - chunks: [...], - chunkDelayInMs: 50 // Simulate typing delay - }) - ``` - -3. **Test Context Handling** - - Empty context - - Large context - - Special characters in context - -## Common Pitfalls and Solutions - -### Pitfall 1: Over-Mocking -**Problem**: Creating elaborate mocks that mirror entire Obsidian API -**Solution**: Mock only what you use, add mocks as needed - -### Pitfall 2: Testing Framework Code -**Problem**: Testing Solid.js reactivity or Obsidian's internal behavior -**Solution**: Focus on your business logic and user interactions - -### Pitfall 3: Slow Tests -**Problem**: Tests that perform real file I/O or network requests -**Solution**: Use mocks and in-memory operations - -### Pitfall 4: Brittle Tests -**Problem**: Tests that break with minor implementation changes -**Solution**: Test public APIs and user-facing behavior - -## Troubleshooting - -### Vitest + Solid.js Issues - -1. **Component not rendering** - - Ensure `@solidjs/testing-library` is properly configured - - Check that jsdom environment is set - -2. **Reactivity not working in tests** - - Wrap state changes in `act()` if needed - - Use `waitFor` for async updates - -### Obsidian Mock Issues - -1. **Missing API methods** - - Add methods to mocks as you discover them - - Keep a shared mock file updated - -2. **Type errors** - - Use TypeScript's `Partial` types for mocks - - Cast to `any` when necessary (document why) - -### AI SDK Test Issues - -1. **Stream not completing** - - Ensure you include a finish chunk - - Check for proper error handling - -2. **Timing issues** - - Use `waitFor` for async assertions - - Adjust chunk delays if needed - -## Conclusion - -This testing implementation plan provides a pragmatic approach to testing the Co-Intelligence AI Obsidian plugin. By leveraging Vitest's integration with Vite, the AI SDK's built-in testing utilities, and minimal Obsidian mocks, we can achieve good test coverage without the complexity of full end-to-end testing. - -Remember: Start small, focus on high-value tests, and progressively improve coverage as the codebase evolves. The goal is not 100% coverage but confidence that critical functionality works correctly. \ No newline at end of file