diff --git a/eslint.config.mjs b/eslint.config.mjs index d257d9b9..ebc239cb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -91,7 +91,7 @@ export default [ // are exempted via per-file overrides below (see "no-unsafe-member-access // exemptions"). Remaining source files (≤5 violations each) were fixed in // this PR. - "@typescript-eslint/no-unsafe-assignment": "off", // 879 violations + "@typescript-eslint/no-unsafe-assignment": "off", // enabled for tests below; follow-up PR for production "@typescript-eslint/no-unsafe-call": "off", // 679 violations // --- Medium: promise / method ergonomics --- @@ -174,6 +174,15 @@ export default [ }, }, + // Tests have been cleaned of unsafe `any` assignments. Production code + // (~499 violations) is a follow-up; keep tests enforced. + { + files: ["**/*.test.{ts,tsx}"], + rules: { + "@typescript-eslint/no-unsafe-assignment": "error", + }, + }, + // Integration tests bootstrap jsdom fetch via `node-fetch` polyfill — // allow the otherwise-banned import here only. { diff --git a/src/LLMProviders/BedrockChatModel.test.ts b/src/LLMProviders/BedrockChatModel.test.ts index 5b617c8b..3c0d7836 100644 --- a/src/LLMProviders/BedrockChatModel.test.ts +++ b/src/LLMProviders/BedrockChatModel.test.ts @@ -1,5 +1,59 @@ import { BedrockChatModel } from "./BedrockChatModel"; +type ProcessStreamResult = { + deltaChunks: Array<{ + text?: string; + message: { + content: unknown; + additional_kwargs?: { delta?: { reasoning?: string } }; + }; + }>; + usage?: Record; + stopReason?: string; + hasText: boolean; + debugSummaries: string[]; +}; + +type ContentItem = { type: string; text?: string; thinking?: string }; + +type ImageContent = { + type: "image"; + source: { type: "base64"; media_type: string; data: string }; +} | null; + +type RequestBody = { + thinking?: { type: string; budget_tokens: number }; + temperature?: number; + anthropic_version?: string; + messages: Array<{ + role: string; + content: Array<{ + type: string; + text?: string; + source?: { type: string; media_type: string; data: string }; + }>; + }>; +}; + +type BedrockInternal = { + decodeChunkBytes: (encoded: string) => string[]; + processStreamEvent: ( + event: unknown, + runManager: unknown, + currentUsage: unknown, + currentStopReason: unknown + ) => Promise; + buildContentItemsFromDelta: (event: unknown) => ContentItem[] | null; + extractStreamText: (event: unknown) => string | null; + buildRequestBody: (messages: unknown[], options?: unknown) => RequestBody; + convertImageContent: (imageUrl: string) => ImageContent; + normaliseMessageContent: ( + message: unknown + ) => string | Array<{ type: string; [key: string]: unknown }>; +}; + +const asInternal = (m: BedrockChatModel): BedrockInternal => m as unknown as BedrockInternal; + /** * Builds a minimal Amazon EventStream message containing the provided UTF-8 payload. * This helper keeps CRC fields at zero because the decoder ignores them. @@ -48,7 +102,7 @@ describe("BedrockChatModel streaming decode", () => { const base64 = Buffer.from(payload, "utf-8").toString("base64"); const model = createModel(); - const decoded = (model as any).decodeChunkBytes(base64); + const decoded = asInternal(model).decodeChunkBytes(base64); expect(decoded).toEqual([payload]); }); @@ -64,7 +118,7 @@ describe("BedrockChatModel streaming decode", () => { const base64 = buildEventStreamChunk(payload); const model = createModel(); - const decoded = (model as any).decodeChunkBytes(base64); + const decoded = asInternal(model).decodeChunkBytes(base64); expect(decoded).toEqual([payload]); }); @@ -85,7 +139,7 @@ describe("BedrockChatModel streaming decode", () => { }; const model = createModel(); - const processed = await (model as any).processStreamEvent( + const processed = await asInternal(model).processStreamEvent( event, undefined, undefined, @@ -111,10 +165,10 @@ describe("BedrockChatModel streaming decode", () => { }; const model = createModel(); - const contentItems = (model as any).buildContentItemsFromDelta(event); + const contentItems = asInternal(model).buildContentItemsFromDelta(event); expect(contentItems).toHaveLength(1); - expect(contentItems[0]).toEqual({ + expect(contentItems![0]).toEqual({ type: "thinking", thinking: "Let me analyze this problem...", }); @@ -133,10 +187,10 @@ describe("BedrockChatModel streaming decode", () => { }; const model = createModel(); - const contentItems = (model as any).buildContentItemsFromDelta(event); + const contentItems = asInternal(model).buildContentItemsFromDelta(event); expect(contentItems).toHaveLength(1); - expect(contentItems[0]).toEqual({ + expect(contentItems![0]).toEqual({ type: "text", text: "Based on my analysis, the answer is...", }); @@ -161,7 +215,7 @@ describe("BedrockChatModel streaming decode", () => { }; const model = createModel(); - const processed = await (model as any).processStreamEvent( + const processed = await asInternal(model).processStreamEvent( event, undefined, undefined, @@ -208,7 +262,7 @@ describe("BedrockChatModel streaming decode", () => { }; const model = createModel(); - const processed = await (model as any).processStreamEvent( + const processed = await asInternal(model).processStreamEvent( event, undefined, undefined, @@ -254,7 +308,7 @@ describe("BedrockChatModel streaming decode", () => { chunk: { bytes: thinkingBase64 }, }; - const thinkingResult = await (model as any).processStreamEvent( + const thinkingResult = await asInternal(model).processStreamEvent( thinkingEvent, undefined, undefined, @@ -283,7 +337,7 @@ describe("BedrockChatModel streaming decode", () => { chunk: { bytes: textBase64 }, }; - const textResult = await (model as any).processStreamEvent( + const textResult = await asInternal(model).processStreamEvent( textEvent, undefined, undefined, @@ -307,7 +361,7 @@ describe("BedrockChatModel streaming decode", () => { }; const model = createModel(); - const extracted = (model as any).extractStreamText(event); + const extracted = asInternal(model).extractStreamText(event); expect(extracted).toBe("Fallback thinking extraction"); }); @@ -324,10 +378,10 @@ describe("BedrockChatModel streaming decode", () => { }; const model = createModel(); - const contentItems = (model as any).buildContentItemsFromDelta(event); + const contentItems = asInternal(model).buildContentItemsFromDelta(event); expect(contentItems).toHaveLength(1); - expect(contentItems[0]).toEqual({ + expect(contentItems![0]).toEqual({ type: "thinking", thinking: "", }); @@ -337,7 +391,7 @@ describe("BedrockChatModel streaming decode", () => { describe("thinking mode enablement", () => { it("includes thinking parameter when enableThinking is true", () => { const model = createModel(true); - const requestBody = (model as any).buildRequestBody([ + const requestBody = asInternal(model).buildRequestBody([ { role: "user", content: "test", getType: () => "human" }, ]); @@ -351,7 +405,7 @@ describe("BedrockChatModel streaming decode", () => { it("does not include thinking parameter when enableThinking is false", () => { const model = createModel(false); - const requestBody = (model as any).buildRequestBody( + const requestBody = asInternal(model).buildRequestBody( [{ role: "user", content: "test", getType: () => "human" }], { temperature: 0.7 } ); @@ -364,7 +418,7 @@ describe("BedrockChatModel streaming decode", () => { it("respects user temperature when thinking is disabled", () => { const model = createModel(false); - const requestBody = (model as any).buildRequestBody( + const requestBody = asInternal(model).buildRequestBody( [{ role: "user", content: "test", getType: () => "human" }], { temperature: 0.5 } ); @@ -375,7 +429,7 @@ describe("BedrockChatModel streaming decode", () => { it("forces temperature to 1 when thinking is enabled", () => { const model = createModel(true); - const requestBody = (model as any).buildRequestBody( + const requestBody = asInternal(model).buildRequestBody( [{ role: "user", content: "test", getType: () => "human" }], { temperature: 0.5 } // User tries to set 0.5, should be overridden to 1 ); @@ -390,7 +444,7 @@ describe("BedrockChatModel streaming decode", () => { it("converts valid data URL to Claude image format", () => { const model = createModel(); const dataUrl = "data:image/jpeg;base64,/9j/4AAQSkZJRg=="; - const result = (model as any).convertImageContent(dataUrl); + const result = asInternal(model).convertImageContent(dataUrl); expect(result).toEqual({ type: "image", @@ -405,7 +459,7 @@ describe("BedrockChatModel streaming decode", () => { it("handles PNG images", () => { const model = createModel(); const dataUrl = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=="; - const result = (model as any).convertImageContent(dataUrl); + const result = asInternal(model).convertImageContent(dataUrl); expect(result).toEqual({ type: "image", @@ -420,7 +474,7 @@ describe("BedrockChatModel streaming decode", () => { it("returns null for invalid data URL format", () => { const model = createModel(); const invalidUrl = "not-a-data-url"; - const result = (model as any).convertImageContent(invalidUrl); + const result = asInternal(model).convertImageContent(invalidUrl); expect(result).toBeNull(); }); @@ -428,7 +482,7 @@ describe("BedrockChatModel streaming decode", () => { it("returns null for non-image media type", () => { const model = createModel(); const dataUrl = "data:text/plain;base64,SGVsbG8gV29ybGQ="; - const result = (model as any).convertImageContent(dataUrl); + const result = asInternal(model).convertImageContent(dataUrl); expect(result).toBeNull(); }); @@ -448,7 +502,7 @@ describe("BedrockChatModel streaming decode", () => { getType: () => "human", }; - const result = (model as any).normaliseMessageContent(message); + const result = asInternal(model).normaliseMessageContent(message); expect(Array.isArray(result)).toBe(true); expect(result).toHaveLength(2); @@ -469,7 +523,7 @@ describe("BedrockChatModel streaming decode", () => { getType: () => "human", }; - const result = (model as any).normaliseMessageContent(message); + const result = asInternal(model).normaliseMessageContent(message); expect(typeof result).toBe("string"); expect(result).toBe("Hello world!"); @@ -482,7 +536,7 @@ describe("BedrockChatModel streaming decode", () => { getType: () => "human", }; - const result = (model as any).normaliseMessageContent(message); + const result = asInternal(model).normaliseMessageContent(message); expect(result).toBe("Simple text message"); }); @@ -504,7 +558,7 @@ describe("BedrockChatModel streaming decode", () => { }, ]; - const requestBody = (model as any).buildRequestBody(messages); + const requestBody = asInternal(model).buildRequestBody(messages); expect(requestBody.messages).toHaveLength(1); expect(requestBody.messages[0].content).toHaveLength(2); @@ -545,14 +599,14 @@ describe("BedrockChatModel streaming decode", () => { }, ]; - const requestBody = (model as any).buildRequestBody(messages); + const requestBody = asInternal(model).buildRequestBody(messages); expect(requestBody.messages[0].content).toHaveLength(3); expect(requestBody.messages[0].content[0].type).toBe("text"); expect(requestBody.messages[0].content[1].type).toBe("image"); - expect(requestBody.messages[0].content[1].source.media_type).toBe("image/jpeg"); + expect(requestBody.messages[0].content[1].source!.media_type).toBe("image/jpeg"); expect(requestBody.messages[0].content[2].type).toBe("image"); - expect(requestBody.messages[0].content[2].source.media_type).toBe("image/png"); + expect(requestBody.messages[0].content[2].source!.media_type).toBe("image/png"); }); it("handles text-only messages correctly", () => { @@ -564,7 +618,7 @@ describe("BedrockChatModel streaming decode", () => { }, ]; - const requestBody = (model as any).buildRequestBody(messages); + const requestBody = asInternal(model).buildRequestBody(messages); expect(requestBody.messages).toHaveLength(1); expect(requestBody.messages[0].content).toHaveLength(1); @@ -593,7 +647,7 @@ describe("BedrockChatModel streaming decode", () => { }, ]; - const requestBody = (model as any).buildRequestBody(messages); + const requestBody = asInternal(model).buildRequestBody(messages); // Should have text + 1 valid image (invalid one skipped) expect(requestBody.messages[0].content).toHaveLength(2); diff --git a/src/LLMProviders/chainRunner/utils/imageExtraction.test.ts b/src/LLMProviders/chainRunner/utils/imageExtraction.test.ts index 0b35da73..af5a5d22 100644 --- a/src/LLMProviders/chainRunner/utils/imageExtraction.test.ts +++ b/src/LLMProviders/chainRunner/utils/imageExtraction.test.ts @@ -5,11 +5,11 @@ describe("Image extraction from content", () => { // Mock the global app object const mockApp = { metadataCache: { - getFirstLinkpathDest: jest.fn(), + getFirstLinkpathDest: jest.fn() as jest.Mock<{ path: string } | null, [string, string]>, }, }; - (window as any).app = mockApp; + (window as unknown as { app: typeof mockApp }).app = mockApp; beforeEach(() => { jest.clearAllMocks(); @@ -33,7 +33,7 @@ describe("Image extraction from content", () => { if (resolvedFile) { // Use the resolved path - resolvedImages.push(resolvedFile.path as string); + resolvedImages.push(resolvedFile.path); } else { // If file not found, still include the raw filename resolvedImages.push(imageName); @@ -69,7 +69,7 @@ describe("Image extraction from content", () => { if (resolvedFile) { // Use the resolved path - resolvedImages.push(resolvedFile.path as string); + resolvedImages.push(resolvedFile.path); } else { // If file not found, still include the raw path // Let ImageBatchProcessor handle validation diff --git a/src/LLMProviders/chainRunner/utils/promptDebugService.test.ts b/src/LLMProviders/chainRunner/utils/promptDebugService.test.ts index bcd0acd1..67a9f040 100644 --- a/src/LLMProviders/chainRunner/utils/promptDebugService.test.ts +++ b/src/LLMProviders/chainRunner/utils/promptDebugService.test.ts @@ -1,6 +1,6 @@ import { generatePromptDebugReportForAgent, resolveBasePrompt } from "./promptDebugService"; import type ChainManager from "@/LLMProviders/chainManager"; -import { PromptSection } from "./modelAdapter"; +import { ModelAdapter, PromptSection } from "./modelAdapter"; import { PromptDebugReport } from "./toolPromptDebugger"; const createAdapter = () => ({ @@ -25,7 +25,7 @@ const createAdapter = () => ({ constructor: { name: "TestAdapter" }, }); -const createChainContext = (history: any[] = []): any => { +const createChainContext = (history: unknown[] = []): ChainManager => { const memory = { loadMemoryVariables: jest.fn().mockResolvedValue({ history }), }; @@ -37,7 +37,7 @@ const createChainContext = (history: any[] = []): any => { userMemoryManager: { getUserMemoryPrompt: jest.fn().mockResolvedValue(null), }, - } as any; + } as unknown as ChainManager; }; describe("promptDebugService", () => { @@ -47,7 +47,7 @@ describe("promptDebugService", () => { const report: PromptDebugReport = await generatePromptDebugReportForAgent({ chainManager, - adapter: adapter as any, + adapter: adapter as unknown as ModelAdapter, basePrompt: "BasePrompt", toolDescriptions: "", toolNames: ["localSearch"], @@ -91,9 +91,9 @@ describe("promptDebugService", () => { userMemoryManager: { getUserMemoryPrompt: jest.fn().mockResolvedValue(memoryPrompt), }, - } as any; + } as unknown as ChainManager; - const prompt = await resolveBasePrompt(chainManager as ChainManager); + const prompt = await resolveBasePrompt(chainManager); expect(prompt).toContain(memoryPrompt); }); }); diff --git a/src/LLMProviders/chainRunner/utils/promptPayloadRecorder.test.ts b/src/LLMProviders/chainRunner/utils/promptPayloadRecorder.test.ts index e573be01..86884540 100644 --- a/src/LLMProviders/chainRunner/utils/promptPayloadRecorder.test.ts +++ b/src/LLMProviders/chainRunner/utils/promptPayloadRecorder.test.ts @@ -58,7 +58,7 @@ describe("promptPayloadRecorder", () => { }); it("serializes circular message graphs without throwing", async () => { - const circular: any = { role: "system" }; + const circular: { role: string; self?: unknown } = { role: "system" }; circular.self = circular; expect(() => recordPromptPayload({ messages: [circular] })).not.toThrow(); diff --git a/src/commands/customCommandUtils.test.ts b/src/commands/customCommandUtils.test.ts index eaedda31..b4d3f49e 100644 --- a/src/commands/customCommandUtils.test.ts +++ b/src/commands/customCommandUtils.test.ts @@ -32,7 +32,7 @@ jest.mock("@/logger", () => ({ // Mock the utility functions jest.mock("@/utils", () => { - const actual = jest.requireActual("@/utils"); + const actual = jest.requireActual<{ stripFrontmatter: unknown }>("@/utils"); return { extractTemplateNoteFiles: jest.fn().mockReturnValue([]), getFileContent: jest.fn(), @@ -111,7 +111,7 @@ describe("processedPrompt()", () => { .mockResolvedValueOnce("here is the note content for note0") .mockResolvedValueOnce("note content for note1"); - const { getFileName } = jest.requireMock("@/utils"); + const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils"); getFileName.mockReturnValueOnce("Variable1 Note").mockReturnValueOnce("Variable2 Note"); const mockNote1 = mockTFile({ path: "path/to/note1.md", basename: "Variable1 Note" }); @@ -175,7 +175,7 @@ describe("processedPrompt()", () => { const selectedText = ""; (getFileContent as jest.Mock).mockResolvedValue("Content of the active note"); - const { getFileName } = jest.requireMock("@/utils"); + const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils"); getFileName.mockReturnValue("Active Note"); const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote); @@ -238,7 +238,7 @@ describe("processedPrompt()", () => { (getNotesFromTags as jest.Mock).mockReturnValue([mockNoteForTag]); // Mock getFileName to return the basename - const { getFileName } = jest.requireMock("@/utils"); + const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils"); getFileName.mockReturnValue("Tagged Note"); // Mock getFileContent to return content for the note @@ -270,7 +270,7 @@ describe("processedPrompt()", () => { (getNotesFromTags as jest.Mock).mockReturnValue([mockNoteForTag1, mockNoteForTag2]); // Mock getFileName to return the basename - const { getFileName } = jest.requireMock("@/utils"); + const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils"); getFileName.mockImplementation((file: TFile) => file.basename); // Mock getFileContent to return content for each note @@ -324,7 +324,7 @@ describe("processedPrompt()", () => { (extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockNoteFile]); - const { getFileName } = jest.requireMock("@/utils"); + const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils"); getFileName.mockReturnValue("Test Note"); (getFileContent as jest.Mock).mockResolvedValue("Test note content"); @@ -362,7 +362,7 @@ describe("processedPrompt()", () => { // Only Note1 should be extracted since it's wrapped in {[[]]} (extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockNote1]); - const { getFileName } = jest.requireMock("@/utils"); + const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils"); getFileName.mockImplementation((file: TFile) => file.basename); (getFileContent as jest.Mock).mockImplementation((file: TFile) => { @@ -459,7 +459,7 @@ describe("processedPrompt()", () => { const selectedText = ""; // Mock getFileName and getFileContent - const { getFileName } = jest.requireMock("@/utils"); + const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils"); getFileName.mockReturnValue("Active Note"); (getFileContent as jest.Mock).mockResolvedValue("Content of the active note"); @@ -511,7 +511,7 @@ describe("processedPrompt()", () => { // Mock getFileContent for the active note when processed via {} (getFileContent as jest.Mock).mockResolvedValue("Content of the active note"); - const { getFileName } = jest.requireMock("@/utils"); + const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils"); getFileName.mockReturnValue("Active Note"); const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote); @@ -663,14 +663,30 @@ describe("sortSlashCommands", () => { }); describe("parseCustomCommandFile", () => { - let originalApp: any; - let mockFile: any; - let mockFrontmatter: any; - let mockMetadata: any; + interface MockFrontmatter { + "copilot-command-context-menu-enabled"?: boolean; + "copilot-command-slash-enabled"?: boolean; + "copilot-command-context-menu-order"?: number; + "copilot-command-model-key"?: string; + "copilot-command-last-used"?: number; + } + interface MockMetadata { + frontmatter: MockFrontmatter; + } + interface MockAppLike { + vault: { read: jest.Mock }; + metadataCache: { getFileCache: jest.Mock }; + } + type AppRef = { app: unknown }; + + let originalApp: unknown; + let mockFile: TFile; + let mockFrontmatter: MockFrontmatter; + let mockMetadata: MockMetadata; beforeEach(() => { // Save and mock global app - originalApp = window.app; + originalApp = (window as unknown as AppRef).app; mockFrontmatter = { "copilot-command-context-menu-enabled": true, "copilot-command-slash-enabled": false, @@ -679,34 +695,33 @@ describe("parseCustomCommandFile", () => { "copilot-command-last-used": 1234567890, }; mockMetadata = { frontmatter: mockFrontmatter }; - window.app = { + const mockedApp: MockAppLike = { vault: { read: jest .fn() .mockResolvedValue( "---\ncopilot-command-context-menu-enabled: true\ncopilot-command-slash-enabled: false\ncopilot-command-context-menu-order: 42\ncopilot-command-model-key: gpt-4\ncopilot-command-last-used: 1234567890\n---\nPrompt content here." - ) as any, - } as any, + ), + }, metadataCache: { - getFileCache: jest.fn().mockReturnValue(mockMetadata) as any, - } as any, - } as any; - mockFile = { + getFileCache: jest.fn().mockReturnValue(mockMetadata), + }, + }; + (window as unknown as AppRef).app = mockedApp; + mockFile = mockTFile({ basename: "Test Command", extension: "md", path: "CustomCommands/Test Command.md", - }; + }); }); afterEach(() => { - window.app = originalApp; + (window as unknown as AppRef).app = originalApp; }); it("parses a custom command file with frontmatter and content", async () => { const { parseCustomCommandFile } = await import("@/commands/customCommandUtils"); - const result = await parseCustomCommandFile( - mockFile as Parameters[0] - ); + const result = await parseCustomCommandFile(mockFile); expect(result).toEqual({ title: "Test Command", modelKey: "gpt-4", @@ -724,9 +739,7 @@ describe("parseCustomCommandFile", () => { (window.app.vault as any).read.mockResolvedValue("Prompt content only, no frontmatter."); (window.app.metadataCache as any).getFileCache.mockReturnValue({}); const { parseCustomCommandFile } = await import("@/commands/customCommandUtils"); - const result = await parseCustomCommandFile( - mockFile as Parameters[0] - ); + const result = await parseCustomCommandFile(mockFile); expect(result).toEqual({ title: "Test Command", modelKey: "", diff --git a/src/components/chat-components/ChatSingleMessage.test.tsx b/src/components/chat-components/ChatSingleMessage.test.tsx index 9493d421..d9096d5d 100644 --- a/src/components/chat-components/ChatSingleMessage.test.tsx +++ b/src/components/chat-components/ChatSingleMessage.test.tsx @@ -63,7 +63,9 @@ jest.mock("obsidian", () => { }; }); -const { __renderMarkdownMock: renderMarkdownMock } = jest.requireMock("obsidian"); +const { __renderMarkdownMock: renderMarkdownMock } = jest.requireMock<{ + __renderMarkdownMock: jest.Mock; +}>("obsidian"); // --------------------------------------------------------------------------- // Verifies that the HTML string passed to MarkdownRenderer.renderMarkdown diff --git a/src/components/chat-components/utils/lexicalTextUtils.test.ts b/src/components/chat-components/utils/lexicalTextUtils.test.ts index 7c9d17b9..b17bdd42 100644 --- a/src/components/chat-components/utils/lexicalTextUtils.test.ts +++ b/src/components/chat-components/utils/lexicalTextUtils.test.ts @@ -1,5 +1,6 @@ import { parseTextForPills } from "./lexicalTextUtils"; -import { TFile, TFolder } from "obsidian"; +import { TFile } from "obsidian"; +import { mockTFolder } from "@/__tests__/mockObsidian"; // Mock dependencies jest.mock("@/logger", () => ({ @@ -87,7 +88,7 @@ describe("parseTextForPills", () => { { type: "note-pill", content: "Valid Note", - file: expect.any(TFile), + file: expect.any(TFile) as unknown, isActive: false, }, { @@ -219,8 +220,8 @@ describe("parseTextForPills", () => { describe("with folders only", () => { beforeEach(() => { - // Create a mock folder using the TFolder constructor from the mock - const mockFolder = new (TFolder as any)("Projects"); + // Create a mock folder using mockTFolder helper + const mockFolder = mockTFolder({ path: "Projects", name: "Projects" }); // Mock folder resolution mockApp.vault.getAllLoadedFiles.mockReturnValue([mockFolder]); @@ -238,7 +239,7 @@ describe("parseTextForPills", () => { { type: "folder-pill", content: "Projects", - folder: expect.any(Object), + folder: expect.any(Object) as unknown, }, { type: "text", @@ -287,7 +288,7 @@ describe("parseTextForPills", () => { frontmatter: { tags: ["test"] }, }); - const mockFolder = new (TFolder as any)("TestFolder"); + const mockFolder = mockTFolder({ path: "TestFolder", name: "TestFolder" }); mockApp.vault.getAllLoadedFiles.mockReturnValue([mockFolder]); mockApp.workspace.getActiveFile.mockReturnValue(null); @@ -418,7 +419,7 @@ describe("parseTextForPills", () => { const text = "@tool-name #tag_with_underscores {folder with spaces}"; // Mock folder resolution for folder with spaces - const mockFolder = new (TFolder as any)("folder with spaces"); + const mockFolder = mockTFolder({ path: "folder with spaces", name: "folder with spaces" }); mockApp.vault.getAllLoadedFiles.mockReturnValue([mockFolder]); const result = parseTextForPills(text, { diff --git a/src/contextProcessor.dataview.test.ts b/src/contextProcessor.dataview.test.ts index 0f4a3cb2..5f3d9d1f 100644 --- a/src/contextProcessor.dataview.test.ts +++ b/src/contextProcessor.dataview.test.ts @@ -16,7 +16,7 @@ window.app = { plugins: { plugins: {}, }, -} as any; +} as unknown as typeof window.app; describe("ContextProcessor - Dataview Integration", () => { let contextProcessor: ContextProcessor; diff --git a/src/contextProcessor.embeds.test.ts b/src/contextProcessor.embeds.test.ts index c054e903..4ee9387d 100644 --- a/src/contextProcessor.embeds.test.ts +++ b/src/contextProcessor.embeds.test.ts @@ -12,7 +12,7 @@ import { EMBEDDED_NOTE_TAG } from "@/constants"; import { ChainType } from "@/chainType"; import { TFile, Vault } from "obsidian"; -type FileCacheMap = Record>; +type FileCacheMap = Record; type FileContentMap = Record; const createMockFile = (path: string): TFile => @@ -67,7 +67,7 @@ describe("ContextProcessor - Embedded Notes", () => { }; }); - const registerFile = (file: TFile, content: string, cache: any = {}): void => { + const registerFile = (file: TFile, content: string, cache: unknown = {}): void => { fileIndex.set(file.path, file); fileContents[file.path] = content; fileCaches[file.path] = cache; diff --git a/src/core/ChatManager.test.ts b/src/core/ChatManager.test.ts index ee7b248f..98e4c841 100644 --- a/src/core/ChatManager.test.ts +++ b/src/core/ChatManager.test.ts @@ -73,6 +73,7 @@ import type CopilotPlugin from "@/main"; import { ChainType } from "@/chainType"; import { getWebViewerService } from "@/services/webViewerService/webViewerServiceSingleton"; import { ChatMessage, MessageContext } from "@/types/message"; +import { PromptContextEnvelope } from "@/context/PromptContextTypes"; import { mockTFile } from "@/__tests__/mockObsidian"; const USER_SENDER = "user"; @@ -113,7 +114,7 @@ describe("ChatManager", () => { editMessage: jest.fn(), getDebugInfo: jest.fn(), truncateAfter: jest.fn(), - } as any; + } as unknown as jest.Mocked; mockChainManager = { memoryManager: { @@ -140,7 +141,7 @@ describe("ChatManager", () => { mockContextManager = { processMessageContext: jest.fn(), reprocessMessageContext: jest.fn(), - } as any; + } as unknown as jest.Mocked; // Mock ContextManager.getInstance (ContextManager.getInstance as jest.Mock).mockReturnValue(mockContextManager); @@ -335,7 +336,7 @@ describe("ChatManager", () => { const mockUserMessage = createMockMessage("msg-1", "Hello", USER_SENDER); const mockLLMMessage = { ...createMockMessage("msg-1", "Hello with context", USER_SENDER), - contextEnvelope: { layers: [] } as any, // Has envelope, no lazy reprocessing needed + contextEnvelope: { layers: [] } as unknown as PromptContextEnvelope, // Has envelope, no lazy reprocessing needed }; mockMessageRepo.getMessage.mockReturnValue(mockAiMessage); @@ -376,7 +377,7 @@ describe("ChatManager", () => { // Second call: after reprocessing, has envelope const mockLLMMessageWithEnvelope = { ...createMockMessage("msg-1", "Hello with context", USER_SENDER), - contextEnvelope: { layers: [] } as any, + contextEnvelope: { layers: [] } as unknown as PromptContextEnvelope, }; mockMessageRepo.getMessage.mockReturnValue(mockAiMessage); @@ -677,7 +678,7 @@ describe("ChatManager", () => { const mockUserMessage = createMockMessage("msg-1", "Hello", USER_SENDER); const mockLLMMessage = { ...createMockMessage("msg-1", "Hello with context", USER_SENDER), - contextEnvelope: { layers: [] } as any, + contextEnvelope: { layers: [] } as unknown as PromptContextEnvelope, }; mockMessageRepo.getMessage.mockReturnValue(mockAiMessage); @@ -753,7 +754,7 @@ describe("ChatManager", () => { url: "https://active.example.com", isActive: true, }), - ]), + ]) as unknown, }), undefined ); @@ -797,7 +798,7 @@ describe("ChatManager", () => { url: "https://marker.example.com", isActive: true, }), - ]), + ]) as unknown, }), undefined ); @@ -1224,12 +1225,17 @@ describe("ChatManager", () => { describe("System Prompt Template Processing", () => { // Import mocked modules for manipulation - const { processPrompt } = jest.requireMock("@/commands/customCommandUtils"); - const { getSystemPrompt, getSystemPromptWithMemory, getEffectiveUserPrompt } = jest.requireMock( - "@/system-prompts/systemPromptBuilder" + const { processPrompt } = jest.requireMock<{ processPrompt: jest.Mock }>( + "@/commands/customCommandUtils" ); - const { getSettings } = jest.requireMock("@/settings/model"); - const { getCurrentProject } = jest.requireMock("@/aiParams"); + const { getSystemPrompt, getSystemPromptWithMemory, getEffectiveUserPrompt } = + jest.requireMock<{ + getSystemPrompt: jest.Mock; + getSystemPromptWithMemory: jest.Mock; + getEffectiveUserPrompt: jest.Mock; + }>("@/system-prompts/systemPromptBuilder"); + const { getSettings } = jest.requireMock<{ getSettings: jest.Mock }>("@/settings/model"); + const { getCurrentProject } = jest.requireMock<{ getCurrentProject: jest.Mock }>("@/aiParams"); beforeEach(() => { // Reset to defaults diff --git a/src/core/ChatPersistenceManager.test.ts b/src/core/ChatPersistenceManager.test.ts index 934a430d..11b65166 100644 --- a/src/core/ChatPersistenceManager.test.ts +++ b/src/core/ChatPersistenceManager.test.ts @@ -1,13 +1,20 @@ +import type ChainManager from "@/LLMProviders/chainManager"; import { ChatMessage } from "@/types/message"; import { App, Notice, TFile } from "obsidian"; import type { MessageRepository } from "./MessageRepository"; -import type ChainManager from "@/LLMProviders/chainManager"; import { ChatPersistenceManager } from "./ChatPersistenceManager"; import { mockTFile } from "@/__tests__/mockObsidian"; const USER_SENDER = "user"; const AI_SENDER = "ai"; +type PMInternal = { + formatChatContent: (messages: ChatMessage[]) => string; + parseChatContent: (content: string) => ChatMessage[]; + generateNoteContent: (chatContent: string, epoch: number, modelKey: string) => string; +}; +const asInternal = (pm: ChatPersistenceManager): PMInternal => pm as unknown as PMInternal; + // Mock the imports jest.mock("obsidian", () => ({ Notice: jest.fn(), @@ -181,7 +188,7 @@ describe("ChatPersistenceManager", () => { }, ]; - const result = (persistenceManager as any).formatChatContent(messages); + const result = asInternal(persistenceManager).formatChatContent(messages); const expected = `**user**: my name is logan, what's your name [Timestamp: 2024/09/23 22:18:00] @@ -209,7 +216,7 @@ describe("ChatPersistenceManager", () => { }, ]; - const result = (persistenceManager as any).formatChatContent(messages); + const result = asInternal(persistenceManager).formatChatContent(messages); expect(result).toBe(`**user**: Hello [Timestamp: Unknown time]`); @@ -240,7 +247,7 @@ tags: **ai**: Your name is Logan. [Timestamp: 2024/09/23 22:56:20]`; - const result = (persistenceManager as any).parseChatContent(content); + const result = asInternal(persistenceManager).parseChatContent(content); expect(result).toHaveLength(5); expect(result[0]).toMatchObject({ @@ -292,7 +299,7 @@ Gentle breeze whispers secrets Nature's quiet song [Timestamp: 2024/09/23 22:18:01]`; - const result = (persistenceManager as any).parseChatContent(content); + const result = asInternal(persistenceManager).parseChatContent(content); expect(result).toHaveLength(2); expect(result[1].message).toBe(`Here's a haiku for you: @@ -309,7 +316,7 @@ Nature's quiet song`); **ai**: Hi there! [Timestamp: Unknown time]`; - const result = (persistenceManager as any).parseChatContent(content); + const result = asInternal(persistenceManager).parseChatContent(content); expect(result).toHaveLength(2); expect(result[0].timestamp).toBeNull(); @@ -342,7 +349,7 @@ Nature's quiet song`); "test-folder/Hello@20240923_221800.md", expect.stringContaining("**user**: Hello") ); - const savedContent = mockApp.vault.create.mock.calls[0][1]; + const savedContent = mockApp.vault.create.mock.calls[0][1] as string; expect(savedContent).not.toContain("topic:"); }); @@ -379,16 +386,16 @@ Nature's quiet song`); ], }); - const chainManager = { + const chainManager: ChainManager = { chatModelManager: { getChatModel: jest.fn().mockReturnValue({ invoke }), }, - } as any; + } as unknown as ChainManager; persistenceManager = new ChatPersistenceManager( mockApp as App, mockMessageRepo as MessageRepository, - chainManager as ChainManager + chainManager ); const mockFile = mockTFile({ path: "test-folder/Summarize_weather_data@20240923_221800.md", @@ -410,7 +417,7 @@ Nature's quiet song`); "test-folder/Summarize_weather_data@20240923_221800.md", expect.stringContaining("Summarize weather data") ); - const savedContent = mockApp.vault.create.mock.calls[0][1]; + const savedContent = mockApp.vault.create.mock.calls[0][1] as string; expect(savedContent).not.toContain("topic:"); await Promise.resolve(); await Promise.resolve(); @@ -640,7 +647,7 @@ Nature's quiet song`); ]; // Mock getCurrentProject to return a project - const getCurrentProject = jest.requireMock("@/aiParams").getCurrentProject; + const getCurrentProject = jest.requireMock("@/aiParams").getCurrentProject as jest.Mock; getCurrentProject.mockReturnValue({ id: "project-123", name: "Test Project" }); mockMessageRepo.getDisplayMessages.mockReturnValue(messages); @@ -749,7 +756,7 @@ Nature's quiet song`); ]; // Mock getCurrentProject to return a project - const getCurrentProject = jest.requireMock("@/aiParams").getCurrentProject; + const getCurrentProject = jest.requireMock("@/aiParams").getCurrentProject as jest.Mock; getCurrentProject.mockReturnValue({ id: "songs-of-syx", name: "Songs of Syx Translation" }); mockMessageRepo.getDisplayMessages.mockReturnValue(messages); @@ -804,8 +811,7 @@ Nature's quiet song`); }, ]; - const existingFallbackFile: TFile = Object.create(TFile.prototype); - Object.assign(existingFallbackFile, { + const existingFallbackFile = mockTFile({ path: "test-folder/chat-1729873880000.md", basename: "chat-1729873880000", }); @@ -898,8 +904,7 @@ Nature's quiet song`); }, ]; - const existingFile: TFile = Object.create(TFile.prototype); - Object.assign(existingFile, { + const existingFile = mockTFile({ path: "test-folder/Hello_again@20240923_221800.md", basename: "Hello_again@20240923_221800", }); @@ -951,8 +956,7 @@ Nature's quiet song`); }, ]; - const existingFile: TFile = Object.create(TFile.prototype); - Object.assign(existingFile, { + const existingFile = mockTFile({ path: "test-folder/Conflict_message@20240923_221800.md", basename: "Conflict_message@20240923_221800", }); @@ -1080,7 +1084,7 @@ tags: ]; // Format the content - const formattedContent = (persistenceManager as any).formatChatContent(originalMessages); + const formattedContent = asInternal(persistenceManager).formatChatContent(originalMessages); // Add frontmatter const fullContent = `--- @@ -1093,7 +1097,7 @@ tags: ${formattedContent}`; // Parse it back - const parsedMessages = (persistenceManager as any).parseChatContent(fullContent); + const parsedMessages = asInternal(persistenceManager).parseChatContent(fullContent); // Verify the messages match expect(parsedMessages).toHaveLength(2); @@ -1104,16 +1108,16 @@ ${formattedContent}`; }); it("should preserve context information through save and load cycle", async () => { - // Create mock TFile for the note - must use Object.create to pass instanceof check - const mockTFile: TFile = Object.create(TFile.prototype); - mockTFile.basename = "typescript-guide.md"; - mockTFile.path = "docs/typescript-guide.md"; - mockTFile.extension = "md"; - mockTFile.name = "typescript-guide.md"; + const noteFile = mockTFile({ + basename: "typescript-guide.md", + path: "docs/typescript-guide.md", + extension: "md", + name: "typescript-guide.md", + }); // Mock vault to return the file when resolving by path mockApp.vault.getAbstractFileByPath = jest.fn().mockImplementation((path: string) => { - return path === "docs/typescript-guide.md" ? mockTFile : null; + return path === "docs/typescript-guide.md" ? noteFile : null; }); // Recreate persistence manager with the updated mock @@ -1134,7 +1138,7 @@ ${formattedContent}`; }, isVisible: true, context: { - notes: [mockTFile], + notes: [noteFile], urls: ["https://typescriptlang.org"], tags: ["programming", "typescript"], folders: ["docs/"], @@ -1174,7 +1178,8 @@ ${formattedContent}`; ]; // Format the content - const formattedContent = (testPersistenceManager as any).formatChatContent(originalMessages); + const formattedContent = + asInternal(testPersistenceManager).formatChatContent(originalMessages); // Verify the formatted content contains the full path (not just basename) expect(formattedContent).toContain("docs/typescript-guide.md"); @@ -1190,7 +1195,7 @@ tags: ${formattedContent}`; // Parse it back - const parsedMessages = (testPersistenceManager as any).parseChatContent(fullContent); + const parsedMessages = asInternal(testPersistenceManager).parseChatContent(fullContent); // Verify the messages match expect(parsedMessages).toHaveLength(2); @@ -1199,17 +1204,17 @@ ${formattedContent}`; // Verify context is preserved (Tags, Folders, WebTabs are optional in type) expect(parsedMessages[0].context).toBeDefined(); - expect(parsedMessages[0].context.notes).toHaveLength(1); - expect(parsedMessages[0].context.notes[0].basename).toBe("typescript-guide.md"); - expect(parsedMessages[0].context.notes[0].path).toBe("docs/typescript-guide.md"); - expect(parsedMessages[0].context.urls).toEqual(["https://typescriptlang.org"]); - expect(parsedMessages[0].context.tags).toEqual(["programming", "typescript"]); - expect(parsedMessages[0].context.folders).toHaveLength(1); - expect(parsedMessages[0].context.folders[0]).toBe("docs/"); - expect(parsedMessages[0].context.webTabs).toHaveLength(3); - expect(parsedMessages[0].context.webTabs[0].url).toBe("https://example.com/"); - expect(parsedMessages[0].context.webTabs[1].url).toBe("https://lucide.dev/"); - expect(parsedMessages[0].context.webTabs[2].url).toBe("https://obsidian.md/"); + expect(parsedMessages[0].context!.notes).toHaveLength(1); + expect(parsedMessages[0].context!.notes[0].basename).toBe("typescript-guide.md"); + expect(parsedMessages[0].context!.notes[0].path).toBe("docs/typescript-guide.md"); + expect(parsedMessages[0].context!.urls).toEqual(["https://typescriptlang.org"]); + expect(parsedMessages[0].context!.tags!).toEqual(["programming", "typescript"]); + expect(parsedMessages[0].context!.folders!).toHaveLength(1); + expect(parsedMessages[0].context!.folders![0]).toBe("docs/"); + expect(parsedMessages[0].context!.webTabs!).toHaveLength(3); + expect(parsedMessages[0].context!.webTabs![0].url).toBe("https://example.com/"); + expect(parsedMessages[0].context!.webTabs![1].url).toBe("https://lucide.dev/"); + expect(parsedMessages[0].context!.webTabs![2].url).toBe("https://obsidian.md/"); // Verify the second message has no context expect(parsedMessages[1].context).toBeUndefined(); @@ -1243,13 +1248,13 @@ tags: **ai**: Here's what I found about TypeScript in your files... [Timestamp: 2024/09/23 22:18:01]`; - const parsedMessages = (persistenceManager as any).parseChatContent(content); + const parsedMessages = asInternal(persistenceManager).parseChatContent(content); expect(parsedMessages).toHaveLength(2); expect(parsedMessages[0].context).toBeDefined(); - expect(parsedMessages[0].context.notes).toHaveLength(1); - expect(parsedMessages[0].context.notes[0].basename).toBe("typescript-guide.md"); - expect(parsedMessages[0].context.notes[0].path).toBe("docs/typescript-guide.md"); + expect(parsedMessages[0].context!.notes).toHaveLength(1); + expect(parsedMessages[0].context!.notes[0].basename).toBe("typescript-guide.md"); + expect(parsedMessages[0].context!.notes[0].path).toBe("docs/typescript-guide.md"); }); it("should handle ambiguous basename resolution gracefully", async () => { @@ -1284,13 +1289,13 @@ tags: [Context: Notes: typescript-guide.md | URLs: https://typescriptlang.org] [Timestamp: 2024/09/23 22:18:00]`; - const parsedMessages = (persistenceManager as any).parseChatContent(content); + const parsedMessages = asInternal(persistenceManager).parseChatContent(content); // Should skip ambiguous note (logs warning) but preserve other context expect(parsedMessages).toHaveLength(1); expect(parsedMessages[0].context).toBeDefined(); - expect(parsedMessages[0].context.notes).toHaveLength(0); // Skipped due to ambiguity - expect(parsedMessages[0].context.urls).toEqual(["https://typescriptlang.org"]); // Other context preserved + expect(parsedMessages[0].context!.notes).toHaveLength(0); // Skipped due to ambiguity + expect(parsedMessages[0].context!.urls).toEqual(["https://typescriptlang.org"]); // Other context preserved }); it("should handle deleted notes gracefully", async () => { @@ -1310,13 +1315,13 @@ tags: [Context: Notes: docs/deleted-file.md | Tags: typescript, programming] [Timestamp: 2024/09/23 22:18:00]`; - const parsedMessages = (persistenceManager as any).parseChatContent(content); + const parsedMessages = asInternal(persistenceManager).parseChatContent(content); // Should skip missing note (logs warning) but preserve other context expect(parsedMessages).toHaveLength(1); expect(parsedMessages[0].context).toBeDefined(); - expect(parsedMessages[0].context.notes).toHaveLength(0); // Skipped - not found - expect(parsedMessages[0].context.tags).toEqual(["typescript", "programming"]); // Other context preserved + expect(parsedMessages[0].context!.notes).toHaveLength(0); // Skipped - not found + expect(parsedMessages[0].context!.tags!).toEqual(["typescript", "programming"]); // Other context preserved }); it("should handle messages without context (backward compatibility)", async () => { @@ -1333,7 +1338,7 @@ tags: **ai**: Hi there! [Timestamp: 2024/09/23 22:18:01]`; - const parsedMessages = (persistenceManager as any).parseChatContent(content); + const parsedMessages = asInternal(persistenceManager).parseChatContent(content); expect(parsedMessages).toHaveLength(2); expect(parsedMessages[0].context).toBeUndefined(); @@ -1363,7 +1368,7 @@ tags: // Test with user-defined display name containing special characters await persistenceManager.saveChat("[芥兰]Gemini-2.5-pro|3rd party"); - const savedContent = mockApp.vault.create.mock.calls[0][1]; + const savedContent = mockApp.vault.create.mock.calls[0][1] as string; expect(savedContent).toContain('modelKey: "[芥兰]Gemini-2.5-pro|3rd party"'); }); @@ -1387,7 +1392,7 @@ tags: await persistenceManager.saveChat("x-ai/grok-4-fast"); - const savedContent = mockApp.vault.create.mock.calls[0][1]; + const savedContent = mockApp.vault.create.mock.calls[0][1] as string; expect(savedContent).toContain('modelKey: "x-ai/grok-4-fast"'); }); @@ -1411,7 +1416,7 @@ tags: await persistenceManager.saveChat("copilot-plus-flash|copilot-plus"); - const savedContent = mockApp.vault.create.mock.calls[0][1]; + const savedContent = mockApp.vault.create.mock.calls[0][1] as string; expect(savedContent).toContain('modelKey: "copilot-plus-flash|copilot-plus"'); }); @@ -1435,7 +1440,7 @@ tags: await persistenceManager.saveChat('model"with"quotes|provider'); - const savedContent = mockApp.vault.create.mock.calls[0][1]; + const savedContent = mockApp.vault.create.mock.calls[0][1] as string; expect(savedContent).toContain('modelKey: "model\\"with\\"quotes|provider"'); }); @@ -1459,7 +1464,7 @@ tags: await persistenceManager.saveChat("model\\with\\backslash|provider"); - const savedContent = mockApp.vault.create.mock.calls[0][1]; + const savedContent = mockApp.vault.create.mock.calls[0][1] as string; expect(savedContent).toContain('modelKey: "model\\\\with\\\\backslash|provider"'); }); @@ -1480,8 +1485,8 @@ tags: ]; // Generate the note content - const chatContent = (persistenceManager as any).formatChatContent(messages); - const noteContent = (persistenceManager as any).generateNoteContent( + const chatContent = asInternal(persistenceManager).formatChatContent(messages); + const noteContent = asInternal(persistenceManager).generateNoteContent( chatContent, messages[0].timestamp!.epoch, testModelKey @@ -1513,8 +1518,8 @@ tags: ]; // Generate the note content - const chatContent = (persistenceManager as any).formatChatContent(messages); - const noteContent = (persistenceManager as any).generateNoteContent( + const chatContent = asInternal(persistenceManager).formatChatContent(messages); + const noteContent = asInternal(persistenceManager).generateNoteContent( chatContent, messages[0].timestamp!.epoch, testModelKey @@ -1546,8 +1551,8 @@ tags: ]; // Generate the note content - const chatContent = (persistenceManager as any).formatChatContent(messages); - const noteContent = (persistenceManager as any).generateNoteContent( + const chatContent = asInternal(persistenceManager).formatChatContent(messages); + const noteContent = asInternal(persistenceManager).generateNoteContent( chatContent, messages[0].timestamp!.epoch, testModelKey @@ -1634,8 +1639,8 @@ tags: await persistenceManager.saveChat("gpt-4"); // Verify that create was called with content NOT containing lastAccessedAt - const createCall = mockApp.vault.create.mock.calls[0]; - const content = createCall[1] as string; + const createCall = mockApp.vault.create.mock.calls[0] as [string, string]; + const content = createCall[1]; expect(content).not.toContain("lastAccessedAt:"); }); @@ -1654,8 +1659,7 @@ tags: }, ]; - const existingFile: TFile = Object.create(TFile.prototype); - Object.assign(existingFile, { + const existingFile = mockTFile({ path: "test-folder/Conflict_test@20240923_221800.md", basename: "Conflict_test@20240923_221800", }); diff --git a/src/core/ContextManager.l2Promotion.test.ts b/src/core/ContextManager.l2Promotion.test.ts index 2339f8da..3eb7e174 100644 --- a/src/core/ContextManager.l2Promotion.test.ts +++ b/src/core/ContextManager.l2Promotion.test.ts @@ -56,6 +56,17 @@ jest.mock("@/commands/customCommandUtils", () => ({ jest.mock("./ContextCompactor", () => ({})); import { ContextManager } from "./ContextManager"; +import type { MessageRepository } from "./MessageRepository"; + +type ContextManagerInternal = { + buildL2ContextFromPreviousTurns: ( + currentMessageId: string, + messageRepo: MessageRepository + ) => { l2Context: string; l2Paths: Set }; +}; + +const asInternal = (m: ContextManager): ContextManagerInternal => + m as unknown as ContextManagerInternal; /** * Build a minimal PromptContextEnvelope with L3_TURN segments. @@ -76,7 +87,7 @@ function buildEnvelopeWithL3Segments(segments: PromptLayerSegment[]): PromptCont }, ], serializedText: "", - layerHashes: {} as any, + layerHashes: {} as PromptContextEnvelope["layerHashes"], combinedHash: "test", }; } @@ -86,7 +97,7 @@ function buildEnvelopeWithL3Segments(segments: PromptLayerSegment[]): PromptCont */ function createMockMessageRepo( messages: Array<{ id: string; sender: string; contextEnvelope?: PromptContextEnvelope }> -): any { +): MessageRepository { return { getDisplayMessages: () => messages.map((msg) => ({ @@ -96,11 +107,11 @@ function createMockMessageRepo( isVisible: true, contextEnvelope: msg.contextEnvelope, })), - }; + } as unknown as MessageRepository; } describe("ContextManager L2 promotion filtering", () => { - let contextManager: any; + let contextManager: ContextManager; beforeEach(() => { contextManager = ContextManager.getInstance(); @@ -130,7 +141,10 @@ describe("ContextManager L2 promotion filtering", () => { { id: "msg-2", sender: "user" }, // current message (no envelope yet) ]); - const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo); + const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns( + "msg-2", + mockRepo + ); // note_context should be promoted to L2 expect(l2Context).toContain("note_context"); @@ -158,7 +172,10 @@ describe("ContextManager L2 promotion filtering", () => { { id: "msg-2", sender: "user" }, ]); - const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo); + const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns( + "msg-2", + mockRepo + ); expect(l2Context).not.toContain("web_selected_text"); expect(l2Context).not.toContain("Web selection content"); @@ -188,7 +205,10 @@ describe("ContextManager L2 promotion filtering", () => { { id: "msg-2", sender: "user" }, ]); - const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo); + const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns( + "msg-2", + mockRepo + ); expect(l2Context).toContain("Note content here"); expect(l2Context).toContain("URL content here"); @@ -211,7 +231,10 @@ describe("ContextManager L2 promotion filtering", () => { { id: "msg-2", sender: "user" }, ]); - const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo); + const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns( + "msg-2", + mockRepo + ); // prior_context is not in the registry, so it should pass through expect(l2Context).toContain("prior_context"); @@ -249,7 +272,10 @@ describe("ContextManager L2 promotion filtering", () => { { id: "msg-2", sender: "user" }, ]); - const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo); + const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns( + "msg-2", + mockRepo + ); expect(l2Context).toContain("Should be in L2"); expect(l2Context).toContain("Also in L2"); @@ -280,7 +306,10 @@ describe("ContextManager L2 promotion filtering", () => { { id: "msg-2", sender: "user" }, ]); - const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo); + const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns( + "msg-2", + mockRepo + ); expect(l2Context).toContain("Note in compacted segment"); expect(l2Context).toContain("URL in compacted segment"); @@ -311,7 +340,10 @@ describe("ContextManager L2 promotion filtering", () => { { id: "msg-2", sender: "user" }, ]); - const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo); + const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns( + "msg-2", + mockRepo + ); expect(l2Context).toContain("Recoverable note"); expect(l2Context).not.toContain(""); @@ -341,7 +373,10 @@ describe("ContextManager L2 promotion filtering", () => { { id: "msg-2", sender: "user" }, ]); - const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo); + const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns( + "msg-2", + mockRepo + ); expect(l2Context).toContain("Some compacted note"); // prior_context_note should appear exactly once (appended by the caller), @@ -371,7 +406,10 @@ describe("ContextManager L2 promotion filtering", () => { { id: "msg-2", sender: "user" }, ]); - const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo); + const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns( + "msg-2", + mockRepo + ); // No prior_context blocks exist, so no re-fetch instruction should be appended expect(l2Context).toContain("Regular note content"); @@ -400,7 +438,10 @@ describe("ContextManager L2 promotion filtering", () => { { id: "msg-2", sender: "user" }, ]); - const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo); + const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns( + "msg-2", + mockRepo + ); expect(l2Context).toContain("Known block"); // Unknown tag must NOT be dropped @@ -428,7 +469,10 @@ describe("ContextManager L2 promotion filtering", () => { { id: "msg-2", sender: "user" }, ]); - const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo); + const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns( + "msg-2", + mockRepo + ); expect(l2Context).toContain("XML Docs"); // No real prior_context blocks, so no refetch instruction @@ -455,7 +499,10 @@ describe("ContextManager L2 promotion filtering", () => { { id: "msg-2", sender: "user" }, ]); - const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo); + const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns( + "msg-2", + mockRepo + ); expect(l2Context).toContain("XML Guide"); // The literal inside note content must survive @@ -484,7 +531,10 @@ describe("ContextManager L2 promotion filtering", () => { { id: "msg-2", sender: "user" }, ]); - const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo); + const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns( + "msg-2", + mockRepo + ); // Both notes must be present (compacted or verbatim) expect(l2Context).toContain("Note A"); @@ -510,7 +560,10 @@ describe("ContextManager L2 promotion filtering", () => { { id: "msg-2", sender: "user" }, ]); - const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo); + const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns( + "msg-2", + mockRepo + ); expect(l2Context).toContain("Compacted note summary"); // Real prior_context block exists, so re-fetch instruction should be present diff --git a/src/core/MessageLifecycle.test.ts b/src/core/MessageLifecycle.test.ts index 6c9e207e..1b519e42 100644 --- a/src/core/MessageLifecycle.test.ts +++ b/src/core/MessageLifecycle.test.ts @@ -139,13 +139,13 @@ The team appears to be taking a pragmatic approach with a focused MVP scope and context: { notes: expect.arrayContaining([ expect.objectContaining({ basename: "meeting-notes-2024-01-15" }), - ]), + ]) as unknown, }, }); // AI response expect(finalDisplayMessages[1]).toMatchObject({ - message: expect.stringContaining("Based on the meeting notes"), + message: expect.stringContaining("Based on the meeting notes") as unknown, sender: AI_SENDER, }); diff --git a/src/core/MessageRepository.test.ts b/src/core/MessageRepository.test.ts index 688b28d6..dc31d657 100644 --- a/src/core/MessageRepository.test.ts +++ b/src/core/MessageRepository.test.ts @@ -1,6 +1,6 @@ import { MessageRepository } from "./MessageRepository"; import { ChatMessage, MessageContext, StoredMessage } from "@/types/message"; -import { formatDateTime } from "@/utils"; +import { formatDateTime, FormattedDateTime } from "@/utils"; import { mockTFile } from "@/__tests__/mockObsidian"; // Mock dependencies @@ -14,11 +14,12 @@ jest.mock("@/logger", () => ({ describe("MessageRepository", () => { let messageRepo: MessageRepository; - let mockFormattedDateTime: any; + let mockFormattedDateTime: FormattedDateTime; beforeEach(() => { messageRepo = new MessageRepository(); mockFormattedDateTime = { + fileName: "20231201_103000", display: "2023-12-01 10:30:00", epoch: 1701423000000, }; diff --git a/src/editor/replaceGuard.test.ts b/src/editor/replaceGuard.test.ts index 39ecf8a4..3a147b39 100644 --- a/src/editor/replaceGuard.test.ts +++ b/src/editor/replaceGuard.test.ts @@ -376,7 +376,9 @@ describe("createMapPosReplaceGuard", () => { }); describe("createHighlightReplaceGuard", () => { - const { SelectionHighlight } = jest.requireMock("./selectionHighlight"); + const { SelectionHighlight } = jest.requireMock<{ + SelectionHighlight: { getRange: jest.Mock }; + }>("./selectionHighlight"); const createMockEditorView = (docContent: string): EditorView => { return { diff --git a/src/encryptionService.test.ts b/src/encryptionService.test.ts index 1c5c6fd3..17c336f6 100644 --- a/src/encryptionService.test.ts +++ b/src/encryptionService.test.ts @@ -16,7 +16,7 @@ const mockElectron = { jest.mock("electron", () => mockElectron); window.TextEncoder = TextEncoder; -window.TextDecoder = TextDecoder as any; +window.TextDecoder = TextDecoder as unknown as typeof window.TextDecoder; // Now we can import our modules import { encryptAllKeys, getDecryptedKey, getEncryptedKey } from "@/encryptionService"; diff --git a/src/integration_tests/composerPrompt.test.ts b/src/integration_tests/composerPrompt.test.ts index 4cf6ca69..1da4ee12 100644 --- a/src/integration_tests/composerPrompt.test.ts +++ b/src/integration_tests/composerPrompt.test.ts @@ -104,7 +104,10 @@ describe.skip("Composer Instructions - Integration Tests", () => { // For canvas files, validate JSON structure if (path.endsWith(".canvas")) { try { - const canvasJson = JSON.parse(contentStr); + const canvasJson = JSON.parse(contentStr) as { + nodes: unknown[]; + edges?: unknown[]; + }; expect(canvasJson).toHaveProperty("nodes"); expect(Array.isArray(canvasJson.nodes)).toBe(true); if (canvasJson.edges) { diff --git a/src/memory/UserMemoryManager.test.ts b/src/memory/UserMemoryManager.test.ts index 4acd52ea..9e12c953 100644 --- a/src/memory/UserMemoryManager.test.ts +++ b/src/memory/UserMemoryManager.test.ts @@ -21,15 +21,26 @@ import { getSettings } from "@/settings/model"; import { ensureFolderExists } from "@/utils"; import { BaseChatModel } from "@langchain/core/language_models/chat_models"; import { AIMessageChunk } from "@langchain/core/messages"; +import { mockTFile } from "@/__tests__/mockObsidian"; + +// Typed accessor for private UserMemoryManager methods used in tests. +type UserMemoryManagerInternal = { + updateMemory: (messages: ChatMessage[], chatModel?: BaseChatModel) => Promise; + extractJsonFromResponse: (content: string) => string; + parseExistingConversations: (content: string) => string[]; +}; +const asInternal = (m: UserMemoryManager): UserMemoryManagerInternal => + m as unknown as UserMemoryManagerInternal; // Helper to create TFile mock instances const createMockTFile = (path: string): TFile => { - const file: TFile = Object.create(TFile.prototype); - file.path = path; - file.name = path.split("/").pop() || ""; - file.basename = file.name.replace(/\.[^/.]+$/, ""); - file.extension = path.split(".").pop() || ""; - return file; + const name = path.split("/").pop() || ""; + return mockTFile({ + path, + name, + basename: name.replace(/\.[^/.]+$/, ""), + extension: path.split(".").pop() || "", + }); }; describe("UserMemoryManager", () => { @@ -58,12 +69,12 @@ describe("UserMemoryManager", () => { modify: jest.fn(), create: jest.fn(), createFolder: jest.fn(), - } as any; + } as unknown as jest.Mocked; // Mock app mockApp = { vault: mockVault, - } as any; + } as unknown as jest.Mocked; // Reset ensureFolderExists mock (ensureFolderExists as jest.Mock).mockClear(); @@ -71,7 +82,7 @@ describe("UserMemoryManager", () => { // Mock chat model mockChatModel = { invoke: jest.fn(), - } as any; + } as unknown as jest.Mocked; userMemoryManager = new UserMemoryManager(mockApp); }); @@ -159,7 +170,7 @@ describe("UserMemoryManager", () => { mockChatModel.invoke.mockResolvedValueOnce(mockResponse); // Execute the updateMemory function directly to ensure proper awaiting - await (userMemoryManager as any).updateMemory(messages, mockChatModel); + await asInternal(userMemoryManager).updateMemory(messages, mockChatModel); // Verify the end result: file was modified with new conversation const modifyCall = mockVault.modify.mock.calls[0]; @@ -183,7 +194,7 @@ describe("UserMemoryManager", () => { expect(mockChatModel.invoke).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ - content: expect.stringContaining("generate both a title and a summary"), + content: expect.stringContaining("generate both a title and a summary") as unknown, }), ]) ); @@ -201,7 +212,7 @@ describe("UserMemoryManager", () => { const mockResponse = new AIMessageChunk({ content: "Invalid JSON response" }); mockChatModel.invoke.mockResolvedValueOnce(mockResponse); - await (userMemoryManager as any).updateMemory(messages, mockChatModel); + await asInternal(userMemoryManager).updateMemory(messages, mockChatModel); // Should still create a conversation entry with fallback values const modifyCall = mockVault.modify.mock.calls[0]; @@ -229,7 +240,7 @@ describe("UserMemoryManager", () => { That's the JSON data.`; - const result = (userMemoryManager as any).extractJsonFromResponse(content); + const result = asInternal(userMemoryManager).extractJsonFromResponse(content); expect(result).toBe('{\n "title": "Test Title",\n "summary": "Test Summary"\n}'); }); @@ -241,7 +252,7 @@ That's the JSON data.`; } \`\`\``; - const result = (userMemoryManager as any).extractJsonFromResponse(content); + const result = asInternal(userMemoryManager).extractJsonFromResponse(content); expect(result).toBe( '{\n "title": "Unmarked Block",\n "summary": "No language specified"\n}' ); @@ -250,14 +261,14 @@ That's the JSON data.`; it("should extract JSON object when no code blocks present", () => { const content = `Some text before {"title": "Inline JSON", "summary": "Direct JSON"} and after`; - const result = (userMemoryManager as any).extractJsonFromResponse(content); + const result = asInternal(userMemoryManager).extractJsonFromResponse(content); expect(result).toBe('{"title": "Inline JSON", "summary": "Direct JSON"}'); }); it("should return original content when no JSON patterns found", () => { const content = "No JSON here, just plain text"; - const result = (userMemoryManager as any).extractJsonFromResponse(content); + const result = asInternal(userMemoryManager).extractJsonFromResponse(content); expect(result).toBe(content); }); @@ -269,7 +280,7 @@ That's the JSON data.`; } \`\`\``; - const result = (userMemoryManager as any).extractJsonFromResponse(content); + const result = asInternal(userMemoryManager).extractJsonFromResponse(content); expect(result).toContain('"title": "Multi-line Test"'); expect(result).toContain("special characters: äöü"); }); @@ -277,7 +288,7 @@ That's the JSON data.`; describe("parseExistingConversations", () => { it("should return empty array for empty string", () => { - const result = (userMemoryManager as any).parseExistingConversations(""); + const result = asInternal(userMemoryManager).parseExistingConversations(""); expect(result).toEqual([]); }); @@ -287,7 +298,7 @@ It has multiple lines but no conversations. # This is H1, not H2 ### This is H3, not H2`; - const result = (userMemoryManager as any).parseExistingConversations(content); + const result = asInternal(userMemoryManager).parseExistingConversations(content); expect(result).toEqual([]); }); @@ -296,7 +307,7 @@ It has multiple lines but no conversations. **Time:** 2024-01-01 10:00 **Summary:** User asked about creating daily note templates with automatic date formatting.`; - const result = (userMemoryManager as any).parseExistingConversations(content); + const result = asInternal(userMemoryManager).parseExistingConversations(content); expect(result).toEqual([ `## Daily Note Template Setup **Time:** 2024-01-01 10:00 @@ -317,7 +328,7 @@ It has multiple lines but no conversations. **Time:** 2024-01-01 11:00 **Summary:** User learned about backlinks.`; - const result = (userMemoryManager as any).parseExistingConversations(content); + const result = asInternal(userMemoryManager).parseExistingConversations(content); expect(result).toEqual([ `## First Conversation **Time:** 2024-01-01 09:00 @@ -343,7 +354,7 @@ It might contain important information, but it's before the first conversation. **Time:** 2024-01-01 10:00 **Summary:** This conversation should also be included.`; - const result = (userMemoryManager as any).parseExistingConversations(content); + const result = asInternal(userMemoryManager).parseExistingConversations(content); expect(result).toEqual([ `## First Conversation **Time:** 2024-01-01 09:00 @@ -363,7 +374,7 @@ It might contain important information, but it's before the first conversation. **Time:** 2024-01-01 10:00 **Summary:** User inquired about linking notes. `; - const result = (userMemoryManager as any).parseExistingConversations(content); + const result = asInternal(userMemoryManager).parseExistingConversations(content); expect(result).toEqual([ `## First Conversation **Time:** 2024-01-01 09:00 @@ -388,7 +399,7 @@ The conversation covered advanced features and included code examples. **Time:** 2024-01-01 10:00 **Summary:** Short summary.`; - const result = (userMemoryManager as any).parseExistingConversations(content); + const result = asInternal(userMemoryManager).parseExistingConversations(content); expect(result).toEqual([ `## Complex Conversation **Time:** 2024-01-01 09:00 @@ -409,7 +420,7 @@ The conversation covered advanced features and included code examples.`, **Time:** 2024-01-01 09:00 **Summary:** This is the only conversation and it's at the end.`; - const result = (userMemoryManager as any).parseExistingConversations(content); + const result = asInternal(userMemoryManager).parseExistingConversations(content); expect(result).toEqual([ `## Only Conversation **Time:** 2024-01-01 09:00 diff --git a/src/miyo/miyoUtils.test.ts b/src/miyo/miyoUtils.test.ts index d19accdf..7ebc6a58 100644 --- a/src/miyo/miyoUtils.test.ts +++ b/src/miyo/miyoUtils.test.ts @@ -2,8 +2,8 @@ jest.mock("@/plusUtils", () => ({ isSelfHostAccessValid: jest.fn(), })); -import { getMiyoFilePath, getMiyoFolderName, getVaultRelativeMiyoPath } from "@/miyo/miyoUtils"; import type { App } from "obsidian"; +import { getMiyoFilePath, getMiyoFolderName, getVaultRelativeMiyoPath } from "@/miyo/miyoUtils"; describe("getMiyoFolderName", () => { it("uses the vault folder name even when an adapter exposes an absolute path", () => { diff --git a/src/projects/ProjectFileManager.test.ts b/src/projects/ProjectFileManager.test.ts index 9d8555d9..c2cc97ba 100644 --- a/src/projects/ProjectFileManager.test.ts +++ b/src/projects/ProjectFileManager.test.ts @@ -1,6 +1,7 @@ -import { App, TFile, Vault } from "obsidian"; +import { App, Vault } from "obsidian"; import { ProjectConfig } from "@/aiParams"; import { ProjectFileManager } from "@/projects/ProjectFileManager"; +import { mockTFile } from "@/__tests__/mockObsidian"; // --------------------------------------------------------------------------- // Module mocks @@ -87,11 +88,7 @@ function makeConfig( /** Build a minimal Vault mock. */ function makeMockVault(): jest.Mocked { return { - create: jest.fn(async (path: string) => { - const file: TFile = Object.create(TFile.prototype); - Object.assign(file, { path }); - return file; - }), + create: jest.fn(async (path: string) => mockTFile({ path })), // Reason: null = file does not exist yet, avoids collision error in createProject getAbstractFileByPath: jest.fn(() => null), adapter: { exists: jest.fn(async () => false) }, diff --git a/src/projects/projectUtils.test.ts b/src/projects/projectUtils.test.ts index 7379bc48..2b2ff9c4 100644 --- a/src/projects/projectUtils.test.ts +++ b/src/projects/projectUtils.test.ts @@ -40,11 +40,7 @@ function setupAppMock(rawContent: string, frontmatter: Record | read: jest.fn().mockResolvedValue(rawContent), // Reason: parseProjectConfigFile uses `cachedFile instanceof TFile` to detect synthetic TFiles. // Return an object with TFile prototype so tests exercise the vault.read() path by default. - getAbstractFileByPath: jest.fn((path: string): TFile => { - const file: TFile = Object.create(TFile.prototype); - Object.assign(file, { path }); - return file; - }), + getAbstractFileByPath: jest.fn((path: string): TFile => mockTFile({ path })), adapter: { read: jest.fn().mockResolvedValue(rawContent) }, }, metadataCache: { diff --git a/src/search/searchUtils.test.ts b/src/search/searchUtils.test.ts index 7cfbad67..a3895296 100644 --- a/src/search/searchUtils.test.ts +++ b/src/search/searchUtils.test.ts @@ -16,8 +16,8 @@ jest.mock("obsidian", () => ({ path: string; }, Modal: class Modal { - app: any; - constructor(app: any) { + app: unknown; + constructor(app: unknown) { this.app = app; } open() {} @@ -48,7 +48,7 @@ const mockApp = { vault: { getAbstractFileByPath: mockGetAbstractFileByPath, }, -} as any; +} as unknown as typeof window.app; // Mock getTagsFromNote utility function jest.mock( diff --git a/src/search/v3/FilterRetriever.test.ts b/src/search/v3/FilterRetriever.test.ts index f0deb97a..db0d82c9 100644 --- a/src/search/v3/FilterRetriever.test.ts +++ b/src/search/v3/FilterRetriever.test.ts @@ -1,5 +1,19 @@ import { App, TFile } from "obsidian"; import { FilterRetriever } from "./FilterRetriever"; +import { mockTFile } from "@/__tests__/mockObsidian"; + +// Helper: create a TFile-typed mock from a path. Uses mockTFile so `instanceof TFile` +// works for downstream code paths. +const createTFile = (path: string, stat: { mtime: number; ctime: number }): TFile => { + const basename = path.split("/").pop()?.replace(/\.md$/, "") ?? ""; + return mockTFile({ path, basename, stat: { ...stat, size: 0 } }); +}; + +// Typed accessors for mocked module exports used in tests. +const getObsidianMock = (): { getAllTags: jest.Mock } => jest.requireMock("obsidian"); +const getUtilsMock = (): { extractNoteFiles: jest.Mock } => jest.requireMock("@/utils"); +const getSearchUtilsMock = (): { shouldIndexFile: jest.Mock } => + jest.requireMock("@/search/searchUtils"); // Mock modules jest.mock("obsidian"); @@ -17,7 +31,7 @@ describe("FilterRetriever", () => { let mockApp: any; beforeEach(() => { - const obsidianMock = jest.requireMock("obsidian"); + const obsidianMock = getObsidianMock(); obsidianMock.getAllTags = jest.fn().mockReturnValue([]); mockApp = { @@ -34,12 +48,8 @@ describe("FilterRetriever", () => { describe("title matches", () => { it("should return title-matched notes with includeInContext true", async () => { - const { extractNoteFiles } = jest.requireMock("@/utils"); - const mockFile = new (TFile as any)("mentioned.md"); - Object.setPrototypeOf(mockFile, TFile.prototype); - mockFile.path = "mentioned.md"; - mockFile.basename = "mentioned"; - mockFile.stat = { mtime: 1000, ctime: 500 }; + const extractNoteFiles = jest.requireMock("@/utils").extractNoteFiles as jest.Mock; + const mockFile = createTFile("mentioned.md", { mtime: 1000, ctime: 500 }); extractNoteFiles.mockReturnValueOnce([mockFile]); mockApp.vault.cachedRead.mockResolvedValue("Note content here"); @@ -61,7 +71,7 @@ describe("FilterRetriever", () => { }); it("should return empty when no [[note]] mentions in query", async () => { - const { extractNoteFiles } = jest.requireMock("@/utils"); + const extractNoteFiles = jest.requireMock("@/utils").extractNoteFiles as jest.Mock; extractNoteFiles.mockReturnValueOnce([]); const retriever = new FilterRetriever(mockApp as App, { @@ -76,22 +86,18 @@ describe("FilterRetriever", () => { describe("tag matches", () => { it("should return tag-matched notes with includeInContext true", async () => { - const obsidianMock = jest.requireMock("obsidian"); + const obsidianMock = getObsidianMock(); - const taggedFile = new (TFile as any)("projects/alpha.md"); - Object.setPrototypeOf(taggedFile, TFile.prototype); - taggedFile.path = "projects/alpha.md"; - taggedFile.basename = "alpha"; - taggedFile.stat = { mtime: 2000, ctime: 1000 }; + const taggedFile = createTFile("projects/alpha.md", { mtime: 2000, ctime: 1000 }); mockApp.vault.getMarkdownFiles.mockReturnValue([taggedFile]); - mockApp.metadataCache.getFileCache.mockImplementation((file: any) => { + mockApp.metadataCache.getFileCache.mockImplementation((file: TFile) => { if (file.path === "projects/alpha.md") return { tags: [{ tag: "#project" }] }; return null; }); obsidianMock.getAllTags.mockImplementation((cache: { tags?: { tag: string }[] }) => { if (!cache?.tags) return []; - return cache.tags.map((t: { tag: string }) => t.tag); + return cache.tags.map((t) => t.tag); }); mockApp.vault.cachedRead.mockResolvedValue("Alpha content"); @@ -110,22 +116,18 @@ describe("FilterRetriever", () => { }); it("should support hierarchical prefix matching (#project matches #project/beta)", async () => { - const obsidianMock = jest.requireMock("obsidian"); + const obsidianMock = getObsidianMock(); - const betaFile = new (TFile as any)("projects/beta.md"); - Object.setPrototypeOf(betaFile, TFile.prototype); - betaFile.path = "projects/beta.md"; - betaFile.basename = "beta"; - betaFile.stat = { mtime: 3000, ctime: 1000 }; + const betaFile = createTFile("projects/beta.md", { mtime: 3000, ctime: 1000 }); mockApp.vault.getMarkdownFiles.mockReturnValue([betaFile]); - mockApp.metadataCache.getFileCache.mockImplementation((file: any) => { + mockApp.metadataCache.getFileCache.mockImplementation((file: TFile) => { if (file.path === "projects/beta.md") return { tags: [{ tag: "#project/beta" }] }; return null; }); obsidianMock.getAllTags.mockImplementation((cache: { tags?: { tag: string }[] }) => { if (!cache?.tags) return []; - return cache.tags.map((t: { tag: string }) => t.tag); + return cache.tags.map((t) => t.tag); }); mockApp.vault.cachedRead.mockResolvedValue("Beta content"); @@ -151,23 +153,18 @@ describe("FilterRetriever", () => { }); it("should cap tag matches at maxK when returnAll is false", async () => { - const obsidianMock = jest.requireMock("obsidian"); + const obsidianMock = getObsidianMock(); // Create 10 files all matching the tag, but set maxK to 3 - const files: TFile[] = Array.from({ length: 10 }, (_, i): TFile => { - const f: TFile = new (TFile as any)(`note${i}.md`); - Object.setPrototypeOf(f, TFile.prototype); - f.path = `note${i}.md`; - f.basename = `note${i}`; - f.stat = { mtime: 1000 + i, ctime: 500 } as TFile["stat"]; - return f; - }); + const files = Array.from({ length: 10 }, (_, i) => + createTFile(`note${i}.md`, { mtime: 1000 + i, ctime: 500 }) + ); mockApp.vault.getMarkdownFiles.mockReturnValue(files); mockApp.metadataCache.getFileCache.mockReturnValue({ tags: [{ tag: "#daily" }] }); obsidianMock.getAllTags.mockImplementation((cache: { tags?: { tag: string }[] }) => { if (!cache?.tags) return []; - return cache.tags.map((t: { tag: string }) => t.tag); + return cache.tags.map((t) => t.tag); }); mockApp.vault.cachedRead.mockResolvedValue("Content"); @@ -181,23 +178,18 @@ describe("FilterRetriever", () => { }); it("should use RETURN_ALL_LIMIT when returnAll is true (tag queries need expanded limits)", async () => { - const obsidianMock = jest.requireMock("obsidian"); + const obsidianMock = getObsidianMock(); // Create 40 files all matching the tag, set maxK to 3 but returnAll to true - const files: TFile[] = Array.from({ length: 40 }, (_, i): TFile => { - const f: TFile = new (TFile as any)(`note${i}.md`); - Object.setPrototypeOf(f, TFile.prototype); - f.path = `note${i}.md`; - f.basename = `note${i}`; - f.stat = { mtime: 1000 + i, ctime: 500 } as TFile["stat"]; - return f; - }); + const files = Array.from({ length: 40 }, (_, i) => + createTFile(`note${i}.md`, { mtime: 1000 + i, ctime: 500 }) + ); mockApp.vault.getMarkdownFiles.mockReturnValue(files); mockApp.metadataCache.getFileCache.mockReturnValue({ tags: [{ tag: "#daily" }] }); obsidianMock.getAllTags.mockImplementation((cache: { tags?: { tag: string }[] }) => { if (!cache?.tags) return []; - return cache.tags.map((t: { tag: string }) => t.tag); + return cache.tags.map((t) => t.tag); }); mockApp.vault.cachedRead.mockResolvedValue("Content"); @@ -217,14 +209,10 @@ describe("FilterRetriever", () => { describe("deduplication", () => { it("should deduplicate title and tag matches by path (title wins)", async () => { - const obsidianMock = jest.requireMock("obsidian"); - const { extractNoteFiles } = jest.requireMock("@/utils"); + const obsidianMock = getObsidianMock(); + const { extractNoteFiles } = getUtilsMock(); - const sharedFile = new (TFile as any)("shared.md"); - Object.setPrototypeOf(sharedFile, TFile.prototype); - sharedFile.path = "shared.md"; - sharedFile.basename = "shared"; - sharedFile.stat = { mtime: 1000, ctime: 500 }; + const sharedFile = createTFile("shared.md", { mtime: 1000, ctime: 500 }); // File appears in both title matches and tag matches extractNoteFiles.mockReturnValueOnce([sharedFile]); @@ -232,7 +220,7 @@ describe("FilterRetriever", () => { mockApp.metadataCache.getFileCache.mockReturnValue({ tags: [{ tag: "#tag1" }] }); obsidianMock.getAllTags.mockImplementation((cache: { tags?: { tag: string }[] }) => { if (!cache?.tags) return []; - return cache.tags.map((t: { tag: string }) => t.tag); + return cache.tags.map((t) => t.tag); }); mockApp.vault.cachedRead.mockResolvedValue("Shared content"); @@ -251,16 +239,14 @@ describe("FilterRetriever", () => { describe("time range", () => { it("should return time-filtered documents when timeRange is set", async () => { - const { extractNoteFiles } = jest.requireMock("@/utils"); + const { extractNoteFiles } = getUtilsMock(); extractNoteFiles.mockReturnValue([]); const now = Date.now(); - const recentFile = { - path: "notes/recent.md", - basename: "recent", - stat: { mtime: now - 1000, ctime: now - 2000 }, - }; - Object.setPrototypeOf(recentFile, TFile.prototype); + const recentFile = createTFile("notes/recent.md", { + mtime: now - 1000, + ctime: now - 2000, + }); mockApp.vault.getMarkdownFiles.mockReturnValue([recentFile]); mockApp.vault.cachedRead.mockResolvedValue("Recent content"); @@ -284,26 +270,21 @@ describe("FilterRetriever", () => { }); it("should exclude files matching QA exclusion patterns in time-range searches", async () => { - const { shouldIndexFile } = jest.requireMock("@/search/searchUtils"); - const { extractNoteFiles } = jest.requireMock("@/utils"); + const { shouldIndexFile } = getSearchUtilsMock(); + const { extractNoteFiles } = getUtilsMock(); extractNoteFiles.mockReturnValue([]); const now = Date.now(); - const validFile = { - path: "notes/valid.md", - basename: "valid", - stat: { mtime: now - 1000, ctime: now - 2000 }, - }; - const excludedFile = { - path: "copilot/excluded.md", - basename: "excluded", - stat: { mtime: now - 1000, ctime: now - 2000 }, - }; - [validFile, excludedFile].forEach((f) => { - Object.setPrototypeOf(f, TFile.prototype); + const validFile = createTFile("notes/valid.md", { + mtime: now - 1000, + ctime: now - 2000, + }); + const excludedFile = createTFile("copilot/excluded.md", { + mtime: now - 1000, + ctime: now - 2000, }); - shouldIndexFile.mockImplementation((file: any) => !file.path.startsWith("copilot/")); + shouldIndexFile.mockImplementation((file: TFile) => !file.path.startsWith("copilot/")); mockApp.vault.getMarkdownFiles.mockReturnValue([validFile, excludedFile]); mockApp.vault.cachedRead.mockResolvedValue("Content"); mockApp.metadataCache.getFileCache.mockReturnValue({ tags: [] }); diff --git a/src/search/v3/MergedSemanticRetriever.test.ts b/src/search/v3/MergedSemanticRetriever.test.ts index 5e2cea32..13e7c6ae 100644 --- a/src/search/v3/MergedSemanticRetriever.test.ts +++ b/src/search/v3/MergedSemanticRetriever.test.ts @@ -5,8 +5,8 @@ import { TieredLexicalRetriever } from "./TieredLexicalRetriever"; import { HybridRetriever } from "@/search/hybridRetriever"; const optionsSpy: { - lexicalOptions?: Record; - semanticOptions?: Record; + lexicalOptions?: Record; + semanticOptions?: Record; } = {}; let lexicalResults: Document[] = []; @@ -14,18 +14,20 @@ let semanticResults: Document[] = []; jest.mock("@/search/v3/TieredLexicalRetriever", () => { return { - TieredLexicalRetriever: jest.fn().mockImplementation((_app: any, options: any) => { - optionsSpy.lexicalOptions = options; - return { - getRelevantDocuments: jest.fn(async () => lexicalResults), - }; - }), + TieredLexicalRetriever: jest + .fn() + .mockImplementation((_app: unknown, options: Record) => { + optionsSpy.lexicalOptions = options; + return { + getRelevantDocuments: jest.fn(async () => lexicalResults), + }; + }), }; }); jest.mock("@/search/hybridRetriever", () => { return { - HybridRetriever: jest.fn().mockImplementation((options: any) => { + HybridRetriever: jest.fn().mockImplementation((options: Record) => { optionsSpy.semanticOptions = options; return { getRelevantDocuments: jest.fn(async () => semanticResults), @@ -35,7 +37,7 @@ jest.mock("@/search/hybridRetriever", () => { }); describe("MergedSemanticRetriever", () => { - const mockApp = {} as any; + const mockApp = {} as unknown as App; beforeEach(() => { lexicalResults = []; @@ -68,7 +70,7 @@ describe("MergedSemanticRetriever", () => { lexicalResults = [lexicalInput]; semanticResults = [semanticInput]; - const retriever = new MergedSemanticRetriever(mockApp as App, { + const retriever = new MergedSemanticRetriever(mockApp, { maxK: 5, salientTerms: [], }); @@ -102,7 +104,7 @@ describe("MergedSemanticRetriever", () => { lexicalResults = [lexicalInput]; semanticResults = [semanticInput]; - const retriever = new MergedSemanticRetriever(mockApp as App, { + const retriever = new MergedSemanticRetriever(mockApp, { maxK: 5, salientTerms: [], }); @@ -142,7 +144,7 @@ describe("MergedSemanticRetriever", () => { ]; semanticResults = []; - const retriever = new MergedSemanticRetriever(mockApp as App, { + const retriever = new MergedSemanticRetriever(mockApp, { maxK: 5, salientTerms: [], }); @@ -175,7 +177,7 @@ describe("MergedSemanticRetriever", () => { }), ]; - const retriever = new MergedSemanticRetriever(mockApp as App, { + const retriever = new MergedSemanticRetriever(mockApp, { maxK: 5, salientTerms: [], }); @@ -209,7 +211,7 @@ describe("MergedSemanticRetriever", () => { }), ]; - const retriever = new MergedSemanticRetriever(mockApp as App, { + const retriever = new MergedSemanticRetriever(mockApp, { maxK: 1, salientTerms: [], returnAll: true, diff --git a/src/search/v3/TieredLexicalRetriever.test.ts b/src/search/v3/TieredLexicalRetriever.test.ts index ffe8cd8f..db34ce02 100644 --- a/src/search/v3/TieredLexicalRetriever.test.ts +++ b/src/search/v3/TieredLexicalRetriever.test.ts @@ -1,5 +1,22 @@ -import { App, TFile } from "obsidian"; +import { App } from "obsidian"; import { TieredLexicalRetriever } from "./TieredLexicalRetriever"; +import { mockTFile } from "@/__tests__/mockObsidian"; + +type MockApp = { + vault: { + getAbstractFileByPath: jest.Mock; + cachedRead: jest.Mock; + getMarkdownFiles: jest.Mock; + }; + metadataCache: { + getFileCache: jest.Mock; + }; +}; + +type MockChunkManager = { + getChunkTextSync: jest.Mock; + getChunkText: jest.Mock; +}; const retrieveMock = jest.fn(); @@ -32,12 +49,14 @@ jest.mock("./chunks", () => { }); describe("TieredLexicalRetriever", () => { - let mockApp: any; - let mockChunkManager: any; + let mockApp: MockApp; + let mockChunkManager: MockChunkManager; beforeEach(() => { // Get reference to the mocked chunk manager - const chunksModule = jest.requireMock("./chunks"); + const chunksModule = jest.requireMock<{ getSharedChunkManager: () => MockChunkManager }>( + "./chunks" + ); mockChunkManager = chunksModule.getSharedChunkManager(); retrieveMock.mockReset(); @@ -88,9 +107,7 @@ describe("TieredLexicalRetriever", () => { // Mock file system mockApp.vault.getAbstractFileByPath.mockImplementation((path: string) => { if (path === "note1.md" || path === "note2.md") { - const file: TFile = new (TFile as any)(path); - Object.setPrototypeOf(file, TFile.prototype); - file.stat = { mtime: 1000, ctime: 1000 } as TFile["stat"]; + const file = mockTFile({ path, stat: { mtime: 1000, ctime: 1000, size: 0 } }); return file; } return null; @@ -116,7 +133,7 @@ describe("TieredLexicalRetriever", () => { }, }); - const chunkRetriever = new TieredLexicalRetriever(mockApp as App, { + const chunkRetriever = new TieredLexicalRetriever(mockApp as unknown as App, { minSimilarityScore: 0.1, maxK: 30, salientTerms: [], @@ -143,7 +160,7 @@ describe("TieredLexicalRetriever", () => { expandedQueries: [], }, }); - const emptyRetriever = new TieredLexicalRetriever(mockApp as App, { + const emptyRetriever = new TieredLexicalRetriever(mockApp as unknown as App, { minSimilarityScore: 0.1, maxK: 30, salientTerms: [], @@ -166,7 +183,7 @@ describe("TieredLexicalRetriever", () => { }, }); - const sortRetriever = new TieredLexicalRetriever(mockApp as App, { + const sortRetriever = new TieredLexicalRetriever(mockApp as unknown as App, { minSimilarityScore: 0.1, maxK: 30, salientTerms: [], @@ -207,15 +224,13 @@ describe("TieredLexicalRetriever", () => { mockApp.vault.getAbstractFileByPath.mockImplementation((path: string) => { if (path === "test.md") { - const file: TFile = new (TFile as any)(path); - Object.setPrototypeOf(file, TFile.prototype); - file.stat = { mtime: 1000, ctime: 1000 } as TFile["stat"]; + const file = mockTFile({ path, stat: { mtime: 1000, ctime: 1000, size: 0 } }); return file; } return null; }); - const chunkRetriever = new TieredLexicalRetriever(mockApp as App, { + const chunkRetriever = new TieredLexicalRetriever(mockApp as unknown as App, { minSimilarityScore: 0.1, maxK: 30, salientTerms: [], @@ -243,7 +258,7 @@ describe("TieredLexicalRetriever", () => { }, }); - const emptyChunkRetriever = new TieredLexicalRetriever(mockApp as App, { + const emptyChunkRetriever = new TieredLexicalRetriever(mockApp as unknown as App, { minSimilarityScore: 0.1, maxK: 30, salientTerms: [], @@ -258,9 +273,7 @@ describe("TieredLexicalRetriever", () => { it("should handle multiple chunks from same note correctly", async () => { mockApp.vault.getAbstractFileByPath.mockImplementation((path: string) => { if (path === "large.md" || path === "other.md") { - const file: TFile = new (TFile as any)(path); - Object.setPrototypeOf(file, TFile.prototype); - file.stat = { mtime: 1000, ctime: 1000 } as TFile["stat"]; + const file = mockTFile({ path, stat: { mtime: 1000, ctime: 1000, size: 0 } }); return file; } return null; @@ -291,7 +304,7 @@ describe("TieredLexicalRetriever", () => { Promise.resolve(getMultiChunkContent(id)) ); - const multiChunkRetriever = new TieredLexicalRetriever(mockApp as App, { + const multiChunkRetriever = new TieredLexicalRetriever(mockApp as unknown as App, { minSimilarityScore: 0.1, maxK: 30, salientTerms: [], diff --git a/src/search/v3/chunks.test.ts b/src/search/v3/chunks.test.ts index cb2b7b78..39d7dd82 100644 --- a/src/search/v3/chunks.test.ts +++ b/src/search/v3/chunks.test.ts @@ -32,6 +32,16 @@ jest.mock("@/logger", () => ({ import { App, TFile } from "obsidian"; import { ChunkManager, ChunkOptions } from "./chunks"; +import { mockTFile } from "@/__tests__/mockObsidian"; + +type ChunkManagerInternal = { + cache: Map; + maxCacheBytes: number; + generateChunkId: (path: string, index: number) => string; +}; + +const asChunkInternal = (m: ChunkManager): ChunkManagerInternal => + m as unknown as ChunkManagerInternal; describe("ChunkManager", () => { let chunkManager: ChunkManager; @@ -49,9 +59,11 @@ describe("ChunkManager", () => { if (mockFileCache.has(path)) { return mockFileCache.get(path); } - const file: TFile = new (TFile as any)(path); - // Ensure the file passes instanceof TFile checks - Object.setPrototypeOf(file, TFile.prototype); + const file = mockTFile({ + path, + basename: path.replace(".md", ""), + stat: { mtime: Date.now(), ctime: 0, size: 0 }, + }); mockFileCache.set(path, file); return file; }), @@ -487,7 +499,7 @@ describe("ChunkManager", () => { it("should evict cache when memory limit is exceeded", async () => { // Mock a manager with very small cache limit const smallCacheManager = new ChunkManager(mockApp as App); - (smallCacheManager as any).maxCacheBytes = 1000; // 1KB limit + asChunkInternal(smallCacheManager).maxCacheBytes = 1000; // 1KB limit // Add multiple large documents await smallCacheManager.getChunks(["long.md"], { @@ -503,7 +515,7 @@ describe("ChunkManager", () => { }); // Cache should not grow indefinitely - check cache size doesn't exceed notes added - const cacheSize = (smallCacheManager as any).cache.size; + const cacheSize = asChunkInternal(smallCacheManager).cache.size; expect(cacheSize).toBeLessThanOrEqual(3); // Should not cache more than a few notes due to memory limits }); @@ -713,7 +725,7 @@ describe("ChunkManager", () => { for (const testCase of testCases) { const manager = new ChunkManager(mockApp as App); - const generatedId = (manager as any).generateChunkId("test.md", testCase.index); + const generatedId = asChunkInternal(manager).generateChunkId("test.md", testCase.index); expect(generatedId).toBe(`test.md#${testCase.expected}`); } }); diff --git a/src/search/v3/engines/FullTextEngine.test.ts b/src/search/v3/engines/FullTextEngine.test.ts index dcfb2823..f645f356 100644 --- a/src/search/v3/engines/FullTextEngine.test.ts +++ b/src/search/v3/engines/FullTextEngine.test.ts @@ -222,6 +222,16 @@ jest.mock("../chunks", () => { import { App, TFile } from "obsidian"; import { FullTextEngine } from "./FullTextEngine"; +// Typed accessor for private FullTextEngine fields/methods used in tests. +type FullTextEngineInternal = { + tokenizeMixed: (str: string) => string[]; + getFieldWeight: (field: string) => number; + index: ({ destroy?: () => void; clear?: () => void } & Record) | null; + indexedChunks: Set; +}; +const asInternal = (e: FullTextEngine): FullTextEngineInternal => + e as unknown as FullTextEngineInternal; + describe("FullTextEngine", () => { let engine: FullTextEngine; let mockApp: any; @@ -304,7 +314,7 @@ describe("FullTextEngine", () => { vault: { getAbstractFileByPath: jest.fn((path: string) => { if (!path || path === "missing.md") return null; - const file: TFile = new (TFile as any)(path); + const file: TFile = new (TFile as unknown as new (path: string) => TFile)(path); // Make it pass instanceof TFile check Object.setPrototypeOf(file, TFile.prototype); return file; @@ -345,7 +355,7 @@ describe("FullTextEngine", () => { describe("tokenizeMixed", () => { it("should tokenize ASCII words", () => { - const tokens = (engine as any).tokenizeMixed("Hello World TypeScript"); + const tokens = asInternal(engine).tokenizeMixed("Hello World TypeScript"); expect(tokens).toContain("hello"); expect(tokens).toContain("world"); @@ -353,14 +363,14 @@ describe("FullTextEngine", () => { }); it("should tokenize alphanumeric and underscores", () => { - const tokens = (engine as any).tokenizeMixed("test_123 var_name"); + const tokens = asInternal(engine).tokenizeMixed("test_123 var_name"); expect(tokens).toContain("test_123"); expect(tokens).toContain("var_name"); }); it("should generate CJK bigrams", () => { - const tokens = (engine as any).tokenizeMixed("中文编程"); + const tokens = asInternal(engine).tokenizeMixed("中文编程"); expect(tokens).toContain("中文"); expect(tokens).toContain("文编"); @@ -368,7 +378,7 @@ describe("FullTextEngine", () => { }); it("should handle mixed content", () => { - const tokens = (engine as any).tokenizeMixed("TypeScript 和 JavaScript 编程"); + const tokens = asInternal(engine).tokenizeMixed("TypeScript 和 JavaScript 编程"); expect(tokens).toContain("typescript"); expect(tokens).toContain("javascript"); @@ -376,14 +386,14 @@ describe("FullTextEngine", () => { }); it("should handle single CJK characters", () => { - const tokens = (engine as any).tokenizeMixed("中 文"); + const tokens = asInternal(engine).tokenizeMixed("中 文"); expect(tokens).toContain("中"); expect(tokens).toContain("文"); }); it("should tokenize hash tags and their hierarchy variants", () => { - const tokens = (engine as any).tokenizeMixed("Working on #Project/Alpha and #deepWork"); + const tokens = asInternal(engine).tokenizeMixed("Working on #Project/Alpha and #deepWork"); expect(tokens).toEqual( expect.arrayContaining([ @@ -399,7 +409,7 @@ describe("FullTextEngine", () => { }); it("should not split hyphenated tags into partial word tokens", () => { - const tokens = (engine as any).tokenizeMixed("#copilot-conversation updates"); + const tokens = asInternal(engine).tokenizeMixed("#copilot-conversation updates"); expect(tokens).toContain("#copilot-conversation"); expect(tokens).toContain("copilot-conversation"); @@ -408,7 +418,7 @@ describe("FullTextEngine", () => { }); it("should return empty array for empty input", () => { - const tokens = (engine as any).tokenizeMixed(""); + const tokens = asInternal(engine).tokenizeMixed(""); expect(tokens).toEqual([]); }); }); @@ -429,7 +439,7 @@ describe("FullTextEngine", () => { const candidates = Array.from({ length: 100 }, (_, i) => `note${i}.md`); // Mock vault to return files for all candidates - mockApp.vault.getAbstractFileByPath = jest.fn((path) => ({ + mockApp.vault.getAbstractFileByPath = jest.fn((path: string) => ({ path, basename: path.replace(".md", ""), stat: { mtime: Date.now() }, @@ -448,7 +458,7 @@ describe("FullTextEngine", () => { it("should handle missing files gracefully", async () => { mockApp.vault.getAbstractFileByPath = jest.fn((path: string) => { if (path === "missing.md") return null; - const file: TFile = new (TFile as any)(path); + const file: TFile = new (TFile as unknown as new (path: string) => TFile)(path); Object.setPrototypeOf(file, TFile.prototype); return file; }); @@ -477,7 +487,7 @@ describe("FullTextEngine", () => { await engine.buildFromCandidates(["note1.md"]); // Mock the index to throw an error on destroy/clear - const mockIndex = (engine as any).index; + const mockIndex = asInternal(engine).index; if (mockIndex) { mockIndex.destroy = jest.fn(() => { throw new Error("Mock cleanup error"); @@ -491,15 +501,15 @@ describe("FullTextEngine", () => { expect(() => engine.clear()).not.toThrow(); // Should still reset state - expect((engine as any).index).toBeNull(); - expect((engine as any).indexedChunks.size).toBe(0); + expect(asInternal(engine).index).toBeNull(); + expect(asInternal(engine).indexedChunks.size).toBe(0); }); it("should skip unsafe vault paths", async () => { // Mock vault to return files for any safe path mockApp.vault.getAbstractFileByPath = jest.fn((path: string) => { if (path === "note1.md") { - const file: TFile = new (TFile as any)(path); + const file: TFile = new (TFile as unknown as new (path: string) => TFile)(path); Object.setPrototypeOf(file, TFile.prototype); return file; } @@ -755,7 +765,9 @@ describe("FullTextEngine", () => { describe("getFieldWeight", () => { it("should return correct weights for known fields", () => { - const getFieldWeight = (engine as any).getFieldWeight.bind(engine); + const getFieldWeight = asInternal(engine).getFieldWeight.bind(engine) as ( + f: string + ) => number; expect(getFieldWeight("title")).toBe(5); expect(getFieldWeight("heading")).toBe(2.5); @@ -768,7 +780,9 @@ describe("FullTextEngine", () => { }); it("should return default weight for unknown fields", () => { - const getFieldWeight = (engine as any).getFieldWeight.bind(engine); + const getFieldWeight = asInternal(engine).getFieldWeight.bind(engine) as ( + f: string + ) => number; expect(getFieldWeight("unknown")).toBe(1); }); }); @@ -927,12 +941,12 @@ describe("FullTextEngine", () => { }); it("should handle circular frontmatter safely", async () => { - const a: any = { name: "A" }; - const b: any = { name: "B" }; + const a: Record = { name: "A" }; + const b: Record = { name: "B" }; a.ref = b; b.ref = a; // circular - const propsCache: Record = { + const propsCache: Record = { "circular.md": { headings: [], frontmatter: a, @@ -951,13 +965,13 @@ describe("FullTextEngine", () => { describe("clear method", () => { it("should safely clear when index is null (not yet created)", () => { // Engine starts with null index - expect((engine as any).index).toBeNull(); + expect(asInternal(engine).index).toBeNull(); // Should not throw error when clearing uninitialized index expect(() => engine.clear()).not.toThrow(); // Should still reset collections - expect((engine as any).indexedChunks.size).toBe(0); + expect(asInternal(engine).indexedChunks.size).toBe(0); }); it("should properly clear after index has been created", async () => { @@ -965,15 +979,15 @@ describe("FullTextEngine", () => { await engine.buildFromCandidates(["note1.md", "note2.md"]); // Verify index was created and chunks indexed - expect((engine as any).index).not.toBeNull(); - expect((engine as any).indexedChunks.size).toBeGreaterThan(0); + expect(asInternal(engine).index).not.toBeNull(); + expect(asInternal(engine).indexedChunks.size).toBeGreaterThan(0); // Clear should not throw expect(() => engine.clear()).not.toThrow(); // Should reset state - expect((engine as any).index).toBeNull(); - expect((engine as any).indexedChunks.size).toBe(0); + expect(asInternal(engine).index).toBeNull(); + expect(asInternal(engine).indexedChunks.size).toBe(0); }); it("should allow multiple clear calls safely", async () => { @@ -986,20 +1000,20 @@ describe("FullTextEngine", () => { expect(() => engine.clear()).not.toThrow(); // State should remain clean - expect((engine as any).index).toBeNull(); - expect((engine as any).indexedChunks.size).toBe(0); + expect(asInternal(engine).index).toBeNull(); + expect(asInternal(engine).indexedChunks.size).toBe(0); }); it("should nullify MiniSearch index on clear", async () => { await engine.buildFromCandidates(["note1.md"]); // Verify index exists - expect((engine as any).index).not.toBeNull(); + expect(asInternal(engine).index).not.toBeNull(); engine.clear(); // MiniSearch cleanup is just nullifying the reference - expect((engine as any).index).toBeNull(); + expect(asInternal(engine).index).toBeNull(); }); it("should allow new index to be created after clear", async () => { @@ -1008,28 +1022,28 @@ describe("FullTextEngine", () => { // Should be able to create a new index after clearing await engine.buildFromCandidates(["note2.md"]); - expect((engine as any).index).not.toBeNull(); - expect((engine as any).indexedChunks.size).toBeGreaterThan(0); + expect(asInternal(engine).index).not.toBeNull(); + expect(asInternal(engine).indexedChunks.size).toBeGreaterThan(0); }); it("should handle index without destroy or clear methods", async () => { await engine.buildFromCandidates(["note1.md"]); // Mock index without cleanup methods - (engine as any).index = { + asInternal(engine).index = { someOtherMethod: jest.fn(), }; // Should not throw and still reset expect(() => engine.clear()).not.toThrow(); - expect((engine as any).index).toBeNull(); + expect(asInternal(engine).index).toBeNull(); }); it("should continue cleanup even if index cleanup throws", async () => { await engine.buildFromCandidates(["note1.md"]); // Mock index that throws on cleanup - (engine as any).index = { + asInternal(engine).index = { destroy: jest.fn(() => { throw new Error("Mock cleanup failure"); }), @@ -1039,8 +1053,8 @@ describe("FullTextEngine", () => { expect(() => engine.clear()).not.toThrow(); // Should still reset state - expect((engine as any).index).toBeNull(); - expect((engine as any).indexedChunks.size).toBe(0); + expect(asInternal(engine).index).toBeNull(); + expect(asInternal(engine).indexedChunks.size).toBe(0); }); it("should reset memory manager during clear", async () => { diff --git a/src/search/v3/scanners/GrepScanner.test.ts b/src/search/v3/scanners/GrepScanner.test.ts index 58c0c1cb..b6c53448 100644 --- a/src/search/v3/scanners/GrepScanner.test.ts +++ b/src/search/v3/scanners/GrepScanner.test.ts @@ -1,10 +1,15 @@ -import type { App } from "obsidian"; +import { App } from "obsidian"; import { GrepScanner } from "./GrepScanner"; +interface MockFile { + path: string; + content: string; +} + describe("GrepScanner", () => { let scanner: GrepScanner; - let mockApp: any; - let mockFiles: any[]; + let mockApp: App; + let mockFiles: MockFile[]; beforeEach(() => { // Mock file data @@ -20,14 +25,14 @@ describe("GrepScanner", () => { mockApp = { vault: { getMarkdownFiles: jest.fn(() => mockFiles.map((f) => ({ path: f.path }))), - cachedRead: jest.fn((file) => { + cachedRead: jest.fn((file: { path: string }) => { const mockFile = mockFiles.find((f) => f.path === file.path); return Promise.resolve(mockFile?.content || ""); }), }, - }; + } as unknown as App; - scanner = new GrepScanner(mockApp as App); + scanner = new GrepScanner(mockApp); }); describe("batchCachedReadGrep", () => { @@ -82,7 +87,7 @@ describe("GrepScanner", () => { }); it("should skip files that can't be read", async () => { - mockApp.vault.cachedRead = jest.fn((file) => { + mockApp.vault.cachedRead = jest.fn((file: { path: string }) => { if (file.path === "note2.md") { return Promise.reject(new Error("File read error")); } diff --git a/src/settings/model.test.ts b/src/settings/model.test.ts index b1589b5f..5b156e54 100644 --- a/src/settings/model.test.ts +++ b/src/settings/model.test.ts @@ -5,7 +5,7 @@ import { DEFAULT_SETTINGS, SEND_SHORTCUT, } from "@/constants"; -import { sanitizeQaExclusions, sanitizeSettings } from "@/settings/model"; +import { sanitizeQaExclusions, sanitizeSettings, CopilotSettings } from "@/settings/model"; import { getEffectiveUserPrompt, getSystemPrompt } from "@/system-prompts/systemPromptBuilder"; import * as systemPromptsState from "@/system-prompts/state"; import * as settingsModel from "@/settings/model"; @@ -18,9 +18,9 @@ jest.mock("@/system-prompts/state", () => ({ // Mock settings/model getSettings for legacy fallback tests jest.mock("@/settings/model", () => { - const actual = jest.requireActual("@/settings/model"); + const actual = jest.requireActual("@/settings/model"); return { - ...(actual as object), + ...actual, getSettings: jest.fn(() => ({ userSystemPrompt: "" })), }; }); @@ -57,8 +57,8 @@ describe("sanitizeSettings - defaultSendShortcut migration", () => { it("should use default when defaultSendShortcut is missing", () => { const settingsWithoutShortcut = { ...DEFAULT_SETTINGS, - defaultSendShortcut: undefined as any, - }; + defaultSendShortcut: undefined, + } as unknown as CopilotSettings; const sanitized = sanitizeSettings(settingsWithoutShortcut); @@ -68,8 +68,8 @@ describe("sanitizeSettings - defaultSendShortcut migration", () => { it("should use default when defaultSendShortcut is invalid", () => { const settingsWithInvalidShortcut = { ...DEFAULT_SETTINGS, - defaultSendShortcut: "invalid-shortcut" as any, - }; + defaultSendShortcut: "invalid-shortcut", + } as unknown as CopilotSettings; const sanitized = sanitizeSettings(settingsWithInvalidShortcut); @@ -103,9 +103,9 @@ describe("sanitizeSettings - autoAddActiveContentToContext migration", () => { it("should migrate from old includeActiveNoteAsContext=true", () => { const oldSettings = { ...DEFAULT_SETTINGS, - autoAddActiveContentToContext: undefined as any, + autoAddActiveContentToContext: undefined, includeActiveNoteAsContext: true, - }; + } as unknown as CopilotSettings; const sanitized = sanitizeSettings(oldSettings); @@ -115,9 +115,9 @@ describe("sanitizeSettings - autoAddActiveContentToContext migration", () => { it("should migrate from old includeActiveNoteAsContext=false", () => { const oldSettings = { ...DEFAULT_SETTINGS, - autoAddActiveContentToContext: undefined as any, + autoAddActiveContentToContext: undefined, includeActiveNoteAsContext: false, - }; + } as unknown as CopilotSettings; const sanitized = sanitizeSettings(oldSettings); @@ -127,8 +127,8 @@ describe("sanitizeSettings - autoAddActiveContentToContext migration", () => { it("should use default when no old setting exists", () => { const newSettings = { ...DEFAULT_SETTINGS, - autoAddActiveContentToContext: undefined as any, - }; + autoAddActiveContentToContext: undefined, + } as unknown as CopilotSettings; const sanitized = sanitizeSettings(newSettings); @@ -142,9 +142,9 @@ describe("sanitizeSettings - autoAddSelectionToContext migration", () => { it("should migrate from old autoIncludeTextSelection=true", () => { const oldSettings = { ...DEFAULT_SETTINGS, - autoAddSelectionToContext: undefined as any, + autoAddSelectionToContext: undefined, autoIncludeTextSelection: true, - }; + } as unknown as CopilotSettings; const sanitized = sanitizeSettings(oldSettings); @@ -154,9 +154,9 @@ describe("sanitizeSettings - autoAddSelectionToContext migration", () => { it("should migrate from old autoIncludeTextSelection=false", () => { const oldSettings = { ...DEFAULT_SETTINGS, - autoAddSelectionToContext: undefined as any, + autoAddSelectionToContext: undefined, autoIncludeTextSelection: false, - }; + } as unknown as CopilotSettings; const sanitized = sanitizeSettings(oldSettings); @@ -166,8 +166,8 @@ describe("sanitizeSettings - autoAddSelectionToContext migration", () => { it("should use default when no old setting exists", () => { const newSettings = { ...DEFAULT_SETTINGS, - autoAddSelectionToContext: undefined as any, - }; + autoAddSelectionToContext: undefined, + } as unknown as CopilotSettings; const sanitized = sanitizeSettings(newSettings); @@ -179,11 +179,11 @@ describe("sanitizeSettings - legacy Miyo settings cleanup", () => { it("migrates legacy Miyo settings and strips obsolete remote vault path state", () => { const legacySettings = { ...DEFAULT_SETTINGS, - enableMiyo: undefined as any, + enableMiyo: undefined, enableMiyoSearch: true, miyoServerUrl: "http://127.0.0.1:8742", miyoRemoteVaultPath: "\\\\Mac\\Home\\Downloads\\graham-essays-main", - }; + } as unknown as CopilotSettings; const sanitized = sanitizeSettings(legacySettings); diff --git a/src/system-prompts/migration.test.ts b/src/system-prompts/migration.test.ts index 2adf8d11..79d744bf 100644 --- a/src/system-prompts/migration.test.ts +++ b/src/system-prompts/migration.test.ts @@ -36,7 +36,7 @@ jest.mock("@/system-prompts/systemPromptUtils", () => ({ // Mock utils jest.mock("@/utils", () => { - const actual = jest.requireActual("@/utils"); + const actual = jest.requireActual<{ stripFrontmatter: unknown }>("@/utils"); return { ensureFolderExists: jest.fn(), stripFrontmatter: actual.stripFrontmatter, @@ -52,7 +52,7 @@ jest.mock("@/components/modals/ConfirmModal", () => ({ describe("migrateSystemPromptsFromSettings", () => { let mockVault: Vault; - let originalApp: any; + let originalApp: typeof window.app; beforeEach(() => { jest.clearAllMocks(); @@ -78,7 +78,7 @@ describe("migrateSystemPromptsFromSettings", () => { originalApp = window.app; window.app = { vault: mockVault, - } as any; + } as unknown as typeof window.app; }); afterEach(() => { @@ -423,7 +423,10 @@ describe("migrateSystemPromptsFromSettings", () => { }) ); - const callArgs = (systemPromptUtils.ensurePromptFrontmatter as jest.Mock).mock.calls[0][1]; + const callArgs = (systemPromptUtils.ensurePromptFrontmatter as jest.Mock).mock.calls[0][1] as { + createdMs: number; + modifiedMs: number; + }; expect(callArgs.createdMs).toBeGreaterThanOrEqual(beforeTime); expect(callArgs.createdMs).toBeLessThanOrEqual(afterTime); expect(callArgs.modifiedMs).toBeGreaterThanOrEqual(beforeTime); diff --git a/src/system-prompts/systemPromptManager.test.ts b/src/system-prompts/systemPromptManager.test.ts index 5cbd7d76..f5c086dd 100644 --- a/src/system-prompts/systemPromptManager.test.ts +++ b/src/system-prompts/systemPromptManager.test.ts @@ -48,7 +48,7 @@ jest.mock("@/system-prompts/state", () => ({ describe("SystemPromptManager", () => { let manager: SystemPromptManager; let mockVault: Vault; - let originalApp: any; + let originalApp: typeof window.app; beforeEach(() => { // Reset the singleton instance before each test @@ -74,7 +74,7 @@ describe("SystemPromptManager", () => { renameFile: jest.fn(), trashFile: jest.fn().mockResolvedValue(undefined), }, - } as any; + } as unknown as typeof window.app; // Initialize manager manager = SystemPromptManager.getInstance(mockVault); diff --git a/src/system-prompts/systemPromptRegister.test.ts b/src/system-prompts/systemPromptRegister.test.ts index b90bdee9..dad3151c 100644 --- a/src/system-prompts/systemPromptRegister.test.ts +++ b/src/system-prompts/systemPromptRegister.test.ts @@ -239,11 +239,16 @@ describe("SystemPromptRegister", () => { jest.useFakeTimers(); // Capture the settings change handler - const { subscribeToSettingsChange } = jest.requireMock("@/settings/model"); - settingsChangeHandler = subscribeToSettingsChange.mock.calls[0]?.[0]; + const { subscribeToSettingsChange } = jest.requireMock<{ + subscribeToSettingsChange: jest.Mock; + }>("@/settings/model"); + settingsChangeHandler = subscribeToSettingsChange.mock + .calls[0]?.[0] as typeof settingsChangeHandler; // Get reference to mock manager - const { SystemPromptManager } = jest.requireMock("@/system-prompts/systemPromptManager"); + const { SystemPromptManager } = jest.requireMock<{ + SystemPromptManager: { getInstance: () => { fetchPrompts: jest.Mock } }; + }>("@/system-prompts/systemPromptManager"); mockManager = SystemPromptManager.getInstance(); }); @@ -280,10 +285,13 @@ describe("SystemPromptRegister", () => { }); it("clears defaultSystemPromptTitle when prompt not found in new folder", async () => { - const { getSettings, updateSetting } = jest.requireMock("@/settings/model"); + const { getSettings, updateSetting } = jest.requireMock<{ + getSettings: jest.Mock; + updateSetting: jest.Mock; + }>("@/settings/model"); // Set up: default prompt points to a prompt that won't exist in new folder - (getSettings as jest.Mock).mockReturnValue({ + getSettings.mockReturnValue({ defaultSystemPromptTitle: "OldDefault", userSystemPromptsFolder: "NewFolder", }); @@ -313,10 +321,13 @@ describe("SystemPromptRegister", () => { }); it("does not clear prompts when they exist in new folder", async () => { - const { getSettings, updateSetting } = jest.requireMock("@/settings/model"); + const { getSettings, updateSetting } = jest.requireMock<{ + getSettings: jest.Mock; + updateSetting: jest.Mock; + }>("@/settings/model"); // Set up: prompts exist in new folder - (getSettings as jest.Mock).mockReturnValue({ + getSettings.mockReturnValue({ defaultSystemPromptTitle: "ExistingPrompt", userSystemPromptsFolder: "NewFolder", }); diff --git a/src/system-prompts/systemPromptUtils.test.ts b/src/system-prompts/systemPromptUtils.test.ts index 4d13aedf..bfea4356 100644 --- a/src/system-prompts/systemPromptUtils.test.ts +++ b/src/system-prompts/systemPromptUtils.test.ts @@ -247,7 +247,7 @@ describe("isSystemPromptFile", () => { }); describe("parseSystemPromptFile", () => { - let originalApp: any; + let originalApp: typeof window.app; let mockFile: TFile; beforeEach(() => { @@ -265,7 +265,7 @@ describe("parseSystemPromptFile", () => { metadataCache: { getFileCache: jest.fn(), }, - } as any; + } as unknown as typeof window.app; }); afterEach(() => { diff --git a/src/tests/context-manage-modal.test.ts b/src/tests/context-manage-modal.test.ts index 41c932cb..35bf4b30 100644 --- a/src/tests/context-manage-modal.test.ts +++ b/src/tests/context-manage-modal.test.ts @@ -162,7 +162,17 @@ describe("Context Manage Modal Functions", () => { (shouldIndexFile as jest.Mock).mockReturnValue(true); (getFilePattern as jest.Mock).mockImplementation((file: TFile) => `[[${file.basename}]]`); (createPatternSettingsValue as jest.Mock).mockImplementation( - ({ tagPatterns = [], folderPatterns = [], extensionPatterns = [], notePatterns = [] }) => { + ({ + tagPatterns = [], + folderPatterns = [], + extensionPatterns = [], + notePatterns = [], + }: { + tagPatterns?: string[]; + folderPatterns?: string[]; + extensionPatterns?: string[]; + notePatterns?: string[]; + }) => { const patterns = [...tagPatterns, ...folderPatterns, ...extensionPatterns, ...notePatterns]; return patterns.join(","); } @@ -421,9 +431,9 @@ describe("Context Manage Modal Functions", () => { convertGroupListToInclusions(groupList, []); expect(createPatternSettingsValue).toHaveBeenCalledWith({ - tagPatterns: expect.arrayContaining([`#tag0`, `#tag99`]), - folderPatterns: expect.arrayContaining([`folder0`, `folder99`]), - extensionPatterns: expect.arrayContaining([`*.ext0`, `*.ext99`]), + tagPatterns: expect.arrayContaining([`#tag0`, `#tag99`]) as string[], + folderPatterns: expect.arrayContaining([`folder0`, `folder99`]) as string[], + extensionPatterns: expect.arrayContaining([`*.ext0`, `*.ext99`]) as string[], notePatterns: [], }); }); @@ -464,7 +474,7 @@ describe("Context Manage Modal Functions", () => { expect(getFilePattern).toHaveBeenCalledTimes(1000); expect(createPatternSettingsValue).toHaveBeenCalledWith({ - notePatterns: expect.arrayContaining(["[[file0]]", "[[file999]]"]), + notePatterns: expect.arrayContaining(["[[file0]]", "[[file999]]"]) as string[], }); }); @@ -526,7 +536,7 @@ describe("Context Manage Modal Functions", () => { tagPatterns: [], folderPatterns: ["docs", "src"], extensionPatterns: ["*.md", "*.ts"], - notePatterns: expect.any(Array), + notePatterns: expect.any(Array) as string[], }); expect(createPatternSettingsValue).toHaveBeenCalledWith({ diff --git a/src/tests/projectContextCache.test.ts b/src/tests/projectContextCache.test.ts index 66611b93..c64d2faa 100644 --- a/src/tests/projectContextCache.test.ts +++ b/src/tests/projectContextCache.test.ts @@ -3,14 +3,19 @@ import { ContextCache, ProjectContextCache } from "@/cache/projectContextCache"; import type { TFile } from "obsidian"; // Mock dependencies +interface MockFileStat { + mtime: number; + size: number; +} + jest.mock("obsidian", () => ({ TFile: class MockTFile { path: string; extension: string; basename: string; - stat: any; + stat: MockFileStat; - constructor(path: string, extension: string, basename: string, stat: any) { + constructor(path: string, extension: string, basename: string, stat: MockFileStat) { this.path = path; this.extension = extension; this.basename = basename; @@ -112,13 +117,26 @@ describe("ProjectContextCache", () => { let mockProject: ProjectConfig; // Mock files - const { TFile: MockedTFile } = jest.requireMock("obsidian"); - const mockMarkdownFile = new MockedTFile("test/file.md", "md", "file", { + interface MockedTFileType { + path: string; + extension: string; + basename: string; + stat: MockFileStat; + } + const { TFile: MockedTFile } = jest.requireMock<{ + TFile: new ( + path: string, + extension: string, + basename: string, + stat: MockFileStat + ) => MockedTFileType; + }>("obsidian"); + const mockMarkdownFile: MockedTFileType = new MockedTFile("test/file.md", "md", "file", { mtime: Date.now(), size: 100, }); - const mockPdfFile = new MockedTFile("test/document.pdf", "pdf", "document", { + const mockPdfFile: MockedTFileType = new MockedTFile("test/document.pdf", "pdf", "document", { mtime: Date.now(), size: 200, }); @@ -128,7 +146,7 @@ describe("ProjectContextCache", () => { jest.clearAllMocks(); // Mock globals - window.app = mockApp as any; + (window as unknown as { app: unknown }).app = mockApp; // Set up mock vault file retrieval mockApp.vault.getFiles.mockReturnValue([mockMarkdownFile, mockPdfFile]); @@ -154,7 +172,7 @@ describe("ProjectContextCache", () => { inclusions: "**/*.md, **/*.pdf", exclusions: "", }, - } as ProjectConfig; + } as unknown as ProjectConfig; }); test("should store and retrieve file content", async () => { @@ -203,11 +221,7 @@ describe("ProjectContextCache", () => { await projectContextCache.getOrInitializeCache(mockProject); // First add some context - await projectContextCache.setFileContext( - mockProject, - mockPdfFile.path as string, - "PDF content" - ); + await projectContextCache.setFileContext(mockProject, mockPdfFile.path, "PDF content"); // Then clean it up await projectContextCache.cleanupProjectFileReferences(mockProject); @@ -408,8 +422,8 @@ describe("ProjectContextCache", () => { expect(mockApp.vault.adapter.write).toHaveBeenCalled(); // Check that written content contains updated values - const writeCall = mockApp.vault.adapter.write.mock.calls[0]; - const writtenContent = JSON.parse(writeCall[1] as string); + const writeCall = mockApp.vault.adapter.write.mock.calls[0] as unknown[]; + const writtenContent = JSON.parse(writeCall[1] as string) as ContextCache; expect(writtenContent.markdownContext).toBe("Updated markdown content"); expect(writtenContent.markdownNeedsReload).toBe(true); }); @@ -445,8 +459,8 @@ describe("ProjectContextCache", () => { expect(mockApp.vault.adapter.write).toHaveBeenCalled(); // Check that written content contains updated values - const writeCall = mockApp.vault.adapter.write.mock.calls[0]; - const writtenContent = JSON.parse(writeCall[1] as string); + const writeCall = mockApp.vault.adapter.write.mock.calls[0] as unknown[]; + const writtenContent = JSON.parse(writeCall[1] as string) as ContextCache; expect(writtenContent.markdownContext).toBe("Async updated content"); expect(writtenContent.webContexts["https://example.com"]).toBe("Async web content"); }); @@ -460,7 +474,7 @@ describe("ProjectContextCache", () => { inclusions: "**/*.md, **/*.pdf", exclusions: "", }, - } as ProjectConfig; + } as unknown as ProjectConfig; // Mock cache non-existence for this isolated project mockApp.vault.adapter.exists.mockImplementation((path) => { @@ -506,7 +520,7 @@ describe("ProjectContextCache", () => { return Promise.resolve(JSON.stringify(currentCache)); }); mockApp.vault.adapter.write.mockImplementation((path, content) => { - currentCache = JSON.parse(content as string); + currentCache = JSON.parse(content as string) as ContextCache; return Promise.resolve(); }); @@ -580,7 +594,7 @@ describe("ProjectContextCache", () => { const startTime = Date.now(); mockApp.vault.adapter.write.mockImplementation((path, content) => { - const parsed = JSON.parse(content as string); + const parsed = JSON.parse(content as string) as ContextCache; currentCache = parsed; // Track what was written and when diff --git a/src/tools/FileTreeTools.test.ts b/src/tools/FileTreeTools.test.ts index b9dc29d6..6c220dc0 100644 --- a/src/tools/FileTreeTools.test.ts +++ b/src/tools/FileTreeTools.test.ts @@ -130,12 +130,11 @@ describe("FileTreeTools", () => { // Also test the tool to ensure it uses buildFileTree correctly const tool = createGetFileTreeTool(root); - const result = await ToolManager.callTool(tool, {}); + const result = (await ToolManager.callTool(tool, {})) as string; // Extract JSON part after the prompt - const resultStr = result as string; - const jsonPart = resultStr.substring(resultStr.indexOf("{")); - const treeFromTool = JSON.parse(jsonPart); + const jsonPart = result.substring(result.indexOf("{")); + const treeFromTool = JSON.parse(jsonPart) as typeof expectedTree; expect(treeFromTool).toEqual(expectedTree); }); diff --git a/src/tools/ObsidianCliDailyTools.test.ts b/src/tools/ObsidianCliDailyTools.test.ts index e90cad49..fa3d70ee 100644 --- a/src/tools/ObsidianCliDailyTools.test.ts +++ b/src/tools/ObsidianCliDailyTools.test.ts @@ -4,6 +4,9 @@ import { runRandomReadCommand, } from "@/services/obsidianCli/ObsidianCliClient"; +type InvokableTool = { invoke: (args: Record) => Promise }; +const asInvokable = (t: unknown): InvokableTool => t as InvokableTool; + jest.mock("@/services/obsidianCli/ObsidianCliClient", () => ({ runDailyReadCommand: jest.fn(), runRandomReadCommand: jest.fn(), @@ -91,8 +94,13 @@ describe("ObsidianCliDailyTools", () => { buildSuccessResult("daily:read", "Today I worked on CLI integration.") ); - const response = await (obsidianDailyReadTool as any).invoke({ vault: "Work" }); - const parsed = JSON.parse(response as string); + const response = await asInvokable(obsidianDailyReadTool).invoke({ vault: "Work" }); + const parsed = JSON.parse(response) as { + type: string; + command: string; + vault: string | null; + content: string; + }; expect(parsed.type).toBe("obsidian_cli_daily_read"); expect(parsed.command).toBe("daily:read"); @@ -106,8 +114,13 @@ describe("ObsidianCliDailyTools", () => { buildSuccessResult("random:read", "Random note body") ); - const response = await (obsidianRandomReadTool as any).invoke({}); - const parsed = JSON.parse(response as string); + const response = await asInvokable(obsidianRandomReadTool).invoke({}); + const parsed = JSON.parse(response) as { + type: string; + command: string; + vault: string | null; + content: string; + }; expect(parsed.type).toBe("obsidian_cli_random_read"); expect(parsed.command).toBe("random:read"); @@ -121,7 +134,7 @@ describe("ObsidianCliDailyTools", () => { buildFailedResult("daily:read", "EFAIL", "daily note unavailable") ); - await expect((obsidianDailyReadTool as any).invoke({})).rejects.toThrow( + await expect(asInvokable(obsidianDailyReadTool).invoke({})).rejects.toThrow( "daily note unavailable" ); }); @@ -129,7 +142,7 @@ describe("ObsidianCliDailyTools", () => { test("obsidianRandomReadTool surfaces actionable ENOENT failure details", async () => { mockedRunRandomReadCommand.mockResolvedValue(buildFailedResult("random:read", "ENOENT", "")); - await expect((obsidianRandomReadTool as any).invoke({})).rejects.toThrow( + await expect(asInvokable(obsidianRandomReadTool).invoke({})).rejects.toThrow( "CLI binary not found" ); }); diff --git a/src/tools/ObsidianCliTools.test.ts b/src/tools/ObsidianCliTools.test.ts index 5d30b90a..87678501 100644 --- a/src/tools/ObsidianCliTools.test.ts +++ b/src/tools/ObsidianCliTools.test.ts @@ -14,6 +14,20 @@ jest.mock("@/services/obsidianCli/ObsidianCliClient", () => ({ const mockedRunCommand = runObsidianCliCommand as jest.MockedFunction; +/** + * Minimal interface for invoking a LangChain tool in tests. + * The actual tool's `invoke` is generic; we only need the string-result form here. + */ +type InvokableTool = { invoke: (args: Record) => Promise }; +const asInvokable = (t: unknown): InvokableTool => t as InvokableTool; + +type ParsedToolResponse = { + type?: string; + command?: string; + vault?: string | null; + content?: string; +}; + type CliResult = { command: string; args: string[]; @@ -81,8 +95,8 @@ describe("obsidianDailyNoteTool", () => { test("daily creates today's daily note", async () => { mockedRunCommand.mockResolvedValue(buildSuccessResult("daily", "")); - const response = await (obsidianDailyNoteTool as any).invoke({ command: "daily" }); - const parsed = JSON.parse(response as string); + const response = await asInvokable(obsidianDailyNoteTool).invoke({ command: "daily" }); + const parsed = JSON.parse(response) as ParsedToolResponse; expect(parsed.type).toBe("obsidian_cli_daily_note"); expect(parsed.command).toBe("daily"); @@ -98,8 +112,8 @@ describe("obsidianDailyNoteTool", () => { buildSuccessResult("daily:read", "# 2026-03-03\n\nToday's tasks...") ); - const response = await (obsidianDailyNoteTool as any).invoke({ command: "daily:read" }); - const parsed = JSON.parse(response as string); + const response = await asInvokable(obsidianDailyNoteTool).invoke({ command: "daily:read" }); + const parsed = JSON.parse(response) as ParsedToolResponse; expect(parsed.type).toBe("obsidian_cli_daily_note"); expect(parsed.command).toBe("daily:read"); @@ -115,11 +129,11 @@ describe("obsidianDailyNoteTool", () => { test("daily:path returns path payload", async () => { mockedRunCommand.mockResolvedValue(buildSuccessResult("daily:path", "Daily/2026-03-03.md")); - const response = await (obsidianDailyNoteTool as any).invoke({ + const response = await asInvokable(obsidianDailyNoteTool).invoke({ command: "daily:path", vault: "Work", }); - const parsed = JSON.parse(response as string); + const parsed = JSON.parse(response) as ParsedToolResponse; expect(parsed.type).toBe("obsidian_cli_daily_note"); expect(parsed.command).toBe("daily:path"); @@ -132,17 +146,17 @@ describe("obsidianDailyNoteTool", () => { buildFailedResult("daily:read", "EFAIL", "Daily note plugin not enabled", 1) ); - await expect((obsidianDailyNoteTool as any).invoke({ command: "daily:read" })).rejects.toThrow( - "Daily note plugin not enabled" - ); + await expect( + asInvokable(obsidianDailyNoteTool).invoke({ command: "daily:read" }) + ).rejects.toThrow("Daily note plugin not enabled"); }); test("throws ENOENT failure with actionable message", async () => { mockedRunCommand.mockResolvedValue(buildFailedResult("daily:read", "ENOENT", "")); - await expect((obsidianDailyNoteTool as any).invoke({ command: "daily:read" })).rejects.toThrow( - "CLI binary not found" - ); + await expect( + asInvokable(obsidianDailyNoteTool).invoke({ command: "daily:read" }) + ).rejects.toThrow("CLI binary not found"); }); }); @@ -156,8 +170,8 @@ describe("obsidianPropertiesTool", () => { buildSuccessResult("properties", "aliases\nauthor\ndate\ntags") ); - const response = await (obsidianPropertiesTool as any).invoke({ command: "properties" }); - const parsed = JSON.parse(response as string); + const response = await asInvokable(obsidianPropertiesTool).invoke({ command: "properties" }); + const parsed = JSON.parse(response) as ParsedToolResponse; expect(parsed.type).toBe("obsidian_cli_properties"); expect(parsed.command).toBe("properties"); @@ -174,7 +188,7 @@ describe("obsidianPropertiesTool", () => { buildSuccessResult("properties", "tags: false\ntitle: My Note") ); - await (obsidianPropertiesTool as any).invoke({ + await asInvokable(obsidianPropertiesTool).invoke({ command: "properties", file: "My Note", counts: true, @@ -190,12 +204,12 @@ describe("obsidianPropertiesTool", () => { test("property:read returns single property value", async () => { mockedRunCommand.mockResolvedValue(buildSuccessResult("property:read", "project, review")); - const response = await (obsidianPropertiesTool as any).invoke({ + const response = await asInvokable(obsidianPropertiesTool).invoke({ command: "property:read", name: "tags", file: "My Note", }); - const parsed = JSON.parse(response as string); + const parsed = JSON.parse(response) as ParsedToolResponse; expect(parsed.content).toBe("project, review"); expect(mockedRunCommand).toHaveBeenCalledWith({ @@ -207,7 +221,7 @@ describe("obsidianPropertiesTool", () => { test("property:read throws when name is missing", async () => { await expect( - (obsidianPropertiesTool as any).invoke({ command: "property:read" }) + asInvokable(obsidianPropertiesTool).invoke({ command: "property:read" }) ).rejects.toThrow("name is required for property:read"); }); @@ -215,9 +229,9 @@ describe("obsidianPropertiesTool", () => { // When error code is present, it takes precedence over exit code in error message mockedRunCommand.mockResolvedValue(buildFailedResult("properties", "EFAIL", "", 1)); - await expect((obsidianPropertiesTool as any).invoke({ command: "properties" })).rejects.toThrow( - "error code EFAIL" - ); + await expect( + asInvokable(obsidianPropertiesTool).invoke({ command: "properties" }) + ).rejects.toThrow("error code EFAIL"); }); }); @@ -231,8 +245,8 @@ describe("obsidianTasksTool", () => { buildSuccessResult("tasks", "- [ ] Review PR #2181\n- [x] Write tests") ); - const response = await (obsidianTasksTool as any).invoke({ command: "tasks" }); - const parsed = JSON.parse(response as string); + const response = await asInvokable(obsidianTasksTool).invoke({ command: "tasks" }); + const parsed = JSON.parse(response) as ParsedToolResponse; expect(parsed.type).toBe("obsidian_cli_tasks"); expect(parsed.command).toBe("tasks"); @@ -247,7 +261,7 @@ describe("obsidianTasksTool", () => { test("tasks passes all filter params", async () => { mockedRunCommand.mockResolvedValue(buildSuccessResult("tasks", "- [ ] Task A")); - await (obsidianTasksTool as any).invoke({ + await asInvokable(obsidianTasksTool).invoke({ command: "tasks", file: "Project Plan", todo: true, @@ -265,7 +279,7 @@ describe("obsidianTasksTool", () => { test("tasks with daily flag", async () => { mockedRunCommand.mockResolvedValue(buildSuccessResult("tasks", "- [ ] Daily task")); - await (obsidianTasksTool as any).invoke({ command: "tasks", daily: true }); + await asInvokable(obsidianTasksTool).invoke({ command: "tasks", daily: true }); expect(mockedRunCommand).toHaveBeenCalledWith({ command: "tasks", @@ -277,7 +291,7 @@ describe("obsidianTasksTool", () => { test("throws on CLI failure with stderr", async () => { mockedRunCommand.mockResolvedValue(buildFailedResult("tasks", "ENOENT", "")); - await expect((obsidianTasksTool as any).invoke({ command: "tasks" })).rejects.toThrow( + await expect(asInvokable(obsidianTasksTool).invoke({ command: "tasks" })).rejects.toThrow( "CLI binary not found" ); }); @@ -293,11 +307,11 @@ describe("obsidianLinksTool", () => { buildSuccessResult("backlinks", "Projects/roadmap.md\nDaily/2026-03-01.md") ); - const response = await (obsidianLinksTool as any).invoke({ + const response = await asInvokable(obsidianLinksTool).invoke({ command: "backlinks", file: "My Note", }); - const parsed = JSON.parse(response as string); + const parsed = JSON.parse(response) as ParsedToolResponse; expect(parsed.type).toBe("obsidian_cli_links"); expect(parsed.command).toBe("backlinks"); @@ -314,7 +328,7 @@ describe("obsidianLinksTool", () => { buildSuccessResult("links", "Ideas/brainstorm.md\nProjects/roadmap.md") ); - await (obsidianLinksTool as any).invoke({ command: "links", path: "Notes/note.md" }); + await asInvokable(obsidianLinksTool).invoke({ command: "links", path: "Notes/note.md" }); expect(mockedRunCommand).toHaveBeenCalledWith({ command: "links", @@ -328,8 +342,8 @@ describe("obsidianLinksTool", () => { buildSuccessResult("orphans", "Inbox/draft.md\nAttic/old.md") ); - const response = await (obsidianLinksTool as any).invoke({ command: "orphans" }); - const parsed = JSON.parse(response as string); + const response = await asInvokable(obsidianLinksTool).invoke({ command: "orphans" }); + const parsed = JSON.parse(response) as ParsedToolResponse; expect(parsed.content).toBe("Inbox/draft.md\nAttic/old.md"); }); @@ -339,7 +353,7 @@ describe("obsidianLinksTool", () => { buildSuccessResult("unresolved", "Missing Note\t5\nOld Reference\t2") ); - await (obsidianLinksTool as any).invoke({ + await asInvokable(obsidianLinksTool).invoke({ command: "unresolved", counts: true, verbose: false, @@ -355,7 +369,11 @@ describe("obsidianLinksTool", () => { test("backlinks with total flag", async () => { mockedRunCommand.mockResolvedValue(buildSuccessResult("backlinks", "4")); - await (obsidianLinksTool as any).invoke({ command: "backlinks", file: "Note", total: true }); + await asInvokable(obsidianLinksTool).invoke({ + command: "backlinks", + file: "Note", + total: true, + }); expect(mockedRunCommand).toHaveBeenCalledWith({ command: "backlinks", @@ -370,7 +388,7 @@ describe("obsidianLinksTool", () => { ); await expect( - (obsidianLinksTool as any).invoke({ command: "backlinks", file: "note" }) + asInvokable(obsidianLinksTool).invoke({ command: "backlinks", file: "note" }) ).rejects.toThrow('File "note.md" not found.'); }); }); @@ -385,8 +403,8 @@ describe("obsidianTemplatesTool", () => { buildSuccessResult("templates", "Daily Note\nMeeting Notes\nProject Plan") ); - const response = await (obsidianTemplatesTool as any).invoke({ command: "templates" }); - const parsed = JSON.parse(response as string); + const response = await asInvokable(obsidianTemplatesTool).invoke({ command: "templates" }); + const parsed = JSON.parse(response) as ParsedToolResponse; expect(parsed.type).toBe("obsidian_cli_templates"); expect(parsed.command).toBe("templates"); @@ -402,7 +420,7 @@ describe("obsidianTemplatesTool", () => { test("templates passes vault param", async () => { mockedRunCommand.mockResolvedValue(buildSuccessResult("templates", "Daily Note")); - await (obsidianTemplatesTool as any).invoke({ command: "templates", vault: "Work" }); + await asInvokable(obsidianTemplatesTool).invoke({ command: "templates", vault: "Work" }); expect(mockedRunCommand).toHaveBeenCalledWith({ command: "templates", @@ -416,11 +434,11 @@ describe("obsidianTemplatesTool", () => { buildSuccessResult("template:read", "# {{date}}\n\n## Tasks\n- [ ] ") ); - const response = await (obsidianTemplatesTool as any).invoke({ + const response = await asInvokable(obsidianTemplatesTool).invoke({ command: "template:read", name: "Daily Note", }); - const parsed = JSON.parse(response as string); + const parsed = JSON.parse(response) as ParsedToolResponse; expect(parsed.type).toBe("obsidian_cli_templates"); expect(parsed.command).toBe("template:read"); @@ -434,7 +452,7 @@ describe("obsidianTemplatesTool", () => { test("template:read throws when name is missing", async () => { await expect( - (obsidianTemplatesTool as any).invoke({ command: "template:read" }) + asInvokable(obsidianTemplatesTool).invoke({ command: "template:read" }) ).rejects.toThrow("name is required for template:read"); }); @@ -443,17 +461,17 @@ describe("obsidianTemplatesTool", () => { buildFailedResult("templates", "EFAIL", "Templates plugin not enabled", 1) ); - await expect((obsidianTemplatesTool as any).invoke({ command: "templates" })).rejects.toThrow( - "Templates plugin not enabled" - ); + await expect( + asInvokable(obsidianTemplatesTool).invoke({ command: "templates" }) + ).rejects.toThrow("Templates plugin not enabled"); }); test("throws ENOENT failure with actionable message", async () => { mockedRunCommand.mockResolvedValue(buildFailedResult("templates", "ENOENT", "")); - await expect((obsidianTemplatesTool as any).invoke({ command: "templates" })).rejects.toThrow( - "CLI binary not found" - ); + await expect( + asInvokable(obsidianTemplatesTool).invoke({ command: "templates" }) + ).rejects.toThrow("CLI binary not found"); }); }); @@ -467,8 +485,8 @@ describe("obsidianBasesTool", () => { buildSuccessResult("bases", "Contacts.base\nProjects.base\nTasks.base") ); - const response = await (obsidianBasesTool as any).invoke({ command: "bases" }); - const parsed = JSON.parse(response as string); + const response = await asInvokable(obsidianBasesTool).invoke({ command: "bases" }); + const parsed = JSON.parse(response) as ParsedToolResponse; expect(parsed.type).toBe("obsidian_cli_bases"); expect(parsed.command).toBe("bases"); @@ -486,11 +504,11 @@ describe("obsidianBasesTool", () => { buildSuccessResult("base:views", "All Items\nBy Status\nKanban") ); - const response = await (obsidianBasesTool as any).invoke({ + const response = await asInvokable(obsidianBasesTool).invoke({ command: "base:views", file: "Projects", }); - const parsed = JSON.parse(response as string); + const parsed = JSON.parse(response) as ParsedToolResponse; expect(parsed.type).toBe("obsidian_cli_bases"); expect(parsed.content).toBe("All Items\nBy Status\nKanban"); @@ -504,7 +522,7 @@ describe("obsidianBasesTool", () => { test("base:views passes path param", async () => { mockedRunCommand.mockResolvedValue(buildSuccessResult("base:views", "Default View")); - await (obsidianBasesTool as any).invoke({ + await asInvokable(obsidianBasesTool).invoke({ command: "base:views", path: "Databases/Projects.base", }); @@ -521,13 +539,13 @@ describe("obsidianBasesTool", () => { buildSuccessResult("base:query", "Name,Status\nAlpha,Active\nBeta,Done") ); - const response = await (obsidianBasesTool as any).invoke({ + const response = await asInvokable(obsidianBasesTool).invoke({ command: "base:query", file: "Projects", view: "All Items", format: "csv", }); - const parsed = JSON.parse(response as string); + const parsed = JSON.parse(response) as ParsedToolResponse; expect(parsed.content).toBe("Name,Status\nAlpha,Active\nBeta,Done"); expect(mockedRunCommand).toHaveBeenCalledWith({ @@ -540,7 +558,7 @@ describe("obsidianBasesTool", () => { test("base:query with total flag", async () => { mockedRunCommand.mockResolvedValue(buildSuccessResult("base:query", "42")); - await (obsidianBasesTool as any).invoke({ + await asInvokable(obsidianBasesTool).invoke({ command: "base:query", file: "Contacts", total: true, @@ -558,14 +576,14 @@ describe("obsidianBasesTool", () => { buildSuccessResult("base:create", "Created: Library/Dune Messiah.md") ); - const response = await (obsidianBasesTool as any).invoke({ + const response = await asInvokable(obsidianBasesTool).invoke({ command: "base:create", file: "Library", view: "To Read", name: "Dune Messiah", content: "A book by Frank Herbert", }); - const parsed = JSON.parse(response as string); + const parsed = JSON.parse(response) as ParsedToolResponse; expect(parsed.type).toBe("obsidian_cli_bases"); expect(parsed.command).toBe("base:create"); @@ -585,7 +603,7 @@ describe("obsidianBasesTool", () => { test("base:create with path and vault params", async () => { mockedRunCommand.mockResolvedValue(buildSuccessResult("base:create", "Created: item.md")); - await (obsidianBasesTool as any).invoke({ + await asInvokable(obsidianBasesTool).invoke({ command: "base:create", path: "Databases/Library.base", name: "New Item", @@ -602,7 +620,7 @@ describe("obsidianBasesTool", () => { test("base:create with minimal params (file only)", async () => { mockedRunCommand.mockResolvedValue(buildSuccessResult("base:create", "Created: Untitled.md")); - await (obsidianBasesTool as any).invoke({ + await asInvokable(obsidianBasesTool).invoke({ command: "base:create", file: "Projects", }); @@ -615,19 +633,19 @@ describe("obsidianBasesTool", () => { }); test("base:create throws when file and path are both missing", async () => { - await expect((obsidianBasesTool as any).invoke({ command: "base:create" })).rejects.toThrow( + await expect(asInvokable(obsidianBasesTool).invoke({ command: "base:create" })).rejects.toThrow( "file or path is required for base:create" ); }); test("base:views throws when file and path are both missing", async () => { - await expect((obsidianBasesTool as any).invoke({ command: "base:views" })).rejects.toThrow( + await expect(asInvokable(obsidianBasesTool).invoke({ command: "base:views" })).rejects.toThrow( "file or path is required for base:views" ); }); test("base:query throws when file and path are both missing", async () => { - await expect((obsidianBasesTool as any).invoke({ command: "base:query" })).rejects.toThrow( + await expect(asInvokable(obsidianBasesTool).invoke({ command: "base:query" })).rejects.toThrow( "file or path is required for base:query" ); }); @@ -637,7 +655,7 @@ describe("obsidianBasesTool", () => { buildFailedResult("bases", "EFAIL", "Bases plugin not enabled", 1) ); - await expect((obsidianBasesTool as any).invoke({ command: "bases" })).rejects.toThrow( + await expect(asInvokable(obsidianBasesTool).invoke({ command: "bases" })).rejects.toThrow( "Bases plugin not enabled" ); }); @@ -645,7 +663,7 @@ describe("obsidianBasesTool", () => { test("throws ENOENT failure with actionable message", async () => { mockedRunCommand.mockResolvedValue(buildFailedResult("bases", "ENOENT", "")); - await expect((obsidianBasesTool as any).invoke({ command: "bases" })).rejects.toThrow( + await expect(asInvokable(obsidianBasesTool).invoke({ command: "bases" })).rejects.toThrow( "CLI binary not found" ); }); diff --git a/src/tools/ReadNoteTool.test.ts b/src/tools/ReadNoteTool.test.ts index 5aa23f7c..aac723a8 100644 --- a/src/tools/ReadNoteTool.test.ts +++ b/src/tools/ReadNoteTool.test.ts @@ -2,10 +2,35 @@ import { StructuredTool } from "@langchain/core/tools"; const mockRead = jest.fn(); +type ReadNoteResult = { + notePath: string; + status?: string; + message?: string; + noteTitle?: string; + heading?: string; + chunkId?: string; + chunkIndex?: number; + totalChunks?: number; + hasMore?: boolean; + nextChunkIndex?: number | null; + content?: string; + mtime?: number; + linkedNotes?: Array<{ + linkText: string; + displayText: string; + section: string | undefined; + candidates: Array<{ path: string; title: string }>; + }>; + candidates?: Array<{ path: string; title: string }>; +}; + // Helper to invoke tool and parse JSON result -const invokeReadNoteTool = async (tool: StructuredTool, args: any): Promise => { - const result = await tool.invoke(args); - return typeof result === "string" ? JSON.parse(result) : result; +const invokeReadNoteTool = async ( + tool: StructuredTool, + args: { notePath: string; chunkIndex?: number | string } +): Promise => { + const result: unknown = await tool.invoke(args); + return (typeof result === "string" ? JSON.parse(result) : result) as ReadNoteResult; }; class MockTFile { @@ -27,7 +52,7 @@ jest.mock("obsidian", () => ({ describe("readNoteTool", () => { let readNoteTool: StructuredTool; - let originalApp: any; + let originalApp: typeof window.app; let getAbstractFileByPathMock: jest.Mock; let getMarkdownFilesMock: jest.Mock; let getFirstLinkpathDestMock: jest.Mock; @@ -56,7 +81,7 @@ describe("readNoteTool", () => { getLeavesOfType: jest.fn().mockReturnValue([]), getActiveFile: jest.fn().mockReturnValue(null), }, - } as any; + } as unknown as typeof window.app; ({ readNoteTool } = await import("./NoteTools")); }); @@ -101,7 +126,7 @@ describe("readNoteTool", () => { const lines = Array.from({ length: 205 }, (_, i) => `Line ${i + 1}`); mockRead.mockResolvedValue(lines.join("\n")); - const result = await invokeReadNoteTool(readNoteTool, { notePath, chunkIndex: "1" as any }); + const result = await invokeReadNoteTool(readNoteTool, { notePath, chunkIndex: "1" }); expect(result.chunkIndex).toBe(1); expect(result.content).toBe(lines.slice(200).join("\n")); diff --git a/src/tools/TagTools.test.ts b/src/tools/TagTools.test.ts index ba4829bc..6da3d00e 100644 --- a/src/tools/TagTools.test.ts +++ b/src/tools/TagTools.test.ts @@ -1,10 +1,38 @@ import { ToolManager } from "@/tools/toolManager"; import { createGetTagListTool, enforceSizeLimit } from "./TagTools"; -describe("TagTools", () => { - const originalApp = (window as any).app; +interface TagEntry { + tag: string; + occurrences: number; + frontmatterOccurrences: number; + inlineOccurrences: number; +} - const parsePayload = (result: string): any => { +interface TagPayload { + totalUniqueTags: number; + returnedTagCount: number; + totalOccurrences: number; + truncated: boolean; + includedSources: Array<"frontmatter" | "inline">; + tags: TagEntry[]; +} + +interface MockApp { + metadataCache: { + getTags: jest.Mock; + getFrontmatterTags: jest.Mock; + }; +} + +const getMockApp = (): MockApp => (window as unknown as { app: MockApp }).app; +const setMockApp = (value: MockApp | undefined): void => { + (window as unknown as { app: MockApp | undefined }).app = value; +}; + +describe("TagTools", () => { + const originalApp = getMockApp(); + + const parsePayload = (result: string): TagPayload => { const startIndex = result.indexOf('{"'); const endIndex = result.lastIndexOf("}"); @@ -13,7 +41,7 @@ describe("TagTools", () => { const jsonSegment = result.slice(startIndex, endIndex + 1); try { - return JSON.parse(jsonSegment); + return JSON.parse(jsonSegment) as TagPayload; } catch (error) { console.error("Failed to parse tag tool payload:", jsonSegment); throw error; @@ -21,7 +49,7 @@ describe("TagTools", () => { }; beforeEach(() => { - (window as any).app = { + setMockApp({ metadataCache: { getTags: jest.fn().mockReturnValue({ "#project": 5, @@ -33,22 +61,22 @@ describe("TagTools", () => { "#daily": 2, }), }, - }; + }); }); afterEach(() => { - (window as any).app = originalApp; + setMockApp(originalApp); jest.clearAllMocks(); }); it("returns combined tag statistics by default", async () => { const tool = createGetTagListTool(); - const result = await ToolManager.callTool(tool, {}); + const result = (await ToolManager.callTool(tool, {})) as string; - const payload = parsePayload(result as string); + const payload = parsePayload(result); - expect((window as any).app.metadataCache.getTags).toHaveBeenCalled(); - expect((window as any).app.metadataCache.getFrontmatterTags).toHaveBeenCalled(); + expect(getMockApp().metadataCache.getTags).toHaveBeenCalled(); + expect(getMockApp().metadataCache.getFrontmatterTags).toHaveBeenCalled(); expect(payload.totalUniqueTags).toBe(3); expect(payload.returnedTagCount).toBe(3); expect(payload.totalOccurrences).toBe(10); @@ -78,12 +106,12 @@ describe("TagTools", () => { it("supports frontmatter-only mode", async () => { const tool = createGetTagListTool(); - const result = await ToolManager.callTool(tool, { includeInline: false }); + const result = (await ToolManager.callTool(tool, { includeInline: false })) as string; - const payload = parsePayload(result as string); + const payload = parsePayload(result); - expect((window as any).app.metadataCache.getTags).not.toHaveBeenCalled(); - expect((window as any).app.metadataCache.getFrontmatterTags).toHaveBeenCalled(); + expect(getMockApp().metadataCache.getTags).not.toHaveBeenCalled(); + expect(getMockApp().metadataCache.getFrontmatterTags).toHaveBeenCalled(); expect(payload.totalUniqueTags).toBe(2); expect(payload.includedSources).toEqual(["frontmatter"]); expect(payload.tags).toEqual([ @@ -104,9 +132,9 @@ describe("TagTools", () => { it("limits entries when maxEntries is provided", async () => { const tool = createGetTagListTool(); - const result = await ToolManager.callTool(tool, { maxEntries: 2 }); + const result = (await ToolManager.callTool(tool, { maxEntries: 2 })) as string; - const payload = parsePayload(result as string); + const payload = parsePayload(result); expect(payload.totalUniqueTags).toBe(3); expect(payload.returnedTagCount).toBe(2); @@ -117,13 +145,13 @@ describe("TagTools", () => { }); it("handles empty vault gracefully", async () => { - (window as any).app.metadataCache.getTags.mockReturnValue({}); - (window as any).app.metadataCache.getFrontmatterTags.mockReturnValue({}); + getMockApp().metadataCache.getTags.mockReturnValue({}); + getMockApp().metadataCache.getFrontmatterTags.mockReturnValue({}); const tool = createGetTagListTool(); - const result = await ToolManager.callTool(tool, {}); + const result = (await ToolManager.callTool(tool, {})) as string; - const payload = parsePayload(result as string); + const payload = parsePayload(result); expect(payload.totalUniqueTags).toBe(0); expect(payload.tags).toEqual([]); @@ -131,19 +159,19 @@ describe("TagTools", () => { }); it("normalizes malformed tags correctly", async () => { - (window as any).app.metadataCache.getTags.mockReturnValue({ + getMockApp().metadataCache.getTags.mockReturnValue({ project: 5, "##Weird/Tag": 4, }); - (window as any).app.metadataCache.getFrontmatterTags.mockReturnValue({ + getMockApp().metadataCache.getFrontmatterTags.mockReturnValue({ " #Project ": 3, "##Weird/Tag": 1, }); const tool = createGetTagListTool(); - const result = await ToolManager.callTool(tool, {}); + const result = (await ToolManager.callTool(tool, {})) as string; - const payload = parsePayload(result as string); + const payload = parsePayload(result); expect(payload.tags).toEqual([ { @@ -162,17 +190,17 @@ describe("TagTools", () => { }); it("falls back when inline counts omit frontmatter occurrences", async () => { - (window as any).app.metadataCache.getTags.mockReturnValue({ + getMockApp().metadataCache.getTags.mockReturnValue({ "#project": 1, }); - (window as any).app.metadataCache.getFrontmatterTags.mockReturnValue({ + getMockApp().metadataCache.getFrontmatterTags.mockReturnValue({ "#project": 3, }); const tool = createGetTagListTool(); - const result = await ToolManager.callTool(tool, {}); + const result = (await ToolManager.callTool(tool, {})) as string; - const payload = parsePayload(result as string); + const payload = parsePayload(result); expect(payload.tags).toEqual([ { diff --git a/src/tools/TimeTools.test.ts b/src/tools/TimeTools.test.ts index 69d1d909..f2c38cfa 100644 --- a/src/tools/TimeTools.test.ts +++ b/src/tools/TimeTools.test.ts @@ -1,11 +1,17 @@ import { DateTime } from "luxon"; import { getTimeRangeMsTool } from "./TimeTools"; +type InvokableTool = { invoke: (args: Record) => Promise }; +type TimeRangeResult = { startTime: number; endTime: number; error?: string }; + // Helper function to call the tool and parse result -const getTimeRangeMs = async (timeExpression: string): Promise => { - const result = await (getTimeRangeMsTool as any).invoke({ timeExpression }); +const getTimeRangeMs = async (timeExpression: string): Promise => { + const result = await (getTimeRangeMsTool as unknown as InvokableTool).invoke({ + timeExpression, + }); // The tool returns JSON string, parse it - const parsed = typeof result === "string" ? JSON.parse(result) : result; + const parsed: TimeRangeResult = + typeof result === "string" ? (JSON.parse(result) as TimeRangeResult) : result; // Return undefined if it's an error response if (parsed.error) return undefined; return parsed; @@ -21,8 +27,8 @@ const verifyDateRange = async (expression: string, expected: DateRange) => { const result = await getTimeRangeMs(expression); expect(result).toBeDefined(); // getTimeRangeMs now returns epoch values directly: {startTime: number, endTime: number} - const startDate = DateTime.fromMillis(result!.startTime as number); - const endDate = DateTime.fromMillis(result!.endTime as number); + const startDate = DateTime.fromMillis(result!.startTime); + const endDate = DateTime.fromMillis(result!.endTime); expect(startDate.toISODate()).toBe(expected.startDate); expect(endDate.toISODate()).toBe(expected.endDate); diff --git a/src/tools/TimeTools.timezone.test.ts b/src/tools/TimeTools.timezone.test.ts index 4cebe682..9bb93e57 100644 --- a/src/tools/TimeTools.timezone.test.ts +++ b/src/tools/TimeTools.timezone.test.ts @@ -1,19 +1,34 @@ import { DateTime } from "luxon"; import { getCurrentTimeTool, convertTimeBetweenTimezonesTool } from "./TimeTools"; +interface TimeResult { + epoch: number; + isoString: string; + userLocaleString: string; + localDateString: string; + timezoneOffset: number; + timezone: string; + originalTime?: string; + convertedTime?: string; +} + +interface InvokableTool { + invoke: (args: unknown) => Promise; +} + // Helper to invoke tool and parse result -const invokeGetCurrentTime = async (args: { timezoneOffset?: string }): Promise => { - const result = await (getCurrentTimeTool as any).invoke(args); - return typeof result === "string" ? JSON.parse(result) : result; +const invokeGetCurrentTime = async (args: { timezoneOffset?: string }): Promise => { + const result = await (getCurrentTimeTool as unknown as InvokableTool).invoke(args); + return (typeof result === "string" ? JSON.parse(result) : result) as TimeResult; }; const invokeConvertTime = async (args: { time: string; fromOffset: string; toOffset: string; -}): Promise => { - const result = await (convertTimeBetweenTimezonesTool as any).invoke(args); - return typeof result === "string" ? JSON.parse(result) : result; +}): Promise => { + const result = await (convertTimeBetweenTimezonesTool as unknown as InvokableTool).invoke(args); + return (typeof result === "string" ? JSON.parse(result) : result) as TimeResult; }; describe("TimeTools Timezone Tests", () => { diff --git a/src/utils.test.ts b/src/utils.test.ts index 8b4c428f..e9d4f6e1 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -99,7 +99,7 @@ const mockFileMetadata = { const mockApp = { vault: new Obsidian.Vault(), metadataCache: mockMetadataCache, -} as any; +} as unknown as typeof window.app; describe("isFolderMatch", () => { it("should return file from the folder name 1", async () => { @@ -321,9 +321,10 @@ describe("getNotesFromTags", () => { it("should handle both path and tags, returning files under the specified path with the specified tags", async () => { const mockVault = new Obsidian.Vault(); const tags = ["#tag1"]; + type TFileCtor = new (path: string) => TFile; const noteFiles: TFile[] = [ - new (TFile as any)("test/test2/note1.md"), - new (TFile as any)("test/note2.md"), + new (TFile as unknown as TFileCtor)("test/test2/note1.md"), + new (TFile as unknown as TFileCtor)("test/note2.md"), ]; const expectedPaths = ["test/test2/note1.md"];