Skip to content

Commit 0b40307

Browse files
committed
fix: coverage
1 parent d408d6c commit 0b40307

6 files changed

Lines changed: 200 additions & 19 deletions

File tree

app/components/UI/QRHardware/AnimatedQRScanner.test.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,28 @@ describe('AnimatedQRScannerModal - Metrics', () => {
786786
});
787787
});
788788

789+
it('handles analytics failure gracefully during QR scan error', async () => {
790+
const { withQrKeyring } = jest.requireMock(
791+
'../../../core/QrKeyring/QrKeyring',
792+
);
793+
const originalImpl = withQrKeyring.getMockImplementation();
794+
withQrKeyring.mockRejectedValue(new Error('analytics failure'));
795+
796+
render(<AnimatedQRScannerModal {...defaultProps} />);
797+
798+
await mockOnCodeScanned([{ value: 'not-a-ur', type: 'qr' }]);
799+
800+
await waitFor(() => {
801+
expect(mockAddProperties).toHaveBeenCalledWith(
802+
expect.objectContaining({
803+
device_model: 'Unknown',
804+
}),
805+
);
806+
});
807+
808+
withQrKeyring.mockImplementation(originalImpl);
809+
});
810+
789811
it('does not process QR code when codes array is empty', async () => {
790812
const mockDecoderInstance = {
791813
receivePart: jest.fn(),
@@ -1315,6 +1337,34 @@ describe('AnimatedQRScannerModal - Metrics', () => {
13151337
});
13161338
});
13171339

1340+
describe('showScannerError scanErrorActiveRef guard', () => {
1341+
it('ignores scan errors while an inline error is already being displayed', async () => {
1342+
const { getByText } = render(
1343+
<AnimatedQRScannerModal {...defaultProps} />,
1344+
);
1345+
1346+
await mockOnCodeScanned([{ value: 'not-a-ur', type: 'qr' }]);
1347+
1348+
await waitFor(() => {
1349+
expect(
1350+
getByText(
1351+
'hardware_wallet.qr_scan_errors.non_ur_qr_scanned.pair.title',
1352+
),
1353+
).toBeOnTheScreen();
1354+
});
1355+
1356+
const decoderCallsAfterFirstError =
1357+
mockURRegistryDecoder.mock.calls.length;
1358+
1359+
await mockOnCodeScanned([{ value: 'not-a-ur', type: 'qr' }]);
1360+
1361+
expect(mockURRegistryDecoder).toHaveBeenCalledTimes(
1362+
decoderCallsAfterFirstError,
1363+
);
1364+
expect(mockTrackEvent).toHaveBeenCalledTimes(1);
1365+
});
1366+
});
1367+
13181368
describe('showScannerError without onQRHardwareScanError callback', () => {
13191369
it('renders inline error UI when onQRHardwareScanError is not provided', async () => {
13201370
const { getByText, getByTestId } = render(

app/components/Views/confirmations/components/qr-info/qr-info.test.tsx

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Button, Text, View } from 'react-native';
33
import { fireEvent } from '@testing-library/react-native';
44
import { ETHSignature } from '@keystonehq/bc-ur-registry-eth';
55
import { HardwareWalletError } from '@metamask/hw-wallet-sdk';
6+
import { stringify as uuidStringify } from 'uuid';
67

78
import renderWithProvider from '../../../../../util/test/renderWithProvider';
89
import { typedSignV3ConfirmationState } from '../../../../../util/test/confirm-data-helpers';
@@ -32,9 +33,11 @@ jest.mock('../../../../../core/Engine', () => ({
3233
}));
3334

3435
jest.mock('uuid', () => ({
35-
stringify: jest.fn().mockReturnValue('c95ecc76-d6e9-4a0a-afa3-31429bc80566'),
36+
stringify: jest.fn(),
3637
}));
3738

39+
const mockUuidStringify = uuidStringify as jest.Mock;
40+
3841
const MockView = View;
3942
const MockText = Text;
4043
const MockButton = Button;
@@ -204,7 +207,95 @@ describe('QRInfo', () => {
204207
expect(mockSetScannerVisible).toHaveBeenCalledWith(true);
205208
});
206209

210+
it('registers cleanup that clears QR scan retry handler on unmount', () => {
211+
const mockSetScannerVisible = jest.fn();
212+
createQRHardwareHookSpy({ setScannerVisible: mockSetScannerVisible });
213+
const { unmount } = renderWithProvider(<QRInfo />, {
214+
state: typedSignV3ConfirmationState,
215+
});
216+
217+
unmount();
218+
219+
expect(mockSetQrScanRetryHandler).toHaveBeenCalledWith(null);
220+
});
221+
222+
it('shows error when scanned signature request ID does not match pending request', () => {
223+
mockUuidStringify.mockReturnValue('mismatched-uuid-value');
224+
jest.spyOn(ETHSignature, 'fromCBOR').mockReturnValue({
225+
getRequestId: () => Buffer.from('different-request-id'),
226+
} as unknown as ETHSignature);
227+
const mockSetScannerVisible = jest.fn();
228+
createQRHardwareHookSpy({
229+
scannerVisible: true,
230+
setScannerVisible: mockSetScannerVisible,
231+
});
232+
const { getByText } = renderWithProvider(<QRInfo />, {
233+
state: typedSignV3ConfirmationState,
234+
});
235+
236+
fireEvent.press(getByText('onScanSuccess'));
237+
238+
expect(mockSetScannerVisible).toHaveBeenCalledWith(false);
239+
expect(mockQrKeyringBridge.resolvePendingScan).not.toHaveBeenCalled();
240+
expect(
241+
getByText(
242+
"Incongruent transaction data. Please use your hardware wallet to sign the QR code below and tap 'Get Signature'.",
243+
),
244+
).toBeOnTheScreen();
245+
});
246+
247+
it('clears error message when error alert is pressed', () => {
248+
mockUuidStringify.mockReturnValue('mismatched-uuid-value');
249+
jest.spyOn(ETHSignature, 'fromCBOR').mockReturnValue({
250+
getRequestId: () => Buffer.from('different-request-id'),
251+
} as unknown as ETHSignature);
252+
const mockSetScannerVisible = jest.fn();
253+
createQRHardwareHookSpy({
254+
scannerVisible: true,
255+
setScannerVisible: mockSetScannerVisible,
256+
});
257+
const { getByText, queryByText } = renderWithProvider(<QRInfo />, {
258+
state: typedSignV3ConfirmationState,
259+
});
260+
261+
fireEvent.press(getByText('onScanSuccess'));
262+
263+
const errorText =
264+
"Incongruent transaction data. Please use your hardware wallet to sign the QR code below and tap 'Get Signature'.";
265+
expect(getByText(errorText)).toBeOnTheScreen();
266+
267+
fireEvent.press(getByText(errorText));
268+
269+
expect(queryByText(errorText)).toBeNull();
270+
});
271+
272+
it('shows error when scanned signature has no request ID', () => {
273+
mockUuidStringify.mockReturnValue('c95ecc76-d6e9-4a0a-afa3-31429bc80566');
274+
jest.spyOn(ETHSignature, 'fromCBOR').mockReturnValue({
275+
getRequestId: () => null,
276+
} as unknown as ETHSignature);
277+
const mockSetScannerVisible = jest.fn();
278+
createQRHardwareHookSpy({
279+
scannerVisible: true,
280+
setScannerVisible: mockSetScannerVisible,
281+
});
282+
const { getByText } = renderWithProvider(<QRInfo />, {
283+
state: typedSignV3ConfirmationState,
284+
});
285+
286+
fireEvent.press(getByText('onScanSuccess'));
287+
288+
expect(mockSetScannerVisible).toHaveBeenCalledWith(false);
289+
expect(mockQrKeyringBridge.resolvePendingScan).not.toHaveBeenCalled();
290+
expect(
291+
getByText(
292+
"Incongruent transaction data. Please use your hardware wallet to sign the QR code below and tap 'Get Signature'.",
293+
),
294+
).toBeOnTheScreen();
295+
});
296+
207297
it('submits request when onScanSuccess is called by scanner', () => {
298+
mockUuidStringify.mockReturnValue('c95ecc76-d6e9-4a0a-afa3-31429bc80566');
208299
jest.spyOn(ETHSignature, 'fromCBOR').mockReturnValue({
209300
getRequestId: () => mockPendingScanRequest.request?.requestId,
210301
} as unknown as ETHSignature);

app/core/HardwareWallet/adapters/QRWalletAdapter.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ describe('QRWalletAdapter', () => {
149149
const result = await adapter.ensureDeviceReady('qr-account-address');
150150

151151
expect(result).toBe(false);
152-
expect(mockRequestCameraPermission).not.toHaveBeenCalled();
152+
expect(mockRequestCameraPermission).toHaveBeenCalledTimes(1);
153153
expect(adapter.getConnectedDeviceId()).toBeNull();
154154
expect(adapter.isConnected()).toBe(false);
155155
expect(onDeviceEvent).toHaveBeenCalledWith({
@@ -168,6 +168,7 @@ describe('QRWalletAdapter', () => {
168168
const result = await adapter.ensureDeviceReady('qr-account-address');
169169

170170
expect(result).toBe(false);
171+
expect(mockRequestCameraPermission).toHaveBeenCalledTimes(1);
171172
expect(adapter.getConnectedDeviceId()).toBeNull();
172173
expect(adapter.isConnected()).toBe(false);
173174
expect(onDeviceEvent).toHaveBeenCalledWith({
@@ -270,6 +271,7 @@ describe('QRWalletAdapter', () => {
270271
const result = await adapter.ensurePermissions();
271272

272273
expect(result).toBe(false);
274+
expect(mockRequestCameraPermission).toHaveBeenCalledTimes(1);
273275
expect(onDeviceEvent).toHaveBeenCalledWith({
274276
event: DeviceEvent.ConnectionFailed,
275277
error: {

app/core/HardwareWallet/adapters/QRWalletAdapter.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ export class QRWalletAdapter implements HardwareWalletAdapter {
9393

9494
/**
9595
* For QR wallets, device readiness requires camera permission.
96-
* This checks camera permission and emits an error if denied.
96+
* This requests permission for non-granted statuses and emits an error if still denied.
9797
*/
9898
async ensureDeviceReady(deviceId: string): Promise<boolean> {
9999
if (this.#isDestroyed) {
@@ -169,7 +169,7 @@ export class QRWalletAdapter implements HardwareWalletAdapter {
169169

170170
/**
171171
* Ensures camera permission is granted.
172-
* Requests permission if not determined, emits error if denied.
172+
* Requests permission for non-granted statuses, then emits an error if still denied.
173173
*/
174174
async ensurePermissions(): Promise<boolean> {
175175
return this.#checkCameraPermission();
@@ -243,8 +243,7 @@ export class QRWalletAdapter implements HardwareWalletAdapter {
243243
/**
244244
* Checks camera permission status and handles the flow:
245245
* - granted: returns true
246-
* - not-determined: requests permission
247-
* - denied: emits ConnectionFailed event with CameraPermissionDenied error
246+
* - non-granted: requests permission, then emits ConnectionFailed if still denied
248247
*/
249248
async #checkCameraPermission(): Promise<boolean> {
250249
try {

app/core/HardwareWallet/hooks/useQrConfirm.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,4 +243,54 @@ describe('useQrConfirm', () => {
243243

244244
mockApprovalRequest.requestData = originalRequestData;
245245
});
246+
247+
it('rethrows error from onTransactionConfirm onError callback', async () => {
248+
const transactionError = new Error('transaction onError thrown');
249+
mockExecuteHardwareWalletOperation.mockImplementation(
250+
async ({ execute }) => {
251+
await execute();
252+
},
253+
);
254+
onTransactionConfirm.mockImplementationOnce(async ({ onError }) => {
255+
onError?.(transactionError);
256+
});
257+
mockExecuteHardwareWalletOperation.mockRejectedValueOnce(transactionError);
258+
259+
const { result } = renderHook(() =>
260+
useQrConfirm({ ...defaultOptions, isTransactionReq: true }),
261+
);
262+
263+
await act(async () => {
264+
await result.current.onConfirm();
265+
});
266+
267+
expect(onReject).toHaveBeenCalled();
268+
});
269+
270+
it('does not reject twice when onRejected is called before execute throws', async () => {
271+
const error = new Error('execute failed');
272+
onTransactionConfirm.mockImplementationOnce(async ({ onError }) => {
273+
onError?.(error);
274+
});
275+
mockExecuteHardwareWalletOperation.mockImplementation(
276+
async ({ onRejected, execute }) => {
277+
await onRejected();
278+
try {
279+
await execute();
280+
} catch (_) {
281+
// Already rejected
282+
}
283+
},
284+
);
285+
286+
const { result } = renderHook(() =>
287+
useQrConfirm({ ...defaultOptions, isTransactionReq: true }),
288+
);
289+
290+
await act(async () => {
291+
await result.current.onConfirm();
292+
});
293+
294+
expect(onReject).toHaveBeenCalledTimes(1);
295+
});
246296
});

app/core/HardwareWallet/hooks/useQrConfirm.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -92,17 +92,7 @@ export function useQrConfirm({
9292
showAwaitingConfirmation,
9393
hideAwaitingConfirmation,
9494
showHardwareWalletError,
95-
execute: async () => {
96-
if (isTransactionReq) {
97-
await onTransactionConfirm({
98-
onError: (err) => {
99-
throw err;
100-
},
101-
});
102-
} else {
103-
await executeApproval();
104-
}
105-
},
95+
execute: executeQrConfirmation,
10696
onRejected: rejectOnce,
10797
});
10898
} catch (err) {
@@ -115,10 +105,9 @@ export function useQrConfirm({
115105
approvalRequest?.requestData?.from,
116106
transactionMetadata?.txParams?.from,
117107
isSigningQRObject,
108+
executeQrConfirmation,
118109
isTransactionReq,
119110
onReject,
120-
onTransactionConfirm,
121-
executeApproval,
122111
ensureDeviceReady,
123112
showAwaitingConfirmation,
124113
hideAwaitingConfirmation,

0 commit comments

Comments
 (0)