mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 16:30:27 +00:00
- Add pageNumber field to ISearchSnippet and IPageText interfaces - Update extractSnippets to process content as paginated text - Switch PDF reading to use readPDF helper for page extraction - Update search results to include page numbers in snippets - Add page number assertions to VaultService tests
1510 lines
55 KiB
TypeScript
1510 lines
55 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, IVaultkeeperAISettings } from '../../Services/SettingsService';
|
|
import { AIProviderModel } from '../../Enums/ApiProvider';
|
|
import { Exception } from '../../Helpers/Exception';
|
|
|
|
/**
|
|
* 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_5,
|
|
apiKeys: {
|
|
claude: 'test-claude-key',
|
|
openai: 'test-openai-key',
|
|
gemini: 'test-gemini-key'
|
|
},
|
|
exclusions: [],
|
|
userInstruction: '',
|
|
searchResultsLimit: 15,
|
|
snippetSizeLimit: 300
|
|
};
|
|
|
|
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
|
|
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', () => {
|
|
const files = [
|
|
createMockFile('public/note1.md'),
|
|
createMockFile('private/secret.md'),
|
|
createMockFile('public/note2.md')
|
|
];
|
|
mockVault.getMarkdownFiles.mockReturnValue(files);
|
|
|
|
// Update settings to include exclusion
|
|
settingsService.settings.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 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('File 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('File 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('createFolder', () => {
|
|
it('should create folder with sanitized path', async () => {
|
|
const mockFolder = createMockFolder('folder');
|
|
mockVault.createFolder.mockResolvedValue(mockFolder);
|
|
|
|
const result = await vaultService.createFolder('folder');
|
|
|
|
expect(mockVault.createFolder).toHaveBeenCalledWith('folder');
|
|
expect(result).toBe(mockFolder);
|
|
});
|
|
|
|
it('should return error when trying to create folder in excluded path', async () => {
|
|
const result = await vaultService.createFolder('Vaultkeeper AI/subfolder', false);
|
|
|
|
expect(result).toBeInstanceOf(Error);
|
|
expect((result as Error).message).toContain('Failed to create folder, permission denied');
|
|
});
|
|
});
|
|
|
|
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);
|
|
settingsService.settings.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);
|
|
settingsService.settings.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;
|
|
});
|
|
|
|
// Use pattern that matches the folder itself and its contents
|
|
settingsService.settings.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 () => {
|
|
// Set custom limit
|
|
settingsService.settings.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 () => {
|
|
// Set custom snippet size
|
|
settingsService.settings.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 () => {
|
|
settingsService.settings.exclusions = ['secret.md'];
|
|
|
|
const result = await vaultService.exists('secret.md');
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should handle wildcard * (matches any non-slash)', async () => {
|
|
settingsService.settings.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 () => {
|
|
settingsService.settings.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 () => {
|
|
settingsService.settings.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 () => {
|
|
settingsService.settings.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 () => {
|
|
settingsService.settings.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;
|
|
});
|
|
|
|
settingsService.settings.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([]);
|
|
});
|
|
});
|
|
});
|