2025-05-20 13:02:41 -07:00
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
2026-03-04 05:42:59 +05:30
import {
vi ,
describe ,
it ,
expect ,
beforeEach ,
afterEach ,
type Mock ,
} from 'vitest' ;
2025-05-20 13:02:41 -07:00
import { mockControl } from '../__mocks__/fs/promises.js' ;
import { ReadManyFilesTool } from './read-many-files.js' ;
2025-06-03 21:40:46 -07:00
import { FileDiscoveryService } from '../services/fileDiscoveryService.js' ;
2025-08-25 22:11:27 +02:00
import path from 'node:path' ;
2026-01-27 13:17:40 -08:00
import { isSubpath } from '../utils/paths.js' ;
2025-08-25 22:11:27 +02:00
import fs from 'node:fs' ; // Actual fs for setup
import os from 'node:os' ;
2025-08-26 00:04:53 +02:00
import type { Config } from '../config/config.js' ;
2025-07-31 05:38:20 +09:00
import { WorkspaceContext } from '../utils/workspaceContext.js' ;
2025-08-18 16:29:45 -06:00
import { StandardFileSystemService } from '../services/fileSystemService.js' ;
2025-08-21 14:40:18 -07:00
import { ToolErrorType } from './tool-error.js' ;
2025-08-23 13:35:00 +09:00
import {
COMMON_IGNORE_PATTERNS ,
DEFAULT_FILE_EXCLUDES ,
} from '../utils/ignorePatterns.js' ;
2025-08-21 14:40:18 -07:00
import * as glob from 'glob' ;
2026-01-04 14:59:35 -05:00
import { createMockMessageBus } from '../test-utils/mock-message-bus.js' ;
2026-01-27 17:19:13 -08:00
import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js' ;
2025-08-21 14:40:18 -07:00
vi . mock ( 'glob' , { spy : true } ) ;
2025-05-20 13:02:41 -07:00
2025-09-04 23:00:27 +02:00
vi . mock ( 'mime' , ( ) = > {
const getType = ( filename : string ) = > {
2025-07-02 00:52:32 +05:30
if ( filename . endsWith ( '.ts' ) || filename . endsWith ( '.js' ) ) {
return 'text/plain' ;
}
if ( filename . endsWith ( '.png' ) ) {
return 'image/png' ;
}
if ( filename . endsWith ( '.pdf' ) ) {
return 'application/pdf' ;
}
if ( filename . endsWith ( '.mp3' ) || filename . endsWith ( '.wav' ) ) {
return 'audio/mpeg' ;
}
if ( filename . endsWith ( '.mp4' ) || filename . endsWith ( '.mov' ) ) {
return 'video/mp4' ;
}
return false ;
} ;
return {
default : {
2025-09-04 23:00:27 +02:00
getType ,
2025-07-02 00:52:32 +05:30
} ,
2025-09-04 23:00:27 +02:00
getType ,
2025-07-02 00:52:32 +05:30
} ;
} ) ;
2025-08-22 17:47:32 +05:30
vi . mock ( '../telemetry/loggers.js' , ( ) = > ( {
logFileOperation : vi.fn ( ) ,
} ) ) ;
2025-05-20 13:02:41 -07:00
describe ( 'ReadManyFilesTool' , ( ) = > {
let tool : ReadManyFilesTool ;
let tempRootDir : string ;
let tempDirOutsideRoot : string ;
let mockReadFileFn : Mock ;
beforeEach ( async ( ) = > {
2025-07-31 05:38:20 +09:00
tempRootDir = fs . realpathSync (
fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'read-many-files-root-' ) ) ,
2025-05-20 13:02:41 -07:00
) ;
2025-07-31 05:38:20 +09:00
tempDirOutsideRoot = fs . realpathSync (
fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'read-many-files-external-' ) ) ,
2025-05-20 13:02:41 -07:00
) ;
2026-01-27 17:19:13 -08:00
fs . writeFileSync ( path . join ( tempRootDir , GEMINI_IGNORE_FILE_NAME ) , 'foo.*' ) ;
2025-06-14 10:25:34 -04:00
const fileService = new FileDiscoveryService ( tempRootDir ) ;
const mockConfig = {
getFileService : ( ) = > fileService ,
2025-08-18 16:29:45 -06:00
getFileSystemService : ( ) = > new StandardFileSystemService ( ) ,
2025-07-20 00:55:33 -07:00
getFileFilteringOptions : ( ) = > ( {
respectGitIgnore : true ,
respectGeminiIgnore : true ,
2026-01-27 17:19:13 -08:00
customIgnoreFilePaths : [ ] ,
2025-07-20 00:55:33 -07:00
} ) ,
2025-07-14 22:55:49 -07:00
getTargetDir : ( ) = > tempRootDir ,
2025-07-31 05:38:20 +09:00
getWorkspaceDirs : ( ) = > [ tempRootDir ] ,
getWorkspaceContext : ( ) = > new WorkspaceContext ( tempRootDir ) ,
2025-08-23 13:35:00 +09:00
getFileExclusions : ( ) = > ( {
getCoreIgnorePatterns : ( ) = > COMMON_IGNORE_PATTERNS ,
getDefaultExcludePatterns : ( ) = > DEFAULT_FILE_EXCLUDES ,
getGlobExcludes : ( ) = > COMMON_IGNORE_PATTERNS ,
buildExcludePatterns : ( ) = > DEFAULT_FILE_EXCLUDES ,
getReadManyFilesExcludes : ( ) = > DEFAULT_FILE_EXCLUDES ,
} ) ,
2025-11-11 02:03:32 -08:00
isInteractive : ( ) = > false ,
2026-01-27 13:17:40 -08:00
storage : {
getProjectTempDir : vi.fn ( ) . mockReturnValue ( '/tmp/project' ) ,
} ,
isPathAllowed ( this : Config , absolutePath : string ) : boolean {
const workspaceContext = this . getWorkspaceContext ( ) ;
if ( workspaceContext . isPathWithinWorkspace ( absolutePath ) ) {
return true ;
}
const projectTempDir = this . storage . getProjectTempDir ( ) ;
return isSubpath ( path . resolve ( projectTempDir ) , absolutePath ) ;
} ,
validatePathAccess ( this : Config , absolutePath : string ) : string | null {
if ( this . isPathAllowed ( absolutePath ) ) {
return null ;
}
const workspaceDirs = this . getWorkspaceContext ( ) . getDirectories ( ) ;
const projectTempDir = this . storage . getProjectTempDir ( ) ;
return ` Path not in workspace: Attempted path " ${ absolutePath } " resolves outside the allowed workspace directories: ${ workspaceDirs . join ( ', ' ) } or the project temp directory: ${ projectTempDir } ` ;
} ,
} as unknown as Config ;
2026-01-04 14:59:35 -05:00
tool = new ReadManyFilesTool ( mockConfig , createMockMessageBus ( ) ) ;
2025-05-20 13:02:41 -07:00
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 } ) ;
}
} ) ;
2025-08-13 12:27:09 -07:00
describe ( 'build' , ( ) = > {
it ( 'should return an invocation for valid relative paths within root' , ( ) = > {
2025-11-06 15:03:52 -08:00
const params = { include : [ 'file1.txt' , 'subdir/file2.txt' ] } ;
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
expect ( invocation ) . toBeDefined ( ) ;
2025-05-20 13:02:41 -07:00
} ) ;
2025-08-13 12:27:09 -07:00
it ( 'should return an invocation for valid glob patterns within root' , ( ) = > {
2025-11-06 15:03:52 -08:00
const params = { include : [ '*.txt' , 'subdir/**/*.js' ] } ;
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
expect ( invocation ) . toBeDefined ( ) ;
2025-05-20 13:02:41 -07:00
} ) ;
2025-08-13 12:27:09 -07:00
it ( 'should return an invocation for paths trying to escape the root (e.g., ../) as execute handles this' , ( ) = > {
2025-11-06 15:03:52 -08:00
const params = { include : [ '../outside.txt' ] } ;
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
expect ( invocation ) . toBeDefined ( ) ;
2025-05-20 13:02:41 -07:00
} ) ;
2025-08-13 12:27:09 -07:00
it ( 'should return an invocation for absolute paths as execute handles this' , ( ) = > {
2025-11-06 15:03:52 -08:00
const params = {
include : [ path . join ( tempDirOutsideRoot , 'absolute.txt' ) ] ,
} ;
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
expect ( invocation ) . toBeDefined ( ) ;
2025-05-20 13:02:41 -07:00
} ) ;
2025-08-13 12:27:09 -07:00
it ( 'should throw error if paths array is empty' , ( ) = > {
2025-11-06 15:03:52 -08:00
const params = { include : [ ] } ;
2025-08-13 12:27:09 -07:00
expect ( ( ) = > tool . build ( params ) ) . toThrow (
2025-11-06 15:03:52 -08:00
'params/include must NOT have fewer than 1 items' ,
2025-05-20 13:02:41 -07:00
) ;
} ) ;
2025-08-13 12:27:09 -07:00
it ( 'should return an invocation for valid exclude and include patterns' , ( ) = > {
2025-05-20 13:02:41 -07:00
const params = {
exclude : [ '**/*.test.ts' ] ,
2025-11-06 15:03:52 -08:00
include : [ 'src/**/*.ts' , 'src/utils/*.ts' ] ,
2025-05-20 13:02:41 -07:00
} ;
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
expect ( invocation ) . toBeDefined ( ) ;
2025-05-20 13:02:41 -07:00
} ) ;
2025-05-29 22:30:18 +00:00
2025-08-13 12:27:09 -07:00
it ( 'should throw error if paths array contains an empty string' , ( ) = > {
2025-11-06 15:03:52 -08:00
const params = { include : [ 'file1.txt' , '' ] } ;
2025-08-13 12:27:09 -07:00
expect ( ( ) = > tool . build ( params ) ) . toThrow (
2025-11-06 15:03:52 -08:00
'params/include/1 must NOT have fewer than 1 characters' ,
2025-05-29 22:30:18 +00:00
) ;
} ) ;
2025-08-13 12:27:09 -07:00
it ( 'should throw error if include array contains non-string elements' , ( ) = > {
2025-05-29 22:30:18 +00:00
const params = {
include : [ '*.ts' , 123 ] as string [ ] ,
} ;
2025-08-13 12:27:09 -07:00
expect ( ( ) = > tool . build ( params ) ) . toThrow (
2025-07-07 23:48:44 -07:00
'params/include/1 must be string' ,
2025-05-29 22:30:18 +00:00
) ;
} ) ;
2025-08-13 12:27:09 -07:00
it ( 'should throw error if exclude array contains non-string elements' , ( ) = > {
2025-05-29 22:30:18 +00:00
const params = {
2025-11-06 15:03:52 -08:00
include : [ 'file1.txt' ] ,
2025-05-29 22:30:18 +00:00
exclude : [ '*.log' , { } ] as string [ ] ,
} ;
2025-08-13 12:27:09 -07:00
expect ( ( ) = > tool . build ( params ) ) . toThrow (
2025-07-07 23:48:44 -07:00
'params/exclude/1 must be string' ,
2025-05-29 22:30:18 +00:00
) ;
} ) ;
2025-05-20 13:02:41 -07:00
} ) ;
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' ) ;
2025-11-06 15:03:52 -08:00
const params = { include : [ 'file1.txt' ] } ;
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
const result = await invocation . execute ( new AbortController ( ) . signal ) ;
2025-07-11 17:49:26 -07:00
const expectedPath = path . join ( tempRootDir , 'file1.txt' ) ;
2025-05-20 13:02:41 -07:00
expect ( result . llmContent ) . toEqual ( [
2025-07-11 17:49:26 -07:00
` --- ${ expectedPath } --- \ n \ nContent of file1 \ n \ n ` ,
2025-08-22 08:10:14 -07:00
` \ n--- End of content --- ` ,
2025-05-20 13:02:41 -07:00
] ) ;
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' ) ;
2025-11-06 15:03:52 -08:00
const params = { include : [ 'file1.txt' , 'subdir/file2.js' ] } ;
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
const result = await invocation . execute ( new AbortController ( ) . signal ) ;
2025-05-20 13:02:41 -07:00
const content = result . llmContent as string [ ] ;
2025-07-11 17:49:26 -07:00
const expectedPath1 = path . join ( tempRootDir , 'file1.txt' ) ;
const expectedPath2 = path . join ( tempRootDir , 'subdir/file2.js' ) ;
2025-05-20 13:02:41 -07:00
expect (
2025-07-11 17:49:26 -07:00
content . some ( ( c ) = >
c . includes ( ` --- ${ expectedPath1 } --- \ n \ nContent1 \ n \ n ` ) ,
) ,
2025-05-20 13:02:41 -07:00
) . toBe ( true ) ;
expect (
content . some ( ( c ) = >
2025-07-11 17:49:26 -07:00
c . includes ( ` --- ${ expectedPath2 } --- \ n \ nContent2 \ n \ n ` ) ,
2025-05-20 13:02:41 -07:00
) ,
) . 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' , '{}' ) ;
2025-11-06 15:03:52 -08:00
const params = { include : [ '*.txt' ] } ;
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
const result = await invocation . execute ( new AbortController ( ) . signal ) ;
2025-05-20 13:02:41 -07:00
const content = result . llmContent as string [ ] ;
2025-07-11 17:49:26 -07:00
const expectedPath1 = path . join ( tempRootDir , 'file.txt' ) ;
const expectedPath2 = path . join ( tempRootDir , 'another.txt' ) ;
2025-05-20 13:02:41 -07:00
expect (
2025-07-11 17:49:26 -07:00
content . some ( ( c ) = >
c . includes ( ` --- ${ expectedPath1 } --- \ n \ nText file \ n \ n ` ) ,
) ,
2025-05-20 13:02:41 -07:00
) . toBe ( true ) ;
expect (
content . some ( ( c ) = >
2025-07-11 17:49:26 -07:00
c . includes ( ` --- ${ expectedPath2 } --- \ n \ nAnother text \ n \ n ` ) ,
2025-05-20 13:02:41 -07:00
) ,
) . 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' ) ;
2025-11-06 15:03:52 -08:00
const params = { include : [ 'src/**/*.ts' ] , exclude : [ '**/*.test.ts' ] } ;
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
const result = await invocation . execute ( new AbortController ( ) . signal ) ;
2025-05-20 13:02:41 -07:00
const content = result . llmContent as string [ ] ;
2025-07-11 17:49:26 -07:00
const expectedPath = path . join ( tempRootDir , 'src/main.ts' ) ;
2025-08-22 08:10:14 -07:00
expect ( content ) . toEqual ( [
` --- ${ expectedPath } --- \ n \ nMain content \ n \ n ` ,
` \ n--- End of content --- ` ,
] ) ;
2025-05-20 13:02:41 -07:00
expect (
content . find ( ( c ) = > c . includes ( 'src/main.test.ts' ) ) ,
) . toBeUndefined ( ) ;
expect ( result . returnDisplay ) . toContain (
'Successfully read and concatenated content from **1 file(s)**' ,
) ;
} ) ;
2025-07-21 17:54:44 -04:00
it ( 'should handle nonexistent specific files gracefully' , async ( ) = > {
2025-11-06 15:03:52 -08:00
const params = { include : [ 'nonexistent-file.txt' ] } ;
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
const result = await invocation . execute ( new AbortController ( ) . signal ) ;
2025-05-20 13:02:41 -07:00
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' ) ;
2025-11-06 15:03:52 -08:00
const params = { include : [ '**/*.js' ] } ;
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
const result = await invocation . execute ( new AbortController ( ) . signal ) ;
2025-05-20 13:02:41 -07:00
const content = result . llmContent as string [ ] ;
2025-07-11 17:49:26 -07:00
const expectedPath = path . join ( tempRootDir , 'src/app.js' ) ;
2025-08-22 08:10:14 -07:00
expect ( content ) . toEqual ( [
` --- ${ expectedPath } --- \ n \ napp code \ n \ n ` ,
` \ n--- End of content --- ` ,
] ) ;
2025-05-20 13:02:41 -07:00
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' ) ;
2025-11-06 15:03:52 -08:00
const params = { include : [ '**/*.js' ] , useDefaultExcludes : false } ;
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
const result = await invocation . execute ( new AbortController ( ) . signal ) ;
2025-05-20 13:02:41 -07:00
const content = result . llmContent as string [ ] ;
2025-07-11 17:49:26 -07:00
const expectedPath1 = path . join (
tempRootDir ,
'node_modules/some-lib/index.js' ,
) ;
const expectedPath2 = path . join ( tempRootDir , 'src/app.js' ) ;
2025-05-20 13:02:41 -07:00
expect (
content . some ( ( c ) = >
2025-07-11 17:49:26 -07:00
c . includes ( ` --- ${ expectedPath1 } --- \ n \ nlib code \ n \ n ` ) ,
2025-05-20 13:02:41 -07:00
) ,
) . toBe ( true ) ;
expect (
2025-07-11 17:49:26 -07:00
content . some ( ( c ) = >
c . includes ( ` --- ${ expectedPath2 } --- \ n \ napp code \ n \ n ` ) ,
) ,
2025-05-20 13:02:41 -07:00
) . 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 ] ) ,
) ;
2025-11-06 15:03:52 -08:00
const params = { include : [ '*.png' ] } ; // Explicitly requesting .png
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
const result = await invocation . execute ( new AbortController ( ) . signal ) ;
2025-05-20 13:02:41 -07:00
expect ( result . llmContent ) . toEqual ( [
{
inlineData : {
data : Buffer.from ( [
0x89 , 0x50 , 0x4e , 0x47 , 0x0d , 0x0a , 0x1a , 0x0a ,
] ) . toString ( 'base64' ) ,
mimeType : 'image/png' ,
} ,
} ,
2025-08-22 08:10:14 -07:00
'\n--- End of content ---' ,
2025-05-20 13:02:41 -07:00
] ) ;
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 ] ) ,
) ;
2025-11-06 15:03:52 -08:00
const params = { include : [ 'myExactImage.png' ] } ; // Explicitly requesting by full name
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
const result = await invocation . execute ( new AbortController ( ) . signal ) ;
2025-05-20 13:02:41 -07:00
expect ( result . llmContent ) . toEqual ( [
{
inlineData : {
data : Buffer.from ( [
0x89 , 0x50 , 0x4e , 0x47 , 0x0d , 0x0a , 0x1a , 0x0a ,
] ) . toString ( 'base64' ) ,
mimeType : 'image/png' ,
} ,
} ,
2025-08-22 08:10:14 -07:00
'\n--- End of content ---' ,
2025-05-20 13:02:41 -07:00
] ) ;
} ) ;
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' ) ;
2025-11-06 15:03:52 -08:00
const params = { include : [ '*' ] } ; // Generic glob, not specific to .pdf
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
const result = await invocation . execute ( new AbortController ( ) . signal ) ;
2025-05-20 13:02:41 -07:00
const content = result . llmContent as string [ ] ;
2025-07-11 17:49:26 -07:00
const expectedPath = path . join ( tempRootDir , 'notes.txt' ) ;
2025-05-20 13:02:41 -07:00
expect (
content . some (
2025-07-11 17:49:26 -07:00
( c ) = >
typeof c === 'string' &&
c . includes ( ` --- ${ expectedPath } --- \ n \ ntext notes \ n \ n ` ) ,
2025-05-20 13:02:41 -07:00
) ,
) . toBe ( true ) ;
2025-05-29 14:03:24 -07:00
expect ( result . returnDisplay ) . toContain ( '**Skipped 1 item(s):**' ) ;
2025-05-20 13:02:41 -07:00
expect ( result . returnDisplay ) . toContain (
2025-12-08 12:46:33 -05:00
'- `document.pdf` (Reason: asset file (image/pdf/audio) was not explicitly requested by name or extension)' ,
2025-05-20 13:02:41 -07:00
) ;
} ) ;
it ( 'should include PDF files as inlineData parts if explicitly requested by extension' , async ( ) = > {
createBinaryFile ( 'important.pdf' , Buffer . from ( '%PDF-1.4...' ) ) ;
2025-11-06 15:03:52 -08:00
const params = { include : [ '*.pdf' ] } ; // Explicitly requesting .pdf files
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
const result = await invocation . execute ( new AbortController ( ) . signal ) ;
2025-05-20 13:02:41 -07:00
expect ( result . llmContent ) . toEqual ( [
{
inlineData : {
data : Buffer.from ( '%PDF-1.4...' ) . toString ( 'base64' ) ,
mimeType : 'application/pdf' ,
} ,
} ,
2025-08-22 08:10:14 -07:00
'\n--- End of content ---' ,
2025-05-20 13:02:41 -07:00
] ) ;
} ) ;
it ( 'should include PDF files as inlineData parts if explicitly requested by name' , async ( ) = > {
createBinaryFile ( 'report-final.pdf' , Buffer . from ( '%PDF-1.4...' ) ) ;
2025-11-06 15:03:52 -08:00
const params = { include : [ 'report-final.pdf' ] } ;
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
const result = await invocation . execute ( new AbortController ( ) . signal ) ;
2025-05-20 13:02:41 -07:00
expect ( result . llmContent ) . toEqual ( [
{
inlineData : {
data : Buffer.from ( '%PDF-1.4...' ) . toString ( 'base64' ) ,
mimeType : 'application/pdf' ,
} ,
} ,
2025-08-22 08:10:14 -07:00
'\n--- End of content ---' ,
2025-05-20 13:02:41 -07:00
] ) ;
} ) ;
2025-06-05 10:15:27 -07:00
it ( 'should return error if path is ignored by a .geminiignore pattern' , async ( ) = > {
createFile ( 'foo.bar' , '' ) ;
2025-06-14 10:25:34 -04:00
createFile ( 'bar.ts' , '' ) ;
2025-06-05 10:15:27 -07:00
createFile ( 'foo.quux' , '' ) ;
2025-11-06 15:03:52 -08:00
const params = { include : [ 'foo.bar' , 'bar.ts' , 'foo.quux' ] } ;
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
const result = await invocation . execute ( new AbortController ( ) . signal ) ;
2025-06-05 10:15:27 -07:00
expect ( result . returnDisplay ) . not . toContain ( 'foo.bar' ) ;
expect ( result . returnDisplay ) . not . toContain ( 'foo.quux' ) ;
2025-06-14 10:25:34 -04:00
expect ( result . returnDisplay ) . toContain ( 'bar.ts' ) ;
2025-06-05 10:15:27 -07:00
} ) ;
2025-07-31 05:38:20 +09:00
it ( 'should read files from multiple workspace directories' , async ( ) = > {
const tempDir1 = fs . realpathSync (
fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'multi-dir-1-' ) ) ,
) ;
const tempDir2 = fs . realpathSync (
fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'multi-dir-2-' ) ) ,
) ;
const fileService = new FileDiscoveryService ( tempDir1 ) ;
const mockConfig = {
getFileService : ( ) = > fileService ,
2025-08-18 16:29:45 -06:00
getFileSystemService : ( ) = > new StandardFileSystemService ( ) ,
2025-07-31 05:38:20 +09:00
getFileFilteringOptions : ( ) = > ( {
respectGitIgnore : true ,
respectGeminiIgnore : true ,
2026-01-27 17:19:13 -08:00
customIgnoreFilePaths : [ ] ,
2025-07-31 05:38:20 +09:00
} ) ,
getWorkspaceContext : ( ) = > new WorkspaceContext ( tempDir1 , [ tempDir2 ] ) ,
getTargetDir : ( ) = > tempDir1 ,
2025-08-23 13:35:00 +09:00
getFileExclusions : ( ) = > ( {
getCoreIgnorePatterns : ( ) = > COMMON_IGNORE_PATTERNS ,
getDefaultExcludePatterns : ( ) = > [ ] ,
getGlobExcludes : ( ) = > COMMON_IGNORE_PATTERNS ,
buildExcludePatterns : ( ) = > [ ] ,
getReadManyFilesExcludes : ( ) = > [ ] ,
} ) ,
2025-11-11 02:03:32 -08:00
isInteractive : ( ) = > false ,
2026-01-27 13:17:40 -08:00
storage : {
getProjectTempDir : vi.fn ( ) . mockReturnValue ( '/tmp/project' ) ,
} ,
isPathAllowed ( this : Config , absolutePath : string ) : boolean {
const workspaceContext = this . getWorkspaceContext ( ) ;
if ( workspaceContext . isPathWithinWorkspace ( absolutePath ) ) {
return true ;
}
const projectTempDir = this . storage . getProjectTempDir ( ) ;
return isSubpath ( path . resolve ( projectTempDir ) , absolutePath ) ;
} ,
validatePathAccess ( this : Config , absolutePath : string ) : string | null {
if ( this . isPathAllowed ( absolutePath ) ) {
return null ;
}
const workspaceDirs = this . getWorkspaceContext ( ) . getDirectories ( ) ;
const projectTempDir = this . storage . getProjectTempDir ( ) ;
return ` Path not in workspace: Attempted path " ${ absolutePath } " resolves outside the allowed workspace directories: ${ workspaceDirs . join ( ', ' ) } or the project temp directory: ${ projectTempDir } ` ;
} ,
} as unknown as Config ;
2026-01-04 14:59:35 -05:00
tool = new ReadManyFilesTool ( mockConfig , createMockMessageBus ( ) ) ;
2025-07-31 05:38:20 +09:00
fs . writeFileSync ( path . join ( tempDir1 , 'file1.txt' ) , 'Content1' ) ;
fs . writeFileSync ( path . join ( tempDir2 , 'file2.txt' ) , 'Content2' ) ;
2025-11-06 15:03:52 -08:00
const params = { include : [ '*.txt' ] } ;
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
const result = await invocation . execute ( new AbortController ( ) . signal ) ;
2025-07-31 05:38:20 +09:00
const content = result . llmContent as string [ ] ;
if ( ! Array . isArray ( content ) ) {
throw new Error ( ` llmContent is not an array: ${ content } ` ) ;
}
const expectedPath1 = path . join ( tempDir1 , 'file1.txt' ) ;
const expectedPath2 = path . join ( tempDir2 , 'file2.txt' ) ;
expect (
content . some ( ( c ) = >
c . includes ( ` --- ${ expectedPath1 } --- \ n \ nContent1 \ n \ n ` ) ,
) ,
) . toBe ( true ) ;
expect (
content . some ( ( c ) = >
c . includes ( ` --- ${ expectedPath2 } --- \ n \ nContent2 \ n \ n ` ) ,
) ,
) . toBe ( true ) ;
expect ( result . returnDisplay ) . toContain (
'Successfully read and concatenated content from **2 file(s)**' ,
) ;
fs . rmSync ( tempDir1 , { recursive : true , force : true } ) ;
fs . rmSync ( tempDir2 , { recursive : true , force : true } ) ;
} ) ;
2025-08-06 13:52:04 -07:00
it ( 'should add a warning for truncated files' , async ( ) = > {
createFile ( 'file1.txt' , 'Content1' ) ;
// Create a file that will be "truncated" by making it long
const longContent = Array . from ( { length : 2500 } , ( _ , i ) = > ` L ${ i } ` ) . join (
'\n' ,
) ;
createFile ( 'large-file.txt' , longContent ) ;
2025-11-06 15:03:52 -08:00
const params = { include : [ '*.txt' ] } ;
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
const result = await invocation . execute ( new AbortController ( ) . signal ) ;
2025-08-06 13:52:04 -07:00
const content = result . llmContent as string [ ] ;
const normalFileContent = content . find ( ( c ) = > c . includes ( 'file1.txt' ) ) ;
const truncatedFileContent = content . find ( ( c ) = >
c . includes ( 'large-file.txt' ) ,
) ;
expect ( normalFileContent ) . not . toContain (
'[WARNING: This file was truncated.' ,
) ;
expect ( truncatedFileContent ) . toContain (
"[WARNING: This file was truncated. To view the full content, use the 'read_file' tool on this specific file.]" ,
) ;
// Check that the actual content is still there but truncated
expect ( truncatedFileContent ) . toContain ( 'L200' ) ;
expect ( truncatedFileContent ) . not . toContain ( 'L2400' ) ;
} ) ;
2025-08-18 16:39:05 -07:00
it ( 'should read files with special characters like [] and () in the path' , async ( ) = > {
const filePath = 'src/app/[test]/(dashboard)/testing/components/code.tsx' ;
createFile ( filePath , 'Content of receive-detail' ) ;
2025-11-06 15:03:52 -08:00
const params = { include : [ filePath ] } ;
2025-08-18 16:39:05 -07:00
const invocation = tool . build ( params ) ;
const result = await invocation . execute ( new AbortController ( ) . signal ) ;
const expectedPath = path . join ( tempRootDir , filePath ) ;
expect ( result . llmContent ) . toEqual ( [
` --- ${ expectedPath } ---
Content of receive-detail
` ,
2025-08-22 08:10:14 -07:00
` \ n--- End of content --- ` ,
2025-08-18 16:39:05 -07:00
] ) ;
expect ( result . returnDisplay ) . toContain (
'Successfully read and concatenated content from **1 file(s)**' ,
) ;
} ) ;
it ( 'should read files with special characters in the name' , async ( ) = > {
createFile ( 'file[1].txt' , 'Content of file[1]' ) ;
2025-11-06 15:03:52 -08:00
const params = { include : [ 'file[1].txt' ] } ;
2025-08-18 16:39:05 -07:00
const invocation = tool . build ( params ) ;
const result = await invocation . execute ( new AbortController ( ) . signal ) ;
const expectedPath = path . join ( tempRootDir , 'file[1].txt' ) ;
expect ( result . llmContent ) . toEqual ( [
` --- ${ expectedPath } ---
Content of file[1]
` ,
2025-08-22 08:10:14 -07:00
` \ n--- End of content --- ` ,
2025-08-18 16:39:05 -07:00
] ) ;
expect ( result . returnDisplay ) . toContain (
'Successfully read and concatenated content from **1 file(s)**' ,
) ;
} ) ;
2025-05-20 13:02:41 -07:00
} ) ;
2025-08-06 07:47:18 +09:00
2025-08-21 14:40:18 -07:00
describe ( 'Error handling' , ( ) = > {
it ( 'should return an INVALID_TOOL_PARAMS error if no paths are provided' , async ( ) = > {
2025-11-06 15:03:52 -08:00
const params = { include : [ ] } ;
2025-08-21 14:40:18 -07:00
expect ( ( ) = > {
tool . build ( params ) ;
2025-11-06 15:03:52 -08:00
} ) . toThrow ( 'params/include must NOT have fewer than 1 items' ) ;
2025-08-21 14:40:18 -07:00
} ) ;
it ( 'should return a READ_MANY_FILES_SEARCH_ERROR on glob failure' , async ( ) = > {
vi . mocked ( glob . glob ) . mockRejectedValue ( new Error ( 'Glob failed' ) ) ;
2025-11-06 15:03:52 -08:00
const params = { include : [ '*.txt' ] } ;
2025-08-21 14:40:18 -07:00
const invocation = tool . build ( params ) ;
const result = await invocation . execute ( new AbortController ( ) . signal ) ;
expect ( result . error ? . type ) . toBe (
ToolErrorType . READ_MANY_FILES_SEARCH_ERROR ,
) ;
expect ( result . llmContent ) . toBe ( 'Error during file search: Glob failed' ) ;
// Reset glob.
vi . mocked ( glob . glob ) . mockReset ( ) ;
} ) ;
} ) ;
2025-08-06 07:47:18 +09:00
describe ( 'Batch Processing' , ( ) = > {
const createMultipleFiles = ( count : number , contentPrefix = 'Content' ) = > {
const files : string [ ] = [ ] ;
for ( let i = 0 ; i < count ; i ++ ) {
const fileName = ` file ${ i } .txt ` ;
createFile ( fileName , ` ${ contentPrefix } ${ i } ` ) ;
files . push ( fileName ) ;
}
return files ;
} ;
const createFile = ( filePath : string , content = '' ) = > {
const fullPath = path . join ( tempRootDir , filePath ) ;
fs . mkdirSync ( path . dirname ( fullPath ) , { recursive : true } ) ;
fs . writeFileSync ( fullPath , content ) ;
} ;
2025-08-07 15:38:21 -07:00
it ( 'should process files in parallel' , async ( ) = > {
2025-08-06 07:47:18 +09:00
// Mock detectFileType to add artificial delay to simulate I/O
const detectFileTypeSpy = vi . spyOn (
await import ( '../utils/fileUtils.js' ) ,
'detectFileType' ,
) ;
// Create files
const fileCount = 4 ;
const files = createMultipleFiles ( fileCount , 'Batch test' ) ;
2025-08-07 15:38:21 -07:00
// Mock with 10ms delay per file to simulate I/O operations
2025-08-06 07:47:18 +09:00
detectFileTypeSpy . mockImplementation ( async ( _filePath : string ) = > {
2025-08-07 15:38:21 -07:00
await new Promise ( ( resolve ) = > setTimeout ( resolve , 10 ) ) ;
2025-08-06 07:47:18 +09:00
return 'text' ;
} ) ;
2025-11-06 15:03:52 -08:00
const params = { include : files } ;
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
const result = await invocation . execute ( new AbortController ( ) . signal ) ;
2025-08-06 07:47:18 +09:00
2025-08-22 08:10:14 -07:00
// Verify all files were processed. The content should have fileCount
// entries + 1 for the output terminator.
2025-08-06 07:47:18 +09:00
const content = result . llmContent as string [ ] ;
2025-08-22 08:10:14 -07:00
expect ( content ) . toHaveLength ( fileCount + 1 ) ;
2025-08-07 15:38:21 -07:00
for ( let i = 0 ; i < fileCount ; i ++ ) {
expect ( content . join ( '' ) ) . toContain ( ` Batch test ${ i } ` ) ;
}
2025-08-06 07:47:18 +09:00
// Cleanup mock
detectFileTypeSpy . mockRestore ( ) ;
} ) ;
it ( 'should handle batch processing errors gracefully' , async ( ) = > {
// Create mix of valid and problematic files
createFile ( 'valid1.txt' , 'Valid content 1' ) ;
createFile ( 'valid2.txt' , 'Valid content 2' ) ;
createFile ( 'valid3.txt' , 'Valid content 3' ) ;
const params = {
2025-11-06 15:03:52 -08:00
include : [
2025-08-06 07:47:18 +09:00
'valid1.txt' ,
'valid2.txt' ,
'nonexistent-file.txt' , // This will fail
'valid3.txt' ,
] ,
} ;
2025-08-13 12:27:09 -07:00
const invocation = tool . build ( params ) ;
const result = await invocation . execute ( new AbortController ( ) . signal ) ;
2025-08-06 07:47:18 +09:00
const content = result . llmContent as string [ ] ;
// Should successfully process valid files despite one failure
expect ( content . length ) . toBeGreaterThanOrEqual ( 3 ) ;
expect ( result . returnDisplay ) . toContain ( 'Successfully read' ) ;
// Verify valid files were processed
const expectedPath1 = path . join ( tempRootDir , 'valid1.txt' ) ;
const expectedPath3 = path . join ( tempRootDir , 'valid3.txt' ) ;
expect ( content . some ( ( c ) = > c . includes ( expectedPath1 ) ) ) . toBe ( true ) ;
expect ( content . some ( ( c ) = > c . includes ( expectedPath3 ) ) ) . toBe ( true ) ;
} ) ;
it ( 'should execute file operations concurrently' , async ( ) = > {
// Track execution order to verify concurrency
const executionOrder : string [ ] = [ ] ;
const detectFileTypeSpy = vi . spyOn (
await import ( '../utils/fileUtils.js' ) ,
'detectFileType' ,
) ;
const files = [ 'file1.txt' , 'file2.txt' , 'file3.txt' ] ;
files . forEach ( ( file ) = > createFile ( file , 'test content' ) ) ;
// Mock to track concurrent vs sequential execution
detectFileTypeSpy . mockImplementation ( async ( filePath : string ) = > {
const fileName = filePath . split ( '/' ) . pop ( ) || '' ;
executionOrder . push ( ` start: ${ fileName } ` ) ;
// Add delay to make timing differences visible
await new Promise ( ( resolve ) = > setTimeout ( resolve , 50 ) ) ;
executionOrder . push ( ` end: ${ fileName } ` ) ;
return 'text' ;
} ) ;
2025-11-06 15:03:52 -08:00
const invocation = tool . build ( { include : files } ) ;
2025-08-13 12:27:09 -07:00
await invocation . execute ( new AbortController ( ) . signal ) ;
2025-08-06 07:47:18 +09:00
// Verify concurrent execution pattern
// In parallel execution: all "start:" events should come before all "end:" events
// In sequential execution: "start:file1", "end:file1", "start:file2", "end:file2", etc.
const startEvents = executionOrder . filter ( ( e ) = >
e . startsWith ( 'start:' ) ,
) . length ;
const firstEndIndex = executionOrder . findIndex ( ( e ) = >
e . startsWith ( 'end:' ) ,
) ;
const startsBeforeFirstEnd = executionOrder
. slice ( 0 , firstEndIndex )
. filter ( ( e ) = > e . startsWith ( 'start:' ) ) . length ;
// For parallel processing, ALL start events should happen before the first end event
expect ( startsBeforeFirstEnd ) . toBe ( startEvents ) ; // Should PASS with parallel implementation
detectFileTypeSpy . mockRestore ( ) ;
} ) ;
} ) ;
2025-05-20 13:02:41 -07:00
} ) ;