indexer.test.ts
1 /** 2 * Tests for search indexer 3 */ 4 import { describe, it, expect, beforeEach } from 'vitest'; 5 import { 6 createSearchIndex, 7 buildIndex, 8 indexWorkspace, 9 indexWorkspaceTabs, 10 indexTab, 11 updateWorkspaceInIndex, 12 removeDocument, 13 removeWorkspaceFromIndex, 14 getIndexStats, 15 serializeIndex, 16 deserializeIndex, 17 } from '../../../src/lib/search/indexer'; 18 import { search } from '../../../src/lib/search/searcher'; 19 import type { Workspace, StandaloneWorkspace, ChildWorkspace, SavedTab } from '../../../src/lib/types'; 20 import type { WorkspaceResource } from '../../../src/lib/types/resource'; 21 import type { SearchIndex } from '../../../src/lib/types/search'; 22 23 // Helper to create mock workspace 24 function createMockWorkspace(id: string, name: string, tabs: SavedTab[] = []): StandaloneWorkspace { 25 return { 26 id, 27 name, 28 type: 'standalone', 29 parentId: null, 30 isTranscendent: false, 31 tabs, 32 metadata: { 33 created: new Date().toISOString(), 34 lastModified: new Date().toISOString(), 35 lastAccessed: new Date().toISOString(), 36 switchCount: 0, 37 totalTimeActive: 0, 38 }, 39 }; 40 } 41 42 describe('createSearchIndex', () => { 43 it('creates an empty index', () => { 44 const index = createSearchIndex(); 45 46 expect(index.tokens.size).toBe(0); 47 expect(index.documents.size).toBe(0); 48 expect(index.stats.documentCount).toBe(0); 49 expect(index.stats.tokenCount).toBe(0); 50 }); 51 }); 52 53 describe('indexWorkspace', () => { 54 let index: SearchIndex; 55 56 beforeEach(() => { 57 index = createSearchIndex(); 58 }); 59 60 it('adds workspace to index', () => { 61 const workspace = createMockWorkspace('ws-1', 'Test Workspace'); 62 indexWorkspace(index, workspace); 63 64 expect(index.documents.has('ws-1')).toBe(true); 65 expect(index.stats.documentCount).toBe(1); 66 }); 67 68 it('indexes workspace name tokens', () => { 69 const workspace = createMockWorkspace('ws-1', 'React Project'); 70 indexWorkspace(index, workspace); 71 72 // Should have indexed 'react' and 'project' 73 expect(index.tokens.has('react')).toBe(true); 74 expect(index.tokens.has('project')).toBe(true); 75 }); 76 77 it('updates stats after indexing', () => { 78 const workspace = createMockWorkspace('ws-1', 'Test'); 79 indexWorkspace(index, workspace); 80 81 expect(index.stats.documentCount).toBeGreaterThan(0); 82 expect(index.stats.lastUpdated).toBeTruthy(); 83 }); 84 }); 85 86 describe('indexTab', () => { 87 let index: SearchIndex; 88 89 beforeEach(() => { 90 index = createSearchIndex(); 91 }); 92 93 it('adds tab to index', () => { 94 const workspace = createMockWorkspace('ws-1', 'Test'); 95 const tab: SavedTab = { 96 url: 'https://github.com/user/repo', 97 title: 'GitHub Repository', 98 pinned: false, 99 }; 100 101 indexTab(index, tab, 0, workspace); 102 103 expect(index.documents.has('ws-1:tab:0')).toBe(true); 104 }); 105 106 it('indexes tab title and URL', () => { 107 const workspace = createMockWorkspace('ws-1', 'Test'); 108 const tab: SavedTab = { 109 url: 'https://github.com/user/repo', 110 title: 'GitHub Repository', 111 pinned: false, 112 }; 113 114 indexTab(index, tab, 0, workspace); 115 116 expect(index.tokens.has('github')).toBe(true); 117 // 'repository' is stemmed by removing 'y' -> 'repositor' 118 expect(index.tokens.has('repository') || index.tokens.has('repositor')).toBe(true); 119 }); 120 }); 121 122 describe('removeDocument', () => { 123 let index: SearchIndex; 124 125 beforeEach(() => { 126 index = createSearchIndex(); 127 const workspace = createMockWorkspace('ws-1', 'Test Workspace'); 128 indexWorkspace(index, workspace); 129 }); 130 131 it('removes document from index', () => { 132 expect(index.documents.has('ws-1')).toBe(true); 133 134 removeDocument(index, 'ws-1'); 135 136 expect(index.documents.has('ws-1')).toBe(false); 137 }); 138 139 it('cleans up token entries', () => { 140 const tokensBefore = index.tokens.size; 141 removeDocument(index, 'ws-1'); 142 143 // After removing the only document with these tokens, 144 // empty token entries should be cleaned up 145 expect(index.tokens.size).toBeLessThanOrEqual(tokensBefore); 146 }); 147 }); 148 149 describe('removeWorkspaceFromIndex', () => { 150 let index: SearchIndex; 151 152 beforeEach(() => { 153 index = createSearchIndex(); 154 const workspace = createMockWorkspace('ws-1', 'Test', [ 155 { url: 'https://example.com/1', title: 'Tab 1', pinned: false }, 156 { url: 'https://example.com/2', title: 'Tab 2', pinned: false }, 157 ]); 158 indexWorkspace(index, workspace); 159 workspace.tabs.forEach((tab, i) => indexTab(index, tab, i, workspace)); 160 }); 161 162 it('removes workspace and all its tabs', () => { 163 expect(index.documents.size).toBe(3); // workspace + 2 tabs 164 165 removeWorkspaceFromIndex(index, 'ws-1'); 166 167 expect(index.documents.size).toBe(0); 168 }); 169 }); 170 171 describe('buildIndex', () => { 172 it('builds index from workspaces array', () => { 173 const workspaces: Workspace[] = [ 174 createMockWorkspace('ws-1', 'First Workspace'), 175 createMockWorkspace('ws-2', 'Second Workspace'), 176 ]; 177 178 const index = buildIndex(workspaces); 179 180 expect(index.documents.size).toBe(2); 181 expect(index.stats.documentCount).toBe(2); 182 }); 183 184 it('indexes tabs from tab workspaces', () => { 185 const workspaces: Workspace[] = [ 186 createMockWorkspace('ws-1', 'Test', [ 187 { url: 'https://example.com', title: 'Example', pinned: false }, 188 ]), 189 ]; 190 191 const index = buildIndex(workspaces); 192 193 // Should have workspace + 1 tab 194 expect(index.documents.size).toBe(2); 195 }); 196 }); 197 198 describe('getIndexStats', () => { 199 it('returns correct statistics', () => { 200 const workspaces: Workspace[] = [ 201 createMockWorkspace('ws-1', 'First', [ 202 { url: 'https://example.com/1', title: 'Tab 1', pinned: false }, 203 { url: 'https://example.com/2', title: 'Tab 2', pinned: false }, 204 ]), 205 createMockWorkspace('ws-2', 'Second'), 206 ]; 207 208 const index = buildIndex(workspaces); 209 const stats = getIndexStats(index); 210 211 expect(stats.workspaceCount).toBe(2); 212 expect(stats.tabCount).toBe(2); 213 expect(stats.documentCount).toBe(4); // 2 workspaces + 2 tabs 214 }); 215 }); 216 217 describe('serializeIndex / deserializeIndex', () => { 218 it('serializes and deserializes index', () => { 219 const workspaces: Workspace[] = [ 220 createMockWorkspace('ws-1', 'Test Workspace'), 221 ]; 222 223 const originalIndex = buildIndex(workspaces); 224 const serialized = serializeIndex(originalIndex); 225 const deserializedIndex = deserializeIndex(serialized); 226 227 expect(deserializedIndex.documents.size).toBe(originalIndex.documents.size); 228 expect(deserializedIndex.tokens.size).toBe(originalIndex.tokens.size); 229 expect(deserializedIndex.stats.documentCount).toBe(originalIndex.stats.documentCount); 230 }); 231 232 it('produces valid JSON', () => { 233 const index = createSearchIndex(); 234 indexWorkspace(index, createMockWorkspace('ws-1', 'Test')); 235 236 const serialized = serializeIndex(index); 237 238 expect(() => JSON.parse(serialized)).not.toThrow(); 239 }); 240 }); 241 242 describe('updateWorkspaceInIndex', () => { 243 let index: SearchIndex; 244 245 beforeEach(() => { 246 // Build an index with a workspace that has tabs 247 const workspace = createMockWorkspace('ws-1', 'Original Name', [ 248 { url: 'https://example.com', title: 'Example Page', pinned: false }, 249 ]); 250 index = buildIndex([workspace]); 251 }); 252 253 it('updates workspace name in index after rename', () => { 254 // Verify old name is indexed (the indexer indexes the document directly) 255 expect(index.documents.get('ws-1')?.title).toBe('Original Name'); 256 257 // Simulate rename: create workspace with new name, same id 258 const renamed = createMockWorkspace('ws-1', 'Quantum Project', [ 259 { url: 'https://example.com', title: 'Example Page', pinned: false }, 260 ]); 261 updateWorkspaceInIndex(index, renamed); 262 263 // New name should appear in document store 264 expect(index.documents.get('ws-1')?.title).toBe('Quantum Project'); 265 266 // New name tokens should be in the index 267 expect(index.tokens.has('quantum')).toBe(true); 268 269 // Old exclusive token should be gone (only ws-1 had "original") 270 expect(index.tokens.has('original')).toBe(false); 271 }); 272 273 it('updates tabs in index after tab changes', () => { 274 // Verify old tab document exists 275 expect(index.documents.has('ws-1:tab:0')).toBe(true); 276 expect(index.documents.get('ws-1:tab:0')?.title).toBe('Example Page'); 277 278 // Simulate tab update: same workspace, different tabs 279 const updated = createMockWorkspace('ws-1', 'Original Name', [ 280 { url: 'https://newsite.org', title: 'Quantum Portal', pinned: false }, 281 ]); 282 updateWorkspaceInIndex(index, updated); 283 284 // New tab should be in document store 285 expect(index.documents.get('ws-1:tab:0')?.title).toBe('Quantum Portal'); 286 287 // New tab tokens should be in the index 288 expect(index.tokens.has('quantum')).toBe(true); 289 expect(index.tokens.has('portal')).toBe(true); 290 }); 291 292 it('updates summary in index after summary change', () => { 293 // Initially no summary tokens 294 expect(index.tokens.has('quantum')).toBe(false); 295 expect(index.tokens.has('photon')).toBe(false); 296 297 // Add summary to workspace 298 const withSummary = { 299 ...createMockWorkspace('ws-1', 'Original Name', [ 300 { url: 'https://example.com', title: 'Example Page', pinned: false }, 301 ]), 302 summary: { text: 'Research about quantum photon physics', isUserGenerated: false }, 303 }; 304 updateWorkspaceInIndex(index, withSummary as any); 305 306 // Summary tokens should now be in the index 307 expect(index.tokens.has('quantum')).toBe(true); 308 expect(index.tokens.has('photon')).toBe(true); 309 310 // Document should have the summary field 311 expect(index.documents.get('ws-1')?.summary).toBe('Research about quantum photon physics'); 312 }); 313 314 it('preserves document count after update', () => { 315 // 1 workspace + 1 tab = 2 documents 316 expect(index.documents.size).toBe(2); 317 318 const updated = createMockWorkspace('ws-1', 'Updated Name', [ 319 { url: 'https://example.com', title: 'Example Page', pinned: false }, 320 ]); 321 updateWorkspaceInIndex(index, updated); 322 323 // Should still be 2 documents, not more (no duplicates) 324 expect(index.documents.size).toBe(2); 325 }); 326 327 it('handles adding more tabs during update', () => { 328 expect(index.documents.size).toBe(2); // ws + 1 tab 329 330 const updated = createMockWorkspace('ws-1', 'Original Name', [ 331 { url: 'https://example.com', title: 'Example Page', pinned: false }, 332 { url: 'https://another.com', title: 'Quantum Portal', pinned: false }, 333 { url: 'https://third.com', title: 'Photon Dashboard', pinned: false }, 334 ]); 335 updateWorkspaceInIndex(index, updated); 336 337 // 1 workspace + 3 tabs = 4 documents 338 expect(index.documents.size).toBe(4); 339 340 // New tab documents should exist 341 expect(index.documents.has('ws-1:tab:1')).toBe(true); 342 expect(index.documents.has('ws-1:tab:2')).toBe(true); 343 expect(index.tokens.has('quantum')).toBe(true); 344 expect(index.tokens.has('photon')).toBe(true); 345 }); 346 347 it('handles removing all tabs during update', () => { 348 const updated = createMockWorkspace('ws-1', 'Original Name', []); 349 updateWorkspaceInIndex(index, updated); 350 351 // Only workspace remains, no tabs 352 expect(index.documents.size).toBe(1); 353 expect(index.documents.has('ws-1')).toBe(true); 354 }); 355 }); 356 357 describe('resource indexing', () => { 358 function createMockResource(id: string, title: string, value: string, type: 'url' | 'logseq' | 'attachment' = 'url'): WorkspaceResource { 359 return { 360 id, 361 type, 362 title, 363 value, 364 createdAt: new Date().toISOString(), 365 }; 366 } 367 368 function createWorkspaceWithResources(id: string, name: string, resources: WorkspaceResource[], tabs: SavedTab[] = []): StandaloneWorkspace & { resources: WorkspaceResource[] } { 369 return { 370 ...createMockWorkspace(id, name, tabs), 371 resources, 372 }; 373 } 374 375 it('indexes resource titles as searchable documents', () => { 376 const ws = createWorkspaceWithResources('ws-1', 'Main', [ 377 createMockResource('r-1', 'FLX1s - FuriPhone FLX1s Linux Phone', 'https://furilabs.com/shop/flx1s/'), 378 ]); 379 const index = buildIndex([ws]); 380 381 // Resource should be indexed as its own document 382 expect(index.documents.has('ws-1:resource:0')).toBe(true); 383 const doc = index.documents.get('ws-1:resource:0'); 384 expect(doc?.type).toBe('resource'); 385 expect(doc?.title).toBe('FLX1s - FuriPhone FLX1s Linux Phone'); 386 }); 387 388 it('indexes resource URL tokens', () => { 389 const ws = createWorkspaceWithResources('ws-1', 'Main', [ 390 createMockResource('r-1', 'FuriPhone Store', 'https://furilabs.com/shop/flx1s/'), 391 ]); 392 const index = buildIndex([ws]); 393 394 // URL domain/path tokens should be in the index 395 expect(index.tokens.has('furilabs')).toBe(true); 396 expect(index.tokens.has('flx1s')).toBe(true); 397 expect(index.tokens.has('shop')).toBe(true); 398 }); 399 400 it('indexes resource title tokens', () => { 401 const ws = createWorkspaceWithResources('ws-1', 'Main', [ 402 createMockResource('r-1', 'FLX1s FuriPhone Linux Phone', 'https://furilabs.com/shop/flx1s/'), 403 ]); 404 const index = buildIndex([ws]); 405 406 expect(index.tokens.has('furiphone')).toBe(true); 407 expect(index.tokens.has('linux')).toBe(true); 408 expect(index.tokens.has('phone')).toBe(true); 409 }); 410 411 it('includes workspace context in resource documents', () => { 412 const ws = createWorkspaceWithResources('ws-1', 'Shopping', [ 413 createMockResource('r-1', 'FuriPhone Store', 'https://furilabs.com/shop/flx1s/'), 414 ]); 415 const index = buildIndex([ws]); 416 417 const doc = index.documents.get('ws-1:resource:0'); 418 expect(doc?.workspaceName).toBe('Shopping'); 419 expect(doc?.workspaceId).toBe('ws-1'); 420 }); 421 422 it('counts resources alongside workspaces and tabs', () => { 423 const ws = createWorkspaceWithResources('ws-1', 'Main', [ 424 createMockResource('r-1', 'Resource One', 'https://example.com'), 425 createMockResource('r-2', 'Resource Two', 'https://other.com'), 426 ], [ 427 { url: 'https://tab.com', title: 'A Tab', pinned: false }, 428 ]); 429 const index = buildIndex([ws]); 430 431 // 1 workspace + 1 tab + 2 resources = 4 documents 432 expect(index.documents.size).toBe(4); 433 }); 434 435 it('removes resources when workspace is removed from index', () => { 436 const ws = createWorkspaceWithResources('ws-1', 'Main', [ 437 createMockResource('r-1', 'Resource One', 'https://example.com'), 438 ], [ 439 { url: 'https://tab.com', title: 'A Tab', pinned: false }, 440 ]); 441 const index = buildIndex([ws]); 442 443 expect(index.documents.size).toBe(3); // ws + tab + resource 444 445 removeWorkspaceFromIndex(index, 'ws-1'); 446 447 expect(index.documents.size).toBe(0); 448 }); 449 450 it('updates resources when workspace is updated in index', () => { 451 const ws = createWorkspaceWithResources('ws-1', 'Main', [ 452 createMockResource('r-1', 'Old Resource', 'https://old.com'), 453 ]); 454 const index = buildIndex([ws]); 455 456 expect(index.documents.get('ws-1:resource:0')?.title).toBe('Old Resource'); 457 458 // Update with different resources 459 const updated = createWorkspaceWithResources('ws-1', 'Main', [ 460 createMockResource('r-2', 'Quantum Resource', 'https://quantum.org'), 461 ]); 462 updateWorkspaceInIndex(index, updated); 463 464 expect(index.documents.get('ws-1:resource:0')?.title).toBe('Quantum Resource'); 465 expect(index.tokens.has('quantum')).toBe(true); 466 }); 467 468 it('indexes Logseq resources by page name', () => { 469 const ws = createWorkspaceWithResources('ws-1', 'Research', [ 470 createMockResource('r-1', 'Project Notes', 'quantum-computing-notes', 'logseq'), 471 ]); 472 const index = buildIndex([ws]); 473 474 // Logseq page name should be tokenized (hyphens split into words) 475 expect(index.tokens.has('quantum')).toBe(true); 476 // 'computing' stems to 'comput', 'notes' stems to 'not' (removes 'es') 477 expect(index.tokens.has('comput')).toBe(true); 478 }); 479 });