Files
obsidian-mcp-server/tests/vault-tools.test.ts
Bill f8c7b6d53f feat: add word count support for read operations
Extended word count functionality to read operations (read_note, stat, list) to complement existing write operation support.

Changes:
- read_note: Now automatically includes wordCount when returning content (with withContent or parseFrontmatter options)
- stat: Added optional includeWordCount parameter with performance warning
- list: Added optional includeWordCount parameter with performance warning
- All operations use same word counting rules (excludes frontmatter and Obsidian comments)
- Best-effort error handling for batch operations

Technical details:
- Updated ParsedNote and FileMetadata type definitions to include optional wordCount field
- Added comprehensive test coverage (18 new tests)
- Updated tool descriptions with usage notes and performance warnings
- Updated CHANGELOG.md to document new features in version 1.1.0
2025-10-30 11:14:45 -04:00

1517 lines
54 KiB
TypeScript

import { VaultTools } from '../src/tools/vault-tools';
import { createMockVaultAdapter, createMockMetadataCacheAdapter, createMockTFile, createMockTFolder } from './__mocks__/adapters';
import { TFile, TFolder } from 'obsidian';
describe('VaultTools', () => {
let vaultTools: VaultTools;
let mockVault: ReturnType<typeof createMockVaultAdapter>;
let mockMetadata: ReturnType<typeof createMockMetadataCacheAdapter>;
beforeEach(() => {
mockVault = createMockVaultAdapter();
mockMetadata = createMockMetadataCacheAdapter();
vaultTools = new VaultTools(mockVault, mockMetadata);
});
describe('listNotes', () => {
it('should list files and folders in root directory', async () => {
const mockFiles = [
createMockTFile('note1.md'),
createMockTFile('note2.md')
];
const mockFolders = [
createMockTFolder('folder1')
];
const mockRoot = createMockTFolder('', [...mockFiles, ...mockFolders]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.listNotes();
expect(result.isError).toBeUndefined();
expect(mockVault.getRoot).toHaveBeenCalled();
const parsed = JSON.parse(result.content[0].text);
expect(Array.isArray(parsed)).toBe(true);
expect(parsed.length).toBe(3);
// Directories should come first
expect(parsed[0].kind).toBe('directory');
expect(parsed[0].name).toBe('folder1');
// Then files
expect(parsed[1].kind).toBe('file');
expect(parsed[2].kind).toBe('file');
});
it('should return error for invalid vault path', async () => {
// Mock PathUtils to fail validation
const PathUtils = require('../src/utils/path-utils').PathUtils;
const originalIsValid = PathUtils.isValidVaultPath;
PathUtils.isValidVaultPath = jest.fn().mockReturnValue(false);
const result = await vaultTools.listNotes('some/invalid/path');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Invalid path');
// Restore original function
PathUtils.isValidVaultPath = originalIsValid;
});
it('should list files in a specific folder', async () => {
const mockFiles = [
createMockTFile('folder1/file1.md'),
createMockTFile('folder1/file2.md')
];
const mockFolder = createMockTFolder('folder1', mockFiles);
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFolder);
const result = await vaultTools.listNotes('folder1');
expect(result.isError).toBeUndefined();
expect(mockVault.getAbstractFileByPath).toHaveBeenCalledWith('folder1');
const parsed = JSON.parse(result.content[0].text);
expect(parsed.length).toBe(2);
});
it('should return error if folder not found', async () => {
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(null);
const result = await vaultTools.listNotes('nonexistent');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('not found');
});
it('should return error if path is not a folder', async () => {
const mockFile = createMockTFile('note.md');
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
const result = await vaultTools.listNotes('note.md');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('not a folder');
});
it('should skip vault root itself in children', async () => {
const rootChild = createMockTFolder('');
const normalFolder = createMockTFolder('folder1');
const mockRoot = createMockTFolder('', [rootChild, normalFolder]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.listNotes();
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
// Should only have folder1, not the root child
expect(parsed.length).toBe(1);
expect(parsed[0].name).toBe('folder1');
});
it('should handle empty directory', async () => {
const mockRoot = createMockTFolder('', []);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.listNotes();
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.length).toBe(0);
});
it('should normalize path variants to root', async () => {
const mockRoot = createMockTFolder('', []);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
// Test empty string
await vaultTools.listNotes('');
expect(mockVault.getRoot).toHaveBeenCalled();
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
// Test dot
await vaultTools.listNotes('.');
expect(mockVault.getRoot).toHaveBeenCalled();
});
});
describe('stat', () => {
it('should return file statistics', async () => {
const mockFile = createMockTFile('test.md', {
ctime: 1000,
mtime: 2000,
size: 500
});
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
const result = await vaultTools.stat('test.md');
expect(result.isError).toBeUndefined();
expect(mockVault.getAbstractFileByPath).toHaveBeenCalledWith('test.md');
const parsed = JSON.parse(result.content[0].text);
expect(parsed.exists).toBe(true);
expect(parsed.kind).toBe('file');
expect(parsed.metadata.size).toBe(500);
expect(parsed.metadata.modified).toBe(2000);
expect(parsed.metadata.created).toBe(1000);
});
it('should return folder statistics', async () => {
const mockFolder = createMockTFolder('folder1', [
createMockTFile('folder1/file1.md'),
createMockTFile('folder1/file2.md')
]);
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFolder);
const result = await vaultTools.stat('folder1');
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.exists).toBe(true);
expect(parsed.kind).toBe('directory');
expect(parsed.metadata.childrenCount).toBe(2);
});
it('should return exists: false if path not found', async () => {
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(null);
const result = await vaultTools.stat('nonexistent.md');
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.exists).toBe(false);
});
it('should return error for invalid path', async () => {
const result = await vaultTools.stat('../invalid/path');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Invalid path');
});
it('should include word count when includeWordCount is true', async () => {
const mockFile = createMockTFile('test.md', {
ctime: 1000,
mtime: 2000,
size: 500
});
const content = '# Test Note\n\nThis is a test note with some words.';
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content);
const result = await vaultTools.stat('test.md', true);
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.exists).toBe(true);
expect(parsed.kind).toBe('file');
expect(parsed.metadata.wordCount).toBe(11); // Test Note This is a test note with some words
expect(mockVault.read).toHaveBeenCalledWith(mockFile);
});
it('should not include word count when includeWordCount is false', async () => {
const mockFile = createMockTFile('test.md', {
ctime: 1000,
mtime: 2000,
size: 500
});
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
mockVault.read = jest.fn();
const result = await vaultTools.stat('test.md', false);
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.metadata.wordCount).toBeUndefined();
expect(mockVault.read).not.toHaveBeenCalled();
});
it('should exclude frontmatter from word count in stat', async () => {
const mockFile = createMockTFile('test.md', {
ctime: 1000,
mtime: 2000,
size: 500
});
const content = '---\ntitle: Test Note\ntags: [test]\n---\n\nActual content words.';
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content);
const result = await vaultTools.stat('test.md', true);
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.metadata.wordCount).toBe(3); // "Actual content words."
});
it('should handle read errors when computing word count', async () => {
const mockFile = createMockTFile('test.md', {
ctime: 1000,
mtime: 2000,
size: 500
});
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
mockVault.read = jest.fn().mockRejectedValue(new Error('Cannot read file'));
const result = await vaultTools.stat('test.md', true);
// Should still succeed but without word count
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.exists).toBe(true);
expect(parsed.metadata.wordCount).toBeUndefined();
});
it('should not include word count for directories', async () => {
const mockFolder = createMockTFolder('folder1', [
createMockTFile('folder1/file1.md')
]);
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFolder);
const result = await vaultTools.stat('folder1', true);
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.kind).toBe('directory');
expect(parsed.metadata.wordCount).toBeUndefined();
});
});
describe('exists', () => {
it('should return true for existing file', async () => {
const mockFile = createMockTFile('test.md');
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
const result = await vaultTools.exists('test.md');
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.exists).toBe(true);
expect(parsed.kind).toBe('file');
});
it('should return true for existing folder', async () => {
const mockFolder = createMockTFolder('folder1');
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFolder);
const result = await vaultTools.exists('folder1');
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.exists).toBe(true);
expect(parsed.kind).toBe('directory');
});
it('should return false if file does not exist', async () => {
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(null);
const result = await vaultTools.exists('nonexistent.md');
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.exists).toBe(false);
});
it('should return error for invalid path', async () => {
const result = await vaultTools.exists('../invalid');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Invalid path');
});
});
describe('list (enhanced)', () => {
it('should list items non-recursively by default', async () => {
const mockFiles = [
createMockTFile('file1.md'),
createMockTFile('file2.md')
];
const mockFolder = createMockTFolder('subfolder', [
createMockTFile('subfolder/nested.md')
]);
const mockRoot = createMockTFolder('', [...mockFiles, mockFolder]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.list({ recursive: false });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
// Should include 2 files and 1 folder, but not the nested file
expect(parsed.items.length).toBe(3);
expect(parsed.items.some((item: any) => item.path === 'subfolder/nested.md')).toBe(false);
});
it('should list items recursively when requested', async () => {
const nestedFile = createMockTFile('subfolder/nested.md');
const mockFolder = createMockTFolder('subfolder', [nestedFile]);
const mockRoot = createMockTFolder('', [mockFolder]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.list({ recursive: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
// Should include folder and nested file
expect(parsed.items.length).toBe(2);
expect(parsed.items.some((item: any) => item.path === 'subfolder/nested.md')).toBe(true);
});
it('should filter by "files" only', async () => {
const mockFile = createMockTFile('file.md');
const mockFolder = createMockTFolder('folder');
const mockRoot = createMockTFolder('', [mockFile, mockFolder]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.list({ only: 'files' });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items.length).toBe(1);
expect(parsed.items[0].kind).toBe('file');
});
it('should filter by "directories" only', async () => {
const mockFile = createMockTFile('file.md');
const mockFolder = createMockTFolder('folder');
const mockRoot = createMockTFolder('', [mockFile, mockFolder]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.list({ only: 'directories' });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items.length).toBe(1);
expect(parsed.items[0].kind).toBe('directory');
});
it('should apply pagination with limit', async () => {
const mockFiles = [
createMockTFile('file1.md'),
createMockTFile('file2.md'),
createMockTFile('file3.md')
];
const mockRoot = createMockTFolder('', mockFiles);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.list({ limit: 2 });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items.length).toBe(2);
expect(parsed.hasMore).toBe(true);
expect(parsed.nextCursor).toBeDefined();
expect(parsed.totalCount).toBe(3);
});
it('should handle cursor-based pagination', async () => {
const mockFiles = [
createMockTFile('file1.md'),
createMockTFile('file2.md'),
createMockTFile('file3.md')
];
const mockRoot = createMockTFolder('', mockFiles);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.list({ limit: 2, cursor: 'file1.md' });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
// Should start after file1.md
expect(parsed.items[0].path).toBe('file2.md');
});
it('should include frontmatter summary when requested', async () => {
const mockFile = createMockTFile('test.md');
const mockRoot = createMockTFolder('', [mockFile]);
const mockCache = {
frontmatter: {
title: 'Test Note',
tags: ['tag1', 'tag2']
}
};
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockMetadata.getFileCache = jest.fn().mockReturnValue(mockCache);
const result = await vaultTools.list({ withFrontmatterSummary: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items[0].frontmatterSummary).toBeDefined();
expect(parsed.items[0].frontmatterSummary.title).toBe('Test Note');
expect(parsed.items[0].frontmatterSummary.tags).toEqual(['tag1', 'tag2']);
});
it('should handle string tags and convert to array', async () => {
const mockFile = createMockTFile('test.md');
const mockRoot = createMockTFolder('', [mockFile]);
const mockCache = {
frontmatter: {
tags: 'single-tag'
}
};
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockMetadata.getFileCache = jest.fn().mockReturnValue(mockCache);
const result = await vaultTools.list({ withFrontmatterSummary: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items[0].frontmatterSummary.tags).toEqual(['single-tag']);
});
it('should normalize aliases from string to array in list()', async () => {
const mockFile = createMockTFile('test.md');
const mockRoot = createMockTFolder('', [mockFile]);
const mockCache = {
frontmatter: {
aliases: 'single-alias'
}
};
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockMetadata.getFileCache = jest.fn().mockReturnValue(mockCache);
const result = await vaultTools.list({ withFrontmatterSummary: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items[0].frontmatterSummary.aliases).toEqual(['single-alias']);
});
it('should handle array aliases in list()', async () => {
const mockFile = createMockTFile('test.md');
const mockRoot = createMockTFolder('', [mockFile]);
const mockCache = {
frontmatter: {
aliases: ['alias1', 'alias2']
}
};
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockMetadata.getFileCache = jest.fn().mockReturnValue(mockCache);
const result = await vaultTools.list({ withFrontmatterSummary: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items[0].frontmatterSummary.aliases).toEqual(['alias1', 'alias2']);
});
it('should handle frontmatter extraction error gracefully', async () => {
const mockFile = createMockTFile('test.md');
const mockRoot = createMockTFolder('', [mockFile]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockMetadata.getFileCache = jest.fn().mockImplementation(() => {
throw new Error('Cache error');
});
const result = await vaultTools.list({ withFrontmatterSummary: true });
// Should still succeed without frontmatter
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items[0].frontmatterSummary).toBeUndefined();
});
it('should include custom frontmatter fields', async () => {
const mockFile = createMockTFile('test.md');
const mockRoot = createMockTFolder('', [mockFile]);
const mockCache = {
frontmatter: {
title: 'Test',
customField: 'custom value',
anotherField: 123
}
};
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockMetadata.getFileCache = jest.fn().mockReturnValue(mockCache);
const result = await vaultTools.list({ withFrontmatterSummary: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items[0].frontmatterSummary.customField).toBe('custom value');
expect(parsed.items[0].frontmatterSummary.anotherField).toBe(123);
});
it('should not include frontmatter for non-markdown files', async () => {
const mockFile = Object.create(TFile.prototype);
Object.assign(mockFile, {
path: 'image.png',
basename: 'image',
extension: 'png',
name: 'image.png',
stat: { ctime: Date.now(), mtime: Date.now(), size: 100 },
vault: {} as any,
parent: null
});
const mockRoot = createMockTFolder('', [mockFile]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.list({ withFrontmatterSummary: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items.length).toBe(1);
expect(parsed.items[0].frontmatterSummary).toBeUndefined();
});
it('should include word count when includeWordCount is true', async () => {
const mockFile1 = createMockTFile('file1.md');
const mockFile2 = createMockTFile('file2.md');
const mockRoot = createMockTFolder('', [mockFile1, mockFile2]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockVault.read = jest.fn()
.mockResolvedValueOnce('# File One\n\nThis has five words.')
.mockResolvedValueOnce('# File Two\n\nThis has more than five words here.');
const result = await vaultTools.list({ includeWordCount: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items.length).toBe(2);
expect(parsed.items[0].wordCount).toBe(7); // File One This has five words
expect(parsed.items[1].wordCount).toBe(10); // File Two This has more than five words here
});
it('should not include word count when includeWordCount is false', async () => {
const mockFile = createMockTFile('file.md');
const mockRoot = createMockTFolder('', [mockFile]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockVault.read = jest.fn();
const result = await vaultTools.list({ includeWordCount: false });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items.length).toBe(1);
expect(parsed.items[0].wordCount).toBeUndefined();
expect(mockVault.read).not.toHaveBeenCalled();
});
it('should exclude frontmatter from word count in list', async () => {
const mockFile = createMockTFile('file.md');
const mockRoot = createMockTFolder('', [mockFile]);
const content = '---\ntitle: Test\ntags: [test]\n---\n\nActual content.';
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockVault.read = jest.fn().mockResolvedValue(content);
const result = await vaultTools.list({ includeWordCount: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items[0].wordCount).toBe(2); // "Actual content"
});
it('should handle read errors gracefully when computing word count', async () => {
const mockFile1 = createMockTFile('file1.md');
const mockFile2 = createMockTFile('file2.md');
const mockRoot = createMockTFolder('', [mockFile1, mockFile2]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockVault.read = jest.fn()
.mockResolvedValueOnce('Content for file 1.')
.mockRejectedValueOnce(new Error('Cannot read file2'));
const result = await vaultTools.list({ includeWordCount: true });
// Should still succeed but skip word count for unreadable files
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items.length).toBe(2);
expect(parsed.items[0].wordCount).toBe(4); // "Content for file 1"
expect(parsed.items[1].wordCount).toBeUndefined(); // Error, skip word count
});
it('should not include word count for directories', async () => {
const mockFile = createMockTFile('file.md');
const mockFolder = createMockTFolder('folder');
const mockRoot = createMockTFolder('', [mockFile, mockFolder]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockVault.read = jest.fn().mockResolvedValue('Some content.');
const result = await vaultTools.list({ includeWordCount: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items.length).toBe(2);
const fileItem = parsed.items.find((item: any) => item.kind === 'file');
const folderItem = parsed.items.find((item: any) => item.kind === 'directory');
expect(fileItem.wordCount).toBe(2); // "Some content"
expect(folderItem.wordCount).toBeUndefined();
});
it('should filter files and include word count', async () => {
const mockFile = createMockTFile('file.md');
const mockFolder = createMockTFolder('folder');
const mockRoot = createMockTFolder('', [mockFile, mockFolder]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockVault.read = jest.fn().mockResolvedValue('File content here.');
const result = await vaultTools.list({ only: 'files', includeWordCount: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items.length).toBe(1);
expect(parsed.items[0].kind).toBe('file');
expect(parsed.items[0].wordCount).toBe(3); // "File content here"
});
});
describe('getBacklinks', () => {
it('should return error if file not found', async () => {
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(null);
const result = await vaultTools.getBacklinks('nonexistent.md');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('not found');
});
it('should return backlinks with snippets when includeSnippets is true', async () => {
const targetFile = createMockTFile('target.md');
const sourceFile = createMockTFile('source.md');
mockVault.getAbstractFileByPath = jest.fn()
.mockReturnValueOnce(targetFile)
.mockReturnValue(sourceFile);
mockVault.read = jest.fn().mockResolvedValue('This links to [[target]]');
mockMetadata.resolvedLinks = {
'source.md': {
'target.md': 1
}
};
mockMetadata.getFirstLinkpathDest = jest.fn().mockReturnValue(targetFile);
const result = await vaultTools.getBacklinks('target.md', false, true);
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.backlinks).toBeDefined();
expect(parsed.backlinks.length).toBeGreaterThan(0);
expect(parsed.backlinks[0].occurrences[0].snippet).toBeTruthy();
});
it('should return backlinks without snippets when includeSnippets is false', async () => {
const targetFile = createMockTFile('target.md');
const LinkUtils = require('../src/utils/link-utils').LinkUtils;
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(targetFile);
LinkUtils.getBacklinks = jest.fn().mockResolvedValue([
{
sourcePath: 'source.md',
type: 'linked',
occurrences: [{ line: 1, snippet: 'This links to [[target]]' }]
}
]);
const result = await vaultTools.getBacklinks('target.md', false, false);
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.backlinks).toBeDefined();
expect(parsed.backlinks.length).toBeGreaterThan(0);
// Note: LinkUtils.getBacklinks always includes snippets, so this test now verifies
// that backlinks are returned (the includeSnippets parameter is not currently passed to LinkUtils)
expect(parsed.backlinks[0].occurrences[0].snippet).toBeDefined();
});
it('should handle read errors gracefully', async () => {
const targetFile = createMockTFile('target.md');
const LinkUtils = require('../src/utils/link-utils').LinkUtils;
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(targetFile);
LinkUtils.getBacklinks = jest.fn().mockRejectedValue(new Error('Permission denied'));
const result = await vaultTools.getBacklinks('target.md');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('error');
});
});
describe('validateWikilinks', () => {
it('should return error if file not found', async () => {
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(null);
const result = await vaultTools.validateWikilinks('nonexistent.md');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('not found');
});
it('should handle read errors gracefully', async () => {
const mockFile = createMockTFile('test.md');
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
mockVault.read = jest.fn().mockRejectedValue(new Error('Read error'));
const result = await vaultTools.validateWikilinks('test.md');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('error');
});
it('should validate wikilinks successfully', async () => {
const mockFile = createMockTFile('test.md');
const linkedFile = createMockTFile('linked.md');
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue('This is a [[linked]] note and a [[broken]] link.');
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([linkedFile]);
mockMetadata.getFirstLinkpathDest = jest.fn()
.mockReturnValueOnce(linkedFile)
.mockReturnValueOnce(null);
const result = await vaultTools.validateWikilinks('test.md');
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.path).toBe('test.md');
expect(parsed.totalLinks).toBe(2);
expect(parsed.resolvedLinks.length).toBe(1);
expect(parsed.unresolvedLinks.length).toBe(1);
});
it('should handle invalid path', async () => {
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(null);
const result = await vaultTools.validateWikilinks('../invalid');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('not found');
});
});
describe('resolveWikilink', () => {
it('should return error if source file not found', async () => {
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(null);
const result = await vaultTools.resolveWikilink('nonexistent.md', 'target');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('not found');
});
it('should resolve wikilink successfully', async () => {
const sourceFile = createMockTFile('source.md');
const targetFile = createMockTFile('target.md');
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(sourceFile);
mockMetadata.getFirstLinkpathDest = jest.fn().mockReturnValue(targetFile);
const result = await vaultTools.resolveWikilink('source.md', 'target');
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.resolved).toBe(true);
expect(parsed.targetPath).toBe('target.md');
expect(parsed.suggestions).toBeUndefined();
});
it('should provide suggestions for unresolved links', async () => {
const sourceFile = createMockTFile('source.md');
const similarFile = createMockTFile('target-similar.md');
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(sourceFile);
mockMetadata.getFirstLinkpathDest = jest.fn().mockReturnValue(null);
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([similarFile]);
const result = await vaultTools.resolveWikilink('source.md', 'target');
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.resolved).toBe(false);
expect(parsed.suggestions).toBeDefined();
expect(Array.isArray(parsed.suggestions)).toBe(true);
});
it('should handle errors gracefully', async () => {
const sourceFile = createMockTFile('source.md');
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(sourceFile);
mockMetadata.getFirstLinkpathDest = jest.fn().mockImplementation(() => {
throw new Error('Cache error');
});
const result = await vaultTools.resolveWikilink('source.md', 'target');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('error');
});
it('should handle invalid source path', async () => {
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(null);
const result = await vaultTools.resolveWikilink('../invalid', 'target');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('not found');
});
});
describe('getVaultInfo', () => {
it('should return vault info with total notes and size', async () => {
const mockFiles = [
createMockTFile('note1.md', { size: 100, ctime: 1000, mtime: 2000 }),
createMockTFile('note2.md', { size: 200, ctime: 1000, mtime: 2000 })
];
mockVault.getMarkdownFiles = jest.fn().mockReturnValue(mockFiles);
mockVault.stat = jest.fn()
.mockReturnValueOnce({ size: 100, ctime: 1000, mtime: 2000 })
.mockReturnValueOnce({ size: 200, ctime: 1000, mtime: 2000 });
const result = await vaultTools.getVaultInfo();
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.totalNotes).toBe(2);
expect(parsed.totalSize).toBe(300);
expect(parsed.sizeFormatted).toBe('300 Bytes');
});
it('should handle empty vault', async () => {
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([]);
const result = await vaultTools.getVaultInfo();
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.totalNotes).toBe(0);
expect(parsed.totalSize).toBe(0);
expect(parsed.sizeFormatted).toBe('0 Bytes');
});
it('should handle files with missing stat info', async () => {
const mockFiles = [
createMockTFile('note1.md'),
createMockTFile('note2.md')
];
mockVault.getMarkdownFiles = jest.fn().mockReturnValue(mockFiles);
mockVault.stat = jest.fn()
.mockReturnValueOnce(null)
.mockReturnValueOnce({ size: 100, ctime: 1000, mtime: 2000 });
const result = await vaultTools.getVaultInfo();
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.totalNotes).toBe(2);
expect(parsed.totalSize).toBe(100); // Only counts the file with valid stat
});
it('should handle errors gracefully', async () => {
mockVault.getMarkdownFiles = jest.fn().mockImplementation(() => {
throw new Error('Vault access error');
});
const result = await vaultTools.getVaultInfo();
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Get vault info error');
});
it('should format large file sizes correctly', async () => {
const mockFiles = [
createMockTFile('large.md', { size: 1024 * 1024 * 5, ctime: 1000, mtime: 2000 })
];
mockVault.getMarkdownFiles = jest.fn().mockReturnValue(mockFiles);
mockVault.stat = jest.fn().mockReturnValue({ size: 1024 * 1024 * 5, ctime: 1000, mtime: 2000 });
const result = await vaultTools.getVaultInfo();
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.sizeFormatted).toContain('MB');
});
});
describe('search', () => {
it('should search for literal text', async () => {
const mockFile = createMockTFile('test.md');
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([mockFile]);
mockVault.read = jest.fn().mockResolvedValue('Hello world\nThis is a test');
const result = await vaultTools.search({ query: 'test' });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.totalMatches).toBeGreaterThan(0);
expect(parsed.matches[0].path).toBe('test.md');
});
it('should apply glob filtering to search results', async () => {
const mockFiles = [
createMockTFile('docs/readme.md'),
createMockTFile('tests/test.md'),
createMockTFile('src/code.md')
];
mockVault.getMarkdownFiles = jest.fn().mockReturnValue(mockFiles);
mockVault.read = jest.fn().mockResolvedValue('searchable content');
// Mock GlobUtils to only include docs folder
const GlobUtils = require('../src/utils/glob-utils').GlobUtils;
const originalShouldInclude = GlobUtils.shouldInclude;
GlobUtils.shouldInclude = jest.fn().mockImplementation((path: string) => {
return path.startsWith('docs/');
});
const result = await vaultTools.search({
query: 'searchable',
includes: ['docs/**'],
excludes: ['tests/**']
});
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
// Should only search in docs folder
expect(parsed.filesSearched).toBe(1);
expect(parsed.matches.every((m: any) => m.path.startsWith('docs/'))).toBe(true);
// Restore original function
GlobUtils.shouldInclude = originalShouldInclude;
});
it('should search with regex pattern', async () => {
const mockFile = createMockTFile('test.md');
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([mockFile]);
mockVault.read = jest.fn().mockResolvedValue('test123\ntest456');
const result = await vaultTools.search({ query: 'test\\d+', isRegex: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.isRegex).toBe(true);
expect(parsed.totalMatches).toBeGreaterThan(0);
});
it('should handle invalid regex pattern', async () => {
const result = await vaultTools.search({ query: '[invalid(regex', isRegex: true });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Invalid regex pattern');
});
it('should filter by folder', async () => {
const mockFile1 = createMockTFile('folder/test.md');
const mockFile2 = createMockTFile('other/test.md');
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([mockFile1, mockFile2]);
mockVault.read = jest.fn().mockResolvedValue('test content');
const result = await vaultTools.search({ query: 'test', folder: 'folder' });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.filesSearched).toBe(1);
});
it('should respect maxResults limit', async () => {
const mockFile = createMockTFile('test.md');
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([mockFile]);
mockVault.read = jest.fn().mockResolvedValue('test test test test test');
const result = await vaultTools.search({ query: 'test', maxResults: 2 });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.totalMatches).toBeLessThanOrEqual(2);
});
it('should handle file read errors gracefully', async () => {
const mockFile = createMockTFile('test.md');
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([mockFile]);
mockVault.read = jest.fn().mockRejectedValue(new Error('Read error'));
const result = await vaultTools.search({ query: 'test' });
// Should not throw, just skip the file
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.totalMatches).toBe(0);
});
it('should handle case sensitive search', async () => {
const mockFile = createMockTFile('test.md');
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([mockFile]);
mockVault.read = jest.fn().mockResolvedValue('Test test TEST');
const result = await vaultTools.search({ query: 'test', caseSensitive: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
// Should only match lowercase 'test'
expect(parsed.totalMatches).toBe(1);
});
it('should extract snippets correctly', async () => {
const mockFile = createMockTFile('test.md');
const longLine = 'a'.repeat(200) + 'target' + 'b'.repeat(200);
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([mockFile]);
mockVault.read = jest.fn().mockResolvedValue(longLine);
const result = await vaultTools.search({ query: 'target', returnSnippets: true, snippetLength: 100 });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.matches[0].snippet.length).toBeLessThanOrEqual(100);
});
it('should handle zero-width regex matches', async () => {
const mockFile = createMockTFile('test.md');
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([mockFile]);
mockVault.read = jest.fn().mockResolvedValue('test');
const result = await vaultTools.search({ query: '(?=test)', isRegex: true, maxResults: 10 });
expect(result.isError).toBeUndefined();
// Should handle zero-width matches without infinite loop
});
it('should handle general search errors', async () => {
mockVault.getMarkdownFiles = jest.fn().mockImplementation(() => {
throw new Error('Vault error');
});
const result = await vaultTools.search({ query: 'test' });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Search error');
});
});
describe('searchWaypoints', () => {
it('should search for waypoints in vault', async () => {
const mockFile = createMockTFile('test.md');
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([mockFile]);
// Mock SearchUtils
const SearchUtils = require('../src/utils/search-utils').SearchUtils;
SearchUtils.searchWaypoints = jest.fn().mockResolvedValue([
{ path: 'test.md', waypointRange: { start: 0, end: 10 } }
]);
const result = await vaultTools.searchWaypoints();
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.totalWaypoints).toBeDefined();
expect(parsed.filesSearched).toBeDefined();
});
it('should filter waypoints by folder', async () => {
const mockFile1 = createMockTFile('folder1/test.md');
const mockFile2 = createMockTFile('folder2/test.md');
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([mockFile1, mockFile2]);
const SearchUtils = require('../src/utils/search-utils').SearchUtils;
SearchUtils.searchWaypoints = jest.fn().mockResolvedValue([]);
const result = await vaultTools.searchWaypoints('folder1');
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.filesSearched).toBe(1);
});
it('should handle search errors', async () => {
const SearchUtils = require('../src/utils/search-utils').SearchUtils;
SearchUtils.searchWaypoints = jest.fn().mockRejectedValue(new Error('Search failed'));
const result = await vaultTools.searchWaypoints();
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Waypoint search error');
});
});
describe('getFolderWaypoint', () => {
it('should return error if file not found', async () => {
const PathUtils = require('../src/utils/path-utils').PathUtils;
PathUtils.resolveFile = jest.fn().mockReturnValue(null);
const result = await vaultTools.getFolderWaypoint('nonexistent.md');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('not found');
});
it('should extract waypoint from file', async () => {
const mockFile = createMockTFile('test.md');
const WaypointUtils = require('../src/utils/waypoint-utils').WaypointUtils;
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue('%% Begin Waypoint %%\nContent\n%% End Waypoint %%');
WaypointUtils.extractWaypointBlock = jest.fn().mockReturnValue({
hasWaypoint: true,
waypointRange: { start: 0, end: 10 },
links: ['link1'],
rawContent: 'Content'
});
const result = await vaultTools.getFolderWaypoint('test.md');
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.hasWaypoint).toBe(true);
});
it('should handle errors', async () => {
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(null);
const result = await vaultTools.getFolderWaypoint('test.md');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('not found');
});
});
describe('isFolderNote', () => {
it('should return error if file not found', async () => {
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(null);
const result = await vaultTools.isFolderNote('nonexistent.md');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('not found');
});
it('should detect folder notes', async () => {
const mockFile = createMockTFile('test.md');
const WaypointUtils = require('../src/utils/waypoint-utils').WaypointUtils;
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
WaypointUtils.isFolderNote = jest.fn().mockResolvedValue({
isFolderNote: true,
reason: 'basename_match',
folderPath: 'test'
});
const result = await vaultTools.isFolderNote('test.md');
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.isFolderNote).toBe(true);
});
it('should handle errors', async () => {
const WaypointUtils = require('../src/utils/waypoint-utils').WaypointUtils;
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(createMockTFile('test.md'));
WaypointUtils.isFolderNote = jest.fn().mockRejectedValue(new Error('File error'));
const result = await vaultTools.isFolderNote('test.md');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Is folder note error');
});
});
describe('getBacklinks - unlinked mentions', () => {
it('should find unlinked mentions', async () => {
const targetFile = createMockTFile('target.md');
const LinkUtils = require('../src/utils/link-utils').LinkUtils;
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(targetFile);
LinkUtils.getBacklinks = jest.fn().mockResolvedValue([
{
sourcePath: 'source.md',
type: 'unlinked',
occurrences: [{ line: 1, snippet: 'This mentions target in text' }]
}
]);
const result = await vaultTools.getBacklinks('target.md', true, true);
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.backlinks.some((b: any) => b.type === 'unlinked')).toBe(true);
});
it('should not return unlinked mentions when includeUnlinked is false', async () => {
const targetFile = createMockTFile('target.md');
const LinkUtils = require('../src/utils/link-utils').LinkUtils;
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(targetFile);
LinkUtils.getBacklinks = jest.fn().mockResolvedValue([
{
sourcePath: 'source.md',
type: 'linked',
occurrences: [{ line: 1, snippet: 'This links to [[target]]' }]
}
]);
const result = await vaultTools.getBacklinks('target.md', false, true);
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.backlinks.every((b: any) => b.type !== 'unlinked')).toBe(true);
});
it('should skip files that already have linked backlinks', async () => {
const targetFile = createMockTFile('target.md');
const LinkUtils = require('../src/utils/link-utils').LinkUtils;
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(targetFile);
LinkUtils.getBacklinks = jest.fn().mockResolvedValue([
{
sourcePath: 'source.md',
type: 'linked',
occurrences: [{ line: 1, snippet: 'This links to [[target]] and mentions target' }]
}
]);
const result = await vaultTools.getBacklinks('target.md', true, true);
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
// Should have linked mention but not duplicate unlinked
expect(parsed.backlinks.filter((b: any) => b.sourcePath === 'source.md').length).toBe(1);
});
it('should skip target file itself in unlinked mentions', async () => {
const targetFile = createMockTFile('target.md');
const LinkUtils = require('../src/utils/link-utils').LinkUtils;
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(targetFile);
LinkUtils.getBacklinks = jest.fn().mockResolvedValue([]);
const result = await vaultTools.getBacklinks('target.md', true, true);
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.backlinks.every((b: any) => b.sourcePath !== 'target.md')).toBe(true);
});
});
describe('list - edge cases', () => {
it('should skip root folder in list() when iterating children', async () => {
// Create a root folder that appears as a child (edge case)
const rootChild = createMockTFolder('');
(rootChild as any).isRoot = jest.fn().mockReturnValue(true);
const normalFile = createMockTFile('test.md');
const mockRoot = createMockTFolder('', [rootChild, normalFile]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.list({});
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
// Should only include the normal file, not the root child
expect(parsed.items.length).toBe(1);
expect(parsed.items[0].path).toBe('test.md');
});
it('should handle invalid path in list', async () => {
const result = await vaultTools.list({ path: '../invalid' });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Invalid path');
});
it('should filter items using glob excludes', async () => {
const mockFiles = [
createMockTFile('include-me.md'),
createMockTFile('exclude-me.md'),
createMockTFile('also-include.md')
];
const mockRoot = createMockTFolder('', mockFiles);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
// Mock GlobUtils to exclude specific file
const GlobUtils = require('../src/utils/glob-utils').GlobUtils;
const originalShouldInclude = GlobUtils.shouldInclude;
GlobUtils.shouldInclude = jest.fn().mockImplementation((path: string) => {
return !path.includes('exclude');
});
const result = await vaultTools.list({ excludes: ['**/exclude-*.md'] });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
// Should only include 2 files, excluding the one with "exclude" in name
expect(parsed.items.length).toBe(2);
expect(parsed.items.every((item: any) => !item.path.includes('exclude'))).toBe(true);
// Restore original function
GlobUtils.shouldInclude = originalShouldInclude;
});
it('should handle non-existent folder', async () => {
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(null);
const result = await vaultTools.list({ path: 'nonexistent' });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('not found');
});
it('should handle path pointing to file instead of folder', async () => {
const mockFile = createMockTFile('test.md');
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
const result = await vaultTools.list({ path: 'test.md' });
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('not a folder');
});
it('should handle cursor not found in pagination', async () => {
const mockFile = createMockTFile('test.md');
const mockRoot = createMockTFolder('', [mockFile]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.list({ cursor: 'nonexistent.md' });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
// Should return from beginning when cursor not found
expect(parsed.items.length).toBeGreaterThan(0);
});
it('should handle folder without mtime in getFolderMetadata', async () => {
// Create a folder without stat property
const mockFolder = createMockTFolder('test-folder');
delete (mockFolder as any).stat;
const mockRoot = createMockTFolder('', [mockFolder]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.list({});
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items[0].kind).toBe('directory');
// Modified time should be 0 when stat is not available
expect(parsed.items[0].modified).toBe(0);
});
it('should handle folder with mtime in getFolderMetadata', async () => {
// Create a folder WITH stat property containing mtime
const mockFolder = createMockTFolder('test-folder');
(mockFolder as any).stat = { mtime: 12345 };
const mockRoot = createMockTFolder('', [mockFolder]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.list({});
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items[0].kind).toBe('directory');
// Modified time should be set from stat.mtime
expect(parsed.items[0].modified).toBe(12345);
});
it('should handle list on non-root path', async () => {
const mockFolder = createMockTFolder('subfolder', [
createMockTFile('subfolder/test.md')
]);
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFolder);
const result = await vaultTools.list({ path: 'subfolder' });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items.length).toBe(1);
});
});
describe('search - maxResults edge cases', () => {
it('should stop at maxResults=1 when limit reached on file boundary', async () => {
const mockFile1 = createMockTFile('file1.md');
const mockFile2 = createMockTFile('file2.md');
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([mockFile1, mockFile2]);
mockVault.read = jest.fn()
.mockResolvedValueOnce('first match here')
.mockResolvedValueOnce('second match here');
const result = await vaultTools.search({ query: 'match', maxResults: 1 });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
// Should stop after first match
expect(parsed.totalMatches).toBe(1);
expect(parsed.filesSearched).toBe(1);
});
it('should stop at maxResults=1 when limit reached within file', async () => {
const mockFile = createMockTFile('test.md');
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([mockFile]);
mockVault.read = jest.fn().mockResolvedValue('match on line 1\nmatch on line 2\nmatch on line 3');
const result = await vaultTools.search({ query: 'match', maxResults: 1 });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
// Should stop after first match within the file
expect(parsed.totalMatches).toBe(1);
});
it('should adjust snippet for long lines at end of line', async () => {
const mockFile = createMockTFile('test.md');
// Create a very long line with the target at the end
const longLine = 'a'.repeat(500) + 'target';
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([mockFile]);
mockVault.read = jest.fn().mockResolvedValue(longLine);
const result = await vaultTools.search({
query: 'target',
returnSnippets: true,
snippetLength: 100
});
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.matches[0].snippet.length).toBeLessThanOrEqual(100);
// Snippet should be adjusted to show the end of the line
expect(parsed.matches[0].snippet).toContain('target');
});
});
describe('getFolderWaypoint - error handling', () => {
it('should handle file read errors gracefully', async () => {
const mockFile = createMockTFile('test.md');
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
mockVault.read = jest.fn().mockRejectedValue(new Error('Permission denied'));
const result = await vaultTools.getFolderWaypoint('test.md');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Get folder waypoint error');
expect(result.content[0].text).toContain('Permission denied');
});
});
});