2025-08-22 14:10:45 +08:00
/ * *
* @license
* Copyright 2025 Google LLC
* SPDX - License - Identifier : Apache - 2.0
* /
2025-09-08 14:44:56 -07:00
import {
describe ,
it ,
expect ,
beforeEach ,
afterEach ,
2025-10-20 19:17:44 -04:00
afterAll ,
2025-09-08 14:44:56 -07:00
vi ,
} from 'vitest' ;
2025-08-26 00:04:53 +02:00
import type { RipGrepToolParams } from './ripGrep.js' ;
2025-09-11 15:18:29 -07:00
import { canUseRipgrep , RipGrepTool , ensureRgPath } from './ripGrep.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/promises' ;
2025-11-10 19:01:01 -05:00
import os from 'node:os' ;
2025-08-26 00:04:53 +02:00
import type { Config } from '../config/config.js' ;
2025-10-20 19:17:44 -04:00
import { Storage } from '../config/storage.js' ;
2026-01-27 17:19:13 -08:00
import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js' ;
2025-08-22 14:10:45 +08:00
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js' ;
2025-08-26 00:04:53 +02:00
import type { ChildProcess } from 'node:child_process' ;
import { spawn } from 'node:child_process' ;
2026-01-26 16:52:19 -05:00
import { PassThrough , Readable } from 'node:stream' ;
import EventEmitter from 'node:events' ;
2025-09-19 11:08:41 -07:00
import { downloadRipGrep } from '@joshua.litt/get-ripgrep' ;
2026-01-04 17:11:43 -05:00
import { createMockMessageBus } from '../test-utils/mock-message-bus.js' ;
2025-09-08 14:44:56 -07:00
// Mock dependencies for canUseRipgrep
2025-09-19 11:08:41 -07:00
vi . mock ( '@joshua.litt/get-ripgrep' , ( ) = > ( {
2025-09-08 14:44:56 -07:00
downloadRipGrep : vi.fn ( ) ,
} ) ) ;
2025-08-22 14:10:45 +08:00
// Mock child_process for ripgrep calls
vi . mock ( 'child_process' , ( ) = > ( {
spawn : vi.fn ( ) ,
} ) ) ;
const mockSpawn = vi . mocked ( spawn ) ;
2025-10-20 19:17:44 -04:00
const downloadRipGrepMock = vi . mocked ( downloadRipGrep ) ;
const originalGetGlobalBinDir = Storage . getGlobalBinDir . bind ( Storage ) ;
const storageSpy = vi . spyOn ( Storage , 'getGlobalBinDir' ) ;
2025-08-22 14:10:45 +08:00
2025-11-17 22:24:09 +05:30
function getRipgrepBinaryName() {
return process . platform === 'win32' ? 'rg.exe' : 'rg' ;
}
2025-09-08 14:44:56 -07:00
describe ( 'canUseRipgrep' , ( ) = > {
2025-10-20 19:17:44 -04:00
let tempRootDir : string ;
let binDir : string ;
beforeEach ( async ( ) = > {
downloadRipGrepMock . mockReset ( ) ;
downloadRipGrepMock . mockResolvedValue ( undefined ) ;
tempRootDir = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , 'ripgrep-bin-' ) ) ;
binDir = path . join ( tempRootDir , 'bin' ) ;
await fs . mkdir ( binDir , { recursive : true } ) ;
storageSpy . mockImplementation ( ( ) = > binDir ) ;
} ) ;
afterEach ( async ( ) = > {
storageSpy . mockImplementation ( ( ) = > originalGetGlobalBinDir ( ) ) ;
await fs . rm ( tempRootDir , { recursive : true , force : true } ) ;
2025-09-08 14:44:56 -07:00
} ) ;
it ( 'should return true if ripgrep already exists' , async ( ) = > {
2025-11-17 22:24:09 +05:30
const existingPath = path . join ( binDir , getRipgrepBinaryName ( ) ) ;
2025-10-20 19:17:44 -04:00
await fs . writeFile ( existingPath , '' ) ;
2025-09-08 14:44:56 -07:00
const result = await canUseRipgrep ( ) ;
expect ( result ) . toBe ( true ) ;
2025-10-20 19:17:44 -04:00
expect ( downloadRipGrepMock ) . not . toHaveBeenCalled ( ) ;
2025-09-08 14:44:56 -07:00
} ) ;
it ( 'should download ripgrep and return true if it does not exist initially' , async ( ) = > {
2025-11-17 22:24:09 +05:30
const expectedPath = path . join ( binDir , getRipgrepBinaryName ( ) ) ;
2025-10-20 19:17:44 -04:00
downloadRipGrepMock . mockImplementation ( async ( ) = > {
await fs . writeFile ( expectedPath , '' ) ;
} ) ;
2025-09-08 14:44:56 -07:00
const result = await canUseRipgrep ( ) ;
expect ( result ) . toBe ( true ) ;
2025-10-20 19:17:44 -04:00
expect ( downloadRipGrep ) . toHaveBeenCalledWith ( binDir ) ;
await expect ( fs . access ( expectedPath ) ) . resolves . toBeUndefined ( ) ;
2025-09-08 14:44:56 -07:00
} ) ;
it ( 'should return false if download fails and file does not exist' , async ( ) = > {
const result = await canUseRipgrep ( ) ;
expect ( result ) . toBe ( false ) ;
2025-10-20 19:17:44 -04:00
expect ( downloadRipGrep ) . toHaveBeenCalledWith ( binDir ) ;
2025-09-08 14:44:56 -07:00
} ) ;
it ( 'should propagate errors from downloadRipGrep' , async ( ) = > {
const error = new Error ( 'Download failed' ) ;
2025-10-20 19:17:44 -04:00
downloadRipGrepMock . mockRejectedValue ( error ) ;
2025-09-08 14:44:56 -07:00
await expect ( canUseRipgrep ( ) ) . rejects . toThrow ( error ) ;
2025-10-20 19:17:44 -04:00
expect ( downloadRipGrep ) . toHaveBeenCalledWith ( binDir ) ;
} ) ;
it ( 'should only download once when called concurrently' , async ( ) = > {
2025-11-17 22:24:09 +05:30
const expectedPath = path . join ( binDir , getRipgrepBinaryName ( ) ) ;
2025-10-20 19:17:44 -04:00
downloadRipGrepMock . mockImplementation (
( ) = >
new Promise < void > ( ( resolve , reject ) = > {
setTimeout ( ( ) = > {
fs . writeFile ( expectedPath , '' )
. then ( ( ) = > resolve ( ) )
. catch ( reject ) ;
} , 0 ) ;
} ) ,
) ;
const firstCall = ensureRgPath ( ) ;
const secondCall = ensureRgPath ( ) ;
const [ pathOne , pathTwo ] = await Promise . all ( [ firstCall , secondCall ] ) ;
expect ( pathOne ) . toBe ( expectedPath ) ;
expect ( pathTwo ) . toBe ( expectedPath ) ;
expect ( downloadRipGrepMock ) . toHaveBeenCalledTimes ( 1 ) ;
await expect ( fs . access ( expectedPath ) ) . resolves . toBeUndefined ( ) ;
2025-09-08 14:44:56 -07:00
} ) ;
} ) ;
2025-09-11 15:18:29 -07:00
describe ( 'ensureRgPath' , ( ) = > {
2025-10-20 19:17:44 -04:00
let tempRootDir : string ;
let binDir : string ;
beforeEach ( async ( ) = > {
downloadRipGrepMock . mockReset ( ) ;
downloadRipGrepMock . mockResolvedValue ( undefined ) ;
tempRootDir = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , 'ripgrep-bin-' ) ) ;
binDir = path . join ( tempRootDir , 'bin' ) ;
await fs . mkdir ( binDir , { recursive : true } ) ;
storageSpy . mockImplementation ( ( ) = > binDir ) ;
} ) ;
afterEach ( async ( ) = > {
storageSpy . mockImplementation ( ( ) = > originalGetGlobalBinDir ( ) ) ;
await fs . rm ( tempRootDir , { recursive : true , force : true } ) ;
2025-09-11 15:18:29 -07:00
} ) ;
it ( 'should return rg path if ripgrep already exists' , async ( ) = > {
2025-11-17 22:24:09 +05:30
const existingPath = path . join ( binDir , getRipgrepBinaryName ( ) ) ;
2025-10-20 19:17:44 -04:00
await fs . writeFile ( existingPath , '' ) ;
2025-09-11 15:18:29 -07:00
const rgPath = await ensureRgPath ( ) ;
2025-10-20 19:17:44 -04:00
expect ( rgPath ) . toBe ( existingPath ) ;
2025-09-11 15:18:29 -07:00
expect ( downloadRipGrep ) . not . toHaveBeenCalled ( ) ;
} ) ;
it ( 'should return rg path if ripgrep is downloaded successfully' , async ( ) = > {
2025-11-17 22:24:09 +05:30
const expectedPath = path . join ( binDir , getRipgrepBinaryName ( ) ) ;
2025-10-20 19:17:44 -04:00
downloadRipGrepMock . mockImplementation ( async ( ) = > {
await fs . writeFile ( expectedPath , '' ) ;
} ) ;
2025-09-11 15:18:29 -07:00
const rgPath = await ensureRgPath ( ) ;
2025-10-20 19:17:44 -04:00
expect ( rgPath ) . toBe ( expectedPath ) ;
expect ( downloadRipGrep ) . toHaveBeenCalledTimes ( 1 ) ;
await expect ( fs . access ( expectedPath ) ) . resolves . toBeUndefined ( ) ;
2025-09-11 15:18:29 -07:00
} ) ;
it ( 'should throw an error if ripgrep cannot be used after download attempt' , async ( ) = > {
await expect ( ensureRgPath ( ) ) . rejects . toThrow ( 'Cannot use ripgrep.' ) ;
2025-10-20 19:17:44 -04:00
expect ( downloadRipGrep ) . toHaveBeenCalledTimes ( 1 ) ;
2025-09-11 15:18:29 -07:00
} ) ;
it ( 'should propagate errors from downloadRipGrep' , async ( ) = > {
const error = new Error ( 'Download failed' ) ;
2025-10-20 19:17:44 -04:00
downloadRipGrepMock . mockRejectedValue ( error ) ;
2025-09-11 15:18:29 -07:00
await expect ( ensureRgPath ( ) ) . rejects . toThrow ( error ) ;
2025-10-20 19:17:44 -04:00
expect ( downloadRipGrep ) . toHaveBeenCalledWith ( binDir ) ;
2025-09-11 15:18:29 -07:00
} ) ;
2025-10-20 19:17:44 -04:00
it . runIf ( process . platform === 'win32' ) (
'should detect ripgrep when only rg.exe exists on Windows' ,
async ( ) = > {
const expectedRgExePath = path . join ( binDir , 'rg.exe' ) ;
await fs . writeFile ( expectedRgExePath , '' ) ;
const rgPath = await ensureRgPath ( ) ;
expect ( rgPath ) . toBe ( expectedRgExePath ) ;
expect ( downloadRipGrep ) . not . toHaveBeenCalled ( ) ;
await expect ( fs . access ( expectedRgExePath ) ) . resolves . toBeUndefined ( ) ;
} ,
) ;
2025-09-11 15:18:29 -07:00
} ) ;
2025-08-22 14:10:45 +08:00
// Helper function to create mock spawn implementations
function createMockSpawn (
options : {
outputData? : string ;
2026-01-26 16:52:19 -05:00
exitCode? : number | null ;
2025-08-22 14:10:45 +08:00
signal? : string ;
} = { } ,
) {
const { outputData , exitCode = 0 , signal } = options ;
return ( ) = > {
2026-01-26 16:52:19 -05:00
// strict Readable implementation
let pushed = false ;
const stdout = new Readable ( {
read() {
if ( ! pushed ) {
if ( outputData ) {
this . push ( outputData ) ;
}
this . push ( null ) ; // EOF
pushed = true ;
}
2025-08-22 14:10:45 +08:00
} ,
2026-01-26 16:52:19 -05:00
} ) ;
2025-08-22 14:10:45 +08:00
2026-01-26 16:52:19 -05:00
const stderr = new PassThrough ( ) ;
const mockProcess = new EventEmitter ( ) as ChildProcess ;
mockProcess . stdout = stdout as unknown as Readable ;
mockProcess . stderr = stderr ;
mockProcess . kill = vi . fn ( ) ;
// @ts-expect-error - mocking private/internal property
mockProcess . killed = false ;
// @ts-expect-error - mocking private/internal property
mockProcess . exitCode = null ;
// Emulating process exit
2025-08-22 14:10:45 +08:00
setTimeout ( ( ) = > {
2026-01-26 16:52:19 -05:00
mockProcess . emit ( 'close' , exitCode , signal ) ;
} , 10 ) ;
2025-08-22 14:10:45 +08:00
2026-01-26 16:52:19 -05:00
return mockProcess ;
2025-08-22 14:10:45 +08:00
} ;
}
describe ( 'RipGrepTool' , ( ) = > {
let tempRootDir : string ;
2025-10-20 19:17:44 -04:00
let tempBinRoot : string ;
let binDir : string ;
let ripgrepBinaryPath : string ;
2025-08-22 14:10:45 +08:00
let grepTool : RipGrepTool ;
const abortSignal = new AbortController ( ) . signal ;
2026-01-27 17:19:13 -08:00
let mockConfig = {
getTargetDir : ( ) = > tempRootDir ,
getWorkspaceContext : ( ) = > createMockWorkspaceContext ( tempRootDir ) ,
getDebugMode : ( ) = > false ,
2026-02-03 21:37:21 +02:00
getFileFilteringRespectGitIgnore : ( ) = > true ,
2026-01-27 17:19:13 -08:00
getFileFilteringRespectGeminiIgnore : ( ) = > true ,
getFileFilteringOptions : ( ) = > ( {
respectGitIgnore : true ,
respectGeminiIgnore : true ,
} ) ,
} as unknown as Config ;
2025-08-22 14:10:45 +08:00
beforeEach ( async ( ) = > {
2025-10-20 19:17:44 -04:00
downloadRipGrepMock . mockReset ( ) ;
downloadRipGrepMock . mockResolvedValue ( undefined ) ;
mockSpawn . mockReset ( ) ;
tempBinRoot = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , 'ripgrep-bin-' ) ) ;
binDir = path . join ( tempBinRoot , 'bin' ) ;
await fs . mkdir ( binDir , { recursive : true } ) ;
const binaryName = process . platform === 'win32' ? 'rg.exe' : 'rg' ;
ripgrepBinaryPath = path . join ( binDir , binaryName ) ;
await fs . writeFile ( ripgrepBinaryPath , '' ) ;
storageSpy . mockImplementation ( ( ) = > binDir ) ;
2025-08-22 14:10:45 +08:00
tempRootDir = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , 'grep-tool-root-' ) ) ;
2026-01-27 13:17:40 -08:00
mockConfig = {
getTargetDir : ( ) = > tempRootDir ,
getWorkspaceContext : ( ) = > createMockWorkspaceContext ( tempRootDir ) ,
getDebugMode : ( ) = > false ,
2026-02-03 21:37:21 +02:00
getFileFilteringRespectGitIgnore : ( ) = > true ,
2026-01-27 13:17:40 -08:00
getFileFilteringRespectGeminiIgnore : ( ) = > true ,
2026-01-27 17:19:13 -08:00
getFileFilteringOptions : ( ) = > ( {
respectGitIgnore : true ,
respectGeminiIgnore : true ,
} ) ,
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 17:11:43 -05:00
grepTool = new RipGrepTool ( mockConfig , createMockMessageBus ( ) ) ;
2025-08-22 14:10:45 +08:00
// Create some test files and directories
await fs . writeFile (
path . join ( tempRootDir , 'fileA.txt' ) ,
'hello world\nsecond line with world' ,
) ;
await fs . writeFile (
path . join ( tempRootDir , 'fileB.js' ) ,
'const foo = "bar";\nfunction baz() { return "hello"; }' ,
) ;
await fs . mkdir ( path . join ( tempRootDir , 'sub' ) ) ;
await fs . writeFile (
path . join ( tempRootDir , 'sub' , 'fileC.txt' ) ,
'another world in sub dir' ,
) ;
await fs . writeFile (
path . join ( tempRootDir , 'sub' , 'fileD.md' ) ,
'# Markdown file\nThis is a test.' ,
) ;
} ) ;
afterEach ( async ( ) = > {
2025-10-20 19:17:44 -04:00
storageSpy . mockImplementation ( ( ) = > originalGetGlobalBinDir ( ) ) ;
2025-08-22 14:10:45 +08:00
await fs . rm ( tempRootDir , { recursive : true , force : true } ) ;
2025-10-20 19:17:44 -04:00
await fs . rm ( tempBinRoot , { recursive : true , force : true } ) ;
2025-08-22 14:10:45 +08:00
} ) ;
describe ( 'validateToolParams' , ( ) = > {
2025-11-17 22:24:09 +05:30
it . each ( [
{
name : 'pattern only' ,
params : { pattern : 'hello' } ,
expected : null ,
} ,
{
name : 'pattern and path' ,
params : { pattern : 'hello' , dir_path : '.' } ,
expected : null ,
} ,
{
name : 'pattern, path, and include' ,
params : { pattern : 'hello' , dir_path : '.' , include : '*.txt' } ,
expected : null ,
} ,
] ) (
'should return null for valid params ($name)' ,
( { params , expected } ) = > {
expect ( grepTool . validateToolParams ( params ) ) . toBe ( expected ) ;
} ,
) ;
2025-08-22 14:10:45 +08:00
2026-01-27 13:17:40 -08:00
it ( 'should throw error for invalid regex pattern' , ( ) = > {
const params : RipGrepToolParams = { pattern : '[[' } ;
expect ( grepTool . validateToolParams ( params ) ) . toMatch (
/Invalid regular expression pattern provided/ ,
) ;
} ) ;
2025-08-22 14:10:45 +08:00
it ( 'should return error if pattern is missing' , ( ) = > {
2025-11-06 15:03:52 -08:00
const params = { dir_path : '.' } as unknown as RipGrepToolParams ;
2025-08-22 14:10:45 +08:00
expect ( grepTool . validateToolParams ( params ) ) . toBe (
` params must have required property 'pattern' ` ,
) ;
} ) ;
it ( 'should return error if path does not exist' , ( ) = > {
const params : RipGrepToolParams = {
pattern : 'hello' ,
2025-11-06 15:03:52 -08:00
dir_path : 'nonexistent' ,
2025-08-22 14:10:45 +08:00
} ;
// Check for the core error message, as the full path might vary
2026-01-27 13:17:40 -08:00
const result = grepTool . validateToolParams ( params ) ;
expect ( result ) . toMatch ( /Path does not exist/ ) ;
expect ( result ) . toMatch ( /nonexistent/ ) ;
2025-08-22 14:10:45 +08:00
} ) ;
2025-11-12 00:11:19 -05:00
it ( 'should allow path to be a file' , async ( ) = > {
2025-08-22 14:10:45 +08:00
const filePath = path . join ( tempRootDir , 'fileA.txt' ) ;
2025-11-06 15:03:52 -08:00
const params : RipGrepToolParams = {
pattern : 'hello' ,
dir_path : filePath ,
} ;
2025-11-12 00:11:19 -05:00
expect ( grepTool . validateToolParams ( params ) ) . toBeNull ( ) ;
2025-08-22 14:10:45 +08:00
} ) ;
} ) ;
describe ( 'execute' , ( ) = > {
it ( 'should find matches for a simple pattern in all files' , async ( ) = > {
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
2025-11-10 19:01:01 -05:00
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'fileA.txt' } ,
line_number : 1 ,
lines : { text : 'hello world\n' } ,
} ,
} ) +
'\n' +
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'fileA.txt' } ,
line_number : 2 ,
lines : { text : 'second line with world\n' } ,
} ,
} ) +
'\n' +
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'sub/fileC.txt' } ,
line_number : 1 ,
lines : { text : 'another world in sub dir\n' } ,
} ,
} ) +
'\n' ,
2025-08-22 14:10:45 +08:00
exitCode : 0 ,
} ) ,
) ;
const params : RipGrepToolParams = { pattern : 'world' } ;
const invocation = grepTool . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
expect ( result . llmContent ) . toContain (
2025-11-12 00:11:19 -05:00
'Found 3 matches for pattern "world" in path "."' ,
2025-08-22 14:10:45 +08:00
) ;
expect ( result . llmContent ) . toContain ( 'File: fileA.txt' ) ;
expect ( result . llmContent ) . toContain ( 'L1: hello world' ) ;
expect ( result . llmContent ) . toContain ( 'L2: second line with world' ) ;
expect ( result . llmContent ) . toContain (
` File: ${ path . join ( 'sub' , 'fileC.txt' ) } ` ,
) ;
expect ( result . llmContent ) . toContain ( 'L1: another world in sub dir' ) ;
expect ( result . returnDisplay ) . toBe ( 'Found 3 matches' ) ;
} ) ;
2026-01-26 16:52:19 -05:00
it ( 'should ignore matches that escape the base path' , async ( ) = > {
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : '..env' } ,
line_number : 1 ,
lines : { text : 'world in ..env\n' } ,
} ,
} ) +
'\n' +
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : '../secret.txt' } ,
line_number : 1 ,
lines : { text : 'leak\n' } ,
} ,
} ) +
'\n' ,
exitCode : 0 ,
} ) ,
) ;
const params : RipGrepToolParams = { pattern : 'world' } ;
const invocation = grepTool . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
expect ( result . llmContent ) . toContain ( 'File: ..env' ) ;
expect ( result . llmContent ) . toContain ( 'L1: world in ..env' ) ;
expect ( result . llmContent ) . not . toContain ( 'secret.txt' ) ;
} ) ;
2025-08-22 14:10:45 +08:00
it ( 'should find matches in a specific path' , async ( ) = > {
// Setup specific mock for this test - searching in 'sub' should only return matches from that directory
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
2025-11-10 19:01:01 -05:00
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'fileC.txt' } ,
line_number : 1 ,
lines : { text : 'another world in sub dir\n' } ,
} ,
} ) + '\n' ,
2025-08-22 14:10:45 +08:00
exitCode : 0 ,
} ) ,
) ;
2025-11-06 15:03:52 -08:00
const params : RipGrepToolParams = { pattern : 'world' , dir_path : 'sub' } ;
2025-08-22 14:10:45 +08:00
const invocation = grepTool . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
expect ( result . llmContent ) . toContain (
'Found 1 match for pattern "world" in path "sub"' ,
) ;
expect ( result . llmContent ) . toContain ( 'File: fileC.txt' ) ; // Path relative to 'sub'
expect ( result . llmContent ) . toContain ( 'L1: another world in sub dir' ) ;
expect ( result . returnDisplay ) . toBe ( 'Found 1 match' ) ;
} ) ;
it ( 'should find matches with an include glob' , async ( ) = > {
// Setup specific mock for this test
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
2025-11-10 19:01:01 -05:00
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'fileB.js' } ,
line_number : 2 ,
lines : { text : 'function baz() { return "hello"; }\n' } ,
} ,
} ) + '\n' ,
2025-08-22 14:10:45 +08:00
exitCode : 0 ,
} ) ,
) ;
const params : RipGrepToolParams = { pattern : 'hello' , include : '*.js' } ;
const invocation = grepTool . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
expect ( result . llmContent ) . toContain (
2025-11-12 00:11:19 -05:00
'Found 1 match for pattern "hello" in path "." (filter: "*.js"):' ,
2025-08-22 14:10:45 +08:00
) ;
expect ( result . llmContent ) . toContain ( 'File: fileB.js' ) ;
expect ( result . llmContent ) . toContain (
'L2: function baz() { return "hello"; }' ,
) ;
expect ( result . returnDisplay ) . toBe ( 'Found 1 match' ) ;
} ) ;
it ( 'should find matches with an include glob and path' , async ( ) = > {
await fs . writeFile (
path . join ( tempRootDir , 'sub' , 'another.js' ) ,
'const greeting = "hello";' ,
) ;
// Setup specific mock for this test - searching for 'hello' in 'sub' with '*.js' filter
2026-01-26 16:52:19 -05:00
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'another.js' } ,
line_number : 1 ,
lines : { text : 'const greeting = "hello";\n' } ,
} ,
} ) + '\n' ,
exitCode : 0 ,
} ) ,
) ;
2025-08-22 14:10:45 +08:00
const params : RipGrepToolParams = {
pattern : 'hello' ,
2025-11-06 15:03:52 -08:00
dir_path : 'sub' ,
2025-08-22 14:10:45 +08:00
include : '*.js' ,
} ;
const invocation = grepTool . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
expect ( result . llmContent ) . toContain (
'Found 1 match for pattern "hello" in path "sub" (filter: "*.js")' ,
) ;
expect ( result . llmContent ) . toContain ( 'File: another.js' ) ;
expect ( result . llmContent ) . toContain ( 'L1: const greeting = "hello";' ) ;
expect ( result . returnDisplay ) . toBe ( 'Found 1 match' ) ;
} ) ;
it ( 'should return "No matches found" when pattern does not exist' , async ( ) = > {
// Setup specific mock for no matches
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
exitCode : 1 , // No matches found
} ) ,
) ;
const params : RipGrepToolParams = { pattern : 'nonexistentpattern' } ;
const invocation = grepTool . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
expect ( result . llmContent ) . toContain (
2025-11-12 00:11:19 -05:00
'No matches found for pattern "nonexistentpattern" in path ".".' ,
2025-08-22 14:10:45 +08:00
) ;
expect ( result . returnDisplay ) . toBe ( 'No matches found' ) ;
} ) ;
2026-01-27 13:17:40 -08:00
it ( 'should throw error for invalid regex pattern during build' , async ( ) = > {
2025-08-22 14:10:45 +08:00
const params : RipGrepToolParams = { pattern : '[[' } ;
2026-01-27 13:17:40 -08:00
expect ( ( ) = > grepTool . build ( params ) ) . toThrow (
/Invalid regular expression pattern provided/ ,
2025-08-22 14:10:45 +08:00
) ;
} ) ;
2026-01-26 16:52:19 -05:00
it ( 'should ignore invalid regex error from ripgrep when it is not a user error' , async ( ) = > {
mockSpawn . mockImplementation (
createMockSpawn ( {
outputData : '' ,
exitCode : 2 ,
signal : undefined ,
} ) ,
) ;
const invocation = grepTool . build ( {
pattern : 'foo' ,
dir_path : tempRootDir ,
} ) ;
const result = await invocation . execute ( abortSignal ) ;
expect ( result . llmContent ) . toContain ( 'Process exited with code 2' ) ;
expect ( result . returnDisplay ) . toContain (
'Error: Process exited with code 2' ,
) ;
} ) ;
it ( 'should handle massive output by terminating early without crashing (Regression)' , async ( ) = > {
const massiveOutputLines = 30000 ;
// Custom mock for massive streaming
mockSpawn . mockImplementation ( ( ) = > {
const stdout = new PassThrough ( ) ;
const stderr = new PassThrough ( ) ;
const mockProcess = new EventEmitter ( ) as ChildProcess ;
mockProcess . stdout = stdout ;
mockProcess . stderr = stderr ;
mockProcess . kill = vi . fn ( ) ;
// @ts-expect-error - mocking private/internal property
mockProcess . killed = false ;
// @ts-expect-error - mocking private/internal property
mockProcess . exitCode = null ;
// Push data over time
let linesPushed = 0 ;
const pushInterval = setInterval ( ( ) = > {
if ( linesPushed >= massiveOutputLines ) {
clearInterval ( pushInterval ) ;
stdout . end ( ) ;
mockProcess . emit ( 'close' , 0 ) ;
return ;
2025-08-22 14:10:45 +08:00
}
2026-01-26 16:52:19 -05:00
// Push a batch
try {
for ( let i = 0 ; i < 2000 && linesPushed < massiveOutputLines ; i ++ ) {
const match = JSON . stringify ( {
type : 'match' ,
data : {
path : { text : ` file_ ${ linesPushed } .txt ` } ,
line_number : 1 ,
lines : { text : ` match ${ linesPushed } \ n ` } ,
} ,
} ) ;
stdout . write ( match + '\n' ) ;
linesPushed ++ ;
}
} catch ( _e ) {
clearInterval ( pushInterval ) ;
2025-08-22 14:10:45 +08:00
}
2026-01-26 16:52:19 -05:00
} , 1 ) ;
mockProcess . kill = vi . fn ( ) . mockImplementation ( ( ) = > {
clearInterval ( pushInterval ) ;
stdout . end ( ) ;
// Emit close async to allow listeners to attach
setTimeout ( ( ) = > mockProcess . emit ( 'close' , 0 , 'SIGTERM' ) , 0 ) ;
return true ;
} ) ;
2025-08-22 14:10:45 +08:00
2026-01-26 16:52:19 -05:00
return mockProcess ;
} ) ;
const invocation = grepTool . build ( {
pattern : 'test' ,
dir_path : tempRootDir ,
2025-08-22 14:10:45 +08:00
} ) ;
2026-01-26 16:52:19 -05:00
const result = await invocation . execute ( abortSignal ) ;
expect ( result . returnDisplay ) . toContain ( '(limited)' ) ;
} , 10000 ) ;
2026-01-27 17:19:13 -08:00
it ( 'should filter out files based on FileDiscoveryService even if ripgrep returns them' , async ( ) = > {
// Create .geminiignore to ignore 'ignored.txt'
await fs . writeFile (
path . join ( tempRootDir , GEMINI_IGNORE_FILE_NAME ) ,
'ignored.txt' ,
) ;
// Re-initialize tool so FileDiscoveryService loads the new .geminiignore
const toolWithIgnore = new RipGrepTool (
mockConfig ,
createMockMessageBus ( ) ,
) ;
// Mock ripgrep returning both an ignored file and an allowed file
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'ignored.txt' } ,
line_number : 1 ,
lines : { text : 'should be ignored\n' } ,
} ,
} ) +
'\n' +
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'allowed.txt' } ,
line_number : 1 ,
lines : { text : 'should be kept\n' } ,
} ,
} ) +
'\n' ,
exitCode : 0 ,
} ) ,
) ;
const params : RipGrepToolParams = { pattern : 'should' } ;
const invocation = toolWithIgnore . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
// Verify ignored file is filtered out
expect ( result . llmContent ) . toContain ( 'allowed.txt' ) ;
expect ( result . llmContent ) . toContain ( 'should be kept' ) ;
expect ( result . llmContent ) . not . toContain ( 'ignored.txt' ) ;
expect ( result . llmContent ) . not . toContain ( 'should be ignored' ) ;
expect ( result . returnDisplay ) . toContain ( 'Found 1 match' ) ;
} ) ;
2026-01-26 16:52:19 -05:00
it ( 'should handle regex special characters correctly' , async ( ) = > {
// Setup specific mock for this test - regex pattern 'foo.*bar' should match 'const foo = "bar";'
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'fileB.js' } ,
line_number : 1 ,
lines : { text : 'const foo = "bar";\n' } ,
} ,
} ) + '\n' ,
exitCode : 0 ,
} ) ,
) ;
2025-08-22 14:10:45 +08:00
const params : RipGrepToolParams = { pattern : 'foo.*bar' } ; // Matches 'const foo = "bar";'
const invocation = grepTool . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
expect ( result . llmContent ) . toContain (
2025-11-12 00:11:19 -05:00
'Found 1 match for pattern "foo.*bar" in path ".":' ,
2025-08-22 14:10:45 +08:00
) ;
expect ( result . llmContent ) . toContain ( 'File: fileB.js' ) ;
expect ( result . llmContent ) . toContain ( 'L1: const foo = "bar";' ) ;
} ) ;
it ( 'should be case-insensitive by default (JS fallback)' , async ( ) = > {
// Setup specific mock for this test - case insensitive search for 'HELLO'
2026-01-26 16:52:19 -05:00
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'fileA.txt' } ,
line_number : 1 ,
lines : { text : 'hello world\n' } ,
} ,
} ) +
'\n' +
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'fileB.js' } ,
line_number : 2 ,
lines : { text : 'function baz() { return "hello"; }\n' } ,
} ,
} ) +
'\n' ,
exitCode : 0 ,
} ) ,
) ;
2025-08-22 14:10:45 +08:00
const params : RipGrepToolParams = { pattern : 'HELLO' } ;
const invocation = grepTool . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
expect ( result . llmContent ) . toContain (
2025-11-12 00:11:19 -05:00
'Found 2 matches for pattern "HELLO" in path ".":' ,
2025-08-22 14:10:45 +08:00
) ;
expect ( result . llmContent ) . toContain ( 'File: fileA.txt' ) ;
expect ( result . llmContent ) . toContain ( 'L1: hello world' ) ;
expect ( result . llmContent ) . toContain ( 'File: fileB.js' ) ;
expect ( result . llmContent ) . toContain (
'L2: function baz() { return "hello"; }' ,
) ;
} ) ;
it ( 'should throw an error if params are invalid' , async ( ) = > {
2025-11-06 15:03:52 -08:00
const params = { dir_path : '.' } as unknown as RipGrepToolParams ; // Invalid: pattern missing
2025-08-22 14:10:45 +08:00
expect ( ( ) = > grepTool . build ( params ) ) . toThrow (
/params must have required property 'pattern'/ ,
) ;
} ) ;
2025-09-11 15:18:29 -07:00
it ( 'should throw an error if ripgrep is not available' , async ( ) = > {
2025-10-20 19:17:44 -04:00
await fs . rm ( ripgrepBinaryPath , { force : true } ) ;
downloadRipGrepMock . mockResolvedValue ( undefined ) ;
2025-09-11 15:18:29 -07:00
const params : RipGrepToolParams = { pattern : 'world' } ;
const invocation = grepTool . build ( params ) ;
expect ( await invocation . execute ( abortSignal ) ) . toStrictEqual ( {
llmContent : 'Error during grep search operation: Cannot use ripgrep.' ,
returnDisplay : 'Error: Cannot use ripgrep.' ,
} ) ;
} ) ;
2025-08-22 14:10:45 +08:00
} ) ;
describe ( 'multi-directory workspace' , ( ) = > {
2025-11-12 00:11:19 -05:00
it ( 'should search only CWD when no path is specified (default behavior)' , async ( ) = > {
2025-08-22 14:10:45 +08:00
// Create additional directory with test files
const secondDir = await fs . mkdtemp (
path . join ( os . tmpdir ( ) , 'grep-tool-second-' ) ,
) ;
await fs . writeFile (
path . join ( secondDir , 'other.txt' ) ,
'hello from second directory\nworld in second' ,
) ;
await fs . writeFile (
path . join ( secondDir , 'another.js' ) ,
'function world() { return "test"; }' ,
) ;
// Create a mock config with multiple directories
const multiDirConfig = {
getTargetDir : ( ) = > tempRootDir ,
getWorkspaceContext : ( ) = >
createMockWorkspaceContext ( tempRootDir , [ secondDir ] ) ,
getDebugMode : ( ) = > false ,
2026-02-03 21:37:21 +02:00
getFileFilteringRespectGitIgnore : ( ) = > true ,
2025-12-22 06:25:26 +02:00
getFileFilteringRespectGeminiIgnore : ( ) = > true ,
2026-01-27 17:19:13 -08:00
getFileFilteringOptions : ( ) = > ( {
respectGitIgnore : true ,
respectGeminiIgnore : true ,
} ) ,
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 } ` ;
} ,
2025-08-22 14:10:45 +08:00
} as unknown as Config ;
// Setup specific mock for this test - multi-directory search for 'world'
// Mock will be called twice - once for each directory
2026-01-26 16:52:19 -05:00
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'fileA.txt' } ,
line_number : 1 ,
lines : { text : 'hello world\n' } ,
} ,
} ) +
'\n' +
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'fileA.txt' } ,
line_number : 2 ,
lines : { text : 'second line with world\n' } ,
} ,
} ) +
'\n' +
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'sub/fileC.txt' } ,
line_number : 1 ,
lines : { text : 'another world in sub dir\n' } ,
} ,
} ) +
'\n' ,
} ) ,
) ;
2025-08-22 14:10:45 +08:00
2026-01-04 17:11:43 -05:00
const multiDirGrepTool = new RipGrepTool (
multiDirConfig ,
createMockMessageBus ( ) ,
) ;
2025-08-22 14:10:45 +08:00
const params : RipGrepToolParams = { pattern : 'world' } ;
const invocation = multiDirGrepTool . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
2025-11-12 00:11:19 -05:00
// Should find matches in CWD only (default behavior now)
2025-08-22 14:10:45 +08:00
expect ( result . llmContent ) . toContain (
2025-11-12 00:11:19 -05:00
'Found 3 matches for pattern "world" in path "."' ,
2025-08-22 14:10:45 +08:00
) ;
// Matches from first directory
expect ( result . llmContent ) . toContain ( 'fileA.txt' ) ;
expect ( result . llmContent ) . toContain ( 'L1: hello world' ) ;
expect ( result . llmContent ) . toContain ( 'L2: second line with world' ) ;
expect ( result . llmContent ) . toContain ( 'fileC.txt' ) ;
expect ( result . llmContent ) . toContain ( 'L1: another world in sub dir' ) ;
2025-11-12 00:11:19 -05:00
// Should NOT find matches from second directory
expect ( result . llmContent ) . not . toContain ( 'other.txt' ) ;
expect ( result . llmContent ) . not . toContain ( 'world in second' ) ;
expect ( result . llmContent ) . not . toContain ( 'another.js' ) ;
expect ( result . llmContent ) . not . toContain ( 'function world()' ) ;
2025-08-22 14:10:45 +08:00
// Clean up
await fs . rm ( secondDir , { recursive : true , force : true } ) ;
mockSpawn . mockClear ( ) ;
} ) ;
it ( 'should search only specified path within workspace directories' , async ( ) = > {
// Create additional directory
const secondDir = await fs . mkdtemp (
path . join ( os . tmpdir ( ) , 'grep-tool-second-' ) ,
) ;
await fs . mkdir ( path . join ( secondDir , 'sub' ) ) ;
await fs . writeFile (
path . join ( secondDir , 'sub' , 'test.txt' ) ,
'hello from second sub directory' ,
) ;
// Create a mock config with multiple directories
const multiDirConfig = {
getTargetDir : ( ) = > tempRootDir ,
getWorkspaceContext : ( ) = >
createMockWorkspaceContext ( tempRootDir , [ secondDir ] ) ,
getDebugMode : ( ) = > false ,
2026-02-03 21:37:21 +02:00
getFileFilteringRespectGitIgnore : ( ) = > true ,
2025-12-22 06:25:26 +02:00
getFileFilteringRespectGeminiIgnore : ( ) = > true ,
2026-01-27 17:19:13 -08:00
getFileFilteringOptions : ( ) = > ( {
respectGitIgnore : true ,
respectGeminiIgnore : true ,
} ) ,
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 } ` ;
} ,
2025-08-22 14:10:45 +08:00
} as unknown as Config ;
// Setup specific mock for this test - searching in 'sub' should only return matches from that directory
2026-01-26 16:52:19 -05:00
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'fileC.txt' } ,
line_number : 1 ,
lines : { text : 'another world in sub dir\n' } ,
} ,
} ) + '\n' ,
} ) ,
) ;
2025-08-22 14:10:45 +08:00
2026-01-04 17:11:43 -05:00
const multiDirGrepTool = new RipGrepTool (
multiDirConfig ,
createMockMessageBus ( ) ,
) ;
2025-08-22 14:10:45 +08:00
// Search only in the 'sub' directory of the first workspace
2025-11-06 15:03:52 -08:00
const params : RipGrepToolParams = { pattern : 'world' , dir_path : 'sub' } ;
2025-08-22 14:10:45 +08:00
const invocation = multiDirGrepTool . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
// Should only find matches in the specified sub directory
expect ( result . llmContent ) . toContain (
'Found 1 match for pattern "world" in path "sub"' ,
) ;
expect ( result . llmContent ) . toContain ( 'File: fileC.txt' ) ;
expect ( result . llmContent ) . toContain ( 'L1: another world in sub dir' ) ;
// Should not contain matches from second directory
expect ( result . llmContent ) . not . toContain ( 'test.txt' ) ;
// Clean up
await fs . rm ( secondDir , { recursive : true , force : true } ) ;
} ) ;
} ) ;
describe ( 'abort signal handling' , ( ) = > {
it ( 'should handle AbortSignal during search' , async ( ) = > {
const controller = new AbortController ( ) ;
const params : RipGrepToolParams = { pattern : 'world' } ;
const invocation = grepTool . build ( params ) ;
controller . abort ( ) ;
const result = await invocation . execute ( controller . signal ) ;
expect ( result ) . toBeDefined ( ) ;
} ) ;
it ( 'should abort streaming search when signal is triggered' , async ( ) = > {
// Setup specific mock for this test - simulate process being killed due to abort
2026-01-26 16:52:19 -05:00
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
exitCode : null ,
signal : 'SIGTERM' ,
} ) ,
) ;
2025-08-22 14:10:45 +08:00
const controller = new AbortController ( ) ;
const params : RipGrepToolParams = { pattern : 'test' } ;
const invocation = grepTool . build ( params ) ;
// Abort immediately before starting the search
controller . abort ( ) ;
const result = await invocation . execute ( controller . signal ) ;
2026-01-26 16:52:19 -05:00
expect ( result . returnDisplay ) . toContain ( 'No matches found' ) ;
2025-08-22 14:10:45 +08:00
} ) ;
} ) ;
describe ( 'error handling and edge cases' , ( ) = > {
it ( 'should handle workspace boundary violations' , ( ) = > {
2025-11-06 15:03:52 -08:00
const params : RipGrepToolParams = {
pattern : 'test' ,
dir_path : '../outside' ,
} ;
2026-01-27 13:17:40 -08:00
expect ( ( ) = > grepTool . build ( params ) ) . toThrow ( /Path not in workspace/ ) ;
2025-08-22 14:10:45 +08:00
} ) ;
2025-11-17 22:24:09 +05:30
it . each ( [
{
name : 'empty directories' ,
setup : async ( ) = > {
const emptyDir = path . join ( tempRootDir , 'empty' ) ;
await fs . mkdir ( emptyDir ) ;
return { pattern : 'test' , dir_path : 'empty' } ;
} ,
} ,
{
name : 'empty files' ,
setup : async ( ) = > {
await fs . writeFile ( path . join ( tempRootDir , 'empty.txt' ) , '' ) ;
return { pattern : 'anything' } ;
} ,
} ,
] ) ( 'should handle $name gracefully' , async ( { setup } ) = > {
mockSpawn . mockImplementationOnce ( createMockSpawn ( { exitCode : 1 } ) ) ;
2025-08-22 14:10:45 +08:00
2025-11-17 22:24:09 +05:30
const params = await setup ( ) ;
2025-08-22 14:10:45 +08:00
const invocation = grepTool . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
expect ( result . llmContent ) . toContain ( 'No matches found' ) ;
} ) ;
it ( 'should handle special characters in file names' , async ( ) = > {
const specialFileName = 'file with spaces & symbols!.txt' ;
await fs . writeFile (
path . join ( tempRootDir , specialFileName ) ,
'hello world with special chars' ,
) ;
// Setup specific mock for this test - searching for 'world' should find the file with special characters
2026-01-26 16:52:19 -05:00
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : specialFileName } ,
line_number : 1 ,
lines : { text : 'hello world with special chars\n' } ,
} ,
} ) + '\n' ,
} ) ,
) ;
2025-08-22 14:10:45 +08:00
const params : RipGrepToolParams = { pattern : 'world' } ;
const invocation = grepTool . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
expect ( result . llmContent ) . toContain ( specialFileName ) ;
expect ( result . llmContent ) . toContain ( 'hello world with special chars' ) ;
} ) ;
it ( 'should handle deeply nested directories' , async ( ) = > {
const deepPath = path . join ( tempRootDir , 'a' , 'b' , 'c' , 'd' , 'e' ) ;
await fs . mkdir ( deepPath , { recursive : true } ) ;
await fs . writeFile (
path . join ( deepPath , 'deep.txt' ) ,
'content in deep directory' ,
) ;
// Setup specific mock for this test - searching for 'deep' should find the deeply nested file
2026-01-26 16:52:19 -05:00
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'a/b/c/d/e/deep.txt' } ,
line_number : 1 ,
lines : { text : 'content in deep directory\n' } ,
} ,
} ) + '\n' ,
} ) ,
) ;
2025-08-22 14:10:45 +08:00
const params : RipGrepToolParams = { pattern : 'deep' } ;
const invocation = grepTool . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
expect ( result . llmContent ) . toContain ( 'deep.txt' ) ;
expect ( result . llmContent ) . toContain ( 'content in deep directory' ) ;
} ) ;
} ) ;
describe ( 'regex pattern validation' , ( ) = > {
it ( 'should handle complex regex patterns' , async ( ) = > {
await fs . writeFile (
path . join ( tempRootDir , 'code.js' ) ,
'function getName() { return "test"; }\nconst getValue = () => "value";' ,
) ;
// Setup specific mock for this test - regex pattern should match function declarations
2026-01-26 16:52:19 -05:00
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'code.js' } ,
line_number : 1 ,
lines : { text : 'function getName() { return "test"; }\n' } ,
} ,
} ) + '\n' ,
} ) ,
) ;
2025-08-22 14:10:45 +08:00
const params : RipGrepToolParams = { pattern : 'function\\s+\\w+\\s*\\(' } ;
const invocation = grepTool . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
expect ( result . llmContent ) . toContain ( 'function getName()' ) ;
expect ( result . llmContent ) . not . toContain ( 'const getValue' ) ;
} ) ;
it ( 'should handle case sensitivity correctly in JS fallback' , async ( ) = > {
await fs . writeFile (
path . join ( tempRootDir , 'case.txt' ) ,
'Hello World\nhello world\nHELLO WORLD' ,
) ;
// Setup specific mock for this test - case insensitive search should match all variants
2026-01-26 16:52:19 -05:00
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'case.txt' } ,
line_number : 1 ,
lines : { text : 'Hello World\n' } ,
} ,
} ) +
'\n' +
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'case.txt' } ,
line_number : 2 ,
lines : { text : 'hello world\n' } ,
} ,
} ) +
'\n' +
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'case.txt' } ,
line_number : 3 ,
lines : { text : 'HELLO WORLD\n' } ,
} ,
} ) +
'\n' ,
} ) ,
) ;
2025-08-22 14:10:45 +08:00
const params : RipGrepToolParams = { pattern : 'hello' } ;
const invocation = grepTool . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
expect ( result . llmContent ) . toContain ( 'Hello World' ) ;
expect ( result . llmContent ) . toContain ( 'hello world' ) ;
expect ( result . llmContent ) . toContain ( 'HELLO WORLD' ) ;
} ) ;
it ( 'should handle escaped regex special characters' , async ( ) = > {
await fs . writeFile (
path . join ( tempRootDir , 'special.txt' ) ,
'Price: $19.99\nRegex: [a-z]+ pattern\nEmail: test@example.com' ,
) ;
// Setup specific mock for this test - escaped regex pattern should match price format
2026-01-26 16:52:19 -05:00
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'special.txt' } ,
line_number : 1 ,
lines : { text : 'Price: $19.99\n' } ,
} ,
} ) + '\n' ,
} ) ,
) ;
2025-08-22 14:10:45 +08:00
const params : RipGrepToolParams = { pattern : '\\$\\d+\\.\\d+' } ;
const invocation = grepTool . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
expect ( result . llmContent ) . toContain ( 'Price: $19.99' ) ;
expect ( result . llmContent ) . not . toContain ( 'Email: test@example.com' ) ;
} ) ;
} ) ;
describe ( 'include pattern filtering' , ( ) = > {
it ( 'should handle multiple file extensions in include pattern' , async ( ) = > {
await fs . writeFile (
path . join ( tempRootDir , 'test.ts' ) ,
'typescript content' ,
) ;
await fs . writeFile ( path . join ( tempRootDir , 'test.tsx' ) , 'tsx content' ) ;
await fs . writeFile (
path . join ( tempRootDir , 'test.js' ) ,
'javascript content' ,
) ;
await fs . writeFile ( path . join ( tempRootDir , 'test.txt' ) , 'text content' ) ;
// Setup specific mock for this test - include pattern should filter to only ts/tsx files
2026-01-26 16:52:19 -05:00
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'test.ts' } ,
line_number : 1 ,
lines : { text : 'typescript content\n' } ,
} ,
} ) +
'\n' +
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'test.tsx' } ,
line_number : 1 ,
lines : { text : 'tsx content\n' } ,
} ,
} ) +
'\n' ,
} ) ,
) ;
2025-08-22 14:10:45 +08:00
const params : RipGrepToolParams = {
pattern : 'content' ,
include : '*.{ts,tsx}' ,
} ;
const invocation = grepTool . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
expect ( result . llmContent ) . toContain ( 'test.ts' ) ;
expect ( result . llmContent ) . toContain ( 'test.tsx' ) ;
expect ( result . llmContent ) . not . toContain ( 'test.js' ) ;
expect ( result . llmContent ) . not . toContain ( 'test.txt' ) ;
} ) ;
it ( 'should handle directory patterns in include' , async ( ) = > {
await fs . mkdir ( path . join ( tempRootDir , 'src' ) , { recursive : true } ) ;
await fs . writeFile (
path . join ( tempRootDir , 'src' , 'main.ts' ) ,
'source code' ,
) ;
await fs . writeFile ( path . join ( tempRootDir , 'other.ts' ) , 'other code' ) ;
// Setup specific mock for this test - include pattern should filter to only src/** files
2026-01-26 16:52:19 -05:00
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'src/main.ts' } ,
line_number : 1 ,
lines : { text : 'source code\n' } ,
} ,
} ) + '\n' ,
} ) ,
) ;
2025-08-22 14:10:45 +08:00
const params : RipGrepToolParams = {
pattern : 'code' ,
include : 'src/**' ,
} ;
const invocation = grepTool . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
expect ( result . llmContent ) . toContain ( 'main.ts' ) ;
expect ( result . llmContent ) . not . toContain ( 'other.ts' ) ;
} ) ;
} ) ;
2025-11-12 00:11:19 -05:00
describe ( 'advanced search options' , ( ) = > {
it ( 'should handle case_sensitive parameter' , async ( ) = > {
// Case-insensitive search (default)
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'fileA.txt' } ,
line_number : 1 ,
lines : { text : 'hello world\n' } ,
} ,
} ) + '\n' ,
exitCode : 0 ,
} ) ,
) ;
let params : RipGrepToolParams = { pattern : 'HELLO' } ;
let invocation = grepTool . build ( params ) ;
let result = await invocation . execute ( abortSignal ) ;
expect ( mockSpawn ) . toHaveBeenLastCalledWith (
expect . anything ( ) ,
expect . arrayContaining ( [ '--ignore-case' ] ) ,
expect . anything ( ) ,
) ;
expect ( result . llmContent ) . toContain ( 'Found 1 match for pattern "HELLO"' ) ;
expect ( result . llmContent ) . toContain ( 'L1: hello world' ) ;
// Case-sensitive search
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'fileA.txt' } ,
line_number : 1 ,
lines : { text : 'HELLO world\n' } ,
} ,
} ) + '\n' ,
exitCode : 0 ,
} ) ,
) ;
params = { pattern : 'HELLO' , case_sensitive : true } ;
invocation = grepTool . build ( params ) ;
result = await invocation . execute ( abortSignal ) ;
expect ( mockSpawn ) . toHaveBeenLastCalledWith (
expect . anything ( ) ,
expect . not . arrayContaining ( [ '--ignore-case' ] ) ,
expect . anything ( ) ,
) ;
expect ( result . llmContent ) . toContain ( 'Found 1 match for pattern "HELLO"' ) ;
expect ( result . llmContent ) . toContain ( 'L1: HELLO world' ) ;
} ) ;
2025-11-17 22:24:09 +05:30
it . each ( [
{
name : 'fixed_strings parameter' ,
params : { pattern : 'hello.world' , fixed_strings : true } ,
mockOutput : {
path : { text : 'fileA.txt' } ,
line_number : 1 ,
lines : { text : 'hello.world\n' } ,
} ,
expectedArgs : [ '--fixed-strings' ] ,
expectedPattern : 'hello.world' ,
} ,
] ) (
'should handle $name' ,
async ( { params , mockOutput , expectedArgs , expectedPattern } ) = > {
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( { type : 'match' , data : mockOutput } ) + '\n' ,
exitCode : 0 ,
} ) ,
) ;
const invocation = grepTool . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
expect ( mockSpawn ) . toHaveBeenLastCalledWith (
expect . anything ( ) ,
expect . arrayContaining ( expectedArgs ) ,
expect . anything ( ) ,
) ;
expect ( result . llmContent ) . toContain (
` Found 1 match for pattern " ${ expectedPattern } " ` ,
) ;
} ,
) ;
2025-11-12 00:11:19 -05:00
it ( 'should handle no_ignore parameter' , async ( ) = > {
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'ignored.log' } ,
line_number : 1 ,
lines : { text : 'secret log entry\n' } ,
} ,
} ) + '\n' ,
exitCode : 0 ,
} ) ,
) ;
const params : RipGrepToolParams = { pattern : 'secret' , no_ignore : true } ;
const invocation = grepTool . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
expect ( mockSpawn ) . toHaveBeenLastCalledWith (
expect . anything ( ) ,
expect . arrayContaining ( [ '--no-ignore' ] ) ,
expect . anything ( ) ,
) ;
expect ( mockSpawn ) . toHaveBeenLastCalledWith (
expect . anything ( ) ,
expect . not . arrayContaining ( [ '--glob' , '!node_modules' ] ) ,
expect . anything ( ) ,
) ;
expect ( result . llmContent ) . toContain ( 'Found 1 match for pattern "secret"' ) ;
expect ( result . llmContent ) . toContain ( 'File: ignored.log' ) ;
expect ( result . llmContent ) . toContain ( 'L1: secret log entry' ) ;
} ) ;
2026-02-03 21:37:21 +02:00
it ( 'should disable gitignore rules when respectGitIgnore is false' , async ( ) = > {
const configWithoutGitIgnore = {
getTargetDir : ( ) = > tempRootDir ,
getWorkspaceContext : ( ) = > createMockWorkspaceContext ( tempRootDir ) ,
getDebugMode : ( ) = > false ,
getFileFilteringRespectGitIgnore : ( ) = > false ,
getFileFilteringRespectGeminiIgnore : ( ) = > true ,
getFileFilteringOptions : ( ) = > ( {
respectGitIgnore : false ,
respectGeminiIgnore : true ,
} ) ,
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 ;
const gitIgnoreDisabledTool = new RipGrepTool (
configWithoutGitIgnore ,
createMockMessageBus ( ) ,
) ;
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'ignored.log' } ,
line_number : 1 ,
lines : { text : 'secret log entry\n' } ,
} ,
} ) + '\n' ,
exitCode : 0 ,
} ) ,
) ;
const params : RipGrepToolParams = { pattern : 'secret' } ;
const invocation = gitIgnoreDisabledTool . build ( params ) ;
await invocation . execute ( abortSignal ) ;
expect ( mockSpawn ) . toHaveBeenLastCalledWith (
expect . anything ( ) ,
expect . arrayContaining ( [ '--no-ignore-vcs' , '--no-ignore-exclude' ] ) ,
expect . anything ( ) ,
) ;
} ) ;
2025-12-22 06:25:26 +02:00
it ( 'should add .geminiignore when enabled and patterns exist' , async ( ) = > {
2026-01-27 17:19:13 -08:00
const geminiIgnorePath = path . join ( tempRootDir , GEMINI_IGNORE_FILE_NAME ) ;
2025-12-22 06:25:26 +02:00
await fs . writeFile ( geminiIgnorePath , 'ignored.log' ) ;
const configWithGeminiIgnore = {
getTargetDir : ( ) = > tempRootDir ,
getWorkspaceContext : ( ) = > createMockWorkspaceContext ( tempRootDir ) ,
getDebugMode : ( ) = > false ,
2026-02-03 21:37:21 +02:00
getFileFilteringRespectGitIgnore : ( ) = > true ,
2025-12-22 06:25:26 +02:00
getFileFilteringRespectGeminiIgnore : ( ) = > true ,
2026-01-27 17:19:13 -08:00
getFileFilteringOptions : ( ) = > ( {
respectGitIgnore : true ,
respectGeminiIgnore : true ,
} ) ,
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 } ` ;
} ,
2025-12-22 06:25:26 +02:00
} as unknown as Config ;
2026-01-04 17:11:43 -05:00
const geminiIgnoreTool = new RipGrepTool (
configWithGeminiIgnore ,
createMockMessageBus ( ) ,
) ;
2025-12-22 06:25:26 +02:00
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'ignored.log' } ,
line_number : 1 ,
lines : { text : 'secret log entry\n' } ,
} ,
} ) + '\n' ,
exitCode : 0 ,
} ) ,
) ;
const params : RipGrepToolParams = { pattern : 'secret' } ;
const invocation = geminiIgnoreTool . build ( params ) ;
await invocation . execute ( abortSignal ) ;
expect ( mockSpawn ) . toHaveBeenLastCalledWith (
expect . anything ( ) ,
expect . arrayContaining ( [ '--ignore-file' , geminiIgnorePath ] ) ,
expect . anything ( ) ,
) ;
} ) ;
it ( 'should skip .geminiignore when disabled' , async ( ) = > {
2026-01-27 17:19:13 -08:00
const geminiIgnorePath = path . join ( tempRootDir , GEMINI_IGNORE_FILE_NAME ) ;
2025-12-22 06:25:26 +02:00
await fs . writeFile ( geminiIgnorePath , 'ignored.log' ) ;
const configWithoutGeminiIgnore = {
getTargetDir : ( ) = > tempRootDir ,
getWorkspaceContext : ( ) = > createMockWorkspaceContext ( tempRootDir ) ,
getDebugMode : ( ) = > false ,
2026-02-03 21:37:21 +02:00
getFileFilteringRespectGitIgnore : ( ) = > true ,
2025-12-22 06:25:26 +02:00
getFileFilteringRespectGeminiIgnore : ( ) = > false ,
2026-01-27 17:19:13 -08:00
getFileFilteringOptions : ( ) = > ( {
respectGitIgnore : true ,
respectGeminiIgnore : 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 } ` ;
} ,
2025-12-22 06:25:26 +02:00
} as unknown as Config ;
2026-01-04 17:11:43 -05:00
const geminiIgnoreTool = new RipGrepTool (
configWithoutGeminiIgnore ,
createMockMessageBus ( ) ,
) ;
2025-12-22 06:25:26 +02:00
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'ignored.log' } ,
line_number : 1 ,
lines : { text : 'secret log entry\n' } ,
} ,
} ) + '\n' ,
exitCode : 0 ,
} ) ,
) ;
const params : RipGrepToolParams = { pattern : 'secret' } ;
const invocation = geminiIgnoreTool . build ( params ) ;
await invocation . execute ( abortSignal ) ;
expect ( mockSpawn ) . toHaveBeenLastCalledWith (
expect . anything ( ) ,
expect . not . arrayContaining ( [ '--ignore-file' , geminiIgnorePath ] ) ,
expect . anything ( ) ,
) ;
} ) ;
2025-11-12 00:11:19 -05:00
it ( 'should handle context parameters' , async ( ) = > {
mockSpawn . mockImplementationOnce (
createMockSpawn ( {
outputData :
JSON . stringify ( {
type : 'match' ,
data : {
path : { text : 'fileA.txt' } ,
line_number : 2 ,
lines : { text : 'second line with world\n' } ,
lines_before : [ { text : 'hello world\n' } ] ,
lines_after : [
{ text : 'third line\n' } ,
{ text : 'fourth line\n' } ,
] ,
} ,
} ) + '\n' ,
exitCode : 0 ,
} ) ,
) ;
const params : RipGrepToolParams = {
pattern : 'world' ,
context : 1 ,
after : 2 ,
before : 1 ,
} ;
const invocation = grepTool . build ( params ) ;
const result = await invocation . execute ( abortSignal ) ;
expect ( mockSpawn ) . toHaveBeenLastCalledWith (
expect . anything ( ) ,
expect . arrayContaining ( [
'--context' ,
'1' ,
'--after-context' ,
'2' ,
'--before-context' ,
'1' ,
] ) ,
expect . anything ( ) ,
) ;
expect ( result . llmContent ) . toContain ( 'Found 1 match for pattern "world"' ) ;
expect ( result . llmContent ) . toContain ( 'File: fileA.txt' ) ;
expect ( result . llmContent ) . toContain ( 'L2: second line with world' ) ;
// Note: Ripgrep JSON output for context lines doesn't include line numbers for context lines directly
// The current parsing only extracts the matched line, so we only assert on that.
} ) ;
} ) ;
2025-08-22 14:10:45 +08:00
describe ( 'getDescription' , ( ) = > {
2025-11-17 22:24:09 +05:30
it . each ( [
{
name : 'pattern only' ,
params : { pattern : 'testPattern' } ,
expected : "'testPattern' within ./" ,
} ,
{
name : 'pattern and include' ,
params : { pattern : 'testPattern' , include : '*.ts' } ,
expected : "'testPattern' in *.ts within ./" ,
} ,
{
name : 'root path in description' ,
params : { pattern : 'testPattern' , dir_path : '.' } ,
expected : "'testPattern' within ./" ,
} ,
] ) (
'should generate correct description with $name' ,
( { params , expected } ) = > {
const invocation = grepTool . build ( params ) ;
expect ( invocation . getDescription ( ) ) . toBe ( expected ) ;
} ,
) ;
2025-08-22 14:10:45 +08:00
it ( 'should generate correct description with pattern and path' , async ( ) = > {
const dirPath = path . join ( tempRootDir , 'src' , 'app' ) ;
await fs . mkdir ( dirPath , { recursive : true } ) ;
const params : RipGrepToolParams = {
pattern : 'testPattern' ,
2025-11-06 15:03:52 -08:00
dir_path : path.join ( 'src' , 'app' ) ,
2025-08-22 14:10:45 +08:00
} ;
const invocation = grepTool . build ( params ) ;
expect ( invocation . getDescription ( ) ) . toContain ( "'testPattern' within" ) ;
expect ( invocation . getDescription ( ) ) . toContain ( path . join ( 'src' , 'app' ) ) ;
} ) ;
2025-11-12 00:11:19 -05:00
it ( 'should use ./ when no path is specified (defaults to CWD)' , ( ) = > {
2025-08-22 14:10:45 +08:00
const multiDirConfig = {
getTargetDir : ( ) = > tempRootDir ,
getWorkspaceContext : ( ) = >
createMockWorkspaceContext ( tempRootDir , [ '/another/dir' ] ) ,
getDebugMode : ( ) = > false ,
2026-01-27 17:19:13 -08:00
getFileFilteringOptions : ( ) = > ( {
respectGitIgnore : true ,
respectGeminiIgnore : true ,
} ) ,
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 } ` ;
} ,
2025-08-22 14:10:45 +08:00
} as unknown as Config ;
2026-01-04 17:11:43 -05:00
const multiDirGrepTool = new RipGrepTool (
multiDirConfig ,
createMockMessageBus ( ) ,
) ;
2025-08-22 14:10:45 +08:00
const params : RipGrepToolParams = { pattern : 'testPattern' } ;
const invocation = multiDirGrepTool . build ( params ) ;
2025-11-12 00:11:19 -05:00
expect ( invocation . getDescription ( ) ) . toBe ( "'testPattern' within ./" ) ;
2025-08-22 14:10:45 +08:00
} ) ;
it ( 'should generate correct description with pattern, include, and path' , async ( ) = > {
const dirPath = path . join ( tempRootDir , 'src' , 'app' ) ;
await fs . mkdir ( dirPath , { recursive : true } ) ;
const params : RipGrepToolParams = {
pattern : 'testPattern' ,
include : '*.ts' ,
2025-11-06 15:03:52 -08:00
dir_path : path.join ( 'src' , 'app' ) ,
2025-08-22 14:10:45 +08:00
} ;
const invocation = grepTool . build ( params ) ;
expect ( invocation . getDescription ( ) ) . toContain (
"'testPattern' in *.ts within" ,
) ;
expect ( invocation . getDescription ( ) ) . toContain ( path . join ( 'src' , 'app' ) ) ;
} ) ;
} ) ;
} ) ;
2025-12-22 06:25:26 +02:00
2025-10-20 19:17:44 -04:00
afterAll ( ( ) = > {
storageSpy . mockRestore ( ) ;
} ) ;