test: add cross-stack coverage for core and tauri flows (#130)

* Add unit tests for Mnemonic page

- Introduced comprehensive tests for the Mnemonic page, covering initial render, copy to clipboard functionality, confirmation checkbox behavior, and mode switching between generate and import.
- Validated user interactions, including input handling and button states, ensuring robust functionality and user experience.
- Enhanced test coverage for various scenarios, including validation of mnemonic phrases and loading states during operations.

* test: add cross-stack test coverage for core and tauri flows

Add focused Rust and frontend tests for core process startup behavior, CLI argument parsing, JSON-RPC error handling, and Tauri command/RPC mapping paths to improve confidence for issue #57.

Closes #57

Made-with: Cursor

* refactor(tests): streamline test code and improve readability

Consolidate mock imports and simplify function calls in coreRpcClient tests. Adjust formatting in Rust core_process and CLI tests for better clarity. Update mnemonic test assertions for improved accuracy.

Made-with: Cursor
This commit is contained in:
Mega Mind
2026-04-01 00:25:04 +05:30
committed by GitHub
parent 4d3b63857f
commit 52b59f62d7
7 changed files with 992 additions and 5 deletions
+85 -3
View File
@@ -258,7 +258,8 @@ pub fn default_core_bin() -> Option<PathBuf> {
continue;
};
#[cfg(windows)]
let matches = file_name.starts_with("openhuman-core-") && file_name.ends_with(".exe");
let matches =
file_name.starts_with("openhuman-core-") && file_name.ends_with(".exe");
#[cfg(not(windows))]
let matches = file_name.starts_with("openhuman-core-");
if matches {
@@ -321,8 +322,8 @@ pub fn default_core_bin() -> Option<PathBuf> {
let matches = (file_name.starts_with("openhuman-core-") && file_name.ends_with(".exe"))
|| (file_name.starts_with("openhuman-core-") && file_name.ends_with(".exe"));
#[cfg(not(windows))]
let matches =
file_name.starts_with("openhuman-core-") || file_name.starts_with("openhuman-core-");
let matches = file_name.starts_with("openhuman-core-")
|| file_name.starts_with("openhuman-core-");
if matches && !same_executable_path(&path, &exe) {
return Some(path);
@@ -332,3 +333,84 @@ pub fn default_core_bin() -> Option<PathBuf> {
None
}
#[cfg(test)]
mod tests {
use super::{
default_core_port, default_core_run_mode, same_executable_path, CoreProcessHandle,
CoreRunMode,
};
struct EnvGuard {
key: &'static str,
old: Option<String>,
}
impl EnvGuard {
fn set(key: &'static str, value: &str) -> Self {
let old = std::env::var(key).ok();
std::env::set_var(key, value);
Self { key, old }
}
fn unset(key: &'static str) -> Self {
let old = std::env::var(key).ok();
std::env::remove_var(key);
Self { key, old }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
if let Some(old) = &self.old {
std::env::set_var(self.key, old);
} else {
std::env::remove_var(self.key);
}
}
}
#[test]
fn default_core_run_mode_env_parsing() {
let _guard = EnvGuard::set("OPENHUMAN_CORE_RUN_MODE", "in-process");
assert_eq!(default_core_run_mode(false), CoreRunMode::InProcess);
let _guard = EnvGuard::set("OPENHUMAN_CORE_RUN_MODE", "sidecar");
assert_eq!(default_core_run_mode(false), CoreRunMode::ChildProcess);
}
#[test]
fn default_core_port_env_and_fallback() {
let _unset = EnvGuard::unset("OPENHUMAN_CORE_PORT");
assert_eq!(default_core_port(), 7788);
let _set = EnvGuard::set("OPENHUMAN_CORE_PORT", "8899");
assert_eq!(default_core_port(), 8899);
}
#[test]
fn same_executable_path_handles_equal_and_non_equal_paths() {
let current = std::env::current_exe().expect("current exe");
assert!(same_executable_path(&current, &current));
let different = current.with_file_name("definitely-not-the-current-exe");
assert!(!same_executable_path(&current, &different));
}
#[test]
fn ensure_running_returns_ok_when_rpc_port_already_open() {
let rt = tokio::runtime::Runtime::new().expect("runtime");
let result = rt.block_on(async {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test listener");
let port = listener.local_addr().expect("local addr").port();
let handle = CoreProcessHandle::new(port, None, CoreRunMode::ChildProcess);
handle.ensure_running().await
});
assert!(
result.is_ok(),
"ensure_running should fast-path: {result:?}"
);
}
}
+7 -1
View File
@@ -41,7 +41,13 @@ fn show_main_window(app: &AppHandle) {
fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
log::info!("[tray] setting up tray icon");
let show_item = MenuItem::with_id(app, "tray_show_window", "Open OpenHuman", true, None::<&str>)?;
let show_item = MenuItem::with_id(
app,
"tray_show_window",
"Open OpenHuman",
true,
None::<&str>,
)?;
let quit_item = MenuItem::with_id(app, "tray_quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { dispatchLocalAiMethod } from '../../lib/ai/localCoreAiMemory';
import { callCoreRpc } from '../coreRpcClient';
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn(() => false) }));
vi.mock('../../lib/ai/localCoreAiMemory', () => ({
dispatchLocalAiMethod: vi.fn(async (_method: string) => ({ source: 'local-ai' })),
}));
describe('coreRpcClient', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('fetch', vi.fn());
});
test('normalizes legacy auth methods from dotted to underscored', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ jsonrpc: '2.0', id: 1, result: { ok: true } }),
} as Response);
await callCoreRpc({ method: 'openhuman.auth.get_state' });
expect(fetchMock).toHaveBeenCalledTimes(1);
const requestInit = fetchMock.mock.calls[0][1] as RequestInit;
const body = JSON.parse(String(requestInit.body));
expect(body.method).toBe('openhuman.auth_get_state');
});
test('maps accessibility prefix to screen intelligence prefix', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ jsonrpc: '2.0', id: 2, result: { accepted: true } }),
} as Response);
await callCoreRpc({ method: 'openhuman.accessibility_status' });
const requestInit = fetchMock.mock.calls[0][1] as RequestInit;
const body = JSON.parse(String(requestInit.body));
expect(body.method).toBe('openhuman.screen_intelligence_status');
});
test('throws clean error when JSON-RPC error payload is returned', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
jsonrpc: '2.0',
id: 3,
error: { code: -32000, message: 'boom from core' },
}),
} as Response);
await expect(callCoreRpc({ method: 'openhuman.config_get' })).rejects.toThrow('boom from core');
});
test('throws on non-ok HTTP response', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValueOnce({
ok: false,
status: 503,
statusText: 'Service Unavailable',
text: async () => 'temporarily unavailable',
} as Response);
await expect(callCoreRpc({ method: 'openhuman.config_get' })).rejects.toThrow(
'Core RPC HTTP 503: temporarily unavailable'
);
});
test('routes ai methods to local dispatch without HTTP', async () => {
const localDispatchMock = vi.mocked(dispatchLocalAiMethod);
localDispatchMock.mockResolvedValueOnce({ state: 'ready' });
const result = await callCoreRpc<{ state: string }>({ method: 'ai.get_config', params: {} });
expect(localDispatchMock).toHaveBeenCalledWith('ai.get_config', {});
expect(fetch).not.toHaveBeenCalled();
expect(result).toEqual({ state: 'ready' });
});
});
@@ -0,0 +1,61 @@
import { isTauri } from '@tauri-apps/api/core';
import { beforeEach, describe, expect, type Mock, test, vi } from 'vitest';
import { callCoreRpc } from '../../services/coreRpcClient';
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn() }));
vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
describe('tauriCommands', () => {
const mockIsTauri = isTauri as Mock;
const mockCallCoreRpc = callCoreRpc as Mock;
let getAuthState: typeof import('../tauriCommands').getAuthState;
let storeSession: typeof import('../tauriCommands').storeSession;
let openhumanLocalAiStatus: typeof import('../tauriCommands').openhumanLocalAiStatus;
let openhumanServiceStatus: typeof import('../tauriCommands').openhumanServiceStatus;
beforeEach(async () => {
vi.clearAllMocks();
mockIsTauri.mockReturnValue(true);
const actual = await vi.importActual<typeof import('../tauriCommands')>('../tauriCommands');
getAuthState = actual.getAuthState;
storeSession = actual.storeSession;
openhumanLocalAiStatus = actual.openhumanLocalAiStatus;
openhumanServiceStatus = actual.openhumanServiceStatus;
});
test('getAuthState maps result shape from core response', async () => {
mockCallCoreRpc.mockResolvedValueOnce({
result: { isAuthenticated: true, user: { id: 'u1' } },
});
const response = await getAuthState();
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.auth.get_state' });
expect(response).toEqual({ is_authenticated: true, user: { id: 'u1' } });
});
test('storeSession calls expected RPC method and params', async () => {
await storeSession('jwt-token', { id: 'u1' });
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.auth.store_session',
params: { token: 'jwt-token', user: { id: 'u1' } },
});
});
test('openhumanLocalAiStatus returns upgrade hint on unknown method', async () => {
mockCallCoreRpc.mockRejectedValueOnce(new Error('unknown method: openhuman.local_ai_status'));
await expect(openhumanLocalAiStatus()).rejects.toThrow(
'Local model runtime is unavailable in this core build. Restart app after updating to the latest build.'
);
});
test('openhumanServiceStatus throws when not running in Tauri', async () => {
mockIsTauri.mockReturnValue(false);
await expect(openhumanServiceStatus()).rejects.toThrow('Not running in Tauri');
expect(mockCallCoreRpc).not.toHaveBeenCalled();
});
});
+670
View File
@@ -0,0 +1,670 @@
/// <reference types="@testing-library/jest-dom/vitest" />
/**
* Tests for the Mnemonic page.
*
* Coverage areas:
* - Initial render: generate mode UI, word grid, buttons
* - Copy to clipboard (success + fallback paths)
* - Confirmation checkbox gates the Continue button
* - Mode switch: generate ↔ import, state resets on switch
* - Import mode: word input, auto-advance, backspace navigation, paste
* - Validation: incomplete phrase, invalid phrase, valid phrase
* - handleContinue — generate mode: happy path, no user, crypto error
* - handleContinue — import mode: happy path, validation failure, no user
* - Loading state during continue
* - Navigation to /home on success
* - Redux dispatch of setEncryptionKeyForUser
*/
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import Mnemonic from '../src/pages/Mnemonic';
import { renderWithProviders } from '../src/test/test-utils';
import type { User } from '../src/types/api';
// ---------------------------------------------------------------------------
// Module mocks
// ---------------------------------------------------------------------------
const FIXED_MNEMONIC =
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon ' +
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art';
const {
mockGenerateMnemonicPhrase,
mockValidateMnemonicPhrase,
mockDeriveAesKey,
mockDeriveEvm,
mockSetWalletAddress,
} = vi.hoisted(() => ({
mockGenerateMnemonicPhrase: vi.fn(() => FIXED_MNEMONIC),
mockValidateMnemonicPhrase: vi.fn(() => true),
mockDeriveAesKey: vi.fn(() => 'aes-key-hex'),
mockDeriveEvm: vi.fn(() => '0xDeAdBeEf'),
mockSetWalletAddress: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../src/utils/cryptoKeys', () => ({
MNEMONIC_GENERATE_WORD_COUNT: 24,
generateMnemonicPhrase: mockGenerateMnemonicPhrase,
validateMnemonicPhrase: mockValidateMnemonicPhrase,
deriveAesKeyFromMnemonic: mockDeriveAesKey,
deriveEvmAddressFromMnemonic: mockDeriveEvm,
}));
vi.mock('../src/lib/skills/manager', () => ({
skillManager: { setWalletAddress: mockSetWalletAddress },
}));
// LottieAnimation makes network calls; stub it out
vi.mock('../src/components/LottieAnimation', () => ({
default: () => <div data-testid="lottie" />,
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const WORD_COUNT = 24;
const FIXED_WORDS = FIXED_MNEMONIC.split(' '); // 24 words
/** User with a valid _id so the "user not loaded" guard passes. */
const mockUser: Partial<User> = { _id: 'user-123', username: 'tester' };
/** Render with a user already in the store. */
const renderWithUser = () =>
renderWithProviders(<Mnemonic />, {
preloadedState: { user: { user: mockUser as User, loading: false, error: null } },
});
/** Render without a user in the store (unauthenticated). */
const renderWithoutUser = () => renderWithProviders(<Mnemonic />);
/** Switch to import mode. */
const switchToImport = () => fireEvent.click(screen.getByText('I already have a recovery phrase'));
/** Fill all 24 import inputs with the words from `phrase`. */
const fillAllImportWords = (phrase = FIXED_MNEMONIC) => {
const words = phrase.split(' ');
const inputs = screen.getAllByRole('textbox');
// Paste into the first field to trigger multi-word paste handling
fireEvent.change(inputs[0], { target: { value: words.join(' ') } });
};
/** Get the Continue button. */
const continueButton = () => screen.getByRole('button', { name: /import & continue|let's go/i });
// ---------------------------------------------------------------------------
// Generate mode — initial render
// ---------------------------------------------------------------------------
describe('Mnemonic — generate mode: initial render', () => {
it('renders the "Your Recovery Phrase" heading', () => {
renderWithUser();
expect(screen.getByText('Your Recovery Phrase')).toBeInTheDocument();
});
it('renders all 24 words from the generated mnemonic', () => {
renderWithUser();
for (const word of FIXED_WORDS) {
expect(screen.getAllByText(word).length).toBeGreaterThan(0);
}
});
it('renders 24 numbered word tiles', () => {
renderWithUser();
expect(screen.getByText('1.')).toBeInTheDocument();
expect(screen.getByText('24.')).toBeInTheDocument();
});
it('renders the Copy to Clipboard button', () => {
renderWithUser();
expect(screen.getByRole('button', { name: /copy to clipboard/i })).toBeInTheDocument();
});
it('renders the amber warning notice', () => {
renderWithUser();
expect(screen.getByText(/can never be recovered if lost/i)).toBeInTheDocument();
});
it('renders the confirmation checkbox unchecked', () => {
renderWithUser();
expect(screen.getByRole('checkbox')).not.toBeChecked();
});
it('renders the Continue button disabled before confirmation', () => {
renderWithUser();
expect(continueButton()).toBeDisabled();
});
it('renders the "I already have a recovery phrase" link', () => {
renderWithUser();
expect(screen.getByText('I already have a recovery phrase')).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Generate mode — confirmation checkbox
// ---------------------------------------------------------------------------
describe('Mnemonic — generate mode: confirmation checkbox', () => {
it('enables the Continue button when checkbox is checked', () => {
renderWithUser();
fireEvent.click(screen.getByRole('checkbox'));
expect(continueButton()).toBeEnabled();
});
it('disables the Continue button again when checkbox is unchecked', () => {
renderWithUser();
const checkbox = screen.getByRole('checkbox');
fireEvent.click(checkbox);
fireEvent.click(checkbox);
expect(continueButton()).toBeDisabled();
});
});
// ---------------------------------------------------------------------------
// Generate mode — copy to clipboard
// ---------------------------------------------------------------------------
describe('Mnemonic — generate mode: copy to clipboard', () => {
it('calls navigator.clipboard.writeText with the full mnemonic', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText } });
renderWithUser();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /copy to clipboard/i }));
});
expect(writeText).toHaveBeenCalledWith(FIXED_MNEMONIC);
});
it('shows "Copied to Clipboard" after clicking copy', async () => {
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
renderWithUser();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /copy to clipboard/i }));
});
await waitFor(() => expect(screen.getByText('Copied to Clipboard')).toBeInTheDocument());
});
it('resets "Copied" text back to "Copy to Clipboard" after 3 s', async () => {
vi.useFakeTimers();
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
renderWithUser();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /copy to clipboard/i }));
});
// Flush the resolved clipboard promise so setCopied(true) fires
await act(async () => {
await vi.runAllTimersAsync();
});
// Now the 3-second reset timer has also been run
expect(screen.queryByText('Copied to Clipboard')).not.toBeInTheDocument();
vi.useRealTimers();
});
it('uses execCommand fallback when clipboard API throws', async () => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockRejectedValue(new Error('blocked')) },
});
// jsdom does not implement execCommand — define it so we can spy
if (!document.execCommand) {
Object.defineProperty(document, 'execCommand', {
value: vi.fn().mockReturnValue(true),
writable: true,
configurable: true,
});
}
const execCommand = vi.spyOn(document, 'execCommand').mockReturnValue(true);
renderWithUser();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /copy to clipboard/i }));
});
await waitFor(() => expect(screen.getByText('Copied to Clipboard')).toBeInTheDocument());
expect(execCommand).toHaveBeenCalledWith('copy');
execCommand.mockRestore();
});
});
// ---------------------------------------------------------------------------
// Mode switching
// ---------------------------------------------------------------------------
describe('Mnemonic — mode switching', () => {
it('switches to import mode on "I already have a recovery phrase" click', () => {
renderWithUser();
switchToImport();
expect(screen.getByText('Import Recovery Phrase')).toBeInTheDocument();
});
it('shows 24 text inputs in import mode', () => {
renderWithUser();
switchToImport();
expect(screen.getAllByRole('textbox')).toHaveLength(WORD_COUNT);
});
it('switches back to generate mode on "Generate a new recovery phrase instead"', () => {
renderWithUser();
switchToImport();
fireEvent.click(screen.getByText('Generate a new recovery phrase instead'));
expect(screen.getByText('Your Recovery Phrase')).toBeInTheDocument();
});
it('resets error when switching modes', async () => {
renderWithUser();
// Trigger an error in generate mode (click continue without confirming)
fireEvent.click(continueButton()); // disabled, won't trigger, so force via import mode
// Switch to import mode and back — confirmed state should reset
switchToImport();
expect(screen.queryByText(/please enter all/i)).not.toBeInTheDocument();
});
it('resets confirmation when switching from generate to import and back', () => {
renderWithUser();
fireEvent.click(screen.getByRole('checkbox'));
expect(continueButton()).toBeEnabled();
switchToImport();
fireEvent.click(screen.getByText('Generate a new recovery phrase instead'));
// Confirmed state is reset — Continue should be disabled again
expect(continueButton()).toBeDisabled();
});
});
// ---------------------------------------------------------------------------
// Import mode — word input behaviour
// ---------------------------------------------------------------------------
describe('Mnemonic — import mode: word input', () => {
beforeEach(() => {
renderWithUser();
switchToImport();
});
it('updates input value when a word is typed', () => {
const inputs = screen.getAllByRole('textbox');
fireEvent.change(inputs[0], { target: { value: 'abandon' } });
expect((inputs[0] as HTMLInputElement).value).toBe('abandon');
});
it('lowercases the entered word', () => {
const inputs = screen.getAllByRole('textbox');
fireEvent.change(inputs[0], { target: { value: 'ABANDON' } });
expect((inputs[0] as HTMLInputElement).value).toBe('abandon');
});
it('Continue button stays disabled until all 24 words are filled', () => {
expect(continueButton()).toBeDisabled();
const inputs = screen.getAllByRole('textbox');
// Fill only 23 words
for (let i = 0; i < 23; i++) {
fireEvent.change(inputs[i], { target: { value: 'abandon' } });
}
expect(continueButton()).toBeDisabled();
});
it('Continue button becomes enabled when all 24 words are filled (via paste)', () => {
fillAllImportWords();
expect(continueButton()).toBeEnabled();
});
it('distributes pasted multi-word phrase across inputs starting at index 0', () => {
const inputs = screen.getAllByRole('textbox');
fireEvent.change(inputs[0], { target: { value: FIXED_WORDS.join(' ') } });
// After paste the first input gets the first word
expect((inputs[0] as HTMLInputElement).value).toBe(FIXED_WORDS[0]);
});
it('distributes pasted phrase starting from a non-zero index', () => {
const inputs = screen.getAllByRole('textbox');
const remaining = FIXED_WORDS.slice(1).join(' ');
fireEvent.change(inputs[1], { target: { value: remaining } });
expect((inputs[1] as HTMLInputElement).value).toBe(FIXED_WORDS[1]);
expect((inputs[2] as HTMLInputElement).value).toBe(FIXED_WORDS[2]);
});
});
// ---------------------------------------------------------------------------
// Import mode — keyboard navigation
// ---------------------------------------------------------------------------
describe('Mnemonic — import mode: keyboard navigation', () => {
beforeEach(() => {
renderWithUser();
switchToImport();
});
it('does not move focus backward on Backspace when input has text', () => {
const inputs = screen.getAllByRole('textbox');
fireEvent.change(inputs[1], { target: { value: 'test' } });
inputs[1].focus();
fireEvent.keyDown(inputs[1], { key: 'Backspace' });
// focus should stay on inputs[1]
expect(document.activeElement).toBe(inputs[1]);
});
});
// ---------------------------------------------------------------------------
// Import mode — validation
// ---------------------------------------------------------------------------
describe('Mnemonic — import mode: validation', () => {
beforeEach(() => {
renderWithUser();
switchToImport();
});
it('shows error when the phrase fails BIP39 validation after all 24 words are entered', async () => {
// The Continue button is only enabled when all 24 inputs are filled, so the
// "please enter all words" branch is unreachable via normal UI.
// The reachable validation error is the invalid-phrase message.
mockValidateMnemonicPhrase.mockReturnValueOnce(false);
fillAllImportWords();
await act(async () => {
fireEvent.click(continueButton());
});
await waitFor(() => expect(screen.getByText(/invalid recovery phrase/i)).toBeInTheDocument());
});
it('shows error when the 24-word phrase is invalid (BIP39)', async () => {
mockValidateMnemonicPhrase.mockReturnValueOnce(false);
fillAllImportWords();
await act(async () => {
fireEvent.click(continueButton());
});
await waitFor(() => expect(screen.getByText(/invalid recovery phrase/i)).toBeInTheDocument());
});
it('shows "Valid recovery phrase" text when phrase passes BIP39 validation', async () => {
mockValidateMnemonicPhrase.mockReturnValue(true);
fillAllImportWords();
await act(async () => {
fireEvent.click(continueButton());
});
await waitFor(() => expect(screen.getByText('Valid recovery phrase')).toBeInTheDocument());
});
});
// ---------------------------------------------------------------------------
// handleContinue — generate mode
// ---------------------------------------------------------------------------
describe('Mnemonic — handleContinue: generate mode', () => {
it('shows loading text while processing', async () => {
// Make setWalletAddress hang so we can observe the loading state
let resolveWallet!: () => void;
mockSetWalletAddress.mockReturnValueOnce(
new Promise<void>(res => {
resolveWallet = res;
})
);
renderWithUser();
fireEvent.click(screen.getByRole('checkbox'));
await act(async () => {
fireEvent.click(continueButton());
});
await waitFor(() => expect(screen.getByText('Securing Your Data...')).toBeInTheDocument());
await act(async () => {
resolveWallet();
});
});
it('dispatches setEncryptionKeyForUser with userId and derived AES key', async () => {
const { store } = renderWithUser();
fireEvent.click(screen.getByRole('checkbox'));
await act(async () => {
fireEvent.click(continueButton());
});
await waitFor(() => expect(mockSetWalletAddress).toHaveBeenCalled());
const authState = store.getState().auth as unknown as Record<string, unknown>;
// The encryption key should be stored under the user's id
expect(authState.encryptionKeyByUser).toMatchObject({ 'user-123': 'aes-key-hex' });
});
it('calls deriveAesKeyFromMnemonic and deriveEvmAddressFromMnemonic with the mnemonic', async () => {
renderWithUser();
fireEvent.click(screen.getByRole('checkbox'));
await act(async () => {
fireEvent.click(continueButton());
});
await waitFor(() => expect(mockSetWalletAddress).toHaveBeenCalled());
expect(mockDeriveAesKey).toHaveBeenCalledWith(FIXED_MNEMONIC);
expect(mockDeriveEvm).toHaveBeenCalledWith(FIXED_MNEMONIC);
});
it('calls skillManager.setWalletAddress with the derived EVM address', async () => {
renderWithUser();
fireEvent.click(screen.getByRole('checkbox'));
await act(async () => {
fireEvent.click(continueButton());
});
await waitFor(() => expect(mockSetWalletAddress).toHaveBeenCalledWith('0xDeAdBeEf'));
});
it('shows "User not loaded" error when user._id is missing', async () => {
renderWithoutUser();
// Manually enable Continue (can't check the box and navigate since no user)
// We need to directly click after enabling. Simulate by rendering with checkbox ticked.
renderWithProviders(<Mnemonic />, {
preloadedState: { user: { user: null, loading: false, error: null } },
});
// The checkbox click + continue in generate mode with no user
const checkboxes = screen.getAllByRole('checkbox');
fireEvent.click(checkboxes[checkboxes.length - 1]);
const buttons = screen.getAllByRole('button', { name: /let's go/i });
await act(async () => {
fireEvent.click(buttons[buttons.length - 1]);
});
await waitFor(() => expect(screen.getAllByText(/user not loaded/i).length).toBeGreaterThan(0));
});
it('shows an error message when deriveAesKeyFromMnemonic throws', async () => {
mockDeriveAesKey.mockImplementationOnce(() => {
throw new Error('crypto failure');
});
renderWithUser();
fireEvent.click(screen.getByRole('checkbox'));
await act(async () => {
fireEvent.click(continueButton());
});
await waitFor(() => expect(screen.getByText('crypto failure')).toBeInTheDocument());
});
it('does not navigate when unconfirmed in generate mode', async () => {
const { store } = renderWithUser();
// Do NOT check the checkbox
await act(async () => {
fireEvent.click(continueButton());
});
// No dispatch should have happened
await new Promise(r => setTimeout(r, 50));
expect(mockSetWalletAddress).not.toHaveBeenCalled();
expect(
(store.getState().auth as unknown as Record<string, unknown>).encryptionKeyByUser ?? {}
).not.toMatchObject({ 'user-123': expect.anything() });
});
});
// ---------------------------------------------------------------------------
// handleContinue — import mode
// ---------------------------------------------------------------------------
describe('Mnemonic — handleContinue: import mode', () => {
beforeEach(() => {
mockValidateMnemonicPhrase.mockReturnValue(true);
});
it('derives keys from the imported phrase and navigates to /home', async () => {
renderWithUser();
switchToImport();
fillAllImportWords();
await act(async () => {
fireEvent.click(continueButton());
});
await waitFor(() => expect(mockSetWalletAddress).toHaveBeenCalledWith('0xDeAdBeEf'));
expect(mockDeriveAesKey).toHaveBeenCalledWith(FIXED_MNEMONIC);
});
it('dispatches setEncryptionKeyForUser on successful import', async () => {
const { store } = renderWithUser();
switchToImport();
fillAllImportWords();
await act(async () => {
fireEvent.click(continueButton());
});
await waitFor(() => expect(mockSetWalletAddress).toHaveBeenCalled());
const authState = store.getState().auth as unknown as Record<string, unknown>;
expect(authState.encryptionKeyByUser).toMatchObject({ 'user-123': 'aes-key-hex' });
});
it('does not call deriveAesKey when validation fails', async () => {
mockValidateMnemonicPhrase.mockReturnValueOnce(false);
renderWithUser();
switchToImport();
fillAllImportWords();
await act(async () => {
fireEvent.click(continueButton());
});
await waitFor(() => expect(screen.getByText(/invalid recovery phrase/i)).toBeInTheDocument());
expect(mockDeriveAesKey).not.toHaveBeenCalled();
});
it('shows "User not loaded" error when user is absent during import', async () => {
mockValidateMnemonicPhrase.mockReturnValue(true);
renderWithProviders(<Mnemonic />, {
preloadedState: { user: { user: null, loading: false, error: null } },
});
switchToImport();
fillAllImportWords();
await act(async () => {
fireEvent.click(continueButton());
});
await waitFor(() => expect(screen.getByText(/user not loaded/i)).toBeInTheDocument());
});
it('shows an error when skillManager.setWalletAddress throws during import', async () => {
mockSetWalletAddress.mockRejectedValueOnce(new Error('wallet error'));
renderWithUser();
switchToImport();
fillAllImportWords();
await act(async () => {
fireEvent.click(continueButton());
});
await waitFor(() => expect(screen.getByText('wallet error')).toBeInTheDocument());
});
});
// ---------------------------------------------------------------------------
// Loading state during continue
// ---------------------------------------------------------------------------
describe('Mnemonic — loading state', () => {
it('disables Continue button while loading', async () => {
let resolveWallet!: () => void;
mockSetWalletAddress.mockReturnValueOnce(
new Promise<void>(res => {
resolveWallet = res;
})
);
renderWithUser();
fireEvent.click(screen.getByRole('checkbox'));
const btn = continueButton();
await act(async () => {
fireEvent.click(btn);
});
await waitFor(() => expect(screen.getByText('Securing Your Data...')).toBeInTheDocument());
expect(btn).toBeDisabled();
await act(async () => {
resolveWallet();
});
});
it('restores button label after an error', async () => {
mockDeriveAesKey.mockImplementationOnce(() => {
throw new Error('oops');
});
renderWithUser();
fireEvent.click(screen.getByRole('checkbox'));
await act(async () => {
fireEvent.click(continueButton());
});
await waitFor(() => expect(screen.getByText('oops')).toBeInTheDocument());
expect(screen.queryByText('Securing Your Data...')).not.toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Provider configuration sanity checks
// ---------------------------------------------------------------------------
describe('Mnemonic — providerConfigs sanity', () => {
it('calls generateMnemonicPhrase exactly once on mount', () => {
mockGenerateMnemonicPhrase.mockClear();
renderWithUser();
// useMemo with [] dep runs once per render
expect(mockGenerateMnemonicPhrase).toHaveBeenCalledTimes(1);
});
it('does not call generateMnemonicPhrase again after mode switch', () => {
mockGenerateMnemonicPhrase.mockClear();
renderWithUser();
const callsBefore = mockGenerateMnemonicPhrase.mock.calls.length;
switchToImport();
fireEvent.click(screen.getByText('Generate a new recovery phrase instead'));
expect(mockGenerateMnemonicPhrase.mock.calls.length).toBe(callsBefore);
});
});
+44 -1
View File
@@ -376,7 +376,11 @@ fn is_help(value: &str) -> bool {
#[cfg(test)]
mod tests {
use super::grouped_schemas;
use super::{
grouped_schemas, parse_autocomplete_start_cli_options, parse_function_params,
parse_input_value,
};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
#[test]
fn grouped_schemas_contains_migrated_namespaces() {
@@ -392,4 +396,43 @@ mod tests {
assert!(grouped.contains_key("migrate"));
assert!(grouped.contains_key("local_ai"));
}
#[test]
fn parse_autocomplete_start_cli_options_rejects_serve_and_spawn() {
let args = vec!["--serve".to_string(), "--spawn".to_string()];
let err = parse_autocomplete_start_cli_options(&args)
.expect_err("must reject mutually exclusive flags");
assert!(err.to_string().contains("mutually exclusive"));
}
#[test]
fn parse_function_params_rejects_unknown_param() {
let schema = ControllerSchema {
namespace: "test",
function: "echo",
description: "test schema",
inputs: vec![FieldSchema {
name: "message",
ty: TypeSchema::String,
required: true,
comment: "message text",
}],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::String,
required: true,
comment: "echo response",
}],
};
let args = vec!["--unknown".to_string(), "value".to_string()];
let err = parse_function_params(&schema, &args).expect_err("unknown param should fail");
assert!(err.contains("unknown param"));
}
#[test]
fn parse_input_value_rejects_invalid_bool() {
let err = parse_input_value(&TypeSchema::Bool, "not-a-bool")
.expect_err("invalid bool should fail");
assert!(err.contains("expected bool"));
}
}
+41
View File
@@ -334,6 +334,47 @@ async fn json_rpc_protocol_auth_and_agent_hello() {
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_rejects_non_object_params_with_clear_error() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
tokio::time::sleep(Duration::from_millis(100)).await;
let invalid = post_json_rpc(
&rpc_base,
1001,
"openhuman.auth_get_state",
json!(["invalid", "params"]),
)
.await;
let err_message = invalid
.get("error")
.and_then(|e| e.get("message"))
.and_then(Value::as_str)
.unwrap_or("");
assert!(
!err_message.is_empty(),
"expected non-empty JSON-RPC error message: {invalid}"
);
mock_join.abort();
rpc_join.abort();
}
// ---------------------------------------------------------------------------
// Skills registry E2E: fetch, search, install, list, uninstall
// ---------------------------------------------------------------------------