webviewMessageHandler.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  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. getValue: vi.fn(),
  28. },
  29. log: vi.fn(),
  30. postStateToWebview: vi.fn(),
  31. getCurrentTask: vi.fn(),
  32. getTaskWithId: vi.fn(),
  33. createTaskWithHistoryItem: vi.fn(),
  34. } as unknown as ClineProvider
  35. import { t } from "../../../i18n"
  36. vi.mock("vscode", () => ({
  37. window: {
  38. showInformationMessage: vi.fn(),
  39. showErrorMessage: vi.fn(),
  40. },
  41. workspace: {
  42. workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
  43. },
  44. }))
  45. vi.mock("../../../i18n", () => ({
  46. t: vi.fn((key: string, args?: Record<string, any>) => {
  47. // For the delete confirmation with rules, we need to return the interpolated string
  48. if (key === "common:confirmation.delete_custom_mode_with_rules" && args) {
  49. 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}`
  50. }
  51. // Return the translated value for "Yes"
  52. if (key === "common:answers.yes") {
  53. return "Yes"
  54. }
  55. // Return the translated value for "Cancel"
  56. if (key === "common:answers.cancel") {
  57. return "Cancel"
  58. }
  59. return key
  60. }),
  61. }))
  62. vi.mock("fs/promises", () => {
  63. const mockRm = vi.fn().mockResolvedValue(undefined)
  64. const mockMkdir = vi.fn().mockResolvedValue(undefined)
  65. return {
  66. default: {
  67. rm: mockRm,
  68. mkdir: mockMkdir,
  69. },
  70. rm: mockRm,
  71. mkdir: mockMkdir,
  72. }
  73. })
  74. import * as vscode from "vscode"
  75. import * as fs from "fs/promises"
  76. import * as os from "os"
  77. import * as path from "path"
  78. import * as fsUtils from "../../../utils/fs"
  79. import { getWorkspacePath } from "../../../utils/path"
  80. import { ensureSettingsDirectoryExists } from "../../../utils/globalContext"
  81. import type { ModeConfig } from "@roo-code/types"
  82. vi.mock("../../../utils/fs")
  83. vi.mock("../../../utils/path")
  84. vi.mock("../../../utils/globalContext")
  85. describe("webviewMessageHandler - requestLmStudioModels", () => {
  86. beforeEach(() => {
  87. vi.clearAllMocks()
  88. mockClineProvider.getState = vi.fn().mockResolvedValue({
  89. apiConfiguration: {
  90. lmStudioModelId: "model-1",
  91. lmStudioBaseUrl: "http://localhost:1234",
  92. },
  93. })
  94. })
  95. it("successfully fetches models from LMStudio", 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: "requestLmStudioModels",
  113. })
  114. expect(mockGetModels).toHaveBeenCalledWith({ provider: "lmstudio", baseUrl: "http://localhost:1234" })
  115. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  116. type: "lmStudioModels",
  117. lmStudioModels: mockModels,
  118. })
  119. })
  120. })
  121. describe("webviewMessageHandler - requestRouterModels", () => {
  122. beforeEach(() => {
  123. vi.clearAllMocks()
  124. mockClineProvider.getState = vi.fn().mockResolvedValue({
  125. apiConfiguration: {
  126. openRouterApiKey: "openrouter-key",
  127. requestyApiKey: "requesty-key",
  128. glamaApiKey: "glama-key",
  129. unboundApiKey: "unbound-key",
  130. litellmApiKey: "litellm-key",
  131. litellmBaseUrl: "http://localhost:4000",
  132. },
  133. })
  134. })
  135. it("successfully fetches models from all providers", async () => {
  136. const mockModels: ModelRecord = {
  137. "model-1": {
  138. maxTokens: 4096,
  139. contextWindow: 8192,
  140. supportsPromptCache: false,
  141. description: "Test model 1",
  142. },
  143. "model-2": {
  144. maxTokens: 8192,
  145. contextWindow: 16384,
  146. supportsPromptCache: false,
  147. description: "Test model 2",
  148. },
  149. }
  150. mockGetModels.mockResolvedValue(mockModels)
  151. await webviewMessageHandler(mockClineProvider, {
  152. type: "requestRouterModels",
  153. })
  154. // Verify getModels was called for each provider
  155. expect(mockGetModels).toHaveBeenCalledWith({ provider: "openrouter" })
  156. expect(mockGetModels).toHaveBeenCalledWith({ provider: "requesty", apiKey: "requesty-key" })
  157. expect(mockGetModels).toHaveBeenCalledWith({ provider: "glama" })
  158. expect(mockGetModels).toHaveBeenCalledWith({ provider: "unbound", apiKey: "unbound-key" })
  159. expect(mockGetModels).toHaveBeenCalledWith({ provider: "vercel-ai-gateway" })
  160. expect(mockGetModels).toHaveBeenCalledWith({
  161. provider: "litellm",
  162. apiKey: "litellm-key",
  163. baseUrl: "http://localhost:4000",
  164. })
  165. // Verify response was sent
  166. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  167. type: "routerModels",
  168. routerModels: {
  169. openrouter: mockModels,
  170. requesty: mockModels,
  171. glama: mockModels,
  172. unbound: mockModels,
  173. litellm: mockModels,
  174. ollama: {},
  175. lmstudio: {},
  176. "vercel-ai-gateway": mockModels,
  177. },
  178. })
  179. })
  180. it("handles LiteLLM models with values from message when config is missing", async () => {
  181. mockClineProvider.getState = vi.fn().mockResolvedValue({
  182. apiConfiguration: {
  183. openRouterApiKey: "openrouter-key",
  184. requestyApiKey: "requesty-key",
  185. glamaApiKey: "glama-key",
  186. unboundApiKey: "unbound-key",
  187. // Missing litellm config
  188. },
  189. })
  190. const mockModels: ModelRecord = {
  191. "model-1": {
  192. maxTokens: 4096,
  193. contextWindow: 8192,
  194. supportsPromptCache: false,
  195. description: "Test model 1",
  196. },
  197. }
  198. mockGetModels.mockResolvedValue(mockModels)
  199. await webviewMessageHandler(mockClineProvider, {
  200. type: "requestRouterModels",
  201. values: {
  202. litellmApiKey: "message-litellm-key",
  203. litellmBaseUrl: "http://message-url:4000",
  204. },
  205. })
  206. // Verify LiteLLM was called with values from message
  207. expect(mockGetModels).toHaveBeenCalledWith({
  208. provider: "litellm",
  209. apiKey: "message-litellm-key",
  210. baseUrl: "http://message-url:4000",
  211. })
  212. })
  213. it("skips LiteLLM when both config and message values are missing", async () => {
  214. mockClineProvider.getState = vi.fn().mockResolvedValue({
  215. apiConfiguration: {
  216. openRouterApiKey: "openrouter-key",
  217. requestyApiKey: "requesty-key",
  218. glamaApiKey: "glama-key",
  219. unboundApiKey: "unbound-key",
  220. // Missing litellm config
  221. },
  222. })
  223. const mockModels: ModelRecord = {
  224. "model-1": {
  225. maxTokens: 4096,
  226. contextWindow: 8192,
  227. supportsPromptCache: false,
  228. description: "Test model 1",
  229. },
  230. }
  231. mockGetModels.mockResolvedValue(mockModels)
  232. await webviewMessageHandler(mockClineProvider, {
  233. type: "requestRouterModels",
  234. // No values provided
  235. })
  236. // Verify LiteLLM was NOT called
  237. expect(mockGetModels).not.toHaveBeenCalledWith(
  238. expect.objectContaining({
  239. provider: "litellm",
  240. }),
  241. )
  242. // Verify response includes empty object for LiteLLM
  243. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  244. type: "routerModels",
  245. routerModels: {
  246. openrouter: mockModels,
  247. requesty: mockModels,
  248. glama: mockModels,
  249. unbound: mockModels,
  250. litellm: {},
  251. ollama: {},
  252. lmstudio: {},
  253. "vercel-ai-gateway": mockModels,
  254. },
  255. })
  256. })
  257. it("handles individual provider failures gracefully", async () => {
  258. const mockModels: ModelRecord = {
  259. "model-1": {
  260. maxTokens: 4096,
  261. contextWindow: 8192,
  262. supportsPromptCache: false,
  263. description: "Test model 1",
  264. },
  265. }
  266. // Mock some providers to succeed and others to fail
  267. mockGetModels
  268. .mockResolvedValueOnce(mockModels) // openrouter
  269. .mockRejectedValueOnce(new Error("Requesty API error")) // requesty
  270. .mockResolvedValueOnce(mockModels) // glama
  271. .mockRejectedValueOnce(new Error("Unbound API error")) // unbound
  272. .mockResolvedValueOnce(mockModels) // vercel-ai-gateway
  273. .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm
  274. await webviewMessageHandler(mockClineProvider, {
  275. type: "requestRouterModels",
  276. })
  277. // Verify successful providers are included
  278. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  279. type: "routerModels",
  280. routerModels: {
  281. openrouter: mockModels,
  282. requesty: {},
  283. glama: mockModels,
  284. unbound: {},
  285. litellm: {},
  286. ollama: {},
  287. lmstudio: {},
  288. "vercel-ai-gateway": mockModels,
  289. },
  290. })
  291. // Verify error messages were sent for failed providers
  292. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  293. type: "singleRouterModelFetchResponse",
  294. success: false,
  295. error: "Requesty API error",
  296. values: { provider: "requesty" },
  297. })
  298. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  299. type: "singleRouterModelFetchResponse",
  300. success: false,
  301. error: "Unbound API error",
  302. values: { provider: "unbound" },
  303. })
  304. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  305. type: "singleRouterModelFetchResponse",
  306. success: false,
  307. error: "LiteLLM connection failed",
  308. values: { provider: "litellm" },
  309. })
  310. })
  311. it("handles Error objects and string errors correctly", async () => {
  312. // Mock providers to fail with different error types
  313. mockGetModels
  314. .mockRejectedValueOnce(new Error("Structured error message")) // openrouter
  315. .mockRejectedValueOnce(new Error("Requesty API error")) // requesty
  316. .mockRejectedValueOnce(new Error("Glama API error")) // glama
  317. .mockRejectedValueOnce(new Error("Unbound API error")) // unbound
  318. .mockRejectedValueOnce(new Error("Vercel AI Gateway error")) // vercel-ai-gateway
  319. .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm
  320. await webviewMessageHandler(mockClineProvider, {
  321. type: "requestRouterModels",
  322. })
  323. // Verify error handling for different error types
  324. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  325. type: "singleRouterModelFetchResponse",
  326. success: false,
  327. error: "Structured error message",
  328. values: { provider: "openrouter" },
  329. })
  330. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  331. type: "singleRouterModelFetchResponse",
  332. success: false,
  333. error: "Requesty API error",
  334. values: { provider: "requesty" },
  335. })
  336. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  337. type: "singleRouterModelFetchResponse",
  338. success: false,
  339. error: "Glama API error",
  340. values: { provider: "glama" },
  341. })
  342. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  343. type: "singleRouterModelFetchResponse",
  344. success: false,
  345. error: "Unbound API error",
  346. values: { provider: "unbound" },
  347. })
  348. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  349. type: "singleRouterModelFetchResponse",
  350. success: false,
  351. error: "LiteLLM connection failed",
  352. values: { provider: "litellm" },
  353. })
  354. })
  355. it("prefers config values over message values for LiteLLM", async () => {
  356. const mockModels: ModelRecord = {}
  357. mockGetModels.mockResolvedValue(mockModels)
  358. await webviewMessageHandler(mockClineProvider, {
  359. type: "requestRouterModels",
  360. values: {
  361. litellmApiKey: "message-key",
  362. litellmBaseUrl: "http://message-url",
  363. },
  364. })
  365. // Verify config values are used over message values
  366. expect(mockGetModels).toHaveBeenCalledWith({
  367. provider: "litellm",
  368. apiKey: "litellm-key", // From config
  369. baseUrl: "http://localhost:4000", // From config
  370. })
  371. })
  372. })
  373. describe("webviewMessageHandler - deleteCustomMode", () => {
  374. beforeEach(() => {
  375. vi.clearAllMocks()
  376. vi.mocked(getWorkspacePath).mockReturnValue("/mock/workspace")
  377. vi.mocked(vscode.window.showErrorMessage).mockResolvedValue(undefined)
  378. vi.mocked(ensureSettingsDirectoryExists).mockResolvedValue("/mock/global/storage/.roo")
  379. })
  380. it("should delete a project mode and its rules folder", async () => {
  381. const slug = "test-project-mode"
  382. const rulesFolderPath = path.join("/mock/workspace", ".roo", `rules-${slug}`)
  383. vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([
  384. {
  385. name: "Test Project Mode",
  386. slug,
  387. roleDefinition: "Test Role",
  388. groups: [],
  389. source: "project",
  390. } as ModeConfig,
  391. ])
  392. vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(true)
  393. vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined)
  394. await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug })
  395. // The confirmation dialog is now handled in the webview, so we don't expect showInformationMessage to be called
  396. expect(vscode.window.showInformationMessage).not.toHaveBeenCalled()
  397. expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith(slug)
  398. expect(fs.rm).toHaveBeenCalledWith(rulesFolderPath, { recursive: true, force: true })
  399. })
  400. it("should delete a global mode and its rules folder", async () => {
  401. const slug = "test-global-mode"
  402. const homeDir = os.homedir()
  403. const rulesFolderPath = path.join(homeDir, ".roo", `rules-${slug}`)
  404. vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([
  405. {
  406. name: "Test Global Mode",
  407. slug,
  408. roleDefinition: "Test Role",
  409. groups: [],
  410. source: "global",
  411. } as ModeConfig,
  412. ])
  413. vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(true)
  414. vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined)
  415. await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug })
  416. // The confirmation dialog is now handled in the webview, so we don't expect showInformationMessage to be called
  417. expect(vscode.window.showInformationMessage).not.toHaveBeenCalled()
  418. expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith(slug)
  419. expect(fs.rm).toHaveBeenCalledWith(rulesFolderPath, { recursive: true, force: true })
  420. })
  421. it("should only delete the mode when rules folder does not exist", async () => {
  422. const slug = "test-mode-no-rules"
  423. vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([
  424. {
  425. name: "Test Mode No Rules",
  426. slug,
  427. roleDefinition: "Test Role",
  428. groups: [],
  429. source: "project",
  430. } as ModeConfig,
  431. ])
  432. vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(false)
  433. vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined)
  434. await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug })
  435. // The confirmation dialog is now handled in the webview, so we don't expect showInformationMessage to be called
  436. expect(vscode.window.showInformationMessage).not.toHaveBeenCalled()
  437. expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith(slug)
  438. expect(fs.rm).not.toHaveBeenCalled()
  439. })
  440. it("should handle errors when deleting rules folder", async () => {
  441. const slug = "test-mode-error"
  442. const rulesFolderPath = path.join("/mock/workspace", ".roo", `rules-${slug}`)
  443. const error = new Error("Permission denied")
  444. vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([
  445. {
  446. name: "Test Mode Error",
  447. slug,
  448. roleDefinition: "Test Role",
  449. groups: [],
  450. source: "project",
  451. } as ModeConfig,
  452. ])
  453. vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(true)
  454. vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined)
  455. vi.mocked(fs.rm).mockRejectedValue(error)
  456. await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug })
  457. expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith(slug)
  458. expect(fs.rm).toHaveBeenCalledWith(rulesFolderPath, { recursive: true, force: true })
  459. // Verify error message is shown to the user
  460. expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
  461. t("common:errors.delete_rules_folder_failed", {
  462. rulesFolderPath,
  463. error: error.message,
  464. }),
  465. )
  466. // No error response is sent anymore - we just continue with deletion
  467. expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled()
  468. })
  469. })
  470. describe("webviewMessageHandler - message dialog preferences", () => {
  471. beforeEach(() => {
  472. vi.clearAllMocks()
  473. // Mock a current Cline instance
  474. vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({
  475. taskId: "test-task-id",
  476. apiConversationHistory: [],
  477. clineMessages: [],
  478. } as any)
  479. // Reset getValue mock
  480. vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue(false)
  481. })
  482. describe("deleteMessage", () => {
  483. it("should always show dialog for delete confirmation", async () => {
  484. vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({} as any)
  485. await webviewMessageHandler(mockClineProvider, {
  486. type: "deleteMessage",
  487. value: 123456789, // Changed from messageTs to value
  488. })
  489. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  490. type: "showDeleteMessageDialog",
  491. messageTs: 123456789,
  492. })
  493. })
  494. })
  495. describe("submitEditedMessage", () => {
  496. it("should always show dialog for edit confirmation", async () => {
  497. vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({} as any)
  498. await webviewMessageHandler(mockClineProvider, {
  499. type: "submitEditedMessage",
  500. value: 123456789,
  501. editedMessageContent: "edited content",
  502. })
  503. expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
  504. type: "showEditMessageDialog",
  505. messageTs: 123456789,
  506. text: "edited content",
  507. })
  508. })
  509. })
  510. })
  511. describe("webviewMessageHandler - mcpEnabled", () => {
  512. let mockMcpHub: any
  513. beforeEach(() => {
  514. vi.clearAllMocks()
  515. // Create a mock McpHub instance
  516. mockMcpHub = {
  517. handleMcpEnabledChange: vi.fn().mockResolvedValue(undefined),
  518. }
  519. // Ensure provider exposes getMcpHub and returns our mock
  520. ;(mockClineProvider as any).getMcpHub = vi.fn().mockReturnValue(mockMcpHub)
  521. })
  522. it("delegates enable=true to McpHub and posts updated state", async () => {
  523. await webviewMessageHandler(mockClineProvider, {
  524. type: "mcpEnabled",
  525. bool: true,
  526. })
  527. expect((mockClineProvider as any).getMcpHub).toHaveBeenCalledTimes(1)
  528. expect(mockMcpHub.handleMcpEnabledChange).toHaveBeenCalledTimes(1)
  529. expect(mockMcpHub.handleMcpEnabledChange).toHaveBeenCalledWith(true)
  530. expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)
  531. })
  532. it("delegates enable=false to McpHub and posts updated state", async () => {
  533. await webviewMessageHandler(mockClineProvider, {
  534. type: "mcpEnabled",
  535. bool: false,
  536. })
  537. expect((mockClineProvider as any).getMcpHub).toHaveBeenCalledTimes(1)
  538. expect(mockMcpHub.handleMcpEnabledChange).toHaveBeenCalledTimes(1)
  539. expect(mockMcpHub.handleMcpEnabledChange).toHaveBeenCalledWith(false)
  540. expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)
  541. })
  542. it("handles missing McpHub instance gracefully and still posts state", async () => {
  543. ;(mockClineProvider as any).getMcpHub = vi.fn().mockReturnValue(undefined)
  544. await webviewMessageHandler(mockClineProvider, {
  545. type: "mcpEnabled",
  546. bool: true,
  547. })
  548. expect((mockClineProvider as any).getMcpHub).toHaveBeenCalledTimes(1)
  549. expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)
  550. })
  551. })