diff options
Diffstat (limited to 'packages/server/src/tools/read-many-files.test.ts')
| -rw-r--r-- | packages/server/src/tools/read-many-files.test.ts | 332 |
1 files changed, 332 insertions, 0 deletions
diff --git a/packages/server/src/tools/read-many-files.test.ts b/packages/server/src/tools/read-many-files.test.ts new file mode 100644 index 00000000..50156b55 --- /dev/null +++ b/packages/server/src/tools/read-many-files.test.ts @@ -0,0 +1,332 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; +import type { Mock } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mockControl } from '../__mocks__/fs/promises.js'; +import { ReadManyFilesTool } from './read-many-files.js'; +import path from 'path'; +import fs from 'fs'; // Actual fs for setup +import os from 'os'; + +describe('ReadManyFilesTool', () => { + let tool: ReadManyFilesTool; + let tempRootDir: string; + let tempDirOutsideRoot: string; + let mockReadFileFn: Mock; + + beforeEach(async () => { + tempRootDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'read-many-files-root-'), + ); + tempDirOutsideRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'read-many-files-external-'), + ); + tool = new ReadManyFilesTool(tempRootDir); + + mockReadFileFn = mockControl.mockReadFile; + mockReadFileFn.mockReset(); + + mockReadFileFn.mockImplementation( + async (filePath: fs.PathLike, options?: Record<string, unknown>) => { + const fp = + typeof filePath === 'string' + ? filePath + : (filePath as Buffer).toString(); + + if (fs.existsSync(fp)) { + const originalFs = await vi.importActual<typeof fs>('fs'); + return originalFs.promises.readFile(fp, options); + } + + if (fp.endsWith('nonexistent-file.txt')) { + const err = new Error( + `ENOENT: no such file or directory, open '${fp}'`, + ); + (err as NodeJS.ErrnoException).code = 'ENOENT'; + throw err; + } + if (fp.endsWith('unreadable.txt')) { + const err = new Error(`EACCES: permission denied, open '${fp}'`); + (err as NodeJS.ErrnoException).code = 'EACCES'; + throw err; + } + if (fp.endsWith('.png')) + return Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG header + if (fp.endsWith('.pdf')) return Buffer.from('%PDF-1.4...'); // PDF start + if (fp.endsWith('binary.bin')) + return Buffer.from([0x00, 0x01, 0x02, 0x00, 0x03]); + + const err = new Error( + `ENOENT: no such file or directory, open '${fp}' (unmocked path)`, + ); + (err as NodeJS.ErrnoException).code = 'ENOENT'; + throw err; + }, + ); + }); + + afterEach(() => { + if (fs.existsSync(tempRootDir)) { + fs.rmSync(tempRootDir, { recursive: true, force: true }); + } + if (fs.existsSync(tempDirOutsideRoot)) { + fs.rmSync(tempDirOutsideRoot, { recursive: true, force: true }); + } + }); + + describe('validateParams', () => { + it('should return null for valid relative paths within root', () => { + const params = { paths: ['file1.txt', 'subdir/file2.txt'] }; + expect(tool.validateParams(params)).toBeNull(); + }); + + it('should return null for valid glob patterns within root', () => { + const params = { paths: ['*.txt', 'subdir/**/*.js'] }; + expect(tool.validateParams(params)).toBeNull(); + }); + + it('should return null for paths trying to escape the root (e.g., ../) as execute handles this', () => { + const params = { paths: ['../outside.txt'] }; + expect(tool.validateParams(params)).toBeNull(); + }); + + it('should return null for absolute paths as execute handles this', () => { + const params = { paths: [path.join(tempDirOutsideRoot, 'absolute.txt')] }; + expect(tool.validateParams(params)).toBeNull(); + }); + + it('should return error if paths array is empty', () => { + const params = { paths: [] }; + expect(tool.validateParams(params)).toBe( + 'The "paths" parameter is required and must be a non-empty array of strings/glob patterns.', + ); + }); + + it('should return null for valid exclude and include patterns', () => { + const params = { + paths: ['src/**/*.ts'], + exclude: ['**/*.test.ts'], + include: ['src/utils/*.ts'], + }; + expect(tool.validateParams(params)).toBeNull(); + }); + }); + + describe('execute', () => { + const createFile = (filePath: string, content = '') => { + const fullPath = path.join(tempRootDir, filePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); + }; + const createBinaryFile = (filePath: string, data: Uint8Array) => { + const fullPath = path.join(tempRootDir, filePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, data); + }; + + it('should read a single specified file', async () => { + createFile('file1.txt', 'Content of file1'); + const params = { paths: ['file1.txt'] }; + const result = await tool.execute(params, new AbortController().signal); + expect(result.llmContent).toEqual([ + '--- file1.txt ---\n\nContent of file1\n\n', + ]); + expect(result.returnDisplay).toContain( + 'Successfully read and concatenated content from **1 file(s)**', + ); + }); + + it('should read multiple specified files', async () => { + createFile('file1.txt', 'Content1'); + createFile('subdir/file2.js', 'Content2'); + const params = { paths: ['file1.txt', 'subdir/file2.js'] }; + const result = await tool.execute(params, new AbortController().signal); + const content = result.llmContent as string[]; + expect( + content.some((c) => c.includes('--- file1.txt ---\n\nContent1\n\n')), + ).toBe(true); + expect( + content.some((c) => + c.includes('--- subdir/file2.js ---\n\nContent2\n\n'), + ), + ).toBe(true); + expect(result.returnDisplay).toContain( + 'Successfully read and concatenated content from **2 file(s)**', + ); + }); + + it('should handle glob patterns', async () => { + createFile('file.txt', 'Text file'); + createFile('another.txt', 'Another text'); + createFile('sub/data.json', '{}'); + const params = { paths: ['*.txt'] }; + const result = await tool.execute(params, new AbortController().signal); + const content = result.llmContent as string[]; + expect( + content.some((c) => c.includes('--- file.txt ---\n\nText file\n\n')), + ).toBe(true); + expect( + content.some((c) => + c.includes('--- another.txt ---\n\nAnother text\n\n'), + ), + ).toBe(true); + expect(content.find((c) => c.includes('sub/data.json'))).toBeUndefined(); + expect(result.returnDisplay).toContain( + 'Successfully read and concatenated content from **2 file(s)**', + ); + }); + + it('should respect exclude patterns', async () => { + createFile('src/main.ts', 'Main content'); + createFile('src/main.test.ts', 'Test content'); + const params = { paths: ['src/**/*.ts'], exclude: ['**/*.test.ts'] }; + const result = await tool.execute(params, new AbortController().signal); + const content = result.llmContent as string[]; + expect(content).toEqual(['--- src/main.ts ---\n\nMain content\n\n']); + expect( + content.find((c) => c.includes('src/main.test.ts')), + ).toBeUndefined(); + expect(result.returnDisplay).toContain( + 'Successfully read and concatenated content from **1 file(s)**', + ); + }); + + it('should handle non-existent specific files gracefully', async () => { + const params = { paths: ['nonexistent-file.txt'] }; + const result = await tool.execute(params, new AbortController().signal); + expect(result.llmContent).toEqual([ + 'No files matching the criteria were found or all were skipped.', + ]); + expect(result.returnDisplay).toContain( + 'No files were read and concatenated based on the criteria.', + ); + }); + + it('should use default excludes', async () => { + createFile('node_modules/some-lib/index.js', 'lib code'); + createFile('src/app.js', 'app code'); + const params = { paths: ['**/*.js'] }; + const result = await tool.execute(params, new AbortController().signal); + const content = result.llmContent as string[]; + expect(content).toEqual(['--- src/app.js ---\n\napp code\n\n']); + expect( + content.find((c) => c.includes('node_modules/some-lib/index.js')), + ).toBeUndefined(); + expect(result.returnDisplay).toContain( + 'Successfully read and concatenated content from **1 file(s)**', + ); + }); + + it('should NOT use default excludes if useDefaultExcludes is false', async () => { + createFile('node_modules/some-lib/index.js', 'lib code'); + createFile('src/app.js', 'app code'); + const params = { paths: ['**/*.js'], useDefaultExcludes: false }; + const result = await tool.execute(params, new AbortController().signal); + const content = result.llmContent as string[]; + expect( + content.some((c) => + c.includes('--- node_modules/some-lib/index.js ---\n\nlib code\n\n'), + ), + ).toBe(true); + expect( + content.some((c) => c.includes('--- src/app.js ---\n\napp code\n\n')), + ).toBe(true); + expect(result.returnDisplay).toContain( + 'Successfully read and concatenated content from **2 file(s)**', + ); + }); + + it('should include images as inlineData parts if explicitly requested by extension', async () => { + createBinaryFile( + 'image.png', + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + ); + const params = { paths: ['*.png'] }; // Explicitly requesting .png + const result = await tool.execute(params, new AbortController().signal); + expect(result.llmContent).toEqual([ + { + inlineData: { + data: Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]).toString('base64'), + mimeType: 'image/png', + }, + }, + ]); + expect(result.returnDisplay).toContain( + 'Successfully read and concatenated content from **1 file(s)**', + ); + }); + + it('should include images as inlineData parts if explicitly requested by name', async () => { + createBinaryFile( + 'myExactImage.png', + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + ); + const params = { paths: ['myExactImage.png'] }; // Explicitly requesting by full name + const result = await tool.execute(params, new AbortController().signal); + expect(result.llmContent).toEqual([ + { + inlineData: { + data: Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]).toString('base64'), + mimeType: 'image/png', + }, + }, + ]); + }); + + it('should skip PDF files if not explicitly requested by extension or name', async () => { + createBinaryFile('document.pdf', Buffer.from('%PDF-1.4...')); + createFile('notes.txt', 'text notes'); + const params = { paths: ['*'] }; // Generic glob, not specific to .pdf + const result = await tool.execute(params, new AbortController().signal); + const content = result.llmContent as string[]; + expect( + content.some( + (c) => typeof c === 'string' && c.includes('--- notes.txt ---'), + ), + ).toBe(true); + expect(result.returnDisplay).toContain( + '**Skipped 1 item(s) (up to 5 shown):**', + ); + expect(result.returnDisplay).toContain( + '- `document.pdf` (Reason: asset file (image/pdf) was not explicitly requested by name or extension)', + ); + }); + + it('should include PDF files as inlineData parts if explicitly requested by extension', async () => { + createBinaryFile('important.pdf', Buffer.from('%PDF-1.4...')); + const params = { paths: ['*.pdf'] }; // Explicitly requesting .pdf files + const result = await tool.execute(params, new AbortController().signal); + expect(result.llmContent).toEqual([ + { + inlineData: { + data: Buffer.from('%PDF-1.4...').toString('base64'), + mimeType: 'application/pdf', + }, + }, + ]); + }); + + it('should include PDF files as inlineData parts if explicitly requested by name', async () => { + createBinaryFile('report-final.pdf', Buffer.from('%PDF-1.4...')); + const params = { paths: ['report-final.pdf'] }; + const result = await tool.execute(params, new AbortController().signal); + expect(result.llmContent).toEqual([ + { + inlineData: { + data: Buffer.from('%PDF-1.4...').toString('base64'), + mimeType: 'application/pdf', + }, + }, + ]); + }); + }); +}); |
