From 1db1e2cd285c915657030a30cd00d28f109a96ef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 08:00:23 +0000 Subject: [PATCH 1/3] fix: show dynamic version and gateway URL in sidebar, prompt for updates on startup The sidebar footer previously hardcoded "McpMux v0.1.0" and "Gateway: localhost:9315" which never reflected the actual app version or gateway URL. Now fetches the real version via get_version Tauri command and tracks gateway status reactively via getGatewayStatus + useGatewayEvents. Also surfaces a dismissible update banner at the top of the content area when a new version is detected on startup. https://claude.ai/code/session_01K876wXjp55HfRLDCAG9FmU Signed-off-by: Claude --- apps/desktop/src/App.tsx | 75 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 5e2d4740..81ca3896 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,4 +1,5 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; +import { invoke } from '@tauri-apps/api/core'; import { Home, Server, @@ -12,6 +13,8 @@ import { Loader2, FolderOpen, FileText, + Download, + X, } from 'lucide-react'; import { AppShell, @@ -82,6 +85,7 @@ function AppContent() { useDataSync(); const [activeNav, setActiveNav] = useState('home'); + const [availableUpdate, setAvailableUpdate] = useState<{ version: string } | null>(null); // Auto-check for updates on startup (silent check after 5 seconds) useEffect(() => { @@ -91,7 +95,7 @@ function AppContent() { const update = await check(); if (update) { console.log(`[Auto-Update] Update available: ${update.version}`); - // User can check Settings page to see the update + setAvailableUpdate({ version: update.version }); } } catch (error) { console.error('[Auto-Update] Failed to check for updates:', error); @@ -106,6 +110,39 @@ function AppContent() { const theme = useTheme(); const setTheme = useAppStore((state) => state.setTheme); const activeSpace = useActiveSpace(); + const viewSpace = useViewSpace(); + + // App version from Rust backend + const [appVersion, setAppVersion] = useState(''); + useEffect(() => { + invoke('get_version') + .then(setAppVersion) + .catch((err) => console.error('Failed to get version:', err)); + }, []); + + // Gateway status for sidebar footer + const [gatewayUrl, setGatewayUrl] = useState(null); + const loadGatewayUrl = useCallback(async () => { + try { + const { getGatewayStatus } = await import('@/lib/api/gateway'); + const status = await getGatewayStatus(viewSpace?.id); + setGatewayUrl(status.running && status.url ? status.url : null); + } catch { + setGatewayUrl(null); + } + }, [viewSpace?.id]); + + useEffect(() => { + loadGatewayUrl(); + }, [loadGatewayUrl]); + + useGatewayEvents((payload) => { + if (payload.action === 'started') { + setGatewayUrl(payload.url || null); + } else if (payload.action === 'stopped') { + setGatewayUrl(null); + } + }); // Toggle dark mode const toggleDarkMode = () => { @@ -119,8 +156,8 @@ function AppContent() { } footer={
-
McpMux v0.1.0
-
Gateway: localhost:9315
+
McpMux{appVersion ? ` v${appVersion}` : ''}
+
Gateway: {gatewayUrl ?? 'Not running'}
} > @@ -234,6 +271,36 @@ function AppContent() { } >
+ {availableUpdate && ( +
+
+ + + McpMux v{availableUpdate.version} is available. + + +
+ +
+ )} {activeNav === 'home' && } {activeNav === 'registry' && } {activeNav === 'servers' && } From 7a7fabf702cfa2762848aa1517eb028ca4304c9e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 08:00:56 +0000 Subject: [PATCH 2/3] chore: update Cargo.lock https://claude.ai/code/session_01K876wXjp55HfRLDCAG9FmU Signed-off-by: Claude --- Cargo.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4ed98c65..bfd3d0f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2609,7 +2609,7 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "mcpmux" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "async-trait", @@ -2646,7 +2646,7 @@ dependencies = [ [[package]] name = "mcpmux-core" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "async-trait", @@ -2669,7 +2669,7 @@ dependencies = [ [[package]] name = "mcpmux-gateway" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "async-stream", @@ -2709,7 +2709,7 @@ dependencies = [ [[package]] name = "mcpmux-mcp" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "async-trait", @@ -2728,7 +2728,7 @@ dependencies = [ [[package]] name = "mcpmux-storage" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "async-trait", From a3209b21241f3c871ee58ffaf831f254801911d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 10:12:22 +0000 Subject: [PATCH 3/3] test: add App.tsx tests for dynamic version, gateway URL, and update banner 12 new tests covering: - Version display: fetches from get_version command, handles loading/error states - Gateway URL: default "Not running" state, null URL handling, reactive updates via useGatewayEvents (started/stopped) - Update banner: shows when update available, hidden when no update or check fails, dismiss button, "Update now" navigates to Settings Also adds @tauri-apps package aliases to vitest config so vi.mock() calls resolve to the same module IDs as source imports from apps/desktop/src/. https://claude.ai/code/session_01K876wXjp55HfRLDCAG9FmU Signed-off-by: Claude --- tests/ts/components/App.test.tsx | 362 +++++++++++++++++++++++++++++++ tests/ts/vitest.config.ts | 7 + 2 files changed, 369 insertions(+) create mode 100644 tests/ts/components/App.test.tsx diff --git a/tests/ts/components/App.test.tsx b/tests/ts/components/App.test.tsx new file mode 100644 index 00000000..2c9fb8ab --- /dev/null +++ b/tests/ts/components/App.test.tsx @@ -0,0 +1,362 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +// ---------- Hoisted mock functions (available before vi.mock factories run) ---------- + +const { mockInvoke, mockCheck, mockGetGatewayStatus } = vi.hoisted(() => ({ + mockInvoke: vi.fn(), + mockCheck: vi.fn(), + mockGetGatewayStatus: vi.fn(), +})); + +// ---------- Module mocks ---------- + +// Override Tauri core mock from setup.ts with our local reference +vi.mock('@tauri-apps/api/core', () => ({ + invoke: mockInvoke, +})); + +vi.mock('@tauri-apps/plugin-updater', () => ({ + check: mockCheck, +})); + +// Mock page components as lightweight stubs +vi.mock('@/features/registry', () => ({ + RegistryPage: () =>
, +})); +vi.mock('@/features/featuresets', () => ({ + FeatureSetsPage: () =>
, +})); +vi.mock('@/features/clients', () => ({ + ClientsPage: () =>
, +})); +vi.mock('@/features/servers', () => ({ + ServersPage: () =>
, +})); +vi.mock('@/features/spaces', () => ({ + SpacesPage: () =>
, +})); +vi.mock('@/features/settings', () => ({ + SettingsPage: () =>
, +})); + +// Mock non-essential components +vi.mock('@/components/OAuthConsentModal', () => ({ + OAuthConsentModal: () => null, +})); +vi.mock('@/components/ServerInstallModal', () => ({ + ServerInstallModal: () => null, +})); +vi.mock('@/components/SpaceSwitcher', () => ({ + SpaceSwitcher: () =>
, +})); +vi.mock('@/components/ThemeProvider', () => ({ + ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +// Mock hooks +vi.mock('@/hooks/useDataSync', () => ({ + useDataSync: vi.fn(), +})); + +type GatewayPayload = { action: string; url?: string; port?: number }; +let gatewayEventCallbacks: ((payload: GatewayPayload) => void)[] = []; + +vi.mock('@/hooks/useDomainEvents', () => ({ + useGatewayEvents: vi.fn((cb: (payload: GatewayPayload) => void) => { + gatewayEventCallbacks.push(cb); + }), + useServerStatusEvents: vi.fn(), +})); + +function fireGatewayEvent(payload: GatewayPayload) { + gatewayEventCallbacks.forEach((cb) => cb(payload)); +} + +// Mock API modules (used via dynamic import in DashboardView and AppContent) +vi.mock('@/lib/api/gateway', () => ({ + getGatewayStatus: mockGetGatewayStatus, + startGateway: vi.fn().mockResolvedValue('http://localhost:45818'), + stopGateway: vi.fn().mockResolvedValue(undefined), + restartGateway: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('@/lib/api/clients', () => ({ + listClients: vi.fn().mockResolvedValue([]), +})); +vi.mock('@/lib/api/featureSets', () => ({ + listFeatureSets: vi.fn().mockResolvedValue([]), + listFeatureSetsBySpace: vi.fn().mockResolvedValue([]), +})); +vi.mock('@/lib/api/registry', () => ({ + listInstalledServers: vi.fn().mockResolvedValue([]), +})); + +// Mock window API for WindowButton +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: vi.fn(() => ({ + minimize: vi.fn(), + maximize: vi.fn(), + close: vi.fn(), + })), +})); + +// ---------- Import after mocks ---------- +import App from '@/App'; + +// ---------- Helpers ---------- + +function setupInvoke(responses: Record) { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd in responses) { + const val = responses[cmd]; + if (val instanceof Error) return Promise.reject(val); + return Promise.resolve(val); + } + return Promise.resolve(undefined); + }); +} + +function setupGateway(status: { running: boolean; url: string | null }) { + mockGetGatewayStatus.mockResolvedValue({ + running: status.running, + url: status.url, + active_sessions: 0, + connected_backends: 0, + }); +} + +// ---------- Tests ---------- + +describe('App – dynamic version display', () => { + beforeEach(() => { + gatewayEventCallbacks = []; + setupGateway({ running: false, url: null }); + }); + + it('should display version from get_version command', async () => { + setupInvoke({ get_version: '1.2.3' }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('sidebar')).toHaveTextContent('McpMux v1.2.3'); + }); + }); + + it('should display "McpMux" without version suffix while loading', () => { + // invoke never resolves + mockInvoke.mockImplementation(() => new Promise(() => {})); + + render(); + + const sidebar = screen.getByTestId('sidebar'); + expect(sidebar).toHaveTextContent('McpMux'); + expect(sidebar).not.toHaveTextContent('McpMux v'); + }); + + it('should display "McpMux" without crashing when version fetch fails', async () => { + setupInvoke({ get_version: new Error('command failed') }); + + render(); + + // Wait for the rejected promise to be handled + await waitFor(() => { + const sidebar = screen.getByTestId('sidebar'); + expect(sidebar).toHaveTextContent('McpMux'); + }); + + // Should not show a version number + expect(screen.getByTestId('sidebar')).not.toHaveTextContent('McpMux v'); + }); +}); + +describe('App – dynamic gateway URL display', () => { + beforeEach(() => { + gatewayEventCallbacks = []; + setupInvoke({ get_version: '0.1.2' }); + }); + + it('should show "Not running" as default gateway state', async () => { + setupGateway({ running: false, url: null }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('sidebar')).toHaveTextContent('Gateway: Not running'); + }); + }); + + it('should show "Not running" when gateway is running but url is null', async () => { + setupGateway({ running: true, url: null }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('sidebar')).toHaveTextContent('Gateway: Not running'); + }); + }); + + it('should update URL when gateway-started event fires', async () => { + setupGateway({ running: false, url: null }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('sidebar')).toHaveTextContent('Gateway: Not running'); + }); + + // Simulate gateway started event + act(() => { + fireGatewayEvent({ action: 'started', url: 'http://localhost:9999' }); + }); + + await waitFor(() => { + expect(screen.getByTestId('sidebar')).toHaveTextContent( + 'Gateway: http://localhost:9999' + ); + }); + }); + + it('should show "Not running" when gateway-stopped event fires', async () => { + setupGateway({ running: false, url: null }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('sidebar')).toHaveTextContent('Gateway: Not running'); + }); + + // Start the gateway via event, then stop it + act(() => { + fireGatewayEvent({ action: 'started', url: 'http://localhost:45818' }); + }); + + await waitFor(() => { + expect(screen.getByTestId('sidebar')).toHaveTextContent( + 'Gateway: http://localhost:45818' + ); + }); + + // Simulate gateway stopped event + act(() => { + fireGatewayEvent({ action: 'stopped' }); + }); + + await waitFor(() => { + expect(screen.getByTestId('sidebar')).toHaveTextContent('Gateway: Not running'); + }); + }); +}); + +describe('App – update banner', () => { + beforeEach(() => { + vi.useFakeTimers(); + gatewayEventCallbacks = []; + setupInvoke({ get_version: '0.1.2' }); + setupGateway({ running: false, url: null }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should show update banner when update is available', async () => { + mockCheck.mockResolvedValue({ version: '2.0.0', body: 'New features' }); + + render(); + + // Banner should not be visible before the 5s delay + expect(screen.queryByTestId('update-banner')).not.toBeInTheDocument(); + + // Trigger the setTimeout, then switch to real timers so waitFor can poll + vi.advanceTimersByTime(5000); + vi.useRealTimers(); + + await waitFor(() => { + const banner = screen.getByTestId('update-banner'); + expect(banner).toBeInTheDocument(); + expect(banner).toHaveTextContent('v2.0.0'); + expect(banner).toHaveTextContent('is available'); + }); + }); + + it('should not show banner when no update is available', async () => { + mockCheck.mockResolvedValue(null); + + render(); + + vi.advanceTimersByTime(5000); + vi.useRealTimers(); + + // Give the async check time to resolve and confirm no banner appears + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + expect(screen.queryByTestId('update-banner')).not.toBeInTheDocument(); + }); + + it('should not show banner when update check fails', async () => { + mockCheck.mockRejectedValue(new Error('network error')); + + render(); + + vi.advanceTimersByTime(5000); + vi.useRealTimers(); + + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + expect(screen.queryByTestId('update-banner')).not.toBeInTheDocument(); + }); + + it('should dismiss banner when X button is clicked', async () => { + vi.useRealTimers(); + const user = userEvent.setup(); + + mockCheck.mockResolvedValue({ version: '2.0.0', body: '' }); + + render(); + + // Wait for the 5s setTimeout + async check to complete + await waitFor( + () => { + expect(screen.getByTestId('update-banner')).toBeInTheDocument(); + }, + { timeout: 7000 } + ); + + // Click dismiss + await user.click(screen.getByTestId('dismiss-update-banner')); + + await waitFor(() => { + expect(screen.queryByTestId('update-banner')).not.toBeInTheDocument(); + }); + }); + + it('should navigate to Settings and hide banner when "Update now" is clicked', async () => { + vi.useRealTimers(); + const user = userEvent.setup(); + + mockCheck.mockResolvedValue({ version: '2.0.0', body: '' }); + + render(); + + await waitFor( + () => { + expect(screen.getByTestId('update-banner')).toBeInTheDocument(); + }, + { timeout: 7000 } + ); + + // Click "Update now" + await user.click(screen.getByText('Update now')); + + await waitFor(() => { + // Banner should be gone + expect(screen.queryByTestId('update-banner')).not.toBeInTheDocument(); + // Settings page should be rendered + expect(screen.getByTestId('settings-page')).toBeInTheDocument(); + }); + }); +}); diff --git a/tests/ts/vitest.config.ts b/tests/ts/vitest.config.ts index 874ac692..0122020c 100644 --- a/tests/ts/vitest.config.ts +++ b/tests/ts/vitest.config.ts @@ -32,6 +32,13 @@ export default defineConfig({ alias: { '@': path.resolve(__dirname, '../../apps/desktop/src'), '@mcpmux/ui': path.resolve(__dirname, '../../packages/ui/src'), + // Tauri packages live in apps/desktop/node_modules — alias them so + // vi.mock() calls in tests resolve to the same module IDs as the + // source code imports from apps/desktop/src/. + '@tauri-apps/api': path.resolve(__dirname, '../../apps/desktop/node_modules/@tauri-apps/api'), + '@tauri-apps/plugin-updater': path.resolve(__dirname, '../../apps/desktop/node_modules/@tauri-apps/plugin-updater'), + '@tauri-apps/plugin-process': path.resolve(__dirname, '../../apps/desktop/node_modules/@tauri-apps/plugin-process'), + '@tauri-apps/plugin-opener': path.resolve(__dirname, '../../apps/desktop/node_modules/@tauri-apps/plugin-opener'), }, }, });