From 47c30cf6756d311fcf1f1059bced3786aaeef330 Mon Sep 17 00:00:00 2001 From: Jessie Li Date: Wed, 2 Sep 2026 12:24:26 -0700 Subject: [PATCH 1/2] fix: add reset action for network proxy edits --- src/components/Settings/General/index.tsx | 71 ++-- .../components/GeneralSettingsProxy.test.tsx | 311 ++++++++++++++++++ 2 files changed, 357 insertions(+), 25 deletions(-) create mode 100644 test/unit/components/GeneralSettingsProxy.test.tsx diff --git a/src/components/Settings/General/index.tsx b/src/components/Settings/General/index.tsx index def4cb389..6e4281427 100644 --- a/src/components/Settings/General/index.tsx +++ b/src/components/Settings/General/index.tsx @@ -67,9 +67,13 @@ export default function SettingGeneral({ // Proxy configuration state const [proxyUrl, setProxyUrl] = useState(''); + const [savedProxyUrl, setSavedProxyUrl] = useState(''); const [proxyLoading, setProxyLoading] = useState(true); const [isProxySaving, setIsProxySaving] = useState(false); const [proxyNeedsRestart, setProxyNeedsRestart] = useState(false); + const hasProxyValueChanged = proxyUrl !== savedProxyUrl; + const hasUnsavedProxyChanges = proxyUrl.trim() !== savedProxyUrl.trim(); + const showProxyRestartAction = proxyNeedsRestart && !hasUnsavedProxyChanges; const languageList = [ { @@ -124,9 +128,9 @@ export default function SettingGeneral({ try { if (host?.electronAPI?.readGlobalEnv) { const result = await host.electronAPI.readGlobalEnv('HTTP_PROXY'); - if (result?.value) { - setProxyUrl(result.value); - } + const loadedProxyUrl = result?.value ?? ''; + setProxyUrl(loadedProxyUrl); + setSavedProxyUrl(loadedProxyUrl); } } catch (_error) { console.log('No proxy configured'); @@ -182,6 +186,8 @@ export default function SettingGeneral({ ); if (!result?.success) throw new Error('envRemove returned no success'); } + setProxyUrl(trimmed); + setSavedProxyUrl(trimmed); setProxyNeedsRestart(true); toast.success(t('setting.proxy-saved-restart-required')); } catch (error) { @@ -295,45 +301,60 @@ export default function SettingGeneral({ description={t('setting.network-proxy-description')} actionClassName="w-[280px]" action={ - { - setProxyUrl(e.target.value); - setProxyNeedsRestart(false); - }} - className="w-[280px]" - size="default" - disabled={proxyLoading} - note={ - proxyNeedsRestart - ? t('setting.proxy-restart-hint') - : undefined - } - trailingButton={ +
+ { + setProxyUrl(e.target.value); + }} + size="default" + disabled={proxyLoading} + note={ + showProxyRestartAction + ? t('setting.proxy-restart-hint') + : undefined + } + /> +
+ - } - /> +
+
} /> )} diff --git a/test/unit/components/GeneralSettingsProxy.test.tsx b/test/unit/components/GeneralSettingsProxy.test.tsx new file mode 100644 index 000000000..390534d80 --- /dev/null +++ b/test/unit/components/GeneralSettingsProxy.test.tsx @@ -0,0 +1,311 @@ +// ========= 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. ========= + +// These tests protect the persisted Network Proxy baseline: loading and saving +// establish it, edits enable Save and Reset, and Reset never writes to disk. +// They also cover disabling an existing proxy and the restart-required action. + +import SettingGeneral from '@/components/Settings/General'; +import { HostProvider } from '@/host'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const dependencyMocks = vi.hoisted(() => ({ + clearTasks: vi.fn(), + logout: vi.fn(), + resetInstallation: vi.fn(), + setLanguage: vi.fn(), + setNeedsBackendRestart: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), +})); + +vi.mock('@/hooks/useChatStoreAdapter', () => ({ + default: () => ({ + chatStore: { clearTasks: dependencyMocks.clearTasks }, + }), +})); + +vi.mock('@/store/authStore', () => ({ + getAuthStore: () => ({ + language: 'en-US', + setLanguage: dependencyMocks.setLanguage, + }), + useAuthStore: () => ({ + email: 'test@example.com', + language: 'en-US', + logout: dependencyMocks.logout, + setLanguage: dependencyMocks.setLanguage, + }), +})); + +vi.mock('@/store/installationStore', () => ({ + useInstallationStore: ( + selector: (state: { + reset: typeof dependencyMocks.resetInstallation; + setNeedsBackendRestart: typeof dependencyMocks.setNeedsBackendRestart; + }) => unknown + ) => + selector({ + reset: dependencyMocks.resetInstallation, + setNeedsBackendRestart: dependencyMocks.setNeedsBackendRestart, + }), +})); + +vi.mock('sonner', () => ({ + toast: { + error: dependencyMocks.toastError, + success: dependencyMocks.toastSuccess, + }, +})); + +type ProxyElectronAPI = { + envRemove: ReturnType; + envWrite: ReturnType; + readGlobalEnv: ReturnType; + restartApp: ReturnType; +}; + +function renderProxySettings( + loadedValue: string | undefined, + overrides: Partial = {} +) { + const electronAPI = { + envRemove: vi.fn().mockResolvedValue({ success: true }), + envWrite: vi.fn().mockResolvedValue({ success: true }), + readGlobalEnv: vi.fn().mockResolvedValue({ value: loadedValue }), + restartApp: vi.fn(), + ...overrides, + }; + + render( + + + + + + ); + + return electronAPI; +} + +describe('General settings Network Proxy', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('loads the saved proxy as the disabled action baseline', async () => { + const electronAPI = renderProxySettings('http://saved-proxy:8080'); + + expect( + await screen.findByDisplayValue('http://saved-proxy:8080') + ).toBeEnabled(); + expect(electronAPI.readGlobalEnv).toHaveBeenCalledWith('HTTP_PROXY'); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled(); + }); + + it('enables Save and Reset for an edit, then resets without writing', async () => { + const user = userEvent.setup(); + const electronAPI = renderProxySettings('http://saved-proxy:8080'); + const input = await screen.findByDisplayValue('http://saved-proxy:8080'); + + await user.clear(input); + await user.type(input, 'http://edited-proxy:9090'); + + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); + const resetButton = screen.getByRole('button', { name: 'Reset' }); + expect(resetButton).toBeEnabled(); + + await user.click(resetButton); + + expect(input).toHaveValue('http://saved-proxy:8080'); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + expect(resetButton).toBeDisabled(); + expect(electronAPI.envWrite).not.toHaveBeenCalled(); + expect(electronAPI.envRemove).not.toHaveBeenCalled(); + }); + + it('keeps Reset available for a whitespace-only edit', async () => { + const user = userEvent.setup(); + renderProxySettings('http://saved-proxy:8080'); + const input = await screen.findByDisplayValue('http://saved-proxy:8080'); + + await user.type(input, ' '); + + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + const resetButton = screen.getByRole('button', { name: 'Reset' }); + expect(resetButton).toBeEnabled(); + + await user.click(resetButton); + + expect(input).toHaveValue('http://saved-proxy:8080'); + expect(resetButton).toBeDisabled(); + }); + + it('uses a successful save as the new reset baseline', async () => { + const user = userEvent.setup(); + const electronAPI = renderProxySettings('http://saved-proxy:8080'); + const input = await screen.findByDisplayValue('http://saved-proxy:8080'); + + await user.clear(input); + await user.type(input, 'http://new-proxy:9090'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(electronAPI.envWrite).toHaveBeenCalledWith('test@example.com', { + key: 'HTTP_PROXY', + value: 'http://new-proxy:9090', + }); + expect( + screen.getByRole('button', { name: 'Restart to Apply' }) + ).toBeEnabled(); + expect( + screen.getByText('Restart required to apply proxy changes.') + ).toBeInTheDocument(); + }); + + await user.clear(input); + await user.type(input, 'http://another-proxy:7070'); + expect( + screen.queryByText('Restart required to apply proxy changes.') + ).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Reset' })); + + expect(input).toHaveValue('http://new-proxy:9090'); + expect(electronAPI.envWrite).toHaveBeenCalledTimes(1); + expect(electronAPI.envRemove).not.toHaveBeenCalled(); + expect( + screen.getByRole('button', { name: 'Restart to Apply' }) + ).toBeEnabled(); + expect( + screen.getByText('Restart required to apply proxy changes.') + ).toBeInTheDocument(); + }); + + it('rejects an invalid URL without writing or disabling Reset', async () => { + const user = userEvent.setup(); + const electronAPI = renderProxySettings('http://saved-proxy:8080'); + const input = await screen.findByDisplayValue('http://saved-proxy:8080'); + + await user.clear(input); + await user.type(input, 'ftp://invalid-proxy'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(dependencyMocks.toastError).toHaveBeenCalledWith( + 'Invalid proxy URL. Must start with http://, https://, socks4://, or socks5://.' + ); + expect(electronAPI.envWrite).not.toHaveBeenCalled(); + expect(electronAPI.envRemove).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Reset' })).toBeEnabled(); + }); + + it('keeps the loaded baseline after a failed save', async () => { + const user = userEvent.setup(); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const envWrite = vi.fn().mockResolvedValue({ success: false }); + const electronAPI = renderProxySettings('http://saved-proxy:8080', { + envWrite, + }); + const input = await screen.findByDisplayValue('http://saved-proxy:8080'); + + await user.clear(input); + await user.type(input, 'http://failed-proxy:9090'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(dependencyMocks.toastError).toHaveBeenCalledWith( + 'Failed to save proxy configuration.' + ); + }); + expect(input).toHaveValue('http://failed-proxy:9090'); + expect(screen.getByRole('button', { name: 'Reset' })).toBeEnabled(); + + await user.click(screen.getByRole('button', { name: 'Reset' })); + + expect(input).toHaveValue('http://saved-proxy:8080'); + expect(electronAPI.envWrite).toHaveBeenCalledTimes(1); + expect(electronAPI.envRemove).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + it('disables Reset while a save is in flight', async () => { + const user = userEvent.setup(); + let resolveSave: (result: { success: boolean }) => void = () => {}; + const envWrite = vi.fn( + () => + new Promise<{ success: boolean }>((resolve) => { + resolveSave = resolve; + }) + ); + renderProxySettings('http://saved-proxy:8080', { envWrite }); + const input = await screen.findByDisplayValue('http://saved-proxy:8080'); + + await user.clear(input); + await user.type(input, 'http://pending-proxy:9090'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(envWrite).toHaveBeenCalledTimes(1)); + expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Saving...' })).toBeDisabled(); + + await act(async () => { + resolveSave({ success: true }); + }); + + expect( + await screen.findByRole('button', { name: 'Restart to Apply' }) + ).toBeEnabled(); + }); + + it('resets edits to an empty loaded proxy without removing it', async () => { + const user = userEvent.setup(); + const electronAPI = renderProxySettings(undefined); + const input = screen.getByPlaceholderText('http://127.0.0.1:7890'); + + await waitFor(() => expect(input).toBeEnabled()); + expect(input).toHaveValue(''); + + await user.type(input, 'socks5://temporary-proxy:1080'); + await user.click(screen.getByRole('button', { name: 'Reset' })); + + expect(input).toHaveValue(''); + expect(electronAPI.envWrite).not.toHaveBeenCalled(); + expect(electronAPI.envRemove).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + }); + + it('saves an empty proxy with envRemove and keeps Restart to Apply', async () => { + const user = userEvent.setup(); + const electronAPI = renderProxySettings('http://saved-proxy:8080'); + const input = await screen.findByDisplayValue('http://saved-proxy:8080'); + + await user.clear(input); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(electronAPI.envRemove).toHaveBeenCalledWith( + 'test@example.com', + 'HTTP_PROXY' + ); + expect( + screen.getByRole('button', { name: 'Restart to Apply' }) + ).toBeEnabled(); + }); + expect(input).toHaveValue(''); + expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled(); + }); +}); From ce578a36c41461ea4db2c930ab242accafa8bd73 Mon Sep 17 00:00:00 2001 From: Douglas Date: Thu, 3 Sep 2026 12:40:58 +0100 Subject: [PATCH 2/2] fix: collapse network proxy actions into one dynamic button Replaces the separate Save and Reset buttons with a single action nested inside the input field. It reads "Save" while the field differs from the persisted baseline and "Reset" once they match, where Reset clears the saved HTTP_PROXY. - Compare untrimmed so a whitespace-only edit still reads "Save". The destructive Reset must never sit under a field the user just typed in. - Disable the action when there is no saved proxy left to clear. - Move the restart affordance off the button into the success toast, as a sonner action, echoed by a DsText meta footnote in the error tone so a pending restart stays visible after the toast is gone. - Reserve right padding on the field so a long proxy URL does not slide under the nested button. - Add proxy-cleared-restart-required and proxy-reset-failed across all 11 locales. Co-Authored-By: Claude Opus 5 --- src/components/Settings/General/index.tsx | 130 +++++--- src/i18n/locales/ar/setting.json | 2 + src/i18n/locales/de/setting.json | 2 + src/i18n/locales/en-us/setting.json | 2 + src/i18n/locales/es/setting.json | 2 + src/i18n/locales/fr/setting.json | 2 + src/i18n/locales/it/setting.json | 2 + src/i18n/locales/ja/setting.json | 2 + src/i18n/locales/ko/setting.json | 2 + src/i18n/locales/ru/setting.json | 2 + src/i18n/locales/zh-Hans/setting.json | 2 + src/i18n/locales/zh-Hant/setting.json | 2 + .../components/GeneralSettingsProxy.test.tsx | 295 +++++++++++------- 13 files changed, 294 insertions(+), 153 deletions(-) diff --git a/src/components/Settings/General/index.tsx b/src/components/Settings/General/index.tsx index 6e4281427..ba228d7f7 100644 --- a/src/components/Settings/General/index.tsx +++ b/src/components/Settings/General/index.tsx @@ -13,6 +13,7 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { Button } from '@/components/ui/button'; +import { DsText } from '@/components/ui/ds-text'; import { Input } from '@/components/ui/input'; import { LocaleEnum, switchLanguage } from '@/i18n'; import { SITE_URL } from '@/lib'; @@ -71,9 +72,12 @@ export default function SettingGeneral({ const [proxyLoading, setProxyLoading] = useState(true); const [isProxySaving, setIsProxySaving] = useState(false); const [proxyNeedsRestart, setProxyNeedsRestart] = useState(false); - const hasProxyValueChanged = proxyUrl !== savedProxyUrl; - const hasUnsavedProxyChanges = proxyUrl.trim() !== savedProxyUrl.trim(); - const showProxyRestartAction = proxyNeedsRestart && !hasUnsavedProxyChanges; + // One action button drives the field: it saves while the input differs from + // the persisted baseline, and clears the saved proxy once the two match. + // Compared untrimmed so a whitespace-only edit still reads as "Save" — the + // destructive Reset must never sit under a field the user has just typed in. + const isProxyDirty = proxyUrl !== savedProxyUrl; + const canClearSavedProxy = savedProxyUrl.trim() !== ''; const languageList = [ { @@ -141,6 +145,21 @@ export default function SettingGeneral({ void loadProxyConfig(); }, [host]); + // The restart affordance lives in the toast rather than on the action button, + // so the button is only ever Save or Reset. The note under the input repeats + // it for anyone who dismisses or misses the toast. + const notifyProxyRestartRequired = (message: string) => { + const restartApp = host?.electronAPI?.restartApp; + toast.success(message, { + action: restartApp + ? { + label: t('setting.restart-to-apply'), + onClick: () => restartApp(), + } + : undefined, + }); + }; + // Save proxy configuration const handleSaveProxy = async () => { if (!authStore.email) { @@ -189,7 +208,7 @@ export default function SettingGeneral({ setProxyUrl(trimmed); setSavedProxyUrl(trimmed); setProxyNeedsRestart(true); - toast.success(t('setting.proxy-saved-restart-required')); + notifyProxyRestartRequired(t('setting.proxy-saved-restart-required')); } catch (error) { console.error('Failed to save proxy:', error); toast.error(t('setting.proxy-save-failed')); @@ -198,6 +217,33 @@ export default function SettingGeneral({ } }; + // Clear the saved proxy. Only reachable once the input matches the persisted + // baseline, so it never discards an edit the user can still see. + const handleResetProxy = async () => { + if (!authStore.email || !host?.electronAPI?.envRemove) { + toast.error(t('setting.proxy-reset-failed')); + return; + } + + setIsProxySaving(true); + try { + const result = await host.electronAPI.envRemove( + authStore.email, + 'HTTP_PROXY' + ); + if (!result?.success) throw new Error('envRemove returned no success'); + setProxyUrl(''); + setSavedProxyUrl(''); + setProxyNeedsRestart(true); + notifyProxyRestartRequired(t('setting.proxy-cleared-restart-required')); + } catch (error) { + console.error('Failed to reset proxy:', error); + toast.error(t('setting.proxy-reset-failed')); + } finally { + setIsProxySaving(false); + } + }; + return ( @@ -310,50 +356,44 @@ export default function SettingGeneral({ }} size="default" disabled={proxyLoading} - note={ - showProxyRestartAction - ? t('setting.proxy-restart-hint') - : undefined + // Reserve room for the nested action so a long proxy URL + // scrolls behind the field edge instead of under the button. + className="pr-24" + trailingButton={ + } /> -
- - -
+ {t('setting.proxy-restart-hint')} + + ) : null} } /> diff --git a/src/i18n/locales/ar/setting.json b/src/i18n/locales/ar/setting.json index ebd6c2b16..54e4d4e04 100644 --- a/src/i18n/locales/ar/setting.json +++ b/src/i18n/locales/ar/setting.json @@ -186,6 +186,8 @@ "proxy-placeholder": "http://127.0.0.1:7890", "proxy-saved-restart-required": "تم حفظ تكوين الوكيل. أعد تشغيل التطبيق لتطبيق التغييرات.", "proxy-save-failed": "فشل حفظ تكوين الوكيل.", + "proxy-cleared-restart-required": "تم مسح تكوين الوكيل. أعد تشغيل التطبيق لتطبيق التغييرات.", + "proxy-reset-failed": "فشل إعادة تعيين تكوين الوكيل.", "proxy-invalid-url": "عنوان URL للوكيل غير صالح. يجب أن يبدأ بـ http:// أو https:// أو socks4:// أو socks5://.", "proxy-restart-hint": "يجب إعادة التشغيل لتطبيق تغييرات الوكيل.", "cloud-not-available-in-local-proxy": "إصدار السحابة غير متاح في وضع الوكيل المحلي", diff --git a/src/i18n/locales/de/setting.json b/src/i18n/locales/de/setting.json index 0e3ccf746..549efadd8 100644 --- a/src/i18n/locales/de/setting.json +++ b/src/i18n/locales/de/setting.json @@ -210,6 +210,8 @@ "proxy-placeholder": "http://127.0.0.1:7890", "proxy-saved-restart-required": "Proxy-Konfiguration gespeichert. Starten Sie die App neu, um die Änderungen anzuwenden.", "proxy-save-failed": "Proxy-Konfiguration konnte nicht gespeichert werden.", + "proxy-cleared-restart-required": "Proxy-Konfiguration gelöscht. Starten Sie die App neu, um die Änderungen anzuwenden.", + "proxy-reset-failed": "Proxy-Konfiguration konnte nicht zurückgesetzt werden.", "proxy-invalid-url": "Ungültige Proxy-URL. Muss mit http://, https://, socks4:// oder socks5:// beginnen.", "proxy-restart-hint": "Neustart erforderlich, um Proxy-Änderungen anzuwenden.", "cloud-not-available-in-local-proxy": "Cloud-Version ist im lokalen Proxy-Modus nicht verfügbar", diff --git a/src/i18n/locales/en-us/setting.json b/src/i18n/locales/en-us/setting.json index 060736981..9af8d2bf8 100644 --- a/src/i18n/locales/en-us/setting.json +++ b/src/i18n/locales/en-us/setting.json @@ -210,6 +210,8 @@ "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.", + "proxy-cleared-restart-required": "Proxy configuration cleared. Restart the app to apply changes.", + "proxy-reset-failed": "Failed to reset proxy configuration.", "proxy-invalid-url": "Invalid proxy URL. Must start with http://, https://, socks4://, or socks5://.", "proxy-restart-hint": "Restart required to apply proxy changes.", "cloud-not-available-in-local-proxy": "Cloud version is not available in local proxy mode", diff --git a/src/i18n/locales/es/setting.json b/src/i18n/locales/es/setting.json index 1db136205..df2d07540 100644 --- a/src/i18n/locales/es/setting.json +++ b/src/i18n/locales/es/setting.json @@ -210,6 +210,8 @@ "proxy-placeholder": "http://127.0.0.1:7890", "proxy-saved-restart-required": "Configuración de proxy guardada. Reinicie la aplicación para aplicar los cambios.", "proxy-save-failed": "Error al guardar la configuración del proxy.", + "proxy-cleared-restart-required": "Configuración de proxy borrada. Reinicie la aplicación para aplicar los cambios.", + "proxy-reset-failed": "Error al restablecer la configuración del proxy.", "proxy-invalid-url": "URL de proxy no válida. Debe comenzar con http://, https://, socks4:// o socks5://.", "proxy-restart-hint": "Es necesario reiniciar para aplicar los cambios del proxy.", "cloud-not-available-in-local-proxy": "La versión en la nube no está disponible en modo proxy local", diff --git a/src/i18n/locales/fr/setting.json b/src/i18n/locales/fr/setting.json index 141b198ff..809b5cc26 100644 --- a/src/i18n/locales/fr/setting.json +++ b/src/i18n/locales/fr/setting.json @@ -210,6 +210,8 @@ "proxy-placeholder": "http://127.0.0.1:7890", "proxy-saved-restart-required": "Configuration du proxy enregistrée. Redémarrez l'application pour appliquer les modifications.", "proxy-save-failed": "Échec de l'enregistrement de la configuration du proxy.", + "proxy-cleared-restart-required": "Configuration du proxy effacée. Redémarrez l'application pour appliquer les modifications.", + "proxy-reset-failed": "Échec de la réinitialisation de la configuration du proxy.", "proxy-invalid-url": "URL de proxy invalide. Doit commencer par http://, https://, socks4:// ou socks5://.", "proxy-restart-hint": "Redémarrage nécessaire pour appliquer les modifications du proxy.", "cloud-not-available-in-local-proxy": "La version cloud n'est pas disponible en mode proxy local", diff --git a/src/i18n/locales/it/setting.json b/src/i18n/locales/it/setting.json index 9b9beb2ed..4bbcb3934 100644 --- a/src/i18n/locales/it/setting.json +++ b/src/i18n/locales/it/setting.json @@ -210,6 +210,8 @@ "proxy-placeholder": "http://127.0.0.1:7890", "proxy-saved-restart-required": "Configurazione proxy salvata. Riavvia l'app per applicare le modifiche.", "proxy-save-failed": "Impossibile salvare la configurazione del proxy.", + "proxy-cleared-restart-required": "Configurazione proxy cancellata. Riavvia l'app per applicare le modifiche.", + "proxy-reset-failed": "Impossibile reimpostare la configurazione del proxy.", "proxy-invalid-url": "URL proxy non valido. Deve iniziare con http://, https://, socks4:// o socks5://.", "proxy-restart-hint": "Riavvio necessario per applicare le modifiche del proxy.", "cloud-not-available-in-local-proxy": "La versione cloud non è disponibile in modalità proxy locale", diff --git a/src/i18n/locales/ja/setting.json b/src/i18n/locales/ja/setting.json index 836e0c8f8..7e64fa2d2 100644 --- a/src/i18n/locales/ja/setting.json +++ b/src/i18n/locales/ja/setting.json @@ -210,6 +210,8 @@ "proxy-placeholder": "http://127.0.0.1:7890", "proxy-saved-restart-required": "プロキシ設定が保存されました。変更を適用するにはアプリを再起動してください。", "proxy-save-failed": "プロキシ設定の保存に失敗しました。", + "proxy-cleared-restart-required": "プロキシ設定が消去されました。変更を適用するにはアプリを再起動してください。", + "proxy-reset-failed": "プロキシ設定のリセットに失敗しました。", "proxy-invalid-url": "無効なプロキシURLです。http://、https://、socks4://、またはsocks5://で始まる必要があります。", "proxy-restart-hint": "プロキシの変更を適用するには再起動が必要です。", "cloud-not-available-in-local-proxy": "ローカルプロキシモードではクラウド版は利用できません", diff --git a/src/i18n/locales/ko/setting.json b/src/i18n/locales/ko/setting.json index 37ad9c53e..07f784423 100644 --- a/src/i18n/locales/ko/setting.json +++ b/src/i18n/locales/ko/setting.json @@ -210,6 +210,8 @@ "proxy-placeholder": "http://127.0.0.1:7890", "proxy-saved-restart-required": "프록시 설정이 저장되었습니다. 변경 사항을 적용하려면 앱을 다시 시작하세요.", "proxy-save-failed": "프록시 설정 저장에 실패했습니다.", + "proxy-cleared-restart-required": "프록시 설정이 지워졌습니다. 변경 사항을 적용하려면 앱을 다시 시작하세요.", + "proxy-reset-failed": "프록시 설정 초기화에 실패했습니다.", "proxy-invalid-url": "잘못된 프록시 URL입니다. http://, https://, socks4://, 또는 socks5://로 시작해야 합니다.", "proxy-restart-hint": "프록시 변경 사항을 적용하려면 다시 시작해야 합니다.", "cloud-not-available-in-local-proxy": "로컬 프록시 모드에서는 클라우드 버전을 사용할 수 없습니다", diff --git a/src/i18n/locales/ru/setting.json b/src/i18n/locales/ru/setting.json index c2fad649a..7285a99fa 100644 --- a/src/i18n/locales/ru/setting.json +++ b/src/i18n/locales/ru/setting.json @@ -210,6 +210,8 @@ "proxy-placeholder": "http://127.0.0.1:7890", "proxy-saved-restart-required": "Конфигурация прокси сохранена. Перезапустите приложение для применения изменений.", "proxy-save-failed": "Не удалось сохранить конфигурацию прокси.", + "proxy-cleared-restart-required": "Конфигурация прокси очищена. Перезапустите приложение для применения изменений.", + "proxy-reset-failed": "Не удалось сбросить конфигурацию прокси.", "proxy-invalid-url": "Недопустимый URL прокси. Должен начинаться с http://, https://, socks4:// или socks5://.", "proxy-restart-hint": "Для применения изменений прокси требуется перезапуск.", "cloud-not-available-in-local-proxy": "Облачная версия недоступна в режиме локального прокси", diff --git a/src/i18n/locales/zh-Hans/setting.json b/src/i18n/locales/zh-Hans/setting.json index 287c4c07f..e04a16966 100644 --- a/src/i18n/locales/zh-Hans/setting.json +++ b/src/i18n/locales/zh-Hans/setting.json @@ -217,6 +217,8 @@ "proxy-placeholder": "http://127.0.0.1:7890", "proxy-saved-restart-required": "代理配置已保存。请重启应用以应用更改。", "proxy-save-failed": "保存代理配置失败。", + "proxy-cleared-restart-required": "代理配置已清除。请重启应用以应用更改。", + "proxy-reset-failed": "重置代理配置失败。", "proxy-invalid-url": "无效的代理 URL。必须以 http://、https://、socks4:// 或 socks5:// 开头。", "proxy-restart-hint": "需要重启应用以应用代理更改。", diff --git a/src/i18n/locales/zh-Hant/setting.json b/src/i18n/locales/zh-Hant/setting.json index 48c105b38..45788177a 100644 --- a/src/i18n/locales/zh-Hant/setting.json +++ b/src/i18n/locales/zh-Hant/setting.json @@ -246,6 +246,8 @@ "proxy-placeholder": "http://127.0.0.1:7890", "proxy-saved-restart-required": "代理設定已儲存。請重新啟動應用程式以套用變更。", "proxy-save-failed": "儲存代理設定失敗。", + "proxy-cleared-restart-required": "代理設定已清除。請重新啟動應用程式以套用變更。", + "proxy-reset-failed": "重設代理設定失敗。", "proxy-invalid-url": "無效的代理 URL。必須以 http://、https://、socks4:// 或 socks5:// 開頭。", "proxy-restart-hint": "需要重新啟動應用程式以套用代理變更。", "preferred-ide": "偏好 IDE", diff --git a/test/unit/components/GeneralSettingsProxy.test.tsx b/test/unit/components/GeneralSettingsProxy.test.tsx index 390534d80..8358db1b1 100644 --- a/test/unit/components/GeneralSettingsProxy.test.tsx +++ b/test/unit/components/GeneralSettingsProxy.test.tsx @@ -12,9 +12,10 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -// These tests protect the persisted Network Proxy baseline: loading and saving -// establish it, edits enable Save and Reset, and Reset never writes to disk. -// They also cover disabling an existing proxy and the restart-required action. +// The Network Proxy row exposes a single action button: it reads "Save" while +// the input differs from the persisted baseline and "Reset" once they match, +// where Reset clears the saved proxy. Restarting is offered from the toast and +// echoed by the note under the input, never by the button itself. import SettingGeneral from '@/components/Settings/General'; import { HostProvider } from '@/host'; @@ -23,6 +24,8 @@ import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +const RESTART_NOTE = 'Restart required to apply proxy changes.'; + const dependencyMocks = vi.hoisted(() => ({ clearTasks: vi.fn(), logout: vi.fn(), @@ -102,129 +105,250 @@ function renderProxySettings( return electronAPI; } +/** The row renders exactly one action button, whatever it currently reads. */ +function actionButton() { + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(1); + return buttons[0]; +} + +/** Options passed alongside the most recent success toast. */ +function lastSuccessToastAction() { + const calls = dependencyMocks.toastSuccess.mock.calls; + const [, options] = calls[calls.length - 1] as [ + string, + { action?: { label: string; onClick: () => void } } | undefined, + ]; + return options?.action; +} + describe('General settings Network Proxy', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('loads the saved proxy as the disabled action baseline', async () => { + it('offers a single Reset action for a saved proxy', async () => { const electronAPI = renderProxySettings('http://saved-proxy:8080'); expect( await screen.findByDisplayValue('http://saved-proxy:8080') ).toBeEnabled(); expect(electronAPI.readGlobalEnv).toHaveBeenCalledWith('HTTP_PROXY'); - expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled(); + + const button = actionButton(); + expect(button).toHaveTextContent('Reset'); + expect(button).toBeEnabled(); + expect(screen.queryByText(RESTART_NOTE)).not.toBeInTheDocument(); }); - it('enables Save and Reset for an edit, then resets without writing', async () => { - const user = userEvent.setup(); - const electronAPI = renderProxySettings('http://saved-proxy:8080'); + it('nests the action inside the input field', async () => { + renderProxySettings('http://saved-proxy:8080'); const input = await screen.findByDisplayValue('http://saved-proxy:8080'); - await user.clear(input); - await user.type(input, 'http://edited-proxy:9090'); + // The button sits in the field row beside the , not in a sibling + // block underneath it. + const field = input.parentElement; + expect(field).not.toBeNull(); + expect(field).toContainElement(actionButton()); + // Padding keeps a long URL from sliding under the nested button. + expect(input).toHaveClass('pr-24'); + }); - expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); - const resetButton = screen.getByRole('button', { name: 'Reset' }); - expect(resetButton).toBeEnabled(); + it('disables the action when there is no saved proxy to clear', async () => { + renderProxySettings(undefined); + const input = screen.getByPlaceholderText('http://127.0.0.1:7890'); - await user.click(resetButton); + await waitFor(() => expect(input).toBeEnabled()); + expect(input).toHaveValue(''); - expect(input).toHaveValue('http://saved-proxy:8080'); - expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); - expect(resetButton).toBeDisabled(); - expect(electronAPI.envWrite).not.toHaveBeenCalled(); - expect(electronAPI.envRemove).not.toHaveBeenCalled(); + const button = actionButton(); + expect(button).toHaveTextContent('Reset'); + expect(button).toBeDisabled(); }); - it('keeps Reset available for a whitespace-only edit', async () => { + it('switches the action to Save while the input is edited', async () => { const user = userEvent.setup(); renderProxySettings('http://saved-proxy:8080'); const input = await screen.findByDisplayValue('http://saved-proxy:8080'); - await user.type(input, ' '); + await user.clear(input); + await user.type(input, 'http://edited-proxy:9090'); + + const button = actionButton(); + expect(button).toHaveTextContent('Save'); + expect(button).toBeEnabled(); + }); - expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); - const resetButton = screen.getByRole('button', { name: 'Reset' }); - expect(resetButton).toBeEnabled(); + it('treats a whitespace-only edit as Save, never as Reset', async () => { + const user = userEvent.setup(); + renderProxySettings('http://saved-proxy:8080'); + const input = await screen.findByDisplayValue('http://saved-proxy:8080'); - await user.click(resetButton); + await user.type(input, ' '); - expect(input).toHaveValue('http://saved-proxy:8080'); - expect(resetButton).toBeDisabled(); + // Guards the destructive path: the button must not read "Reset" under a + // field the user has typed into, even when the change trims away to nothing. + const button = actionButton(); + expect(button).toHaveTextContent('Save'); + expect(button).toBeEnabled(); }); - it('uses a successful save as the new reset baseline', async () => { + it('saves an edit, then returns the action to Reset', async () => { const user = userEvent.setup(); const electronAPI = renderProxySettings('http://saved-proxy:8080'); const input = await screen.findByDisplayValue('http://saved-proxy:8080'); await user.clear(input); await user.type(input, 'http://new-proxy:9090'); - await user.click(screen.getByRole('button', { name: 'Save' })); + await user.click(actionButton()); await waitFor(() => { expect(electronAPI.envWrite).toHaveBeenCalledWith('test@example.com', { key: 'HTTP_PROXY', value: 'http://new-proxy:9090', }); - expect( - screen.getByRole('button', { name: 'Restart to Apply' }) - ).toBeEnabled(); - expect( - screen.getByText('Restart required to apply proxy changes.') - ).toBeInTheDocument(); }); + expect(dependencyMocks.toastSuccess).toHaveBeenCalledWith( + 'Proxy configuration saved. Restart the app to apply changes.', + expect.objectContaining({ + action: expect.objectContaining({ label: 'Restart to Apply' }), + }) + ); + // A DsText span on the `meta` role, tinted with the error tone so the + // pending restart is hard to miss while the field itself stays valid. + const note = screen.getByText(RESTART_NOTE); + expect(note.tagName).toBe('SPAN'); + // Both survive tailwind-merge: the role owns the size, the class the color. + expect(note).toHaveClass('!text-ds-text-meta'); + expect(note).toHaveClass('text-ds-text-status-error-strong-default'); + expect(input).not.toHaveClass( + 'border-ds-border-status-error-default-default' + ); + + const button = actionButton(); + expect(button).toHaveTextContent('Reset'); + expect(button).toBeEnabled(); + expect(electronAPI.envRemove).not.toHaveBeenCalled(); + }); + + it('restarts from the toast action rather than the button', async () => { + const user = userEvent.setup(); + const electronAPI = renderProxySettings('http://saved-proxy:8080'); + const input = await screen.findByDisplayValue('http://saved-proxy:8080'); + await user.clear(input); - await user.type(input, 'http://another-proxy:7070'); + await user.type(input, 'http://new-proxy:9090'); + await user.click(actionButton()); + + await waitFor(() => expect(electronAPI.envWrite).toHaveBeenCalledTimes(1)); + expect( - screen.queryByText('Restart required to apply proxy changes.') + screen.queryByRole('button', { name: 'Restart to Apply' }) ).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Reset' })); - expect(input).toHaveValue('http://new-proxy:9090'); - expect(electronAPI.envWrite).toHaveBeenCalledTimes(1); - expect(electronAPI.envRemove).not.toHaveBeenCalled(); - expect( - screen.getByRole('button', { name: 'Restart to Apply' }) - ).toBeEnabled(); - expect( - screen.getByText('Restart required to apply proxy changes.') - ).toBeInTheDocument(); + const action = lastSuccessToastAction(); + expect(action?.label).toBe('Restart to Apply'); + act(() => action?.onClick()); + expect(electronAPI.restartApp).toHaveBeenCalledTimes(1); + }); + + it('clears the saved proxy when Reset is used', async () => { + const user = userEvent.setup(); + const electronAPI = renderProxySettings('http://saved-proxy:8080'); + const input = await screen.findByDisplayValue('http://saved-proxy:8080'); + + await user.click(actionButton()); + + await waitFor(() => { + expect(electronAPI.envRemove).toHaveBeenCalledWith( + 'test@example.com', + 'HTTP_PROXY' + ); + }); + + expect(input).toHaveValue(''); + expect(electronAPI.envWrite).not.toHaveBeenCalled(); + expect(dependencyMocks.toastSuccess).toHaveBeenCalledWith( + 'Proxy configuration cleared. Restart the app to apply changes.', + expect.objectContaining({ + action: expect.objectContaining({ label: 'Restart to Apply' }), + }) + ); + expect(screen.getByText(RESTART_NOTE)).toBeInTheDocument(); + + // Baseline is now empty, so there is nothing left to clear. + const button = actionButton(); + expect(button).toHaveTextContent('Reset'); + expect(button).toBeDisabled(); }); - it('rejects an invalid URL without writing or disabling Reset', async () => { + it('keeps the saved proxy when the reset write fails', async () => { + const user = userEvent.setup(); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const envRemove = vi.fn().mockResolvedValue({ success: false }); + renderProxySettings('http://saved-proxy:8080', { envRemove }); + const input = await screen.findByDisplayValue('http://saved-proxy:8080'); + + await user.click(actionButton()); + + await waitFor(() => { + expect(dependencyMocks.toastError).toHaveBeenCalledWith( + 'Failed to reset proxy configuration.' + ); + }); + expect(input).toHaveValue('http://saved-proxy:8080'); + expect(actionButton()).toHaveTextContent('Reset'); + expect(screen.queryByText(RESTART_NOTE)).not.toBeInTheDocument(); + errorSpy.mockRestore(); + }); + + it('saves an emptied field with envRemove', async () => { + const user = userEvent.setup(); + const electronAPI = renderProxySettings('http://saved-proxy:8080'); + const input = await screen.findByDisplayValue('http://saved-proxy:8080'); + + await user.clear(input); + expect(actionButton()).toHaveTextContent('Save'); + await user.click(actionButton()); + + await waitFor(() => { + expect(electronAPI.envRemove).toHaveBeenCalledWith( + 'test@example.com', + 'HTTP_PROXY' + ); + }); + expect(input).toHaveValue(''); + expect(actionButton()).toBeDisabled(); + }); + + it('rejects an invalid URL without writing', async () => { const user = userEvent.setup(); const electronAPI = renderProxySettings('http://saved-proxy:8080'); const input = await screen.findByDisplayValue('http://saved-proxy:8080'); await user.clear(input); await user.type(input, 'ftp://invalid-proxy'); - await user.click(screen.getByRole('button', { name: 'Save' })); + await user.click(actionButton()); expect(dependencyMocks.toastError).toHaveBeenCalledWith( 'Invalid proxy URL. Must start with http://, https://, socks4://, or socks5://.' ); expect(electronAPI.envWrite).not.toHaveBeenCalled(); expect(electronAPI.envRemove).not.toHaveBeenCalled(); - expect(screen.getByRole('button', { name: 'Reset' })).toBeEnabled(); + expect(actionButton()).toHaveTextContent('Save'); }); - it('keeps the loaded baseline after a failed save', async () => { + it('keeps the edit and the baseline after a failed save', async () => { const user = userEvent.setup(); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const envWrite = vi.fn().mockResolvedValue({ success: false }); - const electronAPI = renderProxySettings('http://saved-proxy:8080', { - envWrite, - }); + renderProxySettings('http://saved-proxy:8080', { envWrite }); const input = await screen.findByDisplayValue('http://saved-proxy:8080'); await user.clear(input); await user.type(input, 'http://failed-proxy:9090'); - await user.click(screen.getByRole('button', { name: 'Save' })); + await user.click(actionButton()); await waitFor(() => { expect(dependencyMocks.toastError).toHaveBeenCalledWith( @@ -232,17 +356,11 @@ describe('General settings Network Proxy', () => { ); }); expect(input).toHaveValue('http://failed-proxy:9090'); - expect(screen.getByRole('button', { name: 'Reset' })).toBeEnabled(); - - await user.click(screen.getByRole('button', { name: 'Reset' })); - - expect(input).toHaveValue('http://saved-proxy:8080'); - expect(electronAPI.envWrite).toHaveBeenCalledTimes(1); - expect(electronAPI.envRemove).not.toHaveBeenCalled(); + expect(actionButton()).toHaveTextContent('Save'); errorSpy.mockRestore(); }); - it('disables Reset while a save is in flight', async () => { + it('disables the action while a save is in flight', async () => { const user = userEvent.setup(); let resolveSave: (result: { success: boolean }) => void = () => {}; const envWrite = vi.fn( @@ -256,56 +374,17 @@ describe('General settings Network Proxy', () => { await user.clear(input); await user.type(input, 'http://pending-proxy:9090'); - await user.click(screen.getByRole('button', { name: 'Save' })); + await user.click(actionButton()); await waitFor(() => expect(envWrite).toHaveBeenCalledTimes(1)); - expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Saving...' })).toBeDisabled(); + expect(actionButton()).toHaveTextContent('Saving...'); + expect(actionButton()).toBeDisabled(); await act(async () => { resolveSave({ success: true }); }); - expect( - await screen.findByRole('button', { name: 'Restart to Apply' }) - ).toBeEnabled(); - }); - - it('resets edits to an empty loaded proxy without removing it', async () => { - const user = userEvent.setup(); - const electronAPI = renderProxySettings(undefined); - const input = screen.getByPlaceholderText('http://127.0.0.1:7890'); - - await waitFor(() => expect(input).toBeEnabled()); - expect(input).toHaveValue(''); - - await user.type(input, 'socks5://temporary-proxy:1080'); - await user.click(screen.getByRole('button', { name: 'Reset' })); - - expect(input).toHaveValue(''); - expect(electronAPI.envWrite).not.toHaveBeenCalled(); - expect(electronAPI.envRemove).not.toHaveBeenCalled(); - expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); - }); - - it('saves an empty proxy with envRemove and keeps Restart to Apply', async () => { - const user = userEvent.setup(); - const electronAPI = renderProxySettings('http://saved-proxy:8080'); - const input = await screen.findByDisplayValue('http://saved-proxy:8080'); - - await user.clear(input); - await user.click(screen.getByRole('button', { name: 'Save' })); - - await waitFor(() => { - expect(electronAPI.envRemove).toHaveBeenCalledWith( - 'test@example.com', - 'HTTP_PROXY' - ); - expect( - screen.getByRole('button', { name: 'Restart to Apply' }) - ).toBeEnabled(); - }); - expect(input).toHaveValue(''); - expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled(); + await waitFor(() => expect(actionButton()).toHaveTextContent('Reset')); + expect(actionButton()).toBeEnabled(); }); });