mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
Remove deprecated model versions (Claude 4/4.5/4.6, Gemini 2.5, GPT 5.4 Pro, Mistral latest aliases) and replace with current model identifiers. Add DEFAULT_QUICK_MODEL_BY_PROVIDER configuration, update default models to use Sonnet 4.6 standard and Opus 4.8 for planning, and adjust Mistral agent to use Small model.
1587 lines
58 KiB
TypeScript
1587 lines
58 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { VaultService } from '../../Services/VaultService';
|
|
import { TFile, TFolder, TAbstractFile, FileManager } from 'obsidian';
|
|
import { Path } from '../../Enums/Path';
|
|
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
|
|
import { Services } from '../../Services/Services';
|
|
import { SanitiserService } from '../../Services/SanitiserService';
|
|
import { SettingsService, type IVaultkeeperAISettings } from '../../Services/SettingsService';
|
|
import { AIProvider, AIProviderModel } from '../../Enums/ApiProvider';
|
|
import { Exception } from '../../Helpers/Exception';
|
|
import { ChatMode } from '../../Enums/ChatMode';
|
|
|
|
/**
|
|
* INTEGRATION TESTS
|
|
*
|
|
* These tests use real dependencies (SanitiserService, DependencyService) and only mock
|
|
* the Obsidian API (Vault, FileManager, TFile, etc.) which is unavoidable in a test environment.
|
|
*
|
|
* This approach tests the actual integration between services and avoids complex mocking.
|
|
*/
|
|
|
|
// Create mock instances
|
|
const mockVault = {
|
|
getMarkdownFiles: vi.fn(),
|
|
getAbstractFileByPath: vi.fn(),
|
|
read: vi.fn(),
|
|
cachedRead: vi.fn(),
|
|
create: vi.fn(),
|
|
process: vi.fn(),
|
|
delete: vi.fn(),
|
|
trash: vi.fn(),
|
|
createFolder: vi.fn(),
|
|
getFiles: vi.fn(),
|
|
getAllFolders: vi.fn(),
|
|
on: vi.fn(),
|
|
adapter: {
|
|
exists: vi.fn()
|
|
}
|
|
};
|
|
|
|
const mockFileManager = {
|
|
renameFile: vi.fn(),
|
|
trashFile: vi.fn()
|
|
} as unknown as FileManager & {
|
|
renameFile: ReturnType<typeof vi.fn>;
|
|
trashFile: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
// Create a mutable settings object that tests can modify
|
|
const mockSettings: IVaultkeeperAISettings = {
|
|
firstTimeStart: false,
|
|
model: AIProviderModel.ClaudeSonnet_4_6,
|
|
planningModel: AIProviderModel.ClaudeSonnet_4_6,
|
|
apiKeys: {
|
|
claude: 'test-claude-key',
|
|
openai: 'test-openai-key',
|
|
gemini: 'test-gemini-key', mistral: 'test-mistral-key'
|
|
},
|
|
exclusions: [],
|
|
userInstruction: '',
|
|
searchResultsLimit: 15,
|
|
snippetSizeLimit: 300,
|
|
enableMemories: false,
|
|
allowUpdatingMemories: true,
|
|
enableWebSearch: true,
|
|
enableWebViewer: false,
|
|
provider: AIProvider.Claude,
|
|
quickActionModel: AIProviderModel.ClaudeSonnet_4_6,
|
|
enableContextMenuActions: false,
|
|
enableToolbarActions: false,
|
|
hideDrawerElements: false,
|
|
chatMode: ChatMode.Edit
|
|
};
|
|
|
|
const mockPlugin = {
|
|
app: {
|
|
vault: mockVault,
|
|
fileManager: mockFileManager
|
|
},
|
|
saveData: vi.fn().mockResolvedValue(undefined),
|
|
registerEvent: vi.fn()
|
|
};
|
|
|
|
// Helper to create mock TFile
|
|
function createMockFile(path: string): TFile {
|
|
const name = path.split('/').pop() || '';
|
|
const basename = name.split('.')[0];
|
|
const file = new TFile();
|
|
file.path = path;
|
|
file.name = name;
|
|
file.basename = basename;
|
|
file.extension = 'md';
|
|
file.stat = { ctime: Date.now(), mtime: Date.now(), size: 100 };
|
|
file.parent = null;
|
|
file.vault = mockVault as any;
|
|
return file;
|
|
}
|
|
|
|
// Helper to create mock TFolder
|
|
function createMockFolder(path: string, children: TAbstractFile[] = []): TFolder {
|
|
const name = path.split('/').pop() || '';
|
|
const folder = new TFolder();
|
|
folder.path = path;
|
|
folder.name = name;
|
|
folder.children = children;
|
|
folder.parent = null;
|
|
folder.vault = mockVault as any;
|
|
return folder;
|
|
}
|
|
|
|
describe('VaultService - Integration Tests', () => {
|
|
let vaultService: VaultService;
|
|
let settingsService: SettingsService;
|
|
let mockDiffService: any;
|
|
let consoleErrorSpy: any;
|
|
|
|
beforeEach(() => {
|
|
// Reset all mocks
|
|
vi.clearAllMocks();
|
|
|
|
// Reset settings to defaults (mutating before SettingsService construction is fine)
|
|
mockSettings.exclusions = [];
|
|
mockSettings.searchResultsLimit = 15;
|
|
mockSettings.snippetSizeLimit = 300;
|
|
|
|
// Set default mock for adapter.exists (can be overridden in individual tests)
|
|
mockVault.adapter.exists.mockResolvedValue(false);
|
|
|
|
// Mock console.error to prevent noise in tests
|
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
// Mock Exception.log
|
|
vi.spyOn(Exception, 'log').mockImplementation(() => {});
|
|
|
|
// Register real dependencies in DependencyService
|
|
RegisterSingleton(Services.VaultkeeperAIPlugin, mockPlugin as any);
|
|
RegisterSingleton(Services.SanitiserService, new SanitiserService());
|
|
|
|
// Mock EventService and DiffService to avoid Obsidian Events dependency
|
|
const mockEventService = { trigger: vi.fn(), on: vi.fn(), off: vi.fn() };
|
|
mockDiffService = {
|
|
requestDiff: vi.fn().mockResolvedValue({ accepted: true }),
|
|
applyPatch: vi.fn(),
|
|
onAccept: vi.fn(),
|
|
onReject: vi.fn(),
|
|
onSuggest: vi.fn()
|
|
};
|
|
RegisterSingleton(Services.EventService, mockEventService as any);
|
|
RegisterSingleton(Services.DiffService, mockDiffService as any);
|
|
|
|
// Create and register SettingsService
|
|
settingsService = new SettingsService(mockSettings);
|
|
RegisterSingleton(Services.SettingsService, settingsService);
|
|
|
|
// Create a fresh instance - it will resolve real dependencies
|
|
vaultService = new VaultService();
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Clear singleton registry to prevent memory leaks
|
|
DeregisterAllServices();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('getMarkdownFiles', () => {
|
|
it('should return all markdown files when no exclusions are set', () => {
|
|
const files = [
|
|
createMockFile('note1.md'),
|
|
createMockFile('note2.md'),
|
|
createMockFile('folder/note3.md')
|
|
];
|
|
mockVault.getMarkdownFiles.mockReturnValue(files);
|
|
|
|
const result = vaultService.getMarkdownFiles();
|
|
|
|
expect(result).toHaveLength(3);
|
|
expect(mockVault.getMarkdownFiles).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('should filter out files in the Vaultkeeper AI root directory by default', () => {
|
|
const files = [
|
|
createMockFile('note1.md'),
|
|
createMockFile('Vaultkeeper AI/conversation.md'),
|
|
createMockFile('Vaultkeeper AI/subfolder/data.md')
|
|
];
|
|
mockVault.getMarkdownFiles.mockReturnValue(files);
|
|
|
|
const result = vaultService.getMarkdownFiles(false);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].path).toBe('note1.md');
|
|
});
|
|
|
|
it('should allow access to Vaultkeeper AI directory when allowAccessToPluginRoot is true', () => {
|
|
const files = [
|
|
createMockFile('note1.md'),
|
|
createMockFile('Vaultkeeper AI/conversation.md')
|
|
];
|
|
mockVault.getMarkdownFiles.mockReturnValue(files);
|
|
|
|
const result = vaultService.getMarkdownFiles(true);
|
|
|
|
expect(result).toHaveLength(2);
|
|
});
|
|
|
|
it('should filter out user-defined exclusions', async () => {
|
|
const files = [
|
|
createMockFile('public/note1.md'),
|
|
createMockFile('private/secret.md'),
|
|
createMockFile('public/note2.md')
|
|
];
|
|
mockVault.getMarkdownFiles.mockReturnValue(files);
|
|
|
|
await settingsService.updateSettings(s => { s.exclusions = ['private/**']; });
|
|
|
|
const result = vaultService.getMarkdownFiles();
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result.every(f => !f.path.startsWith('private/'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('getAbstractFileByPath', () => {
|
|
it('should return file when path is not excluded', () => {
|
|
const mockFile = createMockFile('note.md');
|
|
mockVault.getAbstractFileByPath.mockReturnValue(mockFile);
|
|
|
|
const result = vaultService.getAbstractFileByPath('note.md');
|
|
|
|
expect(result).toBe(mockFile);
|
|
});
|
|
|
|
it('should return null when path is excluded', () => {
|
|
mockVault.getAbstractFileByPath.mockReturnValue(createMockFile('Vaultkeeper AI/test.md'));
|
|
|
|
const result = vaultService.getAbstractFileByPath('Vaultkeeper AI/test.md', false);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should sanitize the path before checking', () => {
|
|
// Path with illegal characters that will be sanitized
|
|
const unsanitizedPath = 'folder/file?.md';
|
|
const mockFile = createMockFile('folder/file.md');
|
|
mockVault.getAbstractFileByPath.mockReturnValue(mockFile);
|
|
|
|
vaultService.getAbstractFileByPath(unsanitizedPath);
|
|
|
|
// SanitiserService will remove the ? character
|
|
expect(mockVault.getAbstractFileByPath).toHaveBeenCalledWith('folder/file.md');
|
|
});
|
|
|
|
it('should allow access to excluded paths when allowAccessToPluginRoot is true', () => {
|
|
const mockFile = createMockFile('Vaultkeeper AI/conversation.md');
|
|
mockVault.getAbstractFileByPath.mockReturnValue(mockFile);
|
|
|
|
const result = vaultService.getAbstractFileByPath('Vaultkeeper AI/conversation.md', true);
|
|
|
|
expect(result).toBe(mockFile);
|
|
});
|
|
|
|
it('should exclude the Vaultkeeper AI directory itself', () => {
|
|
const mockFolder = createMockFolder('Vaultkeeper AI');
|
|
mockVault.getAbstractFileByPath.mockReturnValue(mockFolder);
|
|
|
|
const result = vaultService.getAbstractFileByPath('Vaultkeeper AI', false);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should allow access to Vaultkeeper AI directory when allowAccessToPluginRoot is true', () => {
|
|
const mockFolder = createMockFolder('Vaultkeeper AI');
|
|
mockVault.getAbstractFileByPath.mockReturnValue(mockFolder);
|
|
|
|
const result = vaultService.getAbstractFileByPath('Vaultkeeper AI', true);
|
|
|
|
expect(result).toBe(mockFolder);
|
|
});
|
|
});
|
|
|
|
describe('exists', () => {
|
|
it('should return true when file exists and is not excluded', async () => {
|
|
mockVault.adapter.exists.mockResolvedValue(true);
|
|
|
|
const result = await vaultService.exists('note.md');
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should return false when file is excluded', async () => {
|
|
const result = await vaultService.exists('Vaultkeeper AI/test.md', false);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should return false when file does not exist', async () => {
|
|
mockVault.adapter.exists.mockResolvedValue(false);
|
|
|
|
const result = await vaultService.exists('nonexistent.md');
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should return true when folder exists', async () => {
|
|
mockVault.adapter.exists.mockResolvedValue(true);
|
|
|
|
const result = await vaultService.exists('folder');
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('read', () => {
|
|
it('should read file content when file is not excluded', async () => {
|
|
const mockFile = createMockFile('note.md');
|
|
mockVault.read.mockResolvedValue('file content');
|
|
|
|
const result = await vaultService.read(mockFile);
|
|
|
|
expect(result).toBe('file content');
|
|
expect(mockVault.read).toHaveBeenCalledWith(mockFile);
|
|
});
|
|
|
|
it('should return empty string when file is excluded', async () => {
|
|
const mockFile = createMockFile('Vaultkeeper AI/test.md');
|
|
|
|
const result = await vaultService.read(mockFile, false);
|
|
|
|
expect(result).toBeInstanceOf(Error);
|
|
expect((result as Error).message).toContain('File does not exist: Vaultkeeper AI/test.md');
|
|
expect(mockVault.read).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should allow reading excluded files when allowAccessToPluginRoot is true', async () => {
|
|
const mockFile = createMockFile('Vaultkeeper AI/test.md');
|
|
mockVault.read.mockResolvedValue('content');
|
|
|
|
const result = await vaultService.read(mockFile, true);
|
|
|
|
expect(result).toBe('content');
|
|
expect(mockVault.read).toHaveBeenCalledWith(mockFile);
|
|
});
|
|
});
|
|
|
|
describe('create', () => {
|
|
it('should create a file with sanitized path', async () => {
|
|
const mockFile = createMockFile('note.md');
|
|
mockVault.getAbstractFileByPath.mockReturnValue(null); // No existing directories
|
|
mockVault.create.mockResolvedValue(mockFile);
|
|
mockVault.createFolder.mockResolvedValue(createMockFolder('folder'));
|
|
|
|
const result = await vaultService.create('note.md', 'content');
|
|
|
|
expect(mockVault.create).toHaveBeenCalledWith('note.md', 'content');
|
|
expect(result).toBe(mockFile);
|
|
});
|
|
|
|
it('should return error when trying to create file in excluded path', async () => {
|
|
const result = await vaultService.create('Vaultkeeper AI/test.md', 'content', false);
|
|
|
|
expect(result).toBeInstanceOf(Error);
|
|
expect((result as Error).message).toContain('Failed to create file, permission denied');
|
|
});
|
|
|
|
it('should create parent directories if they do not exist', async () => {
|
|
const mockFile = createMockFile('folder/subfolder/note.md');
|
|
mockVault.getAbstractFileByPath.mockReturnValue(null);
|
|
mockVault.create.mockResolvedValue(mockFile);
|
|
mockVault.createFolder.mockResolvedValue(createMockFolder('folder'));
|
|
|
|
await vaultService.create('folder/subfolder/note.md', 'content');
|
|
|
|
expect(mockVault.createFolder).toHaveBeenCalledTimes(2);
|
|
expect(mockVault.createFolder).toHaveBeenCalledWith('folder');
|
|
expect(mockVault.createFolder).toHaveBeenCalledWith('folder/subfolder');
|
|
});
|
|
|
|
it('should not create directories that already exist', async () => {
|
|
const mockFile = createMockFile('existing/note.md');
|
|
|
|
mockVault.adapter.exists.mockImplementation(async (path: string) => {
|
|
return path === 'existing';
|
|
});
|
|
mockVault.create.mockResolvedValue(mockFile);
|
|
|
|
await vaultService.create('existing/note.md', 'content');
|
|
|
|
// Should not call createFolder for 'existing' since it already exists
|
|
expect(mockVault.createFolder).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('modify', () => {
|
|
it('should modify file content when file is not excluded', async () => {
|
|
const mockFile = createMockFile('note.md');
|
|
mockVault.process.mockResolvedValue(undefined);
|
|
|
|
await vaultService.modify(mockFile, 'new content');
|
|
|
|
expect(mockVault.process).toHaveBeenCalledWith(mockFile, expect.any(Function));
|
|
});
|
|
|
|
it('should not modify file when file is excluded', async () => {
|
|
const mockFile = createMockFile('Vaultkeeper AI/test.md');
|
|
|
|
await vaultService.modify(mockFile, 'new content', false);
|
|
|
|
expect(mockVault.process).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should call vault.process with function that returns new content', async () => {
|
|
const mockFile = createMockFile('note.md');
|
|
let processCallback: any;
|
|
|
|
mockVault.process.mockImplementation((file, fn) => {
|
|
processCallback = fn;
|
|
return Promise.resolve();
|
|
});
|
|
|
|
await vaultService.modify(mockFile, 'new content');
|
|
|
|
expect(processCallback()).toBe('new content');
|
|
});
|
|
});
|
|
|
|
describe('patch', () => {
|
|
it('should apply patch successfully when file is not excluded', async () => {
|
|
const mockFile = createMockFile('note.md');
|
|
const oldContent = ['old content'];
|
|
const newContent = ['new content'];
|
|
const currentContent = '# Title\nold content\nmore lines';
|
|
|
|
mockVault.read.mockResolvedValue(currentContent);
|
|
mockDiffService.requestDiff.mockResolvedValue({ accepted: true });
|
|
mockVault.process.mockResolvedValue(undefined);
|
|
|
|
const result = await vaultService.patch(mockFile, oldContent, newContent);
|
|
|
|
expect(mockVault.read).toHaveBeenCalledWith(mockFile);
|
|
expect(mockDiffService.requestDiff).toHaveBeenCalled();
|
|
expect(mockVault.process).toHaveBeenCalled();
|
|
expect(result).toBe(mockFile);
|
|
});
|
|
|
|
it('should return error when file is excluded', async () => {
|
|
const mockFile = createMockFile('Vaultkeeper AI/test.md');
|
|
const oldContent = ['old'];
|
|
const newContent = ['new'];
|
|
|
|
const result = await vaultService.patch(mockFile, oldContent, newContent, false);
|
|
|
|
expect(result).toBeInstanceOf(Error);
|
|
expect((result as Error).message).toContain('File does not exist');
|
|
expect(mockVault.process).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return error when old content is not found in file', async () => {
|
|
const mockFile = createMockFile('note.md');
|
|
const oldContent = ['old'];
|
|
const newContent = ['new'];
|
|
const currentContent = '# Different content';
|
|
|
|
mockVault.read.mockResolvedValue(currentContent);
|
|
|
|
const result = await vaultService.patch(mockFile, oldContent, newContent);
|
|
|
|
expect(result).toBeInstanceOf(Error);
|
|
expect((result as Error).message).toContain('Content to replace was not found in the file');
|
|
expect(mockVault.process).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should allow patching excluded files when allowAccessToPluginRoot is true', async () => {
|
|
const mockFile = createMockFile('Vaultkeeper AI/config.md');
|
|
const oldContent = ['setting=old'];
|
|
const newContent = ['setting=new'];
|
|
const currentContent = 'setting=old';
|
|
|
|
mockVault.read.mockResolvedValue(currentContent);
|
|
mockDiffService.requestDiff.mockResolvedValue({ accepted: true });
|
|
mockVault.process.mockResolvedValue(undefined);
|
|
|
|
const result = await vaultService.patch(mockFile, oldContent, newContent, true);
|
|
|
|
expect(mockVault.read).toHaveBeenCalledWith(mockFile);
|
|
expect(result).toBe(mockFile);
|
|
});
|
|
|
|
it('should request diff confirmation when requiresConfirmation is true', async () => {
|
|
const mockFile = createMockFile('note.md');
|
|
const oldContent = ['old'];
|
|
const newContent = ['new'];
|
|
const currentContent = 'old';
|
|
const updatedContent = 'new';
|
|
|
|
mockVault.read.mockResolvedValue(currentContent);
|
|
mockDiffService.requestDiff.mockResolvedValue({ accepted: true });
|
|
mockVault.process.mockResolvedValue(undefined);
|
|
|
|
await vaultService.patch(mockFile, oldContent, newContent, false, true);
|
|
|
|
expect(mockDiffService.requestDiff).toHaveBeenCalledWith(
|
|
mockFile.name,
|
|
mockFile.name,
|
|
currentContent,
|
|
updatedContent
|
|
);
|
|
});
|
|
|
|
it('should replace only the first occurrence of old content', async () => {
|
|
const mockFile = createMockFile('document.md');
|
|
const oldContent = ['duplicate text'];
|
|
const newContent = ['replaced text'];
|
|
const currentContent = 'duplicate text\nSome content\nduplicate text';
|
|
const expectedContent = 'replaced text\nSome content\nduplicate text';
|
|
|
|
mockVault.read.mockResolvedValue(currentContent);
|
|
mockDiffService.requestDiff.mockResolvedValue({ accepted: true });
|
|
|
|
let processCallback: any;
|
|
mockVault.process.mockImplementation((_file, fn) => {
|
|
processCallback = fn;
|
|
return Promise.resolve();
|
|
});
|
|
|
|
await vaultService.patch(mockFile, oldContent, newContent);
|
|
|
|
expect(processCallback()).toBe(expectedContent);
|
|
});
|
|
|
|
it('should call vault.process with function that returns updated content', async () => {
|
|
const mockFile = createMockFile('note.md');
|
|
const oldContent = ['old'];
|
|
const newContent = ['new'];
|
|
const updatedContent = 'new';
|
|
let processCallback: any;
|
|
|
|
mockVault.read.mockResolvedValue('old');
|
|
mockDiffService.requestDiff.mockResolvedValue({ accepted: true });
|
|
mockVault.process.mockImplementation((file, fn) => {
|
|
processCallback = fn;
|
|
return Promise.resolve();
|
|
});
|
|
|
|
await vaultService.patch(mockFile, oldContent, newContent);
|
|
|
|
expect(processCallback()).toBe(updatedContent);
|
|
});
|
|
|
|
it('should fall back to whitespace-flexible matching when exact match fails', async () => {
|
|
const mockFile = createMockFile('note.md');
|
|
const oldContent = [' if (x) {\n return true;\n }'];
|
|
const newContent = [' if (x) {\n return false;\n }'];
|
|
const currentContent = ' if (x) {\n return true;\n }';
|
|
|
|
mockVault.read.mockResolvedValue(currentContent);
|
|
mockDiffService.requestDiff.mockResolvedValue({ accepted: true });
|
|
|
|
let processCallback: any;
|
|
mockVault.process.mockImplementation((_file, fn) => {
|
|
processCallback = fn;
|
|
return Promise.resolve();
|
|
});
|
|
|
|
const result = await vaultService.patch(mockFile, oldContent, newContent);
|
|
|
|
expect(result).toBe(mockFile);
|
|
expect(processCallback()).toBe(' if (x) {\n return false;\n }');
|
|
});
|
|
|
|
it('should prefer exact match over whitespace-flexible match', async () => {
|
|
const mockFile = createMockFile('note.md');
|
|
const oldContent = ['hello world'];
|
|
const newContent = ['goodbye world'];
|
|
const currentContent = 'hello world';
|
|
|
|
mockVault.read.mockResolvedValue(currentContent);
|
|
mockDiffService.requestDiff.mockResolvedValue({ accepted: true });
|
|
|
|
let processCallback: any;
|
|
mockVault.process.mockImplementation((_file, fn) => {
|
|
processCallback = fn;
|
|
return Promise.resolve();
|
|
});
|
|
|
|
await vaultService.patch(mockFile, oldContent, newContent);
|
|
|
|
expect(processCallback()).toBe('goodbye world');
|
|
});
|
|
|
|
it('should skip confirmation when requiresConfirmation is false', async () => {
|
|
const mockFile = createMockFile('note.md');
|
|
const oldContent = ['old'];
|
|
const newContent = ['new'];
|
|
|
|
mockVault.read.mockResolvedValue('old');
|
|
mockVault.process.mockResolvedValue(undefined);
|
|
|
|
// Clear previous calls
|
|
mockDiffService.requestDiff.mockClear();
|
|
|
|
await vaultService.patch(mockFile, oldContent, newContent, false, false);
|
|
|
|
// When requiresConfirmation is false, requestDiff should not be called
|
|
// because proposeChange should skip the diff step
|
|
expect(mockVault.process).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('delete', () => {
|
|
it('should delete file successfully when not excluded', async () => {
|
|
const mockFile = createMockFile('note.md');
|
|
mockFileManager.trashFile.mockResolvedValue(undefined);
|
|
|
|
const result = await vaultService.delete(mockFile);
|
|
|
|
expect(result).toBeUndefined(); // void = success
|
|
expect(mockFileManager.trashFile).toHaveBeenCalledWith(mockFile);
|
|
});
|
|
|
|
it('should not delete file and return error when excluded', async () => {
|
|
const mockFile = createMockFile('Vaultkeeper AI/test.md');
|
|
|
|
const result = await vaultService.delete(mockFile, false);
|
|
|
|
expect(result).toBeInstanceOf(Error);
|
|
expect((result as Error).message).toContain('File does not exist');
|
|
expect(mockFileManager.trashFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should call fileManager.trashFile to delete file', async () => {
|
|
const mockFile = createMockFile('note.md');
|
|
mockFileManager.trashFile.mockResolvedValue(undefined);
|
|
|
|
await vaultService.delete(mockFile, true);
|
|
|
|
expect(mockFileManager.trashFile).toHaveBeenCalledWith(mockFile);
|
|
});
|
|
|
|
it('should return error when deletion fails', async () => {
|
|
const mockFile = createMockFile('note.md');
|
|
mockFileManager.trashFile.mockRejectedValue(new Error('Deletion failed'));
|
|
|
|
const result = await vaultService.delete(mockFile);
|
|
|
|
expect(result).toBeInstanceOf(Error);
|
|
expect((result as Error).message).toBe('Deletion failed');
|
|
});
|
|
|
|
it('should handle non-Error objects in catch block', async () => {
|
|
const mockFile = createMockFile('note.md');
|
|
mockFileManager.trashFile.mockRejectedValue('string error');
|
|
|
|
const result = await vaultService.delete(mockFile);
|
|
|
|
expect(result).toBeInstanceOf(Error);
|
|
expect((result as Error).message).toBe('string error');
|
|
});
|
|
});
|
|
|
|
describe('move', () => {
|
|
it('should move file successfully when source is not excluded', async () => {
|
|
const mockFile = createMockFile('source.md');
|
|
mockVault.getAbstractFileByPath.mockReturnValue(mockFile);
|
|
mockFileManager.renameFile.mockResolvedValue(undefined);
|
|
|
|
const result = await vaultService.move('source.md', 'dest.md');
|
|
|
|
expect(result).toBeUndefined(); // void = success
|
|
expect(mockFileManager.renameFile).toHaveBeenCalledWith(mockFile, 'dest.md');
|
|
});
|
|
|
|
it('should return error when source file is excluded', async () => {
|
|
const result = await vaultService.move('Vaultkeeper AI/test.md', 'dest.md', false);
|
|
|
|
expect(result).toBeInstanceOf(Error);
|
|
expect((result as Error).message).toContain('Move failed as source does not exist');
|
|
expect(mockFileManager.renameFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return error when source file does not exist', async () => {
|
|
mockVault.getAbstractFileByPath.mockReturnValue(null);
|
|
|
|
const result = await vaultService.move('nonexistent.md', 'dest.md');
|
|
|
|
expect(result).toBeInstanceOf(Error);
|
|
expect((result as Error).message).toContain('Move failed as source does not exist');
|
|
expect(mockFileManager.renameFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should create destination directories if needed', async () => {
|
|
const mockFile = createMockFile('source.md');
|
|
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
|
|
if (path === 'source.md') return mockFile;
|
|
return null;
|
|
});
|
|
mockVault.createFolder.mockResolvedValue(createMockFolder('folder'));
|
|
mockFileManager.renameFile.mockResolvedValue(undefined);
|
|
|
|
await vaultService.move('source.md', 'folder/dest.md');
|
|
|
|
expect(mockVault.createFolder).toHaveBeenCalledWith('folder');
|
|
});
|
|
|
|
it('should return error when move operation fails', async () => {
|
|
const mockFile = createMockFile('source.md');
|
|
mockVault.getAbstractFileByPath.mockReturnValue(mockFile);
|
|
mockFileManager.renameFile.mockRejectedValue(new Error('Move failed'));
|
|
|
|
const result = await vaultService.move('source.md', 'dest.md');
|
|
|
|
expect(result).toBeInstanceOf(Error);
|
|
expect((result as Error).message).toBe('Move failed');
|
|
});
|
|
});
|
|
|
|
describe('createDirectories', () => {
|
|
it('should create a single directory with no slashes', async () => {
|
|
mockVault.getAbstractFileByPath.mockReturnValue(null);
|
|
mockVault.createFolder.mockResolvedValue(createMockFolder('Zap'));
|
|
|
|
await vaultService.createDirectories('Zap');
|
|
|
|
expect(mockVault.createFolder).toHaveBeenCalledWith('Zap');
|
|
});
|
|
|
|
it('should create all intermediate directories for a directory path', async () => {
|
|
mockVault.getAbstractFileByPath.mockReturnValue(null);
|
|
mockVault.createFolder.mockResolvedValue(createMockFolder(''));
|
|
|
|
await vaultService.createDirectories('Zap/Test');
|
|
|
|
expect(mockVault.createFolder).toHaveBeenCalledWith('Zap');
|
|
expect(mockVault.createFolder).toHaveBeenCalledWith('Zap/Test');
|
|
});
|
|
|
|
it('should create parent directories for a file path', async () => {
|
|
mockVault.getAbstractFileByPath.mockReturnValue(null);
|
|
mockVault.createFolder.mockResolvedValue(createMockFolder(''));
|
|
|
|
await vaultService.createDirectories('Zap/Test/file.md');
|
|
|
|
expect(mockVault.createFolder).toHaveBeenCalledWith('Zap');
|
|
expect(mockVault.createFolder).toHaveBeenCalledWith('Zap/Test');
|
|
expect(mockVault.createFolder).not.toHaveBeenCalledWith('Zap/Test/file.md');
|
|
});
|
|
|
|
it('should skip directories that already exist', async () => {
|
|
mockVault.adapter.exists.mockResolvedValue(true);
|
|
|
|
await vaultService.createDirectories('Zap');
|
|
|
|
expect(mockVault.createFolder).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return error when trying to create directory in excluded path', async () => {
|
|
const result = await vaultService.createDirectories('Vaultkeeper AI/subfolder', false);
|
|
|
|
expect(result).toBeInstanceOf(Error);
|
|
expect((result as Error).message).toContain('Failed to create the following directories');
|
|
});
|
|
});
|
|
|
|
describe('listFilesInDirectory', () => {
|
|
it('should list all files in directory non-recursively', async () => {
|
|
const file1 = createMockFile('folder/file1.md');
|
|
const file2 = createMockFile('folder/file2.md');
|
|
const folder = createMockFolder('folder', [file1, file2]);
|
|
|
|
mockVault.getAbstractFileByPath.mockReturnValue(folder);
|
|
|
|
const result = await vaultService.listFilesInDirectory('folder', false);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result).toContain(file1);
|
|
expect(result).toContain(file2);
|
|
});
|
|
|
|
it('should list all files recursively', async () => {
|
|
const file1 = createMockFile('folder/file1.md');
|
|
const file2 = createMockFile('folder/sub/file2.md');
|
|
const subfolder = createMockFolder('folder/sub', [file2]);
|
|
const folder = createMockFolder('folder', [file1, subfolder]);
|
|
|
|
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
|
|
if (path === 'folder') return folder;
|
|
if (path === 'folder/sub') return subfolder;
|
|
return null;
|
|
});
|
|
|
|
const result = await vaultService.listFilesInDirectory('folder', true);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result).toContain(file1);
|
|
expect(result).toContain(file2);
|
|
});
|
|
|
|
it('should filter out excluded files from results', async () => {
|
|
const file1 = createMockFile('folder/public.md');
|
|
const file2 = createMockFile('folder/private.md');
|
|
const folder = createMockFolder('folder', [file1, file2]);
|
|
|
|
mockVault.getAbstractFileByPath.mockReturnValue(folder);
|
|
await settingsService.updateSettings(s => { s.exclusions = ['**/private.md']; });
|
|
|
|
const result = await vaultService.listFilesInDirectory('folder', false);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].path).toBe('folder/public.md');
|
|
});
|
|
|
|
it('should return empty array when directory does not exist', async () => {
|
|
mockVault.getAbstractFileByPath.mockReturnValue(null);
|
|
|
|
const result = await vaultService.listFilesInDirectory('nonexistent');
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('should return empty array when path is a file, not a directory', async () => {
|
|
const mockFile = createMockFile('file.md');
|
|
mockVault.getAbstractFileByPath.mockReturnValue(mockFile);
|
|
|
|
const result = await vaultService.listFilesInDirectory('file.md');
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('should respect allowAccessToPluginRoot parameter when accessing directory', async () => {
|
|
const file1 = createMockFile('Vaultkeeper AI/test.md');
|
|
const file2 = createMockFile('Vaultkeeper AI/notes.md');
|
|
const agentFolder = createMockFolder('Vaultkeeper AI', [file1, file2]);
|
|
|
|
mockVault.getAbstractFileByPath.mockReturnValue(agentFolder);
|
|
|
|
// Create a spy on the vaultService.getAbstractFileByPath method
|
|
const getAbstractFileSpy = vi.spyOn(vaultService, 'getAbstractFileByPath');
|
|
|
|
// Call with allowAccessToPluginRoot = false (should block access)
|
|
await vaultService.listFilesInDirectory('Vaultkeeper AI', true, false);
|
|
|
|
// Verify getAbstractFileByPath was called with the correct parameter
|
|
expect(getAbstractFileSpy).toHaveBeenCalledWith('Vaultkeeper AI', false);
|
|
|
|
// Reset
|
|
getAbstractFileSpy.mockClear();
|
|
|
|
// Call with allowAccessToPluginRoot = true (should allow access)
|
|
await vaultService.listFilesInDirectory('Vaultkeeper AI', true, true);
|
|
|
|
// Verify getAbstractFileByPath was called with the correct parameter
|
|
expect(getAbstractFileSpy).toHaveBeenCalledWith('Vaultkeeper AI', true);
|
|
});
|
|
|
|
it('should not access excluded directory when allowAccessToPluginRoot is false', async () => {
|
|
// Mock to simulate VaultService exclusion behavior
|
|
mockVault.getAbstractFileByPath.mockReturnValue(null);
|
|
|
|
// Create a spy to verify the correct parameter is passed
|
|
const getAbstractFileSpy = vi.spyOn(vaultService, 'getAbstractFileByPath');
|
|
|
|
// Try to list files in Vaultkeeper AI directory with allowAccessToPluginRoot = false
|
|
const result = await vaultService.listFilesInDirectory('Vaultkeeper AI', true, false);
|
|
|
|
// Should call getAbstractFileByPath with false (not hardcoded true)
|
|
expect(getAbstractFileSpy).toHaveBeenCalledWith('Vaultkeeper AI', false);
|
|
|
|
// Should return empty array since directory is excluded
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('listFoldersInDirectory', () => {
|
|
it('should list all folders in directory non-recursively', async () => {
|
|
const folder1 = createMockFolder('parent/folder1', []);
|
|
const folder2 = createMockFolder('parent/folder2', []);
|
|
const parentFolder = createMockFolder('parent', [folder1, folder2]);
|
|
|
|
mockVault.getAbstractFileByPath.mockReturnValue(parentFolder);
|
|
|
|
const result = await vaultService.listFoldersInDirectory('parent', false);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result).toContain(folder1);
|
|
expect(result).toContain(folder2);
|
|
});
|
|
|
|
it('should list all folders recursively', async () => {
|
|
const subfolder1 = createMockFolder('parent/child/subfolder1', []);
|
|
const childFolder = createMockFolder('parent/child', [subfolder1]);
|
|
const folder1 = createMockFolder('parent/folder1', []);
|
|
const parentFolder = createMockFolder('parent', [folder1, childFolder]);
|
|
|
|
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
|
|
if (path === 'parent') return parentFolder;
|
|
if (path === 'parent/folder1') return folder1;
|
|
if (path === 'parent/child') return childFolder;
|
|
return null;
|
|
});
|
|
|
|
const result = await vaultService.listFoldersInDirectory('parent', true);
|
|
|
|
expect(result).toHaveLength(3);
|
|
expect(result).toContain(folder1);
|
|
expect(result).toContain(childFolder);
|
|
expect(result).toContain(subfolder1);
|
|
});
|
|
|
|
it('should filter out excluded folders from results', async () => {
|
|
const publicFolder = createMockFolder('parent/public', []);
|
|
const privateFolder = createMockFolder('parent/private', []);
|
|
const parentFolder = createMockFolder('parent', [publicFolder, privateFolder]);
|
|
|
|
mockVault.getAbstractFileByPath.mockReturnValue(parentFolder);
|
|
await settingsService.updateSettings(s => { s.exclusions = ['**/private']; });
|
|
|
|
const result = await vaultService.listFoldersInDirectory('parent', false);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].path).toBe('parent/public');
|
|
});
|
|
|
|
it('should return empty array when directory does not exist', async () => {
|
|
mockVault.getAbstractFileByPath.mockReturnValue(null);
|
|
|
|
const result = await vaultService.listFoldersInDirectory('nonexistent');
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('should return empty array when path is a file, not a directory', async () => {
|
|
const mockFile = createMockFile('file.md');
|
|
mockVault.getAbstractFileByPath.mockReturnValue(mockFile);
|
|
|
|
const result = await vaultService.listFoldersInDirectory('file.md');
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('should respect allowAccessToPluginRoot parameter', async () => {
|
|
const agentFolder = createMockFolder('Vaultkeeper AI', []);
|
|
const notesFolder = createMockFolder('notes', []);
|
|
const rootFolder = createMockFolder('/', [agentFolder, notesFolder]);
|
|
|
|
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
|
|
if (path === '/' || path === '') return rootFolder;
|
|
// Vaultkeeper AI blocked when allowAccessToPluginRoot = false
|
|
if (path === 'Vaultkeeper AI') return null;
|
|
if (path === 'notes') return notesFolder;
|
|
return null;
|
|
});
|
|
|
|
// With allowAccessToPluginRoot = false (should exclude Vaultkeeper AI)
|
|
const result1 = await vaultService.listFoldersInDirectory('/', true, false);
|
|
expect(result1.some((folder) => folder.path === 'Vaultkeeper AI')).toBe(false);
|
|
expect(result1.some((folder) => folder.path === 'notes')).toBe(true);
|
|
|
|
// With allowAccessToPluginRoot = true (should include Vaultkeeper AI)
|
|
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
|
|
if (path === '/' || path === '') return rootFolder;
|
|
if (path === 'Vaultkeeper AI') return agentFolder; // Now allowed
|
|
if (path === 'notes') return notesFolder;
|
|
return null;
|
|
});
|
|
|
|
const result2 = await vaultService.listFoldersInDirectory('/', true, true);
|
|
expect(result2.some((folder) => folder.path === 'Vaultkeeper AI')).toBe(true);
|
|
expect(result2.some((folder) => folder.path === 'notes')).toBe(true);
|
|
});
|
|
|
|
it('should not recurse into excluded folders', async () => {
|
|
const excludedSubfolder = createMockFolder('parent/excluded/sub', []);
|
|
const excludedFolder = createMockFolder('parent/excluded', [excludedSubfolder]);
|
|
const allowedFolder = createMockFolder('parent/allowed', []);
|
|
const parentFolder = createMockFolder('parent', [excludedFolder, allowedFolder]);
|
|
|
|
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
|
|
if (path === 'parent') return parentFolder;
|
|
if (path === 'parent/excluded') return excludedFolder; // Should not be accessed
|
|
if (path === 'parent/allowed') return allowedFolder;
|
|
return null;
|
|
});
|
|
|
|
await settingsService.updateSettings(s => { s.exclusions = ['parent/excluded/**', 'parent/excluded']; });
|
|
|
|
const result = await vaultService.listFoldersInDirectory('parent', true);
|
|
|
|
// Should not include excluded folder or its subfolders
|
|
expect(result.some((folder) => folder.path === 'parent/excluded')).toBe(false);
|
|
expect(result.some((folder) => folder.path === 'parent/excluded/sub')).toBe(false);
|
|
expect(result.some((folder) => folder.path === 'parent/allowed')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('searchVaultFiles', () => {
|
|
it('should find matches in file content', async () => {
|
|
const file1 = createMockFile('note1.md');
|
|
const file2 = createMockFile('note2.md');
|
|
const folder = createMockFolder('/', [file1, file2]);
|
|
|
|
mockVault.getAbstractFileByPath.mockReturnValue(folder);
|
|
mockVault.cachedRead.mockImplementation((file: TFile) => {
|
|
if (file.path === 'note1.md') return Promise.resolve('This is a test document with test word');
|
|
if (file.path === 'note2.md') return Promise.resolve('Another document');
|
|
return Promise.resolve('');
|
|
});
|
|
|
|
const results = await vaultService.searchVaultFiles('test');
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
const match = results.find(r => r.file.path === 'note1.md');
|
|
expect(match).toBeDefined();
|
|
expect(match!.snippets.length).toBeGreaterThan(0);
|
|
expect(match!.snippets[0].pageNumber).toBe(1);
|
|
});
|
|
|
|
it('should find filename matches', async () => {
|
|
const file = createMockFile('test-file.md');
|
|
const folder = createMockFolder('/', [file]);
|
|
|
|
mockVault.getAbstractFileByPath.mockReturnValue(folder);
|
|
mockVault.cachedRead.mockResolvedValue('No matches in content');
|
|
|
|
const results = await vaultService.searchVaultFiles('test');
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
const match = results.find(r => r.file.path === 'test-file.md');
|
|
expect(match).toBeDefined();
|
|
});
|
|
|
|
it('should handle invalid regex gracefully by escaping', async () => {
|
|
const file = createMockFile('note.md');
|
|
const folder = createMockFolder('/', [file]);
|
|
|
|
mockVault.getAbstractFileByPath.mockReturnValue(folder);
|
|
mockVault.cachedRead.mockResolvedValue('Contains [special] characters');
|
|
|
|
// Should not throw error
|
|
const results = await vaultService.searchVaultFiles('[invalid regex');
|
|
|
|
expect(results).toBeDefined();
|
|
});
|
|
|
|
it('should extract snippets with context around matches', async () => {
|
|
const file = createMockFile('note.md');
|
|
const folder = createMockFolder('/', [file]);
|
|
const content = 'a'.repeat(100) + 'MATCH' + 'b'.repeat(100);
|
|
|
|
mockVault.getAbstractFileByPath.mockReturnValue(folder);
|
|
mockVault.cachedRead.mockResolvedValue(content);
|
|
|
|
const results = await vaultService.searchVaultFiles('MATCH');
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
const match = results[0];
|
|
expect(match.snippets[0].text.length).toBeGreaterThan('MATCH'.length);
|
|
expect(match.snippets[0].text).toContain('MATCH');
|
|
expect(match.snippets[0].pageNumber).toBe(1);
|
|
});
|
|
|
|
it('should merge overlapping snippets', async () => {
|
|
const file = createMockFile('note.md');
|
|
const folder = createMockFolder('/', [file]);
|
|
// Two matches close together that should be merged
|
|
const content = 'test ' + 'a'.repeat(50) + ' test';
|
|
|
|
mockVault.getAbstractFileByPath.mockReturnValue(folder);
|
|
mockVault.cachedRead.mockResolvedValue(content);
|
|
|
|
const results = await vaultService.searchVaultFiles('test');
|
|
|
|
// Should merge into one snippet since they overlap
|
|
expect(results.length).toBeGreaterThan(0);
|
|
// The exact behavior depends on implementation details
|
|
});
|
|
|
|
it('should randomly sample when more than searchResultsLimit matches', async () => {
|
|
// Create 25 files, each with a match
|
|
const files: TFile[] = [];
|
|
for (let i = 0; i < 25; i++) {
|
|
files.push(createMockFile(`note${i}.md`));
|
|
}
|
|
const folder = createMockFolder('/', files);
|
|
|
|
mockVault.getAbstractFileByPath.mockReturnValue(folder);
|
|
mockVault.cachedRead.mockResolvedValue('This contains the search term');
|
|
|
|
const results = await vaultService.searchVaultFiles('search');
|
|
|
|
// Should have at most searchResultsLimit snippet matches (plus potentially filename matches)
|
|
const totalSnippets = results.reduce((sum, r) => sum + r.snippets.length, 0);
|
|
expect(totalSnippets).toBeLessThanOrEqual(settingsService.settings.searchResultsLimit);
|
|
});
|
|
|
|
it('should respect custom searchResultsLimit setting', async () => {
|
|
await settingsService.updateSettings(s => { s.searchResultsLimit = 5; });
|
|
|
|
// Create 10 files, each with a match
|
|
const files: TFile[] = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
files.push(createMockFile(`note${i}.md`));
|
|
}
|
|
const folder = createMockFolder('/', files);
|
|
|
|
mockVault.getAbstractFileByPath.mockReturnValue(folder);
|
|
mockVault.cachedRead.mockResolvedValue('This contains the search term');
|
|
|
|
const results = await vaultService.searchVaultFiles('search');
|
|
|
|
// Should have at most 5 snippet matches
|
|
const totalSnippets = results.reduce((sum, r) => sum + r.snippets.length, 0);
|
|
expect(totalSnippets).toBeLessThanOrEqual(5);
|
|
});
|
|
|
|
it('should respect custom snippetSizeLimit setting for snippet extraction', async () => {
|
|
await settingsService.updateSettings(s => { s.snippetSizeLimit = 20; });
|
|
|
|
const file = createMockFile('note.md');
|
|
const folder = createMockFolder('/', [file]);
|
|
const content = 'a'.repeat(100) + 'MATCH' + 'b'.repeat(100);
|
|
|
|
mockVault.getAbstractFileByPath.mockReturnValue(folder);
|
|
mockVault.cachedRead.mockResolvedValue(content);
|
|
|
|
const results = await vaultService.searchVaultFiles('MATCH');
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
const match = results[0];
|
|
// Snippet should be approximately snippetSizeLimit characters (10 before + 5 for MATCH + 10 after)
|
|
// Allow some margin for the match itself
|
|
expect(match.snippets[0].text.length).toBeLessThanOrEqual(settingsService.settings.snippetSizeLimit + 10);
|
|
expect(match.snippets[0].pageNumber).toBe(1);
|
|
});
|
|
|
|
it('should perform case-insensitive search', async () => {
|
|
const file = createMockFile('note.md');
|
|
const folder = createMockFolder('/', [file]);
|
|
|
|
mockVault.getAbstractFileByPath.mockReturnValue(folder);
|
|
mockVault.cachedRead.mockResolvedValue('Test TEST tEsT');
|
|
|
|
const results = await vaultService.searchVaultFiles('test');
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
// Should find all three variants
|
|
});
|
|
|
|
it('should parse regex literal with case-insensitive flag', async () => {
|
|
const file = createMockFile('note.md');
|
|
const folder = createMockFolder('/', [file]);
|
|
|
|
mockVault.getAbstractFileByPath.mockReturnValue(folder);
|
|
mockVault.cachedRead.mockResolvedValue('This note contains information about MAUI development');
|
|
|
|
// Build the regex pattern string to avoid vitest transformer issues
|
|
const pattern = '/' + 'maui' + '/' + 'i';
|
|
const results = await vaultService.searchVaultFiles(pattern);
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
const match = results.find(r => r.file.path === 'note.md');
|
|
expect(match).toBeDefined();
|
|
expect(match!.snippets.length).toBeGreaterThan(0);
|
|
expect(match!.snippets[0].text).toContain('MAUI');
|
|
expect(match!.snippets[0].pageNumber).toBe(1);
|
|
});
|
|
|
|
it('should parse regex with word boundary patterns', async () => {
|
|
const file = createMockFile('note.md');
|
|
const folder = createMockFolder('/', [file]);
|
|
|
|
mockVault.getAbstractFileByPath.mockReturnValue(folder);
|
|
mockVault.cachedRead.mockResolvedValue('docker dockerfiles and dockers');
|
|
|
|
// Should match only whole word "docker", not "dockers" or "dockerfiles"
|
|
const pattern = '/' + '\\b' + 'docker' + '\\b' + '/' + 'i';
|
|
const results = await vaultService.searchVaultFiles(pattern);
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
const match = results.find(r => r.file.path === 'note.md');
|
|
expect(match).toBeDefined();
|
|
});
|
|
|
|
it('should parse regex with alternation patterns', async () => {
|
|
const file = createMockFile('note.md');
|
|
const folder = createMockFolder('/', [file]);
|
|
|
|
mockVault.getAbstractFileByPath.mockReturnValue(folder);
|
|
mockVault.cachedRead.mockResolvedValue('I prefer gray over grey');
|
|
|
|
// Should match both "gray" and "grey"
|
|
const pattern = '/' + 'gr(a|e)y' + '/' + 'i';
|
|
const results = await vaultService.searchVaultFiles(pattern);
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
const match = results.find(r => r.file.path === 'note.md');
|
|
expect(match).toBeDefined();
|
|
});
|
|
|
|
it('should parse regex with optional characters', async () => {
|
|
const file = createMockFile('note.md');
|
|
const folder = createMockFolder('/', [file]);
|
|
|
|
mockVault.getAbstractFileByPath.mockReturnValue(folder);
|
|
mockVault.cachedRead.mockResolvedValue('kubernetes k8s and kube configurations');
|
|
|
|
// Should match kubernetes, k8s, or kube
|
|
const pattern = '/' + '(kubernetes|k8s|kube)' + '/' + 'i';
|
|
const results = await vaultService.searchVaultFiles(pattern);
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
const match = results.find(r => r.file.path === 'note.md');
|
|
expect(match).toBeDefined();
|
|
});
|
|
|
|
it('should parse regex with wildcard sequences', async () => {
|
|
const file = createMockFile('note.md');
|
|
const folder = createMockFolder('/', [file]);
|
|
|
|
mockVault.getAbstractFileByPath.mockReturnValue(folder);
|
|
mockVault.cachedRead.mockResolvedValue('project alpha and proj_alpha are both valid');
|
|
|
|
// Should match "project alpha", "proj_alpha", "proj alpha", etc.
|
|
const pattern = '/' + 'proj.*alpha' + '/' + 'i';
|
|
const results = await vaultService.searchVaultFiles(pattern);
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
const match = results.find(r => r.file.path === 'note.md');
|
|
expect(match).toBeDefined();
|
|
});
|
|
|
|
it('should parse regex with character classes', async () => {
|
|
const file = createMockFile('note.md');
|
|
const folder = createMockFolder('/', [file]);
|
|
|
|
mockVault.getAbstractFileByPath.mockReturnValue(folder);
|
|
mockVault.cachedRead.mockResolvedValue('Version v1.2 and v10.5 released');
|
|
|
|
// Should match version numbers like v1.2, v10.5, etc.
|
|
const pattern = '/' + 'v\\d+\\.\\d+' + '/';
|
|
const results = await vaultService.searchVaultFiles(pattern);
|
|
|
|
expect(results.length).toBeGreaterThan(0);
|
|
const match = results.find(r => r.file.path === 'note.md');
|
|
expect(match).toBeDefined();
|
|
});
|
|
|
|
it('should not log errors when vault contains excluded directories', async () => {
|
|
// Setup: Create a vault structure with excluded "Vaultkeeper AI" directory
|
|
const normalFile = createMockFile('notes/document.md');
|
|
const excludedFile = createMockFile('Vaultkeeper AI/secret.md');
|
|
const notesFolder = createMockFolder('notes', [normalFile]);
|
|
const excludedFolder = createMockFolder('Vaultkeeper AI', [excludedFile]);
|
|
const rootFolder = createMockFolder('/', [normalFile, notesFolder, excludedFolder]);
|
|
|
|
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
|
|
if (path === '/' || path === '') return rootFolder;
|
|
if (path === 'notes') return notesFolder;
|
|
if (path === 'notes/document.md') return normalFile;
|
|
// Vaultkeeper AI directory should be blocked by getAbstractFileByPath
|
|
if (path === 'Vaultkeeper AI') return null;
|
|
return null;
|
|
});
|
|
|
|
mockVault.cachedRead.mockResolvedValue('test content');
|
|
|
|
// Spy on console.error
|
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
// Execute search
|
|
const results = await vaultService.searchVaultFiles('test');
|
|
|
|
// Should not log exclusion errors during normal search operation
|
|
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
|
|
expect.stringContaining('Plugin attempted to retrieve a file that is in the exclusions list')
|
|
);
|
|
|
|
// Should still return results from non-excluded files
|
|
expect(results.length).toBeGreaterThan(0);
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
|
|
it('should respect allowAccessToPluginRoot parameter when true', async () => {
|
|
// Setup: Create vault with Vaultkeeper AI directory
|
|
const agentFile = createMockFile('Vaultkeeper AI/notes.md');
|
|
const normalFile = createMockFile('normal.md');
|
|
const agentFolder = createMockFolder('Vaultkeeper AI', [agentFile]);
|
|
const rootFolder = createMockFolder('/', [normalFile, agentFolder]);
|
|
|
|
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
|
|
if (path === '/' || path === '') return rootFolder;
|
|
if (path === 'Vaultkeeper AI') return agentFolder;
|
|
if (path === 'Vaultkeeper AI/notes.md') return agentFile;
|
|
if (path === 'normal.md') return normalFile;
|
|
return null;
|
|
});
|
|
|
|
mockVault.cachedRead.mockResolvedValue('searchable content');
|
|
|
|
// Search with allowAccessToPluginRoot = true
|
|
const results = await vaultService.searchVaultFiles('searchable', true);
|
|
|
|
// Should include files from Vaultkeeper AI directory
|
|
const paths = results.map(r => r.file.path);
|
|
expect(paths).toContain('Vaultkeeper AI/notes.md');
|
|
expect(paths).toContain('normal.md');
|
|
});
|
|
|
|
it('should exclude Vaultkeeper AI directory when allowAccessToPluginRoot is false', async () => {
|
|
// Setup: Create vault with Vaultkeeper AI directory
|
|
const agentFile = createMockFile('Vaultkeeper AI/notes.md');
|
|
const normalFile = createMockFile('normal.md');
|
|
const agentFolder = createMockFolder('Vaultkeeper AI', [agentFile]);
|
|
const rootFolder = createMockFolder('/', [normalFile, agentFolder]);
|
|
|
|
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
|
|
if (path === '/' || path === '') return rootFolder;
|
|
// When allowAccessToPluginRoot is false, Vaultkeeper AI should be blocked
|
|
if (path === 'Vaultkeeper AI') return null;
|
|
if (path === 'normal.md') return normalFile;
|
|
return null;
|
|
});
|
|
|
|
mockVault.cachedRead.mockResolvedValue('searchable content');
|
|
|
|
// Search with allowAccessToPluginRoot = false (default)
|
|
const results = await vaultService.searchVaultFiles('searchable', false);
|
|
|
|
// Should NOT include files from Vaultkeeper AI directory
|
|
const paths = results.map(r => r.file.path);
|
|
expect(paths).not.toContain('Vaultkeeper AI/notes.md');
|
|
expect(paths).toContain('normal.md');
|
|
});
|
|
|
|
it('should pass allowAccessToPluginRoot to listFilesInDirectory', async () => {
|
|
const file = createMockFile('test.md');
|
|
const folder = createMockFolder('/', [file]);
|
|
|
|
mockVault.getAbstractFileByPath.mockReturnValue(folder);
|
|
mockVault.cachedRead.mockResolvedValue('content');
|
|
|
|
// Create a spy on listFilesInDirectory
|
|
const listFilesSpy = vi.spyOn(vaultService, 'listFilesInDirectory');
|
|
|
|
// Call with allowAccessToPluginRoot = true
|
|
await vaultService.searchVaultFiles('test', true);
|
|
|
|
// Verify listFilesInDirectory was called with the correct parameter
|
|
expect(listFilesSpy).toHaveBeenCalledWith(Path.Root, true, true);
|
|
|
|
listFilesSpy.mockClear();
|
|
|
|
// Call with allowAccessToPluginRoot = false
|
|
await vaultService.searchVaultFiles('test', false);
|
|
|
|
// Verify listFilesInDirectory was called with the correct parameter
|
|
expect(listFilesSpy).toHaveBeenCalledWith(Path.Root, true, false);
|
|
|
|
listFilesSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('isExclusion (private method behavior)', () => {
|
|
it('should exclude exact path matches', async () => {
|
|
await settingsService.updateSettings(s => { s.exclusions = ['secret.md']; });
|
|
|
|
const result = await vaultService.exists('secret.md');
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should handle wildcard * (matches any non-slash)', async () => {
|
|
await settingsService.updateSettings(s => { s.exclusions = ['folder/*.md']; });
|
|
|
|
// Mock files to exist in vault
|
|
mockVault.adapter.exists.mockResolvedValue(true);
|
|
|
|
expect(await vaultService.exists('folder/file.md')).toBe(false);
|
|
expect(await vaultService.exists('folder/sub/file.md')).toBe(true); // * doesn't match /
|
|
});
|
|
|
|
it('should handle double wildcard ** (matches anything including slashes)', async () => {
|
|
await settingsService.updateSettings(s => { s.exclusions = ['private/**']; });
|
|
|
|
// Mock files to exist in vault
|
|
mockVault.adapter.exists.mockResolvedValue(true);
|
|
|
|
expect(await vaultService.exists('private/file.md')).toBe(false);
|
|
expect(await vaultService.exists('private/sub/deep/file.md')).toBe(false);
|
|
expect(await vaultService.exists('public/file.md')).toBe(true);
|
|
});
|
|
|
|
it('should handle patterns ending with / to match directory and contents', async () => {
|
|
await settingsService.updateSettings(s => { s.exclusions = ['temp/']; });
|
|
|
|
expect(await vaultService.exists('temp/file.md')).toBe(false);
|
|
expect(await vaultService.exists('temp/sub/file.md')).toBe(false);
|
|
});
|
|
|
|
it('should always exclude Vaultkeeper AI root by default', async () => {
|
|
const result = await vaultService.exists('Vaultkeeper AI/file.md', false);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should handle special regex characters in patterns', async () => {
|
|
await settingsService.updateSettings(s => { s.exclusions = ['folder[test].md']; });
|
|
|
|
// Mock files to exist in vault
|
|
mockVault.adapter.exists.mockResolvedValue(true);
|
|
|
|
// Should match literally, not as regex character class
|
|
expect(await vaultService.exists('folder[test].md')).toBe(false);
|
|
expect(await vaultService.exists('foldert.md')).toBe(true);
|
|
});
|
|
|
|
it('should handle multiple exclusion patterns', async () => {
|
|
await settingsService.updateSettings(s => { s.exclusions = ['private/**', 'temp/', '*.secret']; });
|
|
|
|
// Mock files to exist in vault
|
|
mockVault.adapter.exists.mockResolvedValue(true);
|
|
|
|
expect(await vaultService.exists('private/file.md')).toBe(false);
|
|
expect(await vaultService.exists('temp/file.md')).toBe(false);
|
|
expect(await vaultService.exists('data.secret')).toBe(false);
|
|
expect(await vaultService.exists('public/file.md')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('registerFileEvents', () => {
|
|
it('should register all file event handlers', () => {
|
|
const mockEventRef = { event: 'mock' };
|
|
mockVault.on.mockReturnValue(mockEventRef);
|
|
|
|
const handler = vi.fn();
|
|
vaultService.registerFileEvents(handler);
|
|
|
|
// Should register 4 events (create, modify, rename, delete)
|
|
expect(mockVault.on).toHaveBeenCalledTimes(4);
|
|
expect(mockVault.on).toHaveBeenCalledWith('create', expect.any(Function));
|
|
expect(mockVault.on).toHaveBeenCalledWith('modify', expect.any(Function));
|
|
expect(mockVault.on).toHaveBeenCalledWith('rename', expect.any(Function));
|
|
expect(mockVault.on).toHaveBeenCalledWith('delete', expect.any(Function));
|
|
expect(mockPlugin.registerEvent).toHaveBeenCalledTimes(4);
|
|
});
|
|
|
|
it('should call handler with correct parameters for create event', () => {
|
|
const handler = vi.fn();
|
|
let createCallback: any;
|
|
|
|
mockVault.on.mockImplementation((event: string, callback: any) => {
|
|
if (event === 'create') {
|
|
createCallback = callback;
|
|
}
|
|
return { event: 'mock' };
|
|
});
|
|
|
|
vaultService.registerFileEvents(handler);
|
|
|
|
const mockFile = createMockFile('test.md');
|
|
createCallback(mockFile);
|
|
|
|
expect(handler).toHaveBeenCalledWith('create', mockFile, { oldPath: '' });
|
|
});
|
|
|
|
it('should call handler with correct parameters for rename event', () => {
|
|
const handler = vi.fn();
|
|
let renameCallback: any;
|
|
|
|
mockVault.on.mockImplementation((event: string, callback: any) => {
|
|
if (event === 'rename') {
|
|
renameCallback = callback;
|
|
}
|
|
return { event: 'mock' };
|
|
});
|
|
|
|
vaultService.registerFileEvents(handler);
|
|
|
|
const mockFile = createMockFile('new.md');
|
|
renameCallback(mockFile, 'old.md');
|
|
|
|
expect(handler).toHaveBeenCalledWith('rename', mockFile, { oldPath: 'old.md' });
|
|
});
|
|
});
|
|
|
|
describe('listDirectoryContents', () => {
|
|
it('should return all files and folders when no exclusions', async () => {
|
|
const file1 = createMockFile('note1.md');
|
|
const file2 = createMockFile('note2.md');
|
|
const folder1 = createMockFolder('folder1', []);
|
|
const folder2 = createMockFolder('folder2', []);
|
|
const rootFolder = createMockFolder('/', [file1, file2, folder1, folder2]);
|
|
|
|
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
|
|
if (path === '/' || path === '') return rootFolder;
|
|
if (path === 'folder1') return folder1;
|
|
if (path === 'folder2') return folder2;
|
|
return null;
|
|
});
|
|
|
|
const result = await vaultService.listDirectoryContents(Path.Root);
|
|
|
|
expect(result).toHaveLength(4);
|
|
expect(result).toEqual(expect.arrayContaining([file1, file2, folder1, folder2]));
|
|
});
|
|
|
|
it('should filter out excluded files and folders', async () => {
|
|
const publicNote = createMockFile('public/note.md');
|
|
const agentNote = createMockFile('Vaultkeeper AI/conversation.md');
|
|
const privateNote = createMockFile('private/secret.md');
|
|
const publicFolder = createMockFolder('public', [publicNote]);
|
|
const agentFolder = createMockFolder('Vaultkeeper AI', [agentNote]);
|
|
const privateFolder = createMockFolder('private', [privateNote]);
|
|
const rootFolder = createMockFolder('/', [publicNote, agentNote, privateNote, publicFolder, agentFolder, privateFolder]);
|
|
|
|
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
|
|
if (path === '/' || path === '') return rootFolder;
|
|
if (path === 'public') return publicFolder;
|
|
if (path === 'Vaultkeeper AI') return null; // Excluded by default
|
|
if (path === 'private') return privateFolder;
|
|
return null;
|
|
});
|
|
|
|
await settingsService.updateSettings(s => { s.exclusions = ['private/**']; });
|
|
|
|
const result = await vaultService.listDirectoryContents(Path.Root, true, false);
|
|
|
|
// Should include: public/note.md and public folder
|
|
// Should exclude: Vaultkeeper AI folder (default exclusion), private/** content
|
|
expect(result.some((item: any) => item.path === 'public/note.md')).toBe(true);
|
|
expect(result.some((item: any) => item.path === 'public')).toBe(true);
|
|
expect(result.some((item: any) => item.path === 'Vaultkeeper AI/conversation.md')).toBe(false);
|
|
expect(result.some((item: any) => item.path === 'private/secret.md')).toBe(false);
|
|
expect(result.some((item: any) => item.path === 'private')).toBe(true); // Folder itself not excluded by 'private/**'
|
|
});
|
|
|
|
it('should exclude the Vaultkeeper AI directory itself from folder listings', async () => {
|
|
const publicNote = createMockFile('public/note.md');
|
|
const publicFolder = createMockFolder('public', [publicNote]);
|
|
const agentFolder = createMockFolder('Vaultkeeper AI', []);
|
|
const notesFolder = createMockFolder('notes', []);
|
|
const rootFolder = createMockFolder('/', [publicNote, publicFolder, agentFolder, notesFolder]);
|
|
|
|
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
|
|
if (path === '/' || path === '') return rootFolder;
|
|
if (path === 'public') return publicFolder;
|
|
if (path === 'Vaultkeeper AI') return null; // Excluded by default
|
|
if (path === 'notes') return notesFolder;
|
|
return null;
|
|
});
|
|
|
|
const result = await vaultService.listDirectoryContents(Path.Root, true, false);
|
|
|
|
// Vaultkeeper AI directory itself should be excluded
|
|
expect(result.some((item: any) => item.path === 'Vaultkeeper AI')).toBe(false);
|
|
// Other folders should be included
|
|
expect(result.some((item: any) => item.path === 'public')).toBe(true);
|
|
expect(result.some((item: any) => item.path === 'notes')).toBe(true);
|
|
});
|
|
|
|
it('should include Vaultkeeper AI directory when allowAccessToPluginRoot is true', async () => {
|
|
const note = createMockFile('note.md');
|
|
const agentNote = createMockFile('Vaultkeeper AI/conversation.md');
|
|
const agentFolder = createMockFolder('Vaultkeeper AI', [agentNote]);
|
|
const rootFolder = createMockFolder('/', [note, agentFolder]);
|
|
|
|
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
|
|
if (path === '/' || path === '') return rootFolder;
|
|
if (path === 'Vaultkeeper AI') return agentFolder; // Allowed with flag
|
|
return null;
|
|
});
|
|
|
|
const result = await vaultService.listDirectoryContents(Path.Root, true, true);
|
|
|
|
expect(result).toHaveLength(3);
|
|
expect(result.some((item: any) => item.path === 'Vaultkeeper AI/conversation.md')).toBe(true);
|
|
});
|
|
|
|
it('should return empty array when vault is empty', async () => {
|
|
const emptyRoot = createMockFolder('/', []);
|
|
mockVault.getAbstractFileByPath.mockReturnValue(emptyRoot);
|
|
|
|
const result = await vaultService.listDirectoryContents(Path.Root);
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
});
|