Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
443 changes: 443 additions & 0 deletions app/src/components/settings/panels/CoreConnectionPanel.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
/**
* Tests for CoreConnectionPanel (GH-4396) — the first-class Settings surface
* that promotes cloud-mode remote-core config and adds a live status
* indicator. Covers: live status rendering per mode, the remote toggle
* revealing the URL/token form, and the save flow persisting + dispatching +
* restarting.
*/
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';

import { renderWithProviders } from '../../../../test/test-utils';

const hoisted = vi.hoisted(() => ({
testCoreRpcConnection: vi.fn(),
clearCoreRpcUrlCache: vi.fn(),
clearCoreRpcTokenCache: vi.fn(),
restartApp: vi.fn(),
}));

vi.mock('../../../../services/coreRpcClient', () => ({
testCoreRpcConnection: hoisted.testCoreRpcConnection,
clearCoreRpcUrlCache: hoisted.clearCoreRpcUrlCache,
clearCoreRpcTokenCache: hoisted.clearCoreRpcTokenCache,
}));

vi.mock('../../../../utils/tauriCommands/core', () => ({ restartApp: hoisted.restartApp }));

function okResponse() {
return { ok: true, status: 200, json: async () => ({ jsonrpc: '2.0', id: 1, result: {} }) };
}

/** A Response-shaped stub for an arbitrary HTTP status. */
function statusResponse(status: number) {
return { ok: status >= 200 && status < 300, status, json: async () => ({}) };
}

const CLOUD_STATE = {
coreMode: { mode: { kind: 'cloud', url: 'https://core.example.com/rpc', token: 'tok-123456' } },
};

async function importPanel() {
const mod = await import('../CoreConnectionPanel');
return mod.default;
}

describe('CoreConnectionPanel', () => {
beforeEach(() => {
vi.resetModules();
hoisted.testCoreRpcConnection.mockReset();
hoisted.clearCoreRpcUrlCache.mockReset();
hoisted.clearCoreRpcTokenCache.mockReset();
hoisted.restartApp.mockReset();
hoisted.restartApp.mockResolvedValue(undefined);
localStorage.clear();
});

test('local mode shows the local connected status once the live check passes', async () => {
hoisted.testCoreRpcConnection.mockResolvedValue(okResponse());
const Panel = await importPanel();
renderWithProviders(<Panel />, { preloadedState: { coreMode: { mode: { kind: 'local' } } } });

await waitFor(() => expect(screen.getByText('Connected to local core')).toBeInTheDocument());
// Remote toggle is off in local mode → no URL field.
expect(screen.queryByLabelText(/Runtime URL/i)).not.toBeInTheDocument();
});

test('cloud mode surfaces the remote URL and remote connected status', async () => {
hoisted.testCoreRpcConnection.mockResolvedValue(okResponse());
const Panel = await importPanel();
renderWithProviders(<Panel />, {
preloadedState: {
coreMode: {
mode: { kind: 'cloud', url: 'https://core.example.com/rpc', token: 'tok-123456' },
},
},
});

await waitFor(() => expect(screen.getByText('Connected to remote core')).toBeInTheDocument());
// Toggle on → the URL field is pre-filled with the persisted value.
expect(screen.getByDisplayValue('https://core.example.com/rpc')).toBeInTheDocument();
});

test('unreachable core surfaces the failure status', async () => {
hoisted.testCoreRpcConnection.mockRejectedValue(new Error('boom'));
const Panel = await importPanel();
renderWithProviders(<Panel />, { preloadedState: { coreMode: { mode: { kind: 'local' } } } });

await waitFor(() => expect(screen.getByText(/Cannot reach the core/i)).toBeInTheDocument());
});

test('switching to remote core persists, dispatches, and restarts', async () => {
hoisted.testCoreRpcConnection.mockResolvedValue(okResponse());
const Panel = await importPanel();
const { store } = renderWithProviders(<Panel />, {
preloadedState: { coreMode: { mode: { kind: 'local' } } },
});

await waitFor(() => expect(screen.getByText('Connected to local core')).toBeInTheDocument());

// Flip the remote toggle on to reveal the form.
fireEvent.click(screen.getByTestId('core-use-remote-toggle'));

fireEvent.change(screen.getByLabelText(/Runtime URL/i), {
target: { value: 'https://core.example.com/rpc' },
});
fireEvent.change(screen.getByLabelText(/Auth Token/i), {
target: { value: 'remote-token-xyz' },
});

fireEvent.click(screen.getByTestId('core-save-btn'));

await waitFor(() => expect(hoisted.restartApp).toHaveBeenCalledTimes(1));

// Redux is now in cloud mode with the typed URL + token.
const mode = store.getState().coreMode.mode as { kind: string; url?: string; token?: string };
expect(mode.kind).toBe('cloud');
expect(mode.url).toBe('https://core.example.com/rpc');
expect(mode.token).toBe('remote-token-xyz');

// Persisted synchronously to localStorage (mirrors the cloud-mode picker).
expect(localStorage.getItem('openhuman_core_mode')).toBe('cloud');
expect(localStorage.getItem('openhuman_core_rpc_url')).toBe('https://core.example.com/rpc');
expect(localStorage.getItem('openhuman_core_rpc_token')).toBe('remote-token-xyz');

// Caches cleared so the new endpoint takes effect on restart.
expect(hoisted.clearCoreRpcUrlCache).toHaveBeenCalled();
expect(hoisted.clearCoreRpcTokenCache).toHaveBeenCalled();
});

test('a rejected token surfaces the token-rejected live status', async () => {
hoisted.testCoreRpcConnection.mockResolvedValue(statusResponse(401));
const Panel = await importPanel();
renderWithProviders(<Panel />, { preloadedState: CLOUD_STATE });

await waitFor(() => expect(screen.getByText(/the token was rejected/i)).toBeInTheDocument());
});

test('a non-ok response surfaces the unreachable live status with the HTTP code', async () => {
hoisted.testCoreRpcConnection.mockResolvedValue(statusResponse(503));
const Panel = await importPanel();
renderWithProviders(<Panel />, { preloadedState: CLOUD_STATE });

await waitFor(() =>
expect(screen.getByText(/Cannot reach the core — HTTP 503/i)).toBeInTheDocument()
);
});

test('Test connection reports success for the typed remote inputs', async () => {
hoisted.testCoreRpcConnection.mockResolvedValue(okResponse());
const Panel = await importPanel();
renderWithProviders(<Panel />, { preloadedState: CLOUD_STATE });

// Wait for the mount live-check to settle first.
await waitFor(() => expect(screen.getByText('Connected to remote core')).toBeInTheDocument());

fireEvent.click(screen.getByText('Test Connection'));
await waitFor(() => expect(screen.getByTestId('core-test-ok')).toBeInTheDocument());
});

test('Test connection reports an auth failure', async () => {
hoisted.testCoreRpcConnection.mockResolvedValue(statusResponse(403));
const Panel = await importPanel();
renderWithProviders(<Panel />, { preloadedState: CLOUD_STATE });

fireEvent.click(screen.getByText('Test Connection'));
await waitFor(() => expect(screen.getByTestId('core-test-auth')).toBeInTheDocument());
});

test('Test connection reports an unreachable endpoint', async () => {
hoisted.testCoreRpcConnection.mockRejectedValue(new Error('network down'));
const Panel = await importPanel();
renderWithProviders(<Panel />, { preloadedState: CLOUD_STATE });

fireEvent.click(screen.getByText('Test Connection'));
await waitFor(() => expect(screen.getByTestId('core-test-unreachable')).toBeInTheDocument());
});

test('validation blocks the form when the URL is empty and when the token is missing', async () => {
hoisted.testCoreRpcConnection.mockResolvedValue(okResponse());
const Panel = await importPanel();
renderWithProviders(<Panel />, { preloadedState: { coreMode: { mode: { kind: 'local' } } } });

await waitFor(() => expect(screen.getByText('Connected to local core')).toBeInTheDocument());
// Reveal the empty remote form.
fireEvent.click(screen.getByTestId('core-use-remote-toggle'));

// Empty URL → invalid-URL error.
fireEvent.click(screen.getByText('Test Connection'));
await waitFor(() => expect(screen.getByText(/enter a runtime URL/i)).toBeInTheDocument());

// Valid URL but empty token → token-required error.
fireEvent.change(screen.getByLabelText(/Runtime URL/i), {
target: { value: 'https://core.example.com/rpc' },
});
fireEvent.click(screen.getByText('Test Connection'));
await waitFor(() =>
expect(screen.getByText(/need an auth token to connect/i)).toBeInTheDocument()
);

// The typed connection was never attempted (validation short-circuits).
expect(hoisted.testCoreRpcConnection).not.toHaveBeenCalledWith(
'https://core.example.com/rpc',
''
);
});

test('switching from remote back to local clears persistence, dispatches, and restarts', async () => {
hoisted.testCoreRpcConnection.mockResolvedValue(okResponse());
// Seed persisted cloud values so we can assert they are cleared.
localStorage.setItem('openhuman_core_mode', 'cloud');
localStorage.setItem('openhuman_core_rpc_url', 'https://core.example.com/rpc');
localStorage.setItem('openhuman_core_rpc_token', 'tok-123456');

const Panel = await importPanel();
const { store } = renderWithProviders(<Panel />, { preloadedState: CLOUD_STATE });

await waitFor(() => expect(screen.getByText('Connected to remote core')).toBeInTheDocument());

// Flip remote off → local, then save.
fireEvent.click(screen.getByTestId('core-use-remote-toggle'));
fireEvent.click(screen.getByTestId('core-save-btn'));

await waitFor(() => expect(hoisted.restartApp).toHaveBeenCalledTimes(1));

expect(store.getState().coreMode.mode.kind).toBe('local');
expect(localStorage.getItem('openhuman_core_mode')).toBe('local');
expect(localStorage.getItem('openhuman_core_rpc_url')).toBeNull();
expect(localStorage.getItem('openhuman_core_rpc_token')).toBeNull();
});
});
4 changes: 4 additions & 0 deletions app/src/components/settings/settingsRouteElements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import AutocompletePanel from './panels/AutocompletePanel';
import BillingPanel from './panels/BillingPanel';
import CompanionPanel from './panels/CompanionPanel';
import ComposioTriagePanel from './panels/ComposioTriagePanel';
import CoreConnectionPanel from './panels/CoreConnectionPanel';
import CronJobsPanel from './panels/CronJobsPanel';
import DesktopAgentPanel from './panels/DesktopAgentPanel';
import DeveloperOptionsPanel from './panels/DeveloperOptionsPanel';
Expand Down Expand Up @@ -154,6 +155,9 @@ export function settingsRouteElements(): ReactNode {
<Route path="autocomplete" element={wrapSettingsPage(<AutocompletePanel />)} />

{/* ── System ──────────────────────────────────────────────── */}
{/* Core connection — promotes cloud-mode remote-core config into a
first-class setting with a live status indicator (GH-4396). */}
<Route path="core" element={wrapSettingsPage(<CoreConnectionPanel />)} />
<Route path="keyboard-shortcuts" element={wrapSettingsPage(<KeyboardShortcutsPanel />)} />
<Route path="developer-options" element={wrapSettingsPage(<DeveloperOptionsPanel />)} />
<Route path="token-usage" element={wrapSettingsPage(<TokenUsagePanel />)} />
Expand Down
24 changes: 24 additions & 0 deletions app/src/components/settings/settingsRouteRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,30 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [
navGroup: 'general',
navOrder: 98,
},
{
// Core connection — promotes cloud-mode remote-core config (persisted
// RPC URL + token) into a first-class setting plus a live status
// indicator (GH-4396). Sits just above About in General.
id: 'core',
titleKey: 'settings.core.title',
descriptionKey: 'settings.core.menuDesc',
section: 'home',
searchKeywords: [
'core',
'remote',
'rpc',
'url',
'token',
'cloud',
'local',
'connection',
'server',
'attach',
'self-hosted',
],
navGroup: 'general',
navOrder: 97,
},
{
id: 'about',
titleKey: 'settings.about',
Expand Down
14 changes: 14 additions & 0 deletions app/src/lib/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@ import type { TranslationMap } from './types';
// Arabic (العربية) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
// Core connection panel (GH-4396)
'settings.core.title': 'اتصال النواة',
'settings.core.menuDesc': 'استخدم النواة المحلية المدمجة أو اتصل بنواة بعيدة.',
'settings.core.useRemoteToggle': 'استخدام نواة بعيدة',
'settings.core.useRemoteToggleDesc':
'الاتصال بنواة بعيدة عبر HTTP بدلاً من النواة المحلية المدمجة.',
'settings.core.statusConnectedRemote': 'متصل بالنواة البعيدة',
'settings.core.statusConnectedLocal': 'متصل بالنواة المحلية',
'settings.core.statusChecking': 'جارٍ التحقق من الاتصال…',
'settings.core.statusAuthFailed': 'يمكن الوصول إليه، لكن تم رفض الرمز',
'settings.core.statusUnreachable': 'تعذّر الوصول إلى النواة',
'settings.core.recheck': 'إعادة التحقق',
'settings.core.save': 'حفظ وإعادة التشغيل',
'settings.core.applyRestartNote': 'يؤدي الحفظ إلى إعادة تشغيل OpenHuman لإعادة الاتصال.',
// Cross-host vault (#4278)
'crossHostVault.title': 'الخزنة موجودة على مضيف النواة.',
'crossHostVault.message':
Expand Down
15 changes: 15 additions & 0 deletions app/src/lib/i18n/bn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@ import type { TranslationMap } from './types';
// Bengali (বাংলা) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
// Core connection panel (GH-4396)
'settings.core.title': 'কোর সংযোগ',
'settings.core.menuDesc':
'অন্তর্নির্মিত স্থানীয় কোর ব্যবহার করুন বা একটি রিমোট কোরে সংযোগ করুন।',
'settings.core.useRemoteToggle': 'রিমোট কোর ব্যবহার করুন',
'settings.core.useRemoteToggleDesc':
'অন্তর্নির্মিত স্থানীয় কোরের পরিবর্তে HTTP-এর মাধ্যমে একটি রিমোট কোরে সংযোগ করুন।',
'settings.core.statusConnectedRemote': 'রিমোট কোরে সংযুক্ত',
'settings.core.statusConnectedLocal': 'স্থানীয় কোরে সংযুক্ত',
'settings.core.statusChecking': 'সংযোগ পরীক্ষা করা হচ্ছে…',
'settings.core.statusAuthFailed': 'পৌঁছানো যায়, তবে টোকেন প্রত্যাখ্যাত হয়েছে',
'settings.core.statusUnreachable': 'কোরে পৌঁছানো যাচ্ছে না',
'settings.core.recheck': 'পুনরায় পরীক্ষা করুন',
'settings.core.save': 'সংরক্ষণ করে পুনরায় চালু করুন',
'settings.core.applyRestartNote': 'সংরক্ষণ করলে পুনরায় সংযোগের জন্য OpenHuman পুনরায় চালু হয়।',
// Cross-host vault (#4278)
'crossHostVault.title': 'ভল্টটি কোর হোস্টে রয়েছে।',
'crossHostVault.message':
Expand Down
16 changes: 16 additions & 0 deletions app/src/lib/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@ import type { TranslationMap } from './types';
// German (Deutsch) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
// Core connection panel (GH-4396)
'settings.core.title': 'Core-Verbindung',
'settings.core.menuDesc':
'Den integrierten lokalen Core verwenden oder mit einem Remote-Core verbinden.',
'settings.core.useRemoteToggle': 'Remote-Core verwenden',
'settings.core.useRemoteToggleDesc':
'Über HTTP mit einem Remote-Core verbinden statt den integrierten lokalen Core zu nutzen.',
'settings.core.statusConnectedRemote': 'Mit Remote-Core verbunden',
'settings.core.statusConnectedLocal': 'Mit lokalem Core verbunden',
'settings.core.statusChecking': 'Verbindung wird geprüft…',
'settings.core.statusAuthFailed': 'Erreichbar, aber das Token wurde abgelehnt',
'settings.core.statusUnreachable': 'Core nicht erreichbar',
'settings.core.recheck': 'Erneut prüfen',
'settings.core.save': 'Speichern & neu starten',
'settings.core.applyRestartNote':
'Beim Speichern startet OpenHuman neu, um die Verbindung herzustellen.',
// Cross-host vault (#4278)
'crossHostVault.title': 'Der Vault liegt auf dem Core-Host.',
'crossHostVault.message':
Expand Down
14 changes: 14 additions & 0 deletions app/src/lib/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1479,6 +1479,20 @@ const en: TranslationMap = {
'Spawned in-process by the Tauri shell on app launch. The port is chosen at startup, so this URL changes between launches.',
'settings.about.connectionHelperCloud':
'Connected to a remote core. Change this in BootCheck or the cloud mode picker.',
// Core connection panel (GH-4396)
'settings.core.title': 'Core connection',
'settings.core.menuDesc': 'Use the built-in local core or connect to a remote core.',
'settings.core.useRemoteToggle': 'Use remote core',
'settings.core.useRemoteToggleDesc':
'Connect to a remote core over HTTP instead of the built-in local core.',
'settings.core.statusConnectedRemote': 'Connected to remote core',
'settings.core.statusConnectedLocal': 'Connected to local core',
'settings.core.statusChecking': 'Checking connection…',
'settings.core.statusAuthFailed': 'Reachable, but the token was rejected',
'settings.core.statusUnreachable': 'Cannot reach the core',
'settings.core.recheck': 'Recheck',
'settings.core.save': 'Save & restart',
'settings.core.applyRestartNote': 'Saving restarts OpenHuman to reconnect.',
'settings.heartbeat.title': 'Heartbeat & loops',
'settings.usage.title': 'Usage & Limits',
'settings.usage.menuDesc': 'Costs, token usage, budgets, and background activity',
Expand Down
14 changes: 14 additions & 0 deletions app/src/lib/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@ import type { TranslationMap } from './types';
// Spanish (Español) translations. Keys mirror en.ts; missing/
// English-identical values fall back to English via I18nContext.resolveEn().
const messages: TranslationMap = {
// Core connection panel (GH-4396)
'settings.core.title': 'Conexión del core',
'settings.core.menuDesc': 'Usa el core local integrado o conéctate a un core remoto.',
'settings.core.useRemoteToggle': 'Usar core remoto',
'settings.core.useRemoteToggleDesc':
'Conéctate a un core remoto por HTTP en lugar del core local integrado.',
'settings.core.statusConnectedRemote': 'Conectado al core remoto',
'settings.core.statusConnectedLocal': 'Conectado al core local',
'settings.core.statusChecking': 'Comprobando la conexión…',
'settings.core.statusAuthFailed': 'Accesible, pero el token fue rechazado',
'settings.core.statusUnreachable': 'No se puede conectar con el core',
'settings.core.recheck': 'Volver a comprobar',
'settings.core.save': 'Guardar y reiniciar',
'settings.core.applyRestartNote': 'Al guardar, OpenHuman se reinicia para volver a conectarse.',
// Cross-host vault (#4278)
'crossHostVault.title': 'El vault está en el host del core.',
'crossHostVault.message':
Expand Down
Loading
Loading