agent-ecosystem/test/main/ipc/globalSearch.test.ts
2026-02-21 14:34:25 +05:30

405 lines
13 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ProjectScanner } from '../../../src/main/services/discovery/ProjectScanner';
import type { Project, SearchSessionsResult } from '../../../src/main/types';
/**
* Tests for global search functionality across all projects
*/
describe('Global Search - ProjectScanner.searchAllProjects', () => {
let projectScanner: ProjectScanner;
let mockScan: ReturnType<typeof vi.fn>;
let mockSearchSessions: ReturnType<typeof vi.fn>;
beforeEach(() => {
// Create a real ProjectScanner instance
projectScanner = new ProjectScanner();
// Mock the scan() method
mockScan = vi.fn();
projectScanner.scan = mockScan;
// Mock the sessionSearcher.searchSessions() method
mockSearchSessions = vi.fn();
// @ts-expect-error - Accessing private property for testing
projectScanner.sessionSearcher = {
searchSessions: mockSearchSessions,
};
});
describe('searchAllProjects', () => {
it('should return empty results for empty query', async () => {
const result = await projectScanner.searchAllProjects('', 50);
expect(result.results).toEqual([]);
expect(result.totalMatches).toBe(0);
expect(result.sessionsSearched).toBe(0);
expect(mockScan).not.toHaveBeenCalled();
});
it('should return empty results for whitespace query', async () => {
const result = await projectScanner.searchAllProjects(' ', 50);
expect(result.results).toEqual([]);
expect(result.totalMatches).toBe(0);
expect(result.sessionsSearched).toBe(0);
expect(mockScan).not.toHaveBeenCalled();
});
it('should return empty results when no projects exist', async () => {
mockScan.mockResolvedValue([]);
const result = await projectScanner.searchAllProjects('test', 50);
expect(result.results).toEqual([]);
expect(result.totalMatches).toBe(0);
expect(result.sessionsSearched).toBe(0);
expect(mockScan).toHaveBeenCalledOnce();
});
it('should search across multiple projects and merge results', async () => {
const now = Date.now();
// Mock scan() to return 2 projects
const mockProjects: Project[] = [
{
id: 'project1',
path: '/path/to/project1',
name: 'Project 1',
sessions: ['session1'],
createdAt: now - 10000,
mostRecentSession: now,
},
{
id: 'project2',
path: '/path/to/project2',
name: 'Project 2',
sessions: ['session2'],
createdAt: now - 20000,
mostRecentSession: now - 1000,
},
];
mockScan.mockResolvedValue(mockProjects);
// Mock searchSessions() to return different results for each project
mockSearchSessions.mockImplementation((projectId: string) => {
if (projectId === 'project1') {
return Promise.resolve({
results: [
{
projectId: 'project1',
sessionId: 'session1',
sessionTitle: 'Test Session 1',
context: 'This is a test message',
matchedText: 'test',
messageType: 'user' as const,
timestamp: now,
groupId: 'group1',
matchIndexInItem: 0,
matchStartOffset: 10,
messageUuid: 'uuid1',
},
],
totalMatches: 1,
sessionsSearched: 5,
query: 'test',
} satisfies SearchSessionsResult);
} else {
return Promise.resolve({
results: [
{
projectId: 'project2',
sessionId: 'session2',
sessionTitle: 'Test Session 2',
context: 'Another test message',
matchedText: 'test',
messageType: 'assistant' as const,
timestamp: now - 1000,
groupId: 'group2',
matchIndexInItem: 0,
matchStartOffset: 8,
messageUuid: 'uuid2',
},
],
totalMatches: 1,
sessionsSearched: 3,
query: 'test',
} satisfies SearchSessionsResult);
}
});
const result = await projectScanner.searchAllProjects('test', 50);
expect(mockScan).toHaveBeenCalledOnce();
expect(mockSearchSessions).toHaveBeenCalledTimes(2);
expect(mockSearchSessions).toHaveBeenCalledWith('project1', 'test', 50);
expect(mockSearchSessions).toHaveBeenCalledWith('project2', 'test', 50);
expect(result.results).toHaveLength(2);
expect(result.totalMatches).toBe(2);
expect(result.sessionsSearched).toBe(8); // 5 + 3
// Verify results from different projects
expect(result.results[0].projectId).toBe('project1');
expect(result.results[1].projectId).toBe('project2');
});
it('should sort results by timestamp (most recent first)', async () => {
const now = Date.now();
const mockProjects: Project[] = [
{
id: 'project1',
path: '/path/to/project1',
name: 'Project 1',
sessions: ['session1'],
createdAt: now - 10000,
},
{
id: 'project2',
path: '/path/to/project2',
name: 'Project 2',
sessions: ['session2'],
createdAt: now - 20000,
},
];
mockScan.mockResolvedValue(mockProjects);
// Project1 has older result, Project2 has newer result
mockSearchSessions.mockImplementation((projectId: string) => {
if (projectId === 'project1') {
return Promise.resolve({
results: [
{
projectId: 'project1',
sessionId: 'session1',
sessionTitle: 'Old Session',
context: 'test',
matchedText: 'test',
messageType: 'user' as const,
timestamp: now - 10000, // Older
groupId: 'group1',
matchIndexInItem: 0,
matchStartOffset: 0,
messageUuid: 'uuid1',
},
],
totalMatches: 1,
sessionsSearched: 5,
query: 'test',
} satisfies SearchSessionsResult);
} else {
return Promise.resolve({
results: [
{
projectId: 'project2',
sessionId: 'session2',
sessionTitle: 'New Session',
context: 'test',
matchedText: 'test',
messageType: 'user' as const,
timestamp: now, // Newer
groupId: 'group2',
matchIndexInItem: 0,
matchStartOffset: 0,
messageUuid: 'uuid2',
},
],
totalMatches: 1,
sessionsSearched: 3,
query: 'test',
} satisfies SearchSessionsResult);
}
});
const result = await projectScanner.searchAllProjects('test', 50);
// Should be sorted newest first
expect(result.results[0].sessionTitle).toBe('New Session');
expect(result.results[1].sessionTitle).toBe('Old Session');
expect(result.results[0].timestamp).toBeGreaterThan(result.results[1].timestamp);
});
it('should respect maxResults limit', async () => {
const now = Date.now();
const mockProjects: Project[] = [
{
id: 'project1',
path: '/path/to/project1',
name: 'Project 1',
sessions: ['session1'],
createdAt: now,
},
];
mockScan.mockResolvedValue(mockProjects);
// Return 30 results from search
const mockResults = Array.from({ length: 30 }, (_, i) => ({
projectId: 'project1',
sessionId: `session${i}`,
sessionTitle: `Session ${i}`,
context: 'test context',
matchedText: 'test',
messageType: 'user' as const,
timestamp: now - i * 1000,
groupId: `group${i}`,
matchIndexInItem: 0,
matchStartOffset: 0,
messageUuid: `uuid${i}`,
}));
mockSearchSessions.mockResolvedValue({
results: mockResults,
totalMatches: 30,
sessionsSearched: 50,
query: 'test',
} satisfies SearchSessionsResult);
const result = await projectScanner.searchAllProjects('test', 25);
expect(result.results.length).toBe(25); // Limited to maxResults
expect(mockSearchSessions).toHaveBeenCalledWith('project1', 'test', 25);
});
it('should handle search errors gracefully', async () => {
const now = Date.now();
const mockProjects: Project[] = [
{
id: 'project1',
path: '/path/to/project1',
name: 'Project 1',
sessions: ['session1'],
createdAt: now,
},
{
id: 'project2',
path: '/path/to/project2',
name: 'Project 2',
sessions: ['session2'],
createdAt: now - 1000,
},
];
mockScan.mockResolvedValue(mockProjects);
// First project fails, second succeeds
mockSearchSessions.mockImplementation((projectId: string) => {
if (projectId === 'project1') {
return Promise.reject(new Error('Search failed'));
} else {
return Promise.resolve({
results: [
{
projectId: 'project2',
sessionId: 'session2',
sessionTitle: 'Test Session 2',
context: 'test',
matchedText: 'test',
messageType: 'user' as const,
timestamp: now,
groupId: 'group2',
matchIndexInItem: 0,
matchStartOffset: 0,
messageUuid: 'uuid2',
},
],
totalMatches: 1,
sessionsSearched: 3,
query: 'test',
} satisfies SearchSessionsResult);
}
});
const result = await projectScanner.searchAllProjects('test', 50);
// Should still return results from successful project
expect(result.results).toHaveLength(1);
expect(result.results[0].projectId).toBe('project2');
expect(result.totalMatches).toBe(1);
expect(result.sessionsSearched).toBe(3);
});
it('should use batched concurrency for local FS', async () => {
const now = Date.now();
// Create 10 projects to test batching (local uses batch size 4)
const mockProjects: Project[] = Array.from({ length: 10 }, (_, i) => ({
id: `project${i}`,
path: `/path/to/project${i}`,
name: `Project ${i}`,
sessions: [`session${i}`],
createdAt: now - i * 1000,
}));
mockScan.mockResolvedValue(mockProjects);
// Track call order to verify batching
const callOrder: string[] = [];
mockSearchSessions.mockImplementation((projectId: string) => {
callOrder.push(projectId);
return Promise.resolve({
results: [],
totalMatches: 0,
sessionsSearched: 1,
query: 'test',
} satisfies SearchSessionsResult);
});
await projectScanner.searchAllProjects('test', 50);
// All 10 projects should be searched
expect(mockSearchSessions).toHaveBeenCalledTimes(10);
expect(callOrder).toHaveLength(10);
});
it('should stop searching when enough results are found', async () => {
const now = Date.now();
// Create 10 projects
const mockProjects: Project[] = Array.from({ length: 10 }, (_, i) => ({
id: `project${i}`,
path: `/path/to/project${i}`,
name: `Project ${i}`,
sessions: [`session${i}`],
createdAt: now - i * 1000,
}));
mockScan.mockResolvedValue(mockProjects);
// Each project returns 10 results (total would be 100)
mockSearchSessions.mockImplementation((projectId: string) => {
const results = Array.from({ length: 10 }, (_, i) => ({
projectId,
sessionId: `session${i}`,
sessionTitle: `Session ${i}`,
context: 'test',
matchedText: 'test',
messageType: 'user' as const,
timestamp: now - i * 1000,
groupId: `group${i}`,
matchIndexInItem: 0,
matchStartOffset: 0,
messageUuid: `uuid${i}`,
}));
return Promise.resolve({
results,
totalMatches: 10,
sessionsSearched: 1,
query: 'test',
} satisfies SearchSessionsResult);
});
const result = await projectScanner.searchAllProjects('test', 50);
// Should stop after getting enough results (checks after each batch of 4)
// Batch 1 (4 projects): 40 matches < 50, continue
// Batch 2 (4 projects): 80 matches >= 50, stop
expect(mockSearchSessions.mock.calls.length).toBeGreaterThanOrEqual(4);
expect(mockSearchSessions.mock.calls.length).toBeLessThanOrEqual(8);
// Result should be limited to maxResults
expect(result.results.length).toBe(50);
});
});
});