feat(config): RPC bootstrap hardening, socket alignment, and exhaustive tests (#1313)

This commit is contained in:
Mega Mind
2026-05-06 16:45:42 -07:00
committed by GitHub
parent abec9921c4
commit 4e9f2a8f77
10 changed files with 974 additions and 34 deletions
+4
View File
@@ -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}`);
}
+450 -1
View File
@@ -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(<Welcome />);
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(<Welcome />);
expect(
screen.getByRole('button', { name: 'Configure RPC URL (Advanced)' })
).toBeInTheDocument();
});
it('clicking the toggle opens the advanced panel', () => {
render(<Welcome />);
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(<Welcome />);
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(<Welcome />);
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(<Welcome />);
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(<Welcome />);
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(<Welcome />);
openPanel();
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(clearCoreRpcUrlCache).toHaveBeenCalledTimes(1);
});
it('clicking Save calls clearBackendUrlCache()', () => {
render(<Welcome />);
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(<Welcome />);
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(<Welcome />);
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(<Welcome />);
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(<Welcome />);
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(<Welcome />);
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(<Welcome />);
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(<Welcome />);
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(<Welcome />);
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(<Welcome />);
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<Response>(resolve => {
resolveProbe = resolve;
})
);
render(<Welcome />);
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(<Welcome />);
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(<Welcome />);
openPanel();
fireEvent.click(screen.getByRole('button', { name: 'Reset to Default' }));
expect(clearStoredRpcUrl).toHaveBeenCalledTimes(1);
});
it('clicking Reset calls clearCoreRpcUrlCache()', () => {
render(<Welcome />);
openPanel();
fireEvent.click(screen.getByRole('button', { name: 'Reset to Default' }));
expect(clearCoreRpcUrlCache).toHaveBeenCalledTimes(1);
});
it('clicking Reset calls clearBackendUrlCache()', () => {
render(<Welcome />);
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(<Welcome />);
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(<Welcome />);
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(<Welcome />);
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(<Welcome />);
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(<Welcome />);
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(<Welcome />);
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(<Welcome />);
expect(screen.queryByRole('button', { name: 'google' })).not.toBeInTheDocument();
});
});
+69 -5
View File
@@ -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');
});
});
@@ -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');
});
});
@@ -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<void> {
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<string>>() }));
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');
}
});
});
+28 -3
View File
@@ -5,6 +5,25 @@ import { callCoreRpc } from './coreRpcClient';
let resolvedBackendUrl: string | null = null;
let resolvingBackendUrl: Promise<string> | 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<string> {
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<string> {
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;
+10 -12
View File
@@ -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<string> {
if (!coreIsTauri()) {
return coreSocketBaseFromRpcUrl(CORE_RPC_URL);
}
try {
const rpcUrl = await invoke<string>('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 {
@@ -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();
}
});
});
});
+23 -1
View File
@@ -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;
-11
View File
@@ -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`;
}