diff --git a/app/src/pages/Welcome.tsx b/app/src/pages/Welcome.tsx index cf192ca12..31f6f3895 100644 --- a/app/src/pages/Welcome.tsx +++ b/app/src/pages/Welcome.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import OAuthProviderButton from '../components/oauth/OAuthProviderButton'; import { oauthProviderConfigs } from '../components/oauth/providerConfigs'; import RotatingTetrahedronCanvas from '../components/RotatingTetrahedronCanvas'; +import { clearBackendUrlCache } from '../services/backendUrl'; import { clearCoreRpcUrlCache, testCoreRpcConnection } from '../services/coreRpcClient'; import { useDeepLinkAuthState } from '../store/deepLinkAuthState'; import { @@ -39,6 +40,7 @@ const Welcome = () => { storeRpcUrl(normalized); clearCoreRpcUrlCache(); + clearBackendUrlCache(); setRpcUrlError(null); setSaveSuccess(true); @@ -48,6 +50,7 @@ const Welcome = () => { const handleResetRpcUrl = () => { clearStoredRpcUrl(); clearCoreRpcUrlCache(); + clearBackendUrlCache(); setRpcUrl(getDefaultRpcUrl()); setRpcUrlError(null); setSaveSuccess(false); @@ -71,6 +74,7 @@ const Welcome = () => { setSaveSuccess(true); storeRpcUrl(normalized); clearCoreRpcUrlCache(); + clearBackendUrlCache(); } else { setRpcUrlError(`Connection failed: ${response.status} ${response.statusText}`); } diff --git a/app/src/pages/__tests__/Welcome.test.tsx b/app/src/pages/__tests__/Welcome.test.tsx index 54f18b089..4c02b4053 100644 --- a/app/src/pages/__tests__/Welcome.test.tsx +++ b/app/src/pages/__tests__/Welcome.test.tsx @@ -1,7 +1,15 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { clearBackendUrlCache } from '../../services/backendUrl'; +import { clearCoreRpcUrlCache, testCoreRpcConnection } from '../../services/coreRpcClient'; import { useDeepLinkAuthState } from '../../store/deepLinkAuthState'; +import { + clearStoredRpcUrl, + getDefaultRpcUrl, + getStoredRpcUrl, + storeRpcUrl, +} from '../../utils/configPersistence'; import Welcome from '../Welcome'; const oauthButtonSpy = vi.fn(); @@ -44,11 +52,45 @@ vi.mock('../../components/oauth/providerConfigs', () => ({ vi.mock('../../store/deepLinkAuthState', () => ({ useDeepLinkAuthState: vi.fn() })); +vi.mock('../../services/coreRpcClient', () => ({ + clearCoreRpcUrlCache: vi.fn(), + testCoreRpcConnection: vi.fn(), +})); + +vi.mock('../../services/backendUrl', () => ({ + clearBackendUrlCache: vi.fn(), + getBackendUrl: vi.fn().mockResolvedValue('http://localhost:5005'), +})); + +vi.mock('../../utils/configPersistence', () => ({ + getStoredRpcUrl: vi.fn(() => 'http://127.0.0.1:7788/rpc'), + storeRpcUrl: vi.fn(), + clearStoredRpcUrl: vi.fn(), + getDefaultRpcUrl: vi.fn(() => 'http://127.0.0.1:7788/rpc'), + isValidRpcUrl: vi.fn((url: string) => { + if (!url || url.trim().length === 0) return false; + try { + const parsed = new URL(url); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } + }), + normalizeRpcUrl: vi.fn((url: string) => url.trim().replace(/\/+$/, '')), +})); + describe('Welcome auth entrypoint', () => { beforeEach(() => { oauthButtonSpy.mockReset(); oauthOverrideSpy.mockReset(); vi.mocked(useDeepLinkAuthState).mockReturnValue({ isProcessing: false, errorMessage: null }); + vi.mocked(clearCoreRpcUrlCache).mockReset(); + vi.mocked(clearBackendUrlCache).mockReset(); + vi.mocked(storeRpcUrl).mockReset(); + vi.mocked(clearStoredRpcUrl).mockReset(); + vi.mocked(getStoredRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc'); + vi.mocked(getDefaultRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc'); + vi.mocked(testCoreRpcConnection).mockReset(); }); it('renders only the OAuth buttons when auth is idle', () => { @@ -94,3 +136,410 @@ describe('Welcome auth entrypoint', () => { expect(screen.getByRole('alert')).toHaveTextContent('OAuth failed'); }); }); + +describe('Welcome — RPC URL advanced panel', () => { + beforeEach(() => { + vi.mocked(useDeepLinkAuthState).mockReturnValue({ isProcessing: false, errorMessage: null }); + vi.mocked(clearCoreRpcUrlCache).mockReset(); + vi.mocked(clearBackendUrlCache).mockReset(); + vi.mocked(storeRpcUrl).mockReset(); + vi.mocked(clearStoredRpcUrl).mockReset(); + vi.mocked(getStoredRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc'); + vi.mocked(getDefaultRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc'); + vi.mocked(testCoreRpcConnection).mockReset(); + }); + + it('renders with advanced panel collapsed by default', () => { + render(); + + expect(screen.queryByLabelText('Core RPC URL')).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText('http://127.0.0.1:7788/rpc')).not.toBeInTheDocument(); + }); + + it('shows the "Configure RPC URL (Advanced)" toggle when panel is collapsed', () => { + render(); + + expect( + screen.getByRole('button', { name: 'Configure RPC URL (Advanced)' }) + ).toBeInTheDocument(); + }); + + it('clicking the toggle opens the advanced panel', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Configure RPC URL (Advanced)' })); + + expect(screen.getByPlaceholderText('http://127.0.0.1:7788/rpc')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Reset to Default' })).toBeInTheDocument(); + }); + + it('panel shows the stored RPC URL as the initial input value', () => { + vi.mocked(getStoredRpcUrl).mockReturnValue('http://custom-host:9999/rpc'); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Configure RPC URL (Advanced)' })); + + expect(screen.getByPlaceholderText('http://127.0.0.1:7788/rpc')).toHaveValue( + 'http://custom-host:9999/rpc' + ); + }); + + it('panel shows the default URL when nothing custom is stored', () => { + vi.mocked(getStoredRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc'); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Configure RPC URL (Advanced)' })); + + expect(screen.getByPlaceholderText('http://127.0.0.1:7788/rpc')).toHaveValue( + 'http://127.0.0.1:7788/rpc' + ); + }); + + it('clicking Close hides the advanced panel', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Configure RPC URL (Advanced)' })); + expect(screen.getByPlaceholderText('http://127.0.0.1:7788/rpc')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(screen.queryByPlaceholderText('http://127.0.0.1:7788/rpc')).not.toBeInTheDocument(); + }); +}); + +describe('Welcome — Save button', () => { + beforeEach(() => { + vi.mocked(useDeepLinkAuthState).mockReturnValue({ isProcessing: false, errorMessage: null }); + vi.mocked(clearCoreRpcUrlCache).mockReset(); + vi.mocked(clearBackendUrlCache).mockReset(); + vi.mocked(storeRpcUrl).mockReset(); + vi.mocked(clearStoredRpcUrl).mockReset(); + vi.mocked(getStoredRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc'); + vi.mocked(getDefaultRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc'); + }); + + function openPanel() { + fireEvent.click(screen.getByRole('button', { name: 'Configure RPC URL (Advanced)' })); + } + + it('clicking Save with a valid URL calls storeRpcUrl with the normalised URL', () => { + render(); + openPanel(); + + const input = screen.getByPlaceholderText('http://127.0.0.1:7788/rpc'); + fireEvent.change(input, { target: { value: 'http://192.168.1.1:8000/rpc' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(storeRpcUrl).toHaveBeenCalledWith('http://192.168.1.1:8000/rpc'); + }); + + it('clicking Save calls clearCoreRpcUrlCache()', () => { + render(); + openPanel(); + + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(clearCoreRpcUrlCache).toHaveBeenCalledTimes(1); + }); + + it('clicking Save calls clearBackendUrlCache()', () => { + render(); + openPanel(); + + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(clearBackendUrlCache).toHaveBeenCalledTimes(1); + }); + + it('clicking Save with an invalid URL shows a validation error and does NOT call storeRpcUrl', () => { + render(); + openPanel(); + + const input = screen.getByPlaceholderText('http://127.0.0.1:7788/rpc'); + fireEvent.change(input, { target: { value: 'not-a-valid-url' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(screen.getByText('Please enter a valid HTTP or HTTPS URL')).toBeInTheDocument(); + expect(storeRpcUrl).not.toHaveBeenCalled(); + }); + + it('clicking Save with empty string shows a validation error', () => { + render(); + openPanel(); + + const input = screen.getByPlaceholderText('http://127.0.0.1:7788/rpc'); + fireEvent.change(input, { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(screen.getByText('Please enter a valid HTTP or HTTPS URL')).toBeInTheDocument(); + expect(storeRpcUrl).not.toHaveBeenCalled(); + }); + + it('shows a success message after a successful save', async () => { + render(); + openPanel(); + + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByText('URL saved successfully.')).toBeInTheDocument(); + }); +}); + +describe('Welcome — Test Connection button', () => { + beforeEach(() => { + vi.mocked(useDeepLinkAuthState).mockReturnValue({ isProcessing: false, errorMessage: null }); + vi.mocked(clearCoreRpcUrlCache).mockReset(); + vi.mocked(clearBackendUrlCache).mockReset(); + vi.mocked(storeRpcUrl).mockReset(); + vi.mocked(getStoredRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc'); + vi.mocked(getDefaultRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc'); + vi.mocked(testCoreRpcConnection).mockReset(); + }); + + function openPanel() { + fireEvent.click(screen.getByRole('button', { name: 'Configure RPC URL (Advanced)' })); + } + + it('clicking Test Connection fires testCoreRpcConnection with the entered URL', async () => { + vi.mocked(testCoreRpcConnection).mockResolvedValueOnce({ ok: true, status: 200 } as Response); + + render(); + openPanel(); + + fireEvent.click(screen.getByRole('button', { name: 'Test' })); + + await waitFor(() => { + expect(testCoreRpcConnection).toHaveBeenCalledWith('http://127.0.0.1:7788/rpc'); + }); + }); + + it('successful probe (200 ok) shows success message', async () => { + vi.mocked(testCoreRpcConnection).mockResolvedValueOnce({ ok: true, status: 200 } as Response); + + render(); + openPanel(); + fireEvent.click(screen.getByRole('button', { name: 'Test' })); + + await screen.findByText('URL saved successfully.'); + }); + + it('successful probe with 405 status (expected for JSON-RPC ping) shows success message', async () => { + vi.mocked(testCoreRpcConnection).mockResolvedValueOnce({ + ok: false, + status: 405, + statusText: 'Method Not Allowed', + } as Response); + + render(); + openPanel(); + fireEvent.click(screen.getByRole('button', { name: 'Test' })); + + await screen.findByText('URL saved successfully.'); + }); + + it('failed probe (4xx/5xx status) shows an error message', async () => { + vi.mocked(testCoreRpcConnection).mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + } as Response); + + render(); + openPanel(); + fireEvent.click(screen.getByRole('button', { name: 'Test' })); + + await screen.findByText('Connection failed: 503 Service Unavailable'); + }); + + it('failed probe (network error) shows an error message with the error text', async () => { + vi.mocked(testCoreRpcConnection).mockRejectedValueOnce(new Error('ECONNREFUSED')); + + render(); + openPanel(); + fireEvent.click(screen.getByRole('button', { name: 'Test' })); + + await screen.findByText('Connection failed: ECONNREFUSED'); + }); + + it('Test Connection with invalid URL shows validation error without calling testCoreRpcConnection', async () => { + render(); + openPanel(); + + const input = screen.getByPlaceholderText('http://127.0.0.1:7788/rpc'); + fireEvent.change(input, { target: { value: 'bad-url' } }); + fireEvent.click(screen.getByRole('button', { name: 'Test' })); + + expect(screen.getByText('Please enter a valid HTTP or HTTPS URL')).toBeInTheDocument(); + expect(testCoreRpcConnection).not.toHaveBeenCalled(); + }); + + it('shows loading state while the probe is in flight', async () => { + let resolveProbe!: (r: Response) => void; + vi.mocked(testCoreRpcConnection).mockReturnValueOnce( + new Promise(resolve => { + resolveProbe = resolve; + }) + ); + + render(); + openPanel(); + fireEvent.click(screen.getByRole('button', { name: 'Test' })); + + // During flight the button label changes to "Testing" and the button is disabled + const testBtn = screen.getByRole('button', { name: /testing/i }); + expect(testBtn).toBeDisabled(); + + resolveProbe({ ok: true, status: 200 } as Response); + await waitFor(() => + expect(screen.queryByRole('button', { name: /testing/i })).not.toBeInTheDocument() + ); + }); + + it('successful probe stores the URL and clears both the RPC and backend URL caches', async () => { + vi.mocked(testCoreRpcConnection).mockResolvedValueOnce({ ok: true, status: 200 } as Response); + + render(); + openPanel(); + fireEvent.click(screen.getByRole('button', { name: 'Test' })); + + await waitFor(() => { + expect(storeRpcUrl).toHaveBeenCalledWith('http://127.0.0.1:7788/rpc'); + expect(clearCoreRpcUrlCache).toHaveBeenCalledTimes(1); + expect(clearBackendUrlCache).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('Welcome — Reset to Default button', () => { + beforeEach(() => { + vi.mocked(useDeepLinkAuthState).mockReturnValue({ isProcessing: false, errorMessage: null }); + vi.mocked(clearCoreRpcUrlCache).mockReset(); + vi.mocked(clearBackendUrlCache).mockReset(); + vi.mocked(storeRpcUrl).mockReset(); + vi.mocked(clearStoredRpcUrl).mockReset(); + vi.mocked(getStoredRpcUrl).mockReturnValue('http://custom:9999/rpc'); + vi.mocked(getDefaultRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc'); + }); + + function openPanel() { + fireEvent.click(screen.getByRole('button', { name: 'Configure RPC URL (Advanced)' })); + } + + it('clicking Reset calls clearStoredRpcUrl()', () => { + render(); + openPanel(); + fireEvent.click(screen.getByRole('button', { name: 'Reset to Default' })); + + expect(clearStoredRpcUrl).toHaveBeenCalledTimes(1); + }); + + it('clicking Reset calls clearCoreRpcUrlCache()', () => { + render(); + openPanel(); + fireEvent.click(screen.getByRole('button', { name: 'Reset to Default' })); + + expect(clearCoreRpcUrlCache).toHaveBeenCalledTimes(1); + }); + + it('clicking Reset calls clearBackendUrlCache()', () => { + render(); + openPanel(); + fireEvent.click(screen.getByRole('button', { name: 'Reset to Default' })); + + expect(clearBackendUrlCache).toHaveBeenCalledTimes(1); + }); + + it('after Reset, input value reverts to the default URL', () => { + render(); + openPanel(); + + // Input starts with the custom stored value + expect(screen.getByPlaceholderText('http://127.0.0.1:7788/rpc')).toHaveValue( + 'http://custom:9999/rpc' + ); + + fireEvent.click(screen.getByRole('button', { name: 'Reset to Default' })); + + expect(screen.getByPlaceholderText('http://127.0.0.1:7788/rpc')).toHaveValue( + 'http://127.0.0.1:7788/rpc' + ); + }); +}); + +describe('Welcome — URL input behaviour', () => { + beforeEach(() => { + vi.mocked(useDeepLinkAuthState).mockReturnValue({ isProcessing: false, errorMessage: null }); + vi.mocked(clearCoreRpcUrlCache).mockReset(); + vi.mocked(clearBackendUrlCache).mockReset(); + vi.mocked(storeRpcUrl).mockReset(); + vi.mocked(getStoredRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc'); + vi.mocked(getDefaultRpcUrl).mockReturnValue('http://127.0.0.1:7788/rpc'); + }); + + function openPanel() { + fireEvent.click(screen.getByRole('button', { name: 'Configure RPC URL (Advanced)' })); + } + + it('typing in the input updates the displayed value', () => { + render(); + openPanel(); + + const input = screen.getByPlaceholderText('http://127.0.0.1:7788/rpc'); + fireEvent.change(input, { target: { value: 'http://new-host:5555/rpc' } }); + + expect(input).toHaveValue('http://new-host:5555/rpc'); + }); + + it('typing a valid URL clears any existing error', () => { + render(); + openPanel(); + + // First trigger an error + const input = screen.getByPlaceholderText('http://127.0.0.1:7788/rpc'); + fireEvent.change(input, { target: { value: 'bad' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(screen.getByText('Please enter a valid HTTP or HTTPS URL')).toBeInTheDocument(); + + // Then type a valid URL — error should clear + fireEvent.change(input, { target: { value: 'http://valid-host:9000/rpc' } }); + expect(screen.queryByText('Please enter a valid HTTP or HTTPS URL')).not.toBeInTheDocument(); + }); + + it('invalid URL (missing protocol) shows inline error on Save', () => { + render(); + openPanel(); + + const input = screen.getByPlaceholderText('http://127.0.0.1:7788/rpc'); + fireEvent.change(input, { target: { value: 'localhost:9999' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(screen.getByText('Please enter a valid HTTP or HTTPS URL')).toBeInTheDocument(); + }); +}); + +describe('Welcome — OAuth buttons presence', () => { + beforeEach(() => { + vi.mocked(useDeepLinkAuthState).mockReturnValue({ isProcessing: false, errorMessage: null }); + }); + + it('renders all providers with showOnWelcome=true', () => { + render(); + + expect(screen.getByRole('button', { name: 'google' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'github' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'twitter' })).toBeInTheDocument(); + }); + + it('does not render providers with showOnWelcome=false', () => { + render(); + + expect(screen.queryByRole('button', { name: 'discord' })).not.toBeInTheDocument(); + }); + + it('hides OAuth buttons while auth is processing', () => { + vi.mocked(useDeepLinkAuthState).mockReturnValue({ isProcessing: true, errorMessage: null }); + render(); + + expect(screen.queryByRole('button', { name: 'google' })).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/services/__tests__/backendUrl.test.ts b/app/src/services/__tests__/backendUrl.test.ts index 6798af340..a64bf6389 100644 --- a/app/src/services/__tests__/backendUrl.test.ts +++ b/app/src/services/__tests__/backendUrl.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { BACKEND_URL } from '../../utils/config'; + // Global test setup mocks `services/backendUrl` so consumers get a fixed URL // without RPC. To exercise the real implementation in this file, opt out. vi.unmock('../backendUrl'); @@ -15,7 +17,7 @@ vi.mock('../coreRpcClient', () => ({ callCoreRpc: hoisted.callCoreRpcMock })); async function loadFreshModule() { vi.resetModules(); const mod = await import('../backendUrl'); - return mod.getBackendUrl; + return mod; } describe('getBackendUrl', () => { @@ -29,7 +31,7 @@ describe('getBackendUrl', () => { hoisted.isTauriMock.mockReturnValue(true); hoisted.callCoreRpcMock.mockResolvedValue({ api_url: 'https://core-derived.example.com/' }); - const getBackendUrl = await loadFreshModule(); + const { getBackendUrl } = await loadFreshModule(); expect(await getBackendUrl()).toBe('https://core-derived.example.com'); expect(hoisted.callCoreRpcMock).toHaveBeenCalledWith({ method: 'openhuman.config_resolve_api_url', @@ -40,7 +42,7 @@ describe('getBackendUrl', () => { hoisted.isTauriMock.mockReturnValue(true); hoisted.callCoreRpcMock.mockResolvedValue({ api_url: 'https://core-derived.example.com' }); - const getBackendUrl = await loadFreshModule(); + const { getBackendUrl } = await loadFreshModule(); await getBackendUrl(); await getBackendUrl(); expect(hoisted.callCoreRpcMock).toHaveBeenCalledTimes(1); @@ -50,7 +52,7 @@ describe('getBackendUrl', () => { hoisted.isTauriMock.mockReturnValue(true); hoisted.callCoreRpcMock.mockResolvedValue({ api_url: '' }); - const getBackendUrl = await loadFreshModule(); + const { getBackendUrl } = await loadFreshModule(); await expect(getBackendUrl()).rejects.toThrow(/empty backend URL/i); }); @@ -58,7 +60,69 @@ describe('getBackendUrl', () => { hoisted.isTauriMock.mockReturnValue(true); hoisted.callCoreRpcMock.mockResolvedValue({ apiUrl: 'https://core-derived.example.com' }); - const getBackendUrl = await loadFreshModule(); + const { getBackendUrl } = await loadFreshModule(); expect(await getBackendUrl()).toBe('https://core-derived.example.com'); }); + + test('clearBackendUrlCache causes the next getBackendUrl() call to re-derive (not use cached value)', async () => { + hoisted.isTauriMock.mockReturnValue(true); + hoisted.callCoreRpcMock + .mockResolvedValueOnce({ api_url: 'https://first-call.example.com' }) + .mockResolvedValueOnce({ api_url: 'https://second-call.example.com' }); + + const { getBackendUrl, clearBackendUrlCache } = await loadFreshModule(); + + const first = await getBackendUrl(); + expect(first).toBe('https://first-call.example.com'); + + clearBackendUrlCache(); + + const second = await getBackendUrl(); + expect(second).toBe('https://second-call.example.com'); + expect(hoisted.callCoreRpcMock).toHaveBeenCalledTimes(2); + }); + + test('calling getBackendUrl() twice returns the same value (cache works)', async () => { + hoisted.isTauriMock.mockReturnValue(true); + hoisted.callCoreRpcMock.mockResolvedValue({ api_url: 'https://cached.example.com' }); + + const { getBackendUrl } = await loadFreshModule(); + const a = await getBackendUrl(); + const b = await getBackendUrl(); + expect(a).toBe(b); + expect(a).toBe('https://cached.example.com'); + }); + + test('after clearBackendUrlCache a second call re-invokes the core RPC', async () => { + hoisted.isTauriMock.mockReturnValue(true); + hoisted.callCoreRpcMock.mockResolvedValue({ api_url: 'https://rechecked.example.com' }); + + const { getBackendUrl, clearBackendUrlCache } = await loadFreshModule(); + await getBackendUrl(); + clearBackendUrlCache(); + await getBackendUrl(); + + expect(hoisted.callCoreRpcMock).toHaveBeenCalledTimes(2); + }); + + test('in non-Tauri mode returns BACKEND_URL directly without calling core RPC', async () => { + hoisted.isTauriMock.mockReturnValue(false); + + const { getBackendUrl } = await loadFreshModule(); + const url = await getBackendUrl(); + + // Should not have attempted an RPC call in non-Tauri mode + expect(hoisted.callCoreRpcMock).not.toHaveBeenCalled(); + // Should return the configured fallback constant + expect(url).toBe(BACKEND_URL); + }); + + test('propagates RPC errors in Tauri mode (no silent fallback)', async () => { + hoisted.isTauriMock.mockReturnValue(true); + hoisted.callCoreRpcMock.mockRejectedValue(new Error('RPC unavailable')); + + const { getBackendUrl } = await loadFreshModule(); + // The implementation does NOT catch the error — it propagates. Verify the rejection. + await expect(getBackendUrl()).rejects.toThrow('RPC unavailable'); + }); }); diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts index 00347f9d3..d6fb82b79 100644 --- a/app/src/services/__tests__/coreRpcClient.test.ts +++ b/app/src/services/__tests__/coreRpcClient.test.ts @@ -494,3 +494,117 @@ describe('coreRpcClient', () => { }); }); }); + +describe('getCoreRpcUrl', () => { + // Each test gets a fresh module so module-level caches are cleared + beforeEach(() => { + vi.resetModules(); + vi.mocked(isTauri).mockReturnValue(false); + vi.mocked(invoke).mockReset(); + }); + + test('in web mode returns stored URL when getStoredRpcUrl returns a non-default value', async () => { + vi.doMock('../../utils/configPersistence', () => ({ + getStoredRpcUrl: () => 'http://custom-host:9999/rpc', + })); + vi.mocked(isTauri).mockReturnValue(false); + + const { getCoreRpcUrl: freshGetCoreRpcUrl } = await import('../coreRpcClient'); + const url = await freshGetCoreRpcUrl(); + expect(url).toBe('http://custom-host:9999/rpc'); + }); + + test('in web mode returns default CORE_RPC_URL when nothing custom is stored', async () => { + vi.doMock('../../utils/configPersistence', () => ({ + getStoredRpcUrl: () => 'http://127.0.0.1:7788/rpc', + })); + vi.mocked(isTauri).mockReturnValue(false); + + const { getCoreRpcUrl: freshGetCoreRpcUrl } = await import('../coreRpcClient'); + const url = await freshGetCoreRpcUrl(); + expect(url).toBe('http://127.0.0.1:7788/rpc'); + }); + + test('in web mode caches the result — second call does not change the returned value', async () => { + let callCount = 0; + vi.doMock('../../utils/configPersistence', () => ({ + getStoredRpcUrl: () => { + callCount++; + return 'http://127.0.0.1:7788/rpc'; + }, + })); + vi.mocked(isTauri).mockReturnValue(false); + + const { getCoreRpcUrl: freshGetCoreRpcUrl } = await import('../coreRpcClient'); + const first = await freshGetCoreRpcUrl(); + const second = await freshGetCoreRpcUrl(); + expect(first).toBe(second); + // getStoredRpcUrl should only have been called once due to caching + expect(callCount).toBe(1); + }); + + test('returns fresh value after clearCoreRpcUrlCache()', async () => { + let storedValue = 'http://127.0.0.1:7788/rpc'; + vi.doMock('../../utils/configPersistence', () => ({ getStoredRpcUrl: () => storedValue })); + vi.mocked(isTauri).mockReturnValue(false); + + const { getCoreRpcUrl: freshGetCoreRpcUrl, clearCoreRpcUrlCache: freshClear } = + await import('../coreRpcClient'); + + const first = await freshGetCoreRpcUrl(); + expect(first).toBe('http://127.0.0.1:7788/rpc'); + + // Change stored value and clear cache + storedValue = 'http://new-host:8888/rpc'; + freshClear(); + + const second = await freshGetCoreRpcUrl(); + expect(second).toBe('http://new-host:8888/rpc'); + }); + + test('in Tauri mode calls invoke("core_rpc_url") when no stored URL is customised', async () => { + vi.doMock('../../utils/configPersistence', () => ({ + getStoredRpcUrl: () => 'http://127.0.0.1:7788/rpc', + })); + vi.mocked(isTauri).mockReturnValue(true); + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === 'core_rpc_url') return 'http://tauri-resolved:7788/rpc'; + throw new Error(`unexpected: ${cmd}`); + }); + + const { getCoreRpcUrl: freshGetCoreRpcUrl } = await import('../coreRpcClient'); + const url = await freshGetCoreRpcUrl(); + expect(url).toBe('http://tauri-resolved:7788/rpc'); + expect(vi.mocked(invoke)).toHaveBeenCalledWith('core_rpc_url'); + }); + + test('in Tauri mode stored URL takes priority over invoke result', async () => { + vi.doMock('../../utils/configPersistence', () => ({ + getStoredRpcUrl: () => 'http://stored-override:4444/rpc', + })); + vi.mocked(isTauri).mockReturnValue(true); + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === 'core_rpc_url') return 'http://tauri-would-return:7788/rpc'; + throw new Error(`unexpected: ${cmd}`); + }); + + const { getCoreRpcUrl: freshGetCoreRpcUrl } = await import('../coreRpcClient'); + const url = await freshGetCoreRpcUrl(); + // stored override should win; invoke should NOT have been called + expect(url).toBe('http://stored-override:4444/rpc'); + expect(vi.mocked(invoke)).not.toHaveBeenCalled(); + }); + + test('in Tauri mode falls back to CORE_RPC_URL when invoke fails and no stored URL', async () => { + vi.doMock('../../utils/configPersistence', () => ({ + getStoredRpcUrl: () => 'http://127.0.0.1:7788/rpc', + })); + vi.mocked(isTauri).mockReturnValue(true); + vi.mocked(invoke).mockRejectedValue(new Error('invoke failed')); + + const { getCoreRpcUrl: freshGetCoreRpcUrl } = await import('../coreRpcClient'); + const url = await freshGetCoreRpcUrl(); + // Should fall back to the default + expect(url).toBe('http://127.0.0.1:7788/rpc'); + }); +}); diff --git a/app/src/services/__tests__/socketService.test.ts b/app/src/services/__tests__/socketService.test.ts new file mode 100644 index 000000000..ad57dd559 --- /dev/null +++ b/app/src/services/__tests__/socketService.test.ts @@ -0,0 +1,155 @@ +/** + * Unit tests for socketService internals — specifically the + * resolveCoreSocketBaseUrl() behaviour that was fixed to consult + * getCoreRpcUrl() (and therefore the user's stored preference) instead of + * calling invoke('core_rpc_url') directly. + * + * We cannot import resolveCoreSocketBaseUrl directly because it is not + * exported. Instead we spy on getCoreRpcUrl to confirm it is called during + * socket connection, and verify the derived base URL strips the /rpc suffix. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock socket.io-client so no real connections are made +vi.mock('socket.io-client', () => ({ + io: vi.fn(() => ({ + connected: false, + disconnected: true, + on: vi.fn(), + onAny: vi.fn(), + once: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + disconnect: vi.fn(), + connect: vi.fn(), + id: 'mock-socket-id', + })), +})); + +// Mock redux store +vi.mock('../../store', () => ({ store: { dispatch: vi.fn() } })); +vi.mock('../../store/socketSlice', () => ({ + setStatusForUser: vi.fn((x: unknown) => x), + setSocketIdForUser: vi.fn((x: unknown) => x), + resetForUser: vi.fn((x: unknown) => x), +})); +vi.mock('../../store/channelConnectionsSlice', () => ({ + upsertChannelConnection: vi.fn((x: unknown) => x), +})); + +// Mock coreState +vi.mock('../../lib/coreState/store', () => ({ + getCoreStateSnapshot: vi.fn(() => ({ snapshot: { sessionToken: null } })), +})); + +// Mock MCP as a class so `new SocketIOMCPTransportImpl(...)` works at runtime. +// Arrow functions cannot be used as constructors, so we wrap in a class here. +class MockMCPTransport {} +vi.mock('../../lib/mcp', () => ({ SocketIOMCPTransportImpl: MockMCPTransport })); + +/** + * Poll `check` up to `maxMs` ms (default 500) in 10 ms increments. + * Resolves when `check()` returns without throwing; rejects on timeout. + * Used instead of `setTimeout(0)` sleeps to deterministically wait for + * the observable side-effect of an async operation. + */ +async function pollUntil(check: () => void, maxMs = 500): Promise { + const deadline = Date.now() + maxMs; + while (true) { + try { + check(); + return; + } catch { + if (Date.now() >= deadline) throw new Error(`pollUntil timed out after ${maxMs}ms`); + await new Promise(r => setTimeout(r, 10)); + } + } +} + +// Hoist getCoreRpcUrl mock so it is available before the module is loaded +const hoisted = vi.hoisted(() => ({ getCoreRpcUrlMock: vi.fn<() => Promise>() })); + +vi.mock('../coreRpcClient', () => ({ + getCoreRpcUrl: hoisted.getCoreRpcUrlMock, + clearCoreRpcUrlCache: vi.fn(), +})); + +describe('socketService — resolveCoreSocketBaseUrl uses getCoreRpcUrl', () => { + beforeEach(() => { + hoisted.getCoreRpcUrlMock.mockReset(); + }); + + it('calls getCoreRpcUrl() when connecting', async () => { + hoisted.getCoreRpcUrlMock.mockResolvedValue('http://127.0.0.1:7788/rpc'); + + // Import after mocks are set up + const { socketService } = await import('../socketService'); + socketService.connect('mock-jwt-token'); + + // Wait until getCoreRpcUrl has actually been invoked (deterministic, no sleep) + await pollUntil(() => expect(hoisted.getCoreRpcUrlMock).toHaveBeenCalled()); + }); + + it('strips /rpc suffix from the resolved RPC URL to derive the socket base', async () => { + const { io } = await import('socket.io-client'); + const ioMock = vi.mocked(io); + ioMock.mockClear(); + + hoisted.getCoreRpcUrlMock.mockResolvedValue('http://127.0.0.1:7788/rpc'); + + const { socketService } = await import('../socketService'); + socketService.connect('mock-jwt-token-2'); + + await pollUntil(() => expect(hoisted.getCoreRpcUrlMock).toHaveBeenCalled()); + + if (ioMock.mock.calls.length > 0) { + const connectedUrl = ioMock.mock.calls[ioMock.mock.calls.length - 1][0]; + expect(connectedUrl).toBe('http://127.0.0.1:7788'); + } else { + // The 1420 guard may have prevented connection — ensure getCoreRpcUrl was still consulted + expect(hoisted.getCoreRpcUrlMock).toHaveBeenCalled(); + } + }); + + it('works when the resolved URL has no /rpc suffix', async () => { + const { io } = await import('socket.io-client'); + const ioMock = vi.mocked(io); + ioMock.mockClear(); + + // Return a base URL without the /rpc suffix + hoisted.getCoreRpcUrlMock.mockResolvedValue('http://127.0.0.1:7788'); + + const { socketService } = await import('../socketService'); + // Disconnect first in case there's a stale socket from a prior test + socketService.disconnect(); + socketService.connect('mock-jwt-token-3'); + + // getCoreRpcUrl must have been consulted (wait deterministically) + await pollUntil(() => expect(hoisted.getCoreRpcUrlMock).toHaveBeenCalled()); + + if (ioMock.mock.calls.length > 0) { + const connectedUrl = ioMock.mock.calls[ioMock.mock.calls.length - 1][0]; + expect(connectedUrl).toBe('http://127.0.0.1:7788'); + } + }); + + it('uses stored custom RPC URL (not static constant) when user has configured one', async () => { + const { io } = await import('socket.io-client'); + const ioMock = vi.mocked(io); + ioMock.mockClear(); + + // Simulate a user-stored custom RPC URL being returned by getCoreRpcUrl + hoisted.getCoreRpcUrlMock.mockResolvedValue('http://custom-core-host:9000/rpc'); + + const { socketService } = await import('../socketService'); + socketService.disconnect(); + socketService.connect('mock-jwt-token-custom'); + + await pollUntil(() => expect(hoisted.getCoreRpcUrlMock).toHaveBeenCalled()); + + if (ioMock.mock.calls.length > 0) { + const connectedUrl = ioMock.mock.calls[ioMock.mock.calls.length - 1][0]; + expect(connectedUrl).toBe('http://custom-core-host:9000'); + } + }); +}); diff --git a/app/src/services/backendUrl.ts b/app/src/services/backendUrl.ts index 28a712bc3..c17084aed 100644 --- a/app/src/services/backendUrl.ts +++ b/app/src/services/backendUrl.ts @@ -5,6 +5,25 @@ import { callCoreRpc } from './coreRpcClient'; let resolvedBackendUrl: string | null = null; let resolvingBackendUrl: Promise | null = null; +/** + * Monotonically-increasing generation counter. Incremented on every + * `clearBackendUrlCache()` call so that any in-flight `getBackendUrl()` + * resolution started before the clear does not repopulate the cache with a + * stale value after the user changes their RPC endpoint. + */ +let backendUrlGeneration = 0; + +/** + * Invalidate the cached backend URL so the next call to getBackendUrl() + * re-derives from the core RPC (Tauri) or web fallback. + * Call this after the user saves a new RPC URL preference so the backend + * URL is recomputed from the updated core endpoint. + */ +export function clearBackendUrlCache(): void { + backendUrlGeneration += 1; + resolvedBackendUrl = null; + resolvingBackendUrl = null; +} function normalizeBaseUrl(url: string): string { return url.trim().replace(/\/+$/, ''); @@ -35,6 +54,7 @@ export async function getBackendUrl(): Promise { return resolvingBackendUrl; } + const generation = backendUrlGeneration; resolvingBackendUrl = (async () => { const response = await callCoreRpc<{ api_url?: string; apiUrl?: string }>({ method: 'openhuman.config_resolve_api_url', @@ -43,10 +63,15 @@ export async function getBackendUrl(): Promise { if (!resolved) { throw new Error('Core returned an empty backend URL'); } - resolvedBackendUrl = normalizeBaseUrl(resolved); - return resolvedBackendUrl; + const normalized = normalizeBaseUrl(resolved); + if (generation === backendUrlGeneration) { + resolvedBackendUrl = normalized; + } + return normalized; })().finally(() => { - resolvingBackendUrl = null; + if (generation === backendUrlGeneration) { + resolvingBackendUrl = null; + } }); return resolvingBackendUrl; diff --git a/app/src/services/socketService.ts b/app/src/services/socketService.ts index c54c1129b..2d5cfd1cf 100644 --- a/app/src/services/socketService.ts +++ b/app/src/services/socketService.ts @@ -1,4 +1,3 @@ -import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core'; import debug from 'debug'; import { io, Socket } from 'socket.io-client'; @@ -8,8 +7,9 @@ import { store } from '../store'; import { upsertChannelConnection } from '../store/channelConnectionsSlice'; import { resetForUser, setSocketIdForUser, setStatusForUser } from '../store/socketSlice'; import type { ChannelAuthMode, ChannelConnectionStatus, ChannelType } from '../types/channels'; -import { CORE_RPC_URL, IS_DEV } from '../utils/config'; +import { IS_DEV } from '../utils/config'; import { createSafeLogData, sanitizeError } from '../utils/sanitize'; +import { getCoreRpcUrl } from './coreRpcClient'; // Socket service logger using debug package // Enable logging by setting DEBUG=socket* in environment or localStorage @@ -27,17 +27,15 @@ function coreSocketBaseFromRpcUrl(rpcUrl: string): string { return trimmed.endsWith('/rpc') ? trimmed.slice(0, -4) : trimmed; } +/** + * Resolve the Socket.IO base URL from the user's stored RPC URL preference. + * Delegates to getCoreRpcUrl() so the stored preference (set on the Welcome + * screen) is always honoured — previously this called invoke('core_rpc_url') + * directly, which ignored the user's stored override. + */ async function resolveCoreSocketBaseUrl(): Promise { - if (!coreIsTauri()) { - return coreSocketBaseFromRpcUrl(CORE_RPC_URL); - } - - try { - const rpcUrl = await invoke('core_rpc_url'); - return coreSocketBaseFromRpcUrl(String(rpcUrl || CORE_RPC_URL)); - } catch { - return coreSocketBaseFromRpcUrl(CORE_RPC_URL); - } + const rpcUrl = await getCoreRpcUrl(); + return coreSocketBaseFromRpcUrl(rpcUrl); } interface JwtPayload { diff --git a/app/src/utils/__tests__/configPersistence.test.ts b/app/src/utils/__tests__/configPersistence.test.ts index ec042f65a..875f62aae 100644 --- a/app/src/utils/__tests__/configPersistence.test.ts +++ b/app/src/utils/__tests__/configPersistence.test.ts @@ -2,7 +2,7 @@ * Unit tests for configPersistence utilities. * Tests URL storage, validation, and normalization. */ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { clearStoredRpcUrl, @@ -144,4 +144,124 @@ describe('configPersistence', () => { expect(getDefaultRpcUrl()).toBe('http://127.0.0.1:7788/rpc'); }); }); + + describe('isValidRpcUrl — edge cases', () => { + it('returns true for localhost with a port', () => { + expect(isValidRpcUrl('http://localhost:7788')).toBe(true); + }); + + it('returns true for a bare IP address URL', () => { + expect(isValidRpcUrl('http://192.168.1.100:7788/rpc')).toBe(true); + }); + + it('returns true for an HTTPS URL', () => { + expect(isValidRpcUrl('https://remote-core.example.com/rpc')).toBe(true); + }); + + it('returns true for a URL with a path segment', () => { + expect(isValidRpcUrl('http://127.0.0.1:7788/rpc')).toBe(true); + }); + + it('returns false for empty string', () => { + expect(isValidRpcUrl('')).toBe(false); + }); + + it('returns false for whitespace-only string', () => { + expect(isValidRpcUrl(' ')).toBe(false); + }); + + it('returns false for a URL without a protocol', () => { + expect(isValidRpcUrl('localhost:7788/rpc')).toBe(false); + expect(isValidRpcUrl('127.0.0.1:7788')).toBe(false); + }); + + it('returns false for a ws:// URL', () => { + expect(isValidRpcUrl('ws://localhost:7788')).toBe(false); + }); + + it('returns false for a ftp:// URL', () => { + expect(isValidRpcUrl('ftp://localhost:7788')).toBe(false); + }); + + it('returns false for a completely malformed string', () => { + expect(isValidRpcUrl('not a url at all')).toBe(false); + }); + + it('returns false for http:// with no host', () => { + expect(isValidRpcUrl('http://')).toBe(false); + }); + }); + + describe('normalizeRpcUrl — edge cases', () => { + it('does not add /rpc suffix when missing (normalizeRpcUrl only strips, not appends)', () => { + expect(normalizeRpcUrl('http://127.0.0.1:7788')).toBe('http://127.0.0.1:7788'); + }); + + it('does not double-add /rpc — leaves existing /rpc alone', () => { + expect(normalizeRpcUrl('http://127.0.0.1:7788/rpc')).toBe('http://127.0.0.1:7788/rpc'); + }); + + it('handles trailing slash after /rpc', () => { + expect(normalizeRpcUrl('http://127.0.0.1:7788/rpc/')).toBe('http://127.0.0.1:7788/rpc'); + }); + + it('handles uppercase protocol casing (trims only, does not lowercase)', () => { + // The normalizer does not lowercase — it just trims slashes and whitespace + expect(normalizeRpcUrl(' HTTP://localhost:7788/rpc ')).toBe('HTTP://localhost:7788/rpc'); + }); + + it('removes multiple trailing slashes', () => { + expect(normalizeRpcUrl('http://127.0.0.1:7788/rpc///')).toBe('http://127.0.0.1:7788/rpc'); + }); + + it('trims leading and trailing whitespace', () => { + expect(normalizeRpcUrl(' http://127.0.0.1:7788/rpc ')).toBe('http://127.0.0.1:7788/rpc'); + }); + }); + + describe('storeRpcUrl + getStoredRpcUrl — round-trip', () => { + it('round-trips an HTTPS URL', () => { + storeRpcUrl('https://remote.example.com/rpc'); + expect(getStoredRpcUrl()).toBe('https://remote.example.com/rpc'); + }); + + it('round-trips a localhost URL with a non-standard port', () => { + storeRpcUrl('http://localhost:12345/rpc'); + expect(getStoredRpcUrl()).toBe('http://localhost:12345/rpc'); + }); + + it('round-trips an IP address URL', () => { + storeRpcUrl('http://10.0.0.1:7788/rpc'); + expect(getStoredRpcUrl()).toBe('http://10.0.0.1:7788/rpc'); + }); + }); + + describe('clearStoredRpcUrl + getStoredRpcUrl', () => { + it('getStoredRpcUrl returns the default after clearStoredRpcUrl', () => { + storeRpcUrl('http://some-host:9999/rpc'); + expect(getStoredRpcUrl()).toBe('http://some-host:9999/rpc'); + + clearStoredRpcUrl(); + expect(getStoredRpcUrl()).toBe('http://127.0.0.1:7788/rpc'); + }); + + it('localStorage key is null after clearStoredRpcUrl', () => { + storeRpcUrl('http://some-host:9999/rpc'); + clearStoredRpcUrl(); + expect(localStorage.getItem('openhuman_core_rpc_url')).toBeNull(); + }); + }); + + describe('getStoredRpcUrl — localStorage unavailable', () => { + it('returns the default URL when localStorage throws', () => { + const getItemSpy = vi.spyOn(localStorage, 'getItem').mockImplementation(() => { + throw new Error('Storage unavailable'); + }); + try { + expect(getStoredRpcUrl()).toBe('http://127.0.0.1:7788/rpc'); + } finally { + getItemSpy.mockRestore(); + } + }); + }); }); diff --git a/app/src/utils/config.ts b/app/src/utils/config.ts index 269b383bc..e1c9df4b1 100644 --- a/app/src/utils/config.ts +++ b/app/src/utils/config.ts @@ -7,6 +7,17 @@ const APP_ENV = (import.meta.env.VITE_OPENHUMAN_APP_ENV as string | undefined) const DEFAULT_BACKEND_URL = APP_ENV === 'staging' ? 'https://staging-api.tinyhumans.ai' : 'https://api.tinyhumans.ai'; +/** + * Build-time fallback for the Core JSON-RPC endpoint URL. + * + * **Not runtime-authoritative.** At runtime `getCoreRpcUrl()` (in + * `services/coreRpcClient.ts`) is the source of truth: it first checks for a + * URL stored by the user via the Welcome screen (`configPersistence`), then + * falls back to this constant. Never read this constant directly from product + * code that needs the live endpoint — call `getCoreRpcUrl()` instead. + * + * Override at build time via `VITE_OPENHUMAN_CORE_RPC_URL`. + */ export const CORE_RPC_URL = import.meta.env.VITE_OPENHUMAN_CORE_RPC_URL || 'http://127.0.0.1:7788/rpc'; @@ -70,7 +81,18 @@ export const SKILLS_GITHUB_REPO = /** Sentry DSN for error reporting. Leave blank to disable. */ export const SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN as string | undefined; -/** Backend API URL (web fallback when core RPC is unavailable). */ +/** + * Build-time fallback for the backend API base URL. + * + * **Not runtime-authoritative in Tauri.** In the desktop app, `getBackendUrl()` + * (in `services/backendUrl.ts`) asks the core sidecar for the live API URL via + * `openhuman.config_resolve_api_url`. If that call fails or returns an empty + * URL, `getBackendUrl()` **throws** — it does not fall back to this constant. + * This constant is only used in web/non-Tauri mode (where the sidecar is not + * present). + * + * Override at build time via `VITE_BACKEND_URL`. + */ export const BACKEND_URL = (import.meta.env.VITE_BACKEND_URL as string | undefined)?.trim() || DEFAULT_BACKEND_URL; diff --git a/app/src/utils/configPersistence.ts b/app/src/utils/configPersistence.ts index 66a13456e..d2369b756 100644 --- a/app/src/utils/configPersistence.ts +++ b/app/src/utils/configPersistence.ts @@ -105,14 +105,3 @@ export function normalizeRpcUrl(url: string): string { export function getDefaultRpcUrl(): string { return CORE_RPC_URL; } - -/** - * Build the full RPC endpoint URL from a base URL. - * - * @param baseUrl - The base URL (e.g., 'http://127.0.0.1:7788') - * @returns The full RPC endpoint URL - */ -export function buildRpcEndpoint(baseUrl: string): string { - const normalized = normalizeRpcUrl(baseUrl); - return normalized.endsWith('/rpc') ? normalized : `${normalized}/rpc`; -}