2025-05-22 10:47:21 -07:00
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
2025-06-08 18:42:38 -07:00
/* eslint-disable @typescript-eslint/no-explicit-any */
2025-05-22 10:47:21 -07:00
import { describe , it , expect , vi , beforeEach , afterEach , Mock } from 'vitest' ;
import fsPromises from 'fs/promises' ;
2025-06-14 10:25:34 -04:00
import * as fs from 'fs' ;
2025-05-22 10:47:21 -07:00
import * as nodePath from 'path' ;
import { getFolderStructure } from './getFolderStructure.js' ;
2025-06-08 18:42:38 -07:00
import * as gitUtils from './gitUtils.js' ;
2025-06-13 14:26:31 -04:00
import { FileDiscoveryService } from '../services/fileDiscoveryService.js' ;
2025-05-22 10:47:21 -07:00
vi . mock ( 'path' , async ( importOriginal ) = > {
const original = ( await importOriginal ( ) ) as typeof nodePath ;
return {
. . . original ,
resolve : vi.fn ( ( str ) = > str ) ,
// Other path functions (basename, join, normalize, etc.) will use original implementation
} ;
} ) ;
vi . mock ( 'fs/promises' ) ;
2025-06-14 10:25:34 -04:00
vi . mock ( 'fs' ) ;
2025-06-08 18:42:38 -07:00
vi . mock ( './gitUtils.js' ) ;
2025-05-22 10:47:21 -07:00
// Import 'path' again here, it will be the mocked version
import * as path from 'path' ;
2025-07-20 16:51:18 +09:00
interface TestDirent {
name : string ;
isFile : ( ) = > boolean ;
isDirectory : ( ) = > boolean ;
isBlockDevice : ( ) = > boolean ;
isCharacterDevice : ( ) = > boolean ;
isSymbolicLink : ( ) = > boolean ;
isFIFO : ( ) = > boolean ;
isSocket : ( ) = > boolean ;
path : string ;
parentPath : string ;
}
2025-05-22 10:47:21 -07:00
// Helper to create Dirent-like objects for mocking fs.readdir
2025-07-20 16:51:18 +09:00
const createDirent = ( name : string , type : 'file' | 'dir' ) : TestDirent = > ( {
2025-05-22 10:47:21 -07:00
name ,
isFile : ( ) = > type === 'file' ,
isDirectory : ( ) = > type === 'dir' ,
isBlockDevice : ( ) = > false ,
isCharacterDevice : ( ) = > false ,
isSymbolicLink : ( ) = > false ,
isFIFO : ( ) = > false ,
isSocket : ( ) = > false ,
path : '' ,
2025-06-13 17:44:14 -07:00
parentPath : '' ,
2025-05-22 10:47:21 -07:00
} ) ;
describe ( 'getFolderStructure' , ( ) = > {
beforeEach ( ( ) = > {
vi . resetAllMocks ( ) ;
// path.resolve is now a vi.fn() due to the top-level vi.mock.
// We ensure its implementation is set for each test (or rely on the one from vi.mock).
// vi.resetAllMocks() clears call history but not the implementation set by vi.fn() in vi.mock.
// If we needed to change it per test, we would do it here:
( path . resolve as Mock ) . mockImplementation ( ( str : string ) = > str ) ;
// Re-apply/define the mock implementation for fsPromises.readdir for each test
( fsPromises . readdir as Mock ) . mockImplementation (
async ( dirPath : string | Buffer | URL ) = > {
// path.normalize here will use the mocked path module.
// Since normalize is spread from original, it should be the real one.
const normalizedPath = path . normalize ( dirPath . toString ( ) ) ;
if ( mockFsStructure [ normalizedPath ] ) {
return mockFsStructure [ normalizedPath ] ;
}
throw Object . assign (
new Error (
` ENOENT: no such file or directory, scandir ' ${ normalizedPath } ' ` ,
) ,
{ code : 'ENOENT' } ,
) ;
} ,
) ;
} ) ;
afterEach ( ( ) = > {
vi . restoreAllMocks ( ) ; // Restores spies (like fsPromises.readdir) and resets vi.fn mocks (like path.resolve)
} ) ;
2025-07-20 16:51:18 +09:00
const mockFsStructure : Record < string , TestDirent [ ] > = {
2025-05-22 10:47:21 -07:00
'/testroot' : [
createDirent ( 'file1.txt' , 'file' ) ,
createDirent ( 'subfolderA' , 'dir' ) ,
createDirent ( 'emptyFolder' , 'dir' ) ,
createDirent ( '.hiddenfile' , 'file' ) ,
createDirent ( 'node_modules' , 'dir' ) ,
] ,
'/testroot/subfolderA' : [
createDirent ( 'fileA1.ts' , 'file' ) ,
createDirent ( 'fileA2.js' , 'file' ) ,
createDirent ( 'subfolderB' , 'dir' ) ,
] ,
'/testroot/subfolderA/subfolderB' : [ createDirent ( 'fileB1.md' , 'file' ) ] ,
'/testroot/emptyFolder' : [ ] ,
'/testroot/node_modules' : [ createDirent ( 'somepackage' , 'dir' ) ] ,
'/testroot/manyFilesFolder' : Array . from ( { length : 10 } , ( _ , i ) = >
createDirent ( ` file- ${ i } .txt ` , 'file' ) ,
) ,
'/testroot/manyFolders' : Array . from ( { length : 5 } , ( _ , i ) = >
createDirent ( ` folder- ${ i } ` , 'dir' ) ,
) ,
. . . Array . from ( { length : 5 } , ( _ , i ) = > ( {
[ ` /testroot/manyFolders/folder- ${ i } ` ] : [
createDirent ( 'child.txt' , 'file' ) ,
] ,
} ) ) . reduce ( ( acc , val ) = > ( { . . . acc , . . . val } ) , { } ) ,
'/testroot/deepFolders' : [ createDirent ( 'level1' , 'dir' ) ] ,
'/testroot/deepFolders/level1' : [ createDirent ( 'level2' , 'dir' ) ] ,
'/testroot/deepFolders/level1/level2' : [ createDirent ( 'level3' , 'dir' ) ] ,
'/testroot/deepFolders/level1/level2/level3' : [
createDirent ( 'file.txt' , 'file' ) ,
] ,
} ;
it ( 'should return basic folder structure' , async ( ) = > {
const structure = await getFolderStructure ( '/testroot/subfolderA' ) ;
const expected = `
Showing up to 200 items (files + folders).
/testroot/subfolderA/
├───fileA1.ts
├───fileA2.js
└───subfolderB/
└───fileB1.md
` . trim ( ) ;
expect ( structure . trim ( ) ) . toBe ( expected ) ;
} ) ;
it ( 'should handle an empty folder' , async ( ) = > {
const structure = await getFolderStructure ( '/testroot/emptyFolder' ) ;
const expected = `
Showing up to 200 items (files + folders).
/testroot/emptyFolder/
` . trim ( ) ;
expect ( structure . trim ( ) ) . toBe ( expected . trim ( ) ) ;
} ) ;
it ( 'should ignore folders specified in ignoredFolders (default)' , async ( ) = > {
const structure = await getFolderStructure ( '/testroot' ) ;
const expected = `
Showing up to 200 items (files + folders). Folders or files indicated with ... contain more items not shown, were ignored, or the display limit (200 items) was reached.
/testroot/
├───.hiddenfile
├───file1.txt
├───emptyFolder/
├───node_modules/...
└───subfolderA/
├───fileA1.ts
├───fileA2.js
└───subfolderB/
└───fileB1.md
` . trim ( ) ;
expect ( structure . trim ( ) ) . toBe ( expected ) ;
} ) ;
it ( 'should ignore folders specified in custom ignoredFolders' , async ( ) = > {
const structure = await getFolderStructure ( '/testroot' , {
ignoredFolders : new Set ( [ 'subfolderA' , 'node_modules' ] ) ,
} ) ;
const expected = `
Showing up to 200 items (files + folders). Folders or files indicated with ... contain more items not shown, were ignored, or the display limit (200 items) was reached.
/testroot/
├───.hiddenfile
├───file1.txt
├───emptyFolder/
├───node_modules/...
└───subfolderA/...
` . trim ( ) ;
expect ( structure . trim ( ) ) . toBe ( expected ) ;
} ) ;
it ( 'should filter files by fileIncludePattern' , async ( ) = > {
const structure = await getFolderStructure ( '/testroot/subfolderA' , {
fileIncludePattern : /\.ts$/ ,
} ) ;
const expected = `
Showing up to 200 items (files + folders).
/testroot/subfolderA/
├───fileA1.ts
└───subfolderB/
` . trim ( ) ;
expect ( structure . trim ( ) ) . toBe ( expected ) ;
} ) ;
it ( 'should handle maxItems truncation for files within a folder' , async ( ) = > {
const structure = await getFolderStructure ( '/testroot/subfolderA' , {
maxItems : 3 ,
} ) ;
const expected = `
Showing up to 3 items (files + folders).
/testroot/subfolderA/
├───fileA1.ts
├───fileA2.js
└───subfolderB/
` . trim ( ) ;
expect ( structure . trim ( ) ) . toBe ( expected ) ;
} ) ;
it ( 'should handle maxItems truncation for subfolders' , async ( ) = > {
const structure = await getFolderStructure ( '/testroot/manyFolders' , {
maxItems : 4 ,
} ) ;
const expectedRevised = `
Showing up to 4 items (files + folders). Folders or files indicated with ... contain more items not shown, were ignored, or the display limit (4 items) was reached.
/testroot/manyFolders/
├───folder-0/
├───folder-1/
├───folder-2/
├───folder-3/
└───...
` . trim ( ) ;
expect ( structure . trim ( ) ) . toBe ( expectedRevised ) ;
} ) ;
it ( 'should handle maxItems that only allows the root folder itself' , async ( ) = > {
const structure = await getFolderStructure ( '/testroot/subfolderA' , {
maxItems : 1 ,
} ) ;
const expectedRevisedMax1 = `
Showing up to 1 items (files + folders). Folders or files indicated with ... contain more items not shown, were ignored, or the display limit (1 items) was reached.
/testroot/subfolderA/
├───fileA1.ts
├───...
└───...
` . trim ( ) ;
expect ( structure . trim ( ) ) . toBe ( expectedRevisedMax1 ) ;
} ) ;
2025-07-21 17:54:44 -04:00
it ( 'should handle nonexistent directory' , async ( ) = > {
2025-05-22 10:47:21 -07:00
// Temporarily make fsPromises.readdir throw ENOENT for this specific path
const originalReaddir = fsPromises . readdir ;
( fsPromises . readdir as Mock ) . mockImplementation (
async ( p : string | Buffer | URL ) = > {
if ( p === '/nonexistent' ) {
throw Object . assign ( new Error ( 'ENOENT' ) , { code : 'ENOENT' } ) ;
}
return originalReaddir ( p ) ;
} ,
) ;
const structure = await getFolderStructure ( '/nonexistent' ) ;
expect ( structure ) . toContain (
'Error: Could not read directory "/nonexistent"' ,
) ;
} ) ;
it ( 'should handle deep folder structure within limits' , async ( ) = > {
const structure = await getFolderStructure ( '/testroot/deepFolders' , {
maxItems : 10 ,
} ) ;
const expected = `
Showing up to 10 items (files + folders).
/testroot/deepFolders/
└───level1/
└───level2/
└───level3/
└───file.txt
` . trim ( ) ;
expect ( structure . trim ( ) ) . toBe ( expected ) ;
} ) ;
it ( 'should truncate deep folder structure if maxItems is small' , async ( ) = > {
const structure = await getFolderStructure ( '/testroot/deepFolders' , {
maxItems : 3 ,
} ) ;
const expected = `
Showing up to 3 items (files + folders).
/testroot/deepFolders/
└───level1/
└───level2/
└───level3/
` . trim ( ) ;
expect ( structure . trim ( ) ) . toBe ( expected ) ;
} ) ;
} ) ;
2025-06-08 18:42:38 -07:00
describe ( 'getFolderStructure gitignore' , ( ) = > {
beforeEach ( ( ) = > {
vi . resetAllMocks ( ) ;
( path . resolve as Mock ) . mockImplementation ( ( str : string ) = > str ) ;
( fsPromises . readdir as Mock ) . mockImplementation ( async ( p ) = > {
const path = p . toString ( ) ;
if ( path === '/test/project' ) {
return [
createDirent ( 'file1.txt' , 'file' ) ,
createDirent ( 'node_modules' , 'dir' ) ,
createDirent ( 'ignored.txt' , 'file' ) ,
2025-07-20 00:55:33 -07:00
createDirent ( 'gem_ignored.txt' , 'file' ) ,
2025-06-08 18:42:38 -07:00
createDirent ( '.gemini' , 'dir' ) ,
] as any ;
}
if ( path === '/test/project/node_modules' ) {
return [ createDirent ( 'some-package' , 'dir' ) ] as any ;
}
if ( path === '/test/project/.gemini' ) {
return [
createDirent ( 'config.yaml' , 'file' ) ,
createDirent ( 'logs.json' , 'file' ) ,
] as any ;
}
return [ ] ;
} ) ;
2025-06-14 10:25:34 -04:00
( fs . readFileSync as Mock ) . mockImplementation ( ( p ) = > {
2025-06-08 18:42:38 -07:00
const path = p . toString ( ) ;
if ( path === '/test/project/.gitignore' ) {
return 'ignored.txt\nnode_modules/\n.gemini/\n!/.gemini/config.yaml' ;
}
2025-07-20 00:55:33 -07:00
if ( path === '/test/project/.geminiignore' ) {
return 'gem_ignored.txt\nnode_modules/\n.gemini/\n!/.gemini/config.yaml' ;
}
2025-06-08 18:42:38 -07:00
return '' ;
} ) ;
vi . mocked ( gitUtils . isGitRepository ) . mockReturnValue ( true ) ;
} ) ;
it ( 'should ignore files and folders specified in .gitignore' , async ( ) = > {
2025-06-13 14:26:31 -04:00
const fileService = new FileDiscoveryService ( '/test/project' ) ;
2025-06-08 18:42:38 -07:00
const structure = await getFolderStructure ( '/test/project' , {
2025-06-13 14:26:31 -04:00
fileService ,
2025-06-08 18:42:38 -07:00
} ) ;
expect ( structure ) . not . toContain ( 'ignored.txt' ) ;
expect ( structure ) . toContain ( 'node_modules/...' ) ;
expect ( structure ) . not . toContain ( 'logs.json' ) ;
} ) ;
it ( 'should not ignore files if respectGitIgnore is false' , async ( ) = > {
2025-06-13 14:26:31 -04:00
const fileService = new FileDiscoveryService ( '/test/project' ) ;
2025-06-08 18:42:38 -07:00
const structure = await getFolderStructure ( '/test/project' , {
2025-06-13 14:26:31 -04:00
fileService ,
2025-07-20 00:55:33 -07:00
fileFilteringOptions : {
respectGeminiIgnore : false ,
respectGitIgnore : false ,
} ,
2025-06-08 18:42:38 -07:00
} ) ;
expect ( structure ) . toContain ( 'ignored.txt' ) ;
// node_modules is still ignored by default
expect ( structure ) . toContain ( 'node_modules/...' ) ;
} ) ;
2025-07-20 00:55:33 -07:00
it ( 'should ignore files and folders specified in .geminiignore' , async ( ) = > {
const fileService = new FileDiscoveryService ( '/test/project' ) ;
const structure = await getFolderStructure ( '/test/project' , {
fileService ,
} ) ;
expect ( structure ) . not . toContain ( 'gem_ignored.txt' ) ;
expect ( structure ) . toContain ( 'node_modules/...' ) ;
expect ( structure ) . not . toContain ( 'logs.json' ) ;
} ) ;
it ( 'should not ignore files if respectGeminiIgnore is false' , async ( ) = > {
const fileService = new FileDiscoveryService ( '/test/project' ) ;
const structure = await getFolderStructure ( '/test/project' , {
fileService ,
fileFilteringOptions : {
respectGeminiIgnore : false ,
respectGitIgnore : true , // Explicitly disable gemini ignore only
} ,
} ) ;
expect ( structure ) . toContain ( 'gem_ignored.txt' ) ;
// node_modules is still ignored by default
expect ( structure ) . toContain ( 'node_modules/...' ) ;
} ) ;
2025-06-08 18:42:38 -07:00
} ) ;