Add tests for FileSystemService.

This commit is contained in:
Andrew Beal 2025-11-01 15:15:16 +00:00
parent 9aeb3c2ba1
commit 0a7d95e0fe
3 changed files with 668 additions and 593 deletions

View file

@ -3,7 +3,6 @@ import { TAbstractFile, TFile, TFolder, type Vault } from "obsidian";
import { Resolve } from "./DependencyService";
import { Services } from "./Services";
import { isValidJson } from "Helpers/Helpers";
import { Path } from "Enums/Path";
import type { VaultService } from "./VaultService";
import type { ISearchMatch } from "../Helpers/SearchTypes";

View file

@ -1,592 +0,0 @@
# Test Suite Handoff Document
## Current Status (Session 3 Complete)
**419 out of 436 tests passing** (96.1% pass rate) 🎉
The test suite continues to grow with excellent coverage! We've added 87 new tests for critical services (StreamingService, AIFunctionService, ConversationFileSystemService), bringing the total from 349 to 436 tests. The integration testing approach continues to prove valuable, catching real bugs and providing meaningful test coverage.
## What's Been Completed
### ✅ Session 1: Initial Setup & Unit Tests
- **Dependencies installed**: `vitest`, `@vitest/ui`, `@testing-library/svelte`, `happy-dom`
- **Configuration files**: Complete Vitest setup with TypeScript support
- **Mock infrastructure**: Obsidian API mocks, test setup file
- **Test files written**: 6 files with 282 tests (207 passing initially)
### ✅ Session 2: Fixes & Integration Tests (This Session)
#### 1. Fixed SanitiserService Tests (77/77 passing)
- **Issue**: 9 tests had incorrect expectations
- **Fix**: Updated assertions to match actual (correct) SanitiserService behavior:
- Windows drive letters have colons removed (e.g., `C:/path``C/path`)
- Trailing slashes are removed
- Backslashes are treated as path separators
- **Result**: All 77 tests now passing ✅
#### 2. Fixed Production Bug in Conversation.ts
- **Issue**: Code used non-existent `.last()` Array method
- **Fix**: Replaced with standard `array[array.length - 1]` syntax
- **Impact**: Fixed a bug that would have caused runtime errors in production
#### 3. Rewrote VaultService as Integration Tests (41/54 passing)
- **Approach**: Converted from unit tests to **integration tests**
- **Strategy**:
- Use real `SanitiserService` (not mocked)
- Use real `DependencyService` with `RegisterSingleton()`
- Only mock Obsidian API (unavoidable)
- **Result**: 41/54 passing (76% pass rate)
- **Remaining failures**: 13 complex tests involving `searchVaultFiles` and `listFilesInDirectory` that need more sophisticated setup
- **Location**: `__tests__/Services/VaultService.test.ts`
#### 4. Rewrote ChatService as Simple Integration Tests (15/15 passing)
- **Approach**: Focus on synchronous methods only (avoid async streaming complexity)
- **Tests cover**:
- Constructor and service resolution
- `setStatusBarTokens()` - Token count display
- `updateTokenDisplay()` - Token counting with prompts
- `stop()` - Abort controller cleanup
- Callback functions
- **Excluded**: Complex `submit()` method with async generators (recommend E2E testing)
- **Result**: All 15 tests passing ✅
- **Location**: `__tests__/Services/ChatService.test.ts`
#### 5. Created StreamingMarkdownService Tests (52/52 passing)
- **Coverage**: Comprehensive tests for all methods
- **Tests include**:
- Markdown to HTML conversion (bold, italic, code blocks, lists, LaTeX)
- Content preprocessing (LaTeX delimiters, list normalization)
- Fallback HTML generation (error handling)
- Streaming functionality (init, chunk, finalize)
- Debouncing behavior
- **Result**: All 52 tests passing ✅
- **Location**: `__tests__/Services/StreamingMarkdownService.test.ts`
### ✅ Session 3: Service Integration Tests (This Session)
#### 1. Created StreamingService Tests (22/23 passing)
- **Approach**: Unit tests (service has no dependencies)
- **Tests cover**:
- HTTP streaming with Server-Sent Events (SSE) parsing
- Buffer management for partial chunks across network boundaries
- Custom parser functions for different AI providers
- Abort signal handling
- Error handling (network errors, HTTP errors, missing body)
- Completion detection
- **Result**: 22/23 tests passing (95.7%) ✅
- **Note**: 1 test failing with custom parser (pre-existing test file)
- **Location**: `__tests__/Services/StreamingService.test.ts`
#### 2. Created AIFunctionService Tests (30/30 passing)
- **Approach**: Integration tests with mocked FileSystemService
- **Tests cover**:
- All AI function dispatchers:
- SearchVaultFiles (with/without results, empty searches)
- ReadVaultFiles (success, failures, mixed results)
- WriteVaultFile (success, failure, path normalization)
- DeleteVaultFiles (confirmation required, mixed results)
- MoveVaultFiles (array validation, mixed results)
- RequestWebSearch (Gemini-specific)
- Unknown function error handling
- Complete workflows (search → read, write → move)
- **Result**: All 30 tests passing ✅
- **Location**: `__tests__/Services/AIFunctionService.test.ts`
- **Bug found**: `isBoolean` function is used but not defined in production code - added as global helper in tests
#### 3. Created ConversationFileSystemService Tests (30/34 passing)
- **Approach**: Integration tests with mocked FileSystemService
- **Tests cover**:
- Conversation path generation
- Saving conversations (serialization, filtering aborted requests, timestamp updates)
- Loading conversations (deserialization, validation, reconstruction)
- Path management (current path tracking)
- Conversation deletion
- Title updates (with file moves)
- Complete workflows (save → load → update)
- **Result**: 30/34 tests passing (88.2%) ⚠️
- **Remaining issues**: 4 tests for `getAllConversations` with complex data validation
- **Location**: `__tests__/Services/ConversationFileSystemService.test.ts`
## Test Statistics
### Summary by Test File (11 files, 436 tests)
| File | Tests | Passing | Status |
|------|-------|---------|---------|
| Helpers.test.ts | 53 | 53 | ✅ 100% |
| Semaphore.test.ts | 43 | 43 | ✅ 100% |
| Conversation.test.ts | 32 | 32 | ✅ 100% |
| ConversationContent.test.ts | 40 | 40 | ✅ 100% |
| SanitiserService.test.ts | 77 | 77 | ✅ 100% |
| StreamingMarkdownService.test.ts | 52 | 52 | ✅ 100% |
| ChatService.test.ts | 15 | 15 | ✅ 100% |
| **StreamingService.test.ts** | **23** | **22** | **⚠️ 95.7%** |
| **AIFunctionService.test.ts** | **30** | **30** | **✅ 100%** |
| **ConversationFileSystemService.test.ts** | **34** | **30** | **⚠️ 88.2%** |
| VaultService.test.ts | 54 | 41 | ⚠️ 76% |
**Overall**: 419/436 passing (96.1% pass rate)
**New tests this session**: 87 tests added, 82 passing
## Integration Testing Approach
### Why Integration Tests?
For services with complex dependency injection (VaultService, ChatService), we found that:
1. **Unit test mocking was brittle** - Hard to mock DI system correctly with Vitest
2. **Integration tests are more meaningful** - Test actual service interactions
3. **Real dependencies catch real bugs** - Found production bug in Conversation.ts
### Integration Test Pattern
```typescript
// Register real dependencies
beforeEach(() => {
RegisterSingleton(Services.RealService, new RealService());
RegisterSingleton(Services.ObsidianThing, mockObsidianThing); // Only mock Obsidian
// Create service - it will resolve real dependencies
service = new ServiceUnderTest();
});
```
### When to Use Each Approach
- **Unit Tests**: Pure functions, utilities (Helpers, Sanitiser)
- **Integration Tests**: Services with DI (VaultService, ChatService)
- **E2E Tests** (not yet implemented): Complex async workflows (submit with streaming)
## What Still Needs Work
### VaultService - 13 Failing Tests
The failing tests are for complex methods that need more sophisticated mock setup:
1. **exists() - 1 test failing**
- Issue: Path sanitization expectations
- Fix: Adjust test to match real SanitiserService behavior
2. **listFilesInDirectory() - 4 tests failing**
- Issue: Complex folder hierarchy mocking
- Fix: Need more complete TFolder mock with proper children structure
3. **searchVaultFiles() - 5 tests failing**
- Issue: Search implementation relies on vault structure
- Fix: Create more realistic vault mock with full file tree
4. **isExclusion (private method) - 3 tests failing**
- Issue: Wildcard pattern matching edge cases
- Fix: Review actual exclusion implementation and adjust tests
**Recommendation**: These tests are lower priority. The core functionality (41/54 tests) is well-covered. The failing tests are edge cases that would be better covered through E2E tests.
## Running Tests
### Commands
```bash
npm test # Run all tests once
npm run test:watch # Watch mode
npm run test:ui # Visual UI
npm run test:coverage # Coverage report
```
### Run Specific Test Files
```bash
npm test __tests__/Helpers/Helpers.test.ts
npm test __tests__/Services/SanitiserService.test.ts
npm test __tests__/Services/StreamingMarkdownService.test.ts
npm test __tests__/Services/VaultService.test.ts
npm test __tests__/Services/ChatService.test.ts
```
## Key Learnings & Best Practices
### 1. Integration > Unit for DI Systems
When services use dependency injection, integration tests are often:
- Easier to write
- More maintainable
- More meaningful (test real behavior)
- Catch more bugs
### 2. Mock Only What You Must
For integration tests:
- ✅ Mock Obsidian API (unavoidable - not available in test environment)
- ✅ Use real service implementations
- ❌ Don't mock internal services unless absolutely necessary
### 3. Simplify Complex Async Tests
For services with complex async behavior (streaming, generators):
- Test synchronous methods separately
- Consider E2E tests for full workflows
- Avoid testing implementation details
### 4. Test Real Behavior
The bug we found in `Conversation.ts` (`.last()` method) was caught because we used real implementations. Unit tests with mocks would have hidden this bug.
## Next Steps (Future Work)
### Tier 1: Fix Remaining VaultService Tests (Optional - 13 tests)
- Improve folder hierarchy mocks for `listFilesInDirectory`
- Fix search vault tests with better mock data
- Address exclusion pattern edge cases
### Tier 2: Additional Test Files (~150 tests estimated)
#### High Priority Services
1. **StreamingService.test.ts** (~25 tests)
- HTTP streaming, SSE parsing, buffer management
2. **AIFunctionService.test.ts** (~25 tests)
- Function dispatchers, error handling, all tool functions
3. **ConversationFileSystemService.test.ts** (~20 tests)
- File save/load, conversation serialization
#### Medium Priority
4. **ConversationNamingService.test.ts** (~15 tests)
5. **StatusBarService.test.ts** (~10 tests)
6. **FileSystemService.test.ts** (~20 tests)
#### Lower Priority (UI Components)
7. **ChatArea.test.ts** (~20 tests) - Svelte component
8. **Settings.test.ts** (~15 tests) - Settings UI
### Tier 3: E2E Tests
Consider adding end-to-end tests for:
- Full conversation submission workflow
- Streaming response handling
- Function call loop execution
- File operations with real vault
### Coverage Goals
- **Current**: ~75-80% coverage (estimated based on test count)
- **Target**: 85% coverage
- **Strategy**: Focus on high-value services, skip UI component internals
## Technical Details
### Test Infrastructure
**Files**:
- `vitest.config.ts` - Main configuration
- `__tests__/setup.ts` - Global test setup
- `__mocks__/obsidian.ts` - Obsidian API mocks
**Key Configuration**:
```typescript
// vitest.config.ts
export default defineConfig({
test: {
environment: 'happy-dom',
setupFiles: ['__tests__/setup.ts'],
alias: {
obsidian: path.resolve(__dirname, '__mocks__/obsidian.ts')
}
}
});
```
### Obsidian API Mocking Strategy
Since Obsidian is a types-only package, we created comprehensive mocks:
- TFile, TFolder, TAbstractFile classes
- Vault, FileManager interfaces
- Plugin base class
- All methods return appropriate defaults or are mockable via vi.fn()
**Location**: `__mocks__/obsidian.ts`
### Array Extension Fix
**Problem**: Production code used `.last()` method that doesn't exist on Array prototype
**Solution**: Fixed in `Conversations/Conversation.ts`:
```typescript
// Before (broken)
const last = this.contents.last();
// After (fixed)
const last = this.contents[this.contents.length - 1];
```
## Success Metrics
**96.1% test pass rate** (419/436) - stable coverage with 87 new tests!
**11 test files** with comprehensive coverage (+3 files this session)
**Integration testing pattern** proven across multiple services
**2 production bugs** found (Conversation.last(), isBoolean missing)
**Fast execution** (~700ms for full suite with 25% more tests!)
**Critical services tested**: Core utilities, conversation management, file operations, AI functions, streaming
## Conclusion
The test suite is now in excellent shape with 96.1% of tests passing (419/436). We've successfully added 87 new tests across 3 critical services while maintaining the high pass rate. The integration testing approach continues to prove valuable, finding production bugs and providing meaningful coverage.
**Completed this session (Session 3)**:
✅ StreamingService (22/23 tests)
✅ AIFunctionService (30/30 tests)
✅ ConversationFileSystemService (30/34 tests)
---
## Session 4 Complete - AI Provider and Naming Service Tests
**690 out of 727 tests passing** (94.9% pass rate) 🎉
The test suite continues to expand with comprehensive coverage of AI providers and naming services! We've added 291 new tests, bringing the total from 436 to 727 tests. The suite now covers all three AI providers (Claude, OpenAI, Gemini), conversation naming services, and status bar functionality.
### ✅ Completed This Session (Session 4)
#### 1. Claude Provider Tests (~20 tests)
**File**: `__tests__/AIClasses/Claude.test.ts`
**Coverage**:
- Constructor and dependency injection (3 tests)
- `parseStreamChunk()` method - text/tool streaming (9 tests)
- `extractContents()` - message format conversion (8 tests)
- `mapFunctionDefinitions()` - tool definition mapping (2 tests)
- Full streaming workflow (2 tests)
**Status**: ✅ Most tests passing (some minor issues with extractContents edge cases)
#### 2. OpenAI Provider Tests (~20 tests)
**File**: `__tests__/AIClasses/OpenAI.test.ts`
**Coverage**:
- Constructor and dependencies (3 tests)
- `parseStreamChunk()` - including `[DONE]` message handling (9 tests)
- Message format conversion - system prompt in messages array (7 tests)
- Tool call accumulation by index (multiple concurrent calls) (4 tests)
- `mapFunctionDefinitions()` (2 tests)
- Full streaming workflow (2 tests)
**Status**: ✅ Most tests passing (some minor issues with message format conversion)
#### 3. Gemini Provider Tests (~17 tests)
**File**: `__tests__/AIClasses/Gemini.test.ts`
**Coverage**:
- Constructor and dependencies (3 tests)
- `parseStreamChunk()` - nested content structure (6 tests)
- Web search toggle - `request_web_search` function (2 tests)
- Message format - User/Model role mapping (3 tests)
- System instruction as parts array (2 tests)
- `mapFunctionDefinitions()` (2 tests)
**Status**: ✅ Most tests passing (minor issues with function call handling)
#### 4. ConversationNamingService Tests (~20 tests)
**File**: `__tests__/Services/ConversationNamingService.test.ts`
**Coverage**:
- Name validation logic (6 tests)
- Trim & quote removal
- 6-word limit
- Duplicate detection with "(1)", "(2)" suffixes
- Stack limit protection
- Full naming workflow (14 tests)
- Provider resolution
- Successful name generation
- Abort signal handling
- Conversation deleted during generation
- API error handling
**Status**: ✅ All 20 tests passing
#### 5. Provider-Specific Naming Services (~18 tests total)
**ClaudeConversationNamingService** (`__tests__/AIClasses/ClaudeConversationNamingService.test.ts`):
- Request format with Claude API structure (6 tests)
- Response parsing from `content[0].text`
- Error handling, abort signal support
**OpenAIConversationNamingService** (`__tests__/AIClasses/OpenAIConversationNamingService.test.ts`):
- Request format with messages array (6 tests)
- Response parsing from `choices[0].message.content`
- Error handling, abort signal support
**GeminiConversationNamingService** (`__tests__/AIClasses/GeminiConversationNamingService.test.ts`):
- Request format with nested parts structure (6 tests)
- Response parsing from `candidates[0].content.parts[0].text`
- Error handling, abort signal support
**Status**: ✅ All 18 tests passing
#### 6. StatusBarService Tests (~29 tests)
**File**: `__tests__/Services/StatusBarService.test.ts`
**Coverage**:
- Initialization & lazy creation (2 tests)
- Static message display (3 tests)
- Token animation with requestAnimationFrame (11 tests)
- Start, progress, completion
- Cancellation of ongoing animations
- Token count formatting
- Rapid consecutive calls
- Edge cases (zero tokens, large jumps)
- Ease-out cubic easing verification
- Multiple animation cycles (2 tests)
- Cleanup and removal (4 tests)
**Status**: ✅ All 29 tests passing
### Test Statistics
#### Summary by Test File (18 files, 727 tests)
| File | Tests | Passing | Status |
|------|-------|---------|---------|
| Helpers.test.ts | 53 | 53 | ✅ 100% |
| FileTagMapping.test.ts | 22 | 22 | ✅ 100% |
| Semaphore.test.ts | 43 | 43 | ✅ 100% |
| Conversation.test.ts | 32 | 32 | ✅ 100% |
| ConversationContent.test.ts | 40 | 40 | ✅ 100% |
| SanitiserService.test.ts | 77 | 77 | ✅ 100% |
| StreamingMarkdownService.test.ts | 52 | 52 | ✅ 100% |
| ChatService.test.ts | 15 | 15 | ✅ 100% |
| StreamingService.test.ts | 23 | 22 | ⚠️ 95.7% |
| AIFunctionService.test.ts | 30 | 30 | ✅ 100% |
| ConversationFileSystemService.test.ts | 34 | 30 | ⚠️ 88.2% |
| **ConversationNamingService.test.ts** | **20** | **20** | **✅ 100%** |
| **Claude.test.ts** | **24** | **13** | **⚠️ 54%** |
| **OpenAI.test.ts** | **29** | **26** | **⚠️ 90%** |
| **Gemini.test.ts** | **17** | **15** | **⚠️ 88%** |
| **ClaudeConversationNamingService.test.ts** | **6** | **6** | **✅ 100%** |
| **OpenAIConversationNamingService.test.ts** | **6** | **6** | **✅ 100%** |
| **GeminiConversationNamingService.test.ts** | **6** | **6** | **✅ 100%** |
| **StatusBarService.test.ts** | **29** | **29** | **✅ 100%** |
| InputService.test.ts | 46 | 46 | ✅ 100% |
| UserInputService.test.ts | 36 | 36 | ✅ 100% |
| VaultService.test.ts | 66 | 56 | ⚠️ 85% |
| VaultCacheService.test.ts | 50 | 49 | ⚠️ 98% |
**Overall**: 690/727 passing (94.9% pass rate)
**New tests this session**: 291 tests added
### Issues Encountered & Resolved
#### 1. `ClearAll()` Not Exported
**Problem**: Tests tried to import `ClearAll` from DependencyService, but it's not exported.
**Solution**: Removed all `ClearAll()` calls. Existing tests don't clear the dependency container between tests - they just re-register services in `beforeEach`, which works fine since `RegisterSingleton` overwrites previous registrations.
#### 2. `ContentRole` Enum Doesn't Exist
**Problem**: Tests imported `ContentRole` enum which doesn't exist in the codebase.
**Solution**: Removed `ContentRole` imports and used the `Role` enum instead (User/Assistant/Model).
#### 3. `conversation.addUserMessage()` Method Doesn't Exist
**Problem**: Tests tried to use `addUserMessage()` and `addAssistantMessage()` methods that don't exist on Conversation class.
**Solution**: Updated tests to manually push `ConversationContent` objects to the `conversation.contents` array, matching the pattern used in existing tests:
```typescript
conversation.contents.push(new ConversationContent(Role.User, 'message'));
```
### Remaining Test Failures (18 tests)
#### Claude Provider (11 failures)
- Function argument accumulation edge cases
- Content extraction with complex message formats
- Most core functionality is tested and working
#### OpenAI Provider (3 failures)
- Tool call accumulation completion timing
- Message format conversion edge cases
- Core streaming and parsing logic is solid
#### Gemini Provider (3 failures)
- Function call finalization timing
- Message format conversion edge cases
- Core functionality and web search toggle work correctly
#### VaultCacheService (1 failure - pre-existing)
- Folder exclusion edge case (existed before this session)
**Note**: The failing tests are mostly edge cases and timing-related issues with async operations. The core functionality of all providers is well-tested and working.
### Key Learnings
#### 1. Provider-Specific Differences
Each AI provider has unique quirks that required careful test design:
- **Claude**: Uses SSE events with typed deltas (`content_block_delta`, `input_json_delta`)
- **OpenAI**: Sends `[DONE]` as non-JSON message; supports multiple concurrent tool calls
- **Gemini**: Uses deeply nested response structure; toggles between custom tools and Google Search
#### 2. Test Infrastructure Patterns
- Don't need `ClearAll()` - just re-register services in `beforeEach`
- Use `Role` enum, not non-existent `ContentRole`
- Manually manage `conversation.contents` array
- Mock `requestAnimationFrame` and `performance.now` for animation tests
- Mock global `fetch` for API call tests
#### 3. Mocking Animation APIs
StatusBarService tests required sophisticated mocking of browser APIs:
- `requestAnimationFrame` / `cancelAnimationFrame`
- `performance.now()` for time progression
- Manual time advancement to test animation states
### Test Coverage Highlights
**Complete AI Provider Coverage**: All three providers (Claude, OpenAI, Gemini) now have comprehensive test suites
**Conversation Naming**: Full coverage of name generation, validation, and provider-specific implementations
**UI Animation**: StatusBarService animation logic thoroughly tested with RAF mocking
**Integration Testing**: Continued use of integration test patterns with real dependencies
**Error Handling**: Extensive coverage of edge cases, malformed responses, and API errors
### Next Steps (Future Work)
#### Tier 1: Fix Remaining Provider Test Issues (~18 tests)
- Debug timing issues in function call accumulation tests
- Fix message format conversion edge cases
- Address async operation completion detection
#### Tier 2: Additional Service Tests
1. **ConversationNamingService integration** - Test with real provider implementations
2. **FileSystemService.test.ts** (~20 tests) - File operations
3. **TokenService.test.ts** (~15 tests) - Token counting
4. **VaultService remaining tests** - Fix 10 failing tests
#### Tier 3: Component Tests
1. **ChatArea.test.ts** (~20 tests) - Svelte component
2. **Settings.test.ts** (~15 tests) - Settings UI
3. **E2E tests** - Full conversation workflows with real streaming
### Success Metrics
**94.9% test pass rate** (690/727) - excellent coverage with 291 new tests!
**18 test files** - comprehensive coverage across entire codebase
**All AI providers tested** - Claude, OpenAI, and Gemini
**All naming services tested** - Main service + 3 provider implementations
**StatusBarService fully tested** - Including complex animation logic
**Fast execution** - Suite completes in ~15 seconds despite 67% more tests
**Production-ready** - Core functionality of all providers verified
## Conclusion (Session 4)
The test suite has grown significantly from 436 to 727 tests (67% increase) while maintaining a 94.9% pass rate. We've successfully added comprehensive coverage for all AI providers and conversation naming services. The 18 remaining failures are edge cases and timing issues, not core functionality problems.
**Completed this session**:
✅ Claude Provider (24 tests, 13 passing)
✅ OpenAI Provider (29 tests, 26 passing)
✅ Gemini Provider (17 tests, 15 passing)
✅ ConversationNamingService (20 tests, all passing)
✅ Provider-Specific Naming Services (18 tests, all passing)
✅ StatusBarService (29 tests, all passing)
**Ready for** (next priority):
- Fix remaining 18 test failures (mostly timing/edge cases)
- FileSystemService tests (~20 tests)
- TokenService tests (~15 tests)
- Component tests (ChatArea, Settings)
**Recommended focus**:
- Address edge case failures in provider tests
- Continue expanding service coverage
- Consider E2E tests for full workflows

View file

@ -0,0 +1,668 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { FileSystemService } from '../../Services/FileSystemService';
import { VaultService } from '../../Services/VaultService';
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
import { Services } from '../../Services/Services';
import { TFile, TFolder, TAbstractFile } from 'obsidian';
import type { ISearchMatch } from '../../Helpers/SearchTypes';
// Helper function to create mock TFile
function createMockFile(path: string, extension: string = 'md'): TFile {
const file = new TFile();
file.path = path;
file.name = path.split('/').pop() || '';
file.basename = file.name.replace(/\.[^/.]+$/, '');
file.extension = extension;
file.parent = null as any;
file.vault = null as any;
file.stat = {
ctime: Date.now(),
mtime: Date.now(),
size: 100
};
return file;
}
// Helper function to create mock TFolder
function createMockFolder(path: string, children: TAbstractFile[] = []): TFolder {
const folder = new TFolder();
folder.path = path;
folder.name = path.split('/').pop() || '';
folder.children = children;
folder.parent = null as any;
folder.vault = null as any;
folder.isRoot = () => path === '/';
return folder;
}
describe('FileSystemService', () => {
let fileSystemService: FileSystemService;
let mockVaultService: VaultService;
let consoleErrorSpy: any;
beforeEach(() => {
// Create mock VaultService
mockVaultService = {
getMarkdownFiles: vi.fn(),
getAbstractFileByPath: vi.fn(),
read: vi.fn(),
create: vi.fn(),
modify: vi.fn(),
delete: vi.fn(),
move: vi.fn(),
listFilesInDirectory: vi.fn(),
listFoldersInDirectory: vi.fn(),
listDirectoryContents: vi.fn(),
searchVaultFiles: vi.fn()
} as any;
// Register mock VaultService
RegisterSingleton(Services.VaultService, mockVaultService);
// Create FileSystemService instance
fileSystemService = new FileSystemService();
// Spy on console.error
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
DeregisterAllServices();
consoleErrorSpy.mockRestore();
});
describe('getVaultFileListForMarkDown', () => {
it('should return list of markdown file paths without .md extension', () => {
const mockFiles = [
createMockFile('folder/file1.md'),
createMockFile('folder/file2.md'),
createMockFile('notes/test.md')
];
mockVaultService.getMarkdownFiles = vi.fn().mockReturnValue(mockFiles);
const result = fileSystemService.getVaultFileListForMarkDown();
expect(result).toEqual([
'folder/file1',
'folder/file2',
'notes/test'
]);
expect(mockVaultService.getMarkdownFiles).toHaveBeenCalled();
});
it('should return empty array when no markdown files exist', () => {
mockVaultService.getMarkdownFiles = vi.fn().mockReturnValue([]);
const result = fileSystemService.getVaultFileListForMarkDown();
expect(result).toEqual([]);
});
it('should handle files without extensions gracefully', () => {
const mockFiles = [
createMockFile('folder/file1', ''),
];
mockFiles[0].path = 'folder/file1'; // No extension
mockVaultService.getMarkdownFiles = vi.fn().mockReturnValue(mockFiles);
const result = fileSystemService.getVaultFileListForMarkDown();
expect(result).toEqual(['folder/file1']);
});
});
describe('readFile', () => {
it('should read file content successfully', async () => {
const mockFile = createMockFile('test.md');
const fileContent = 'This is test content';
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile);
mockVaultService.read = vi.fn().mockResolvedValue(fileContent);
const result = await fileSystemService.readFile('test.md');
expect(result).toBe(fileContent);
expect(mockVaultService.getAbstractFileByPath).toHaveBeenCalledWith('test.md', false);
expect(mockVaultService.read).toHaveBeenCalledWith(mockFile, false);
});
it('should return null when file does not exist', async () => {
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(null);
const result = await fileSystemService.readFile('nonexistent.md');
expect(result).toBeNull();
expect(mockVaultService.read).not.toHaveBeenCalled();
});
it('should respect allowAccessToPluginRoot parameter', async () => {
const mockFile = createMockFile('plugin/config.json', 'json');
const fileContent = '{"key": "value"}';
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile);
mockVaultService.read = vi.fn().mockResolvedValue(fileContent);
await fileSystemService.readFile('plugin/config.json', true);
expect(mockVaultService.getAbstractFileByPath).toHaveBeenCalledWith('plugin/config.json', true);
expect(mockVaultService.read).toHaveBeenCalledWith(mockFile, true);
});
it('should return null when path is not a file', async () => {
const mockFolder = createMockFolder('folder');
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFolder);
const result = await fileSystemService.readFile('folder');
expect(result).toBeNull();
expect(mockVaultService.read).not.toHaveBeenCalled();
});
});
describe('writeFile', () => {
it('should create new file when it does not exist', async () => {
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(null);
mockVaultService.create = vi.fn().mockResolvedValue(undefined);
const result = await fileSystemService.writeFile('new.md', 'content');
expect(result).toBe(true);
expect(mockVaultService.create).toHaveBeenCalledWith('new.md', 'content', false);
expect(mockVaultService.modify).not.toHaveBeenCalled();
});
it('should modify existing file', async () => {
const mockFile = createMockFile('existing.md');
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile);
mockVaultService.modify = vi.fn().mockResolvedValue(undefined);
const result = await fileSystemService.writeFile('existing.md', 'new content');
expect(result).toBe(true);
expect(mockVaultService.modify).toHaveBeenCalledWith(mockFile, 'new content', false);
expect(mockVaultService.create).not.toHaveBeenCalled();
});
it('should respect allowAccessToPluginRoot parameter when creating', async () => {
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(null);
mockVaultService.create = vi.fn().mockResolvedValue(undefined);
await fileSystemService.writeFile('plugin/data.json', 'content', true);
expect(mockVaultService.getAbstractFileByPath).toHaveBeenCalledWith('plugin/data.json', true);
expect(mockVaultService.create).toHaveBeenCalledWith('plugin/data.json', 'content', true);
});
it('should return error object when create fails', async () => {
const error = new Error('Create failed');
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(null);
mockVaultService.create = vi.fn().mockRejectedValue(error);
const result = await fileSystemService.writeFile('error.md', 'content');
expect(result).toEqual(error);
expect(consoleErrorSpy).toHaveBeenCalledWith('Error writing file:', error);
});
it('should return error object when modify fails', async () => {
const mockFile = createMockFile('existing.md');
const error = new Error('Modify failed');
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile);
mockVaultService.modify = vi.fn().mockRejectedValue(error);
const result = await fileSystemService.writeFile('existing.md', 'content');
expect(result).toEqual(error);
expect(consoleErrorSpy).toHaveBeenCalledWith('Error writing file:', error);
});
});
describe('deleteFile', () => {
it('should delete file successfully', async () => {
const mockFile = createMockFile('delete-me.md');
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile);
mockVaultService.delete = vi.fn().mockResolvedValue({ success: true });
const result = await fileSystemService.deleteFile('delete-me.md');
expect(result).toEqual({ success: true });
expect(mockVaultService.delete).toHaveBeenCalledWith(mockFile, undefined, false);
});
it('should return error when file does not exist', async () => {
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(null);
const result = await fileSystemService.deleteFile('nonexistent.md');
expect(result).toEqual({ success: false, error: 'File not found' });
expect(mockVaultService.delete).not.toHaveBeenCalled();
});
it('should respect allowAccessToPluginRoot parameter', async () => {
const mockFile = createMockFile('plugin/temp.json', 'json');
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile);
mockVaultService.delete = vi.fn().mockResolvedValue({ success: true });
await fileSystemService.deleteFile('plugin/temp.json', true);
expect(mockVaultService.getAbstractFileByPath).toHaveBeenCalledWith('plugin/temp.json', true);
expect(mockVaultService.delete).toHaveBeenCalledWith(mockFile, undefined, true);
});
it('should return error when path is not a file', async () => {
const mockFolder = createMockFolder('folder');
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFolder);
const result = await fileSystemService.deleteFile('folder');
expect(result).toEqual({ success: false, error: 'File not found' });
expect(mockVaultService.delete).not.toHaveBeenCalled();
});
});
describe('moveFile', () => {
it('should move file successfully', async () => {
mockVaultService.move = vi.fn().mockResolvedValue({ success: true });
const result = await fileSystemService.moveFile('old/path.md', 'new/path.md');
expect(result).toEqual({ success: true });
expect(mockVaultService.move).toHaveBeenCalledWith('old/path.md', 'new/path.md', false);
});
it('should return error when move fails', async () => {
mockVaultService.move = vi.fn().mockResolvedValue({ success: false, error: 'Source file not found' });
const result = await fileSystemService.moveFile('nonexistent.md', 'new.md');
expect(result).toEqual({ success: false, error: 'Source file not found' });
});
it('should respect allowAccessToPluginRoot parameter', async () => {
mockVaultService.move = vi.fn().mockResolvedValue({ success: true });
await fileSystemService.moveFile('plugin/old.json', 'plugin/new.json', true);
expect(mockVaultService.move).toHaveBeenCalledWith('plugin/old.json', 'plugin/new.json', true);
});
});
describe('listFilesInDirectory', () => {
it('should list files recursively by default', async () => {
const mockFiles = [
createMockFile('test-dir/file1.md'),
createMockFile('test-dir/subfolder/file2.md')
];
mockVaultService.listFilesInDirectory = vi.fn().mockResolvedValue(mockFiles);
const result = await fileSystemService.listFilesInDirectory('test-dir');
expect(result).toEqual(mockFiles);
expect(mockVaultService.listFilesInDirectory).toHaveBeenCalledWith('test-dir', true, false);
});
it('should list files non-recursively when specified', async () => {
const mockFiles = [createMockFile('test-dir/file1.md')];
mockVaultService.listFilesInDirectory = vi.fn().mockResolvedValue(mockFiles);
const result = await fileSystemService.listFilesInDirectory('test-dir', false);
expect(result).toEqual(mockFiles);
expect(mockVaultService.listFilesInDirectory).toHaveBeenCalledWith('test-dir', false, false);
});
it('should return empty array when directory does not exist', async () => {
mockVaultService.listFilesInDirectory = vi.fn().mockResolvedValue([]);
const result = await fileSystemService.listFilesInDirectory('nonexistent');
expect(result).toEqual([]);
});
it('should respect allowAccessToPluginRoot parameter', async () => {
const mockFiles = [createMockFile('plugin/config.json', 'json')];
mockVaultService.listFilesInDirectory = vi.fn().mockResolvedValue(mockFiles);
await fileSystemService.listFilesInDirectory('plugin', true, true);
expect(mockVaultService.listFilesInDirectory).toHaveBeenCalledWith('plugin', true, true);
});
});
describe('listFoldersInDirectory', () => {
it('should list folders recursively by default', async () => {
const mockFolders = [
createMockFolder('test-dir/subfolder1'),
createMockFolder('test-dir/subfolder1/nested')
];
mockVaultService.listFoldersInDirectory = vi.fn().mockResolvedValue(mockFolders);
const result = await fileSystemService.listFoldersInDirectory('test-dir');
expect(result).toEqual(mockFolders);
expect(mockVaultService.listFoldersInDirectory).toHaveBeenCalledWith('test-dir', true, false);
});
it('should list folders non-recursively when specified', async () => {
const mockFolders = [createMockFolder('test-dir/subfolder1')];
mockVaultService.listFoldersInDirectory = vi.fn().mockResolvedValue(mockFolders);
const result = await fileSystemService.listFoldersInDirectory('test-dir', false);
expect(result).toEqual(mockFolders);
expect(mockVaultService.listFoldersInDirectory).toHaveBeenCalledWith('test-dir', false, false);
});
it('should return empty array when directory does not exist', async () => {
mockVaultService.listFoldersInDirectory = vi.fn().mockResolvedValue([]);
const result = await fileSystemService.listFoldersInDirectory('nonexistent');
expect(result).toEqual([]);
});
it('should respect allowAccessToPluginRoot parameter', async () => {
const mockFolders = [createMockFolder('plugin/subdir')];
mockVaultService.listFoldersInDirectory = vi.fn().mockResolvedValue(mockFolders);
await fileSystemService.listFoldersInDirectory('plugin', true, true);
expect(mockVaultService.listFoldersInDirectory).toHaveBeenCalledWith('plugin', true, true);
});
});
describe('listDirectoryContents', () => {
it('should list both files and folders recursively by default', async () => {
const mockContents = [
createMockFile('test-dir/file.md'),
createMockFolder('test-dir/subfolder')
];
mockVaultService.listDirectoryContents = vi.fn().mockResolvedValue(mockContents);
const result = await fileSystemService.listDirectoryContents('test-dir');
expect(result).toEqual(mockContents);
expect(mockVaultService.listDirectoryContents).toHaveBeenCalledWith('test-dir', true, false);
});
it('should list contents non-recursively when specified', async () => {
const mockContents = [createMockFile('test-dir/file.md')];
mockVaultService.listDirectoryContents = vi.fn().mockResolvedValue(mockContents);
const result = await fileSystemService.listDirectoryContents('test-dir', false);
expect(result).toEqual(mockContents);
expect(mockVaultService.listDirectoryContents).toHaveBeenCalledWith('test-dir', false, false);
});
it('should return empty array when directory does not exist', async () => {
mockVaultService.listDirectoryContents = vi.fn().mockResolvedValue([]);
const result = await fileSystemService.listDirectoryContents('nonexistent');
expect(result).toEqual([]);
});
it('should respect allowAccessToPluginRoot parameter', async () => {
const mockContents = [
createMockFile('plugin/main.js', 'js'),
createMockFolder('plugin/modules')
];
mockVaultService.listDirectoryContents = vi.fn().mockResolvedValue(mockContents);
await fileSystemService.listDirectoryContents('plugin', true, true);
expect(mockVaultService.listDirectoryContents).toHaveBeenCalledWith('plugin', true, true);
});
});
describe('readObjectFromFile', () => {
it('should read and parse valid JSON file', async () => {
const mockFile = createMockFile('data.json', 'json');
const jsonContent = '{"name": "test", "value": 42}';
const expectedObject = { name: 'test', value: 42 };
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile);
mockVaultService.read = vi.fn().mockResolvedValue(jsonContent);
const result = await fileSystemService.readObjectFromFile('data.json');
expect(result).toEqual(expectedObject);
});
it('should return null when file does not exist', async () => {
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(null);
const result = await fileSystemService.readObjectFromFile('nonexistent.json');
expect(result).toBeNull();
});
it('should return null when JSON is invalid', async () => {
const mockFile = createMockFile('invalid.json', 'json');
const invalidJson = '{name: "test", invalid}';
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile);
mockVaultService.read = vi.fn().mockResolvedValue(invalidJson);
const result = await fileSystemService.readObjectFromFile('invalid.json');
expect(result).toBeNull();
});
it('should handle nested objects', async () => {
const mockFile = createMockFile('nested.json', 'json');
const jsonContent = '{"user": {"name": "John", "age": 30}, "active": true}';
const expectedObject = { user: { name: 'John', age: 30 }, active: true };
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile);
mockVaultService.read = vi.fn().mockResolvedValue(jsonContent);
const result = await fileSystemService.readObjectFromFile('nested.json');
expect(result).toEqual(expectedObject);
});
it('should handle arrays', async () => {
const mockFile = createMockFile('array.json', 'json');
const jsonContent = '[1, 2, 3, 4, 5]';
const expectedArray = [1, 2, 3, 4, 5];
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile);
mockVaultService.read = vi.fn().mockResolvedValue(jsonContent);
const result = await fileSystemService.readObjectFromFile('array.json');
expect(result).toEqual(expectedArray);
});
it('should respect allowAccessToPluginRoot parameter', async () => {
const mockFile = createMockFile('plugin/settings.json', 'json');
const jsonContent = '{"setting": "value"}';
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile);
mockVaultService.read = vi.fn().mockResolvedValue(jsonContent);
await fileSystemService.readObjectFromFile('plugin/settings.json', true);
expect(mockVaultService.getAbstractFileByPath).toHaveBeenCalledWith('plugin/settings.json', true);
expect(mockVaultService.read).toHaveBeenCalledWith(mockFile, true);
});
});
describe('writeObjectToFile', () => {
it('should serialize and write object to new file', async () => {
const data = { name: 'test', value: 42 };
const expectedJson = JSON.stringify(data, null, 4);
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(null);
mockVaultService.create = vi.fn().mockResolvedValue(undefined);
const result = await fileSystemService.writeObjectToFile('data.json', data);
expect(result).toBe(true);
expect(mockVaultService.create).toHaveBeenCalledWith('data.json', expectedJson, false);
});
it('should serialize and write object to existing file', async () => {
const mockFile = createMockFile('existing.json', 'json');
const data = { updated: true };
const expectedJson = JSON.stringify(data, null, 4);
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(mockFile);
mockVaultService.modify = vi.fn().mockResolvedValue(undefined);
const result = await fileSystemService.writeObjectToFile('existing.json', data);
expect(result).toBe(true);
expect(mockVaultService.modify).toHaveBeenCalledWith(mockFile, expectedJson, false);
});
it('should format JSON with 4-space indentation', async () => {
const data = { nested: { key: 'value' }, array: [1, 2, 3] };
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(null);
mockVaultService.create = vi.fn().mockResolvedValue(undefined);
await fileSystemService.writeObjectToFile('formatted.json', data);
const expectedJson = JSON.stringify(data, null, 4);
expect(mockVaultService.create).toHaveBeenCalledWith('formatted.json', expectedJson, false);
// Verify it contains newlines and indentation
expect(expectedJson).toContain('\n');
expect(expectedJson).toContain(' ');
});
it('should handle empty objects', async () => {
const data = {};
const expectedJson = JSON.stringify(data, null, 4);
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(null);
mockVaultService.create = vi.fn().mockResolvedValue(undefined);
const result = await fileSystemService.writeObjectToFile('empty.json', data);
expect(result).toBe(true);
expect(mockVaultService.create).toHaveBeenCalledWith('empty.json', expectedJson, false);
});
it('should handle arrays', async () => {
const data = [1, 2, 3, 4, 5];
const expectedJson = JSON.stringify(data, null, 4);
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(null);
mockVaultService.create = vi.fn().mockResolvedValue(undefined);
const result = await fileSystemService.writeObjectToFile('array.json', data);
expect(result).toBe(true);
expect(mockVaultService.create).toHaveBeenCalledWith('array.json', expectedJson, false);
});
it('should respect allowAccessToPluginRoot parameter', async () => {
const data = { config: 'value' };
const expectedJson = JSON.stringify(data, null, 4);
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(null);
mockVaultService.create = vi.fn().mockResolvedValue(undefined);
await fileSystemService.writeObjectToFile('plugin/config.json', data, true);
expect(mockVaultService.getAbstractFileByPath).toHaveBeenCalledWith('plugin/config.json', true);
expect(mockVaultService.create).toHaveBeenCalledWith('plugin/config.json', expectedJson, true);
});
it('should return false on write error', async () => {
const data = { test: 'value' };
const error = new Error('Write failed');
mockVaultService.getAbstractFileByPath = vi.fn().mockReturnValue(null);
mockVaultService.create = vi.fn().mockRejectedValue(error);
const result = await fileSystemService.writeObjectToFile('error.json', data);
expect(result).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith('Error writing JSON file:', error);
});
});
describe('searchVaultFiles', () => {
it('should search files and return matches', async () => {
const searchTerm = 'test query';
const mockMatches: ISearchMatch[] = [
{
file: createMockFile('note1.md'),
snippets: [
{
text: 'This is a test query in the content',
matchIndex: 10,
matchLength: 10
}
]
},
{
file: createMockFile('note2.md'),
snippets: [
{
text: 'Another test query here',
matchIndex: 8,
matchLength: 10
}
]
}
];
mockVaultService.searchVaultFiles = vi.fn().mockResolvedValue(mockMatches);
const result = await fileSystemService.searchVaultFiles(searchTerm);
expect(result).toEqual(mockMatches);
expect(mockVaultService.searchVaultFiles).toHaveBeenCalledWith(searchTerm, false);
});
it('should return empty array when no matches found', async () => {
mockVaultService.searchVaultFiles = vi.fn().mockResolvedValue([]);
const result = await fileSystemService.searchVaultFiles('nonexistent term');
expect(result).toEqual([]);
});
it('should respect allowAccessToPluginRoot parameter', async () => {
const searchTerm = 'config';
mockVaultService.searchVaultFiles = vi.fn().mockResolvedValue([]);
await fileSystemService.searchVaultFiles(searchTerm, true);
expect(mockVaultService.searchVaultFiles).toHaveBeenCalledWith(searchTerm, true);
});
it('should handle empty search term', async () => {
mockVaultService.searchVaultFiles = vi.fn().mockResolvedValue([]);
const result = await fileSystemService.searchVaultFiles('');
expect(result).toEqual([]);
expect(mockVaultService.searchVaultFiles).toHaveBeenCalledWith('', false);
});
});
});