diff --git a/Cargo.lock b/Cargo.lock index e41aa170..0020bed3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2653,6 +2653,7 @@ version = "0.1.2" dependencies = [ "anyhow", "async-trait", + "base64 0.22.1", "chrono", "dirs 5.0.1", "filetime", @@ -2667,6 +2668,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tracing", + "urlencoding", "uuid", ] @@ -4220,7 +4222,6 @@ dependencies = [ [[package]] name = "rmcp" version = "0.15.0" -source = "git+https://github.com/mcpmux/rust-sdk.git?branch=fix%2Fsse-channel-replacement-conflict#14eddf421f0cb7eae58bab926f4d7e54b47b84c5" dependencies = [ "async-trait", "base64 0.22.1", @@ -4254,7 +4255,6 @@ dependencies = [ [[package]] name = "rmcp-macros" version = "0.15.0" -source = "git+https://github.com/mcpmux/rust-sdk.git?branch=fix%2Fsse-channel-replacement-conflict#14eddf421f0cb7eae58bab926f4d7e54b47b84c5" dependencies = [ "darling 0.23.0", "proc-macro2", diff --git a/apps/desktop/src-tauri/src/commands/client_install.rs b/apps/desktop/src-tauri/src/commands/client_install.rs new file mode 100644 index 00000000..ef9b8574 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/client_install.rs @@ -0,0 +1,74 @@ +//! One-click IDE install commands. +//! +//! Opens deep link URIs for VS Code and Cursor to install the McpMux MCP server. + +use mcpmux_core::{cursor_deep_link, vscode_deep_link}; +use tracing::info; + +/// Add McpMux to VS Code via deep link. +#[tauri::command] +pub async fn add_to_vscode(gateway_url: String) -> Result<(), String> { + let uri = vscode_deep_link(&gateway_url); + info!("[ClientInstall] Opening VS Code deep link: {}", uri); + open_deep_link(&uri) +} + +/// Add McpMux to Cursor via deep link. +#[tauri::command] +pub async fn add_to_cursor(gateway_url: String) -> Result<(), String> { + let uri = cursor_deep_link(&gateway_url); + info!("[ClientInstall] Opening Cursor deep link: {}", uri); + open_deep_link(&uri) +} + +/// Open a deep link URI using the system handler. +fn open_deep_link(uri: &str) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + open_url_shell_execute(uri) + } + #[cfg(not(target_os = "windows"))] + { + open::that(uri).map_err(|e| format!("Failed to open URI: {}", e)) + } +} + +/// Windows: Use ShellExecuteW to open URI without flashing a console window. +#[cfg(target_os = "windows")] +fn open_url_shell_execute(url: &str) -> Result<(), String> { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use std::ptr; + + #[link(name = "shell32")] + extern "system" { + fn ShellExecuteW( + hwnd: *mut std::ffi::c_void, + operation: *const u16, + file: *const u16, + parameters: *const u16, + directory: *const u16, + show_cmd: i32, + ) -> isize; + } + + let url_wide: Vec = OsStr::new(url).encode_wide().chain(Some(0)).collect(); + let open_wide: Vec = OsStr::new("open").encode_wide().chain(Some(0)).collect(); + + let result = unsafe { + ShellExecuteW( + ptr::null_mut(), + open_wide.as_ptr(), + url_wide.as_ptr(), + ptr::null(), + ptr::null(), + 1, // SW_SHOWNORMAL + ) + }; + + if result > 32 { + Ok(()) + } else { + Err(format!("ShellExecuteW failed with code: {}", result)) + } +} diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 9c6f72fb..7e775b70 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod client; pub mod client_custom_features; +pub mod client_install; pub mod config_export; pub mod credential; pub mod feature_members; @@ -22,6 +23,7 @@ pub mod space; // Re-export commands for convenience pub use client::*; pub use client_custom_features::*; +pub use client_install::*; pub use config_export::*; pub use feature_members::*; pub use feature_set::*; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index d1a46f67..008ee446 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -761,6 +761,9 @@ pub fn run() { commands::get_config_paths, commands::check_config_exists, commands::backup_existing_config, + // Client install commands (one-click IDE setup) + commands::add_to_vscode, + commands::add_to_cursor, // Gateway commands commands::get_gateway_status, commands::start_gateway, diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 81ca3896..0cef2153 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -9,7 +9,6 @@ import { Settings, Sun, Moon, - Check, Loader2, FolderOpen, FileText, @@ -32,6 +31,7 @@ import { ThemeProvider } from '@/components/ThemeProvider'; import { OAuthConsentModal } from '@/components/OAuthConsentModal'; import { ServerInstallModal } from '@/components/ServerInstallModal'; import { SpaceSwitcher } from '@/components/SpaceSwitcher'; +import { ConnectIDEs } from '@/components/ConnectIDEs'; import { useDataSync } from '@/hooks/useDataSync'; import { useAppStore, useActiveSpace, useViewSpace, useTheme } from '@/stores'; import { RegistryPage } from '@/features/registry'; @@ -337,7 +337,6 @@ function DashboardView() { running: boolean; url: string | null; }>({ running: false, url: null }); - const [exportSuccess, setExportSuccess] = useState(null); const viewSpace = useViewSpace(); // Load stats on mount and when gateway changes @@ -505,61 +504,11 @@ function DashboardView() { - {/* Client Config */} - - - Connect Your Client - - Add this server configuration to your MCP client settings (e.g., inside mcpServers section). - - - -
- {/* Gateway URL */} -
- - Gateway: - - {gatewayStatus.url || 'http://localhost:3100'} - - {!gatewayStatus.running && ( - (not running) - )} -
- - {/* Config Display */} -
-
-{`"mcpmux": {
-  "type": "http",
-  "url": "${gatewayStatus.url || 'http://localhost:3100'}/mcp"
-}`}
-              
- -
- - {exportSuccess && ( -
- - {exportSuccess} -
- )} -
-
-
+ {/* Connect IDEs — one-click install */} + ); } diff --git a/apps/desktop/src/components/ConnectIDEs.tsx b/apps/desktop/src/components/ConnectIDEs.tsx new file mode 100644 index 00000000..790f7f79 --- /dev/null +++ b/apps/desktop/src/components/ConnectIDEs.tsx @@ -0,0 +1,196 @@ +import { useState, useRef, useEffect } from 'react'; +import { Check, Copy, Braces } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button } from '@mcpmux/ui'; +import cursorIcon from '@/assets/client-icons/cursor.svg'; +import vscodeIcon from '@/assets/client-icons/vscode.png'; +import claudeIcon from '@/assets/client-icons/claude.svg'; +import { addToVscode, addToCursor } from '@/lib/api/clientInstall'; + +type GridAction = 'deep_link' | 'copy_command' | 'copy_config'; + +interface GridEntry { + id: string; + name: string; + label: string; + icon?: string; + action: GridAction; + handler: (() => Promise) | string; +} + +interface ConnectIDEsProps { + gatewayUrl: string; + gatewayRunning: boolean; +} + +export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { + const [activeId, setActiveId] = useState(null); + const [copiedId, setCopiedId] = useState(null); + const popoverRef = useRef(null); + + const mcpUrl = `${gatewayUrl}/mcp`; + + const entries: GridEntry[] = [ + { + id: 'vscode', + name: 'VS Code', + label: 'VS Code', + icon: vscodeIcon, + action: 'deep_link', + handler: () => addToVscode(gatewayUrl), + }, + { + id: 'cursor', + name: 'Cursor', + label: 'Cursor', + icon: cursorIcon, + action: 'deep_link', + handler: () => addToCursor(gatewayUrl), + }, + { + id: 'claude-code', + name: 'Claude Code', + label: 'Claude', + icon: claudeIcon, + action: 'copy_command', + handler: `claude mcp add --transport http --scope user mcpmux ${mcpUrl}`, + }, + { + id: 'copy-config', + name: 'JSON Config', + label: 'JSON', + action: 'copy_config', + handler: `"mcpmux": {\n "type": "http",\n "url": "${mcpUrl}"\n}`, + }, + ]; + + // Close popover on outside click + useEffect(() => { + if (!activeId) return; + const handleClick = (e: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + setActiveId(null); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [activeId]); + + const handleDeepLink = async (entry: GridEntry) => { + if (typeof entry.handler === 'function') { + await entry.handler(); + } + setActiveId(null); + }; + + const handleCopy = async (entry: GridEntry) => { + if (typeof entry.handler === 'string') { + await navigator.clipboard.writeText(entry.handler); + setCopiedId(entry.id); + setTimeout(() => { + setCopiedId(null); + setActiveId(null); + }, 1500); + } + }; + + return ( + + +
+
+ Connect Your IDEs + + Add McpMux to your AI clients. Auth happens on first connect. + +
+
+ + {gatewayUrl} +
+
+
+ +
+ {entries.map((entry) => { + const isActive = activeId === entry.id; + const isCopied = copiedId === entry.id; + + return ( +
+ + + {entry.label} + + + {/* Popover */} + {isActive && ( +
+ {/* Arrow */} +
+ +

+ {entry.name} +

+ + {entry.action === 'deep_link' ? ( + + ) : isCopied ? ( +
+ + Copied! +
+ ) : ( + + )} +
+ )} +
+ ); + })} +
+ + + ); +} diff --git a/apps/desktop/src/lib/api/clientInstall.ts b/apps/desktop/src/lib/api/clientInstall.ts new file mode 100644 index 00000000..319755c7 --- /dev/null +++ b/apps/desktop/src/lib/api/clientInstall.ts @@ -0,0 +1,11 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** Add McpMux to VS Code via deep link. */ +export async function addToVscode(gatewayUrl: string): Promise { + return invoke('add_to_vscode', { gatewayUrl }); +} + +/** Add McpMux to Cursor via deep link. */ +export async function addToCursor(gatewayUrl: string): Promise { + return invoke('add_to_cursor', { gatewayUrl }); +} diff --git a/apps/desktop/src/lib/api/index.ts b/apps/desktop/src/lib/api/index.ts index 81bbd3ab..03bbbdb9 100644 --- a/apps/desktop/src/lib/api/index.ts +++ b/apps/desktop/src/lib/api/index.ts @@ -4,6 +4,7 @@ export * from './spaces'; export * from './registry'; export * from './featureSets'; export * from './serverFeatures'; +export * from './clientInstall'; export * from './clients'; export * from './gateway'; export * from './serverManager'; diff --git a/crates/mcpmux-core/Cargo.toml b/crates/mcpmux-core/Cargo.toml index e1141fce..10212c6f 100644 --- a/crates/mcpmux-core/Cargo.toml +++ b/crates/mcpmux-core/Cargo.toml @@ -22,6 +22,8 @@ flate2 = "1.0" reqwest = { workspace = true, features = ["json"] } regex = "1.11" lazy_static = "1.5" +base64 = "0.22" +urlencoding = "2.1" [dev-dependencies] tokio = { workspace = true, features = ["test-util", "macros"] } diff --git a/crates/mcpmux-core/src/service/client_install.rs b/crates/mcpmux-core/src/service/client_install.rs new file mode 100644 index 00000000..9d945b28 --- /dev/null +++ b/crates/mcpmux-core/src/service/client_install.rs @@ -0,0 +1,50 @@ +//! Client IDE install helpers. +//! +//! Deep link URI generators for VS Code and Cursor one-click MCP server install. + +/// Generate the VS Code deep link URI for one-click MCP install. +pub fn vscode_deep_link(gateway_url: &str) -> String { + let config = serde_json::json!({ + "name": "mcpmux", + "type": "http", + "url": format!("{}/mcp", gateway_url) + }); + let config_str = config.to_string(); + let encoded = urlencoding::encode(&config_str); + format!("vscode:mcp/install?{}", encoded) +} + +/// Generate the Cursor deep link URI for one-click MCP install. +pub fn cursor_deep_link(gateway_url: &str) -> String { + use base64::Engine; + + let config = serde_json::json!({ + "url": format!("{}/mcp", gateway_url) + }); + let encoded_config = base64::engine::general_purpose::STANDARD.encode(config.to_string()); + format!( + "cursor://anysphere.cursor-deeplink/mcp/install?name=McpMux&config={}", + encoded_config + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_vscode_deep_link() { + let link = vscode_deep_link("http://localhost:45818"); + assert!(link.starts_with("vscode:mcp/install?")); + assert!(link.contains("mcpmux")); + assert!(link.contains("localhost")); + } + + #[test] + fn test_cursor_deep_link() { + let link = cursor_deep_link("http://localhost:45818"); + assert!(link.starts_with("cursor://anysphere.cursor-deeplink/mcp/install?")); + assert!(link.contains("name=McpMux")); + assert!(link.contains("config=")); + } +} diff --git a/crates/mcpmux-core/src/service/mod.rs b/crates/mcpmux-core/src/service/mod.rs index 497a4c6a..d5533575 100644 --- a/crates/mcpmux-core/src/service/mod.rs +++ b/crates/mcpmux-core/src/service/mod.rs @@ -4,6 +4,7 @@ pub mod app_settings_service; mod cimd_fetcher; +mod client_install; mod client_service; mod config_export; pub mod gateway_port_service; @@ -15,6 +16,7 @@ mod space_service; pub use app_settings_service::{keys, AppSettingsService}; pub use cimd_fetcher::*; +pub use client_install::{cursor_deep_link, vscode_deep_link}; pub use client_service::*; pub use config_export::*; pub use gateway_port_service::{ diff --git a/crates/mcpmux-gateway/src/mcp/handler.rs b/crates/mcpmux-gateway/src/mcp/handler.rs index d13db579..d6f254ae 100644 --- a/crates/mcpmux-gateway/src/mcp/handler.rs +++ b/crates/mcpmux-gateway/src/mcp/handler.rs @@ -115,6 +115,7 @@ impl ServerHandler for McpMuxGatewayHandler { server_info: Implementation { name: "mcpmux-gateway".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), + title: Some("McpMux".to_string()), ..Default::default() }, instructions: Some( diff --git a/tests/e2e/pages/DashboardPage.ts b/tests/e2e/pages/DashboardPage.ts index 358cfde4..7b54bd0f 100644 --- a/tests/e2e/pages/DashboardPage.ts +++ b/tests/e2e/pages/DashboardPage.ts @@ -1,4 +1,4 @@ -import { Page, Locator, expect } from '@playwright/test'; +import { Page, Locator } from '@playwright/test'; import { BasePage } from './BasePage'; /** @@ -12,7 +12,8 @@ export class DashboardPage extends BasePage { readonly featureSetsCard: Locator; readonly clientsCard: Locator; readonly activeSpaceCard: Locator; - readonly configCopyButton: Locator; + readonly connectIDEsSection: Locator; + readonly clientGrid: Locator; constructor(page: Page) { super(page); @@ -23,7 +24,8 @@ export class DashboardPage extends BasePage { this.featureSetsCard = page.locator('text=FeatureSets').first(); this.clientsCard = page.locator('text=Clients').first(); this.activeSpaceCard = page.locator('text=Active Space').first(); - this.configCopyButton = page.getByRole('button', { name: /Copy/ }); + this.connectIDEsSection = page.locator('text=Connect Your IDEs'); + this.clientGrid = page.locator('[data-testid="client-grid"]'); } async navigate() { @@ -49,6 +51,8 @@ export class DashboardPage extends BasePage { } async copyConfig() { - await this.configCopyButton.click(); + // Open JSON config popover and click copy + await this.page.locator('[data-testid="client-icon-copy-config"]').click(); + await this.page.locator('[data-testid="copy-config-btn"]').click(); } } diff --git a/tests/e2e/specs/dashboard.spec.ts b/tests/e2e/specs/dashboard.spec.ts index ad2c408f..2ed10a2a 100644 --- a/tests/e2e/specs/dashboard.spec.ts +++ b/tests/e2e/specs/dashboard.spec.ts @@ -22,16 +22,16 @@ test.describe('Dashboard', () => { await expect(dashboard.activeSpaceCard).toBeVisible(); }); - test('should display client config section', async ({ page }) => { + test('should display connect IDEs section', async ({ page }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); - // Config section should be visible - await expect(page.locator('text=Connect Your Client')).toBeVisible(); - await expect(dashboard.configCopyButton).toBeVisible(); + // Connect IDEs section should be visible + await expect(page.locator('text=Connect Your IDEs')).toBeVisible(); + await expect(page.locator('[data-testid="client-grid"]')).toBeVisible(); }); - test('should copy config to clipboard', async ({ page, context, browserName }) => { + test('should copy config via JSON button', async ({ page, context, browserName }) => { // Clipboard permissions only work on Chromium test.skip(browserName !== 'chromium', 'Clipboard permissions not supported'); @@ -40,9 +40,12 @@ test.describe('Dashboard', () => { const dashboard = new DashboardPage(page); await dashboard.navigate(); - await dashboard.copyConfig(); + // Click the JSON config icon to open popover + await page.locator('[data-testid="client-icon-copy-config"]').click(); + // Click copy button in popover + await page.locator('[data-testid="copy-config-btn"]').click(); // Check for success message - await expect(page.locator('text=copied')).toBeVisible({ timeout: 2000 }); + await expect(page.locator('text=Copied!')).toBeVisible({ timeout: 2000 }); }); }); diff --git a/tests/e2e/specs/gateway.wdio.ts b/tests/e2e/specs/gateway.wdio.ts index 27d56686..4a13361e 100644 --- a/tests/e2e/specs/gateway.wdio.ts +++ b/tests/e2e/specs/gateway.wdio.ts @@ -76,12 +76,12 @@ describe('Gateway Status - Dashboard', () => { expect(hasGatewayUrl).toBe(true); }); - it('TC-GW-007: Copy client config button exists', async () => { - const copyBtn = await byTestId('copy-config-btn'); - const isDisplayed = await copyBtn.isDisplayed().catch(() => false); - - await browser.saveScreenshot('./tests/e2e/screenshots/gw-07-copy-config.png'); - + it('TC-GW-007: Connect IDEs client grid exists', async () => { + const clientGrid = await byTestId('client-grid'); + const isDisplayed = await clientGrid.isDisplayed().catch(() => false); + + await browser.saveScreenshot('./tests/e2e/screenshots/gw-07-connect-ides.png'); + expect(isDisplayed).toBe(true); }); diff --git a/tests/e2e/specs/user-flows.spec.ts b/tests/e2e/specs/user-flows.spec.ts index 0e15e0ae..c29c5e87 100644 --- a/tests/e2e/specs/user-flows.spec.ts +++ b/tests/e2e/specs/user-flows.spec.ts @@ -120,15 +120,15 @@ test.describe('Dashboard Interactions', () => { await expect(dashboard.activeSpaceCard).toBeVisible(); }); - test('should show connection config section', async ({ page }) => { + test('should show connect IDEs section', async ({ page }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); - - // Config section should be present - await expect(page.locator('text=Connect Your Client')).toBeVisible(); - - // Config code block should be present - await expect(page.locator('pre')).toBeVisible(); + + // Connect IDEs section should be present + await expect(page.locator('text=Connect Your IDEs')).toBeVisible(); + + // Client grid should be present + await expect(page.locator('[data-testid="client-grid"]')).toBeVisible(); }); }); diff --git a/tests/ts/components/ConnectIDEs.test.tsx b/tests/ts/components/ConnectIDEs.test.tsx new file mode 100644 index 00000000..ae847fd7 --- /dev/null +++ b/tests/ts/components/ConnectIDEs.test.tsx @@ -0,0 +1,184 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('../../../apps/desktop/src/lib/api/clientInstall', () => ({ + addToVscode: vi.fn(), + addToCursor: vi.fn(), +})); + +import { ConnectIDEs } from '../../../apps/desktop/src/components/ConnectIDEs'; +import { + addToVscode, + addToCursor, +} from '../../../apps/desktop/src/lib/api/clientInstall'; + +const mockedAddVscode = vi.mocked(addToVscode); +const mockedAddCursor = vi.mocked(addToCursor); + +describe('ConnectIDEs', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render the card title', () => { + render( + + ); + expect(screen.getByText('Connect Your IDEs')).toBeInTheDocument(); + }); + + it('should render icon buttons for all entries', () => { + render( + + ); + expect(screen.getByTestId('client-icon-vscode')).toBeInTheDocument(); + expect(screen.getByTestId('client-icon-cursor')).toBeInTheDocument(); + expect(screen.getByTestId('client-icon-claude-code')).toBeInTheDocument(); + expect(screen.getByTestId('client-icon-copy-config')).toBeInTheDocument(); + }); + + it('should show labels under icons', () => { + render( + + ); + expect(screen.getByText('VS Code')).toBeInTheDocument(); + expect(screen.getByText('Cursor')).toBeInTheDocument(); + expect(screen.getByText('Claude')).toBeInTheDocument(); + expect(screen.getByText('JSON')).toBeInTheDocument(); + }); + + it('should show popover when clicking a client icon', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByTestId('client-icon-vscode')); + + expect(screen.getByTestId('client-popover')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Add to VS Code/i })).toBeInTheDocument(); + }); + + it('should close popover when clicking the same icon again', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByTestId('client-icon-vscode')); + expect(screen.getByTestId('client-popover')).toBeInTheDocument(); + + await user.click(screen.getByTestId('client-icon-vscode')); + expect(screen.queryByTestId('client-popover')).not.toBeInTheDocument(); + }); + + it('should call addToVscode when clicking Add in VS Code popover', async () => { + const user = userEvent.setup(); + mockedAddVscode.mockResolvedValue(undefined); + + render( + + ); + + await user.click(screen.getByTestId('client-icon-vscode')); + await user.click(screen.getByRole('button', { name: /Add to VS Code/i })); + + expect(mockedAddVscode).toHaveBeenCalledWith('http://localhost:45818'); + }); + + it('should call addToCursor when clicking Add in Cursor popover', async () => { + const user = userEvent.setup(); + mockedAddCursor.mockResolvedValue(undefined); + + render( + + ); + + await user.click(screen.getByTestId('client-icon-cursor')); + await user.click(screen.getByRole('button', { name: /Add to Cursor/i })); + + expect(mockedAddCursor).toHaveBeenCalledWith('http://localhost:45818'); + }); + + it('should show Copy command for Claude Code', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByTestId('client-icon-claude-code')); + + expect(screen.getByText('Claude Code')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Copy command/i })).toBeInTheDocument(); + }); + + it('should copy CLI command for Claude Code', async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + writable: true, + configurable: true, + }); + + render( + + ); + + await user.click(screen.getByTestId('client-icon-claude-code')); + await user.click(screen.getByRole('button', { name: /Copy command/i })); + + expect(writeText).toHaveBeenCalledWith( + expect.stringContaining('claude mcp add') + ); + }); + + it('should copy config when clicking Copy Config icon', async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + writable: true, + configurable: true, + }); + + render( + + ); + + await user.click(screen.getByTestId('client-icon-copy-config')); + await user.click(screen.getByTestId('copy-config-btn')); + + expect(writeText).toHaveBeenCalledWith( + expect.stringContaining('localhost:45818/mcp') + ); + }); + + it('should disable Add button when gateway not running', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByTestId('client-icon-vscode')); + + const addBtn = screen.getByRole('button', { name: /Add to VS Code/i }); + expect(addBtn).toBeDisabled(); + }); + + it('should show orange indicator when gateway not running', () => { + const { container } = render( + + ); + const dot = container.querySelector('.bg-orange-500'); + expect(dot).toBeInTheDocument(); + }); + + it('should show gateway URL', () => { + render( + + ); + expect(screen.getByText('http://localhost:45818')).toBeInTheDocument(); + }); +}); diff --git a/tests/ts/lib/clientInstall.test.ts b/tests/ts/lib/clientInstall.test.ts new file mode 100644 index 00000000..0911606c --- /dev/null +++ b/tests/ts/lib/clientInstall.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../../apps/desktop/src/lib/api/clientInstall', () => ({ + addToVscode: vi.fn(), + addToCursor: vi.fn(), +})); + +import { + addToVscode, + addToCursor, +} from '../../../apps/desktop/src/lib/api/clientInstall'; + +const mockedAddVscode = vi.mocked(addToVscode); +const mockedAddCursor = vi.mocked(addToCursor); + +describe('clientInstall API', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('addToVscode returns void on success', async () => { + mockedAddVscode.mockResolvedValue(undefined); + await expect(addToVscode('http://localhost:45818')).resolves.toBeUndefined(); + }); + + it('addToCursor returns void on success', async () => { + mockedAddCursor.mockResolvedValue(undefined); + await expect(addToCursor('http://localhost:45818')).resolves.toBeUndefined(); + }); + + it('addToVscode rejects on error', async () => { + mockedAddVscode.mockRejectedValue(new Error('VS Code not found')); + await expect(addToVscode('http://localhost:45818')).rejects.toThrow('VS Code not found'); + }); + + it('addToCursor rejects on error', async () => { + mockedAddCursor.mockRejectedValue(new Error('Cursor not found')); + await expect(addToCursor('http://localhost:45818')).rejects.toThrow('Cursor not found'); + }); +});