diff --git a/Cargo.lock b/Cargo.lock index 30e6afd3..9afc7585 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2548,7 +2548,7 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "mcpmux" -version = "0.0.5" +version = "0.0.6" dependencies = [ "anyhow", "async-trait", @@ -2585,7 +2585,7 @@ dependencies = [ [[package]] name = "mcpmux-core" -version = "0.0.5" +version = "0.0.6" dependencies = [ "anyhow", "async-trait", @@ -2607,7 +2607,7 @@ dependencies = [ [[package]] name = "mcpmux-gateway" -version = "0.0.5" +version = "0.0.6" dependencies = [ "anyhow", "async-stream", @@ -2647,7 +2647,7 @@ dependencies = [ [[package]] name = "mcpmux-mcp" -version = "0.0.5" +version = "0.0.6" dependencies = [ "anyhow", "async-trait", @@ -2666,7 +2666,7 @@ dependencies = [ [[package]] name = "mcpmux-storage" -version = "0.0.5" +version = "0.0.6" dependencies = [ "anyhow", "async-trait", @@ -5149,6 +5149,7 @@ dependencies = [ "serde", "serde_json", "tauri", + "tauri-plugin-deep-link", "thiserror 2.0.18", "tracing", "windows-sys 0.60.2", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index ca0d082d..4eee018a 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -17,7 +17,7 @@ serde_json.workspace = true [dependencies] tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-opener = "2" -tauri-plugin-single-instance = "2" +tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } tauri-plugin-deep-link = "2" tauri-plugin-updater = "2" tauri-plugin-autostart = "2" diff --git a/apps/desktop/src-tauri/src/commands/gateway.rs b/apps/desktop/src-tauri/src/commands/gateway.rs index cac15623..c75aba4f 100644 --- a/apps/desktop/src-tauri/src/commands/gateway.rs +++ b/apps/desktop/src-tauri/src/commands/gateway.rs @@ -504,9 +504,10 @@ fn create_gateway_dependencies( builder.build().map_err(|e: String| e) } -/// Get gateway status +/// Get gateway status, optionally scoped to a specific space #[tauri::command] pub async fn get_gateway_status( + space_id: Option, gateway_state: State<'_, Arc>>, server_manager_state: State<'_, Arc>>, ) -> Result { @@ -519,19 +520,24 @@ pub async fn get_gateway_status( 0 }; - // Get connected count from ServerManager + // Get connected count from ServerManager, scoped to space if provided let connected_backends = { let sm_state = server_manager_state.read().await; if let Some(ref manager) = sm_state.manager { - manager.connected_count().await + if let Some(ref sid) = space_id { + let uuid = Uuid::parse_str(sid).map_err(|e| e.to_string())?; + manager.connected_count_for_space(&uuid).await + } else { + manager.connected_count().await + } } else { 0 } }; info!( - "[Gateway] get_gateway_status: running={}, url={:?}, sessions={}, backends={}", - state.running, state.url, active_sessions, connected_backends + "[Gateway] get_gateway_status: running={}, url={:?}, sessions={}, backends={}, space={:?}", + state.running, state.url, active_sessions, connected_backends, space_id ); Ok(GatewayStatus { diff --git a/apps/desktop/src-tauri/src/commands/settings.rs b/apps/desktop/src-tauri/src/commands/settings.rs index dde5b369..70c10bc5 100644 --- a/apps/desktop/src-tauri/src/commands/settings.rs +++ b/apps/desktop/src-tauri/src/commands/settings.rs @@ -22,8 +22,8 @@ pub struct StartupSettings { impl Default for StartupSettings { fn default() -> Self { Self { - auto_launch: false, - start_minimized: false, + auto_launch: true, + start_minimized: true, close_to_tray: true, // Default to close-to-tray behavior } } @@ -44,20 +44,22 @@ pub async fn get_startup_settings( .is_enabled() .map_err(|e| format!("Failed to check auto-launch status: {}", e))?; - // Get other settings from database + // Get other settings from database; use defaults when key is missing or DB read fails (e.g. no settings yet) let start_minimized = settings_repo .get("startup.start_minimized") .await - .map_err(|e| format!("Failed to get start_minimized setting: {}", e))? + .ok() + .flatten() .map(|v| v == "true") - .unwrap_or(false); + .unwrap_or(true); let close_to_tray = settings_repo .get("ui.close_to_tray") .await - .map_err(|e| format!("Failed to get close_to_tray setting: {}", e))? + .ok() + .flatten() .map(|v| v == "true") - .unwrap_or(true); // Default to true + .unwrap_or(true); Ok(StartupSettings { auto_launch, @@ -96,6 +98,12 @@ pub async fn update_startup_settings( info!("[Settings] Auto-launch unchanged, skipping OS update"); } + // Mark autostart as explicitly configured so first-launch logic won't re-enable it + settings_repo + .set("startup.autostart_configured", "true") + .await + .map_err(|e| format!("Failed to save autostart_configured flag: {}", e))?; + // Update other settings in database settings_repo .set( @@ -127,8 +135,8 @@ mod tests { #[test] fn test_startup_settings_default() { let settings = StartupSettings::default(); - assert_eq!(settings.auto_launch, false); - assert_eq!(settings.start_minimized, false); + assert_eq!(settings.auto_launch, true); + assert_eq!(settings.start_minimized, true); assert_eq!(settings.close_to_tray, true); } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 49527560..f00a0b85 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -569,6 +569,61 @@ pub fn run() { } } + // Enable auto-start on first launch if not already configured. + // The OS-level autostart is only set if not previously enabled/disabled by the user. + // This ensures fresh installs get autostart without requiring manual Settings toggle. + { + let autostart_manager: tauri::State<'_, tauri_plugin_autostart::AutoLaunchManager> = app.state(); + match autostart_manager.is_enabled() { + Ok(false) => { + // Check if user has ever explicitly configured autostart + let app_state: tauri::State<'_, AppState> = app.state(); + let was_configured = tauri::async_runtime::block_on(async { + app_state.settings_repository + .get("startup.autostart_configured") + .await + .ok() + .flatten() + .is_some() + }); + + if !was_configured { + // First launch: enable autostart and mark as configured + if let Err(e) = autostart_manager.enable() { + warn!("[Autostart] Failed to enable on first launch: {}", e); + } else { + info!("[Autostart] Enabled on first launch"); + } + tauri::async_runtime::block_on(async { + let _ = app_state.settings_repository + .set("startup.autostart_configured", "true") + .await; + }); + } + } + Ok(true) => { + info!("[Autostart] Already enabled"); + } + Err(e) => { + warn!("[Autostart] Failed to check status: {}", e); + } + } + } + + // Register deep link protocol in OS (Windows registry / Linux xdg-mime) + // NSIS writes to HKCU, MSI writes to HKLM — both register during install. + // This register_all() call is a safety net for dev mode and edge cases + // (e.g. AppImage on Linux, portable installs). + #[cfg(any(windows, target_os = "linux"))] + { + use tauri_plugin_deep_link::DeepLinkExt; + if let Err(e) = app.deep_link().register_all() { + warn!("[DeepLink] Failed to register protocol schemes: {}", e); + } else { + info!("[DeepLink] Protocol schemes registered successfully"); + } + } + // Register deep link handler for when app receives URLs #[cfg(desktop)] { diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 0f5aeadd..b9817fc7 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -230,7 +230,7 @@ function DashboardView() { import('@/lib/api/featureSets').then((m) => viewSpace?.id ? m.listFeatureSetsBySpace(viewSpace.id) : m.listFeatureSets() ), - import('@/lib/api/gateway').then((m) => m.getGatewayStatus()), + import('@/lib/api/gateway').then((m) => m.getGatewayStatus(viewSpace?.id)), import('@/lib/api/registry').then((m) => m.listInstalledServers(viewSpace?.id)), ]); console.log('[Dashboard] Gateway status received:', gateway); diff --git a/apps/desktop/src/components/ConfigEditorModal.tsx b/apps/desktop/src/components/ConfigEditorModal.tsx index 912b52bf..60b8f014 100644 --- a/apps/desktop/src/components/ConfigEditorModal.tsx +++ b/apps/desktop/src/components/ConfigEditorModal.tsx @@ -4,6 +4,7 @@ import { readSpaceConfig, saveSpaceConfig } from '@/lib/api/spaces'; import { refreshRegistry } from '@/lib/api/registry'; import Editor, { type Monaco } from '@monaco-editor/react'; import type { editor } from 'monaco-editor'; +import { useToast, ToastContainer } from '@mcpmux/ui'; import USER_SPACE_CONFIG_SCHEMA from '../../../../schemas/user-space.schema.json'; interface ConfigEditorModalProps { @@ -23,6 +24,7 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf const [editorReady, setEditorReady] = useState(false); const editorRef = useRef(null); const monacoRef = useRef(null); + const { toasts, success, error: showError } = useToast(); // Delay editor mount to avoid glitch during modal open useEffect(() => { @@ -61,6 +63,7 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf } catch (e) { setIsValidJson(false); setError(`Invalid JSON: ${(e as Error).message}`); + showError('Invalid JSON', (e as Error).message); return; } @@ -69,10 +72,14 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf await saveSpaceConfig(spaceId, content); // Refresh server discovery to pick up new/changed servers await refreshRegistry(); + + success('Configuration saved', 'Space configuration updated successfully'); onSaved(); onClose(); } catch (e) { - setError(String(e)); + const errorMsg = e instanceof Error ? e.message : String(e); + setError(errorMsg); + showError('Failed to save configuration', errorMsg); } finally { setIsSaving(false); } @@ -150,7 +157,9 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf }, [handleFormat, onClose]); return ( -
+ <> + toasts.find(t => t.id === id)?.onClose(id)} /> +
{/* Header */}
@@ -256,5 +265,6 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf )}
+ ); } diff --git a/apps/desktop/src/components/ServerLogViewer.tsx b/apps/desktop/src/components/ServerLogViewer.tsx index 4f9d2e85..99bca323 100644 --- a/apps/desktop/src/components/ServerLogViewer.tsx +++ b/apps/desktop/src/components/ServerLogViewer.tsx @@ -1,5 +1,6 @@ import { useEffect, useState, useRef } from 'react'; import { X, Download, Trash2, RefreshCw } from 'lucide-react'; +import { useToast, ToastContainer } from '@mcpmux/ui'; import { getServerLogs, clearServerLogs, getServerLogFile, type ServerLogEntry } from '@/lib/api/logs'; interface ServerLogViewerProps { @@ -38,6 +39,7 @@ export function ServerLogViewer({ serverId, serverName, onClose }: ServerLogView const [autoRefresh, setAutoRefresh] = useState(false); const scrollContainerRef = useRef(null); const shouldScrollRef = useRef(true); + const { toasts, success, error: showError, dismiss } = useToast(); const loadLogs = async () => { try { @@ -97,19 +99,19 @@ export function ServerLogViewer({ serverId, serverName, onClose }: ServerLogView try { await clearServerLogs(serverId); setLogs([]); + success('Logs cleared', `All logs for "${serverName}" have been cleared`); } catch (e) { - alert(`Failed to clear logs: ${e}`); + showError('Failed to clear logs', e instanceof Error ? e.message : String(e)); } }; const handleOpenInEditor = async () => { try { const filePath = await getServerLogFile(serverId); - // Copy path to clipboard for user to paste into their editor await navigator.clipboard.writeText(filePath); - alert(`Log file path copied to clipboard:\n${filePath}\n\nPaste this into your file explorer or text editor.`); + success('Path copied', `Log file path copied to clipboard`); } catch (e) { - alert(`Failed to get log file path: ${e}`); + showError('Failed to get log file path', e instanceof Error ? e.message : String(e)); } }; @@ -131,6 +133,7 @@ export function ServerLogViewer({ serverId, serverName, onClose }: ServerLogView return (
+
{/* Header */}
diff --git a/apps/desktop/src/components/SpaceSwitcher.tsx b/apps/desktop/src/components/SpaceSwitcher.tsx index 234ade0f..f125d8dd 100644 --- a/apps/desktop/src/components/SpaceSwitcher.tsx +++ b/apps/desktop/src/components/SpaceSwitcher.tsx @@ -5,7 +5,7 @@ import { Plus, Loader2, } from 'lucide-react'; -import { Button } from '@mcpmux/ui'; +import { Button, useToast, ToastContainer } from '@mcpmux/ui'; import { useAppStore, useActiveSpace, @@ -25,6 +25,7 @@ export function SpaceSwitcher({ className = '' }: SpaceSwitcherProps) { const [newName, setNewName] = useState(''); const [showCreateInput, setShowCreateInput] = useState(false); const dropdownRef = useRef(null); + const { toasts, success, error: showError, dismiss } = useToast(); const spaces = useSpaces(); const activeSpace = useActiveSpace(); @@ -56,8 +57,10 @@ export function SpaceSwitcher({ className = '' }: SpaceSwitcherProps) { await setActiveSpaceAPI(spaceId); setActiveSpaceInStore(spaceId); setIsOpen(false); + const activatedSpace = spaces.find(s => s.id === spaceId); + success('Space activated', `Switched to "${activatedSpace?.name || 'Space'}"`); } catch (e) { - console.error('Failed to switch space:', e); + showError('Failed to switch space', e instanceof Error ? e.message : String(e)); } }; @@ -73,8 +76,9 @@ export function SpaceSwitcher({ className = '' }: SpaceSwitcherProps) { setNewName(''); setShowCreateInput(false); setIsOpen(false); + success('Space created', `"${space.name}" has been created and activated`); } catch (e) { - console.error('Failed to create space:', e); + showError('Failed to create space', e instanceof Error ? e.message : String(e)); } finally { setIsCreating(false); } @@ -82,6 +86,7 @@ export function SpaceSwitcher({ className = '' }: SpaceSwitcherProps) { return (
+ {/* Trigger Button */}
)} - {/* Toast notification */} - {toast && ( -
-
-
- - {toast.message} -
-
-
- )} +
); } diff --git a/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx b/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx index 3e603f98..bf26e009 100644 --- a/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx +++ b/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx @@ -20,7 +20,7 @@ import { Shield, Save, } from 'lucide-react'; -import { Button } from '@mcpmux/ui'; +import { Button, useToast, ToastContainer } from '@mcpmux/ui'; import type { FeatureSet, AddMemberInput } from '@/lib/api/featureSets'; import { setFeatureSetMembers } from '@/lib/api/featureSets'; import type { ServerFeature } from '@/lib/api/serverFeatures'; @@ -48,10 +48,7 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); const [expandedServers, setExpandedServers] = useState>(new Set()); - // Edit state for custom sets (setters reserved for future metadata edit UI) - const [editName, _setEditName] = useState(featureSet.name); - const [editDescription, _setEditDescription] = useState(featureSet.description || ''); - const [editIcon, _setEditIcon] = useState(featureSet.icon || ''); + const { toasts, success, error: showError, dismiss } = useToast(); // Collapsible sections - only one expanded at a time, features by default const [expandedSections, setExpandedSections] = useState({ @@ -221,30 +218,12 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda await setFeatureSetMembers(featureSet.id, members); - // Update metadata if custom - if (isCustom) { - // Only update if changed - if (editName !== featureSet.name || - editDescription !== (featureSet.description || '') || - editIcon !== (featureSet.icon || '')) { - // Note: Update logic would go here if API supports it. - // Assuming we might not have updateFeatureSet endpoint exposed fully or need to check - // For now, let's assume we can only update members based on the previous file. - // But wait, ClientsPage.tsx imported updateClient. - // FeatureSetsPage.tsx didn't show updateFeatureSet. - // I'll check if updateFeatureSet is available in the library if not I might need to skip metadata update - // or use what's available. - // The read of FeatureSetsPage.tsx showed createFeatureSet. - // I'll assume for now we just save members, but I added the UI for it. - // If I can't update metadata, I'll remove that part or implement it if possible. - // Let's check imports. I added `updateFeatureSet` to imports but I need to verify if it exists. - // I'll leave it out for now to be safe and just focus on members unless I see it exists. - } - } - + success('Changes saved', `"${featureSet.name}" has been updated with ${members.length} feature${members.length !== 1 ? 's' : ''}`); onUpdate?.(); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + const errorMsg = e instanceof Error ? e.message : String(e); + setError(errorMsg); + showError('Failed to save changes', errorMsg); } finally { setIsSaving(false); } @@ -299,6 +278,7 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda return (
+ {/* Panel Header */}
diff --git a/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx b/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx index bc256b82..44d0c817 100644 --- a/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx +++ b/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx @@ -18,6 +18,8 @@ import { CardTitle, CardContent, Button, + useToast, + ToastContainer, } from '@mcpmux/ui'; import type { FeatureSet, CreateFeatureSetInput } from '@/lib/api/featureSets'; import { @@ -67,6 +69,7 @@ export function FeatureSetsPage() { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(''); + const { toasts, success, error: showError } = useToast(); // Create modal state const [showCreateModal, setShowCreateModal] = useState(false); @@ -122,10 +125,14 @@ export function FeatureSetsPage() { setCreateIcon(''); setShowCreateModal(false); + success('Feature set created', `"${newFs.name}" has been created successfully`); + // Automatically open the new feature set handleOpenPanel(newFs); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + const errorMsg = e instanceof Error ? e.message : String(e); + setError(errorMsg); + showError('Failed to create feature set', errorMsg); } finally { setIsCreating(false); } @@ -134,13 +141,18 @@ export function FeatureSetsPage() { const handleDelete = async (id: string) => { // Confirmation handled by caller if needed, but we do it here too just in case called directly try { + const deletedSet = featureSets.find(fs => fs.id === id); await deleteFeatureSet(id); setFeatureSets((prev) => prev.filter((fs) => fs.id !== id)); if (selectedFeatureSet?.id === id) { setSelectedFeatureSet(null); } + + success('Feature set deleted', `"${deletedSet?.name || 'Feature set'}" has been deleted`); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + const errorMsg = e instanceof Error ? e.message : String(e); + setError(errorMsg); + showError('Failed to delete feature set', errorMsg); } }; @@ -176,7 +188,9 @@ export function FeatureSetsPage() { }); return ( -
+ <> + toasts.find(t => t.id === id)?.onClose(id)} /> +
{/* Header */}
@@ -422,6 +436,7 @@ export function FeatureSetsPage() {
)}
+ ); } diff --git a/apps/desktop/src/features/registry/RegistryPage.tsx b/apps/desktop/src/features/registry/RegistryPage.tsx index 447a3dc4..f7ad77c2 100644 --- a/apps/desktop/src/features/registry/RegistryPage.tsx +++ b/apps/desktop/src/features/registry/RegistryPage.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from 'react'; import { ChevronDown } from 'lucide-react'; +import { useToast, ToastContainer } from '@mcpmux/ui'; import { useRegistryStore } from '../../stores/registryStore'; import { ServerCard } from './ServerCard'; import { ServerDetailModal } from './ServerDetailModal'; @@ -37,6 +38,7 @@ export function RegistryPage() { const [localSearch, setLocalSearch] = useState(''); const viewSpace = useViewSpace(); + const { toasts, success, error: showToastError, dismiss } = useToast(); const itemsPerPage = uiConfig?.items_per_page ?? 24; @@ -89,13 +91,27 @@ export function RegistryPage() { }, [localSearch, searchQuery, search]); const handleInstall = async (id: string) => { - await installServer(id, viewSpace?.id); + const server = servers.find(s => s.id === id); + const serverName = server?.name || 'Server'; + try { + await installServer(id, viewSpace?.id); + success('Server installed', `"${serverName}" has been installed`); + } catch { + showToastError('Install failed', `Failed to install "${serverName}"`); + } }; const handleUninstall = async (id: string) => { - await uninstallServer(id); - if (selectedServer?.id === id) { - selectServer(null); + const server = servers.find(s => s.id === id); + const serverName = server?.name || 'Server'; + try { + await uninstallServer(id); + if (selectedServer?.id === id) { + selectServer(null); + } + success('Server uninstalled', `"${serverName}" has been uninstalled`); + } catch { + showToastError('Uninstall failed', `Failed to uninstall "${serverName}"`); } }; @@ -104,6 +120,7 @@ export function RegistryPage() { return (
+ {/* Header */}
diff --git a/apps/desktop/src/features/servers/ServersPage.tsx b/apps/desktop/src/features/servers/ServersPage.tsx index 79562d07..7b9f2fbb 100644 --- a/apps/desktop/src/features/servers/ServersPage.tsx +++ b/apps/desktop/src/features/servers/ServersPage.tsx @@ -273,7 +273,7 @@ export function ServersPage() { // Use allSettled so we can show installed servers even if registry is offline const [installedResult, gatewayResult, definitionsResult] = await Promise.allSettled([ import('@/lib/api/registry').then((m) => m.listInstalledServers(viewSpace?.id)), - import('@/lib/api/gateway').then((m) => m.getGatewayStatus()), + import('@/lib/api/gateway').then((m) => m.getGatewayStatus(viewSpace?.id)), import('@/lib/api/registry').then((m) => m.discoverServers()), ]); @@ -285,6 +285,7 @@ export function ServersPage() { const definitions = definitionsResult.status === 'fulfilled' ? definitionsResult.value : []; + // Log if registry is offline but we have installed servers if (definitionsResult.status === 'rejected' && installed.length > 0) { @@ -298,8 +299,17 @@ export function ServersPage() { if (definitions.length > 0) { // Normal case: merge definitions with states - mergedServers = mergeDefinitionsWithStates(definitions, installed) - .filter(s => s.is_installed); + const allMerged = mergeDefinitionsWithStates(definitions, installed); + mergedServers = allMerged.filter(s => s.is_installed); + + // Handle installed servers not present in registry definitions + // (e.g., registry changed, using different registry, or servers installed from user config) + const matchedServerIds = new Set(mergedServers.map(s => s.id)); + const unmatchedInstalled = installed.filter(s => !matchedServerIds.has(s.server_id)); + if (unmatchedInstalled.length > 0) { + const offlineViewModels = unmatchedInstalled.map(state => createOfflineServerViewModel(state)); + mergedServers = [...mergedServers, ...offlineViewModels]; + } } else { // Offline case: create minimal view models from installed states only mergedServers = installed.map(state => createOfflineServerViewModel(state)); diff --git a/apps/desktop/src/features/settings/SettingsPage.tsx b/apps/desktop/src/features/settings/SettingsPage.tsx index a13a2a0c..38402a3d 100644 --- a/apps/desktop/src/features/settings/SettingsPage.tsx +++ b/apps/desktop/src/features/settings/SettingsPage.tsx @@ -8,6 +8,8 @@ import { CardContent, Button, Switch, + useToast, + ToastContainer, } from '@mcpmux/ui'; import { Sun, @@ -34,6 +36,7 @@ export function SettingsPage() { const setTheme = useAppStore((state) => state.setTheme); const [logsPath, setLogsPath] = useState(''); const [openingLogs, setOpeningLogs] = useState(false); + const { toasts, success, error } = useToast(); // Startup settings state const [startupSettings, setStartupSettings] = useState({ @@ -91,8 +94,14 @@ export function SettingsPage() { console.log('[Settings] Invoking update_startup_settings:', newSettings); await invoke('update_startup_settings', { settings: newSettings }); console.log('[Settings] Successfully saved:', newSettings); - } catch (error) { - console.error('[Settings] Failed to save:', error); + + // Show success toast + success('Settings saved', 'Your preferences have been updated'); + } catch (err) { + console.error('[Settings] Failed to save:', err); + // Show error toast + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + error('Failed to save settings', errorMessage); // Revert on error setStartupSettings(oldSettings); } finally { @@ -112,17 +121,19 @@ export function SettingsPage() { }; return ( -
-
-

Settings

-

Configure McpMux preferences.

-
+ <> + toasts.find(t => t.id === id)?.onClose(id)} /> +
+
+

Settings

+

Configure McpMux preferences.

+
{/* Updates Section */} - {/* Startup & System Tray Section */} - + {/* Startup & System Tray Section - always show toggles so e2e and slow backends see the section */} + @@ -134,11 +145,12 @@ export function SettingsPage() { {loadingSettings ? ( -
- +
+ + Loading…
- ) : ( -
+ ) : null} +
@@ -208,8 +220,7 @@ export function SettingsPage() { Saving settings...
)} -
- )} +
@@ -297,5 +308,6 @@ export function SettingsPage() {
+ ); } diff --git a/apps/desktop/src/features/spaces/SpacesPage.tsx b/apps/desktop/src/features/spaces/SpacesPage.tsx index 5b8cef83..1c62ddc4 100644 --- a/apps/desktop/src/features/spaces/SpacesPage.tsx +++ b/apps/desktop/src/features/spaces/SpacesPage.tsx @@ -14,6 +14,8 @@ import { CardTitle, CardContent, Button, + useToast, + ToastContainer, } from '@mcpmux/ui'; import { useAppStore, @@ -37,6 +39,7 @@ export function SpacesPage() { const [searchQuery, setSearchQuery] = useState(''); const [error, setError] = useState(null); const [isActionLoading, setIsActionLoading] = useState(null); // ID of space being acted on + const { toasts, success, error: showError, dismiss } = useToast(); // Create Modal State const [showCreateModal, setShowCreateModal] = useState(false); @@ -55,8 +58,11 @@ export function SpacesPage() { setNewSpaceName(''); setNewSpaceIcon('🌐'); setShowCreateModal(false); + success('Space created', `"${space.name}" has been created`); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + const msg = e instanceof Error ? e.message : String(e); + setError(msg); + showError('Failed to create space', msg); } finally { setIsCreating(false); } @@ -68,10 +74,14 @@ export function SpacesPage() { setIsActionLoading(id); setError(null); try { + const deletedSpace = spaces.find(s => s.id === id); await deleteSpace(id); removeSpace(id); + success('Space deleted', `"${deletedSpace?.name || 'Space'}" has been deleted`); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + const msg = e instanceof Error ? e.message : String(e); + setError(msg); + showError('Failed to delete space', msg); } finally { setIsActionLoading(null); } @@ -83,8 +93,12 @@ export function SpacesPage() { try { await setActiveSpaceAPI(id); setActiveSpaceInStore(id); + const activatedSpace = spaces.find(s => s.id === id); + success('Active space changed', `"${activatedSpace?.name || 'Space'}" is now active`); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + const msg = e instanceof Error ? e.message : String(e); + setError(msg); + showError('Failed to set active space', msg); } finally { setIsActionLoading(null); } @@ -101,6 +115,8 @@ export function SpacesPage() { }); return ( + <> +
{/* Header */}
@@ -323,6 +339,7 @@ export function SpacesPage() {
)}
+ ); } diff --git a/apps/desktop/src/lib/api/gateway.ts b/apps/desktop/src/lib/api/gateway.ts index 240e1dd9..03023ffe 100644 --- a/apps/desktop/src/lib/api/gateway.ts +++ b/apps/desktop/src/lib/api/gateway.ts @@ -18,8 +18,8 @@ export type ExportFormat = 'cursor' | 'vscode' | 'claude'; /** * Get gateway status. */ -export async function getGatewayStatus(): Promise { - return invoke('get_gateway_status'); +export async function getGatewayStatus(spaceId?: string): Promise { + return invoke('get_gateway_status', { spaceId }); } /** diff --git a/apps/desktop/src/stores/appStore.ts b/apps/desktop/src/stores/appStore.ts index 7025a3be..f3030da2 100644 --- a/apps/desktop/src/stores/appStore.ts +++ b/apps/desktop/src/stores/appStore.ts @@ -24,8 +24,11 @@ export const useAppStore = create()( setSpaces: (spaces) => set((state) => { state.spaces = spaces; - // Auto-select active space if none selected - if (!state.activeSpaceId && spaces.length > 0) { + // Validate persisted activeSpaceId still exists, reset to default if not + const activeExists = state.activeSpaceId + ? spaces.some((s) => s.id === state.activeSpaceId) + : false; + if (!activeExists && spaces.length > 0) { const defaultSpace = spaces.find((s) => s.is_default); state.activeSpaceId = defaultSpace?.id ?? spaces[0].id; } diff --git a/crates/mcpmux-gateway/src/pool/server_manager.rs b/crates/mcpmux-gateway/src/pool/server_manager.rs index 213baab7..1f702d52 100644 --- a/crates/mcpmux-gateway/src/pool/server_manager.rs +++ b/crates/mcpmux-gateway/src/pool/server_manager.rs @@ -280,6 +280,20 @@ impl ServerManager { count } + /// Count connected servers for a specific space + pub async fn connected_count_for_space(&self, space_id: &Uuid) -> usize { + let mut count = 0; + for entry in self.states.iter() { + if &entry.key().space_id == space_id { + let state = entry.value().read().await; + if state.status == ConnectionStatus::Connected { + count += 1; + } + } + } + count + } + /// Emit a domain event (unified event system) fn emit(&self, event: DomainEvent) { // Trace Refreshing events to find the source diff --git a/crates/mcpmux-mcp/src/transports.rs b/crates/mcpmux-mcp/src/transports.rs index 56280ba7..792ec83f 100644 --- a/crates/mcpmux-mcp/src/transports.rs +++ b/crates/mcpmux-mcp/src/transports.rs @@ -9,6 +9,10 @@ use std::collections::HashMap; use std::process::Stdio; use std::sync::Arc; +#[cfg(windows)] +#[allow(unused_imports)] // Trait is used via method call in closure +use std::os::windows::process::CommandExt; + use anyhow::{Context, Result}; use rmcp::{ model::{ @@ -140,6 +144,13 @@ impl McpSession { .envs(&env) .stderr(Stdio::null()) .kill_on_drop(true); + + // On Windows, prevent console window from appearing + #[cfg(windows)] + { + const CREATE_NO_WINDOW: u32 = 0x08000000; + cmd.creation_flags(CREATE_NO_WINDOW); + } }) ).context(format!( "Failed to spawn child process. Command not found: {}. Ensure it's installed and in PATH.", diff --git a/packages/ui/package.json b/packages/ui/package.json index 127b23c8..ab0fc47f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -38,6 +38,7 @@ }, "dependencies": { "clsx": "^2.1.1", + "lucide-react": "^0.468.0", "tailwind-merge": "^3.4.0" } } diff --git a/packages/ui/src/components/common/Toast.tsx b/packages/ui/src/components/common/Toast.tsx new file mode 100644 index 00000000..a6cfac4e --- /dev/null +++ b/packages/ui/src/components/common/Toast.tsx @@ -0,0 +1,95 @@ +import { useEffect } from 'react'; +import { X, CheckCircle, XCircle, AlertCircle, Info } from 'lucide-react'; +import { cn } from '../../lib/cn'; + +export type ToastType = 'success' | 'error' | 'warning' | 'info'; + +export interface ToastProps { + id: string; + type: ToastType; + title: string; + message?: string; + duration?: number; + onClose: (id: string) => void; +} + +const iconMap = { + success: CheckCircle, + error: XCircle, + warning: AlertCircle, + info: Info, +}; + +const colorMap = { + success: 'text-green-500', + error: 'text-red-500', + warning: 'text-yellow-500', + info: 'text-blue-500', +}; + +export function Toast({ + id, + type, + title, + message, + duration = 3000, + onClose, +}: ToastProps) { + const Icon = iconMap[type]; + + useEffect(() => { + if (duration > 0) { + const timer = setTimeout(() => { + onClose(id); + }, duration); + return () => clearTimeout(timer); + } + }, [id, duration, onClose]); + + return ( +
+ +
+

{title}

+ {message && ( +

{message}

+ )} +
+ +
+ ); +} + +export function ToastContainer({ + toasts, + onClose, +}: { + toasts: ToastProps[]; + onClose: (id: string) => void; +}) { + return ( +
+ {toasts.map((toast) => ( + + ))} +
+ ); +} diff --git a/packages/ui/src/hooks/useToast.ts b/packages/ui/src/hooks/useToast.ts new file mode 100644 index 00000000..28418948 --- /dev/null +++ b/packages/ui/src/hooks/useToast.ts @@ -0,0 +1,72 @@ +import { useState, useCallback } from 'react'; +import { ToastProps, ToastType } from '../components/common/Toast'; + +export interface ToastOptions { + title: string; + message?: string; + type?: ToastType; + duration?: number; +} + +export function useToast() { + const [toasts, setToasts] = useState([]); + + const showToast = useCallback((options: ToastOptions) => { + const id = `toast-${Date.now()}-${Math.random()}`; + const toast: ToastProps = { + id, + type: options.type || 'info', + title: options.title, + message: options.message, + duration: options.duration ?? 3000, + onClose: (toastId: string) => { + setToasts((prev) => prev.filter((t) => t.id !== toastId)); + }, + }; + + setToasts((prev) => [...prev, toast]); + return id; + }, []); + + const success = useCallback( + (title: string, message?: string, duration?: number) => { + return showToast({ title, message, type: 'success', duration }); + }, + [showToast] + ); + + const error = useCallback( + (title: string, message?: string, duration?: number) => { + return showToast({ title, message, type: 'error', duration }); + }, + [showToast] + ); + + const warning = useCallback( + (title: string, message?: string, duration?: number) => { + return showToast({ title, message, type: 'warning', duration }); + }, + [showToast] + ); + + const info = useCallback( + (title: string, message?: string, duration?: number) => { + return showToast({ title, message, type: 'info', duration }); + }, + [showToast] + ); + + const dismiss = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + return { + toasts, + showToast, + success, + error, + warning, + info, + dismiss, + }; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index c881f5ec..5497a1d8 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -14,6 +14,12 @@ export { Button } from './components/common/Button'; export { Input } from './components/common/Input'; export { Card, CardHeader, CardTitle, CardDescription, CardContent } from './components/common/Card'; export { Switch } from './components/common/Switch'; +export { Toast, ToastContainer } from './components/common/Toast'; +export type { ToastProps, ToastType } from './components/common/Toast'; + +// Hooks +export { useToast } from './hooks/useToast'; +export type { ToastOptions } from './hooks/useToast'; // Utilities export { cn } from './lib/cn'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7e907ff..16f882a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,6 +183,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + lucide-react: + specifier: ^0.468.0 + version: 0.468.0(react@19.2.3) react: specifier: ^19.0.0 version: 19.2.3 @@ -3078,6 +3081,11 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} + lucide-react@0.468.0: + resolution: {integrity: sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + lucide-react@0.561.0: resolution: {integrity: sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==} peerDependencies: @@ -7266,6 +7274,10 @@ snapshots: lru-cache@7.18.3: {} + lucide-react@0.468.0(react@19.2.3): + dependencies: + react: 19.2.3 + lucide-react@0.561.0(react@19.2.3): dependencies: react: 19.2.3 diff --git a/tests/e2e/pages/BasePage.ts b/tests/e2e/pages/BasePage.ts index 52a1fd3e..b825d88d 100644 --- a/tests/e2e/pages/BasePage.ts +++ b/tests/e2e/pages/BasePage.ts @@ -61,4 +61,33 @@ export abstract class BasePage { async screenshot(name: string) { await this.page.screenshot({ path: `./reports/screenshots/${name}.png` }); } + + /** + * Wait for a toast notification of the given type to appear + */ + async waitForToast(type: 'success' | 'error' | 'warning' | 'info', timeout = 5000) { + await this.page.getByTestId(`toast-${type}`).first().waitFor({ timeout }); + } + + /** + * Get text content of the first visible toast + */ + async getToastText(): Promise { + const toast = this.page.getByRole('main').getByTestId('toast-container').locator('[role="alert"]').first(); + return toast.textContent(); + } + + /** + * Dismiss the first visible toast + */ + async dismissToast() { + await this.page.getByTestId('toast-close').first().click(); + } + + /** + * Assert that a toast container is present in the main content area + */ + get toastContainer(): Locator { + return this.page.getByRole('main').getByTestId('toast-container'); + } } diff --git a/tests/e2e/pages/SettingsPage.ts b/tests/e2e/pages/SettingsPage.ts index 75f0d7a2..9de09403 100644 --- a/tests/e2e/pages/SettingsPage.ts +++ b/tests/e2e/pages/SettingsPage.ts @@ -11,6 +11,10 @@ export class SettingsPage extends BasePage { readonly systemThemeButton: Locator; readonly openLogsButton: Locator; readonly logsPath: Locator; + readonly autoLaunchSwitch: Locator; + readonly startMinimizedSwitch: Locator; + readonly closeToTraySwitch: Locator; + readonly toastContainer: Locator; constructor(page: Page) { super(page); @@ -20,6 +24,10 @@ export class SettingsPage extends BasePage { this.systemThemeButton = page.getByRole('button', { name: 'System', exact: true }); this.openLogsButton = page.getByRole('button', { name: /Open Logs/i }); this.logsPath = page.locator('.font-mono').filter({ hasText: /logs|mcpmux/i }); + this.autoLaunchSwitch = page.getByTestId('auto-launch-switch'); + this.startMinimizedSwitch = page.getByTestId('start-minimized-switch'); + this.closeToTraySwitch = page.getByTestId('close-to-tray-switch'); + this.toastContainer = page.getByRole('main').getByTestId('toast-container'); } async selectTheme(theme: 'light' | 'dark' | 'system') { @@ -46,4 +54,17 @@ export class SettingsPage extends BasePage { } return 'system'; } + + async waitForToast(type: 'success' | 'error' | 'warning' | 'info', timeout = 5000) { + await this.page.getByTestId(`toast-${type}`).waitFor({ timeout }); + } + + async getToastText() { + const toast = this.page.getByRole('main').getByTestId('toast-container').locator('[role="alert"]').first(); + return toast.textContent(); + } + + async closeToast() { + await this.page.getByTestId('toast-close').first().click(); + } } diff --git a/tests/e2e/specs/clients.spec.ts b/tests/e2e/specs/clients.spec.ts index 2e2653ae..a1c5fa4e 100644 --- a/tests/e2e/specs/clients.spec.ts +++ b/tests/e2e/specs/clients.spec.ts @@ -111,3 +111,103 @@ test.describe('Client Management', () => { } }); }); + +test.describe('Client Toast Notifications', () => { + test('should have toast container on clients page', async ({ page }) => { + const dashboard = new DashboardPage(page); + const clients = new ClientsPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Clients")').click(); + await expect(clients.heading).toBeVisible(); + + await expect(clients.toastContainer).toBeAttached(); + }); + + // Skip in web mode - requires Tauri API for client operations + test.skip('should show success toast when saving client config', async ({ page }) => { + const dashboard = new DashboardPage(page); + const clients = new ClientsPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Clients")').click(); + + // Click first client card to open panel + const clientCards = page.locator('[data-testid^="client-card-"]'); + const count = await clientCards.count(); + + if (count > 0) { + await clientCards.first().click(); + + // Wait for panel to open + await expect(page.locator('text=Quick Settings')).toBeVisible(); + + // Click Save Changes + const saveButton = page.getByRole('button', { name: /Save Changes/i }); + if (await saveButton.isVisible()) { + await saveButton.click(); + + await clients.waitForToast('success'); + const toastText = await clients.getToastText(); + expect(toastText).toContain('Client settings saved'); + } + } + }); + + // Skip in web mode - requires Tauri API for client deletion + test.skip('should show success toast when removing a client', async ({ page }) => { + const dashboard = new DashboardPage(page); + const clients = new ClientsPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Clients")').click(); + + const clientCards = page.locator('[data-testid^="client-card-"]'); + const count = await clientCards.count(); + + if (count > 0) { + await clientCards.first().click(); + + // Click Remove Client in panel footer + page.on('dialog', dialog => dialog.accept()); + const removeButton = page.getByRole('button', { name: /Remove Client/i }); + if (await removeButton.isVisible()) { + await removeButton.click(); + + await clients.waitForToast('success'); + const toastText = await clients.getToastText(); + expect(toastText).toContain('Client removed'); + } + } + }); + + // Skip in web mode - requires Tauri API for permission toggle + test.skip('should show success toast when toggling feature set grant', async ({ page }) => { + const dashboard = new DashboardPage(page); + const clients = new ClientsPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Clients")').click(); + + const clientCards = page.locator('[data-testid^="client-card-"]'); + const count = await clientCards.count(); + + if (count > 0) { + await clientCards.first().click(); + + // Expand Permissions section + await page.locator('text=Permissions').click(); + await page.waitForTimeout(300); + + // Find a non-default feature set checkbox + const featureSetToggle = page.locator('button:has([class*="rounded border"])').first(); + if (await featureSetToggle.isVisible()) { + await featureSetToggle.click(); + + await clients.waitForToast('success'); + const toastText = await clients.getToastText(); + expect(toastText).toMatch(/Permission (granted|revoked)/); + } + } + }); +}); diff --git a/tests/e2e/specs/featuresets.spec.ts b/tests/e2e/specs/featuresets.spec.ts index d5a89864..e5ea517c 100644 --- a/tests/e2e/specs/featuresets.spec.ts +++ b/tests/e2e/specs/featuresets.spec.ts @@ -71,3 +71,195 @@ test.describe('FeatureSet Details', () => { expect(count).toBeGreaterThanOrEqual(0); }); }); + +test.describe('Feature Set Toast Container', () => { + test('should have toast container on feature sets page', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("FeatureSets")').click({ force: true }); + await expect(page.getByRole('heading', { name: 'Feature Sets' }).first()).toBeVisible(); + + await expect(page.getByRole('main').getByTestId('toast-container')).toBeAttached(); + }); +}); + +test.describe('Feature Set Operations with Toast', () => { + // Skip in web mode - requires Tauri API + test.skip('should show toast when creating feature set', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("FeatureSets")').click({ force: true }); + + // Open create modal + await page.getByRole('button', { name: /Create New/i }).click(); + + // Fill in form + await page.getByLabel(/Name/i).fill('Test Feature Set'); + await page.getByLabel(/Description/i).fill('Test description'); + + // Create + await page.getByRole('button', { name: /Create/i }).click(); + + // Wait for success toast + await expect(page.getByTestId('toast-success')).toBeVisible({ timeout: 2000 }); + await expect(page.getByText('Feature set created')).toBeVisible(); + await expect(page.getByText(/Test Feature Set.*created successfully/i)).toBeVisible(); + + // Toast should auto-dismiss + await expect(page.getByTestId('toast-success')).not.toBeVisible({ timeout: 4000 }); + }); + + // Skip in web mode - requires Tauri API + test.skip('should show toast when deleting feature set', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("FeatureSets")').click({ force: true }); + + // Find a custom feature set to delete (not built-in) + const customSet = page.locator('[data-testid="feature-set-card"]').first(); + + if (await customSet.isVisible()) { + // Click delete button + await customSet.getByRole('button', { name: /Delete/i }).click(); + + // Confirm deletion if modal appears + const confirmButton = page.getByRole('button', { name: /Confirm|Yes|Delete/i }); + if (await confirmButton.isVisible({ timeout: 1000 })) { + await confirmButton.click(); + } + + // Wait for success toast + await expect(page.getByTestId('toast-success')).toBeVisible({ timeout: 2000 }); + await expect(page.getByText('Feature set deleted')).toBeVisible(); + } + }); + + // Skip in web mode - requires Tauri API + test.skip('should show error toast on failed create', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("FeatureSets")').click({ force: true }); + + // Open create modal + await page.getByRole('button', { name: /Create New/i }).click(); + + // Try to create without name (should fail) + await page.getByRole('button', { name: /Create/i }).click(); + + // Button should be disabled or show validation error + const createButton = page.getByRole('button', { name: /Create/i }); + await expect(createButton).toBeDisabled(); + }); +}); + +test.describe('Feature Set Panel Save Toast', () => { + // Skip in web mode - requires Tauri API + test.skip('should show success toast when saving feature set members', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("FeatureSets")').click({ force: true }); + + // Click on a configurable feature set (Default or Custom) + const configurableSet = page.locator('[data-testid^="featureset-card-"]').first(); + if (await configurableSet.isVisible()) { + await configurableSet.click(); + + // Wait for panel to open + await expect(page.locator('text=Save Changes')).toBeVisible({ timeout: 5000 }); + + // Click Save Changes + await page.getByRole('button', { name: /Save Changes/i }).click(); + + // Wait for success toast + await expect(page.getByTestId('toast-success').first()).toBeVisible({ timeout: 5000 }); + const toastText = await page.getByRole('main').getByTestId('toast-container').locator('[role="alert"]').first().textContent(); + expect(toastText).toContain('Changes saved'); + } + }); + + // Skip in web mode - requires Tauri API + test.skip('should show error toast on failed save', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("FeatureSets")').click({ force: true }); + + // Click on a feature set + const featureSetCard = page.locator('[data-testid^="featureset-card-"]').first(); + if (await featureSetCard.isVisible()) { + await featureSetCard.click(); + + // Simulate network error scenario - panel save should show error toast + await page.route('**/feature-sets/*/members', route => route.abort()); + + const saveButton = page.getByRole('button', { name: /Save Changes/i }); + if (await saveButton.isVisible()) { + await saveButton.click(); + + await expect(page.getByTestId('toast-error').first()).toBeVisible({ timeout: 5000 }); + const toastText = await page.getByRole('main').getByTestId('toast-container').locator('[role="alert"]').first().textContent(); + expect(toastText).toContain('Failed to save'); + } + } + }); +}); + +test.describe('Config Editor Toast', () => { + // Skip in web mode - requires Tauri API + test.skip('should show toast when saving space configuration', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + // Go to Spaces page + await page.locator('nav button:has-text("Spaces")').click({ force: true }); + + // Open config editor (usually via "Edit Config" or similar button) + const editConfigButton = page.getByRole('button', { name: /Edit.*Config|Manual/i }); + if (await editConfigButton.isVisible({ timeout: 2000 })) { + await editConfigButton.click(); + + // Wait for editor to load + await page.waitForTimeout(500); + + // Make a change (add a comment or modify JSON) + const editor = page.locator('.monaco-editor'); + if (await editor.isVisible()) { + // Click save button + await page.getByRole('button', { name: /Save/i }).click(); + + // Wait for success toast + await expect(page.getByTestId('toast-success')).toBeVisible({ timeout: 2000 }); + await expect(page.getByText('Configuration saved')).toBeVisible(); + await expect(page.getByText(/updated successfully/i)).toBeVisible(); + } + } + }); + + // Skip in web mode - requires Tauri API + test.skip('should show error toast for invalid JSON', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Spaces")').click({ force: true }); + + const editConfigButton = page.getByRole('button', { name: /Edit.*Config|Manual/i }); + if (await editConfigButton.isVisible({ timeout: 2000 })) { + await editConfigButton.click(); + + await page.waitForTimeout(500); + + // Try to enter invalid JSON (if we can manipulate the editor) + // This is tricky with Monaco editor, so we'll just test the error state + const editor = page.locator('.monaco-editor'); + if (await editor.isVisible()) { + // If save is disabled due to invalid JSON, that's the expected behavior + // The toast would show if we could actually trigger a save with invalid JSON + } + } + }); +}); diff --git a/tests/e2e/specs/registry.spec.ts b/tests/e2e/specs/registry.spec.ts index 2525ea92..d89e11e9 100644 --- a/tests/e2e/specs/registry.spec.ts +++ b/tests/e2e/specs/registry.spec.ts @@ -143,3 +143,56 @@ test.describe('Registry Pagination', () => { } }); }); + +test.describe('Registry Toast Notifications', () => { + test('should have toast container on registry page', async ({ page }) => { + const dashboard = new DashboardPage(page); + const registry = new RegistryPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Discover")').click(); + await expect(registry.heading).toBeVisible(); + + await expect(registry.toastContainer).toBeAttached(); + }); + + // Skip in web mode - requires Tauri API for install + test.skip('should show success toast when installing a server', async ({ page }) => { + const dashboard = new DashboardPage(page); + const registry = new RegistryPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Discover")').click(); + await expect(registry.heading).toBeVisible(); + + // Find an uninstalled server's install button + const installBtn = page.getByRole('button', { name: /Install/i }).first(); + if (await installBtn.isVisible()) { + await installBtn.click(); + + await registry.waitForToast('success'); + const toastText = await registry.getToastText(); + expect(toastText).toContain('Server installed'); + } + }); + + // Skip in web mode - requires Tauri API for uninstall + test.skip('should show success toast when uninstalling a server', async ({ page }) => { + const dashboard = new DashboardPage(page); + const registry = new RegistryPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Discover")').click(); + await expect(registry.heading).toBeVisible(); + + // Find an installed server's uninstall button + const uninstallBtn = page.getByRole('button', { name: /Uninstall/i }).first(); + if (await uninstallBtn.isVisible()) { + await uninstallBtn.click(); + + await registry.waitForToast('success'); + const toastText = await registry.getToastText(); + expect(toastText).toContain('Server uninstalled'); + } + }); +}); diff --git a/tests/e2e/specs/servers.spec.ts b/tests/e2e/specs/servers.spec.ts index 72d3400a..5a570574 100644 --- a/tests/e2e/specs/servers.spec.ts +++ b/tests/e2e/specs/servers.spec.ts @@ -89,3 +89,70 @@ test.describe('Server Actions', () => { // If no server cards found, test passes (no servers installed) }); }); + +test.describe('Server Toast Notifications', () => { + // Skip in web mode - requires Tauri API for server enable/disable + test.skip('should show success toast on server enable', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("My Servers")').click(); + + const enableBtn = page.getByRole('button', { name: /Enable/i }).first(); + if (await enableBtn.isVisible()) { + await enableBtn.click(); + + await expect(page.getByTestId('toast-success')).toBeVisible({ timeout: 5000 }); + } + }); + + // Skip in web mode - requires Tauri API for log viewer + test.skip('should show toast when clearing server logs', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("My Servers")').click(); + + // Open log viewer for first server (if available) + const logButton = page.getByRole('button', { name: /Logs/i }).first(); + if (await logButton.isVisible()) { + await logButton.click(); + + // Accept confirmation dialog + page.on('dialog', dialog => dialog.accept()); + + // Click clear logs button + const clearBtn = page.locator('button[title="Clear all logs"]'); + if (await clearBtn.isVisible()) { + await clearBtn.click(); + + await expect(page.getByTestId('toast-success').first()).toBeVisible({ timeout: 5000 }); + const toastText = await page.getByRole('main').getByTestId('toast-container').locator('[role="alert"]').first().textContent(); + expect(toastText).toContain('Logs cleared'); + } + } + }); + + // Skip in web mode - requires Tauri API for log file path + test.skip('should show toast when copying log file path', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("My Servers")').click(); + + const logButton = page.getByRole('button', { name: /Logs/i }).first(); + if (await logButton.isVisible()) { + await logButton.click(); + + // Click copy path button + const copyBtn = page.locator('button[title="Open log file in external editor"]'); + if (await copyBtn.isVisible()) { + await copyBtn.click(); + + await expect(page.getByTestId('toast-success').first()).toBeVisible({ timeout: 5000 }); + const toastText = await page.getByRole('main').getByTestId('toast-container').locator('[role="alert"]').first().textContent(); + expect(toastText).toContain('Path copied'); + } + } + }); +}); diff --git a/tests/e2e/specs/settings-desktop.wdio.ts b/tests/e2e/specs/settings-desktop.wdio.ts index 18f52de4..c8706968 100644 --- a/tests/e2e/specs/settings-desktop.wdio.ts +++ b/tests/e2e/specs/settings-desktop.wdio.ts @@ -4,20 +4,29 @@ */ import { expect, browser } from '@wdio/globals'; +import { byTestId, TIMEOUT } from '../helpers/selectors'; describe('Settings - Desktop Features', () => { - beforeEach(async () => { - // Navigate to settings page - const dashboardBtn = await $('nav button[data-testid="nav-dashboard"]'); - await dashboardBtn.waitForClickable(); - await dashboardBtn.click(); + before(async () => { + // Let the app and WebView load (spec may run in isolation so no prior tests have warmed the UI) + await browser.pause(5000); + // Ensure app shell is ready before any test + const sidebar = await byTestId('sidebar'); + await sidebar.waitForDisplayed({ timeout: TIMEOUT.veryLong }); + const navSettings = await byTestId('nav-settings'); + await navSettings.waitForClickable({ timeout: TIMEOUT.medium }); + }); - const settingsBtn = await $('nav button[data-testid="nav-settings"]'); - await settingsBtn.waitForClickable(); + beforeEach(async () => { + // Go to Settings (same pattern as settings.wdio.ts) + const settingsBtn = await byTestId('nav-settings'); await settingsBtn.click(); - - // Wait for settings page to load - await browser.pause(500); + // Wait for desktop Startup section to be present (section is always rendered; toggles may still be loading) + const startupSection = await byTestId('settings-startup-section'); + await startupSection.waitForDisplayed({ timeout: TIMEOUT.medium }); + // Wait for toggles to be interactive (get_startup_settings has resolved) + const autoLaunchSwitch = await byTestId('auto-launch-switch'); + await autoLaunchSwitch.waitForDisplayed({ timeout: TIMEOUT.medium }); }); describe('Startup & System Tray Settings', () => { @@ -117,8 +126,9 @@ describe('Settings - Desktop Features', () => { const isDisabled = await startMinimizedSwitch.getAttribute('disabled'); expect(isDisabled).toBe('true'); + // Note: When disabled, the value stays at its default (true) const ariaChecked = await startMinimizedSwitch.getAttribute('aria-checked'); - expect(ariaChecked).toBe('false'); + expect(ariaChecked).toBe('true'); }); it('start minimized should be enabled when auto-launch is on', async () => { @@ -174,20 +184,20 @@ describe('Settings - Desktop Features', () => { // Reload the page await browser.refresh(); - await browser.pause(1000); - // Navigate to settings again + // Navigate to settings again and wait for section to load const settingsBtn = await $('nav button[data-testid="nav-settings"]'); await settingsBtn.waitForClickable(); await settingsBtn.click(); - await browser.pause(500); + const switchAfterReload = await $('[data-testid="close-to-tray-switch"]'); + await switchAfterReload.waitForDisplayed({ timeout: TIMEOUT.medium }); // Verify state persisted - const persistedState = await closeToTraySwitch.getAttribute('aria-checked'); + const persistedState = await switchAfterReload.getAttribute('aria-checked'); expect(persistedState).not.toBe(initialState); // Restore original state - await closeToTraySwitch.click(); + await switchAfterReload.click(); await browser.pause(500); }); diff --git a/tests/e2e/specs/settings.spec.ts b/tests/e2e/specs/settings.spec.ts index a2a4e2ba..0e835040 100644 --- a/tests/e2e/specs/settings.spec.ts +++ b/tests/e2e/specs/settings.spec.ts @@ -192,6 +192,7 @@ test.describe('Settings', () => { // Verify sections appear in expected order const sections = [ page.getByText('Software Updates'), + page.getByText('Startup & System Tray'), page.getByText('Appearance'), page.locator('h3:has-text("Logs"), h2:has-text("Logs")').first(), ]; @@ -212,4 +213,121 @@ test.describe('Settings', () => { await expect(mainContent).toBeVisible(); }); }); + + test.describe('Startup & System Tray Settings', () => { + test('should display startup settings section', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Settings")').click(); + + await expect(page.getByText('Startup & System Tray')).toBeVisible(); + await expect(page.getByText('Launch at Startup')).toBeVisible(); + await expect(page.getByText('Start Minimized')).toBeVisible(); + await expect(page.getByText('Close to Tray')).toBeVisible(); + }); + + test('should have startup settings switches', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Settings")').click(); + + const autoLaunchSwitch = page.getByTestId('auto-launch-switch'); + const startMinimizedSwitch = page.getByTestId('start-minimized-switch'); + const closeToTraySwitch = page.getByTestId('close-to-tray-switch'); + + await expect(autoLaunchSwitch).toBeVisible(); + await expect(startMinimizedSwitch).toBeVisible(); + await expect(closeToTraySwitch).toBeVisible(); + }); + + // Skip in web mode - requires Tauri API + test.skip('should toggle startup settings and show success toast', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Settings")').click(); + + const closeToTraySwitch = page.getByTestId('close-to-tray-switch'); + + // Toggle the switch + await closeToTraySwitch.click(); + + // Wait for success toast + await expect(page.getByTestId('toast-success')).toBeVisible({ timeout: 2000 }); + await expect(page.getByText('Settings saved')).toBeVisible(); + await expect(page.getByText('Your preferences have been updated')).toBeVisible(); + + // Toast should auto-dismiss after 3 seconds + await expect(page.getByTestId('toast-success')).not.toBeVisible({ timeout: 4000 }); + }); + + // Skip in web mode - requires Tauri API + test.skip('should show loading state while saving', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Settings")').click(); + + const closeToTraySwitch = page.getByTestId('close-to-tray-switch'); + + // Toggle the switch + await closeToTraySwitch.click(); + + // Should show saving indicator briefly + await expect(page.getByText('Saving settings...')).toBeVisible(); + }); + + test('should disable start minimized when auto-launch is off', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Settings")').click(); + + const startMinimizedSwitch = page.getByTestId('start-minimized-switch'); + + // Start minimized should be disabled if auto-launch is off + // Note: This test assumes auto-launch might be off by default on test env + const isDisabled = await startMinimizedSwitch.isDisabled(); + if (isDisabled) { + await expect(startMinimizedSwitch).toBeDisabled(); + } + }); + }); + + test.describe('Toast Notifications', () => { + test('should have toast container', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Settings")').click(); + + // Toast container should exist in main content (even if empty) + const toastContainer = page.getByRole('main').getByTestId('toast-container'); + await expect(toastContainer).toBeAttached(); + }); + + // Skip in web mode - requires Tauri API + test.skip('should allow manual toast dismissal', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await page.locator('nav button:has-text("Settings")').click(); + + const closeToTraySwitch = page.getByTestId('close-to-tray-switch'); + + // Toggle to trigger toast + await closeToTraySwitch.click(); + + // Wait for toast + await expect(page.getByTestId('toast-success')).toBeVisible({ timeout: 2000 }); + + // Click close button + await page.getByTestId('toast-close').click(); + + // Toast should disappear immediately + await expect(page.getByTestId('toast-success')).not.toBeVisible({ timeout: 500 }); + }); + }); }); diff --git a/tests/e2e/specs/spaces.spec.ts b/tests/e2e/specs/spaces.spec.ts index 55c04be5..8c440a3c 100644 --- a/tests/e2e/specs/spaces.spec.ts +++ b/tests/e2e/specs/spaces.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { DashboardPage } from '../pages'; +import { DashboardPage, SpacesPage } from '../pages'; // Helper to click Spaces in sidebar (avoids space switcher button) async function goToSpaces(page: import('@playwright/test').Page) { @@ -88,3 +88,117 @@ test.describe('Space Management', () => { expect(count).toBeGreaterThan(0); }); }); + +test.describe('Space Toast Notifications', () => { + test('should have toast container on spaces page', async ({ page }) => { + const dashboard = new DashboardPage(page); + const spacesPage = new SpacesPage(page); + await dashboard.navigate(); + + await goToSpaces(page); + await expect(page.locator('h1:has-text("Workspaces")')).toBeVisible(); + + await expect(spacesPage.toastContainer).toBeAttached(); + }); + + test('should show create space modal with form', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await goToSpaces(page); + + // Open create modal + await page.getByTestId('create-space-btn').click(); + + // Modal should be visible + await expect(page.getByTestId('create-space-modal')).toBeVisible(); + await expect(page.getByTestId('create-space-name-input')).toBeVisible(); + await expect(page.getByTestId('create-space-submit-btn')).toBeVisible(); + await expect(page.getByTestId('create-space-cancel-btn')).toBeVisible(); + }); + + test('should close create space modal on cancel', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await goToSpaces(page); + + await page.getByTestId('create-space-btn').click(); + await expect(page.getByTestId('create-space-modal')).toBeVisible(); + + await page.getByTestId('create-space-cancel-btn').click(); + await expect(page.getByTestId('create-space-modal')).not.toBeVisible(); + }); + + test('should disable submit when name is empty', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + + await goToSpaces(page); + + await page.getByTestId('create-space-btn').click(); + + // Submit should be disabled without a name + await expect(page.getByTestId('create-space-submit-btn')).toBeDisabled(); + + // Type a name + await page.getByTestId('create-space-name-input').fill('Test Space'); + await expect(page.getByTestId('create-space-submit-btn')).toBeEnabled(); + }); + + // Skip in web mode - requires Tauri API + test.skip('should show success toast on space creation', async ({ page }) => { + const dashboard = new DashboardPage(page); + const spacesPage = new SpacesPage(page); + await dashboard.navigate(); + + await goToSpaces(page); + + await page.getByTestId('create-space-btn').click(); + await page.getByTestId('create-space-name-input').fill('Test Toast Space'); + await page.getByTestId('create-space-submit-btn').click(); + + await spacesPage.waitForToast('success'); + const toastText = await spacesPage.getToastText(); + expect(toastText).toContain('Space created'); + }); + + // Skip in web mode - requires Tauri API + test.skip('should show success toast on set active space', async ({ page }) => { + const dashboard = new DashboardPage(page); + const spacesPage = new SpacesPage(page); + await dashboard.navigate(); + + await goToSpaces(page); + + // Find a non-active space and click "Set Active" + const setActiveBtn = page.locator('[data-testid^="set-active-space-"]').first(); + if (await setActiveBtn.isVisible()) { + await setActiveBtn.click(); + + await spacesPage.waitForToast('success'); + const toastText = await spacesPage.getToastText(); + expect(toastText).toContain('Active space changed'); + } + }); + + // Skip in web mode - requires Tauri API + test.skip('should show success toast on space deletion', async ({ page }) => { + const dashboard = new DashboardPage(page); + const spacesPage = new SpacesPage(page); + await dashboard.navigate(); + + await goToSpaces(page); + + // Find a deletable space + const deleteBtn = page.locator('[data-testid^="delete-space-"]').first(); + if (await deleteBtn.isVisible()) { + page.on('dialog', dialog => dialog.accept()); + await deleteBtn.click(); + + await spacesPage.waitForToast('success'); + const toastText = await spacesPage.getToastText(); + expect(toastText).toContain('Space deleted'); + } + }); +}); diff --git a/tests/ts/components/Toast.test.tsx b/tests/ts/components/Toast.test.tsx new file mode 100644 index 00000000..4334407b --- /dev/null +++ b/tests/ts/components/Toast.test.tsx @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Toast, ToastContainer } from '../../../packages/ui/src/components/common/Toast'; + +describe('Toast', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should render success toast', () => { + const onClose = vi.fn(); + render( + + ); + + expect(screen.getByText('Success!')).toBeInTheDocument(); + expect(screen.getByText('Operation completed')).toBeInTheDocument(); + expect(screen.getByTestId('toast-success')).toBeInTheDocument(); + }); + + it('should render error toast', () => { + const onClose = vi.fn(); + render( + + ); + + expect(screen.getByText('Error!')).toBeInTheDocument(); + expect(screen.getByTestId('toast-error')).toBeInTheDocument(); + }); + + it('should render toast without message', () => { + const onClose = vi.fn(); + render( + + ); + + expect(screen.getByText('Info')).toBeInTheDocument(); + expect(screen.queryByText('Operation completed')).not.toBeInTheDocument(); + }); + + it('should call onClose when close button is clicked', async () => { + vi.useRealTimers(); // Use real timers for user interaction + const user = userEvent.setup(); + const onClose = vi.fn(); + + render( + + ); + + const closeButton = screen.getByTestId('toast-close'); + await user.click(closeButton); + + expect(onClose).toHaveBeenCalledWith('test-1'); + vi.useFakeTimers(); // Restore fake timers for other tests + }); + + it('should auto-dismiss after duration', () => { + const onClose = vi.fn(); + + render( + + ); + + expect(onClose).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(3000); + + expect(onClose).toHaveBeenCalledWith('test-1'); + }); + + it('should not auto-dismiss when duration is 0', () => { + const onClose = vi.fn(); + + render( + + ); + + vi.advanceTimersByTime(10000); + + expect(onClose).not.toHaveBeenCalled(); + }); +}); + +describe('ToastContainer', () => { + it('should render multiple toasts', () => { + const onClose = vi.fn(); + const toasts = [ + { + id: 'toast-1', + type: 'success' as const, + title: 'Success 1', + duration: 3000, + onClose, + }, + { + id: 'toast-2', + type: 'error' as const, + title: 'Error 1', + duration: 3000, + onClose, + }, + ]; + + render(); + + expect(screen.getByText('Success 1')).toBeInTheDocument(); + expect(screen.getByText('Error 1')).toBeInTheDocument(); + expect(screen.getByTestId('toast-container')).toBeInTheDocument(); + }); + + it('should render empty container when no toasts', () => { + const onClose = vi.fn(); + + render(); + + const container = screen.getByTestId('toast-container'); + expect(container).toBeInTheDocument(); + expect(container.children).toHaveLength(0); + }); +}); diff --git a/tests/ts/hooks/useToast.test.ts b/tests/ts/hooks/useToast.test.ts new file mode 100644 index 00000000..e52b5bc2 --- /dev/null +++ b/tests/ts/hooks/useToast.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useToast } from '../../../packages/ui/src/hooks/useToast'; + +describe('useToast', () => { + it('should initialize with empty toasts', () => { + const { result } = renderHook(() => useToast()); + expect(result.current.toasts).toEqual([]); + }); + + it('should add a success toast', () => { + const { result } = renderHook(() => useToast()); + + act(() => { + result.current.success('Success!', 'Operation completed'); + }); + + expect(result.current.toasts).toHaveLength(1); + expect(result.current.toasts[0].type).toBe('success'); + expect(result.current.toasts[0].title).toBe('Success!'); + expect(result.current.toasts[0].message).toBe('Operation completed'); + }); + + it('should add an error toast', () => { + const { result } = renderHook(() => useToast()); + + act(() => { + result.current.error('Error!', 'Something went wrong'); + }); + + expect(result.current.toasts).toHaveLength(1); + expect(result.current.toasts[0].type).toBe('error'); + expect(result.current.toasts[0].title).toBe('Error!'); + expect(result.current.toasts[0].message).toBe('Something went wrong'); + }); + + it('should add a warning toast', () => { + const { result } = renderHook(() => useToast()); + + act(() => { + result.current.warning('Warning!', 'Be careful'); + }); + + expect(result.current.toasts).toHaveLength(1); + expect(result.current.toasts[0].type).toBe('warning'); + }); + + it('should add an info toast', () => { + const { result } = renderHook(() => useToast()); + + act(() => { + result.current.info('Info', 'For your information'); + }); + + expect(result.current.toasts).toHaveLength(1); + expect(result.current.toasts[0].type).toBe('info'); + }); + + it('should add multiple toasts', () => { + const { result } = renderHook(() => useToast()); + + act(() => { + result.current.success('Toast 1'); + result.current.error('Toast 2'); + result.current.info('Toast 3'); + }); + + expect(result.current.toasts).toHaveLength(3); + }); + + it('should dismiss a toast by id', () => { + const { result } = renderHook(() => useToast()); + + let toastId: string = ''; + act(() => { + toastId = result.current.success('Toast 1'); + result.current.error('Toast 2'); + }); + + expect(result.current.toasts).toHaveLength(2); + + act(() => { + result.current.dismiss(toastId); + }); + + expect(result.current.toasts).toHaveLength(1); + expect(result.current.toasts[0].title).toBe('Toast 2'); + }); + + it('should generate unique ids for toasts', () => { + const { result } = renderHook(() => useToast()); + + let id1: string = ''; + let id2: string = ''; + + act(() => { + id1 = result.current.success('Toast 1'); + id2 = result.current.success('Toast 2'); + }); + + expect(id1).not.toBe(id2); + }); + + it('should allow custom duration', () => { + const { result } = renderHook(() => useToast()); + + act(() => { + result.current.success('Toast', undefined, 5000); + }); + + expect(result.current.toasts[0].duration).toBe(5000); + }); + + it('should use default duration when not specified', () => { + const { result } = renderHook(() => useToast()); + + act(() => { + result.current.success('Toast'); + }); + + expect(result.current.toasts[0].duration).toBe(3000); + }); +}); diff --git a/tests/ts/stores/appStore.test.ts b/tests/ts/stores/appStore.test.ts index ccbcdf9d..9fb20e2f 100644 --- a/tests/ts/stores/appStore.test.ts +++ b/tests/ts/stores/appStore.test.ts @@ -55,6 +55,50 @@ describe('appStore', () => { expect(useAppStore.getState().viewSpaceId).toBe(useAppStore.getState().activeSpaceId); }); + + it('should reset activeSpaceId when persisted value points to deleted space', () => { + const spaces = [ + createTestSpace({ name: 'Space A', is_default: false }), + createDefaultSpace({ name: 'Default Space' }), + ]; + // Simulate a persisted activeSpaceId that no longer exists in the spaces list + useAppStore.setState({ activeSpaceId: 'deleted-space-id' }); + useAppStore.getState().setSpaces(spaces); + + // Should fallback to the default space + expect(useAppStore.getState().activeSpaceId).toBe(spaces[1].id); + }); + + it('should reset activeSpaceId to first space when no default exists', () => { + const spaces = [ + createTestSpace({ name: 'Space A', is_default: false }), + createTestSpace({ name: 'Space B', is_default: false }), + ]; + useAppStore.setState({ activeSpaceId: 'deleted-space-id' }); + useAppStore.getState().setSpaces(spaces); + + expect(useAppStore.getState().activeSpaceId).toBe(spaces[0].id); + }); + + it('should keep activeSpaceId when it still exists in spaces list', () => { + const spaces = createTestSpaces(3); + useAppStore.setState({ activeSpaceId: spaces[1].id }); + useAppStore.getState().setSpaces(spaces); + + expect(useAppStore.getState().activeSpaceId).toBe(spaces[1].id); + }); + + it('should reset both activeSpaceId and viewSpaceId when both point to deleted spaces', () => { + const spaces = [createDefaultSpace({ name: 'My Space' })]; + useAppStore.setState({ + activeSpaceId: 'deleted-active-id', + viewSpaceId: 'deleted-view-id', + }); + useAppStore.getState().setSpaces(spaces); + + expect(useAppStore.getState().activeSpaceId).toBe(spaces[0].id); + expect(useAppStore.getState().viewSpaceId).toBe(spaces[0].id); + }); }); describe('setActiveSpace', () => {