webviewMessageHandler.spec.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. import type { Mock } from "vitest"
  2. // Mock dependencies - must come before imports
  3. vi.mock("../../../api/providers/fetchers/modelCache")
  4. import { webviewMessageHandler } from "../webviewMessageHandler"
  5. import type { ClineProvider } from "../ClineProvider"
  6. import { getModels } from "../../../api/providers/fetchers/modelCache"
  7. import type { ModelRecord } from "../../../shared/api"
  8. const mockGetModels = getModels as Mock<typeof getModels>
  9. // Mock ClineProvider
  10. const mockClineProvider = {
  11. getState: vi.fn(),
  12. postMessageToWebview: vi.fn(),
  13. customModesManager: {
  14. getCustomModes: vi.fn(),
  15. deleteCustomMode: vi.fn(),
  16. },
  17. context: {
  18. extensionPath: "/mock/extension/path",
  19. globalStorageUri: { fsPath: "/mock/global/storage" },
  20. },
  21. contextProxy: {
  22. context: {
  23. extensionPath: "/mock/extension/path",
  24. globalStorageUri: { fsPath: "/mock/global/storage" },
  25. },
  26. setValue: vi.fn(),
  27. },
  28. log: vi.fn(),
  29. postStateToWebview: vi.fn(),
  30. } as unknown as ClineProvider
  31. import { t } from "../../../i18n"
  32. vi.mock("vscode", () => ({
  33. window: {
  34. showInformationMessage: vi.fn(),
  35. showErrorMessage: vi.fn(),
  36. },
  37. workspace: {
  38. workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
  39. },
  40. }))
  41. vi.mock("../../../i18n", () => ({
  42. t: vi.fn((key: string, args?: Record<string, any>) => {
  43. // For the delete confirmation with rules, we need to return the interpolated string
  44. if (key === "common:confirmation.delete_custom_mode_with_rules" && args) {
  45. 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}`
  46. }
  47. // Return the translated value for "Yes"
  48. if (key === "common:answers.yes") {
  49. return "Yes"
  50. }
  51. // Return the translated value for "Cancel"
  52. if (key === "common:answers.cancel") {
  53. return "Cancel"
  54. }
  55. return key
  56. }),
  57. }))
  58. vi.mock("fs/promises", () => {
  59. const mockRm = vi.fn().mockResolvedValue(undefined)
  60. const mockMkdir = vi.fn().mockResolvedValue(undefined)
  61. return {
  62. default: {
  63. rm: mockRm,
  64. mkdir: mockMkdir,
  65. },
  66. rm: mockRm,
  67. mkdir: mockMkdir,
  68. }
  69. })
  70. import * as vscode from "vscode"
  71. import * as fs from "fs/promises"
  72. import * as os from "os"
  73. import * as path from "path"
  74. import * as fsUtils from "../../../utils/fs"
  75. import { getWorkspacePath } from "../../../utils/path"
  76. import { ensureSettingsDirectoryExists } from "../../../utils/globalContext"
  77. import type { ModeConfig } from "@roo-code/types"
  78. vi.mock("../../../utils/fs")
  79. vi.mock("../../../utils/path")
  80. vi.mock("../../../utils/globalContext")
  81. describe("webviewMessageHandler - requestRouterModels", () => {
  82. beforeEach(() => {
  83. vi.clearAllMocks()
  84. mockClineProvider.getState = vi.fn().mockResolvedValue({
  85. apiConfiguration: {
  86. openRouterApiKey: "openrouter-key",
  87. requestyApiKey: "requesty-key",
  88. glamaApiKey: "glama-key",
  89. unboundApiKey: "unbound-key",
  90. litellmApiKey: "litellm-key",
  91. litellmBaseUrl: "http://localhost:4000",
  92. },
  93. })
  94. })
  95. it("successfully fetches models from all providers", async () => {
  96. const mockModels: ModelRecord = {
  97. "model-1": {
  98. maxTokens: 4096,
  99. contextWindow: 8192,
  100. supportsPromptCache: false,
  101. description: "Test model 1",
  102. },
  103. "model-2": {
  104. maxTokens: 8192,
  105. contextWindow: 16384,
  106. supportsPromptCache: false,
  107. description: "Test model 2",
  108. },
  109. }
  110. mockGetModels.mockResolvedValue(mockModels)
  111. await webviewMessageHandler(mockClineProvider, {
  112. type: "requestRouterModels",
  113. })
  114. // Verify getModels was called for each provider
  115. expect(mockGetModels).toHaveBeenCalledWith({ provider: "openrouter" })
  116. expect(mockGetModels).toHaveBeenCalledWith({ provider: "requesty", apiKey: "requesty-key" })
  117. expect(mockGetModels).toHaveBeenCalledWith({ provider: "glama" })
  118. expect(mockGetModels).toHaveBeenCalledWith({ provider: "unbound", apiKey: "unbound-key" })
  119. expect(mockGetModels).toHaveBeenCalledWith({
  120. provider: "litellm",
  121. apiKey: "litellm-key",
  122. baseUrl: "http://localhost:4000",
  123. })
  124. // Verify response was sent
  125. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  126. type: "routerModels",
  127. routerModels: {
  128. openrouter: mockModels,
  129. requesty: mockModels,
  130. glama: mockModels,
  131. unbound: mockModels,
  132. litellm: mockModels,
  133. ollama: {},
  134. lmstudio: {},
  135. },
  136. })
  137. })
  138. it("handles LiteLLM models with values from message when config is missing", async () => {
  139. mockClineProvider.getState = vi.fn().mockResolvedValue({
  140. apiConfiguration: {
  141. openRouterApiKey: "openrouter-key",
  142. requestyApiKey: "requesty-key",
  143. glamaApiKey: "glama-key",
  144. unboundApiKey: "unbound-key",
  145. // Missing litellm config
  146. },
  147. })
  148. const mockModels: ModelRecord = {
  149. "model-1": {
  150. maxTokens: 4096,
  151. contextWindow: 8192,
  152. supportsPromptCache: false,
  153. description: "Test model 1",
  154. },
  155. }
  156. mockGetModels.mockResolvedValue(mockModels)
  157. await webviewMessageHandler(mockClineProvider, {
  158. type: "requestRouterModels",
  159. values: {
  160. litellmApiKey: "message-litellm-key",
  161. litellmBaseUrl: "http://message-url:4000",
  162. },
  163. })
  164. // Verify LiteLLM was called with values from message
  165. expect(mockGetModels).toHaveBeenCalledWith({
  166. provider: "litellm",
  167. apiKey: "message-litellm-key",
  168. baseUrl: "http://message-url:4000",
  169. })
  170. })
  171. it("skips LiteLLM when both config and message values are missing", async () => {
  172. mockClineProvider.getState = vi.fn().mockResolvedValue({
  173. apiConfiguration: {
  174. openRouterApiKey: "openrouter-key",
  175. requestyApiKey: "requesty-key",
  176. glamaApiKey: "glama-key",
  177. unboundApiKey: "unbound-key",
  178. // Missing litellm config
  179. },
  180. })
  181. const mockModels: ModelRecord = {
  182. "model-1": {
  183. maxTokens: 4096,
  184. contextWindow: 8192,
  185. supportsPromptCache: false,
  186. description: "Test model 1",
  187. },
  188. }
  189. mockGetModels.mockResolvedValue(mockModels)
  190. await webviewMessageHandler(mockClineProvider, {
  191. type: "requestRouterModels",
  192. // No values provided
  193. })
  194. // Verify LiteLLM was NOT called
  195. expect(mockGetModels).not.toHaveBeenCalledWith(
  196. expect.objectContaining({
  197. provider: "litellm",
  198. }),
  199. )
  200. // Verify response includes empty object for LiteLLM
  201. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  202. type: "routerModels",
  203. routerModels: {
  204. openrouter: mockModels,
  205. requesty: mockModels,
  206. glama: mockModels,
  207. unbound: mockModels,
  208. litellm: {},
  209. ollama: {},
  210. lmstudio: {},
  211. },
  212. })
  213. })
  214. it("handles individual provider failures gracefully", async () => {
  215. const mockModels: ModelRecord = {
  216. "model-1": {
  217. maxTokens: 4096,
  218. contextWindow: 8192,
  219. supportsPromptCache: false,
  220. description: "Test model 1",
  221. },
  222. }
  223. // Mock some providers to succeed and others to fail
  224. mockGetModels
  225. .mockResolvedValueOnce(mockModels) // openrouter
  226. .mockRejectedValueOnce(new Error("Requesty API error")) // requesty
  227. .mockResolvedValueOnce(mockModels) // glama
  228. .mockRejectedValueOnce(new Error("Unbound API error")) // unbound
  229. .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm
  230. await webviewMessageHandler(mockClineProvider, {
  231. type: "requestRouterModels",
  232. })
  233. // Verify successful providers are included
  234. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  235. type: "routerModels",
  236. routerModels: {
  237. openrouter: mockModels,
  238. requesty: {},
  239. glama: mockModels,
  240. unbound: {},
  241. litellm: {},
  242. ollama: {},
  243. lmstudio: {},
  244. },
  245. })
  246. // Verify error messages were sent for failed providers
  247. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  248. type: "singleRouterModelFetchResponse",
  249. success: false,
  250. error: "Requesty API error",
  251. values: { provider: "requesty" },
  252. })
  253. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  254. type: "singleRouterModelFetchResponse",
  255. success: false,
  256. error: "Unbound API error",
  257. values: { provider: "unbound" },
  258. })
  259. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  260. type: "singleRouterModelFetchResponse",
  261. success: false,
  262. error: "LiteLLM connection failed",
  263. values: { provider: "litellm" },
  264. })
  265. })
  266. it("handles Error objects and string errors correctly", async () => {
  267. // Mock providers to fail with different error types
  268. mockGetModels
  269. .mockRejectedValueOnce(new Error("Structured error message")) // openrouter
  270. .mockRejectedValueOnce(new Error("Requesty API error")) // requesty
  271. .mockRejectedValueOnce(new Error("Glama API error")) // glama
  272. .mockRejectedValueOnce(new Error("Unbound API error")) // unbound
  273. .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm
  274. await webviewMessageHandler(mockClineProvider, {
  275. type: "requestRouterModels",
  276. })
  277. // Verify error handling for different error types
  278. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  279. type: "singleRouterModelFetchResponse",
  280. success: false,
  281. error: "Structured error message",
  282. values: { provider: "openrouter" },
  283. })
  284. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  285. type: "singleRouterModelFetchResponse",
  286. success: false,
  287. error: "Requesty API error",
  288. values: { provider: "requesty" },
  289. })
  290. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  291. type: "singleRouterModelFetchResponse",
  292. success: false,
  293. error: "Glama API error",
  294. values: { provider: "glama" },
  295. })
  296. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  297. type: "singleRouterModelFetchResponse",
  298. success: false,
  299. error: "Unbound API error",
  300. values: { provider: "unbound" },
  301. })
  302. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  303. type: "singleRouterModelFetchResponse",
  304. success: false,
  305. error: "LiteLLM connection failed",
  306. values: { provider: "litellm" },
  307. })
  308. })
  309. it("prefers config values over message values for LiteLLM", async () => {
  310. const mockModels: ModelRecord = {}
  311. mockGetModels.mockResolvedValue(mockModels)
  312. await webviewMessageHandler(mockClineProvider, {
  313. type: "requestRouterModels",
  314. values: {
  315. litellmApiKey: "message-key",
  316. litellmBaseUrl: "http://message-url",
  317. },
  318. })
  319. // Verify config values are used over message values
  320. expect(mockGetModels).toHaveBeenCalledWith({
  321. provider: "litellm",
  322. apiKey: "litellm-key", // From config
  323. baseUrl: "http://localhost:4000", // From config
  324. })
  325. })
  326. })
  327. describe("webviewMessageHandler - deleteCustomMode", () => {
  328. beforeEach(() => {
  329. vi.clearAllMocks()
  330. vi.mocked(getWorkspacePath).mockReturnValue("/mock/workspace")
  331. vi.mocked(vscode.window.showErrorMessage).mockResolvedValue(undefined)
  332. vi.mocked(ensureSettingsDirectoryExists).mockResolvedValue("/mock/global/storage/.roo")
  333. })
  334. it("should delete a project mode and its rules folder", async () => {
  335. const slug = "test-project-mode"
  336. const rulesFolderPath = path.join("/mock/workspace", ".roo", `rules-${slug}`)
  337. vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([
  338. {
  339. name: "Test Project Mode",
  340. slug,
  341. roleDefinition: "Test Role",
  342. groups: [],
  343. source: "project",
  344. } as ModeConfig,
  345. ])
  346. vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(true)
  347. vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined)
  348. await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug })
  349. // The confirmation dialog is now handled in the webview, so we don't expect showInformationMessage to be called
  350. expect(vscode.window.showInformationMessage).not.toHaveBeenCalled()
  351. expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith(slug)
  352. expect(fs.rm).toHaveBeenCalledWith(rulesFolderPath, { recursive: true, force: true })
  353. })
  354. it("should delete a global mode and its rules folder", async () => {
  355. const slug = "test-global-mode"
  356. const homeDir = os.homedir()
  357. const rulesFolderPath = path.join(homeDir, ".roo", `rules-${slug}`)
  358. vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([
  359. {
  360. name: "Test Global Mode",
  361. slug,
  362. roleDefinition: "Test Role",
  363. groups: [],
  364. source: "global",
  365. } as ModeConfig,
  366. ])
  367. vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(true)
  368. vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined)
  369. await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug })
  370. // The confirmation dialog is now handled in the webview, so we don't expect showInformationMessage to be called
  371. expect(vscode.window.showInformationMessage).not.toHaveBeenCalled()
  372. expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith(slug)
  373. expect(fs.rm).toHaveBeenCalledWith(rulesFolderPath, { recursive: true, force: true })
  374. })
  375. it("should only delete the mode when rules folder does not exist", async () => {
  376. const slug = "test-mode-no-rules"
  377. vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([
  378. {
  379. name: "Test Mode No Rules",
  380. slug,
  381. roleDefinition: "Test Role",
  382. groups: [],
  383. source: "project",
  384. } as ModeConfig,
  385. ])
  386. vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(false)
  387. vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined)
  388. await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug })
  389. // The confirmation dialog is now handled in the webview, so we don't expect showInformationMessage to be called
  390. expect(vscode.window.showInformationMessage).not.toHaveBeenCalled()
  391. expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith(slug)
  392. expect(fs.rm).not.toHaveBeenCalled()
  393. })
  394. it("should handle errors when deleting rules folder", async () => {
  395. const slug = "test-mode-error"
  396. const rulesFolderPath = path.join("/mock/workspace", ".roo", `rules-${slug}`)
  397. const error = new Error("Permission denied")
  398. vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([
  399. {
  400. name: "Test Mode Error",
  401. slug,
  402. roleDefinition: "Test Role",
  403. groups: [],
  404. source: "project",
  405. } as ModeConfig,
  406. ])
  407. vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(true)
  408. vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined)
  409. vi.mocked(fs.rm).mockRejectedValue(error)
  410. await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug })
  411. expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith(slug)
  412. expect(fs.rm).toHaveBeenCalledWith(rulesFolderPath, { recursive: true, force: true })
  413. // Verify error message is shown to the user
  414. expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
  415. t("common:errors.delete_rules_folder_failed", {
  416. rulesFolderPath,
  417. error: error.message,
  418. }),
  419. )
  420. // No error response is sent anymore - we just continue with deletion
  421. expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled()
  422. })
  423. })