Unit testing setup complete

This commit is contained in:
Mike Thicke 2025-08-08 09:19:41 -04:00
parent 73022a71dd
commit 31e42b6f06
13 changed files with 1409 additions and 742 deletions

View file

@ -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
}

View file

@ -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();
});
});
});

View file

@ -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);
});
});
});

View file

@ -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 (
<div data-testid="chat-interface">
<div data-testid="initial-messages">
{JSON.stringify(props.initialMessages)}
</div>
<div data-testid="initial-context">
{JSON.stringify(props.initialContext)}
</div>
<div data-testid="initial-sources">
{JSON.stringify(props.initialSources)}
</div>
<div data-testid="has-plugin">{plugin ? "true" : "false"}</div>
<div data-testid="has-app">{app ? "true" : "false"}</div>
<div data-testid="has-file">{file ? "true" : "false"}</div>
<button
onClick={() =>
props.onChange({
newMessages: [{ role: "user", content: "test" }],
newTitle: "Test Title",
contextItems: null,
lastModelId: "test-model",
})
}
>
Trigger Change
</button>
</div>
);
},
}));
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(() => (
<CoiChatApp
app={app}
file={file}
plugin={plugin}
onChange={onChange}
initialMessages={initialMessages}
initialContext={initialContext}
initialSources={initialSources}
/>
));
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(() => (
<CoiChatApp
app={app}
file={file}
plugin={plugin}
onChange={onChange}
initialMessages={initialMessages}
initialContext={initialContext}
/>
));
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(() => (
<CoiChatApp
app={app}
file={file}
plugin={plugin}
onChange={onChange}
initialMessages={initialMessages}
initialContext={initialContext}
/>
));
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(() => (
<CoiChatApp
app={app}
file={file}
plugin={plugin}
onChange={onChange}
initialMessages={initialMessages}
initialContext={initialContext}
/>
));
expect(screen.getByTestId("initial-sources").textContent).toBe(
JSON.stringify([]),
);
});
it("should wrap content in coi-app div", () => {
render(() => (
<CoiChatApp
app={app}
file={file}
plugin={plugin}
onChange={onChange}
initialMessages={initialMessages}
initialContext={initialContext}
/>
));
const wrapper = screen.getByTestId("chat-interface").parentElement;
expect(wrapper).toHaveClass("coi-app");
});
it("should handle empty initial messages", () => {
render(() => (
<CoiChatApp
app={app}
file={file}
plugin={plugin}
onChange={onChange}
initialMessages={[]}
initialContext={initialContext}
/>
));
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(() => (
<CoiChatApp
app={app}
file={file}
plugin={plugin}
onChange={onChange}
initialMessages={initialMessages}
initialContext={complexContext}
/>
));
expect(screen.getByTestId("initial-context").textContent).toBe(
JSON.stringify(complexContext),
);
});
});

View file

@ -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);
});
});
});
});

View file

@ -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);
});
});

View file

@ -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();
});
});
});

View file

@ -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";

View file

@ -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) {

View file

@ -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');
});
});

View file

@ -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<TArgs extends readonly unknown[]>(
func: (...args: TArgs) => Promise<void>,
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);
};
}

View file

@ -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<string> = 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.
*/

View file

@ -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(() => <UserInput {...defaultProps} />);
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(() => <UserInput {...defaultProps} />);
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(() => <UserInput {...defaultProps} isLoading={true} />);
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('<!-- CHAT-THREAD-START -->');
expect(note).toContain('<!-- CHAT-THREAD-END -->');
});
});
describe('deserializeChatThread', () => {
it('should parse messages from note content', () => {
const content = `
---
is-coi-chat: true
---
# Chat
<!-- CHAT-THREAD-START -->
[{"role":"user","content":"Hello"},{"role":"assistant","content":"Hi!"}]
<!-- CHAT-THREAD-END -->
`;
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.