diff --git a/Cargo.lock b/Cargo.lock index 30e6afd3..c649254e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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/settings.rs b/apps/desktop/src-tauri/src/commands/settings.rs index dde5b369..25fd5565 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 } } @@ -50,7 +50,7 @@ pub async fn get_startup_settings( .await .map_err(|e| format!("Failed to get start_minimized setting: {}", e))? .map(|v| v == "true") - .unwrap_or(false); + .unwrap_or(true); let close_to_tray = settings_repo .get("ui.close_to_tray") @@ -96,6 +96,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 +133,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/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/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/settings/SettingsPage.tsx b/apps/desktop/src/features/settings/SettingsPage.tsx index a13a2a0c..10a0ab48 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,11 +121,13 @@ export function SettingsPage() { }; return ( -
-
-

Settings

-

Configure McpMux preferences.

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

Settings

+

Configure McpMux preferences.

+
{/* Updates Section */} @@ -297,5 +308,6 @@ export function SettingsPage() {
+ ); } 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..cabe6c78 --- /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/SettingsPage.ts b/tests/e2e/pages/SettingsPage.ts index 75f0d7a2..8507574b 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.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.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/featuresets.spec.ts b/tests/e2e/specs/featuresets.spec.ts index d5a89864..9f2e703b 100644 --- a/tests/e2e/specs/featuresets.spec.ts +++ b/tests/e2e/specs/featuresets.spec.ts @@ -71,3 +71,132 @@ test.describe('FeatureSet Details', () => { expect(count).toBeGreaterThanOrEqual(0); }); }); + +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('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()) { + const saveButton = page.getByRole('button', { name: /Save/i }); + + // 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/settings-desktop.wdio.ts b/tests/e2e/specs/settings-desktop.wdio.ts index 18f52de4..84e857d2 100644 --- a/tests/e2e/specs/settings-desktop.wdio.ts +++ b/tests/e2e/specs/settings-desktop.wdio.ts @@ -117,8 +117,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 () => { diff --git a/tests/e2e/specs/settings.spec.ts b/tests/e2e/specs/settings.spec.ts index a2a4e2ba..62498f05 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 (even if empty) + const toastContainer = page.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/ts/components/Toast.test.tsx b/tests/ts/components/Toast.test.tsx new file mode 100644 index 00000000..60fde69b --- /dev/null +++ b/tests/ts/components/Toast.test.tsx @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } 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 () => { + const user = userEvent.setup({ delay: null }); + const onClose = vi.fn(); + + render( + + ); + + const closeButton = screen.getByTestId('toast-close'); + await user.click(closeButton); + + expect(onClose).toHaveBeenCalledWith('test-1'); + }); + + 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); + }); +});