Download packages/core/src/tools/ls.test.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 15.7 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/tools/ls.test.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/core/src/tools/ls.test.ts
-
curl -L -o ls.test.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/tools/ls.test.ts
15.7 kB
| /** | |
| * @license | |
| * Copyright 2025 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; | |
| import fs from 'node:fs/promises'; | |
| import path from 'node:path'; | |
| import { isSubpath } from '../utils/paths.js'; | |
| import os from 'node:os'; | |
| import { LSTool } from './ls.js'; | |
| import type { Config } from '../config/config.js'; | |
| import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; | |
| import { ToolErrorType } from './tool-error.js'; | |
| import { WorkspaceContext } from '../utils/workspaceContext.js'; | |
| import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; | |
| import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js'; | |
| vi.mock('./jit-context.js', () => ({ | |
| discoverJitContext: vi.fn().mockResolvedValue(''), | |
| appendJitContext: vi.fn().mockImplementation((content, context) => { | |
| if (!context) return content; | |
| return `${content}\n\n--- Newly Discovered Project Context ---\n${context}\n--- End Project Context ---`; | |
| }), | |
| })); | |
| describe('LSTool', () => { | |
| let lsTool: LSTool; | |
| let tempRootDir: string; | |
| let tempSecondaryDir: string; | |
| let mockConfig: Config; | |
| const abortSignal = new AbortController().signal; | |
| beforeEach(async () => { | |
| const realTmp = await fs.realpath(os.tmpdir()); | |
| tempRootDir = await fs.mkdtemp(path.join(realTmp, 'ls-tool-root-')); | |
| tempSecondaryDir = await fs.mkdtemp( | |
| path.join(realTmp, 'ls-tool-secondary-'), | |
| ); | |
| const mockStorage = { | |
| getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'), | |
| }; | |
| mockConfig = { | |
| getTargetDir: () => tempRootDir, | |
| getWorkspaceContext: () => | |
| new WorkspaceContext(tempRootDir, [tempSecondaryDir]), | |
| getFileService: () => new FileDiscoveryService(tempRootDir), | |
| getFileFilteringOptions: () => ({ | |
| respectGitIgnore: true, | |
| respectGeminiIgnore: true, | |
| }), | |
| 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}`; | |
| }, | |
| } as unknown as Config; | |
| lsTool = new LSTool(mockConfig, createMockMessageBus()); | |
| }); | |
| afterEach(async () => { | |
| await fs.rm(tempRootDir, { recursive: true, force: true }); | |
| await fs.rm(tempSecondaryDir, { recursive: true, force: true }); | |
| }); | |
| describe('parameter validation', () => { | |
| it('should accept valid absolute paths within workspace', async () => { | |
| const testPath = path.join(tempRootDir, 'src'); | |
| await fs.mkdir(testPath); | |
| const invocation = lsTool.build({ dir_path: testPath }); | |
| expect(invocation).toBeDefined(); | |
| }); | |
| 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(); | |
| }); | |
| it('should reject paths outside workspace with clear error message', () => { | |
| expect(() => lsTool.build({ dir_path: '/etc/passwd' })).toThrow( | |
| /Path not in workspace: Attempted path ".*" resolves outside the allowed workspace directories: .*/, | |
| ); | |
| }); | |
| it('should accept paths in secondary workspace directory', async () => { | |
| const testPath = path.join(tempSecondaryDir, 'lib'); | |
| await fs.mkdir(testPath); | |
| const invocation = lsTool.build({ dir_path: testPath }); | |
| expect(invocation).toBeDefined(); | |
| }); | |
| }); | |
| describe('execute', () => { | |
| it('should list files in a directory', async () => { | |
| 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', | |
| ); | |
| const invocation = lsTool.build({ dir_path: tempRootDir }); | |
| const result = await invocation.execute({ abortSignal }); | |
| expect(result.llmContent).toContain('[DIR] subdir'); | |
| expect(result.llmContent).toContain('file1.txt'); | |
| expect(result.returnDisplay).toEqual({ | |
| summary: 'Found 2 item(s).', | |
| files: ['[DIR] subdir', 'file1.txt'], | |
| }); | |
| }); | |
| it('should list files from secondary workspace directory', async () => { | |
| 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', | |
| ); | |
| const invocation = lsTool.build({ dir_path: tempSecondaryDir }); | |
| const result = await invocation.execute({ abortSignal }); | |
| expect(result.llmContent).toContain('secondary-file.txt'); | |
| expect(result.returnDisplay).toEqual({ | |
| summary: 'Found 1 item(s).', | |
| files: expect.any(Array), | |
| }); | |
| }); | |
| it('should handle empty directories', async () => { | |
| const emptyDir = path.join(tempRootDir, 'empty'); | |
| await fs.mkdir(emptyDir); | |
| const invocation = lsTool.build({ dir_path: emptyDir }); | |
| const result = await invocation.execute({ abortSignal }); | |
| expect(result.llmContent).toBe(`Directory ${emptyDir} is empty.`); | |
| expect(result.returnDisplay).toBe('Directory is empty.'); | |
| }); | |
| it('should respect ignore patterns', async () => { | |
| await fs.writeFile(path.join(tempRootDir, 'file1.txt'), 'content1'); | |
| await fs.writeFile(path.join(tempRootDir, 'file2.log'), 'content1'); | |
| const invocation = lsTool.build({ | |
| dir_path: tempRootDir, | |
| ignore: ['*.log'], | |
| }); | |
| const result = await invocation.execute({ abortSignal }); | |
| expect(result.llmContent).toContain('file1.txt'); | |
| expect(result.llmContent).not.toContain('file2.log'); | |
| expect(result.returnDisplay).toEqual({ | |
| summary: 'Found 1 item(s).', | |
| files: expect.any(Array), | |
| }); | |
| }); | |
| it('should respect gitignore patterns', async () => { | |
| 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'); | |
| const invocation = lsTool.build({ dir_path: tempRootDir }); | |
| 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. | |
| expect(result.returnDisplay).toEqual( | |
| expect.objectContaining({ summary: 'Found 2 item(s). (2 ignored)' }), | |
| ); | |
| }); | |
| it('should respect geminiignore patterns', async () => { | |
| await fs.writeFile(path.join(tempRootDir, 'file1.txt'), 'content1'); | |
| await fs.writeFile(path.join(tempRootDir, 'file2.log'), 'content1'); | |
| await fs.writeFile( | |
| path.join(tempRootDir, GEMINI_IGNORE_FILE_NAME), | |
| '*.log', | |
| ); | |
| const invocation = lsTool.build({ dir_path: tempRootDir }); | |
| const result = await invocation.execute({ abortSignal }); | |
| expect(result.llmContent).toContain('file1.txt'); | |
| expect(result.llmContent).not.toContain('file2.log'); | |
| expect(result.returnDisplay).toEqual( | |
| expect.objectContaining({ summary: 'Found 2 item(s). (1 ignored)' }), | |
| ); | |
| }); | |
| it('should handle non-directory paths', async () => { | |
| const testPath = path.join(tempRootDir, 'file1.txt'); | |
| await fs.writeFile(testPath, 'content1'); | |
| const invocation = lsTool.build({ dir_path: testPath }); | |
| const result = await invocation.execute({ abortSignal }); | |
| expect(result.llmContent).toContain('Path is not a directory'); | |
| expect(result.returnDisplay).toBe('Error: Path is not a directory.'); | |
| expect(result.error?.type).toBe(ToolErrorType.PATH_IS_NOT_A_DIRECTORY); | |
| }); | |
| it('should handle non-existent paths', async () => { | |
| const testPath = path.join(tempRootDir, 'does-not-exist'); | |
| const invocation = lsTool.build({ dir_path: testPath }); | |
| const result = await invocation.execute({ abortSignal }); | |
| expect(result.llmContent).toContain('Error listing directory'); | |
| expect(result.returnDisplay).toBe('Error: Failed to list directory.'); | |
| expect(result.error?.type).toBe(ToolErrorType.LS_EXECUTION_ERROR); | |
| }); | |
| it('should sort directories first, then files alphabetically', async () => { | |
| 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')); | |
| const invocation = lsTool.build({ dir_path: tempRootDir }); | |
| const result = await invocation.execute({ abortSignal }); | |
| const lines = ( | |
| typeof result.llmContent === 'string' ? result.llmContent : '' | |
| ) | |
| .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'); | |
| expect(entries[2]).toBe('a-file.txt (8 bytes)'); | |
| expect(entries[3]).toBe('b-file.txt (8 bytes)'); | |
| }); | |
| it('should handle permission errors gracefully', async () => { | |
| const restrictedDir = path.join(tempRootDir, 'restricted'); | |
| await fs.mkdir(restrictedDir); | |
| // 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); | |
| const invocation = lsTool.build({ dir_path: restrictedDir }); | |
| const result = await invocation.execute({ abortSignal }); | |
| expect(result.llmContent).toContain('Error listing directory'); | |
| expect(result.llmContent).toContain('permission denied'); | |
| expect(result.returnDisplay).toBe('Error: Failed to list directory.'); | |
| expect(result.error?.type).toBe(ToolErrorType.LS_EXECUTION_ERROR); | |
| }); | |
| it('should handle errors accessing individual files during listing', async () => { | |
| 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'); | |
| } | |
| return originalStat(p); | |
| }); | |
| const invocation = lsTool.build({ dir_path: tempRootDir }); | |
| const result = await invocation.execute({ abortSignal }); | |
| // Should still list the other files | |
| expect(result.llmContent).toContain('file1.txt'); | |
| expect(result.llmContent).not.toContain('problematic.txt'); | |
| expect(result.returnDisplay).toEqual({ | |
| summary: 'Found 1 item(s).', | |
| files: expect.any(Array), | |
| }); | |
| statSpy.mockRestore(); | |
| }); | |
| }); | |
| describe('getDescription', () => { | |
| it('should return shortened relative path', () => { | |
| const deeplyNestedDir = path.join(tempRootDir, 'deeply', 'nested'); | |
| const params = { | |
| dir_path: path.join(deeplyNestedDir, 'directory'), | |
| }; | |
| const invocation = lsTool.build(params); | |
| const description = invocation.getDescription(); | |
| expect(description).toBe(path.join('deeply', 'nested', 'directory')); | |
| }); | |
| it('should handle paths in secondary workspace', () => { | |
| const params = { | |
| dir_path: path.join(tempSecondaryDir, 'lib'), | |
| }; | |
| const invocation = lsTool.build(params); | |
| const description = invocation.getDescription(); | |
| const expected = path.relative(tempRootDir, params.dir_path); | |
| expect(description).toBe(expected); | |
| }); | |
| }); | |
| describe('workspace boundary validation', () => { | |
| it('should accept paths in primary workspace directory', async () => { | |
| const testPath = path.join(tempRootDir, 'src'); | |
| await fs.mkdir(testPath); | |
| const params = { dir_path: testPath }; | |
| expect(lsTool.build(params)).toBeDefined(); | |
| }); | |
| it('should accept paths in secondary workspace directory', async () => { | |
| const testPath = path.join(tempSecondaryDir, 'lib'); | |
| await fs.mkdir(testPath); | |
| const params = { dir_path: testPath }; | |
| expect(lsTool.build(params)).toBeDefined(); | |
| }); | |
| it('should reject paths outside all workspace directories', () => { | |
| const params = { dir_path: '/etc/passwd' }; | |
| expect(() => lsTool.build(params)).toThrow( | |
| /Path not in workspace: Attempted path ".*" resolves outside the allowed workspace directories: .*/, | |
| ); | |
| }); | |
| it('should list files from secondary workspace directory', async () => { | |
| await fs.writeFile( | |
| path.join(tempSecondaryDir, 'secondary-file.txt'), | |
| 'secondary', | |
| ); | |
| const invocation = lsTool.build({ dir_path: tempSecondaryDir }); | |
| const result = await invocation.execute({ abortSignal }); | |
| expect(result.llmContent).toContain('secondary-file.txt'); | |
| expect(result.returnDisplay).toEqual({ | |
| summary: 'Found 1 item(s).', | |
| files: expect.any(Array), | |
| }); | |
| }); | |
| }); | |
| describe('JIT context discovery', () => { | |
| it('should append JIT context to output when enabled and context is found', async () => { | |
| const { discoverJitContext } = await import('./jit-context.js'); | |
| vi.mocked(discoverJitContext).mockResolvedValue('Use the useAuth hook.'); | |
| await fs.writeFile(path.join(tempRootDir, 'jit-file.txt'), 'content'); | |
| const invocation = lsTool.build({ dir_path: tempRootDir }); | |
| const result = await invocation.execute({ abortSignal }); | |
| expect(discoverJitContext).toHaveBeenCalled(); | |
| expect(result.llmContent).toContain('Newly Discovered Project Context'); | |
| expect(result.llmContent).toContain('Use the useAuth hook.'); | |
| }); | |
| it('should not append JIT context when disabled', async () => { | |
| const { discoverJitContext } = await import('./jit-context.js'); | |
| vi.mocked(discoverJitContext).mockResolvedValue(''); | |
| await fs.writeFile( | |
| path.join(tempRootDir, 'jit-disabled-file.txt'), | |
| 'content', | |
| ); | |
| const invocation = lsTool.build({ dir_path: tempRootDir }); | |
| const result = await invocation.execute({ abortSignal }); | |
| expect(result.llmContent).not.toContain( | |
| 'Newly Discovered Project Context', | |
| ); | |
| }); | |
| }); | |
| }); | |