From 4a2b2600beff345509a40a4a2cea9bbd78df4d03 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 11 Mar 2026 19:48:02 +0200 Subject: [PATCH] Improve settings UX and tests --- src/i18n/locales/en-us/setting.json | 1 + src/pages/Setting/General.tsx | 16 +- src/pages/Setting/Privacy.tsx | 64 ++++- test/unit/pages/Setting/GeneralProxy.test.tsx | 228 ++++++++++++++++++ test/unit/pages/Setting/Privacy.test.tsx | 133 ++++++++++ 5 files changed, 430 insertions(+), 12 deletions(-) create mode 100644 test/unit/pages/Setting/GeneralProxy.test.tsx create mode 100644 test/unit/pages/Setting/Privacy.test.tsx diff --git a/src/i18n/locales/en-us/setting.json b/src/i18n/locales/en-us/setting.json index 25e5fc9fa..fed29d0f8 100644 --- a/src/i18n/locales/en-us/setting.json +++ b/src/i18n/locales/en-us/setting.json @@ -180,6 +180,7 @@ "network-proxy": "Network Proxy", "network-proxy-description": "Configure a proxy server for network requests. This is useful if you need to access external APIs through a proxy.", + "network-proxy-helper": "Leave empty to disable the proxy. When set, all outbound HTTP requests from Eigent will respect this proxy configuration where supported.", "proxy-placeholder": "http://127.0.0.1:7890", "proxy-saved-restart-required": "Proxy configuration saved. Restart the app to apply changes.", "proxy-save-failed": "Failed to save proxy configuration.", diff --git a/src/pages/Setting/General.tsx b/src/pages/Setting/General.tsx index 8ab1c96bf..29aaec889 100644 --- a/src/pages/Setting/General.tsx +++ b/src/pages/Setting/General.tsx @@ -79,6 +79,8 @@ export default function SettingGeneral() { const [proxyUrl, setProxyUrl] = useState(''); const [isProxySaving, setIsProxySaving] = useState(false); const [proxyNeedsRestart, setProxyNeedsRestart] = useState(false); + const [initialProxyUrl, setInitialProxyUrl] = useState(null); + const hasProxyChanged = proxyUrl.trim() !== (initialProxyUrl ?? '').trim(); useEffect(() => { const platform = window.electronAPI.getPlatform(); @@ -165,6 +167,9 @@ export default function SettingGeneral() { const result = await window.electronAPI.readGlobalEnv('HTTP_PROXY'); if (result?.value) { setProxyUrl(result.value); + setInitialProxyUrl(result.value); + } else { + setInitialProxyUrl(''); } } catch (_error) { console.log('No proxy configured'); @@ -219,6 +224,7 @@ export default function SettingGeneral() { ); if (!result?.success) throw new Error('envRemove returned no success'); } + setInitialProxyUrl(trimmed); setProxyNeedsRestart(true); toast.success(t('setting.proxy-saved-restart-required')); } catch (error) { @@ -358,9 +364,12 @@ export default function SettingGeneral() {
{t('setting.network-proxy')}
-
+
{t('setting.network-proxy-description')}
+
+ {t('setting.network-proxy-helper')} +
window.electronAPI?.restartApp() : handleSaveProxy } - disabled={!proxyNeedsRestart && isProxySaving} + disabled={ + (!proxyNeedsRestart && isProxySaving) || + (!proxyNeedsRestart && !hasProxyChanged) + } > {proxyNeedsRestart ? t('setting.restart-to-apply') diff --git a/src/pages/Setting/Privacy.tsx b/src/pages/Setting/Privacy.tsx index fe139e8f9..71053e1a0 100644 --- a/src/pages/Setting/Privacy.tsx +++ b/src/pages/Setting/Privacy.tsx @@ -18,24 +18,60 @@ import { Switch } from '@/components/ui/switch'; import { ChevronDown } from 'lucide-react'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; export default function SettingPrivacy() { const [helpImprove, setHelpImprove] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); const { t } = useTranslation(); const [isHowWeHandleOpen, setIsHowWeHandleOpen] = useState(false); useEffect(() => { - proxyFetchGet('/api/user/privacy') - .then((res) => { - setHelpImprove(res.help_improve || false); - }) - .catch((err) => console.error('Failed to fetch settings:', err)); - }, []); + let isCancelled = false; - const handleToggleHelpImprove = (checked: boolean) => { + const loadPrivacySettings = async () => { + try { + const res = await proxyFetchGet('/api/user/privacy'); + if (!isCancelled) { + setHelpImprove(Boolean(res?.help_improve)); + } + } catch (err) { + if (!isCancelled) { + // Log to console for debugging while keeping user feedback localized + // eslint-disable-next-line no-console + console.error('Failed to fetch settings:', err); + toast.error(t('setting.load-failed')); + } + } finally { + if (!isCancelled) { + setIsLoading(false); + } + } + }; + + void loadPrivacySettings(); + + return () => { + isCancelled = true; + }; + }, [t]); + + const handleToggleHelpImprove = async (checked: boolean) => { + // Optimistically update UI but revert on failure + const previousValue = helpImprove; setHelpImprove(checked); - proxyFetchPut('/api/user/privacy', { help_improve: checked }).catch((err) => - console.error('Failed to update settings:', err) - ); + setIsSaving(true); + + try { + await proxyFetchPut('/api/user/privacy', { help_improve: checked }); + } catch (err) { + // eslint-disable-next-line no-console + console.error('Failed to update settings:', err); + setHelpImprove(previousValue); + toast.error(t('setting.save-failed')); + } finally { + setIsSaving(false); + } }; return ( @@ -121,11 +157,19 @@ export default function SettingPrivacy() {
{t('setting.help-improve-eigent-description')}
+ {!isLoading && ( +
+ {helpImprove + ? t('setting.enabled') + : t('setting.disabled')} +
+ )}
diff --git a/test/unit/pages/Setting/GeneralProxy.test.tsx b/test/unit/pages/Setting/GeneralProxy.test.tsx new file mode 100644 index 000000000..86bda7804 --- /dev/null +++ b/test/unit/pages/Setting/GeneralProxy.test.tsx @@ -0,0 +1,228 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import SettingGeneral from '@/pages/Setting/General'; + +vi.mock('react-i18next', () => ({ + Trans: ({ children }: { children: React.ReactNode }) => children, + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const mockClearTasks = vi.fn(); + +vi.mock('@/hooks/useChatStoreAdapter', () => ({ + default: () => ({ + chatStore: { + clearTasks: mockClearTasks, + }, + }), +})); + +const mockResetInstallation = vi.fn(); +const mockSetNeedsBackendRestart = vi.fn(); + +vi.mock('@/store/installationStore', async () => { + const actual = + await vi.importActual( + '@/store/installationStore' + ); + return { + ...actual, + useInstallationStore: (selector: any) => + selector({ + reset: mockResetInstallation, + setNeedsBackendRestart: mockSetNeedsBackendRestart, + }), + }; +}); + +const mockLogout = vi.fn(); + +vi.mock('@/store/authStore', async () => { + const actual = await vi.importActual( + '@/store/authStore' + ); + return { + ...actual, + useAuthStore: () => ({ + email: 'test@example.com', + appearance: 'dark', + language: 'system', + setAppearance: vi.fn(), + setLanguage: vi.fn(), + logout: mockLogout, + }), + getAuthStore: () => ({ + token: 'token', + }), + }; +}); + +const toastErrorMock = vi.fn(); +const toastSuccessMock = vi.fn(); + +vi.mock('sonner', () => ({ + toast: { + error: (msg: string) => toastErrorMock(msg), + success: (msg: string) => toastSuccessMock(msg), + }, +})); + +const navigateMock = vi.fn(); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual( + 'react-router-dom' + ); + return { + ...actual, + useNavigate: () => navigateMock, + }; +}); + +describe('SettingGeneral Network Proxy', () => { + beforeEach(() => { + vi.clearAllMocks(); + + (window as any).electronAPI = { + getPlatform: () => 'linux', + readGlobalEnv: vi.fn().mockResolvedValue({ value: 'http://proxy:8080' }), + envWrite: vi.fn().mockResolvedValue({ success: true }), + envRemove: vi.fn().mockResolvedValue({ success: true }), + restartApp: vi.fn(), + }; + }); + + it('loads existing proxy value and disables save button until changed', async () => { + render(); + + const input = await screen.findByPlaceholderText( + 'setting.proxy-placeholder' + ); + expect((input as HTMLInputElement).value).toBe('http://proxy:8080'); + + const button = screen.getByRole('button', { name: 'setting.save' }); + expect(button).toBeDisabled(); + + await userEvent.clear(input); + await userEvent.type(input, 'http://proxy:9090'); + + expect(button).not.toBeDisabled(); + }); + + it('validates proxy URL and shows error for invalid protocol', async () => { + render(); + + const input = await screen.findByPlaceholderText( + 'setting.proxy-placeholder' + ); + + await userEvent.clear(input); + await userEvent.type(input, 'ftp://invalid-proxy'); + + const button = screen.getByRole('button', { name: 'setting.save' }); + await userEvent.click(button); + + await waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith('setting.proxy-invalid-url'); + }); + }); + + it('saves proxy URL and requires restart', async () => { + render(); + + const input = await screen.findByPlaceholderText( + 'setting.proxy-placeholder' + ); + + await userEvent.clear(input); + await userEvent.type(input, 'http://proxy:9090'); + + const button = screen.getByRole('button', { name: 'setting.save' }); + await userEvent.click(button); + + await waitFor(() => { + expect((window as any).electronAPI.envWrite).toHaveBeenCalledWith( + 'test@example.com', + { + key: 'HTTP_PROXY', + value: 'http://proxy:9090', + } + ); + expect(toastSuccessMock).toHaveBeenCalledWith( + 'setting.proxy-saved-restart-required' + ); + }); + + // Button should now show restart label and trigger restart + const restartButton = screen.getByRole('button', { + name: 'setting.restart-to-apply', + }); + await userEvent.click(restartButton); + + expect((window as any).electronAPI.restartApp).toHaveBeenCalled(); + }); + it('removes proxy when input cleared', async () => { + // Start with existing value + render(); + + const input = await screen.findByPlaceholderText( + 'setting.proxy-placeholder' + ); + + await userEvent.clear(input); + + const button = screen.getByRole('button', { name: 'setting.save' }); + await userEvent.click(button); + + await waitFor(() => { + expect((window as any).electronAPI.envRemove).toHaveBeenCalledWith( + 'test@example.com', + 'HTTP_PROXY' + ); + expect(toastSuccessMock).toHaveBeenCalledWith( + 'setting.proxy-saved-restart-required' + ); + }); + }); + + it('shows error when electron env APIs are missing', async () => { + (window as any).electronAPI = { + getPlatform: () => 'linux', + readGlobalEnv: vi.fn().mockResolvedValue({ value: '' }), + // envWrite/envRemove intentionally omitted + }; + + render(); + + const input = await screen.findByPlaceholderText( + 'setting.proxy-placeholder' + ); + await userEvent.type(input, 'http://proxy:8080'); + + const button = screen.getByRole('button', { name: 'setting.save' }); + await userEvent.click(button); + + await waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith('setting.proxy-save-failed'); + }); + }); +}); + diff --git a/test/unit/pages/Setting/Privacy.test.tsx b/test/unit/pages/Setting/Privacy.test.tsx new file mode 100644 index 000000000..6cc37a41a --- /dev/null +++ b/test/unit/pages/Setting/Privacy.test.tsx @@ -0,0 +1,133 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import SettingPrivacy from '@/pages/Setting/Privacy'; + +vi.mock('@/api/http', () => ({ + proxyFetchGet: vi.fn(), + proxyFetchPut: vi.fn(), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const toastErrorMock = vi.fn(); + +vi.mock('sonner', () => ({ + toast: { + error: (msg: string) => toastErrorMock(msg), + }, +})); + +const proxyFetchGetMock = vi.mocked( + (await import('@/api/http')).proxyFetchGet +); +const proxyFetchPutMock = vi.mocked( + (await import('@/api/http')).proxyFetchPut +); + +describe('SettingPrivacy', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('loads initial privacy settings and shows current state', async () => { + proxyFetchGetMock.mockResolvedValueOnce({ help_improve: true }); + + render(); + + // Switch should eventually reflect the loaded state + const switchEl = await screen.findByRole('switch'); + expect(switchEl).toBeChecked(); + + // Status text should use enabled key + expect( + screen.getByText('setting.enabled', { exact: false }) + ).toBeInTheDocument(); + }); + + it('handles load failure and shows error toast', async () => { + proxyFetchGetMock.mockRejectedValueOnce(new Error('network error')); + + render(); + + await waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith('setting.load-failed'); + }); + }); + + it('optimistically toggles help improve and calls API', async () => { + proxyFetchGetMock.mockResolvedValueOnce({ help_improve: false }); + proxyFetchPutMock.mockResolvedValueOnce({}); + + render(); + + const switchEl = await screen.findByRole('switch'); + expect(switchEl).not.toBeChecked(); + + await userEvent.click(switchEl); + + expect(proxyFetchPutMock).toHaveBeenCalledWith('/api/user/privacy', { + help_improve: true, + }); + }); + + it('reverts switch and shows toast when save fails', async () => { + proxyFetchGetMock.mockResolvedValueOnce({ help_improve: false }); + proxyFetchPutMock.mockRejectedValueOnce(new Error('save failed')); + + render(); + + const switchEl = await screen.findByRole('switch'); + expect(switchEl).not.toBeChecked(); + + await userEvent.click(switchEl); + + await waitFor(() => { + expect(switchEl).not.toBeChecked(); + expect(toastErrorMock).toHaveBeenCalledWith('setting.save-failed'); + }); + }); + + it('disables switch while loading and enables after load', async () => { + proxyFetchGetMock.mockResolvedValueOnce({ help_improve: false }); + + render(); + + const switchEl = await screen.findByRole('switch'); + + // After initial load completes, loading should be false and switch enabled + expect(switchEl).not.toBeDisabled(); + }); + + it('shows disabled status text when help improve is off', async () => { + proxyFetchGetMock.mockResolvedValueOnce({ help_improve: false }); + + render(); + + await screen.findByRole('switch'); + + expect( + screen.getByText('setting.disabled', { exact: false }) + ).toBeInTheDocument(); + }); +}); +