|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import { |
|
|
describe, |
|
|
it, |
|
|
expect, |
|
|
vi, |
|
|
beforeEach, |
|
|
afterEach, |
|
|
type Mock, |
|
|
} from 'vitest'; |
|
|
|
|
|
import * as actualNodeFs from 'node:fs'; |
|
|
import fsPromises from 'node:fs/promises'; |
|
|
import path from 'node:path'; |
|
|
import os from 'node:os'; |
|
|
import mime from 'mime-types'; |
|
|
|
|
|
import { |
|
|
isWithinRoot, |
|
|
isBinaryFile, |
|
|
detectFileType, |
|
|
processSingleFileContent, |
|
|
} from './fileUtils.js'; |
|
|
|
|
|
vi.mock('mime-types', () => ({ |
|
|
default: { lookup: vi.fn() }, |
|
|
lookup: vi.fn(), |
|
|
})); |
|
|
|
|
|
const mockMimeLookup = mime.lookup as Mock; |
|
|
|
|
|
describe('fileUtils', () => { |
|
|
let tempRootDir: string; |
|
|
const originalProcessCwd = process.cwd; |
|
|
|
|
|
let testTextFilePath: string; |
|
|
let testImageFilePath: string; |
|
|
let testPdfFilePath: string; |
|
|
let testBinaryFilePath: string; |
|
|
let nonExistentFilePath: string; |
|
|
let directoryPath: string; |
|
|
|
|
|
beforeEach(() => { |
|
|
vi.resetAllMocks(); |
|
|
|
|
|
tempRootDir = actualNodeFs.mkdtempSync( |
|
|
path.join(os.tmpdir(), 'fileUtils-test-'), |
|
|
); |
|
|
process.cwd = vi.fn(() => tempRootDir); |
|
|
|
|
|
testTextFilePath = path.join(tempRootDir, 'test.txt'); |
|
|
testImageFilePath = path.join(tempRootDir, 'image.png'); |
|
|
testPdfFilePath = path.join(tempRootDir, 'document.pdf'); |
|
|
testBinaryFilePath = path.join(tempRootDir, 'app.exe'); |
|
|
nonExistentFilePath = path.join(tempRootDir, 'notfound.txt'); |
|
|
directoryPath = path.join(tempRootDir, 'subdir'); |
|
|
|
|
|
actualNodeFs.mkdirSync(directoryPath, { recursive: true }); |
|
|
}); |
|
|
|
|
|
afterEach(() => { |
|
|
if (actualNodeFs.existsSync(tempRootDir)) { |
|
|
actualNodeFs.rmSync(tempRootDir, { recursive: true, force: true }); |
|
|
} |
|
|
process.cwd = originalProcessCwd; |
|
|
vi.restoreAllMocks(); |
|
|
}); |
|
|
|
|
|
describe('isWithinRoot', () => { |
|
|
const root = path.resolve('/project/root'); |
|
|
|
|
|
it('should return true for paths directly within the root', () => { |
|
|
expect(isWithinRoot(path.join(root, 'file.txt'), root)).toBe(true); |
|
|
expect(isWithinRoot(path.join(root, 'subdir', 'file.txt'), root)).toBe( |
|
|
true, |
|
|
); |
|
|
}); |
|
|
|
|
|
it('should return true for the root path itself', () => { |
|
|
expect(isWithinRoot(root, root)).toBe(true); |
|
|
}); |
|
|
|
|
|
it('should return false for paths outside the root', () => { |
|
|
expect( |
|
|
isWithinRoot(path.resolve('/project/other', 'file.txt'), root), |
|
|
).toBe(false); |
|
|
expect(isWithinRoot(path.resolve('/unrelated', 'file.txt'), root)).toBe( |
|
|
false, |
|
|
); |
|
|
}); |
|
|
|
|
|
it('should return false for paths that only partially match the root prefix', () => { |
|
|
expect( |
|
|
isWithinRoot( |
|
|
path.resolve('/project/root-but-actually-different'), |
|
|
root, |
|
|
), |
|
|
).toBe(false); |
|
|
}); |
|
|
|
|
|
it('should handle paths with trailing slashes correctly', () => { |
|
|
expect(isWithinRoot(path.join(root, 'file.txt') + path.sep, root)).toBe( |
|
|
true, |
|
|
); |
|
|
expect(isWithinRoot(root + path.sep, root)).toBe(true); |
|
|
}); |
|
|
|
|
|
it('should handle different path separators (POSIX vs Windows)', () => { |
|
|
const posixRoot = '/project/root'; |
|
|
const posixPathInside = '/project/root/file.txt'; |
|
|
const posixPathOutside = '/project/other/file.txt'; |
|
|
expect(isWithinRoot(posixPathInside, posixRoot)).toBe(true); |
|
|
expect(isWithinRoot(posixPathOutside, posixRoot)).toBe(false); |
|
|
}); |
|
|
|
|
|
it('should return false for a root path that is a sub-path of the path to check', () => { |
|
|
const pathToCheck = path.resolve('/project/root/sub'); |
|
|
const rootSub = path.resolve('/project/root'); |
|
|
expect(isWithinRoot(pathToCheck, rootSub)).toBe(true); |
|
|
|
|
|
const pathToCheckSuper = path.resolve('/project/root'); |
|
|
const rootSuper = path.resolve('/project/root/sub'); |
|
|
expect(isWithinRoot(pathToCheckSuper, rootSuper)).toBe(false); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe('isBinaryFile', () => { |
|
|
let filePathForBinaryTest: string; |
|
|
|
|
|
beforeEach(() => { |
|
|
filePathForBinaryTest = path.join(tempRootDir, 'binaryCheck.tmp'); |
|
|
}); |
|
|
|
|
|
afterEach(() => { |
|
|
if (actualNodeFs.existsSync(filePathForBinaryTest)) { |
|
|
actualNodeFs.unlinkSync(filePathForBinaryTest); |
|
|
} |
|
|
}); |
|
|
|
|
|
it('should return false for an empty file', () => { |
|
|
actualNodeFs.writeFileSync(filePathForBinaryTest, ''); |
|
|
expect(isBinaryFile(filePathForBinaryTest)).toBe(false); |
|
|
}); |
|
|
|
|
|
it('should return false for a typical text file', () => { |
|
|
actualNodeFs.writeFileSync( |
|
|
filePathForBinaryTest, |
|
|
'Hello, world!\nThis is a test file with normal text content.', |
|
|
); |
|
|
expect(isBinaryFile(filePathForBinaryTest)).toBe(false); |
|
|
}); |
|
|
|
|
|
it('should return true for a file with many null bytes', () => { |
|
|
const binaryContent = Buffer.from([ |
|
|
0x48, 0x65, 0x00, 0x6c, 0x6f, 0x00, 0x00, 0x00, 0x00, 0x00, |
|
|
]); |
|
|
actualNodeFs.writeFileSync(filePathForBinaryTest, binaryContent); |
|
|
expect(isBinaryFile(filePathForBinaryTest)).toBe(true); |
|
|
}); |
|
|
|
|
|
it('should return true for a file with high percentage of non-printable ASCII', () => { |
|
|
const binaryContent = Buffer.from([ |
|
|
0x41, 0x42, 0x01, 0x02, 0x03, 0x04, 0x05, 0x43, 0x44, 0x06, |
|
|
]); |
|
|
actualNodeFs.writeFileSync(filePathForBinaryTest, binaryContent); |
|
|
expect(isBinaryFile(filePathForBinaryTest)).toBe(true); |
|
|
}); |
|
|
|
|
|
it('should return false if file access fails (e.g., ENOENT)', () => { |
|
|
|
|
|
if (actualNodeFs.existsSync(filePathForBinaryTest)) { |
|
|
actualNodeFs.unlinkSync(filePathForBinaryTest); |
|
|
} |
|
|
expect(isBinaryFile(filePathForBinaryTest)).toBe(false); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe('detectFileType', () => { |
|
|
let filePathForDetectTest: string; |
|
|
|
|
|
beforeEach(() => { |
|
|
filePathForDetectTest = path.join(tempRootDir, 'detectType.tmp'); |
|
|
|
|
|
actualNodeFs.writeFileSync(filePathForDetectTest, 'Plain text content'); |
|
|
}); |
|
|
|
|
|
afterEach(() => { |
|
|
if (actualNodeFs.existsSync(filePathForDetectTest)) { |
|
|
actualNodeFs.unlinkSync(filePathForDetectTest); |
|
|
} |
|
|
vi.restoreAllMocks(); |
|
|
}); |
|
|
|
|
|
it('should detect image type by extension (png)', () => { |
|
|
mockMimeLookup.mockReturnValueOnce('image/png'); |
|
|
expect(detectFileType('file.png')).toBe('image'); |
|
|
}); |
|
|
|
|
|
it('should detect image type by extension (jpeg)', () => { |
|
|
mockMimeLookup.mockReturnValueOnce('image/jpeg'); |
|
|
expect(detectFileType('file.jpg')).toBe('image'); |
|
|
}); |
|
|
|
|
|
it('should detect pdf type by extension', () => { |
|
|
mockMimeLookup.mockReturnValueOnce('application/pdf'); |
|
|
expect(detectFileType('file.pdf')).toBe('pdf'); |
|
|
}); |
|
|
|
|
|
it('should detect known binary extensions as binary (e.g. .zip)', () => { |
|
|
mockMimeLookup.mockReturnValueOnce('application/zip'); |
|
|
expect(detectFileType('archive.zip')).toBe('binary'); |
|
|
}); |
|
|
it('should detect known binary extensions as binary (e.g. .exe)', () => { |
|
|
mockMimeLookup.mockReturnValueOnce('application/octet-stream'); |
|
|
expect(detectFileType('app.exe')).toBe('binary'); |
|
|
}); |
|
|
|
|
|
it('should use isBinaryFile for unknown extensions and detect as binary', () => { |
|
|
mockMimeLookup.mockReturnValueOnce(false); |
|
|
|
|
|
const binaryContent = Buffer.from([ |
|
|
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, |
|
|
]); |
|
|
actualNodeFs.writeFileSync(filePathForDetectTest, binaryContent); |
|
|
expect(detectFileType(filePathForDetectTest)).toBe('binary'); |
|
|
}); |
|
|
|
|
|
it('should default to text if mime type is unknown and content is not binary', () => { |
|
|
mockMimeLookup.mockReturnValueOnce(false); |
|
|
|
|
|
expect(detectFileType(filePathForDetectTest)).toBe('text'); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe('processSingleFileContent', () => { |
|
|
beforeEach(() => { |
|
|
|
|
|
if (actualNodeFs.existsSync(testTextFilePath)) |
|
|
actualNodeFs.unlinkSync(testTextFilePath); |
|
|
if (actualNodeFs.existsSync(testImageFilePath)) |
|
|
actualNodeFs.unlinkSync(testImageFilePath); |
|
|
if (actualNodeFs.existsSync(testPdfFilePath)) |
|
|
actualNodeFs.unlinkSync(testPdfFilePath); |
|
|
if (actualNodeFs.existsSync(testBinaryFilePath)) |
|
|
actualNodeFs.unlinkSync(testBinaryFilePath); |
|
|
}); |
|
|
|
|
|
it('should read a text file successfully', async () => { |
|
|
const content = 'Line 1\\nLine 2\\nLine 3'; |
|
|
actualNodeFs.writeFileSync(testTextFilePath, content); |
|
|
const result = await processSingleFileContent( |
|
|
testTextFilePath, |
|
|
tempRootDir, |
|
|
); |
|
|
expect(result.llmContent).toBe(content); |
|
|
expect(result.returnDisplay).toBe(''); |
|
|
expect(result.error).toBeUndefined(); |
|
|
}); |
|
|
|
|
|
it('should handle file not found', async () => { |
|
|
const result = await processSingleFileContent( |
|
|
nonExistentFilePath, |
|
|
tempRootDir, |
|
|
); |
|
|
expect(result.error).toContain('File not found'); |
|
|
expect(result.returnDisplay).toContain('File not found'); |
|
|
}); |
|
|
|
|
|
it('should handle read errors for text files', async () => { |
|
|
actualNodeFs.writeFileSync(testTextFilePath, 'content'); |
|
|
const readError = new Error('Simulated read error'); |
|
|
vi.spyOn(fsPromises, 'readFile').mockRejectedValueOnce(readError); |
|
|
|
|
|
const result = await processSingleFileContent( |
|
|
testTextFilePath, |
|
|
tempRootDir, |
|
|
); |
|
|
expect(result.error).toContain('Simulated read error'); |
|
|
expect(result.returnDisplay).toContain('Simulated read error'); |
|
|
}); |
|
|
|
|
|
it('should handle read errors for image/pdf files', async () => { |
|
|
actualNodeFs.writeFileSync(testImageFilePath, 'content'); |
|
|
mockMimeLookup.mockReturnValue('image/png'); |
|
|
const readError = new Error('Simulated image read error'); |
|
|
vi.spyOn(fsPromises, 'readFile').mockRejectedValueOnce(readError); |
|
|
|
|
|
const result = await processSingleFileContent( |
|
|
testImageFilePath, |
|
|
tempRootDir, |
|
|
); |
|
|
expect(result.error).toContain('Simulated image read error'); |
|
|
expect(result.returnDisplay).toContain('Simulated image read error'); |
|
|
}); |
|
|
|
|
|
it('should process an image file', async () => { |
|
|
const fakePngData = Buffer.from('fake png data'); |
|
|
actualNodeFs.writeFileSync(testImageFilePath, fakePngData); |
|
|
mockMimeLookup.mockReturnValue('image/png'); |
|
|
const result = await processSingleFileContent( |
|
|
testImageFilePath, |
|
|
tempRootDir, |
|
|
); |
|
|
expect( |
|
|
(result.llmContent as { inlineData: unknown }).inlineData, |
|
|
).toBeDefined(); |
|
|
expect( |
|
|
(result.llmContent as { inlineData: { mimeType: string } }).inlineData |
|
|
.mimeType, |
|
|
).toBe('image/png'); |
|
|
expect( |
|
|
(result.llmContent as { inlineData: { data: string } }).inlineData.data, |
|
|
).toBe(fakePngData.toString('base64')); |
|
|
expect(result.returnDisplay).toContain('Read image file: image.png'); |
|
|
}); |
|
|
|
|
|
it('should process a PDF file', async () => { |
|
|
const fakePdfData = Buffer.from('fake pdf data'); |
|
|
actualNodeFs.writeFileSync(testPdfFilePath, fakePdfData); |
|
|
mockMimeLookup.mockReturnValue('application/pdf'); |
|
|
const result = await processSingleFileContent( |
|
|
testPdfFilePath, |
|
|
tempRootDir, |
|
|
); |
|
|
expect( |
|
|
(result.llmContent as { inlineData: unknown }).inlineData, |
|
|
).toBeDefined(); |
|
|
expect( |
|
|
(result.llmContent as { inlineData: { mimeType: string } }).inlineData |
|
|
.mimeType, |
|
|
).toBe('application/pdf'); |
|
|
expect( |
|
|
(result.llmContent as { inlineData: { data: string } }).inlineData.data, |
|
|
).toBe(fakePdfData.toString('base64')); |
|
|
expect(result.returnDisplay).toContain('Read pdf file: document.pdf'); |
|
|
}); |
|
|
|
|
|
it('should skip binary files', async () => { |
|
|
actualNodeFs.writeFileSync( |
|
|
testBinaryFilePath, |
|
|
Buffer.from([0x00, 0x01, 0x02]), |
|
|
); |
|
|
mockMimeLookup.mockReturnValueOnce('application/octet-stream'); |
|
|
|
|
|
|
|
|
const result = await processSingleFileContent( |
|
|
testBinaryFilePath, |
|
|
tempRootDir, |
|
|
); |
|
|
expect(result.llmContent).toContain( |
|
|
'Cannot display content of binary file', |
|
|
); |
|
|
expect(result.returnDisplay).toContain('Skipped binary file: app.exe'); |
|
|
}); |
|
|
|
|
|
it('should handle path being a directory', async () => { |
|
|
const result = await processSingleFileContent(directoryPath, tempRootDir); |
|
|
expect(result.error).toContain('Path is a directory'); |
|
|
expect(result.returnDisplay).toContain('Path is a directory'); |
|
|
}); |
|
|
|
|
|
it('should paginate text files correctly (offset and limit)', async () => { |
|
|
const lines = Array.from({ length: 20 }, (_, i) => `Line ${i + 1}`); |
|
|
actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n')); |
|
|
|
|
|
const result = await processSingleFileContent( |
|
|
testTextFilePath, |
|
|
tempRootDir, |
|
|
5, |
|
|
5, |
|
|
); |
|
|
const expectedContent = lines.slice(5, 10).join('\n'); |
|
|
|
|
|
expect(result.llmContent).toContain(expectedContent); |
|
|
expect(result.llmContent).toContain( |
|
|
'[File content truncated: showing lines 6-10 of 20 total lines. Use offset/limit parameters to view more.]', |
|
|
); |
|
|
expect(result.returnDisplay).toBe('(truncated)'); |
|
|
expect(result.isTruncated).toBe(true); |
|
|
expect(result.originalLineCount).toBe(20); |
|
|
expect(result.linesShown).toEqual([6, 10]); |
|
|
}); |
|
|
|
|
|
it('should handle limit exceeding file length', async () => { |
|
|
const lines = ['Line 1', 'Line 2']; |
|
|
actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n')); |
|
|
|
|
|
const result = await processSingleFileContent( |
|
|
testTextFilePath, |
|
|
tempRootDir, |
|
|
0, |
|
|
10, |
|
|
); |
|
|
const expectedContent = lines.join('\n'); |
|
|
|
|
|
expect(result.llmContent).toBe(expectedContent); |
|
|
expect(result.returnDisplay).toBe(''); |
|
|
expect(result.isTruncated).toBe(false); |
|
|
expect(result.originalLineCount).toBe(2); |
|
|
expect(result.linesShown).toEqual([1, 2]); |
|
|
}); |
|
|
|
|
|
it('should truncate long lines in text files', async () => { |
|
|
const longLine = 'a'.repeat(2500); |
|
|
actualNodeFs.writeFileSync( |
|
|
testTextFilePath, |
|
|
`Short line\n${longLine}\nAnother short line`, |
|
|
); |
|
|
|
|
|
const result = await processSingleFileContent( |
|
|
testTextFilePath, |
|
|
tempRootDir, |
|
|
); |
|
|
|
|
|
expect(result.llmContent).toContain('Short line'); |
|
|
expect(result.llmContent).toContain( |
|
|
longLine.substring(0, 2000) + '... [truncated]', |
|
|
); |
|
|
expect(result.llmContent).toContain('Another short line'); |
|
|
expect(result.llmContent).toContain( |
|
|
'[File content partially truncated: some lines exceeded maximum length of 2000 characters.]', |
|
|
); |
|
|
expect(result.isTruncated).toBe(true); |
|
|
}); |
|
|
}); |
|
|
}); |
|
|
|