mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
@@ -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(¤t, ¤t));
|
||||
|
||||
let different = current.with_file_name("definitely-not-the-current-exe");
|
||||
assert!(!same_executable_path(¤t, &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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user