/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ReadFileTool, ReadFileToolParams } from './read-file.js'; import path from 'path'; import os from 'os'; import fs from 'fs'; import fsp from 'fs/promises'; import { Config } from '../config/config.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; import { ToolInvocation, ToolResult } from './tools.js'; describe('ReadFileTool', () => { let tempRootDir: string; let tool: ReadFileTool; const abortSignal = new AbortController().signal; beforeEach(async () => { // Create a unique temporary root directory for each test run tempRootDir = await fsp.mkdtemp( path.join(os.tmpdir(), 'read-file-tool-root-'), ); const mockConfigInstance = { getFileService: () => new FileDiscoveryService(tempRootDir), getTargetDir: () => tempRootDir, getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), } as unknown as Config; tool = new ReadFileTool(mockConfigInstance); }); afterEach(async () => { // Clean up the temporary root directory if (fs.existsSync(tempRootDir)) { await fsp.rm(tempRootDir, { recursive: true, force: true }); } }); describe('build', () => { it('should return an invocation for valid params (absolute path within root)', () => { const params: ReadFileToolParams = { absolute_path: path.join(tempRootDir, 'test.txt'), }; const result = tool.build(params); expect(result).not.toBeTypeOf('string'); expect(typeof result).toBe('object'); expect( (result as ToolInvocation).params, ).toEqual(params); }); it('should return an invocation for valid params with offset and limit', () => { const params: ReadFileToolParams = { absolute_path: path.join(tempRootDir, 'test.txt'), offset: 0, limit: 10, }; const result = tool.build(params); expect(result).not.toBeTypeOf('string'); }); it('should throw error for relative path', () => { const params: ReadFileToolParams = { absolute_path: 'test.txt' }; expect(() => tool.build(params)).toThrow( `File path must be absolute, but was relative: test.txt. You must provide an absolute path.`, ); }); it('should throw error for path outside root', () => { const outsidePath = path.resolve(os.tmpdir(), 'outside-root.txt'); const params: ReadFileToolParams = { absolute_path: outsidePath }; expect(() => tool.build(params)).toThrow( 'File path must be within one of the workspace directories', ); }); it('should throw error for negative offset', () => { const params: ReadFileToolParams = { absolute_path: path.join(tempRootDir, 'test.txt'), offset: -1, limit: 10, }; expect(() => tool.build(params)).toThrow( 'Offset must be a non-negative number', ); }); it('should throw error for non-positive limit', () => { const paramsZero: ReadFileToolParams = { absolute_path: path.join(tempRootDir, 'test.txt'), offset: 0, limit: 0, }; expect(() => tool.build(paramsZero)).toThrow( 'Limit must be a positive number', ); const paramsNegative: ReadFileToolParams = { absolute_path: path.join(tempRootDir, 'test.txt'), offset: 0, limit: -5, }; expect(() => tool.build(paramsNegative)).toThrow( 'Limit must be a positive number', ); }); it('should throw error for schema validation failure (e.g. missing path)', () => { const params = { offset: 0 } as unknown as ReadFileToolParams; expect(() => tool.build(params)).toThrow( `params must have required property 'absolute_path'`, ); }); }); describe('ToolInvocation', () => { describe('getDescription', () => { it('should return a shortened, relative path', () => { const filePath = path.join(tempRootDir, 'sub', 'dir', 'file.txt'); const params: ReadFileToolParams = { absolute_path: filePath }; const invocation = tool.build(params); expect(typeof invocation).not.toBe('string'); expect( ( invocation as ToolInvocation ).getDescription(), ).toBe(path.join('sub', 'dir', 'file.txt')); }); it('should return . if path is the root directory', () => { const params: ReadFileToolParams = { absolute_path: tempRootDir }; const invocation = tool.build(params); expect(typeof invocation).not.toBe('string'); expect( ( invocation as ToolInvocation ).getDescription(), ).toBe('.'); }); }); describe('execute', () => { it('should return error if file does not exist', async () => { const filePath = path.join(tempRootDir, 'nonexistent.txt'); const params: ReadFileToolParams = { absolute_path: filePath }; const invocation = tool.build(params) as ToolInvocation< ReadFileToolParams, ToolResult >; expect(await invocation.execute(abortSignal)).toEqual({ llmContent: `File not found: ${filePath}`, returnDisplay: 'File not found.', }); }); it('should return success result for a text file', async () => { const filePath = path.join(tempRootDir, 'textfile.txt'); const fileContent = 'This is a test file.'; await fsp.writeFile(filePath, fileContent, 'utf-8'); const params: ReadFileToolParams = { absolute_path: filePath }; const invocation = tool.build(params) as ToolInvocation< ReadFileToolParams, ToolResult >; expect(await invocation.execute(abortSignal)).toEqual({ llmContent: fileContent, returnDisplay: '', }); }); it('should return success result for an image file', async () => { // A minimal 1x1 transparent PNG file. const pngContent = Buffer.from([ 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 10, 73, 68, 65, 84, 120, 156, 99, 0, 1, 0, 0, 5, 0, 1, 13, 10, 45, 180, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, ]); const filePath = path.join(tempRootDir, 'image.png'); await fsp.writeFile(filePath, pngContent); const params: ReadFileToolParams = { absolute_path: filePath }; const invocation = tool.build(params) as ToolInvocation< ReadFileToolParams, ToolResult >; expect(await invocation.execute(abortSignal)).toEqual({ llmContent: { inlineData: { mimeType: 'image/png', data: pngContent.toString('base64'), }, }, returnDisplay: `Read image file: image.png`, }); }); it('should treat a non-image file with image extension as an image', async () => { const filePath = path.join(tempRootDir, 'fake-image.png'); const fileContent = 'This is not a real png.'; await fsp.writeFile(filePath, fileContent, 'utf-8'); const params: ReadFileToolParams = { absolute_path: filePath }; const invocation = tool.build(params) as ToolInvocation< ReadFileToolParams, ToolResult >; expect(await invocation.execute(abortSignal)).toEqual({ llmContent: { inlineData: { mimeType: 'image/png', data: Buffer.from(fileContent).toString('base64'), }, }, returnDisplay: `Read image file: fake-image.png`, }); }); it('should pass offset and limit to read a slice of a text file', async () => { const filePath = path.join(tempRootDir, 'paginated.txt'); const fileContent = Array.from( { length: 20 }, (_, i) => `Line ${i + 1}`, ).join('\n'); await fsp.writeFile(filePath, fileContent, 'utf-8'); const params: ReadFileToolParams = { absolute_path: filePath, offset: 5, // Start from line 6 limit: 3, }; const invocation = tool.build(params) as ToolInvocation< ReadFileToolParams, ToolResult >; expect(await invocation.execute(abortSignal)).toEqual({ llmContent: [ '[File content truncated: showing lines 6-8 of 20 total lines. Use offset/limit parameters to view more.]', 'Line 6', 'Line 7', 'Line 8', ].join('\n'), returnDisplay: 'Read lines 6-8 of 20 from paginated.txt', }); }); describe('with .geminiignore', () => { beforeEach(async () => { await fsp.writeFile( path.join(tempRootDir, '.geminiignore'), ['foo.*', 'ignored/'].join('\n'), ); }); it('should throw error if path is ignored by a .geminiignore pattern', async () => { const ignoredFilePath = path.join(tempRootDir, 'foo.bar'); await fsp.writeFile(ignoredFilePath, 'content', 'utf-8'); const params: ReadFileToolParams = { absolute_path: ignoredFilePath, }; const expectedError = `File path '${ignoredFilePath}' is ignored by .geminiignore pattern(s).`; expect(() => tool.build(params)).toThrow(expectedError); }); it('should throw error if path is in an ignored directory', async () => { const ignoredDirPath = path.join(tempRootDir, 'ignored'); await fsp.mkdir(ignoredDirPath); const filePath = path.join(ignoredDirPath, 'somefile.txt'); await fsp.writeFile(filePath, 'content', 'utf-8'); const params: ReadFileToolParams = { absolute_path: filePath, }; const expectedError = `File path '${filePath}' is ignored by .geminiignore pattern(s).`; expect(() => tool.build(params)).toThrow(expectedError); }); }); }); }); describe('workspace boundary validation', () => { it('should validate paths are within workspace root', () => { const params: ReadFileToolParams = { absolute_path: path.join(tempRootDir, 'file.txt'), }; expect(() => tool.build(params)).not.toThrow(); }); it('should reject paths outside workspace root', () => { const params: ReadFileToolParams = { absolute_path: '/etc/passwd', }; expect(() => tool.build(params)).toThrow( 'File path must be within one of the workspace directories', ); }); it('should provide clear error message with workspace directories', () => { const outsidePath = path.join(os.tmpdir(), 'outside-workspace.txt'); const params: ReadFileToolParams = { absolute_path: outsidePath, }; expect(() => tool.build(params)).toThrow( 'File path must be within one of the workspace directories', ); }); }); });