diff --git a/ui/pages/create-account/connect-hardware/__snapshots__/index.test.tsx.snap b/ui/pages/create-account/connect-hardware/__snapshots__/index.test.tsx.snap
index 0ec258838664..7c84c5bbac17 100644
--- a/ui/pages/create-account/connect-hardware/__snapshots__/index.test.tsx.snap
+++ b/ui/pages/create-account/connect-hardware/__snapshots__/index.test.tsx.snap
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`ConnectHardwareForm should match snapshot 1`] = `
+exports[`ConnectHardwareForm matchs snapshot 1`] = `
{
// If we have < 5 accounts, it's restricted by BIP-44
if (this.props.accounts.length === 5) {
- this.props.getPage(this.props.device, 1, this.props.selectedPath);
+ this.props.getPage(this.props.device, 1, this.props.selectedPath, false);
} else {
this.props.onAccountRestriction();
}
};
goToPreviousPage = () => {
- this.props.getPage(this.props.device, -1, this.props.selectedPath);
+ this.props.getPage(this.props.device, -1, this.props.selectedPath, false);
};
setPath(pathValue) {
diff --git a/ui/pages/create-account/connect-hardware/index.js b/ui/pages/create-account/connect-hardware/index.js
index 6c95c99ae8d8..76b226062415 100644
--- a/ui/pages/create-account/connect-hardware/index.js
+++ b/ui/pages/create-account/connect-hardware/index.js
@@ -149,7 +149,7 @@ class ConnectHardwareForm extends Component {
}
// Default values
- this.getPage(device, 0, this.props.defaultHdPaths[device]);
+ this.getPage(device, 0, this.props.defaultHdPaths[device], true);
};
onPathChange = (path) => {
@@ -185,9 +185,9 @@ class ConnectHardwareForm extends Component {
}, SECOND * 5);
}
- getPage = (device, page, hdPath) => {
+ getPage = (device, page, hdPath, loadHid) => {
this.props
- .connectHardware(device, page, hdPath, this.context.t)
+ .connectHardware(device, page, hdPath, loadHid, this.context.t)
.then((accounts) => {
if (accounts.length) {
// If we just loaded the accounts for the first time
@@ -475,8 +475,10 @@ const mapDispatchToProps = (dispatch) => {
setHardwareWalletDefaultHdPath: ({ device, path }) => {
return dispatch(actions.setHardwareWalletDefaultHdPath({ device, path }));
},
- connectHardware: (deviceName, page, hdPath, t) => {
- return dispatch(actions.connectHardware(deviceName, page, hdPath, t));
+ connectHardware: (deviceName, page, hdPath, loadHid, t) => {
+ return dispatch(
+ actions.connectHardware(deviceName, page, hdPath, loadHid, t),
+ );
},
checkHardwareStatus: (deviceName, hdPath) => {
return dispatch(actions.checkHardwareStatus(deviceName, hdPath));
diff --git a/ui/pages/create-account/connect-hardware/index.test.tsx b/ui/pages/create-account/connect-hardware/index.test.tsx
index 1fc2a8ca3437..54c31aed8964 100644
--- a/ui/pages/create-account/connect-hardware/index.test.tsx
+++ b/ui/pages/create-account/connect-hardware/index.test.tsx
@@ -1,7 +1,8 @@
-import configureMockStore from 'redux-mock-store';
import { fireEvent, waitFor } from '@testing-library/react';
import thunk from 'redux-thunk';
import React from 'react';
+import configureMockStore from 'redux-mock-store';
+
import { renderWithProvider } from '../../../../test/lib/render-helpers';
import {
LedgerTransportTypes,
@@ -50,6 +51,7 @@ const mockProps = {
hideAlert: () => jest.fn(),
unlockHardwareWalletAccount: () => jest.fn(),
setHardwareWalletDefaultHdPath: () => jest.fn(),
+ connectHardware: () => mockConnectHardware,
history: {
push: mockHistoryPush,
},
@@ -95,7 +97,7 @@ const mockState = {
describe('ConnectHardwareForm', () => {
const mockStore = configureMockStore([thunk])(mockState);
- it('should match snapshot', () => {
+ it('matchs snapshot', () => {
const { container } = renderWithProvider(
,
mockStore,
@@ -104,7 +106,7 @@ describe('ConnectHardwareForm', () => {
expect(container).toMatchSnapshot();
});
- it('should close the form when close button is clicked', () => {
+ it('closes the form when close button is clicked', () => {
const { getByTestId } = renderWithProvider(
,
mockStore,
@@ -116,7 +118,7 @@ describe('ConnectHardwareForm', () => {
});
describe('U2F Error', () => {
- it('should render a U2F error', async () => {
+ it('renders a U2F error', async () => {
mockConnectHardware.mockRejectedValue(new Error('U2F Error'));
const mockStateWithU2F = Object.assign(mockState, {});
mockStateWithU2F.appState.ledgerTransportType = LedgerTransportTypes.u2f;
@@ -146,7 +148,7 @@ describe('ConnectHardwareForm', () => {
});
});
- it('should render a different U2F error for firefox', async () => {
+ it('renders a different U2F error for firefox', async () => {
jest
.spyOn(window.navigator, 'userAgent', 'get')
.mockReturnValue(
@@ -180,7 +182,7 @@ describe('ConnectHardwareForm', () => {
});
describe('QR Hardware Wallet Steps', () => {
- it('should render the QR hardware wallet steps', async () => {
+ it('renders the QR hardware wallet steps', async () => {
const { getByText, getByLabelText } = renderWithProvider(
,
mockStore,
@@ -203,7 +205,7 @@ describe('ConnectHardwareForm', () => {
});
describe('Select Hardware', () => {
- it('should check link buttons for Ngrave Zero brand', async () => {
+ it('checks link buttons for Ngrave Zero brand', async () => {
window.open = jest.fn();
const { getByLabelText, getByTestId } = renderWithProvider(
@@ -226,4 +228,86 @@ describe('ConnectHardwareForm', () => {
expect(window.open).toHaveBeenCalled();
});
});
+
+ describe('getPage method', () => {
+ beforeEach(() => {
+ mockConnectHardware.mockReset();
+ });
+
+ it('calls connectHardware with loadHid=true', async () => {
+ mockConnectHardware.mockReset();
+
+ const mockAccounts = [
+ { address: '0xAddress1', balance: null, index: 0 },
+ { address: '0xAddress2', balance: null, index: 1 },
+ ];
+ mockConnectHardware.mockResolvedValue(mockAccounts);
+
+ renderWithProvider(, mockStore);
+
+ const hdPath = "m/44'/60'/0'/0";
+ const deviceName = 'ledger';
+ const pageIndex = 0;
+ const loadHidValue = true;
+
+ await mockConnectHardware(
+ deviceName,
+ pageIndex,
+ hdPath,
+ loadHidValue,
+ jest.fn(),
+ );
+
+ expect(mockConnectHardware).toHaveBeenCalledWith(
+ deviceName,
+ pageIndex,
+ hdPath,
+ loadHidValue,
+ expect.any(Function),
+ );
+ });
+
+ it('calls connectHardware with loadHid=false', async () => {
+ mockConnectHardware.mockReset();
+
+ const mockAccounts = [
+ { address: '0xAddress1', balance: null, index: 0 },
+ { address: '0xAddress2', balance: null, index: 1 },
+ ];
+ mockConnectHardware.mockResolvedValue(mockAccounts);
+
+ renderWithProvider(, mockStore);
+
+ const hdPath = "m/44'/60'/0'/0";
+ const deviceName = 'ledger';
+ const pageIndex = 0;
+ const loadHidValue = false;
+
+ await mockConnectHardware(
+ deviceName,
+ pageIndex,
+ hdPath,
+ loadHidValue,
+ jest.fn(),
+ );
+
+ expect(mockConnectHardware).toHaveBeenCalledWith(
+ deviceName,
+ pageIndex,
+ hdPath,
+ loadHidValue,
+ expect.any(Function),
+ );
+ });
+
+ it('handles errors when connectHardware fails', async () => {
+ const testError = new Error('Test Error');
+ mockConnectHardware.mockReset();
+ mockConnectHardware.mockRejectedValue(testError);
+
+ renderWithProvider(, mockStore);
+
+ await expect(mockConnectHardware()).rejects.toThrow('Test Error');
+ });
+ });
});
diff --git a/ui/store/actions.test.js b/ui/store/actions.test.js
index 3e82adfc3d98..25a5dcd6bb09 100644
--- a/ui/store/actions.test.js
+++ b/ui/store/actions.test.js
@@ -85,7 +85,12 @@ describe('Actions', () => {
const currentChainId = '0x5';
+ let originalNavigator;
+
beforeEach(async () => {
+ // Save original navigator for restoring after tests
+ originalNavigator = global.navigator;
+
background = sinon.createStubInstance(MetaMaskController, {
getState: sinon.stub().callsFake((cb) => cb(null, [])),
});
@@ -100,6 +105,27 @@ describe('Actions', () => {
background.requestAccountsAndChainPermissionsWithId = sinon.stub();
background.grantPermissions = sinon.stub();
background.grantPermissionsIncremental = sinon.stub();
+
+ // Make sure navigator.hid is defined for WebHID tests
+ if (!global.navigator) {
+ global.navigator = {};
+ }
+
+ if (!global.navigator.hid) {
+ global.navigator.hid = {
+ requestDevice: sinon.stub(),
+ };
+ }
+ });
+
+ afterEach(() => {
+ // Restore original window.navigator after each test
+ Object.defineProperty(window, 'navigator', {
+ value: originalNavigator,
+ writable: true,
+ });
+
+ sinon.restore();
});
describe('#tryUnlockMetamask', () => {
@@ -622,6 +648,264 @@ describe('Actions', () => {
expect(store.getActions()).toStrictEqual(expectedActions);
});
+
+ it('handles WebHID connection when loadHid=true for Ledger devices', async () => {
+ const store = mockStore({
+ ...defaultState,
+ metamask: {
+ ...defaultState.metamask,
+ ledgerTransportType: 'webhid',
+ },
+ });
+
+ const mockHidDevice = { vendorId: 11415 };
+ const mockRequestDevice = sinon.stub().resolves([mockHidDevice]);
+
+ Object.defineProperty(window, 'navigator', {
+ value: {
+ ...window.navigator,
+ hid: {
+ requestDevice: mockRequestDevice,
+ },
+ },
+ writable: true,
+ });
+
+ const connectHardware = background.connectHardware.callsFake(
+ (_, __, ___, cb) => cb(null, [{ address: '0xLedgerAddress' }]),
+ );
+
+ setBackgroundConnection(background);
+
+ const expectedActions = [
+ {
+ type: 'SHOW_LOADING_INDICATION',
+ payload: 'Looking for your Ledger...',
+ },
+ { type: 'HIDE_LOADING_INDICATION' },
+ ];
+
+ const accounts = await store.dispatch(
+ actions.connectHardware(
+ HardwareDeviceNames.ledger,
+ 0,
+ `m/44'/60'/0'/0`,
+ true,
+ (key) => `translated_${key}`,
+ ),
+ );
+
+ expect(connectHardware.callCount).toStrictEqual(1);
+ expect(mockRequestDevice.callCount).toStrictEqual(1);
+ expect(accounts).toStrictEqual([{ address: '0xLedgerAddress' }]);
+ expect(store.getActions()).toStrictEqual(expectedActions);
+ });
+
+ it('throws a specific error when user denies WebHID permissions with loadHid=true', async () => {
+ const store = mockStore({
+ ...defaultState,
+ metamask: {
+ ...defaultState.metamask,
+ ledgerTransportType: 'webhid',
+ },
+ });
+
+ const mockRequestDevice = sinon.stub();
+ mockRequestDevice.resolves([]);
+ Object.defineProperty(window, 'navigator', {
+ value: {
+ ...window.navigator,
+ hid: {
+ requestDevice: mockRequestDevice,
+ },
+ },
+ writable: true,
+ });
+
+ setBackgroundConnection(background);
+
+ const expectedActions = [
+ {
+ type: 'SHOW_LOADING_INDICATION',
+ payload: 'Looking for your Ledger...',
+ },
+ {
+ type: 'DISPLAY_WARNING',
+ payload: 'translated_ledgerWebHIDNotConnectedErrorMessage',
+ },
+ { type: 'HIDE_LOADING_INDICATION' },
+ ];
+
+ const mockTranslation = (key) => `translated_${key}`;
+
+ await expect(
+ store.dispatch(
+ actions.connectHardware(
+ HardwareDeviceNames.ledger,
+ 0,
+ `m/44'/60'/0'/0`,
+ true,
+ mockTranslation,
+ ),
+ ),
+ ).rejects.toThrow('translated_ledgerWebHIDNotConnectedErrorMessage');
+
+ expect(mockRequestDevice.callCount).toStrictEqual(1);
+ expect(store.getActions()).toStrictEqual(expectedActions);
+ });
+
+ it('handles loadHid=false and skips WebHID request process', async () => {
+ const store = mockStore({
+ ...defaultState,
+ metamask: {
+ ...defaultState.metamask,
+ ledgerTransportType: 'webhid',
+ },
+ });
+
+ const mockRequestDevice = sinon.spy();
+ Object.defineProperty(window, 'navigator', {
+ value: {
+ ...window.navigator,
+ hid: {
+ requestDevice: mockRequestDevice,
+ },
+ },
+ writable: true,
+ });
+
+ const connectHardware = background.connectHardware.callsFake(
+ (_, __, ___, cb) => cb(null, [{ address: '0xLedgerAddress' }]),
+ );
+
+ setBackgroundConnection(background);
+
+ const expectedActions = [
+ {
+ type: 'SHOW_LOADING_INDICATION',
+ payload: 'Looking for your Ledger...',
+ },
+ { type: 'HIDE_LOADING_INDICATION' },
+ ];
+
+ const accounts = await store.dispatch(
+ actions.connectHardware(
+ HardwareDeviceNames.ledger,
+ 0,
+ `m/44'/60'/0'/0`,
+ false,
+ (key) => `translated_${key}`,
+ ),
+ );
+
+ expect(connectHardware.callCount).toStrictEqual(1);
+ expect(mockRequestDevice.callCount).toStrictEqual(0);
+ expect(accounts).toStrictEqual([{ address: '0xLedgerAddress' }]);
+ expect(store.getActions()).toStrictEqual(expectedActions);
+ });
+
+ it('handles specific Ledger WebHID device open failure error', async () => {
+ const store = mockStore({
+ ...defaultState,
+ metamask: {
+ ...defaultState.metamask,
+ ledgerTransportType: 'webhid',
+ },
+ });
+
+ const mockHidDevice = { vendorId: 11415 };
+ const mockRequestDevice = sinon.stub();
+ mockRequestDevice.resolves([mockHidDevice]);
+ Object.defineProperty(window, 'navigator', {
+ value: {
+ ...window.navigator,
+ hid: {
+ requestDevice: mockRequestDevice,
+ },
+ },
+ writable: true,
+ });
+
+ const deviceOpenError = new Error('Failed to open the device');
+ background.connectHardware.callsFake((_, __, ___, cb) =>
+ cb(deviceOpenError),
+ );
+
+ setBackgroundConnection(background);
+
+ const expectedActions = [
+ {
+ type: 'SHOW_LOADING_INDICATION',
+ payload: 'Looking for your Ledger...',
+ },
+ {
+ type: 'DISPLAY_WARNING',
+ payload: 'translated_ledgerDeviceOpenFailureMessage',
+ },
+ { type: 'HIDE_LOADING_INDICATION' },
+ ];
+
+ const mockTranslation = (key) => `translated_${key}`;
+
+ await expect(
+ store.dispatch(
+ actions.connectHardware(
+ HardwareDeviceNames.ledger,
+ 0,
+ `m/44'/60'/0'/0`,
+ true,
+ mockTranslation,
+ ),
+ ),
+ ).rejects.toThrow('translated_ledgerDeviceOpenFailureMessage');
+
+ expect(mockRequestDevice.callCount).toStrictEqual(1);
+ expect(store.getActions()).toStrictEqual(expectedActions);
+ });
+
+ it('handles non-Ledger hardware devices', async () => {
+ const store = mockStore();
+
+ const mockRequestDevice = sinon.spy();
+ Object.defineProperty(window, 'navigator', {
+ value: {
+ ...window.navigator,
+ hid: {
+ requestDevice: mockRequestDevice,
+ },
+ },
+ writable: true,
+ });
+
+ const connectHardware = background.connectHardware.callsFake(
+ (_, __, ___, cb) => cb(null, [{ address: '0xTrezorAddress' }]),
+ );
+
+ setBackgroundConnection(background);
+
+ const expectedActions = [
+ {
+ type: 'SHOW_LOADING_INDICATION',
+ payload: 'Looking for your Trezor...',
+ },
+ { type: 'HIDE_LOADING_INDICATION' },
+ ];
+
+ const accounts = await store.dispatch(
+ actions.connectHardware(
+ HardwareDeviceNames.trezor,
+ 0,
+ `m/44'/60'/0'/0`,
+ true,
+ (key) => `translated_${key}`,
+ ),
+ );
+
+ expect(connectHardware.callCount).toStrictEqual(1);
+ expect(mockRequestDevice.callCount).toStrictEqual(0);
+ expect(accounts).toStrictEqual([{ address: '0xTrezorAddress' }]);
+ expect(store.getActions()).toStrictEqual(expectedActions);
+ });
});
describe('#unlockHardwareWalletAccount', () => {
diff --git a/ui/store/actions.ts b/ui/store/actions.ts
index 93a0155cab33..527c4eebe796 100644
--- a/ui/store/actions.ts
+++ b/ui/store/actions.ts
@@ -612,6 +612,7 @@ export function connectHardware(
deviceName: HardwareDeviceNames,
page: string,
hdPath: string,
+ loadHid: boolean,
t: (key: string) => string,
): ThunkAction<
Promise<{ address: string }[]>,
@@ -630,6 +631,7 @@ export function connectHardware(
let accounts: { address: string }[];
try {
if (
+ loadHid &&
deviceName === HardwareDeviceNames.ledger &&
ledgerTransportType === LedgerTransportTypes.webhid
) {