| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636 |
- import type { Mock } from "vitest"
- // Mock dependencies - must come before imports
- vi.mock("../../../api/providers/fetchers/modelCache")
- import { webviewMessageHandler } from "../webviewMessageHandler"
- import type { ClineProvider } from "../ClineProvider"
- import { getModels } from "../../../api/providers/fetchers/modelCache"
- import type { ModelRecord } from "../../../shared/api"
- const mockGetModels = getModels as Mock<typeof getModels>
- // Mock ClineProvider
- const mockClineProvider = {
- getState: vi.fn(),
- postMessageToWebview: vi.fn(),
- customModesManager: {
- getCustomModes: vi.fn(),
- deleteCustomMode: vi.fn(),
- },
- context: {
- extensionPath: "/mock/extension/path",
- globalStorageUri: { fsPath: "/mock/global/storage" },
- },
- contextProxy: {
- context: {
- extensionPath: "/mock/extension/path",
- globalStorageUri: { fsPath: "/mock/global/storage" },
- },
- setValue: vi.fn(),
- getValue: vi.fn(),
- },
- log: vi.fn(),
- postStateToWebview: vi.fn(),
- getCurrentTask: vi.fn(),
- getTaskWithId: vi.fn(),
- createTaskWithHistoryItem: vi.fn(),
- } as unknown as ClineProvider
- import { t } from "../../../i18n"
- vi.mock("vscode", () => ({
- window: {
- showInformationMessage: vi.fn(),
- showErrorMessage: vi.fn(),
- },
- workspace: {
- workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
- },
- }))
- vi.mock("../../../i18n", () => ({
- t: vi.fn((key: string, args?: Record<string, any>) => {
- // For the delete confirmation with rules, we need to return the interpolated string
- if (key === "common:confirmation.delete_custom_mode_with_rules" && args) {
- return `Are you sure you want to delete this ${args.scope} mode?\n\nThis will also delete the associated rules folder at:\n${args.rulesFolderPath}`
- }
- // Return the translated value for "Yes"
- if (key === "common:answers.yes") {
- return "Yes"
- }
- // Return the translated value for "Cancel"
- if (key === "common:answers.cancel") {
- return "Cancel"
- }
- return key
- }),
- }))
- vi.mock("fs/promises", () => {
- const mockRm = vi.fn().mockResolvedValue(undefined)
- const mockMkdir = vi.fn().mockResolvedValue(undefined)
- return {
- default: {
- rm: mockRm,
- mkdir: mockMkdir,
- },
- rm: mockRm,
- mkdir: mockMkdir,
- }
- })
- import * as vscode from "vscode"
- import * as fs from "fs/promises"
- import * as os from "os"
- import * as path from "path"
- import * as fsUtils from "../../../utils/fs"
- import { getWorkspacePath } from "../../../utils/path"
- import { ensureSettingsDirectoryExists } from "../../../utils/globalContext"
- import type { ModeConfig } from "@roo-code/types"
- vi.mock("../../../utils/fs")
- vi.mock("../../../utils/path")
- vi.mock("../../../utils/globalContext")
- describe("webviewMessageHandler - requestLmStudioModels", () => {
- beforeEach(() => {
- vi.clearAllMocks()
- mockClineProvider.getState = vi.fn().mockResolvedValue({
- apiConfiguration: {
- lmStudioModelId: "model-1",
- lmStudioBaseUrl: "http://localhost:1234",
- },
- })
- })
- it("successfully fetches models from LMStudio", async () => {
- const mockModels: ModelRecord = {
- "model-1": {
- maxTokens: 4096,
- contextWindow: 8192,
- supportsPromptCache: false,
- description: "Test model 1",
- },
- "model-2": {
- maxTokens: 8192,
- contextWindow: 16384,
- supportsPromptCache: false,
- description: "Test model 2",
- },
- }
- mockGetModels.mockResolvedValue(mockModels)
- await webviewMessageHandler(mockClineProvider, {
- type: "requestLmStudioModels",
- })
- expect(mockGetModels).toHaveBeenCalledWith({ provider: "lmstudio", baseUrl: "http://localhost:1234" })
- expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
- type: "lmStudioModels",
- lmStudioModels: mockModels,
- })
- })
- })
- describe("webviewMessageHandler - requestRouterModels", () => {
- beforeEach(() => {
- vi.clearAllMocks()
- mockClineProvider.getState = vi.fn().mockResolvedValue({
- apiConfiguration: {
- openRouterApiKey: "openrouter-key",
- requestyApiKey: "requesty-key",
- glamaApiKey: "glama-key",
- unboundApiKey: "unbound-key",
- litellmApiKey: "litellm-key",
- litellmBaseUrl: "http://localhost:4000",
- },
- })
- })
- it("successfully fetches models from all providers", async () => {
- const mockModels: ModelRecord = {
- "model-1": {
- maxTokens: 4096,
- contextWindow: 8192,
- supportsPromptCache: false,
- description: "Test model 1",
- },
- "model-2": {
- maxTokens: 8192,
- contextWindow: 16384,
- supportsPromptCache: false,
- description: "Test model 2",
- },
- }
- mockGetModels.mockResolvedValue(mockModels)
- await webviewMessageHandler(mockClineProvider, {
- type: "requestRouterModels",
- })
- // Verify getModels was called for each provider
- expect(mockGetModels).toHaveBeenCalledWith({ provider: "openrouter" })
- expect(mockGetModels).toHaveBeenCalledWith({ provider: "requesty", apiKey: "requesty-key" })
- expect(mockGetModels).toHaveBeenCalledWith({ provider: "glama" })
- expect(mockGetModels).toHaveBeenCalledWith({ provider: "unbound", apiKey: "unbound-key" })
- expect(mockGetModels).toHaveBeenCalledWith({ provider: "vercel-ai-gateway" })
- expect(mockGetModels).toHaveBeenCalledWith({
- provider: "litellm",
- apiKey: "litellm-key",
- baseUrl: "http://localhost:4000",
- })
- // Verify response was sent
- expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
- type: "routerModels",
- routerModels: {
- openrouter: mockModels,
- requesty: mockModels,
- glama: mockModels,
- unbound: mockModels,
- litellm: mockModels,
- ollama: {},
- lmstudio: {},
- "vercel-ai-gateway": mockModels,
- },
- })
- })
- it("handles LiteLLM models with values from message when config is missing", async () => {
- mockClineProvider.getState = vi.fn().mockResolvedValue({
- apiConfiguration: {
- openRouterApiKey: "openrouter-key",
- requestyApiKey: "requesty-key",
- glamaApiKey: "glama-key",
- unboundApiKey: "unbound-key",
- // Missing litellm config
- },
- })
- const mockModels: ModelRecord = {
- "model-1": {
- maxTokens: 4096,
- contextWindow: 8192,
- supportsPromptCache: false,
- description: "Test model 1",
- },
- }
- mockGetModels.mockResolvedValue(mockModels)
- await webviewMessageHandler(mockClineProvider, {
- type: "requestRouterModels",
- values: {
- litellmApiKey: "message-litellm-key",
- litellmBaseUrl: "http://message-url:4000",
- },
- })
- // Verify LiteLLM was called with values from message
- expect(mockGetModels).toHaveBeenCalledWith({
- provider: "litellm",
- apiKey: "message-litellm-key",
- baseUrl: "http://message-url:4000",
- })
- })
- it("skips LiteLLM when both config and message values are missing", async () => {
- mockClineProvider.getState = vi.fn().mockResolvedValue({
- apiConfiguration: {
- openRouterApiKey: "openrouter-key",
- requestyApiKey: "requesty-key",
- glamaApiKey: "glama-key",
- unboundApiKey: "unbound-key",
- // Missing litellm config
- },
- })
- const mockModels: ModelRecord = {
- "model-1": {
- maxTokens: 4096,
- contextWindow: 8192,
- supportsPromptCache: false,
- description: "Test model 1",
- },
- }
- mockGetModels.mockResolvedValue(mockModels)
- await webviewMessageHandler(mockClineProvider, {
- type: "requestRouterModels",
- // No values provided
- })
- // Verify LiteLLM was NOT called
- expect(mockGetModels).not.toHaveBeenCalledWith(
- expect.objectContaining({
- provider: "litellm",
- }),
- )
- // Verify response includes empty object for LiteLLM
- expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
- type: "routerModels",
- routerModels: {
- openrouter: mockModels,
- requesty: mockModels,
- glama: mockModels,
- unbound: mockModels,
- litellm: {},
- ollama: {},
- lmstudio: {},
- "vercel-ai-gateway": mockModels,
- },
- })
- })
- it("handles individual provider failures gracefully", async () => {
- const mockModels: ModelRecord = {
- "model-1": {
- maxTokens: 4096,
- contextWindow: 8192,
- supportsPromptCache: false,
- description: "Test model 1",
- },
- }
- // Mock some providers to succeed and others to fail
- mockGetModels
- .mockResolvedValueOnce(mockModels) // openrouter
- .mockRejectedValueOnce(new Error("Requesty API error")) // requesty
- .mockResolvedValueOnce(mockModels) // glama
- .mockRejectedValueOnce(new Error("Unbound API error")) // unbound
- .mockResolvedValueOnce(mockModels) // vercel-ai-gateway
- .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm
- await webviewMessageHandler(mockClineProvider, {
- type: "requestRouterModels",
- })
- // Verify successful providers are included
- expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
- type: "routerModels",
- routerModels: {
- openrouter: mockModels,
- requesty: {},
- glama: mockModels,
- unbound: {},
- litellm: {},
- ollama: {},
- lmstudio: {},
- "vercel-ai-gateway": mockModels,
- },
- })
- // Verify error messages were sent for failed providers
- expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
- type: "singleRouterModelFetchResponse",
- success: false,
- error: "Requesty API error",
- values: { provider: "requesty" },
- })
- expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
- type: "singleRouterModelFetchResponse",
- success: false,
- error: "Unbound API error",
- values: { provider: "unbound" },
- })
- expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
- type: "singleRouterModelFetchResponse",
- success: false,
- error: "LiteLLM connection failed",
- values: { provider: "litellm" },
- })
- })
- it("handles Error objects and string errors correctly", async () => {
- // Mock providers to fail with different error types
- mockGetModels
- .mockRejectedValueOnce(new Error("Structured error message")) // openrouter
- .mockRejectedValueOnce(new Error("Requesty API error")) // requesty
- .mockRejectedValueOnce(new Error("Glama API error")) // glama
- .mockRejectedValueOnce(new Error("Unbound API error")) // unbound
- .mockRejectedValueOnce(new Error("Vercel AI Gateway error")) // vercel-ai-gateway
- .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm
- await webviewMessageHandler(mockClineProvider, {
- type: "requestRouterModels",
- })
- // Verify error handling for different error types
- expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
- type: "singleRouterModelFetchResponse",
- success: false,
- error: "Structured error message",
- values: { provider: "openrouter" },
- })
- expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
- type: "singleRouterModelFetchResponse",
- success: false,
- error: "Requesty API error",
- values: { provider: "requesty" },
- })
- expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
- type: "singleRouterModelFetchResponse",
- success: false,
- error: "Glama API error",
- values: { provider: "glama" },
- })
- expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
- type: "singleRouterModelFetchResponse",
- success: false,
- error: "Unbound API error",
- values: { provider: "unbound" },
- })
- expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
- type: "singleRouterModelFetchResponse",
- success: false,
- error: "LiteLLM connection failed",
- values: { provider: "litellm" },
- })
- })
- it("prefers config values over message values for LiteLLM", async () => {
- const mockModels: ModelRecord = {}
- mockGetModels.mockResolvedValue(mockModels)
- await webviewMessageHandler(mockClineProvider, {
- type: "requestRouterModels",
- values: {
- litellmApiKey: "message-key",
- litellmBaseUrl: "http://message-url",
- },
- })
- // Verify config values are used over message values
- expect(mockGetModels).toHaveBeenCalledWith({
- provider: "litellm",
- apiKey: "litellm-key", // From config
- baseUrl: "http://localhost:4000", // From config
- })
- })
- })
- describe("webviewMessageHandler - deleteCustomMode", () => {
- beforeEach(() => {
- vi.clearAllMocks()
- vi.mocked(getWorkspacePath).mockReturnValue("/mock/workspace")
- vi.mocked(vscode.window.showErrorMessage).mockResolvedValue(undefined)
- vi.mocked(ensureSettingsDirectoryExists).mockResolvedValue("/mock/global/storage/.roo")
- })
- it("should delete a project mode and its rules folder", async () => {
- const slug = "test-project-mode"
- const rulesFolderPath = path.join("/mock/workspace", ".roo", `rules-${slug}`)
- vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([
- {
- name: "Test Project Mode",
- slug,
- roleDefinition: "Test Role",
- groups: [],
- source: "project",
- } as ModeConfig,
- ])
- vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(true)
- vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined)
- await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug })
- // The confirmation dialog is now handled in the webview, so we don't expect showInformationMessage to be called
- expect(vscode.window.showInformationMessage).not.toHaveBeenCalled()
- expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith(slug)
- expect(fs.rm).toHaveBeenCalledWith(rulesFolderPath, { recursive: true, force: true })
- })
- it("should delete a global mode and its rules folder", async () => {
- const slug = "test-global-mode"
- const homeDir = os.homedir()
- const rulesFolderPath = path.join(homeDir, ".roo", `rules-${slug}`)
- vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([
- {
- name: "Test Global Mode",
- slug,
- roleDefinition: "Test Role",
- groups: [],
- source: "global",
- } as ModeConfig,
- ])
- vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(true)
- vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined)
- await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug })
- // The confirmation dialog is now handled in the webview, so we don't expect showInformationMessage to be called
- expect(vscode.window.showInformationMessage).not.toHaveBeenCalled()
- expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith(slug)
- expect(fs.rm).toHaveBeenCalledWith(rulesFolderPath, { recursive: true, force: true })
- })
- it("should only delete the mode when rules folder does not exist", async () => {
- const slug = "test-mode-no-rules"
- vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([
- {
- name: "Test Mode No Rules",
- slug,
- roleDefinition: "Test Role",
- groups: [],
- source: "project",
- } as ModeConfig,
- ])
- vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(false)
- vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined)
- await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug })
- // The confirmation dialog is now handled in the webview, so we don't expect showInformationMessage to be called
- expect(vscode.window.showInformationMessage).not.toHaveBeenCalled()
- expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith(slug)
- expect(fs.rm).not.toHaveBeenCalled()
- })
- it("should handle errors when deleting rules folder", async () => {
- const slug = "test-mode-error"
- const rulesFolderPath = path.join("/mock/workspace", ".roo", `rules-${slug}`)
- const error = new Error("Permission denied")
- vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([
- {
- name: "Test Mode Error",
- slug,
- roleDefinition: "Test Role",
- groups: [],
- source: "project",
- } as ModeConfig,
- ])
- vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(true)
- vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined)
- vi.mocked(fs.rm).mockRejectedValue(error)
- await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug })
- expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith(slug)
- expect(fs.rm).toHaveBeenCalledWith(rulesFolderPath, { recursive: true, force: true })
- // Verify error message is shown to the user
- expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
- t("common:errors.delete_rules_folder_failed", {
- rulesFolderPath,
- error: error.message,
- }),
- )
- // No error response is sent anymore - we just continue with deletion
- expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled()
- })
- })
- describe("webviewMessageHandler - message dialog preferences", () => {
- beforeEach(() => {
- vi.clearAllMocks()
- // Mock a current Cline instance
- vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({
- taskId: "test-task-id",
- apiConversationHistory: [],
- clineMessages: [],
- } as any)
- // Reset getValue mock
- vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue(false)
- })
- describe("deleteMessage", () => {
- it("should always show dialog for delete confirmation", async () => {
- vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({} as any)
- await webviewMessageHandler(mockClineProvider, {
- type: "deleteMessage",
- value: 123456789, // Changed from messageTs to value
- })
- expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
- type: "showDeleteMessageDialog",
- messageTs: 123456789,
- })
- })
- })
- describe("submitEditedMessage", () => {
- it("should always show dialog for edit confirmation", async () => {
- vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({} as any)
- await webviewMessageHandler(mockClineProvider, {
- type: "submitEditedMessage",
- value: 123456789,
- editedMessageContent: "edited content",
- })
- expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
- type: "showEditMessageDialog",
- messageTs: 123456789,
- text: "edited content",
- })
- })
- })
- })
- describe("webviewMessageHandler - mcpEnabled", () => {
- let mockMcpHub: any
- beforeEach(() => {
- vi.clearAllMocks()
- // Create a mock McpHub instance
- mockMcpHub = {
- handleMcpEnabledChange: vi.fn().mockResolvedValue(undefined),
- }
- // Ensure provider exposes getMcpHub and returns our mock
- ;(mockClineProvider as any).getMcpHub = vi.fn().mockReturnValue(mockMcpHub)
- })
- it("delegates enable=true to McpHub and posts updated state", async () => {
- await webviewMessageHandler(mockClineProvider, {
- type: "mcpEnabled",
- bool: true,
- })
- expect((mockClineProvider as any).getMcpHub).toHaveBeenCalledTimes(1)
- expect(mockMcpHub.handleMcpEnabledChange).toHaveBeenCalledTimes(1)
- expect(mockMcpHub.handleMcpEnabledChange).toHaveBeenCalledWith(true)
- expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)
- })
- it("delegates enable=false to McpHub and posts updated state", async () => {
- await webviewMessageHandler(mockClineProvider, {
- type: "mcpEnabled",
- bool: false,
- })
- expect((mockClineProvider as any).getMcpHub).toHaveBeenCalledTimes(1)
- expect(mockMcpHub.handleMcpEnabledChange).toHaveBeenCalledTimes(1)
- expect(mockMcpHub.handleMcpEnabledChange).toHaveBeenCalledWith(false)
- expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)
- })
- it("handles missing McpHub instance gracefully and still posts state", async () => {
- ;(mockClineProvider as any).getMcpHub = vi.fn().mockReturnValue(undefined)
- await webviewMessageHandler(mockClineProvider, {
- type: "mcpEnabled",
- bool: true,
- })
- expect((mockClineProvider as any).getMcpHub).toHaveBeenCalledTimes(1)
- expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)
- })
- })
|