diff --git a/src/__mocks__/vscode.js b/src/__mocks__/vscode.js index 7fc82f559f0..505497fc9f1 100644 --- a/src/__mocks__/vscode.js +++ b/src/__mocks__/vscode.js @@ -41,7 +41,7 @@ export const workspace = { getWorkspaceFolder: () => null, onDidChangeWorkspaceFolders: () => mockDisposable, getConfiguration: () => ({ - get: () => null, + get: (key, defaultValue) => defaultValue, }), createFileSystemWatcher: () => ({ onDidCreate: () => mockDisposable, diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index 6c12c473fb9..89729fbbf3a 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -171,6 +171,44 @@ vi.mock("../i18n", () => ({ t: vi.fn((key) => key), })) +// Mock ClineProvider - remoteControlEnabled must call BridgeOrchestrator.disconnect for the test +vi.mock("../core/webview/ClineProvider", async () => { + const { BridgeOrchestrator } = await import("@roo-code/cloud") + const mockInstance = { + resolveWebviewView: vi.fn(), + postMessageToWebview: vi.fn(), + postStateToWebview: vi.fn(), + getState: vi.fn().mockResolvedValue({}), + remoteControlEnabled: vi.fn().mockImplementation(async (enabled: boolean) => { + if (!enabled) { + await BridgeOrchestrator.disconnect() + } + }), + initializeCloudProfileSyncWhenReady: vi.fn().mockResolvedValue(undefined), + providerSettingsManager: {}, + contextProxy: { getGlobalState: vi.fn() }, + customModesManager: {}, + upsertProviderProfile: vi.fn().mockResolvedValue(undefined), + } + return { + ClineProvider: Object.assign( + vi.fn().mockImplementation(() => mockInstance), + { + // Static method used by extension.ts + getVisibleInstance: vi.fn().mockReturnValue(mockInstance), + sideBarId: "roo-cline-sidebar", + }, + ), + } +}) + +// Mock modelCache to prevent network requests during module loading +vi.mock("../api/providers/fetchers/modelCache", () => ({ + flushModels: vi.fn(), + getModels: vi.fn().mockResolvedValue([]), + initializeModelCacheRefresh: vi.fn(), +})) + describe("extension.ts", () => { let mockContext: vscode.ExtensionContext let authStateChangedHandler: diff --git a/src/api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts b/src/api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts new file mode 100644 index 00000000000..baa7ae953bc --- /dev/null +++ b/src/api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts @@ -0,0 +1,119 @@ +// npx vitest run api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts + +import type { ModelInfo } from "@roo-code/types" + +import { BaseOpenAiCompatibleProvider } from "../base-openai-compatible-provider" + +// Mock the timeout config utility +vitest.mock("../utils/timeout-config", () => ({ + getApiRequestTimeout: vitest.fn(), +})) + +import { getApiRequestTimeout } from "../utils/timeout-config" + +// Mock OpenAI and capture constructor calls +const mockOpenAIConstructor = vitest.fn() + +vitest.mock("openai", () => { + return { + __esModule: true, + default: vitest.fn().mockImplementation((config) => { + mockOpenAIConstructor(config) + return { + chat: { + completions: { + create: vitest.fn(), + }, + }, + } + }), + } +}) + +// Create a concrete test implementation of the abstract base class +class TestOpenAiCompatibleProvider extends BaseOpenAiCompatibleProvider<"test-model"> { + constructor(apiKey: string) { + const testModels: Record<"test-model", ModelInfo> = { + "test-model": { + maxTokens: 4096, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0.5, + outputPrice: 1.5, + }, + } + + super({ + providerName: "TestProvider", + baseURL: "https://test.example.com/v1", + defaultProviderModelId: "test-model", + providerModels: testModels, + apiKey, + }) + } +} + +describe("BaseOpenAiCompatibleProvider Timeout Configuration", () => { + beforeEach(() => { + vitest.clearAllMocks() + }) + + it("should call getApiRequestTimeout when creating the provider", () => { + ;(getApiRequestTimeout as any).mockReturnValue(600000) + + new TestOpenAiCompatibleProvider("test-api-key") + + expect(getApiRequestTimeout).toHaveBeenCalled() + }) + + it("should pass the default timeout to the OpenAI client constructor", () => { + ;(getApiRequestTimeout as any).mockReturnValue(600000) // 600 seconds in ms + + new TestOpenAiCompatibleProvider("test-api-key") + + expect(mockOpenAIConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "https://test.example.com/v1", + apiKey: "test-api-key", + timeout: 600000, + }), + ) + }) + + it("should use custom timeout value from getApiRequestTimeout", () => { + ;(getApiRequestTimeout as any).mockReturnValue(1800000) // 30 minutes in ms + + new TestOpenAiCompatibleProvider("test-api-key") + + expect(mockOpenAIConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + timeout: 1800000, + }), + ) + }) + + it("should handle zero timeout (no timeout)", () => { + ;(getApiRequestTimeout as any).mockReturnValue(0) + + new TestOpenAiCompatibleProvider("test-api-key") + + expect(mockOpenAIConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + timeout: 0, + }), + ) + }) + + it("should pass DEFAULT_HEADERS to the OpenAI client constructor", () => { + ;(getApiRequestTimeout as any).mockReturnValue(600000) + + new TestOpenAiCompatibleProvider("test-api-key") + + expect(mockOpenAIConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + defaultHeaders: expect.any(Object), + }), + ) + }) +}) diff --git a/src/api/providers/__tests__/groq.spec.ts b/src/api/providers/__tests__/groq.spec.ts index 3b6239fd767..f89fd62a7fd 100644 --- a/src/api/providers/__tests__/groq.spec.ts +++ b/src/api/providers/__tests__/groq.spec.ts @@ -1,8 +1,5 @@ // npx vitest run src/api/providers/__tests__/groq.spec.ts -// Mock vscode first to avoid import errors -vitest.mock("vscode", () => ({})) - import OpenAI from "openai" import { Anthropic } from "@anthropic-ai/sdk" diff --git a/src/api/providers/__tests__/sambanova.spec.ts b/src/api/providers/__tests__/sambanova.spec.ts index f03f6720bb6..685cedf34c2 100644 --- a/src/api/providers/__tests__/sambanova.spec.ts +++ b/src/api/providers/__tests__/sambanova.spec.ts @@ -1,8 +1,5 @@ // npx vitest run src/api/providers/__tests__/sambanova.spec.ts -// Mock vscode first to avoid import errors -vitest.mock("vscode", () => ({})) - import OpenAI from "openai" import { Anthropic } from "@anthropic-ai/sdk" diff --git a/src/api/providers/__tests__/zai.spec.ts b/src/api/providers/__tests__/zai.spec.ts index 083fdc13ef8..72f6d5436eb 100644 --- a/src/api/providers/__tests__/zai.spec.ts +++ b/src/api/providers/__tests__/zai.spec.ts @@ -1,8 +1,5 @@ // npx vitest run src/api/providers/__tests__/zai.spec.ts -// Mock vscode first to avoid import errors -vitest.mock("vscode", () => ({})) - import OpenAI from "openai" import { Anthropic } from "@anthropic-ai/sdk" diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index 6678f21d68c..92b9558c451 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -13,6 +13,7 @@ import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import { handleOpenAIError } from "./utils/openai-error-handler" import { calculateApiCostOpenAI } from "../../shared/cost" +import { getApiRequestTimeout } from "./utils/timeout-config" type BaseOpenAiCompatibleProviderOptions = ApiHandlerOptions & { providerName: string @@ -62,6 +63,7 @@ export abstract class BaseOpenAiCompatibleProvider baseURL, apiKey: this.options.apiKey, defaultHeaders: DEFAULT_HEADERS, + timeout: getApiRequestTimeout(), }) } diff --git a/src/core/task/__tests__/grounding-sources.test.ts b/src/core/task/__tests__/grounding-sources.test.ts index ba747f40c77..a33e4fd5d2c 100644 --- a/src/core/task/__tests__/grounding-sources.test.ts +++ b/src/core/task/__tests__/grounding-sources.test.ts @@ -1,7 +1,10 @@ -import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest" +import { describe, it, expect, vi, beforeEach } from "vitest" import type { ClineProvider } from "../../webview/ClineProvider" import type { ProviderSettings } from "@roo-code/types" +// All vi.mock() calls are hoisted to the top of the file by Vitest +// and are applied before any imports are resolved + // Mock vscode module before importing Task vi.mock("vscode", () => ({ workspace: { @@ -72,16 +75,92 @@ vi.mock("@roo-code/telemetry", () => ({ }, })) +// Mock @roo-code/cloud to prevent socket.io-client initialization issues +vi.mock("@roo-code/cloud", () => ({ + CloudService: { + isEnabled: () => false, + }, + BridgeOrchestrator: { + subscribeToTask: vi.fn(), + }, +})) + +// Mock delay to prevent actual delays +vi.mock("delay", () => ({ + __esModule: true, + default: vi.fn().mockResolvedValue(undefined), +})) + +// Mock p-wait-for to prevent hanging on async conditions +vi.mock("p-wait-for", () => ({ + default: vi.fn().mockResolvedValue(undefined), +})) + +// Mock execa +vi.mock("execa", () => ({ + execa: vi.fn(), +})) + +// Mock fs/promises +vi.mock("fs/promises", () => ({ + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue("[]"), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), +})) + +// Mock mentions +vi.mock("../../mentions", () => ({ + parseMentions: vi.fn().mockImplementation((text) => Promise.resolve(text)), + openMention: vi.fn(), + getLatestTerminalOutput: vi.fn(), +})) + +// Mock extract-text +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"), +})) + +// Mock getEnvironmentDetails +vi.mock("../../environment/getEnvironmentDetails", () => ({ + getEnvironmentDetails: vi.fn().mockResolvedValue(""), +})) + +// Mock RooIgnoreController +vi.mock("../../ignore/RooIgnoreController") + +// Mock condense +vi.mock("../../condense", () => ({ + summarizeConversation: vi.fn().mockResolvedValue({ + messages: [], + summary: "summary", + cost: 0, + newContextTokens: 1, + }), +})) + +// Mock storage utilities +vi.mock("../../../utils/storage", () => ({ + getTaskDirectoryPath: vi + .fn() + .mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)), + getSettingsDirectoryPath: vi + .fn() + .mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)), +})) + +// Mock fs utilities +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockReturnValue(false), +})) + +// Import Task AFTER all vi.mock() calls - Vitest hoists mocks so this works +import { Task } from "../Task" + describe("Task grounding sources handling", () => { let mockProvider: Partial let mockApiConfiguration: ProviderSettings - let Task: any - - beforeAll(async () => { - // Import Task after mocks are set up - const taskModule = await import("../Task") - Task = taskModule.Task - }) beforeEach(() => { // Mock provider with necessary methods diff --git a/src/core/task/__tests__/reasoning-preservation.test.ts b/src/core/task/__tests__/reasoning-preservation.test.ts index 7a73d2b1d07..3b0f773956c 100644 --- a/src/core/task/__tests__/reasoning-preservation.test.ts +++ b/src/core/task/__tests__/reasoning-preservation.test.ts @@ -1,7 +1,10 @@ -import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest" +import { describe, it, expect, vi, beforeEach } from "vitest" import type { ClineProvider } from "../../webview/ClineProvider" import type { ProviderSettings, ModelInfo } from "@roo-code/types" +// All vi.mock() calls are hoisted to the top of the file by Vitest +// and are applied before any imports are resolved + // Mock vscode module before importing Task vi.mock("vscode", () => ({ workspace: { @@ -72,16 +75,92 @@ vi.mock("@roo-code/telemetry", () => ({ }, })) +// Mock @roo-code/cloud to prevent socket.io-client initialization issues +vi.mock("@roo-code/cloud", () => ({ + CloudService: { + isEnabled: () => false, + }, + BridgeOrchestrator: { + subscribeToTask: vi.fn(), + }, +})) + +// Mock delay to prevent actual delays +vi.mock("delay", () => ({ + __esModule: true, + default: vi.fn().mockResolvedValue(undefined), +})) + +// Mock p-wait-for to prevent hanging on async conditions +vi.mock("p-wait-for", () => ({ + default: vi.fn().mockResolvedValue(undefined), +})) + +// Mock execa +vi.mock("execa", () => ({ + execa: vi.fn(), +})) + +// Mock fs/promises +vi.mock("fs/promises", () => ({ + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue("[]"), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), +})) + +// Mock mentions +vi.mock("../../mentions", () => ({ + parseMentions: vi.fn().mockImplementation((text) => Promise.resolve(text)), + openMention: vi.fn(), + getLatestTerminalOutput: vi.fn(), +})) + +// Mock extract-text +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"), +})) + +// Mock getEnvironmentDetails +vi.mock("../../environment/getEnvironmentDetails", () => ({ + getEnvironmentDetails: vi.fn().mockResolvedValue(""), +})) + +// Mock RooIgnoreController +vi.mock("../../ignore/RooIgnoreController") + +// Mock condense +vi.mock("../../condense", () => ({ + summarizeConversation: vi.fn().mockResolvedValue({ + messages: [], + summary: "summary", + cost: 0, + newContextTokens: 1, + }), +})) + +// Mock storage utilities +vi.mock("../../../utils/storage", () => ({ + getTaskDirectoryPath: vi + .fn() + .mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)), + getSettingsDirectoryPath: vi + .fn() + .mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)), +})) + +// Mock fs utilities +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockReturnValue(false), +})) + +// Import Task AFTER all vi.mock() calls - Vitest hoists mocks so this works +import { Task } from "../Task" + describe("Task reasoning preservation", () => { let mockProvider: Partial let mockApiConfiguration: ProviderSettings - let Task: any - - beforeAll(async () => { - // Import Task after mocks are set up - const taskModule = await import("../Task") - Task = taskModule.Task - }) beforeEach(() => { // Mock provider with necessary methods @@ -127,7 +206,7 @@ describe("Task reasoning preservation", () => { id: "test-model", info: mockModelInfo, }), - } + } as any // Mock the API conversation history task.apiConversationHistory = [] @@ -163,10 +242,12 @@ describe("Task reasoning preservation", () => { // Verify the API conversation history contains the message with reasoning expect(task.apiConversationHistory).toHaveLength(1) - expect(task.apiConversationHistory[0].content[0].text).toContain("") - expect(task.apiConversationHistory[0].content[0].text).toContain("") - expect(task.apiConversationHistory[0].content[0].text).toContain("Here is my response to your question.") - expect(task.apiConversationHistory[0].content[0].text).toContain( + expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toContain("") + expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toContain("") + expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toContain( + "Here is my response to your question.", + ) + expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toContain( "Let me think about this step by step. First, I need to...", ) }) @@ -192,7 +273,7 @@ describe("Task reasoning preservation", () => { id: "test-model", info: mockModelInfo, }), - } + } as any // Mock the API conversation history task.apiConversationHistory = [] @@ -223,8 +304,10 @@ describe("Task reasoning preservation", () => { // Verify the API conversation history does NOT contain reasoning expect(task.apiConversationHistory).toHaveLength(1) - expect(task.apiConversationHistory[0].content[0].text).toBe("Here is my response to your question.") - expect(task.apiConversationHistory[0].content[0].text).not.toContain("") + expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toBe( + "Here is my response to your question.", + ) + expect((task.apiConversationHistory[0].content[0] as { text: string }).text).not.toContain("") }) it("should handle empty reasoning message gracefully when preserveReasoning is true", async () => { @@ -248,7 +331,7 @@ describe("Task reasoning preservation", () => { id: "test-model", info: mockModelInfo, }), - } + } as any // Mock the API conversation history task.apiConversationHistory = [] @@ -277,8 +360,8 @@ describe("Task reasoning preservation", () => { }) // Verify the message doesn't contain reasoning tags - expect(task.apiConversationHistory[0].content[0].text).toBe("Here is my response.") - expect(task.apiConversationHistory[0].content[0].text).not.toContain("") + expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toBe("Here is my response.") + expect((task.apiConversationHistory[0].content[0] as { text: string }).text).not.toContain("") }) it("should handle undefined preserveReasoning (defaults to false)", async () => { @@ -302,7 +385,7 @@ describe("Task reasoning preservation", () => { id: "test-model", info: mockModelInfo, }), - } + } as any // Mock the API conversation history task.apiConversationHistory = [] @@ -322,8 +405,8 @@ describe("Task reasoning preservation", () => { }) // Verify reasoning was NOT prepended (undefined defaults to false) - expect(task.apiConversationHistory[0].content[0].text).toBe("Here is my response.") - expect(task.apiConversationHistory[0].content[0].text).not.toContain("") + expect((task.apiConversationHistory[0].content[0] as { text: string }).text).toBe("Here is my response.") + expect((task.apiConversationHistory[0].content[0] as { text: string }).text).not.toContain("") }) it("should embed encrypted reasoning as first assistant content block", async () => {