mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
Introduce a new AbortService to centralize cancellation logic across all async operations, replacing scattered AbortSignal parameters with a unified singleton service. This improves maintainability and provides consistent cancellation behavior throughout the application. Key changes: - Add AbortService for centralized abort signal management with automatic cleanup - Refactor all AI providers (Claude, Gemini, OpenAI) to use AbortService instead of passing AbortSignal parameters - Update streaming operations to use centralized abort handling - Add CancellationIndicator component to show visual feedback during operation cancellation - Rename ChatAreaThought to ThoughtIndicator for better semantic clarity - Add Environment enum for consistent environment detection - Enhance ChatService lifecycle with proper cancellation state management - Remove scattered abort-related UI selectors and error messages in favor of dedicated indicator - Add safeContinue() factory method to ConversationContent for internal continuations - Update all tests to reflect new abort handling architecture This change simplifies the API surface by removing AbortSignal parameters from method signatures while improving the user experience with clearer cancellation feedback.
997 lines
31 KiB
TypeScript
997 lines
31 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { VaultCacheService } from '../../Services/VaultCacheService';
|
|
import { VaultService } from '../../Services/VaultService';
|
|
import { TFile, TFolder } from 'obsidian';
|
|
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
|
|
import { Services } from '../../Services/Services';
|
|
import { FileEvent } from '../../Enums/FileEvent';
|
|
|
|
// Mock getAllTags from obsidian
|
|
vi.mock('obsidian', async () => {
|
|
const actual = await vi.importActual('obsidian');
|
|
return {
|
|
...actual,
|
|
Component: class Component {
|
|
public load() {}
|
|
public onload() {}
|
|
public unload() {}
|
|
public onunload() {}
|
|
public addChild<T extends Component>(component: T): T {
|
|
return component;
|
|
}
|
|
public removeChild<T extends Component>(component: T): T {
|
|
return component;
|
|
}
|
|
public register(_cb: () => any): void {}
|
|
public registerEvent(_eventRef: any): void {}
|
|
public registerDomEvent(_el: any, _type: any, _callback: any, _options?: any): void {}
|
|
public registerInterval(id: number): number {
|
|
return id;
|
|
}
|
|
},
|
|
getAllTags: vi.fn((metadata: any) => {
|
|
if (!metadata || !metadata.tags) return null;
|
|
return metadata.tags.map((t: any) => t.tag);
|
|
})
|
|
};
|
|
});
|
|
|
|
// Mock fuzzysort
|
|
vi.mock('fuzzysort', () => {
|
|
const mockPrepare = vi.fn((str: string) => ({ target: str }));
|
|
const mockGo = vi.fn((input: string, targets: any[], options: any) => {
|
|
// Simple mock implementation that filters based on includes
|
|
const results = targets
|
|
.filter((t: any) => {
|
|
const searchIn = t.tag || t.file?.basename || t.folder?.path || '';
|
|
return searchIn.toLowerCase().includes(input.toLowerCase());
|
|
})
|
|
.slice(0, options.limit || 10)
|
|
.map((t: any) => ({
|
|
obj: t,
|
|
score: 0
|
|
}));
|
|
return results;
|
|
});
|
|
|
|
return {
|
|
default: {
|
|
go: mockGo,
|
|
prepare: mockPrepare
|
|
},
|
|
go: mockGo,
|
|
prepare: mockPrepare
|
|
};
|
|
});
|
|
|
|
/**
|
|
* INTEGRATION TESTS
|
|
*
|
|
* These tests use real dependencies where possible and mock only the Obsidian API.
|
|
*/
|
|
|
|
// Create mock instances
|
|
const mockMetadataCache = {
|
|
getCache: vi.fn(),
|
|
on: vi.fn()
|
|
};
|
|
|
|
const mockVault = {
|
|
getFiles: vi.fn(),
|
|
getAllFolders: vi.fn(),
|
|
on: vi.fn()
|
|
};
|
|
|
|
const mockPlugin = {
|
|
app: {
|
|
vault: mockVault,
|
|
metadataCache: mockMetadataCache
|
|
},
|
|
settings: {
|
|
exclusions: []
|
|
},
|
|
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): TFolder {
|
|
const name = path.split('/').pop() || '';
|
|
const folder = new TFolder();
|
|
folder.path = path;
|
|
folder.name = name;
|
|
folder.children = [];
|
|
folder.parent = null;
|
|
folder.vault = mockVault as any;
|
|
return folder;
|
|
}
|
|
|
|
describe('VaultCacheService - Integration Tests', () => {
|
|
let vaultCacheService: VaultCacheService;
|
|
let vaultService: VaultService;
|
|
let fileEventHandler: any;
|
|
|
|
beforeEach(() => {
|
|
// Clear singleton registry to prevent memory leaks
|
|
DeregisterAllServices();
|
|
// Reset all mocks
|
|
vi.clearAllMocks();
|
|
|
|
// Capture file event handler
|
|
fileEventHandler = null;
|
|
|
|
// Mock VaultService to capture the event handler
|
|
const mockVaultService = {
|
|
registerFileEvents: vi.fn((handler: any) => {
|
|
fileEventHandler = handler;
|
|
}),
|
|
listDirectoryContents: vi.fn(async () => []),
|
|
getAbstractFileByPath: vi.fn((path: string) => {
|
|
// By default, allow all folders (return a mock folder)
|
|
// This maintains existing test behavior
|
|
return createMockFolder(path);
|
|
}),
|
|
isExclusion: vi.fn((path: string, allowAccessToPluginRoot: boolean) => {
|
|
// By default, return false to allow all paths (not excluded)
|
|
// This maintains backward compatibility with existing tests
|
|
return false;
|
|
})
|
|
};
|
|
|
|
// Register dependencies
|
|
RegisterSingleton(Services.VaultkeeperAIPlugin, mockPlugin as any);
|
|
RegisterSingleton(Services.VaultService, mockVaultService as any);
|
|
|
|
// Create fresh instance
|
|
vaultCacheService = new VaultCacheService();
|
|
vaultService = mockVaultService as any;
|
|
});
|
|
|
|
describe('initialization', () => {
|
|
it('should register file event handlers on construction', () => {
|
|
expect(vaultService.registerFileEvents).toHaveBeenCalledOnce();
|
|
expect(fileEventHandler).toBeDefined();
|
|
});
|
|
|
|
it('should initialize cache with existing vault contents', () => {
|
|
const files = [
|
|
createMockFile('note1.md'),
|
|
createMockFile('note2.md')
|
|
];
|
|
const folders = [createMockFolder('folder')];
|
|
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#tag1' }, { tag: '#tag2' }]
|
|
});
|
|
|
|
const mockListVaultContents = vi.fn(() => [...files, ...folders]);
|
|
const mockRegisterEvents = vi.fn((handler: any) => {
|
|
fileEventHandler = handler;
|
|
});
|
|
|
|
let resolvedHandler: any = null;
|
|
const mockMetadataCacheWithHandler = {
|
|
getCache: mockMetadataCache.getCache,
|
|
on: vi.fn((event: string, handler: any) => {
|
|
if (event === 'resolved') {
|
|
resolvedHandler = handler;
|
|
}
|
|
})
|
|
};
|
|
|
|
const mockPluginWithMetadata = {
|
|
app: {
|
|
vault: mockVault,
|
|
metadataCache: mockMetadataCacheWithHandler
|
|
},
|
|
settings: {
|
|
exclusions: []
|
|
},
|
|
registerEvent: vi.fn()
|
|
};
|
|
|
|
const mockVaultServiceWithContent = {
|
|
registerFileEvents: mockRegisterEvents,
|
|
listDirectoryContents: mockListVaultContents
|
|
};
|
|
|
|
RegisterSingleton(Services.VaultkeeperAIPlugin, mockPluginWithMetadata as any);
|
|
RegisterSingleton(Services.VaultService, mockVaultServiceWithContent as any);
|
|
|
|
// Create new instance to trigger initialization
|
|
new VaultCacheService();
|
|
|
|
// Trigger the resolved event
|
|
expect(resolvedHandler).toBeDefined();
|
|
resolvedHandler();
|
|
|
|
expect(mockListVaultContents).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('file event handling - create', () => {
|
|
it('should cache file on create event', () => {
|
|
const mockFile = createMockFile('new.md');
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#test' }]
|
|
});
|
|
|
|
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
|
|
|
|
// Verify by triggering delete and checking orphaned tags
|
|
const orphanedTags = fileEventHandler(FileEvent.Delete, mockFile, { oldPath: mockFile.path });
|
|
|
|
// If the file was cached, deleting it should not cause issues
|
|
expect(orphanedTags).not.toThrow;
|
|
});
|
|
|
|
it('should extract and cache tags on file create', () => {
|
|
const mockFile = createMockFile('tagged.md');
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#important' }, { tag: '#project' }]
|
|
});
|
|
|
|
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
|
|
|
|
// Verify tags are cached by creating another file with same tags
|
|
const mockFile2 = createMockFile('tagged2.md');
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#important' }]
|
|
});
|
|
|
|
fileEventHandler(FileEvent.Create, mockFile2, { oldPath: '' });
|
|
|
|
// Delete first file - #important should not be orphaned
|
|
fileEventHandler(FileEvent.Delete, mockFile, { oldPath: mockFile.path });
|
|
|
|
// Both files should be tracked
|
|
expect(fileEventHandler).toBeDefined();
|
|
});
|
|
|
|
it('should handle files with no tags', () => {
|
|
const mockFile = createMockFile('untagged.md');
|
|
mockMetadataCache.getCache.mockReturnValue(null);
|
|
|
|
// Should not throw
|
|
expect(() => {
|
|
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should handle files with empty tag array', () => {
|
|
const mockFile = createMockFile('notags.md');
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: []
|
|
});
|
|
|
|
expect(() => {
|
|
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('file event handling - modify', () => {
|
|
it('should update tags on file modify', () => {
|
|
const mockFile = createMockFile('modified.md');
|
|
|
|
// Create file with initial tags
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#old' }, { tag: '#keep' }]
|
|
});
|
|
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
|
|
|
|
// Modify file with new tags
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#new' }, { tag: '#keep' }]
|
|
});
|
|
fileEventHandler(FileEvent.Modify, mockFile, { oldPath: '' });
|
|
|
|
// The implementation should handle tag updates correctly
|
|
expect(fileEventHandler).toBeDefined();
|
|
});
|
|
|
|
it('should remove orphaned tags on modify', () => {
|
|
const mockFile = createMockFile('file.md');
|
|
|
|
// Create with tags
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#unique' }]
|
|
});
|
|
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
|
|
|
|
// Modify to remove all tags
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: []
|
|
});
|
|
fileEventHandler(FileEvent.Modify, mockFile, { oldPath: '' });
|
|
|
|
// #unique should be removed from cache
|
|
expect(fileEventHandler).toBeDefined();
|
|
});
|
|
|
|
it('should not remove shared tags on modify', () => {
|
|
const mockFile1 = createMockFile('file1.md');
|
|
const mockFile2 = createMockFile('file2.md');
|
|
|
|
// Create two files with shared tag
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#shared' }]
|
|
});
|
|
fileEventHandler(FileEvent.Create, mockFile1, { oldPath: '' });
|
|
fileEventHandler(FileEvent.Create, mockFile2, { oldPath: '' });
|
|
|
|
// Modify file1 to remove shared tag
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: []
|
|
});
|
|
fileEventHandler(FileEvent.Modify, mockFile1, { oldPath: '' });
|
|
|
|
// #shared should still exist because file2 has it
|
|
expect(fileEventHandler).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('file event handling - rename', () => {
|
|
it('should update file path on rename', () => {
|
|
const oldPath = 'old.md';
|
|
const mockFile = createMockFile('new.md');
|
|
|
|
// Create file
|
|
const oldFile = createMockFile(oldPath);
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#test' }]
|
|
});
|
|
fileEventHandler(FileEvent.Create, oldFile, { oldPath: '' });
|
|
|
|
// Rename file
|
|
fileEventHandler(FileEvent.Rename, mockFile, { oldPath: oldPath });
|
|
|
|
// Verify rename worked by deleting with new path
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#test' }]
|
|
});
|
|
expect(() => {
|
|
fileEventHandler(FileEvent.Delete, mockFile, { oldPath: mockFile.path });
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should preserve tags on rename', () => {
|
|
const oldPath = 'folder/old.md';
|
|
const newFile = createMockFile('folder/new.md');
|
|
|
|
// Create file with tags
|
|
const oldFile = createMockFile(oldPath);
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#important' }]
|
|
});
|
|
fileEventHandler(FileEvent.Create, oldFile, { oldPath: '' });
|
|
|
|
// Rename file
|
|
fileEventHandler(FileEvent.Rename, newFile, { oldPath: oldPath });
|
|
|
|
// Tags should still be associated with file
|
|
expect(fileEventHandler).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('file event handling - delete', () => {
|
|
it('should remove file from cache on delete', () => {
|
|
const mockFile = createMockFile('deleted.md');
|
|
|
|
// Create file
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#test' }]
|
|
});
|
|
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
|
|
|
|
// Delete file
|
|
fileEventHandler(FileEvent.Delete, mockFile, { oldPath: mockFile.path });
|
|
|
|
// Deleting again should handle gracefully
|
|
expect(() => {
|
|
fileEventHandler(FileEvent.Delete, mockFile, { oldPath: mockFile.path });
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should remove orphaned tags on delete', () => {
|
|
const mockFile = createMockFile('file.md');
|
|
|
|
// Create file with unique tag
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#unique' }]
|
|
});
|
|
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
|
|
|
|
// Delete file
|
|
fileEventHandler(FileEvent.Delete, mockFile, { oldPath: mockFile.path });
|
|
|
|
// #unique should be removed from cache
|
|
expect(fileEventHandler).toBeDefined();
|
|
});
|
|
|
|
it('should not remove shared tags on delete', () => {
|
|
const mockFile1 = createMockFile('file1.md');
|
|
const mockFile2 = createMockFile('file2.md');
|
|
|
|
// Create two files with shared tag
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#shared' }]
|
|
});
|
|
fileEventHandler(FileEvent.Create, mockFile1, { oldPath: '' });
|
|
fileEventHandler(FileEvent.Create, mockFile2, { oldPath: '' });
|
|
|
|
// Delete one file
|
|
fileEventHandler(FileEvent.Delete, mockFile1, { oldPath: mockFile1.path });
|
|
|
|
// #shared should still exist because file2 has it
|
|
expect(fileEventHandler).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('complex scenarios', () => {
|
|
it('should handle multiple files with overlapping tags', () => {
|
|
const file1 = createMockFile('file1.md');
|
|
const file2 = createMockFile('file2.md');
|
|
const file3 = createMockFile('file3.md');
|
|
|
|
// file1: tag1, tag2
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#tag1' }, { tag: '#tag2' }]
|
|
});
|
|
fileEventHandler(FileEvent.Create, file1, { oldPath: '' });
|
|
|
|
// file2: tag2, tag3
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#tag2' }, { tag: '#tag3' }]
|
|
});
|
|
fileEventHandler(FileEvent.Create, file2, { oldPath: '' });
|
|
|
|
// file3: tag3, tag4
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#tag3' }, { tag: '#tag4' }]
|
|
});
|
|
fileEventHandler(FileEvent.Create, file3, { oldPath: '' });
|
|
|
|
// Delete file2
|
|
fileEventHandler(FileEvent.Delete, file2, { oldPath: file2.path });
|
|
|
|
// tag2 should still exist (file1), tag3 should still exist (file3)
|
|
expect(fileEventHandler).toBeDefined();
|
|
});
|
|
|
|
it('should handle file lifecycle: create, modify, rename, delete', () => {
|
|
let mockFile = createMockFile('initial.md');
|
|
|
|
// Create
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#v1' }]
|
|
});
|
|
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
|
|
|
|
// Modify
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#v2' }]
|
|
});
|
|
fileEventHandler(FileEvent.Modify, mockFile, { oldPath: '' });
|
|
|
|
// Rename
|
|
const renamedFile = createMockFile('renamed.md');
|
|
fileEventHandler(FileEvent.Rename, renamedFile, { oldPath: mockFile.path });
|
|
|
|
// Delete
|
|
fileEventHandler(FileEvent.Delete, renamedFile, { oldPath: renamedFile.path });
|
|
|
|
// Should complete without errors
|
|
expect(fileEventHandler).toBeDefined();
|
|
});
|
|
|
|
it('should handle files in different folders', () => {
|
|
const file1 = createMockFile('folder1/note.md');
|
|
const file2 = createMockFile('folder2/note.md');
|
|
const file3 = createMockFile('note.md');
|
|
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#test' }]
|
|
});
|
|
|
|
fileEventHandler(FileEvent.Create, file1, { oldPath: '' });
|
|
fileEventHandler(FileEvent.Create, file2, { oldPath: '' });
|
|
fileEventHandler(FileEvent.Create, file3, { oldPath: '' });
|
|
|
|
// All three files should be tracked separately
|
|
expect(fileEventHandler).toBeDefined();
|
|
});
|
|
|
|
it('should handle rapid tag changes', () => {
|
|
const mockFile = createMockFile('volatile.md');
|
|
|
|
// Create with tags
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#a' }, { tag: '#b' }]
|
|
});
|
|
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
|
|
|
|
// Modify multiple times
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#b' }, { tag: '#c' }]
|
|
});
|
|
fileEventHandler(FileEvent.Modify, mockFile, { oldPath: '' });
|
|
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#c' }, { tag: '#d' }]
|
|
});
|
|
fileEventHandler(FileEvent.Modify, mockFile, { oldPath: '' });
|
|
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#d' }, { tag: '#e' }]
|
|
});
|
|
fileEventHandler(FileEvent.Modify, mockFile, { oldPath: '' });
|
|
|
|
// Should handle gracefully
|
|
expect(fileEventHandler).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle folder events - create', () => {
|
|
const mockFolder = createMockFolder('new-folder');
|
|
|
|
expect(() => {
|
|
fileEventHandler(FileEvent.Create, mockFolder, { oldPath: '' });
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should handle folder events - rename', () => {
|
|
const mockFolder = createMockFolder('folder');
|
|
fileEventHandler(FileEvent.Create, mockFolder, { oldPath: '' });
|
|
|
|
const renamedFolder = createMockFolder('renamed-folder');
|
|
expect(() => {
|
|
fileEventHandler(FileEvent.Rename, renamedFolder, { oldPath: mockFolder.path });
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should handle folder events - delete', () => {
|
|
const mockFolder = createMockFolder('folder-to-delete');
|
|
fileEventHandler(FileEvent.Create, mockFolder, { oldPath: '' });
|
|
|
|
expect(() => {
|
|
fileEventHandler(FileEvent.Delete, mockFolder, { oldPath: mockFolder.path });
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should handle files with special characters in path', () => {
|
|
const mockFile = createMockFile('folder/file with spaces & (special).md');
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#test' }]
|
|
});
|
|
|
|
expect(() => {
|
|
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should handle tags with special characters', () => {
|
|
const mockFile = createMockFile('file.md');
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#tag-with-dash' }, { tag: '#tag_with_underscore' }, { tag: '#tag123' }]
|
|
});
|
|
|
|
expect(() => {
|
|
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should handle deleting non-existent file', () => {
|
|
const mockFile = createMockFile('never-created.md');
|
|
|
|
expect(() => {
|
|
fileEventHandler(FileEvent.Delete, mockFile, { oldPath: mockFile.path });
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should handle modifying non-existent file', () => {
|
|
const mockFile = createMockFile('never-created.md');
|
|
mockMetadataCache.getCache.mockReturnValue({
|
|
tags: [{ tag: '#test' }]
|
|
});
|
|
|
|
expect(() => {
|
|
fileEventHandler(FileEvent.Modify, mockFile, { oldPath: '' });
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should handle renaming non-existent file', () => {
|
|
const mockFile = createMockFile('new.md');
|
|
|
|
expect(() => {
|
|
fileEventHandler(FileEvent.Rename, mockFile, { oldPath: 'old.md' });
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('folder event handling - exclusions', () => {
|
|
it('should not cache excluded folders on create (Vaultkeeper AI directory)', () => {
|
|
// Mock VaultService to return null for excluded paths
|
|
const mockVaultServiceWithExclusions = {
|
|
registerFileEvents: vi.fn((handler: any) => {
|
|
fileEventHandler = handler;
|
|
}),
|
|
listDirectoryContents: vi.fn(async () => []),
|
|
getAbstractFileByPath: vi.fn((path: string) => {
|
|
// Return null for excluded paths (simulating VaultService exclusion logic)
|
|
if (path.startsWith('Vaultkeeper AI')) {
|
|
return null;
|
|
}
|
|
// Return a folder for allowed paths
|
|
return createMockFolder(path);
|
|
}),
|
|
isExclusion: vi.fn((path: string) => {
|
|
// Return true for Vaultkeeper AI directory paths
|
|
return path.startsWith('Vaultkeeper AI');
|
|
})
|
|
};
|
|
|
|
RegisterSingleton(Services.VaultService, mockVaultServiceWithExclusions as any);
|
|
vaultCacheService = new VaultCacheService();
|
|
|
|
const excludedFolder = createMockFolder('Vaultkeeper AI/subfolder');
|
|
fileEventHandler(FileEvent.Create, excludedFolder, { oldPath: '' });
|
|
|
|
const results = vaultCacheService.matchFolder('subfolder');
|
|
expect(results).toHaveLength(0);
|
|
});
|
|
|
|
it('should not cache user-excluded folders on create', () => {
|
|
// Create plugin with user exclusions
|
|
const mockPluginWithExclusions = {
|
|
app: {
|
|
vault: mockVault,
|
|
metadataCache: mockMetadataCache
|
|
},
|
|
settings: {
|
|
exclusions: ['private/**']
|
|
},
|
|
registerEvent: vi.fn()
|
|
};
|
|
|
|
// Mock VaultService to return null for excluded paths
|
|
const mockVaultServiceWithExclusions = {
|
|
registerFileEvents: vi.fn((handler: any) => {
|
|
fileEventHandler = handler;
|
|
}),
|
|
listDirectoryContents: vi.fn(async () => []),
|
|
getAbstractFileByPath: vi.fn((path: string) => {
|
|
// Return null for excluded paths
|
|
if (path.startsWith('private/')) {
|
|
return null;
|
|
}
|
|
return createMockFolder(path);
|
|
}),
|
|
isExclusion: vi.fn((path: string) => {
|
|
// Return true for user-excluded paths
|
|
return path.startsWith('private/');
|
|
})
|
|
};
|
|
|
|
RegisterSingleton(Services.VaultkeeperAIPlugin, mockPluginWithExclusions as any);
|
|
RegisterSingleton(Services.VaultService, mockVaultServiceWithExclusions as any);
|
|
vaultCacheService = new VaultCacheService();
|
|
|
|
const excludedFolder = createMockFolder('private/secrets');
|
|
fileEventHandler(FileEvent.Create, excludedFolder, { oldPath: '' });
|
|
|
|
const results = vaultCacheService.matchFolder('secrets');
|
|
expect(results).toHaveLength(0);
|
|
});
|
|
|
|
it('should remove folder from cache when renamed to excluded path', () => {
|
|
// Mock VaultService for rename scenario
|
|
const mockVaultServiceWithExclusions = {
|
|
registerFileEvents: vi.fn((handler: any) => {
|
|
fileEventHandler = handler;
|
|
}),
|
|
listDirectoryContents: vi.fn(async () => []),
|
|
getAbstractFileByPath: vi.fn((path: string, allowAccessToPluginRoot: boolean) => {
|
|
// First call (public-folder) should succeed
|
|
if (path === 'public-folder') {
|
|
return createMockFolder(path);
|
|
}
|
|
// Second call (Vaultkeeper AI/renamed) should fail
|
|
if (path.startsWith('Vaultkeeper AI')) {
|
|
return null;
|
|
}
|
|
return createMockFolder(path);
|
|
}),
|
|
isExclusion: vi.fn((path: string) => {
|
|
// Return true for Vaultkeeper AI paths
|
|
return path.startsWith('Vaultkeeper AI');
|
|
})
|
|
};
|
|
|
|
RegisterSingleton(Services.VaultService, mockVaultServiceWithExclusions as any);
|
|
vaultCacheService = new VaultCacheService();
|
|
|
|
// Create a folder first
|
|
const folder = createMockFolder('public-folder');
|
|
fileEventHandler(FileEvent.Create, folder, { oldPath: '' });
|
|
|
|
// Verify it's in the cache
|
|
let results = vaultCacheService.matchFolder('public-folder');
|
|
expect(results.length).toBeGreaterThan(0);
|
|
|
|
// Rename to excluded path
|
|
const renamedFolder = createMockFolder('Vaultkeeper AI/renamed');
|
|
fileEventHandler(FileEvent.Rename, renamedFolder, { oldPath: folder.path });
|
|
|
|
// Should not be in cache under new name
|
|
results = vaultCacheService.matchFolder('renamed');
|
|
expect(results).toHaveLength(0);
|
|
|
|
// Old name should also be removed
|
|
results = vaultCacheService.matchFolder('public-folder');
|
|
expect(results).toHaveLength(0);
|
|
});
|
|
|
|
it('should cache allowed folders even when exclusions are configured', () => {
|
|
// Mock VaultService with exclusions
|
|
const mockVaultServiceWithExclusions = {
|
|
registerFileEvents: vi.fn((handler: any) => {
|
|
fileEventHandler = handler;
|
|
}),
|
|
listDirectoryContents: vi.fn(async () => []),
|
|
getAbstractFileByPath: vi.fn((path: string) => {
|
|
if (path.startsWith('Vaultkeeper AI')) {
|
|
return null;
|
|
}
|
|
return createMockFolder(path);
|
|
}),
|
|
isExclusion: vi.fn((path: string) => {
|
|
// Return true for Vaultkeeper AI paths, false for others
|
|
return path.startsWith('Vaultkeeper AI');
|
|
})
|
|
};
|
|
|
|
RegisterSingleton(Services.VaultService, mockVaultServiceWithExclusions as any);
|
|
vaultCacheService = new VaultCacheService();
|
|
|
|
// Create an allowed folder
|
|
const allowedFolder = createMockFolder('allowed-folder');
|
|
fileEventHandler(FileEvent.Create, allowedFolder, { oldPath: '' });
|
|
|
|
// Should be in cache
|
|
const results = vaultCacheService.matchFolder('allowed-folder');
|
|
expect(results.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('fuzzy search - matchTag', () => {
|
|
beforeEach(() => {
|
|
// Create files with various tags
|
|
const file1 = createMockFile('file1.md');
|
|
const file2 = createMockFile('file2.md');
|
|
const file3 = createMockFile('file3.md');
|
|
|
|
mockMetadataCache.getCache.mockReturnValueOnce({
|
|
tags: [{ tag: '#project' }, { tag: '#important' }]
|
|
});
|
|
fileEventHandler(FileEvent.Create, file1, { oldPath: '' });
|
|
|
|
mockMetadataCache.getCache.mockReturnValueOnce({
|
|
tags: [{ tag: '#personal' }, { tag: '#todo' }]
|
|
});
|
|
fileEventHandler(FileEvent.Create, file2, { oldPath: '' });
|
|
|
|
mockMetadataCache.getCache.mockReturnValueOnce({
|
|
tags: [{ tag: '#work' }, { tag: '#meeting' }]
|
|
});
|
|
fileEventHandler(FileEvent.Create, file3, { oldPath: '' });
|
|
});
|
|
|
|
it('should find matching tags', () => {
|
|
const results = vaultCacheService.matchTag('project');
|
|
expect(results).toBeDefined();
|
|
expect(results.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should return empty results for non-matching input', () => {
|
|
const results = vaultCacheService.matchTag('nonexistent');
|
|
expect(results).toBeDefined();
|
|
expect(results.length).toBe(0);
|
|
});
|
|
|
|
it('should be case insensitive', () => {
|
|
const results = vaultCacheService.matchTag('PROJECT');
|
|
expect(results).toBeDefined();
|
|
expect(results.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should handle partial matches', () => {
|
|
const results = vaultCacheService.matchTag('proj');
|
|
expect(results).toBeDefined();
|
|
});
|
|
|
|
it('should limit results to configured limit', () => {
|
|
// Create many tags to test limit
|
|
for (let i = 0; i < 20; i++) {
|
|
const file = createMockFile(`file${i}.md`);
|
|
mockMetadataCache.getCache.mockReturnValueOnce({
|
|
tags: [{ tag: `#test${i}` }]
|
|
});
|
|
fileEventHandler(FileEvent.Create, file, { oldPath: '' });
|
|
}
|
|
|
|
const results = vaultCacheService.matchTag('test');
|
|
expect(results.length).toBeLessThanOrEqual(10);
|
|
});
|
|
});
|
|
|
|
describe('fuzzy search - matchFile', () => {
|
|
beforeEach(() => {
|
|
// Create files with various names
|
|
mockMetadataCache.getCache.mockReturnValue(null);
|
|
|
|
fileEventHandler(FileEvent.Create, createMockFile('project-notes.md'), { oldPath: '' });
|
|
fileEventHandler(FileEvent.Create, createMockFile('meeting-2024.md'), { oldPath: '' });
|
|
fileEventHandler(FileEvent.Create, createMockFile('todo-list.md'), { oldPath: '' });
|
|
fileEventHandler(FileEvent.Create, createMockFile('important-doc.md'), { oldPath: '' });
|
|
});
|
|
|
|
it('should find matching files by basename', () => {
|
|
const results = vaultCacheService.matchFile('project');
|
|
expect(results).toBeDefined();
|
|
expect(results.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should return empty results for non-matching input', () => {
|
|
const results = vaultCacheService.matchFile('zzznomatch');
|
|
expect(results).toBeDefined();
|
|
expect(results.length).toBe(0);
|
|
});
|
|
|
|
it('should be case insensitive', () => {
|
|
const results = vaultCacheService.matchFile('PROJECT');
|
|
expect(results).toBeDefined();
|
|
expect(results.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should handle partial matches', () => {
|
|
const results = vaultCacheService.matchFile('meet');
|
|
expect(results).toBeDefined();
|
|
});
|
|
|
|
it('should limit results to configured limit', () => {
|
|
// Create many files to test limit
|
|
for (let i = 0; i < 20; i++) {
|
|
const file = createMockFile(`document${i}.md`);
|
|
fileEventHandler(FileEvent.Create, file, { oldPath: '' });
|
|
}
|
|
|
|
const results = vaultCacheService.matchFile('document');
|
|
expect(results.length).toBeLessThanOrEqual(10);
|
|
});
|
|
|
|
it('should update results after file is added', () => {
|
|
const resultsBefore = vaultCacheService.matchFile('newfile');
|
|
expect(resultsBefore.length).toBe(0);
|
|
|
|
const newFile = createMockFile('newfile-test.md');
|
|
fileEventHandler(FileEvent.Create, newFile, { oldPath: '' });
|
|
|
|
const resultsAfter = vaultCacheService.matchFile('newfile');
|
|
expect(resultsAfter.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should update results after file is deleted', () => {
|
|
const file = createMockFile('todelete.md');
|
|
fileEventHandler(FileEvent.Create, file, { oldPath: '' });
|
|
|
|
const resultsBefore = vaultCacheService.matchFile('todelete');
|
|
expect(resultsBefore.length).toBeGreaterThan(0);
|
|
|
|
fileEventHandler(FileEvent.Delete, file, { oldPath: file.path });
|
|
|
|
const resultsAfter = vaultCacheService.matchFile('todelete');
|
|
expect(resultsAfter.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('fuzzy search - matchFolder', () => {
|
|
beforeEach(() => {
|
|
// Create folders with various paths
|
|
fileEventHandler(FileEvent.Create, createMockFolder('projects'), { oldPath: '' });
|
|
fileEventHandler(FileEvent.Create, createMockFolder('projects/frontend'), { oldPath: '' });
|
|
fileEventHandler(FileEvent.Create, createMockFolder('projects/backend'), { oldPath: '' });
|
|
fileEventHandler(FileEvent.Create, createMockFolder('archive'), { oldPath: '' });
|
|
});
|
|
|
|
it('should find matching folders', () => {
|
|
const results = vaultCacheService.matchFolder('projects');
|
|
expect(results).toBeDefined();
|
|
expect(results.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should return empty results for non-matching input', () => {
|
|
const results = vaultCacheService.matchFolder('nonexistent');
|
|
expect(results).toBeDefined();
|
|
expect(results.length).toBe(0);
|
|
});
|
|
|
|
it('should be case insensitive', () => {
|
|
const results = vaultCacheService.matchFolder('PROJECTS');
|
|
expect(results).toBeDefined();
|
|
expect(results.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should handle partial matches', () => {
|
|
const results = vaultCacheService.matchFolder('proj');
|
|
expect(results).toBeDefined();
|
|
});
|
|
|
|
it('should limit results to configured limit', () => {
|
|
// Create many folders to test limit
|
|
for (let i = 0; i < 20; i++) {
|
|
const folder = createMockFolder(`folder${i}`);
|
|
fileEventHandler(FileEvent.Create, folder, { oldPath: '' });
|
|
}
|
|
|
|
const results = vaultCacheService.matchFolder('folder');
|
|
expect(results.length).toBeLessThanOrEqual(10);
|
|
});
|
|
|
|
it('should update results after folder is added', () => {
|
|
const resultsBefore = vaultCacheService.matchFolder('newfolder');
|
|
expect(resultsBefore.length).toBe(0);
|
|
|
|
const newFolder = createMockFolder('newfolder');
|
|
fileEventHandler(FileEvent.Create, newFolder, { oldPath: '' });
|
|
|
|
const resultsAfter = vaultCacheService.matchFolder('newfolder');
|
|
expect(resultsAfter.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should update results after folder is renamed', () => {
|
|
const folder = createMockFolder('oldname');
|
|
fileEventHandler(FileEvent.Create, folder, { oldPath: '' });
|
|
|
|
const resultsBefore = vaultCacheService.matchFolder('oldname');
|
|
expect(resultsBefore.length).toBeGreaterThan(0);
|
|
|
|
const renamedFolder = createMockFolder('newname');
|
|
fileEventHandler(FileEvent.Rename, renamedFolder, { oldPath: folder.path });
|
|
|
|
const resultsOldName = vaultCacheService.matchFolder('oldname');
|
|
expect(resultsOldName.length).toBe(0);
|
|
|
|
const resultsNewName = vaultCacheService.matchFolder('newname');
|
|
expect(resultsNewName.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should update results after folder is deleted', () => {
|
|
const folder = createMockFolder('todelete');
|
|
fileEventHandler(FileEvent.Create, folder, { oldPath: '' });
|
|
|
|
const resultsBefore = vaultCacheService.matchFolder('todelete');
|
|
expect(resultsBefore.length).toBeGreaterThan(0);
|
|
|
|
fileEventHandler(FileEvent.Delete, folder, { oldPath: folder.path });
|
|
|
|
const resultsAfter = vaultCacheService.matchFolder('todelete');
|
|
expect(resultsAfter.length).toBe(0);
|
|
});
|
|
});
|
|
});
|