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