Skip to main content
Glama
UnifiedIndexManager.test.ts38.5 kB
/** * Tests for Unified Index Manager */ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; import { UnifiedIndexManager, UnifiedIndexEntry, UnifiedSearchResult } from '../../../../src/portfolio/UnifiedIndexManager.js'; import { PortfolioIndexManager, IndexEntry, SearchResult } from '../../../../src/portfolio/PortfolioIndexManager.js'; import { GitHubPortfolioIndexer, GitHubIndexEntry, GitHubPortfolioIndex } from '../../../../src/portfolio/GitHubPortfolioIndexer.js'; import { ElementType } from '../../../../src/portfolio/types.js'; import { CollectionIndexCache } from '../../../../src/cache/CollectionIndexCache.js'; import { ElementSource } from '../../../../src/config/sourcePriority.js'; describe('UnifiedIndexManager', () => { let unifiedManager: UnifiedIndexManager; let mockLocalIndexManager: jest.Mocked<PortfolioIndexManager>; let mockGitHubIndexer: jest.Mocked<GitHubPortfolioIndexer>; let mockCollectionIndexCache: jest.Mocked<CollectionIndexCache>; beforeEach(() => { // Reset singleton (UnifiedIndexManager as any).instance = null; // Create mocks mockLocalIndexManager = { search: jest.fn(), findByName: jest.fn(), getElementsByType: jest.fn(), getStats: jest.fn(), rebuildIndex: jest.fn() } as any; mockGitHubIndexer = { getIndex: jest.fn(), invalidateAfterAction: jest.fn(), clearCache: jest.fn(), getCacheStats: jest.fn() } as any; mockCollectionIndexCache = { getIndex: jest.fn(), getCacheStats: jest.fn(), clearCache: jest.fn() } as any; // Spy on getInstance methods jest.spyOn(PortfolioIndexManager, 'getInstance').mockReturnValue(mockLocalIndexManager); jest.spyOn(GitHubPortfolioIndexer, 'getInstance').mockReturnValue(mockGitHubIndexer); // Mock CollectionIndexCache constructor jest.spyOn(CollectionIndexCache.prototype, 'getIndex').mockImplementation(mockCollectionIndexCache.getIndex); jest.spyOn(CollectionIndexCache.prototype, 'getCacheStats').mockImplementation(mockCollectionIndexCache.getCacheStats); jest.spyOn(CollectionIndexCache.prototype, 'clearCache').mockImplementation(mockCollectionIndexCache.clearCache); // Set up default collection mocks const defaultCollectionIndex = { version: '1.0.0', generated: new Date().toISOString(), total_elements: 0, index: {}, metadata: { build_time_ms: 0, file_count: 0, skipped_files: 0, categories: 0, nodejs_version: process.version, builder_version: '1.0.0' } }; const defaultCollectionCacheStats = { isValid: true, age: 0, hasCache: true, elements: 0, memoryCache: {}, performanceMetrics: { averageResponseTime: 0, cacheHitRate: 0 } }; mockCollectionIndexCache.getIndex.mockResolvedValue(defaultCollectionIndex); mockCollectionIndexCache.getCacheStats.mockReturnValue(defaultCollectionCacheStats); mockCollectionIndexCache.clearCache.mockResolvedValue(); // Ensure rebuildIndex returns a Promise mockLocalIndexManager.rebuildIndex.mockResolvedValue(undefined); // Create unified manager unifiedManager = UnifiedIndexManager.getInstance(); }); afterEach(() => { jest.clearAllMocks(); }); describe('Singleton Pattern', () => { it('should return the same instance', () => { const instance1 = UnifiedIndexManager.getInstance(); const instance2 = UnifiedIndexManager.getInstance(); expect(instance1).toBe(instance2); }); }); describe('Unified Search', () => { it('should combine results from local and GitHub searches when includeAll is true', async () => { const localResults: SearchResult[] = [{ entry: { filePath: '/local/personas/sample.md', elementType: ElementType.PERSONA, metadata: { name: 'Local Test Persona', description: 'Local persona' }, lastModified: new Date(), filename: 'sample' }, matchType: 'name', score: 3 }]; const githubIndex: GitHubPortfolioIndex = { username: 'testuser', repository: 'dollhouse-portfolio', lastUpdated: new Date(), elements: new Map([[ElementType.PERSONA, [{ path: 'personas/github-sample.md', name: 'GitHub Test Persona', description: 'GitHub persona', elementType: ElementType.PERSONA, sha: 'abc123', htmlUrl: 'https://github.com/test/repo/blob/main/personas/github-sample.md', downloadUrl: 'https://raw.githubusercontent.com/test/repo/main/personas/github-sample.md', lastModified: new Date(), size: 1024 }]]]), totalElements: 1, sha: 'abc123' }; mockLocalIndexManager.search.mockResolvedValue(localResults); mockGitHubIndexer.getIndex.mockResolvedValue(githubIndex); // Use includeAll to search all sources (Issue #1446) const results = await unifiedManager.search({ query: 'test persona', includeAll: true }); expect(results.length).toBeGreaterThanOrEqual(2); // FIX: Build Sets without arrow functions to avoid 5th nesting level // SonarCloud suggestion: Use Set with .has() instead of array with .includes() // Previously: results.map(r => r.source) caused function nesting depth violation const sources = new Set<string>(); const names = new Set<string>(); for (const result of results) { sources.add(result.source); names.add(result.entry.name); } expect(sources.has('local')).toBe(true); expect(sources.has('github')).toBe(true); expect(names.has('Local Test Persona')).toBe(true); expect(names.has('GitHub Test Persona')).toBe(true); }); it('should handle local search failures gracefully', async () => { const githubIndex: GitHubPortfolioIndex = { username: 'testuser', repository: 'dollhouse-portfolio', lastUpdated: new Date(), elements: new Map([[ElementType.PERSONA, [{ path: 'personas/github-sample.md', name: 'GitHub Test Persona', elementType: ElementType.PERSONA, sha: 'abc123', htmlUrl: 'https://github.com/test/repo', downloadUrl: 'https://raw.githubusercontent.com/test/repo', lastModified: new Date(), size: 1024 }]]]), totalElements: 1, sha: 'abc123' }; mockLocalIndexManager.search.mockRejectedValue(new Error('Local search failed')); mockGitHubIndexer.getIndex.mockResolvedValue(githubIndex); const results = await unifiedManager.search({ query: 'test persona' }); expect(results).toHaveLength(1); expect(results[0].source).toBe('github'); }); it('should handle GitHub search failures gracefully', async () => { const localResults: SearchResult[] = [{ entry: { filePath: '/local/personas/sample.md', elementType: ElementType.PERSONA, metadata: { name: 'Local Test Persona' }, lastModified: new Date(), filename: 'sample' }, matchType: 'name', score: 3 }]; mockLocalIndexManager.search.mockResolvedValue(localResults); mockGitHubIndexer.getIndex.mockRejectedValue(new Error('GitHub search failed')); const results = await unifiedManager.search({ query: 'test persona' }); expect(results).toHaveLength(1); expect(results[0].source).toBe('local'); }); it('should deduplicate results by name and type when includeAll is true', async () => { const localResults: SearchResult[] = [{ entry: { filePath: '/local/personas/sample.md', elementType: ElementType.PERSONA, metadata: { name: 'Test Persona' }, lastModified: new Date(), filename: 'sample' }, matchType: 'name', score: 3 }]; const githubIndex: GitHubPortfolioIndex = { username: 'testuser', repository: 'dollhouse-portfolio', lastUpdated: new Date(), elements: new Map([[ElementType.PERSONA, [{ path: 'personas/sample.md', name: 'Test Persona', // Same name as local elementType: ElementType.PERSONA, sha: 'abc123', htmlUrl: 'https://github.com/test/repo', downloadUrl: 'https://raw.githubusercontent.com/test/repo', lastModified: new Date(), size: 1024 }]]]), totalElements: 1, sha: 'abc123' }; mockLocalIndexManager.search.mockResolvedValue(localResults); mockGitHubIndexer.getIndex.mockResolvedValue(githubIndex); // Use includeAll to get both sources (Issue #1446) const results = await unifiedManager.search({ query: 'test persona', includeAll: true }); expect(results).toHaveLength(2); // Both results are kept but marked as duplicates // Both should be marked as duplicates expect(results[0].isDuplicate).toBe(true); expect(results[1].isDuplicate).toBe(true); // FIX: Build Set without arrow functions to avoid 5th nesting level // SonarCloud suggestion: Use Set with .has() instead of array with .includes() // Previously: results.map(r => r.source) caused function nesting depth violation const sources = new Set<string>(); for (const result of results) { sources.add(result.source); } expect(sources.has('local')).toBe(true); expect(sources.has('github')).toBe(true); }); it('should sort results by score when includeAll is true', async () => { const localResults: SearchResult[] = [{ entry: { filePath: '/local/personas/low-score.md', elementType: ElementType.PERSONA, metadata: { name: 'Low Score Persona' }, lastModified: new Date(), filename: 'low-score' }, matchType: 'description', score: 1 }]; const githubIndex: GitHubPortfolioIndex = { username: 'testuser', repository: 'dollhouse-portfolio', lastUpdated: new Date(), elements: new Map([[ElementType.PERSONA, [{ path: 'personas/high-score.md', name: 'High Score Persona', elementType: ElementType.PERSONA, sha: 'abc123', htmlUrl: 'https://github.com/test/repo', downloadUrl: 'https://raw.githubusercontent.com/test/repo', lastModified: new Date(), size: 1024 }]]]), totalElements: 1, sha: 'abc123' }; mockLocalIndexManager.search.mockResolvedValue(localResults); mockGitHubIndexer.getIndex.mockResolvedValue(githubIndex); // Use includeAll to get all sources (Issue #1446) const results = await unifiedManager.search({ query: 'persona', includeAll: true }); expect(results).toHaveLength(2); // First result should have higher score expect(results[0].entry.name).toBe('High Score Persona'); expect(results[1].entry.name).toBe('Low Score Persona'); }); }); describe('Find by Name', () => { it('should find element in local portfolio first', async () => { const localResults: SearchResult[] = [{ entry: { filePath: '/local/personas/sample.md', elementType: ElementType.PERSONA, metadata: { name: 'Test Persona' }, lastModified: new Date(), filename: 'sample' }, matchType: 'name', score: 10 }]; // Mock search to return the local result mockLocalIndexManager.search.mockResolvedValue(localResults); mockGitHubIndexer.getIndex.mockResolvedValue({ username: 'testuser', repository: 'dollhouse-portfolio', lastUpdated: new Date(), elements: new Map(), totalElements: 0, sha: '' }); const result = await unifiedManager.findByName('Test Persona'); expect(result).toBeTruthy(); expect(result!.source).toBe('local'); expect(result!.name).toBe('Test Persona'); expect(mockLocalIndexManager.search).toHaveBeenCalledWith('Test Persona', expect.any(Object)); }); it('should search GitHub when not found locally', async () => { const githubIndex: GitHubPortfolioIndex = { username: 'testuser', repository: 'dollhouse-portfolio', lastUpdated: new Date(), elements: new Map([[ElementType.PERSONA, [{ path: 'personas/sample.md', name: 'Test Persona', elementType: ElementType.PERSONA, sha: 'abc123', htmlUrl: 'https://github.com/test/repo', downloadUrl: 'https://raw.githubusercontent.com/test/repo', lastModified: new Date(), size: 1024 }]]]), totalElements: 1, sha: 'abc123' }; mockLocalIndexManager.findByName.mockResolvedValue(null); mockGitHubIndexer.getIndex.mockResolvedValue(githubIndex); const result = await unifiedManager.findByName('Test Persona'); expect(result).toBeTruthy(); expect(result!.source).toBe('github'); expect(result!.name).toBe('Test Persona'); }); it('should return null when not found anywhere', async () => { const emptyGithubIndex: GitHubPortfolioIndex = { username: 'testuser', repository: 'dollhouse-portfolio', lastUpdated: new Date(), elements: new Map([[ElementType.PERSONA, []]]), totalElements: 0, sha: 'abc123' }; mockLocalIndexManager.findByName.mockResolvedValue(null); mockGitHubIndexer.getIndex.mockResolvedValue(emptyGithubIndex); const result = await unifiedManager.findByName('Nonexistent Persona'); expect(result).toBe(null); }); }); describe('Get Elements by Type', () => { it('should combine elements from both sources', async () => { const localEntries: IndexEntry[] = [{ filePath: '/local/personas/local.md', elementType: ElementType.PERSONA, metadata: { name: 'Local Persona' }, lastModified: new Date(), filename: 'local' }]; const githubEntries: GitHubIndexEntry[] = [{ path: 'personas/github.md', name: 'GitHub Persona', elementType: ElementType.PERSONA, sha: 'abc123', htmlUrl: 'https://github.com/test/repo', downloadUrl: 'https://raw.githubusercontent.com/test/repo', lastModified: new Date(), size: 1024 }]; const githubIndex: GitHubPortfolioIndex = { username: 'testuser', repository: 'dollhouse-portfolio', lastUpdated: new Date(), elements: new Map([[ElementType.PERSONA, githubEntries]]), totalElements: 1, sha: 'abc123' }; mockLocalIndexManager.getElementsByType.mockResolvedValue(localEntries); mockGitHubIndexer.getIndex.mockResolvedValue(githubIndex); const result = await unifiedManager.getElementsByType(ElementType.PERSONA); expect(result).toHaveLength(2); expect(result[0].source).toBe('local'); expect(result[1].source).toBe('github'); }); it('should deduplicate elements by name and type', async () => { const localEntries: IndexEntry[] = [{ filePath: '/local/personas/sample.md', elementType: ElementType.PERSONA, metadata: { name: 'Test Persona' }, lastModified: new Date(), filename: 'sample' }]; const githubEntries: GitHubIndexEntry[] = [{ path: 'personas/sample.md', name: 'Test Persona', // Same name elementType: ElementType.PERSONA, sha: 'abc123', htmlUrl: 'https://github.com/test/repo', downloadUrl: 'https://raw.githubusercontent.com/test/repo', lastModified: new Date(), size: 1024 }]; const githubIndex: GitHubPortfolioIndex = { username: 'testuser', repository: 'dollhouse-portfolio', lastUpdated: new Date(), elements: new Map([[ElementType.PERSONA, githubEntries]]), totalElements: 1, sha: 'abc123' }; mockLocalIndexManager.getElementsByType.mockResolvedValue(localEntries); mockGitHubIndexer.getIndex.mockResolvedValue(githubIndex); const result = await unifiedManager.getElementsByType(ElementType.PERSONA); expect(result).toHaveLength(1); // Deduplicated expect(result[0].source).toBe('local'); // Local has priority }); }); describe('Statistics', () => { it('should provide comprehensive statistics', async () => { const localStats = { totalElements: 5, elementsByType: { [ElementType.PERSONA]: 3, [ElementType.SKILL]: 2 } as Record<ElementType, number>, lastBuilt: new Date(), isStale: false }; const githubCacheStats = { hasCachedData: true, lastFetch: new Date(), isStale: false, recentUserAction: false, totalElements: 3 }; const githubIndex: GitHubPortfolioIndex = { username: 'testuser', repository: 'dollhouse-portfolio', lastUpdated: new Date(), elements: new Map([ [ElementType.PERSONA, [{ elementType: ElementType.PERSONA } as GitHubIndexEntry]], [ElementType.SKILL, [ { elementType: ElementType.SKILL } as GitHubIndexEntry, { elementType: ElementType.SKILL } as GitHubIndexEntry ]] ]), totalElements: 3, sha: 'abc123' }; const mockCollectionIndex = { version: '1.0.0', generated: new Date().toISOString(), total_elements: 0, index: {}, metadata: { build_time_ms: 0, file_count: 0, skipped_files: 0, categories: 0, nodejs_version: process.version, builder_version: '1.0.0' } }; const mockCollectionCacheStats = { isValid: true, age: 0, hasCache: true, elements: 0, memoryCache: {}, performanceMetrics: { averageResponseTime: 0, cacheHitRate: 0 } }; mockLocalIndexManager.getStats.mockResolvedValue(localStats); mockGitHubIndexer.getCacheStats.mockReturnValue(githubCacheStats); mockGitHubIndexer.getIndex.mockResolvedValue(githubIndex); mockCollectionIndexCache.getIndex.mockResolvedValue(mockCollectionIndex); mockCollectionIndexCache.getCacheStats.mockReturnValue(mockCollectionCacheStats); const stats = await unifiedManager.getStats(); expect(stats.local.totalElements).toBe(5); expect(stats.github.totalElements).toBe(3); expect(stats.combined.totalElements).toBe(8); // 5 + 3 + 0 (collection) expect(stats.github.username).toBe('testuser'); }); it('should handle errors in statistics gathering', async () => { const mockCollectionIndex = { version: '1.0.0', generated: new Date().toISOString(), total_elements: 0, index: {}, metadata: { build_time_ms: 0, file_count: 0, skipped_files: 0, categories: 0, nodejs_version: process.version, builder_version: '1.0.0' } }; const mockCollectionCacheStats = { isValid: true, age: 0, hasCache: true, elements: 0, memoryCache: {}, performanceMetrics: { averageResponseTime: 0, cacheHitRate: 0 } }; mockLocalIndexManager.getStats.mockRejectedValue(new Error('Local stats failed')); mockGitHubIndexer.getCacheStats.mockReturnValue({ hasCachedData: false, lastFetch: null, isStale: true, recentUserAction: false, totalElements: 0 }); mockGitHubIndexer.getIndex.mockRejectedValue(new Error('GitHub stats failed')); mockCollectionIndexCache.getIndex.mockResolvedValue(mockCollectionIndex); mockCollectionIndexCache.getCacheStats.mockReturnValue(mockCollectionCacheStats); const stats = await unifiedManager.getStats(); expect(stats.local.totalElements).toBe(0); expect(stats.github.totalElements).toBe(0); expect(stats.combined.totalElements).toBe(0); // 0 + 0 + 0 (collection) }); }); describe('Cache Invalidation', () => { it('should invalidate both local and GitHub caches', () => { unifiedManager.invalidateAfterAction('submit_content'); expect(mockLocalIndexManager.rebuildIndex).toHaveBeenCalled(); expect(mockGitHubIndexer.invalidateAfterAction).toHaveBeenCalledWith('submit_content'); }); it('should rebuild all indexes', async () => { mockLocalIndexManager.rebuildIndex.mockResolvedValue(); mockCollectionIndexCache.clearCache.mockResolvedValue(); mockGitHubIndexer.clearCache.mockImplementation(() => {}); await unifiedManager.rebuildAll(); expect(mockLocalIndexManager.rebuildIndex).toHaveBeenCalled(); expect(mockGitHubIndexer.clearCache).toHaveBeenCalled(); }); }); describe('Entry Conversion', () => { it('should convert local entries correctly', () => { const localEntry: IndexEntry = { filePath: '/local/personas/sample.md', elementType: ElementType.PERSONA, metadata: { name: 'Test Persona', description: 'A test persona', version: '1.0.0', author: 'Test Author', tags: ['test', 'example'], keywords: ['testing'], triggers: ['test trigger'] }, lastModified: new Date('2023-01-01'), filename: 'sample' }; const converted = (unifiedManager as any).convertLocalEntry(localEntry); expect(converted.source).toBe('local'); expect(converted.name).toBe('Test Persona'); expect(converted.localFilePath).toBe('/local/personas/sample.md'); expect(converted.tags).toEqual(['test', 'example']); expect(converted.githubPath).toBeUndefined(); }); it('should convert GitHub entries correctly', () => { const githubEntry: GitHubIndexEntry = { path: 'personas/sample.md', name: 'Test Persona', description: 'A test persona', version: '1.0.0', author: 'Test Author', elementType: ElementType.PERSONA, sha: 'abc123', htmlUrl: 'https://github.com/test/repo/blob/main/personas/sample.md', downloadUrl: 'https://raw.githubusercontent.com/test/repo/main/personas/sample.md', lastModified: new Date('2023-01-01'), size: 1024 }; const converted = (unifiedManager as any).convertGitHubEntry(githubEntry); expect(converted.source).toBe('github'); expect(converted.name).toBe('Test Persona'); expect(converted.githubPath).toBe('personas/sample.md'); expect(converted.githubSha).toBe('abc123'); expect(converted.localFilePath).toBeUndefined(); }); }); describe('Source Priority (Issue #1446)', () => { describe('Priority-based Search', () => { it('should search sources in default priority order (local → github → collection)', async () => { const localResults: SearchResult[] = [{ entry: { filePath: '/local/personas/local.md', elementType: ElementType.PERSONA, metadata: { name: 'Local Persona' }, lastModified: new Date(), filename: 'local' }, matchType: 'name', score: 3 }]; mockLocalIndexManager.search.mockResolvedValue(localResults); mockGitHubIndexer.getIndex.mockResolvedValue({ username: 'testuser', repository: 'dollhouse-portfolio', lastUpdated: new Date(), elements: new Map(), totalElements: 0, sha: '' }); const results = await unifiedManager.search({ query: 'persona' }); // Should find local result and stop (stopOnFirst = true by default) expect(results).toHaveLength(1); expect(results[0].source).toBe('local'); expect(mockLocalIndexManager.search).toHaveBeenCalled(); }); it('should stop on first source with results when stopOnFirst is enabled', async () => { const localResults: SearchResult[] = [{ entry: { filePath: '/local/personas/local.md', elementType: ElementType.PERSONA, metadata: { name: 'Local Persona' }, lastModified: new Date(), filename: 'local' }, matchType: 'name', score: 3 }]; const githubIndex: GitHubPortfolioIndex = { username: 'testuser', repository: 'dollhouse-portfolio', lastUpdated: new Date(), elements: new Map([[ElementType.PERSONA, [{ path: 'personas/github.md', name: 'GitHub Persona', elementType: ElementType.PERSONA, sha: 'abc123', htmlUrl: 'https://github.com/test/repo', downloadUrl: 'https://raw.githubusercontent.com/test/repo', lastModified: new Date(), size: 1024 }]]]), totalElements: 1, sha: 'abc123' }; mockLocalIndexManager.search.mockResolvedValue(localResults); mockGitHubIndexer.getIndex.mockResolvedValue(githubIndex); const results = await unifiedManager.search({ query: 'persona' }); // Should stop after finding local results expect(results).toHaveLength(1); expect(results[0].source).toBe('local'); expect(results[0].entry.name).toBe('Local Persona'); }); it('should search all sources when includeAll is true', async () => { const localResults: SearchResult[] = [{ entry: { filePath: '/local/personas/local.md', elementType: ElementType.PERSONA, metadata: { name: 'Local Persona' }, lastModified: new Date(), filename: 'local' }, matchType: 'name', score: 3 }]; const githubIndex: GitHubPortfolioIndex = { username: 'testuser', repository: 'dollhouse-portfolio', lastUpdated: new Date(), elements: new Map([[ElementType.PERSONA, [{ path: 'personas/github.md', name: 'GitHub Persona', elementType: ElementType.PERSONA, sha: 'abc123', htmlUrl: 'https://github.com/test/repo', downloadUrl: 'https://raw.githubusercontent.com/test/repo', lastModified: new Date(), size: 1024 }]]]), totalElements: 1, sha: 'abc123' }; mockLocalIndexManager.search.mockResolvedValue(localResults); mockGitHubIndexer.getIndex.mockResolvedValue(githubIndex); const results = await unifiedManager.search({ query: 'persona', includeAll: true }); // Should search all sources even though local had results expect(results.length).toBeGreaterThanOrEqual(2); // FIX: Build Set without arrow functions to avoid 5th nesting level // SonarCloud suggestion: Use Set with .has() instead of array with .includes() // Previously: results.map(r => r.source) caused function nesting depth violation const resultSources = new Set<string>(); for (const result of results) { resultSources.add(result.source); } const hasLocal = resultSources.has('local'); const hasGitHub = resultSources.has('github'); expect(hasLocal).toBe(true); expect(hasGitHub).toBe(true); }); it('should fallback to next source when current source fails', async () => { const githubIndex: GitHubPortfolioIndex = { username: 'testuser', repository: 'dollhouse-portfolio', lastUpdated: new Date(), elements: new Map([[ElementType.PERSONA, [{ path: 'personas/github.md', name: 'GitHub Persona', elementType: ElementType.PERSONA, sha: 'abc123', htmlUrl: 'https://github.com/test/repo', downloadUrl: 'https://raw.githubusercontent.com/test/repo', lastModified: new Date(), size: 1024 }]]]), totalElements: 1, sha: 'abc123' }; // Local search fails mockLocalIndexManager.search.mockRejectedValue(new Error('Local search failed')); mockGitHubIndexer.getIndex.mockResolvedValue(githubIndex); const results = await unifiedManager.search({ query: 'persona' }); // Should fallback to GitHub and find results expect(results).toHaveLength(1); expect(results[0].source).toBe('github'); expect(results[0].entry.name).toBe('GitHub Persona'); }); }); describe('Source Priority Overrides', () => { it('should use preferredSource option', async () => { const localResults: SearchResult[] = []; const githubIndex: GitHubPortfolioIndex = { username: 'testuser', repository: 'dollhouse-portfolio', lastUpdated: new Date(), elements: new Map([[ElementType.PERSONA, [{ path: 'personas/github.md', name: 'GitHub Persona', elementType: ElementType.PERSONA, sha: 'abc123', htmlUrl: 'https://github.com/test/repo', downloadUrl: 'https://raw.githubusercontent.com/test/repo', lastModified: new Date(), size: 1024 }]]]), totalElements: 1, sha: 'abc123' }; mockLocalIndexManager.search.mockResolvedValue(localResults); mockGitHubIndexer.getIndex.mockResolvedValue(githubIndex); const results = await unifiedManager.search({ query: 'persona', preferredSource: ElementSource.GITHUB }); // Should try GitHub first expect(results).toHaveLength(1); expect(results[0].source).toBe('github'); }); it('should use custom sourcePriority order', async () => { const collectionIndex = { version: '1.0.0', generated: new Date().toISOString(), total_elements: 1, index: { personas: [{ name: 'Collection Persona', description: 'Collection persona', path: 'library/personas/collection.md', sha: 'abc123', author: 'test', version: '1.0.0', tags: [], category: 'test', created: new Date().toISOString(), license: 'MIT' }] }, metadata: { build_time_ms: 0, file_count: 1, skipped_files: 0, categories: 1, nodejs_version: process.version, builder_version: '1.0.0' } }; mockLocalIndexManager.search.mockResolvedValue([]); mockGitHubIndexer.getIndex.mockResolvedValue({ username: 'testuser', repository: 'dollhouse-portfolio', lastUpdated: new Date(), elements: new Map(), totalElements: 0, sha: '' }); mockCollectionIndexCache.getIndex.mockResolvedValue(collectionIndex); const results = await unifiedManager.search({ query: 'persona', sourcePriority: [ElementSource.COLLECTION, ElementSource.LOCAL, ElementSource.GITHUB], includeCollection: true }); // Should try collection first expect(results).toHaveLength(1); expect(results[0].source).toBe('collection'); }); }); describe('checkForUpdates()', () => { it('should check all sources for version information', async () => { // Mock the search to return results from both sources // The search method will be called multiple times during checkDuplicates const localResults: SearchResult[] = [{ entry: { filePath: '/local/personas/test.md', elementType: ElementType.PERSONA, metadata: { name: 'Test Persona', version: '1.0.0' }, lastModified: new Date('2023-01-01'), filename: 'test' }, matchType: 'name', score: 3 }]; const githubIndex: GitHubPortfolioIndex = { username: 'testuser', repository: 'dollhouse-portfolio', lastUpdated: new Date(), elements: new Map([[ElementType.PERSONA, [{ path: 'personas/test.md', name: 'Test Persona', version: '2.0.0', // Newer version elementType: ElementType.PERSONA, sha: 'abc123', htmlUrl: 'https://github.com/test/repo', downloadUrl: 'https://raw.githubusercontent.com/test/repo', lastModified: new Date('2023-06-01'), size: 1024 }]]]), totalElements: 1, sha: 'abc123' }; // The internal search will use includeAll=true mockLocalIndexManager.search.mockResolvedValue(localResults); mockGitHubIndexer.getIndex.mockResolvedValue(githubIndex); const versionInfo = await unifiedManager.checkForUpdates('Test Persona'); expect(versionInfo).toBeTruthy(); if (versionInfo) { expect(versionInfo.name).toBe('Test Persona'); expect(versionInfo.versions.local?.version).toBe('1.0.0'); expect(versionInfo.versions.github?.version).toBe('2.0.0'); expect(versionInfo.updateAvailable).toBe(true); expect(versionInfo.updateFrom).toBe('github'); } }); it('should return null when element not found', async () => { mockLocalIndexManager.search.mockResolvedValue([]); mockGitHubIndexer.getIndex.mockResolvedValue({ username: 'testuser', repository: 'dollhouse-portfolio', lastUpdated: new Date(), elements: new Map(), totalElements: 0, sha: '' }); const versionInfo = await unifiedManager.checkForUpdates('Nonexistent Element'); expect(versionInfo).toBeNull(); }); }); describe('Edge Cases', () => { it('should handle empty priority list gracefully', async () => { const results = await unifiedManager.search({ query: 'test', includeLocal: false, includeGitHub: false, includeCollection: false }); expect(results).toHaveLength(0); }); it('should handle invalid source in preferredSource', async () => { const localResults: SearchResult[] = [{ entry: { filePath: '/local/personas/test.md', elementType: ElementType.PERSONA, metadata: { name: 'Test' }, lastModified: new Date(), filename: 'test' }, matchType: 'name', score: 3 }]; mockLocalIndexManager.search.mockResolvedValue(localResults); mockGitHubIndexer.getIndex.mockResolvedValue({ username: 'testuser', repository: 'dollhouse-portfolio', lastUpdated: new Date(), elements: new Map(), totalElements: 0, sha: '' }); // Use any invalid source - TypeScript will prevent this but test runtime behavior const results = await unifiedManager.search({ query: 'test', preferredSource: 'invalid' as any }); // Should still work, falling back to default behavior expect(results).toHaveLength(1); }); it('should handle all sources disabled', async () => { const results = await unifiedManager.search({ query: 'test', includeLocal: false, includeGitHub: false, includeCollection: false }); expect(results).toHaveLength(0); }); }); describe('Performance', () => { it('should use early termination for performance', async () => { const localResults: SearchResult[] = [{ entry: { filePath: '/local/personas/test.md', elementType: ElementType.PERSONA, metadata: { name: 'Test' }, lastModified: new Date(), filename: 'test' }, matchType: 'name', score: 3 }]; mockLocalIndexManager.search.mockResolvedValue(localResults); const results = await unifiedManager.search({ query: 'test' }); // Should use early termination and only return local results expect(mockLocalIndexManager.search).toHaveBeenCalled(); expect(results).toHaveLength(1); expect(results[0].source).toBe('local'); }); it('should continue searching when no results in first source', async () => { const localResults: SearchResult[] = []; const githubIndex: GitHubPortfolioIndex = { username: 'testuser', repository: 'dollhouse-portfolio', lastUpdated: new Date(), elements: new Map([[ElementType.PERSONA, [{ path: 'personas/test.md', name: 'Test', elementType: ElementType.PERSONA, sha: 'abc123', htmlUrl: 'https://github.com/test/repo', downloadUrl: 'https://raw.githubusercontent.com/test/repo', lastModified: new Date(), size: 1024 }]]]), totalElements: 1, sha: 'abc123' }; mockLocalIndexManager.search.mockResolvedValue(localResults); mockGitHubIndexer.getIndex.mockResolvedValue(githubIndex); const results = await unifiedManager.search({ query: 'test' }); // Should continue to GitHub when local is empty expect(results).toHaveLength(1); expect(results[0].source).toBe('github'); expect(mockLocalIndexManager.search).toHaveBeenCalled(); expect(mockGitHubIndexer.getIndex).toHaveBeenCalled(); }); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/DollhouseMCP/DollhouseMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server