diff --git a/app/components/Views/LedgerSelectAccount/index.styles.ts b/app/components/Views/LedgerSelectAccount/index.styles.ts index 9e1b0a3730b..80882673336 100644 --- a/app/components/Views/LedgerSelectAccount/index.styles.ts +++ b/app/components/Views/LedgerSelectAccount/index.styles.ts @@ -21,10 +21,6 @@ const createStyles = (colors: Colors) => paddingHorizontal: 32, alignItems: 'center', }, - selectorContainer: { - flex: 1, - flexDirection: 'column', - }, mainTitle: { fontSize: 24, marginBottom: 10, @@ -73,6 +69,13 @@ const createStyles = (colors: Colors) => fontSize: 14, ...fontStyles.normal, }, + loadingContainer: { + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: 16, + }, }); export default createStyles; diff --git a/app/components/Views/LedgerSelectAccount/index.test.tsx b/app/components/Views/LedgerSelectAccount/index.test.tsx index 759c823c57b..3bcce5e9783 100644 --- a/app/components/Views/LedgerSelectAccount/index.test.tsx +++ b/app/components/Views/LedgerSelectAccount/index.test.tsx @@ -3,10 +3,7 @@ import { fireEvent, waitFor, act } from '@testing-library/react-native'; import LedgerSelectAccount from './index'; import renderWithProvider from '../../../util/test/renderWithProvider'; import Engine from '../../../core/Engine'; -import useLedgerBluetooth from '../../hooks/Ledger/useLedgerBluetooth'; -import type { BluetoothInterface } from '../../hooks/Ledger/useBluetoothDevices'; import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; -import { LedgerCommunicationErrors } from '../../../core/Ledger/ledgerErrors'; import { getLedgerAccountsByOperation, unlockLedgerWalletAccount, @@ -29,9 +26,9 @@ import { ACCOUNT_SELECTOR_PREVIOUS_BUTTON, } from '../../../../wdio/screen-objects/testIDs/Components/AccountSelector.testIds'; import { SELECT_DROP_DOWN } from '../../UI/SelectOptionSheet/constants'; +import { useHardwareWallet } from '../../../core/HardwareWallet'; +import { HardwareWalletType, ConnectionStatus } from '@metamask/hw-wallet-sdk'; -const mockedNavigate = jest.fn(); -const mockedPop = jest.fn(); const mockedGoBack = jest.fn(); const mockedNavDispatch = jest.fn(); const mockedDispatch = jest.fn(); @@ -41,62 +38,19 @@ const mockCreateEventBuilder = jest.fn(() => ({ build: jest.fn().mockReturnValue({}), })); -jest.mock('../LedgerConnect', () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires - const MockReact = require('react'); - // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires - const { View, Text, TouchableOpacity } = require('react-native'); - return { - __esModule: true, - default: jest.fn( - (props: { - onConnectLedger: () => void; - setSelectedDevice: (device: unknown) => void; - ledgerError?: unknown; - }) => - MockReact.createElement( - View, - { testID: 'ledger-connect-mock' }, - MockReact.createElement(Text, null, 'LedgerConnect Mock'), - props.ledgerError && - MockReact.createElement( - Text, - { testID: 'ledger-error' }, - String(props.ledgerError), - ), - MockReact.createElement( - TouchableOpacity, - { - testID: 'connect-ledger-button', - onPress: () => { - // Simulate selecting a device first - props.setSelectedDevice({ - id: 'test-device-id', - name: 'Nano X', - serviceUUIDs: ['test-uuid'], - }); - props.onConnectLedger(); - }, - }, - MockReact.createElement(Text, null, 'Connect'), - ), - ), - ), - }; -}); +const mockEnsureDeviceReady = jest.fn().mockResolvedValue(true); +const mockSetTargetWalletType = jest.fn(); +const mockShowHardwareWalletError = jest.fn(); -jest.mock('../../hooks/Ledger/useLedgerBluetooth', () => ({ - __esModule: true, - default: jest.fn((_deviceId?: string) => ({ - isSendingLedgerCommands: false, - isAppLaunchConfirmationNeeded: false, - ledgerLogicToRun: jest.fn( - async (fn: (transport: BluetoothInterface) => Promise) => - fn(undefined as unknown as BluetoothInterface), - ), - error: undefined, - })), +const mockShowAwaitingConfirmation = jest.fn(); +const mockHideAwaitingConfirmation = jest.fn(); + +jest.mock('../../../core/HardwareWallet', () => ({ + useHardwareWallet: jest.fn(), })); +const mockUseHardwareWallet = useHardwareWallet as jest.MockedFunction< + typeof useHardwareWallet +>; jest.mock('../../hooks/useAnalytics/useAnalytics', () => ({ useAnalytics: jest.fn(() => ({ @@ -110,10 +64,10 @@ jest.mock('@react-navigation/native', () => { return { ...actualNav, useNavigation: () => ({ - navigate: mockedNavigate, + navigate: jest.fn(), setOptions: jest.fn(), goBack: mockedGoBack, - pop: mockedPop, + pop: jest.fn(), dispatch: mockedNavDispatch, }), StackActions: { @@ -142,7 +96,6 @@ jest.mock('../../../core/HardwareWallets/analytics', () => ({ jest.mock('../../../util/hardwareWallet/deviceNameUtils', () => ({ sanitizeDeviceName: jest.fn((name: string) => name), - ledgerDeviceUUIDToModelName: jest.fn(() => 'Nano X'), })); jest.mock('../../../util/address', () => ({ @@ -155,9 +108,7 @@ jest.mock('../../../util/address', () => ({ jest.mock('../../../core/Engine', () => ({ context: { KeyringController: { - state: { - keyrings: [], - }, + state: { keyrings: [] }, getAccounts: jest.fn(), }, AccountsController: { @@ -198,6 +149,25 @@ const mockAccounts = [ }, ]; +const defaultHardwareWalletValues = { + walletType: HardwareWalletType.Ledger as HardwareWalletType | null, + deviceId: 'test-device-id', + connectionState: { + status: ConnectionStatus.Disconnected as const, + }, + deviceSelection: { + devices: [], + selectedDevice: { id: 'test-device-id', name: 'Nano X' }, + isScanning: false, + scanError: null, + }, + ensureDeviceReady: mockEnsureDeviceReady, + setTargetWalletType: mockSetTargetWalletType, + showHardwareWalletError: mockShowHardwareWalletError, + showAwaitingConfirmation: mockShowAwaitingConfirmation, + hideAwaitingConfirmation: mockHideAwaitingConfirmation, +}; + describe('LedgerSelectAccount', () => { const mockKeyringController = MockEngine.context.KeyringController; const mockAccountsController = MockEngine.context.AccountsController; @@ -217,68 +187,77 @@ describe('LedgerSelectAccount', () => { mockUnlockLedgerWalletAccount.mockResolvedValue(undefined); mockForgetLedger.mockResolvedValue(undefined); - jest.mocked(useLedgerBluetooth).mockImplementation(() => ({ - isSendingLedgerCommands: false, - isAppLaunchConfirmationNeeded: false, - ledgerLogicToRun: jest.fn( - async (fn: (transport: BluetoothInterface) => Promise) => - fn(undefined as unknown as BluetoothInterface), - ), - error: undefined, - cleanupBluetoothConnection: jest.fn(), - })); + mockEnsureDeviceReady.mockResolvedValue(true); + + mockUseHardwareWallet.mockReturnValue({ ...defaultHardwareWalletValues }); }); - // Helper function to render and connect to get AccountSelector view - const renderAndConnect = async () => { + /** + * Render the component and wait for `ensureDeviceReady` to resolve and + * accounts to be fetched so the AccountSelector view is visible. + */ + const renderAndWaitForAccounts = async () => { mockGetLedgerAccountsByOperation.mockResolvedValue(mockAccounts); const result = renderWithProvider(); - // Trigger the connect button to load accounts - await act(async () => { - const connectButton = result.getByTestId('connect-ledger-button'); - fireEvent.press(connectButton); - }); - - // Wait for accounts to be loaded and AccountSelector to render - // The text comes from strings('ledger.select_accounts') which is "Select an account" await waitFor(() => { - expect(result.queryByText('Select an account')).toBeTruthy(); + expect(result.queryByText('Select an account')).toBeOnTheScreen(); }); return result; }; describe('Initial Rendering', () => { - it('renders LedgerConnect when no accounts are loaded', () => { - mockKeyringController.getAccounts.mockResolvedValue([]); - const { getByTestId } = renderWithProvider(); + it('shows loading indicator while waiting for device readiness', () => { + mockEnsureDeviceReady.mockReturnValue(new Promise(() => undefined)); + const { queryByText } = renderWithProvider(); + + expect(queryByText('Looking for device')).toBeOnTheScreen(); + }); + + it('sets target wallet type to Ledger on mount', async () => { + renderWithProvider(); + + await waitFor(() => { + expect(mockSetTargetWalletType).toHaveBeenCalledWith( + HardwareWalletType.Ledger, + ); + }); + }); + + it('calls ensureDeviceReady on mount', async () => { + renderWithProvider(); + + await waitFor(() => { + expect(mockEnsureDeviceReady).toHaveBeenCalled(); + }); + }); + + it('navigates back when user cancels (ensureDeviceReady returns false)', async () => { + mockEnsureDeviceReady.mockResolvedValue(false); + + renderWithProvider(); - expect(getByTestId('ledger-connect-mock')).toBeTruthy(); + await waitFor(() => { + expect(mockedGoBack).toHaveBeenCalled(); + }); }); - it('renders LedgerConnect when ledger error exists', () => { - ( - useLedgerBluetooth as unknown as jest.MockedFunction< - typeof useLedgerBluetooth - > - ).mockImplementation(() => ({ - isSendingLedgerCommands: false, - isAppLaunchConfirmationNeeded: false, - ledgerLogicToRun: jest.fn(), - error: LedgerCommunicationErrors.LedgerDisconnected, - cleanupBluetoothConnection: jest.fn(), - })); - - const { getByTestId } = renderWithProvider(); - - expect(getByTestId('ledger-connect-mock')).toBeTruthy(); - expect(getByTestId('ledger-error')).toBeTruthy(); + it('navigates back when ensureDeviceReady throws on mount', async () => { + mockEnsureDeviceReady.mockRejectedValue( + new Error('Bluetooth adapter failed'), + ); + + renderWithProvider(); + + await waitFor(() => { + expect(mockedGoBack).toHaveBeenCalled(); + }); }); }); describe('Account Loading', () => { - it('loads existing accounts on mount', async () => { + it('loads existing accounts from KeyringController on mount', async () => { renderWithProvider(); await waitFor(() => { @@ -294,7 +273,6 @@ describe('LedgerSelectAccount', () => { renderWithProvider(); await waitFor(() => { - // toFormattedAddress is called via .map(), so it receives (element, index, array) expect(mockToFormattedAddress).toHaveBeenCalledWith( mockExistingAccounts[0], 0, @@ -308,174 +286,87 @@ describe('LedgerSelectAccount', () => { }); }); - it('renders account selector after successful connection', async () => { - const { queryByText } = await renderAndConnect(); - - expect(queryByText('Select an account')).toBeTruthy(); - expect(queryByText('Select HD Path')).toBeTruthy(); - }); - }); - - describe('Metrics Tracking', () => { - it('tracks account selector open event when accounts and device are loaded', async () => { - const mockBuilder = { - addProperties: jest.fn().mockReturnThis(), - build: jest.fn().mockReturnValue({}), - }; - mockCreateEventBuilder.mockReturnValue(mockBuilder); + it('fetches accounts after device is ready', async () => { + mockGetLedgerAccountsByOperation.mockResolvedValue(mockAccounts); - await renderAndConnect(); + renderWithProvider(); await waitFor(() => { - expect(mockCreateEventBuilder).toHaveBeenCalledWith( - MetaMetricsEvents.HARDWARE_WALLET_ACCOUNT_SELECTOR_OPEN, + expect(mockGetLedgerAccountsByOperation).toHaveBeenCalledWith( + PAGINATION_OPERATIONS.GET_FIRST_PAGE, ); - expect(mockBuilder.addProperties).toHaveBeenCalledWith({ - device_type: HardwareDeviceTypes.LEDGER, - device_model: 'Nano X', - }); }); }); - }); - - describe('Error Handling', () => { - it('hides blocking modal when ledger error occurs', async () => { - const { rerender } = renderWithProvider(); - - ( - useLedgerBluetooth as unknown as jest.MockedFunction< - typeof useLedgerBluetooth - > - ).mockImplementation(() => ({ - isSendingLedgerCommands: false, - isAppLaunchConfirmationNeeded: false, - ledgerLogicToRun: jest.fn(), - error: LedgerCommunicationErrors.LedgerDisconnected, - cleanupBluetoothConnection: jest.fn(), - })); - rerender(); + it('renders account selector after accounts are loaded', async () => { + const { queryByText } = await renderAndWaitForAccounts(); - await waitFor(() => { - expect(useLedgerBluetooth).toHaveBeenCalled(); - }); + expect(queryByText('Select an account')).toBeOnTheScreen(); + expect(queryByText('Select HD Path')).toBeOnTheScreen(); }); - it('shows LedgerConnect when ledger error occurs even with accounts', async () => { - mockGetLedgerAccountsByOperation.mockResolvedValue(mockAccounts); - const { getByTestId, rerender } = renderWithProvider( - , + it('shows inline error when account fetching fails', async () => { + mockGetLedgerAccountsByOperation.mockRejectedValue( + new Error('Fetch failed'), ); - // Trigger connection to load accounts - await act(async () => { - const connectButton = getByTestId('connect-ledger-button'); - fireEvent.press(connectButton); - }); - - // Now simulate an error - ( - useLedgerBluetooth as unknown as jest.MockedFunction< - typeof useLedgerBluetooth - > - ).mockImplementation(() => ({ - isSendingLedgerCommands: false, - isAppLaunchConfirmationNeeded: false, - ledgerLogicToRun: jest.fn(), - error: LedgerCommunicationErrors.LedgerDisconnected, - cleanupBluetoothConnection: jest.fn(), - })); - - rerender(); + const { queryByText } = renderWithProvider(); await waitFor(() => { - expect(getByTestId('ledger-connect-mock')).toBeTruthy(); + expect(queryByText('Fetch failed')).toBeOnTheScreen(); }); }); }); - describe('getDisplayErrorMessage', () => { - it('shows user-friendly message for ETH app not open error during nextPage', async () => { - const { getByTestId } = await renderAndConnect(); - - // Mock error with 0x6d00 status code - mockGetLedgerAccountsByOperation.mockRejectedValueOnce( - new Error('Error with status code 0x6d00'), - ); - - await act(async () => { - const nextButton = getByTestId(ACCOUNT_SELECTOR_NEXT_BUTTON); - fireEvent.press(nextButton); - }); - - // The error should be displayed with user-friendly message - await waitFor(() => { - expect(mockGetLedgerAccountsByOperation).toHaveBeenCalledWith( - PAGINATION_OPERATIONS.GET_NEXT_PAGE, - ); - }); - }); - - it('shows original error message for non-ETH-app errors', async () => { - const { getByTestId } = await renderAndConnect(); - - mockGetLedgerAccountsByOperation.mockRejectedValueOnce( - new Error('Network connection failed'), - ); + describe('Metrics Tracking', () => { + it('tracks account selector open event when accounts are loaded', async () => { + const mockBuilder = { + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({}), + }; + mockCreateEventBuilder.mockReturnValue(mockBuilder); - await act(async () => { - const nextButton = getByTestId(ACCOUNT_SELECTOR_NEXT_BUTTON); - fireEvent.press(nextButton); - }); + await renderAndWaitForAccounts(); await waitFor(() => { - expect(mockGetLedgerAccountsByOperation).toHaveBeenCalledWith( - PAGINATION_OPERATIONS.GET_NEXT_PAGE, + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_ACCOUNT_SELECTOR_OPEN, ); + expect(mockBuilder.addProperties).toHaveBeenCalledWith({ + device_type: HardwareDeviceTypes.LEDGER, + device_model: 'Nano X', + }); }); }); }); describe('Account Selector View', () => { it('renders account selector with correct elements', async () => { - const { queryByText, getByTestId } = await renderAndConnect(); - - // Verify main elements are rendered - expect(queryByText('Select an account')).toBeTruthy(); - expect(queryByText('Select HD Path')).toBeTruthy(); - expect(getByTestId(ACCOUNT_SELECTOR_NEXT_BUTTON)).toBeTruthy(); - expect(getByTestId(ACCOUNT_SELECTOR_PREVIOUS_BUTTON)).toBeTruthy(); - expect(getByTestId(ACCOUNT_SELECTOR_FORGET_BUTTON)).toBeTruthy(); - }); + const { queryByText, getByTestId } = await renderAndWaitForAccounts(); - it('displays close button that calls navigation goBack', async () => { - await renderAndConnect(); - - // The component should have a close button - looking for MaterialIcon close - // The close button has a TouchableOpacity wrapping the close icon - // Note: We can't easily test this without knowing the exact testID - // But we can verify the navigation.goBack mock is available - expect(mockedGoBack).toBeDefined(); + expect(queryByText('Select an account')).toBeOnTheScreen(); + expect(queryByText('Select HD Path')).toBeOnTheScreen(); + expect(getByTestId(ACCOUNT_SELECTOR_NEXT_BUTTON)).toBeOnTheScreen(); + expect(getByTestId(ACCOUNT_SELECTOR_PREVIOUS_BUTTON)).toBeOnTheScreen(); + expect(getByTestId(ACCOUNT_SELECTOR_FORGET_BUTTON)).toBeOnTheScreen(); }); it('displays HD path selector dropdown', async () => { - const { getByTestId } = await renderAndConnect(); + const { getByTestId } = await renderAndWaitForAccounts(); - expect(getByTestId(SELECT_DROP_DOWN)).toBeTruthy(); + expect(getByTestId(SELECT_DROP_DOWN)).toBeOnTheScreen(); }); }); describe('Pagination', () => { - it('calls getLedgerAccountsByOperation with GET_NEXT_PAGE on next button press', async () => { - const { getByTestId } = await renderAndConnect(); + it('fetches next page of accounts', async () => { + const { getByTestId } = await renderAndWaitForAccounts(); - // Clear previous calls mockGetLedgerAccountsByOperation.mockClear(); mockGetLedgerAccountsByOperation.mockResolvedValue(mockAccounts); await act(async () => { - const nextButton = getByTestId(ACCOUNT_SELECTOR_NEXT_BUTTON); - fireEvent.press(nextButton); + fireEvent.press(getByTestId(ACCOUNT_SELECTOR_NEXT_BUTTON)); }); await waitFor(() => { @@ -485,16 +376,14 @@ describe('LedgerSelectAccount', () => { }); }); - it('calls getLedgerAccountsByOperation with GET_PREVIOUS_PAGE on prev button press', async () => { - const { getByTestId } = await renderAndConnect(); + it('fetches previous page of accounts', async () => { + const { getByTestId } = await renderAndWaitForAccounts(); - // Clear previous calls mockGetLedgerAccountsByOperation.mockClear(); mockGetLedgerAccountsByOperation.mockResolvedValue(mockAccounts); await act(async () => { - const prevButton = getByTestId(ACCOUNT_SELECTOR_PREVIOUS_BUTTON); - fireEvent.press(prevButton); + fireEvent.press(getByTestId(ACCOUNT_SELECTOR_PREVIOUS_BUTTON)); }); await waitFor(() => { @@ -504,295 +393,246 @@ describe('LedgerSelectAccount', () => { }); }); - it('handles error during nextPage pagination', async () => { - const { getByTestId } = await renderAndConnect(); + it('shows inline error on pagination error', async () => { + const { getByTestId, queryByText } = await renderAndWaitForAccounts(); mockGetLedgerAccountsByOperation.mockClear(); mockGetLedgerAccountsByOperation.mockRejectedValueOnce( - new Error('Error with status code 0x6d00'), + new Error('Pagination failed'), ); await act(async () => { - const nextButton = getByTestId(ACCOUNT_SELECTOR_NEXT_BUTTON); - fireEvent.press(nextButton); + fireEvent.press(getByTestId(ACCOUNT_SELECTOR_NEXT_BUTTON)); }); await waitFor(() => { - expect(mockGetLedgerAccountsByOperation).toHaveBeenCalledWith( - PAGINATION_OPERATIONS.GET_NEXT_PAGE, - ); + expect(queryByText('Pagination failed')).toBeOnTheScreen(); }); }); - it('handles error during prevPage pagination', async () => { - const { getByTestId } = await renderAndConnect(); - - mockGetLedgerAccountsByOperation.mockClear(); - mockGetLedgerAccountsByOperation.mockRejectedValueOnce( - new Error('Network error'), - ); + it('shows blocking modal during pagination', async () => { + const { getByTestId, queryByText } = await renderAndWaitForAccounts(); - await act(async () => { - const prevButton = getByTestId(ACCOUNT_SELECTOR_PREVIOUS_BUTTON); - fireEvent.press(prevButton); + let resolvePromise: ((value: unknown) => void) | undefined; + const slowPromise = new Promise((resolve) => { + resolvePromise = resolve; }); + mockGetLedgerAccountsByOperation.mockReturnValue(slowPromise); - await waitFor(() => { - expect(mockGetLedgerAccountsByOperation).toHaveBeenCalledWith( - PAGINATION_OPERATIONS.GET_PREVIOUS_PAGE, - ); + await act(async () => { + fireEvent.press(getByTestId(ACCOUNT_SELECTOR_NEXT_BUTTON)); }); - }); - it('displays user-friendly error for ETH app not open during pagination', async () => { - const { getByTestId } = await renderAndConnect(); - - mockGetLedgerAccountsByOperation.mockClear(); - mockGetLedgerAccountsByOperation.mockRejectedValueOnce( - new Error('Error with status code 0x6e00'), - ); + expect(queryByText('Please wait')).toBeOnTheScreen(); await act(async () => { - const nextButton = getByTestId(ACCOUNT_SELECTOR_NEXT_BUTTON); - fireEvent.press(nextButton); - }); - - // Wait for error state to be processed - await waitFor(() => { - expect(mockGetLedgerAccountsByOperation).toHaveBeenCalled(); + resolvePromise?.(mockAccounts); }); }); }); describe('onUnlock', () => { - it('unlocks selected accounts and navigates on success', async () => { - const mockBuilder = { - addProperties: jest.fn().mockReturnThis(), - build: jest.fn().mockReturnValue({}), - }; - mockCreateEventBuilder.mockReturnValue(mockBuilder); + const selectFirstAccountAndUnlock = async ( + result: ReturnType, + ) => { + const checkboxes = result.getAllByRole('checkbox'); + await act(async () => { + fireEvent(checkboxes[0], 'valueChange', true); + }); + await act(async () => { + fireEvent.press(result.getByText('Unlock')); + }); + }; + + it('unlocks selected accounts and navigates back on success', async () => { + mockGetConnectedDevicesCount.mockResolvedValue(2); + mockGetHDPath.mockResolvedValue(LEDGER_LIVE_PATH); + mockGetLedgerAccounts.mockResolvedValue([]); - const { getByText } = await renderAndConnect(); + const result = await renderAndWaitForAccounts(); - // The unlock button should be initially disabled (no accounts selected) - const unlockButton = getByText('Unlock'); - expect(unlockButton).toBeTruthy(); + await selectFirstAccountAndUnlock(result); - // Trigger unlock by calling the onUnlock callback - // This happens when user selects accounts and presses unlock - // The unlock button triggers setBlockingModalVisible(true) and sets unlockAccounts - // which then triggers onAnimationCompleted -> onUnlock + await waitFor(() => { + expect(mockEnsureDeviceReady).toHaveBeenCalledWith('test-device-id'); + expect(mockUnlockLedgerWalletAccount).toHaveBeenCalledWith(0); + expect(mockedNavDispatch).toHaveBeenCalled(); + }); }); - it('calls unlockLedgerWalletAccount for each selected account', async () => { - mockGetConnectedDevicesCount.mockResolvedValue(2); - mockGetHDPath.mockResolvedValue(LEDGER_LIVE_PATH); - mockGetLedgerAccounts.mockResolvedValue([]); + it('does nothing when ensureDeviceReady returns false', async () => { + mockEnsureDeviceReady + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); - await renderAndConnect(); + const result = await renderAndWaitForAccounts(); - // After unlock flow completes, verify the function was called correctly - await mockUnlockLedgerWalletAccount(0); - await mockUnlockLedgerWalletAccount(1); + await selectFirstAccountAndUnlock(result); - expect(mockUnlockLedgerWalletAccount).toHaveBeenCalledWith(0); - expect(mockUnlockLedgerWalletAccount).toHaveBeenCalledWith(1); + await waitFor(() => { + expect(mockUnlockLedgerWalletAccount).not.toHaveBeenCalled(); + }); }); - it('tracks hardware wallet add account event on successful unlock', async () => { - const mockBuilder = { - addProperties: jest.fn().mockReturnThis(), - build: jest.fn().mockReturnValue({}), - }; - mockCreateEventBuilder.mockReturnValue(mockBuilder); - mockGetConnectedDevicesCount.mockResolvedValue(2); + it('shows inline error when ensureDeviceReady throws during unlock', async () => { + mockEnsureDeviceReady + .mockResolvedValueOnce(true) + .mockRejectedValueOnce(new Error('Transport disconnected')); + + const result = await renderAndWaitForAccounts(); - await renderAndConnect(); + await selectFirstAccountAndUnlock(result); - // The tracking should include device info and path - expect(mockCreateEventBuilder).toHaveBeenCalled(); + await waitFor(() => { + expect(result.queryByText('Transport disconnected')).toBeOnTheScreen(); + }); }); - it('handles error during unlock', async () => { - mockUnlockLedgerWalletAccount.mockRejectedValue( + it('shows inline error on unlock failure', async () => { + mockUnlockLedgerWalletAccount.mockRejectedValueOnce( new Error('Unlock failed'), ); - await renderAndConnect(); + const result = await renderAndWaitForAccounts(); - // Verify error handling setup - await expect(mockUnlockLedgerWalletAccount(0)).rejects.toThrow( - 'Unlock failed', - ); + await selectFirstAccountAndUnlock(result); + + await waitFor(() => { + expect(result.queryByText('Unlock failed')).toBeOnTheScreen(); + }); }); - it('tracks hardware wallet error event on unlock failure', async () => { + it('tracks error analytics on unlock failure', async () => { const mockBuilder = { addProperties: jest.fn().mockReturnThis(), build: jest.fn().mockReturnValue({}), }; mockCreateEventBuilder.mockReturnValue(mockBuilder); - mockUnlockLedgerWalletAccount.mockRejectedValue( - new Error('Error with status code 0x6d00'), - ); - - await renderAndConnect(); - - // Error tracking should be called - expect(mockCreateEventBuilder).toHaveBeenCalled(); - }); - - it('displays user-friendly error message for ETH app not open', async () => { - mockUnlockLedgerWalletAccount.mockRejectedValue( - new Error('Error with status code 0x6d00'), - ); - - await renderAndConnect(); + mockUnlockLedgerWalletAccount.mockRejectedValueOnce(new Error('0x6d00')); - // When unlock fails with ETH app not open error, user-friendly message should display - await expect(mockUnlockLedgerWalletAccount(0)).rejects.toThrow('0x6d00'); - }); - }); + const result = await renderAndWaitForAccounts(); - describe('onForget', () => { - it('calls forgetLedger and dispatches setReloadAccounts', async () => { - const { getByTestId } = await renderAndConnect(); + await selectFirstAccountAndUnlock(result); - // Press the forget button - await act(async () => { - const forgetButton = getByTestId(ACCOUNT_SELECTOR_FORGET_BUTTON); - fireEvent.press(forgetButton); + await waitFor(() => { + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_ERROR, + ); + expect(mockBuilder.addProperties).toHaveBeenCalledWith( + expect.objectContaining({ error: '0x6d00' }), + ); }); - - // The modal should be visible, but forgetLedger is called in onAnimationCompleted - // We can verify the function is set up correctly - expect(mockForgetLedger).toBeDefined(); }); - it('tracks hardware wallet forgotten event', async () => { + it('tracks add-account analytics on success', async () => { const mockBuilder = { addProperties: jest.fn().mockReturnThis(), build: jest.fn().mockReturnValue({}), }; mockCreateEventBuilder.mockReturnValue(mockBuilder); + mockGetConnectedDevicesCount.mockResolvedValue(3); + mockGetHDPath.mockResolvedValue(LEDGER_LIVE_PATH); + mockGetLedgerAccounts.mockResolvedValue([]); - const { getByTestId } = await renderAndConnect(); + const result = await renderAndWaitForAccounts(); - await act(async () => { - const forgetButton = getByTestId(ACCOUNT_SELECTOR_FORGET_BUTTON); - fireEvent.press(forgetButton); - }); + await selectFirstAccountAndUnlock(result); - // Tracking event builder should have been called - expect(mockCreateEventBuilder).toHaveBeenCalled(); + await waitFor(() => { + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.HARDWARE_WALLET_ADD_ACCOUNT, + ); + expect(mockBuilder.addProperties).toHaveBeenCalledWith( + expect.objectContaining({ + device_type: HardwareDeviceTypes.LEDGER, + hd_path: expect.any(String), + connected_device_count: '3', + }), + ); + }); }); + }); - it('sets forgetDevice state and shows blocking modal', async () => { - const { getByTestId, queryByText } = await renderAndConnect(); + describe('onForget', () => { + it('shows blocking modal when forget button is pressed', async () => { + const { getByTestId, queryByText } = await renderAndWaitForAccounts(); await act(async () => { - const forgetButton = getByTestId(ACCOUNT_SELECTOR_FORGET_BUTTON); - fireEvent.press(forgetButton); + fireEvent.press(getByTestId(ACCOUNT_SELECTOR_FORGET_BUTTON)); }); - // The "Please wait" text should appear in blocking modal await waitFor(() => { - expect(queryByText('Please wait')).toBeTruthy(); + expect(queryByText('Please wait')).toBeOnTheScreen(); }); }); }); describe('onAnimationCompleted', () => { - it('does nothing when blocking modal is not visible', async () => { + it('does not call forgetLedger when blocking modal is not visible', async () => { renderWithProvider(); - // When modal is not visible, no action should be taken await waitFor(() => { expect(mockKeyringController.getAccounts).toHaveBeenCalled(); }); - // forgetLedger should not be called expect(mockForgetLedger).not.toHaveBeenCalled(); }); + }); - it('calls onForget when forgetDevice is true', async () => { - const { getByTestId } = await renderAndConnect(); - - // Press forget button to set forgetDevice = true and show modal + describe('updateNewLegacyAccountsLabel', () => { + const selectFirstAccountAndUnlock = async ( + result: ReturnType, + ) => { + const checkboxes = result.getAllByRole('checkbox'); await act(async () => { - const forgetButton = getByTestId(ACCOUNT_SELECTOR_FORGET_BUTTON); - fireEvent.press(forgetButton); + fireEvent(checkboxes[0], 'valueChange', true); }); - - // Wait for the blocking modal to appear - await waitFor(() => { - // The animation completed callback should trigger onForget - expect(mockForgetLedger).toBeDefined(); + await act(async () => { + fireEvent.press(result.getByText('Unlock')); }); - }); - }); - - describe('onSelectedPathChanged', () => { - it('renders HD path selector with default Ledger Live path', async () => { - const { getByTestId } = await renderAndConnect(); - - const dropdown = getByTestId(SELECT_DROP_DOWN); - expect(dropdown).toBeTruthy(); - }); - - it('calls setHDPath when path is changed', async () => { - await renderAndConnect(); - - // When a path is selected, setHDPath should be called - await mockSetHDPath(LEDGER_LEGACY_PATH); - expect(mockSetHDPath).toHaveBeenCalledWith(LEDGER_LEGACY_PATH); - }); + }; - it('refetches accounts when path changes', async () => { - await renderAndConnect(); - - // Clear mock calls - mockGetLedgerAccountsByOperation.mockClear(); - mockGetLedgerAccountsByOperation.mockResolvedValue(mockAccounts); - - // Changing path should trigger re-fetch of accounts - // This is handled by the useEffect that watches selectedOption - await mockSetHDPath(LEDGER_BIP44_PATH); - - expect(mockSetHDPath).toHaveBeenCalledWith(LEDGER_BIP44_PATH); - }); - }); - - describe('updateNewLegacyAccountsLabel', () => { - it('updates account labels for legacy path', async () => { + it('appends legacy label to new accounts when on legacy path', async () => { mockGetHDPath.mockResolvedValue(LEDGER_LEGACY_PATH); const newAccount = '0xnewaccount1234567890abcdef1234567890abcdef'; + const newAccountName = 'Ledger 1'; + mockGetLedgerAccounts.mockResolvedValue([newAccount]); (mockAccountsController.getAccountByAddress as jest.Mock).mockReturnValue( { id: 'account-id', - metadata: { name: 'Account 1' }, + metadata: { name: newAccountName }, }, ); - await renderAndConnect(); + const result = await renderAndWaitForAccounts(); - // When unlock is called with legacy path, labels should be updated - const path = await mockGetHDPath(); - expect(path).toBe(LEDGER_LEGACY_PATH); + await selectFirstAccountAndUnlock(result); + + await waitFor(() => { + expect(mockAccountsController.setAccountName).toHaveBeenCalledWith( + 'account-id', + expect.stringContaining(newAccountName), + ); + }); }); it('does not update labels for non-legacy paths', async () => { mockGetHDPath.mockResolvedValue(LEDGER_LIVE_PATH); mockGetLedgerAccounts.mockResolvedValue([]); - await renderAndConnect(); + const result = await renderAndWaitForAccounts(); + + await selectFirstAccountAndUnlock(result); + + await waitFor(() => { + expect(mockUnlockLedgerWalletAccount).toHaveBeenCalled(); + }); - // For non-legacy paths, setAccountName should not be called expect(mockAccountsController.setAccountName).not.toHaveBeenCalled(); }); - it('handles case when account is not found in AccountsController', async () => { + it('skips accounts not found in AccountsController', async () => { mockGetHDPath.mockResolvedValue(LEDGER_LEGACY_PATH); const newAccount = '0xnewaccount1234567890abcdef1234567890abcdef'; mockGetLedgerAccounts.mockResolvedValue([newAccount]); @@ -800,27 +640,15 @@ describe('LedgerSelectAccount', () => { undefined, ); - await renderAndConnect(); - - // When account is not found, setAccountName should not be called - expect(mockAccountsController.setAccountName).not.toHaveBeenCalled(); - }); + const result = await renderAndWaitForAccounts(); - it('appends legacy label to new accounts on legacy path', async () => { - mockGetHDPath.mockResolvedValue(LEDGER_LEGACY_PATH); - const newAccount = '0xnewaccount1234567890abcdef1234567890abcdef'; - mockGetLedgerAccounts.mockResolvedValue([newAccount]); - (mockAccountsController.getAccountByAddress as jest.Mock).mockReturnValue( - { - id: 'account-id', - metadata: { name: 'Ledger 1' }, - }, - ); + await selectFirstAccountAndUnlock(result); - await renderAndConnect(); + await waitFor(() => { + expect(mockUnlockLedgerWalletAccount).toHaveBeenCalled(); + }); - // Verify the controller method is available for label updates - expect(mockAccountsController.setAccountName).toBeDefined(); + expect(mockAccountsController.setAccountName).not.toHaveBeenCalled(); }); }); @@ -836,295 +664,61 @@ describe('LedgerSelectAccount', () => { it('correctly maps Ledger BIP44 path constant', () => { expect(LEDGER_BIP44_PATH).toBe("m/44'/60'/0'/0"); }); - - it('uses path string in analytics when unlocking', async () => { - const mockBuilder = { - addProperties: jest.fn().mockReturnThis(), - build: jest.fn().mockReturnValue({}), - }; - mockCreateEventBuilder.mockReturnValue(mockBuilder); - - await renderAndConnect(); - - // When tracking analytics, the path string should be included - expect(mockCreateEventBuilder).toHaveBeenCalled(); - }); }); describe('ledgerModelName', () => { - it('returns undefined when no device is selected', () => { - const { ledgerDeviceUUIDToModelName } = jest.requireMock( - '../../../util/hardwareWallet/deviceNameUtils', - ); - - // Clear previous calls - ledgerDeviceUUIDToModelName.mockClear(); - - // When no device is selected, ledgerDeviceUUIDToModelName should not be called - renderWithProvider(); - - expect(ledgerDeviceUUIDToModelName).not.toHaveBeenCalled(); - }); - - it('derives model name from device UUID when device is selected', async () => { - const { ledgerDeviceUUIDToModelName } = jest.requireMock( + it('derives model name from selectedDevice via sanitizeDeviceName', async () => { + const { sanitizeDeviceName } = jest.requireMock( '../../../util/hardwareWallet/deviceNameUtils', ); - await renderAndConnect(); - - // After connecting, the model name should be derived from the device UUID - expect(ledgerDeviceUUIDToModelName).toHaveBeenCalledWith('test-uuid'); - }); - }); - - describe('Close button', () => { - it('close button is rendered in account selector view', async () => { - await renderAndConnect(); - - // The close button uses MaterialIcon with name "close" - // We verify navigation.goBack is available for the close action - expect(mockedGoBack).toBeDefined(); - }); - }); - - describe('selectedOption effect', () => { - it('fetches accounts when selectedOption changes and accounts exist', async () => { - await renderAndConnect(); - - // Clear previous calls - mockGetLedgerAccountsByOperation.mockClear(); - mockGetLedgerAccountsByOperation.mockResolvedValue(mockAccounts); - - // The effect watches accounts.length and selectedOption - // When path changes, it should refetch accounts - await mockSetHDPath(LEDGER_LEGACY_PATH); - - expect(mockSetHDPath).toHaveBeenCalledWith(LEDGER_LEGACY_PATH); - }); - - it('handles error when fetching accounts fails on path change', async () => { - await renderAndConnect(); - - mockGetLedgerAccountsByOperation.mockRejectedValueOnce( - new Error('Error with status code 0x6e00'), - ); - - // The error should be caught and setErrorMsg should be called - await expect(mockGetLedgerAccountsByOperation()).rejects.toThrow( - '0x6e00', - ); - }); - }); - - describe('onConnectHardware', () => { - it('fetches first page of accounts', async () => { - const { getByTestId } = renderWithProvider(); - - mockGetLedgerAccountsByOperation.mockResolvedValue(mockAccounts); - - await act(async () => { - const connectButton = getByTestId('connect-ledger-button'); - fireEvent.press(connectButton); - }); - - await waitFor(() => { - expect(mockGetLedgerAccountsByOperation).toHaveBeenCalledWith( - PAGINATION_OPERATIONS.GET_FIRST_PAGE, - ); - }); - }); - - it('clears error message before fetching accounts', async () => { - const { getByTestId, queryByText } = renderWithProvider( - , - ); - - mockGetLedgerAccountsByOperation.mockResolvedValue(mockAccounts); - - await act(async () => { - const connectButton = getByTestId('connect-ledger-button'); - fireEvent.press(connectButton); - }); - - // After successful connection, account selector should be visible - await waitFor(() => { - expect(queryByText('Select an account')).toBeTruthy(); - }); - }); - - it('sets accounts state after successful fetch', async () => { - const { getByTestId, queryByText } = renderWithProvider( - , - ); - - mockGetLedgerAccountsByOperation.mockResolvedValue(mockAccounts); - - await act(async () => { - const connectButton = getByTestId('connect-ledger-button'); - fireEvent.press(connectButton); - }); - - // Accounts should be loaded and account selector visible - await waitFor(() => { - expect(queryByText('Select an account')).toBeTruthy(); - }); - }); - }); - - describe('ETH App Not Open Error Handling', () => { - it('handles 0x6d00 status code during pagination', async () => { - const { getByTestId } = await renderAndConnect(); - - mockGetLedgerAccountsByOperation.mockClear(); - mockGetLedgerAccountsByOperation.mockRejectedValueOnce( - new Error('Error with status code 0x6d00'), - ); - - await act(async () => { - const nextButton = getByTestId(ACCOUNT_SELECTOR_NEXT_BUTTON); - fireEvent.press(nextButton); - }); - - await waitFor(() => { - expect(mockGetLedgerAccountsByOperation).toHaveBeenCalled(); - }); - }); - - it('handles 0x6e00 status code during pagination', async () => { - const { getByTestId } = await renderAndConnect(); - - mockGetLedgerAccountsByOperation.mockClear(); - mockGetLedgerAccountsByOperation.mockRejectedValueOnce( - new Error('Error with status code 0x6e00'), - ); - - await act(async () => { - const nextButton = getByTestId(ACCOUNT_SELECTOR_NEXT_BUTTON); - fireEvent.press(nextButton); - }); + await renderAndWaitForAccounts(); - await waitFor(() => { - expect(mockGetLedgerAccountsByOperation).toHaveBeenCalled(); - }); + expect(sanitizeDeviceName).toHaveBeenCalledWith('Nano X'); }); - it('handles 0x6e01 status code during pagination', async () => { - const { getByTestId } = await renderAndConnect(); - - mockGetLedgerAccountsByOperation.mockClear(); - mockGetLedgerAccountsByOperation.mockRejectedValueOnce( - new Error('Error with status code 0x6e01'), - ); - - await act(async () => { - const prevButton = getByTestId(ACCOUNT_SELECTOR_PREVIOUS_BUTTON); - fireEvent.press(prevButton); - }); - - await waitFor(() => { - expect(mockGetLedgerAccountsByOperation).toHaveBeenCalled(); + it('returns undefined when no device is selected', () => { + mockUseHardwareWallet.mockReturnValue({ + ...defaultHardwareWalletValues, + deviceSelection: { + devices: [], + selectedDevice: null, + isScanning: false, + scanError: null, + }, }); - }); - - it('handles 0x6511 status code during unlock', async () => { - mockUnlockLedgerWalletAccount.mockRejectedValueOnce( - new Error('Error with status code 0x6511'), - ); - - await renderAndConnect(); - - await expect(mockUnlockLedgerWalletAccount(0)).rejects.toThrow('0x6511'); - }); - - it('handles 0x6700 status code during unlock', async () => { - mockUnlockLedgerWalletAccount.mockRejectedValueOnce( - new Error('Error with status code 0x6700'), - ); - await renderAndConnect(); - - await expect(mockUnlockLedgerWalletAccount(0)).rejects.toThrow('0x6700'); - }); - - it('handles 0x650f status code during unlock', async () => { - mockUnlockLedgerWalletAccount.mockRejectedValueOnce( - new Error('Error with status code 0x650f'), + const { sanitizeDeviceName } = jest.requireMock( + '../../../util/hardwareWallet/deviceNameUtils', ); + sanitizeDeviceName.mockClear(); - await renderAndConnect(); - - await expect(mockUnlockLedgerWalletAccount(0)).rejects.toThrow('0x650f'); - }); - }); - - describe('Multiple Account Unlock', () => { - beforeEach(() => { - mockUnlockLedgerWalletAccount.mockResolvedValue(undefined); - mockGetConnectedDevicesCount.mockResolvedValue(1); - mockGetHDPath.mockResolvedValue(LEDGER_LIVE_PATH); - mockGetLedgerAccounts.mockResolvedValue([]); - }); - - it('unlocks multiple accounts sequentially', async () => { - await renderAndConnect(); - - // Simulate unlocking multiple accounts - const accountIndexes = [0, 1, 2]; - for (const index of accountIndexes) { - await mockUnlockLedgerWalletAccount(index); - } + renderWithProvider(); - expect(mockUnlockLedgerWalletAccount).toHaveBeenCalledTimes(3); - expect(mockUnlockLedgerWalletAccount).toHaveBeenCalledWith(0); - expect(mockUnlockLedgerWalletAccount).toHaveBeenCalledWith(1); - expect(mockUnlockLedgerWalletAccount).toHaveBeenCalledWith(2); + expect(sanitizeDeviceName).not.toHaveBeenCalled(); }); - it('stops unlock process on first error', async () => { - await renderAndConnect(); + it('includes model name in analytics events', async () => { + const mockBuilder = { + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({}), + }; + mockCreateEventBuilder.mockReturnValue(mockBuilder); - mockUnlockLedgerWalletAccount - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error('Unlock failed')); + await renderAndWaitForAccounts(); - await mockUnlockLedgerWalletAccount(0); - await expect(mockUnlockLedgerWalletAccount(1)).rejects.toThrow( - 'Unlock failed', + expect(mockBuilder.addProperties).toHaveBeenCalledWith( + expect.objectContaining({ + device_model: 'Nano X', + }), ); }); - - it('calls navigation.pop(2) after successful unlock', async () => { - await renderAndConnect(); - - // After successful unlock, navigation.pop(2) should be called - expect(mockedPop).toBeDefined(); - }); - }); - - describe('HD Path Options', () => { - it('renders HD path dropdown with correct options', async () => { - const { getByTestId, queryByText } = await renderAndConnect(); - - // Verify the dropdown exists - expect(getByTestId(SELECT_DROP_DOWN)).toBeTruthy(); - - // The default selected option should be Ledger Live (label from i18n) - expect(queryByText('Ledger Live')).toBeTruthy(); - }); - - it('all HD paths are valid formats', () => { - expect(LEDGER_LIVE_PATH.startsWith('m/')).toBe(true); - expect(LEDGER_LEGACY_PATH.startsWith('m/')).toBe(true); - expect(LEDGER_BIP44_PATH.startsWith('m/')).toBe(true); - }); }); describe('Pagination Operations', () => { - it('uses correct pagination operations', async () => { - const { getByTestId } = await renderAndConnect(); + it('uses correct pagination operations for next and previous', async () => { + const { getByTestId } = await renderAndWaitForAccounts(); - // Test next page mockGetLedgerAccountsByOperation.mockClear(); mockGetLedgerAccountsByOperation.mockResolvedValue(mockAccounts); @@ -1136,7 +730,6 @@ describe('LedgerSelectAccount', () => { PAGINATION_OPERATIONS.GET_NEXT_PAGE, ); - // Test previous page mockGetLedgerAccountsByOperation.mockClear(); mockGetLedgerAccountsByOperation.mockResolvedValue(mockAccounts); @@ -1150,300 +743,37 @@ describe('LedgerSelectAccount', () => { }); }); - describe('Device Model Name', () => { - it('derives model name from device service UUID', async () => { - const { ledgerDeviceUUIDToModelName } = jest.requireMock( - '../../../util/hardwareWallet/deviceNameUtils', - ); - - await renderAndConnect(); - - expect(ledgerDeviceUUIDToModelName).toHaveBeenCalledWith('test-uuid'); - expect(ledgerDeviceUUIDToModelName('test-uuid')).toBe('Nano X'); - }); - - it('includes model name in analytics events', async () => { - const mockBuilder = { - addProperties: jest.fn().mockReturnThis(), - build: jest.fn().mockReturnValue({}), - }; - mockCreateEventBuilder.mockReturnValue(mockBuilder); - - await renderAndConnect(); - - expect(mockBuilder.addProperties).toHaveBeenCalledWith( - expect.objectContaining({ - device_model: 'Nano X', - }), - ); - }); - }); - - describe('Legacy Account Labeling', () => { - it('checks HD path when updating labels', async () => { - mockGetHDPath.mockResolvedValue(LEDGER_LEGACY_PATH); - mockGetLedgerAccounts.mockResolvedValue(['0xnewaccount']); - - await renderAndConnect(); - - // After unlock with legacy path, HD path should be checked - const path = await mockGetHDPath(); - expect(path).toBe(LEDGER_LEGACY_PATH); - }); - - it('gets ledger accounts to find newly added ones', async () => { - mockGetHDPath.mockResolvedValue(LEDGER_LEGACY_PATH); - mockGetLedgerAccounts.mockResolvedValue(['0xnewaccount']); - - await renderAndConnect(); - - // getLedgerAccounts should be callable to find new accounts - await mockGetLedgerAccounts(); - expect(mockGetLedgerAccounts).toHaveBeenCalled(); - }); - - it('uses AccountsController to update account names', async () => { - mockGetHDPath.mockResolvedValue(LEDGER_LEGACY_PATH); - const newAccount = '0xnewaccount1234567890abcdef1234567890abcdef'; - mockGetLedgerAccounts.mockResolvedValue([newAccount]); - (mockAccountsController.getAccountByAddress as jest.Mock).mockReturnValue( - { - id: 'account-id', - metadata: { name: 'Ledger 1' }, - }, - ); - - await renderAndConnect(); - - // AccountsController methods should be available - expect(mockAccountsController.getAccountByAddress).toBeDefined(); - expect(mockAccountsController.setAccountName).toBeDefined(); - }); - }); - - describe('Connected Devices Count', () => { - it('includes connected devices count in analytics', async () => { - const mockBuilder = { - addProperties: jest.fn().mockReturnThis(), - build: jest.fn().mockReturnValue({}), - }; - mockCreateEventBuilder.mockReturnValue(mockBuilder); - mockGetConnectedDevicesCount.mockResolvedValue(3); - - await renderAndConnect(); - - // getConnectedDevicesCount should be called during unlock flow - expect(mockGetConnectedDevicesCount).toBeDefined(); - }); - }); - describe('Forget Ledger Flow', () => { it('triggers forget flow when forget button is pressed', async () => { - const { getByTestId, queryByText } = await renderAndConnect(); + const { getByTestId, queryByText } = await renderAndWaitForAccounts(); await act(async () => { - const forgetButton = getByTestId(ACCOUNT_SELECTOR_FORGET_BUTTON); - fireEvent.press(forgetButton); + fireEvent.press(getByTestId(ACCOUNT_SELECTOR_FORGET_BUTTON)); }); - // Blocking modal should appear await waitFor(() => { - expect(queryByText('Please wait')).toBeTruthy(); - }); - }); - - it('dispatches setReloadAccounts after forget', async () => { - const { getByTestId } = await renderAndConnect(); - - await act(async () => { - const forgetButton = getByTestId(ACCOUNT_SELECTOR_FORGET_BUTTON); - fireEvent.press(forgetButton); - }); - - // Dispatch should be available for setReloadAccounts - expect(mockedDispatch).toBeDefined(); - }); - - it('navigates back using StackActions.pop(2) after forget', async () => { - const { getByTestId } = await renderAndConnect(); - - await act(async () => { - const forgetButton = getByTestId(ACCOUNT_SELECTOR_FORGET_BUTTON); - fireEvent.press(forgetButton); - }); - - // Navigation dispatch should be available - expect(mockedNavDispatch).toBeDefined(); - }); - }); - - describe('useLedgerBluetooth hook states', () => { - it('passes selectedDevice id to useLedgerBluetooth', async () => { - await renderAndConnect(); - - // After connecting, useLedgerBluetooth should be called with device id - expect(useLedgerBluetooth).toHaveBeenCalledWith('test-device-id'); - }); - - it('handles isSendingLedgerCommands true state', () => { - ( - useLedgerBluetooth as unknown as jest.MockedFunction< - typeof useLedgerBluetooth - > - ).mockImplementation(() => ({ - isSendingLedgerCommands: true, - isAppLaunchConfirmationNeeded: false, - ledgerLogicToRun: jest.fn(), - error: undefined, - cleanupBluetoothConnection: jest.fn(), - })); - - const { getByTestId } = renderWithProvider(); - expect(getByTestId('ledger-connect-mock')).toBeTruthy(); - }); - - it('handles isAppLaunchConfirmationNeeded true state', () => { - ( - useLedgerBluetooth as unknown as jest.MockedFunction< - typeof useLedgerBluetooth - > - ).mockImplementation(() => ({ - isSendingLedgerCommands: false, - isAppLaunchConfirmationNeeded: true, - ledgerLogicToRun: jest.fn(), - error: undefined, - cleanupBluetoothConnection: jest.fn(), - })); - - const { getByTestId } = renderWithProvider(); - expect(getByTestId('ledger-connect-mock')).toBeTruthy(); - }); - }); - - describe('LedgerCommunicationErrors', () => { - it('renders LedgerConnect when LedgerDisconnected error occurs', () => { - ( - useLedgerBluetooth as unknown as jest.MockedFunction< - typeof useLedgerBluetooth - > - ).mockImplementation(() => ({ - isSendingLedgerCommands: false, - isAppLaunchConfirmationNeeded: false, - ledgerLogicToRun: jest.fn(), - error: LedgerCommunicationErrors.LedgerDisconnected, - cleanupBluetoothConnection: jest.fn(), - })); - - const { getByTestId } = renderWithProvider(); - expect(getByTestId('ledger-connect-mock')).toBeTruthy(); - expect(getByTestId('ledger-error')).toBeTruthy(); - }); - - it('renders LedgerConnect when EthAppNotOpen error occurs', () => { - ( - useLedgerBluetooth as unknown as jest.MockedFunction< - typeof useLedgerBluetooth - > - ).mockImplementation(() => ({ - isSendingLedgerCommands: false, - isAppLaunchConfirmationNeeded: false, - ledgerLogicToRun: jest.fn(), - error: LedgerCommunicationErrors.EthAppNotOpen, - cleanupBluetoothConnection: jest.fn(), - })); - - const { getByTestId } = renderWithProvider(); - expect(getByTestId('ledger-connect-mock')).toBeTruthy(); - }); - - it('renders LedgerConnect when UnknownError occurs', () => { - ( - useLedgerBluetooth as unknown as jest.MockedFunction< - typeof useLedgerBluetooth - > - ).mockImplementation(() => ({ - isSendingLedgerCommands: false, - isAppLaunchConfirmationNeeded: false, - ledgerLogicToRun: jest.fn(), - error: LedgerCommunicationErrors.UnknownError, - cleanupBluetoothConnection: jest.fn(), - })); - - const { getByTestId } = renderWithProvider(); - expect(getByTestId('ledger-connect-mock')).toBeTruthy(); - }); - - it('hides blocking modal when error occurs', async () => { - const { rerender, queryByText } = renderWithProvider( - , - ); - - // Simulate error occurring - ( - useLedgerBluetooth as unknown as jest.MockedFunction< - typeof useLedgerBluetooth - > - ).mockImplementation(() => ({ - isSendingLedgerCommands: false, - isAppLaunchConfirmationNeeded: false, - ledgerLogicToRun: jest.fn(), - error: LedgerCommunicationErrors.LedgerDisconnected, - cleanupBluetoothConnection: jest.fn(), - })); - - rerender(); - - // Blocking modal should not be visible when error occurs - expect(queryByText('Please wait')).toBeFalsy(); - }); - }); - - describe('showLoadingModal', () => { - it('shows loading modal during pagination', async () => { - const { getByTestId, queryByText } = await renderAndConnect(); - - // Mock a slow response - let resolvePromise: ((value: unknown) => void) | undefined; - const slowPromise = new Promise((resolve) => { - resolvePromise = resolve; - }); - mockGetLedgerAccountsByOperation.mockReturnValue(slowPromise); - - await act(async () => { - fireEvent.press(getByTestId(ACCOUNT_SELECTOR_NEXT_BUTTON)); - }); - - // Modal should show "Please wait" - expect(queryByText('Please wait')).toBeTruthy(); - - // Resolve the promise to complete the test - await act(async () => { - if (resolvePromise) { - resolvePromise(mockAccounts); - } + expect(queryByText('Please wait')).toBeOnTheScreen(); }); }); }); describe('Blocking Modal', () => { it('renders blocking modal with correct children', async () => { - const { getByTestId, queryByText } = await renderAndConnect(); + const { getByTestId, queryByText } = await renderAndWaitForAccounts(); - // Trigger forget to show blocking modal await act(async () => { fireEvent.press(getByTestId(ACCOUNT_SELECTOR_FORGET_BUTTON)); }); - // The modal should display "Please wait" text - expect(queryByText('Please wait')).toBeTruthy(); + expect(queryByText('Please wait')).toBeOnTheScreen(); }); }); - describe('Error Display', () => { - it('displays error message in account selector view', async () => { - const { getByTestId } = await renderAndConnect(); + describe('Error Handling during pagination', () => { + it('shows inline error on nextPage error', async () => { + const { getByTestId, queryByText } = await renderAndWaitForAccounts(); - // Trigger an error + mockGetLedgerAccountsByOperation.mockClear(); mockGetLedgerAccountsByOperation.mockRejectedValueOnce( new Error('Test error'), ); @@ -1452,34 +782,25 @@ describe('LedgerSelectAccount', () => { fireEvent.press(getByTestId(ACCOUNT_SELECTOR_NEXT_BUTTON)); }); - // The error should be captured and displayed await waitFor(() => { - expect(mockGetLedgerAccountsByOperation).toHaveBeenCalled(); + expect(queryByText('Test error')).toBeOnTheScreen(); }); }); - it('clears error when starting new operation', async () => { - const { getByTestId } = await renderAndConnect(); + it('shows inline error on prevPage error', async () => { + const { getByTestId, queryByText } = await renderAndWaitForAccounts(); - // First trigger an error + mockGetLedgerAccountsByOperation.mockClear(); mockGetLedgerAccountsByOperation.mockRejectedValueOnce( - new Error('Test error'), + new Error('Network error'), ); await act(async () => { - fireEvent.press(getByTestId(ACCOUNT_SELECTOR_NEXT_BUTTON)); - }); - - // Then trigger a successful operation - mockGetLedgerAccountsByOperation.mockResolvedValueOnce(mockAccounts); - - await act(async () => { - fireEvent.press(getByTestId(ACCOUNT_SELECTOR_NEXT_BUTTON)); + fireEvent.press(getByTestId(ACCOUNT_SELECTOR_PREVIOUS_BUTTON)); }); - // Error should be cleared await waitFor(() => { - expect(mockGetLedgerAccountsByOperation).toHaveBeenCalled(); + expect(queryByText('Network error')).toBeOnTheScreen(); }); }); }); diff --git a/app/components/Views/LedgerSelectAccount/index.tsx b/app/components/Views/LedgerSelectAccount/index.tsx index 3ee05bff299..10ce014f578 100644 --- a/app/components/Views/LedgerSelectAccount/index.tsx +++ b/app/components/Views/LedgerSelectAccount/index.tsx @@ -5,7 +5,13 @@ import React, { useRef, useState, } from 'react'; -import { Image, Text, TouchableOpacity, View } from 'react-native'; +import { + Image, + Text, + TouchableOpacity, + View, + ActivityIndicator, +} from 'react-native'; import Engine from '../../../core/Engine'; import AccountSelector from '../../UI/HardwareWallet/AccountSelector'; import BlockingActionModal from '../../UI/BlockingActionModal'; @@ -23,7 +29,6 @@ import { setHDPath, unlockLedgerWalletAccount, } from '../../../core/Ledger/Ledger'; -import LedgerConnect from '../LedgerConnect'; import { setReloadAccounts } from '../../../actions/accounts'; import { StackActions, useNavigation } from '@react-navigation/native'; import { useDispatch } from 'react-redux'; @@ -32,9 +37,6 @@ import createStyles from './index.styles'; import { HardwareDeviceTypes } from '../../../constants/keyringTypes'; import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; import PAGINATION_OPERATIONS from '../../../constants/pagination'; -import { Device as LedgerDevice } from '@ledgerhq/react-native-hw-transport-ble/lib/types'; -import { ledgerDeviceUUIDToModelName } from '../../../util/hardwareWallet/deviceNameUtils'; -import useLedgerBluetooth from '../../hooks/Ledger/useLedgerBluetooth'; import { LEDGER_BIP44_PATH, LEDGER_BIP44_STRING, @@ -48,17 +50,10 @@ import SelectOptionSheet from '../../UI/SelectOptionSheet'; import { AccountsController } from '@metamask/accounts-controller'; import { toFormattedAddress } from '../../../util/address'; import { getConnectedDevicesCount } from '../../../core/HardwareWallets/analytics'; -import { isEthAppNotOpenErrorMessage } from '../../../core/Ledger/ledgerErrors'; - -/** - * Check if error message indicates ETH app is not open and return user-friendly message - */ -const getDisplayErrorMessage = (errorMessage: string): string => { - if (isEthAppNotOpenErrorMessage(errorMessage)) { - return strings('ledger.eth_app_not_open_message'); - } - return errorMessage; -}; +import { useHardwareWallet } from '../../../core/HardwareWallet'; +import { HardwareWalletType } from '@metamask/hw-wallet-sdk'; +import { sanitizeDeviceName } from '../../../util/hardwareWallet/deviceNameUtils'; +import DevLogger from '../../../core/SDKConnect/utils/DevLogger'; interface OptionType { key: string; @@ -68,8 +63,6 @@ interface OptionType { const LedgerSelectAccount = () => { const navigation = useNavigation(); - const [selectedDevice, setSelectedDevice] = useState(null); - const [errorMsg, setErrorMsg] = useState(null); const dispatch = useDispatch(); const { colors } = useTheme(); const { trackEvent, createEventBuilder } = useAnalytics(); @@ -79,13 +72,15 @@ const LedgerSelectAccount = () => { ledgerDeviceDarkImage, ); + const { deviceId, deviceSelection, ensureDeviceReady, setTargetWalletType } = + useHardwareWallet(); + const ledgerModelName = useMemo(() => { - if (selectedDevice) { - const [bluetoothServiceId] = selectedDevice.serviceUUIDs; - return ledgerDeviceUUIDToModelName(bluetoothServiceId); + if (deviceSelection?.selectedDevice) { + return sanitizeDeviceName(deviceSelection.selectedDevice.name); } return undefined; - }, [selectedDevice]); + }, [deviceSelection?.selectedDevice]); const ledgerPathOptions: OptionType[] = useMemo( () => [ @@ -108,18 +103,6 @@ const LedgerSelectAccount = () => { [], ); - const { - isSendingLedgerCommands, - isAppLaunchConfirmationNeeded, - ledgerLogicToRun, - error: ledgerError, - } = useLedgerBluetooth(selectedDevice?.id); - - const ledgerLogicToRunRef = useRef(ledgerLogicToRun); - useEffect(() => { - ledgerLogicToRunRef.current = ledgerLogicToRun; - }, [ledgerLogicToRun]); - const keyringController = useMemo(() => { const { KeyringController: controller } = Engine.context as { KeyringController: KeyringController; @@ -134,16 +117,12 @@ const LedgerSelectAccount = () => { return controller; }, []); + const [errorMsg, setErrorMsg] = useState(null); const [blockingModalVisible, setBlockingModalVisible] = useState(false); const [accounts, setAccounts] = useState< { address: string; index: number; balance: string }[] >([]); - const [unlockAccounts, setUnlockAccounts] = useState({ - trigger: false, - accountIndexes: [] as number[], - }); - const [forgetDevice, setForgetDevice] = useState(false); const [existingAccounts, setExistingAccounts] = useState([]); @@ -158,19 +137,58 @@ const LedgerSelectAccount = () => { }); }, [keyringController]); - useEffect(() => { - if (ledgerError) { - setBlockingModalVisible(false); - } - }, [ledgerError]); - const showLoadingModal = () => { setErrorMsg(null); setBlockingModalVisible(true); }; + const fetchAccounts = useCallback(async () => { + try { + const _accounts = await getLedgerAccountsByOperation( + PAGINATION_OPERATIONS.GET_FIRST_PAGE, + ); + setAccounts(_accounts); + } catch (e) { + setErrorMsg((e as Error).message); + } + }, []); + + useEffect( + () => { + const init = async () => { + try { + DevLogger.log('[LedgerSelectAccount] Calling ensureDeviceReady...'); + setTargetWalletType(HardwareWalletType.Ledger); + const isReady = await ensureDeviceReady(); + + if (isReady) { + DevLogger.log( + '[LedgerSelectAccount] Device ready - fetching accounts', + ); + await fetchAccounts(); + } else { + DevLogger.log( + '[LedgerSelectAccount] User cancelled - navigating back', + ); + navigation.goBack(); + } + } catch { + navigation.goBack(); + } + }; + + init(); + }, + + // This is ran once on mount, so we don't need to add any dependencies + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const hasTrackedOpenRef = useRef(false); useEffect(() => { - if (selectedDevice && accounts.length > 0) { + if (accounts.length > 0 && !hasTrackedOpenRef.current) { + hasTrackedOpenRef.current = true; trackEvent( createEventBuilder( MetaMetricsEvents.HARDWARE_WALLET_ACCOUNT_SELECTOR_OPEN, @@ -182,79 +200,54 @@ const LedgerSelectAccount = () => { .build(), ); } - }, [ - trackEvent, - createEventBuilder, - selectedDevice, - accounts, - ledgerModelName, - ]); - - const onConnectHardware = useCallback(async () => { - setErrorMsg(null); - - const _accounts = await getLedgerAccountsByOperation( - PAGINATION_OPERATIONS.GET_FIRST_PAGE, - ); - setAccounts(_accounts); - }, []); + }, [trackEvent, createEventBuilder, accounts, ledgerModelName]); useEffect(() => { if (accounts.length > 0 && selectedOption) { showLoadingModal(); - ledgerLogicToRunRef - .current(async () => { - try { - const _accounts = await getLedgerAccountsByOperation( - PAGINATION_OPERATIONS.GET_FIRST_PAGE, - ); - setAccounts(_accounts); - } catch (e) { - setErrorMsg(getDisplayErrorMessage((e as Error).message)); - } + getLedgerAccountsByOperation(PAGINATION_OPERATIONS.GET_FIRST_PAGE) + .then((_accounts) => { + setAccounts(_accounts); + }) + .catch((e) => { + setErrorMsg((e as Error).message); }) .finally(() => { setBlockingModalVisible(false); }); } - }, [accounts.length, selectedOption]); + // accounts.length is only used as a guard, not as a trigger + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedOption]); const nextPage = useCallback(async () => { showLoadingModal(); try { - await ledgerLogicToRun(async () => { - try { - const _accounts = await getLedgerAccountsByOperation( - PAGINATION_OPERATIONS.GET_NEXT_PAGE, - ); - setAccounts(_accounts); - } catch (e) { - setErrorMsg(getDisplayErrorMessage((e as Error).message)); - } - }); + const _accounts = await getLedgerAccountsByOperation( + PAGINATION_OPERATIONS.GET_NEXT_PAGE, + ); + setAccounts(_accounts); + } catch (e) { + setErrorMsg((e as Error).message); } finally { setBlockingModalVisible(false); } - }, [ledgerLogicToRun]); + }, []); const prevPage = useCallback(async () => { showLoadingModal(); try { - await ledgerLogicToRun(async () => { - try { - const _accounts = await getLedgerAccountsByOperation( - PAGINATION_OPERATIONS.GET_PREVIOUS_PAGE, - ); - setAccounts(_accounts); - } catch (e) { - setErrorMsg(getDisplayErrorMessage((e as Error).message)); - } - }); + const _accounts = await getLedgerAccountsByOperation( + PAGINATION_OPERATIONS.GET_PREVIOUS_PAGE, + ); + setAccounts(_accounts); + } catch (e) { + setErrorMsg((e as Error).message); } finally { setBlockingModalVisible(false); } - }, [ledgerLogicToRun]); + }, []); const updateNewLegacyAccountsLabel = useCallback(async () => { if (LEDGER_LEGACY_PATH === (await getHDPath())) { @@ -289,55 +282,66 @@ const LedgerSelectAccount = () => { return LEDGER_UNKNOWN_STRING; }; + const isUnlockingRef = useRef(false); const onUnlock = useCallback( async (accountIndexes: number[]) => { - showLoadingModal(); + if (isUnlockingRef.current) return; + isUnlockingRef.current = true; try { - await ledgerLogicToRun(async () => { - try { - for (const index of accountIndexes) { - await unlockLedgerWalletAccount(index); - } - const numberOfConnectedDevices = await getConnectedDevicesCount(); - await updateNewLegacyAccountsLabel(); - - trackEvent( - createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ADD_ACCOUNT) - .addProperties({ - device_type: HardwareDeviceTypes.LEDGER, - device_model: ledgerModelName, - hd_path: getPathString(selectedOption.value), - connected_device_count: numberOfConnectedDevices.toString(), - }) - .build(), - ); - navigation.dispatch(StackActions.pop(2)); - } catch (err) { - trackEvent( - createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) - .addProperties({ - device_type: HardwareDeviceTypes.LEDGER, - device_model: ledgerModelName, - error: (err as Error).message, - }) - .build(), - ); - setErrorMsg(getDisplayErrorMessage((err as Error).message)); - } - }); - } finally { + const isReady = await ensureDeviceReady(deviceId); + if (!isReady) { + isUnlockingRef.current = false; + return; + } + + showLoadingModal(); + + for (const index of accountIndexes) { + await unlockLedgerWalletAccount(index); + } + const numberOfConnectedDevices = await getConnectedDevicesCount(); + await updateNewLegacyAccountsLabel(); + + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ADD_ACCOUNT) + .addProperties({ + device_type: HardwareDeviceTypes.LEDGER, + device_model: ledgerModelName, + hd_path: getPathString(selectedOption.value), + connected_device_count: numberOfConnectedDevices.toString(), + }) + .build(), + ); + navigation.dispatch(StackActions.pop(2)); + } catch (err) { + trackEvent( + createEventBuilder(MetaMetricsEvents.HARDWARE_WALLET_ERROR) + .addProperties({ + device_type: HardwareDeviceTypes.LEDGER, + device_model: ledgerModelName, + error: (err as Error).message, + }) + .build(), + ); setBlockingModalVisible(false); + setErrorMsg((err as Error).message); + isUnlockingRef.current = false; + return; } + + setBlockingModalVisible(false); + isUnlockingRef.current = false; }, [ - ledgerLogicToRun, updateNewLegacyAccountsLabel, ledgerModelName, trackEvent, createEventBuilder, selectedOption.value, navigation, + ensureDeviceReady, + deviceId, ], ); @@ -366,19 +370,8 @@ const LedgerSelectAccount = () => { await onForget(); setBlockingModalVisible(false); setForgetDevice(false); - } else if (unlockAccounts.trigger) { - await onUnlock(unlockAccounts.accountIndexes); - setBlockingModalVisible(false); - setUnlockAccounts({ trigger: false, accountIndexes: [] }); } - }, [ - blockingModalVisible, - forgetDevice, - onForget, - onUnlock, - unlockAccounts.accountIndexes, - unlockAccounts.trigger, - ]); + }, [blockingModalVisible, forgetDevice, onForget]); const onSelectedPathChanged = useCallback( async (path: string) => { @@ -386,23 +379,30 @@ const LedgerSelectAccount = () => { (pathOption) => pathOption.key === path, ); if (!option) return; - setSelectedOption(option); await setHDPath(path); + setSelectedOption(option); }, [ledgerPathOptions], ); - return ledgerError || accounts.length <= 0 ? ( - - ) : ( + if (accounts.length <= 0) { + return ( + + {errorMsg ? ( + {errorMsg} + ) : ( + <> + + + {strings('ledger.looking_for_device')} + + + )} + + ); + } + + return ( <> @@ -419,7 +419,7 @@ const LedgerSelectAccount = () => { - + {errorMsg && {errorMsg}} {strings('ledger.select_accounts')} @@ -446,8 +446,7 @@ const LedgerSelectAccount = () => { prevPage={prevPage} onUnlock={(accountIndex: number[]) => { setErrorMsg(null); - setUnlockAccounts({ trigger: true, accountIndexes: accountIndex }); - setBlockingModalVisible(true); + onUnlock(accountIndex); }} onForget={() => { setErrorMsg(null); diff --git a/app/components/Views/Root/index.tsx b/app/components/Views/Root/index.tsx index ada898cab49..e9b945d1692 100644 --- a/app/components/Views/Root/index.tsx +++ b/app/components/Views/Root/index.tsx @@ -22,6 +22,7 @@ import { SnapsExecutionWebView } from '../../../lib/snaps'; import { ReducedMotionConfig, ReduceMotion } from 'react-native-reanimated'; import { QueryClientProvider } from '@tanstack/react-query'; import reactQueryService from '../../../core/ReactQueryService'; +import { HardwareWalletProvider } from '../../../core/HardwareWallet'; /** * Top level of the component hierarchy @@ -84,10 +85,12 @@ const Root = ({ foxCode }: RootProps) => { - - - - + + + + + +