Skip to content

Commit a3209b2

Browse files
committed
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 <noreply@anthropic.com>
1 parent 7a7fabf commit a3209b2

2 files changed

Lines changed: 369 additions & 0 deletions

File tree

tests/ts/components/App.test.tsx

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { render, screen, waitFor, act } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
5+
// ---------- Hoisted mock functions (available before vi.mock factories run) ----------
6+
7+
const { mockInvoke, mockCheck, mockGetGatewayStatus } = vi.hoisted(() => ({
8+
mockInvoke: vi.fn(),
9+
mockCheck: vi.fn(),
10+
mockGetGatewayStatus: vi.fn(),
11+
}));
12+
13+
// ---------- Module mocks ----------
14+
15+
// Override Tauri core mock from setup.ts with our local reference
16+
vi.mock('@tauri-apps/api/core', () => ({
17+
invoke: mockInvoke,
18+
}));
19+
20+
vi.mock('@tauri-apps/plugin-updater', () => ({
21+
check: mockCheck,
22+
}));
23+
24+
// Mock page components as lightweight stubs
25+
vi.mock('@/features/registry', () => ({
26+
RegistryPage: () => <div data-testid="registry-page" />,
27+
}));
28+
vi.mock('@/features/featuresets', () => ({
29+
FeatureSetsPage: () => <div data-testid="featuresets-page" />,
30+
}));
31+
vi.mock('@/features/clients', () => ({
32+
ClientsPage: () => <div data-testid="clients-page" />,
33+
}));
34+
vi.mock('@/features/servers', () => ({
35+
ServersPage: () => <div data-testid="servers-page" />,
36+
}));
37+
vi.mock('@/features/spaces', () => ({
38+
SpacesPage: () => <div data-testid="spaces-page" />,
39+
}));
40+
vi.mock('@/features/settings', () => ({
41+
SettingsPage: () => <div data-testid="settings-page" />,
42+
}));
43+
44+
// Mock non-essential components
45+
vi.mock('@/components/OAuthConsentModal', () => ({
46+
OAuthConsentModal: () => null,
47+
}));
48+
vi.mock('@/components/ServerInstallModal', () => ({
49+
ServerInstallModal: () => null,
50+
}));
51+
vi.mock('@/components/SpaceSwitcher', () => ({
52+
SpaceSwitcher: () => <div data-testid="space-switcher" />,
53+
}));
54+
vi.mock('@/components/ThemeProvider', () => ({
55+
ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
56+
}));
57+
58+
// Mock hooks
59+
vi.mock('@/hooks/useDataSync', () => ({
60+
useDataSync: vi.fn(),
61+
}));
62+
63+
type GatewayPayload = { action: string; url?: string; port?: number };
64+
let gatewayEventCallbacks: ((payload: GatewayPayload) => void)[] = [];
65+
66+
vi.mock('@/hooks/useDomainEvents', () => ({
67+
useGatewayEvents: vi.fn((cb: (payload: GatewayPayload) => void) => {
68+
gatewayEventCallbacks.push(cb);
69+
}),
70+
useServerStatusEvents: vi.fn(),
71+
}));
72+
73+
function fireGatewayEvent(payload: GatewayPayload) {
74+
gatewayEventCallbacks.forEach((cb) => cb(payload));
75+
}
76+
77+
// Mock API modules (used via dynamic import in DashboardView and AppContent)
78+
vi.mock('@/lib/api/gateway', () => ({
79+
getGatewayStatus: mockGetGatewayStatus,
80+
startGateway: vi.fn().mockResolvedValue('http://localhost:45818'),
81+
stopGateway: vi.fn().mockResolvedValue(undefined),
82+
restartGateway: vi.fn().mockResolvedValue(undefined),
83+
}));
84+
vi.mock('@/lib/api/clients', () => ({
85+
listClients: vi.fn().mockResolvedValue([]),
86+
}));
87+
vi.mock('@/lib/api/featureSets', () => ({
88+
listFeatureSets: vi.fn().mockResolvedValue([]),
89+
listFeatureSetsBySpace: vi.fn().mockResolvedValue([]),
90+
}));
91+
vi.mock('@/lib/api/registry', () => ({
92+
listInstalledServers: vi.fn().mockResolvedValue([]),
93+
}));
94+
95+
// Mock window API for WindowButton
96+
vi.mock('@tauri-apps/api/window', () => ({
97+
getCurrentWindow: vi.fn(() => ({
98+
minimize: vi.fn(),
99+
maximize: vi.fn(),
100+
close: vi.fn(),
101+
})),
102+
}));
103+
104+
// ---------- Import after mocks ----------
105+
import App from '@/App';
106+
107+
// ---------- Helpers ----------
108+
109+
function setupInvoke(responses: Record<string, unknown>) {
110+
mockInvoke.mockImplementation((cmd: string) => {
111+
if (cmd in responses) {
112+
const val = responses[cmd];
113+
if (val instanceof Error) return Promise.reject(val);
114+
return Promise.resolve(val);
115+
}
116+
return Promise.resolve(undefined);
117+
});
118+
}
119+
120+
function setupGateway(status: { running: boolean; url: string | null }) {
121+
mockGetGatewayStatus.mockResolvedValue({
122+
running: status.running,
123+
url: status.url,
124+
active_sessions: 0,
125+
connected_backends: 0,
126+
});
127+
}
128+
129+
// ---------- Tests ----------
130+
131+
describe('App – dynamic version display', () => {
132+
beforeEach(() => {
133+
gatewayEventCallbacks = [];
134+
setupGateway({ running: false, url: null });
135+
});
136+
137+
it('should display version from get_version command', async () => {
138+
setupInvoke({ get_version: '1.2.3' });
139+
140+
render(<App />);
141+
142+
await waitFor(() => {
143+
expect(screen.getByTestId('sidebar')).toHaveTextContent('McpMux v1.2.3');
144+
});
145+
});
146+
147+
it('should display "McpMux" without version suffix while loading', () => {
148+
// invoke never resolves
149+
mockInvoke.mockImplementation(() => new Promise(() => {}));
150+
151+
render(<App />);
152+
153+
const sidebar = screen.getByTestId('sidebar');
154+
expect(sidebar).toHaveTextContent('McpMux');
155+
expect(sidebar).not.toHaveTextContent('McpMux v');
156+
});
157+
158+
it('should display "McpMux" without crashing when version fetch fails', async () => {
159+
setupInvoke({ get_version: new Error('command failed') });
160+
161+
render(<App />);
162+
163+
// Wait for the rejected promise to be handled
164+
await waitFor(() => {
165+
const sidebar = screen.getByTestId('sidebar');
166+
expect(sidebar).toHaveTextContent('McpMux');
167+
});
168+
169+
// Should not show a version number
170+
expect(screen.getByTestId('sidebar')).not.toHaveTextContent('McpMux v');
171+
});
172+
});
173+
174+
describe('App – dynamic gateway URL display', () => {
175+
beforeEach(() => {
176+
gatewayEventCallbacks = [];
177+
setupInvoke({ get_version: '0.1.2' });
178+
});
179+
180+
it('should show "Not running" as default gateway state', async () => {
181+
setupGateway({ running: false, url: null });
182+
183+
render(<App />);
184+
185+
await waitFor(() => {
186+
expect(screen.getByTestId('sidebar')).toHaveTextContent('Gateway: Not running');
187+
});
188+
});
189+
190+
it('should show "Not running" when gateway is running but url is null', async () => {
191+
setupGateway({ running: true, url: null });
192+
193+
render(<App />);
194+
195+
await waitFor(() => {
196+
expect(screen.getByTestId('sidebar')).toHaveTextContent('Gateway: Not running');
197+
});
198+
});
199+
200+
it('should update URL when gateway-started event fires', async () => {
201+
setupGateway({ running: false, url: null });
202+
203+
render(<App />);
204+
205+
await waitFor(() => {
206+
expect(screen.getByTestId('sidebar')).toHaveTextContent('Gateway: Not running');
207+
});
208+
209+
// Simulate gateway started event
210+
act(() => {
211+
fireGatewayEvent({ action: 'started', url: 'http://localhost:9999' });
212+
});
213+
214+
await waitFor(() => {
215+
expect(screen.getByTestId('sidebar')).toHaveTextContent(
216+
'Gateway: http://localhost:9999'
217+
);
218+
});
219+
});
220+
221+
it('should show "Not running" when gateway-stopped event fires', async () => {
222+
setupGateway({ running: false, url: null });
223+
224+
render(<App />);
225+
226+
await waitFor(() => {
227+
expect(screen.getByTestId('sidebar')).toHaveTextContent('Gateway: Not running');
228+
});
229+
230+
// Start the gateway via event, then stop it
231+
act(() => {
232+
fireGatewayEvent({ action: 'started', url: 'http://localhost:45818' });
233+
});
234+
235+
await waitFor(() => {
236+
expect(screen.getByTestId('sidebar')).toHaveTextContent(
237+
'Gateway: http://localhost:45818'
238+
);
239+
});
240+
241+
// Simulate gateway stopped event
242+
act(() => {
243+
fireGatewayEvent({ action: 'stopped' });
244+
});
245+
246+
await waitFor(() => {
247+
expect(screen.getByTestId('sidebar')).toHaveTextContent('Gateway: Not running');
248+
});
249+
});
250+
});
251+
252+
describe('App – update banner', () => {
253+
beforeEach(() => {
254+
vi.useFakeTimers();
255+
gatewayEventCallbacks = [];
256+
setupInvoke({ get_version: '0.1.2' });
257+
setupGateway({ running: false, url: null });
258+
});
259+
260+
afterEach(() => {
261+
vi.useRealTimers();
262+
});
263+
264+
it('should show update banner when update is available', async () => {
265+
mockCheck.mockResolvedValue({ version: '2.0.0', body: 'New features' });
266+
267+
render(<App />);
268+
269+
// Banner should not be visible before the 5s delay
270+
expect(screen.queryByTestId('update-banner')).not.toBeInTheDocument();
271+
272+
// Trigger the setTimeout, then switch to real timers so waitFor can poll
273+
vi.advanceTimersByTime(5000);
274+
vi.useRealTimers();
275+
276+
await waitFor(() => {
277+
const banner = screen.getByTestId('update-banner');
278+
expect(banner).toBeInTheDocument();
279+
expect(banner).toHaveTextContent('v2.0.0');
280+
expect(banner).toHaveTextContent('is available');
281+
});
282+
});
283+
284+
it('should not show banner when no update is available', async () => {
285+
mockCheck.mockResolvedValue(null);
286+
287+
render(<App />);
288+
289+
vi.advanceTimersByTime(5000);
290+
vi.useRealTimers();
291+
292+
// Give the async check time to resolve and confirm no banner appears
293+
await act(async () => {
294+
await new Promise((r) => setTimeout(r, 50));
295+
});
296+
expect(screen.queryByTestId('update-banner')).not.toBeInTheDocument();
297+
});
298+
299+
it('should not show banner when update check fails', async () => {
300+
mockCheck.mockRejectedValue(new Error('network error'));
301+
302+
render(<App />);
303+
304+
vi.advanceTimersByTime(5000);
305+
vi.useRealTimers();
306+
307+
await act(async () => {
308+
await new Promise((r) => setTimeout(r, 50));
309+
});
310+
expect(screen.queryByTestId('update-banner')).not.toBeInTheDocument();
311+
});
312+
313+
it('should dismiss banner when X button is clicked', async () => {
314+
vi.useRealTimers();
315+
const user = userEvent.setup();
316+
317+
mockCheck.mockResolvedValue({ version: '2.0.0', body: '' });
318+
319+
render(<App />);
320+
321+
// Wait for the 5s setTimeout + async check to complete
322+
await waitFor(
323+
() => {
324+
expect(screen.getByTestId('update-banner')).toBeInTheDocument();
325+
},
326+
{ timeout: 7000 }
327+
);
328+
329+
// Click dismiss
330+
await user.click(screen.getByTestId('dismiss-update-banner'));
331+
332+
await waitFor(() => {
333+
expect(screen.queryByTestId('update-banner')).not.toBeInTheDocument();
334+
});
335+
});
336+
337+
it('should navigate to Settings and hide banner when "Update now" is clicked', async () => {
338+
vi.useRealTimers();
339+
const user = userEvent.setup();
340+
341+
mockCheck.mockResolvedValue({ version: '2.0.0', body: '' });
342+
343+
render(<App />);
344+
345+
await waitFor(
346+
() => {
347+
expect(screen.getByTestId('update-banner')).toBeInTheDocument();
348+
},
349+
{ timeout: 7000 }
350+
);
351+
352+
// Click "Update now"
353+
await user.click(screen.getByText('Update now'));
354+
355+
await waitFor(() => {
356+
// Banner should be gone
357+
expect(screen.queryByTestId('update-banner')).not.toBeInTheDocument();
358+
// Settings page should be rendered
359+
expect(screen.getByTestId('settings-page')).toBeInTheDocument();
360+
});
361+
});
362+
});

tests/ts/vitest.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ export default defineConfig({
3232
alias: {
3333
'@': path.resolve(__dirname, '../../apps/desktop/src'),
3434
'@mcpmux/ui': path.resolve(__dirname, '../../packages/ui/src'),
35+
// Tauri packages live in apps/desktop/node_modules — alias them so
36+
// vi.mock() calls in tests resolve to the same module IDs as the
37+
// source code imports from apps/desktop/src/.
38+
'@tauri-apps/api': path.resolve(__dirname, '../../apps/desktop/node_modules/@tauri-apps/api'),
39+
'@tauri-apps/plugin-updater': path.resolve(__dirname, '../../apps/desktop/node_modules/@tauri-apps/plugin-updater'),
40+
'@tauri-apps/plugin-process': path.resolve(__dirname, '../../apps/desktop/node_modules/@tauri-apps/plugin-process'),
41+
'@tauri-apps/plugin-opener': path.resolve(__dirname, '../../apps/desktop/node_modules/@tauri-apps/plugin-opener'),
3542
},
3643
},
3744
});

0 commit comments

Comments
 (0)