Merge pull request #6 from Epistemic-Technology/testing-improvements

Add test infrastructure and 106 unit tests
This commit is contained in:
Mike Thicke 2026-02-06 12:43:27 -05:00 committed by GitHub
commit 638d33dc7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1767 additions and 0 deletions

View file

@ -0,0 +1,237 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock the AI SDK providers before importing ModelRegistry
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn().mockReturnValue({ id: "openai" }),
}));
vi.mock("@ai-sdk/anthropic", () => ({
createAnthropic: vi.fn().mockReturnValue({ id: "anthropic" }),
}));
vi.mock("@ai-sdk/google", () => ({
createGoogleGenerativeAI: vi.fn().mockReturnValue({ id: "google" }),
}));
vi.mock("@ai-sdk/perplexity", () => ({
createPerplexity: vi.fn().mockReturnValue({ id: "perplexity" }),
}));
vi.mock("ai", () => ({
createProviderRegistry: vi.fn().mockReturnValue({
languageModel: vi.fn().mockReturnValue({ modelId: "mock-model" }),
providers: {},
}),
}));
import { ModelRegistry } from "@/services/model-registry";
import type { CoIntelligencePlugin } from "@/CoIntelligencePlugin";
import { createProviderRegistry } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
import { createAnthropic } from "@ai-sdk/anthropic";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createPerplexity } from "@ai-sdk/perplexity";
function createMockPlugin(
settings: Partial<CoIntelligencePlugin["settings"]> = {},
): CoIntelligencePlugin {
return {
settings: {
openaiApiKey: "",
anthropicApiKey: "",
googleApiKey: "",
perplexityApiKey: "",
defaultFolder: "coi",
defaultModel: "",
renamingModel: "",
systemPromptFolder: "coi/prompts",
defaultSystemPromptNote: "",
...settings,
},
} as unknown as CoIntelligencePlugin;
}
describe("ModelRegistry", () => {
beforeEach(() => {
// Reset the singleton between tests
// @ts-expect-error accessing private static for test reset
ModelRegistry.instance = null;
vi.clearAllMocks();
});
afterEach(() => {
// @ts-expect-error accessing private static for test reset
ModelRegistry.instance = null;
});
describe("getInstance", () => {
it("creates a new instance when none exists", () => {
const plugin = createMockPlugin({ openaiApiKey: "test-key" });
const registry = ModelRegistry.getInstance(plugin);
expect(registry).toBeInstanceOf(ModelRegistry);
});
it("returns same instance on subsequent calls", () => {
const plugin = createMockPlugin({ openaiApiKey: "test-key" });
const registry1 = ModelRegistry.getInstance(plugin);
const registry2 = ModelRegistry.getInstance();
expect(registry1).toBe(registry2);
});
it("throws when no instance exists and no plugin provided", () => {
expect(() => ModelRegistry.getInstance()).toThrow(
"No existing registry instance and no plugin instance provided.",
);
});
});
describe("provider initialization", () => {
it("initializes OpenAI provider when API key is set", () => {
const plugin = createMockPlugin({ openaiApiKey: "sk-test" });
ModelRegistry.getInstance(plugin);
expect(createOpenAI).toHaveBeenCalledWith({ apiKey: "sk-test" });
});
it("initializes Anthropic provider when API key is set", () => {
const plugin = createMockPlugin({ anthropicApiKey: "ant-test" });
ModelRegistry.getInstance(plugin);
expect(createAnthropic).toHaveBeenCalledWith({ apiKey: "ant-test" });
});
it("initializes Google provider when API key is set", () => {
const plugin = createMockPlugin({ googleApiKey: "ggl-test" });
ModelRegistry.getInstance(plugin);
expect(createGoogleGenerativeAI).toHaveBeenCalledWith({
apiKey: "ggl-test",
});
});
it("initializes Perplexity provider when API key is set", () => {
const plugin = createMockPlugin({ perplexityApiKey: "pplx-test" });
ModelRegistry.getInstance(plugin);
expect(createPerplexity).toHaveBeenCalledWith({ apiKey: "pplx-test" });
});
it("does not initialize providers without API keys", () => {
const plugin = createMockPlugin();
ModelRegistry.getInstance(plugin);
expect(createOpenAI).not.toHaveBeenCalled();
expect(createAnthropic).not.toHaveBeenCalled();
expect(createGoogleGenerativeAI).not.toHaveBeenCalled();
expect(createPerplexity).not.toHaveBeenCalled();
});
it("initializes all providers when all keys are set", () => {
const plugin = createMockPlugin({
openaiApiKey: "sk-1",
anthropicApiKey: "ant-1",
googleApiKey: "ggl-1",
perplexityApiKey: "pplx-1",
});
ModelRegistry.getInstance(plugin);
expect(createOpenAI).toHaveBeenCalled();
expect(createAnthropic).toHaveBeenCalled();
expect(createGoogleGenerativeAI).toHaveBeenCalled();
expect(createPerplexity).toHaveBeenCalled();
});
it("passes all initialized providers to createProviderRegistry", () => {
const plugin = createMockPlugin({
openaiApiKey: "sk-1",
anthropicApiKey: "ant-1",
});
ModelRegistry.getInstance(plugin);
expect(createProviderRegistry).toHaveBeenCalledWith(
expect.objectContaining({
openai: expect.anything(),
anthropic: expect.anything(),
}),
);
});
});
describe("availableModels", () => {
it("lists only models for initialized providers", () => {
const plugin = createMockPlugin({ openaiApiKey: "sk-1" });
const registry = ModelRegistry.getInstance(plugin);
expect(registry.availableModels.length).toBeGreaterThan(0);
for (const model of registry.availableModels) {
expect(model.provider).toBe("openai");
}
});
it("includes models from multiple providers", () => {
const plugin = createMockPlugin({
openaiApiKey: "sk-1",
anthropicApiKey: "ant-1",
});
const registry = ModelRegistry.getInstance(plugin);
const providers = new Set(registry.availableModels.map((m) => m.provider));
expect(providers).toContain("openai");
expect(providers).toContain("anthropic");
});
it("returns empty list when no providers initialized", () => {
const plugin = createMockPlugin();
const registry = ModelRegistry.getInstance(plugin);
expect(registry.availableModels).toHaveLength(0);
});
});
describe("getModel", () => {
it("returns model by ID", () => {
const plugin = createMockPlugin({ openaiApiKey: "sk-1" });
const registry = ModelRegistry.getInstance(plugin);
const model = registry.getModel("openai:gpt-4o");
expect(model.id).toBe("openai:gpt-4o");
expect(model.provider).toBe("openai");
});
it("throws for unknown model ID", () => {
const plugin = createMockPlugin({ openaiApiKey: "sk-1" });
const registry = ModelRegistry.getInstance(plugin);
expect(() =>
registry.getModel("openai:nonexistent" as never),
).toThrow("Model not found");
});
});
describe("getDefaultModel", () => {
it("returns first available model", () => {
const plugin = createMockPlugin({ openaiApiKey: "sk-1" });
const registry = ModelRegistry.getInstance(plugin);
const model = registry.getDefaultModel();
expect(model).not.toBeNull();
expect(model!.provider).toBe("openai");
});
it("returns null when no models available", () => {
const plugin = createMockPlugin();
const registry = ModelRegistry.getInstance(plugin);
expect(registry.getDefaultModel()).toBeNull();
});
});
describe("hasInitializedProviders", () => {
it("returns true when providers are initialized", () => {
const plugin = createMockPlugin({ openaiApiKey: "sk-1" });
const registry = ModelRegistry.getInstance(plugin);
expect(registry.hasInitializedProviders()).toBe(true);
});
it("returns false when no providers initialized", () => {
const plugin = createMockPlugin();
const registry = ModelRegistry.getInstance(plugin);
expect(registry.hasInitializedProviders()).toBe(false);
});
});
describe("reinitialize", () => {
it("re-initializes providers from current settings", () => {
const plugin = createMockPlugin({ openaiApiKey: "sk-1" });
const registry = ModelRegistry.getInstance(plugin);
vi.clearAllMocks();
registry.reinitialize();
expect(createOpenAI).toHaveBeenCalledWith({ apiKey: "sk-1" });
expect(createProviderRegistry).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,267 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("ai", () => ({
streamText: vi.fn().mockReturnValue({
textStream: (async function* () {
yield "Hello";
})(),
text: Promise.resolve("Hello"),
}),
generateText: vi.fn().mockResolvedValue({ text: "Chat summary title" }),
createProviderRegistry: vi.fn().mockReturnValue({
languageModel: vi.fn().mockReturnValue({
modelId: "mock-model",
provider: "openai",
}),
providers: {},
}),
}));
vi.mock("@ai-sdk/openai", () => ({
openai: {
tools: {
webSearchPreview: vi.fn().mockReturnValue({ type: "web_search" }),
},
},
createOpenAI: vi.fn().mockReturnValue({ id: "openai" }),
}));
vi.mock("@ai-sdk/google", () => ({
google: {
tools: {
googleSearch: vi.fn().mockReturnValue({ type: "google_search" }),
},
},
createGoogleGenerativeAI: vi.fn().mockReturnValue({ id: "google" }),
}));
vi.mock("@ai-sdk/anthropic", () => ({
createAnthropic: vi.fn().mockReturnValue({ id: "anthropic" }),
}));
vi.mock("@ai-sdk/perplexity", () => ({
createPerplexity: vi.fn().mockReturnValue({ id: "perplexity" }),
}));
import {
generateChatResponse,
cancelChatResponse,
generateChatTitle,
} from "@/services/model-service";
import { ModelRegistry } from "@/services/model-registry";
import { streamText, generateText } from "ai";
import type { ChatRequest, ModelId } from "@/types";
import type CoIntelligencePlugin from "@/CoIntelligencePlugin";
function createMockPlugin(
settings: Partial<CoIntelligencePlugin["settings"]> = {},
): CoIntelligencePlugin {
return {
settings: {
openaiApiKey: "sk-test",
anthropicApiKey: "",
googleApiKey: "",
perplexityApiKey: "",
defaultFolder: "coi",
defaultModel: "",
renamingModel: "openai:gpt-4o" as ModelId,
systemPromptFolder: "coi/prompts",
defaultSystemPromptNote: "",
...settings,
},
} as unknown as CoIntelligencePlugin;
}
function createMockRegistry(): ModelRegistry {
const plugin = createMockPlugin();
// @ts-expect-error accessing private static for test reset
ModelRegistry.instance = null;
return ModelRegistry.getInstance(plugin);
}
describe("generateChatResponse", () => {
let registry: ModelRegistry;
beforeEach(() => {
vi.clearAllMocks();
registry = createMockRegistry();
});
it("calls streamText with the correct model", async () => {
const request: ChatRequest = {
requestID: "test-1",
modelId: "openai:gpt-4o",
messages: [{ role: "user", content: "Hello" }],
};
await generateChatResponse(request, registry);
expect(streamText).toHaveBeenCalledWith(
expect.objectContaining({
messages: request.messages,
system: expect.stringContaining("helpful assistant"),
}),
);
});
it("includes system prompt when provided", async () => {
const request: ChatRequest = {
requestID: "test-2",
modelId: "openai:gpt-4o",
messages: [{ role: "user", content: "Hello" }],
systemPrompt: "You are a pirate.",
};
await generateChatResponse(request, registry);
expect(streamText).toHaveBeenCalledWith(
expect.objectContaining({
system: expect.stringContaining("You are a pirate."),
}),
);
});
it("appends web search instructions when webSearch is true", async () => {
const request: ChatRequest = {
requestID: "test-3",
modelId: "openai:gpt-4o",
messages: [{ role: "user", content: "Latest news" }],
webSearch: true,
};
await generateChatResponse(request, registry);
expect(streamText).toHaveBeenCalledWith(
expect.objectContaining({
system: expect.stringContaining("search the web"),
}),
);
});
it("appends context to system prompt when provided", async () => {
const request: ChatRequest = {
requestID: "test-4",
modelId: "openai:gpt-4o",
messages: [{ role: "user", content: "Hello" }],
context: [{ title: "Note A", content: "Important info" }],
};
await generateChatResponse(request, registry);
expect(streamText).toHaveBeenCalledWith(
expect.objectContaining({
system: expect.stringContaining("Important info"),
}),
);
});
it("disables web search for models that don't support it", async () => {
// o1 has toggleWebSearch: false
const request: ChatRequest = {
requestID: "test-5",
modelId: "openai:o1",
messages: [{ role: "user", content: "Hello" }],
webSearch: true,
};
await generateChatResponse(request, registry);
expect(streamText).toHaveBeenCalledWith(
expect.objectContaining({
system: expect.not.stringContaining("search the web"),
}),
);
});
});
describe("cancelChatResponse", () => {
let registry: ModelRegistry;
beforeEach(() => {
vi.clearAllMocks();
registry = createMockRegistry();
});
it("cancels an active request", async () => {
const request: ChatRequest = {
requestID: "cancel-test",
modelId: "openai:gpt-4o",
messages: [{ role: "user", content: "Hello" }],
};
await generateChatResponse(request, registry);
// Should not throw
cancelChatResponse(request);
});
it("does nothing for unknown request ID", () => {
const request: ChatRequest = {
requestID: "nonexistent",
modelId: "openai:gpt-4o",
messages: [],
};
// Should not throw
cancelChatResponse(request);
});
});
describe("generateChatTitle", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset singleton
// @ts-expect-error accessing private static for test reset
ModelRegistry.instance = null;
});
it("generates a title from messages", async () => {
const plugin = createMockPlugin({ renamingModel: "openai:gpt-4o" as ModelId });
ModelRegistry.getInstance(plugin);
const messages = [
{ role: "user" as const, content: "Tell me about quantum physics" },
{
role: "assistant" as const,
content: "Quantum physics is the study of matter at the atomic level.",
},
];
const title = await generateChatTitle(messages, plugin);
expect(title).toBe("Chat summary title (Chat)");
expect(generateText).toHaveBeenCalled();
});
it("returns empty string when no renaming model configured", async () => {
const plugin = createMockPlugin({ renamingModel: "" });
const title = await generateChatTitle([], plugin);
expect(title).toBe("");
expect(generateText).not.toHaveBeenCalled();
});
it("strips disallowed filename characters from title", async () => {
vi.mocked(generateText).mockResolvedValueOnce({
text: "Title/with\\bad:chars",
} as never);
const plugin = createMockPlugin({ renamingModel: "openai:gpt-4o" as ModelId });
ModelRegistry.getInstance(plugin);
const messages = [{ role: "user" as const, content: "Hello" }];
const title = await generateChatTitle(messages, plugin);
expect(title).not.toContain("/");
expect(title).not.toContain("\\");
expect(title).not.toContain(":");
});
it("truncates long titles to 50 characters plus suffix", async () => {
vi.mocked(generateText).mockResolvedValueOnce({
text: "A".repeat(100),
} as never);
const plugin = createMockPlugin({ renamingModel: "openai:gpt-4o" as ModelId });
ModelRegistry.getInstance(plugin);
const messages = [{ role: "user" as const, content: "Hello" }];
const title = await generateChatTitle(messages, plugin);
// Title is truncated to 50 chars + " (Chat)" suffix
expect(title.length).toBeLessThanOrEqual(57);
});
it("returns empty string on generation error", async () => {
vi.mocked(generateText).mockRejectedValueOnce(new Error("API error"));
const plugin = createMockPlugin({ renamingModel: "openai:gpt-4o" as ModelId });
ModelRegistry.getInstance(plugin);
const messages = [{ role: "user" as const, content: "Hello" }];
const title = await generateChatTitle(messages, plugin);
expect(title).toBe("");
});
});

View file

@ -0,0 +1,220 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { App, TFile, requestUrl } from "obsidian";
import { getContext, makeContext, contextTokenEstimate } from "@/utils/model-context";
import { ContextItems, ContextItemContent } from "@/types";
function createMockApp(): App {
return new App();
}
function createMockFile(props?: Partial<TFile>): TFile {
const file = new TFile();
if (props) {
Object.assign(file, props);
}
return file;
}
// --- makeContext ---
describe("makeContext", () => {
it("returns empty string for empty array", () => {
expect(makeContext([])).toBe("");
});
it("formats a single context item", () => {
const items: ContextItemContent[] = [
{ title: "Note A", content: "Content of note A" },
];
const result = makeContext(items);
expect(result).toBe("--- Note: Note A ---\nContent of note A\n---");
});
it("formats multiple context items separated by double newlines", () => {
const items: ContextItemContent[] = [
{ title: "Note A", content: "Content A" },
{ title: "Note B", content: "Content B" },
];
const result = makeContext(items);
expect(result).toContain("--- Note: Note A ---");
expect(result).toContain("Content A");
expect(result).toContain("--- Note: Note B ---");
expect(result).toContain("Content B");
expect(result).toContain("\n\n");
});
it("preserves newlines within content", () => {
const items: ContextItemContent[] = [
{ title: "Note", content: "Line 1\nLine 2\nLine 3" },
];
const result = makeContext(items);
expect(result).toContain("Line 1\nLine 2\nLine 3");
});
});
// --- getContext ---
describe("getContext", () => {
let app: App;
beforeEach(() => {
app = createMockApp();
vi.mocked(requestUrl).mockReset();
});
it("returns empty array for null items", async () => {
const result = await getContext(null, app);
expect(result).toEqual([]);
});
it("returns empty array for empty items", async () => {
const items: ContextItems = { notes: [], tags: [], sources: [] };
const result = await getContext(items, app);
expect(result).toEqual([]);
});
it("fetches content from linked notes", async () => {
const file = createMockFile({ name: "research.md", path: "research.md" });
vi.mocked(app.vault.cachedRead).mockResolvedValue("Note content here");
const items: ContextItems = { notes: [file], tags: [], sources: [] };
const result = await getContext(items, app);
expect(result).toHaveLength(1);
expect(result[0].title).toBe("research.md");
expect(result[0].content).toBe("Note content here");
expect(app.vault.cachedRead).toHaveBeenCalledWith(file);
});
it("fetches content from tagged notes", async () => {
const file = createMockFile({ name: "tagged.md", path: "tagged.md" });
vi.mocked(app.vault.getMarkdownFiles).mockReturnValue([file]);
vi.mocked(app.metadataCache.getCache).mockReturnValue({
tags: [{ tag: "#research", position: {} }],
});
vi.mocked(app.vault.cachedRead).mockResolvedValue("Tagged note content");
const items: ContextItems = {
notes: [],
tags: ["#research"],
sources: [],
};
const result = await getContext(items, app);
expect(result).toHaveLength(1);
expect(result[0].title).toBe("tagged.md");
expect(result[0].content).toBe("Tagged note content");
});
it("fetches content from web sources", async () => {
vi.mocked(requestUrl).mockResolvedValue({
text: "<p>Web content</p>",
json: {},
} as never);
const items: ContextItems = {
notes: [],
tags: [],
sources: [{ url: "https://example.com", title: "Example" }],
};
const result = await getContext(items, app);
expect(result).toHaveLength(1);
expect(result[0].title).toBe("Example");
// htmlToMarkdown in mock returns the input as-is
expect(result[0].content).toBe("<p>Web content</p>");
expect(requestUrl).toHaveBeenCalledWith({ url: "https://example.com" });
});
it("uses URL as title fallback for web sources", async () => {
vi.mocked(requestUrl).mockResolvedValue({
text: "<p>Content</p>",
json: {},
} as never);
const items: ContextItems = {
notes: [],
tags: [],
sources: [{ url: "https://example.com" }],
};
const result = await getContext(items, app);
expect(result[0].title).toBe("https://example.com");
});
it("combines notes, tags, and sources", async () => {
const noteFile = createMockFile({
name: "direct.md",
path: "direct.md",
});
const taggedFile = createMockFile({
name: "tagged.md",
path: "tagged.md",
});
vi.mocked(app.vault.getMarkdownFiles).mockReturnValue([taggedFile]);
vi.mocked(app.metadataCache.getCache).mockReturnValue({
tags: [{ tag: "#test", position: {} }],
});
vi.mocked(app.vault.cachedRead).mockImplementation(async (file: TFile) => {
if (file.name === "direct.md") return "Direct content";
if (file.name === "tagged.md") return "Tagged content";
return "";
});
vi.mocked(requestUrl).mockResolvedValue({
text: "Web content",
json: {},
} as never);
const items: ContextItems = {
notes: [noteFile],
tags: ["#test"],
sources: [{ url: "https://example.com", title: "Web" }],
};
const result = await getContext(items, app);
expect(result).toHaveLength(3);
expect(result[0].title).toBe("direct.md");
expect(result[1].title).toBe("tagged.md");
expect(result[2].title).toBe("Web");
});
});
// --- contextTokenEstimate ---
describe("contextTokenEstimate", () => {
let app: App;
beforeEach(() => {
app = createMockApp();
});
it("returns 0 for empty context", async () => {
const items: ContextItems = { notes: [], tags: [], sources: [] };
const result = await contextTokenEstimate(items, app);
expect(result).toBe(0);
});
it("estimates tokens as content length / 4", async () => {
const file = createMockFile({ name: "note.md", path: "note.md" });
// 40 characters -> 10 tokens
vi.mocked(app.vault.cachedRead).mockResolvedValue(
"a".repeat(40),
);
const items: ContextItems = { notes: [file], tags: [], sources: [] };
const result = await contextTokenEstimate(items, app);
expect(result).toBe(10);
});
it("sums token estimates across multiple items", async () => {
const file1 = createMockFile({ name: "a.md", path: "a.md" });
const file2 = createMockFile({ name: "b.md", path: "b.md" });
vi.mocked(app.vault.cachedRead).mockImplementation(async (file: TFile) => {
if (file.name === "a.md") return "a".repeat(100); // 25 tokens
if (file.name === "b.md") return "b".repeat(200); // 50 tokens
return "";
});
const items: ContextItems = { notes: [file1, file2], tags: [], sources: [] };
const result = await contextTokenEstimate(items, app);
expect(result).toBe(75);
});
});

View file

@ -0,0 +1,594 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
App,
TFile,
TFolder,
CachedMetadata,
} from "obsidian";
import {
isCoiNote,
isPathCoiNote,
isPathActiveCoiNote,
isActiveCoiNote,
serializeCoiNoteContent,
deserializeCoiNoteContent,
sanitizeForTagName,
getFilesWithTag,
} from "@/utils/notes";
const CHAT_START = "<!-- CHAT-THREAD-START -->";
const CHAT_END = "<!-- CHAT-THREAD-END -->";
function createMockApp(overrides?: Partial<App>): App {
const app = new App();
if (overrides) {
Object.assign(app, overrides);
}
return app;
}
function createMockFile(props?: Partial<TFile>): TFile {
const file = new TFile();
if (props) {
Object.assign(file, props);
}
return file;
}
// --- isCoiNote ---
describe("isCoiNote", () => {
let app: App;
beforeEach(() => {
app = createMockApp();
});
it("returns true when note has is-coi-chat frontmatter", () => {
const file = createMockFile({ path: "test.md" });
vi.mocked(app.metadataCache.getFileCache).mockReturnValue({
frontmatter: { "is-coi-chat": true },
});
expect(isCoiNote(file, app)).toBe(true);
});
it("returns false when note lacks is-coi-chat frontmatter", () => {
const file = createMockFile({ path: "test.md" });
vi.mocked(app.metadataCache.getFileCache).mockReturnValue({
frontmatter: {},
});
expect(isCoiNote(file, app)).toBe(false);
});
it("returns false when metadata cache returns null", () => {
const file = createMockFile({ path: "test.md" });
vi.mocked(app.metadataCache.getFileCache).mockReturnValue(null);
expect(isCoiNote(file, app)).toBe(false);
});
it("returns false when is-coi-chat is false", () => {
const file = createMockFile({ path: "test.md" });
vi.mocked(app.metadataCache.getFileCache).mockReturnValue({
frontmatter: { "is-coi-chat": false },
});
expect(isCoiNote(file, app)).toBe(false);
});
});
// --- isPathCoiNote ---
describe("isPathCoiNote", () => {
let app: App;
beforeEach(() => {
app = createMockApp();
});
it("returns true for COI note path", () => {
vi.mocked(app.metadataCache.getCache).mockReturnValue({
frontmatter: { "is-coi-chat": true },
});
expect(isPathCoiNote("chat.md", app)).toBe(true);
});
it("returns false for empty path", () => {
expect(isPathCoiNote("", app)).toBe(false);
});
it("returns false for non-COI note", () => {
vi.mocked(app.metadataCache.getCache).mockReturnValue({
frontmatter: {},
});
expect(isPathCoiNote("note.md", app)).toBe(false);
});
});
// --- isPathActiveCoiNote ---
describe("isPathActiveCoiNote", () => {
let app: App;
beforeEach(() => {
app = createMockApp();
});
it("returns true when both is-coi-chat and coi-chat-view are true", () => {
vi.mocked(app.metadataCache.getCache).mockReturnValue({
frontmatter: { "is-coi-chat": true, "coi-chat-view": true },
});
expect(isPathActiveCoiNote("chat.md", app)).toBe(true);
});
it("returns false when is-coi-chat is true but coi-chat-view is false", () => {
vi.mocked(app.metadataCache.getCache).mockReturnValue({
frontmatter: { "is-coi-chat": true, "coi-chat-view": false },
});
expect(isPathActiveCoiNote("chat.md", app)).toBe(false);
});
it("returns false for empty path", () => {
expect(isPathActiveCoiNote("", app)).toBe(false);
});
});
// --- isActiveCoiNote ---
describe("isActiveCoiNote", () => {
let app: App;
beforeEach(() => {
app = createMockApp();
});
it("returns true when note has both flags set", () => {
const file = createMockFile();
vi.mocked(app.metadataCache.getFileCache).mockReturnValue({
frontmatter: { "is-coi-chat": true, "coi-chat-view": true },
});
expect(isActiveCoiNote(file, app)).toBe(true);
});
it("returns false when coi-chat-view is missing", () => {
const file = createMockFile();
vi.mocked(app.metadataCache.getFileCache).mockReturnValue({
frontmatter: { "is-coi-chat": true },
});
expect(isActiveCoiNote(file, app)).toBe(false);
});
});
// --- sanitizeForTagName ---
describe("sanitizeForTagName", () => {
it("converts to lowercase", () => {
expect(sanitizeForTagName("Hello")).toBe("hello");
});
it("replaces special characters with hyphens", () => {
expect(sanitizeForTagName("openai:gpt-4")).toBe("openai-gpt-4");
});
it("collapses multiple hyphens", () => {
expect(sanitizeForTagName("a---b")).toBe("a-b");
});
it("strips leading and trailing hyphens/slashes", () => {
expect(sanitizeForTagName("-hello-")).toBe("hello");
expect(sanitizeForTagName("/hello/")).toBe("hello");
});
it("preserves forward slashes in the middle", () => {
expect(sanitizeForTagName("foo/bar")).toBe("foo/bar");
});
it("preserves underscores", () => {
expect(sanitizeForTagName("my_tag")).toBe("my_tag");
});
it("handles empty string", () => {
expect(sanitizeForTagName("")).toBe("");
});
it("handles string with only special chars", () => {
expect(sanitizeForTagName(":::")).toBe("");
});
it("trims whitespace before processing", () => {
expect(sanitizeForTagName(" hello ")).toBe("hello");
});
});
// --- serializeCoiNoteContent ---
describe("serializeCoiNoteContent", () => {
const baseContent = `---\nis-coi-chat: true\n---\n${CHAT_START}\n\n${CHAT_END}`;
it("serializes a single user message", async () => {
const messages = [{ role: "user" as const, content: "Hello" }];
const result = await serializeCoiNoteContent(
baseContent,
createMockApp(),
messages,
null,
);
expect(result).toContain("## user:");
expect(result).toContain("Hello");
expect(result).toContain(CHAT_START);
expect(result).toContain(CHAT_END);
});
it("serializes user and assistant messages", async () => {
const messages = [
{ role: "user" as const, content: "Hello" },
{ role: "assistant" as const, content: "Hi there!" },
];
const result = await serializeCoiNoteContent(
baseContent,
createMockApp(),
messages,
null,
);
expect(result).toContain("## user:");
expect(result).toContain("Hello");
expect(result).toContain("## assistant:");
expect(result).toContain("Hi there!");
});
it("adjusts h1 headers in content by adding ##", async () => {
const messages = [
{
role: "assistant" as const,
content: "# Top heading\n\nSome text\n\n## Sub heading",
},
];
const result = await serializeCoiNoteContent(
baseContent,
createMockApp(),
messages,
null,
);
// h1 should become h3, h2 should become h4
expect(result).toContain("### Top heading");
expect(result).toContain("#### Sub heading");
});
it("adjusts h2 headers in content by adding #", async () => {
const messages = [
{
role: "assistant" as const,
content: "## Sub heading\n\nSome text\n\n### Deeper",
},
];
const result = await serializeCoiNoteContent(
baseContent,
createMockApp(),
messages,
null,
);
// h2 should become h3, h3 should become h4
expect(result).toContain("### Sub heading");
expect(result).toContain("#### Deeper");
});
it("does not adjust h3+ headers", async () => {
const messages = [
{ role: "assistant" as const, content: "### Already deep\n\n#### Deeper" },
];
const result = await serializeCoiNoteContent(
baseContent,
createMockApp(),
messages,
null,
);
expect(result).toContain("### Already deep");
expect(result).toContain("#### Deeper");
});
it("preserves wikilinks", async () => {
const messages = [
{ role: "user" as const, content: "See [[My Note]] for details" },
];
const result = await serializeCoiNoteContent(
baseContent,
createMockApp(),
messages,
null,
);
expect(result).toContain("[[My Note]]");
});
it("serializes sources", async () => {
const messages = [{ role: "user" as const, content: "Hello" }];
const sources = [
{ url: "https://example.com", title: "Example" },
{ url: "https://other.com", title: "Other" },
];
const result = await serializeCoiNoteContent(
baseContent,
createMockApp(),
messages,
null,
sources,
);
expect(result).toContain("## Sources");
expect(result).toContain("1. [Example](https://example.com)");
expect(result).toContain("2. [Other](https://other.com)");
});
it("uses URL as source link text when title is missing", async () => {
const messages = [{ role: "user" as const, content: "Hello" }];
const sources = [{ url: "https://example.com" }];
const result = await serializeCoiNoteContent(
baseContent,
createMockApp(),
messages,
null,
sources,
);
expect(result).toContain("1. [https://example.com](https://example.com)");
});
it("preserves content before and after chat markers", async () => {
const contentBefore = "# My Note\n\nSome intro text.\n\n";
const contentAfter = "\n\nSome footer text.";
const fullContent = `${contentBefore}${CHAT_START}\n\n${CHAT_END}${contentAfter}`;
const messages = [{ role: "user" as const, content: "Hello" }];
const result = await serializeCoiNoteContent(
fullContent,
createMockApp(),
messages,
null,
);
expect(result).toContain("# My Note");
expect(result).toContain("Some intro text.");
expect(result).toContain("Some footer text.");
});
it("handles empty messages array", async () => {
const result = await serializeCoiNoteContent(
baseContent,
createMockApp(),
[],
null,
);
expect(result).toContain(CHAT_START);
expect(result).toContain(CHAT_END);
});
});
// --- deserializeCoiNoteContent ---
describe("deserializeCoiNoteContent", () => {
it("deserializes a single user message", async () => {
const content = `${CHAT_START}\n## user:\n\nHello world\n${CHAT_END}`;
const result = await deserializeCoiNoteContent(content, null, createMockApp());
expect(result.messages).toHaveLength(1);
expect(result.messages[0].role).toBe("user");
expect(result.messages[0].content).toBe("Hello world");
});
it("deserializes user and assistant messages", async () => {
const content = `${CHAT_START}\n## user:\n\nHello\n\n## assistant:\n\nHi there!\n${CHAT_END}`;
const result = await deserializeCoiNoteContent(content, null, createMockApp());
expect(result.messages).toHaveLength(2);
expect(result.messages[0].role).toBe("user");
expect(result.messages[0].content).toBe("Hello");
expect(result.messages[1].role).toBe("assistant");
expect(result.messages[1].content).toBe("Hi there!");
});
it("deserializes sources section", async () => {
const content = `${CHAT_START}\n## user:\n\nQuery\n\n## Sources\n\n1. [Example](https://example.com)\n2. [Other](https://other.com)\n${CHAT_END}`;
const result = await deserializeCoiNoteContent(content, null, createMockApp());
expect(result.sources).toHaveLength(2);
expect(result.sources[0]).toEqual({
title: "Example",
url: "https://example.com",
});
expect(result.sources[1]).toEqual({
title: "Other",
url: "https://other.com",
});
});
it("returns empty arrays when no chat markers found", async () => {
const content = "# Regular note\n\nNo chat here.";
const result = await deserializeCoiNoteContent(content, null, createMockApp());
expect(result.messages).toHaveLength(0);
expect(result.sources).toHaveLength(0);
expect(result.contextItems).toEqual({ notes: [], tags: [], sources: [] });
});
it("handles multiline message content", async () => {
const content = `${CHAT_START}\n## user:\n\nLine 1\nLine 2\n\nLine 3\n${CHAT_END}`;
const result = await deserializeCoiNoteContent(content, null, createMockApp());
expect(result.messages).toHaveLength(1);
expect(result.messages[0].content).toContain("Line 1");
expect(result.messages[0].content).toContain("Line 2");
expect(result.messages[0].content).toContain("Line 3");
});
it("restores linked notes from frontmatter", async () => {
const content = `${CHAT_START}\n## user:\n\nHello\n${CHAT_END}`;
const app = createMockApp();
const mockFile = createMockFile({ path: "notes/linked.md" });
vi.mocked(app.vault.getAbstractFileByPath).mockReturnValue(mockFile);
const metadata: CachedMetadata = {
frontmatter: {
"linked-notes": ["notes/linked.md"],
},
};
const result = await deserializeCoiNoteContent(content, metadata, app);
expect(result.contextItems.notes).toHaveLength(1);
expect(result.contextItems.notes[0].path).toBe("notes/linked.md");
});
it("restores linked tags from frontmatter", async () => {
const content = `${CHAT_START}\n## user:\n\nHello\n${CHAT_END}`;
const metadata: CachedMetadata = {
frontmatter: {
"linked-tags": ["#research", "#draft"],
},
};
const result = await deserializeCoiNoteContent(
content,
metadata,
createMockApp(),
);
expect(result.contextItems.tags).toEqual(["#research", "#draft"]);
});
it("handles empty chat section", async () => {
const content = `${CHAT_START}\n${CHAT_END}`;
const result = await deserializeCoiNoteContent(content, null, createMockApp());
expect(result.messages).toHaveLength(0);
expect(result.sources).toHaveLength(0);
});
it("ignores invalid mode headers", async () => {
const content = `${CHAT_START}\n## unknown:\n\nShould be ignored\n\n## user:\n\nValid\n${CHAT_END}`;
const result = await deserializeCoiNoteContent(content, null, createMockApp());
expect(result.messages).toHaveLength(1);
expect(result.messages[0].role).toBe("user");
expect(result.messages[0].content).toBe("Valid");
});
});
// --- Round-trip serialization ---
describe("serialize/deserialize round-trip", () => {
const baseContent = `---\nis-coi-chat: true\n---\n${CHAT_START}\n\n${CHAT_END}`;
it("preserves messages through round-trip", async () => {
const messages = [
{ role: "user" as const, content: "What is 2+2?" },
{ role: "assistant" as const, content: "The answer is 4." },
{ role: "user" as const, content: "Thanks!" },
];
const serialized = await serializeCoiNoteContent(
baseContent,
createMockApp(),
messages,
null,
);
const deserialized = await deserializeCoiNoteContent(
serialized,
null,
createMockApp(),
);
expect(deserialized.messages).toHaveLength(3);
expect(deserialized.messages[0].role).toBe("user");
expect(deserialized.messages[0].content).toBe("What is 2+2?");
expect(deserialized.messages[1].role).toBe("assistant");
expect(deserialized.messages[1].content).toBe("The answer is 4.");
expect(deserialized.messages[2].role).toBe("user");
expect(deserialized.messages[2].content).toBe("Thanks!");
});
it("preserves sources through round-trip", async () => {
const messages = [{ role: "user" as const, content: "Query" }];
const sources = [
{ url: "https://example.com", title: "Example" },
{ url: "https://other.com", title: "Other Site" },
];
const serialized = await serializeCoiNoteContent(
baseContent,
createMockApp(),
messages,
null,
sources,
);
const deserialized = await deserializeCoiNoteContent(
serialized,
null,
createMockApp(),
);
expect(deserialized.sources).toHaveLength(2);
expect(deserialized.sources[0]).toEqual({
title: "Example",
url: "https://example.com",
});
expect(deserialized.sources[1]).toEqual({
title: "Other Site",
url: "https://other.com",
});
});
it("preserves wikilinks through round-trip", async () => {
const messages = [
{ role: "user" as const, content: "See [[My Note]] and [[Other Note]]" },
];
const serialized = await serializeCoiNoteContent(
baseContent,
createMockApp(),
messages,
null,
);
const deserialized = await deserializeCoiNoteContent(
serialized,
null,
createMockApp(),
);
expect(deserialized.messages[0].content).toContain("[[My Note]]");
expect(deserialized.messages[0].content).toContain("[[Other Note]]");
});
});
// --- getFilesWithTag ---
describe("getFilesWithTag", () => {
let app: App;
beforeEach(() => {
app = createMockApp();
});
it("returns files that have the specified tag", () => {
const file1 = createMockFile({ path: "note1.md" });
const file2 = createMockFile({ path: "note2.md" });
const file3 = createMockFile({ path: "note3.md" });
vi.mocked(app.vault.getMarkdownFiles).mockReturnValue([
file1,
file2,
file3,
]);
vi.mocked(app.metadataCache.getCache).mockImplementation((path: string) => {
if (path === "note1.md") {
return { tags: [{ tag: "#research", position: {} }] };
}
if (path === "note2.md") {
return { tags: [{ tag: "#draft", position: {} }] };
}
if (path === "note3.md") {
return { tags: [{ tag: "#research", position: {} }] };
}
return null;
});
const result = getFilesWithTag("#research", app);
expect(result).toHaveLength(2);
expect(result[0].path).toBe("note1.md");
expect(result[1].path).toBe("note3.md");
});
it("returns empty array when no files have the tag", () => {
vi.mocked(app.vault.getMarkdownFiles).mockReturnValue([]);
const result = getFilesWithTag("#nonexistent", app);
expect(result).toHaveLength(0);
});
it("skips files with no cache", () => {
const file = createMockFile({ path: "note.md" });
vi.mocked(app.vault.getMarkdownFiles).mockReturnValue([file]);
vi.mocked(app.metadataCache.getCache).mockReturnValue(null);
const result = getFilesWithTag("#test", app);
expect(result).toHaveLength(0);
});
});

View file

@ -0,0 +1,89 @@
import { describe, it, expect } from "vitest";
import { getDomainFromUrl, ensureSourceTitle } from "@/utils/url";
describe("getDomainFromUrl", () => {
it("extracts domain from a standard https URL", () => {
expect(getDomainFromUrl("https://example.com/path")).toBe("example.com");
});
it("extracts domain from http URL", () => {
expect(getDomainFromUrl("http://example.com")).toBe("example.com");
});
it("extracts domain with subdomain", () => {
expect(getDomainFromUrl("https://www.example.com/page")).toBe(
"www.example.com",
);
});
it("extracts domain without port (hostname only)", () => {
expect(getDomainFromUrl("https://example.com:8080/path")).toBe(
"example.com",
);
});
it("extracts domain from URL with query params", () => {
expect(getDomainFromUrl("https://example.com/path?q=1&b=2")).toBe(
"example.com",
);
});
it("falls back to regex for malformed URLs", () => {
expect(getDomainFromUrl("not-a-url.com/path")).toBe("not-a-url.com");
});
it("falls back to regex for URLs without protocol (strips www.)", () => {
expect(getDomainFromUrl("www.example.com/page")).toBe("example.com");
});
it("returns original string for completely invalid input", () => {
expect(getDomainFromUrl("")).toBe("");
});
});
describe("ensureSourceTitle", () => {
it("returns existing title when present", () => {
const result = ensureSourceTitle({
url: "https://example.com",
title: "My Title",
});
expect(result.title).toBe("My Title");
expect(result.url).toBe("https://example.com");
});
it("trims whitespace from existing title", () => {
const result = ensureSourceTitle({
url: "https://example.com",
title: " My Title ",
});
expect(result.title).toBe("My Title");
});
it("uses domain as fallback when title is missing", () => {
const result = ensureSourceTitle({ url: "https://example.com/page" });
expect(result.title).toBe("example.com");
});
it("uses domain as fallback when title is empty string", () => {
const result = ensureSourceTitle({
url: "https://example.com",
title: "",
});
expect(result.title).toBe("example.com");
});
it("uses domain as fallback when title is whitespace", () => {
const result = ensureSourceTitle({
url: "https://example.com",
title: " ",
});
expect(result.title).toBe("example.com");
});
it("does not mutate the original source object", () => {
const original = { url: "https://example.com", title: "Original" };
const result = ensureSourceTitle(original);
expect(result).not.toBe(original);
expect(original.title).toBe("Original");
});
});

352
test/mocks/obsidian.ts Normal file
View file

@ -0,0 +1,352 @@
/**
* Mock implementation of the Obsidian API for testing.
*/
import { vi } from "vitest";
// --- Core file types ---
// Note: vault property is typed as any to avoid circular instantiation
// (TAbstractFile -> Vault -> TFile -> TAbstractFile -> ...).
export class TAbstractFile {
path = "";
name = "";
parent: TFolder | null = null;
vault: any = null;
}
export class TFile extends TAbstractFile {
basename = "";
extension = "md";
stat = { ctime: 0, mtime: 0, size: 0 };
}
export class TFolder extends TAbstractFile {
children: TAbstractFile[] = [];
isRoot(): boolean {
return this.parent === null;
}
}
// --- Vault ---
export class Vault {
read = vi.fn().mockResolvedValue("");
cachedRead = vi.fn().mockResolvedValue("");
create = vi.fn().mockImplementation(async () => new TFile());
modify = vi.fn().mockResolvedValue(undefined);
delete = vi.fn().mockResolvedValue(undefined);
rename = vi.fn().mockResolvedValue(undefined);
getAbstractFileByPath = vi.fn().mockReturnValue(null);
getMarkdownFiles = vi.fn().mockReturnValue([]);
createFolder = vi.fn().mockImplementation(async () => new TFolder());
}
// --- Workspace ---
export class WorkspaceLeaf {
view: Record<string, unknown> = {};
openFile = vi.fn().mockResolvedValue(undefined);
setViewState = vi.fn().mockResolvedValue(undefined);
}
export class Workspace {
getLeaf = vi.fn().mockReturnValue(new WorkspaceLeaf());
getActiveViewOfType = vi.fn().mockReturnValue(null);
on = vi.fn().mockReturnValue({ id: "mock-event-ref" });
off = vi.fn();
getLeavesOfType = vi.fn().mockReturnValue([]);
}
// --- MetadataCache ---
export class MetadataCache {
getFileCache = vi.fn().mockReturnValue(null);
getCache = vi.fn().mockReturnValue(null);
on = vi.fn().mockReturnValue({ id: "mock-event-ref" });
}
// --- FileManager ---
export class FileManager {
processFrontMatter = vi.fn().mockImplementation(
async (_file: TFile, fn: (frontmatter: Record<string, unknown>) => void) => {
const frontmatter: Record<string, unknown> = {};
fn(frontmatter);
},
);
renameFile = vi.fn().mockResolvedValue(undefined);
}
// --- App ---
export class App {
vault = new Vault();
workspace = new Workspace();
metadataCache = new MetadataCache();
fileManager = new FileManager();
}
// --- View classes ---
export class TextFileView {
app: App;
leaf: WorkspaceLeaf;
file: TFile | null = null;
data = "";
requestSave = vi.fn();
constructor(leaf: WorkspaceLeaf) {
this.leaf = leaf;
this.app = new App();
}
getViewData(): string {
return this.data;
}
setViewData(_data: string, _clear: boolean): void {}
clear(): void {}
}
// --- UI classes ---
export class Notice {
constructor(_message: string, _timeout?: number) {}
}
export class Setting {
settingEl = document.createElement("div");
controlEl = document.createElement("div");
nameEl = document.createElement("div");
descEl = document.createElement("div");
constructor(_containerEl: HTMLElement) {}
setName(_name: string): this {
return this;
}
setDesc(_desc: string): this {
return this;
}
addText(cb: (component: TextComponent) => void): this {
cb(new TextComponent(document.createElement("input")));
return this;
}
addDropdown(cb: (component: DropdownComponent) => void): this {
cb(new DropdownComponent(document.createElement("select")));
return this;
}
addToggle(cb: (component: ToggleComponent) => void): this {
cb(new ToggleComponent(document.createElement("div")));
return this;
}
addButton(cb: (component: ButtonComponent) => void): this {
cb(new ButtonComponent(document.createElement("button")));
return this;
}
}
class TextComponent {
inputEl: HTMLInputElement;
constructor(inputEl: HTMLElement) {
this.inputEl = inputEl as HTMLInputElement;
}
setPlaceholder(_ph: string): this {
return this;
}
setValue(_val: string): this {
return this;
}
onChange(_cb: (value: string) => void): this {
return this;
}
}
class DropdownComponent {
selectEl: HTMLSelectElement;
constructor(selectEl: HTMLElement) {
this.selectEl = selectEl as HTMLSelectElement;
}
addOption(_value: string, _display: string): this {
return this;
}
setValue(_val: string): this {
return this;
}
onChange(_cb: (value: string) => void): this {
return this;
}
}
class ToggleComponent {
toggleEl: HTMLElement;
constructor(el: HTMLElement) {
this.toggleEl = el;
}
setValue(_val: boolean): this {
return this;
}
onChange(_cb: (value: boolean) => void): this {
return this;
}
}
class ButtonComponent {
buttonEl: HTMLButtonElement;
constructor(el: HTMLElement) {
this.buttonEl = el as HTMLButtonElement;
}
setButtonText(_text: string): this {
return this;
}
setCta(): this {
return this;
}
onClick(_cb: () => void): this {
return this;
}
}
export class SuggestModal<T> {
app: App;
inputEl = document.createElement("input");
resultContainerEl = document.createElement("div");
constructor(app: App) {
this.app = app;
}
open(): void {}
close(): void {}
getSuggestions(_query: string): T[] {
return [];
}
renderSuggestion(_item: T, _el: HTMLElement): void {}
onChooseSuggestion(_item: T, _evt: MouseEvent | KeyboardEvent): void {}
}
// --- Plugin classes ---
export class Plugin {
app: App;
manifest: PluginManifest;
constructor(app: App, manifest: PluginManifest) {
this.app = app;
this.manifest = manifest;
}
addCommand = vi.fn();
addRibbonIcon = vi.fn();
addSettingTab = vi.fn();
registerView = vi.fn();
registerEvent = vi.fn();
registerExtensions = vi.fn();
loadData = vi.fn().mockResolvedValue(null);
saveData = vi.fn().mockResolvedValue(undefined);
}
export class PluginSettingTab {
app: App;
plugin: Plugin;
containerEl = document.createElement("div");
constructor(app: App, plugin: Plugin) {
this.app = app;
this.plugin = plugin;
}
display(): void {}
hide(): void {}
}
// --- Interfaces / types ---
export interface PluginManifest {
id: string;
name: string;
version: string;
author: string;
description: string;
}
export interface ViewState {
type: string;
state?: Record<string, unknown>;
active?: boolean;
}
export interface CachedMetadata {
frontmatter?: Record<string, unknown>;
tags?: { tag: string; position: unknown }[];
}
export interface Menu {
addItem: (cb: (item: MenuItem) => void) => Menu;
showAtMouseEvent: (evt: MouseEvent) => void;
}
export interface MenuItem {
setTitle: (title: string) => MenuItem;
setIcon: (icon: string) => MenuItem;
onClick: (cb: () => void) => MenuItem;
}
export interface Command {
id: string;
name: string;
callback?: () => void;
checkCallback?: (checking: boolean) => boolean;
}
// --- Utility functions ---
export function normalizePath(path: string): string {
return path.replace(/\\/g, "/").replace(/\/+/g, "/");
}
export function getAllTags(cache: CachedMetadata): string[] | null {
if (!cache.tags) return null;
return cache.tags.map((t) => t.tag);
}
export const requestUrl = vi.fn().mockResolvedValue({ text: "", json: {} });
export function htmlToMarkdown(html: string): string {
return html;
}
export function setIcon(_el: HTMLElement, _iconId: string): void {}
export function getIcon(_iconId: string): SVGSVGElement | null {
return null;
}
export function debounce<T extends (...args: unknown[]) => unknown>(
fn: T,
_delay: number,
): T {
return fn;
}
export class MarkdownRenderer {
static render = vi.fn().mockResolvedValue(undefined);
}
export class MarkdownRenderChild {
containerEl: HTMLElement;
constructor(containerEl: HTMLElement) {
this.containerEl = containerEl;
}
onload(): void {}
onunload(): void {}
}

8
test/setup.ts Normal file
View file

@ -0,0 +1,8 @@
import "@testing-library/jest-dom/vitest";
// Obsidian adds .contains() to String.prototype. Polyfill it for tests.
if (!String.prototype.contains) {
String.prototype.contains = function (searchString: string): boolean {
return this.includes(searchString);
};
}