From d4528f8bc0d7a88b1a02269d782562cf1a7320cc Mon Sep 17 00:00:00 2001 From: Rohan Date: Sun, 17 May 2026 17:27:22 +0530 Subject: [PATCH] feat(ui): restore cross-provider RespondQueuePanel and purge legacy Mnemonic page --- app/src/pages/Accounts.tsx | 19 +- app/src/pages/Mnemonic.tsx | 358 -------------------- app/test/Mnemonic.test.tsx | 656 ------------------------------------- 3 files changed, 9 insertions(+), 1024 deletions(-) delete mode 100644 app/src/pages/Mnemonic.tsx delete mode 100644 app/test/Mnemonic.test.tsx diff --git a/app/src/pages/Accounts.tsx b/app/src/pages/Accounts.tsx index eb43983f78..2b7fb3352d 100644 --- a/app/src/pages/Accounts.tsx +++ b/app/src/pages/Accounts.tsx @@ -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(null); @@ -281,9 +281,8 @@ const Accounts = () => {
- {/* Respond queue side panel hidden for now — bring back when - the cross-provider surface is ready to ship. */} - {/* { onRefresh={() => { void dispatch(fetchRespondQueue()); }} - /> */} + /> ) : active ? (
diff --git a/app/src/pages/Mnemonic.tsx b/app/src/pages/Mnemonic.tsx deleted file mode 100644 index 2102009c80..0000000000 --- a/app/src/pages/Mnemonic.tsx +++ /dev/null @@ -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(null); - - // Generate mode state - const mnemonic = useMemo(() => generateMnemonicPhrase(), []); - const words = useMemo(() => mnemonic.split(' '), [mnemonic]); - - // Import mode state - const [importWords, setImportWords] = useState(Array(IMPORT_SLOTS_INITIAL).fill('')); - const [importValid, setImportValid] = useState(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) => { - 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 ( -
-
-
- -
- -
- {mode === 'generate' ? ( - <> -
-

Your Recovery Phrase

-

- 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. -

-
- - {/* Mnemonic Grid */} -
-
- {words.map((word, index) => ( -
- - {index + 1}. - - {word} -
- ))} -
-
- - {/* Copy Button */} - - - {/* Import existing link */} - - - {/* Confirmation Checkbox */} - - - ) : ( - <> -
-

Import Recovery Phrase

-

{t('mnemonic.copyWarning')}

-
- - {/* Import Word Inputs Grid */} -
-
- {importWords.map((word, index) => ( -
- - {index + 1}. - - { - 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' - }`} - /> -
- ))} -
-
- - {/* Validation status */} - {importValid === true && ( -
- - - - Valid recovery phrase -
- )} - - {/* Back to generate link */} - - - )} - - {error &&

{error}

} - - {/* Continue Button */} - -
-
-
- ); -}; - -export default Mnemonic; diff --git a/app/test/Mnemonic.test.tsx b/app/test/Mnemonic.test.tsx deleted file mode 100644 index 756f8d4166..0000000000 --- a/app/test/Mnemonic.test.tsx +++ /dev/null @@ -1,656 +0,0 @@ -/// -/** - * 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: () =>
, -})); - -// --------------------------------------------------------------------------- -// 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 = { _id: 'user-123', username: 'tester' }; - -/** Render with a user already in the store. */ -const renderWithUser = () => renderWithProviders(); - -/** Render without a user in the store (unauthenticated). */ -const renderWithoutUser = () => renderWithProviders(); - -/** 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(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(); - 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(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); - }); -});