chore(eslint): enable no-unsafe-assignment for tests (#2434)

Adds a test-only override enforcing @typescript-eslint/no-unsafe-assignment
on **/*.test.{ts,tsx} and fixes all 378 violations across 44 test files.
Production code (~499 violations) remains a follow-up PR — the rule stays
"off" globally, with the violation count comment updated to reflect that.

Common fix patterns applied:
- Typed accessor helpers (asInternal) for private-method access via
  `as unknown as Shape`, replacing `(instance as any).method(...)`.
- Inline `as <Type>` on mock indexing (mock.calls[i][j]), JSON.parse,
  and jest.requireMock results.
- mockTFile()/mockTFolder() from @/__tests__/mockObsidian replacing
  `Object.create(TFile.prototype)` (also avoids obsidianmd/no-tfile-tfolder-cast).
- `as unknown as RealType` for mock-object casts where `as any` previously
  masked the assignment.
This commit is contained in:
Zero Liu 2026-05-13 22:56:46 -07:00 committed by GitHub
parent 500bc347a0
commit 280d95ebde
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 891 additions and 569 deletions

View file

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

View file

@ -1,5 +1,59 @@
import { BedrockChatModel } from "./BedrockChatModel";
type ProcessStreamResult = {
deltaChunks: Array<{
text?: string;
message: {
content: unknown;
additional_kwargs?: { delta?: { reasoning?: string } };
};
}>;
usage?: Record<string, unknown>;
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<ProcessStreamResult>;
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);

View file

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

View file

@ -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: "<tool></tool>",
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);
});
});

View file

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

View file

@ -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<typeof parseCustomCommandFile>[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<typeof parseCustomCommandFile>[0]
);
const result = await parseCustomCommandFile(mockFile);
expect(result).toEqual({
title: "Test Command",
modelKey: "",

View file

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

View file

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

View file

@ -16,7 +16,7 @@ window.app = {
plugins: {
plugins: {},
},
} as any;
} as unknown as typeof window.app;
describe("ContextProcessor - Dataview Integration", () => {
let contextProcessor: ContextProcessor;

View file

@ -12,7 +12,7 @@ import { EMBEDDED_NOTE_TAG } from "@/constants";
import { ChainType } from "@/chainType";
import { TFile, Vault } from "obsidian";
type FileCacheMap = Record<string, Record<string, unknown>>;
type FileCacheMap = Record<string, unknown>;
type FileContentMap = Record<string, string>;
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;

View file

@ -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<MessageRepository>;
mockChainManager = {
memoryManager: {
@ -140,7 +141,7 @@ describe("ChatManager", () => {
mockContextManager = {
processMessageContext: jest.fn(),
reprocessMessageContext: jest.fn(),
} as any;
} as unknown as jest.Mocked<ContextManager>;
// 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

View file

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

View file

@ -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<string> };
};
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("<web_selected_text>");
@ -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 <selected_text> 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<void>;
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<Vault>;
// Mock app
mockApp = {
vault: mockVault,
} as any;
} as unknown as jest.Mocked<App>;
// 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<BaseChatModel>;
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

View file

@ -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", () => {

View file

@ -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<Vault> {
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) },

View file

@ -40,11 +40,7 @@ function setupAppMock(rawContent: string, frontmatter: Record<string, unknown> |
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: {

View file

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

View file

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

View file

@ -5,8 +5,8 @@ import { TieredLexicalRetriever } from "./TieredLexicalRetriever";
import { HybridRetriever } from "@/search/hybridRetriever";
const optionsSpy: {
lexicalOptions?: Record<string, any>;
semanticOptions?: Record<string, any>;
lexicalOptions?: Record<string, unknown>;
semanticOptions?: Record<string, unknown>;
} = {};
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<string, unknown>) => {
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<string, unknown>) => {
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,

View file

@ -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: [],

View file

@ -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<string, unknown>;
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}`);
}
});

View file

@ -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<string, unknown>) | null;
indexedChunks: Set<string>;
};
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<string, unknown> = { name: "A" };
const b: Record<string, unknown> = { name: "B" };
a.ref = b;
b.ref = a; // circular
const propsCache: Record<string, unknown> = {
const propsCache: Record<string, { headings: unknown[]; frontmatter: unknown }> = {
"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 () => {

View file

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

View file

@ -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<object>("@/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);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,6 +4,9 @@ import {
runRandomReadCommand,
} from "@/services/obsidianCli/ObsidianCliClient";
type InvokableTool = { invoke: (args: Record<string, unknown>) => Promise<string> };
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"
);
});

View file

@ -14,6 +14,20 @@ jest.mock("@/services/obsidianCli/ObsidianCliClient", () => ({
const mockedRunCommand = runObsidianCliCommand as jest.MockedFunction<typeof runObsidianCliCommand>;
/**
* 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<string, unknown>) => Promise<string> };
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"
);
});

View file

@ -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<any> => {
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<ReadNoteResult> => {
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"));

View file

@ -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([
{

View file

@ -1,11 +1,17 @@
import { DateTime } from "luxon";
import { getTimeRangeMsTool } from "./TimeTools";
type InvokableTool = { invoke: (args: Record<string, unknown>) => Promise<string> };
type TimeRangeResult = { startTime: number; endTime: number; error?: string };
// Helper function to call the tool and parse result
const getTimeRangeMs = async (timeExpression: string): Promise<any> => {
const result = await (getTimeRangeMsTool as any).invoke({ timeExpression });
const getTimeRangeMs = async (timeExpression: string): Promise<TimeRangeResult | undefined> => {
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);

View file

@ -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<unknown>;
}
// Helper to invoke tool and parse result
const invokeGetCurrentTime = async (args: { timezoneOffset?: string }): Promise<any> => {
const result = await (getCurrentTimeTool as any).invoke(args);
return typeof result === "string" ? JSON.parse(result) : result;
const invokeGetCurrentTime = async (args: { timezoneOffset?: string }): Promise<TimeResult> => {
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<any> => {
const result = await (convertTimeBetweenTimezonesTool as any).invoke(args);
return typeof result === "string" ? JSON.parse(result) : result;
}): Promise<TimeResult> => {
const result = await (convertTimeBetweenTimezonesTool as unknown as InvokableTool).invoke(args);
return (typeof result === "string" ? JSON.parse(result) : result) as TimeResult;
};
describe("TimeTools Timezone Tests", () => {

View file

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