|
|
@@ -1,14 +1,33 @@
|
|
|
-// npx vitest src/core/tools/__tests__/readFileTool.spec.ts
|
|
|
+/**
|
|
|
+ * Tests for ReadFileTool - Codex-inspired file reading with indentation mode support.
|
|
|
+ *
|
|
|
+ * These tests cover:
|
|
|
+ * - Input validation (missing path parameter)
|
|
|
+ * - RooIgnore blocking
|
|
|
+ * - Directory read error handling
|
|
|
+ * - Binary file handling (images, PDF, DOCX, unsupported)
|
|
|
+ * - Image memory limits
|
|
|
+ * - Approval flow (approve, deny, feedback)
|
|
|
+ * - Text file processing (slice and indentation modes)
|
|
|
+ * - Output structure formatting
|
|
|
+ */
|
|
|
+
|
|
|
+import path from "path"
|
|
|
|
|
|
-import * as path from "path"
|
|
|
-
|
|
|
-import { countFileLines } from "../../../integrations/misc/line-counter"
|
|
|
-import { readLines } from "../../../integrations/misc/read-lines"
|
|
|
-import { extractTextFromFile } from "../../../integrations/misc/extract-text"
|
|
|
-import { parseSourceCodeDefinitionsForFile } from "../../../services/tree-sitter"
|
|
|
import { isBinaryFile } from "isbinaryfile"
|
|
|
-import { ReadFileToolUse, ToolResponse } from "../../../shared/tools"
|
|
|
-import { readFileTool } from "../ReadFileTool"
|
|
|
+
|
|
|
+import { readFileTool, ReadFileTool } from "../ReadFileTool"
|
|
|
+import { formatResponse } from "../../prompts/responses"
|
|
|
+import {
|
|
|
+ validateImageForProcessing,
|
|
|
+ processImageFile,
|
|
|
+ isSupportedImageFormat,
|
|
|
+ ImageMemoryTracker,
|
|
|
+} from "../helpers/imageHelpers"
|
|
|
+import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../../integrations/misc/extract-text"
|
|
|
+import { readWithIndentation, readWithSlice } from "../../../integrations/misc/indentation-reader"
|
|
|
+
|
|
|
+// ─── Mocks ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
vi.mock("path", async () => {
|
|
|
const originalPath = await vi.importActual("path")
|
|
|
@@ -19,76 +38,39 @@ vi.mock("path", async () => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
-// Already mocked above with hoisted fsPromises
|
|
|
-
|
|
|
-vi.mock("isbinaryfile")
|
|
|
-
|
|
|
-vi.mock("../../../integrations/misc/line-counter")
|
|
|
-vi.mock("../../../integrations/misc/read-lines")
|
|
|
-
|
|
|
-// Mock fs/promises readFile for image tests
|
|
|
-const fsPromises = vi.hoisted(() => ({
|
|
|
+vi.mock("fs/promises", () => ({
|
|
|
readFile: vi.fn(),
|
|
|
- stat: vi.fn().mockResolvedValue({ size: 1024 }),
|
|
|
+ stat: vi.fn(),
|
|
|
}))
|
|
|
-vi.mock("fs/promises", () => fsPromises)
|
|
|
-
|
|
|
-// Mock input content for tests
|
|
|
-let mockInputContent = ""
|
|
|
|
|
|
-// Create hoisted mocks that can be used in vi.mock factories
|
|
|
-const { addLineNumbersMock, mockReadFileWithTokenBudget } = vi.hoisted(() => {
|
|
|
- const addLineNumbersMock = vi.fn().mockImplementation((text: string, startLine = 1) => {
|
|
|
- if (!text) return ""
|
|
|
- const lines = typeof text === "string" ? text.split("\n") : [text]
|
|
|
- return lines.map((line: string, i: number) => `${startLine + i} | ${line}`).join("\n")
|
|
|
- })
|
|
|
- const mockReadFileWithTokenBudget = vi.fn()
|
|
|
- return { addLineNumbersMock, mockReadFileWithTokenBudget }
|
|
|
-})
|
|
|
+vi.mock("isbinaryfile")
|
|
|
|
|
|
-// First create all the mocks
|
|
|
vi.mock("../../../integrations/misc/extract-text", () => ({
|
|
|
extractTextFromFile: vi.fn(),
|
|
|
- addLineNumbers: addLineNumbersMock,
|
|
|
+ addLineNumbers: vi.fn().mockImplementation((text: string, startLine = 1) => {
|
|
|
+ if (!text) return ""
|
|
|
+ const lines = text.split("\n")
|
|
|
+ return lines.map((line, i) => `${startLine + i} | ${line}`).join("\n")
|
|
|
+ }),
|
|
|
getSupportedBinaryFormats: vi.fn(() => [".pdf", ".docx", ".ipynb"]),
|
|
|
}))
|
|
|
-vi.mock("../../../services/tree-sitter")
|
|
|
|
|
|
-// Mock readFileWithTokenBudget - must be mocked to prevent actual file system access
|
|
|
-vi.mock("../../../integrations/misc/read-file-with-budget", () => ({
|
|
|
- readFileWithTokenBudget: (...args: any[]) => mockReadFileWithTokenBudget(...args),
|
|
|
+vi.mock("../../../integrations/misc/indentation-reader", () => ({
|
|
|
+ readWithIndentation: vi.fn(),
|
|
|
+ readWithSlice: vi.fn(),
|
|
|
}))
|
|
|
|
|
|
-const extractTextFromFileMock = vi.fn()
|
|
|
-const getSupportedBinaryFormatsMock = vi.fn(() => [".pdf", ".docx", ".ipynb"])
|
|
|
-
|
|
|
-// Mock formatResponse - use vi.hoisted to ensure mocks are available before vi.mock
|
|
|
-const { toolResultMock, imageBlocksMock } = vi.hoisted(() => {
|
|
|
- const toolResultMock = vi.fn((text: string, images?: string[]) => {
|
|
|
- if (images && images.length > 0) {
|
|
|
- return [
|
|
|
- { type: "text", text },
|
|
|
- ...images.map((img) => {
|
|
|
- const [header, data] = img.split(",")
|
|
|
- const media_type = header.match(/:(.*?);/)?.[1] || "image/png"
|
|
|
- return { type: "image", source: { type: "base64", media_type, data } }
|
|
|
- }),
|
|
|
- ]
|
|
|
- }
|
|
|
- return text
|
|
|
- })
|
|
|
- const imageBlocksMock = vi.fn((images?: string[]) => {
|
|
|
- return images
|
|
|
- ? images.map((img) => {
|
|
|
- const [header, data] = img.split(",")
|
|
|
- const media_type = header.match(/:(.*?);/)?.[1] || "image/png"
|
|
|
- return { type: "image", source: { type: "base64", media_type, data } }
|
|
|
- })
|
|
|
- : []
|
|
|
- })
|
|
|
- return { toolResultMock, imageBlocksMock }
|
|
|
-})
|
|
|
+vi.mock("../helpers/imageHelpers", () => ({
|
|
|
+ DEFAULT_MAX_IMAGE_FILE_SIZE_MB: 5,
|
|
|
+ DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB: 20,
|
|
|
+ isSupportedImageFormat: vi.fn(),
|
|
|
+ validateImageForProcessing: vi.fn(),
|
|
|
+ processImageFile: vi.fn(),
|
|
|
+ ImageMemoryTracker: vi.fn().mockImplementation(() => ({
|
|
|
+ getTotalMemoryUsed: vi.fn().mockReturnValue(0),
|
|
|
+ addMemoryUsage: vi.fn(),
|
|
|
+ })),
|
|
|
+}))
|
|
|
|
|
|
vi.mock("../../prompts/responses", () => ({
|
|
|
formatResponse: {
|
|
|
@@ -102,1904 +84,650 @@ vi.mock("../../prompts/responses", () => ({
|
|
|
`The user approved this operation and responded with the message:\n<user_message>\n${feedback}\n</user_message>`,
|
|
|
),
|
|
|
rooIgnoreError: vi.fn(
|
|
|
- (path: string) =>
|
|
|
- `Access to ${path} is blocked by the .rooignore file settings. You must try to continue in the task without using this file, or ask the user to update the .rooignore file.`,
|
|
|
+ (filePath: string) =>
|
|
|
+ `Access to ${filePath} is blocked by the .rooignore file settings. You must try to continue in the task without using this file, or ask the user to update the .rooignore file.`,
|
|
|
),
|
|
|
- toolResult: toolResultMock,
|
|
|
- imageBlocks: imageBlocksMock,
|
|
|
- },
|
|
|
-}))
|
|
|
-
|
|
|
-vi.mock("../../ignore/RooIgnoreController", () => ({
|
|
|
- RooIgnoreController: class {
|
|
|
- initialize() {
|
|
|
- return Promise.resolve()
|
|
|
- }
|
|
|
- validateAccess() {
|
|
|
- return true
|
|
|
- }
|
|
|
+ toolResult: vi.fn((text: string, images?: string[]) => {
|
|
|
+ if (images && images.length > 0) {
|
|
|
+ return [
|
|
|
+ { type: "text", text },
|
|
|
+ ...images.map((img) => {
|
|
|
+ const [header, data] = img.split(",")
|
|
|
+ const media_type = header.match(/:(.*?);/)?.[1] || "image/png"
|
|
|
+ return { type: "image", source: { type: "base64", media_type, data } }
|
|
|
+ }),
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ return text
|
|
|
+ }),
|
|
|
+ imageBlocks: vi.fn((images?: string[]) => {
|
|
|
+ return images
|
|
|
+ ? images.map((img) => {
|
|
|
+ const [header, data] = img.split(",")
|
|
|
+ const media_type = header.match(/:(.*?);/)?.[1] || "image/png"
|
|
|
+ return { type: "image", source: { type: "base64", media_type, data } }
|
|
|
+ })
|
|
|
+ : []
|
|
|
+ }),
|
|
|
},
|
|
|
}))
|
|
|
|
|
|
-vi.mock("../../../utils/fs", () => ({
|
|
|
- fileExistsAtPath: vi.fn().mockReturnValue(true),
|
|
|
-}))
|
|
|
-
|
|
|
-// Global beforeEach to ensure clean mock state between all test suites
|
|
|
-beforeEach(() => {
|
|
|
- // NOTE: Removed vi.clearAllMocks() to prevent interference with setImageSupport calls
|
|
|
- // Instead, individual suites clear their specific mocks to maintain isolation
|
|
|
-
|
|
|
- // Explicitly reset the hoisted mock implementations to prevent cross-suite pollution
|
|
|
- toolResultMock.mockImplementation((text: string, images?: string[]) => {
|
|
|
- if (images && images.length > 0) {
|
|
|
- return [
|
|
|
- { type: "text", text },
|
|
|
- ...images.map((img) => {
|
|
|
- const [header, data] = img.split(",")
|
|
|
- const media_type = header.match(/:(.*?);/)?.[1] || "image/png"
|
|
|
- return { type: "image", source: { type: "base64", media_type, data } }
|
|
|
- }),
|
|
|
- ]
|
|
|
- }
|
|
|
- return text
|
|
|
- })
|
|
|
-
|
|
|
- imageBlocksMock.mockImplementation((images?: string[]) => {
|
|
|
- return images
|
|
|
- ? images.map((img) => {
|
|
|
- const [header, data] = img.split(",")
|
|
|
- const media_type = header.match(/:(.*?);/)?.[1] || "image/png"
|
|
|
- return { type: "image", source: { type: "base64", media_type, data } }
|
|
|
- })
|
|
|
- : []
|
|
|
- })
|
|
|
-
|
|
|
- // Reset addLineNumbers mock to its default implementation (prevents cross-test pollution)
|
|
|
- addLineNumbersMock.mockReset()
|
|
|
- addLineNumbersMock.mockImplementation((text: string, startLine = 1) => {
|
|
|
- if (!text) return ""
|
|
|
- const lines = typeof text === "string" ? text.split("\n") : [text]
|
|
|
- return lines.map((line: string, i: number) => `${startLine + i} | ${line}`).join("\n")
|
|
|
- })
|
|
|
-
|
|
|
- // Reset readFileWithTokenBudget mock with default implementation
|
|
|
- mockReadFileWithTokenBudget.mockClear()
|
|
|
- mockReadFileWithTokenBudget.mockImplementation(async (_filePath: string, _options: any) => {
|
|
|
- // Default: return the mockInputContent with 5 lines
|
|
|
- const lines = mockInputContent ? mockInputContent.split("\n") : []
|
|
|
- return {
|
|
|
- content: mockInputContent,
|
|
|
- tokenCount: mockInputContent.length / 4, // rough estimate
|
|
|
- lineCount: lines.length,
|
|
|
- complete: true,
|
|
|
- }
|
|
|
- })
|
|
|
-})
|
|
|
-
|
|
|
-// Mock i18n translation function
|
|
|
-vi.mock("../../../i18n", () => ({
|
|
|
- t: vi.fn((key: string, params?: Record<string, any>) => {
|
|
|
- // Map translation keys to English text
|
|
|
- const translations: Record<string, string> = {
|
|
|
- "tools:readFile.imageWithSize": "Image file ({{size}} KB)",
|
|
|
- "tools:readFile.imageTooLarge":
|
|
|
- "Image file is too large ({{size}}). The maximum allowed size is {{max}} MB.",
|
|
|
- "tools:readFile.linesRange": " (lines {{start}}-{{end}})",
|
|
|
- "tools:readFile.definitionsOnly": " (definitions only)",
|
|
|
- "tools:readFile.maxLines": " (max {{max}} lines)",
|
|
|
- }
|
|
|
-
|
|
|
- let result = translations[key] || key
|
|
|
-
|
|
|
- // Simple template replacement
|
|
|
- if (params) {
|
|
|
- Object.entries(params).forEach(([param, value]) => {
|
|
|
- result = result.replace(new RegExp(`{{${param}}}`, "g"), String(value))
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- return result
|
|
|
- }),
|
|
|
-}))
|
|
|
+// Mock fs/promises
|
|
|
+const fsPromises = await import("fs/promises")
|
|
|
+const mockedFsReadFile = vi.mocked(fsPromises.readFile)
|
|
|
+const mockedFsStat = vi.mocked(fsPromises.stat)
|
|
|
+
|
|
|
+const mockedIsBinaryFile = vi.mocked(isBinaryFile)
|
|
|
+const mockedExtractTextFromFile = vi.mocked(extractTextFromFile)
|
|
|
+const mockedReadWithSlice = vi.mocked(readWithSlice)
|
|
|
+const mockedReadWithIndentation = vi.mocked(readWithIndentation)
|
|
|
+const mockedIsSupportedImageFormat = vi.mocked(isSupportedImageFormat)
|
|
|
+const mockedValidateImageForProcessing = vi.mocked(validateImageForProcessing)
|
|
|
+const mockedProcessImageFile = vi.mocked(processImageFile)
|
|
|
+
|
|
|
+// ─── Test Helpers ─────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+interface MockTaskOptions {
|
|
|
+ supportsImages?: boolean
|
|
|
+ rooIgnoreAllowed?: boolean
|
|
|
+ maxImageFileSize?: number
|
|
|
+ maxTotalImageSize?: number
|
|
|
+}
|
|
|
|
|
|
-// Shared mock setup function to ensure consistent state across all test suites
|
|
|
-function createMockCline(): any {
|
|
|
- const mockProvider = {
|
|
|
- getState: vi.fn(),
|
|
|
- deref: vi.fn().mockReturnThis(),
|
|
|
- }
|
|
|
+function createMockTask(options: MockTaskOptions = {}) {
|
|
|
+ const { supportsImages = false, rooIgnoreAllowed = true, maxImageFileSize = 5, maxTotalImageSize = 20 } = options
|
|
|
|
|
|
- const mockCline: any = {
|
|
|
- cwd: "/",
|
|
|
- task: "Test",
|
|
|
- providerRef: mockProvider,
|
|
|
- rooIgnoreController: {
|
|
|
- validateAccess: vi.fn().mockReturnValue(true),
|
|
|
+ return {
|
|
|
+ cwd: "/test/workspace",
|
|
|
+ api: {
|
|
|
+ getModel: vi.fn().mockReturnValue({
|
|
|
+ info: { supportsImages },
|
|
|
+ }),
|
|
|
},
|
|
|
+ consecutiveMistakeCount: 0,
|
|
|
+ didToolFailInCurrentTurn: false,
|
|
|
+ didRejectTool: false,
|
|
|
+ ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }),
|
|
|
say: vi.fn().mockResolvedValue(undefined),
|
|
|
- ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }),
|
|
|
- presentAssistantMessage: vi.fn(),
|
|
|
- handleError: vi.fn().mockResolvedValue(undefined),
|
|
|
- pushToolResult: vi.fn(),
|
|
|
+ sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing required parameter: path"),
|
|
|
+ recordToolError: vi.fn(),
|
|
|
+ rooIgnoreController: {
|
|
|
+ validateAccess: vi.fn().mockReturnValue(rooIgnoreAllowed),
|
|
|
+ },
|
|
|
fileContextTracker: {
|
|
|
trackFileContext: vi.fn().mockResolvedValue(undefined),
|
|
|
},
|
|
|
- recordToolUsage: vi.fn().mockReturnValue(undefined),
|
|
|
- recordToolError: vi.fn().mockReturnValue(undefined),
|
|
|
- didRejectTool: false,
|
|
|
- getTokenUsage: vi.fn().mockReturnValue({
|
|
|
- contextTokens: 10000,
|
|
|
- }),
|
|
|
- apiConfiguration: {
|
|
|
- apiProvider: "anthropic",
|
|
|
- },
|
|
|
- // CRITICAL: Always ensure image support is enabled
|
|
|
- api: {
|
|
|
- getModel: vi.fn().mockReturnValue({
|
|
|
- id: "test-model",
|
|
|
- info: {
|
|
|
- supportsImages: true,
|
|
|
- contextWindow: 200000,
|
|
|
- maxTokens: 4096,
|
|
|
- supportsPromptCache: false,
|
|
|
- // (native tool support is determined at request-time; no model flag)
|
|
|
- },
|
|
|
+ providerRef: {
|
|
|
+ deref: vi.fn().mockReturnValue({
|
|
|
+ getState: vi.fn().mockResolvedValue({
|
|
|
+ maxImageFileSize,
|
|
|
+ maxTotalImageSize,
|
|
|
+ }),
|
|
|
}),
|
|
|
},
|
|
|
}
|
|
|
-
|
|
|
- return { mockCline, mockProvider }
|
|
|
}
|
|
|
|
|
|
-// Helper function to set image support without affecting shared state
|
|
|
-function setImageSupport(mockCline: any, supportsImages: boolean | undefined): void {
|
|
|
- mockCline.api = {
|
|
|
- getModel: vi.fn().mockReturnValue({
|
|
|
- id: "test-model",
|
|
|
- info: { supportsImages },
|
|
|
- }),
|
|
|
+function createMockCallbacks() {
|
|
|
+ return {
|
|
|
+ pushToolResult: vi.fn(),
|
|
|
+ askApproval: vi.fn(),
|
|
|
+ handleError: vi.fn(),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-describe("read_file tool with maxReadFileLine setting", () => {
|
|
|
- // Test data
|
|
|
- const testFilePath = "test/file.txt"
|
|
|
- const absoluteFilePath = "/test/file.txt"
|
|
|
- const fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
|
|
|
- const numberedFileContent = "1 | Line 1\n2 | Line 2\n3 | Line 3\n4 | Line 4\n5 | Line 5\n"
|
|
|
- const sourceCodeDef = "\n\n# file.txt\n1--5 | Content"
|
|
|
-
|
|
|
- // Mocked functions with correct types
|
|
|
- const mockedCountFileLines = vi.mocked(countFileLines)
|
|
|
- const mockedReadLines = vi.mocked(readLines)
|
|
|
- const mockedExtractTextFromFile = vi.mocked(extractTextFromFile)
|
|
|
- const mockedParseSourceCodeDefinitionsForFile = vi.mocked(parseSourceCodeDefinitionsForFile)
|
|
|
-
|
|
|
- const mockedIsBinaryFile = vi.mocked(isBinaryFile)
|
|
|
- const mockedPathResolve = vi.mocked(path.resolve)
|
|
|
-
|
|
|
- let mockCline: any
|
|
|
- let mockProvider: any
|
|
|
- let toolResult: ToolResponse | undefined
|
|
|
+// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
+describe("ReadFileTool", () => {
|
|
|
beforeEach(() => {
|
|
|
- // Clear specific mocks (not all mocks to preserve shared state)
|
|
|
- mockedCountFileLines.mockClear()
|
|
|
- mockedExtractTextFromFile.mockClear()
|
|
|
- mockedIsBinaryFile.mockClear()
|
|
|
- mockedPathResolve.mockClear()
|
|
|
- addLineNumbersMock.mockClear()
|
|
|
- extractTextFromFileMock.mockClear()
|
|
|
- toolResultMock.mockClear()
|
|
|
-
|
|
|
- // Use shared mock setup function
|
|
|
- const mocks = createMockCline()
|
|
|
- mockCline = mocks.mockCline
|
|
|
- mockProvider = mocks.mockProvider
|
|
|
-
|
|
|
- // Explicitly disable image support for text file tests to prevent cross-suite pollution
|
|
|
- setImageSupport(mockCline, false)
|
|
|
-
|
|
|
- mockedPathResolve.mockReturnValue(absoluteFilePath)
|
|
|
- mockedIsBinaryFile.mockResolvedValue(false)
|
|
|
+ vi.clearAllMocks()
|
|
|
|
|
|
- // Mock fsPromises.stat to return a file (not directory) by default
|
|
|
- fsPromises.stat.mockResolvedValue({
|
|
|
- isDirectory: () => false,
|
|
|
- isFile: () => true,
|
|
|
- isSymbolicLink: () => false,
|
|
|
- } as any)
|
|
|
+ // Default mock implementations
|
|
|
+ mockedFsStat.mockResolvedValue({ isDirectory: () => false } as any)
|
|
|
+ mockedIsBinaryFile.mockResolvedValue(false)
|
|
|
+ mockedFsReadFile.mockResolvedValue(Buffer.from("test content"))
|
|
|
+ mockedReadWithSlice.mockReturnValue({
|
|
|
+ content: "1 | test content",
|
|
|
+ returnedLines: 1,
|
|
|
+ totalLines: 1,
|
|
|
+ wasTruncated: false,
|
|
|
+ includedRanges: [[1, 1]],
|
|
|
+ })
|
|
|
+ })
|
|
|
|
|
|
- mockInputContent = fileContent
|
|
|
+ describe("input validation", () => {
|
|
|
+ it("should return error when path is missing", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- // Setup the extractTextFromFile mock implementation with the current mockInputContent
|
|
|
- // Reset the spy before each test
|
|
|
- addLineNumbersMock.mockClear()
|
|
|
+ await readFileTool.execute({ path: "" } as any, mockTask as any, callbacks)
|
|
|
|
|
|
- // Setup the extractTextFromFile mock to call our spy
|
|
|
- mockedExtractTextFromFile.mockImplementation((_filePath) => {
|
|
|
- // Call the spy and return its result
|
|
|
- return Promise.resolve(addLineNumbersMock(mockInputContent))
|
|
|
+ expect(mockTask.consecutiveMistakeCount).toBe(1)
|
|
|
+ expect(mockTask.recordToolError).toHaveBeenCalledWith("read_file")
|
|
|
+ expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledWith("read_file", "path")
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error:"))
|
|
|
})
|
|
|
|
|
|
- toolResult = undefined
|
|
|
- })
|
|
|
+ it("should return error when path is undefined", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- /**
|
|
|
- * Helper function to execute the read file tool with different maxReadFileLine settings
|
|
|
- */
|
|
|
- async function executeReadFileTool(
|
|
|
- params: Partial<ReadFileToolUse["params"]> = {},
|
|
|
- options: {
|
|
|
- maxReadFileLine?: number
|
|
|
- totalLines?: number
|
|
|
- skipAddLineNumbersCheck?: boolean // Flag to skip addLineNumbers check
|
|
|
- path?: string
|
|
|
- start_line?: string
|
|
|
- end_line?: string
|
|
|
- } = {},
|
|
|
- ): Promise<ToolResponse | undefined> {
|
|
|
- // Configure mocks based on test scenario
|
|
|
- const maxReadFileLine = options.maxReadFileLine ?? 500
|
|
|
- const totalLines = options.totalLines ?? 5
|
|
|
-
|
|
|
- mockProvider.getState.mockResolvedValue({ maxReadFileLine, maxImageFileSize: 20, maxTotalImageSize: 20 })
|
|
|
- mockedCountFileLines.mockResolvedValue(totalLines)
|
|
|
-
|
|
|
- // Reset the spy before each test
|
|
|
- addLineNumbersMock.mockClear()
|
|
|
-
|
|
|
- const lineRanges =
|
|
|
- options.start_line && options.end_line
|
|
|
- ? [
|
|
|
- {
|
|
|
- start: Number(options.start_line),
|
|
|
- end: Number(options.end_line),
|
|
|
- },
|
|
|
- ]
|
|
|
- : []
|
|
|
+ await readFileTool.execute({} as any, mockTask as any, callbacks)
|
|
|
|
|
|
- // Create a tool use object
|
|
|
- const toolUse: ReadFileToolUse = {
|
|
|
- type: "tool_use",
|
|
|
- name: "read_file",
|
|
|
- params: { ...params },
|
|
|
- partial: false,
|
|
|
- nativeArgs: {
|
|
|
- files: [
|
|
|
- {
|
|
|
- path: options.path || testFilePath,
|
|
|
- lineRanges,
|
|
|
- },
|
|
|
- ],
|
|
|
- },
|
|
|
- }
|
|
|
-
|
|
|
- await readFileTool.handle(mockCline, toolUse, {
|
|
|
- askApproval: mockCline.ask,
|
|
|
- handleError: vi.fn(),
|
|
|
- pushToolResult: (result: ToolResponse) => {
|
|
|
- toolResult = result
|
|
|
- },
|
|
|
+ expect(mockTask.consecutiveMistakeCount).toBe(1)
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error:"))
|
|
|
})
|
|
|
|
|
|
- return toolResult
|
|
|
- }
|
|
|
-
|
|
|
- describe("when maxReadFileLine is negative", () => {
|
|
|
- it("should read the entire file using extractTextFromFile", async () => {
|
|
|
- // Setup - use default mockInputContent
|
|
|
- mockInputContent = fileContent
|
|
|
+ it("should return error when offset is 0 or negative", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- // Execute
|
|
|
- const result = await executeReadFileTool({}, { maxReadFileLine: -1 })
|
|
|
+ await readFileTool.execute({ path: "test.txt", offset: 0 }, mockTask as any, callbacks)
|
|
|
|
|
|
- // Verify - check that the result contains the expected native format elements
|
|
|
- expect(result).toContain(`File: ${testFilePath}`)
|
|
|
- expect(result).toContain(`Lines 1-5:`)
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalledWith(
|
|
|
+ expect.stringContaining("offset must be a 1-indexed line number"),
|
|
|
+ )
|
|
|
})
|
|
|
|
|
|
- it("should not show line snippet in approval message when maxReadFileLine is -1", async () => {
|
|
|
- // This test verifies the line snippet behavior for the approval message
|
|
|
- // Setup - use default mockInputContent
|
|
|
- mockInputContent = fileContent
|
|
|
-
|
|
|
- // Execute - we'll reuse executeReadFileTool to run the tool
|
|
|
- await executeReadFileTool({}, { maxReadFileLine: -1 })
|
|
|
+ it("should return error when offset is negative", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- // Verify the empty line snippet for full read was passed to the approval message
|
|
|
- // Look at the parameters passed to the 'ask' method in the approval message
|
|
|
- const askCall = mockCline.ask.mock.calls[0]
|
|
|
- const completeMessage = JSON.parse(askCall[1])
|
|
|
+ await readFileTool.execute({ path: "test.txt", offset: -5 }, mockTask as any, callbacks)
|
|
|
|
|
|
- // Verify the reason (lineSnippet) is empty or undefined for full read
|
|
|
- expect(completeMessage.reason).toBeFalsy()
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalledWith(
|
|
|
+ expect.stringContaining("offset must be a 1-indexed line number"),
|
|
|
+ )
|
|
|
})
|
|
|
- })
|
|
|
|
|
|
- describe("when maxReadFileLine is 0", () => {
|
|
|
- it("should return an empty content with source code definitions", async () => {
|
|
|
- // Setup - for maxReadFileLine = 0, the implementation won't call readLines
|
|
|
- mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(sourceCodeDef)
|
|
|
+ it("should return error when anchor_line is 0 or negative", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- // Execute - skip addLineNumbers check as it's not called for maxReadFileLine=0
|
|
|
- const result = await executeReadFileTool(
|
|
|
- {},
|
|
|
+ await readFileTool.execute(
|
|
|
{
|
|
|
- maxReadFileLine: 0,
|
|
|
- totalLines: 5,
|
|
|
- skipAddLineNumbersCheck: true,
|
|
|
+ path: "test.txt",
|
|
|
+ mode: "indentation",
|
|
|
+ indentation: { anchor_line: 0 },
|
|
|
},
|
|
|
+ mockTask as any,
|
|
|
+ callbacks,
|
|
|
)
|
|
|
|
|
|
- // Verify - native format
|
|
|
- expect(result).toContain(`File: ${testFilePath}`)
|
|
|
- expect(result).toContain(`Code Definitions:`)
|
|
|
-
|
|
|
- // Verify native structure
|
|
|
- expect(result).toContain("Note: Showing only 0 of 5 total lines")
|
|
|
- expect(result).toContain(sourceCodeDef.trim())
|
|
|
- expect(result).not.toContain("Lines 1-") // No content when maxReadFileLine is 0
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- describe("when maxReadFileLine is less than file length", () => {
|
|
|
- it("should read only maxReadFileLine lines and add source code definitions", async () => {
|
|
|
- // Setup
|
|
|
- const content = "Line 1\nLine 2\nLine 3"
|
|
|
- const numberedContent = "1 | Line 1\n2 | Line 2\n3 | Line 3"
|
|
|
- mockedReadLines.mockResolvedValue(content)
|
|
|
- mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(sourceCodeDef)
|
|
|
-
|
|
|
- // Setup addLineNumbers to always return numbered content
|
|
|
- addLineNumbersMock.mockReturnValue(numberedContent)
|
|
|
-
|
|
|
- // Execute
|
|
|
- const result = await executeReadFileTool({}, { maxReadFileLine: 3 })
|
|
|
-
|
|
|
- // Verify - native format
|
|
|
- expect(result).toContain(`File: ${testFilePath}`)
|
|
|
- expect(result).toContain(`Lines 1-3:`)
|
|
|
- expect(result).toContain(`Code Definitions:`)
|
|
|
- expect(result).toContain("Note: Showing only 3 of 5 total lines")
|
|
|
- })
|
|
|
-
|
|
|
- it("should truncate code definitions when file exceeds maxReadFileLine", async () => {
|
|
|
- // Setup - file with 100 lines but we'll only read first 30
|
|
|
- const content = "Line 1\nLine 2\nLine 3"
|
|
|
- const numberedContent = "1 | Line 1\n2 | Line 2\n3 | Line 3"
|
|
|
- const fullDefinitions = `# file.txt
|
|
|
-10--20 | function foo() {
|
|
|
-50--60 | function bar() {
|
|
|
-80--90 | function baz() {`
|
|
|
- const truncatedDefinitions = `# file.txt
|
|
|
-10--20 | function foo() {`
|
|
|
-
|
|
|
- mockedReadLines.mockResolvedValue(content)
|
|
|
- mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(fullDefinitions)
|
|
|
- addLineNumbersMock.mockReturnValue(numberedContent)
|
|
|
-
|
|
|
- // Execute with maxReadFileLine = 30
|
|
|
- const result = await executeReadFileTool({}, { maxReadFileLine: 30, totalLines: 100 })
|
|
|
-
|
|
|
- // Verify - native format
|
|
|
- expect(result).toContain(`File: ${testFilePath}`)
|
|
|
- expect(result).toContain(`Lines 1-30:`)
|
|
|
- expect(result).toContain(`Code Definitions:`)
|
|
|
-
|
|
|
- // Should include foo (starts at line 10) but not bar (starts at line 50) or baz (starts at line 80)
|
|
|
- expect(result).toContain("10--20 | function foo()")
|
|
|
- expect(result).not.toContain("50--60 | function bar()")
|
|
|
- expect(result).not.toContain("80--90 | function baz()")
|
|
|
-
|
|
|
- expect(result).toContain("Note: Showing only 30 of 100 total lines")
|
|
|
- })
|
|
|
-
|
|
|
- it("should handle truncation when all definitions are beyond the line limit", async () => {
|
|
|
- // Setup - all definitions start after maxReadFileLine
|
|
|
- const content = "Line 1\nLine 2\nLine 3"
|
|
|
- const numberedContent = "1 | Line 1\n2 | Line 2\n3 | Line 3"
|
|
|
- const fullDefinitions = `# file.txt
|
|
|
-50--60 | function foo() {
|
|
|
-80--90 | function bar() {`
|
|
|
-
|
|
|
- mockedReadLines.mockResolvedValue(content)
|
|
|
- mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(fullDefinitions)
|
|
|
- addLineNumbersMock.mockReturnValue(numberedContent)
|
|
|
-
|
|
|
- // Execute with maxReadFileLine = 30
|
|
|
- const result = await executeReadFileTool({}, { maxReadFileLine: 30, totalLines: 100 })
|
|
|
-
|
|
|
- // Verify - native format
|
|
|
- expect(result).toContain(`File: ${testFilePath}`)
|
|
|
- expect(result).toContain(`Lines 1-30:`)
|
|
|
- expect(result).toContain(`Code Definitions:`)
|
|
|
- expect(result).toContain("# file.txt")
|
|
|
- expect(result).not.toContain("50--60 | function foo()")
|
|
|
- expect(result).not.toContain("80--90 | function bar()")
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- describe("when maxReadFileLine equals or exceeds file length", () => {
|
|
|
- it("should use extractTextFromFile when maxReadFileLine > totalLines", async () => {
|
|
|
- // Setup
|
|
|
- mockedCountFileLines.mockResolvedValue(5) // File shorter than maxReadFileLine
|
|
|
- mockInputContent = fileContent
|
|
|
-
|
|
|
- // Execute
|
|
|
- const result = await executeReadFileTool({}, { maxReadFileLine: 10, totalLines: 5 })
|
|
|
-
|
|
|
- // Verify - native format
|
|
|
- expect(result).toContain(`File: ${testFilePath}`)
|
|
|
- expect(result).toContain(`Lines 1-5:`)
|
|
|
- })
|
|
|
-
|
|
|
- it("should read with extractTextFromFile when file has few lines", async () => {
|
|
|
- // Setup
|
|
|
- mockedCountFileLines.mockResolvedValue(3) // File shorter than maxReadFileLine
|
|
|
- const threeLineContent = "Line 1\nLine 2\nLine 3"
|
|
|
- mockInputContent = threeLineContent
|
|
|
-
|
|
|
- // Configure the mock to return the correct content for this test
|
|
|
- mockReadFileWithTokenBudget.mockResolvedValueOnce({
|
|
|
- content: threeLineContent,
|
|
|
- tokenCount: threeLineContent.length / 4,
|
|
|
- lineCount: 3,
|
|
|
- complete: true,
|
|
|
- })
|
|
|
-
|
|
|
- // Execute
|
|
|
- const result = await executeReadFileTool({}, { maxReadFileLine: 5, totalLines: 3 })
|
|
|
-
|
|
|
- // Verify - native format
|
|
|
- expect(result).toContain(`File: ${testFilePath}`)
|
|
|
- expect(result).toContain(`Lines 1-3:`)
|
|
|
- })
|
|
|
- })
|
|
|
-
|
|
|
- describe("when file is binary", () => {
|
|
|
- it("should always use extractTextFromFile regardless of maxReadFileLine", async () => {
|
|
|
- // Setup
|
|
|
- mockedIsBinaryFile.mockResolvedValue(true)
|
|
|
- mockedCountFileLines.mockResolvedValue(3)
|
|
|
- mockedExtractTextFromFile.mockResolvedValue("")
|
|
|
-
|
|
|
- // Execute
|
|
|
- const result = await executeReadFileTool({}, { maxReadFileLine: 3, totalLines: 3 })
|
|
|
-
|
|
|
- // Verify - native format for binary files
|
|
|
- expect(result).toContain(`File: ${testFilePath}`)
|
|
|
- expect(typeof result).toBe("string")
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalledWith(
|
|
|
+ expect.stringContaining("anchor_line must be a 1-indexed line number"),
|
|
|
+ )
|
|
|
})
|
|
|
- })
|
|
|
|
|
|
- describe("with range parameters", () => {
|
|
|
- it("should honor start_line and end_line when provided", async () => {
|
|
|
- // Setup
|
|
|
- mockedReadLines.mockResolvedValue("Line 2\nLine 3\nLine 4")
|
|
|
+ it("should return error when anchor_line is negative", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- // Execute using executeReadFileTool with range parameters
|
|
|
- const rangeResult = await executeReadFileTool(
|
|
|
- {},
|
|
|
+ await readFileTool.execute(
|
|
|
{
|
|
|
- start_line: "2",
|
|
|
- end_line: "4",
|
|
|
+ path: "test.txt",
|
|
|
+ mode: "indentation",
|
|
|
+ indentation: { anchor_line: -10 },
|
|
|
},
|
|
|
+ mockTask as any,
|
|
|
+ callbacks,
|
|
|
)
|
|
|
|
|
|
- // Verify - native format
|
|
|
- expect(rangeResult).toContain(`File: ${testFilePath}`)
|
|
|
- expect(rangeResult).toContain(`Lines 2-4:`)
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalledWith(
|
|
|
+ expect.stringContaining("anchor_line must be a 1-indexed line number"),
|
|
|
+ )
|
|
|
})
|
|
|
})
|
|
|
-})
|
|
|
|
|
|
-describe("read_file tool output structure", () => {
|
|
|
- // Test basic native structure
|
|
|
- const testFilePath = "test/file.txt"
|
|
|
- const absoluteFilePath = "/test/file.txt"
|
|
|
- const fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
|
|
|
-
|
|
|
- const mockedCountFileLines = vi.mocked(countFileLines)
|
|
|
- const mockedExtractTextFromFile = vi.mocked(extractTextFromFile)
|
|
|
- const mockedIsBinaryFile = vi.mocked(isBinaryFile)
|
|
|
- const mockedPathResolve = vi.mocked(path.resolve)
|
|
|
- const mockedFsReadFile = vi.mocked(fsPromises.readFile)
|
|
|
- const imageBuffer = Buffer.from(
|
|
|
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
|
|
- "base64",
|
|
|
- )
|
|
|
-
|
|
|
- let mockCline: any
|
|
|
- let mockProvider: any
|
|
|
- let toolResult: ToolResponse | undefined
|
|
|
+ describe("RooIgnore handling", () => {
|
|
|
+ it("should block access to rooignore-protected files", async () => {
|
|
|
+ const mockTask = createMockTask({ rooIgnoreAllowed: false })
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- beforeEach(() => {
|
|
|
- // Clear specific mocks (not all mocks to preserve shared state)
|
|
|
- mockedCountFileLines.mockClear()
|
|
|
- mockedExtractTextFromFile.mockClear()
|
|
|
- mockedIsBinaryFile.mockClear()
|
|
|
- mockedPathResolve.mockClear()
|
|
|
- addLineNumbersMock.mockClear()
|
|
|
- extractTextFromFileMock.mockClear()
|
|
|
- toolResultMock.mockClear()
|
|
|
-
|
|
|
- // CRITICAL: Reset fsPromises mocks to prevent cross-test contamination
|
|
|
- fsPromises.stat.mockClear()
|
|
|
- fsPromises.stat.mockResolvedValue({
|
|
|
- size: 1024,
|
|
|
- isDirectory: () => false,
|
|
|
- isFile: () => true,
|
|
|
- isSymbolicLink: () => false,
|
|
|
- } as any)
|
|
|
- fsPromises.readFile.mockClear()
|
|
|
-
|
|
|
- // Use shared mock setup function
|
|
|
- const mocks = createMockCline()
|
|
|
- mockCline = mocks.mockCline
|
|
|
- mockProvider = mocks.mockProvider
|
|
|
-
|
|
|
- // Explicitly enable image support for this test suite (contains image memory tests)
|
|
|
- setImageSupport(mockCline, true)
|
|
|
-
|
|
|
- mockedPathResolve.mockReturnValue(absoluteFilePath)
|
|
|
- mockedIsBinaryFile.mockResolvedValue(false)
|
|
|
+ await readFileTool.execute({ path: "secret.env" }, mockTask as any, callbacks)
|
|
|
|
|
|
- // Set default implementation for extractTextFromFile
|
|
|
- mockedExtractTextFromFile.mockImplementation((filePath) => {
|
|
|
- return Promise.resolve(addLineNumbersMock(mockInputContent))
|
|
|
+ expect(mockTask.say).toHaveBeenCalledWith("rooignore_error", "secret.env")
|
|
|
+ expect(formatResponse.rooIgnoreError).toHaveBeenCalledWith("secret.env")
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("blocked by the .rooignore"))
|
|
|
})
|
|
|
-
|
|
|
- mockInputContent = fileContent
|
|
|
-
|
|
|
- // Setup mock provider with default maxReadFileLine
|
|
|
- mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, maxTotalImageSize: 20 }) // Default to full file read
|
|
|
-
|
|
|
- // Add additional properties needed for missing param validation tests
|
|
|
- mockCline.sayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing required parameter")
|
|
|
-
|
|
|
- toolResult = undefined
|
|
|
})
|
|
|
|
|
|
- async function executeReadFileTool(
|
|
|
- options: {
|
|
|
- totalLines?: number
|
|
|
- maxReadFileLine?: number
|
|
|
- isBinary?: boolean
|
|
|
- validateAccess?: boolean
|
|
|
- filePath?: string
|
|
|
- } = {},
|
|
|
- ): Promise<ToolResponse | undefined> {
|
|
|
- // Configure mocks based on test scenario
|
|
|
- const totalLines = options.totalLines ?? 5
|
|
|
- const maxReadFileLine = options.maxReadFileLine ?? 500
|
|
|
- const isBinary = options.isBinary ?? false
|
|
|
- const validateAccess = options.validateAccess ?? true
|
|
|
-
|
|
|
- mockProvider.getState.mockResolvedValue({ maxReadFileLine, maxImageFileSize: 20, maxTotalImageSize: 20 })
|
|
|
- mockedCountFileLines.mockResolvedValue(totalLines)
|
|
|
- mockedIsBinaryFile.mockResolvedValue(isBinary)
|
|
|
- mockCline.rooIgnoreController.validateAccess = vi.fn().mockReturnValue(validateAccess)
|
|
|
- const filePath = options.filePath ?? testFilePath
|
|
|
-
|
|
|
- // Create a tool use object
|
|
|
- const toolUse: ReadFileToolUse = {
|
|
|
- type: "tool_use",
|
|
|
- name: "read_file",
|
|
|
- params: {},
|
|
|
- partial: false,
|
|
|
- nativeArgs: {
|
|
|
- files: [{ path: filePath, lineRanges: [] }],
|
|
|
- },
|
|
|
- }
|
|
|
-
|
|
|
- // Execute the tool
|
|
|
- await readFileTool.handle(mockCline, toolUse, {
|
|
|
- askApproval: mockCline.ask,
|
|
|
- handleError: vi.fn(),
|
|
|
- pushToolResult: (result: ToolResponse) => {
|
|
|
- toolResult = result
|
|
|
- },
|
|
|
- })
|
|
|
-
|
|
|
- return toolResult
|
|
|
- }
|
|
|
-
|
|
|
- describe("Basic Structure Tests", () => {
|
|
|
- it("should produce native output with proper format", async () => {
|
|
|
- // Setup
|
|
|
- const numberedContent = "1 | Line 1\n2 | Line 2\n3 | Line 3\n4 | Line 4\n5 | Line 5"
|
|
|
-
|
|
|
- // Configure mockReadFileWithTokenBudget to return the 5-line content
|
|
|
- mockReadFileWithTokenBudget.mockResolvedValueOnce({
|
|
|
- content: fileContent, // "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
|
|
|
- tokenCount: fileContent.length / 4,
|
|
|
- lineCount: 5,
|
|
|
- complete: true,
|
|
|
- })
|
|
|
+ describe("directory handling", () => {
|
|
|
+ it("should return error when trying to read a directory", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- mockProvider.getState.mockResolvedValue({
|
|
|
- maxReadFileLine: -1,
|
|
|
- maxImageFileSize: 20,
|
|
|
- maxTotalImageSize: 20,
|
|
|
- }) // Allow up to 20MB per image and total size
|
|
|
+ mockedFsStat.mockResolvedValue({ isDirectory: () => true } as any)
|
|
|
|
|
|
- // Execute
|
|
|
- const result = await executeReadFileTool()
|
|
|
+ await readFileTool.execute({ path: "src/utils" }, mockTask as any, callbacks)
|
|
|
|
|
|
- // Verify native format
|
|
|
- expect(result).toBe(`File: ${testFilePath}\nLines 1-5:\n${numberedContent}`)
|
|
|
+ expect(mockTask.say).toHaveBeenCalledWith(
|
|
|
+ "error",
|
|
|
+ expect.stringContaining("Cannot read 'src/utils' because it is a directory"),
|
|
|
+ )
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("it is a directory"))
|
|
|
+ expect(mockTask.didToolFailInCurrentTurn).toBe(true)
|
|
|
})
|
|
|
+ })
|
|
|
|
|
|
- it("should follow the correct native structure format", async () => {
|
|
|
- // Setup
|
|
|
- mockInputContent = fileContent
|
|
|
- // Execute
|
|
|
- const result = await executeReadFileTool({ maxReadFileLine: -1 })
|
|
|
-
|
|
|
- // Verify using regex to check native structure
|
|
|
- const nativeStructureRegex = new RegExp(`^File: ${testFilePath}\\nLines 1-5:\\n.*$`, "s")
|
|
|
- expect(result).toMatch(nativeStructureRegex)
|
|
|
+ describe("image handling", () => {
|
|
|
+ beforeEach(() => {
|
|
|
+ mockedIsBinaryFile.mockResolvedValue(true)
|
|
|
+ mockedIsSupportedImageFormat.mockReturnValue(true)
|
|
|
})
|
|
|
|
|
|
- it("should handle empty files correctly", async () => {
|
|
|
- // Setup
|
|
|
- mockedCountFileLines.mockResolvedValue(0)
|
|
|
+ it("should process image file when model supports images", async () => {
|
|
|
+ const mockTask = createMockTask({ supportsImages: true })
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- // Configure mockReadFileWithTokenBudget to return empty content
|
|
|
- mockReadFileWithTokenBudget.mockResolvedValueOnce({
|
|
|
- content: "",
|
|
|
- tokenCount: 0,
|
|
|
- lineCount: 0,
|
|
|
- complete: true,
|
|
|
+ mockedValidateImageForProcessing.mockResolvedValue({
|
|
|
+ isValid: true,
|
|
|
+ sizeInMB: 0.5,
|
|
|
+ })
|
|
|
+ mockedProcessImageFile.mockResolvedValue({
|
|
|
+ dataUrl: "data:image/png;base64,abc123",
|
|
|
+ buffer: Buffer.from("test"),
|
|
|
+ sizeInKB: 512,
|
|
|
+ sizeInMB: 0.5,
|
|
|
+ notice: "Image processed successfully",
|
|
|
})
|
|
|
|
|
|
- mockProvider.getState.mockResolvedValue({
|
|
|
- maxReadFileLine: -1,
|
|
|
- maxImageFileSize: 20,
|
|
|
- maxTotalImageSize: 20,
|
|
|
- }) // Allow up to 20MB per image and total size
|
|
|
-
|
|
|
- // Execute
|
|
|
- const result = await executeReadFileTool({ totalLines: 0 })
|
|
|
+ await readFileTool.execute({ path: "image.png" }, mockTask as any, callbacks)
|
|
|
|
|
|
- // Verify native format for empty file
|
|
|
- expect(result).toBe(`File: ${testFilePath}\nNote: File is empty`)
|
|
|
+ expect(mockedValidateImageForProcessing).toHaveBeenCalled()
|
|
|
+ expect(mockedProcessImageFile).toHaveBeenCalled()
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalled()
|
|
|
})
|
|
|
|
|
|
- describe("Total Image Memory Limit", () => {
|
|
|
- const testImages = [
|
|
|
- { path: "test/image1.png", sizeKB: 5120 }, // 5MB
|
|
|
- { path: "test/image2.jpg", sizeKB: 10240 }, // 10MB
|
|
|
- { path: "test/image3.gif", sizeKB: 8192 }, // 8MB
|
|
|
- ]
|
|
|
-
|
|
|
- // Define imageBuffer for this test suite
|
|
|
- const imageBuffer = Buffer.from(
|
|
|
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
|
|
- "base64",
|
|
|
- )
|
|
|
+ it("should skip image when model does not support images", async () => {
|
|
|
+ const mockTask = createMockTask({ supportsImages: false })
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- beforeEach(() => {
|
|
|
- // CRITICAL: Reset fsPromises mocks to prevent cross-test contamination within this suite
|
|
|
- fsPromises.stat.mockClear()
|
|
|
- fsPromises.readFile.mockClear()
|
|
|
+ mockedValidateImageForProcessing.mockResolvedValue({
|
|
|
+ isValid: false,
|
|
|
+ reason: "unsupported_model",
|
|
|
+ notice: "Model does not support image processing",
|
|
|
})
|
|
|
|
|
|
- async function executeReadMultipleImagesTool(imagePaths: string[]): Promise<ToolResponse | undefined> {
|
|
|
- // Ensure image support is enabled before calling the tool
|
|
|
- setImageSupport(mockCline, true)
|
|
|
-
|
|
|
- const toolUse: ReadFileToolUse = {
|
|
|
- type: "tool_use",
|
|
|
- name: "read_file",
|
|
|
- params: {},
|
|
|
- partial: false,
|
|
|
- nativeArgs: {
|
|
|
- files: imagePaths.map((p) => ({ path: p, lineRanges: [] })),
|
|
|
- },
|
|
|
- }
|
|
|
-
|
|
|
- let localResult: ToolResponse | undefined
|
|
|
- await readFileTool.handle(mockCline, toolUse, {
|
|
|
- askApproval: mockCline.ask,
|
|
|
- handleError: vi.fn(),
|
|
|
- pushToolResult: (result: ToolResponse) => {
|
|
|
- localResult = result
|
|
|
- },
|
|
|
- })
|
|
|
- // In multi-image scenarios, the result is pushed to pushToolResult, not returned directly.
|
|
|
- // We need to check the mock's calls to get the result.
|
|
|
- if (mockCline.pushToolResult.mock.calls.length > 0) {
|
|
|
- return mockCline.pushToolResult.mock.calls[0][0]
|
|
|
- }
|
|
|
-
|
|
|
- return localResult
|
|
|
- }
|
|
|
-
|
|
|
- it("should allow multiple images under the total memory limit", async () => {
|
|
|
- // Setup required mocks (don't clear all mocks - preserve API setup)
|
|
|
- mockedIsBinaryFile.mockResolvedValue(true)
|
|
|
- mockedCountFileLines.mockResolvedValue(0)
|
|
|
- fsPromises.readFile.mockResolvedValue(
|
|
|
- Buffer.from(
|
|
|
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
|
|
- "base64",
|
|
|
- ),
|
|
|
- )
|
|
|
-
|
|
|
- // Setup mockProvider
|
|
|
- mockProvider.getState.mockResolvedValue({
|
|
|
- maxReadFileLine: -1,
|
|
|
- maxImageFileSize: 20,
|
|
|
- maxTotalImageSize: 20,
|
|
|
- }) // Allow up to 20MB per image and total size
|
|
|
-
|
|
|
- // Setup mockCline properties (preserve existing API)
|
|
|
- mockCline.cwd = "/"
|
|
|
- mockCline.task = "Test"
|
|
|
- mockCline.providerRef = mockProvider
|
|
|
- mockCline.rooIgnoreController = {
|
|
|
- validateAccess: vi.fn().mockReturnValue(true),
|
|
|
- }
|
|
|
- mockCline.say = vi.fn().mockResolvedValue(undefined)
|
|
|
- mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" })
|
|
|
- mockCline.presentAssistantMessage = vi.fn()
|
|
|
- mockCline.handleError = vi.fn().mockResolvedValue(undefined)
|
|
|
- mockCline.pushToolResult = vi.fn()
|
|
|
- mockCline.fileContextTracker = {
|
|
|
- trackFileContext: vi.fn().mockResolvedValue(undefined),
|
|
|
- }
|
|
|
- mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined)
|
|
|
- mockCline.recordToolError = vi.fn().mockReturnValue(undefined)
|
|
|
- setImageSupport(mockCline, true)
|
|
|
-
|
|
|
- // Setup - images that fit within 20MB limit
|
|
|
- const smallImages = [
|
|
|
- { path: "test/small1.png", sizeKB: 2048 }, // 2MB
|
|
|
- { path: "test/small2.jpg", sizeKB: 3072 }, // 3MB
|
|
|
- { path: "test/small3.gif", sizeKB: 4096 }, // 4MB
|
|
|
- ] // Total: 9MB (under 20MB limit)
|
|
|
-
|
|
|
- // Mock file stats for each image
|
|
|
- fsPromises.stat = vi.fn().mockImplementation((filePath) => {
|
|
|
- const normalizedFilePath = path.normalize(filePath.toString())
|
|
|
- const image = smallImages.find((img) => normalizedFilePath.includes(path.normalize(img.path)))
|
|
|
- return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024, isDirectory: () => false })
|
|
|
- })
|
|
|
-
|
|
|
- // Mock path.resolve for each image
|
|
|
- mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`)
|
|
|
-
|
|
|
- // Execute
|
|
|
- const result = await executeReadMultipleImagesTool(smallImages.map((img) => img.path))
|
|
|
-
|
|
|
- // Verify all images were processed (should be a multi-part response)
|
|
|
- expect(Array.isArray(result)).toBe(true)
|
|
|
- const parts = result as any[]
|
|
|
-
|
|
|
- // Should have text part and 3 image parts
|
|
|
- const textPart = parts.find((p) => p.type === "text")?.text
|
|
|
- const imageParts = parts.filter((p) => p.type === "image")
|
|
|
-
|
|
|
- expect(textPart).toBeDefined()
|
|
|
- expect(imageParts).toHaveLength(3)
|
|
|
-
|
|
|
- // Verify no memory limit notices
|
|
|
- expect(textPart).not.toContain("Total image memory would exceed")
|
|
|
- })
|
|
|
+ await readFileTool.execute({ path: "image.png" }, mockTask as any, callbacks)
|
|
|
|
|
|
- it("should skip images that would exceed the total memory limit", async () => {
|
|
|
- // Setup required mocks (don't clear all mocks)
|
|
|
- mockedIsBinaryFile.mockResolvedValue(true)
|
|
|
- mockedCountFileLines.mockResolvedValue(0)
|
|
|
- fsPromises.readFile.mockResolvedValue(
|
|
|
- Buffer.from(
|
|
|
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
|
|
- "base64",
|
|
|
- ),
|
|
|
- )
|
|
|
-
|
|
|
- // Setup mockProvider
|
|
|
- mockProvider.getState.mockResolvedValue({
|
|
|
- maxReadFileLine: -1,
|
|
|
- maxImageFileSize: 15,
|
|
|
- maxTotalImageSize: 20,
|
|
|
- }) // Allow up to 15MB per image and 20MB total size
|
|
|
-
|
|
|
- // Setup mockCline properties
|
|
|
- mockCline.cwd = "/"
|
|
|
- mockCline.task = "Test"
|
|
|
- mockCline.providerRef = mockProvider
|
|
|
- mockCline.rooIgnoreController = {
|
|
|
- validateAccess: vi.fn().mockReturnValue(true),
|
|
|
- }
|
|
|
- mockCline.say = vi.fn().mockResolvedValue(undefined)
|
|
|
- mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" })
|
|
|
- mockCline.presentAssistantMessage = vi.fn()
|
|
|
- mockCline.handleError = vi.fn().mockResolvedValue(undefined)
|
|
|
- mockCline.pushToolResult = vi.fn()
|
|
|
- mockCline.fileContextTracker = {
|
|
|
- trackFileContext: vi.fn().mockResolvedValue(undefined),
|
|
|
- }
|
|
|
- mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined)
|
|
|
- mockCline.recordToolError = vi.fn().mockReturnValue(undefined)
|
|
|
- setImageSupport(mockCline, true)
|
|
|
-
|
|
|
- // Setup - images where later ones would exceed 20MB total limit
|
|
|
- // Each must be under 5MB per-file limit (5120KB)
|
|
|
- const largeImages = [
|
|
|
- { path: "test/large1.png", sizeKB: 5017 }, // ~4.9MB
|
|
|
- { path: "test/large2.jpg", sizeKB: 5017 }, // ~4.9MB
|
|
|
- { path: "test/large3.gif", sizeKB: 5017 }, // ~4.9MB
|
|
|
- { path: "test/large4.png", sizeKB: 5017 }, // ~4.9MB
|
|
|
- { path: "test/large5.jpg", sizeKB: 5017 }, // ~4.9MB - This should be skipped (total would be ~24.5MB > 20MB)
|
|
|
- ]
|
|
|
-
|
|
|
- // Mock file stats for each image
|
|
|
- fsPromises.stat = vi.fn().mockImplementation((filePath) => {
|
|
|
- const normalizedFilePath = path.normalize(filePath.toString())
|
|
|
- const image = largeImages.find((img) => normalizedFilePath.includes(path.normalize(img.path)))
|
|
|
- return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024, isDirectory: () => false })
|
|
|
- })
|
|
|
-
|
|
|
- // Mock path.resolve for each image
|
|
|
- mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`)
|
|
|
-
|
|
|
- // Execute
|
|
|
- const result = await executeReadMultipleImagesTool(largeImages.map((img) => img.path))
|
|
|
-
|
|
|
- // Verify result structure - should be a mix of successful images and skipped notices
|
|
|
- expect(Array.isArray(result)).toBe(true)
|
|
|
- const parts = result as any[]
|
|
|
-
|
|
|
- const textPart = Array.isArray(result) ? result.find((p) => p.type === "text")?.text : result
|
|
|
- const imageParts = Array.isArray(result) ? result.filter((p) => p.type === "image") : []
|
|
|
-
|
|
|
- expect(textPart).toBeDefined()
|
|
|
+ expect(mockedValidateImageForProcessing).toHaveBeenCalled()
|
|
|
+ expect(mockedProcessImageFile).not.toHaveBeenCalled()
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalledWith(
|
|
|
+ expect.stringContaining("Model does not support image processing"),
|
|
|
+ )
|
|
|
+ })
|
|
|
|
|
|
- // Debug: Show what we actually got vs expected
|
|
|
- if (imageParts.length !== 4) {
|
|
|
- throw new Error(
|
|
|
- `Expected 4 images, got ${imageParts.length}. Full result: ${JSON.stringify(result, null, 2)}. Text part: ${textPart}`,
|
|
|
- )
|
|
|
- }
|
|
|
- expect(imageParts).toHaveLength(4) // First 4 images should be included (~19.6MB total)
|
|
|
+ it("should skip image when file exceeds size limit", async () => {
|
|
|
+ const mockTask = createMockTask({ supportsImages: true, maxImageFileSize: 1 })
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- // Verify memory limit notice for the fifth image
|
|
|
- expect(textPart).toContain("Image skipped to avoid size limit (20MB)")
|
|
|
- expect(textPart).toMatch(/Current: \d+(\.\d+)? MB/)
|
|
|
- expect(textPart).toMatch(/this file: \d+(\.\d+)? MB/)
|
|
|
+ mockedValidateImageForProcessing.mockResolvedValue({
|
|
|
+ isValid: false,
|
|
|
+ reason: "size_limit",
|
|
|
+ notice: "Image file size (10 MB) exceeds the maximum allowed size (1 MB)",
|
|
|
})
|
|
|
|
|
|
- it("should track memory usage correctly across multiple images", async () => {
|
|
|
- // Setup mocks (don't clear all mocks)
|
|
|
-
|
|
|
- // Setup required mocks
|
|
|
- mockedIsBinaryFile.mockResolvedValue(true)
|
|
|
- mockedCountFileLines.mockResolvedValue(0)
|
|
|
- fsPromises.readFile.mockResolvedValue(
|
|
|
- Buffer.from(
|
|
|
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
|
|
- "base64",
|
|
|
- ),
|
|
|
- )
|
|
|
-
|
|
|
- // Setup mockProvider
|
|
|
- mockProvider.getState.mockResolvedValue({
|
|
|
- maxReadFileLine: -1,
|
|
|
- maxImageFileSize: 15,
|
|
|
- maxTotalImageSize: 20,
|
|
|
- }) // Allow up to 15MB per image and 20MB total size
|
|
|
-
|
|
|
- // Setup mockCline properties
|
|
|
- mockCline.cwd = "/"
|
|
|
- mockCline.task = "Test"
|
|
|
- mockCline.providerRef = mockProvider
|
|
|
- mockCline.rooIgnoreController = {
|
|
|
- validateAccess: vi.fn().mockReturnValue(true),
|
|
|
- }
|
|
|
- mockCline.say = vi.fn().mockResolvedValue(undefined)
|
|
|
- mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" })
|
|
|
- mockCline.presentAssistantMessage = vi.fn()
|
|
|
- mockCline.handleError = vi.fn().mockResolvedValue(undefined)
|
|
|
- mockCline.pushToolResult = vi.fn()
|
|
|
- mockCline.fileContextTracker = {
|
|
|
- trackFileContext: vi.fn().mockResolvedValue(undefined),
|
|
|
- }
|
|
|
- mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined)
|
|
|
- mockCline.recordToolError = vi.fn().mockReturnValue(undefined)
|
|
|
- setImageSupport(mockCline, true)
|
|
|
-
|
|
|
- // Setup - images that exactly reach the limit
|
|
|
- const exactLimitImages = [
|
|
|
- { path: "test/exact1.png", sizeKB: 10240 }, // 10MB
|
|
|
- { path: "test/exact2.jpg", sizeKB: 10240 }, // 10MB - Total exactly 20MB
|
|
|
- { path: "test/exact3.gif", sizeKB: 1024 }, // 1MB - This should be skipped
|
|
|
- ]
|
|
|
+ await readFileTool.execute({ path: "large-image.png" }, mockTask as any, callbacks)
|
|
|
|
|
|
- // Mock file stats with simpler logic
|
|
|
- fsPromises.stat = vi.fn().mockImplementation((filePath) => {
|
|
|
- const normalizedFilePath = path.normalize(filePath.toString())
|
|
|
- const image = exactLimitImages.find((img) => normalizedFilePath.includes(path.normalize(img.path)))
|
|
|
- if (image) {
|
|
|
- return Promise.resolve({ size: image.sizeKB * 1024, isDirectory: () => false })
|
|
|
- }
|
|
|
- return Promise.resolve({ size: 1024 * 1024, isDirectory: () => false }) // Default 1MB
|
|
|
- })
|
|
|
-
|
|
|
- // Mock path.resolve
|
|
|
- mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`)
|
|
|
-
|
|
|
- // Execute
|
|
|
- const result = await executeReadMultipleImagesTool(exactLimitImages.map((img) => img.path))
|
|
|
-
|
|
|
- // Verify
|
|
|
- const textPart = Array.isArray(result) ? result.find((p) => p.type === "text")?.text : result
|
|
|
- const imageParts = Array.isArray(result) ? result.filter((p) => p.type === "image") : []
|
|
|
-
|
|
|
- expect(imageParts).toHaveLength(2) // First 2 images should fit
|
|
|
- expect(textPart).toContain("Image skipped to avoid size limit (20MB)")
|
|
|
- expect(textPart).toMatch(/Current: \d+(\.\d+)? MB/)
|
|
|
- expect(textPart).toMatch(/this file: \d+(\.\d+)? MB/)
|
|
|
- })
|
|
|
+ expect(mockedProcessImageFile).not.toHaveBeenCalled()
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalledWith(
|
|
|
+ expect.stringContaining("exceeds the maximum allowed"),
|
|
|
+ )
|
|
|
+ })
|
|
|
|
|
|
- it("should handle individual image size limit and total memory limit together", async () => {
|
|
|
- // Setup mocks (don't clear all mocks)
|
|
|
-
|
|
|
- // Setup required mocks
|
|
|
- mockedIsBinaryFile.mockResolvedValue(true)
|
|
|
- mockedCountFileLines.mockResolvedValue(0)
|
|
|
- fsPromises.readFile.mockResolvedValue(
|
|
|
- Buffer.from(
|
|
|
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
|
|
- "base64",
|
|
|
- ),
|
|
|
- )
|
|
|
-
|
|
|
- // Setup mockProvider
|
|
|
- mockProvider.getState.mockResolvedValue({
|
|
|
- maxReadFileLine: -1,
|
|
|
- maxImageFileSize: 20,
|
|
|
- maxTotalImageSize: 20,
|
|
|
- }) // Allow up to 20MB per image and total size
|
|
|
-
|
|
|
- // Setup mockCline properties (complete setup)
|
|
|
- mockCline.cwd = "/"
|
|
|
- mockCline.task = "Test"
|
|
|
- mockCline.providerRef = mockProvider
|
|
|
- mockCline.rooIgnoreController = {
|
|
|
- validateAccess: vi.fn().mockReturnValue(true),
|
|
|
- }
|
|
|
- mockCline.say = vi.fn().mockResolvedValue(undefined)
|
|
|
- mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" })
|
|
|
- mockCline.presentAssistantMessage = vi.fn()
|
|
|
- mockCline.handleError = vi.fn().mockResolvedValue(undefined)
|
|
|
- mockCline.pushToolResult = vi.fn()
|
|
|
- mockCline.fileContextTracker = {
|
|
|
- trackFileContext: vi.fn().mockResolvedValue(undefined),
|
|
|
- }
|
|
|
- mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined)
|
|
|
- mockCline.recordToolError = vi.fn().mockReturnValue(undefined)
|
|
|
- setImageSupport(mockCline, true)
|
|
|
-
|
|
|
- // Setup - mix of images with individual size violations and total memory issues
|
|
|
- const mixedImages = [
|
|
|
- { path: "test/ok.png", sizeKB: 3072 }, // 3MB - OK
|
|
|
- { path: "test/too-big.jpg", sizeKB: 6144 }, // 6MB - Exceeds individual 5MB limit
|
|
|
- { path: "test/ok2.gif", sizeKB: 4096 }, // 4MB - OK individually but might exceed total
|
|
|
- ]
|
|
|
+ it("should skip image when total memory limit exceeded", async () => {
|
|
|
+ const mockTask = createMockTask({ supportsImages: true, maxTotalImageSize: 5 })
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- // Mock file stats
|
|
|
- fsPromises.stat = vi.fn().mockImplementation((filePath) => {
|
|
|
- const fileName = path.basename(filePath)
|
|
|
- const baseName = path.parse(fileName).name
|
|
|
- const image = mixedImages.find((img) => img.path.includes(baseName))
|
|
|
- return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024, isDirectory: () => false })
|
|
|
- })
|
|
|
-
|
|
|
- // Mock provider state with 5MB individual limit
|
|
|
- mockProvider.getState.mockResolvedValue({
|
|
|
- maxReadFileLine: -1,
|
|
|
- maxImageFileSize: 5,
|
|
|
- maxTotalImageSize: 20,
|
|
|
- })
|
|
|
-
|
|
|
- // Mock path.resolve
|
|
|
- mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`)
|
|
|
-
|
|
|
- // Execute
|
|
|
- const result = await executeReadMultipleImagesTool(mixedImages.map((img) => img.path))
|
|
|
-
|
|
|
- // Verify
|
|
|
- expect(Array.isArray(result)).toBe(true)
|
|
|
- const parts = result as any[]
|
|
|
-
|
|
|
- const textPart = parts.find((p) => p.type === "text")?.text
|
|
|
- const imageParts = parts.filter((p) => p.type === "image")
|
|
|
-
|
|
|
- // Should have 2 images (ok.png and ok2.gif)
|
|
|
- expect(imageParts).toHaveLength(2)
|
|
|
-
|
|
|
- // Should show individual size limit violation
|
|
|
- expect(textPart).toMatch(
|
|
|
- /Image file is too large \(\d+(\.\d+)? MB\)\. The maximum allowed size is 5 MB\./,
|
|
|
- )
|
|
|
+ mockedValidateImageForProcessing.mockResolvedValue({
|
|
|
+ isValid: false,
|
|
|
+ reason: "memory_limit",
|
|
|
+ notice: "Skipping image: would exceed total memory limit",
|
|
|
})
|
|
|
|
|
|
- it("should correctly calculate total memory and skip the last image", async () => {
|
|
|
- // Setup
|
|
|
- const testImages = [
|
|
|
- { path: "test/image1.png", sizeMB: 8 },
|
|
|
- { path: "test/image2.png", sizeMB: 8 },
|
|
|
- { path: "test/image3.png", sizeMB: 8 }, // This one should be skipped
|
|
|
- ]
|
|
|
-
|
|
|
- mockProvider.getState.mockResolvedValue({
|
|
|
- maxReadFileLine: -1,
|
|
|
- maxImageFileSize: 10, // 10MB per image
|
|
|
- maxTotalImageSize: 20, // 20MB total
|
|
|
- })
|
|
|
-
|
|
|
- mockedIsBinaryFile.mockResolvedValue(true)
|
|
|
- mockedCountFileLines.mockResolvedValue(0)
|
|
|
- mockedFsReadFile.mockResolvedValue(imageBuffer)
|
|
|
-
|
|
|
- fsPromises.stat.mockImplementation(async (filePath) => {
|
|
|
- const normalizedFilePath = path.normalize(filePath.toString())
|
|
|
- const file = testImages.find((f) => normalizedFilePath.includes(path.normalize(f.path)))
|
|
|
- if (file) {
|
|
|
- return { size: file.sizeMB * 1024 * 1024, isDirectory: () => false }
|
|
|
- }
|
|
|
- return { size: 1024 * 1024, isDirectory: () => false } // Default 1MB
|
|
|
- })
|
|
|
-
|
|
|
- const imagePaths = testImages.map((img) => img.path)
|
|
|
- const result = await executeReadMultipleImagesTool(imagePaths)
|
|
|
-
|
|
|
- expect(Array.isArray(result)).toBe(true)
|
|
|
- const parts = result as any[]
|
|
|
- const textPart = parts.find((p) => p.type === "text")?.text
|
|
|
- const imageParts = parts.filter((p) => p.type === "image")
|
|
|
-
|
|
|
- expect(imageParts).toHaveLength(2) // First two images should be processed
|
|
|
- expect(textPart).toContain("Image skipped to avoid size limit (20MB)")
|
|
|
- expect(textPart).toMatch(/Current: \d+(\.\d+)? MB/)
|
|
|
- expect(textPart).toMatch(/this file: \d+(\.\d+)? MB/)
|
|
|
- })
|
|
|
+ await readFileTool.execute({ path: "another-image.png" }, mockTask as any, callbacks)
|
|
|
|
|
|
- it("should reset total memory tracking for each tool invocation", async () => {
|
|
|
- // Setup mocks (don't clear all mocks)
|
|
|
-
|
|
|
- // Setup required mocks for first batch
|
|
|
- mockedIsBinaryFile.mockResolvedValue(true)
|
|
|
- mockedCountFileLines.mockResolvedValue(0)
|
|
|
- fsPromises.readFile.mockResolvedValue(
|
|
|
- Buffer.from(
|
|
|
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
|
|
- "base64",
|
|
|
- ),
|
|
|
- )
|
|
|
-
|
|
|
- // Setup mockProvider
|
|
|
- mockProvider.getState.mockResolvedValue({
|
|
|
- maxReadFileLine: -1,
|
|
|
- maxImageFileSize: 20,
|
|
|
- maxTotalImageSize: 20,
|
|
|
- })
|
|
|
-
|
|
|
- // Setup mockCline properties (complete setup)
|
|
|
- mockCline.cwd = "/"
|
|
|
- mockCline.task = "Test"
|
|
|
- mockCline.providerRef = mockProvider
|
|
|
- mockCline.rooIgnoreController = {
|
|
|
- validateAccess: vi.fn().mockReturnValue(true),
|
|
|
- }
|
|
|
- mockCline.say = vi.fn().mockResolvedValue(undefined)
|
|
|
- mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" })
|
|
|
- mockCline.presentAssistantMessage = vi.fn()
|
|
|
- mockCline.handleError = vi.fn().mockResolvedValue(undefined)
|
|
|
- mockCline.pushToolResult = vi.fn()
|
|
|
- mockCline.fileContextTracker = {
|
|
|
- trackFileContext: vi.fn().mockResolvedValue(undefined),
|
|
|
- }
|
|
|
- mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined)
|
|
|
- mockCline.recordToolError = vi.fn().mockReturnValue(undefined)
|
|
|
- setImageSupport(mockCline, true)
|
|
|
-
|
|
|
- // Setup - first call with images that use memory
|
|
|
- const firstBatch = [{ path: "test/first.png", sizeKB: 10240 }] // 10MB
|
|
|
-
|
|
|
- fsPromises.stat = vi.fn().mockResolvedValue({ size: 10240 * 1024, isDirectory: () => false })
|
|
|
- mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`)
|
|
|
-
|
|
|
- // Execute first batch
|
|
|
- await executeReadMultipleImagesTool(firstBatch.map((img) => img.path))
|
|
|
-
|
|
|
- // Setup second batch (don't clear all mocks)
|
|
|
- mockedIsBinaryFile.mockResolvedValue(true)
|
|
|
- mockedCountFileLines.mockResolvedValue(0)
|
|
|
- fsPromises.readFile.mockResolvedValue(
|
|
|
- Buffer.from(
|
|
|
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
|
|
- "base64",
|
|
|
- ),
|
|
|
- )
|
|
|
- mockProvider.getState.mockResolvedValue({
|
|
|
- maxReadFileLine: -1,
|
|
|
- maxImageFileSize: 20,
|
|
|
- maxTotalImageSize: 20,
|
|
|
- })
|
|
|
-
|
|
|
- // Reset path resolving for second batch
|
|
|
- mockedPathResolve.mockClear()
|
|
|
-
|
|
|
- // Re-setup mockCline properties for second batch (complete setup)
|
|
|
- mockCline.cwd = "/"
|
|
|
- mockCline.task = "Test"
|
|
|
- mockCline.providerRef = mockProvider
|
|
|
- mockCline.rooIgnoreController = {
|
|
|
- validateAccess: vi.fn().mockReturnValue(true),
|
|
|
- }
|
|
|
- mockCline.say = vi.fn().mockResolvedValue(undefined)
|
|
|
- mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" })
|
|
|
- mockCline.presentAssistantMessage = vi.fn()
|
|
|
- mockCline.handleError = vi.fn().mockResolvedValue(undefined)
|
|
|
- mockCline.pushToolResult = vi.fn()
|
|
|
- mockCline.fileContextTracker = {
|
|
|
- trackFileContext: vi.fn().mockResolvedValue(undefined),
|
|
|
- }
|
|
|
- mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined)
|
|
|
- mockCline.recordToolError = vi.fn().mockReturnValue(undefined)
|
|
|
- setImageSupport(mockCline, true)
|
|
|
-
|
|
|
- const secondBatch = [{ path: "test/second.png", sizeKB: 15360 }] // 15MB
|
|
|
-
|
|
|
- // Clear and reset file system mocks for second batch
|
|
|
- fsPromises.stat.mockClear()
|
|
|
- fsPromises.readFile.mockClear()
|
|
|
- mockedIsBinaryFile.mockClear()
|
|
|
- mockedCountFileLines.mockClear()
|
|
|
-
|
|
|
- // Reset mocks for second batch
|
|
|
- fsPromises.stat = vi.fn().mockResolvedValue({ size: 15360 * 1024, isDirectory: () => false })
|
|
|
- fsPromises.readFile.mockResolvedValue(
|
|
|
- Buffer.from(
|
|
|
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
|
|
- "base64",
|
|
|
- ),
|
|
|
- )
|
|
|
- mockedIsBinaryFile.mockResolvedValue(true)
|
|
|
- mockedCountFileLines.mockResolvedValue(0)
|
|
|
- mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`)
|
|
|
-
|
|
|
- // Execute second batch
|
|
|
- const result = await executeReadMultipleImagesTool(secondBatch.map((img) => img.path))
|
|
|
-
|
|
|
- // Verify second batch is processed successfully (memory tracking was reset)
|
|
|
- expect(Array.isArray(result)).toBe(true)
|
|
|
- const parts = result as any[]
|
|
|
- const imageParts = parts.filter((p) => p.type === "image")
|
|
|
-
|
|
|
- expect(imageParts).toHaveLength(1) // Second image should be processed
|
|
|
- })
|
|
|
+ expect(mockedProcessImageFile).not.toHaveBeenCalled()
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("would exceed total memory"))
|
|
|
+ })
|
|
|
|
|
|
- it("should handle a folder with many images just under the individual size limit", async () => {
|
|
|
- // Setup - Create many images that are each just under the 5MB individual limit
|
|
|
- // but together approach the 20MB total limit
|
|
|
- const manyImages = [
|
|
|
- { path: "test/img1.png", sizeKB: 4900 }, // 4.78MB
|
|
|
- { path: "test/img2.png", sizeKB: 4900 }, // 4.78MB
|
|
|
- { path: "test/img3.png", sizeKB: 4900 }, // 4.78MB
|
|
|
- { path: "test/img4.png", sizeKB: 4900 }, // 4.78MB
|
|
|
- { path: "test/img5.png", sizeKB: 4900 }, // 4.78MB - This should be skipped (total would be ~23.9MB)
|
|
|
- ]
|
|
|
+ it("should handle image read errors gracefully", async () => {
|
|
|
+ const mockTask = createMockTask({ supportsImages: true })
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- // Setup mocks
|
|
|
- mockedIsBinaryFile.mockResolvedValue(true)
|
|
|
- mockedCountFileLines.mockResolvedValue(0)
|
|
|
- fsPromises.readFile.mockResolvedValue(imageBuffer)
|
|
|
-
|
|
|
- // Setup provider with 5MB individual limit and 20MB total limit
|
|
|
- mockProvider.getState.mockResolvedValue({
|
|
|
- maxReadFileLine: -1,
|
|
|
- maxImageFileSize: 5,
|
|
|
- maxTotalImageSize: 20,
|
|
|
- })
|
|
|
-
|
|
|
- // Mock file stats for each image
|
|
|
- fsPromises.stat = vi.fn().mockImplementation((filePath) => {
|
|
|
- const normalizedFilePath = path.normalize(filePath.toString())
|
|
|
- const image = manyImages.find((img) => normalizedFilePath.includes(path.normalize(img.path)))
|
|
|
- return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024, isDirectory: () => false })
|
|
|
- })
|
|
|
-
|
|
|
- // Mock path.resolve
|
|
|
- mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`)
|
|
|
-
|
|
|
- // Execute
|
|
|
- const result = await executeReadMultipleImagesTool(manyImages.map((img) => img.path))
|
|
|
-
|
|
|
- // Verify
|
|
|
- expect(Array.isArray(result)).toBe(true)
|
|
|
- const parts = result as any[]
|
|
|
- const textPart = parts.find((p) => p.type === "text")?.text
|
|
|
- const imageParts = parts.filter((p) => p.type === "image")
|
|
|
-
|
|
|
- // Should process first 4 images (total ~19.12MB, under 20MB limit)
|
|
|
- expect(imageParts).toHaveLength(4)
|
|
|
-
|
|
|
- // Should show memory limit notice for the 5th image
|
|
|
- expect(textPart).toContain("Image skipped to avoid size limit (20MB)")
|
|
|
- expect(textPart).toContain("test/img5.png")
|
|
|
-
|
|
|
- // Verify memory tracking worked correctly
|
|
|
- // The notice should show current memory usage around 20MB (4 * 4900KB ≈ 19.14MB, displayed as 20.1MB)
|
|
|
- expect(textPart).toMatch(/Current: \d+(\.\d+)? MB/)
|
|
|
+ mockedValidateImageForProcessing.mockResolvedValue({
|
|
|
+ isValid: true,
|
|
|
+ sizeInMB: 0.5,
|
|
|
})
|
|
|
+ mockedProcessImageFile.mockRejectedValue(new Error("Failed to read image"))
|
|
|
|
|
|
- it("should reset memory tracking between separate tool invocations more explicitly", async () => {
|
|
|
- // This test verifies that totalImageMemoryUsed is reset between calls
|
|
|
- // by making two separate tool invocations and ensuring the second one
|
|
|
- // starts with fresh memory tracking
|
|
|
-
|
|
|
- // Setup mocks
|
|
|
- mockedIsBinaryFile.mockResolvedValue(true)
|
|
|
- mockedCountFileLines.mockResolvedValue(0)
|
|
|
- fsPromises.readFile.mockResolvedValue(imageBuffer)
|
|
|
-
|
|
|
- // Setup provider
|
|
|
- mockProvider.getState.mockResolvedValue({
|
|
|
- maxReadFileLine: -1,
|
|
|
- maxImageFileSize: 20,
|
|
|
- maxTotalImageSize: 20,
|
|
|
- })
|
|
|
+ await readFileTool.execute({ path: "corrupt.png" }, mockTask as any, callbacks)
|
|
|
|
|
|
- // First invocation - use 15MB of memory
|
|
|
- const firstBatch = [{ path: "test/large1.png", sizeKB: 15360 }] // 15MB
|
|
|
-
|
|
|
- fsPromises.stat = vi.fn().mockResolvedValue({ size: 15360 * 1024, isDirectory: () => false })
|
|
|
- mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`)
|
|
|
-
|
|
|
- // Execute first batch
|
|
|
- const result1 = await executeReadMultipleImagesTool(firstBatch.map((img) => img.path))
|
|
|
+ expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading image file"))
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error"))
|
|
|
+ })
|
|
|
+ })
|
|
|
|
|
|
- // Verify first batch processed successfully
|
|
|
- expect(Array.isArray(result1)).toBe(true)
|
|
|
- const parts1 = result1 as any[]
|
|
|
- const imageParts1 = parts1.filter((p) => p.type === "image")
|
|
|
- expect(imageParts1).toHaveLength(1)
|
|
|
+ describe("binary file handling", () => {
|
|
|
+ beforeEach(() => {
|
|
|
+ mockedIsBinaryFile.mockResolvedValue(true)
|
|
|
+ mockedIsSupportedImageFormat.mockReturnValue(false)
|
|
|
+ })
|
|
|
|
|
|
- // Second invocation - should start with 0 memory used, not 15MB
|
|
|
- // If memory tracking wasn't reset, this 18MB image would be rejected
|
|
|
- const secondBatch = [{ path: "test/large2.png", sizeKB: 18432 }] // 18MB
|
|
|
+ it("should extract text from PDF files", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- // Reset mocks for second invocation
|
|
|
- fsPromises.stat.mockClear()
|
|
|
- fsPromises.readFile.mockClear()
|
|
|
- mockedPathResolve.mockClear()
|
|
|
+ mockedExtractTextFromFile.mockResolvedValue("PDF content here")
|
|
|
|
|
|
- fsPromises.stat = vi.fn().mockResolvedValue({ size: 18432 * 1024, isDirectory: () => false })
|
|
|
- fsPromises.readFile.mockResolvedValue(imageBuffer)
|
|
|
- mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`)
|
|
|
+ await readFileTool.execute({ path: "document.pdf" }, mockTask as any, callbacks)
|
|
|
|
|
|
- // Execute second batch
|
|
|
- const result2 = await executeReadMultipleImagesTool(secondBatch.map((img) => img.path))
|
|
|
+ expect(mockedExtractTextFromFile).toHaveBeenCalled()
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("PDF content here"))
|
|
|
+ })
|
|
|
|
|
|
- // Verify second batch processed successfully
|
|
|
- expect(Array.isArray(result2)).toBe(true)
|
|
|
- const parts2 = result2 as any[]
|
|
|
- const imageParts2 = parts2.filter((p) => p.type === "image")
|
|
|
- const textPart2 = parts2.find((p) => p.type === "text")?.text
|
|
|
+ it("should extract text from DOCX files", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- // The 18MB image should be processed successfully because memory was reset
|
|
|
- expect(imageParts2).toHaveLength(1)
|
|
|
+ mockedExtractTextFromFile.mockResolvedValue("DOCX content here")
|
|
|
|
|
|
- // Should NOT contain any memory limit notices
|
|
|
- expect(textPart2).not.toContain("Image skipped to avoid memory limit")
|
|
|
+ await readFileTool.execute({ path: "document.docx" }, mockTask as any, callbacks)
|
|
|
|
|
|
- // This proves memory tracking was reset between invocations
|
|
|
- })
|
|
|
+ expect(mockedExtractTextFromFile).toHaveBeenCalled()
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("DOCX content here"))
|
|
|
})
|
|
|
- })
|
|
|
|
|
|
- describe("Error Handling Tests", () => {
|
|
|
- it("should include error in output for invalid path", async () => {
|
|
|
- // Setup - missing path parameter
|
|
|
- const toolUse: ReadFileToolUse = {
|
|
|
- type: "tool_use",
|
|
|
- name: "read_file",
|
|
|
- params: {},
|
|
|
- partial: false,
|
|
|
- nativeArgs: {
|
|
|
- files: [],
|
|
|
- },
|
|
|
- }
|
|
|
-
|
|
|
- // Execute the tool
|
|
|
- await readFileTool.handle(mockCline, toolUse, {
|
|
|
- askApproval: mockCline.ask,
|
|
|
- handleError: vi.fn(),
|
|
|
- pushToolResult: (result: ToolResponse) => {
|
|
|
- toolResult = result
|
|
|
- },
|
|
|
- })
|
|
|
+ it("should handle unsupported binary formats", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- // Verify - native format for error
|
|
|
- expect(toolResult).toBe(`Error: Missing required parameter`)
|
|
|
- })
|
|
|
+ // Return empty array to indicate .exe is not supported
|
|
|
+ vi.mocked(getSupportedBinaryFormats).mockReturnValue([".pdf", ".docx"])
|
|
|
|
|
|
- it("should include error for RooIgnore error", async () => {
|
|
|
- // Execute - skip addLineNumbers check as it returns early with an error
|
|
|
- const result = await executeReadFileTool({ validateAccess: false })
|
|
|
+ await readFileTool.execute({ path: "program.exe" }, mockTask as any, callbacks)
|
|
|
|
|
|
- // Verify - native format for error
|
|
|
- expect(result).toBe(
|
|
|
- `File: ${testFilePath}\nError: Access to ${testFilePath} is blocked by the .rooignore file settings. You must try to continue in the task without using this file, or ask the user to update the .rooignore file.`,
|
|
|
- )
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Binary file"))
|
|
|
})
|
|
|
|
|
|
- it("should provide helpful error when trying to read a directory", async () => {
|
|
|
- // Setup - mock fsPromises.stat to indicate the path is a directory
|
|
|
- const dirPath = "test/my-directory"
|
|
|
- const absoluteDirPath = "/test/my-directory"
|
|
|
+ it("should handle extraction errors gracefully", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- mockedPathResolve.mockReturnValue(absoluteDirPath)
|
|
|
+ mockedExtractTextFromFile.mockRejectedValue(new Error("Extraction failed"))
|
|
|
|
|
|
- // Mock fs/promises stat to return directory
|
|
|
- fsPromises.stat.mockResolvedValue({
|
|
|
- isDirectory: () => true,
|
|
|
- isFile: () => false,
|
|
|
- isSymbolicLink: () => false,
|
|
|
- } as any)
|
|
|
-
|
|
|
- // Mock isBinaryFile won't be called since we check directory first
|
|
|
- mockedIsBinaryFile.mockResolvedValue(false)
|
|
|
+ await readFileTool.execute({ path: "corrupt.pdf" }, mockTask as any, callbacks)
|
|
|
|
|
|
- // Execute
|
|
|
- const result = await executeReadFileTool({ filePath: dirPath })
|
|
|
-
|
|
|
- // Verify - native format for error
|
|
|
- expect(result).toContain(`File: ${dirPath}`)
|
|
|
- expect(result).toContain(`Error: Error reading file: Cannot read '${dirPath}' because it is a directory`)
|
|
|
- expect(result).toContain("use the list_files tool instead")
|
|
|
-
|
|
|
- // Verify that task.say was called with the error
|
|
|
- expect(mockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("Cannot read"))
|
|
|
- expect(mockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("is a directory"))
|
|
|
- expect(mockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("list_files tool"))
|
|
|
+ expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error extracting text"))
|
|
|
+ expect(mockTask.didToolFailInCurrentTurn).toBe(true)
|
|
|
})
|
|
|
})
|
|
|
-})
|
|
|
|
|
|
-describe("read_file tool with image support", () => {
|
|
|
- const testImagePath = "test/image.png"
|
|
|
- const absoluteImagePath = "/test/image.png"
|
|
|
- const base64ImageData =
|
|
|
- "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
|
|
- const imageBuffer = Buffer.from(base64ImageData, "base64")
|
|
|
+ describe("text file processing", () => {
|
|
|
+ beforeEach(() => {
|
|
|
+ mockedIsBinaryFile.mockResolvedValue(false)
|
|
|
+ })
|
|
|
|
|
|
- const mockedCountFileLines = vi.mocked(countFileLines)
|
|
|
- const mockedIsBinaryFile = vi.mocked(isBinaryFile)
|
|
|
- const mockedPathResolve = vi.mocked(path.resolve)
|
|
|
- const mockedFsReadFile = vi.mocked(fsPromises.readFile)
|
|
|
- const mockedExtractTextFromFile = vi.mocked(extractTextFromFile)
|
|
|
+ it("should read text file with slice mode (default)", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- let localMockCline: any
|
|
|
- let localMockProvider: any
|
|
|
- let toolResult: ToolResponse | undefined
|
|
|
+ const content = "line 1\nline 2\nline 3"
|
|
|
+ mockedFsReadFile.mockResolvedValue(Buffer.from(content))
|
|
|
+ mockedReadWithSlice.mockReturnValue({
|
|
|
+ content: "1 | line 1\n2 | line 2\n3 | line 3",
|
|
|
+ returnedLines: 3,
|
|
|
+ totalLines: 3,
|
|
|
+ wasTruncated: false,
|
|
|
+ includedRanges: [[1, 3]],
|
|
|
+ })
|
|
|
|
|
|
- beforeEach(() => {
|
|
|
- // Clear specific mocks (not all mocks to preserve shared state)
|
|
|
- mockedPathResolve.mockClear()
|
|
|
- mockedIsBinaryFile.mockClear()
|
|
|
- mockedCountFileLines.mockClear()
|
|
|
- mockedFsReadFile.mockClear()
|
|
|
- mockedExtractTextFromFile.mockClear()
|
|
|
- toolResultMock.mockClear()
|
|
|
-
|
|
|
- // CRITICAL: Reset fsPromises.stat to prevent cross-test contamination
|
|
|
- fsPromises.stat.mockClear()
|
|
|
- fsPromises.stat.mockResolvedValue({
|
|
|
- size: 1024,
|
|
|
- isDirectory: () => false,
|
|
|
- isFile: () => true,
|
|
|
- isSymbolicLink: () => false,
|
|
|
- } as any)
|
|
|
-
|
|
|
- // Use shared mock setup function with local variables
|
|
|
- const mocks = createMockCline()
|
|
|
- localMockCline = mocks.mockCline
|
|
|
- localMockProvider = mocks.mockProvider
|
|
|
-
|
|
|
- // CRITICAL: Explicitly ensure image support is enabled for all tests in this suite
|
|
|
- setImageSupport(localMockCline, true)
|
|
|
-
|
|
|
- mockedPathResolve.mockReturnValue(absoluteImagePath)
|
|
|
- mockedIsBinaryFile.mockResolvedValue(true)
|
|
|
- mockedCountFileLines.mockResolvedValue(0)
|
|
|
- mockedFsReadFile.mockResolvedValue(imageBuffer)
|
|
|
-
|
|
|
- // Setup mock provider with default maxReadFileLine
|
|
|
- localMockProvider.getState.mockResolvedValue({ maxReadFileLine: -1 })
|
|
|
-
|
|
|
- toolResult = undefined
|
|
|
- })
|
|
|
+ await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks)
|
|
|
|
|
|
- async function executeReadImageTool(imagePath: string = testImagePath): Promise<ToolResponse | undefined> {
|
|
|
- const toolUse: ReadFileToolUse = {
|
|
|
- type: "tool_use",
|
|
|
- name: "read_file",
|
|
|
- params: {},
|
|
|
- partial: false,
|
|
|
- nativeArgs: {
|
|
|
- files: [{ path: imagePath, lineRanges: [] }],
|
|
|
- },
|
|
|
- }
|
|
|
-
|
|
|
- // Debug: Check if mock is working
|
|
|
- console.log("Mock API:", localMockCline.api)
|
|
|
- console.log("Supports images:", localMockCline.api?.getModel?.()?.info?.supportsImages)
|
|
|
-
|
|
|
- await readFileTool.handle(localMockCline, toolUse, {
|
|
|
- askApproval: localMockCline.ask,
|
|
|
- handleError: vi.fn(),
|
|
|
- pushToolResult: (result: ToolResponse) => {
|
|
|
- toolResult = result
|
|
|
- },
|
|
|
+ expect(mockedReadWithSlice).toHaveBeenCalled()
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("line 1"))
|
|
|
})
|
|
|
|
|
|
- console.log("Result type:", Array.isArray(toolResult) ? "array" : typeof toolResult)
|
|
|
- console.log("Result:", toolResult)
|
|
|
-
|
|
|
- return toolResult
|
|
|
- }
|
|
|
+ it("should read text file with offset and limit", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- describe("Image Format Detection", () => {
|
|
|
- it.each([
|
|
|
- [".png", "image.png", "image/png"],
|
|
|
- [".jpg", "photo.jpg", "image/jpeg"],
|
|
|
- [".jpeg", "picture.jpeg", "image/jpeg"],
|
|
|
- [".gif", "animation.gif", "image/gif"],
|
|
|
- [".bmp", "bitmap.bmp", "image/bmp"],
|
|
|
- [".svg", "vector.svg", "image/svg+xml"],
|
|
|
- [".webp", "modern.webp", "image/webp"],
|
|
|
- [".ico", "favicon.ico", "image/x-icon"],
|
|
|
- [".avif", "new-format.avif", "image/avif"],
|
|
|
- ])("should detect %s as an image format", async (ext, filename, expectedMimeType) => {
|
|
|
- // Setup
|
|
|
- const imagePath = `test/${filename}`
|
|
|
- const absolutePath = `/test/${filename}`
|
|
|
- mockedPathResolve.mockReturnValue(absolutePath)
|
|
|
-
|
|
|
- // Ensure API mock supports images
|
|
|
- setImageSupport(localMockCline, true)
|
|
|
-
|
|
|
- // Execute
|
|
|
- const result = await executeReadImageTool(imagePath)
|
|
|
-
|
|
|
- // Verify result is a multi-part response
|
|
|
- expect(Array.isArray(result)).toBe(true)
|
|
|
- const textPart = (result as any[]).find((p) => p.type === "text")?.text
|
|
|
- const imagePart = (result as any[]).find((p) => p.type === "image")
|
|
|
-
|
|
|
- // Verify text part - native format
|
|
|
- expect(textPart).toContain(`File: ${imagePath}`)
|
|
|
- expect(textPart).not.toContain("<image_data>")
|
|
|
- expect(textPart).toContain(`Note: Image file`)
|
|
|
-
|
|
|
- // Verify image part
|
|
|
- expect(imagePart).toBeDefined()
|
|
|
- expect(imagePart.source.media_type).toBe(expectedMimeType)
|
|
|
- expect(imagePart.source.data).toBe(base64ImageData)
|
|
|
- })
|
|
|
- })
|
|
|
+ mockedFsReadFile.mockResolvedValue(Buffer.from("line 1\nline 2\nline 3\nline 4\nline 5"))
|
|
|
+ mockedReadWithSlice.mockReturnValue({
|
|
|
+ content: "2 | line 2\n3 | line 3",
|
|
|
+ returnedLines: 2,
|
|
|
+ totalLines: 5,
|
|
|
+ wasTruncated: true,
|
|
|
+ includedRanges: [[2, 3]],
|
|
|
+ })
|
|
|
|
|
|
- describe("Image Reading Functionality", () => {
|
|
|
- it("should read image file and return a multi-part response", async () => {
|
|
|
- // Execute
|
|
|
- const result = await executeReadImageTool()
|
|
|
-
|
|
|
- // Verify result is a multi-part response
|
|
|
- expect(Array.isArray(result)).toBe(true)
|
|
|
- const textPart = (result as any[]).find((p) => p.type === "text")?.text
|
|
|
- const imagePart = (result as any[]).find((p) => p.type === "image")
|
|
|
-
|
|
|
- // Verify text part - native format
|
|
|
- expect(textPart).toContain(`File: ${testImagePath}`)
|
|
|
- expect(textPart).not.toContain(`<image_data>`)
|
|
|
- expect(textPart).toContain(`Note: Image file`)
|
|
|
-
|
|
|
- // Verify image part
|
|
|
- expect(imagePart).toBeDefined()
|
|
|
- expect(imagePart.source.media_type).toBe("image/png")
|
|
|
- expect(imagePart.source.data).toBe(base64ImageData)
|
|
|
- })
|
|
|
+ await readFileTool.execute(
|
|
|
+ { path: "test.ts", mode: "slice", offset: 2, limit: 2 },
|
|
|
+ mockTask as any,
|
|
|
+ callbacks,
|
|
|
+ )
|
|
|
|
|
|
- it("should call formatResponse.toolResult with text and image data", async () => {
|
|
|
- // Execute
|
|
|
- await executeReadImageTool()
|
|
|
-
|
|
|
- // Verify toolResultMock was called correctly
|
|
|
- expect(toolResultMock).toHaveBeenCalledTimes(1)
|
|
|
- const callArgs = toolResultMock.mock.calls[0]
|
|
|
- const textArg = callArgs[0]
|
|
|
- const imagesArg = callArgs[1]
|
|
|
-
|
|
|
- // Native format
|
|
|
- expect(textArg).toContain(`File: ${testImagePath}`)
|
|
|
- expect(imagesArg).toBeDefined()
|
|
|
- expect(imagesArg).toBeInstanceOf(Array)
|
|
|
- expect(imagesArg!.length).toBe(1)
|
|
|
- expect(imagesArg![0]).toBe(`data:image/png;base64,${base64ImageData}`)
|
|
|
+ expect(mockedReadWithSlice).toHaveBeenCalledWith(expect.any(String), 1, 2) // offset converted to 0-based
|
|
|
})
|
|
|
|
|
|
- it("should handle large image files", async () => {
|
|
|
- // Setup - simulate a large image
|
|
|
- const largeBase64 = "A".repeat(1000000) // 1MB of base64 data
|
|
|
- const largeBuffer = Buffer.from(largeBase64, "base64")
|
|
|
- mockedFsReadFile.mockResolvedValue(largeBuffer)
|
|
|
-
|
|
|
- // Execute
|
|
|
- const result = await executeReadImageTool()
|
|
|
-
|
|
|
- // Verify it still works with large data
|
|
|
- expect(Array.isArray(result)).toBe(true)
|
|
|
- const imagePart = (result as any[]).find((p) => p.type === "image")
|
|
|
- expect(imagePart).toBeDefined()
|
|
|
- expect(imagePart.source.media_type).toBe("image/png")
|
|
|
- expect(imagePart.source.data).toBe(largeBase64)
|
|
|
- })
|
|
|
+ it("should read text file with indentation mode", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- it("should exclude images when model does not support images", async () => {
|
|
|
- // Setup - mock API handler that doesn't support images
|
|
|
- setImageSupport(localMockCline, false)
|
|
|
+ const content = "class Foo {\n method() {\n return 42\n }\n}"
|
|
|
+ mockedFsReadFile.mockResolvedValue(Buffer.from(content))
|
|
|
+ mockedReadWithIndentation.mockReturnValue({
|
|
|
+ content: "1 | class Foo {\n2 | method() {\n3 | return 42\n4 | }\n5 | }",
|
|
|
+ returnedLines: 5,
|
|
|
+ totalLines: 5,
|
|
|
+ wasTruncated: false,
|
|
|
+ includedRanges: [[1, 5]],
|
|
|
+ })
|
|
|
|
|
|
- // Execute
|
|
|
- const result = await executeReadImageTool()
|
|
|
+ await readFileTool.execute(
|
|
|
+ {
|
|
|
+ path: "test.ts",
|
|
|
+ mode: "indentation",
|
|
|
+ indentation: { anchor_line: 3 },
|
|
|
+ },
|
|
|
+ mockTask as any,
|
|
|
+ callbacks,
|
|
|
+ )
|
|
|
|
|
|
- // When images are not supported, the tool should return just text (not call formatResponse.toolResult)
|
|
|
- expect(toolResultMock).not.toHaveBeenCalled()
|
|
|
- expect(typeof result).toBe("string")
|
|
|
- // Native format
|
|
|
- expect(result).toContain(`File: ${testImagePath}`)
|
|
|
- expect(result).toContain(`Note: Image file`)
|
|
|
+ expect(mockedReadWithIndentation).toHaveBeenCalledWith(
|
|
|
+ content,
|
|
|
+ expect.objectContaining({
|
|
|
+ anchorLine: 3,
|
|
|
+ }),
|
|
|
+ )
|
|
|
})
|
|
|
|
|
|
- it("should include images when model supports images", async () => {
|
|
|
- // Setup - mock API handler that supports images
|
|
|
- setImageSupport(localMockCline, true)
|
|
|
-
|
|
|
- // Execute
|
|
|
- const result = await executeReadImageTool()
|
|
|
-
|
|
|
- // Verify toolResultMock was called with images
|
|
|
- expect(toolResultMock).toHaveBeenCalledTimes(1)
|
|
|
- const callArgs = toolResultMock.mock.calls[0]
|
|
|
- const textArg = callArgs[0]
|
|
|
- const imagesArg = callArgs[1]
|
|
|
-
|
|
|
- // Native format
|
|
|
- expect(textArg).toContain(`File: ${testImagePath}`)
|
|
|
- expect(imagesArg).toBeDefined() // Images should be included
|
|
|
- expect(imagesArg).toBeInstanceOf(Array)
|
|
|
- expect(imagesArg!.length).toBe(1)
|
|
|
- expect(imagesArg![0]).toBe(`data:image/png;base64,${base64ImageData}`)
|
|
|
- })
|
|
|
+ it("should show truncation notice when content is truncated", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- it("should handle undefined supportsImages gracefully", async () => {
|
|
|
- // Setup - mock API handler with undefined supportsImages
|
|
|
- setImageSupport(localMockCline, undefined)
|
|
|
+ mockedFsReadFile.mockResolvedValue(Buffer.from("lots of content..."))
|
|
|
+ mockedReadWithSlice.mockReturnValue({
|
|
|
+ content: "1 | truncated content",
|
|
|
+ returnedLines: 100,
|
|
|
+ totalLines: 5000,
|
|
|
+ wasTruncated: true,
|
|
|
+ includedRanges: [[1, 100]],
|
|
|
+ })
|
|
|
|
|
|
- // Execute
|
|
|
- const result = await executeReadImageTool()
|
|
|
+ await readFileTool.execute({ path: "large.ts" }, mockTask as any, callbacks)
|
|
|
|
|
|
- // When supportsImages is undefined, should default to false and return just text
|
|
|
- expect(toolResultMock).not.toHaveBeenCalled()
|
|
|
- expect(typeof result).toBe("string")
|
|
|
- // Native format
|
|
|
- expect(result).toContain(`File: ${testImagePath}`)
|
|
|
- expect(result).toContain(`Note: Image file`)
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("truncated"))
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("To read more"))
|
|
|
})
|
|
|
|
|
|
- it("should handle errors when reading image files", async () => {
|
|
|
- // Setup - simulate read error
|
|
|
- mockedFsReadFile.mockRejectedValue(new Error("Failed to read image"))
|
|
|
-
|
|
|
- // Execute
|
|
|
- const toolUse: ReadFileToolUse = {
|
|
|
- type: "tool_use",
|
|
|
- name: "read_file",
|
|
|
- params: {},
|
|
|
- partial: false,
|
|
|
- nativeArgs: {
|
|
|
- files: [{ path: testImagePath, lineRanges: [] }],
|
|
|
- },
|
|
|
- }
|
|
|
+ it("should handle empty files", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- await readFileTool.handle(localMockCline, toolUse, {
|
|
|
- askApproval: localMockCline.ask,
|
|
|
- handleError: vi.fn(),
|
|
|
- pushToolResult: (result: ToolResponse) => {
|
|
|
- toolResult = result
|
|
|
- },
|
|
|
+ mockedFsReadFile.mockResolvedValue(Buffer.from(""))
|
|
|
+ mockedReadWithSlice.mockReturnValue({
|
|
|
+ content: "",
|
|
|
+ returnedLines: 0,
|
|
|
+ totalLines: 0,
|
|
|
+ wasTruncated: false,
|
|
|
+ includedRanges: [],
|
|
|
})
|
|
|
|
|
|
- // Verify error handling - native format
|
|
|
- expect(toolResult).toContain("Error: Error reading image file: Failed to read image")
|
|
|
- // Verify that say was called to show error to user
|
|
|
- expect(localMockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("Failed to read image"))
|
|
|
+ await readFileTool.execute({ path: "empty.ts" }, mockTask as any, callbacks)
|
|
|
+
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("empty"))
|
|
|
})
|
|
|
})
|
|
|
|
|
|
- describe("Binary File Handling", () => {
|
|
|
- it("should not treat non-image binary files as images", async () => {
|
|
|
- // Setup
|
|
|
- const binaryPath = "test/document.pdf"
|
|
|
- const absolutePath = "/test/document.pdf"
|
|
|
- mockedPathResolve.mockReturnValue(absolutePath)
|
|
|
- mockedExtractTextFromFile.mockResolvedValue("PDF content extracted")
|
|
|
-
|
|
|
- // Execute
|
|
|
- const result = await executeReadImageTool(binaryPath)
|
|
|
-
|
|
|
- // Verify it uses extractTextFromFile instead
|
|
|
- expect(result).not.toContain("<image_data>")
|
|
|
- // Make the test platform-agnostic by checking the call was made (path normalization can vary)
|
|
|
- expect(mockedExtractTextFromFile).toHaveBeenCalledTimes(1)
|
|
|
- const callArgs = mockedExtractTextFromFile.mock.calls[0]
|
|
|
- expect(callArgs[0]).toMatch(/[\\\/]test[\\\/]document\.pdf$/)
|
|
|
- })
|
|
|
+ describe("approval flow", () => {
|
|
|
+ it("should approve file read when user clicks yes", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- it("should handle unknown binary formats", async () => {
|
|
|
- // Setup
|
|
|
- const binaryPath = "test/unknown.bin"
|
|
|
- const absolutePath = "/test/unknown.bin"
|
|
|
- mockedPathResolve.mockReturnValue(absolutePath)
|
|
|
- mockedExtractTextFromFile.mockResolvedValue("")
|
|
|
+ mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined })
|
|
|
|
|
|
- // Execute
|
|
|
- const result = await executeReadImageTool(binaryPath)
|
|
|
+ await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks)
|
|
|
|
|
|
- // Verify - native format for binary files
|
|
|
- expect(result).not.toContain("<image_data>")
|
|
|
- expect(result).toContain("Binary file (bin)")
|
|
|
+ expect(mockTask.ask).toHaveBeenCalledWith("tool", expect.any(String), false)
|
|
|
+ expect(mockTask.didRejectTool).toBe(false)
|
|
|
})
|
|
|
- })
|
|
|
|
|
|
- describe("Edge Cases", () => {
|
|
|
- it("should handle case-insensitive image extensions", async () => {
|
|
|
- // Test uppercase extensions
|
|
|
- const uppercasePath = "test/IMAGE.PNG"
|
|
|
- const absolutePath = "/test/IMAGE.PNG"
|
|
|
- mockedPathResolve.mockReturnValue(absolutePath)
|
|
|
-
|
|
|
- // Execute
|
|
|
- const result = await executeReadImageTool(uppercasePath)
|
|
|
-
|
|
|
- // Verify
|
|
|
- expect(Array.isArray(result)).toBe(true)
|
|
|
- const imagePart = (result as any[]).find((p) => p.type === "image")
|
|
|
- expect(imagePart).toBeDefined()
|
|
|
- expect(imagePart.source.media_type).toBe("image/png")
|
|
|
- })
|
|
|
+ it("should deny file read when user clicks no", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- it("should handle files with multiple dots in name", async () => {
|
|
|
- // Setup
|
|
|
- const complexPath = "test/my.photo.backup.png"
|
|
|
- const absolutePath = "/test/my.photo.backup.png"
|
|
|
- mockedPathResolve.mockReturnValue(absolutePath)
|
|
|
+ mockTask.ask.mockResolvedValue({ response: "noButtonClicked", text: undefined, images: undefined })
|
|
|
|
|
|
- // Execute
|
|
|
- const result = await executeReadImageTool(complexPath)
|
|
|
+ await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks)
|
|
|
|
|
|
- // Verify
|
|
|
- expect(Array.isArray(result)).toBe(true)
|
|
|
- const imagePart = (result as any[]).find((p) => p.type === "image")
|
|
|
- expect(imagePart).toBeDefined()
|
|
|
- expect(imagePart.source.media_type).toBe("image/png")
|
|
|
+ expect(mockTask.didRejectTool).toBe(true)
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Denied by user"))
|
|
|
})
|
|
|
|
|
|
- it("should handle empty image files", async () => {
|
|
|
- // Setup - empty buffer
|
|
|
- mockedFsReadFile.mockResolvedValue(Buffer.from(""))
|
|
|
+ it("should include user feedback when provided with approval", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
+
|
|
|
+ mockTask.ask.mockResolvedValue({
|
|
|
+ response: "yesButtonClicked",
|
|
|
+ text: "Please be careful with this file",
|
|
|
+ images: undefined,
|
|
|
+ })
|
|
|
+ mockedFsReadFile.mockResolvedValue(Buffer.from("content"))
|
|
|
+ mockedReadWithSlice.mockReturnValue({
|
|
|
+ content: "1 | content",
|
|
|
+ returnedLines: 1,
|
|
|
+ totalLines: 1,
|
|
|
+ wasTruncated: false,
|
|
|
+ includedRanges: [[1, 1]],
|
|
|
+ })
|
|
|
|
|
|
- // Execute
|
|
|
- const result = await executeReadImageTool()
|
|
|
+ await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks)
|
|
|
|
|
|
- // Verify - should still create valid data URL
|
|
|
- expect(Array.isArray(result)).toBe(true)
|
|
|
- const imagePart = (result as any[]).find((p) => p.type === "image")
|
|
|
- expect(imagePart).toBeDefined()
|
|
|
- expect(imagePart.source.media_type).toBe("image/png")
|
|
|
- expect(imagePart.source.data).toBe("")
|
|
|
+ expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "Please be careful with this file", undefined)
|
|
|
+ expect(formatResponse.toolApprovedWithFeedback).toHaveBeenCalledWith("Please be careful with this file")
|
|
|
})
|
|
|
- })
|
|
|
-})
|
|
|
-
|
|
|
-describe("read_file tool concurrent file reads limit", () => {
|
|
|
- const mockedCountFileLines = vi.mocked(countFileLines)
|
|
|
- const mockedIsBinaryFile = vi.mocked(isBinaryFile)
|
|
|
- const mockedPathResolve = vi.mocked(path.resolve)
|
|
|
|
|
|
- let mockCline: any
|
|
|
- let mockProvider: any
|
|
|
- let toolResult: ToolResponse | undefined
|
|
|
+ it("should include user feedback when provided with denial", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- beforeEach(() => {
|
|
|
- // Clear specific mocks
|
|
|
- mockedCountFileLines.mockClear()
|
|
|
- mockedIsBinaryFile.mockClear()
|
|
|
- mockedPathResolve.mockClear()
|
|
|
- addLineNumbersMock.mockClear()
|
|
|
- toolResultMock.mockClear()
|
|
|
-
|
|
|
- // Use shared mock setup function
|
|
|
- const mocks = createMockCline()
|
|
|
- mockCline = mocks.mockCline
|
|
|
- mockProvider = mocks.mockProvider
|
|
|
-
|
|
|
- // Disable image support for these tests
|
|
|
- setImageSupport(mockCline, false)
|
|
|
-
|
|
|
- mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`)
|
|
|
- mockedIsBinaryFile.mockResolvedValue(false)
|
|
|
- mockedCountFileLines.mockResolvedValue(10)
|
|
|
+ mockTask.ask.mockResolvedValue({
|
|
|
+ response: "noButtonClicked",
|
|
|
+ text: "This file contains secrets",
|
|
|
+ images: undefined,
|
|
|
+ })
|
|
|
|
|
|
- // Mock fsPromises.stat to return a file (not directory) by default
|
|
|
- fsPromises.stat.mockResolvedValue({
|
|
|
- isDirectory: () => false,
|
|
|
- isFile: () => true,
|
|
|
- isSymbolicLink: () => false,
|
|
|
- } as any)
|
|
|
+ await readFileTool.execute({ path: "secrets.env" }, mockTask as any, callbacks)
|
|
|
|
|
|
- toolResult = undefined
|
|
|
+ expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "This file contains secrets", undefined)
|
|
|
+ expect(formatResponse.toolDeniedWithFeedback).toHaveBeenCalledWith("This file contains secrets")
|
|
|
+ })
|
|
|
})
|
|
|
|
|
|
- async function executeReadFileToolWithLimit(
|
|
|
- fileCount: number,
|
|
|
- maxConcurrentFileReads: number,
|
|
|
- ): Promise<ToolResponse | undefined> {
|
|
|
- // Setup provider state with the specified limit
|
|
|
- mockProvider.getState.mockResolvedValue({
|
|
|
- maxReadFileLine: -1,
|
|
|
- maxConcurrentFileReads,
|
|
|
- maxImageFileSize: 20,
|
|
|
- maxTotalImageSize: 20,
|
|
|
- })
|
|
|
+ describe("output structure", () => {
|
|
|
+ it("should include file path in output", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
+
|
|
|
+ mockedFsReadFile.mockResolvedValue(Buffer.from("content"))
|
|
|
+ mockedReadWithSlice.mockReturnValue({
|
|
|
+ content: "1 | content",
|
|
|
+ returnedLines: 1,
|
|
|
+ totalLines: 1,
|
|
|
+ wasTruncated: false,
|
|
|
+ includedRanges: [[1, 1]],
|
|
|
+ })
|
|
|
|
|
|
- const toolUse: ReadFileToolUse = {
|
|
|
- type: "tool_use",
|
|
|
- name: "read_file",
|
|
|
- params: {},
|
|
|
- partial: false,
|
|
|
- nativeArgs: {
|
|
|
- files: Array.from({ length: fileCount }, (_, i) => ({ path: `file${i + 1}.txt`, lineRanges: [] })),
|
|
|
- },
|
|
|
- }
|
|
|
-
|
|
|
- // Configure mocks for successful file reads
|
|
|
- mockReadFileWithTokenBudget.mockResolvedValue({
|
|
|
- content: "test content",
|
|
|
- tokenCount: 10,
|
|
|
- lineCount: 1,
|
|
|
- complete: true,
|
|
|
- })
|
|
|
+ await readFileTool.execute({ path: "src/app.ts" }, mockTask as any, callbacks)
|
|
|
|
|
|
- await readFileTool.handle(mockCline, toolUse, {
|
|
|
- askApproval: mockCline.ask,
|
|
|
- handleError: vi.fn(),
|
|
|
- pushToolResult: (result: ToolResponse) => {
|
|
|
- toolResult = result
|
|
|
- },
|
|
|
+ expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("File: src/app.ts"))
|
|
|
})
|
|
|
|
|
|
- return toolResult
|
|
|
- }
|
|
|
+ it("should track file context after successful read", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- it("should reject when file count exceeds maxConcurrentFileReads", async () => {
|
|
|
- // Try to read 6 files when limit is 5
|
|
|
- const result = await executeReadFileToolWithLimit(6, 5)
|
|
|
+ mockedFsReadFile.mockResolvedValue(Buffer.from("content"))
|
|
|
+ mockedReadWithSlice.mockReturnValue({
|
|
|
+ content: "1 | content",
|
|
|
+ returnedLines: 1,
|
|
|
+ totalLines: 1,
|
|
|
+ wasTruncated: false,
|
|
|
+ includedRanges: [[1, 1]],
|
|
|
+ })
|
|
|
|
|
|
- // Verify error result
|
|
|
- expect(result).toContain("Error: Too many files requested")
|
|
|
- expect(result).toContain("You attempted to read 6 files")
|
|
|
- expect(result).toContain("but the concurrent file reads limit is 5")
|
|
|
- expect(result).toContain("Please read files in batches of 5 or fewer")
|
|
|
+ await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks)
|
|
|
|
|
|
- // Verify error tracking
|
|
|
- expect(mockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("Too many files requested"))
|
|
|
+ expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith("test.ts", "read_tool")
|
|
|
+ })
|
|
|
})
|
|
|
|
|
|
- it("should allow reading files when count equals maxConcurrentFileReads", async () => {
|
|
|
- // Try to read exactly 5 files when limit is 5
|
|
|
- const result = await executeReadFileToolWithLimit(5, 5)
|
|
|
+ describe("error handling", () => {
|
|
|
+ it("should handle file read errors gracefully", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- // Should not contain error
|
|
|
- expect(result).not.toContain("Error: Too many files requested")
|
|
|
+ mockedFsReadFile.mockRejectedValue(new Error("ENOENT: no such file or directory"))
|
|
|
|
|
|
- // Should contain file results
|
|
|
- expect(typeof result === "string" ? result : JSON.stringify(result)).toContain("file1.txt")
|
|
|
- })
|
|
|
+ await readFileTool.execute({ path: "nonexistent.ts" }, mockTask as any, callbacks)
|
|
|
|
|
|
- it("should allow reading files when count is below maxConcurrentFileReads", async () => {
|
|
|
- // Try to read 3 files when limit is 5
|
|
|
- const result = await executeReadFileToolWithLimit(3, 5)
|
|
|
+ expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading file"))
|
|
|
+ expect(mockTask.didToolFailInCurrentTurn).toBe(true)
|
|
|
+ })
|
|
|
|
|
|
- // Should not contain error
|
|
|
- expect(result).not.toContain("Error: Too many files requested")
|
|
|
+ it("should handle stat errors gracefully", async () => {
|
|
|
+ const mockTask = createMockTask()
|
|
|
+ const callbacks = createMockCallbacks()
|
|
|
|
|
|
- // Should contain file results
|
|
|
- expect(typeof result === "string" ? result : JSON.stringify(result)).toContain("file1.txt")
|
|
|
- })
|
|
|
+ mockedFsStat.mockRejectedValue(new Error("Permission denied"))
|
|
|
|
|
|
- it("should respect custom maxConcurrentFileReads value of 1", async () => {
|
|
|
- // Try to read 2 files when limit is 1
|
|
|
- const result = await executeReadFileToolWithLimit(2, 1)
|
|
|
+ await readFileTool.execute({ path: "protected.ts" }, mockTask as any, callbacks)
|
|
|
|
|
|
- // Verify error result with limit of 1
|
|
|
- expect(result).toContain("Error: Too many files requested")
|
|
|
- expect(result).toContain("You attempted to read 2 files")
|
|
|
- expect(result).toContain("but the concurrent file reads limit is 1")
|
|
|
+ expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading file"))
|
|
|
+ expect(mockTask.didToolFailInCurrentTurn).toBe(true)
|
|
|
+ })
|
|
|
})
|
|
|
|
|
|
- it("should allow single file read when maxConcurrentFileReads is 1", async () => {
|
|
|
- // Try to read 1 file when limit is 1
|
|
|
- const result = await executeReadFileToolWithLimit(1, 1)
|
|
|
+ describe("getReadFileToolDescription", () => {
|
|
|
+ it("should return description with path when nativeArgs provided", () => {
|
|
|
+ const description = readFileTool.getReadFileToolDescription("read_file", { path: "src/app.ts" })
|
|
|
|
|
|
- // Should not contain error
|
|
|
- expect(result).not.toContain("Error: Too many files requested")
|
|
|
-
|
|
|
- // Should contain file result
|
|
|
- expect(typeof result === "string" ? result : JSON.stringify(result)).toContain("file1.txt")
|
|
|
- })
|
|
|
+ expect(description).toBe("[read_file for 'src/app.ts']")
|
|
|
+ })
|
|
|
|
|
|
- it("should respect higher maxConcurrentFileReads value", async () => {
|
|
|
- // Try to read 15 files when limit is 10
|
|
|
- const result = await executeReadFileToolWithLimit(15, 10)
|
|
|
+ it("should return description with path when params provided", () => {
|
|
|
+ const description = readFileTool.getReadFileToolDescription("read_file", { path: "src/app.ts" })
|
|
|
|
|
|
- // Verify error result
|
|
|
- expect(result).toContain("Error: Too many files requested")
|
|
|
- expect(result).toContain("You attempted to read 15 files")
|
|
|
- expect(result).toContain("but the concurrent file reads limit is 10")
|
|
|
- })
|
|
|
-
|
|
|
- it("should use default value of 5 when maxConcurrentFileReads is not set", async () => {
|
|
|
- // Setup provider state without maxConcurrentFileReads
|
|
|
- mockProvider.getState.mockResolvedValue({
|
|
|
- maxReadFileLine: -1,
|
|
|
- maxImageFileSize: 20,
|
|
|
- maxTotalImageSize: 20,
|
|
|
+ expect(description).toBe("[read_file for 'src/app.ts']")
|
|
|
})
|
|
|
|
|
|
- const toolUse: ReadFileToolUse = {
|
|
|
- type: "tool_use",
|
|
|
- name: "read_file",
|
|
|
- params: {},
|
|
|
- partial: false,
|
|
|
- nativeArgs: {
|
|
|
- files: Array.from({ length: 6 }, (_, i) => ({ path: `file${i + 1}.txt`, lineRanges: [] })),
|
|
|
- },
|
|
|
- }
|
|
|
-
|
|
|
- mockReadFileWithTokenBudget.mockResolvedValue({
|
|
|
- content: "test content",
|
|
|
- tokenCount: 10,
|
|
|
- lineCount: 1,
|
|
|
- complete: true,
|
|
|
- })
|
|
|
+ it("should return description indicating missing path", () => {
|
|
|
+ const description = readFileTool.getReadFileToolDescription("read_file", {})
|
|
|
|
|
|
- await readFileTool.handle(mockCline, toolUse, {
|
|
|
- askApproval: mockCline.ask,
|
|
|
- handleError: vi.fn(),
|
|
|
- pushToolResult: (result: ToolResponse) => {
|
|
|
- toolResult = result
|
|
|
- },
|
|
|
+ expect(description).toBe("[read_file with missing path]")
|
|
|
})
|
|
|
-
|
|
|
- // Should use default limit of 5 and reject 6 files
|
|
|
- expect(toolResult).toContain("Error: Too many files requested")
|
|
|
- expect(toolResult).toContain("but the concurrent file reads limit is 5")
|
|
|
})
|
|
|
})
|