diff --git a/.claude/commands/unit-test-coverage.md b/.claude/commands/unit-test-coverage.md index 051b5ad1c12..208c67a4d4b 100644 --- a/.claude/commands/unit-test-coverage.md +++ b/.claude/commands/unit-test-coverage.md @@ -74,7 +74,7 @@ flowchart TD "failedTests": [{ "file": "usePerps.test.tsx", "error": "Cannot read property 'data' of undefined", - "command": "npx jest usePerps.test.tsx --no-coverage" + "command": "yarn jest usePerps.test.tsx --no-coverage" }] } ``` @@ -316,11 +316,11 @@ cat scripts/reports/coverage-report-*.json | jq '.failedTests | length' cat scripts/reports/coverage-report-*.json | jq '.actionableRecommendations.filesNeedingImprovement[0]' # Debug failing test -npx jest path/to/test.tsx --no-coverage --verbose +yarn jest path/to/test.tsx --no-coverage --verbose # Type check & lint yarn lint:tsc -npx eslint path/to/test.tsx --fix +yarn eslint path/to/test.tsx --fix ``` ## Skip These Files diff --git a/app.config.js b/app.config.js index 1de67933ccd..de9362b11e2 100644 --- a/app.config.js +++ b/app.config.js @@ -1,5 +1,27 @@ const { RUNTIME_VERSION, PROJECT_ID, UPDATE_URL } = require('./ota.config.js'); +// Use METAMASK_ENVIRONMENT to select OTA certs: +// - "production" and "rc" use their own certificates +// - all other environments (exp, dev, test, e2e, beta, etc.) fall back to "exp" +const OTA_ENV_MAP = { + production: 'production', + rc: 'rc', +}; + +const OTA_ENV = OTA_ENV_MAP[process.env.METAMASK_ENVIRONMENT] ?? 'exp'; + +const CODE_SIGNING_CERTS = { + production: './certs/production.certificate.pem', + exp: './certs/exp.certificate.pem', + rc: './certs/rc.certificate.pem', +}; + +const CODE_SIGNING_KEYIDS = { + production: 'production', + exp: 'exp', + rc: 'rc', +}; + module.exports = { name: 'MetaMask', displayName: 'MetaMask', @@ -75,16 +97,12 @@ module.exports = { owner: 'metamask', runtimeVersion: RUNTIME_VERSION, updates: { - codeSigningCertificate: './certs/certificate.pem', + codeSigningCertificate: CODE_SIGNING_CERTS[OTA_ENV], codeSigningMetadata: { - keyid: 'main', + keyid: CODE_SIGNING_KEYIDS[OTA_ENV], alg: 'rsa-v1_5-sha256', }, url: UPDATE_URL, - // Channel is set by requestHeaders, will be overridden with build script - requestHeaders: { - 'expo-channel-name': 'preview', - }, }, extra: { eas: { diff --git a/app/components/Nav/App/App.test.tsx b/app/components/Nav/App/App.test.tsx index 61ebb604309..8e6bc4b112b 100644 --- a/app/components/Nav/App/App.test.tsx +++ b/app/components/Nav/App/App.test.tsx @@ -31,7 +31,6 @@ import { KeyringTypes } from '@metamask/keyring-controller'; import { AccountDetailsIds } from '../../../../e2e/selectors/MultichainAccounts/AccountDetails.selectors'; import { AvatarAccountType } from '../../../component-library/components/Avatars/Avatar'; import AUTHENTICATION_TYPE from '../../../constants/userProperties'; -import { useOTAUpdates } from '../../hooks/useOTAUpdates'; const initialState: DeepPartial = { user: { @@ -83,16 +82,6 @@ jest.mock('../../hooks/useMetrics/useMetrics', () => ({ }), })); -jest.mock('../../hooks/useOTAUpdates', () => ({ - useOTAUpdates: jest.fn().mockReturnValue({ - isCheckingUpdates: false, - }), -})); - -const mockUseOTAUpdates = useOTAUpdates as jest.MockedFunction< - typeof useOTAUpdates ->; - jest.mock( '../../UI/FoxLoader', () => @@ -123,6 +112,10 @@ jest.mock('../../../core/OAuthService/OAuthLoginHandlers', () => ({ createLoginHandler: jest.fn(), })); +jest.mock('../../hooks/useOTAUpdates', () => ({ + useOTAUpdates: jest.fn(), +})); + // Mock the navigation hook const mockNavigate = jest.fn(); const mockReset = jest.fn(); @@ -269,9 +262,6 @@ describe('App', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseOTAUpdates.mockReturnValue({ - isCheckingUpdates: false, - }); mockNavigate.mockClear(); }); @@ -284,20 +274,6 @@ describe('App', () => { jest.useRealTimers(); }); - it('renders FoxLoader when OTA update check runs', () => { - mockUseOTAUpdates.mockReturnValue({ - isCheckingUpdates: true, - }); - - const { getByTestId } = renderScreen( - App, - { name: 'App' }, - { state: initialState }, - ); - - expect(getByTestId(MOCK_FOX_LOADER_ID)).toBeTruthy(); - }); - it('configures MetaMetrics instance and identifies user on startup', async () => { renderScreen(App, { name: 'App' }, { state: initialState }); await waitFor(() => { diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index 5e401008299..f9ff49786b0 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -56,6 +56,7 @@ import ConnectQRHardware from '../../Views/ConnectQRHardware'; import SelectHardwareWallet from '../../Views/ConnectHardware/SelectHardware'; import { AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS } from '../../../constants/error'; import { UpdateNeeded } from '../../../components/UI/UpdateNeeded'; +import { OTAUpdatesModal } from '../../UI/OTAUpdatesModal'; import NetworkSettings from '../../Views/Settings/NetworksSettings/NetworkSettings'; import ModalMandatory from '../../../component-library/components/Modals/ModalMandatory'; import { RestoreWallet } from '../../Views/RestoreWallet'; @@ -149,7 +150,6 @@ import MultichainAccountActions from '../../Views/MultichainAccounts/sheets/Mult import useInterval from '../../hooks/useInterval'; import { Duration } from '@metamask/utils'; import { selectSeedlessOnboardingLoginFlow } from '../../../selectors/seedlessOnboardingController'; -import { useOTAUpdates } from '../../hooks/useOTAUpdates'; import { SmartAccountUpdateModal } from '../../Views/confirmations/components/smart-account-update-modal'; import { PayWithModal } from '../../Views/confirmations/components/modals/pay-with-modal/pay-with-modal'; import { useMetrics } from '../../hooks/useMetrics'; @@ -161,6 +161,7 @@ import { useEmptyNavHeaderForConfirmations } from '../../Views/confirmations/hoo import { trackVaultCorruption } from '../../../util/analytics/vaultCorruptionTracking'; import SocialLoginIosUser from '../../Views/SocialLoginIosUser'; import AUTHENTICATION_TYPE from '../../../constants/userProperties'; +import { useOTAUpdates } from '../../hooks/useOTAUpdates'; const clearStackNavigatorOptions = { headerShown: false, @@ -516,6 +517,10 @@ const RootModalFlow = (props: RootModalFlowProps) => ( + { { ); }; -const AppContent: React.FC = () => { +const App: React.FC = () => { const navigation = useNavigation(); const routes = useNavigationState((state) => state.routes); const { toastRef } = useContext(ToastContext); @@ -1098,6 +1103,8 @@ const AppContent: React.FC = () => { selectSeedlessOnboardingLoginFlow, ); + useOTAUpdates(); + if (isFirstRender.current) { trace({ name: TraceName.NavInit, @@ -1280,14 +1287,4 @@ const AppContent: React.FC = () => { ); }; -const App: React.FC = () => { - const { isCheckingUpdates } = useOTAUpdates(); - - if (isCheckingUpdates) { - return ; - } - - return ; -}; - export default App; diff --git a/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap b/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap index f094f0109ad..a13cbccb177 100644 --- a/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/AssetElement/__snapshots__/index.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`AssetElement should render correctly 1`] = ` +exports[`AssetElement renders correctly 1`] = ` { const onPressMock = jest.fn(); const onLongPressMock = jest.fn(); + const onSecondaryBalancePressMock = jest.fn(); const erc20Token = { name: 'Dai', @@ -26,7 +31,11 @@ describe('AssetElement', () => { image: '', }; - it('should render correctly', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', () => { const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); @@ -205,4 +214,52 @@ describe('AssetElement', () => { }); expect(secondaryBalance.props.children).toBe('0.00%'); }); + + describe('onSecondaryBalancePress', () => { + it('calls onSecondaryBalancePress with asset when secondary balance is pressed', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(SECONDARY_BALANCE_BUTTON_TEST_ID)); + + expect(onSecondaryBalancePressMock).toHaveBeenCalledTimes(1); + expect(onSecondaryBalancePressMock).toHaveBeenCalledWith(erc20Token); + }); + + it('does not call onSecondaryBalancePress when handler is undefined', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(SECONDARY_BALANCE_BUTTON_TEST_ID)); + + expect(onSecondaryBalancePressMock).not.toHaveBeenCalled(); + }); + + it('does not call onSecondaryBalancePress when disabled prop is true', () => { + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId(SECONDARY_BALANCE_BUTTON_TEST_ID)); + + expect(onSecondaryBalancePressMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/UI/AssetElement/index.tsx b/app/components/UI/AssetElement/index.tsx index 6a3a15eb925..b82a39d7310 100644 --- a/app/components/UI/AssetElement/index.tsx +++ b/app/components/UI/AssetElement/index.tsx @@ -20,7 +20,11 @@ import { TOKEN_BALANCE_LOADING_UPPERCASE, TOKEN_RATE_UNDEFINED, } from '../Tokens/constants'; -import { BALANCE_TEST_ID, SECONDARY_BALANCE_TEST_ID } from './index.constants'; +import { + BALANCE_TEST_ID, + SECONDARY_BALANCE_BUTTON_TEST_ID, + SECONDARY_BALANCE_TEST_ID, +} from './index.constants'; interface AssetElementProps { children?: React.ReactNode; @@ -35,6 +39,7 @@ interface AssetElementProps { privacyMode?: boolean; hideSecondaryBalanceInPrivacyMode?: boolean; disabled?: boolean; + onSecondaryBalancePress?: (asset: TokenI) => void; } const createStyles = (colors: Colors) => @@ -74,6 +79,7 @@ const AssetElement: React.FC = ({ privacyMode = false, hideSecondaryBalanceInPrivacyMode = true, disabled = false, + onSecondaryBalancePress, }) => { const { colors } = useTheme(); const styles = createStyles(colors); @@ -86,6 +92,13 @@ const AssetElement: React.FC = ({ onLongPress?.(asset); }; + const isSecondaryDisabled = disabled || !onSecondaryBalancePress; + + const handleOnSecondaryBalancePress = () => { + if (isSecondaryDisabled) return; + onSecondaryBalancePress?.(asset); + }; + // TODO: Use the SensitiveText component when it's available // when privacyMode is true, we should hide the balance and the fiat return ( @@ -119,25 +132,32 @@ const AssetElement: React.FC = ({ )} {secondaryBalance ? ( - - {secondaryBalance === TOKEN_BALANCE_LOADING || - secondaryBalance === TOKEN_BALANCE_LOADING_UPPERCASE ? ( - - ) : ( - secondaryBalance - )} - + + {secondaryBalance === TOKEN_BALANCE_LOADING || + secondaryBalance === TOKEN_BALANCE_LOADING_UPPERCASE ? ( + + ) : ( + secondaryBalance + )} + + ) : null} diff --git a/app/components/UI/Card/components/CardAssetItem/__snapshots__/CardAssetItem.test.tsx.snap b/app/components/UI/Card/components/CardAssetItem/__snapshots__/CardAssetItem.test.tsx.snap index 712491dc53e..c18f81a2891 100644 --- a/app/components/UI/Card/components/CardAssetItem/__snapshots__/CardAssetItem.test.tsx.snap +++ b/app/components/UI/Card/components/CardAssetItem/__snapshots__/CardAssetItem.test.tsx.snap @@ -502,22 +502,28 @@ exports[`CardAssetItem Component handles test network correctly 1`] = ` > $3,000.00 - - 1000000000000000000 ETH - + + 1000000000000000000 ETH + + @@ -978,22 +984,28 @@ exports[`CardAssetItem Component renders non-native token and matches snapshot 1 > $3,000.00 - - 1000000000000000000 USDC - + + 1000000000000000000 USDC + + @@ -1449,22 +1461,28 @@ exports[`CardAssetItem Component renders with all props and matches snapshot 1`] > $3,000.00 - - 1000000000000000000 ETH - + + 1000000000000000000 ETH + + @@ -1920,22 +1938,28 @@ exports[`CardAssetItem Component renders with privacy mode enabled and matches s > ••••••••• - - •••••• - + + •••••• + + @@ -2391,22 +2415,28 @@ exports[`CardAssetItem Component renders with required props and matches snapsho > $3,000.00 - - 1000000000000000000 ETH - + + 1000000000000000000 ETH + + diff --git a/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap b/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap index 28c8df8692b..5e6a3d05c22 100644 --- a/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap +++ b/app/components/UI/Earn/components/EarnLendingBalance/__snapshots__/EarnLendingBalance.test.tsx.snap @@ -460,22 +460,28 @@ exports[`EarnLendingBalance renders balance and buttons when user has lending po > $76.00 - - 32.05 ADAI - + + 32.05 ADAI + + { + const { View } = jest.requireActual('react-native'); + const { forwardRef, useImperativeHandle } = jest.requireActual('react'); + + const MockBottomSheet = forwardRef( + (props: { children: React.ReactNode }, ref: React.Ref) => { + useImperativeHandle(ref, () => ({ + onOpenBottomSheet: jest.fn(), + onCloseBottomSheet: jest.fn((callback?: () => void) => { + if (callback) callback(); + }), + })); + + return ( + + {props.children} + + ); + }, + ); + + return { + __esModule: true, + default: MockBottomSheet, + }; + }, +); + +jest.mock('expo-updates', () => ({ + reloadAsync: jest.fn(), +})); + +jest.mock('../../../util/metrics', () => ({ + __esModule: true, + default: jest.fn().mockReturnValue({}), +})); + +jest.mock('../../../util/Logger', () => ({ + log: jest.fn(), + error: jest.fn(), +})); + +jest.mock( + '../../../component-library/components/HeaderBase', + () => + function HeaderBaseMock({ children }: { children: React.ReactNode }) { + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>{children}; + }, +); + +const mockReloadAsync = reloadAsync as jest.MockedFunction; +const mockLoggerError = Logger.error as jest.MockedFunction< + typeof Logger.error +>; + +interface MockEventBuilder { + addProperties: jest.Mock; + build: jest.Mock; +} + +const mockCreateEventBuilder = jest.fn((event: string): MockEventBuilder => { + const builder: MockEventBuilder = { + addProperties: jest.fn(), + build: jest.fn(), + }; + + builder.addProperties.mockReturnValue(builder); + builder.build.mockReturnValue({ event }); + + return builder; +}); + +const mockTrackEvent = jest.fn(); + +jest.mock('../../hooks/useMetrics', () => ({ + useMetrics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +describe('OTAUpdatesModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + (Platform as unknown as { OS: string }).OS = 'ios'; + }); + + it('tracks view event on mount', () => { + render(); + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: MetaMetricsEvents.OTA_UPDATES_MODAL_VIEWED, + }), + ); + }); + + it('tracks primary action when primary button is pressed', async () => { + const { getByText } = render(); + + fireEvent.press(getByText('Reload')); + + await waitFor(() => { + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event: MetaMetricsEvents.OTA_UPDATES_MODAL_PRIMARY_ACTION_CLICKED, + }), + ); + }); + }); + + it('reloads app when reload button is pressed on iOS', async () => { + const { getByText } = render(); + + fireEvent.press(getByText('Reload')); + + await waitFor(() => { + expect(mockReloadAsync).toHaveBeenCalledTimes(1); + }); + }); + + it('does not reload app when reload button is pressed on Android', async () => { + (Platform as unknown as { OS: string }).OS = 'android'; + + const { getByText } = render(); + + fireEvent.press(getByText('Got it')); + + await waitFor(() => { + expect(mockReloadAsync).not.toHaveBeenCalled(); + }); + }); + + it('logs error when reloadAsync throws', async () => { + const reloadError = new Error('Reload failed'); + + mockReloadAsync.mockRejectedValueOnce(reloadError); + + const { getByText } = render(); + + fireEvent.press(getByText('Reload')); + + await waitFor(() => { + expect(mockLoggerError).toHaveBeenCalledWith( + reloadError, + 'OTA Updates: Error reloading app after modal reload pressed', + ); + }); + }); +}); diff --git a/app/components/UI/OTAUpdatesModal/OTAUpdatesModal.tsx b/app/components/UI/OTAUpdatesModal/OTAUpdatesModal.tsx new file mode 100644 index 00000000000..1997ea1b6fb --- /dev/null +++ b/app/components/UI/OTAUpdatesModal/OTAUpdatesModal.tsx @@ -0,0 +1,138 @@ +import React, { useCallback, useRef, useEffect } from 'react'; +import { Image, Platform } from 'react-native'; +import { reloadAsync } from 'expo-updates'; +import { strings } from '../../../../locales/i18n'; +import Text, { + TextVariant, +} from '../../../component-library/components/Texts/Text'; +import { createNavigationDetails } from '../../../util/navigation/navUtils'; +import Routes from '../../../constants/navigation/Routes'; +import Logger from '../../../util/Logger'; +import Button, { + ButtonVariants, + ButtonWidthTypes, +} from '../../../component-library/components/Buttons/Button'; +import HeaderBase from '../../../component-library/components/HeaderBase'; +import { MetaMetricsEvents } from '../../../core/Analytics'; + +import { ScrollView } from 'react-native-gesture-handler'; +import generateDeviceAnalyticsMetaData from '../../../util/metrics'; +import { useMetrics } from '../../hooks/useMetrics'; +import { Box } from '@metamask/design-system-react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import BottomSheet, { + BottomSheetRef, +} from '../../../component-library/components/BottomSheets/BottomSheet'; + +/* eslint-disable import/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ +const foxLogo = require('../../../images/branding/fox.png'); +const metamaskName = require('../../../images/branding/metamask-name.png'); + +export const createOTAUpdatesModalNavDetails = createNavigationDetails( + Routes.MODAL.ROOT_MODAL_FLOW, + Routes.MODAL.OTA_UPDATES_MODAL, +); + +const OTAUpdatesModal = () => { + const tw = useTailwind(); + const { trackEvent, createEventBuilder } = useMetrics(); + const bottomSheetRef = useRef(null); + + useEffect(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.OTA_UPDATES_MODAL_VIEWED) + .addProperties({ + ...generateDeviceAnalyticsMetaData(), + }) + .build(), + ); + }, [trackEvent, createEventBuilder]); + + const dismissBottomSheet = (cb?: () => void): void => + bottomSheetRef.current?.onCloseBottomSheet(cb); + + const onPress = useCallback(() => { + dismissBottomSheet(async () => { + trackEvent( + createEventBuilder( + MetaMetricsEvents.OTA_UPDATES_MODAL_PRIMARY_ACTION_CLICKED, + ) + .addProperties({ + ...generateDeviceAnalyticsMetaData(), + }) + .build(), + ); + + if (Platform.OS === 'ios') { + try { + await reloadAsync(); + } catch (error) { + Logger.error( + error as Error, + 'OTA Updates: Error reloading app after modal reload pressed', + ); + } + } + }); + }, [trackEvent, createEventBuilder]); + + const primaryActionLabel = + Platform.OS === 'ios' + ? strings('ota_update_modal.primary_action_reload') + : strings('ota_update_modal.primary_action_acknowledge'); + + const description = + Platform.OS === 'ios' + ? strings('ota_update_modal.description_ios') + : strings('ota_update_modal.description_android'); + + return ( + + + + + + + + + + {strings('ota_update_modal.title')} + + + {description} + + + +