176 lines
5.5 KiB
TypeScript
176 lines
5.5 KiB
TypeScript
/**
|
|
* Tests for FileSearchService — literal string search across project files.
|
|
*/
|
|
|
|
import * as path from 'path';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('fs/promises', () => ({
|
|
readdir: vi.fn(),
|
|
readFile: vi.fn(),
|
|
stat: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('isbinaryfile', () => ({
|
|
isBinaryFile: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@shared/utils/logger', () => ({
|
|
createLogger: () => ({
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
debug: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
import * as fs from 'fs/promises';
|
|
import { isBinaryFile } from 'isbinaryfile';
|
|
|
|
import { FileSearchService } from '@main/services/editor/FileSearchService';
|
|
|
|
const PROJECT_ROOT = path.resolve('/test/project');
|
|
|
|
describe('FileSearchService', () => {
|
|
let service: FileSearchService;
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
service = new FileSearchService();
|
|
});
|
|
|
|
function mockFileSystem(files: Record<string, string>) {
|
|
// Normalize keys so lookups work on Windows (backslash vs forward slash)
|
|
const normalizedFiles: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(files)) {
|
|
normalizedFiles[path.normalize(key)] = value;
|
|
}
|
|
|
|
const entries = Object.keys(files).map((filePath) => {
|
|
const name = path.basename(filePath);
|
|
return { name, isFile: () => true, isDirectory: () => false };
|
|
});
|
|
|
|
vi.mocked(fs.readdir).mockResolvedValue(entries as never);
|
|
vi.mocked(isBinaryFile).mockResolvedValue(false);
|
|
|
|
vi.mocked(fs.stat).mockImplementation(async (filePath: unknown) => {
|
|
const p = path.normalize(String(filePath));
|
|
const content = normalizedFiles[p];
|
|
if (content === undefined) throw new Error('ENOENT');
|
|
return { size: content.length } as never;
|
|
});
|
|
|
|
vi.mocked(fs.readFile).mockImplementation(async (filePath: unknown) => {
|
|
const p = path.normalize(String(filePath));
|
|
const content = normalizedFiles[p];
|
|
if (content === undefined) throw new Error('ENOENT');
|
|
return content as never;
|
|
});
|
|
}
|
|
|
|
it('finds matches in files', async () => {
|
|
const files = {
|
|
[`${PROJECT_ROOT}/hello.ts`]: 'const foo = "hello";\nconst bar = "world";\n',
|
|
[`${PROJECT_ROOT}/world.ts`]: 'export const baz = "hello world";\n',
|
|
};
|
|
mockFileSystem(files);
|
|
|
|
const result = await service.searchInFiles(PROJECT_ROOT, { query: 'hello' });
|
|
|
|
expect(result.totalMatches).toBeGreaterThanOrEqual(1);
|
|
expect(result.results.length).toBeGreaterThanOrEqual(1);
|
|
const match = result.results[0].matches[0];
|
|
expect(match.line).toBe(1);
|
|
expect(match.lineContent).toContain('hello');
|
|
});
|
|
|
|
it('returns empty results for empty query', async () => {
|
|
const result = await service.searchInFiles(PROJECT_ROOT, { query: '' });
|
|
expect(result.results).toEqual([]);
|
|
expect(result.totalMatches).toBe(0);
|
|
});
|
|
|
|
it('supports case-sensitive search', async () => {
|
|
const files = {
|
|
[`${PROJECT_ROOT}/test.ts`]: 'Hello World\nhello world\n',
|
|
};
|
|
mockFileSystem(files);
|
|
|
|
const caseInsensitive = await service.searchInFiles(PROJECT_ROOT, { query: 'Hello' });
|
|
expect(caseInsensitive.totalMatches).toBe(2); // both lines match
|
|
|
|
const caseSensitive = await service.searchInFiles(PROJECT_ROOT, {
|
|
query: 'Hello',
|
|
caseSensitive: true,
|
|
});
|
|
expect(caseSensitive.totalMatches).toBe(1); // only first line
|
|
});
|
|
|
|
it('respects maxMatches limit', async () => {
|
|
const lines = Array.from({ length: 20 }, (_, i) => `match line ${i}`).join('\n');
|
|
const files = {
|
|
[`${PROJECT_ROOT}/many.ts`]: lines,
|
|
};
|
|
mockFileSystem(files);
|
|
|
|
const result = await service.searchInFiles(PROJECT_ROOT, {
|
|
query: 'match',
|
|
maxMatches: 5,
|
|
});
|
|
|
|
expect(result.totalMatches).toBeLessThanOrEqual(5);
|
|
expect(result.truncated).toBe(true);
|
|
});
|
|
|
|
it('skips binary files', async () => {
|
|
vi.mocked(fs.readdir).mockResolvedValue([
|
|
{ name: 'binary.bin', isFile: () => true, isDirectory: () => false },
|
|
] as never);
|
|
|
|
vi.mocked(fs.stat).mockResolvedValue({ size: 100 } as never);
|
|
vi.mocked(isBinaryFile).mockResolvedValue(true);
|
|
|
|
const result = await service.searchInFiles(PROJECT_ROOT, { query: 'test' });
|
|
expect(result.results).toEqual([]);
|
|
});
|
|
|
|
it('skips files larger than 1MB', async () => {
|
|
vi.mocked(fs.readdir).mockResolvedValue([
|
|
{ name: 'large.ts', isFile: () => true, isDirectory: () => false },
|
|
] as never);
|
|
|
|
vi.mocked(fs.stat).mockResolvedValue({ size: 2 * 1024 * 1024 } as never);
|
|
|
|
const result = await service.searchInFiles(PROJECT_ROOT, { query: 'test' });
|
|
expect(result.results).toEqual([]);
|
|
});
|
|
|
|
it('respects AbortController cancellation', async () => {
|
|
const files = {
|
|
[`${PROJECT_ROOT}/file.ts`]: 'hello world\n',
|
|
};
|
|
mockFileSystem(files);
|
|
|
|
const controller = new AbortController();
|
|
controller.abort(); // Already aborted
|
|
|
|
const result = await service.searchInFiles(PROJECT_ROOT, { query: 'hello' }, controller.signal);
|
|
// Should return empty or partial results since aborted
|
|
expect(result.totalMatches).toBe(0);
|
|
});
|
|
|
|
it('finds multiple matches in same line', async () => {
|
|
const files = {
|
|
[`${PROJECT_ROOT}/multi.ts`]: 'foo foo foo\n',
|
|
};
|
|
mockFileSystem(files);
|
|
|
|
const result = await service.searchInFiles(PROJECT_ROOT, { query: 'foo' });
|
|
expect(result.totalMatches).toBe(3);
|
|
expect(result.results[0].matches).toHaveLength(3);
|
|
expect(result.results[0].matches[0].column).toBe(0);
|
|
expect(result.results[0].matches[1].column).toBe(4);
|
|
expect(result.results[0].matches[2].column).toBe(8);
|
|
});
|
|
});
|