2025-07-31 05:38:20 +09:00
/ * *
* @license
* Copyright 2025 Google LLC
* SPDX - License - Identifier : Apache - 2.0
* /
2025-09-10 09:54:50 -07:00
import { describe , it , expect , beforeEach , afterEach , vi } from 'vitest' ;
import fs from 'node:fs/promises' ;
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-09-10 09:54:50 -07:00
import os from 'node:os' ;
2025-07-31 05:38:20 +09:00
import { LSTool } from './ls.js' ;
2025-08-26 00:04:53 +02:00
import type { Config } from '../config/config.js' ;
2025-09-10 09:54:50 -07:00
import { FileDiscoveryService } from '../services/fileDiscoveryService.js' ;
2025-08-21 14:40:18 -07:00
import { ToolErrorType } from './tool-error.js' ;
2025-11-06 15:03:52 -08:00
import { WorkspaceContext } from '../utils/workspaceContext.js' ;
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-07-31 05:38:20 +09:00
describe ( 'LSTool' , ( ) = > {
let lsTool : LSTool ;
2025-09-10 09:54:50 -07:00
let tempRootDir : string ;
let tempSecondaryDir : string ;
2025-07-31 05:38:20 +09:00
let mockConfig : Config ;
2025-09-10 09:54:50 -07:00
const abortSignal = new AbortController ( ) . signal ;
beforeEach ( async ( ) = > {
2025-11-06 15:03:52 -08:00
const realTmp = await fs . realpath ( os . tmpdir ( ) ) ;
tempRootDir = await fs . mkdtemp ( path . join ( realTmp , 'ls-tool-root-' ) ) ;
2025-09-10 09:54:50 -07:00
tempSecondaryDir = await fs . mkdtemp (
2025-11-06 15:03:52 -08:00
path . join ( realTmp , 'ls-tool-secondary-' ) ,
2025-09-10 09:54:50 -07:00
) ;
2026-01-27 13:17:40 -08:00
const mockStorage = {
getProjectTempDir : vi.fn ( ) . mockReturnValue ( '/tmp/project' ) ,
} ;
2025-07-31 05:38:20 +09:00
mockConfig = {
2025-09-10 09:54:50 -07:00
getTargetDir : ( ) = > tempRootDir ,
2025-11-06 15:03:52 -08:00
getWorkspaceContext : ( ) = >
new WorkspaceContext ( tempRootDir , [ tempSecondaryDir ] ) ,
2025-09-10 09:54:50 -07:00
getFileService : ( ) = > new FileDiscoveryService ( tempRootDir ) ,
getFileFilteringOptions : ( ) = > ( {
2025-07-31 05:38:20 +09:00
respectGitIgnore : true ,
respectGeminiIgnore : true ,
} ) ,
2026-01-27 13:17:40 -08:00
storage : mockStorage ,
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-07-31 05:38:20 +09:00
} as unknown as Config ;
2026-01-04 14:59:35 -05:00
lsTool = new LSTool ( mockConfig , createMockMessageBus ( ) ) ;
2025-07-31 05:38:20 +09:00
} ) ;
2025-09-10 09:54:50 -07:00
afterEach ( async ( ) = > {
await fs . rm ( tempRootDir , { recursive : true , force : true } ) ;
await fs . rm ( tempSecondaryDir , { recursive : true , force : true } ) ;
} ) ;
2025-07-31 05:38:20 +09:00
describe ( 'parameter validation' , ( ) = > {
2025-09-10 09:54:50 -07:00
it ( 'should accept valid absolute paths within workspace' , async ( ) = > {
const testPath = path . join ( tempRootDir , 'src' ) ;
await fs . mkdir ( testPath ) ;
2025-11-06 15:03:52 -08:00
const invocation = lsTool . build ( { dir_path : testPath } ) ;
2025-09-10 09:54:50 -07:00
2025-08-13 11:57:37 -07:00
expect ( invocation ) . toBeDefined ( ) ;
2025-07-31 05:38:20 +09:00
} ) ;
2025-11-06 15:03:52 -08:00
it ( 'should accept relative paths' , async ( ) = > {
const testPath = path . join ( tempRootDir , 'src' ) ;
await fs . mkdir ( testPath ) ;
const relativePath = path . relative ( tempRootDir , testPath ) ;
const invocation = lsTool . build ( { dir_path : relativePath } ) ;
expect ( invocation ) . toBeDefined ( ) ;
2025-07-31 05:38:20 +09:00
} ) ;
it ( 'should reject paths outside workspace with clear error message' , ( ) = > {
2025-11-06 15:03:52 -08:00
expect ( ( ) = > lsTool . build ( { dir_path : '/etc/passwd' } ) ) . toThrow (
2026-01-27 13:17:40 -08:00
/Path not in workspace: Attempted path ".*" resolves outside the allowed workspace directories: .*/ ,
2025-07-31 05:38:20 +09:00
) ;
} ) ;
2025-09-10 09:54:50 -07:00
it ( 'should accept paths in secondary workspace directory' , async ( ) = > {
const testPath = path . join ( tempSecondaryDir , 'lib' ) ;
await fs . mkdir ( testPath ) ;
2025-11-06 15:03:52 -08:00
const invocation = lsTool . build ( { dir_path : testPath } ) ;
2025-09-10 09:54:50 -07:00
2025-08-13 11:57:37 -07:00
expect ( invocation ) . toBeDefined ( ) ;
2025-07-31 05:38:20 +09:00
} ) ;
} ) ;
describe ( 'execute' , ( ) = > {
it ( 'should list files in a directory' , async ( ) = > {
2025-09-10 09:54:50 -07:00
await fs . writeFile ( path . join ( tempRootDir , 'file1.txt' ) , 'content1' ) ;
await fs . mkdir ( path . join ( tempRootDir , 'subdir' ) ) ;
await fs . writeFile (
path . join ( tempSecondaryDir , 'secondary-file.txt' ) ,
'secondary' ,
) ;
2025-07-31 05:38:20 +09:00
2025-11-06 15:03:52 -08:00
const invocation = lsTool . build ( { dir_path : tempRootDir } ) ;
2025-09-10 09:54:50 -07:00
const result = await invocation . execute ( abortSignal ) ;
2025-07-31 05:38:20 +09:00
expect ( result . llmContent ) . toContain ( '[DIR] subdir' ) ;
2025-09-10 09:54:50 -07:00
expect ( result . llmContent ) . toContain ( 'file1.txt' ) ;
expect ( result . returnDisplay ) . toBe ( 'Listed 2 item(s).' ) ;
2025-07-31 05:38:20 +09:00
} ) ;
it ( 'should list files from secondary workspace directory' , async ( ) = > {
2025-09-10 09:54:50 -07:00
await fs . writeFile ( path . join ( tempRootDir , 'file1.txt' ) , 'content1' ) ;
await fs . mkdir ( path . join ( tempRootDir , 'subdir' ) ) ;
await fs . writeFile (
path . join ( tempSecondaryDir , 'secondary-file.txt' ) ,
'secondary' ,
) ;
2025-07-31 05:38:20 +09:00
2025-11-06 15:03:52 -08:00
const invocation = lsTool . build ( { dir_path : tempSecondaryDir } ) ;
2025-09-10 09:54:50 -07:00
const result = await invocation . execute ( abortSignal ) ;
2025-07-31 05:38:20 +09:00
2025-09-10 09:54:50 -07:00
expect ( result . llmContent ) . toContain ( 'secondary-file.txt' ) ;
expect ( result . returnDisplay ) . toBe ( 'Listed 1 item(s).' ) ;
2025-07-31 05:38:20 +09:00
} ) ;
it ( 'should handle empty directories' , async ( ) = > {
2025-09-10 09:54:50 -07:00
const emptyDir = path . join ( tempRootDir , 'empty' ) ;
await fs . mkdir ( emptyDir ) ;
2025-11-06 15:03:52 -08:00
const invocation = lsTool . build ( { dir_path : emptyDir } ) ;
2025-09-10 09:54:50 -07:00
const result = await invocation . execute ( abortSignal ) ;
2025-07-31 05:38:20 +09:00
2025-09-10 09:54:50 -07:00
expect ( result . llmContent ) . toBe ( ` Directory ${ emptyDir } is empty. ` ) ;
2025-07-31 05:38:20 +09:00
expect ( result . returnDisplay ) . toBe ( 'Directory is empty.' ) ;
} ) ;
it ( 'should respect ignore patterns' , async ( ) = > {
2025-09-10 09:54:50 -07:00
await fs . writeFile ( path . join ( tempRootDir , 'file1.txt' ) , 'content1' ) ;
await fs . writeFile ( path . join ( tempRootDir , 'file2.log' ) , 'content1' ) ;
2025-07-31 05:38:20 +09:00
2025-08-13 11:57:37 -07:00
const invocation = lsTool . build ( {
2025-11-06 15:03:52 -08:00
dir_path : tempRootDir ,
2025-09-10 09:54:50 -07:00
ignore : [ '*.log' ] ,
2025-08-13 11:57:37 -07:00
} ) ;
2025-09-10 09:54:50 -07:00
const result = await invocation . execute ( abortSignal ) ;
2025-07-31 05:38:20 +09:00
2025-09-10 09:54:50 -07:00
expect ( result . llmContent ) . toContain ( 'file1.txt' ) ;
expect ( result . llmContent ) . not . toContain ( 'file2.log' ) ;
expect ( result . returnDisplay ) . toBe ( 'Listed 1 item(s).' ) ;
2025-07-31 05:38:20 +09:00
} ) ;
it ( 'should respect gitignore patterns' , async ( ) = > {
2025-09-10 09:54:50 -07:00
await fs . writeFile ( path . join ( tempRootDir , 'file1.txt' ) , 'content1' ) ;
await fs . writeFile ( path . join ( tempRootDir , 'file2.log' ) , 'content1' ) ;
await fs . writeFile ( path . join ( tempRootDir , '.git' ) , '' ) ;
await fs . writeFile ( path . join ( tempRootDir , '.gitignore' ) , '*.log' ) ;
2025-11-06 15:03:52 -08:00
const invocation = lsTool . build ( { dir_path : tempRootDir } ) ;
2025-09-10 09:54:50 -07:00
const result = await invocation . execute ( abortSignal ) ;
expect ( result . llmContent ) . toContain ( 'file1.txt' ) ;
expect ( result . llmContent ) . not . toContain ( 'file2.log' ) ;
// .git is always ignored by default.
2025-10-22 17:06:31 -07:00
expect ( result . returnDisplay ) . toBe ( 'Listed 2 item(s). (2 ignored)' ) ;
2025-07-31 05:38:20 +09:00
} ) ;
it ( 'should respect geminiignore patterns' , async ( ) = > {
2025-09-10 09:54:50 -07:00
await fs . writeFile ( path . join ( tempRootDir , 'file1.txt' ) , 'content1' ) ;
await fs . writeFile ( path . join ( tempRootDir , 'file2.log' ) , 'content1' ) ;
2026-01-27 17:19:13 -08:00
await fs . writeFile (
path . join ( tempRootDir , GEMINI_IGNORE_FILE_NAME ) ,
'*.log' ,
) ;
2025-11-06 15:03:52 -08:00
const invocation = lsTool . build ( { dir_path : tempRootDir } ) ;
2025-09-10 09:54:50 -07:00
const result = await invocation . execute ( abortSignal ) ;
expect ( result . llmContent ) . toContain ( 'file1.txt' ) ;
expect ( result . llmContent ) . not . toContain ( 'file2.log' ) ;
2025-10-22 17:06:31 -07:00
expect ( result . returnDisplay ) . toBe ( 'Listed 2 item(s). (1 ignored)' ) ;
2025-07-31 05:38:20 +09:00
} ) ;
it ( 'should handle non-directory paths' , async ( ) = > {
2025-09-10 09:54:50 -07:00
const testPath = path . join ( tempRootDir , 'file1.txt' ) ;
await fs . writeFile ( testPath , 'content1' ) ;
2025-07-31 05:38:20 +09:00
2025-11-06 15:03:52 -08:00
const invocation = lsTool . build ( { dir_path : testPath } ) ;
2025-09-10 09:54:50 -07:00
const result = await invocation . execute ( abortSignal ) ;
2025-07-31 05:38:20 +09:00
expect ( result . llmContent ) . toContain ( 'Path is not a directory' ) ;
expect ( result . returnDisplay ) . toBe ( 'Error: Path is not a directory.' ) ;
2025-08-21 14:40:18 -07:00
expect ( result . error ? . type ) . toBe ( ToolErrorType . PATH_IS_NOT_A_DIRECTORY ) ;
2025-07-31 05:38:20 +09:00
} ) ;
it ( 'should handle non-existent paths' , async ( ) = > {
2025-09-10 09:54:50 -07:00
const testPath = path . join ( tempRootDir , 'does-not-exist' ) ;
2025-11-06 15:03:52 -08:00
const invocation = lsTool . build ( { dir_path : testPath } ) ;
2025-09-10 09:54:50 -07:00
const result = await invocation . execute ( abortSignal ) ;
2025-07-31 05:38:20 +09:00
expect ( result . llmContent ) . toContain ( 'Error listing directory' ) ;
expect ( result . returnDisplay ) . toBe ( 'Error: Failed to list directory.' ) ;
2025-08-21 14:40:18 -07:00
expect ( result . error ? . type ) . toBe ( ToolErrorType . LS_EXECUTION_ERROR ) ;
2025-07-31 05:38:20 +09:00
} ) ;
it ( 'should sort directories first, then files alphabetically' , async ( ) = > {
2025-09-10 09:54:50 -07:00
await fs . writeFile ( path . join ( tempRootDir , 'a-file.txt' ) , 'content1' ) ;
await fs . writeFile ( path . join ( tempRootDir , 'b-file.txt' ) , 'content1' ) ;
await fs . mkdir ( path . join ( tempRootDir , 'x-dir' ) ) ;
await fs . mkdir ( path . join ( tempRootDir , 'y-dir' ) ) ;
2025-07-31 05:38:20 +09:00
2025-11-06 15:03:52 -08:00
const invocation = lsTool . build ( { dir_path : tempRootDir } ) ;
2025-09-10 09:54:50 -07:00
const result = await invocation . execute ( abortSignal ) ;
2025-07-31 05:38:20 +09:00
const lines = (
typeof result . llmContent === 'string' ? result . llmContent : ''
2025-09-10 09:54:50 -07:00
)
. split ( '\n' )
. filter ( Boolean ) ;
const entries = lines . slice ( 1 ) ; // Skip header
expect ( entries [ 0 ] ) . toBe ( '[DIR] x-dir' ) ;
expect ( entries [ 1 ] ) . toBe ( '[DIR] y-dir' ) ;
2026-02-17 23:54:08 +00:00
expect ( entries [ 2 ] ) . toBe ( 'a-file.txt (8 bytes)' ) ;
expect ( entries [ 3 ] ) . toBe ( 'b-file.txt (8 bytes)' ) ;
2025-07-31 05:38:20 +09:00
} ) ;
it ( 'should handle permission errors gracefully' , async ( ) = > {
2025-09-10 09:54:50 -07:00
const restrictedDir = path . join ( tempRootDir , 'restricted' ) ;
await fs . mkdir ( restrictedDir ) ;
2025-07-31 05:38:20 +09:00
2025-09-10 09:54:50 -07:00
// To simulate a permission error in a cross-platform way,
// we mock fs.readdir to throw an error.
const error = new Error ( 'EACCES: permission denied' ) ;
vi . spyOn ( fs , 'readdir' ) . mockRejectedValueOnce ( error ) ;
2025-07-31 05:38:20 +09:00
2025-11-06 15:03:52 -08:00
const invocation = lsTool . build ( { dir_path : restrictedDir } ) ;
2025-09-10 09:54:50 -07:00
const result = await invocation . execute ( abortSignal ) ;
2025-07-31 05:38:20 +09:00
expect ( result . llmContent ) . toContain ( 'Error listing directory' ) ;
expect ( result . llmContent ) . toContain ( 'permission denied' ) ;
expect ( result . returnDisplay ) . toBe ( 'Error: Failed to list directory.' ) ;
2025-08-21 14:40:18 -07:00
expect ( result . error ? . type ) . toBe ( ToolErrorType . LS_EXECUTION_ERROR ) ;
2025-07-31 05:38:20 +09:00
} ) ;
it ( 'should handle errors accessing individual files during listing' , async ( ) = > {
2025-09-10 09:54:50 -07:00
await fs . writeFile ( path . join ( tempRootDir , 'file1.txt' ) , 'content1' ) ;
const problematicFile = path . join ( tempRootDir , 'problematic.txt' ) ;
await fs . writeFile ( problematicFile , 'content2' ) ;
// To simulate an error on a single file in a cross-platform way,
// we mock fs.stat to throw for a specific file. This avoids
// platform-specific behavior with things like dangling symlinks.
const originalStat = fs . stat ;
const statSpy = vi . spyOn ( fs , 'stat' ) . mockImplementation ( async ( p ) = > {
if ( p . toString ( ) === problematicFile ) {
throw new Error ( 'Simulated stat error' ) ;
2025-07-31 05:38:20 +09:00
}
2025-09-10 09:54:50 -07:00
return originalStat ( p ) ;
2025-07-31 05:38:20 +09:00
} ) ;
2025-11-06 15:03:52 -08:00
const invocation = lsTool . build ( { dir_path : tempRootDir } ) ;
2025-09-10 09:54:50 -07:00
const result = await invocation . execute ( abortSignal ) ;
2025-07-31 05:38:20 +09:00
2025-09-10 09:54:50 -07:00
// Should still list the other files
expect ( result . llmContent ) . toContain ( 'file1.txt' ) ;
expect ( result . llmContent ) . not . toContain ( 'problematic.txt' ) ;
2025-07-31 05:38:20 +09:00
expect ( result . returnDisplay ) . toBe ( 'Listed 1 item(s).' ) ;
2025-09-10 09:54:50 -07:00
statSpy . mockRestore ( ) ;
2025-07-31 05:38:20 +09:00
} ) ;
} ) ;
describe ( 'getDescription' , ( ) = > {
it ( 'should return shortened relative path' , ( ) = > {
2025-09-10 09:54:50 -07:00
const deeplyNestedDir = path . join ( tempRootDir , 'deeply' , 'nested' ) ;
2025-07-31 05:38:20 +09:00
const params = {
2025-11-06 15:03:52 -08:00
dir_path : path.join ( deeplyNestedDir , 'directory' ) ,
2025-07-31 05:38:20 +09:00
} ;
2025-08-13 11:57:37 -07:00
const invocation = lsTool . build ( params ) ;
const description = invocation . getDescription ( ) ;
2025-07-31 05:38:20 +09:00
expect ( description ) . toBe ( path . join ( 'deeply' , 'nested' , 'directory' ) ) ;
} ) ;
it ( 'should handle paths in secondary workspace' , ( ) = > {
const params = {
2025-11-06 15:03:52 -08:00
dir_path : path.join ( tempSecondaryDir , 'lib' ) ,
2025-07-31 05:38:20 +09:00
} ;
2025-08-13 11:57:37 -07:00
const invocation = lsTool . build ( params ) ;
const description = invocation . getDescription ( ) ;
2025-11-06 15:03:52 -08:00
const expected = path . relative ( tempRootDir , params . dir_path ) ;
2025-09-10 09:54:50 -07:00
expect ( description ) . toBe ( expected ) ;
2025-07-31 05:38:20 +09:00
} ) ;
} ) ;
describe ( 'workspace boundary validation' , ( ) = > {
2025-09-10 09:54:50 -07:00
it ( 'should accept paths in primary workspace directory' , async ( ) = > {
const testPath = path . join ( tempRootDir , 'src' ) ;
await fs . mkdir ( testPath ) ;
2025-11-06 15:03:52 -08:00
const params = { dir_path : testPath } ;
2025-08-13 11:57:37 -07:00
expect ( lsTool . build ( params ) ) . toBeDefined ( ) ;
2025-07-31 05:38:20 +09:00
} ) ;
2025-09-10 09:54:50 -07:00
it ( 'should accept paths in secondary workspace directory' , async ( ) = > {
const testPath = path . join ( tempSecondaryDir , 'lib' ) ;
await fs . mkdir ( testPath ) ;
2025-11-06 15:03:52 -08:00
const params = { dir_path : testPath } ;
2025-08-13 11:57:37 -07:00
expect ( lsTool . build ( params ) ) . toBeDefined ( ) ;
2025-07-31 05:38:20 +09:00
} ) ;
it ( 'should reject paths outside all workspace directories' , ( ) = > {
2025-11-06 15:03:52 -08:00
const params = { dir_path : '/etc/passwd' } ;
2025-08-13 11:57:37 -07:00
expect ( ( ) = > lsTool . build ( params ) ) . toThrow (
2026-01-27 13:17:40 -08:00
/Path not in workspace: Attempted path ".*" resolves outside the allowed workspace directories: .*/ ,
2025-07-31 05:38:20 +09:00
) ;
} ) ;
it ( 'should list files from secondary workspace directory' , async ( ) = > {
2025-09-10 09:54:50 -07:00
await fs . writeFile (
path . join ( tempSecondaryDir , 'secondary-file.txt' ) ,
'secondary' ,
) ;
2025-07-31 05:38:20 +09:00
2025-11-06 15:03:52 -08:00
const invocation = lsTool . build ( { dir_path : tempSecondaryDir } ) ;
2025-09-10 09:54:50 -07:00
const result = await invocation . execute ( abortSignal ) ;
2025-07-31 05:38:20 +09:00
2025-09-10 09:54:50 -07:00
expect ( result . llmContent ) . toContain ( 'secondary-file.txt' ) ;
expect ( result . returnDisplay ) . toBe ( 'Listed 1 item(s).' ) ;
2025-07-31 05:38:20 +09:00
} ) ;
} ) ;
} ) ;