mirror of
https://github.com/aaronsb/obsidian-mcp-plugin.git
synced 2026-07-22 06:45:14 +00:00
Remove eslint config overrides that were masking 1,822 strict type errors flagged by the ObsidianReviewBot. Replace all `any` types with proper TypeScript interfaces, type guards, and narrowed types across 40 files. - Strip eslint.config.mts overrides for no-explicit-any, no-unsafe-* - Add proper interfaces for MCP protocol, Obsidian internals, workers - Add paramStr/paramNum/paramBool helpers in semantic router - Fix recursive-copy test expectations - Remove unused dotenv dependency - Bump version to 0.11.9
361 lines
No EOL
12 KiB
TypeScript
361 lines
No EOL
12 KiB
TypeScript
import { SemanticRouter } from '../src/semantic/router';
|
|
import { ObsidianAPI } from '../src/utils/obsidian-api';
|
|
import { App } from 'obsidian';
|
|
|
|
// Result types for test assertions
|
|
interface FileCopyResult {
|
|
success: boolean;
|
|
sourcePath: string;
|
|
copiedTo: string;
|
|
}
|
|
|
|
interface DirectoryCopyResult {
|
|
success: boolean;
|
|
sourcePath: string;
|
|
destinationPath: string;
|
|
filesCount: number;
|
|
copiedFiles: string[];
|
|
skippedFiles: string[];
|
|
workflow: {
|
|
message: string;
|
|
suggested_next: Array<{ description: string; command: string }>;
|
|
};
|
|
}
|
|
|
|
// Mock the ObsidianAPI
|
|
class MockObsidianAPI extends ObsidianAPI {
|
|
private mockFiles: Map<string, { content: string; isImage?: boolean }> = new Map();
|
|
private mockDirectories: Set<string> = new Set();
|
|
|
|
constructor() {
|
|
super({} as App);
|
|
}
|
|
|
|
// Setup mock data
|
|
setupMockFileSystem() {
|
|
// Mock files
|
|
this.mockFiles.set('source/file1.md', { content: '# File 1\nContent of file 1' });
|
|
this.mockFiles.set('source/file2.md', { content: '# File 2\nContent of file 2' });
|
|
this.mockFiles.set('source/subdir/file3.md', { content: '# File 3\nContent of file 3' });
|
|
this.mockFiles.set('source/subdir/nested/file4.md', { content: '# File 4\nContent of file 4' });
|
|
this.mockFiles.set('source/image.png', { content: 'binary', isImage: true });
|
|
|
|
// Mock directories
|
|
this.mockDirectories.add('source');
|
|
this.mockDirectories.add('source/subdir');
|
|
this.mockDirectories.add('source/subdir/nested');
|
|
}
|
|
|
|
async listFiles(directory?: string): Promise<string[]> {
|
|
const targetDir = directory || '';
|
|
const files: string[] = [];
|
|
|
|
// Add files in this directory (return full paths like the real API)
|
|
for (const [path, _] of this.mockFiles) {
|
|
if (path.startsWith(targetDir)) {
|
|
const remainingPath = path.substring(targetDir.length);
|
|
if (remainingPath.startsWith('/')) {
|
|
const nextSlash = remainingPath.indexOf('/', 1);
|
|
if (nextSlash === -1) {
|
|
// File directly in this directory
|
|
files.push(path);
|
|
}
|
|
} else if (targetDir === '' && !path.includes('/')) {
|
|
// Root level file
|
|
files.push(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add subdirectories (return full paths with trailing slash)
|
|
for (const dirPath of this.mockDirectories) {
|
|
if (dirPath.startsWith(targetDir) && dirPath !== targetDir) {
|
|
const remainingPath = dirPath.substring(targetDir.length);
|
|
if (remainingPath.startsWith('/')) {
|
|
const nextSlash = remainingPath.indexOf('/', 1);
|
|
if (nextSlash === -1) {
|
|
// Direct subdirectory
|
|
files.push(dirPath + '/');
|
|
}
|
|
} else if (targetDir === '' && !dirPath.includes('/')) {
|
|
// Root level directory
|
|
files.push(dirPath + '/');
|
|
}
|
|
}
|
|
}
|
|
|
|
return files.filter(f => f !== '');
|
|
}
|
|
|
|
async getFile(path: string): Promise<any> {
|
|
const file = this.mockFiles.get(path);
|
|
if (!file) {
|
|
throw new Error(`File not found: ${path}`);
|
|
}
|
|
|
|
if (file.isImage) {
|
|
// Mock the same structure that isImageFile() would detect
|
|
return {
|
|
content: file.content,
|
|
path,
|
|
type: 'binary',
|
|
mimeType: 'image/png',
|
|
base64Data: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='
|
|
};
|
|
}
|
|
|
|
return {
|
|
content: file.content,
|
|
path,
|
|
type: 'text'
|
|
};
|
|
}
|
|
|
|
async createFile(path: string, content: string): Promise<any> {
|
|
if (this.mockFiles.has(path)) {
|
|
throw new Error(`File already exists: ${path}`);
|
|
}
|
|
|
|
this.mockFiles.set(path, { content });
|
|
return {
|
|
success: true,
|
|
path,
|
|
content
|
|
};
|
|
}
|
|
|
|
async updateFile(path: string, content: string): Promise<any> {
|
|
this.mockFiles.set(path, { content });
|
|
return {
|
|
success: true,
|
|
path,
|
|
content
|
|
};
|
|
}
|
|
|
|
async listFilesPaginated(directory?: string, page: number = 1, pageSize: number = 20): Promise<any> {
|
|
const targetDir = directory || '';
|
|
const items: any[] = [];
|
|
|
|
// Add files in this directory (return full paths like the real API)
|
|
for (const [path, file] of this.mockFiles) {
|
|
if (path.startsWith(targetDir)) {
|
|
const remainingPath = path.substring(targetDir.length);
|
|
if (remainingPath.startsWith('/')) {
|
|
const nextSlash = remainingPath.indexOf('/', 1);
|
|
if (nextSlash === -1) {
|
|
// File directly in this directory
|
|
items.push({
|
|
path: path,
|
|
name: path.split('/').pop(),
|
|
type: 'file',
|
|
size: file.content.length,
|
|
extension: path.split('.').pop()
|
|
});
|
|
}
|
|
} else if (targetDir === '' && !path.includes('/')) {
|
|
// Root level file
|
|
items.push({
|
|
path: path,
|
|
name: path,
|
|
type: 'file',
|
|
size: file.content.length,
|
|
extension: path.split('.').pop()
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add subdirectories
|
|
for (const dirPath of this.mockDirectories) {
|
|
if (dirPath.startsWith(targetDir) && dirPath !== targetDir) {
|
|
const remainingPath = dirPath.substring(targetDir.length);
|
|
if (remainingPath.startsWith('/')) {
|
|
const nextSlash = remainingPath.indexOf('/', 1);
|
|
if (nextSlash === -1) {
|
|
// Direct subdirectory
|
|
items.push({
|
|
path: dirPath,
|
|
name: dirPath.split('/').pop(),
|
|
type: 'folder'
|
|
});
|
|
}
|
|
} else if (targetDir === '' && !dirPath.includes('/')) {
|
|
// Root level directory
|
|
items.push({
|
|
path: dirPath,
|
|
name: dirPath,
|
|
type: 'folder'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
files: items.filter(item => item !== null),
|
|
page,
|
|
pageSize,
|
|
totalFiles: items.length,
|
|
totalPages: Math.ceil(items.length / pageSize),
|
|
directory
|
|
};
|
|
}
|
|
}
|
|
|
|
describe('Recursive Directory Copy', () => {
|
|
let router: SemanticRouter;
|
|
let mockAPI: MockObsidianAPI;
|
|
|
|
beforeEach(() => {
|
|
mockAPI = new MockObsidianAPI();
|
|
mockAPI.setupMockFileSystem();
|
|
router = new SemanticRouter(mockAPI);
|
|
});
|
|
|
|
describe('isDirectory detection', () => {
|
|
test('should detect directory correctly', async () => {
|
|
const result = await router.route({
|
|
operation: 'vault',
|
|
action: 'copy',
|
|
params: { path: 'source', destination: 'dest' }
|
|
});
|
|
|
|
// Should succeed because directory copying works by default
|
|
expect((result.result as DirectoryCopyResult).success).toBe(true);
|
|
});
|
|
|
|
test('should detect file correctly', async () => {
|
|
const result = await router.route({
|
|
operation: 'vault',
|
|
action: 'copy',
|
|
params: { path: 'source/file1.md', destination: 'dest/file1.md' }
|
|
});
|
|
|
|
expect((result.result as FileCopyResult).success).toBe(true);
|
|
expect((result.result as FileCopyResult).sourcePath).toBe('source/file1.md');
|
|
expect((result.result as FileCopyResult).copiedTo).toBe('dest/file1.md');
|
|
});
|
|
});
|
|
|
|
describe('recursive directory copying', () => {
|
|
test('should copy directory with all files', async () => {
|
|
const result = await router.route({
|
|
operation: 'vault',
|
|
action: 'copy',
|
|
params: { path: 'source', destination: 'dest' }
|
|
});
|
|
|
|
expect((result.result as DirectoryCopyResult).success).toBe(true);
|
|
expect((result.result as DirectoryCopyResult).sourcePath).toBe('source');
|
|
expect((result.result as DirectoryCopyResult).destinationPath).toBe('dest');
|
|
expect((result.result as DirectoryCopyResult).filesCount).toBe(4); // file1.md, file2.md, file3.md, file4.md
|
|
expect((result.result as DirectoryCopyResult).skippedFiles).toContain('source/image.png'); // Image should be skipped
|
|
});
|
|
|
|
test('should copy nested directory structure', async () => {
|
|
const result = await router.route({
|
|
operation: 'vault',
|
|
action: 'copy',
|
|
params: { path: 'source', destination: 'dest' }
|
|
});
|
|
|
|
const copiedFiles = (result.result as DirectoryCopyResult).copiedFiles;
|
|
expect(copiedFiles).toContain('dest/file1.md');
|
|
expect(copiedFiles).toContain('dest/file2.md');
|
|
expect(copiedFiles).toContain('dest/subdir/file3.md');
|
|
expect(copiedFiles).toContain('dest/subdir/nested/file4.md');
|
|
});
|
|
|
|
test('should handle overwrite flag', async () => {
|
|
// First copy
|
|
await router.route({
|
|
operation: 'vault',
|
|
action: 'copy',
|
|
params: { path: 'source', destination: 'dest' }
|
|
});
|
|
|
|
// Second copy without overwrite should fail because files already exist
|
|
const result = await router.route({
|
|
operation: 'vault',
|
|
action: 'copy',
|
|
params: { path: 'source', destination: 'dest', overwrite: false }
|
|
});
|
|
|
|
// result.result is null on error — the error details are in the response
|
|
expect(result.result).toBeNull();
|
|
});
|
|
|
|
test('should overwrite when overwrite=true', async () => {
|
|
// First copy
|
|
await router.route({
|
|
operation: 'vault',
|
|
action: 'copy',
|
|
params: { path: 'source', destination: 'dest' }
|
|
});
|
|
|
|
// Second copy with overwrite should succeed
|
|
const result = await router.route({
|
|
operation: 'vault',
|
|
action: 'copy',
|
|
params: { path: 'source', destination: 'dest', overwrite: true }
|
|
});
|
|
|
|
expect((result.result as DirectoryCopyResult).success).toBe(true);
|
|
expect((result.result as DirectoryCopyResult).filesCount).toBe(4);
|
|
});
|
|
|
|
test('should skip image files but continue copying', async () => {
|
|
const result = await router.route({
|
|
operation: 'vault',
|
|
action: 'copy',
|
|
params: { path: 'source', destination: 'dest' }
|
|
});
|
|
|
|
expect((result.result as DirectoryCopyResult).success).toBe(true);
|
|
expect((result.result as DirectoryCopyResult).skippedFiles).toEqual(['source/image.png']);
|
|
expect((result.result as DirectoryCopyResult).filesCount).toBe(4); // Only text files copied
|
|
});
|
|
|
|
test('should provide helpful workflow suggestions', async () => {
|
|
const result = await router.route({
|
|
operation: 'vault',
|
|
action: 'copy',
|
|
params: { path: 'source', destination: 'dest' }
|
|
});
|
|
|
|
expect((result.result as DirectoryCopyResult).workflow.message).toContain('Directory copied successfully');
|
|
expect((result.result as DirectoryCopyResult).workflow.suggested_next).toHaveLength(3); // List, view, review skipped
|
|
|
|
const suggestions = (result.result as DirectoryCopyResult).workflow.suggested_next;
|
|
expect(suggestions[0].description).toBe('List copied directory contents');
|
|
expect(suggestions[0].command).toContain('vault(action=\'list\', directory=\'dest\')');
|
|
expect(suggestions[2].description).toBe('Review skipped files');
|
|
});
|
|
});
|
|
|
|
describe('backward compatibility', () => {
|
|
test('should maintain existing file copy behavior', async () => {
|
|
const result = await router.route({
|
|
operation: 'vault',
|
|
action: 'copy',
|
|
params: { path: 'source/file1.md', destination: 'copied-file.md' }
|
|
});
|
|
|
|
expect((result.result as FileCopyResult).success).toBe(true);
|
|
expect((result.result as FileCopyResult).sourcePath).toBe('source/file1.md');
|
|
expect((result.result as FileCopyResult).copiedTo).toBe('copied-file.md');
|
|
});
|
|
|
|
test('should default recursive=true for backward compatibility', async () => {
|
|
const result = await router.route({
|
|
operation: 'vault',
|
|
action: 'copy',
|
|
params: { path: 'source', destination: 'dest' }
|
|
// No recursive parameter - should default to true
|
|
});
|
|
|
|
expect((result.result as DirectoryCopyResult).success).toBe(true);
|
|
expect((result.result as DirectoryCopyResult).filesCount).toBe(4);
|
|
});
|
|
});
|
|
}); |