feat(ui): restore cross-provider RespondQueuePanel and purge legacy Mnemonic page (#2000)

This commit is contained in:
rohan
2026-05-17 18:12:35 -07:00
committed by GitHub
parent 8bec23a44c
commit 2ec4e954eb
3 changed files with 9 additions and 1024 deletions
+9 -10
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import AddAccountModal from '../components/accounts/AddAccountModal';
import { AgentIcon, ProviderIcon } from '../components/accounts/providerIcons';
// import RespondQueuePanel from '../components/accounts/RespondQueuePanel';
import RespondQueuePanel from '../components/accounts/RespondQueuePanel';
import WebviewHost from '../components/accounts/WebviewHost';
import { usePrewarmMostRecentAccount } from '../hooks/usePrewarmMostRecentAccount';
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
@@ -109,11 +109,11 @@ const Accounts = () => {
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
// const { snapshot } = useCoreState();
// const welcomeLocked = isWelcomeLocked(snapshot);
// Respond-queue selectors disabled while RespondQueuePanel is hidden.
// const respondQueue = useAppSelector(state => state.providerSurfaces.queue);
// const respondQueueCount = useAppSelector(state => state.providerSurfaces.count);
// const respondQueueStatus = useAppSelector(state => state.providerSurfaces.status);
// const respondQueueError = useAppSelector(state => state.providerSurfaces.error);
// Respond-queue selectors enabled
const respondQueue = useAppSelector(state => state.providerSurfaces.queue);
const respondQueueCount = useAppSelector(state => state.providerSurfaces.count);
const respondQueueStatus = useAppSelector(state => state.providerSurfaces.status);
const respondQueueError = useAppSelector(state => state.providerSurfaces.error);
const [addOpen, setAddOpen] = useState(false);
const [ctxMenu, setCtxMenu] = useState<ContextMenuState | null>(null);
@@ -281,9 +281,8 @@ const Accounts = () => {
<div className="min-w-0 flex-1">
<AgentChatPanel />
</div>
{/* Respond queue side panel hidden for now — bring back when
the cross-provider surface is ready to ship. */}
{/* <RespondQueuePanel
{/* Respond queue side panel restored */}
<RespondQueuePanel
items={respondQueue}
count={respondQueueCount}
status={respondQueueStatus}
@@ -291,7 +290,7 @@ const Accounts = () => {
onRefresh={() => {
void dispatch(fetchRespondQueue());
}}
/> */}
/>
</div>
) : active ? (
<div className="flex-1 py-3 pr-3">
-358
View File
@@ -1,358 +0,0 @@
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import LottieAnimation from '../components/LottieAnimation';
import { persistLocalWalletFromMnemonic } from '../features/wallet/setupLocalWalletFromMnemonic';
import { useT } from '../lib/i18n/I18nContext';
import { useCoreState } from '../providers/CoreStateProvider';
import {
generateMnemonicPhrase,
MNEMONIC_GENERATE_WORD_COUNT,
validateMnemonicPhrase,
} from '../utils/cryptoKeys';
/** Allowed BIP39 phrase lengths for import (includes legacy 24-word backups). */
const BIP39_IMPORT_LENGTHS = [12, 15, 18, 21, 24] as const;
const IMPORT_SLOTS_INITIAL = MNEMONIC_GENERATE_WORD_COUNT;
const Mnemonic = () => {
const { t } = useT();
const navigate = useNavigate();
const { snapshot, setEncryptionKey } = useCoreState();
const user = snapshot.currentUser;
const [mode, setMode] = useState<'generate' | 'import'>('generate');
const [copied, setCopied] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Generate mode state
const mnemonic = useMemo(() => generateMnemonicPhrase(), []);
const words = useMemo(() => mnemonic.split(' '), [mnemonic]);
// Import mode state
const [importWords, setImportWords] = useState<string[]>(Array(IMPORT_SLOTS_INITIAL).fill(''));
const [importValid, setImportValid] = useState<boolean | null>(null);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
useEffect(() => {
if (copied) {
const timer = setTimeout(() => setCopied(false), 3000);
return () => clearTimeout(timer);
}
}, [copied]);
const switchMode = useCallback((nextMode: 'generate' | 'import') => {
setMode(nextMode);
setConfirmed(false);
setError(null);
setImportValid(null);
setImportWords(Array(IMPORT_SLOTS_INITIAL).fill(''));
}, []);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(mnemonic);
setCopied(true);
} catch {
const textarea = document.createElement('textarea');
textarea.value = mnemonic;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
setCopied(true);
}
}, [mnemonic]);
const handleImportWordChange = useCallback(
(index: number, value: string) => {
const pastedWords = value.trim().split(/\s+/).filter(Boolean);
if (pastedWords.length > 1) {
const fullPhraseLen = pastedWords.length;
if (BIP39_IMPORT_LENGTHS.includes(fullPhraseLen as (typeof BIP39_IMPORT_LENGTHS)[number])) {
setImportWords(pastedWords.map(w => w.toLowerCase()));
setImportValid(null);
inputRefs.current[fullPhraseLen - 1]?.focus();
return;
}
const newWords = [...importWords];
const slotCount = newWords.length;
for (let i = 0; i < Math.min(pastedWords.length, slotCount - index); i++) {
newWords[index + i] = pastedWords[i].toLowerCase();
}
setImportWords(newWords);
setImportValid(null);
const nextEmpty = newWords.findIndex(w => !w);
const focusIndex = nextEmpty === -1 ? slotCount - 1 : nextEmpty;
inputRefs.current[focusIndex]?.focus();
return;
}
const newWords = [...importWords];
newWords[index] = value.toLowerCase().trim();
setImportWords(newWords);
setImportValid(null);
if (value.trim() && index < newWords.length - 1) {
inputRefs.current[index + 1]?.focus();
}
},
[importWords]
);
const handleImportKeyDown = useCallback(
(index: number, e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && !importWords[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
},
[importWords]
);
const handleValidateImport = useCallback(() => {
const phrase = importWords.join(' ').trim();
const filledWords = importWords.filter(w => w.trim());
const n = filledWords.length;
if (!BIP39_IMPORT_LENGTHS.includes(n as (typeof BIP39_IMPORT_LENGTHS)[number])) {
setError(`Recovery phrase must be ${BIP39_IMPORT_LENGTHS.join(', ')} words (you have ${n}).`);
setImportValid(false);
return false;
}
const isValid = validateMnemonicPhrase(phrase);
setImportValid(isValid);
if (!isValid) {
setError('Invalid recovery phrase. Please check your words and try again.');
return false;
}
setError(null);
return true;
}, [importWords]);
const handleContinue = async () => {
setError(null);
setLoading(true);
try {
let phraseToUse: string;
if (mode === 'import') {
if (!handleValidateImport()) {
setLoading(false);
return;
}
phraseToUse = importWords.join(' ').trim();
} else {
if (!confirmed) {
setLoading(false);
return;
}
phraseToUse = mnemonic;
}
if (!user?._id) {
const msg = 'User not loaded. Please sign in again or refresh the page.';
setError(msg);
console.error('[Mnemonic] Cannot save encryption key: user not loaded');
return;
}
await persistLocalWalletFromMnemonic({
mnemonic: phraseToUse,
source: mode === 'generate' ? 'generated' : 'imported',
setEncryptionKey,
});
navigate('/home');
} catch (e) {
setError(e instanceof Error ? e.message : 'Something went wrong. Please try again.');
} finally {
setLoading(false);
}
};
const importWordCount = importWords.filter(w => w.trim()).length;
const isImportComplete =
importWords.every(w => w.trim()) &&
BIP39_IMPORT_LENGTHS.includes(importWordCount as (typeof BIP39_IMPORT_LENGTHS)[number]);
const canContinue = mode === 'generate' ? confirmed : isImportComplete;
return (
<div className="min-h-full flex items-center justify-center p-4 pt-6">
<div className="max-w-lg w-full">
<div className="flex justify-center mb-6">
<LottieAnimation src="/lottie/safe3.json" height={120} width={120} />
</div>
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-8 animate-fade-up">
{mode === 'generate' ? (
<>
<div className="text-center mb-4">
<h1 className="text-xl font-bold mb-2">Your Recovery Phrase</h1>
<p className="opacity-70 text-sm">
Write down these {MNEMONIC_GENERATE_WORD_COUNT} words in order and store them
somewhere safe. This phrase unlocks your local encryption key and your EVM, BTC,
Solana, and Tron wallet identities, and can never be recovered if lost.
</p>
</div>
{/* Mnemonic Grid */}
<div className="bg-stone-900/5 rounded-2xl p-4 mb-4">
<div className="grid grid-cols-3 gap-2">
{words.map((word, index) => (
<div
key={index}
className="flex items-center gap-2 bg-white/60 rounded-lg px-3 py-2 text-sm">
<span className="text-stone-400 font-mono text-xs w-5 text-right">
{index + 1}.
</span>
<span className="font-mono font-medium text-stone-800">{word}</span>
</div>
))}
</div>
</div>
{/* Copy Button */}
<button
onClick={handleCopy}
className="w-full flex items-center justify-center gap-2 border border-stone-200 hover:bg-stone-50 text-stone-700 font-medium py-2.5 text-sm rounded-xl transition-all duration-200 mb-3">
{copied ? (
<>
<svg
className="w-4 h-4 text-sage-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span className="text-sage-600">Copied to Clipboard</span>
</>
) : (
<>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
<span>Copy to Clipboard</span>
</>
)}
</button>
{/* Import existing link */}
<button
onClick={() => switchMode('import')}
className="w-full text-center text-sm text-primary-500 hover:text-primary-600 transition-colors mb-3">
{t('mnemonic.alreadyHavePhrase')}
</button>
{/* Confirmation Checkbox */}
<label className="flex items-start gap-3 cursor-pointer mb-4">
<input
type="checkbox"
checked={confirmed}
onChange={e => setConfirmed(e.target.checked)}
className="mt-0.5 w-4 h-4 rounded border-stone-300 text-primary-500 focus:ring-primary-500"
/>
<span className="text-sm text-stone-600">
I saved this phrase and consent to using it for local wallet setup on this device
</span>
</label>
</>
) : (
<>
<div className="text-center mb-4">
<h1 className="text-xl font-bold mb-2">Import Recovery Phrase</h1>
<p className="opacity-70 text-sm">{t('mnemonic.copyWarning')}</p>
</div>
{/* Import Word Inputs Grid */}
<div className="bg-stone-900/5 rounded-2xl p-4 mb-4">
<div className="grid grid-cols-3 gap-2">
{importWords.map((word, index) => (
<div key={index} className="flex items-center gap-1.5">
<span className="text-stone-400 font-mono text-xs w-5 text-right shrink-0">
{index + 1}.
</span>
<input
ref={el => {
inputRefs.current[index] = el;
}}
type="text"
value={word}
onChange={e => handleImportWordChange(index, e.target.value)}
onKeyDown={e => handleImportKeyDown(index, e)}
autoComplete="off"
spellCheck={false}
className={`w-full font-mono text-sm font-medium px-2 py-1.5 rounded-lg border bg-white/60 outline-none transition-colors ${
importValid === false && word.trim()
? 'border-coral-300 focus:border-coral-400'
: importValid === true
? 'border-sage-300 focus:border-sage-400'
: 'border-stone-200 focus:border-primary-400'
}`}
/>
</div>
))}
</div>
</div>
{/* Validation status */}
{importValid === true && (
<div className="flex items-center gap-2 text-sage-600 text-sm mb-3 justify-center">
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span>Valid recovery phrase</span>
</div>
)}
{/* Back to generate link */}
<button
onClick={() => switchMode('generate')}
className="w-full text-center text-sm text-primary-500 hover:text-primary-600 transition-colors mb-3">
{t('mnemonic.generateNewPhrase')}
</button>
</>
)}
{error && <p className="text-coral-500 text-sm mb-3 text-center">{error}</p>}
{/* Continue Button */}
<button
onClick={handleContinue}
disabled={!canContinue || loading}
className="w-full flex items-center justify-center space-x-3 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed text-white font-semibold py-2.5 text-sm rounded-xl transition-all duration-300 hover:shadow-medium">
<span>
{loading
? 'Securing Your Data...'
: mode === 'import'
? 'Import & Continue'
: "I'm Ready! Let's Go!"}
</span>
</button>
</div>
</div>
</div>
);
};
export default Mnemonic;
-656
View File
@@ -1,656 +0,0 @@
/// <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
* - Core-state setEncryptionKey persistence
*/
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,
mockSetEncryptionKey,
mockEncryptSecret,
mockUseCoreState,
} = vi.hoisted(() => ({
mockGenerateMnemonicPhrase: vi.fn(() => FIXED_MNEMONIC),
mockValidateMnemonicPhrase: vi.fn(() => true),
mockDeriveAesKey: vi.fn(() => 'aes-key-hex'),
mockSetEncryptionKey: vi.fn().mockResolvedValue(undefined),
mockEncryptSecret: vi.fn().mockResolvedValue({ result: 'enc2:wallet-secret' }),
mockUseCoreState: vi.fn(),
}));
vi.mock('../src/utils/cryptoKeys', () => ({
MNEMONIC_GENERATE_WORD_COUNT: 24,
generateMnemonicPhrase: mockGenerateMnemonicPhrase,
validateMnemonicPhrase: mockValidateMnemonicPhrase,
deriveAesKeyFromMnemonic: mockDeriveAesKey,
}));
vi.mock('../src/providers/CoreStateProvider', () => ({ useCoreState: () => mockUseCoreState() }));
vi.mock('../src/utils/tauriCommands/auth', () => ({
openhumanEncryptSecret: (...args: unknown[]) => mockEncryptSecret(...args),
}));
// 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 />);
/** 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 });
beforeEach(() => {
mockGenerateMnemonicPhrase.mockClear();
mockValidateMnemonicPhrase.mockClear();
mockDeriveAesKey.mockClear();
mockSetEncryptionKey.mockClear();
mockEncryptSecret.mockClear();
mockUseCoreState.mockReturnValue({
snapshot: { currentUser: mockUser, sessionToken: 'jwt-token' },
setEncryptionKey: mockSetEncryptionKey,
});
});
// ---------------------------------------------------------------------------
// Generate mode — initial render
// ---------------------------------------------------------------------------
describe('Mnemonic — generate mode: initial render', () => {
it('renders the "Your Recovery Phrase" heading', () => {
renderWithUser();
expect(screen.getByText('Your Recovery Phrase')).toBeTruthy();
});
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.')).toBeTruthy();
expect(screen.getByText('24.')).toBeTruthy();
});
it('renders the Copy to Clipboard button', () => {
renderWithUser();
expect(screen.getByRole('button', { name: /copy to clipboard/i })).toBeTruthy();
});
it('renders the amber warning notice', () => {
renderWithUser();
expect(screen.getByText(/can never be recovered if lost/i)).toBeTruthy();
});
it('renders the confirmation checkbox unchecked', () => {
renderWithUser();
expect((screen.getByRole('checkbox') as HTMLInputElement).checked).toBe(false);
});
it('renders the Continue button disabled before confirmation', () => {
renderWithUser();
expect((continueButton() as HTMLButtonElement).disabled).toBe(true);
});
it('renders the "I already have a recovery phrase" link', () => {
renderWithUser();
expect(screen.getByText('I already have a recovery phrase')).toBeTruthy();
});
});
// ---------------------------------------------------------------------------
// 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() as HTMLButtonElement).disabled).toBe(false);
});
it('disables the Continue button again when checkbox is unchecked', () => {
renderWithUser();
const checkbox = screen.getByRole('checkbox');
fireEvent.click(checkbox);
fireEvent.click(checkbox);
expect((continueButton() as HTMLButtonElement).disabled).toBe(true);
});
});
// ---------------------------------------------------------------------------
// 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')).toBeTruthy());
});
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')).toBeNull();
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')).toBeTruthy());
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')).toBeTruthy();
});
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')).toBeTruthy();
});
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)).toBeNull();
});
it('resets confirmation when switching from generate to import and back', () => {
renderWithUser();
fireEvent.click(screen.getByRole('checkbox'));
expect((continueButton() as HTMLButtonElement).disabled).toBe(false);
switchToImport();
fireEvent.click(screen.getByText('Generate a new recovery phrase instead'));
// Confirmed state is reset — Continue should be disabled again
expect((continueButton() as HTMLButtonElement).disabled).toBe(true);
});
});
// ---------------------------------------------------------------------------
// 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() as HTMLButtonElement).disabled).toBe(true);
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() as HTMLButtonElement).disabled).toBe(true);
});
it('Continue button becomes enabled when all 24 words are filled (via paste)', () => {
fillAllImportWords();
expect((continueButton() as HTMLButtonElement).disabled).toBe(false);
});
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)).toBeTruthy());
});
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)).toBeTruthy());
});
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')).toBeTruthy());
});
});
// ---------------------------------------------------------------------------
// handleContinue — generate mode
// ---------------------------------------------------------------------------
describe('Mnemonic — handleContinue: generate mode', () => {
it('shows loading text while processing', async () => {
let resolveEncryptionKey!: () => void;
mockSetEncryptionKey.mockReturnValueOnce(
new Promise<void>(res => {
resolveEncryptionKey = res;
})
);
renderWithUser();
fireEvent.click(screen.getByRole('checkbox'));
await act(async () => {
fireEvent.click(continueButton());
});
await waitFor(() => expect(screen.getByText('Securing Your Data...')).toBeTruthy());
await act(async () => {
resolveEncryptionKey();
});
});
it('calls setEncryptionKey with the derived AES key', async () => {
renderWithUser();
fireEvent.click(screen.getByRole('checkbox'));
await act(async () => {
fireEvent.click(continueButton());
});
expect(mockSetEncryptionKey).toHaveBeenCalledWith('aes-key-hex');
});
it('calls deriveAesKeyFromMnemonic with the generated mnemonic', async () => {
renderWithUser();
fireEvent.click(screen.getByRole('checkbox'));
await act(async () => {
fireEvent.click(continueButton());
});
expect(mockDeriveAesKey).toHaveBeenCalledWith(FIXED_MNEMONIC);
});
it('shows "User not loaded" error when user._id is missing', async () => {
mockUseCoreState.mockReturnValue({
snapshot: { currentUser: null, sessionToken: 'jwt-token' },
setEncryptionKey: mockSetEncryptionKey,
});
renderWithoutUser();
// The checkbox click + continue in generate mode with no user
fireEvent.click(screen.getByRole('checkbox'));
await act(async () => {
fireEvent.click(continueButton());
});
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')).toBeTruthy());
});
it('does not navigate when unconfirmed in generate mode', async () => {
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(mockSetEncryptionKey).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// handleContinue — import mode
// ---------------------------------------------------------------------------
describe('Mnemonic — handleContinue: import mode', () => {
beforeEach(() => {
mockValidateMnemonicPhrase.mockReturnValue(true);
});
it('derives the encryption key from the imported phrase and navigates to /home', async () => {
renderWithUser();
switchToImport();
fillAllImportWords();
await act(async () => {
fireEvent.click(continueButton());
});
expect(mockDeriveAesKey).toHaveBeenCalledWith(FIXED_MNEMONIC);
});
it('calls setEncryptionKey on successful import', async () => {
renderWithUser();
switchToImport();
fillAllImportWords();
await act(async () => {
fireEvent.click(continueButton());
});
expect(mockSetEncryptionKey).toHaveBeenCalledWith('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)).toBeTruthy());
expect(mockDeriveAesKey).not.toHaveBeenCalled();
});
it('shows "User not loaded" error when user is absent during import', async () => {
mockValidateMnemonicPhrase.mockReturnValue(true);
mockUseCoreState.mockReturnValue({
snapshot: { currentUser: null, sessionToken: 'jwt-token' },
setEncryptionKey: mockSetEncryptionKey,
});
renderWithProviders(<Mnemonic />);
switchToImport();
fillAllImportWords();
await act(async () => {
fireEvent.click(continueButton());
});
await waitFor(() => expect(screen.getByText(/user not loaded/i)).toBeTruthy());
});
it('shows an error when setEncryptionKey throws during import', async () => {
mockSetEncryptionKey.mockRejectedValueOnce(new Error('encryption error'));
renderWithUser();
switchToImport();
fillAllImportWords();
await act(async () => {
fireEvent.click(continueButton());
});
await waitFor(() => expect(screen.getByText('encryption error')).toBeTruthy());
});
});
// ---------------------------------------------------------------------------
// Loading state during continue
// ---------------------------------------------------------------------------
describe('Mnemonic — loading state', () => {
it('disables Continue button while loading', async () => {
let resolveEncryptionKey!: () => void;
mockSetEncryptionKey.mockReturnValueOnce(
new Promise<void>(res => {
resolveEncryptionKey = res;
})
);
renderWithUser();
fireEvent.click(screen.getByRole('checkbox'));
const btn = continueButton();
await act(async () => {
fireEvent.click(btn);
});
await waitFor(() => expect(screen.getByText('Securing Your Data...')).toBeTruthy());
expect((btn as HTMLButtonElement).disabled).toBe(true);
await act(async () => {
resolveEncryptionKey();
});
});
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')).toBeTruthy());
expect(screen.queryByText('Securing Your Data...')).toBeNull();
});
});
// ---------------------------------------------------------------------------
// 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);
});
});