|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import { GlobTool, GlobToolParams, GlobPath, sortFileEntries } from './glob.js'; |
|
|
import { partListUnionToString } from '../core/geminiRequest.js'; |
|
|
import path from 'path'; |
|
|
import fs from 'fs/promises'; |
|
|
import os from 'os'; |
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; |
|
|
import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; |
|
|
import { Config } from '../config/config.js'; |
|
|
|
|
|
describe('GlobTool', () => { |
|
|
let tempRootDir: string; |
|
|
let globTool: GlobTool; |
|
|
const abortSignal = new AbortController().signal; |
|
|
|
|
|
|
|
|
const mockConfig = { |
|
|
getFileService: () => new FileDiscoveryService(tempRootDir), |
|
|
getFileFilteringRespectGitIgnore: () => true, |
|
|
} as Partial<Config> as Config; |
|
|
|
|
|
beforeEach(async () => { |
|
|
|
|
|
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'glob-tool-root-')); |
|
|
globTool = new GlobTool(tempRootDir, mockConfig); |
|
|
|
|
|
|
|
|
|
|
|
await fs.writeFile(path.join(tempRootDir, 'fileA.txt'), 'contentA'); |
|
|
await fs.writeFile(path.join(tempRootDir, 'FileB.TXT'), 'contentB'); |
|
|
|
|
|
|
|
|
await fs.mkdir(path.join(tempRootDir, 'sub')); |
|
|
await fs.writeFile(path.join(tempRootDir, 'sub', 'fileC.md'), 'contentC'); |
|
|
await fs.writeFile(path.join(tempRootDir, 'sub', 'FileD.MD'), 'contentD'); |
|
|
|
|
|
|
|
|
await fs.mkdir(path.join(tempRootDir, 'sub', 'deep')); |
|
|
await fs.writeFile( |
|
|
path.join(tempRootDir, 'sub', 'deep', 'fileE.log'), |
|
|
'contentE', |
|
|
); |
|
|
|
|
|
|
|
|
await fs.writeFile(path.join(tempRootDir, 'older.sortme'), 'older_content'); |
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 50)); |
|
|
await fs.writeFile(path.join(tempRootDir, 'newer.sortme'), 'newer_content'); |
|
|
}); |
|
|
|
|
|
afterEach(async () => { |
|
|
|
|
|
await fs.rm(tempRootDir, { recursive: true, force: true }); |
|
|
}); |
|
|
|
|
|
describe('execute', () => { |
|
|
it('should find files matching a simple pattern in the root', async () => { |
|
|
const params: GlobToolParams = { pattern: '*.txt' }; |
|
|
const result = await globTool.execute(params, abortSignal); |
|
|
expect(result.llmContent).toContain('Found 2 file(s)'); |
|
|
expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt')); |
|
|
expect(result.llmContent).toContain(path.join(tempRootDir, 'FileB.TXT')); |
|
|
expect(result.returnDisplay).toBe('Found 2 matching file(s)'); |
|
|
}); |
|
|
|
|
|
it('should find files case-sensitively when case_sensitive is true', async () => { |
|
|
const params: GlobToolParams = { pattern: '*.txt', case_sensitive: true }; |
|
|
const result = await globTool.execute(params, abortSignal); |
|
|
expect(result.llmContent).toContain('Found 1 file(s)'); |
|
|
expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt')); |
|
|
expect(result.llmContent).not.toContain( |
|
|
path.join(tempRootDir, 'FileB.TXT'), |
|
|
); |
|
|
}); |
|
|
|
|
|
it('should find files case-insensitively by default (pattern: *.TXT)', async () => { |
|
|
const params: GlobToolParams = { pattern: '*.TXT' }; |
|
|
const result = await globTool.execute(params, abortSignal); |
|
|
expect(result.llmContent).toContain('Found 2 file(s)'); |
|
|
expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt')); |
|
|
expect(result.llmContent).toContain(path.join(tempRootDir, 'FileB.TXT')); |
|
|
}); |
|
|
|
|
|
it('should find files case-insensitively when case_sensitive is false (pattern: *.TXT)', async () => { |
|
|
const params: GlobToolParams = { |
|
|
pattern: '*.TXT', |
|
|
case_sensitive: false, |
|
|
}; |
|
|
const result = await globTool.execute(params, abortSignal); |
|
|
expect(result.llmContent).toContain('Found 2 file(s)'); |
|
|
expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt')); |
|
|
expect(result.llmContent).toContain(path.join(tempRootDir, 'FileB.TXT')); |
|
|
}); |
|
|
|
|
|
it('should find files using a pattern that includes a subdirectory', async () => { |
|
|
const params: GlobToolParams = { pattern: 'sub/*.md' }; |
|
|
const result = await globTool.execute(params, abortSignal); |
|
|
expect(result.llmContent).toContain('Found 2 file(s)'); |
|
|
expect(result.llmContent).toContain( |
|
|
path.join(tempRootDir, 'sub', 'fileC.md'), |
|
|
); |
|
|
expect(result.llmContent).toContain( |
|
|
path.join(tempRootDir, 'sub', 'FileD.MD'), |
|
|
); |
|
|
}); |
|
|
|
|
|
it('should find files in a specified relative path (relative to rootDir)', async () => { |
|
|
const params: GlobToolParams = { pattern: '*.md', path: 'sub' }; |
|
|
const result = await globTool.execute(params, abortSignal); |
|
|
expect(result.llmContent).toContain('Found 2 file(s)'); |
|
|
expect(result.llmContent).toContain( |
|
|
path.join(tempRootDir, 'sub', 'fileC.md'), |
|
|
); |
|
|
expect(result.llmContent).toContain( |
|
|
path.join(tempRootDir, 'sub', 'FileD.MD'), |
|
|
); |
|
|
}); |
|
|
|
|
|
it('should find files using a deep globstar pattern (e.g., **/*.log)', async () => { |
|
|
const params: GlobToolParams = { pattern: '**/*.log' }; |
|
|
const result = await globTool.execute(params, abortSignal); |
|
|
expect(result.llmContent).toContain('Found 1 file(s)'); |
|
|
expect(result.llmContent).toContain( |
|
|
path.join(tempRootDir, 'sub', 'deep', 'fileE.log'), |
|
|
); |
|
|
}); |
|
|
|
|
|
it('should return "No files found" message when pattern matches nothing', async () => { |
|
|
const params: GlobToolParams = { pattern: '*.nonexistent' }; |
|
|
const result = await globTool.execute(params, abortSignal); |
|
|
expect(result.llmContent).toContain( |
|
|
'No files found matching pattern "*.nonexistent"', |
|
|
); |
|
|
expect(result.returnDisplay).toBe('No files found'); |
|
|
}); |
|
|
|
|
|
it('should correctly sort files by modification time (newest first)', async () => { |
|
|
const params: GlobToolParams = { pattern: '*.sortme' }; |
|
|
const result = await globTool.execute(params, abortSignal); |
|
|
const llmContent = partListUnionToString(result.llmContent); |
|
|
|
|
|
expect(llmContent).toContain('Found 2 file(s)'); |
|
|
|
|
|
expect(typeof llmContent).toBe('string'); |
|
|
|
|
|
const filesListed = llmContent |
|
|
.substring(llmContent.indexOf(':') + 1) |
|
|
.trim() |
|
|
.split('\n'); |
|
|
expect(filesListed[0]).toContain(path.join(tempRootDir, 'newer.sortme')); |
|
|
expect(filesListed[1]).toContain(path.join(tempRootDir, 'older.sortme')); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe('validateToolParams', () => { |
|
|
it('should return null for valid parameters (pattern only)', () => { |
|
|
const params: GlobToolParams = { pattern: '*.js' }; |
|
|
expect(globTool.validateToolParams(params)).toBeNull(); |
|
|
}); |
|
|
|
|
|
it('should return null for valid parameters (pattern and path)', () => { |
|
|
const params: GlobToolParams = { pattern: '*.js', path: 'sub' }; |
|
|
expect(globTool.validateToolParams(params)).toBeNull(); |
|
|
}); |
|
|
|
|
|
it('should return null for valid parameters (pattern, path, and case_sensitive)', () => { |
|
|
const params: GlobToolParams = { |
|
|
pattern: '*.js', |
|
|
path: 'sub', |
|
|
case_sensitive: true, |
|
|
}; |
|
|
expect(globTool.validateToolParams(params)).toBeNull(); |
|
|
}); |
|
|
|
|
|
it('should return error if pattern is missing (schema validation)', () => { |
|
|
|
|
|
const params = { path: '.' }; |
|
|
|
|
|
expect(globTool.validateToolParams(params)).toContain( |
|
|
'Parameters failed schema validation', |
|
|
); |
|
|
}); |
|
|
|
|
|
it('should return error if pattern is an empty string', () => { |
|
|
const params: GlobToolParams = { pattern: '' }; |
|
|
expect(globTool.validateToolParams(params)).toContain( |
|
|
"The 'pattern' parameter cannot be empty.", |
|
|
); |
|
|
}); |
|
|
|
|
|
it('should return error if pattern is only whitespace', () => { |
|
|
const params: GlobToolParams = { pattern: ' ' }; |
|
|
expect(globTool.validateToolParams(params)).toContain( |
|
|
"The 'pattern' parameter cannot be empty.", |
|
|
); |
|
|
}); |
|
|
|
|
|
it('should return error if path is provided but is not a string (schema validation)', () => { |
|
|
const params = { |
|
|
pattern: '*.ts', |
|
|
path: 123, |
|
|
}; |
|
|
|
|
|
expect(globTool.validateToolParams(params)).toContain( |
|
|
'Parameters failed schema validation', |
|
|
); |
|
|
}); |
|
|
|
|
|
it('should return error if case_sensitive is provided but is not a boolean (schema validation)', () => { |
|
|
const params = { |
|
|
pattern: '*.ts', |
|
|
case_sensitive: 'true', |
|
|
}; |
|
|
|
|
|
expect(globTool.validateToolParams(params)).toContain( |
|
|
'Parameters failed schema validation', |
|
|
); |
|
|
}); |
|
|
|
|
|
it("should return error if search path resolves outside the tool's root directory", () => { |
|
|
|
|
|
const deeperRootDir = path.join(tempRootDir, 'sub'); |
|
|
const specificGlobTool = new GlobTool(deeperRootDir, mockConfig); |
|
|
|
|
|
|
|
|
|
|
|
const paramsOutside: GlobToolParams = { |
|
|
pattern: '*.txt', |
|
|
path: '../../../../../../../../../../tmp', |
|
|
}; |
|
|
expect(specificGlobTool.validateToolParams(paramsOutside)).toContain( |
|
|
"resolves outside the tool's root directory", |
|
|
); |
|
|
}); |
|
|
|
|
|
it('should return error if specified search path does not exist', async () => { |
|
|
const params: GlobToolParams = { |
|
|
pattern: '*.txt', |
|
|
path: 'nonexistent_subdir', |
|
|
}; |
|
|
expect(globTool.validateToolParams(params)).toContain( |
|
|
'Search path does not exist', |
|
|
); |
|
|
}); |
|
|
|
|
|
it('should return error if specified search path is a file, not a directory', async () => { |
|
|
const params: GlobToolParams = { pattern: '*.txt', path: 'fileA.txt' }; |
|
|
expect(globTool.validateToolParams(params)).toContain( |
|
|
'Search path is not a directory', |
|
|
); |
|
|
}); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe('sortFileEntries', () => { |
|
|
const nowTimestamp = new Date('2024-01-15T12:00:00.000Z').getTime(); |
|
|
const oneDayInMs = 24 * 60 * 60 * 1000; |
|
|
|
|
|
const createFileEntry = (fullpath: string, mtimeDate: Date): GlobPath => ({ |
|
|
fullpath: () => fullpath, |
|
|
mtimeMs: mtimeDate.getTime(), |
|
|
}); |
|
|
|
|
|
it('should sort a mix of recent and older files correctly', () => { |
|
|
const recentTime1 = new Date(nowTimestamp - 1 * 60 * 60 * 1000); |
|
|
const recentTime2 = new Date(nowTimestamp - 2 * 60 * 60 * 1000); |
|
|
const olderTime1 = new Date( |
|
|
nowTimestamp - (oneDayInMs + 1 * 60 * 60 * 1000), |
|
|
); |
|
|
const olderTime2 = new Date( |
|
|
nowTimestamp - (oneDayInMs + 2 * 60 * 60 * 1000), |
|
|
); |
|
|
|
|
|
const entries: GlobPath[] = [ |
|
|
createFileEntry('older_zebra.txt', olderTime2), |
|
|
createFileEntry('recent_alpha.txt', recentTime1), |
|
|
createFileEntry('older_apple.txt', olderTime1), |
|
|
createFileEntry('recent_beta.txt', recentTime2), |
|
|
createFileEntry('older_banana.txt', olderTime1), |
|
|
]; |
|
|
|
|
|
const sorted = sortFileEntries(entries, nowTimestamp, oneDayInMs); |
|
|
const sortedPaths = sorted.map((e) => e.fullpath()); |
|
|
|
|
|
expect(sortedPaths).toEqual([ |
|
|
'recent_alpha.txt', |
|
|
'recent_beta.txt', |
|
|
'older_apple.txt', |
|
|
'older_banana.txt', |
|
|
'older_zebra.txt', |
|
|
]); |
|
|
}); |
|
|
|
|
|
it('should sort only recent files by mtime descending', () => { |
|
|
const recentTime1 = new Date(nowTimestamp - 1000); |
|
|
const recentTime2 = new Date(nowTimestamp - 2000); |
|
|
const recentTime3 = new Date(nowTimestamp - 3000); |
|
|
|
|
|
const entries: GlobPath[] = [ |
|
|
createFileEntry('c.txt', recentTime2), |
|
|
createFileEntry('a.txt', recentTime3), |
|
|
createFileEntry('b.txt', recentTime1), |
|
|
]; |
|
|
const sorted = sortFileEntries(entries, nowTimestamp, oneDayInMs); |
|
|
expect(sorted.map((e) => e.fullpath())).toEqual([ |
|
|
'b.txt', |
|
|
'c.txt', |
|
|
'a.txt', |
|
|
]); |
|
|
}); |
|
|
|
|
|
it('should sort only older files alphabetically by path', () => { |
|
|
const olderTime = new Date(nowTimestamp - 2 * oneDayInMs); |
|
|
const entries: GlobPath[] = [ |
|
|
createFileEntry('zebra.txt', olderTime), |
|
|
createFileEntry('apple.txt', olderTime), |
|
|
createFileEntry('banana.txt', olderTime), |
|
|
]; |
|
|
const sorted = sortFileEntries(entries, nowTimestamp, oneDayInMs); |
|
|
expect(sorted.map((e) => e.fullpath())).toEqual([ |
|
|
'apple.txt', |
|
|
'banana.txt', |
|
|
'zebra.txt', |
|
|
]); |
|
|
}); |
|
|
|
|
|
it('should handle an empty array', () => { |
|
|
const entries: GlobPath[] = []; |
|
|
const sorted = sortFileEntries(entries, nowTimestamp, oneDayInMs); |
|
|
expect(sorted).toEqual([]); |
|
|
}); |
|
|
|
|
|
it('should correctly sort files when mtimes are identical for older files', () => { |
|
|
const olderTime = new Date(nowTimestamp - 2 * oneDayInMs); |
|
|
const entries: GlobPath[] = [ |
|
|
createFileEntry('b.txt', olderTime), |
|
|
createFileEntry('a.txt', olderTime), |
|
|
]; |
|
|
const sorted = sortFileEntries(entries, nowTimestamp, oneDayInMs); |
|
|
expect(sorted.map((e) => e.fullpath())).toEqual(['a.txt', 'b.txt']); |
|
|
}); |
|
|
|
|
|
it('should correctly sort files when mtimes are identical for recent files (maintaining mtime sort)', () => { |
|
|
const recentTime = new Date(nowTimestamp - 1000); |
|
|
const entries: GlobPath[] = [ |
|
|
createFileEntry('b.txt', recentTime), |
|
|
createFileEntry('a.txt', recentTime), |
|
|
]; |
|
|
const sorted = sortFileEntries(entries, nowTimestamp, oneDayInMs); |
|
|
expect(sorted.map((e) => e.fullpath())).toContain('a.txt'); |
|
|
expect(sorted.map((e) => e.fullpath())).toContain('b.txt'); |
|
|
expect(sorted.length).toBe(2); |
|
|
}); |
|
|
|
|
|
it('should use recencyThresholdMs parameter correctly', () => { |
|
|
const justOverThreshold = new Date(nowTimestamp - (1000 + 1)); |
|
|
const justUnderThreshold = new Date(nowTimestamp - (1000 - 1)); |
|
|
const customThresholdMs = 1000; |
|
|
|
|
|
const entries: GlobPath[] = [ |
|
|
createFileEntry('older_file.txt', justOverThreshold), |
|
|
createFileEntry('recent_file.txt', justUnderThreshold), |
|
|
]; |
|
|
const sorted = sortFileEntries(entries, nowTimestamp, customThresholdMs); |
|
|
expect(sorted.map((e) => e.fullpath())).toEqual([ |
|
|
'recent_file.txt', |
|
|
'older_file.txt', |
|
|
]); |
|
|
}); |
|
|
}); |
|
|
|