diff --git a/.eslintrc.js b/.eslintrc.js index dbe056eb551..af4a55adfec 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -181,6 +181,11 @@ module.exports = { }, { files: [ + 'app/components/hooks/useIsOriginalNativeTokenSymbol/**/*.{js,jsx,ts,tsx}', + 'app/components/hooks/useTokenBalancesController/**/*.{js,jsx,ts,tsx}', + 'app/components/hooks/useTokenBalance.tsx', + 'app/components/hooks/useTokensData/**/*.{js,jsx,ts,tsx}', + 'app/components/hooks/useSafeChains.ts', 'app/components/UI/Card/**/*.{js,jsx,ts,tsx}', 'app/components/Snaps/**/*.{js,jsx,ts,tsx}', 'app/components/UI/Predict/**/*.{js,jsx,ts,tsx}', @@ -189,6 +194,32 @@ module.exports = { 'app/components/UI/Perps/**/*.{js,jsx,ts,tsx}', 'app/components/UI/Earn/**/*.{js,jsx,ts,tsx}', 'app/components/UI/Stake/**/*.{js,jsx,ts,tsx}', + // Assets team has a large number of folder ownership areas, + 'app/components/UI/Assets/**/*.{js,jsx,ts,tsx}', + 'app/components/UI/Tokens/**/*.{js,jsx,ts,tsx}', + 'app/components/UI/AssetOverview/**/*.{js,jsx,ts,tsx}', + 'app/components/UI/Collectibles/**/*.{js,jsx,ts,tsx}', + 'app/components/UI/CollectibleContractElement/**/*.{js,jsx,ts,tsx}', + 'app/components/UI/CollectibleContractInformation/**/*.{js,jsx,ts,tsx}', + 'app/components/UI/CollectibleContractOverview/**/*.{js,jsx,ts,tsx}', + 'app/components/UI/CollectibleContracts/**/*.{js,jsx,ts,tsx}', + 'app/components/UI/CollectibleDetectionModal/**/*.{js,jsx,ts,tsx}', + 'app/components/UI/CollectibleMedia/**/*.{js,jsx,ts,tsx}', + 'app/components/UI/CollectibleModal/**/*.{js,jsx,ts,tsx}', + 'app/components/UI/CollectibleOverview/**/*.{js,jsx,ts,tsx}', + 'app/components/UI/ConfirmAddAsset/**/*.{js,jsx,ts,tsx}', + 'app/components/UI/DeFiPositions/**/*.{js,jsx,ts,tsx}', + 'app/components/UI/TokenDetails/**/*.{js,jsx,ts,tsx}', + 'app/components/Views/AddAsset/**/*.{js,jsx,ts,tsx}', + 'app/components/Views/Asset/**/*.{js,jsx,ts,tsx}', + 'app/components/Views/AssetDetails/**/*.{js,jsx,ts,tsx}', + 'app/components/Views/AssetHideConfirmation/**/*.{js,jsx,ts,tsx}', + 'app/components/Views/AssetOptions/**/*.{js,jsx,ts,tsx}', + 'app/components/Views/Collectible/**/*.{js,jsx,ts,tsx}', + 'app/components/Views/CollectibleView/**/*.{js,jsx,ts,tsx}', + 'app/components/Views/DetectedTokens/**/*.{js,jsx,ts,tsx}', + 'app/components/Views/NFTAutoDetectionModal/**/*.{js,jsx,ts,tsx}', + 'app/components/Views/NftDetails/**/*.{js,jsx,ts,tsx}', ], rules: { '@metamask/design-tokens/color-no-hex': 'error', diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 74922f3462e..9c3ca4f9541 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,15 +14,17 @@ on: type: boolean default: false source_branch: - description: 'Branch, tag, or SHA to build' + description: >- + Branch, tag, or SHA. If skip_version_bump is false: base branch for the version bump (empty uses github.ref_name). + If skip_version_bump is true: ref for prepare, node-modules setup, and build checkout (empty uses the default triggering ref). required: false type: string default: '' - ref: - description: 'Git ref to checkout when skip_version_bump is true. Defaults to the triggering event ref.' + upload_to_sentry: + description: 'If true, enable Sentry CLI upload of JS source maps and native debug symbols during the build' required: false - type: string - default: '' + type: boolean + default: false workflow_dispatch: inputs: build_name: @@ -50,6 +52,11 @@ on: required: false type: boolean default: false + upload_to_sentry: + description: 'Upload JS source maps and native debug symbols to Sentry during the build (requires Sentry auth in the build environment)' + required: false + type: boolean + default: false permissions: contents: read @@ -157,13 +164,13 @@ jobs: submodules: recursive - uses: actions/checkout@v4 - if: ${{ inputs.skip_version_bump && inputs.ref != '' }} + if: ${{ inputs.skip_version_bump && inputs.source_branch != '' }} with: - ref: ${{ inputs.ref }} + ref: ${{ inputs.source_branch }} submodules: recursive - uses: actions/checkout@v4 - if: ${{ inputs.skip_version_bump && inputs.ref == '' }} + if: ${{ inputs.skip_version_bump && inputs.source_branch == '' }} with: submodules: recursive @@ -313,6 +320,19 @@ jobs: cat ios/.xcode.env.local node --version + # Sentry: build.sh defaults SENTRY_DISABLE_AUTO_UPLOAD=true; set false only when opted in. + - name: Configure Sentry upload + env: + UPLOAD_TO_SENTRY: ${{ inputs.upload_to_sentry }} + run: | + if [ "$UPLOAD_TO_SENTRY" = "true" ]; then + echo "SENTRY_DISABLE_AUTO_UPLOAD=false" >> "$GITHUB_ENV" + echo "✅ Sentry auto-upload enabled for this build" + else + echo "SENTRY_DISABLE_AUTO_UPLOAD=true" >> "$GITHUB_ENV" + echo "ℹ️ Sentry auto-upload disabled" + fi + # Build with retry logic. Timeouts: 55min per attempt, 115min total for step, 120min job - name: Build ${{ matrix.platform }} timeout-minutes: 115 diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index a273cb2d4d9..04ea2bbe84a 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -76,7 +76,7 @@ jobs: build_name: main-exp platform: both skip_version_bump: true - ref: ${{ needs.bump-version-exp.outputs.commit-hash }} + source_branch: ${{ needs.bump-version-exp.outputs.commit-hash }} secrets: inherit build-rc: @@ -87,7 +87,7 @@ jobs: build_name: main-rc platform: both skip_version_bump: true - ref: ${{ needs.bump-version-rc.outputs.commit-hash }} + source_branch: ${{ needs.bump-version-rc.outputs.commit-hash }} secrets: inherit upload-exp-testflight: diff --git a/android/app/build.gradle b/android/app/build.gradle index ea4912e5039..d63ee423681 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -187,8 +187,8 @@ android { applicationId "io.metamask" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionName "7.72.0" - versionCode 3607 + versionName "7.71.0" + versionCode 4138 testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.test.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.test.tsx index f113cca9b55..814db82f8bb 100644 --- a/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.test.tsx +++ b/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.test.tsx @@ -24,8 +24,8 @@ const mockCreateEventBuilder = jest.fn((event) => ({ build: jest.fn(() => event), })); -jest.mock('../../../../components/hooks/useMetrics', () => ({ - useMetrics: () => ({ +jest.mock('../../../../components/hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ trackEvent: mockTrackEvent, createEventBuilder: mockCreateEventBuilder, }), diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.tsx index ea98097ace2..9f5bd44cb17 100644 --- a/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.tsx +++ b/app/component-library/components-temp/MultichainAccounts/MultichainAddWalletActions/MultichainAddWalletActions.tsx @@ -10,7 +10,7 @@ import { strings } from '../../../../../locales/i18n'; import { MetaMetricsEvents } from '../../../../core/Analytics'; import { IMetaMetricsEvent } from '../../../../core/Analytics/MetaMetrics.types'; import Routes from '../../../../constants/navigation/Routes'; -import { useMetrics } from '../../../../components/hooks/useMetrics'; +import { useAnalytics } from '../../../../components/hooks/useAnalytics/useAnalytics'; import { AddAccountBottomSheetSelectorsIDs } from '../../../../components/Views/AddAccountActions/AddAccountBottomSheet.testIds'; // Types @@ -31,7 +31,7 @@ const MultichainAddWalletActions = ({ onBack, }: MultichainAddWalletActionsProps) => { const { navigate } = useNavigation(); - const { trackEvent, createEventBuilder } = useMetrics(); + const { trackEvent, createEventBuilder } = useAnalytics(); const createActionHandler = useCallback( (config: Omit) => () => { diff --git a/app/components/Approvals/PermissionApproval/PermissionApproval.test.tsx b/app/components/Approvals/PermissionApproval/PermissionApproval.test.tsx index add23154167..628ce5af442 100644 --- a/app/components/Approvals/PermissionApproval/PermissionApproval.test.tsx +++ b/app/components/Approvals/PermissionApproval/PermissionApproval.test.tsx @@ -7,8 +7,9 @@ import { createAccountConnectNavDetails } from '../../Views/AccountConnect'; import { useSelector } from 'react-redux'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { render } from '@testing-library/react-native'; -import { useMetrics } from '../../../components/hooks/useMetrics'; -import { MetricsEventBuilder } from '../../../core/Analytics/MetricsEventBuilder'; +import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; +import { AnalyticsEventBuilder } from '../../../util/analytics/AnalyticsEventBuilder'; +import { createMockUseAnalyticsHook } from '../../../util/test/analyticsMock'; import useOriginSource from '../../hooks/useOriginSource'; import { Caip25EndowmentPermissionName, @@ -21,7 +22,7 @@ import { selectPendingApprovals } from '../../../selectors/approvalController'; import { selectAccountsLength } from '../../../selectors/accountTrackerController'; jest.mock('../../Views/confirmations/hooks/useApprovalRequest'); -jest.mock('../../../components/hooks/useMetrics'); +jest.mock('../../../components/hooks/useAnalytics/useAnalytics'); jest.mock('../../Views/AccountConnect', () => ({ createAccountConnectNavDetails: jest.fn(), @@ -116,23 +117,22 @@ const mockAccountsLength = (accountsLength: number) => { const mockTrackEvent = jest.fn(); -(useMetrics as jest.MockedFn).mockReturnValue({ - trackEvent: mockTrackEvent, - createEventBuilder: MetricsEventBuilder.createEventBuilder, - enable: jest.fn(), - addTraitsToUser: jest.fn(), - createDataDeletionTask: jest.fn(), - checkDataDeleteStatus: jest.fn(), - getDeleteRegulationCreationDate: jest.fn(), - getDeleteRegulationId: jest.fn(), - isDataRecorded: jest.fn(), - isEnabled: jest.fn(), - getMetaMetricsId: jest.fn(), -}); +jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: AnalyticsEventBuilder.createEventBuilder, + }), +); describe('PermissionApproval', () => { beforeEach(() => { jest.clearAllMocks(); + jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: AnalyticsEventBuilder.createEventBuilder, + }), + ); (useOriginSource as jest.Mock).mockImplementation(() => 'IN_APP_BROWSER'); ( getAllScopesFromPermission as jest.MockedFn< @@ -200,7 +200,7 @@ describe('PermissionApproval', () => { render(); - const expectedEvent = MetricsEventBuilder.createEventBuilder( + const expectedEvent = AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.CONNECT_REQUEST_STARTED, ) .addProperties({ diff --git a/app/components/Approvals/PermissionApproval/PermissionApproval.tsx b/app/components/Approvals/PermissionApproval/PermissionApproval.tsx index 8777fc75e33..a876cd50140 100644 --- a/app/components/Approvals/PermissionApproval/PermissionApproval.tsx +++ b/app/components/Approvals/PermissionApproval/PermissionApproval.tsx @@ -5,7 +5,7 @@ import { MetaMetricsEvents } from '../../../core/Analytics'; import { createAccountConnectNavDetails } from '../../Views/AccountConnect'; import { useSelector } from 'react-redux'; import { selectAccountsLength } from '../../../selectors/accountTrackerController'; -import { useMetrics } from '../../../components/hooks/useMetrics'; +import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; import useOriginSource from '../../hooks/useOriginSource'; import { Caip25EndowmentPermissionName, @@ -22,7 +22,7 @@ export interface PermissionApprovalProps { } const PermissionApproval = (props: PermissionApprovalProps) => { - const { trackEvent, createEventBuilder } = useMetrics(); + const { trackEvent, createEventBuilder } = useAnalytics(); const pendingApprovals = useSelector(selectPendingApprovals, isEqual); const { approvalRequest } = useApprovalRequest(); const totalAccounts = useSelector(selectAccountsLength); diff --git a/app/components/UI/AccountRightButton/index.test.tsx b/app/components/UI/AccountRightButton/index.test.tsx index e2de9b2dc95..4ee5f63836e 100644 --- a/app/components/UI/AccountRightButton/index.test.tsx +++ b/app/components/UI/AccountRightButton/index.test.tsx @@ -25,8 +25,8 @@ jest.mock('@react-navigation/native', () => ({ useRoute: () => ({ params: {} }), })); -jest.mock('../../../components/hooks/useMetrics', () => ({ - useMetrics: () => ({ +jest.mock('../../../components/hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ trackEvent: mockTrackEvent, createEventBuilder: mockCreateEventBuilder, }), diff --git a/app/components/UI/AccountRightButton/index.tsx b/app/components/UI/AccountRightButton/index.tsx index c9d6fee7faa..0d8951a0714 100644 --- a/app/components/UI/AccountRightButton/index.tsx +++ b/app/components/UI/AccountRightButton/index.tsx @@ -29,7 +29,7 @@ import { import Routes from '../../../constants/navigation/Routes'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { AccountOverviewSelectorsIDs } from './AccountOverview.testIds'; -import { useMetrics } from '../../../components/hooks/useMetrics'; +import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; import { useNetworkInfo } from '../../../selectors/selectedNetworkController'; import UrlParser from 'url-parse'; import { selectEvmChainId } from '../../../selectors/networkController'; @@ -67,7 +67,7 @@ const AccountRightButton = ({ // Placeholder ref for dismissing keyboard. Works when the focused input is within a Webview. const placeholderInputRef = useRef(null); const { navigate } = useNavigation(); - const { trackEvent, createEventBuilder } = useMetrics(); + const { trackEvent, createEventBuilder } = useAnalytics(); const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); // TODO: Replace "any" with type diff --git a/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/ResourceRing.test.tsx b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/ResourceRing.test.tsx index 2fcac1e1228..c6d9950a623 100644 --- a/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/ResourceRing.test.tsx +++ b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/ResourceRing.test.tsx @@ -10,15 +10,14 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ }, })); -const mockTheme = { - colors: { - border: { muted: '#CCCCCC' }, - primary: { default: '#0066CC' }, - }, -}; -jest.mock('../../../../util/theme', () => ({ - useTheme: () => mockTheme, -})); +jest.mock('../../../../util/theme', () => { + // Use the real mockTheme to avoid circular mock issues + const actual = jest.requireActual('../../../../util/theme'); + return { + ...actual, + useTheme: () => actual.mockTheme, + }; +}); describe('ResourceRing', () => { it('renders the ring icon', () => { diff --git a/app/components/UI/Bridge/components/TransactionDetails/TransactionDetails.tsx b/app/components/UI/Bridge/components/TransactionDetails/TransactionDetails.tsx index 33d8799415b..61047f04702 100644 --- a/app/components/UI/Bridge/components/TransactionDetails/TransactionDetails.tsx +++ b/app/components/UI/Bridge/components/TransactionDetails/TransactionDetails.tsx @@ -42,6 +42,9 @@ import { getMultichainTxFees } from '../../../../hooks/useMultichainTransactionD import { useMultichainBlockExplorerTxUrl } from '../../hooks/useMultichainBlockExplorerTxUrl'; import { StatusResponse } from '@metamask/bridge-status-controller'; import { toDateFormat } from '../../../../../util/date'; +import TagColored, { + TagColor, +} from '../../../../../component-library/components-temp/TagColored'; // import { renderShortAddress } from '../../../../../util/address'; const styles = StyleSheet.create({ @@ -99,6 +102,24 @@ interface BridgeTransactionDetailsProps { }; } +const PaidByMetaMask = () => ( + + {strings('transactions.paid_by_metamask')} + +); + const BridgeStatusToColorMap: Record = { [StatusTypes.PENDING]: TextColor.Warning, [StatusTypes.COMPLETE]: TextColor.Success, @@ -371,18 +392,24 @@ export const BridgeTransactionDetails = ( {strings('bridge_transaction_details.total_gas_fee')} - {/* TODO get solana gas fee from multiChainTx */} - {evmTotalGasFee && ( - - {evmTotalGasFee}{' '} - {getNativeAssetForChainId(quote.srcChainId).symbol} - - )} - {multiChainTotalGasFee && ( - - {multiChainTotalGasFee}{' '} - {getNativeAssetForChainId(quote.srcChainId).symbol} - + {evmTxMeta?.isGasFeeSponsored ? ( + + ) : ( + <> + {/* TODO get solana gas fee from multiChainTx */} + {evmTotalGasFee && ( + + {evmTotalGasFee}{' '} + {getNativeAssetForChainId(quote.srcChainId).symbol} + + )} + {multiChainTotalGasFee && ( + + {multiChainTotalGasFee}{' '} + {getNativeAssetForChainId(quote.srcChainId).symbol} + + )} + )} diff --git a/app/components/UI/CollectibleContractOverview/index.test.tsx b/app/components/UI/CollectibleContractOverview/index.test.tsx index 441752a1215..7bf0140fe9d 100644 --- a/app/components/UI/CollectibleContractOverview/index.test.tsx +++ b/app/components/UI/CollectibleContractOverview/index.test.tsx @@ -49,6 +49,7 @@ const initialState = { favorite: false, image: 'https://image.com/113', isCurrentlyOwned: true, + // eslint-disable-next-line @metamask/design-tokens/color-no-hex -- false positive: '#113' is part of the NFT name, not a color literal name: 'My Nft #113', standard: 'ERC721', tokenId: '113', @@ -108,6 +109,7 @@ describe('CollectibleContractOverview', () => { favorite: false, image: 'https://image.com/113', isCurrentlyOwned: true, + // eslint-disable-next-line @metamask/design-tokens/color-no-hex -- false positive: '#113' is part of the NFT name, not a color literal name: 'My Nft #113', standard: 'ERC721', tokenId: '113', diff --git a/app/components/UI/CollectibleModal/CollectibleModal.test.tsx b/app/components/UI/CollectibleModal/CollectibleModal.test.tsx index c9c50a2b6da..1881945717b 100644 --- a/app/components/UI/CollectibleModal/CollectibleModal.test.tsx +++ b/app/components/UI/CollectibleModal/CollectibleModal.test.tsx @@ -114,6 +114,7 @@ describe('CollectibleModal', () => { state: mockInitialState, }); + // eslint-disable-next-line @metamask/design-tokens/color-no-hex -- false positive: '#6904' is the NFT token ID text, not a color literal expect(await findAllByText('#6904')).toBeDefined(); expect(await findAllByText('Leopard')).toBeDefined(); }); diff --git a/app/components/UI/DeprecatedNetworkModal/DeprecatedNetworkModal.tsx b/app/components/UI/DeprecatedNetworkModal/DeprecatedNetworkModal.tsx index ee5a2b11058..26453805020 100644 --- a/app/components/UI/DeprecatedNetworkModal/DeprecatedNetworkModal.tsx +++ b/app/components/UI/DeprecatedNetworkModal/DeprecatedNetworkModal.tsx @@ -14,12 +14,12 @@ import Button, { } from '../../../component-library/components/Buttons/Button'; import { CONNECTING_TO_DEPRECATED_NETWORK } from '../../../constants/urls'; import BottomSheet from '../../../component-library/components/BottomSheets/BottomSheet'; -import { useMetrics } from '../../../components/hooks/useMetrics'; +import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../core/Analytics'; const DeprecatedNetworkModal = () => { const { styles } = useStyles(styleSheet, {}); - const { trackEvent, createEventBuilder } = useMetrics(); + const { trackEvent, createEventBuilder } = useAnalytics(); const navigation = useNavigation(); const dismissModal = (): void => { diff --git a/app/components/UI/Identity/BackupAndSyncFeaturesToggles/BackupAndSyncFeaturesToggles.test.tsx b/app/components/UI/Identity/BackupAndSyncFeaturesToggles/BackupAndSyncFeaturesToggles.test.tsx index 0108adca9cf..fefe0e3ca61 100644 --- a/app/components/UI/Identity/BackupAndSyncFeaturesToggles/BackupAndSyncFeaturesToggles.test.tsx +++ b/app/components/UI/Identity/BackupAndSyncFeaturesToggles/BackupAndSyncFeaturesToggles.test.tsx @@ -6,11 +6,12 @@ import BackupAndSyncFeaturesToggles, { import renderWithProvider from '../../../../util/test/renderWithProvider'; import { act, fireEvent, waitFor } from '@testing-library/react-native'; import { BACKUPANDSYNC_FEATURES } from '@metamask/profile-sync-controller/user-storage'; -import { useMetrics } from '../../../../components/hooks/useMetrics'; +import { useAnalytics } from '../../../../components/hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../core/Analytics'; -import { MetricsEventBuilder } from '../../../../core/Analytics/MetricsEventBuilder'; +import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder'; +import { createMockUseAnalyticsHook } from '../../../../util/test/analyticsMock'; -jest.mock('../../../../components/hooks/useMetrics'); +jest.mock('../../../../components/hooks/useAnalytics/useAnalytics'); const MOCK_STORE_STATE = { engine: { @@ -40,19 +41,12 @@ InteractionManager.runAfterInteractions = jest.fn(async (callback) => ); const mockTrackEvent = jest.fn(); -(useMetrics as jest.MockedFn).mockReturnValue({ - trackEvent: mockTrackEvent, - createEventBuilder: MetricsEventBuilder.createEventBuilder, - enable: jest.fn(), - addTraitsToUser: jest.fn(), - createDataDeletionTask: jest.fn(), - checkDataDeleteStatus: jest.fn(), - getDeleteRegulationCreationDate: jest.fn(), - getDeleteRegulationId: jest.fn(), - isDataRecorded: jest.fn(), - isEnabled: jest.fn(), - getMetaMetricsId: jest.fn(), -}); +jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: AnalyticsEventBuilder.createEventBuilder, + }), +); const mockSetIsBackupAndSyncFeatureEnabled = jest.fn(); jest.mock('../../../../util/identity/hooks/useBackupAndSync', () => ({ @@ -65,6 +59,12 @@ jest.mock('../../../../util/identity/hooks/useBackupAndSync', () => ({ describe('BackupAndSyncToggle', () => { beforeEach(() => { jest.clearAllMocks(); + jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: AnalyticsEventBuilder.createEventBuilder, + }), + ); }); it('renders correctly', () => { @@ -88,7 +88,7 @@ describe('BackupAndSyncToggle', () => { fireEvent(switchElement, 'onValueChange', true); }); - const expectedEvent = MetricsEventBuilder.createEventBuilder( + const expectedEvent = AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.SETTINGS_UPDATED, ) .addProperties({ diff --git a/app/components/UI/Identity/BackupAndSyncFeaturesToggles/BackupAndSyncFeaturesToggles.tsx b/app/components/UI/Identity/BackupAndSyncFeaturesToggles/BackupAndSyncFeaturesToggles.tsx index e10a7339678..623f96eb47b 100644 --- a/app/components/UI/Identity/BackupAndSyncFeaturesToggles/BackupAndSyncFeaturesToggles.tsx +++ b/app/components/UI/Identity/BackupAndSyncFeaturesToggles/BackupAndSyncFeaturesToggles.tsx @@ -20,7 +20,8 @@ import Icon, { IconName, } from '../../../../component-library/components/Icons/Icon'; import { strings } from '../../../../../locales/i18n'; -import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import { BACKUP_AND_SYNC_FEATURES_TOGGLES_TEST_IDS } from './BackupAndSyncFeaturesToggles.testIds'; export const backupAndSyncFeaturesTogglesSections = [ @@ -52,7 +53,7 @@ const FeatureToggle = ({ isBackupAndSyncEnabled: boolean; }) => { const theme = useTheme(); - const { trackEvent, createEventBuilder } = useMetrics(); + const { trackEvent, createEventBuilder } = useAnalytics(); const { setIsBackupAndSyncFeatureEnabled } = useBackupAndSync(); const { colors } = theme; diff --git a/app/components/UI/Identity/BackupAndSyncToggle/BackupAndSyncToggle.test.tsx b/app/components/UI/Identity/BackupAndSyncToggle/BackupAndSyncToggle.test.tsx index 23186824477..bfa9c80f922 100644 --- a/app/components/UI/Identity/BackupAndSyncToggle/BackupAndSyncToggle.test.tsx +++ b/app/components/UI/Identity/BackupAndSyncToggle/BackupAndSyncToggle.test.tsx @@ -6,11 +6,12 @@ import Routes from '../../../../constants/navigation/Routes'; import { act, fireEvent, waitFor } from '@testing-library/react-native'; import { toggleBasicFunctionality } from '../../../../actions/settings'; import { BACKUPANDSYNC_FEATURES } from '@metamask/profile-sync-controller/user-storage'; -import { useMetrics } from '../../../../components/hooks/useMetrics'; +import { useAnalytics } from '../../../../components/hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../core/Analytics'; -import { MetricsEventBuilder } from '../../../../core/Analytics/MetricsEventBuilder'; +import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder'; +import { createMockUseAnalyticsHook } from '../../../../util/test/analyticsMock'; -jest.mock('../../../../components/hooks/useMetrics'); +jest.mock('../../../../components/hooks/useAnalytics/useAnalytics'); // Mock Engine for MultichainAccountService calls jest.mock('../../../../core/Engine', () => ({ @@ -52,19 +53,12 @@ InteractionManager.runAfterInteractions = jest.fn(async (callback) => ); const mockTrackEvent = jest.fn(); -(useMetrics as jest.MockedFn).mockReturnValue({ - trackEvent: mockTrackEvent, - createEventBuilder: MetricsEventBuilder.createEventBuilder, - enable: jest.fn(), - addTraitsToUser: jest.fn(), - createDataDeletionTask: jest.fn(), - checkDataDeleteStatus: jest.fn(), - getDeleteRegulationCreationDate: jest.fn(), - getDeleteRegulationId: jest.fn(), - isDataRecorded: jest.fn(), - isEnabled: jest.fn(), - getMetaMetricsId: jest.fn(), -}); +jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: AnalyticsEventBuilder.createEventBuilder, + }), +); const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => { @@ -90,6 +84,12 @@ const mockTrackEventOverride = jest.fn(); describe('BackupAndSyncToggle', () => { beforeEach(() => { jest.clearAllMocks(); + jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: AnalyticsEventBuilder.createEventBuilder, + }), + ); }); it('renders correctly', () => { @@ -115,7 +115,7 @@ describe('BackupAndSyncToggle', () => { fireEvent(switchElement, 'onValueChange', true); }); - const expectedEvent = MetricsEventBuilder.createEventBuilder( + const expectedEvent = AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.SETTINGS_UPDATED, ) .addProperties({ diff --git a/app/components/UI/Identity/BackupAndSyncToggle/BackupAndSyncToggle.tsx b/app/components/UI/Identity/BackupAndSyncToggle/BackupAndSyncToggle.tsx index 7209bfb96e9..5a6f1155975 100644 --- a/app/components/UI/Identity/BackupAndSyncToggle/BackupAndSyncToggle.tsx +++ b/app/components/UI/Identity/BackupAndSyncToggle/BackupAndSyncToggle.tsx @@ -21,7 +21,8 @@ import { // import Routes from '../../../../constants/navigation/Routes'; import SwitchLoadingModal from '../../Notification/SwitchLoadingModal'; import { BACKUPANDSYNC_FEATURES } from '@metamask/profile-sync-controller/user-storage'; -import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import { selectIsMetamaskNotificationsEnabled } from '../../../../selectors/notifications'; import Routes from '../../../../constants/navigation/Routes'; import { useNavigation } from '@react-navigation/native'; @@ -42,7 +43,7 @@ const BackupAndSyncToggle = ({ }: Readonly) => { const theme = useTheme(); const navigation = useNavigation(); - const { trackEvent, createEventBuilder } = useMetrics(); + const { trackEvent, createEventBuilder } = useAnalytics(); const { colors } = theme; diff --git a/app/components/UI/NetworkModal/index.test.tsx b/app/components/UI/NetworkModal/index.test.tsx index 45f8ae6645d..00bbdc800ef 100644 --- a/app/components/UI/NetworkModal/index.test.tsx +++ b/app/components/UI/NetworkModal/index.test.tsx @@ -9,6 +9,8 @@ import { selectUseSafeChainsListValidation } from '../../../selectors/preference import { NetworkApprovalBottomSheetSelectorsIDs } from './NetworkApprovalBottomSheet.testIds'; import { NetworkAddedBottomSheetSelectorsIDs } from './NetworkAddedBottomSheet.testIds'; import { selectNetworkConfigurations } from '../../../selectors/networkController'; +import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; +import { createMockUseAnalyticsHook } from '../../../util/test/analyticsMock'; jest.mock('../../../util/networks', () => ({ ...jest.requireActual('../../../util/networks'), @@ -45,17 +47,11 @@ const mockNetworkControllerAddNetwork = jest.mocked( Engine.context.NetworkController.addNetwork, ); -const mockAddTraitsToUser = jest.fn(); -jest.mock('../../../components/hooks/useMetrics', () => ({ - useMetrics: () => ({ - trackEvent: jest.fn(), - createEventBuilder: jest.fn().mockReturnValue({ - addProperties: jest.fn().mockReturnThis(), - build: jest.fn().mockReturnThis(), - }), - addTraitsToUser: mockAddTraitsToUser, - }), -})); +const mockIdentify = jest.fn(); +jest.mock('../../../components/hooks/useAnalytics/useAnalytics'); +jest + .mocked(useAnalytics) + .mockReturnValue(createMockUseAnalyticsHook({ identify: mockIdentify })); interface NetworkProps { isVisible: boolean; @@ -118,6 +114,9 @@ describe('NetworkDetails', () => { beforeEach(() => { jest.clearAllMocks(); + jest + .mocked(useAnalytics) + .mockReturnValue(createMockUseAnalyticsHook({ identify: mockIdentify })); (useSelector as jest.Mock).mockImplementation((selector) => { if (selector === selectNetworkName) return 'Ethereum Main Network'; if (selector === selectUseSafeChainsListValidation) return true; @@ -258,7 +257,7 @@ describe('NetworkDetails', () => { ).toHaveBeenCalledWith('test-network-id'); }); - it('should call addTraitsToUser with chain ID list when adding a new network', async () => { + it('should call identify with chain ID list when adding a new network', async () => { const { getByTestId } = renderWithTheme(); const approveButton = getByTestId( @@ -281,12 +280,12 @@ describe('NetworkDetails', () => { fireEvent.press(switchButton); }); - expect(mockAddTraitsToUser).toHaveBeenCalledWith({ + expect(mockIdentify).toHaveBeenCalledWith({ chain_id_list: ['eip155:1', 'eip155:137'], }); }); - it('should call addTraitsToUser with chain ID list when updating an existing network', async () => { + it('should call identify with chain ID list when updating an existing network', async () => { (useSelector as jest.Mock).mockImplementation((selector) => { if (selector === selectNetworkName) return 'Ethereum Main Network'; if (selector === selectUseSafeChainsListValidation) return true; @@ -321,7 +320,7 @@ describe('NetworkDetails', () => { fireEvent.press(switchButton); }); - expect(mockAddTraitsToUser).toHaveBeenCalledWith({ + expect(mockIdentify).toHaveBeenCalledWith({ chain_id_list: ['eip155:1', 'eip155:137'], }); }); @@ -329,6 +328,11 @@ describe('NetworkDetails', () => { describe('closeModal', () => { beforeEach(() => { jest.clearAllMocks(); + jest + .mocked(useAnalytics) + .mockReturnValue( + createMockUseAnalyticsHook({ identify: mockIdentify }), + ); }); it('should handle adding new network correctly', async () => { @@ -388,6 +392,11 @@ describe('NetworkDetails', () => { beforeEach(() => { jest.clearAllMocks(); + jest + .mocked(useAnalytics) + .mockReturnValue( + createMockUseAnalyticsHook({ identify: mockIdentify }), + ); mockSelectNetwork = jest.fn(); const useNetworkSelectionModule = jest.requireMock( diff --git a/app/components/UI/NetworkModal/index.tsx b/app/components/UI/NetworkModal/index.tsx index c9fa308d5aa..2de76bcb956 100644 --- a/app/components/UI/NetworkModal/index.tsx +++ b/app/components/UI/NetworkModal/index.tsx @@ -30,7 +30,7 @@ import { ButtonProps } from '../../../component-library/components/Buttons/Butto import checkSafeNetwork from '../../../core/RPCMethods/networkChecker.util'; import NetworkVerificationInfo from '../NetworkVerificationInfo'; import createNetworkModalStyles from './index.styles'; -import { useMetrics } from '../../../components/hooks/useMetrics'; +import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; import { toHex } from '@metamask/controller-utils'; import { rpcIdentifierUtility, @@ -92,7 +92,7 @@ const NetworkModals = (props: NetworkProps) => { allowNetworkSwitch = true, skipEnableNetwork = false, } = props; - const { trackEvent, createEventBuilder, addTraitsToUser } = useMetrics(); + const { trackEvent, createEventBuilder, identify } = useAnalytics(); const [showDetails, setShowDetails] = React.useState(false); const [networkAdded, setNetworkAdded] = React.useState(false); @@ -223,7 +223,7 @@ const NetworkModals = (props: NetworkProps) => { ], }); - addTraitsToUser(addItemToChainIdList(chainId)); + identify(addItemToChainIdList(chainId)); networkClientId = addedNetwork?.rpcEndpoints?.[addedNetwork.defaultRpcEndpointIndex] @@ -313,7 +313,7 @@ const NetworkModals = (props: NetworkProps) => { blockExplorerUrl, ); - addTraitsToUser(addItemToChainIdList(chainId)); + identify(addItemToChainIdList(chainId)); const { networkClientId } = addedNetwork?.rpcEndpoints?.[addedNetwork.defaultRpcEndpointIndex] ?? diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx index aea62bd39ff..7108612c1d9 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx @@ -476,12 +476,6 @@ describe('BuildQuote', () => { type: 'success', url: 'metamask://on-ramp/providers/moonpay?orderId=ord-123', }); - mockGetOrderFromCallback.mockResolvedValue({ - providerOrderId: 'ord-123', - status: 'Pending', - cryptoAmount: '0.05', - cryptoCurrency: { symbol: 'ETH' }, - }); mockGetBuyWidgetData.mockResolvedValue({ url: 'https://widget.example.com/checkout', browser: 'IN_APP_OS_BROWSER', @@ -497,14 +491,18 @@ describe('BuildQuote', () => { }); await waitFor(() => { - expect(mockAddOrder).toHaveBeenCalled(); + expect(mockAddOrder).not.toHaveBeenCalled(); + expect(mockGetOrderFromCallback).not.toHaveBeenCalled(); expect(mockNavigationReset).toHaveBeenCalledWith({ index: 0, routes: [ { name: Routes.RAMP.RAMPS_ORDER_DETAILS, params: { - orderId: 'ord-123', + callbackUrl: + 'metamask://on-ramp/providers/moonpay?orderId=ord-123', + providerCode: 'moonpay', + walletAddress: '0x1234567890123456789012345678901234567890', showCloseButton: true, }, }, diff --git a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx index 407507a18b5..3f51b2f80f7 100644 --- a/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx @@ -20,7 +20,6 @@ import { getWidgetRedirectConfig, } from '../../utils/buildQuoteWithRedirectUrl'; import { computeAmountUpdate } from '../../utils/computeAmountUpdate'; -import { extractOrderCode } from '../../utils/extractOrderCode'; import { getRampCallbackBaseUrl } from '../../utils/getRampCallbackBaseUrl'; import { getNavigateAfterExternalBrowserRoutes } from '../../utils/rampsNavigation'; import { reportRampsError } from '../../utils/reportRampsError'; @@ -176,8 +175,6 @@ function BuildQuote() { paymentMethods, getBuyWidgetData, addPrecreatedOrder, - addOrder, - getOrderFromCallback, paymentMethodsLoading, paymentMethodsFetching, paymentMethodsStatus, @@ -683,36 +680,17 @@ function BuildQuote() { return; } - try { - const order = await getOrderFromCallback( - providerCode, - result.url, - effectiveWallet, - ); - - if (!order || isBailedOrderStatus(order.status)) { - navigateAfterExternalBrowser({ returnDestination: 'buildQuote' }); - return; - } - - addOrder(order); - - const rawOrderId = order.providerOrderId ?? effectiveOrderId; - if (!rawOrderId) { - navigateAfterExternalBrowser({ returnDestination: 'buildQuote' }); - return; - } - - const orderCode = extractOrderCode(rawOrderId); - navigateAfterExternalBrowser({ - returnDestination: 'order', - orderCode, - providerCode, - walletAddress: effectiveWallet || undefined, - }); - } catch { + if (!effectiveWallet) { navigateAfterExternalBrowser({ returnDestination: 'buildQuote' }); + return; } + + navigateAfterExternalBrowser({ + returnDestination: 'order', + callbackUrl: result.url, + providerCode, + walletAddress: effectiveWallet, + }); } finally { InAppBrowser.closeAuth(); } @@ -757,8 +735,6 @@ function BuildQuote() { navigation, getBuyWidgetData, addPrecreatedOrder, - getOrderFromCallback, - addOrder, navigateAfterExternalBrowser, ]); diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.test.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.test.tsx index 1fb9a0b7c91..e9870faf295 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.test.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { ActivityIndicator } from 'react-native'; -import { fireEvent, waitFor } from '@testing-library/react-native'; +import { fireEvent, waitFor, act } from '@testing-library/react-native'; import OrderDetails, { createRampsOrderDetailsNavDetails, } from './OrderDetails'; @@ -11,21 +11,29 @@ import { RampsOrderStatus } from '@metamask/ramps-controller'; const mockSetOptions = jest.fn(); const mockNavigate = jest.fn(); +const mockSetParams = jest.fn(); +const mockReset = jest.fn(); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), useNavigation: () => ({ setOptions: mockSetOptions, navigate: mockNavigate, goBack: jest.fn(), + setParams: mockSetParams, + reset: mockReset, }), })); const mockGetOrderById = jest.fn(); const mockRefreshOrder = jest.fn(); +const mockGetOrderFromCallback = jest.fn(); +const mockAddOrder = jest.fn(); jest.mock('../../hooks/useRampsOrders', () => ({ useRampsOrders: () => ({ getOrderById: mockGetOrderById, refreshOrder: mockRefreshOrder, + getOrderFromCallback: mockGetOrderFromCallback, + addOrder: mockAddOrder, }), })); @@ -52,7 +60,9 @@ jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ }), })); -const mockUseParams = jest.fn(() => ({ orderId: 'test-order-123' })); +const mockUseParams = jest.fn, []>(() => ({ + orderId: 'test-order-123', +})); jest.mock('../../../../../util/navigation/navUtils', () => ({ ...jest.requireActual('../../../../../util/navigation/navUtils'), useParams: () => mockUseParams(), @@ -167,7 +177,9 @@ describe('OrderDetails', () => { expect(getByText('ramps_order_details.try_again')).toBeOnTheScreen(); }); - fireEvent.press(getByText('ramps_order_details.try_again')); + await act(async () => { + fireEvent.press(getByText('ramps_order_details.try_again')); + }); expect(mockRefreshOrder).toHaveBeenCalled(); }); @@ -187,4 +199,34 @@ describe('OrderDetails', () => { const result = createRampsOrderDetailsNavDetails(); expect(result[0]).toBe(Routes.RAMP.RAMPS_ORDER_DETAILS); }); + + it('shows error state with retry when initial callback fetch fails', async () => { + mockUseParams.mockReturnValue({ + callbackUrl: 'metamask://on-ramp/providers/paypal?orderId=abc', + providerCode: 'paypal', + walletAddress: '0x123', + }); + mockGetOrderById.mockReturnValue(undefined); + mockGetOrderFromCallback.mockRejectedValue( + new Error('Network request failed'), + ); + + const { getByText } = render(); + + await waitFor(() => { + expect(getByText('Network request failed')).toBeOnTheScreen(); + }); + expect(getByText('ramps_order_details.try_again')).toBeOnTheScreen(); + + await act(async () => { + fireEvent.press(getByText('ramps_order_details.try_again')); + }); + expect(mockGetOrderFromCallback).toHaveBeenCalledTimes(2); + expect(mockGetOrderFromCallback).toHaveBeenNthCalledWith( + 2, + 'paypal', + 'metamask://on-ramp/providers/paypal?orderId=abc', + '0x123', + ); + }); }); diff --git a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx index 051781bc99c..e903408e333 100644 --- a/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx +++ b/app/components/UI/Ramp/Views/OrderDetails/OrderDetails.tsx @@ -15,7 +15,12 @@ import { normalizeProviderCode, RampsOrderStatus, } from '@metamask/ramps-controller'; +import { isBailedOrderStatus } from '../BuildQuote/BuildQuote'; import { extractOrderCode } from '../../utils/extractOrderCode'; +import { + getNavigateAfterExternalBrowserRoutes, + type RampsOrderDetailsParams, +} from '../../utils/rampsNavigation'; import Button, { ButtonVariants, ButtonSize, @@ -36,10 +41,6 @@ import { useRampsOrders } from '../../hooks/useRampsOrders'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { RampsOrderDetailsSelectorsIDs } from './OrderDetails.testIds'; -interface RampsOrderDetailsParams { - orderId: string; - showCloseButton?: boolean; -} export const createRampsOrderDetailsNavDetails = createNavigationDetails( @@ -69,12 +70,16 @@ const styles = StyleSheet.create({ const OrderDetails = () => { const params = useParams(); - const { getOrderById, refreshOrder } = useRampsOrders(); + const { getOrderById, refreshOrder, getOrderFromCallback, addOrder } = + useRampsOrders(); const orderCode = params.orderId ? extractOrderCode(params.orderId) : ''; const order = getOrderById(orderCode); const isPending = order ? PENDING_STATUSES.has(order.status) : false; + const hasCallbackParams = Boolean( + params.callbackUrl && params.providerCode && params.walletAddress, + ); - const [isLoading, setIsLoading] = useState(isPending); + const [isLoading, setIsLoading] = useState(isPending || hasCallbackParams); const [error, setError] = useState(null); const theme = useTheme(); const { colors } = theme; @@ -82,6 +87,72 @@ const OrderDetails = () => { const { trackEvent, createEventBuilder } = useAnalytics(); const [isRefreshing, setIsRefreshing] = useState(false); + const hasFetchedFromCallback = useRef(false); + + const executeCallbackFetch = useCallback( + async ( + providerCode: string, + callbackUrl: string, + walletAddress: string, + logContext: string, + ) => { + try { + setError(null); + const fetchedOrder = await getOrderFromCallback( + providerCode, + callbackUrl, + walletAddress, + ); + if (!fetchedOrder || isBailedOrderStatus(fetchedOrder.status)) { + navigation.reset({ + index: 0, + routes: getNavigateAfterExternalBrowserRoutes({ + returnDestination: 'buildQuote', + }), + }); + return; + } + addOrder(fetchedOrder); + navigation.setParams({ + orderId: fetchedOrder.providerOrderId, + callbackUrl: undefined, + providerCode: undefined, + walletAddress: undefined, + }); + } catch (fetchError) { + Logger.error(fetchError as Error, { + message: `RampsOrderDetails: error fetching order from callback URL${logContext}`, + callbackUrl, + }); + setError( + fetchError instanceof Error && fetchError.message + ? fetchError.message + : strings('ramps_order_details.error_message'), + ); + } finally { + setIsLoading(false); + } + }, + [getOrderFromCallback, addOrder, navigation], + ); + + const handleRetryCallbackFetch = useCallback(async () => { + if (!params.callbackUrl || !params.providerCode || !params.walletAddress) { + return; + } + setIsLoading(true); + await executeCallbackFetch( + params.providerCode, + params.callbackUrl, + params.walletAddress, + ' (retry)', + ); + }, [ + params.callbackUrl, + params.providerCode, + params.walletAddress, + executeCallbackFetch, + ]); useEffect(() => { navigation.setOptions( @@ -148,12 +219,38 @@ const OrderDetails = () => { }, [order, refreshOrder]); useEffect(() => { - if (isPending) { + if (isPending && !hasCallbackParams) { handleOnRefresh(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if ( + !hasCallbackParams || + hasFetchedFromCallback.current || + !params.callbackUrl || + !params.providerCode || + !params.walletAddress + ) { + return; + } + hasFetchedFromCallback.current = true; + + executeCallbackFetch( + params.providerCode, + params.callbackUrl, + params.walletAddress, + '', + ); + }, [ + hasCallbackParams, + params.callbackUrl, + params.providerCode, + params.walletAddress, + executeCallbackFetch, + ]); + if (isLoading) { return ( @@ -166,11 +263,10 @@ const OrderDetails = () => { ); } - if (!order) { - return ; - } - if (error) { + const onRetry = hasCallbackParams + ? handleRetryCallbackFetch + : handleOnRefresh; return ( @@ -198,7 +294,7 @@ const OrderDetails = () => { size={ButtonSize.Lg} width={ButtonWidthTypes.Full} label={strings('ramps_order_details.try_again')} - onPress={handleOnRefresh} + onPress={onRetry} /> @@ -206,6 +302,10 @@ const OrderDetails = () => { ); } + if (!order) { + return ; + } + return ( { }, }); }); + + it('returns order details route with callbackUrl when returning from external browser', () => { + const routes = getNavigateAfterExternalBrowserRoutes({ + returnDestination: 'order', + callbackUrl: 'metamask://on-ramp/providers/paypal?orderId=abc123', + providerCode: 'paypal-staging', + walletAddress: '0x1234', + }); + + expect(routes).toHaveLength(1); + expect(routes[0]).toEqual({ + name: Routes.RAMP.RAMPS_ORDER_DETAILS, + params: { + callbackUrl: 'metamask://on-ramp/providers/paypal?orderId=abc123', + providerCode: 'paypal-staging', + walletAddress: '0x1234', + showCloseButton: true, + }, + }); + }); }); }); diff --git a/app/components/UI/Ramp/utils/rampsNavigation.ts b/app/components/UI/Ramp/utils/rampsNavigation.ts index 8b9546ed755..844941fedae 100644 --- a/app/components/UI/Ramp/utils/rampsNavigation.ts +++ b/app/components/UI/Ramp/utils/rampsNavigation.ts @@ -1,8 +1,11 @@ import Routes from '../../../../constants/navigation/Routes'; export interface RampsOrderDetailsParams { - orderId: string; + orderId?: string; showCloseButton?: boolean; + callbackUrl?: string; + providerCode?: string; + walletAddress?: string; } export function createRampsOrderDetailsRoute(params: RampsOrderDetailsParams): { @@ -29,6 +32,12 @@ export type NavigateAfterExternalBrowserOpts = orderCode: string; providerCode: string; walletAddress?: string; + } + | { + returnDestination: 'order'; + callbackUrl: string; + providerCode: string; + walletAddress: string; }; /** @@ -43,6 +52,16 @@ export function getNavigateAfterExternalBrowserRoutes( | ReturnType )[] { if (opts.returnDestination === 'order') { + if ('callbackUrl' in opts) { + return [ + createRampsOrderDetailsRoute({ + callbackUrl: opts.callbackUrl, + providerCode: opts.providerCode, + walletAddress: opts.walletAddress, + showCloseButton: true, + }), + ]; + } return [ createRampsOrderDetailsRoute({ orderId: opts.orderCode, diff --git a/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.test.tsx b/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.test.tsx index e73fe1e01df..8b57f487e06 100644 --- a/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.test.tsx +++ b/app/components/UI/Tokens/TokenList/ScamWarningModal/ScamWarningModal.test.tsx @@ -13,15 +13,14 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(), })); -jest.mock('../../../../../util/theme', () => ({ - useTheme: () => ({ - colors: { - background: { default: '#fff' }, - border: { muted: '#ccc' }, - overlay: { default: 'rgba(0,0,0,0.5)' }, - }, - }), -})); +jest.mock('../../../../../util/theme', () => { + // Use the real mockTheme to avoid hardcoded hex literals + const actual = jest.requireActual('../../../../../util/theme'); + return { + ...actual, + useTheme: () => actual.mockTheme, + }; +}); jest.mock( '../../../../../component-library/components/Sheet/SheetHeader', diff --git a/app/components/UI/Tokens/TokenList/TokenList.test.tsx b/app/components/UI/Tokens/TokenList/TokenList.test.tsx index 63cedcce563..aa20d535d33 100644 --- a/app/components/UI/Tokens/TokenList/TokenList.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenList.test.tsx @@ -8,7 +8,6 @@ import { useNavigation } from '@react-navigation/native'; import { WalletViewSelectorsIDs } from '../../../Views/Wallet/WalletView.testIds'; import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEventBuilder'; -import { createMockUseAnalyticsHook } from '../../../../util/test/analyticsMock'; import { SCROLL_TO_TOKEN_EVENT } from '../constants'; // Mock external dependencies @@ -18,14 +17,12 @@ jest.mock('@react-navigation/native', () => ({ jest.mock('../../../hooks/useAnalytics/useAnalytics'); -jest.mock('../../../../util/theme', () => ({ - useTheme: () => ({ - colors: { - primary: { default: '#0376C9' }, - icon: { default: '#24292E' }, - }, - }), -})); +jest.mock('../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../util/theme'); + return { + useTheme: () => mockTheme, + }; +}); jest.mock('../../../../../locales/i18n', () => ({ strings: jest.fn((key) => key), @@ -143,7 +140,9 @@ const mockUseNavigation = useNavigation as jest.MockedFunction< typeof useNavigation >; const mockUseSelector = useSelector as jest.MockedFunction; -const mockUseAnalytics = jest.mocked(useAnalytics); +const mockUseAnalytics = useAnalytics as jest.MockedFunction< + typeof useAnalytics +>; const mockTokenKeys = [ { @@ -175,14 +174,20 @@ describe('TokenList', () => { navigate: mockNavigate, } as unknown as ReturnType); - mockUseAnalytics.mockReturnValue( - createMockUseAnalyticsHook({ - trackEvent: mockTrackEvent, - // The real builder is needed so build() produces the correct event shape - // for assertions on trackEvent call arguments. - createEventBuilder: AnalyticsEventBuilder.createEventBuilder, - }), - ); + mockUseAnalytics.mockReturnValue({ + trackEvent: mockTrackEvent, + createEventBuilder: AnalyticsEventBuilder.createEventBuilder, + enable: jest.fn(), + addTraitsToUser: jest.fn(), + createDataDeletionTask: jest.fn(), + checkDataDeleteStatus: jest.fn(), + getDeleteRegulationCreationDate: jest.fn(), + getDeleteRegulationId: jest.fn(), + isDataRecorded: jest.fn(), + isEnabled: jest.fn(), + getAnalyticsId: jest.fn(), + identify: jest.fn(), + }); // Mock useSelector to call the selector function with empty state mockUseSelector.mockImplementation((selector) => selector({})); diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx index ad96e4651f3..272b2d61fe7 100644 --- a/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListItem/TokenListItem.test.tsx @@ -1,6 +1,7 @@ import { BtcAccountType } from '@metamask/keyring-api'; import React from 'react'; import { useSelector } from 'react-redux'; +import { mockTheme } from '../../../../../util/theme'; import { ACCOUNT_TYPE_LABEL_TEST_ID, TokenListItem } from './TokenListItem'; import { FlashListAssetKey } from '../TokenList'; import { useTokenPricePercentageChange } from '../../hooks/useTokenPricePercentageChange'; @@ -72,9 +73,15 @@ jest.mock('@react-navigation/native', () => ({ }), })); -jest.mock('../../../../../util/theme', () => ({ - useTheme: () => ({ colors: {} }), -})); +jest.mock('../../../../../util/theme', () => { + const { mockTheme: realMockTheme } = jest.requireActual( + '../../../../../util/theme', + ); + return { + useTheme: () => ({ colors: {} }), + mockTheme: realMockTheme, + }; +}); const FIXED_NOW_MS = 1730000000000; const mockTrackEvent = jest.fn(); @@ -547,7 +554,9 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { const percentageText = getByTestId(SECONDARY_BALANCE_TEST_ID); expect(percentageText.props.children).toBe('+5.67%'); - expect(percentageText.props.style.color).toBe('#457a39'); + expect(percentageText.props.style.color).toBe( + mockTheme.colors.success.default, + ); }); it('displays dash when percentage change is not finite', () => { @@ -601,8 +610,9 @@ describe('TokenListItem - Component Rendering Tests for Coverage', () => { const percentageText = getByTestId(SECONDARY_BALANCE_TEST_ID); expect(percentageText.props.children).toBe('-3.45%'); - // Negative percentage should NOT have success color - expect(percentageText.props.style.color).not.toBe('#457a39'); + expect(percentageText.props.style.color).toBe( + mockTheme.colors.error.default, + ); }); it('hides percentage change on testnet', () => { diff --git a/app/components/UI/Tokens/TokenList/TokenListSkeleton/TokenListSkeleton.test.tsx b/app/components/UI/Tokens/TokenList/TokenListSkeleton/TokenListSkeleton.test.tsx index e880da5f4df..311dcd415ef 100644 --- a/app/components/UI/Tokens/TokenList/TokenListSkeleton/TokenListSkeleton.test.tsx +++ b/app/components/UI/Tokens/TokenList/TokenListSkeleton/TokenListSkeleton.test.tsx @@ -3,20 +3,12 @@ import { render } from '@testing-library/react-native'; import TokenListSkeleton from './TokenListSkeleton'; // Mock the theme hook -jest.mock('../../../../../util/theme', () => ({ - useTheme: () => ({ - colors: { - background: { - section: '#E5E5E5', - subsection: '#F5F5F5', - default: '#FFFFFF', - }, - border: { - muted: '#D6D9DC', - }, - }, - }), -})); +jest.mock('../../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../../util/theme'); + return { + useTheme: () => mockTheme, + }; +}); describe('TokenListSkeleton', () => { it('renders without errors', () => { diff --git a/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.test.tsx b/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.test.tsx index 05004b1751a..3e8453d629a 100644 --- a/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.test.tsx +++ b/app/components/UI/Tokens/TokenListControlBar/TokenListControlBar.test.tsx @@ -94,15 +94,12 @@ jest.mock('../../../../selectors/networkInfos', () => ({ })); // Mock the theme -jest.mock('../../../../util/theme', () => ({ - useTheme: () => ({ - colors: { - background: { default: '#ffffff' }, - text: { default: '#000000' }, - border: { muted: '#e0e0e0' }, - }, - }), -})); +jest.mock('../../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../../util/theme'); + return { + useTheme: () => mockTheme, + }; +}); const mockStore = configureMockStore(); diff --git a/app/components/Views/AccountConnect/AccountConnect.test.tsx b/app/components/Views/AccountConnect/AccountConnect.test.tsx index 1c53741b6c7..36fb66351a3 100644 --- a/app/components/Views/AccountConnect/AccountConnect.test.tsx +++ b/app/components/Views/AccountConnect/AccountConnect.test.tsx @@ -90,8 +90,8 @@ jest.mock('@tommasini/react-native-scrollable-tab-view', () => ({ ), })); -jest.mock('../../../components/hooks/useMetrics', () => ({ - useMetrics: () => ({ +jest.mock('../../../components/hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ trackEvent: mockedTrackEvent, createEventBuilder: mockCreateEventBuilder, }), diff --git a/app/components/Views/AccountConnect/AccountConnect.tsx b/app/components/Views/AccountConnect/AccountConnect.tsx index 892f6d3d71a..1045889e9f1 100644 --- a/app/components/Views/AccountConnect/AccountConnect.tsx +++ b/app/components/Views/AccountConnect/AccountConnect.tsx @@ -42,7 +42,7 @@ import { useAccounts } from '../../hooks/useAccounts'; // Internal dependencies. import { PermissionsRequest } from '@metamask/permission-controller'; import PhishingModal from '../../../components/UI/PhishingModal'; -import { useMetrics } from '../../../components/hooks/useMetrics'; +import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; import Routes from '../../../constants/navigation/Routes'; import { MM_BLOCKLIST_ISSUE_URL, @@ -120,7 +120,7 @@ const AccountConnect = (props: AccountConnectProps) => { const previousIdentitiesListSize = useRef(); const internalAccounts = useSelector(selectInternalAccountsWithCaipAccountId); const navigation = useNavigation(); - const { trackEvent, createEventBuilder } = useMetrics(); + const { trackEvent, createEventBuilder } = useAnalytics(); const [blockedUrl, setBlockedUrl] = useState(''); diff --git a/app/components/Views/AccountPermissions/AccountPermissions.test.tsx b/app/components/Views/AccountPermissions/AccountPermissions.test.tsx index c14b10700c2..76e20677a6a 100644 --- a/app/components/Views/AccountPermissions/AccountPermissions.test.tsx +++ b/app/components/Views/AccountPermissions/AccountPermissions.test.tsx @@ -224,8 +224,8 @@ jest.mock('@react-navigation/native', () => { }; }); -jest.mock('../../../components/hooks/useMetrics', () => ({ - useMetrics: () => ({ +jest.mock('../../../components/hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ trackEvent: mockedTrackEvent, }), })); diff --git a/app/components/Views/AccountPermissions/AccountPermissions.tsx b/app/components/Views/AccountPermissions/AccountPermissions.tsx index be09c49c7ad..74568022d0c 100755 --- a/app/components/Views/AccountPermissions/AccountPermissions.tsx +++ b/app/components/Views/AccountPermissions/AccountPermissions.tsx @@ -58,7 +58,7 @@ import AccountPermissionsConnected from './AccountPermissionsConnected'; import { USER_INTENT } from '../../../constants/permissions'; import useFavicon from '../../hooks/useFavicon/useFavicon'; import URLParse from 'url-parse'; -import { useMetrics } from '../../../components/hooks/useMetrics'; +import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; import { selectPermissionControllerState } from '../../../selectors/snaps/permissionController'; import { RootState } from '../../../reducers'; import { getNetworkImageSource } from '../../../util/networks'; @@ -95,7 +95,7 @@ import { getConnectedDevicesCount } from '../../../core/HardwareWallets/analytic const AccountPermissions = (props: AccountPermissionsProps) => { const { navigate } = useNavigation(); const { styles } = useStyles(styleSheet, {}); - const { trackEvent, createEventBuilder } = useMetrics(); + const { trackEvent, createEventBuilder } = useAnalytics(); const { hostInfo: { metadata: { origin: hostname }, diff --git a/app/components/Views/AccountPermissions/NetworkPermissionsConnected/NetworkPermissionsConnected.test.tsx b/app/components/Views/AccountPermissions/NetworkPermissionsConnected/NetworkPermissionsConnected.test.tsx index 762170335a1..320f64afe58 100644 --- a/app/components/Views/AccountPermissions/NetworkPermissionsConnected/NetworkPermissionsConnected.test.tsx +++ b/app/components/Views/AccountPermissions/NetworkPermissionsConnected/NetworkPermissionsConnected.test.tsx @@ -46,8 +46,8 @@ jest.mock('@react-navigation/native', () => { }; }); -jest.mock('../../../../components/hooks/useMetrics', () => ({ - useMetrics: () => ({ +jest.mock('../../../../components/hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ trackEvent: mockedTrackEvent, }), })); diff --git a/app/components/Views/AccountPermissions/NetworkPermissionsConnected/NetworkPermissionsConnected.tsx b/app/components/Views/AccountPermissions/NetworkPermissionsConnected/NetworkPermissionsConnected.tsx index ae72661b2cf..ff5b8527fb4 100644 --- a/app/components/Views/AccountPermissions/NetworkPermissionsConnected/NetworkPermissionsConnected.tsx +++ b/app/components/Views/AccountPermissions/NetworkPermissionsConnected/NetworkPermissionsConnected.tsx @@ -27,7 +27,7 @@ import Logger from '../../../../util/Logger'; // Internal dependencies. import { NetworkPermissionsConnectedProps } from './NetworkPermissionsConnected.types'; import styles from './NetworkPermissionsConnected.styles'; -import { useMetrics } from '../../../../components/hooks/useMetrics'; +import { useAnalytics } from '../../../../components/hooks/useAnalytics/useAnalytics'; import Text, { TextVariant, } from '../../../../component-library/components/Texts/Text'; @@ -56,7 +56,7 @@ const NetworkPermissionsConnected = ({ favicon, }: NetworkPermissionsConnectedProps) => { const { navigate } = useNavigation(); - const { trackEvent, createEventBuilder } = useMetrics(); + const { trackEvent, createEventBuilder } = useAnalytics(); const evmChainId = useSelector(selectEvmChainId); const evmCaipChainId = `eip155:${parseInt(evmChainId, 16)}`; diff --git a/app/components/Views/AddAccountActions/AddAccountActions.test.tsx b/app/components/Views/AddAccountActions/AddAccountActions.test.tsx index c9e797f1c59..c60755a9aed 100644 --- a/app/components/Views/AddAccountActions/AddAccountActions.test.tsx +++ b/app/components/Views/AddAccountActions/AddAccountActions.test.tsx @@ -17,8 +17,9 @@ import { RootState } from '../../../reducers'; import { WalletClientType } from '../../../core/SnapKeyring/MultichainWalletSnapClient'; import { MultichainNetwork } from '@metamask/multichain-transactions-controller'; import { MetaMetricsEvents } from '../../../core/Analytics'; -import { MetricsEventBuilder } from '../../../core/Analytics/MetricsEventBuilder'; -import { useMetrics } from '../../../components/hooks/useMetrics'; +import { AnalyticsEventBuilder } from '../../../util/analytics/AnalyticsEventBuilder'; +import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; +import { createMockUseAnalyticsHook } from '../../../util/test/analyticsMock'; import { TrxScope } from '@metamask/keyring-api'; const mockedNavigate = jest.fn(); @@ -33,8 +34,8 @@ jest.mock('@react-navigation/native', () => { }); const mockTrackEvent = jest.fn(); -jest.mock('../../../components/hooks/useMetrics', () => ({ - useMetrics: jest.fn(), +jest.mock('../../../components/hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: jest.fn(), })); jest.mock('../../../actions/multiSrp', () => ({ @@ -64,10 +65,12 @@ describe('AddAccountActions', () => { beforeEach(() => { jest.clearAllMocks(); - (useMetrics as jest.Mock).mockReturnValue({ - trackEvent: mockTrackEvent, - createEventBuilder: MetricsEventBuilder.createEventBuilder, - }); + jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: AnalyticsEventBuilder.createEventBuilder, + }), + ); }); it('renders correctly', () => { @@ -225,7 +228,7 @@ describe('AddAccountActions', () => { fireEvent.press(importSrpButton); expect(mockTrackEvent).toHaveBeenCalledWith( - MetricsEventBuilder.createEventBuilder( + AnalyticsEventBuilder.createEventBuilder( MetaMetricsEvents.IMPORT_SECRET_RECOVERY_PHRASE_CLICKED, ).build(), ); diff --git a/app/components/Views/AddAccountActions/AddAccountActions.tsx b/app/components/Views/AddAccountActions/AddAccountActions.tsx index 2c021e213c2..6992b2e6907 100644 --- a/app/components/Views/AddAccountActions/AddAccountActions.tsx +++ b/app/components/Views/AddAccountActions/AddAccountActions.tsx @@ -15,7 +15,7 @@ import Logger from '../../../util/Logger'; import { AddAccountActionsProps } from './AddAccountActions.types'; import { AddAccountBottomSheetSelectorsIDs } from './AddAccountBottomSheet.testIds'; import Routes from '../../../constants/navigation/Routes'; -import { useMetrics } from '../../../components/hooks/useMetrics'; +import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; import { useStyles } from '../../hooks/useStyles'; import styleSheet from './AddAccountActions.styles'; @@ -40,7 +40,7 @@ import { useAccountsWithNetworkActivitySync } from '../../hooks/useAccountsWithN const AddAccountActions = ({ onBack }: AddAccountActionsProps) => { const { styles } = useStyles(styleSheet, {}); const { navigate } = useNavigation(); - const { trackEvent, createEventBuilder } = useMetrics(); + const { trackEvent, createEventBuilder } = useAnalytics(); const [isLoading, setIsLoading] = useState(false); const hdKeyrings = useSelector(selectHDKeyrings); const hasMultipleSRPs = hdKeyrings.length > 1; diff --git a/app/components/Views/DetectedTokens/index.test.tsx b/app/components/Views/DetectedTokens/index.test.tsx index 714baba128d..f972fbb0f4b 100644 --- a/app/components/Views/DetectedTokens/index.test.tsx +++ b/app/components/Views/DetectedTokens/index.test.tsx @@ -27,16 +27,12 @@ jest.mock('@react-navigation/native', () => ({ })), })); -jest.mock('../../../util/theme', () => ({ - useTheme: jest.fn(() => ({ - colors: { - background: { default: '#fff' }, - border: { default: '#ccc' }, - text: { default: '#000' }, - primary: { default: '#f00' }, - }, - })), -})); +jest.mock('../../../util/theme', () => { + const { mockTheme } = jest.requireActual('../../../util/theme'); + return { + useTheme: jest.fn(() => mockTheme), + }; +}); jest.mock( '../../../component-library/components/BottomSheets/BottomSheet', diff --git a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.test.tsx b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.test.tsx index faf54eedb69..5185c142b38 100644 --- a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.test.tsx +++ b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.test.tsx @@ -27,6 +27,8 @@ import { PermissionDoesNotExistError } from '@metamask/permission-controller'; import { RpcEndpointType, NetworkStatus } from '@metamask/network-controller'; import { WC2VerifyValidation } from '../../../../actions/sdk/state'; import { AccountConnectMaliciousWarningSelectorsIDs } from '../../AccountConnect/AccountConnectMaliciousWarning/AccountConnectMaliciousWarning.testIds'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; +import { createMockUseAnalyticsHook } from '../../../../util/test/analyticsMock'; const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); @@ -56,12 +58,13 @@ jest.mock('@react-navigation/native', () => { }; }); -jest.mock('../../../hooks/useMetrics', () => ({ - useMetrics: () => ({ +jest.mock('../../../hooks/useAnalytics/useAnalytics'); +jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ trackEvent: mockTrackEvent, createEventBuilder: mockCreateEventBuilder, }), -})); +); jest.mock('@tommasini/react-native-scrollable-tab-view', () => ({ __esModule: true, @@ -502,6 +505,12 @@ mockIsUUID.mockReturnValue(false); describe('MultichainAccountConnect', () => { beforeEach(() => { jest.clearAllMocks(); + jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), + ); }); it('renders correctly with base request when there is no existing CAIP endowment', () => { @@ -732,6 +741,12 @@ describe('MultichainAccountConnect', () => { mockUseSDKV2Connection.mockReset(); mockUseSDKV2Connection.mockReturnValue(undefined); jest.clearAllMocks(); + jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), + ); }); it('handles MMSDK remote connection origin correctly', () => { @@ -1396,6 +1411,12 @@ describe('MultichainAccountConnect', () => { describe('Phishing modal navigation functions coverage', () => { beforeEach(() => { jest.clearAllMocks(); + jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), + ); }); it('creates phishing navigation callback functions', () => { @@ -1483,6 +1504,12 @@ describe('MultichainAccountConnect', () => { describe('handleNetworksSelected function and network tab functionality', () => { beforeEach(() => { jest.clearAllMocks(); + jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), + ); }); it('navigates to network selector screen when editing networks', async () => { @@ -1831,6 +1858,12 @@ describe('MultichainAccountConnect', () => { describe('handleAccountGroupsSelected function tests', () => { beforeEach(() => { jest.clearAllMocks(); + jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), + ); }); it('verifies handleAccountGroupsSelected updates component state after account selection', async () => { @@ -2190,6 +2223,12 @@ describe('MultichainAccountConnect', () => { beforeEach(() => { jest.clearAllMocks(); + jest.mocked(useAnalytics).mockReturnValue( + createMockUseAnalyticsHook({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), + ); mockGetConnection.mockReturnValue(undefined); mockIsUUID.mockReturnValue(false); }); diff --git a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.tsx b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.tsx index 83045953bf8..c5970082a81 100644 --- a/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.tsx +++ b/app/components/Views/MultichainAccounts/MultichainAccountConnect/MultichainAccountConnect.tsx @@ -33,7 +33,7 @@ import { // Internal dependencies. import { PermissionsRequest } from '@metamask/permission-controller'; import PhishingModal from '../../../UI/PhishingModal/index.js'; -import { useMetrics } from '../../../hooks/useMetrics/index.ts'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import Routes from '../../../../constants/navigation/Routes.ts'; import { MM_BLOCKLIST_ISSUE_URL, @@ -133,7 +133,7 @@ const MultichainAccountConnect = (props: AccountConnectProps) => { const [tabIndex, setTabIndex] = useState(0); const previousIdentitiesListSize = useRef(); const navigation = useNavigation(); - const { trackEvent, createEventBuilder } = useMetrics(); + const { trackEvent, createEventBuilder } = useAnalytics(); const [blockedUrl, setBlockedUrl] = useState(''); diff --git a/app/components/Views/TrendingView/TrendingView.view.test.tsx b/app/components/Views/TrendingView/TrendingView.view.test.tsx index 4379531e969..c11cf0d7e93 100644 --- a/app/components/Views/TrendingView/TrendingView.view.test.tsx +++ b/app/components/Views/TrendingView/TrendingView.view.test.tsx @@ -53,7 +53,7 @@ const assertTrendingTokenRowsVisibility = async (opts: { expect(queryByTestId(result.id)).not.toBeOnTheScreen(); }); }, - { timeout: 2000 }, + { timeout: 5000 }, ); }; @@ -319,6 +319,11 @@ describeForPlatforms('TrendingTokensFullView - Component Tests', () => { ).toBeOnTheScreen(); }); + await assertTrendingTokenRowsVisibility({ + queryByTestId, + visible: [{ id: TRENDING_ETHEREUM_ID }], + }); + const searchToggle = getByTestId( TrendingViewSelectorsIDs.TRENDING_TOKENS_HEADER_SEARCH_TOGGLE, ); diff --git a/bitrise.yml b/bitrise.yml index 5788385541d..e54008acc10 100644 --- a/bitrise.yml +++ b/bitrise.yml @@ -3534,13 +3534,13 @@ app: VERSION_NAME: 7.72.0 - opts: is_expand: false - VERSION_NUMBER: 3911 + VERSION_NUMBER: 4138 - opts: is_expand: false FLASK_VERSION_NAME: 7.72.0 - opts: is_expand: false - FLASK_VERSION_NUMBER: 3911 + FLASK_VERSION_NUMBER: 4138 - opts: is_expand: false ANDROID_APK_LINK: '' diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index 86cb45766de..a93ca04e9a7 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -1281,7 +1281,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3911; + CURRENT_PROJECT_VERSION = 4138; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1350,7 +1350,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3911; + CURRENT_PROJECT_VERSION = 4138; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1416,7 +1416,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3911; + CURRENT_PROJECT_VERSION = 4138; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1483,7 +1483,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3911; + CURRENT_PROJECT_VERSION = 4138; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; @@ -1646,7 +1646,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3911; + CURRENT_PROJECT_VERSION = 4138; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = 48XVW22RCG; @@ -1716,7 +1716,7 @@ CODE_SIGN_ENTITLEMENTS = MetaMask/MetaMask.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 3911; + CURRENT_PROJECT_VERSION = 4138; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 48XVW22RCG; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 48XVW22RCG; diff --git a/package.json b/package.json index a7c82bb1bb8..39a1eaede16 100644 --- a/package.json +++ b/package.json @@ -278,7 +278,7 @@ "@metamask/preinstalled-example-snap": "^0.7.2", "@metamask/profile-metrics-controller": "^3.1.0", "@metamask/profile-sync-controller": "^28.0.0", - "@metamask/ramps-controller": "^12.0.0", + "@metamask/ramps-controller": "^12.0.1", "@metamask/react-native-acm": "1.2.0", "@metamask/react-native-actionsheet": "2.4.2", "@metamask/react-native-button": "^3.0.0", diff --git a/tests/component-view/api-mocking/trending.ts b/tests/component-view/api-mocking/trending.ts index d8cc56e25b6..b3a31fc958c 100644 --- a/tests/component-view/api-mocking/trending.ts +++ b/tests/component-view/api-mocking/trending.ts @@ -1,6 +1,11 @@ /** * Trending tokens API mock for component view tests. - * Intercepts GET https://token.api.cx.metamask.io/v3/tokens/trending via nock. + * Intercepts: + * - GET https://token.api.cx.metamask.io/v3/tokens/trending (trending list) + * - GET https://token.api.cx.metamask.io/tokens/search (token search from useSearchRequest) + * + * Search must be mocked when net connect is disabled; otherwise searchTokens can stall + * until fetch timeout while useTrendingSearch stays loading, and short waitFor timeouts flake. * * Use in beforeEach/afterEach of TrendingView.view.test.tsx (or any view that * loads trending data). See tests/component-view/api-mocking/ and references/navigation-mocking.md for adding other API mocks. @@ -82,6 +87,7 @@ export const mockBnbChainToken: MockTrendingToken[] = [ const TRENDING_ORIGIN = 'https://token.api.cx.metamask.io'; const TRENDING_PATH = '/v3/tokens/trending'; +const TOKEN_SEARCH_PATH = '/tokens/search'; /** * Sets up the nock mock for the trending tokens API. @@ -106,6 +112,12 @@ export function setupTrendingApiFetchMock( .query(true) .reply(200, (uri: string) => replyBody(uri)) .persist(); + + nock(TRENDING_ORIGIN) + .get(TOKEN_SEARCH_PATH) + .query(true) + .reply(200, { count: 0, data: [] }) + .persist(); } /** diff --git a/tests/framework/fixtures/json/default-fixture.json b/tests/framework/fixtures/json/default-fixture.json index a1217640f7b..c602cc5d5c0 100644 --- a/tests/framework/fixtures/json/default-fixture.json +++ b/tests/framework/fixtures/json/default-fixture.json @@ -480,6 +480,55 @@ "useTransactionSimulations": true }, "RampsController": { + "countries": { + "data": [], + "error": null, + "isLoading": false, + "selected": null + }, + "nativeProviders": { + "transak": { + "buyQuote": { + "data": null, + "error": null, + "isLoading": false, + "selected": null + }, + "isAuthenticated": false, + "kycRequirement": { + "data": null, + "error": null, + "isLoading": false, + "selected": null + }, + "userDetails": { + "data": null, + "error": null, + "isLoading": false, + "selected": null + } + } + }, + "orders": [], + "paymentMethods": { + "data": [], + "error": null, + "isLoading": false, + "selected": null + }, + "providers": { + "data": [], + "error": null, + "isLoading": false, + "selected": null + }, + "requests": {}, + "tokens": { + "data": null, + "error": null, + "isLoading": false, + "selected": null + }, "userRegion": null }, "RemoteFeatureFlagController": { diff --git a/yarn.lock b/yarn.lock index a1fe3f093ef..877c3144632 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9422,14 +9422,14 @@ __metadata: languageName: node linkType: hard -"@metamask/ramps-controller@npm:^12.0.0": - version: 12.0.0 - resolution: "@metamask/ramps-controller@npm:12.0.0" +"@metamask/ramps-controller@npm:^12.0.1": + version: 12.0.1 + resolution: "@metamask/ramps-controller@npm:12.0.1" dependencies: "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0" "@metamask/messenger": "npm:^0.3.0" - checksum: 10/fb22bbab95045b7c5d80d3fb24bd41b831e0516733cdf1a0159514cbd8b0aededd599818adecfbc86802299095a223251733c271a8d0b6db2d88b25c971da1ed + checksum: 10/a7f9428cb824bd0175ee1cc603d77c650fa7a23c7183e2cc0a0f21ee9b6378d80bbd1e496654e40d2edcfc840e60dd4a09d80feacb1746087a67b66761e1e6c7 languageName: node linkType: hard @@ -35586,7 +35586,7 @@ __metadata: "@metamask/profile-metrics-controller": "npm:^3.1.0" "@metamask/profile-sync-controller": "npm:^28.0.0" "@metamask/providers": "npm:^18.3.1" - "@metamask/ramps-controller": "npm:^12.0.0" + "@metamask/ramps-controller": "npm:^12.0.1" "@metamask/react-native-acm": "npm:1.2.0" "@metamask/react-native-actionsheet": "npm:2.4.2" "@metamask/react-native-button": "npm:^3.0.0"