From 1db8f0f6b50f7c8f01565ee4143d1db93635e8aa Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:48:23 -0400 Subject: [PATCH 01/12] feat: MUSD-954 add "Paid by MetaMask" for Money account deposits when fees are subsidized (#31485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds the "Paid by MetaMask" tag for Money account deposits when fees are subsidized. ## **Changelog** CHANGELOG entry: added paid by metamask tag for Money account deposits when fees are subsidized. ## **Related issues** Fixes: [MUSD-954: Add "Paid by MetaMask" for subsidized Money account deposits](https://consensyssoftware.atlassian.net/browse/MUSD-954) ## **Manual testing steps** ```gherkin Feature: Paid by MetaMask tag for subsidized Money account deposits Scenario: Paid By MetaMask tag displayed on Money account deposit screen Given user has money account AND user has "no fee" token When user selects a token in the "Earn on your crypto" section that has a "no fee" token Then on Money account deposit screen the "Paid by MetaMask" tag is displayed next to the transaction fee ``` ## **Screenshots/Recordings** ### **Before** N/A - We showed `Transaction Fee: $0` ### **After** image ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Small confirmation UI and hook logic change with tests; no auth or payment execution paths modified. > > **Overview** > Extends **"Paid by MetaMask"** on the confirmation fee row to **Money account deposits** when all pay fees are zero and quotes exist—the same subsidized-fee behavior already used for mUSD conversion. > > `useIsPaidByMetaMask` now treats `TransactionType.moneyAccountDeposit` as supported and **returns false** when a fiat payment method is selected (`selectedPaymentMethodId` from `useTransactionPayFiatPayment`), so Apple Pay and similar flows do not show the subsidized label. Tests cover the new deposit type in `bridge-fee-row` and the fiat-payment guard in the hook. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 9e02bc6b62702d13cf1f2b5b20f61d573b288b97. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../rows/bridge-fee-row/bridge-fee-row.test.tsx | 12 ++++++++++++ .../hooks/pay/useIsPaidByMetaMask.test.ts | 16 ++++++++++++++++ .../hooks/pay/useIsPaidByMetaMask.ts | 10 ++++++++-- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx b/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx index 17405b97401..112bc5518cb 100644 --- a/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx +++ b/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx @@ -214,6 +214,18 @@ describe('BridgeFeeRow', () => { expect(queryByTestId('transaction-fee')).toBeNull(); }); + it('renders paid by MetaMask label for Money Account Deposit with all-zero fees and quotes', () => { + useTransactionTotalsMock.mockReturnValue(zeroFeesTotals); + useIsPaidByMetaMaskMock.mockReturnValue(true); + + const { getByText, queryByTestId } = render({ + type: TransactionType.moneyAccountDeposit, + }); + + expect(getByText('Paid by MetaMask')).toBeOnTheScreen(); + expect(queryByTestId('transaction-fee')).toBeNull(); + }); + it('hides the tooltip icon when paid by MetaMask is shown', () => { useTransactionTotalsMock.mockReturnValue(zeroFeesTotals); useIsPaidByMetaMaskMock.mockReturnValue(true); diff --git a/app/components/Views/confirmations/hooks/pay/useIsPaidByMetaMask.test.ts b/app/components/Views/confirmations/hooks/pay/useIsPaidByMetaMask.test.ts index f40eebc2066..0a37b67f38a 100644 --- a/app/components/Views/confirmations/hooks/pay/useIsPaidByMetaMask.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useIsPaidByMetaMask.test.ts @@ -3,6 +3,7 @@ import { TransactionType, } from '@metamask/transaction-controller'; import { + TransactionFiatPayment, TransactionPayQuote, TransactionPayTotals, } from '@metamask/transaction-pay-controller'; @@ -12,6 +13,7 @@ import { Json } from '@metamask/utils'; import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; import { useIsPaidByMetaMask } from './useIsPaidByMetaMask'; import { + useTransactionPayFiatPayment, useTransactionPayQuotes, useTransactionPayTotals, } from './useTransactionPayData'; @@ -54,12 +56,16 @@ function runHook({ type }: { type?: TransactionType } = {}) { } describe('useIsPaidByMetaMask', () => { + const useTransactionPayFiatPaymentMock = jest.mocked( + useTransactionPayFiatPayment, + ); const useTransactionPayQuotesMock = jest.mocked(useTransactionPayQuotes); const useTransactionPayTotalsMock = jest.mocked(useTransactionPayTotals); beforeEach(() => { jest.resetAllMocks(); + useTransactionPayFiatPaymentMock.mockReturnValue(undefined); useTransactionPayQuotesMock.mockReturnValue([ {} as TransactionPayQuote, ]); @@ -88,6 +94,16 @@ describe('useIsPaidByMetaMask', () => { expect(result.current).toBe(false); }); + it('returns false when a fiat payment method is selected', () => { + useTransactionPayFiatPaymentMock.mockReturnValue({ + selectedPaymentMethodId: 'apple-pay', + } as TransactionFiatPayment); + + const { result } = runHook({ type: TransactionType.moneyAccountDeposit }); + + expect(result.current).toBe(false); + }); + it('returns true when all four fee components are zero and the type is musdConversion', () => { const { result } = runHook({ type: TransactionType.musdConversion }); diff --git a/app/components/Views/confirmations/hooks/pay/useIsPaidByMetaMask.ts b/app/components/Views/confirmations/hooks/pay/useIsPaidByMetaMask.ts index 99dfa142885..53b6ba77e24 100644 --- a/app/components/Views/confirmations/hooks/pay/useIsPaidByMetaMask.ts +++ b/app/components/Views/confirmations/hooks/pay/useIsPaidByMetaMask.ts @@ -3,13 +3,18 @@ import { BigNumber } from 'bignumber.js'; import { hasTransactionType } from '../../utils/transaction'; import { useTransactionMetadataOrThrow } from '../transactions/useTransactionMetadataRequest'; import { + useTransactionPayFiatPayment, useTransactionPayQuotes, useTransactionPayTotals, } from './useTransactionPayData'; -const SUPPORTED_TYPES = [TransactionType.musdConversion]; +const SUPPORTED_TYPES = [ + TransactionType.musdConversion, + TransactionType.moneyAccountDeposit, +]; export function useIsPaidByMetaMask(): boolean { + const { selectedPaymentMethodId } = useTransactionPayFiatPayment() || {}; const totals = useTransactionPayTotals(); const quotes = useTransactionPayQuotes(); const transactionMetadata = useTransactionMetadataOrThrow(); @@ -17,7 +22,8 @@ export function useIsPaidByMetaMask(): boolean { if ( !quotes?.length || !totals?.fees || - !hasTransactionType(transactionMetadata, SUPPORTED_TYPES) + !hasTransactionType(transactionMetadata, SUPPORTED_TYPES) || + selectedPaymentMethodId ) { return false; } From 5a6271bd6182b4398f5d8e4e5826de2bb6ae5ae3 Mon Sep 17 00:00:00 2001 From: Wei Sun Date: Wed, 10 Jun 2026 12:49:56 -0700 Subject: [PATCH 02/12] chore: remove unused CollectiblesDetails route and CollectibleModal (#31482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** When working on Main Navigator, I realized CollectiblesDetails route is not in used anymore. This PR is to clean up this route and the component: - Remove the legacy `CollectiblesDetails` stack screen from `MainNavigator`, which was registered but never navigated to - Delete the orphaned `CollectibleModal` component and its tests, styles, and types - Remove the unused `InitSendLocation.CollectibleModal` send analytics constant and the `CollectibleModal` CODEOWNERS entry NFT detail flows already use the `NftDetails` screen (e.g. from `NftGridItem`). The `/nft` deeplink also routes to `NFTS_FULL_VIEW`, not this modal. No in-app navigation, deeplinks, or Braze campaigns referenced `CollectiblesDetails`. simulator_screenshot_123411D7-6C42-4AEE-A9F2-973B5197916D ## **Changelog** CHANGELOG entry:null ## **Related issues** Fixes: clean up CollectiblesDetails route ## **Manual testing steps** ```gherkin Feature: NFT details navigation after CollectiblesDetails removal Background: Given I am logged into MetaMask Mobile And I have at least one NFT in my wallet Scenario: User can still open NFT details from the wallet When I open the wallet NFT list And I tap an NFT Then I should see the NFT details screen Scenario: NFT deeplink still opens the NFT list When I open the app via the "/nft" deeplink Then I should see the wallet NFT full view Scenario: Legacy CollectiblesDetails modal is no longer reachable When I use the app normally Then I should not see the old CollectibleModal bottom sheet ``` ## **Screenshots/Recordings** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Deletion-only cleanup of unreachable navigation and UI; active NFT flows use NftDetails and other existing routes. > > **Overview** > Removes dead NFT UI: the **`CollectiblesDetails`** stack screen is unregistered from **`MainNavigator`**, and the entire **`CollectibleModal`** folder (component, styles, types, tests) is deleted. > > Also drops **`InitSendLocation.CollectibleModal`** from send analytics constants, the **`CollectibleModal`** CODEOWNERS line, and the navigator test that asserted the removed screen. NFT detail flows continue to use **`NftDetails`** and related routes already registered in the main stack. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8610c7325fd960691601c91717b6d0c651356353. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .github/CODEOWNERS | 1 - app/components/Nav/Main/MainNavigator.js | 15 -- .../Nav/Main/MainNavigator.test.tsx | 13 -- .../CollectibleModal.styles.ts | 25 --- .../CollectibleModal.test.tsx | 208 ------------------ .../UI/CollectibleModal/CollectibleModal.tsx | 164 -------------- .../CollectibleModal.types.ts | 11 - app/components/UI/CollectibleModal/index.ts | 1 - .../Views/confirmations/constants/send.ts | 1 - 9 files changed, 439 deletions(-) delete mode 100644 app/components/UI/CollectibleModal/CollectibleModal.styles.ts delete mode 100644 app/components/UI/CollectibleModal/CollectibleModal.test.tsx delete mode 100644 app/components/UI/CollectibleModal/CollectibleModal.tsx delete mode 100644 app/components/UI/CollectibleModal/CollectibleModal.types.ts delete mode 100644 app/components/UI/CollectibleModal/index.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4b554ad7a6d..21686b90367 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -264,7 +264,6 @@ app/components/UI/CollectibleContractOverview @MetaMask/metamask-assets app/components/UI/CollectibleContracts @MetaMask/metamask-assets app/components/UI/CollectibleDetectionModal @MetaMask/metamask-assets app/components/UI/CollectibleMedia @MetaMask/metamask-assets -app/components/UI/CollectibleModal @MetaMask/metamask-assets app/components/UI/CollectibleOverview @MetaMask/metamask-assets app/components/UI/ConfirmAddAsset @MetaMask/metamask-assets app/components/UI/DeFiPositions @MetaMask/metamask-assets diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index b743f576615..3908727c15c 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -64,7 +64,6 @@ import { ExploreFeed } from '../../Views/TrendingView/TrendingView'; import WhatsHappeningDetailView from '../../Views/WhatsHappeningDetailView'; import ExploreSearchScreen from '../../Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen'; import TrendingFeedSessionManager from '../../UI/Trending/services/TrendingFeedSessionManager'; -import CollectiblesDetails from '../../UI/CollectibleModal'; import OptinMetrics from '../../UI/OptinMetrics'; import RampRoutes from '../../UI/Ramp/Aggregator/routes'; @@ -1020,20 +1019,6 @@ const MainNavigator = () => { initialRouteName={'Home'} > - ({ - overlayStyle: { - opacity: 0, - }, - }), - }} - /> { })) as ScreenChild[]; }; - it('includes CollectiblesDetails screen', () => { - const container = renderWithProvider(, { - state: initialRootState, - }); - - const screenProps = getScreenProps(container); - const screen = screenProps?.find( - (s) => s?.name === 'CollectiblesDetails', - ); - - expect(screen).toBeDefined(); - }); - it('includes DeprecatedNetworkDetails screen', () => { const container = renderWithProvider(, { state: initialRootState, diff --git a/app/components/UI/CollectibleModal/CollectibleModal.styles.ts b/app/components/UI/CollectibleModal/CollectibleModal.styles.ts deleted file mode 100644 index 57ef73bcb6a..00000000000 --- a/app/components/UI/CollectibleModal/CollectibleModal.styles.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { StyleSheet } from 'react-native'; -import Device from '../../../util/device'; -import { colors } from '../../../styles/common'; - -const styles = StyleSheet.create({ - bottomModal: { - justifyContent: 'flex-end', - margin: 0, - }, - round: { - borderRadius: 12, - }, - collectibleMediaWrapper: { - position: 'absolute', - top: 32, - left: 0, - right: 0, - marginHorizontal: 16, - marginTop: Device.hasNotch() ? 36 : 16, - borderRadius: 12, - backgroundColor: colors.transparent, - }, -}); - -export default styles; diff --git a/app/components/UI/CollectibleModal/CollectibleModal.test.tsx b/app/components/UI/CollectibleModal/CollectibleModal.test.tsx deleted file mode 100644 index f2774b31f36..00000000000 --- a/app/components/UI/CollectibleModal/CollectibleModal.test.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import React from 'react'; -import { CHAIN_IDS } from '@metamask/transaction-controller'; - -import CollectibleModal from './CollectibleModal'; - -import renderWithProvider from '../../../util/test/renderWithProvider'; -import { backgroundState } from '../../../util/test/initial-root-state'; -import { collectiblesSelector } from '../../../reducers/collectibles'; -import { - selectDisplayNftMedia, - selectIsIpfsGatewayEnabled, -} from '../../../selectors/preferencesController'; -import { useSelector } from 'react-redux'; -import { selectChainId } from '../../../selectors/networkController'; -import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../util/test/accountsControllerTestUtils'; -import { mockNetworkState } from '../../../util/test/network'; - -const mockInitialState = { - engine: { - backgroundState: { - ...backgroundState, - AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, - NetworkController: { - ...mockNetworkState({ - chainId: CHAIN_IDS.MAINNET, - id: 'mainnet', - nickname: 'Ethereum Mainnet', - ticker: 'ETH', - }), - }, - }, - }, -}; - -// Set two collectibles with the same address -const collectibles = [ - { name: 'Lion', tokenId: 6903, address: '0x123' }, - { name: 'Leopard', tokenId: 6904, address: '0x123' }, -]; - -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useSelector: jest.fn(), -})); - -const mockTrackEvent = jest.fn(); -const mockCreateEventBuilder = jest.fn(() => ({ - addProperties: jest.fn().mockReturnThis(), - build: jest.fn().mockReturnValue({ - properties: { chain_id: 1 }, - }), -})); - -jest.mock('../../hooks/useAnalytics/useAnalytics', () => ({ - useAnalytics: () => ({ - trackEvent: mockTrackEvent, - createEventBuilder: mockCreateEventBuilder, - }), -})); - -const mockedNavigate = jest.fn(); -const mockedReplace = jest.fn(); -const mockUseRoute = jest.fn(); - -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => ({ - navigate: mockedNavigate, - replace: mockedReplace, - }), - useRoute: () => mockUseRoute(), -})); - -describe('CollectibleModal', () => { - beforeEach(() => { - jest.clearAllMocks(); - // Default route params without source - mockUseRoute.mockReturnValue({ - params: { - contractName: 'Opensea', - collectible: { name: 'Leopard', tokenId: 6904, address: '0x123' }, - }, - }); - }); - - afterEach(() => { - (useSelector as jest.Mock).mockClear(); - }); - it('renders correctly', async () => { - (useSelector as jest.Mock).mockImplementation((selector) => { - if (selector === collectiblesSelector) return collectibles; - if (selector === selectIsIpfsGatewayEnabled) return false; - if (selector === selectDisplayNftMedia) return false; - if (selector === selectChainId) return '0x1'; - return undefined; - }); - const { getByText } = renderWithProvider(, { - state: mockInitialState, - }); - - expect(getByText('Leopard')).toBeOnTheScreen(); - }); - - it('renders the correct token name and ID', async () => { - (useSelector as jest.Mock).mockImplementation((selector) => { - if (selector === collectiblesSelector) return collectibles; - if (selector === selectIsIpfsGatewayEnabled) return true; - if (selector === selectDisplayNftMedia) return true; - if (selector === selectChainId) return '0x1'; - return undefined; - }); - - const { findAllByText } = renderWithProvider(, { - 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(); - }); - - it('tracks NFT Details Opened event', () => { - (useSelector as jest.Mock).mockImplementation((selector) => { - if (selector === collectiblesSelector) return collectibles; - if (selector === selectIsIpfsGatewayEnabled) return false; - if (selector === selectDisplayNftMedia) return false; - if (selector === selectChainId) return '0x1'; - return undefined; - }); - - renderWithProvider(, { - state: mockInitialState, - }); - - expect(mockCreateEventBuilder).toHaveBeenCalled(); - expect(mockTrackEvent).toHaveBeenCalled(); - }); - - it('tracks NFT Details Opened event with mobile-nft-list source', () => { - const mockAddProperties = jest.fn().mockReturnThis(); - const mockBuild = jest.fn(); - mockCreateEventBuilder.mockReturnValue({ - addProperties: mockAddProperties, - build: mockBuild, - }); - - mockUseRoute.mockReturnValue({ - params: { - contractName: 'Opensea', - collectible: { name: 'Leopard', tokenId: 6904, address: '0x123' }, - source: 'mobile-nft-list', - }, - }); - - (useSelector as jest.Mock).mockImplementation((selector) => { - if (selector === collectiblesSelector) return collectibles; - if (selector === selectIsIpfsGatewayEnabled) return false; - if (selector === selectDisplayNftMedia) return false; - if (selector === selectChainId) return '0x1'; - return undefined; - }); - - renderWithProvider(, { - state: mockInitialState, - }); - - expect(mockAddProperties).toHaveBeenCalledWith( - expect.objectContaining({ - source: 'mobile-nft-list', - }), - ); - }); - - it('tracks NFT Details Opened event with mobile-nft-list-page source', () => { - const mockAddProperties = jest.fn().mockReturnThis(); - const mockBuild = jest.fn(); - mockCreateEventBuilder.mockReturnValue({ - addProperties: mockAddProperties, - build: mockBuild, - }); - - mockUseRoute.mockReturnValue({ - params: { - contractName: 'Opensea', - collectible: { name: 'Leopard', tokenId: 6904, address: '0x123' }, - source: 'mobile-nft-list-page', - }, - }); - - (useSelector as jest.Mock).mockImplementation((selector) => { - if (selector === collectiblesSelector) return collectibles; - if (selector === selectIsIpfsGatewayEnabled) return false; - if (selector === selectDisplayNftMedia) return false; - if (selector === selectChainId) return '0x1'; - return undefined; - }); - - renderWithProvider(, { - state: mockInitialState, - }); - - expect(mockAddProperties).toHaveBeenCalledWith( - expect.objectContaining({ - source: 'mobile-nft-list-page', - }), - ); - }); -}); diff --git a/app/components/UI/CollectibleModal/CollectibleModal.tsx b/app/components/UI/CollectibleModal/CollectibleModal.tsx deleted file mode 100644 index 97108048c10..00000000000 --- a/app/components/UI/CollectibleModal/CollectibleModal.tsx +++ /dev/null @@ -1,164 +0,0 @@ -/* eslint-disable react/prop-types */ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { View } from 'react-native'; -import { useSelector } from 'react-redux'; -import CollectibleMedia from '../CollectibleMedia'; -import { baseStyles } from '../../../styles/common'; -import ReusableModal, { ReusableModalRef } from '../ReusableModal'; -import Routes from '../../../constants/navigation/Routes'; -import CollectibleOverview from '../../UI/CollectibleOverview'; -import { collectiblesSelector } from '../../../reducers/collectibles'; -import { - selectDisplayNftMedia, - selectIsIpfsGatewayEnabled, -} from '../../../selectors/preferencesController'; -import styles from './CollectibleModal.styles'; -import { CollectibleModalParams } from './CollectibleModal.types'; -import { useNavigation } from '@react-navigation/native'; -import { useParams } from '../../../util/navigation/navUtils'; -import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; -import { MetaMetricsEvents } from '../../../core/Analytics'; -import { selectChainId } from '../../../selectors/networkController'; -import { getDecimalChainId } from '../../../util/networks'; -import { Nft } from '@metamask/assets-controllers'; -import { EXTERNAL_LINK_TYPE } from '../../../constants/browser'; -import { InitSendLocation } from '../../Views/confirmations/constants/send'; -import { useSendNavigation } from '../../Views/confirmations/hooks/useSendNavigation'; - -const CollectibleModal = () => { - const navigation = useNavigation(); - const { trackEvent, createEventBuilder } = useAnalytics(); - const chainId = useSelector(selectChainId); - - const { contractName, collectible, source } = - useParams(); - - const modalRef = useRef(null); - - const [mediaZIndex, setMediaZIndex] = useState(20); - const [overviewZIndex, setOverviewZIndex] = useState(10); - - const [updatedCollectible, setUpdatedCollectible] = useState(collectible); - - const collectibles: Nft[] = useSelector(collectiblesSelector); - const isIpfsGatewatEnabled = useSelector(selectIsIpfsGatewayEnabled); - const displayNftMedia = useSelector(selectDisplayNftMedia); - const { navigateToSendPage } = useSendNavigation(); - - const handleUpdateCollectible = useCallback(() => { - if (isIpfsGatewatEnabled || displayNftMedia) { - const newUpdatedCollectible = collectibles.find( - (nft: Nft) => - nft.address === collectible.address && - nft.tokenId === collectible.tokenId, - ); - - if (newUpdatedCollectible) { - setUpdatedCollectible(newUpdatedCollectible); - } - } - }, [isIpfsGatewatEnabled, collectibles, collectible, displayNftMedia]); - - useEffect(() => { - handleUpdateCollectible(); - }, [handleUpdateCollectible]); - - useEffect(() => { - trackEvent( - createEventBuilder(MetaMetricsEvents.NFT_DETAILS_OPENED) - .addProperties({ - chain_id: getDecimalChainId(chainId), - ...(source && { source }), - }) - .build(), - ); - // The linter wants `trackEvent` to be added as a dependency, - // But the event fires twice if I do that. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chainId, source]); - - const onSend = useCallback(async () => { - navigateToSendPage({ - location: InitSendLocation.CollectibleModal, - asset: collectible, - }); - }, [collectible, navigateToSendPage]); - - const isTradable = useCallback( - () => collectible.standard === 'ERC721', - [collectible], - ); - - const openLink = useCallback( - (url: string) => { - navigation.navigate(Routes.BROWSER_TAB_HOME, { - screen: Routes.BROWSER_VIEW, - params: { - newTabUrl: url, - linkType: EXTERNAL_LINK_TYPE, - timestamp: Date.now(), - }, - }); - }, - [navigation], - ); - - const onCollectibleOverviewTranslation = (moveUp: boolean) => { - if (moveUp) { - setTimeout(() => { - setMediaZIndex(20); - setOverviewZIndex(10); - }, 250); - } else { - setMediaZIndex(0); - setOverviewZIndex(10); - } - }; - const collectibleData = useMemo( - () => ({ ...collectible, ...updatedCollectible, contractName }), - [collectible, contractName, updatedCollectible], - ); - return ( - - <> - - modalRef.current?.dismissModal()} - cover - renderAnimation - collectible={collectibleData} - style={styles.round} - /> - - - - - - - ); -}; - -export default CollectibleModal; diff --git a/app/components/UI/CollectibleModal/CollectibleModal.types.ts b/app/components/UI/CollectibleModal/CollectibleModal.types.ts deleted file mode 100644 index dd942afca12..00000000000 --- a/app/components/UI/CollectibleModal/CollectibleModal.types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Nft } from '@metamask/assets-controllers'; - -export interface CollectibleModalParams { - contractName: string; - collectible: Nft; - source?: 'mobile-nft-list' | 'mobile-nft-list-page'; -} - -export interface ReusableModalRef { - dismissModal: () => void; -} diff --git a/app/components/UI/CollectibleModal/index.ts b/app/components/UI/CollectibleModal/index.ts deleted file mode 100644 index 0149213ef96..00000000000 --- a/app/components/UI/CollectibleModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './CollectibleModal'; diff --git a/app/components/Views/confirmations/constants/send.ts b/app/components/Views/confirmations/constants/send.ts index 5e82798ad90..b573041a836 100644 --- a/app/components/Views/confirmations/constants/send.ts +++ b/app/components/Views/confirmations/constants/send.ts @@ -2,7 +2,6 @@ export const InitSendLocation = { AssetOverview: 'asset_overview', HomePage: 'home_page', CollectibleContractOverview: 'collectible_contract_overview', - CollectibleModal: 'collectible_modal', CollectibleView: 'collectible_view', NftDetails: 'nft_details', WalletActions: 'wallet_actions', From f87556846e0917c947f8b0cca78a1be0aa8bbfb5 Mon Sep 17 00:00:00 2001 From: Xavier Brochard Date: Wed, 10 Jun 2026 21:54:02 +0200 Subject: [PATCH 03/12] fix: add Solana, Tron and Bitcoin chips to the Quick Buy receive view (TSA-657) (#31457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The Quick Buy "Receive" view (sell flow) was missing the Solana, Tron, and BTC network chips, so users couldn't select those chains to receive on — breaking parity with the other supported networks. Root cause: the receive-token candidates in `useReceiveTokens` were built EVM-only — the stablecoin curation filtered `DefaultSwapDestTokens` with `chainId.startsWith('0x')`, silently dropping non-EVM (CAIP) chain ids. Since chips are derived from the candidates' chain ids, no non-EVM candidates meant no chips. All surrounding infrastructure (chain-filter display, enabled-network gating, quote flow) already supported non-EVM chains. Changes: - **Solana:** SOL native and Solana USDC are now receive candidates. - **Tron / Bitcoin:** native TRX and BTC are now receive candidates via the bridge native-asset registry. Non-native tokens on these chains (e.g. TRC-20 USDT) are deliberately excluded: `useAssetMetadata` can only resolve Solana base58 / EVM hex addresses, so they cannot resolve as Quick Buy destinations (see TSA-659). - Each non-EVM chip is gated on the selected account actually having an address on that chain (`selectSelectedInternalAccountByScope` per scope), matching app-wide gating. - `enrichSolanaTokenBalance` generalized to `enrichNonEvmTokenBalance` so Tron/BTC holdings get correct balance/fiat enrichment (previously Solana-only). - The chain filter defaults to the position's CAIP chain for non-EVM positions (previously fell back to "All"). Jira: [TSA-657](https://consensyssoftware.atlassian.net/browse/TSA-657) ## **Changelog** CHANGELOG entry: Added Solana, Tron and Bitcoin network options to the Quick Buy receive view ## **Related issues** Fixes: [TSA-657](https://consensyssoftware.atlassian.net/browse/TSA-657) ## **Manual testing steps** ```gherkin Feature: Quick Buy receive view network chips Scenario: user with a multichain account opens the receive picker Given the selected account has Solana, Tron and Bitcoin addresses And the user is selling a token via Quick Buy When the user opens the "Receive" token picker Then Solana, Tron and Bitcoin chips appear alongside the EVM network chips And selecting Tron offers native TRX as the receive option And selecting Bitcoin offers native BTC as the receive option And selecting Solana offers SOL and Solana USDC Scenario: account without non-EVM addresses Given the selected account has no Tron or Bitcoin address When the user opens the "Receive" token picker Then the Tron and Bitcoin chips are not shown ``` ## **Screenshots/Recordings** ### **Before** image ### **After** image ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [TSA-657]: https://consensyssoftware.atlassian.net/browse/TSA-657?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Medium Risk** > Touches sell-flow receive token selection and balance/fiat enrichment across multichain accounts; wrong gating or chain-id mismatches could hide options or misprice rows, but changes are scoped to Quick Buy with broad test coverage. > > **Overview** > Fixes the Quick Buy **Receive** picker (sell flow) so **Solana, Tron, and Bitcoin** network chips and tokens appear when the user’s account and enabled networks allow it. > > **Receive candidates (`useReceiveTokens`):** Stablecoin curation no longer drops non-EVM chains—**Solana USDC** is included alongside EVM stables. **Native TRX and BTC** are added via `NATIVE_ONLY_NON_EVM_CHAINS` (TRC-20 stables on Tron stay excluded because destinations can’t be resolved). Non-EVM options are gated on **`selectSelectedInternalAccountByScope`** per chain and existing network-enabled checks; multichain balances/rates are wired into enrichment. > > **Balances (`enrichTokenBalance`):** Solana-specific enrichment is generalized to **`enrichNonEvmTokenBalance`** with Tron/Bitcoin accounts so held TRX/BTC/SOL show correct fiat in the list. > > **UI filter (`QuickBuyReceiveScreen`):** The default chain filter uses the position’s **CAIP chain id** for non-EVM positions (matching candidate `chainId` format) instead of skipping non-EVM and falling back to “All”. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e4c8ac7505490c27a0381b0af385d428fc9988ff. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Claude Fable 5 --- .../QuickBuy/QuickBuyReceiveScreen.test.tsx | 81 +++++- .../QuickBuy/QuickBuyReceiveScreen.tsx | 21 +- .../QuickBuy/hooks/enrichTokenBalance.test.ts | 95 +++++++ .../QuickBuy/hooks/enrichTokenBalance.ts | 42 ++- .../QuickBuy/hooks/useReceiveTokens.test.ts | 249 +++++++++++++++++- .../QuickBuy/hooks/useReceiveTokens.ts | 117 ++++++-- 6 files changed, 563 insertions(+), 42 deletions(-) diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyReceiveScreen.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyReceiveScreen.test.tsx index 1d4845499e0..34ed895373c 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyReceiveScreen.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyReceiveScreen.test.tsx @@ -19,8 +19,15 @@ jest.mock('../../../../../../../locales/i18n', () => ({ })); jest.mock('@metamask/bridge-controller', () => ({ - formatChainIdToHex: () => '0x1', - isNonEvmChainId: () => false, + formatChainIdToHex: (caipChainId: string) => { + const [namespace, reference] = caipChainId.split(':'); + if (namespace !== 'eip155') { + throw new Error(`unsupported chain ${caipChainId}`); + } + return `0x${parseInt(reference, 10).toString(16)}`; + }, + isNonEvmChainId: (chainId: string) => + !chainId.startsWith('0x') && !chainId.startsWith('eip155:'), isNativeAddress: () => false, getNativeAssetForChainId: () => undefined, })); @@ -39,12 +46,33 @@ const createToken = (overrides: Partial = {}): BridgeToken => ({ ...overrides, }); +const SOLANA_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; +const TRON_CHAIN_ID = 'tron:728126428'; +const BITCOIN_CHAIN_ID = 'bip122:000000000019d6689c085ae165831e93'; + const usdcToken = createToken({ symbol: 'USDC', chainId: '0x1' }); const usdtToken = createToken({ symbol: 'USDT', chainId: '0x1', address: '0xdac17f958d2ee523a2206206994597c13d831ec7', }); +const solanaUsdcToken = createToken({ + symbol: 'USDC', + chainId: SOLANA_CHAIN_ID, + address: `${SOLANA_CHAIN_ID}/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`, +}); +const tronNativeToken = createToken({ + symbol: 'TRX', + name: 'Tron', + chainId: TRON_CHAIN_ID, + address: `${TRON_CHAIN_ID}/slip44:195`, +}); +const bitcoinNativeToken = createToken({ + symbol: 'BTC', + name: 'Bitcoin', + chainId: BITCOIN_CHAIN_ID, + address: `${BITCOIN_CHAIN_ID}/slip44:0`, +}); const buildContext = (overrides: Record = {}) => ({ tradeMode: 'sell', @@ -121,4 +149,53 @@ describe('QuickBuyReceiveScreen', () => { screen.getByText('social_leaderboard.quick_buy.receive_with_no_tokens'), ).toBeOnTheScreen(); }); + + it('renders Solana, Tron and Bitcoin network chips when receive options exist on those chains', () => { + (useQuickBuyContext as jest.Mock).mockReturnValue( + buildContext({ + sellDestTokenOptions: [ + usdcToken, + solanaUsdcToken, + tronNativeToken, + bitcoinNativeToken, + ], + handleSelectDestStable, + setActiveScreen, + }), + ); + + render(); + + expect( + screen.getByTestId(`quick-buy-chain-filter-${SOLANA_CHAIN_ID}`), + ).toBeOnTheScreen(); + expect( + screen.getByTestId(`quick-buy-chain-filter-${TRON_CHAIN_ID}`), + ).toBeOnTheScreen(); + expect( + screen.getByTestId(`quick-buy-chain-filter-${BITCOIN_CHAIN_ID}`), + ).toBeOnTheScreen(); + }); + + it('defaults the chain filter to Solana when the position is on Solana', () => { + (useQuickBuyContext as jest.Mock).mockReturnValue( + buildContext({ + target: { + chain: SOLANA_CHAIN_ID, + tokenAddress: `${SOLANA_CHAIN_ID}/slip44:501`, + tokenSymbol: 'SOL', + tokenName: 'Solana', + }, + sellDestTokenOptions: [usdcToken, solanaUsdcToken], + selectedDestStable: solanaUsdcToken, + handleSelectDestStable, + setActiveScreen, + }), + ); + + render(); + + expect(screen.getByTestId(getRowTestId(solanaUsdcToken))).toBeOnTheScreen(); + expect(screen.queryByTestId(getRowTestId(usdcToken))).toBeNull(); + }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyReceiveScreen.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyReceiveScreen.tsx index a64c6e79c26..644e26f18be 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyReceiveScreen.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyReceiveScreen.tsx @@ -30,15 +30,20 @@ const QuickBuyReceiveScreen: React.FC = () => { // `target.chain` is already a CAIP id — `positionToQuickBuyTarget` does the // chain-name → CAIP conversion when the target is built. const caip = target.chain as CaipChainId; - if (isNonEvmChainId(caip)) return null; - let hexChainId: string; - try { - hexChainId = formatChainIdToHex(caip); - } catch { - return null; + // Receive candidates carry hex chain ids on EVM and CAIP ids on non-EVM + // (e.g. Solana), so the filter id must match the candidate format. + let chainFilterId: string; + if (isNonEvmChainId(caip)) { + chainFilterId = caip; + } else { + try { + chainFilterId = formatChainIdToHex(caip); + } catch { + return null; + } } - return sellDestTokenOptions.some((t) => t.chainId === hexChainId) - ? hexChainId + return sellDestTokenOptions.some((t) => t.chainId === chainFilterId) + ? chainFilterId : null; }, [target.chain, sellDestTokenOptions]); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.test.ts index 44d8e9bc2d8..b633a842610 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.test.ts @@ -232,4 +232,99 @@ describe('enrichTokenBalance', () => { }); }); }); + + describe('Tron and Bitcoin', () => { + const trxAssetId = 'tron:728126428/slip44:195'; + const tronScope = 'tron:728126428'; + const btcAssetId = 'bip122:000000000019d6689c085ae165831e93/slip44:0'; + const bitcoinScope = 'bip122:000000000019d6689c085ae165831e93'; + const tronAccount = { id: 'tron-account-id' }; + const bitcoinAccount = { id: 'bitcoin-account-id' }; + + it('prices a held TRX balance using the Tron account multichain data', () => { + const deps = baseDeps({ + tronAccount, + multichainBalances: { + [tronAccount.id]: { [trxAssetId]: { amount: '100' } }, + }, + multichainRates: { [trxAssetId]: { rate: '0.25' } }, + }); + + const result = enrichTokenBalance( + token({ address: trxAssetId, chainId: tronScope, symbol: 'TRX' }), + deps, + ); + + expect(result).toEqual({ + balance: '100', + balanceFiat: '$25.00', + tokenFiatAmount: 25, + currencyExchangeRate: 0.25, + }); + }); + + it('prices a held BTC balance using the Bitcoin account multichain data', () => { + const deps = baseDeps({ + bitcoinAccount, + multichainBalances: { + [bitcoinAccount.id]: { [btcAssetId]: { amount: '0.5' } }, + }, + multichainRates: { [btcAssetId]: { rate: '100000' } }, + }); + + const result = enrichTokenBalance( + token({ address: btcAssetId, chainId: bitcoinScope, symbol: 'BTC' }), + deps, + ); + + expect(result).toEqual({ + balance: '0.5', + balanceFiat: '$50000.00', + tokenFiatAmount: 50000, + currencyExchangeRate: 100000, + }); + }); + + it('returns null when there is no account for the candidate chain (strict)', () => { + const result = enrichTokenBalance( + token({ address: trxAssetId, chainId: tronScope, symbol: 'TRX' }), + baseDeps(), + ); + + expect(result).toBeNull(); + }); + + it('returns a zero enrichment when there is no account for the chain and lenient', () => { + const result = enrichTokenBalance( + token({ address: btcAssetId, chainId: bitcoinScope, symbol: 'BTC' }), + baseDeps(), + { includeZeroBalance: true }, + ); + + expect(result).toEqual({ + balance: '0', + balanceFiat: '$0.00', + tokenFiatAmount: 0, + currencyExchangeRate: undefined, + }); + }); + + it('does not read another chain account for a non-EVM candidate (Solana account does not price TRX)', () => { + const solanaAccount = { id: 'solana-account-id' }; + const deps = baseDeps({ + solanaAccount, + multichainBalances: { + [solanaAccount.id]: { [trxAssetId]: { amount: '100' } }, + }, + multichainRates: { [trxAssetId]: { rate: '0.25' } }, + }); + + const result = enrichTokenBalance( + token({ address: trxAssetId, chainId: tronScope, symbol: 'TRX' }), + deps, + ); + + expect(result).toBeNull(); + }); + }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.ts index 9dcf8bc88c7..cdab597c5e1 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.ts @@ -1,6 +1,11 @@ import type { Hex } from '@metamask/utils'; import { formatUnits } from 'ethers/lib/utils'; -import { isSolanaChainId, isNativeAddress } from '@metamask/bridge-controller'; +import { + isNonEvmChainId, + isSolanaChainId, + isNativeAddress, +} from '@metamask/bridge-controller'; +import { BtcScope, TrxScope } from '@metamask/keyring-api'; import type { BridgeToken } from '../../../../../../UI/Bridge/types'; import { addCurrencySymbol } from '../../../../../../../util/number/bigint'; import type { selectTokenMarketData } from '../../../../../../../selectors/tokenRatesController'; @@ -41,6 +46,8 @@ export interface TokenBalanceDeps { currencyRates: ReturnType; allNetworkConfigs?: Record; solanaAccount?: { id: string }; + tronAccount?: { id: string }; + bitcoinAccount?: { id: string }; multichainBalances?: Record< string, Record | undefined @@ -173,20 +180,36 @@ const enrichEvmTokenBalance = ( return priced(displayBalance, exchangeRate, balanceNum); }; -const enrichSolanaTokenBalance = ( +/** + * Resolves the non-EVM account whose multichain balances cover the candidate's + * chain. Multichain balances/rates are keyed by account id + CAIP asset id, so + * each non-EVM chain needs the matching account from the selected group. + */ +const getNonEvmAccount = ( + chainId: BridgeToken['chainId'], + deps: TokenBalanceDeps, +): { id: string } | undefined => { + if (isSolanaChainId(chainId)) return deps.solanaAccount; + if (chainId === TrxScope.Mainnet) return deps.tronAccount; + if (chainId === BtcScope.Mainnet) return deps.bitcoinAccount; + return undefined; +}; + +const enrichNonEvmTokenBalance = ( candidate: BridgeToken, deps: TokenBalanceDeps, options: EnrichTokenBalanceOptions, ): TokenBalanceEnrichment | null => { const { includeZeroBalance } = options; - const { solanaAccount, multichainBalances, multichainRates } = deps; + const { multichainBalances, multichainRates } = deps; const dropOrZero = () => (includeZeroBalance ? zeroEnrichment() : null); - if (!solanaAccount) return dropOrZero(); + const nonEvmAccount = getNonEvmAccount(candidate.chainId, deps); + if (!nonEvmAccount) return dropOrZero(); const amountStr = - multichainBalances?.[solanaAccount.id]?.[candidate.address]?.amount; + multichainBalances?.[nonEvmAccount.id]?.[candidate.address]?.amount; if (!amountStr) return dropOrZero(); const balanceNum = parseFloat(amountStr); @@ -207,14 +230,15 @@ const enrichSolanaTokenBalance = ( /** * Prices a single token candidate from cached Redux balances, returning the * shared balance fields (or `null` when the token should be omitted). Handles - * EVM natives, EVM ERC-20s, and Solana assets, keeping the USD exchange-rate - * semantics QuickBuy's amount math depends on. + * EVM natives, EVM ERC-20s, and non-EVM assets (Solana, Tron, Bitcoin) via the + * multichain balance/rate controllers, keeping the USD exchange-rate semantics + * QuickBuy's amount math depends on. */ export const enrichTokenBalance = ( candidate: BridgeToken, deps: TokenBalanceDeps, options: EnrichTokenBalanceOptions = {}, ): TokenBalanceEnrichment | null => - isSolanaChainId(candidate.chainId) - ? enrichSolanaTokenBalance(candidate, deps, options) + isNonEvmChainId(candidate.chainId) + ? enrichNonEvmTokenBalance(candidate, deps, options) : enrichEvmTokenBalance(candidate, deps, options); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.test.ts index 28d59088cc9..d2fa6deb951 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.test.ts @@ -1,16 +1,45 @@ import { renderHook } from '@testing-library/react-native'; +import { useSelector } from 'react-redux'; +import { BtcScope, SolScope, TrxScope } from '@metamask/keyring-api'; +import type { RootState } from '../../../../../../../reducers'; +import { selectSelectedInternalAccountByScope } from '../../../../../../../selectors/multichainAccounts/accounts'; import { useReceiveTokens } from './useReceiveTokens'; import { enrichTokenBalance } from './enrichTokenBalance'; import { useNetworkEnabledPredicate } from './useNetworkEnabledPredicate'; jest.mock('react-redux', () => ({ - useSelector: jest.fn(() => undefined), + useSelector: jest.fn(), })); jest.mock('./useNetworkEnabledPredicate', () => ({ useNetworkEnabledPredicate: jest.fn(), })); +jest.mock('../../../../../../../selectors/multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: jest.fn(), +})); + +jest.mock('../../../../../../../selectors/accountTrackerController', () => ({ + selectAccountsByChainId: jest.fn(() => ({})), +})); + +jest.mock('../../../../../../../selectors/tokenBalancesController', () => ({ + selectTokensBalances: jest.fn(() => ({})), +})); + +jest.mock('../../../../../../../selectors/tokenRatesController', () => ({ + selectTokenMarketData: jest.fn(() => ({})), +})); + +jest.mock('../../../../../../../selectors/currencyRateController', () => ({ + selectCurrencyRates: jest.fn(() => ({})), +})); + +jest.mock('../../../../../../../selectors/multichain/multichain', () => ({ + selectMultichainBalances: jest.fn(() => ({})), + selectMultichainAssetsRates: jest.fn(() => ({})), +})); + jest.mock( '../../../../../../UI/Bridge/constants/default-swap-dest-tokens', () => ({ @@ -36,6 +65,21 @@ jest.mock( decimals: 18, name: 'Wrapped Ether', }, + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': { + symbol: 'USDC', + address: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + decimals: 6, + name: 'USD Coin', + }, + 'tron:728126428': { + symbol: 'USDT', + address: 'tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', + chainId: 'tron:728126428', + decimals: 6, + name: 'Tether USD', + }, }, }), ); @@ -64,17 +108,89 @@ jest.mock('../../../../../../UI/Bridge/utils/tokenUtils', () => ({ chainId: '0x89', }; } + if (chainId === 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp') { + return { + symbol: 'SOL', + name: 'Solana', + address: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + decimals: 9, + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + }; + } + if (chainId === 'tron:728126428') { + return { + symbol: 'TRX', + name: 'Tron', + address: 'tron:728126428/slip44:195', + decimals: 6, + chainId: 'tron:728126428', + }; + } + if (chainId === 'bip122:000000000019d6689c085ae165831e93') { + return { + symbol: 'BTC', + name: 'Bitcoin', + address: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + decimals: 8, + chainId: 'bip122:000000000019d6689c085ae165831e93', + }; + } throw new Error(`unsupported chain ${chainId}`); }), })); const mockEnrich = enrichTokenBalance as jest.Mock; const mockUseNetworkEnabledPredicate = useNetworkEnabledPredicate as jest.Mock; +const mockUseSelector = useSelector as unknown as jest.Mock; +const mockSelectSelectedInternalAccountByScope = + selectSelectedInternalAccountByScope as unknown as jest.Mock; + +const SOLANA_CHAIN_ID = SolScope.Mainnet; +const TRON_CHAIN_ID = TrxScope.Mainnet; +const BITCOIN_CHAIN_ID = BtcScope.Mainnet; + +const MOCK_STATE = { + engine: { + backgroundState: { + NetworkController: { networkConfigurationsByChainId: {} }, + }, + }, +} as unknown as RootState; + +const NON_EVM_ACCOUNTS: Record = { + [SOLANA_CHAIN_ID]: { + id: 'solana-account-id', + address: 'So1anaAddre55111111111111111111111111111111', + }, + [TRON_CHAIN_ID]: { + id: 'tron-account-id', + address: 'TTronAddre55xxxxxxxxxxxxxxxxxxxxxx', + }, + [BITCOIN_CHAIN_ID]: { + id: 'bitcoin-account-id', + address: 'bc1qbitcoinaddre55xxxxxxxxxxxxxxxxxxxxx', + }, +}; + +/** Makes the selected account group expose addresses for the given scopes. */ +const givenAccountsForScopes = (scopes: string[]) => { + mockSelectSelectedInternalAccountByScope.mockReturnValue((scope: string) => + scopes.includes(scope) ? NON_EVM_ACCOUNTS[scope] : undefined, + ); +}; + +/** Makes the selected account group expose a Solana address. */ +const givenSolanaAccount = () => givenAccountsForScopes([SOLANA_CHAIN_ID]); describe('useReceiveTokens', () => { beforeEach(() => { jest.clearAllMocks(); mockUseNetworkEnabledPredicate.mockReturnValue(() => true); + // No account in the selected group resolves for any scope by default. + mockSelectSelectedInternalAccountByScope.mockReturnValue(() => undefined); + mockUseSelector.mockImplementation( + (selector: (state: RootState) => unknown) => selector(MOCK_STATE), + ); mockEnrich.mockReturnValue({ balance: '0', balanceFiat: '$0.00', @@ -160,4 +276,135 @@ describe('useReceiveTokens', () => { expect(chainIds).toContain('0x1'); expect(chainIds).not.toContain('0x89'); }); + + describe('Solana candidates', () => { + it('includes Solana stablecoin and native candidates when the account has a Solana address', () => { + givenSolanaAccount(); + + const { result } = renderHook(() => useReceiveTokens(undefined)); + + const solanaTokens = result.current.filter( + (t) => t.chainId === SOLANA_CHAIN_ID, + ); + expect(solanaTokens.map((t) => t.symbol)).toEqual( + expect.arrayContaining(['USDC', 'SOL']), + ); + }); + + it('omits Solana candidates when the account has no Solana address', () => { + const { result } = renderHook(() => useReceiveTokens(undefined)); + + const chainIds = result.current.map((t) => t.chainId); + expect(chainIds).not.toContain(SOLANA_CHAIN_ID); + }); + + it('drops Solana candidates when the Solana network is not enabled', () => { + givenSolanaAccount(); + mockUseNetworkEnabledPredicate.mockReturnValue( + (chainId: string | undefined) => chainId !== SOLANA_CHAIN_ID, + ); + + const { result } = renderHook(() => useReceiveTokens(undefined)); + + const chainIds = result.current.map((t) => t.chainId); + expect(chainIds).not.toContain(SOLANA_CHAIN_ID); + expect(chainIds).toContain('0x1'); + }); + + it('sorts Solana candidates to the front when the preferred chain is Solana', () => { + givenSolanaAccount(); + + const { result } = renderHook(() => useReceiveTokens(SOLANA_CHAIN_ID)); + + expect(result.current[0].chainId).toBe(SOLANA_CHAIN_ID); + expect(result.current[0].symbol).toBe('USDC'); + }); + + it('passes the Solana account to balance enrichment so Solana holdings are priced', () => { + givenSolanaAccount(); + + renderHook(() => useReceiveTokens(undefined)); + + expect(mockEnrich).toHaveBeenCalledWith( + expect.objectContaining({ chainId: SOLANA_CHAIN_ID }), + expect.objectContaining({ + solanaAccount: expect.objectContaining({ + id: NON_EVM_ACCOUNTS[SOLANA_CHAIN_ID].id, + }), + }), + { includeZeroBalance: true }, + ); + }); + }); + + describe('Tron and Bitcoin candidates', () => { + it('offers native TRX when the account has a Tron address, but never TRC-20 stablecoins', () => { + givenAccountsForScopes([TRON_CHAIN_ID]); + + const { result } = renderHook(() => useReceiveTokens(undefined)); + + const tronTokens = result.current.filter( + (t) => t.chainId === TRON_CHAIN_ID, + ); + // Native-only: useAssetMetadata cannot resolve Tron token addresses + // (e.g. TRC-20 USDT), so only the native asset is offered. + expect(tronTokens.map((t) => t.symbol)).toEqual(['TRX']); + }); + + it('offers native BTC when the account has a Bitcoin address', () => { + givenAccountsForScopes([BITCOIN_CHAIN_ID]); + + const { result } = renderHook(() => useReceiveTokens(undefined)); + + const bitcoinTokens = result.current.filter( + (t) => t.chainId === BITCOIN_CHAIN_ID, + ); + expect(bitcoinTokens.map((t) => t.symbol)).toEqual(['BTC']); + }); + + it('omits Tron and Bitcoin candidates when the account lacks those addresses', () => { + givenSolanaAccount(); + + const { result } = renderHook(() => useReceiveTokens(undefined)); + + const chainIds = result.current.map((t) => t.chainId); + expect(chainIds).toContain(SOLANA_CHAIN_ID); + expect(chainIds).not.toContain(TRON_CHAIN_ID); + expect(chainIds).not.toContain(BITCOIN_CHAIN_ID); + }); + + it('drops Tron and Bitcoin candidates when those networks are not enabled', () => { + givenAccountsForScopes([TRON_CHAIN_ID, BITCOIN_CHAIN_ID]); + mockUseNetworkEnabledPredicate.mockReturnValue( + (chainId: string | undefined) => + chainId !== TRON_CHAIN_ID && chainId !== BITCOIN_CHAIN_ID, + ); + + const { result } = renderHook(() => useReceiveTokens(undefined)); + + const chainIds = result.current.map((t) => t.chainId); + expect(chainIds).not.toContain(TRON_CHAIN_ID); + expect(chainIds).not.toContain(BITCOIN_CHAIN_ID); + expect(chainIds).toContain('0x1'); + }); + + it('passes the Tron and Bitcoin accounts to balance enrichment so their holdings are priced', () => { + givenAccountsForScopes([TRON_CHAIN_ID, BITCOIN_CHAIN_ID]); + + renderHook(() => useReceiveTokens(undefined)); + + expect(mockEnrich).toHaveBeenCalledWith( + expect.objectContaining({ chainId: TRON_CHAIN_ID }), + expect.objectContaining({ + tronAccount: expect.objectContaining({ + id: NON_EVM_ACCOUNTS[TRON_CHAIN_ID].id, + }), + bitcoinAccount: expect.objectContaining({ + id: NON_EVM_ACCOUNTS[BITCOIN_CHAIN_ID].id, + }), + }), + { includeZeroBalance: true }, + ); + }); + }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.ts index b94f300fa2c..5f53854b6cd 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.ts @@ -1,5 +1,8 @@ import { useMemo } from 'react'; import { useSelector } from 'react-redux'; +import { isNonEvmChainId, isSolanaChainId } from '@metamask/bridge-controller'; +import { BtcScope, SolScope, TrxScope } from '@metamask/keyring-api'; +import type { CaipChainId } from '@metamask/utils'; import type { BridgeToken } from '../../../../../../UI/Bridge/types'; import type { RootState } from '../../../../../../../reducers'; import { selectAccountsByChainId } from '../../../../../../../selectors/accountTrackerController'; @@ -7,6 +10,10 @@ import { selectSelectedInternalAccountByScope } from '../../../../../../../selec import { selectTokensBalances } from '../../../../../../../selectors/tokenBalancesController'; import { selectTokenMarketData } from '../../../../../../../selectors/tokenRatesController'; import { selectCurrencyRates } from '../../../../../../../selectors/currencyRateController'; +import { + selectMultichainBalances, + selectMultichainAssetsRates, +} from '../../../../../../../selectors/multichain/multichain'; import { DefaultSwapDestTokens } from '../../../../../../UI/Bridge/constants/default-swap-dest-tokens'; import { EVM_SCOPE } from '../../../../../../UI/Earn/constants/networks'; import { getNativeSourceToken } from '../../../../../../UI/Bridge/utils/tokenUtils'; @@ -17,22 +24,29 @@ import { RECEIVE_STABLECOIN_CANDIDATES } from './receiveStablecoinCandidates'; import { useNetworkEnabledPredicate } from './useNetworkEnabledPredicate'; /** - * Static EVM stablecoin candidates for the Sell "Receive" picker. + * Static stablecoin candidates for the Sell "Receive" picker (EVM + Solana). * * `DefaultSwapDestTokens` carries a single stablecoin per chain (which sets the - * per-chain default selection — e.g. mUSD on mainnet/Linea), so we keep those - * as the leading entries and then append the canonical USDC/USDT set from - * `RECEIVE_STABLECOIN_CANDIDATES`. The append guarantees both major stablecoins - * show on every supported chain (previously USDT was missing on Optimism, - * USDC on Polygon, etc.) without disturbing the existing default ordering. - * Duplicates are removed by stable token identity (`address:chainId`). + * per-chain default selection — e.g. mUSD on mainnet/Linea, USDC on Solana), so + * we keep those as the leading entries and then append the canonical USDC/USDT + * set from `RECEIVE_STABLECOIN_CANDIDATES`. The append guarantees both major + * stablecoins show on every supported chain (previously USDT was missing on + * Optimism, USDC on Polygon, etc.) without disturbing the existing default + * ordering. Duplicates are removed by stable token identity (`address:chainId`). + * + * Stablecoin candidates are limited to the chains whose token addresses + * QuickBuy can actually resolve as destinations: EVM (hex chain ids) and + * Solana. `useAssetMetadata` only resolves Solana base58 / EVM hex token + * addresses, so non-native tokens on other non-EVM chains (e.g. TRC-20 USDT on + * Tron) cannot be quoted and are excluded — those chains are offered as + * native-only via `NATIVE_ONLY_NON_EVM_CHAINS` instead. */ const STABLECOIN_CANDIDATES: BridgeToken[] = (() => { const primaries = Object.values(DefaultSwapDestTokens).filter( (token) => isStablecoinSymbol(token.symbol) && typeof token.chainId === 'string' && - token.chainId.startsWith('0x'), + (token.chainId.startsWith('0x') || isSolanaChainId(token.chainId)), ); const seen = new Set(primaries.map(getTokenKey)); const extras = RECEIVE_STABLECOIN_CANDIDATES.filter( @@ -42,14 +56,29 @@ const STABLECOIN_CANDIDATES: BridgeToken[] = (() => { })(); /** - * Native token candidates for the Sell "Receive" picker, one per chain already - * covered by `STABLECOIN_CANDIDATES`. Built via `getNativeSourceToken` so each - * native uses the bridge-expected address (zero address on EVM). Chains the - * helper can't resolve are skipped. Together with the stablecoins, these are - * the tokens a user can receive when selling a position. + * Non-EVM chains offered as native-asset-only receive candidates (TRX, BTC). + * Their native assets resolve through the bridge native-asset registry + * (`getNativeSourceToken`), unlike their non-native tokens which + * `useAssetMetadata` cannot resolve (see `STABLECOIN_CANDIDATES`). + */ +const NATIVE_ONLY_NON_EVM_CHAINS: CaipChainId[] = [ + TrxScope.Mainnet, + BtcScope.Mainnet, +]; + +/** + * Native token candidates for the Sell "Receive" picker: one per chain already + * covered by `STABLECOIN_CANDIDATES`, plus the native-only non-EVM chains + * (TRX, BTC). Built via `getNativeSourceToken` so each native uses the + * bridge-expected address (zero address on EVM, CAIP asset id on non-EVM). + * Chains the helper can't resolve are skipped. Together with the stablecoins, + * these are the tokens a user can receive when selling a position. */ const NATIVE_CANDIDATES: BridgeToken[] = Array.from( - new Set(STABLECOIN_CANDIDATES.map((token) => token.chainId)), + new Set([ + ...STABLECOIN_CANDIDATES.map((token) => token.chainId), + ...NATIVE_ONLY_NON_EVM_CHAINS, + ]), ).reduce((acc, chainId) => { try { acc.push(getNativeSourceToken(chainId)); @@ -85,27 +114,58 @@ const getReceiveTokenCandidates = ( * a token they don't yet hold). Held tokens are enriched with balance + fiat; * unheld ones show "$0.00". Candidates on `preferredChainId` are sorted to the * top, stablecoins before natives. + * + * Non-EVM candidates (Solana, Tron, Bitcoin) are only offered when the + * selected account actually has an address on that chain (multichain account + * groups may lack one), mirroring how the rest of the app gates non-EVM + * visibility via `selectSelectedInternalAccountByScope(scope)`. */ export const useReceiveTokens = ( preferredChainId: string | undefined, ): BridgeToken[] => { const isChainEnabled = useNetworkEnabledPredicate(); + + const selectAccountByScope = useSelector( + selectSelectedInternalAccountByScope, + ); + const solanaAccount = selectAccountByScope(SolScope.Mainnet); + const tronAccount = selectAccountByScope(TrxScope.Mainnet); + const bitcoinAccount = selectAccountByScope(BtcScope.Mainnet); + + // Non-EVM accounts in the selected group, keyed by the CAIP chain id the + // receive candidates carry. A missing entry means the user has no address on + // that chain and cannot receive there. + const nonEvmAccountByChainId = useMemo( + () => ({ + [SolScope.Mainnet]: solanaAccount, + [TrxScope.Mainnet]: tronAccount, + [BtcScope.Mainnet]: bitcoinAccount, + }), + [solanaAccount, tronAccount, bitcoinAccount], + ); + const candidates = useMemo( () => - getReceiveTokenCandidates(preferredChainId).filter((candidate) => - isChainEnabled(candidate.chainId), - ), - [preferredChainId, isChainEnabled], + getReceiveTokenCandidates(preferredChainId).filter((candidate) => { + if (!isChainEnabled(candidate.chainId)) return false; + if (!isNonEvmChainId(candidate.chainId)) return true; + // Without an address on the non-EVM chain the user cannot receive there. + return Boolean( + nonEvmAccountByChainId[ + candidate.chainId as keyof typeof nonEvmAccountByChainId + ], + ); + }), + [preferredChainId, isChainEnabled, nonEvmAccountByChainId], ); - const accountAddress = useSelector( - (state: RootState) => - selectSelectedInternalAccountByScope(state)(EVM_SCOPE)?.address, - ); + const accountAddress = selectAccountByScope(EVM_SCOPE)?.address; const accountsByChainId = useSelector(selectAccountsByChainId); const tokenBalances = useSelector(selectTokensBalances); const tokenMarketData = useSelector(selectTokenMarketData); const currencyRates = useSelector(selectCurrencyRates); + const multichainBalances = useSelector(selectMultichainBalances); + const multichainRates = useSelector(selectMultichainAssetsRates); const allNetworkConfigs = useSelector( (state: RootState) => state.engine.backgroundState.NetworkController @@ -124,6 +184,14 @@ export const useReceiveTokens = ( tokenMarketData, currencyRates, allNetworkConfigs, + solanaAccount: solanaAccount ?? undefined, + tronAccount: tronAccount ?? undefined, + bitcoinAccount: bitcoinAccount ?? undefined, + multichainBalances, + multichainRates: multichainRates as Record< + string, + { rate?: string } | undefined + >, }, { includeZeroBalance: true, @@ -139,6 +207,11 @@ export const useReceiveTokens = ( tokenMarketData, currencyRates, allNetworkConfigs, + solanaAccount, + tronAccount, + bitcoinAccount, + multichainBalances, + multichainRates, ], ); }; From adbf44cc83d54f7c1689f48b20b985e567d993a2 Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Wed, 10 Jun 2026 16:54:30 -0300 Subject: [PATCH 04/12] fix(card): use slide-from-bottom presentation for Card routes (#31487) ## **Description** The Card feature screens (`Routes.CARD.ROOT` -> `CardRoutes`) were registered in the main navigator using the `ModalPresentationIOS` transition preset. This caused the Card flow to be presented as an iOS card-style modal stacked on top of the current screen, which did not match the intended presentation for these screens and produced inconsistent navigation behavior. This PR switches the Card route presentation to `ModalSlideFromBottomIOS`, so the Card screens slide up from the bottom consistently with the rest of the modal flows in the app. The corresponding test mock is updated to drop the now-unused `ModalPresentationIOS` preset. ## **Changelog** CHANGELOG entry: Fixed the presentation of the Card screens so they slide up from the bottom. ## **Related issues** Fixes: null ## **Manual testing steps** ```gherkin Feature: Card screen presentation Scenario: user opens the Card flow Given the user is on a screen that can navigate to the Card feature When the user navigates to the Card flow Then the Card screens are presented sliding up from the bottom And the transition is consistent with other modal flows in the app ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Single navigation transition preset change with no auth, data, or routing logic changes. > > **Overview** > The Card stack (`Routes.CARD.ROOT` / `CardRoutes`) in `MainNavigator` now uses **`TransitionPresets.ModalSlideFromBottomIOS`** instead of **`ModalPresentationIOS`**, so the flow **slides up from the bottom** rather than using the iOS card-style stacked modal. > > The `@react-navigation/stack` test mock in `MainNavigator.test.tsx` is updated to expose only **`ModalSlideFromBottomIOS`**, removing the unused **`ModalPresentationIOS`** preset. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e5882e51d4f4a8340531ce489cce1e3c6f5786f4. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- app/components/Nav/Main/MainNavigator.js | 2 +- app/components/Nav/Main/MainNavigator.test.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 3908727c15c..432afa468ce 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -1391,7 +1391,7 @@ const MainNavigator = () => { ({ }), TransitionPresets: { ModalSlideFromBottomIOS: {}, - ModalPresentationIOS: {}, }, })); From 155527c0ebe154f16d423411fa8e7b90aff5fa77 Mon Sep 17 00:00:00 2001 From: Xavier Brochard Date: Wed, 10 Jun 2026 22:09:30 +0200 Subject: [PATCH 05/12] fix: drive Quick Buy source and dest balances off live controller state (TSA-632) (#31460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** The available balances shown in the Quick Buy sheet — the "Pay with" balance (Buy) and the "Receive" balance (Sell) — only updated when a Quick Buy trade reached a terminal state, and only really on the pay-with side. Any balance change from another cause was missed: an external incoming transfer, a swap in another flow, etc. Example: buy $0.10 of USDC paying with ETH, wait for the "complete" toast — the displayed ETH balance stayed at its pre-trade value. Two stacked causes: 1. **Frozen token snapshot** — the selected pay-with / receive token is a `useState` snapshot of one entry from the Redux-driven option lists; the auto-select effect only runs while nothing is selected, so the token object (including its cached `balance` / `balanceFiat`) froze at selection time and never re-synced when the balance controllers updated Redux. 2. **One-shot RPC fetch** — `useLatestBalance` fetches the EVM balance once per token/account/`refreshKey`, and its fetched value shadows the cached fallback. Quick Buy keyed it off a Quick-Buy-specific signal, so it only re-fetched when a Quick Buy trade settled. Fix — drive **both** displayed balances off live controller state so they re-render on **any** change, regardless of origin: - Add `resolveLiveTokenBalance`, which re-reads a selected token's balance fields from the reactive option list it was picked from (`usePayWithTokens` / `useReceiveTokens`). Those lists subscribe (via `useSelector`) to `TokenBalancesController` / `AccountTrackerController` (EVM) and the multichain balances state (non-EVM), so both the source and dest balances track live controller state. Only balance fields are read — never the `sourceToken` reference passed to quote fetching — so market-data ticks can't churn quote requests. - Re-key `useLatestBalance` off the live balance itself, so any change to the underlying balance — for any reason — triggers a fresh on-chain read and re-render, independent of Quick Buy's own state. - Expose `destBalanceFiat` from the controller and drive the Sell-mode footer pill from it (previously the frozen `selectedDestStable` snapshot). - Remove the terminal-toast `incrementBridgeBalanceRefreshKey` dispatch: the live subscription already covers the settle case, so the trigger is now "balance state changed," not "Quick Buy finished." Jira: [TSA-632](https://consensyssoftware.atlassian.net/browse/TSA-632) ## **Changelog** CHANGELOG entry: Fixed the Quick Buy "Pay with" and "Receive" available balances not refreshing when the underlying balance changes (a swap settling, an external transfer, a send in another flow, etc.) ## **Related issues** Fixes: [TSA-632](https://consensyssoftware.atlassian.net/browse/TSA-632) ## **Manual testing steps** ```gherkin Feature: Quick Buy available balance freshness Scenario: pay-with balance updates when a Quick Buy swap settles Given the user notes their ETH on Base balance in the Quick Buy sheet And the user buys $0.10 of USDC on Base paying with ETH on Base When the user re-opens the Quick Buy sheet while the swap settles And the "complete" toast appears Then the displayed ETH on Base available balance updates to the post-trade value Scenario: pay-with balance updates on an external transfer Given the Quick Buy sheet is open in Buy mode with a pay-with token selected When the selected token's balance changes from outside Quick Buy (e.g. an incoming transfer) Then the displayed available balance updates without any Quick Buy action Scenario: receive balance updates on balance change (Sell mode) Given the Quick Buy sheet is open in Sell mode with a receive token selected When that token's balance changes (a sell settling, or an external transfer) Then the displayed receive available balance updates to the new value ``` ## **Screenshots/Recordings** ### **Before** **🔊 Sound ON** https://github.com/user-attachments/assets/ddc84e7c-a6a2-4ec0-beee-b4e0a0e04d48 ### **After** **🔊 Sound ON** https://github.com/user-attachments/assets/fc8e7fe3-e869-44f8-b6b1-369a946a4209 https://github.com/user-attachments/assets/2751d57f-7a23-42b9-bc06-a2e823c6fac1 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [TSA-632]: https://consensyssoftware.atlassian.net/browse/TSA-632?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Medium Risk** > Touches balance display, slider max spend, and on-chain balance refresh in the trade flow; quote inputs intentionally keep frozen rates to avoid extra quote churn. > > **Overview** > Fixes **TSA-632**: Quick Buy “Pay with” and “Receive” balances no longer stick to the token snapshot taken at picker selection. > > Adds **`resolveLiveTokenBalance`** to match the selected pay-with / receive token against the reactive **`usePayWithTokens`** / **`useReceiveTokens`** lists (by `address:chainId`) and read only balance/rate fields so quote token references stay stable. **`useQuickBuyController`** feeds live source balance into **`useLatestBalance`** (with **`refreshKey`** tied to that balance), uses **`liveSourceCurrencyExchangeRate`** for fiat display, slider cap, and price migration, and exposes **`destBalanceFiat`** for sell mode. **`QuickBuyActionFooter`** shows **`destBalanceFiat`** in sell mode instead of the frozen **`selectedDestStable.balanceFiat`**. > > Tests cover **`resolveLiveTokenBalance`** and controller behavior for external transfers, rate-only updates, swap settlement, and list fallback. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit d6ef296f5caf03f3c36fb60886d24168adfc39f2. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Claude Fable 5 Co-authored-by: Cursor Co-authored-by: Xavier Brochard --- .../QuickBuy/QuickBuyContext.test.tsx | 1 + .../components/QuickBuy/QuickBuyRoot.test.tsx | 1 + .../QuickBuy/QuickBuySheet.test.tsx | 1 + .../components/QuickBuyActionFooter.tsx | 9 +- .../hooks/liveSelectedTokenBalance.test.ts | 104 ++++++++ .../hooks/liveSelectedTokenBalance.ts | 62 +++++ .../QuickBuy/hooks/useQuickBuyController.ts | 87 ++++++- .../QuickBuy/useQuickBuyController.test.ts | 237 ++++++++++++++++++ 8 files changed, 491 insertions(+), 11 deletions(-) create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/liveSelectedTokenBalance.test.ts create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/liveSelectedTokenBalance.ts diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.test.tsx index 2eadc24736d..93bb31e61d8 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.test.tsx @@ -62,6 +62,7 @@ const buildController = ( estimatedReceiveAmount: undefined, sourceBalanceFiat: '$0.00', sourceBalanceDisplay: undefined, + destBalanceFiat: undefined, formattedNetworkFee: '-', formattedSlippage: '-', formattedMinimumReceived: '-', diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.test.tsx index 3b73a4703bd..5dcbd2f262a 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.test.tsx @@ -177,6 +177,7 @@ const buildHookResult = ( estimatedReceiveAmount: undefined, sourceBalanceFiat: '$0.00', sourceBalanceDisplay: undefined, + destBalanceFiat: undefined, formattedNetworkFee: '-', formattedSlippage: '-', formattedMinimumReceived: '-', diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySheet.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySheet.test.tsx index b10871b1cdd..37150175445 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySheet.test.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySheet.test.tsx @@ -194,6 +194,7 @@ const buildHookResult = ( estimatedReceiveAmount: undefined, sourceBalanceFiat: '$0.00', sourceBalanceDisplay: undefined, + destBalanceFiat: undefined, formattedNetworkFee: '-', formattedSlippage: '-', formattedMinimumReceived: '-', diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx index 0e03a0fd615..07f9706a627 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx @@ -43,6 +43,7 @@ const QuickBuyActionFooter: React.FC = () => { sourceToken, sourceChainId, sourceBalanceFiat, + destBalanceFiat, destToken, selectedDestStable, features, @@ -56,10 +57,12 @@ const QuickBuyActionFooter: React.FC = () => { | import('@metamask/utils').Hex | undefined) : sourceChainId; + // Both balances are driven by live, selector-backed state (TSA-632): + // `sourceBalanceFiat` from `useLatestBalance` re-keyed off the live cached + // balance, and `destBalanceFiat` resynced from the reactive receive-token + // list. Either updates the pill the moment the underlying balance changes. const pickerBalanceFiat = - tradeMode === 'sell' - ? (selectedDestStable?.balanceFiat ?? undefined) - : sourceBalanceFiat; + tradeMode === 'sell' ? destBalanceFiat : sourceBalanceFiat; const networkImage = pickerChainId ? getNetworkImageSource({ chainId: pickerChainId }) diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/liveSelectedTokenBalance.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/liveSelectedTokenBalance.test.ts new file mode 100644 index 00000000000..2695c04943a --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/liveSelectedTokenBalance.test.ts @@ -0,0 +1,104 @@ +import type { BridgeToken } from '../../../../../../UI/Bridge/types'; +import { resolveLiveTokenBalance } from './liveSelectedTokenBalance'; + +const token = (overrides: Partial = {}): BridgeToken => + ({ + address: '0xABC', + chainId: '0x1', + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + balance: '1.0', + balanceFiat: '$2000.00', + tokenFiatAmount: 2000, + currencyExchangeRate: 2000, + ...overrides, + }) as BridgeToken; + +describe('resolveLiveTokenBalance', () => { + it('returns undefined when no token is selected', () => { + // Arrange / Act + const result = resolveLiveTokenBalance(undefined, [token()]); + + // Assert + expect(result).toBeUndefined(); + }); + + it('returns the matching live option balance over the selection snapshot', () => { + // Arrange — the selected snapshot is stale; the live option holds fresh + // balance fields for the same address:chainId. + const selected = token({ balance: '1.0', balanceFiat: '$2000.00' }); + const liveOption = token({ + balance: '0.5', + balanceFiat: '$1000.00', + tokenFiatAmount: 1000, + currencyExchangeRate: 2000, + }); + + // Act + const result = resolveLiveTokenBalance(selected, [liveOption]); + + // Assert + expect(result).toEqual({ + balance: '0.5', + balanceFiat: '$1000.00', + tokenFiatAmount: 1000, + currencyExchangeRate: 2000, + }); + }); + + it('matches by stable key regardless of address casing', () => { + // Arrange + const selected = token({ address: '0xAbC' }); + const liveOption = token({ address: '0xabc', balance: '0.25' }); + + // Act + const result = resolveLiveTokenBalance(selected, [liveOption]); + + // Assert + expect(result?.balance).toBe('0.25'); + }); + + it('does not match a token on a different chain', () => { + // Arrange — same address, different chain → not the same holding. + const selected = token({ chainId: '0x1', balance: '1.0' }); + const otherChain = token({ chainId: '0x89', balance: '5.0' }); + + // Act + const result = resolveLiveTokenBalance(selected, [otherChain]); + + // Assert — falls back to the snapshot's own fields. + expect(result?.balance).toBe('1.0'); + }); + + it('falls back to the snapshot fields when the token is absent from the options', () => { + // Arrange + const selected = token({ balance: '1.0', balanceFiat: '$2000.00' }); + + // Act + const result = resolveLiveTokenBalance(selected, []); + + // Assert + expect(result).toEqual({ + balance: '1.0', + balanceFiat: '$2000.00', + tokenFiatAmount: 2000, + currencyExchangeRate: 2000, + }); + }); + + it('surfaces only balance fields, never identity fields', () => { + // Arrange + const selected = token(); + + // Act + const result = resolveLiveTokenBalance(selected, [selected]); + + // Assert — identity fields must not leak so callers keep their stable token + // reference for quote fetching. + expect(result).not.toHaveProperty('address'); + expect(result).not.toHaveProperty('chainId'); + expect(result).not.toHaveProperty('decimals'); + expect(result).not.toHaveProperty('symbol'); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/liveSelectedTokenBalance.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/liveSelectedTokenBalance.ts new file mode 100644 index 00000000000..a5e160f1651 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/liveSelectedTokenBalance.ts @@ -0,0 +1,62 @@ +import type { BridgeToken } from '../../../../../../UI/Bridge/types'; +import { getTokenKey } from '../tokenKey'; + +/** + * The live balance fields QuickBuy re-reads from the reactive option lists + * (`usePayWithTokens` / `useReceiveTokens`), which are themselves driven by + * `useSelector` subscriptions to `TokenBalancesController` / + * `AccountTrackerController` (EVM) and the multichain balances state (Solana). + * + * Only the balance-shaped fields are surfaced — never identity fields + * (`address`, `chainId`, `decimals`, `symbol`) — so callers can refresh a + * selected token's displayed balance without swapping the reference-stable + * token used for quote fetching. + */ +export interface LiveTokenBalanceFields { + balance?: string; + balanceFiat?: string; + tokenFiatAmount?: number; + currencyExchangeRate?: number; +} + +/** + * Resolves the live balance fields for a selected token by matching it (by + * stable `address:chainId` key) against the reactive option list it was + * originally picked from. + * + * QuickBuy stores the chosen pay-with / receive token as a `useState` snapshot, + * so its cached `balance` / `balanceFiat` freeze at selection time. The option + * lists, by contrast, recompute on every balance-state change (a swap settling, + * an external incoming transfer, a send in another flow, …). Re-reading the + * matching option's balance here makes the displayed available balance track + * the underlying state regardless of *what* changed it — see TSA-632. + * + * Falls back to the snapshot's own fields when no matching option is found + * (e.g. the user spent the entire balance and the held-token list dropped it), + * so the row degrades to the last known value rather than blanking out. + * + * @param selected - The selected token snapshot (or `undefined`). + * @param liveOptions - The reactive option list the token was selected from. + * @returns The live balance fields, or `undefined` when `selected` is absent. + */ +export const resolveLiveTokenBalance = ( + selected: BridgeToken | undefined, + liveOptions: BridgeToken[], +): LiveTokenBalanceFields | undefined => { + if (!selected) { + return undefined; + } + + const selectedKey = getTokenKey(selected); + const liveMatch = liveOptions.find( + (option) => getTokenKey(option) === selectedKey, + ); + const source = liveMatch ?? selected; + + return { + balance: source.balance, + balanceFiat: source.balanceFiat, + tokenFiatAmount: source.tokenFiatAmount, + currencyExchangeRate: source.currencyExchangeRate, + }; +}; diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts index 3d5ba9fae1a..37d5a12bd41 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts @@ -101,6 +101,7 @@ import { endQuickBuySubmission, } from '../quickBuyTradeTracker'; import { resolveQuickBuyTerminalToast } from '../resolveQuickBuyTerminalToast'; +import { resolveLiveTokenBalance } from './liveSelectedTokenBalance'; export type QuickBuyButtonError = | 'insufficient_balance' @@ -160,6 +161,12 @@ export interface UseQuickBuyControllerResult { estimatedReceiveAmount: string | undefined; sourceBalanceFiat: string; sourceBalanceDisplay: string | undefined; + /** + * Live fiat balance of the sell-mode "Receive" token, resynced from the + * reactive receive-token list so it tracks underlying balance changes. + * `undefined` when no receive token is selected or its price is unresolved. + */ + destBalanceFiat: string | undefined; formattedNetworkFee: string; formattedSlippage: string; formattedMinimumReceived: string; @@ -436,15 +443,72 @@ export function useQuickBuyController( const hasInitializedRecipient = useRef(false); useRecipientInitialization(hasInitializedRecipient); + // ─── Live selected-token balances (TSA-632) ──────────────────────────── + // The selected pay-with token (`selectedSourceToken`, buy mode) and receive + // token (`selectedDestStable`, sell mode) are `useState` snapshots, so their + // cached `balance` / `balanceFiat` freeze at selection time. The option lists + // they were picked from — `usePayWithTokens` / `useReceiveTokens` — recompute + // on every balance-state change because they subscribe (via `useSelector`) to + // `TokenBalancesController` / `AccountTrackerController` (EVM) and the + // multichain balances state (Solana). Re-reading the matching option here + // makes both displayed balances track the underlying state whatever changed + // it: a QuickBuy swap settling, an external incoming transfer, a send in + // another flow, etc. + // + // The other side of each trade is already selector-driven and needs no + // resync: sell-mode `positionToken` comes from `usePositionTokenBalance`, and + // buy-mode `destToken` (`positionTokenFromSetup`) carries no displayed + // balance in the footer. + // + // Only the balance fields are pulled from the live option — never the token + // reference passed to quote fetching — so market-data ticks can't churn quote + // requests (see `destTokenForRate` below for the same invariant). + const liveSelectedSourceBalance = resolveLiveTokenBalance( + selectedSourceToken, + sourceTokenOptions, + ); + const liveSelectedDestBalance = resolveLiveTokenBalance( + selectedDestStable, + sellDestTokenOptions, + ); + + // In buy mode, read the live exchange rate from the reactive option list so + // the displayed fiat balance and slider cap stay in sync with the option list + // when `usePayWithTokens` refreshes rates without a balance string change + // (Bugbot: "Stale rate with live balance"). In sell mode `sourceToken` is + // `positionToken` (already selector-driven), so the frozen value is live. + // + // Intentionally NOT used in `sourceTokenAmount` (the quote pipeline): a rate + // tick must not churn quote requests — only balance changes do. + const liveSourceCurrencyExchangeRate = + tradeMode === 'buy' + ? (liveSelectedSourceBalance?.currencyExchangeRate ?? + sourceToken?.currencyExchangeRate) + : sourceToken?.currencyExchangeRate; + const hasSourcePrice = Boolean( - sourceToken?.currencyExchangeRate && sourceToken.currencyExchangeRate > 0, + liveSourceCurrencyExchangeRate && liveSourceCurrencyExchangeRate > 0, ); + // The live balance for whichever token is the *source* this mode: the + // resynced pay-with token in buy mode, or the already-live position token in + // sell mode. + const liveSourceBalance = + tradeMode === 'buy' + ? liveSelectedSourceBalance?.balance + : positionToken?.balance; + const latestSourceBalance = useLatestBalance({ address: sourceToken?.address, decimals: sourceToken?.decimals, chainId: sourceToken?.chainId, - balance: sourceToken?.balance, + balance: liveSourceBalance, + // `useLatestBalance` does a one-shot on-chain RPC fetch that shadows the + // cached value until its token identity or this key changes. Keying it off + // the live balance itself means any change to the underlying balance — for + // ANY reason — triggers a fresh on-chain read and re-render, independent of + // QuickBuy's own state. + refreshKey: liveSourceBalance ?? '', }); const sourceTokenAmount = useMemo(() => { @@ -655,15 +719,15 @@ export function useQuickBuyController( const sourceBalanceFiatUsd = useMemo(() => { if ( !latestSourceBalance?.displayBalance || - !sourceToken?.currencyExchangeRate + !liveSourceCurrencyExchangeRate ) { return 0; } const balance = parseFloat(latestSourceBalance.displayBalance); if (!Number.isFinite(balance)) return 0; - const fiat = balance * sourceToken.currencyExchangeRate; + const fiat = balance * liveSourceCurrencyExchangeRate; return Number.isFinite(fiat) && fiat > 0 ? fiat : 0; - }, [latestSourceBalance?.displayBalance, sourceToken?.currencyExchangeRate]); + }, [latestSourceBalance?.displayBalance, liveSourceCurrencyExchangeRate]); const sourceBalanceFiat = useMemo( () => formatCurrency(sourceBalanceFiatUsd, currentCurrency), @@ -683,6 +747,12 @@ export function useQuickBuyController( return `${formatted} ${sourceToken.symbol}`; }, [latestSourceBalance?.displayBalance, sourceToken?.symbol]); + // Live fiat balance for the sell-mode "Receive" token, resynced from the + // reactive `useReceiveTokens` list so the footer pill tracks balance changes + // (TSA-632). `enrichTokenBalance` already formats this as a fiat string, so we + // pass it straight through rather than re-deriving it from a token amount. + const destBalanceFiat = liveSelectedDestBalance?.balanceFiat; + const maxSpendUsd = sourceBalanceFiatUsd; // Token-amount-based gate: used for sources we can't price. Mirrors how the @@ -893,15 +963,15 @@ export function useQuickBuyController( if ( Number.isFinite(tokens) && tokens > 0 && - sourceToken?.currencyExchangeRate + liveSourceCurrencyExchangeRate ) { - const usd = (tokens * sourceToken.currencyExchangeRate).toFixed(2); + const usd = (tokens * liveSourceCurrencyExchangeRate).toFixed(2); setUsdAmount(usd); setQuotedUsdAmount(usd); lastCommittedUsdRef.current = usd; } } - }, [hasSourcePrice, sourceAmountTokens, sourceToken?.currencyExchangeRate]); + }, [hasSourcePrice, sourceAmountTokens, liveSourceCurrencyExchangeRate]); const handleSelectSourceToken = useCallback( (token: BridgeToken) => { @@ -1350,6 +1420,7 @@ export function useQuickBuyController( estimatedReceiveAmount, sourceBalanceFiat, sourceBalanceDisplay, + destBalanceFiat, formattedNetworkFee, formattedSlippage, formattedMinimumReceived, diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts index 2b2d50b5035..aee01e86f53 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts @@ -1517,6 +1517,243 @@ describe('useQuickBuyController', () => { }); }); + describe('live available balances (TSA-632)', () => { + interface LatestBalanceArgs { + address?: string; + decimals?: number; + chainId?: string; + balance?: string; + refreshKey?: string | number; + } + + const lastLatestBalanceArgs = (): LatestBalanceArgs => + (useLatestBalance as jest.Mock).mock.calls.at( + -1, + )?.[0] as LatestBalanceArgs; + + // `useLatestBalance` is mocked to echo the `balance` it is fed, so the + // displayed source fiat tracks whatever balance the controller resolves. + const echoLatestBalance = () => { + (useLatestBalance as jest.Mock).mockImplementation( + (args: LatestBalanceArgs) => ({ + displayBalance: args.balance, + atomicBalance: undefined, + }), + ); + }; + + const createReceiveToken = ( + overrides: Partial = {}, + ): BridgeToken => + ({ + address: '0xUSDC', + chainId: '0x1', + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + currencyExchangeRate: 1, + balance: '100', + balanceFiat: '$100.00', + tokenFiatAmount: 100, + ...overrides, + }) as BridgeToken; + + describe('source (Pay with) balance', () => { + it('updates the displayed source balance when the underlying balance changes (external transfer)', () => { + // Arrange — sheet opens; ETH auto-selected with a live 1.0 balance. + echoLatestBalance(); + (usePayWithTokens as jest.Mock).mockReturnValue({ + options: [createSourceToken({ balance: '1.0' })], + isLoading: false, + }); + const { result, rerender } = renderHook(() => + useQuickBuyController(createTarget(), jest.fn()), + ); + // 1.0 ETH * $2000 rate. + expect(result.current.sourceBalanceFiat).toBe('$2,000.00'); + + // Act — an external transfer (NOT via QuickBuy) lands: the reactive + // usePayWithTokens list recomputes with a higher balance while the + // selected token snapshot is unchanged. + (usePayWithTokens as jest.Mock).mockReturnValue({ + options: [createSourceToken({ balance: '1.5' })], + isLoading: false, + }); + rerender(undefined); + + // Assert — the displayed available balance reflects the new state. + expect(lastLatestBalanceArgs().balance).toBe('1.5'); + expect(result.current.sourceBalanceFiat).toBe('$3,000.00'); + }); + + it('re-keys the on-chain fetch off the live balance so any change re-fetches', () => { + // Arrange + echoLatestBalance(); + (usePayWithTokens as jest.Mock).mockReturnValue({ + options: [createSourceToken({ balance: '1.0' })], + isLoading: false, + }); + const { rerender } = renderHook(() => + useQuickBuyController(createTarget(), jest.fn()), + ); + // The key is derived from the live balance itself, NOT from any + // QuickBuy-specific state. + const initialRefreshKey = lastLatestBalanceArgs().refreshKey; + expect(initialRefreshKey).toBe('1.0'); + + // Act — any balance change (external or QuickBuy) updates the live value. + (usePayWithTokens as jest.Mock).mockReturnValue({ + options: [createSourceToken({ balance: '0.75' })], + isLoading: false, + }); + rerender(undefined); + + // Assert — the key changes, forcing a fresh on-chain read. + expect(lastLatestBalanceArgs().refreshKey).toBe('0.75'); + expect(lastLatestBalanceArgs().balance).toBe('0.75'); + }); + + it('falls back to the snapshot balance when the selected token leaves the options list', () => { + // Arrange + echoLatestBalance(); + (usePayWithTokens as jest.Mock).mockReturnValue({ + options: [createSourceToken({ balance: '1.0' })], + isLoading: false, + }); + const { result, rerender } = renderHook(() => + useQuickBuyController(createTarget(), jest.fn()), + ); + + // Act — the user spent the whole balance, so the held-token list no + // longer contains the selected token. + (usePayWithTokens as jest.Mock).mockReturnValue({ + options: [], + isLoading: false, + }); + rerender(undefined); + + // Assert — no live match: degrade to the snapshot value rather than + // blanking out. + expect(lastLatestBalanceArgs().balance).toBe('1.0'); + expect(result.current.sourceBalanceFiat).toBe('$2,000.00'); + }); + + it('updates the displayed fiat balance when the exchange rate refreshes without a balance change', () => { + echoLatestBalance(); + (usePayWithTokens as jest.Mock).mockReturnValue({ + options: [ + createSourceToken({ balance: '1.0', currencyExchangeRate: 2000 }), + ], + isLoading: false, + }); + const { result, rerender } = renderHook(() => + useQuickBuyController(createTarget(), jest.fn()), + ); + + expect(result.current.sourceBalanceFiat).toBe('$2,000.00'); + + (usePayWithTokens as jest.Mock).mockReturnValue({ + options: [ + createSourceToken({ balance: '1.0', currencyExchangeRate: 2500 }), + ], + isLoading: false, + }); + rerender(undefined); + + expect(result.current.sourceBalanceFiat).toBe('$2,500.00'); + }); + + it('updates the source balance when a QuickBuy swap settles (existing behaviour preserved)', () => { + // Arrange + echoLatestBalance(); + (usePayWithTokens as jest.Mock).mockReturnValue({ + options: [createSourceToken({ balance: '1.0' })], + isLoading: false, + }); + const { result, rerender } = renderHook(() => + useQuickBuyController(createTarget(), jest.fn()), + ); + expect(result.current.sourceBalanceFiat).toBe('$2,000.00'); + + // Act — a QuickBuy swap settles: TokenBalancesController updates Redux + // and usePayWithTokens recomputes the same token with a lower balance. + (usePayWithTokens as jest.Mock).mockReturnValue({ + options: [createSourceToken({ balance: '0.75' })], + isLoading: false, + }); + rerender(undefined); + + // Assert + expect(result.current.sourceBalanceFiat).toBe('$1,500.00'); + }); + }); + + describe('dest (Receive) balance', () => { + const renderSellMode = () => { + const utils = renderHook(() => + useQuickBuyController(createTarget(), jest.fn()), + ); + act(() => { + utils.result.current.setTradeMode('sell'); + }); + return utils; + }; + + it('updates the displayed dest balance when the underlying balance changes (external transfer)', () => { + // Arrange — sell mode; USDC receive token auto-selected at $100. + (useReceiveTokens as jest.Mock).mockReturnValue([ + createReceiveToken({ balanceFiat: '$100.00' }), + ]); + const { result, rerender } = renderSellMode(); + expect(result.current.destBalanceFiat).toBe('$100.00'); + + // Act — an external transfer lands: the reactive useReceiveTokens list + // recomputes with a higher balance while the selection is unchanged. + (useReceiveTokens as jest.Mock).mockReturnValue([ + createReceiveToken({ balance: '150', balanceFiat: '$150.00' }), + ]); + rerender(undefined); + + // Assert — the displayed receive balance reflects the new state. + expect(result.current.destBalanceFiat).toBe('$150.00'); + }); + + it('updates the dest balance when a QuickBuy swap settles (existing behaviour preserved)', () => { + // Arrange + (useReceiveTokens as jest.Mock).mockReturnValue([ + createReceiveToken({ balanceFiat: '$100.00' }), + ]); + const { result, rerender } = renderSellMode(); + expect(result.current.destBalanceFiat).toBe('$100.00'); + + // Act — a QuickBuy sell settles into the receive token, raising its + // balance; the reactive list recomputes. + (useReceiveTokens as jest.Mock).mockReturnValue([ + createReceiveToken({ balance: '125', balanceFiat: '$125.00' }), + ]); + rerender(undefined); + + // Assert + expect(result.current.destBalanceFiat).toBe('$125.00'); + }); + + it('falls back to the snapshot balance when the selected receive token leaves the list', () => { + // Arrange + (useReceiveTokens as jest.Mock).mockReturnValue([ + createReceiveToken({ balanceFiat: '$100.00' }), + ]); + const { result, rerender } = renderSellMode(); + + // Act — the list no longer contains the selected token. + (useReceiveTokens as jest.Mock).mockReturnValue([]); + rerender(undefined); + + // Assert — degrade to the snapshot's fiat rather than blanking out. + expect(result.current.destBalanceFiat).toBe('$100.00'); + }); + }); + }); + describe('receive token auto-selection', () => { const NATIVE_ADDRESS = '0x0000000000000000000000000000000000000000'; const USDC_DEST = '0xDEST'; // matches the default-mock position token From a5276999fb414d0da1959c20c15361ea0f104e71 Mon Sep 17 00:00:00 2001 From: Kirill Zyusko Date: Wed, 10 Jun 2026 22:21:55 +0200 Subject: [PATCH 06/12] feat: `HeaderBase` (pilot migration) (#29137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Migrated `HeaderBase` to `design-system-react-native` usage. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/DSYS-282 ## **Manual testing steps** ```gherkin Feature: header base update Scenario: user visits screen with modified files Given user landed on the screen When user interacts with modified component Then they behave as before. No visual regressions or functional regressions ``` ## **Screenshots/Recordings** ### **Before** image ### **After** image ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Import-only migration with unchanged HeaderBase usage on navigation and modal headers; main risk is subtle visual or API differences between local and package HeaderBase. > > **Overview** > This PR continues the **HeaderBase** design-system pilot by sourcing it from `@metamask/design-system-react-native` instead of the local `component-library` `HeaderBase` module. > > **Navbar** drops the separate `HeaderBase` / `HeaderBaseVariant` import and pulls `HeaderBase` from the same MMDS bundle as other header-related primitives already used there. **Update needed** and **QR tab switcher** only change their import paths; JSX still uses the same props (`includesTopInset`, `twClassName`, `startAccessory` / `endAccessory`, children). > > No new screens or header behavior are introduced—this is an import consolidation aimed at parity with the canonical design-system component. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit d5f4ffeda0d3c6a548010b6185a7cb2b8d83b2af. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- app/components/UI/Navbar/index.js | 4 +--- app/components/UI/UpdateNeeded/UpdateNeeded.tsx | 7 +++++-- app/components/Views/QRTabSwitcher/QRTabSwitcher.tsx | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index cc25acf5b68..df243d14255 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -37,9 +37,6 @@ import Icon, { } from '../../../component-library/components/Icons/Icon'; import { AddContactViewSelectorsIDs } from '../../Views/Settings/Contacts/AddContactView.testIds'; import { SettingsViewSelectorsIDs } from '../../Views/Settings/SettingsView.testIds'; -import HeaderBase, { - HeaderBaseVariant, -} from '../../../component-library/components/HeaderBase'; import getHeaderCompactStandardNavbarOptions from '../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions'; import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader'; import { AnalyticsEventBuilder } from '../../../util/analytics/AnalyticsEventBuilder'; @@ -52,6 +49,7 @@ import { BadgeWrapper, ButtonIcon, ButtonIconSize, + HeaderBase, IconColor as MMDSIconColor, } from '@metamask/design-system-react-native'; diff --git a/app/components/UI/UpdateNeeded/UpdateNeeded.tsx b/app/components/UI/UpdateNeeded/UpdateNeeded.tsx index 66368c063f9..c491d243852 100644 --- a/app/components/UI/UpdateNeeded/UpdateNeeded.tsx +++ b/app/components/UI/UpdateNeeded/UpdateNeeded.tsx @@ -12,7 +12,6 @@ import Button, { ButtonWidthTypes, } from '../../../component-library/components/Buttons/Button'; import ButtonIcon from '../../../component-library/components/Buttons/ButtonIcon'; -import HeaderBase from '../../../component-library/components/HeaderBase'; import { IconColor, IconName, @@ -23,7 +22,11 @@ import { ScrollView } from 'react-native-gesture-handler'; import generateDeviceAnalyticsMetaData from '../../../util/metrics'; import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics'; -import { Text, TextVariant } from '@metamask/design-system-react-native'; +import { + HeaderBase, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; /* eslint-disable import-x/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ const foxLogo = require('../../../images/branding/fox.png'); diff --git a/app/components/Views/QRTabSwitcher/QRTabSwitcher.tsx b/app/components/Views/QRTabSwitcher/QRTabSwitcher.tsx index 5b74f95a98e..6fefe93d2b2 100644 --- a/app/components/Views/QRTabSwitcher/QRTabSwitcher.tsx +++ b/app/components/Views/QRTabSwitcher/QRTabSwitcher.tsx @@ -11,7 +11,7 @@ import ButtonIcon, { ButtonIconSizes, } from '../../../component-library/components/Buttons/ButtonIcon'; import { IconName } from '../../../component-library/components/Icons/Icon'; -import HeaderBase from '../../../component-library/components/HeaderBase'; +import { HeaderBase } from '@metamask/design-system-react-native'; import { endTrace, trace, TraceName } from '../../../util/trace'; export enum QRTabSwitcherScreens { From 7ecf493abdcde3f639112dc2a1d722141b85dd6d Mon Sep 17 00:00:00 2001 From: infiniteflower <139582705+infiniteflower@users.noreply.github.com> Date: Thu, 11 Jun 2026 05:22:19 +0900 Subject: [PATCH 07/12] chore: pass feature_id to Unified SwapBridge events (#31230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Integrates the required `feature_id` parameter from [MetaMask/core#8964](https://github.com/MetaMask/core/pull/8964) across Unified Swap/Bridge, Batch Sell, and Quick Buy quote flows. **Why:** `bridge-controller` now requires `FeatureId` on `fetchQuotes` and on client-supplied analytics context for unified swap/bridge events. Without it, quote fetches and some metrics payloads are incomplete or fail type-checking against the new API. **What changed:** - **Unified Swap/Bridge:** Pass `feature_id: UNIFIED_SWAP_BRIDGE` in `useUnifiedSwapBridgeContext` (quote polling context) and on client `trackUnifiedSwapBridgeEvent` call sites (`useTrackAllQuotesSortedEvent`, `GaslessQuickPickOptions`, `BridgeTokenSelector` asset tooltip). - **Batch Sell:** Pass `feature_id: BATCH_SELL` in `useBatchSellQuoteRequest` quote context. - **Quick Buy:** Add `getQuickBuyFeatureId` to map `QuickBuySheetSource` → `FeatureId` (`QUICK_BUY_TOKEN_DETAILS` vs `QUICK_BUY_FOLLOW_TRADING`); thread analytics `source` through `useQuickBuyController` and pass the resolved id to `BridgeController.fetchQuotes(params, featureId, signal)`. - **Tests:** Update/add unit tests for the above contexts and Quick Buy quote fetching. **Follow-up after core#8964 merges:** Remove `previewBuilds` / resolutions and bump to released package versions. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: [SWAPS-4561](https://consensyssoftware.atlassian.net/browse/SWAPS-4561) Refs: [MetaMask/core#8964](https://github.com/MetaMask/core/pull/8964) ## **Manual testing steps** ```gherkin Feature: feature_id on swap/bridge quote flows Scenario: Unified Swap/Bridge still fetches quotes Given the app is built with preview bridge-controller packages (yarn install) And I open Unified Swap/Bridge from a token or the wallet swap entry point When the swap form loads and requests quotes Then quotes load without errors and the swap flow remains usable Scenario: Batch Sell still fetches quotes Given Batch Sell is available in the build When I open Batch Sell and trigger a quote request Then quotes load without errors Scenario: Quick Buy still fetches quotes from follow-trading Given Social Leaderboard is enabled When I open a trader position and tap Buy to open Quick Buy Then the Quick Buy sheet loads quotes without error Scenario: Quick Buy still fetches quotes from token details Given socialAiAssetDetailsQuickBuy is enabled When I open Token Details and tap the Quick Buy (lightning) button Then the Quick Buy sheet loads quotes without error ``` **Automated verification run locally:** ```bash yarn lint:tsc yarn jest app/components/UI/Bridge/hooks/useUnifiedSwapBridgeContext/useUnifiedSwapBridgeContext.test.ts yarn jest app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/useBatchSellQuoteRequest.test.ts yarn jest app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.test.ts yarn jest app/components/UI/Bridge/hooks/useBridgeQuoteEvents/useBridgeQuoteEvents.test.tsx yarn jest app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyQuotes.test.ts yarn jest app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getQuickBuyFeatureId.test.ts ``` ## **Screenshots/Recordings** N/A — analytics and controller plumbing only; no user-visible UI changes. ### **Before** N/A ### **After** Screenshot 2026-06-08 at 5 29 57 PM Screenshot 2026-06-08 at 5 30 15 PM Screenshot 2026-06-08 at 5 30 26 PM Screenshot 2026-06-08 at 5 30 54 PM Screenshot 2026-06-08 at 5 31 14 PM Screenshot 2026-06-08 at 5 31 29 PM Screenshot 2026-06-08 at 5 31 36 PM ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. [SWAPS-4561]: https://consensyssoftware.atlassian.net/browse/SWAPS-4561?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- > [!NOTE] > **Medium Risk** > Touches quote fetching and analytics context across Unified Swap/Bridge, Batch Sell, and Quick Buy; behavior should be unchanged aside from attribution, but a wrong or missing id could break typing or mislabel metrics after the controller API change. > > **Overview** > Bumps **`@metamask/bridge-controller`** (and related lockfile entries) so quote and analytics APIs can require a **`FeatureId`**, then threads that id through swap/bridge, batch sell, and Quick Buy. > > **Unified Swap/Bridge** adds `feature_id: FeatureId.UNIFIED_SWAP_BRIDGE` to shared quote context (`useUnifiedSwapBridgeContext`) and to client `trackUnifiedSwapBridgeEvent` payloads (quotes sorted, gasless keypad input, token asset tooltip). **Batch Sell** adds `feature_id: FeatureId.BATCH_SELL` on batch quote request context. > > **Quick Buy** introduces `getQuickBuyFeatureId` to map sheet entry `source` to `QUICK_BUY_TOKEN_DETAILS` vs `QUICK_BUY_FOLLOW_TRADING`, passes analytics `source` from `useQuickBuyController`, and calls `BridgeController.fetchQuotes(params, featureId, signal)` instead of a string literal. Unit tests are updated across these flows, including abort-signal argument index changes for the new `fetchQuotes` signature. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit d06c3e07466684216f7714782965ebebec7ef197. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../BridgeTokenSelector.test.tsx | 4 +- .../BridgeTokenSelector.tsx | 2 + .../GaslessQuickPickOptions/index.tsx | 6 +- .../hooks/useBatchSellQuoteRequest/index.ts | 2 + .../useBatchSellQuoteRequest.test.ts | 5 + .../useBridgeQuoteEvents.test.tsx | 1 + .../index.test.ts | 2 + .../useTrackAllQuotesSortedEvent/index.ts | 2 + .../useUnifiedSwapBridgeContext/index.ts | 2 + .../useUnifiedSwapBridgeContext.test.ts | 3 + .../QuickBuy/hooks/useQuickBuyController.ts | 9 +- .../QuickBuy/hooks/useQuickBuyQuotes.ts | 7 +- .../QuickBuy/useQuickBuyQuotes.test.ts | 295 +++++++++++------- .../utils/getQuickBuyFeatureId.test.ts | 30 ++ .../QuickBuy/utils/getQuickBuyFeatureId.ts | 17 + package.json | 6 +- yarn.lock | 45 +-- 17 files changed, 279 insertions(+), 159 deletions(-) create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getQuickBuyFeatureId.test.ts create mode 100644 app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getQuickBuyFeatureId.ts diff --git a/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.test.tsx b/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.test.tsx index af9356e4a41..cac3ec6af0f 100644 --- a/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.test.tsx +++ b/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.test.tsx @@ -282,15 +282,13 @@ const mockFormatAddressToAssetId = jest.fn( ); const mockIsNonEvmChainId = jest.fn(() => false); jest.mock('@metamask/bridge-controller', () => ({ + ...jest.requireActual('@metamask/bridge-controller'), formatAddressToAssetId: (address: string, chainId: string) => mockFormatAddressToAssetId(address, chainId), formatChainIdToCaip: jest.fn( (chainId: string) => `eip155:${parseInt(chainId, 16)}`, ), isNonEvmChainId: (chainId: string) => mockIsNonEvmChainId(chainId), - UnifiedSwapBridgeEventName: { - AssetDetailTooltipClicked: 'AssetDetailTooltipClicked', - }, })); jest.mock('../../../../../core/Multichain/utils', () => ({ diff --git a/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.tsx b/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.tsx index dbbc3a93a8d..9de89432910 100644 --- a/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.tsx +++ b/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.tsx @@ -32,6 +32,7 @@ import { setTokenSelectorNetworkFilter, } from '../../../../../core/redux/slices/bridge'; import { + FeatureId, formatChainIdToCaip, UnifiedSwapBridgeEventName, } from '@metamask/bridge-controller'; @@ -361,6 +362,7 @@ export const BridgeTokenSelector: React.FC = () => { token_contract: item.address, chain_name: networkName, chain_id: item.chainId, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, }, ); }, diff --git a/app/components/UI/Bridge/components/GaslessQuickPickOptions/index.tsx b/app/components/UI/Bridge/components/GaslessQuickPickOptions/index.tsx index 90b6b631381..86bf31faa3f 100644 --- a/app/components/UI/Bridge/components/GaslessQuickPickOptions/index.tsx +++ b/app/components/UI/Bridge/components/GaslessQuickPickOptions/index.tsx @@ -1,5 +1,8 @@ import React, { useCallback, useMemo } from 'react'; -import { UnifiedSwapBridgeEventName } from '@metamask/bridge-controller'; +import { + FeatureId, + UnifiedSwapBridgeEventName, +} from '@metamask/bridge-controller'; import { QuickPickButtonOption } from '../SwapsKeypad/types'; import { QuickPickButtons } from '../SwapsKeypad/QuickPickButtons'; import { useShouldRenderMaxOption } from '../../hooks/useShouldRenderMaxOption'; @@ -45,6 +48,7 @@ export const GaslessQuickPickOptions = ({ { input: 'token_amount_source', input_value: inputValue, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, ...(preset && { input_amount_preset: preset }), // This Bridge-specific event bypasses the shared analytics wrappers, // so its A/B context still needs to be attached manually here. diff --git a/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/index.ts b/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/index.ts index 6cdb86f8e5d..b78ae91577b 100644 --- a/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/index.ts +++ b/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/index.ts @@ -3,6 +3,7 @@ import { useSelector } from 'react-redux'; import { debounce } from 'lodash'; import BigNumber from 'bignumber.js'; import { + FeatureId, formatAddressToAssetId, formatAddressToCaipReference, } from '@metamask/bridge-controller'; @@ -165,6 +166,7 @@ export function buildBatchSellQuoteRequestData({ sourceToken, sourceAmount, ), + feature_id: FeatureId.BATCH_SELL, }, }); diff --git a/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/useBatchSellQuoteRequest.test.ts b/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/useBatchSellQuoteRequest.test.ts index b32846f5fcd..09eeba5c986 100644 --- a/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/useBatchSellQuoteRequest.test.ts +++ b/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/useBatchSellQuoteRequest.test.ts @@ -1,6 +1,8 @@ import { act } from '@testing-library/react-native'; import { CaipAssetType, Hex } from '@metamask/utils'; +import { FeatureId } from '@metamask/bridge-controller'; + import Engine from '../../../../../core/Engine'; import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; import { createBridgeTestState } from '../../testUtils'; @@ -212,6 +214,7 @@ describe('useBatchSellQuoteRequest', () => { token_symbol_destination: 'USDC', token_security_type_destination: null, usd_amount_source: 1500, + feature_id: FeatureId.BATCH_SELL, }), }), ]); @@ -293,6 +296,7 @@ describe('useBatchSellQuoteRequest', () => { token_symbol_destination: 'USDC', token_security_type_destination: null, usd_amount_source: 1500, + feature_id: FeatureId.BATCH_SELL, }), ); expect( @@ -304,6 +308,7 @@ describe('useBatchSellQuoteRequest', () => { token_symbol_destination: 'USDC', token_security_type_destination: null, usd_amount_source: 250, + feature_id: FeatureId.BATCH_SELL, }), ); }); diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteEvents/useBridgeQuoteEvents.test.tsx b/app/components/UI/Bridge/hooks/useBridgeQuoteEvents/useBridgeQuoteEvents.test.tsx index fb607661b6d..f3ef369cd01 100644 --- a/app/components/UI/Bridge/hooks/useBridgeQuoteEvents/useBridgeQuoteEvents.test.tsx +++ b/app/components/UI/Bridge/hooks/useBridgeQuoteEvents/useBridgeQuoteEvents.test.tsx @@ -115,6 +115,7 @@ describe('useBridgeQuoteEvents', () => { ).toHaveBeenCalledWith('Unified SwapBridge Quotes Received', { best_quote_provider: 'lifi_jupiter', can_submit: true, + feature_id: 'unified_swap_bridge', gas_included: false, gas_included_7702: false, has_sufficient_gas_for_quote: null, diff --git a/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.test.ts b/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.test.ts index 67194901d4a..7cf328218de 100644 --- a/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.test.ts +++ b/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.test.ts @@ -2,6 +2,7 @@ import { renderHook } from '@testing-library/react-native'; import { useTrackAllQuotesSortedEvent } from './index'; import Engine from '../../../../../core/Engine'; import { + FeatureId, SortOrder, UnifiedSwapBridgeEventName, type Quote, @@ -201,6 +202,7 @@ describe('useTrackAllQuotesSortedEvent', () => { token_symbol_source: 'ETH', token_symbol_destination: 'USDC', stx_enabled: true, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, sort_order: SortOrder.COST_ASC, best_quote_provider: 'lifi', }); diff --git a/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.ts b/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.ts index ffb9106181f..9c2312b5517 100644 --- a/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.ts +++ b/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.ts @@ -1,4 +1,5 @@ import { + FeatureId, formatProviderLabel, getNativeAssetForChainId, Quote, @@ -48,6 +49,7 @@ export const useTrackAllQuotesSortedEvent = ( : ' '), token_symbol_destination: destToken?.symbol ?? null, stx_enabled: smartTransactionsEnabled, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, ...(isBridge && { sort_order: SortOrder.COST_ASC, best_quote_provider: formatProviderLabel(quote), diff --git a/app/components/UI/Bridge/hooks/useUnifiedSwapBridgeContext/index.ts b/app/components/UI/Bridge/hooks/useUnifiedSwapBridgeContext/index.ts index dfeb9452a9e..aea430c9043 100644 --- a/app/components/UI/Bridge/hooks/useUnifiedSwapBridgeContext/index.ts +++ b/app/components/UI/Bridge/hooks/useUnifiedSwapBridgeContext/index.ts @@ -1,3 +1,4 @@ +import { FeatureId } from '@metamask/bridge-controller'; import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { @@ -66,6 +67,7 @@ export const useUnifiedSwapBridgeContext = () => { security_warnings: getSecurityWarnings(toToken), warnings: [], // TODO usd_amount_source: usdAmountSource, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, }), [smartTransactionsEnabled, fromToken, toToken, usdAmountSource], ); diff --git a/app/components/UI/Bridge/hooks/useUnifiedSwapBridgeContext/useUnifiedSwapBridgeContext.test.ts b/app/components/UI/Bridge/hooks/useUnifiedSwapBridgeContext/useUnifiedSwapBridgeContext.test.ts index 23f170f3166..7f03f192722 100644 --- a/app/components/UI/Bridge/hooks/useUnifiedSwapBridgeContext/useUnifiedSwapBridgeContext.test.ts +++ b/app/components/UI/Bridge/hooks/useUnifiedSwapBridgeContext/useUnifiedSwapBridgeContext.test.ts @@ -1,4 +1,5 @@ import '../../_mocks_/initialState'; +import { FeatureId } from '@metamask/bridge-controller'; import { createBridgeTestState } from '../../testUtils'; import { useUnifiedSwapBridgeContext } from '.'; import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; @@ -95,6 +96,7 @@ describe('useUnifiedSwapBridgeContext', () => { security_warnings: [], warnings: [], usd_amount_source: 0, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, }); }); @@ -199,6 +201,7 @@ describe('useUnifiedSwapBridgeContext', () => { security_warnings: [], warnings: [], usd_amount_source: 0, + feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, }); }); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts index 37d5a12bd41..cdbcdb2147f 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts @@ -566,8 +566,13 @@ export function useQuickBuyController( return Number.isFinite(v) ? v : 0; }, [usdAmount]); const quotesAnalyticsContext = useMemo( - () => ({ traderAddress, caip19, amountUsd: quotedUsdAmountNumber }), - [traderAddress, caip19, quotedUsdAmountNumber], + () => ({ + traderAddress, + caip19, + amountUsd: quotedUsdAmountNumber, + source: analyticsContext?.source, + }), + [traderAddress, caip19, quotedUsdAmountNumber, analyticsContext?.source], ); const { diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyQuotes.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyQuotes.ts index 2ca7bd8d60c..6ed1176875e 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyQuotes.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyQuotes.ts @@ -35,9 +35,11 @@ import { buildSocialLoggerErrorOptions } from '../../../../../../../util/social/ import { SocialLeaderboardEventProperties, useSocialLeaderboardAnalytics, + type QuickBuySheetSource, } from '../../../../analytics'; import { MetaMetricsEvents } from '../../../../../../../core/Analytics'; import { getQuoteRefreshRate } from '../../../../../../UI/Bridge/utils/quoteUtils'; +import { getQuickBuyFeatureId } from '../utils/getQuickBuyFeatureId'; export type QuickBuyQuote = QuoteResponse & L1GasFees & NonEvmFees; @@ -48,6 +50,8 @@ export interface QuickBuyQuotesAnalyticsContext { caip19?: string; /** USD amount the user has selected; used as `amount_usd`. */ amountUsd?: number; + /** Entry surface for FeatureId mapping on fetchQuotes. */ + source?: QuickBuySheetSource; } export type EnrichedQuickBuyQuote = ReturnType< @@ -319,9 +323,8 @@ export function useQuickBuyQuotes({ try { const result = await Engine.context.BridgeController.fetchQuotes( params, + getQuickBuyFeatureId(analyticsContext?.source), controller.signal, - // @ts-expect-error quickBuy has not been added as a FeatureId yet - 'quickBuy', ); if (controller.signal.aborted) { diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyQuotes.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyQuotes.test.ts index 1339dccbfa2..e919402cf9c 100644 --- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyQuotes.test.ts +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyQuotes.test.ts @@ -1,4 +1,5 @@ import { act, renderHook, waitFor } from '@testing-library/react-native'; +import { FeatureId } from '@metamask/bridge-controller'; import { useSelector } from 'react-redux'; import Engine from '../../../../../../core/Engine'; import { MetaMetricsEvents } from '../../../../../../core/Analytics'; @@ -168,6 +169,18 @@ const setupSelectors = () => { ); }; +type QuickBuyQuotesParams = Parameters[0]; + +function quotesParams(params: QuickBuyQuotesParams): QuickBuyQuotesParams { + return { + ...params, + analyticsContext: { + source: 'leaderboard', + ...params.analyticsContext, + }, + }; +} + describe('useQuickBuyQuotes', () => { beforeEach(() => { jest.useFakeTimers(); @@ -185,11 +198,13 @@ describe('useQuickBuyQuotes', () => { it('returns idle state when any required input is missing', () => { const { result } = renderHook(() => - useQuickBuyQuotes({ - sourceToken: undefined, - destToken: createDestToken(), - sourceTokenAmount: '0.001', - }), + useQuickBuyQuotes( + quotesParams({ + sourceToken: undefined, + destToken: createDestToken(), + sourceTokenAmount: '0.001', + }), + ), ); expect(result.current.activeQuote).toBeUndefined(); @@ -202,11 +217,13 @@ describe('useQuickBuyQuotes', () => { fetchQuotesMock.mockResolvedValue([createFetchedQuote()]); renderHook(() => - useQuickBuyQuotes({ - sourceToken: createSourceToken(), - destToken: createDestToken(), - sourceTokenAmount: '0.001', - }), + useQuickBuyQuotes( + quotesParams({ + sourceToken: createSourceToken(), + destToken: createDestToken(), + sourceTokenAmount: '0.001', + }), + ), ); expect(fetchQuotesMock).not.toHaveBeenCalled(); @@ -223,12 +240,14 @@ describe('useQuickBuyQuotes', () => { const { rerender } = renderHook( ({ token }: { token: number }) => - useQuickBuyQuotes({ - sourceToken: createSourceToken(), - destToken: createDestToken(), - sourceTokenAmount: '0.001', - immediateFetchToken: token, - }), + useQuickBuyQuotes( + quotesParams({ + sourceToken: createSourceToken(), + destToken: createDestToken(), + sourceTokenAmount: '0.001', + immediateFetchToken: token, + }), + ), { initialProps: { token: 0 } }, ); @@ -244,12 +263,14 @@ describe('useQuickBuyQuotes', () => { const { rerender } = renderHook( ({ amount }: { amount: string }) => - useQuickBuyQuotes({ - sourceToken: createSourceToken(), - destToken: createDestToken(), - sourceTokenAmount: amount, - immediateFetchToken: 0, - }), + useQuickBuyQuotes( + quotesParams({ + sourceToken: createSourceToken(), + destToken: createDestToken(), + sourceTokenAmount: amount, + immediateFetchToken: 0, + }), + ), { initialProps: { amount: '0.001' } }, ); @@ -289,12 +310,14 @@ describe('useQuickBuyQuotes', () => { const { result, rerender } = renderHook( ({ token, amount }: { token: number; amount: string }) => - useQuickBuyQuotes({ - sourceToken: createSourceToken(), - destToken: createDestToken(), - sourceTokenAmount: amount, - immediateFetchToken: token, - }), + useQuickBuyQuotes( + quotesParams({ + sourceToken: createSourceToken(), + destToken: createDestToken(), + sourceTokenAmount: amount, + immediateFetchToken: token, + }), + ), { initialProps: { token: 0, amount: '0.001' } }, ); @@ -306,7 +329,7 @@ describe('useQuickBuyQuotes', () => { ); expect(fetchQuotesMock).toHaveBeenCalledTimes(2); - const firstRequestSignal = fetchQuotesMock.mock.calls[0][1]; + const firstRequestSignal = fetchQuotesMock.mock.calls[0][2]; expect(firstRequestSignal.aborted).toBe(true); await act(async () => { @@ -321,11 +344,13 @@ describe('useQuickBuyQuotes', () => { fetchQuotesMock.mockResolvedValue([createFetchedQuote()]); renderHook(() => - useQuickBuyQuotes({ - sourceToken: createSourceToken(), - destToken: createDestToken(), - sourceTokenAmount: '0.001', - }), + useQuickBuyQuotes( + quotesParams({ + sourceToken: createSourceToken(), + destToken: createDestToken(), + sourceTokenAmount: '0.001', + }), + ), ); act(() => { @@ -342,17 +367,47 @@ describe('useQuickBuyQuotes', () => { gasIncluded: false, gasIncluded7702: false, }); + expect(fetchQuotesMock.mock.calls[0][1]).toBe( + FeatureId.QUICK_BUY_FOLLOW_TRADING, + ); + }); + + it('passes QUICK_BUY_TOKEN_DETAILS FeatureId when source is asset_details', async () => { + fetchQuotesMock.mockResolvedValue([createFetchedQuote()]); + + renderHook(() => + useQuickBuyQuotes( + quotesParams({ + sourceToken: createSourceToken(), + destToken: createDestToken(), + sourceTokenAmount: '0.001', + analyticsContext: { source: 'asset_details' }, + }), + ), + ); + + act(() => { + jest.advanceTimersByTime(QUICK_BUY_QUOTE_DEBOUNCE_MS); + }); + + await waitFor(() => expect(fetchQuotesMock).toHaveBeenCalled()); + + expect(fetchQuotesMock.mock.calls[0][1]).toBe( + FeatureId.QUICK_BUY_TOKEN_DETAILS, + ); }); it('flags isNoQuotesAvailable when fetchQuotes returns an empty array', async () => { fetchQuotesMock.mockResolvedValue([]); const { result } = renderHook(() => - useQuickBuyQuotes({ - sourceToken: createSourceToken(), - destToken: createDestToken(), - sourceTokenAmount: '0.001', - }), + useQuickBuyQuotes( + quotesParams({ + sourceToken: createSourceToken(), + destToken: createDestToken(), + sourceTokenAmount: '0.001', + }), + ), ); act(() => { @@ -367,11 +422,13 @@ describe('useQuickBuyQuotes', () => { fetchQuotesMock.mockRejectedValue(new Error('boom')); const { result } = renderHook(() => - useQuickBuyQuotes({ - sourceToken: createSourceToken(), - destToken: createDestToken(), - sourceTokenAmount: '0.001', - }), + useQuickBuyQuotes( + quotesParams({ + sourceToken: createSourceToken(), + destToken: createDestToken(), + sourceTokenAmount: '0.001', + }), + ), ); act(() => { @@ -387,11 +444,13 @@ describe('useQuickBuyQuotes', () => { fetchQuotesMock.mockRejectedValue(fetchError); renderHook(() => - useQuickBuyQuotes({ - sourceToken: createSourceToken(), - destToken: createDestToken(), - sourceTokenAmount: '0.001', - }), + useQuickBuyQuotes( + quotesParams({ + sourceToken: createSourceToken(), + destToken: createDestToken(), + sourceTokenAmount: '0.001', + }), + ), ); act(() => { @@ -417,11 +476,13 @@ describe('useQuickBuyQuotes', () => { it('skips fetching when the atomic source amount normalizes to zero', () => { renderHook(() => - useQuickBuyQuotes({ - sourceToken: createSourceToken({ decimals: 18 }), - destToken: createDestToken(), - sourceTokenAmount: '0', - }), + useQuickBuyQuotes( + quotesParams({ + sourceToken: createSourceToken({ decimals: 18 }), + destToken: createDestToken(), + sourceTokenAmount: '0', + }), + ), ); act(() => { @@ -433,13 +494,15 @@ describe('useQuickBuyQuotes', () => { it('skips fetching when sourceToken.decimals is undefined', () => { renderHook(() => - useQuickBuyQuotes({ - sourceToken: createSourceToken({ - decimals: undefined as unknown as number, + useQuickBuyQuotes( + quotesParams({ + sourceToken: createSourceToken({ + decimals: undefined as unknown as number, + }), + destToken: createDestToken(), + sourceTokenAmount: '0.001', }), - destToken: createDestToken(), - sourceTokenAmount: '0.001', - }), + ), ); act(() => { @@ -454,16 +517,18 @@ describe('useQuickBuyQuotes', () => { fetchQuotesMock.mockResolvedValue([fetched]); renderHook(() => - useQuickBuyQuotes({ - sourceToken: createSourceToken(), - destToken: createDestToken(), - sourceTokenAmount: '0.001', - analyticsContext: { - traderAddress: '0xTRADER', - caip19: 'eip155:8453/erc20:0xDEST', - amountUsd: 50, - }, - }), + useQuickBuyQuotes( + quotesParams({ + sourceToken: createSourceToken(), + destToken: createDestToken(), + sourceTokenAmount: '0.001', + analyticsContext: { + traderAddress: '0xTRADER', + caip19: 'eip155:8453/erc20:0xDEST', + amountUsd: 50, + }, + }), + ), ); act(() => { @@ -497,15 +562,17 @@ describe('useQuickBuyQuotes', () => { fetchQuotesMock.mockRejectedValue(new Error('network error')); renderHook(() => - useQuickBuyQuotes({ - sourceToken: createSourceToken(), - destToken: createDestToken(), - sourceTokenAmount: '0.001', - analyticsContext: { - traderAddress: '0xTRADER', - caip19: 'eip155:8453/erc20:0xDEST', - }, - }), + useQuickBuyQuotes( + quotesParams({ + sourceToken: createSourceToken(), + destToken: createDestToken(), + sourceTokenAmount: '0.001', + analyticsContext: { + traderAddress: '0xTRADER', + caip19: 'eip155:8453/erc20:0xDEST', + }, + }), + ), ); act(() => { @@ -524,16 +591,18 @@ describe('useQuickBuyQuotes', () => { fetchQuotesMock.mockResolvedValue([createFetchedQuote()]); renderHook(() => - useQuickBuyQuotes({ - sourceToken: createSourceToken(), - destToken: createDestToken(), - sourceTokenAmount: '0.001', - analyticsContext: { - traderAddress: '0xTRADER', - caip19: 'eip155:8453/erc20:0xDEST', - // amountUsd intentionally absent - }, - }), + useQuickBuyQuotes( + quotesParams({ + sourceToken: createSourceToken(), + destToken: createDestToken(), + sourceTokenAmount: '0.001', + analyticsContext: { + traderAddress: '0xTRADER', + caip19: 'eip155:8453/erc20:0xDEST', + // amountUsd intentionally absent + }, + }), + ), ); act(() => { @@ -555,11 +624,13 @@ describe('useQuickBuyQuotes', () => { .mockRejectedValue(new Error('network error')); const { result } = renderHook(() => - useQuickBuyQuotes({ - sourceToken: createSourceToken(), - destToken: createDestToken(), - sourceTokenAmount: '0.001', - }), + useQuickBuyQuotes( + quotesParams({ + sourceToken: createSourceToken(), + destToken: createDestToken(), + sourceTokenAmount: '0.001', + }), + ), ); await act(async () => { @@ -601,11 +672,13 @@ describe('useQuickBuyQuotes', () => { ); const { result } = renderHook(() => - useQuickBuyQuotes({ - sourceToken: createSourceToken(), - destToken: createDestToken(), - sourceTokenAmount: '0.001', - }), + useQuickBuyQuotes( + quotesParams({ + sourceToken: createSourceToken(), + destToken: createDestToken(), + sourceTokenAmount: '0.001', + }), + ), ); act(() => { @@ -624,11 +697,13 @@ describe('useQuickBuyQuotes', () => { fetchQuotesMock.mockResolvedValue([createFetchedQuote()]); const { result, rerender } = renderHook(() => - useQuickBuyQuotes({ - sourceToken: createSourceToken(), - destToken: createDestToken(), - sourceTokenAmount: '0.001', - }), + useQuickBuyQuotes( + quotesParams({ + sourceToken: createSourceToken(), + destToken: createDestToken(), + sourceTokenAmount: '0.001', + }), + ), ); await act(async () => { @@ -653,11 +728,13 @@ describe('useQuickBuyQuotes', () => { fetchQuotesMock.mockResolvedValue([createFetchedQuote()]); const { result, rerender } = renderHook(() => - useQuickBuyQuotes({ - sourceToken: createSourceToken(), - destToken: createDestToken(), - sourceTokenAmount: '0.001', - }), + useQuickBuyQuotes( + quotesParams({ + sourceToken: createSourceToken(), + destToken: createDestToken(), + sourceTokenAmount: '0.001', + }), + ), ); await act(async () => { diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getQuickBuyFeatureId.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getQuickBuyFeatureId.test.ts new file mode 100644 index 00000000000..086e58eeb48 --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getQuickBuyFeatureId.test.ts @@ -0,0 +1,30 @@ +import { FeatureId } from '@metamask/bridge-controller'; + +import { getQuickBuyFeatureId } from './getQuickBuyFeatureId'; + +describe('getQuickBuyFeatureId', () => { + it('maps token-details surfaces to QUICK_BUY_TOKEN_DETAILS', () => { + expect(getQuickBuyFeatureId('asset_details')).toBe( + FeatureId.QUICK_BUY_TOKEN_DETAILS, + ); + expect(getQuickBuyFeatureId('market_insights')).toBe( + FeatureId.QUICK_BUY_TOKEN_DETAILS, + ); + }); + + it('maps follow-trading surfaces to QUICK_BUY_FOLLOW_TRADING', () => { + expect(getQuickBuyFeatureId('leaderboard')).toBe( + FeatureId.QUICK_BUY_FOLLOW_TRADING, + ); + expect(getQuickBuyFeatureId('profile_position')).toBe( + FeatureId.QUICK_BUY_FOLLOW_TRADING, + ); + expect(getQuickBuyFeatureId('notification')).toBe( + FeatureId.QUICK_BUY_FOLLOW_TRADING, + ); + }); + + it('defaults to UNKNOWN when source is missing', () => { + expect(getQuickBuyFeatureId()).toBe(FeatureId.UNKNOWN); + }); +}); diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getQuickBuyFeatureId.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getQuickBuyFeatureId.ts new file mode 100644 index 00000000000..685f8c1701f --- /dev/null +++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getQuickBuyFeatureId.ts @@ -0,0 +1,17 @@ +import { FeatureId } from '@metamask/bridge-controller'; + +import type { QuickBuySheetSource } from '../../../../analytics'; + +export function getQuickBuyFeatureId(source?: QuickBuySheetSource): FeatureId { + switch (source) { + case 'asset_details': + case 'market_insights': + return FeatureId.QUICK_BUY_TOKEN_DETAILS; + case 'leaderboard': + case 'profile_position': + case 'notification': + return FeatureId.QUICK_BUY_FOLLOW_TRADING; + default: + return FeatureId.UNKNOWN; + } +} diff --git a/package.json b/package.json index faa171efa5e..90931a91f71 100644 --- a/package.json +++ b/package.json @@ -244,8 +244,8 @@ "@metamask/authenticated-user-storage": "^2.0.0", "@metamask/base-controller": "^9.0.1", "@metamask/bitcoin-wallet-snap": "^1.12.0", - "@metamask/bridge-controller": "^74.0.0", - "@metamask/bridge-status-controller": "^72.0.2", + "@metamask/bridge-controller": "^75.1.0", + "@metamask/bridge-status-controller": "^72.1.0", "@metamask/chain-agnostic-permission": "^1.5.0", "@metamask/chomp-api-service": "^3.1.0", "@metamask/client-controller": "^1.0.1", @@ -344,7 +344,7 @@ "@metamask/storage-service": "^1.0.0", "@metamask/superstruct": "^3.2.1", "@metamask/swappable-obj-proxy": "^2.1.0", - "@metamask/transaction-controller": "^67.0.0", + "@metamask/transaction-controller": "^67.1.0", "@metamask/transaction-pay-controller": "^23.5.0", "@metamask/tron-wallet-snap": "^1.25.6", "@metamask/utils": "^11.11.0", diff --git a/yarn.lock b/yarn.lock index 40a4abbc4a2..da7f08b4839 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7880,7 +7880,7 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controller@npm:^8.3.1, @metamask/assets-controller@npm:^8.3.2, @metamask/assets-controller@npm:^8.3.3": +"@metamask/assets-controller@npm:^8.3.1, @metamask/assets-controller@npm:^8.3.3": version: 8.3.3 resolution: "@metamask/assets-controller@npm:8.3.3" dependencies: @@ -7917,7 +7917,7 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controllers@npm:^108.4.0, @metamask/assets-controllers@npm:^108.5.0, @metamask/assets-controllers@npm:^108.6.0": +"@metamask/assets-controllers@npm:^108.4.0, @metamask/assets-controllers@npm:^108.6.0": version: 108.6.0 resolution: "@metamask/assets-controllers@npm:108.6.0" dependencies: @@ -8082,39 +8082,6 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^74.0.0": - version: 74.0.0 - resolution: "@metamask/bridge-controller@npm:74.0.0" - dependencies: - "@ethersproject/address": "npm:^5.7.0" - "@ethersproject/bignumber": "npm:^5.7.0" - "@ethersproject/constants": "npm:^5.7.0" - "@ethersproject/contracts": "npm:^5.7.0" - "@ethersproject/providers": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^39.0.0" - "@metamask/assets-controller": "npm:^8.3.2" - "@metamask/assets-controllers": "npm:^108.5.0" - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/controller-utils": "npm:^12.1.0" - "@metamask/gas-fee-controller": "npm:^26.2.2" - "@metamask/keyring-api": "npm:^23.1.0" - "@metamask/messenger": "npm:^1.2.0" - "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-network-controller": "npm:^3.1.3" - "@metamask/network-controller": "npm:^32.0.0" - "@metamask/polling-controller": "npm:^16.0.6" - "@metamask/profile-sync-controller": "npm:^28.1.1" - "@metamask/remote-feature-flag-controller": "npm:^4.2.2" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/transaction-controller": "npm:^67.0.0" - "@metamask/utils": "npm:^11.9.0" - bignumber.js: "npm:^9.1.2" - reselect: "npm:^5.1.1" - uuid: "npm:^8.3.2" - checksum: 10/00f9f88567a0f43b1694bfd2becaef6036fc7fbdd0ad11590e0727d9e0163db241f354fd3dd9f22d731111be32b67a4e3c6e8f6d45b14dcaf1522110fc66f481 - languageName: node - linkType: hard - "@metamask/bridge-controller@npm:^75.0.0, @metamask/bridge-controller@npm:^75.1.0": version: 75.1.0 resolution: "@metamask/bridge-controller@npm:75.1.0" @@ -8148,7 +8115,7 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-status-controller@npm:^72.0.2, @metamask/bridge-status-controller@npm:^72.1.0": +"@metamask/bridge-status-controller@npm:^72.1.0": version: 72.1.0 resolution: "@metamask/bridge-status-controller@npm:72.1.0" dependencies: @@ -35243,8 +35210,8 @@ __metadata: "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/base-controller": "npm:^9.0.1" "@metamask/bitcoin-wallet-snap": "npm:^1.12.0" - "@metamask/bridge-controller": "npm:^74.0.0" - "@metamask/bridge-status-controller": "npm:^72.0.2" + "@metamask/bridge-controller": "npm:^75.1.0" + "@metamask/bridge-status-controller": "npm:^72.1.0" "@metamask/browser-passworder": "npm:^5.0.0" "@metamask/browser-playground": "npm:0.3.0" "@metamask/build-utils": "npm:^3.0.0" @@ -35356,7 +35323,7 @@ __metadata: "@metamask/test-dapp": "npm:9.5.0" "@metamask/test-dapp-multichain": "npm:^0.17.1" "@metamask/test-dapp-solana": "npm:^0.3.0" - "@metamask/transaction-controller": "npm:^67.0.0" + "@metamask/transaction-controller": "npm:^67.1.0" "@metamask/transaction-pay-controller": "npm:^23.5.0" "@metamask/tron-wallet-snap": "npm:^1.25.6" "@metamask/utils": "npm:^11.11.0" From 207aea5582abd10b049b91dd92241b68821ecac8 Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Wed, 10 Jun 2026 17:48:52 -0300 Subject: [PATCH 08/12] feat(card): add money account card linkage analytics (#30850) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Product analytics need visibility into how users enter and complete the Money Account ↔ Card linkage flow. Today, linkage can start from several surfaces (Money Home action row, onboarding card, MetaMask card section, Card Home, Spending Limit promo, link sheet) but those paths were not consistently attributed in MetaMetrics. This PR instruments the full linkage funnel: - **View and click events** (`CARD_VIEWED`, `CARD_BUTTON_CLICKED`) on Money Home, Card Home, onboarding card, MetaMask card, spend-and-earn promo, and link confirmation sheet — each tagged with `screen`, `entrypoint`, `flow`, and contextual properties (`card_state`, `card_type`, `mode`, `action`). - **Lifecycle events** (`CARD_MONEY_ACCOUNT_LINKING_STARTED`, `COMPLETED`, `FAILED`) in `useMoneyAccountCardLinkage`, including failure reasons and revoke detection. - **Entry point propagation** through navigation params and a typed Redux pending state (`pendingMoneyAccountCardLink: CardEntryPoint | null`) so the origin entry point survives the post-authentication resume path. The link sheet reports both `entrypoint` (sheet) and `origin_entrypoint` (where the user started). New enums in `app/components/UI/Card/util/metrics.ts`: `CardEntryPoint`, `CardFlow`, `CardLinkingFailureReason`, plus additional `CardScreens` and `CardActions` values. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: null ## **Manual testing steps** ```gherkin Feature: Money Account Card linkage analytics Background: Given I am logged into MetaMask Mobile And I have a Money Account with a positive balance And the Card feature is enabled for my region Scenario: user starts linkage from Money Home MetaMask card Given I am on the Money Home screen And I see the MetaMask Card section in link mode When user taps "Link card" Then the link confirmation sheet should open And confirming should start the linkage flow Scenario: user starts linkage from Money onboarding card step Given I am on the Money Home screen And the onboarding card is visible on step 2 (Get a card) When user taps the primary CTA on the onboarding card Then the app should route into the card linkage flow And the flow should resume after authentication if required Scenario: user starts linkage from Card Home money account card Given I am a cardholder on Card Home And the Money Account card section is shown in link mode When user taps "Link card" on the money account card Then the link confirmation sheet should open Scenario: user starts linkage from Spending Limit spend-and-earn promo Given I am on the Card Spending Limit screen And the spend-and-earn promo is visible When user taps "Use Money account" Then the Money Account should be selected as the spending source Scenario: user confirms linkage in the link card sheet Given the link confirmation sheet is open And the sheet was opened from a tracked entry point When user taps the confirm CTA Then the sheet should dismiss And the linkage should proceed in the background with success or error toast feedback Scenario: user cancels linkage in the link card sheet Given the link confirmation sheet is open When user taps the close button Then the sheet should dismiss And no on-chain linkage should be initiated ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Touches post-auth linkage resume Redux state and delegation analytics hooks; on-chain behavior is unchanged, but mis-attributed entrypoints could skew funnel data. > > **Overview** > Adds **MetaMetrics attribution** for the Money Account ↔ Card linkage funnel across Money Home, Card Home, onboarding, spend-and-earn promo, and the link confirmation sheet. > > **View/click events** (`CARD_VIEWED`, `CARD_BUTTON_CLICKED`) are wired with shared `screen`, `entrypoint`, `flow`, and context (`card_state`, `mode`, `card_type`, `action`). Impressions are **gated** until card home data is settled (`analyticsReady` / `selectCardHomeDataStatus`). > > **Linkage lifecycle** events (`CARD_MONEY_ACCOUNT_LINKING_STARTED`, `COMPLETED`, `FAILED`) run in `useMoneyAccountCardLinkage`, including failure reasons, revoke detection, and balancing failed events when linkage aborts mid-flight. > > **Entry point propagation** replaces the boolean `pendingMoneyAccountCardLink` with `CardEntryPoint | null`, passes `entrypoint` through `startLinkFlow`, link sheet route params, and `confirmLinkInBackground`; the sheet reports `origin_entrypoint` vs sheet `entrypoint`. Pending resume state is **excluded from redux-persist**. > > New taxonomy lives in `metrics.ts` (`CardEntryPoint`, `CardFlow`, `CardLinkingFailureReason`, `deriveCardState`). > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f059ed1db5e98a82714f7fc5fcb377aaf8dea6d7. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../UI/Card/Views/CardHome/CardHome.test.tsx | 3 + .../UI/Card/Views/CardHome/CardHome.tsx | 18 +- .../SpendingLimit/SpendingLimit.test.tsx | 14 ++ .../Views/SpendingLimit/SpendingLimit.tsx | 6 + .../components/SpendAndEarnPromoCard.test.tsx | 66 ++++++ .../components/SpendAndEarnPromoCard.tsx | 53 ++++- .../hooks/useMoneyAccountCardLinkage.test.tsx | 170 +++++++++++-- .../Card/hooks/useMoneyAccountCardLinkage.tsx | 154 +++++++++--- app/components/UI/Card/util/metrics.ts | 62 ++++- .../MoneyHomeView/MoneyHomeView.test.tsx | 92 ++++++- .../Views/MoneyHomeView/MoneyHomeView.tsx | 94 ++++++-- .../MoneyLinkCardSheet.test.tsx | 127 +++++++++- .../MoneyLinkCardSheet/MoneyLinkCardSheet.tsx | 90 ++++++- .../MoneyMetaMaskCard.test.tsx | 224 ++++++++++++++++++ .../MoneyMetaMaskCard/MoneyMetaMaskCard.tsx | 119 +++++++++- .../MoneyOnboardingCard.test.tsx | 139 +++++++++++ .../MoneyOnboardingCard.tsx | 106 ++++++++- app/core/Analytics/MetaMetrics.events.ts | 12 + app/core/redux/slices/card/index.test.ts | 45 ++-- app/core/redux/slices/card/index.ts | 10 +- app/store/persistConfig/index.ts | 25 +- app/store/persistConfig/persistConfig.test.ts | 10 +- 22 files changed, 1516 insertions(+), 123 deletions(-) diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx index 192f4222342..3ae85264f6b 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx @@ -509,6 +509,7 @@ import Engine from '../../../../../core/Engine'; import { CardHomeSelectors } from './CardHome.testIds'; import { CARD_SUPPORT_EMAIL } from '../../constants'; import { isSolanaChainId } from '@metamask/bridge-controller'; +import { CardEntryPoint } from '../../util/metrics'; // Get references to the mocked functions const mockSetActiveNetwork = Engine.context.NetworkController @@ -6368,6 +6369,7 @@ describe('CardHome Component', () => { expect(mockStartMoneyAccountLinkFlow).toHaveBeenCalledWith({ screen: Routes.CARD.HOME, + entrypoint: CardEntryPoint.CARD_HOME_MONEY_ACCOUNT_CARD, }); }); @@ -6383,6 +6385,7 @@ describe('CardHome Component', () => { expect(mockStartMoneyAccountLinkFlow).toHaveBeenCalledWith({ screen: Routes.CARD.HOME, + entrypoint: CardEntryPoint.CARD_HOME_MONEY_ACCOUNT_CARD, }); }); diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx index 5eef8dbd161..302162808f5 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx @@ -38,6 +38,7 @@ import { strings } from '../../../../../../locales/i18n'; import { selectIsCardAuthenticated, selectCardUserLocation, + selectCardHomeDataStatus, } from '../../../../../selectors/cardController'; import { CardStatus, @@ -72,6 +73,7 @@ import CardHomeFooter from './components/CardHomeFooter'; import { useCardHomeActions } from './hooks/useCardHomeActions'; import { useCardHomeAnalytics } from './hooks/useCardHomeAnalytics'; import { useCardProvisioning } from './hooks/useCardProvisioning'; +import { CardEntryPoint, CardFlow, CardScreens } from '../../util/metrics'; interface CardHomeRouteParams { showDeeplinkToast?: boolean; @@ -124,8 +126,15 @@ const CardHome = () => { } = useMoneyAccountCardLinkage(); const { apyPercent: moneyAccountApyPercent } = useMoneyAccountBalance(); const hasMetalCard = data?.card?.type === CardType.METAL; + const cardHomeDataStatus = useSelector(selectCardHomeDataStatus); + const isCardAnalyticsReady = + cardHomeDataStatus === 'success' || cardHomeDataStatus === 'error'; const handleLinkMoneyAccountCard = useCallback( - () => startMoneyAccountLink({ screen: Routes.CARD.HOME }), + () => + startMoneyAccountLink({ + screen: Routes.CARD.HOME, + entrypoint: CardEntryPoint.CARD_HOME_MONEY_ACCOUNT_CARD, + }), [startMoneyAccountLink], ); @@ -427,6 +436,13 @@ const CardHome = () => { onGetNowPress={handleLinkMoneyAccountCard} onHeaderPress={handleLinkMoneyAccountCard} onLinkPress={handleLinkMoneyAccountCard} + analyticsScreen={CardScreens.HOME} + analyticsEntryPoint={ + CardEntryPoint.CARD_HOME_MONEY_ACCOUNT_CARD + } + analyticsFlow={CardFlow.MONEY_ACCOUNT_LINKAGE} + analyticsCardState="unlinked_card" + analyticsReady={isCardAnalyticsReady} /> ({ name: 'built-event' })); +const mockAddProperties = jest.fn(() => ({ build: mockBuild })); +const mockCreateEventBuilder = jest.fn((_eventName?: unknown) => ({ + addProperties: mockAddProperties, + build: mockBuild, +})); jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -30,6 +37,13 @@ jest.mock('@react-navigation/native', () => ({ jest.mock('react-native-linear-gradient', () => 'LinearGradient'); +jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + // Mock useCardHomeData hook (SpendingLimit now reads from it) jest.mock('../../hooks/useCardHomeData', () => ({ useCardHomeData: jest.fn(() => ({ diff --git a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx index b7057417a89..46a9b8dec4e 100644 --- a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx +++ b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx @@ -34,6 +34,7 @@ import useSpendingLimitData from '../../hooks/useSpendingLimitData'; import { buildTokenIconUrl } from '../../util/buildTokenIconUrl'; import { mapCaipChainIdToChainName } from '../../util/mapCaipChainIdToChainName'; import { LINEA_CAIP_CHAIN_ID } from '../../util/buildTokenList'; +import { CardEntryPoint, CardFlow, CardScreens } from '../../util/metrics'; import AccountRow from './components/AccountRow'; import TokenRow from './components/TokenRow'; import SpendAndEarnPromoCard from './components/SpendAndEarnPromoCard'; @@ -327,6 +328,11 @@ const SpendingLimit: React.FC = ({ route }) => { )} diff --git a/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.test.tsx b/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.test.tsx index b7fd738fbe0..2aa872226e0 100644 --- a/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.test.tsx +++ b/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.test.tsx @@ -1,9 +1,31 @@ import React from 'react'; import { fireEvent, render, screen } from '@testing-library/react-native'; import SpendAndEarnPromoCard from './SpendAndEarnPromoCard'; +import { MetaMetricsEvents } from '../../../../../../core/Analytics'; +import { + CardActions, + CardEntryPoint, + CardFlow, + CardScreens, +} from '../../../util/metrics'; + +const mockTrackEvent = jest.fn(); +const mockBuild = jest.fn(() => ({ name: 'built-event' })); +const mockAddProperties = jest.fn(() => ({ build: mockBuild })); +const mockCreateEventBuilder = jest.fn((_eventName?: unknown) => ({ + addProperties: mockAddProperties, + build: mockBuild, +})); jest.mock('react-native-linear-gradient', () => 'LinearGradient'); +jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + jest.mock('../../../../../../../locales/i18n', () => ({ strings: (key: string, params?: Record) => { if (key === 'card.card_spending_limit.spend_and_earn_description_apy') { @@ -30,6 +52,11 @@ describe('SpendAndEarnPromoCard', () => { cashbackPercent: 1, onPress: jest.fn(), }; + const analytics = { + screen: CardScreens.SPENDING_LIMIT, + entrypoint: CardEntryPoint.SPENDING_LIMIT_SPEND_AND_EARN_PROMO, + flow: CardFlow.MONEY_ACCOUNT_LINKAGE, + }; beforeEach(() => { jest.clearAllMocks(); @@ -81,4 +108,43 @@ describe('SpendAndEarnPromoCard', () => { expect(screen.getByTestId('custom-promo')).toBeOnTheScreen(); }); + + it('tracks Card Viewed when analytics props are provided', () => { + render(); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CARD_VIEWED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + screen: CardScreens.SPENDING_LIMIT, + entrypoint: CardEntryPoint.SPENDING_LIMIT_SPEND_AND_EARN_PROMO, + flow: CardFlow.MONEY_ACCOUNT_LINKAGE, + }); + }); + + it('tracks the Use Money account CTA click before invoking onPress', () => { + const onPress = jest.fn(); + + render( + , + ); + jest.clearAllMocks(); + + fireEvent.press(screen.getByTestId('use-money-account-cta')); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CARD_BUTTON_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + screen: CardScreens.SPENDING_LIMIT, + entrypoint: CardEntryPoint.SPENDING_LIMIT_SPEND_AND_EARN_PROMO, + flow: CardFlow.MONEY_ACCOUNT_LINKAGE, + action: CardActions.SPENDING_LIMIT_USE_MONEY_ACCOUNT_BUTTON, + }); + expect(onPress).toHaveBeenCalledTimes(1); + }); }); diff --git a/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.tsx b/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.tsx index c3a19ee49d0..81530849356 100644 --- a/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.tsx +++ b/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { TouchableOpacity } from 'react-native'; import { Box, @@ -13,12 +13,24 @@ import { import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { strings } from '../../../../../../../locales/i18n'; import ShimmerOverlay from './ShimmerOverlay'; +import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../../../core/Analytics'; +import { + CardActions, + CardEntryPoint, + CardScreens, +} from '../../../util/metrics'; export interface SpendAndEarnPromoCardProps { apyPercent?: number; onPress: () => void; testID?: string; accessibilityLabel?: string; + analytics?: { + screen: CardScreens | string; + entrypoint: CardEntryPoint; + flow?: string; + }; } // Pronounced dark sweep that reads against the white Primary button surface. @@ -45,16 +57,51 @@ const SpendAndEarnPromoCard: React.FC = ({ onPress, testID = 'use-money-account-cta', accessibilityLabel, + analytics, }) => { const tw = useTailwind(); + const { trackEvent, createEventBuilder } = useAnalytics(); + const hasTrackedViewRef = useRef(false); const resolvedAccessibilityLabel = accessibilityLabel ?? strings('card.card_spending_limit.use_money_account_cta'); + useEffect(() => { + if (hasTrackedViewRef.current || !analytics) return; + hasTrackedViewRef.current = true; + + trackEvent( + createEventBuilder(MetaMetricsEvents.CARD_VIEWED) + .addProperties({ + screen: analytics.screen, + entrypoint: analytics.entrypoint, + flow: analytics.flow, + }) + .build(), + ); + }, [analytics, trackEvent, createEventBuilder]); + + const handlePress = useCallback(() => { + if (analytics) { + trackEvent( + createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED) + .addProperties({ + screen: analytics.screen, + entrypoint: analytics.entrypoint, + flow: analytics.flow, + action: CardActions.SPENDING_LIMIT_USE_MONEY_ACCOUNT_BUTTON, + }) + .build(), + ); + } + + onPress(); + }, [analytics, trackEvent, createEventBuilder, onPress]); + return ( = ({ diff --git a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx index c287f23b59f..07461bbdbad 100644 --- a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx +++ b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx @@ -24,6 +24,12 @@ import Routes from '../../../../constants/navigation/Routes'; import { BAANX_MAX_LIMIT } from '../constants'; import { FundingStatus } from '../types'; import { useMoneyAccountCardLinkage } from './useMoneyAccountCardLinkage'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { + CardEntryPoint, + CardFlow, + CardLinkingFailureReason, +} from '../util/metrics'; const mockDispatch = jest.fn(); jest.mock('react-redux', () => ({ @@ -114,6 +120,20 @@ jest.mock('../../../../util/theme', () => { const mockUseSelector = useSelector as unknown as jest.Mock; const mockResolveMoneyAccountCardToken = resolveMoneyAccountCardToken as jest.Mock; +const mockTrackEvent = jest.fn(); +const mockBuild = jest.fn(() => ({ name: 'built-event' })); +const mockAddProperties = jest.fn(() => ({ build: mockBuild })); +const mockCreateEventBuilder = jest.fn((_eventName?: unknown) => ({ + addProperties: mockAddProperties, + build: mockBuild, +})); + +jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); const MONEY_ACCOUNT_ADDRESS = '0x1234567890123456789012345678901234567890'; @@ -139,7 +159,7 @@ const buildSelectors = ( isCardholder?: boolean; delegationSettings?: unknown; isAlreadyDelegated?: boolean; - pendingMoneyAccountCardLink?: boolean; + pendingMoneyAccountCardLink?: CardEntryPoint | null; cardHomeDataStatus?: CardHomeDataStatusMock; isMonadSponsorshipEnabled?: boolean; moneyAccountCardLinkInProgress?: boolean; @@ -152,7 +172,7 @@ const buildSelectors = ( isCardholder: false, delegationSettings: { ok: true }, isAlreadyDelegated: false, - pendingMoneyAccountCardLink: false, + pendingMoneyAccountCardLink: null, cardHomeDataStatus: 'success' as CardHomeDataStatusMock, isMonadSponsorshipEnabled: true, moneyAccountCardLinkInProgress: false, @@ -187,6 +207,12 @@ const applySelectorMocks = (state: ReturnType) => { describe('useMoneyAccountCardLinkage', () => { let mockShowToast: jest.Mock; let mockToastRef: { current: { showToast: jest.Mock } }; + const expectedLinkCardSheetRoute = ( + entrypoint: CardEntryPoint | string = CardEntryPoint.MONEY_LINK_CARD_SHEET, + ) => ({ + screen: Routes.MONEY.MODALS.LINK_CARD_SHEET, + params: { entrypoint }, + }); const renderLinkageHook = () => renderHook(() => useMoneyAccountCardLinkage(), { @@ -327,7 +353,7 @@ describe('useMoneyAccountCardLinkage', () => { expect(mockNavigate).toHaveBeenCalledTimes(1); expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { - screen: Routes.MONEY.MODALS.LINK_CARD_SHEET, + ...expectedLinkCardSheetRoute(), }); expect(mockLinkMoneyAccountCard).not.toHaveBeenCalled(); expect(mockShowToast).not.toHaveBeenCalled(); @@ -403,7 +429,7 @@ describe('useMoneyAccountCardLinkage', () => { expect(mockNavigate).toHaveBeenCalledTimes(1); expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { - screen: Routes.MONEY.MODALS.LINK_CARD_SHEET, + ...expectedLinkCardSheetRoute(), }); expect(mockDispatch).not.toHaveBeenCalled(); expect(mockShowToast).not.toHaveBeenCalled(); @@ -460,7 +486,7 @@ describe('useMoneyAccountCardLinkage', () => { expect(mockDispatch).toHaveBeenCalledTimes(1); expect(mockDispatch).toHaveBeenCalledWith( - setPendingMoneyAccountCardLink(true), + setPendingMoneyAccountCardLink(CardEntryPoint.MONEY_LINK_CARD_SHEET), ); expect(mockNavigate).toHaveBeenCalledTimes(1); expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, { @@ -473,6 +499,26 @@ describe('useMoneyAccountCardLinkage', () => { expect(mockShowToast).not.toHaveBeenCalled(); }); + it('stores the origin entrypoint for the post-auth sheet resume', () => { + applySelectorMocks( + buildSelectors({ isCardAuthenticated: false, isCardholder: true }), + ); + const { result } = renderLinkageHook(); + + act(() => { + result.current.startLinkFlow({ + ...ORIGIN, + entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, + }); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + setPendingMoneyAccountCardLink( + CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, + ), + ); + }); + it('routes a not-authenticated non-cardholder to Card Onboarding without arming the sheet-resume flag', () => { applySelectorMocks( buildSelectors({ isCardAuthenticated: false, isCardholder: false }), @@ -507,7 +553,7 @@ describe('useMoneyAccountCardLinkage', () => { }); expect(mockDispatch).toHaveBeenCalledWith( - setPendingMoneyAccountCardLink(true), + setPendingMoneyAccountCardLink(CardEntryPoint.MONEY_LINK_CARD_SHEET), ); expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, { screen: Routes.CARD.HOME, @@ -544,28 +590,50 @@ describe('useMoneyAccountCardLinkage', () => { describe('resume effect (pendingMoneyAccountCardLink)', () => { it('opens the Link Card sheet and clears the flag when authenticated and canLink', () => { - applySelectorMocks(buildSelectors({ pendingMoneyAccountCardLink: true })); + applySelectorMocks( + buildSelectors({ + pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET, + }), + ); renderLinkageHook(); expect(mockDispatch).toHaveBeenCalledWith( - setPendingMoneyAccountCardLink(false), + setPendingMoneyAccountCardLink(null), ); expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { - screen: Routes.MONEY.MODALS.LINK_CARD_SHEET, + ...expectedLinkCardSheetRoute(), }); }); + it('preserves the pending entrypoint when opening the sheet after auth', () => { + applySelectorMocks( + buildSelectors({ + pendingMoneyAccountCardLink: + CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, + }), + ); + renderLinkageHook(); + + expect(mockDispatch).toHaveBeenCalledWith( + setPendingMoneyAccountCardLink(null), + ); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MONEY.MODALS.ROOT, + expectedLinkCardSheetRoute(CardEntryPoint.MONEY_HOME_ONBOARDING_CARD), + ); + }); + it('clears the flag silently when authenticated but already delegated', () => { applySelectorMocks( buildSelectors({ - pendingMoneyAccountCardLink: true, + pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET, isAlreadyDelegated: true, }), ); renderLinkageHook(); expect(mockDispatch).toHaveBeenCalledWith( - setPendingMoneyAccountCardLink(false), + setPendingMoneyAccountCardLink(null), ); expect(mockNavigate).not.toHaveBeenCalled(); expect(mockShowToast).not.toHaveBeenCalled(); @@ -574,14 +642,14 @@ describe('useMoneyAccountCardLinkage', () => { it('clears the flag silently when authenticated but requirements are missing', () => { applySelectorMocks( buildSelectors({ - pendingMoneyAccountCardLink: true, + pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET, vaultConfig: undefined, }), ); renderLinkageHook(); expect(mockDispatch).toHaveBeenCalledWith( - setPendingMoneyAccountCardLink(false), + setPendingMoneyAccountCardLink(null), ); expect(mockNavigate).not.toHaveBeenCalled(); expect(mockShowToast).not.toHaveBeenCalled(); @@ -590,7 +658,7 @@ describe('useMoneyAccountCardLinkage', () => { it('does nothing while the user is not yet authenticated', () => { applySelectorMocks( buildSelectors({ - pendingMoneyAccountCardLink: true, + pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET, isCardAuthenticated: false, }), ); @@ -604,7 +672,7 @@ describe('useMoneyAccountCardLinkage', () => { mockResolveMoneyAccountCardToken.mockReturnValue(null); applySelectorMocks( buildSelectors({ - pendingMoneyAccountCardLink: true, + pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET, cardHomeDataStatus: 'loading', }), ); @@ -619,7 +687,7 @@ describe('useMoneyAccountCardLinkage', () => { mockResolveMoneyAccountCardToken.mockReturnValue(null); applySelectorMocks( buildSelectors({ - pendingMoneyAccountCardLink: true, + pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET, cardHomeDataStatus: 'idle', }), ); @@ -634,14 +702,14 @@ describe('useMoneyAccountCardLinkage', () => { mockResolveMoneyAccountCardToken.mockReturnValue(null); applySelectorMocks( buildSelectors({ - pendingMoneyAccountCardLink: true, + pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET, cardHomeDataStatus: 'success', }), ); renderLinkageHook(); expect(mockDispatch).toHaveBeenCalledWith( - setPendingMoneyAccountCardLink(false), + setPendingMoneyAccountCardLink(null), ); expect(mockNavigate).not.toHaveBeenCalled(); expect(mockShowToast).not.toHaveBeenCalled(); @@ -651,14 +719,14 @@ describe('useMoneyAccountCardLinkage', () => { mockResolveMoneyAccountCardToken.mockReturnValue(null); applySelectorMocks( buildSelectors({ - pendingMoneyAccountCardLink: true, + pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET, cardHomeDataStatus: 'error', }), ); renderLinkageHook(); expect(mockDispatch).toHaveBeenCalledWith( - setPendingMoneyAccountCardLink(false), + setPendingMoneyAccountCardLink(null), ); expect(mockNavigate).not.toHaveBeenCalled(); expect(mockShowToast).not.toHaveBeenCalled(); @@ -668,7 +736,7 @@ describe('useMoneyAccountCardLinkage', () => { mockResolveMoneyAccountCardToken.mockReturnValue(null); applySelectorMocks( buildSelectors({ - pendingMoneyAccountCardLink: true, + pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET, cardHomeDataStatus: 'loading', }), ); @@ -681,17 +749,17 @@ describe('useMoneyAccountCardLinkage', () => { mockResolveMoneyAccountCardToken.mockReturnValue(MOCK_TOKEN); applySelectorMocks( buildSelectors({ - pendingMoneyAccountCardLink: true, + pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET, cardHomeDataStatus: 'success', }), ); rerender(); expect(mockDispatch).toHaveBeenCalledWith( - setPendingMoneyAccountCardLink(false), + setPendingMoneyAccountCardLink(null), ); expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, { - screen: Routes.MONEY.MODALS.LINK_CARD_SHEET, + ...expectedLinkCardSheetRoute(), }); }); }); @@ -715,6 +783,17 @@ describe('useMoneyAccountCardLinkage', () => { moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, delegationAmountHuman: BAANX_MAX_LIMIT, }); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_STARTED, + ); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_COMPLETED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + flow: CardFlow.MONEY_ACCOUNT_LINKAGE, + entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET, + is_revoke: false, + }); }); it('does NOT navigate to the sheet (sheet is the caller, not the callee)', async () => { @@ -810,6 +889,16 @@ describe('useMoneyAccountCardLinkage', () => { expect(result.current.status).toBe('error'); expect(result.current.error?.message).toBe('boom'); expect(Logger.error).toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + flow: CardFlow.MONEY_ACCOUNT_LINKAGE, + entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET, + reason: CardLinkingFailureReason.CONTROLLER_FAILED, + error_name: 'Error', + is_revoke: false, + }); const errorCall = mockShowToast.mock.calls.at(-1)?.[0]; expect(errorCall).toMatchObject({ @@ -833,6 +922,15 @@ describe('useMoneyAccountCardLinkage', () => { expect(returned).toBe(false); expect(result.current.status).toBe('cancelled'); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + flow: CardFlow.MONEY_ACCOUNT_LINKAGE, + entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET, + reason: CardLinkingFailureReason.USER_CANCELLED, + is_revoke: false, + }); // Only the pending toast should have fired — no error toast. const lastCall = mockShowToast.mock.calls.at(-1)?.[0]; @@ -851,6 +949,18 @@ describe('useMoneyAccountCardLinkage', () => { expect(returned).toBe(false); expect(mockLinkMoneyAccountCard).not.toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED, + ); + expect(mockCreateEventBuilder).not.toHaveBeenCalledWith( + MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_STARTED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + flow: CardFlow.MONEY_ACCOUNT_LINKAGE, + entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET, + reason: CardLinkingFailureReason.PRECONDITION_FAILED, + is_revoke: false, + }); expect(mockShowToast).toHaveBeenCalledTimes(1); expect(mockShowToast.mock.calls[0][0]).toMatchObject({ labelOptions: [{ label: 'Something went wrong linking your card' }], @@ -973,6 +1083,7 @@ describe('useMoneyAccountCardLinkage', () => { expect(returned).toBe(false); expect(mockShowToast).not.toHaveBeenCalled(); expect(mockLinkMoneyAccountCard).not.toHaveBeenCalled(); + expect(mockCreateEventBuilder).not.toHaveBeenCalled(); expect(result.current.status).toBe('idle'); expect(result.current.error).toBeNull(); expect(Logger.error).not.toHaveBeenCalled(); @@ -1010,6 +1121,17 @@ describe('useMoneyAccountCardLinkage', () => { expect(result.current.status).toBe('idle'); expect(result.current.isLinking).toBe(false); expect(result.current.error).toBeNull(); + // Must emit a FAILED event to balance the STARTED already emitted, + // otherwise MetaMetrics is left with an orphan started event. + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED, + ); + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + reason: CardLinkingFailureReason.CONTROLLER_FAILED, + error_name: 'CardLinkageInProgressError', + }), + ); }); }); }); diff --git a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx index 388f1bc5731..0c23a7a9aca 100644 --- a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx +++ b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx @@ -49,6 +49,16 @@ import { CardLinkageInProgressError } from '../../../../core/Engine/controllers/ import { BAANX_MAX_LIMIT } from '../constants'; import { CardFundingToken } from '../types'; import { UserCancelledError } from './useCardDelegation'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; +import { + IMetaMetricsEvent, + MetaMetricsEvents, +} from '../../../../core/Analytics'; +import { + CardEntryPoint, + CardFlow, + CardLinkingFailureReason, +} from '../util/metrics'; export type LinkageStatus = | 'idle' @@ -60,6 +70,7 @@ export type LinkageStatus = export interface LinkFlowOrigin { screen: string; params?: object; + entrypoint?: CardEntryPoint; } export interface UseMoneyAccountCardLinkageReturn { @@ -75,9 +86,10 @@ export interface UseMoneyAccountCardLinkageReturn { error: Error | null; startLinkFlow: (origin: LinkFlowOrigin) => void; - openLinkCardSheet: () => void; + openLinkCardSheet: (entrypoint?: CardEntryPoint | string) => void; confirmLinkInBackground: (options?: { delegationAmountHuman?: string; + entrypoint?: CardEntryPoint | string; }) => Promise; reset: () => void; } @@ -96,6 +108,7 @@ export const useMoneyAccountCardLinkage = const theme = useTheme(); const navigation = useNavigation(); const dispatch = useDispatch(); + const { trackEvent, createEventBuilder } = useAnalytics(); const primaryMoneyAccount = useSelector(selectPrimaryMoneyAccount); const vaultConfig = useSelector(selectMoneyAccountVaultConfig); @@ -109,7 +122,7 @@ export const useMoneyAccountCardLinkage = const isAlreadyDelegated = useSelector( selectIsMoneyAccountDelegatedForCard, ); - const pendingMoneyAccountCardLink = useSelector( + const pendingMoneyAccountCardLinkEntryPoint = useSelector( selectPendingMoneyAccountCardLink, ); const linkInProgress = useSelector(selectIsMoneyAccountCardLinkInProgress); @@ -227,24 +240,47 @@ export const useMoneyAccountCardLinkage = [theme.colors.error.default, toastRef], ); - const openLinkCardSheet = useCallback((): void => { - if (linkInProgress) { - return; - } - if (!canLink || !primaryMoneyAccount?.address) { - showErrorToast(); - return; - } - navigation.navigate(Routes.MONEY.MODALS.ROOT, { - screen: Routes.MONEY.MODALS.LINK_CARD_SHEET, - }); - }, [ - linkInProgress, - canLink, - primaryMoneyAccount?.address, - navigation, - showErrorToast, - ]); + const trackMoneyAccountLinkingEvent = useCallback( + ( + eventName: IMetaMetricsEvent, + properties: Record = {}, + ) => { + trackEvent( + createEventBuilder(eventName) + .addProperties({ + flow: CardFlow.MONEY_ACCOUNT_LINKAGE, + ...properties, + }) + .build(), + ); + }, + [trackEvent, createEventBuilder], + ); + + const openLinkCardSheet = useCallback( + (entrypoint?: CardEntryPoint | string): void => { + if (linkInProgress) { + return; + } + if (!canLink || !primaryMoneyAccount?.address) { + showErrorToast(); + return; + } + navigation.navigate(Routes.MONEY.MODALS.ROOT, { + screen: Routes.MONEY.MODALS.LINK_CARD_SHEET, + params: { + entrypoint: entrypoint ?? CardEntryPoint.MONEY_LINK_CARD_SHEET, + }, + }); + }, + [ + linkInProgress, + canLink, + primaryMoneyAccount?.address, + navigation, + showErrorToast, + ], + ); const startLinkFlow = useCallback( (origin: LinkFlowOrigin): void => { @@ -266,12 +302,16 @@ export const useMoneyAccountCardLinkage = return; } - openLinkCardSheet(); + openLinkCardSheet(origin.entrypoint); return; } if (isCardholder) { - dispatch(setPendingMoneyAccountCardLink(true)); + dispatch( + setPendingMoneyAccountCardLink( + origin.entrypoint ?? CardEntryPoint.MONEY_LINK_CARD_SHEET, + ), + ); navigation.navigate(Routes.CARD.ROOT, { screen: Routes.CARD.HOME, params: { @@ -306,16 +346,16 @@ export const useMoneyAccountCardLinkage = ); useEffect(() => { - if (!pendingMoneyAccountCardLink) return; + if (!pendingMoneyAccountCardLinkEntryPoint) return; if (!isCardAuthenticated) return; if (!hasRequirements || !primaryMoneyAccount?.address) { - dispatch(setPendingMoneyAccountCardLink(false)); + dispatch(setPendingMoneyAccountCardLink(null)); return; } if (isAlreadyDelegated) { - dispatch(setPendingMoneyAccountCardLink(false)); + dispatch(setPendingMoneyAccountCardLink(null)); return; } @@ -324,15 +364,16 @@ export const useMoneyAccountCardLinkage = cardHomeDataStatus === 'success' || cardHomeDataStatus === 'error' ) { - dispatch(setPendingMoneyAccountCardLink(false)); + dispatch(setPendingMoneyAccountCardLink(null)); } return; } - dispatch(setPendingMoneyAccountCardLink(false)); - openLinkCardSheet(); + const entrypoint = pendingMoneyAccountCardLinkEntryPoint; + dispatch(setPendingMoneyAccountCardLink(null)); + openLinkCardSheet(entrypoint); }, [ - pendingMoneyAccountCardLink, + pendingMoneyAccountCardLinkEntryPoint, isCardAuthenticated, hasRequirements, moneyAccountCardToken, @@ -346,12 +387,23 @@ export const useMoneyAccountCardLinkage = const confirmLinkInBackground = useCallback( async (options?: { delegationAmountHuman?: string; + entrypoint?: CardEntryPoint | string; }): Promise => { + const entrypoint = + options?.entrypoint ?? CardEntryPoint.MONEY_LINK_CARD_SHEET; const isRevoke = options?.delegationAmountHuman !== undefined && parseFloat(options.delegationAmountHuman) === 0; if (!canSubmitDelegation || !primaryMoneyAccount?.address) { + trackMoneyAccountLinkingEvent( + MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED, + { + entrypoint, + reason: CardLinkingFailureReason.PRECONDITION_FAILED, + is_revoke: isRevoke, + }, + ); showErrorToast(isRevoke); return false; } @@ -365,11 +417,25 @@ export const useMoneyAccountCardLinkage = showPendingToast(isRevoke); try { + trackMoneyAccountLinkingEvent( + MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_STARTED, + { + entrypoint, + is_revoke: isRevoke, + }, + ); await Engine.context.CardController.linkMoneyAccountCard({ moneyAccountAddress: primaryMoneyAccount.address, delegationAmountHuman: options?.delegationAmountHuman ?? BAANX_MAX_LIMIT, }); + trackMoneyAccountLinkingEvent( + MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_COMPLETED, + { + entrypoint, + is_revoke: isRevoke, + }, + ); setStatus('success'); showSuccessToast(isRevoke); return true; @@ -378,16 +444,45 @@ export const useMoneyAccountCardLinkage = caught instanceof Error ? caught : new Error(String(caught)); if (linkageError instanceof UserCancelledError) { + trackMoneyAccountLinkingEvent( + MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED, + { + entrypoint, + reason: CardLinkingFailureReason.USER_CANCELLED, + is_revoke: isRevoke, + }, + ); setStatus('cancelled'); return false; } if (linkageError instanceof CardLinkageInProgressError) { + trackMoneyAccountLinkingEvent( + MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED, + { + entrypoint, + reason: CardLinkingFailureReason.CONTROLLER_FAILED, + error_name: linkageError.name, + is_revoke: isRevoke, + }, + ); setStatus('idle'); setError(null); return false; } + trackMoneyAccountLinkingEvent( + MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED, + { + entrypoint, + reason: + caught instanceof Error + ? CardLinkingFailureReason.CONTROLLER_FAILED + : CardLinkingFailureReason.UNKNOWN, + error_name: linkageError.name, + is_revoke: isRevoke, + }, + ); Logger.error(linkageError, 'useMoneyAccountCardLinkage failed'); setError(linkageError); setStatus('error'); @@ -401,6 +496,7 @@ export const useMoneyAccountCardLinkage = showErrorToast, showPendingToast, showSuccessToast, + trackMoneyAccountLinkingEvent, ], ); diff --git a/app/components/UI/Card/util/metrics.ts b/app/components/UI/Card/util/metrics.ts index e79ee7584c9..be47f0305fb 100644 --- a/app/components/UI/Card/util/metrics.ts +++ b/app/components/UI/Card/util/metrics.ts @@ -1,4 +1,7 @@ enum CardScreens { + HOME = 'HOME', + MONEY_HOME = 'MONEY_HOME', + MONEY_LINK_CARD_SHEET = 'MONEY_LINK_CARD_SHEET', WELCOME = 'WELCOME', AUTHENTICATION = 'AUTHENTICATION', OTP_AUTHENTICATION = 'OTP_AUTHENTICATION', @@ -57,6 +60,17 @@ enum CardActions { UNFREEZE_CARD_BUTTON = 'UNFREEZE_CARD_BUTTON', VIEW_PIN_BUTTON = 'VIEW_PIN_BUTTON', CASHBACK_BUTTON = 'CASHBACK_BUTTON', + MONEY_ACCOUNT_CARD_ACTION_ROW_BUTTON = 'MONEY_ACCOUNT_CARD_ACTION_ROW_BUTTON', + MONEY_ACCOUNT_ONBOARDING_CARD_PRIMARY_BUTTON = 'MONEY_ACCOUNT_ONBOARDING_CARD_PRIMARY_BUTTON', + MONEY_ACCOUNT_ONBOARDING_CARD_SKIP_BUTTON = 'MONEY_ACCOUNT_ONBOARDING_CARD_SKIP_BUTTON', + MONEY_ACCOUNT_METAMASK_CARD_HEADER = 'MONEY_ACCOUNT_METAMASK_CARD_HEADER', + MONEY_ACCOUNT_METAMASK_CARD_GET_NOW_BUTTON = 'MONEY_ACCOUNT_METAMASK_CARD_GET_NOW_BUTTON', + MONEY_ACCOUNT_METAMASK_CARD_LINK_BUTTON = 'MONEY_ACCOUNT_METAMASK_CARD_LINK_BUTTON', + MONEY_ACCOUNT_METAMASK_CARD_MANAGE_BUTTON = 'MONEY_ACCOUNT_METAMASK_CARD_MANAGE_BUTTON', + MONEY_ACCOUNT_METAMASK_CARD_MANAGE_METAL_BUTTON = 'MONEY_ACCOUNT_METAMASK_CARD_MANAGE_METAL_BUTTON', + MONEY_LINK_CARD_SHEET_CONFIRM_BUTTON = 'MONEY_LINK_CARD_SHEET_CONFIRM_BUTTON', + MONEY_LINK_CARD_SHEET_CLOSE_BUTTON = 'MONEY_LINK_CARD_SHEET_CLOSE_BUTTON', + SPENDING_LIMIT_USE_MONEY_ACCOUNT_BUTTON = 'SPENDING_LIMIT_USE_MONEY_ACCOUNT_BUTTON', } enum CardDeeplinkActions { @@ -64,4 +78,50 @@ enum CardDeeplinkActions { CARD_HOME = 'CARD_HOME', } -export { CardScreens, CardActions, CardDeeplinkActions }; +enum CardEntryPoint { + MONEY_HOME_ACTION_ROW = 'MONEY_HOME_ACTION_ROW', + MONEY_HOME_ONBOARDING_CARD = 'MONEY_HOME_ONBOARDING_CARD', + MONEY_HOME_METAMASK_CARD = 'MONEY_HOME_METAMASK_CARD', + CARD_HOME_MONEY_ACCOUNT_CARD = 'CARD_HOME_MONEY_ACCOUNT_CARD', + MONEY_LINK_CARD_SHEET = 'MONEY_LINK_CARD_SHEET', + SPENDING_LIMIT_SPEND_AND_EARN_PROMO = 'SPENDING_LIMIT_SPEND_AND_EARN_PROMO', +} + +enum CardFlow { + MONEY_ACCOUNT_LINKAGE = 'money_account_linkage', +} + +enum CardLinkingFailureReason { + PRECONDITION_FAILED = 'PRECONDITION_FAILED', + USER_CANCELLED = 'USER_CANCELLED', + CONTROLLER_FAILED = 'CONTROLLER_FAILED', + UNKNOWN = 'UNKNOWN', +} + +type CardState = 'non_cardholder' | 'no_card' | 'unlinked_card' | 'linked_card'; + +const deriveCardState = ({ + isCardholder, + isCardAuthenticated, + isCardLinkedToMoneyAccount, +}: { + isCardholder: boolean; + isCardAuthenticated: boolean; + isCardLinkedToMoneyAccount: boolean; +}): CardState => { + if (!isCardholder) return 'non_cardholder'; + if (isCardLinkedToMoneyAccount) return 'linked_card'; + if (isCardAuthenticated) return 'unlinked_card'; + return 'no_card'; +}; + +export { + CardScreens, + CardActions, + CardDeeplinkActions, + CardEntryPoint, + CardFlow, + CardLinkingFailureReason, + deriveCardState, +}; +export type { CardState }; diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx index 3d15475cc93..b207b00a8d0 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx @@ -28,6 +28,7 @@ import type { CardTransaction } from '../../types/moneyActivity'; import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; import useMoneyAccountInfo from '../../hooks/useMoneyAccountInfo'; import { + selectCardHomeDataStatus, selectHasMetalCard, selectIsCardholder, } from '../../../../../selectors/cardController'; @@ -35,11 +36,24 @@ import { useMoneyAccountCardLinkage } from '../../../Card/hooks/useMoneyAccountC import { MONEY_HOME_CARD_ORIGIN } from '../../../Card/hooks/useCardPostAuthRedirect'; import { moneyFormatFiat } from '../../utils/moneyFormatFiat'; import { useMusdBalance } from '../../../Earn/hooks/useMusdBalance'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { + CardActions, + CardEntryPoint, + CardScreens, +} from '../../../Card/util/metrics'; const mockGoBack = jest.fn(); const mockNavigate = jest.fn(); const mockInitiateDeposit = jest.fn(); const mockRefetchBalance = jest.fn(); +const mockTrackEvent = jest.fn(); +const mockBuild = jest.fn(() => ({ name: 'built-event' })); +const mockAddProperties = jest.fn(() => ({ build: mockBuild })); +const mockCreateEventBuilder = jest.fn((_eventName?: unknown) => ({ + addProperties: mockAddProperties, + build: mockBuild, +})); const mockMoneyFormatFiat = moneyFormatFiat as jest.MockedFunction< typeof moneyFormatFiat >; @@ -108,6 +122,21 @@ jest.mock('../../hooks/useMoneyAccountInfo', () => ({ default: jest.fn(), })); +jest.mock('../../../Earn/hooks/useMusdConversion', () => ({ + useMusdConversion: jest.fn(), +})); + +jest.mock('../../../Earn/hooks/useMusdBalance', () => ({ + useMusdBalance: jest.fn(), +})); + +jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + jest.mock('../../../../../core/NavigationService', () => ({ __esModule: true, default: { @@ -126,6 +155,7 @@ jest.mock('../../../../../selectors/cardController', () => ({ ...jest.requireActual('../../../../../selectors/cardController'), selectIsCardholder: jest.fn(), selectHasMetalCard: jest.fn(), + selectCardHomeDataStatus: jest.fn(() => 'idle'), selectIsMoneyAccountDelegatedForCard: jest.fn(() => false), })); @@ -199,6 +229,7 @@ jest.mock('../../hooks/useOnboardingStep', () => ({ const mockSelectIsCardholder = jest.mocked(selectIsCardholder); const mockSelectHasMetalCard = jest.mocked(selectHasMetalCard); +const mockSelectCardHomeDataStatus = jest.mocked(selectCardHomeDataStatus); const mockUseMoneyAccountCardLinkage = jest.mocked(useMoneyAccountCardLinkage); const mockOpenLinkCardSheet = jest.fn(); const mockStartLinkFlow = jest.fn(); @@ -292,6 +323,7 @@ describe('MoneyHomeView', () => { mockSelectIsCardholder.mockReturnValue(false); mockSelectHasMetalCard.mockReturnValue(false); + mockSelectCardHomeDataStatus.mockReturnValue('idle'); mockOpenLinkCardSheet.mockReset(); mockStartLinkFlow.mockReset(); @@ -873,15 +905,63 @@ describe('MoneyHomeView', () => { it('navigates to Card root when Card button is pressed', () => { const { getByTestId } = renderWithProvider(); + jest.clearAllMocks(); fireEvent.press(getByTestId(MoneyActionButtonRowTestIds.CARD_BUTTON)); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CARD_BUTTON_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + screen: CardScreens.MONEY_HOME, + entrypoint: CardEntryPoint.MONEY_HOME_ACTION_ROW, + action: CardActions.MONEY_ACCOUNT_CARD_ACTION_ROW_BUTTON, + }); expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, { screen: Routes.CARD.HOME, params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, }); }); + it('tracks Card Viewed for the Card action row on render', () => { + renderWithProvider(); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CARD_VIEWED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + screen: CardScreens.MONEY_HOME, + entrypoint: CardEntryPoint.MONEY_HOME_ACTION_ROW, + }); + }); + + it('does not track the MetaMask Card impression while card home data is unsettled (idle status)', () => { + mockSelectCardHomeDataStatus.mockReturnValue('idle'); + + renderWithProvider(); + + expect(mockAddProperties).not.toHaveBeenCalledWith( + expect.objectContaining({ + entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD, + }), + ); + }); + + it('tracks the MetaMask Card impression once the card home data fetch has settled', () => { + mockSelectCardHomeDataStatus.mockReturnValue('success'); + + renderWithProvider(); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + screen: CardScreens.MONEY_HOME, + entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD, + mode: 'upsell', + card_state: 'non_cardholder', + }), + ); + }); + it('opens the APY info sheet when the APY info button is pressed', () => { const { getByTestId } = renderWithProvider(); @@ -1249,7 +1329,11 @@ describe('MoneyHomeView', () => { fireEvent.press(getByTestId(MoneyMetaMaskCardTestIds.LINK_BUTTON)); expect(mockStartLinkFlow).toHaveBeenCalledTimes(1); - expect(mockStartLinkFlow).toHaveBeenCalledWith(MONEY_HOME_CARD_ORIGIN); + expect(mockStartLinkFlow).toHaveBeenCalledWith({ + screen: Routes.MONEY.ROOT, + params: { screen: Routes.MONEY.HOME }, + entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD, + }); expect(mockNavigate).not.toHaveBeenCalledWith(Routes.CARD.ROOT, { screen: Routes.CARD.HOME, }); @@ -1282,7 +1366,11 @@ describe('MoneyHomeView', () => { }); expect(mockStartLinkFlow).toHaveBeenCalledTimes(1); - expect(mockStartLinkFlow).toHaveBeenCalledWith(MONEY_HOME_CARD_ORIGIN); + expect(mockStartLinkFlow).toHaveBeenCalledWith({ + screen: Routes.MONEY.ROOT, + params: { screen: Routes.MONEY.HOME }, + entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD, + }); expect(mockNavigate).not.toHaveBeenCalledWith(Routes.CARD.ROOT, { screen: Routes.CARD.HOME, }); diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx index 6e646425bb0..cc3e6df0243 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { Linking, RefreshControl, ScrollView } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; @@ -38,6 +44,7 @@ import { TokenDetailsSource } from '../../../TokenDetails/constants/constants'; import AppConstants from '../../../../../core/AppConstants'; import NavigationService from '../../../../../core/NavigationService'; import { + selectCardHomeDataStatus, selectHasMetalCard, selectIsCardholder, } from '../../../../../selectors/cardController'; @@ -49,6 +56,16 @@ import { useTheme } from '../../../../../util/theme'; import { MoneyBalanceDisplayState } from '../../types'; import { Hex } from '@metamask/utils'; import { AssetType } from '../../../../Views/confirmations/types/token'; +import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { + CardActions, + CardEntryPoint, + CardFlow, + CardScreens, + deriveCardState, +} from '../../../Card/util/metrics'; + import { useMoneyAccountDeposit } from '../../hooks/useMoneyAccount'; import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics'; import useMountEffect from '../../hooks/useMountEffect'; @@ -74,6 +91,8 @@ const MoneyHomeView = () => { const { styles } = useStyles(styleSheet, {}); const currentCurrency = useSelector(selectCurrentCurrency); const { colors } = useTheme(); + const { trackEvent, createEventBuilder } = useAnalytics(); + const hasTrackedCardActionRowViewRef = useRef(false); const { trackButtonClicked, @@ -137,6 +156,7 @@ const MoneyHomeView = () => { ); const isCardholder = useSelector(selectIsCardholder); + const cardHomeDataStatus = useSelector(selectCardHomeDataStatus); const hasMetalCard = useSelector(selectHasMetalCard); const { startLinkFlow, @@ -260,17 +280,21 @@ const MoneyHomeView = () => { }); }, [navigation, trackButtonClicked]); + const navigateToCardHome = useCallback(() => { + navigation.navigate(Routes.CARD.ROOT, { + screen: Routes.CARD.HOME, + params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, + }); + }, [navigation]); + const handleCardHeaderPress = useCallback(() => { trackSurfaceClicked({ component_name: COMPONENT_NAMES.MONEY_CARD_SECTION_HEADER, redirect_target: SCREEN_NAMES.CARD_HOME, }); - navigation.navigate(Routes.CARD.ROOT, { - screen: Routes.CARD.HOME, - params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, - }); - }, [navigation, trackSurfaceClicked]); + navigateToCardHome(); + }, [navigateToCardHome, trackSurfaceClicked]); const handleActionButtonCardPress = useCallback(() => { trackButtonClicked({ @@ -283,23 +307,41 @@ const MoneyHomeView = () => { button_row_button_count: ACTION_BUTTON_ROW_BUTTON_COUNT, }); - navigation.navigate(Routes.CARD.ROOT, { - screen: Routes.CARD.HOME, - params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, - }); - }, [navigation, trackButtonClicked]); + trackEvent( + createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED) + .addProperties({ + screen: CardScreens.MONEY_HOME, + entrypoint: CardEntryPoint.MONEY_HOME_ACTION_ROW, + action: CardActions.MONEY_ACCOUNT_CARD_ACTION_ROW_BUTTON, + }) + .build(), + ); - const handleCardPress = useCallback(() => { - navigation.navigate(Routes.CARD.ROOT, { - screen: Routes.CARD.HOME, - params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN }, - }); - }, [navigation]); + navigateToCardHome(); + }, [trackButtonClicked, trackEvent, createEventBuilder, navigateToCardHome]); const handleLinkCardPress = useCallback(() => { - startLinkFlow(MONEY_HOME_CARD_ORIGIN); + startLinkFlow({ + screen: Routes.MONEY.ROOT, + params: { screen: Routes.MONEY.HOME }, + entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD, + }); }, [startLinkFlow]); + useEffect(() => { + if (hasTrackedCardActionRowViewRef.current) return; + hasTrackedCardActionRowViewRef.current = true; + + trackEvent( + createEventBuilder(MetaMetricsEvents.CARD_VIEWED) + .addProperties({ + screen: CardScreens.MONEY_HOME, + entrypoint: CardEntryPoint.MONEY_HOME_ACTION_ROW, + }) + .build(), + ); + }, [trackEvent, createEventBuilder]); + const handleApyInfoPress = useCallback(() => { trackTooltipClicked({ tooltip_name: MONEY_TOOLTIP_NAMES.APY, @@ -514,6 +556,13 @@ const MoneyHomeView = () => { const { primaryToken: cardPrimaryToken } = useCardHomeData(); const cardBalance = cardPrimaryToken?.balanceFiat ?? formattedZero; + const cardState = deriveCardState({ + isCardholder, + isCardAuthenticated, + isCardLinkedToMoneyAccount, + }); + const isCardAnalyticsReady = + cardHomeDataStatus === 'success' || cardHomeDataStatus === 'error'; return ( { )} {isFunded && ( diff --git a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.test.tsx b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.test.tsx index 8d588fd3dff..0fefec6fee5 100644 --- a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.test.tsx +++ b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.test.tsx @@ -6,13 +6,30 @@ import { MoneyLinkCardSheetTestIds } from './MoneyLinkCardSheet.testIds'; import { strings } from '../../../../../../locales/i18n'; import { useMoneyAccountCardLinkage } from '../../../Card/hooks/useMoneyAccountCardLinkage'; import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; -import { selectCardHomeData } from '../../../../../selectors/cardController'; +import { + selectCardHomeData, + selectCardHomeDataStatus, +} from '../../../../../selectors/cardController'; import { CardType } from '../../../Card/types'; import mmCardRegular from '../../../../../images/mm_card_regular.png'; import mmCardMetal from '../../../../../images/mm_card_metal.png'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { + CardActions, + CardEntryPoint, + CardScreens, +} from '../../../Card/util/metrics'; const mockOnCloseBottomSheet = jest.fn((cb?: () => void) => cb?.()); const mockGoBack = jest.fn(); +let mockRouteParams: { entrypoint?: CardEntryPoint | string } | undefined; +const mockTrackEvent = jest.fn(); +const mockBuild = jest.fn(() => ({ name: 'built-event' })); +const mockAddProperties = jest.fn(() => ({ build: mockBuild })); +const mockCreateEventBuilder = jest.fn((_eventName?: unknown) => ({ + addProperties: mockAddProperties, + build: mockBuild, +})); jest.mock('@react-navigation/native', () => { const actualReactNavigation = jest.requireActual('@react-navigation/native'); @@ -21,6 +38,9 @@ jest.mock('@react-navigation/native', () => { useNavigation: () => ({ goBack: mockGoBack, }), + useRoute: () => ({ + params: mockRouteParams, + }), }; }); @@ -35,6 +55,14 @@ jest.mock('../../hooks/useMoneyAccountBalance', () => ({ jest.mock('../../../../../selectors/cardController', () => ({ selectCardHomeData: jest.fn(), + selectCardHomeDataStatus: jest.fn(), +})); + +jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), })); jest.mock('@metamask/design-system-react-native', () => { @@ -68,12 +96,15 @@ const mockUseMoneyAccountCardLinkage = const mockUseMoneyAccountBalance = useMoneyAccountBalance as jest.MockedFunction; const mockSelectCardHomeData = selectCardHomeData as unknown as jest.Mock; +const mockSelectCardHomeDataStatus = + selectCardHomeDataStatus as unknown as jest.Mock; describe('MoneyLinkCardSheet', () => { let mockConfirmLinkInBackground: jest.Mock; beforeEach(() => { jest.clearAllMocks(); + mockRouteParams = undefined; mockConfirmLinkInBackground = jest.fn().mockResolvedValue(true); mockUseMoneyAccountCardLinkage.mockReturnValue({ confirmLinkInBackground: mockConfirmLinkInBackground, @@ -84,6 +115,7 @@ describe('MoneyLinkCardSheet', () => { mockSelectCardHomeData.mockReturnValue({ card: { type: CardType.VIRTUAL }, }); + mockSelectCardHomeDataStatus.mockReturnValue('success'); }); it('renders the container', () => { @@ -92,6 +124,71 @@ describe('MoneyLinkCardSheet', () => { expect(getByTestId(MoneyLinkCardSheetTestIds.CONTAINER)).toBeOnTheScreen(); }); + it('tracks Card Viewed on mount with generic sheet entrypoint and origin', () => { + mockRouteParams = { + entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, + }; + + renderWithProvider(); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CARD_VIEWED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + screen: CardScreens.MONEY_LINK_CARD_SHEET, + entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET, + origin_entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, + card_type: 'virtual', + }); + }); + + it('does not emit Card Viewed while the card home data fetch is still loading', () => { + mockSelectCardHomeData.mockReturnValue(null); + mockSelectCardHomeDataStatus.mockReturnValue('loading'); + + renderWithProvider(); + + expect(mockCreateEventBuilder).not.toHaveBeenCalledWith( + MetaMetricsEvents.CARD_VIEWED, + ); + }); + + it('emits the resolved card_type once the card home data fetch has succeeded', () => { + mockSelectCardHomeData.mockReturnValue({ + card: { type: CardType.METAL }, + }); + mockSelectCardHomeDataStatus.mockReturnValue('success'); + + renderWithProvider(); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CARD_VIEWED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + screen: CardScreens.MONEY_LINK_CARD_SHEET, + entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET, + origin_entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET, + card_type: 'metal', + }); + }); + + it('still emits Card Viewed (virtual fallback) when card home data fails to load', () => { + mockSelectCardHomeData.mockReturnValue(null); + mockSelectCardHomeDataStatus.mockReturnValue('error'); + + renderWithProvider(); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CARD_VIEWED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + screen: CardScreens.MONEY_LINK_CARD_SHEET, + entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET, + origin_entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET, + card_type: 'virtual', + }); + }); + it('renders the illustration, title, description, and CTA', () => { const { getByTestId, getByText } = renderWithProvider( , @@ -198,20 +295,48 @@ describe('MoneyLinkCardSheet', () => { }); it('dismisses the sheet and dispatches confirmLinkInBackground when the CTA is pressed', () => { + mockRouteParams = { + entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD, + }; const { getByTestId } = renderWithProvider(); + jest.clearAllMocks(); fireEvent.press(getByTestId(MoneyLinkCardSheetTestIds.CTA_BUTTON)); expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); expect(mockConfirmLinkInBackground).toHaveBeenCalledTimes(1); + expect(mockConfirmLinkInBackground).toHaveBeenCalledWith({ + entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD, + }); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CARD_BUTTON_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + screen: CardScreens.MONEY_LINK_CARD_SHEET, + entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET, + origin_entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD, + action: CardActions.MONEY_LINK_CARD_SHEET_CONFIRM_BUTTON, + card_type: 'virtual', + }); }); it('dismisses the sheet without dispatching the linkage when the close button is pressed', () => { const { getByTestId } = renderWithProvider(); + jest.clearAllMocks(); fireEvent.press(getByTestId(MoneyLinkCardSheetTestIds.CLOSE_BUTTON)); expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); expect(mockConfirmLinkInBackground).not.toHaveBeenCalled(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CARD_BUTTON_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + screen: CardScreens.MONEY_LINK_CARD_SHEET, + entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET, + origin_entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET, + action: CardActions.MONEY_LINK_CARD_SHEET_CLOSE_BUTTON, + card_type: 'virtual', + }); }); }); diff --git a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.tsx b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.tsx index 90779198256..9a37854159f 100644 --- a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.tsx +++ b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.tsx @@ -1,6 +1,6 @@ -import React, { useCallback, useRef } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { Image } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import { useNavigation, useRoute } from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { BottomSheet, @@ -18,7 +18,10 @@ import { } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; import { useStyles } from '../../../../../component-library/hooks'; -import { selectCardHomeData } from '../../../../../selectors/cardController'; +import { + selectCardHomeData, + selectCardHomeDataStatus, +} from '../../../../../selectors/cardController'; import { useMoneyAccountCardLinkage } from '../../../Card/hooks/useMoneyAccountCardLinkage'; import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; import { CardType } from '../../../Card/types'; @@ -27,6 +30,17 @@ import mmCardMetal from '../../../../../images/mm_card_metal.png'; import styleSheet from './MoneyLinkCardSheet.styles'; import { MoneyLinkCardSheetTestIds } from './MoneyLinkCardSheet.testIds'; import { useElevatedSurface } from '../../../../../util/theme/themeUtils'; +import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { + CardActions, + CardEntryPoint, + CardScreens, +} from '../../../Card/util/metrics'; + +interface MoneyLinkCardSheetRouteParams { + entrypoint?: CardEntryPoint | string; +} /** * "Spend and earn" confirmation bottom sheet shown before the Money Account ↔ @@ -39,27 +53,91 @@ import { useElevatedSurface } from '../../../../../util/theme/themeUtils'; */ const MoneyLinkCardSheet = () => { const sheetRef = useRef(null); + const hasTrackedViewRef = useRef(false); const navigation = useNavigation(); + const route = useRoute(); const { styles } = useStyles(styleSheet, {}); const { confirmLinkInBackground } = useMoneyAccountCardLinkage(); const { apyPercent } = useMoneyAccountBalance(); + const { trackEvent, createEventBuilder } = useAnalytics(); const cardHomeData = useSelector(selectCardHomeData); + const cardHomeDataStatus = useSelector(selectCardHomeDataStatus); const surfaceClass = useElevatedSurface(); const isMetalCard = cardHomeData?.card?.type === CardType.METAL; + const routeParams = route.params as MoneyLinkCardSheetRouteParams | undefined; + const originEntryPoint = + routeParams?.entrypoint ?? CardEntryPoint.MONEY_LINK_CARD_SHEET; + const cardType = isMetalCard ? 'metal' : 'virtual'; + const isCardDataReady = + cardHomeDataStatus === 'success' || cardHomeDataStatus === 'error'; + + useEffect(() => { + if (hasTrackedViewRef.current || !isCardDataReady) return; + hasTrackedViewRef.current = true; + + trackEvent( + createEventBuilder(MetaMetricsEvents.CARD_VIEWED) + .addProperties({ + screen: CardScreens.MONEY_LINK_CARD_SHEET, + entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET, + origin_entrypoint: originEntryPoint, + card_type: cardType, + }) + .build(), + ); + }, [ + trackEvent, + createEventBuilder, + originEntryPoint, + cardType, + isCardDataReady, + ]); const handleGoBack = useCallback(() => { navigation.goBack(); }, [navigation]); const handleClose = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED) + .addProperties({ + screen: CardScreens.MONEY_LINK_CARD_SHEET, + entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET, + origin_entrypoint: originEntryPoint, + action: CardActions.MONEY_LINK_CARD_SHEET_CLOSE_BUTTON, + card_type: cardType, + }) + .build(), + ); + sheetRef.current?.onCloseBottomSheet(); - }, []); + }, [trackEvent, createEventBuilder, originEntryPoint, cardType]); const handleConfirm = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED) + .addProperties({ + screen: CardScreens.MONEY_LINK_CARD_SHEET, + entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET, + origin_entrypoint: originEntryPoint, + action: CardActions.MONEY_LINK_CARD_SHEET_CONFIRM_BUTTON, + card_type: cardType, + }) + .build(), + ); + sheetRef.current?.onCloseBottomSheet(() => { - confirmLinkInBackground().catch(() => undefined); + confirmLinkInBackground({ entrypoint: originEntryPoint }).catch( + () => undefined, + ); }); - }, [confirmLinkInBackground]); + }, [ + trackEvent, + createEventBuilder, + originEntryPoint, + cardType, + confirmLinkInBackground, + ]); const description: React.ReactNode = apyPercent === undefined ? ( diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx index ccd3c56fe85..ef021ebcba1 100644 --- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx +++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx @@ -4,8 +4,41 @@ import MoneyMetaMaskCard from './MoneyMetaMaskCard'; import { MoneyMetaMaskCardTestIds } from './MoneyMetaMaskCard.testIds'; import { MoneySectionHeaderTestIds } from '../MoneySectionHeader/MoneySectionHeader.testIds'; import { strings } from '../../../../../../locales/i18n'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { + CardActions, + CardEntryPoint, + CardFlow, + CardScreens, +} from '../../../Card/util/metrics'; + +const mockTrackEvent = jest.fn(); +const mockBuild = jest.fn(() => ({ name: 'built-event' })); +const mockAddProperties = jest.fn(() => ({ build: mockBuild })); +const mockCreateEventBuilder = jest.fn((_eventName?: unknown) => ({ + addProperties: mockAddProperties, + build: mockBuild, +})); + +jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); describe('MoneyMetaMaskCard', () => { + const analyticsProps = { + analyticsScreen: CardScreens.MONEY_HOME, + analyticsEntryPoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD, + analyticsFlow: CardFlow.MONEY_ACCOUNT_LINKAGE, + analyticsCardState: 'unlinked_card', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + it('renders the section title and subtitle', () => { const { getByText } = render( , @@ -431,4 +464,195 @@ describe('MoneyMetaMaskCard', () => { ).not.toBeOnTheScreen(); }); }); + + describe('analytics', () => { + it('does not track when analytics props are omitted', () => { + render(); + + expect(mockCreateEventBuilder).not.toHaveBeenCalled(); + expect(mockTrackEvent).not.toHaveBeenCalled(); + }); + + it('tracks Card Viewed once when analytics props are provided', () => { + const { rerender } = render( + , + ); + + rerender( + , + ); + + expect( + mockCreateEventBuilder.mock.calls.filter( + ([eventName]) => eventName === MetaMetricsEvents.CARD_VIEWED, + ), + ).toHaveLength(1); + expect(mockAddProperties).toHaveBeenCalledWith({ + screen: CardScreens.MONEY_HOME, + entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD, + mode: 'upsell', + card_type: 'virtual', + flow: CardFlow.MONEY_ACCOUNT_LINKAGE, + card_state: 'unlinked_card', + action: undefined, + }); + }); + + it('defers Card Viewed while analyticsReady is false', () => { + render( + , + ); + + expect( + mockCreateEventBuilder.mock.calls.filter( + ([eventName]) => eventName === MetaMetricsEvents.CARD_VIEWED, + ), + ).toHaveLength(0); + }); + + it('tracks Card Viewed with settled properties once analyticsReady flips to true', () => { + const { rerender } = render( + , + ); + + // Async cardholder/auth data settles: mode + card_state change and the + // gate opens. Only the post-load values should be recorded. + rerender( + , + ); + + const cardViewedCalls = mockCreateEventBuilder.mock.calls.filter( + ([eventName]) => eventName === MetaMetricsEvents.CARD_VIEWED, + ); + expect(cardViewedCalls).toHaveLength(1); + expect(mockAddProperties).toHaveBeenCalledWith({ + screen: CardScreens.MONEY_HOME, + entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD, + mode: 'manage', + card_type: 'virtual', + flow: CardFlow.MONEY_ACCOUNT_LINKAGE, + card_state: 'linked_card', + action: undefined, + }); + }); + + it('tracks Card Viewed only once even if properties keep changing after the gate opens', () => { + const { rerender } = render( + , + ); + + rerender( + , + ); + + expect( + mockCreateEventBuilder.mock.calls.filter( + ([eventName]) => eventName === MetaMetricsEvents.CARD_VIEWED, + ), + ).toHaveLength(1); + }); + + it('tracks Get now clicks before calling the handler', () => { + const mockGetNow = jest.fn(); + const { getByText } = render( + , + ); + jest.clearAllMocks(); + + fireEvent.press(getByText(strings('money.metamask_card.get_now'))); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CARD_BUTTON_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + screen: CardScreens.MONEY_HOME, + entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD, + mode: 'upsell', + card_type: 'virtual', + flow: CardFlow.MONEY_ACCOUNT_LINKAGE, + card_state: 'unlinked_card', + action: CardActions.MONEY_ACCOUNT_METAMASK_CARD_GET_NOW_BUTTON, + }); + expect(mockGetNow).toHaveBeenCalledTimes(1); + }); + + it('tracks Link card clicks before calling the handler', () => { + const mockLink = jest.fn(); + const { getByTestId } = render( + , + ); + jest.clearAllMocks(); + + fireEvent.press(getByTestId(MoneyMetaMaskCardTestIds.LINK_BUTTON)); + + expect(mockAddProperties).toHaveBeenCalledWith({ + screen: CardScreens.MONEY_HOME, + entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD, + mode: 'link', + card_type: 'virtual', + flow: CardFlow.MONEY_ACCOUNT_LINKAGE, + card_state: 'unlinked_card', + action: CardActions.MONEY_ACCOUNT_METAMASK_CARD_LINK_BUTTON, + }); + expect(mockLink).toHaveBeenCalledTimes(1); + }); + + it('tracks Manage clicks before calling the handler', () => { + const mockManage = jest.fn(); + const { getByTestId } = render( + , + ); + jest.clearAllMocks(); + + fireEvent.press(getByTestId(MoneyMetaMaskCardTestIds.MANAGE_BUTTON)); + + expect(mockAddProperties).toHaveBeenCalledWith({ + screen: CardScreens.MONEY_HOME, + entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD, + mode: 'manage', + card_type: 'virtual', + flow: CardFlow.MONEY_ACCOUNT_LINKAGE, + card_state: 'unlinked_card', + action: CardActions.MONEY_ACCOUNT_METAMASK_CARD_MANAGE_BUTTON, + }); + expect(mockManage).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx index 0fb09b84885..cba26431f33 100644 --- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx +++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { Image, ImageSourcePropType } from 'react-native'; import { Box, @@ -23,6 +23,13 @@ import { strings } from '../../../../../../locales/i18n'; import MoneySectionHeader from '../MoneySectionHeader'; import { MoneyMetaMaskCardTestIds } from './MoneyMetaMaskCard.testIds'; import styles from './MoneyMetaMaskCard.styles'; +import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { + CardActions, + CardEntryPoint, + CardScreens, +} from '../../../Card/util/metrics'; import mmCardRegular from '../../../../../images/mm_card_regular.png'; import mmCardMetal from '../../../../../images/mm_card_metal.png'; @@ -55,6 +62,11 @@ interface MoneyMetaMaskCardProps { * (drops the APY clause from the subtitle and omits the APY bullet). */ apy?: number; + analyticsScreen?: CardScreens | string; + analyticsEntryPoint?: CardEntryPoint; + analyticsFlow?: string; + analyticsCardState?: string; + analyticsReady?: boolean; /** * Link mode only: when true, the card image is omitted and the bullets are * stacked vertically. Used by Card Home where the card image is already @@ -317,13 +329,104 @@ const MoneyMetaMaskCard = ({ cardBalance, apy, hideCardImage = false, + analyticsScreen, + analyticsEntryPoint, + analyticsFlow, + analyticsCardState, + analyticsReady = true, }: MoneyMetaMaskCardProps) => { - const handleLinkPress = useCallback(() => onLinkPress?.(), [onLinkPress]); - const handleManagePress = useCallback( - () => onManagePress?.(), - [onManagePress], + const { trackEvent, createEventBuilder } = useAnalytics(); + const hasTrackedViewRef = useRef(false); + const cardType = showMetalCard ? 'metal' : 'virtual'; + + const buildAnalyticsProperties = useCallback( + (action?: CardActions) => ({ + screen: analyticsScreen, + entrypoint: analyticsEntryPoint, + mode, + card_type: cardType, + flow: analyticsFlow, + card_state: analyticsCardState, + action, + }), + [ + analyticsScreen, + analyticsEntryPoint, + mode, + cardType, + analyticsFlow, + analyticsCardState, + ], ); + const trackCardButtonClick = useCallback( + (action: CardActions) => { + if (!analyticsScreen || !analyticsEntryPoint) return; + + trackEvent( + createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED) + .addProperties(buildAnalyticsProperties(action)) + .build(), + ); + }, + [ + analyticsScreen, + analyticsEntryPoint, + trackEvent, + createEventBuilder, + buildAnalyticsProperties, + ], + ); + + useEffect(() => { + if ( + hasTrackedViewRef.current || + !analyticsReady || + !analyticsScreen || + !analyticsEntryPoint + ) { + return; + } + + hasTrackedViewRef.current = true; + trackEvent( + createEventBuilder(MetaMetricsEvents.CARD_VIEWED) + .addProperties(buildAnalyticsProperties()) + .build(), + ); + }, [ + analyticsReady, + analyticsScreen, + analyticsEntryPoint, + trackEvent, + createEventBuilder, + buildAnalyticsProperties, + ]); + + const handleLinkPress = useCallback(() => { + trackCardButtonClick(CardActions.MONEY_ACCOUNT_METAMASK_CARD_LINK_BUTTON); + onLinkPress?.(); + }, [trackCardButtonClick, onLinkPress]); + + const handleGetNowPress = useCallback(() => { + trackCardButtonClick( + CardActions.MONEY_ACCOUNT_METAMASK_CARD_GET_NOW_BUTTON, + ); + onGetNowPress(); + }, [trackCardButtonClick, onGetNowPress]); + + const handleManagePress = useCallback(() => { + trackCardButtonClick(CardActions.MONEY_ACCOUNT_METAMASK_CARD_MANAGE_BUTTON); + onManagePress?.(); + }, [trackCardButtonClick, onManagePress]); + + const handleHeaderPress = useCallback(() => { + trackCardButtonClick(CardActions.MONEY_ACCOUNT_METAMASK_CARD_HEADER); + onHeaderPress?.(); + }, [trackCardButtonClick, onHeaderPress]); + + const resolvedHeaderPress = onHeaderPress ? handleHeaderPress : undefined; + let content: React.ReactNode = null; if (mode === 'link') { content = ( @@ -357,7 +460,7 @@ const MoneyMetaMaskCard = ({ imageSource={mmCardRegular} cardName={strings('money.metamask_card.virtual_card')} cashbackPercentage="1" - onPress={onGetNowPress} + onPress={handleGetNowPress} testID={MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW} /> @@ -380,7 +483,9 @@ const MoneyMetaMaskCard = ({ > {content} diff --git a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.test.tsx b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.test.tsx index 1191166a562..666384fb1b2 100644 --- a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.test.tsx +++ b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.test.tsx @@ -10,6 +10,12 @@ import { useMoneyAccountCardLinkage } from '../../../Card/hooks/useMoneyAccountC import { MONEY_HOME_CARD_ORIGIN } from '../../../Card/hooks/useCardPostAuthRedirect'; import { strings } from '../../../../../../locales/i18n'; import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { + CardActions, + CardEntryPoint, + CardScreens, +} from '../../../Card/util/metrics'; import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics'; import { BOTTOM_SHEET_NAMES, @@ -18,6 +24,14 @@ import { SCREEN_NAMES, } from '../../constants/moneyEvents'; +const mockTrackEvent = jest.fn(); +const mockBuild = jest.fn(() => ({ name: 'built-event' })); +const mockAddProperties = jest.fn(() => ({ build: mockBuild })); +const mockCreateEventBuilder = jest.fn((_eventName?: unknown) => ({ + addProperties: mockAddProperties, + build: mockBuild, +})); + const mockTrackOnboardingEvent = jest.fn(); jest.mock('../../hooks/useMoneyAnalytics', () => ({ @@ -49,6 +63,23 @@ jest.mock('../../../Card/hooks/useMoneyAccountCardLinkage', () => ({ useMoneyAccountCardLinkage: jest.fn(), })); +jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + +const mockIsCardholder = jest.fn(() => true); +const mockCardHomeDataStatus = jest.fn(() => 'success'); +jest.mock('react-redux', () => ({ + useSelector: (selector: (state: unknown) => unknown) => selector(undefined), +})); +jest.mock('../../../../../selectors/cardController', () => ({ + selectIsCardholder: () => mockIsCardholder(), + selectCardHomeDataStatus: () => mockCardHomeDataStatus(), +})); + const mockUseOnboardingStep = useOnboardingStep as jest.MockedFunction< typeof useOnboardingStep >; @@ -235,16 +266,27 @@ describe('MoneyOnboardingCard', () => { setupDefaultMocks({ currentStep: 1, isCardAuthenticated: false }); const { getByTestId } = render(); + jest.clearAllMocks(); fireEvent.press(getByTestId('money-onboarding-card-cta-button')); expect(mockStartLinkFlow).toHaveBeenCalledTimes(1); expect(mockStartLinkFlow).toHaveBeenCalledWith(MONEY_HOME_CARD_ORIGIN); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CARD_BUTTON_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + screen: CardScreens.MONEY_HOME, + entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, + action: CardActions.MONEY_ACCOUNT_ONBOARDING_CARD_PRIMARY_BUTTON, + card_state: 'no_card', + }); }); it('calls incrementStep when Skip CTA is pressed', () => { setupDefaultMocks({ currentStep: 1, isCardAuthenticated: false }); const { getByText } = render(); + jest.clearAllMocks(); fireEvent.press( getByText( strings('money.onboarding.step_2.no_card_account.cta_secondary'), @@ -252,6 +294,89 @@ describe('MoneyOnboardingCard', () => { ); expect(mockIncrementStep).toHaveBeenCalledTimes(1); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CARD_BUTTON_CLICKED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + screen: CardScreens.MONEY_HOME, + entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, + action: CardActions.MONEY_ACCOUNT_ONBOARDING_CARD_SKIP_BUTTON, + card_state: 'no_card', + }); + }); + + it('tracks Card view when the Card step is rendered', () => { + setupDefaultMocks({ currentStep: 1, isCardAuthenticated: false }); + + render(); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CARD_VIEWED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + screen: CardScreens.MONEY_HOME, + entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, + card_state: 'no_card', + }); + }); + + it('emits card_state="non_cardholder" when account is not a cardholder', () => { + mockIsCardholder.mockReturnValue(false); + setupDefaultMocks({ currentStep: 1, isCardAuthenticated: false }); + + render(); + + expect(mockAddProperties).toHaveBeenCalledWith({ + screen: CardScreens.MONEY_HOME, + entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, + card_state: 'non_cardholder', + }); + + mockIsCardholder.mockReturnValue(true); + }); + + it('does not emit Card view while card home data is still loading', () => { + mockCardHomeDataStatus.mockReturnValue('loading'); + setupDefaultMocks({ currentStep: 1, isCardAuthenticated: false }); + + render(); + + expect(mockCreateEventBuilder).not.toHaveBeenCalledWith( + MetaMetricsEvents.CARD_VIEWED, + ); + + mockCardHomeDataStatus.mockReturnValue('success'); + }); + + it('defers Card view tracking until card flags settle and emits the resolved card_state', () => { + // Initial render mirrors a rehydrating store: card data is loading and the + // cardholder flag has not been restored yet (would derive non_cardholder). + mockIsCardholder.mockReturnValue(false); + mockCardHomeDataStatus.mockReturnValue('loading'); + setupDefaultMocks({ currentStep: 1, isCardAuthenticated: false }); + + const { rerender } = render(); + + expect(mockCreateEventBuilder).not.toHaveBeenCalledWith( + MetaMetricsEvents.CARD_VIEWED, + ); + + // Card data settles: the account is a cardholder without an active card. + mockIsCardholder.mockReturnValue(true); + mockCardHomeDataStatus.mockReturnValue('success'); + rerender(); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.CARD_VIEWED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + screen: CardScreens.MONEY_HOME, + entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, + card_state: 'no_card', + }); + + mockIsCardholder.mockReturnValue(true); + mockCardHomeDataStatus.mockReturnValue('success'); }); }); @@ -312,10 +437,17 @@ describe('MoneyOnboardingCard', () => { }); const { getByTestId } = render(); + jest.clearAllMocks(); fireEvent.press(getByTestId('money-onboarding-card-cta-button')); expect(mockStartLinkFlow).toHaveBeenCalledTimes(1); expect(mockStartLinkFlow).toHaveBeenCalledWith(MONEY_HOME_CARD_ORIGIN); + expect(mockAddProperties).toHaveBeenCalledWith({ + screen: CardScreens.MONEY_HOME, + entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, + action: CardActions.MONEY_ACCOUNT_ONBOARDING_CARD_PRIMARY_BUTTON, + card_state: 'unlinked_card', + }); }); it('calls incrementStep when Skip CTA is pressed', () => { @@ -326,6 +458,7 @@ describe('MoneyOnboardingCard', () => { }); const { getByText } = render(); + jest.clearAllMocks(); fireEvent.press( getByText( strings( @@ -335,6 +468,12 @@ describe('MoneyOnboardingCard', () => { ); expect(mockIncrementStep).toHaveBeenCalledTimes(1); + expect(mockAddProperties).toHaveBeenCalledWith({ + screen: CardScreens.MONEY_HOME, + entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, + action: CardActions.MONEY_ACCOUNT_ONBOARDING_CARD_SKIP_BUTTON, + card_state: 'unlinked_card', + }); }); }); diff --git a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx index e74c2c69d1a..032b7d23740 100644 --- a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx +++ b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { Box } from '@metamask/design-system-react-native'; import moneyOnboardingStepperStep1 from '../../../../../images/money-onboarding-stepper-step-1.png'; import moneyOnboardingStepperStep2 from '../../../../../images/money-onboarding-stepper-step-2.png'; @@ -11,6 +11,19 @@ import StepperCard, { } from '../../../../../component-library/components-temp/StepperCard'; import { useMoneyAccountDeposit } from '../../hooks/useMoneyAccount'; import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; +import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { + CardActions, + CardEntryPoint, + CardScreens, + deriveCardState, +} from '../../../Card/util/metrics'; +import { useSelector } from 'react-redux'; +import { + selectIsCardholder, + selectCardHomeDataStatus, +} from '../../../../../selectors/cardController'; import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics'; import { COMPONENT_NAMES, @@ -23,6 +36,9 @@ import { export const MONEY_ONBOARDING_TOTAL_STEPS = 2; const MoneyOnboardingCard = () => { + const { trackEvent, createEventBuilder } = useAnalytics(); + const hasTrackedCardStepViewRef = useRef(false); + const { currentStep, incrementStep, @@ -45,6 +61,20 @@ const MoneyOnboardingCard = () => { isCardLinkedToMoneyAccount, isLinking, } = useMoneyAccountCardLinkage(); + const isCardholder = useSelector(selectIsCardholder); + const cardHomeDataStatus = useSelector(selectCardHomeDataStatus); + + const isMoneyAccountFunded = Boolean( + !isAggregatedBalanceLoading && tokenTotal?.isGreaterThan(0), + ); + const isCardAnalyticsReady = + cardHomeDataStatus === 'success' || cardHomeDataStatus === 'error'; + + const cardState = deriveCardState({ + isCardholder, + isCardAuthenticated, + isCardLinkedToMoneyAccount, + }); const handleRedirectToCryptoDeposit = useCallback(async () => { await initiateDeposit().catch(() => undefined); @@ -72,6 +102,17 @@ const MoneyOnboardingCard = () => { }); } + trackEvent( + createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED) + .addProperties({ + screen: CardScreens.MONEY_HOME, + entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, + action: CardActions.MONEY_ACCOUNT_ONBOARDING_CARD_PRIMARY_BUTTON, + card_state: cardState, + }) + .build(), + ); + startLinkFlow(MONEY_HOME_CARD_ORIGIN); }, [ @@ -80,6 +121,9 @@ const MoneyOnboardingCard = () => { isCardLinkedToMoneyAccount, startLinkFlow, trackOnboardingEvent, + trackEvent, + createEventBuilder, + cardState, ], ); @@ -93,16 +137,32 @@ const MoneyOnboardingCard = () => { step_action: MONEY_ONBOARDING_STEP_ACTIONS.SKIPPED, }); + trackEvent( + createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED) + .addProperties({ + screen: CardScreens.MONEY_HOME, + entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, + action: CardActions.MONEY_ACCOUNT_ONBOARDING_CARD_SKIP_BUTTON, + card_state: cardState, + }) + .build(), + ); + incrementStep(); }, - [currentStep, incrementStep, trackOnboardingEvent], + [ + currentStep, + incrementStep, + trackOnboardingEvent, + trackEvent, + createEventBuilder, + cardState, + ], ); const targetStepFromCompletion = useMemo(() => { // Step 1 completion is based on having a non-zero balance (after loading). - const isStep1Complete = Boolean( - !isAggregatedBalanceLoading && tokenTotal?.isGreaterThan(0), - ); + const isStep1Complete = isMoneyAccountFunded; // Step 2 completion can be evaluated if either: // - persisted progress is already at step index ≥ 1 (auto-advanced on a @@ -117,8 +177,7 @@ const MoneyOnboardingCard = () => { return 0; }, [ currentStep, - isAggregatedBalanceLoading, - tokenTotal, + isMoneyAccountFunded, isCardAuthenticated, isCardLinkedToMoneyAccount, ]); @@ -135,6 +194,39 @@ const MoneyOnboardingCard = () => { } }, [currentStep, targetStepFromCompletion, incrementStep]); + useEffect(() => { + if ( + hasTrackedCardStepViewRef.current || + isAggregatedBalanceLoading || + !isCardAnalyticsReady || + !isOnboardingCardVisible || + !isVisibleAfterAutoSkip || + effectiveCurrentStep !== 1 + ) { + return; + } + + hasTrackedCardStepViewRef.current = true; + trackEvent( + createEventBuilder(MetaMetricsEvents.CARD_VIEWED) + .addProperties({ + screen: CardScreens.MONEY_HOME, + entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, + card_state: cardState, + }) + .build(), + ); + }, [ + trackEvent, + createEventBuilder, + effectiveCurrentStep, + isAggregatedBalanceLoading, + isCardAnalyticsReady, + isOnboardingCardVisible, + isVisibleAfterAutoSkip, + cardState, + ]); + const handleStep1CtaPressed = useCallback(() => { trackOnboardingEvent({ step: currentStep + 1, // Use 1-based index for event tracking to match total_steps count. diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 816722a2ce3..a4e370769c3 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -631,6 +631,9 @@ enum EVENT_NAME { CARD_DELEGATION_PROCESS_COMPLETED = 'Card Delegation Process Completed', CARD_DELEGATION_PROCESS_FAILED = 'Card Delegation Process Failed', CARD_DELEGATION_PROCESS_USER_CANCELED = 'Card Delegation Process User Canceled', + CARD_MONEY_ACCOUNT_LINKING_STARTED = 'Card Money Account Linking Started', + CARD_MONEY_ACCOUNT_LINKING_COMPLETED = 'Card Money Account Linking Completed', + CARD_MONEY_ACCOUNT_LINKING_FAILED = 'Card Money Account Linking Failed', CARD_PUSH_PROVISIONING_STARTED = 'Card Push Provisioning Started', CARD_PUSH_PROVISIONING_COMPLETED = 'Card Push Provisioning Completed', CARD_PUSH_PROVISIONING_FAILED = 'Card Push Provisioning Failed', @@ -1790,6 +1793,15 @@ const events = { CARD_DELEGATION_PROCESS_USER_CANCELED: generateOpt( EVENT_NAME.CARD_DELEGATION_PROCESS_USER_CANCELED, ), + CARD_MONEY_ACCOUNT_LINKING_STARTED: generateOpt( + EVENT_NAME.CARD_MONEY_ACCOUNT_LINKING_STARTED, + ), + CARD_MONEY_ACCOUNT_LINKING_COMPLETED: generateOpt( + EVENT_NAME.CARD_MONEY_ACCOUNT_LINKING_COMPLETED, + ), + CARD_MONEY_ACCOUNT_LINKING_FAILED: generateOpt( + EVENT_NAME.CARD_MONEY_ACCOUNT_LINKING_FAILED, + ), CARD_PUSH_PROVISIONING_STARTED: generateOpt( EVENT_NAME.CARD_PUSH_PROVISIONING_STARTED, ), diff --git a/app/core/redux/slices/card/index.test.ts b/app/core/redux/slices/card/index.test.ts index 67133c73bb7..a637234ae66 100644 --- a/app/core/redux/slices/card/index.test.ts +++ b/app/core/redux/slices/card/index.test.ts @@ -15,6 +15,7 @@ import cardReducer, { setPendingMoneyAccountCardLink, selectPendingMoneyAccountCardLink, } from '.'; +import { CardEntryPoint } from '../../../../components/UI/Card/util/metrics'; // eslint-disable-next-line @typescript-eslint/no-unused-vars const CARD_STATE_MOCK: CardSliceState = { @@ -25,7 +26,7 @@ const CARD_STATE_MOCK: CardSliceState = { contactVerificationId: null, consentSetId: null, }, - pendingMoneyAccountCardLink: false, + pendingMoneyAccountCardLink: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, }; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -37,7 +38,7 @@ const EMPTY_CARD_STATE_MOCK: CardSliceState = { contactVerificationId: null, consentSetId: null, }, - pendingMoneyAccountCardLink: false, + pendingMoneyAccountCardLink: null, }; describe('Card Selectors', () => { @@ -58,18 +59,22 @@ describe('Card Selectors', () => { }); describe('selectPendingMoneyAccountCardLink', () => { - it('returns false by default from initial state', () => { + it('returns null by default from initial state', () => { const mockRootState = { card: initialState } as unknown as RootState; - expect(selectPendingMoneyAccountCardLink(mockRootState)).toBe(false); + expect(selectPendingMoneyAccountCardLink(mockRootState)).toBe(null); }); - it('returns true when pendingMoneyAccountCardLink is true', () => { - const stateWithFlag: CardSliceState = { + it('returns the pending Money Account Card link entrypoint', () => { + const stateWithEntryPoint: CardSliceState = { ...initialState, - pendingMoneyAccountCardLink: true, + pendingMoneyAccountCardLink: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, }; - const mockRootState = { card: stateWithFlag } as unknown as RootState; - expect(selectPendingMoneyAccountCardLink(mockRootState)).toBe(true); + const mockRootState = { + card: stateWithEntryPoint, + } as unknown as RootState; + expect(selectPendingMoneyAccountCardLink(mockRootState)).toBe( + CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, + ); }); }); @@ -155,7 +160,7 @@ describe('Card Reducer', () => { contactVerificationId: null, consentSetId: null, }, - pendingMoneyAccountCardLink: true, + pendingMoneyAccountCardLink: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, }; const state = cardReducer(currentState, resetCardState()); @@ -164,24 +169,30 @@ describe('Card Reducer', () => { }); describe('setPendingMoneyAccountCardLink', () => { - it('sets pendingMoneyAccountCardLink to true', () => { + it('stores the entrypoint for post-auth sheet resume', () => { const state = cardReducer( initialState, - setPendingMoneyAccountCardLink(true), + setPendingMoneyAccountCardLink( + CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, + ), + ); + + expect(state.pendingMoneyAccountCardLink).toBe( + CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, ); - expect(state.pendingMoneyAccountCardLink).toBe(true); }); - it('sets pendingMoneyAccountCardLink back to false', () => { + it('clears pending state when set to null', () => { const current: CardSliceState = { ...initialState, - pendingMoneyAccountCardLink: true, + pendingMoneyAccountCardLink: + CardEntryPoint.MONEY_HOME_ONBOARDING_CARD, }; const state = cardReducer( current, - setPendingMoneyAccountCardLink(false), + setPendingMoneyAccountCardLink(null), ); - expect(state.pendingMoneyAccountCardLink).toBe(false); + expect(state.pendingMoneyAccountCardLink).toBeNull(); }); }); diff --git a/app/core/redux/slices/card/index.ts b/app/core/redux/slices/card/index.ts index de6798a8cff..2dd836a655c 100644 --- a/app/core/redux/slices/card/index.ts +++ b/app/core/redux/slices/card/index.ts @@ -1,6 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSelector } from 'reselect'; import { RootState } from '../../../../reducers'; +import { CardEntryPoint } from '../../../../components/UI/Card/util/metrics'; export interface OnboardingState { onboardingId: string | null; @@ -12,7 +13,7 @@ export interface CardSliceState { hasViewedCardButton: boolean; onboarding: OnboardingState; isDaimoDemo: boolean; - pendingMoneyAccountCardLink: boolean; + pendingMoneyAccountCardLink: CardEntryPoint | null; } export const initialState: CardSliceState = { @@ -23,7 +24,7 @@ export const initialState: CardSliceState = { consentSetId: null, }, isDaimoDemo: false, - pendingMoneyAccountCardLink: false, + pendingMoneyAccountCardLink: null, }; const name = 'card'; @@ -55,7 +56,10 @@ const slice = createSlice({ consentSetId: null, }; }, - setPendingMoneyAccountCardLink: (state, action: PayloadAction) => { + setPendingMoneyAccountCardLink: ( + state, + action: PayloadAction, + ) => { state.pendingMoneyAccountCardLink = action.payload; }, }, diff --git a/app/store/persistConfig/index.ts b/app/store/persistConfig/index.ts index b17b83b5208..ad816519c68 100644 --- a/app/store/persistConfig/index.ts +++ b/app/store/persistConfig/index.ts @@ -3,6 +3,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import FilesystemStorage from 'redux-persist-filesystem-storage'; import autoMergeLevel2 from 'redux-persist/lib/stateReconciler/autoMergeLevel2'; import { RootState } from '../../reducers'; +import type { CardSliceState } from '../../core/redux/slices/card'; import { version, migrations } from '../migrations'; import Logger from '../../util/Logger'; import Device from '../../util/device'; @@ -182,6 +183,24 @@ const persistOnboardingTransform = createTransform( { whitelist: ['onboarding'] }, ); +type PersistedCardState = Omit; + +const persistCardTransform = createTransform< + CardSliceState, + PersistedCardState +>( + (inboundState) => { + const { pendingMoneyAccountCardLink: _omitSession, ...state } = + inboundState; + return state; + }, + (outboundState) => ({ + ...outboundState, + pendingMoneyAccountCardLink: null, + }), + { whitelist: ['card'] }, +); + const persistConfig = { key: 'root', version, @@ -195,7 +214,11 @@ const persistConfig = { 'securityAlerts', ], storage: MigratedStorage, - transforms: [persistUserTransform, persistOnboardingTransform], + transforms: [ + persistUserTransform, + persistOnboardingTransform, + persistCardTransform, + ], stateReconciler: autoMergeLevel2, // see "Merge Process" section for details. migrate: createMigrate(migrations, { debug: false, diff --git a/app/store/persistConfig/persistConfig.test.ts b/app/store/persistConfig/persistConfig.test.ts index 8e140bbb85d..a35b4914083 100644 --- a/app/store/persistConfig/persistConfig.test.ts +++ b/app/store/persistConfig/persistConfig.test.ts @@ -368,7 +368,7 @@ describe('persistConfig', () => { describe('transforms', () => { it('have correct number of transforms', () => { - expect(persistConfig.transforms).toHaveLength(2); + expect(persistConfig.transforms).toHaveLength(3); }); it('have user transform configured', () => { @@ -386,6 +386,14 @@ describe('persistConfig', () => { > & { whitelist?: string[] }; expect(onboardingTransform.whitelist).toEqual(['onboarding']); }); + + it('has card transform configured', () => { + const cardTransform = persistConfig.transforms[2] as Transform< + unknown, + unknown + > & { whitelist?: string[] }; + expect(cardTransform.whitelist).toEqual(['card']); + }); }); describe('createPersistController', () => { From 79290233006259e6afa7481202fc8ae4c0687b02 Mon Sep 17 00:00:00 2001 From: Vince Howard Date: Wed, 10 Jun 2026 15:10:52 -0600 Subject: [PATCH 09/12] fix: remove RiveRenderer.defaultRenderer Canvas override on Android to prevent Earn onboarding crash (#31475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Removed the global `RiveRenderer.defaultRenderer(..., RiveRendererAndroid.Canvas)` call in `FoxLoader` during app init. Although this lived in the splash-screen loader, `defaultRenderer` is a process-wide setting that forced every Rive view in the app onto Android's Canvas renderer `Surface.lockCanvas path`. This caused the Money/Earn onboarding stepper to hard-crash `SIGABRT`/`SIGSEGV` on Android when the Rive view tore down during tab navigation, the worker thread called `lockCanvas` on an already-released Surface, leaving a pending JNI exception that ART aborted on. Tombstone confirmed on Pixel 6a / Android 16. Reverting to the default Rive GPU renderer removes the `lockCanvas` code path entirely. iOS is unaffected (already uses the GPU renderer). ## **Changelog** CHANGELOG entry: Fixed an Android crash when entering Earn onboarding caused by the Rive Canvas renderer being forced app-wide. ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/31166 https://github.com/MetaMask/metamask-mobile/issues/31167 ## **Manual testing steps** ```gherkin Feature: Earn onboarding no longer crashes on Android Scenario: User enters Earn onboarding on Android after fresh install Given the app is built in release mode on a Pixel 6a (or equivalent) running Android 16 And the user has completed initial app load (FoxLoader splash animation plays) When the user taps the Money/Earn tab And the Rive onboarding stepper mounts Then the app does not crash And the onboarding animation renders and steps through normally Scenario: Splash animation still plays on Android Given a fresh launch on Android When the FoxLoader splash screen appears Then the fox Rive animation plays to completion without visual regressions ``` ## **Screenshots/Recordings** https://github.com/user-attachments/assets/32c75ebd-8a93-4afb-ae8b-250fe4540b4f ### **Before** ### Crash on open https://github.com/user-attachments/assets/75339490-e41f-4c42-9853-c6bce7baaffc ### Crash on close https://github.com/user-attachments/assets/8fe061e9-6e5d-4c21-8d46-06082783de90 ### **After** https://github.com/user-attachments/assets/32c75ebd-8a93-4afb-ae8b-250fe4540b4f ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Small splash-only change with process-wide rendering impact on Android; fixes a production crash but may reintroduce any original Canvas-renderer motivation (e.g. splash geometry). > > **Overview** > Removes the **Android-only** `RiveRenderer.defaultRenderer(..., RiveRendererAndroid.Canvas)` setup from `FoxLoader` so Rive is no longer forced app-wide onto the Canvas renderer during splash init. > > That global override affected every Rive view (not just the fox splash), which led to **SIGABRT/SIGSEGV** when Earn/Money onboarding Rive UI tore down during tab navigation (`lockCanvas` on a released Surface). **iOS is unchanged**; Android again uses Rive’s default GPU renderer. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit a21235664aad3157cbe0df05e890829ab38bcbaa. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- app/components/UI/FoxLoader/FoxLoader.tsx | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/app/components/UI/FoxLoader/FoxLoader.tsx b/app/components/UI/FoxLoader/FoxLoader.tsx index f98366e08b0..087d07d115f 100644 --- a/app/components/UI/FoxLoader/FoxLoader.tsx +++ b/app/components/UI/FoxLoader/FoxLoader.tsx @@ -30,21 +30,6 @@ const ANIMATION_TIMEOUT_MS = 3_000; let animationStarted = false; let animationComplete = false; -// Use Canvas renderer on Android — the default Rive SurfaceView causes geometry distortion -if (Platform.OS === 'android') { - try { - RiveRenderer.defaultRenderer( - RiveRendererIOS.Rive, - RiveRendererAndroid.Canvas, - ); - } catch (error) { - Logger.error( - error as Error, - 'Failed to set Rive Canvas renderer on Android', - ); - } -} - interface FoxLoaderProps { appServicesReady?: boolean; onAnimationComplete?: () => void; From 23b52d7bf49adc7289ec5eae4fa54ffd21d9acf2 Mon Sep 17 00:00:00 2001 From: Aslau Mario-Daniel Date: Thu, 11 Jun 2026 00:22:09 +0300 Subject: [PATCH 10/12] feat(predict): Add generic Predict feed route and deeplink handling (#31489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Part of parent epic **PRED-834 — IA & Navigation Overhaul**. **Reason for the change** The Predict home redesign introduces multiple config-driven feeds (sports, politics, crypto, live, trending, popular-today). Until now there was no single generic route to render an arbitrary feed, and `handlePredictUrl` only knew about market detail links and the dedicated World Cup flow. We need one reusable route plus deeplink support so `feed=` links open the correct feed and so home sections can navigate to a full feed via their "See all" affordance. **What changed** - Added a generic, typed route `Routes.PREDICT.FEED` and registered its `Stack.Screen` (rendered by the existing config-driven `PredictFeedView`). - Extended `handlePredictUrl` so that: - `feed=world-cup` continues to route to the dedicated World Cup flow (unchanged). - Known generic feed IDs route to `PredictFeedView` (gated behind the `predictHomeRedesign` flag). - Unknown feeds and the flag-off case fall back gracefully to the Predict home/market list. - `tab`, `filter`, and `q`/`query` are parsed; `filter` is parsed separately from `tab` (`tab -> initialTabId`, `filter -> initialFilterId`); `utm_source` and existing `market`/`marketId` links are preserved. - Wired the Live Now section "See all" chevron to navigate to the generic feed (`feedId: 'live'`), establishing the reference pattern for the other home sections. - Refactored the deeplink handler's raw tab field from the World-Cup-specific `worldCupTab` to a neutral `rawTab` shared by both the World Cup and generic-feed paths (decoupling). - Updated `docs/readme/deeplinking.md` to document the now-supported predict params. **Constraints / trade-offs** - Generic feed deeplinks are intentionally flag-gated on `predictHomeRedesign`; while the flag is off they fall back to the market list, so nothing is exposed prematurely during rollout. ## **Changelog** CHANGELOG entry: null ## **Related issues** Refs: PRED-834 Fixes: ## **Manual testing steps** > Generic-feed deeplinks require the `predictHomeRedesign` feature flag to be enabled. World Cup and market links work regardless of the flag. ```gherkin Feature: Generic Predict feed route and deeplinks Scenario: Known generic feed deeplink opens PredictFeedView (flag on) Given the predictHomeRedesign flag is enabled When the user opens the deeplink "/predict?feed=sports&tab=all&filter=live" Then the app opens PredictFeedView for the sports feed And the "all" tab and the "live" filter are pre-selected Scenario: Search deeplink opens the feed with the search overlay Given the predictHomeRedesign flag is enabled When the user opens the deeplink "/predict?feed=trending&q=bitcoin" Then the app opens PredictFeedView for the trending feed And the search is pre-populated with "bitcoin" Scenario: World Cup deeplink is unchanged When the user opens the deeplink "/predict?feed=world-cup&tab=live" Then the app opens the dedicated World Cup flow on the live tab Scenario: Unknown feed falls back gracefully Given the predictHomeRedesign flag is enabled When the user opens the deeplink "/predict?feed=not-a-real-feed" Then the app falls back to the Predict home/market list Scenario: Generic feed falls back when the flag is off Given the predictHomeRedesign flag is disabled When the user opens the deeplink "/predict?feed=sports" Then the app falls back to the Predict home/market list Scenario: Existing market deeplink still works When the user opens the deeplink "/predict?market=23246&utm_source=test" Then the app opens the market details for that market Scenario: Live Now "See all" navigates to the generic feed Given the predictHomeRedesign flag is enabled and the Predict home is visible When the user taps "See all" on the Live Now section Then the app opens PredictFeedView for the "live" feed ``` ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/043c5453-6038-4352-9d0f-149ccdb1fcdc ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - [x] I've tested with a power user scenario - [x] I've instrumented key operations with Sentry traces for production performance metrics ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- ## Files changed | File | Change | | --- | --- | | `app/constants/navigation/Routes.ts` | Added `PREDICT.FEED: 'PredictFeed'` route constant | | `app/components/UI/Predict/routes/index.tsx` | Registered the `PredictFeedView` `Stack.Screen` for `Routes.PREDICT.FEED` | | `app/core/DeeplinkManager/handlers/legacy/handlePredictUrl.ts` | Parse `filter` separately; flag-gated generic-feed routing; `worldCupTab` -> `rawTab`; updated TSDoc | | `app/components/UI/Predict/views/PredictHome/components/PredictLiveNowSection/PredictLiveNowSection.tsx` | Wired "See all" to navigate to the generic feed (`feedId: 'live'`) | | `docs/readme/deeplinking.md` | Documented predict params (`feed`/`tab`/`filter`/`q`/`query`) | | `app/core/DeeplinkManager/handlers/legacy/__tests__/handlePredictUrl.test.ts` | Generic-feed, tab/filter, fallback, query, and World Cup compatibility tests | | `app/components/UI/Predict/routes/index.test.tsx` | `Routes.PREDICT.FEED` registration test | | `app/components/UI/Predict/views/.../PredictLiveNowSection.test.tsx` | "See all" navigation test | ## Acceptance criteria coverage - [x] Generic feed route is typed and registered - [x] Deeplinks with known generic feed IDs open `PredictFeedView` - [x] `filter` query param is parsed separately from `tab` - [x] `feed=world-cup` still opens the dedicated World Cup flow - [x] Unknown feeds gracefully fall back to Predict home/market list - [x] Existing query/search and market deeplinks still work - [x] Tests updated (`handlePredictUrl.test.ts`, route registration); manual QA pending recordings --- > [!NOTE] > **Medium Risk** > Touches deeplink routing and entry-point analytics for Predict; behavior is gated behind `predictHomeRedesign` with market-list fallbacks, but incorrect feed/tab mapping could misroute users from external links. > > **Overview** > Adds a **generic Predict feed** navigation path so config-driven feeds (sports, live, trending, etc.) can open in one screen, including from deeplinks and home “See all” actions. > > **Navigation:** New `Routes.PREDICT.FEED` registers `PredictFeedView` on the Predict stack. **Live Now** on Predict home is no longer a static header—the section header is pressable and navigates to `PredictFeedView` with `feedId: 'live'` and home-section analytics. > > **Deeplinks (`handlePredictUrl`):** Parses `filter` separately from `tab` (`tab` → `initialTabId`, `filter` → `initialFilterId`). When `predictHomeRedesign` is on and `feed` is a known generic id, navigation goes to `PredictFeedView` (with optional `q`/`query` and `utm_source` on `entryPoint`). `feed=world-cup` still uses the World Cup flow; unknown feeds or flag-off fall back to the market list; `market` still wins over `feed`. Deeplink docs list the new predict query params. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 7353b4065a679efeca13ddb33b22ad836db4d431. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../UI/Predict/routes/index.test.tsx | 17 ++ app/components/UI/Predict/routes/index.tsx | 3 + .../PredictLiveNowSection.test.tsx | 33 +++- .../PredictLiveNowSection.tsx | 25 ++- app/constants/navigation/Routes.ts | 1 + .../legacy/__tests__/handlePredictUrl.test.ts | 150 +++++++++++++++++- .../handlers/legacy/handlePredictUrl.ts | 79 ++++++++- docs/readme/deeplinking.md | 44 ++--- 8 files changed, 314 insertions(+), 38 deletions(-) diff --git a/app/components/UI/Predict/routes/index.test.tsx b/app/components/UI/Predict/routes/index.test.tsx index 6378480ef71..4abb756732b 100644 --- a/app/components/UI/Predict/routes/index.test.tsx +++ b/app/components/UI/Predict/routes/index.test.tsx @@ -69,6 +69,11 @@ jest.mock('../views/PredictWorldCup', () => { return () => ; }); +jest.mock('../views/PredictFeedView', () => { + const { View } = jest.requireActual('react-native'); + return () => ; +}); + jest.mock('../views/PredictMarketDetails', () => { const { View } = jest.requireActual('react-native'); return () => ; @@ -196,6 +201,18 @@ describe('PredictScreenStack', () => { expect(screen.getByTestId('predict-world-cup')).toBeOnTheScreen(); }); + it('navigates to FEED screen', async () => { + renderWithNavigation(); + + await act(async () => { + navigationRef.current?.navigate(Routes.PREDICT.FEED, { + feedId: 'sports', + }); + }); + + expect(screen.getByTestId('predict-feed-view')).toBeOnTheScreen(); + }); + it('navigates to POSITIONS screen when portfolio flag is enabled', async () => { mockPredictPortfolioEnabled = true; renderWithNavigation(); diff --git a/app/components/UI/Predict/routes/index.tsx b/app/components/UI/Predict/routes/index.tsx index cead671f0a1..3981950189a 100644 --- a/app/components/UI/Predict/routes/index.tsx +++ b/app/components/UI/Predict/routes/index.tsx @@ -18,6 +18,7 @@ import PredictAddFundsModal from '../views/PredictAddFundsModal/PredictAddFundsM import PredictPositionsView from '../views/PredictPositionsView'; import PredictMarketListRoute from './PredictMarketListRoute'; import PredictWorldCup from '../views/PredictWorldCup'; +import PredictFeedView from '../views/PredictFeedView'; import PredictGTMModal from '../components/PredictGTMModal'; import { useSelector } from 'react-redux'; import { PredictPreviewSheetProvider } from '../contexts'; @@ -98,6 +99,8 @@ const PredictScreenStack = () => { component={PredictWorldCup} /> + + ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ navigate: mockNavigate }), +})); + // Mock only the data boundary: the section's own data hook. The hook's own // logic (params, filtering, interleave, loading) is covered by // usePredictLiveNowSection.test.ts. Here we exercise the real PredictMarket / @@ -189,18 +198,26 @@ describe('PredictLiveNowSection', () => { ).not.toBeOnTheScreen(); }); - it('renders a non-pressable "Live now" header without a navigation chevron', () => { + it('renders a pressable "Live now" header that navigates to the live feed', () => { setSection({ items: [createLiveMarket('L1')] }); - const { getByTestId, queryByTestId, getByText } = renderSection(); + const { getByTestId, getByText } = renderSection(); - expect( - getByTestId(PREDICT_LIVE_NOW_SECTION_TEST_IDS.HEADER), - ).toBeOnTheScreen(); + const header = getByTestId(PREDICT_LIVE_NOW_SECTION_TEST_IDS.HEADER); + expect(header).toBeOnTheScreen(); expect(getByText(strings('predict.home.live_now_title'))).toBeOnTheScreen(); - // The chevron only renders when the header is pressable (onPress set); the - // "See all" target route does not exist yet, so it must be absent. - expect(queryByTestId('section-header-arrow-icon')).not.toBeOnTheScreen(); + // The chevron renders only when the header is pressable (onPress set). + expect(getByTestId('section-header-arrow-icon')).toBeOnTheScreen(); + + fireEvent.press(header); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.FEED, + params: { + feedId: 'live', + entryPoint: PredictEventValues.ENTRY_POINT.HOME_SECTION, + }, + }); }); it('renders pagination dots when there are 2+ items after load', () => { diff --git a/app/components/UI/Predict/views/PredictHome/components/PredictLiveNowSection/PredictLiveNowSection.tsx b/app/components/UI/Predict/views/PredictHome/components/PredictLiveNowSection/PredictLiveNowSection.tsx index 7e7c1403871..f4c3d983cff 100644 --- a/app/components/UI/Predict/views/PredictHome/components/PredictLiveNowSection/PredictLiveNowSection.tsx +++ b/app/components/UI/Predict/views/PredictHome/components/PredictLiveNowSection/PredictLiveNowSection.tsx @@ -7,13 +7,16 @@ import { import { Box, BoxBorderColor } from '@metamask/design-system-react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { FlashList, type ListRenderItem } from '@shopify/flash-list'; +import { useNavigation, type NavigationProp } from '@react-navigation/native'; import { strings } from '../../../../../../../../locales/i18n'; +import Routes from '../../../../../../../constants/navigation/Routes'; import SectionHeader from '../../../../../../../component-library/components-temp/SectionHeader'; import PredictMarket from '../../../../components/PredictMarket'; import PredictMarketSkeleton from '../../../../components/PredictMarketSkeleton'; import { PaginationDots } from '../../../../components/PaginationDots/PaginationDots'; import { PredictEventValues } from '../../../../constants/eventNames'; import type { PredictMarket as PredictMarketType } from '../../../../types'; +import type { PredictNavigationParamList } from '../../../../types/navigation'; import { PREDICT_LIVE_NOW_SECTION_TEST_IDS } from './PredictLiveNowSection.testIds'; import { usePredictLiveNowSection } from './usePredictLiveNowSection'; @@ -43,6 +46,8 @@ const PredictLiveNowSection: React.FC = ({ testID = PREDICT_LIVE_NOW_SECTION_TEST_IDS.SECTION, }) => { const tw = useTailwind(); + const navigation = + useNavigation>(); const { width: screenWidth } = useWindowDimensions(); const cardWidth = useMemo( () => screenWidth * CARD_WIDTH_RATIO, @@ -54,6 +59,16 @@ const PredictLiveNowSection: React.FC = ({ const { items, isLoading, isEmpty } = usePredictLiveNowSection(); const [activeIndex, setActiveIndex] = useState(0); + const handleSeeAll = useCallback(() => { + navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.FEED, + params: { + feedId: 'live', + entryPoint: PredictEventValues.ENTRY_POINT.HOME_SECTION, + }, + }); + }, [navigation]); + useEffect(() => { const lastIndex = items.length - 1; setActiveIndex((prev) => @@ -124,14 +139,14 @@ const PredictLiveNowSection: React.FC = ({ return ( - {/* Static (non-pressable) header for now: the "See all" target — a generic - PredictFeedView with feedId 'live' — does not exist yet, so we render - plain header text rather than a dead control. Pass `onPress` (and the - chevron returns) once Routes.PREDICT.FEED lands. See - predict-home-redesign.md (Upcoming Tickets A + B). */} + {/* "See all" navigates to the generic PredictFeedView (feedId 'live'). + Passing `onPress` is what renders the chevron + touchable (see + SectionHeader). This is the reference pattern other home sections + (Trending / Popular Today / Categories) follow with their own feedId. */} diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 2d136f52fb8..a9ce8ea1e23 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -402,6 +402,7 @@ const Routes = { POSITIONS: 'PredictPositions', ACTIVITY_DETAIL: 'PredictActivityDetail', WORLD_CUP: 'PredictWorldCup', + FEED: 'PredictFeed', MODALS: { ROOT: 'PredictModals', BUY_PREVIEW: 'PredictBuyPreview', diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handlePredictUrl.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handlePredictUrl.test.ts index dea6a1b8228..d302b2cb564 100644 --- a/app/core/DeeplinkManager/handlers/legacy/__tests__/handlePredictUrl.test.ts +++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handlePredictUrl.test.ts @@ -3,7 +3,10 @@ import NavigationService from '../../../../NavigationService'; import Routes from '../../../../../constants/navigation/Routes'; import DevLogger from '../../../../SDKConnect/utils/DevLogger'; import { DEFAULT_PREDICT_WORLD_CUP_FLAG } from '../../../../../components/UI/Predict/constants/flags'; -import { selectPredictWorldCupConfig } from '../../../../../components/UI/Predict/selectors/featureFlags'; +import { + selectPredictHomeRedesignEnabledFlag, + selectPredictWorldCupConfig, +} from '../../../../../components/UI/Predict/selectors/featureFlags'; // Mock dependencies jest.mock('../../../../NavigationService'); @@ -20,6 +23,7 @@ jest.mock( '../../../../../components/UI/Predict/selectors/featureFlags', () => ({ selectPredictWorldCupConfig: jest.fn(), + selectPredictHomeRedesignEnabledFlag: jest.fn(), }), ); @@ -40,6 +44,10 @@ describe('handlePredictUrl', () => { jest .mocked(selectPredictWorldCupConfig) .mockReturnValue(DEFAULT_PREDICT_WORLD_CUP_FLAG); + // Generic feed routing is gated by the home redesign flag; default it on so + // generic-feed tests exercise the FEED path. Flag-off behavior is covered + // by a dedicated test below. + jest.mocked(selectPredictHomeRedesignEnabledFlag).mockReturnValue(true); }); describe('with market parameter', () => { @@ -376,6 +384,144 @@ describe('handlePredictUrl', () => { }); }); + describe('with generic feed parameter', () => { + it('navigates to the generic feed for a known feed id', async () => { + await handlePredictUrl({ predictPath: '?feed=sports' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.FEED, + params: { + feedId: 'sports', + entryPoint: 'deeplink', + }, + }); + }); + + it('passes tab as initialTabId and filter as initialFilterId', async () => { + await handlePredictUrl({ + predictPath: '?feed=sports&tab=all&filter=live', + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.FEED, + params: { + feedId: 'sports', + initialTabId: 'all', + initialFilterId: 'live', + entryPoint: 'deeplink', + }, + }); + }); + + it('parses filter separately from tab', async () => { + await handlePredictUrl({ + predictPath: '?feed=popular-today&filter=elections', + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.FEED, + params: { + feedId: 'popular-today', + initialFilterId: 'elections', + entryPoint: 'deeplink', + }, + }); + }); + + it('preserves the search query for a generic feed', async () => { + await handlePredictUrl({ predictPath: '?feed=trending&q=bitcoin' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.FEED, + params: { + feedId: 'trending', + query: 'bitcoin', + entryPoint: 'deeplink', + }, + }); + }); + + it('preserves utm_source attribution for generic feed links', async () => { + await handlePredictUrl({ + predictPath: '?feed=crypto&utm_source=twitter', + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.FEED, + params: { + feedId: 'crypto', + entryPoint: 'deeplink_twitter', + }, + }); + }); + + it('normalizes an uppercase feed id to lowercase', async () => { + await handlePredictUrl({ predictPath: '?feed=POLITICS' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.FEED, + params: { + feedId: 'politics', + entryPoint: 'deeplink', + }, + }); + }); + + it('falls back to the market list for an unknown feed id', async () => { + await handlePredictUrl({ predictPath: '?feed=unknown-feed' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + params: { + entryPoint: 'deeplink', + }, + }); + }); + + it('falls back to the market list when home redesign flag is disabled', async () => { + jest.mocked(selectPredictHomeRedesignEnabledFlag).mockReturnValue(false); + + await handlePredictUrl({ predictPath: '?feed=sports&tab=all' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + params: { + entryPoint: 'deeplink', + }, + }); + }); + + it('prioritizes market parameter over a generic feed', async () => { + await handlePredictUrl({ predictPath: '?feed=sports&market=123' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: '123', + entryPoint: 'deeplink', + }, + }); + }); + + it('routes feed=world-cup to the World Cup flow, not the generic feed', async () => { + jest.mocked(selectPredictWorldCupConfig).mockReturnValue({ + ...DEFAULT_PREDICT_WORLD_CUP_FLAG, + enabled: true, + showWorldCupScreen: true, + }); + + await handlePredictUrl({ predictPath: '?feed=world-cup&tab=live' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.WORLD_CUP, + params: { + entryPoint: 'deeplink', + initialTab: 'live', + }, + }); + }); + }); + describe('with query parameter', () => { it('navigates to market list with query parameter', async () => { await handlePredictUrl({ predictPath: '?query=bitcoin' }); @@ -547,6 +693,7 @@ describe('handlePredictUrl', () => { tab: undefined, worldCupTab: undefined, feed: undefined, + filter: undefined, query: undefined, }, ); @@ -840,6 +987,7 @@ describe('handlePredictUrl', () => { tab: undefined, worldCupTab: undefined, feed: undefined, + filter: undefined, query: undefined, }, ); diff --git a/app/core/DeeplinkManager/handlers/legacy/handlePredictUrl.ts b/app/core/DeeplinkManager/handlers/legacy/handlePredictUrl.ts index b38281f59f4..18a5323d0e4 100644 --- a/app/core/DeeplinkManager/handlers/legacy/handlePredictUrl.ts +++ b/app/core/DeeplinkManager/handlers/legacy/handlePredictUrl.ts @@ -12,8 +12,15 @@ import { type PredictWorldCupTabKey, } from '../../../../components/UI/Predict/constants/worldCupTabs'; import { DEFAULT_PREDICT_WORLD_CUP_FLAG } from '../../../../components/UI/Predict/constants/flags'; -import { selectPredictWorldCupConfig } from '../../../../components/UI/Predict/selectors/featureFlags'; +import { + selectPredictHomeRedesignEnabledFlag, + selectPredictWorldCupConfig, +} from '../../../../components/UI/Predict/selectors/featureFlags'; import type { PredictWorldCupConfig } from '../../../../components/UI/Predict/types/flags'; +import { + isPredictFeedId, + type PredictFeedId, +} from '../../../../components/UI/Predict/constants/feedConfig'; interface HandlePredictUrlParams { predictPath: string; @@ -27,8 +34,12 @@ interface PredictNavigationParams { market?: string; // Market ID utmSource?: string; // UTM source for analytics tracking tab?: PredictTabKey; // Feed tab (when no market param) - worldCupTab?: PredictWorldCupTabKey; // World Cup feed initial tab + // TODO: `worldCupTab` holds the raw (unvalidated) tab value and is also reused + // by the generic feed. Remove/rename to a neutral field once the World Cup + // feature is sunset. + worldCupTab?: PredictWorldCupTabKey; // World Cup feed initial tab (raw tab value) feed?: string; // Dedicated feed key + filter?: string; // Generic feed filter chip id (parsed separately from tab) query?: string; // Search query (when no market param) } @@ -49,6 +60,7 @@ const parsePredictNavigationParams = ( const tabParam = urlParams.get('tab')?.toLowerCase(); const tab = isPredictTabKey(tabParam) ? tabParam : undefined; const feed = urlParams.get('feed')?.toLowerCase(); + const filter = urlParams.get('filter')?.toLowerCase(); const query = urlParams.get('query') || urlParams.get('q') || undefined; return { @@ -57,6 +69,7 @@ const parsePredictNavigationParams = ( tab, worldCupTab: tabParam, feed: feed || undefined, + filter: filter || undefined, query, }; }; @@ -73,6 +86,18 @@ const getPredictWorldCupConfig = (): PredictWorldCupConfig => { } }; +const getPredictHomeRedesignEnabled = (): boolean => { + try { + return selectPredictHomeRedesignEnabledFlag(ReduxService.store.getState()); + } catch (error) { + DevLogger.log( + '[handlePredictUrl] Unable to read home redesign flag, defaulting to disabled:', + error, + ); + return false; + } +}; + const getMarketListParams = ({ entryPoint, tab, @@ -128,6 +153,37 @@ const handleWorldCupNavigation = ({ handleMarketListNavigation({ entryPoint }); }; +/** + * Handle navigation to a generic, config-driven Predict feed (PredictFeedView). + * @param params Resolved generic feed params + */ +const handleGenericFeedNavigation = ({ + feedId, + initialTabId, + initialFilterId, + query, + entryPoint, +}: { + feedId: PredictFeedId; + initialTabId?: string; + initialFilterId?: string; + query?: string; + entryPoint: string; +}) => { + DevLogger.log('[handlePredictUrl] Navigating to generic feed:', feedId); + + NavigationService.navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.FEED, + params: { + feedId, + ...(initialTabId && { initialTabId }), + ...(initialFilterId && { initialFilterId }), + ...(query && { query }), + entryPoint, + }, + }); +}; + /** * Handle market-specific navigation * @param marketId The market ID to navigate to @@ -175,6 +231,10 @@ const handleMarketNavigation = (marketId: string, entryPoint: string) => { * - https://link.metamask.io/predict?query=bitcoin * - https://link.metamask.io/predict?feed=world-cup * - https://link.metamask.io/predict?feed=world-cup&tab=live + * - https://link.metamask.io/predict?feed=sports + * - https://link.metamask.io/predict?feed=sports&tab=all&filter=live + * - https://link.metamask.io/predict?feed=popular-today&filter=elections + * - https://link.metamask.io/predict?feed=trending&q=bitcoin * * Origin/EntryPoint handling: * - Base entryPoint is origin if provided, otherwise 'deeplink' @@ -185,6 +245,8 @@ const handleMarketNavigation = (marketId: string, entryPoint: string) => { * - No market param: Navigate to market list * - market=X or marketId=X: Navigate directly to market details for market X * - feed=world-cup: Navigate to the dedicated World Cup feed when enabled + * - feed= (sports/politics/crypto/live/trending/popular-today): Navigate to the generic PredictFeedView when the predictHomeRedesign flag is enabled (tab -> initialTabId, filter -> initialFilterId) + * - Unknown feed (or flag disabled): Fall back to the Predict market list * - Optional tab param when no market: Open feed on a specific tab * - query=X or q=X: Open feed with search overlay showing results for X */ @@ -227,6 +289,19 @@ export const handlePredictUrl = async ({ requestedTab: navParams.worldCupTab, entryPoint, }); + } else if ( + isPredictFeedId(navParams.feed) && + getPredictHomeRedesignEnabled() + ) { + handleGenericFeedNavigation({ + feedId: navParams.feed, + // worldCupTab holds the raw (unvalidated) tab value; the generic feed's + // sub-tab ids (e.g. basketball/all/live) are resolved by the view. + initialTabId: navParams.worldCupTab, + initialFilterId: navParams.filter, + query: navParams.query, + entryPoint, + }); } else { DevLogger.log('[handlePredictUrl] No market parameter, showing list'); handleMarketListNavigation({ diff --git a/docs/readme/deeplinking.md b/docs/readme/deeplinking.md index d68a8d90bc5..7a31ca203c1 100644 --- a/docs/readme/deeplinking.md +++ b/docs/readme/deeplinking.md @@ -886,28 +886,28 @@ describe('Dynamic signature verification', () => { The `deposit` / `metamask://deposit` deeplink is **deprecated** and no longer opens the cash deposit flow; use buy/universal on-ramp entry points instead. -| Action | Purpose | Handler Function | Notes | -| ---------------------- | --------------------------- | ------------------------ | ----------------------------------------------- | -| `swap` | Token swap/bridge (CAIP-19) | `handleSwapUrl` | Params: `from`, `to`, `amount` (CAIP-19) | -| `buy` / `buy-crypto` | Buy crypto | `handleRampUrl` | | -| `sell` / `sell-crypto` | Sell crypto | `handleRampUrl` | | -| `send` | Send transaction | Recursive `parse()` call | | -| `home` | Navigate home | `navigateToHomeUrl` | Params: `previewToken`, `openNetworkSelector` | -| `asset` | Asset overview | `handleAssetUrl` | Params: `assetId` (CAIP-19) | -| `dapp` | Open dApp browser | `handleBrowserUrl` | | -| `create-account` | Create new account | `handleCreateAccountUrl` | | -| `perps` | Perpetuals trading | `handlePerpsUrl` | Params: `screen` (tabs/markets/asset), `symbol` | -| `perps-markets` | Perps markets list | `handlePerpsUrl` | | -| `perps-asset` | Perps specific asset | ⚠️ NOT IMPLEMENTED | Action defined but missing handler case | -| `predict` | Prediction markets | `handlePredictUrl` | Params: `market` or `marketId`, `utm_source` | -| `rewards` | Rewards program | `handleRewardsUrl` | Params: `referral` (referral code) | -| `trending` | Explore / Trending | `handleTrendingUrl` | Params: `screen=stocks` (geo-block falls back) | -| `wc` | WalletConnect | Recursive `parse()` call | | -| `onboarding` | Fast onboarding | `handleFastOnboarding` | | -| `enable-card-button` | Enable card feature | `handleEnableCardButton` | | -| `bind` | Android SDK binding | `handleMetaMaskDeeplink` | | -| `connect` | SDK connection | `handleMetaMaskDeeplink` | | -| `mmsdk` | MetaMask SDK message | `handleMetaMaskDeeplink` | | +| Action | Purpose | Handler Function | Notes | +| ---------------------- | --------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------- | +| `swap` | Token swap/bridge (CAIP-19) | `handleSwapUrl` | Params: `from`, `to`, `amount` (CAIP-19) | +| `buy` / `buy-crypto` | Buy crypto | `handleRampUrl` | | +| `sell` / `sell-crypto` | Sell crypto | `handleRampUrl` | | +| `send` | Send transaction | Recursive `parse()` call | | +| `home` | Navigate home | `navigateToHomeUrl` | Params: `previewToken`, `openNetworkSelector` | +| `asset` | Asset overview | `handleAssetUrl` | Params: `assetId` (CAIP-19) | +| `dapp` | Open dApp browser | `handleBrowserUrl` | | +| `create-account` | Create new account | `handleCreateAccountUrl` | | +| `perps` | Perpetuals trading | `handlePerpsUrl` | Params: `screen` (tabs/markets/asset), `symbol` | +| `perps-markets` | Perps markets list | `handlePerpsUrl` | | +| `perps-asset` | Perps specific asset | ⚠️ NOT IMPLEMENTED | Action defined but missing handler case | +| `predict` | Prediction markets | `handlePredictUrl` | Params: `market`/`marketId`, `feed` (generic ids + `world-cup`), `tab`, `filter`, `q`/`query`, `utm_source` | +| `rewards` | Rewards program | `handleRewardsUrl` | Params: `referral` (referral code) | +| `trending` | Explore / Trending | `handleTrendingUrl` | Params: `screen=stocks` (geo-block falls back) | +| `wc` | WalletConnect | Recursive `parse()` call | | +| `onboarding` | Fast onboarding | `handleFastOnboarding` | | +| `enable-card-button` | Enable card feature | `handleEnableCardButton` | | +| `bind` | Android SDK binding | `handleMetaMaskDeeplink` | | +| `connect` | SDK connection | `handleMetaMaskDeeplink` | | +| `mmsdk` | MetaMask SDK message | `handleMetaMaskDeeplink` | | > ⚠️ **Bug**: The `perps-asset` action is defined in `SUPPORTED_ACTIONS` and whitelisted, but **there is no case handler** for it in the switch statement. It will pass validation but silently do nothing. From 59061c86620bd9c6d59d004167912ef917f30742 Mon Sep 17 00:00:00 2001 From: cmd-ob Date: Wed, 10 Jun 2026 23:02:57 +0100 Subject: [PATCH 11/12] ci: integrate Appium smoke E2E into CI pipeline (#31182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Integrates Appium smoke tests into CI alongside existing Detox smoke jobs. - Adds **Playwright + Appium** smoke runner (`tests/smoke-appium/`, `tests/playwright.smoke-appium.config.ts`) with **SmokeAccounts** tag selection aligned to smart E2E. - **PR CI (Android only):** `appium-smoke-gate` + `appium-smoke-tests-android` in `ci.yml` after `build-android-apks` (Namespace `metamask-android-build` profile). Respects smart E2E tag selection via `scripts/e2e/appium-smoke-tags.mjs`. - **Main regression (iOS):** runs on `main` every 6 hours (4×/day). Builds iOS E2E artifact, prebuilds WDA in parallel, then runs `run-appium-smoke-tests-ios.yml` with all smoke tags. - iOS runner prep (`prepare-ios-appium-runner.mjs`): sim boot, WDA prebuild/cache, `simctl` install, optional WDA warm-up. Failure screen recordings, JUnit/HTML reports on both platforms. - Extends the Playwright/Appium framework: local emulator/sim boot, Appium server reuse (`SKIP_APPIUM_STOP`), Android cold-boot stabilization (AOSP image, ANR handling), iOS WDA preinstall path, encapsulated page-object branches for shared Detox flows. - Detox Jest config ignores `tests/smoke-appium/`; Appium smoke jobs are **not** merge-blocking (`check-all-jobs-pass` excludes them). Evidence of video recording working: https://github.com/user-attachments/assets/259296b5-aa48-4294-85a9-bb4ad312ae14 https://github.com/user-attachments/assets/37585d75-c37b-4bb8-af8b-8529fe26e505 ## **Changelog** CHANGELOG entry: null ## **Related issues** Refs: N/A — CI / test infrastructure only (no product ticket). ## **Manual testing steps** ```gherkin Feature: Appium smoke CI Scenario: SmokeAccounts SRP reveal runs on Android PR CI Given a PR triggers CI with Appium smoke enabled When the Android Appium smoke job runs on the Namespace runner Then the SmokeAccounts SRP reveal spec passes (or retries then passes) And failure screen recordings are uploaded when a test fails Scenario: SmokeAccounts SRP reveal runs on iOS scheduled regression Given main has run-appium-smoke-tests-ios-scheduled.yml When the scheduled workflow runs (or is triggered via workflow_dispatch) Then the iOS build, WDA prebuild, and SmokeAccounts spec complete And failure screen recordings are uploaded when a test fails ``` Local: `yarn appium-smoke:android` / `yarn appium-smoke:ios` with a debug E2E build. Manual iOS regression: **Actions → Appium Smoke Tests (iOS — main regression) → Run workflow** (after merge to `main`). ## **Screenshots/Recordings** N/A — CI failure screen recordings are uploaded as workflow artifacts (`appium-smoke-videos-*`) when a test fails. ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Large CI and test-infrastructure change (new workflows, emulator/WDA paths) with limited product code impact; Appium smoke is gated and non-merge-blocking, but failures could add CI noise and runner cost. > > **Overview** > Adds **Playwright + Appium** mobile smoke testing to CI and the local dev workflow, parallel to existing Detox smoke jobs. > > **CI:** PR pipeline gets `appium-smoke-gate` (via `appium-smoke-tags.mjs` + smart E2E tags) and **Android** `appium-smoke-tests-android` after APK build. New reusable workflows run Playwright with `tests/playwright.smoke-appium.config.ts`, shard by smoke tag (initially **SmokeAccounts**), and publish JUnit/HTML plus failure videos. **iOS** runs on a **6-hour schedule** on `main` with build + WDA prebuild (`prebuild-wda-ios.yml`, `prepare-ios-appium-runner.mjs`) before tests. `setup-e2e-env` gains optional `install-applesimutils` (off for Appium) and fixes Android SDK `chown` for `sdkmanager`. > > **Runner / framework:** Implements local emulator/sim boot, Android cold-boot stabilization (ANR handling, package trimming), Appium server reuse (`SKIP_APPIUM_STOP`), iOS WDA cache/preinstall, failure screen recording, and cross-framework gestures/assertions in shared page objects and flows. Detox Jest ignores `tests/smoke-appium/`; Appium jobs are **not** merge-blocking in `check-all-jobs-pass`. > > **Local:** `yarn appium-smoke:android` / `yarn appium-smoke:ios`. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e98dd70a3b96bd459845610d6c9b51de4ace6672. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Cursor Agent Co-authored-by: cmd-ob --- .github/CODEOWNERS | 4 + .github/actions/setup-e2e-env/action.yml | 14 +- .github/workflows/ci.yml | 58 ++ .github/workflows/prebuild-wda-ios.yml | 63 ++ .github/workflows/run-appium-e2e-workflow.yml | 354 ++++++++++ .../run-appium-smoke-tests-android.yml | 75 +++ .../workflows/run-appium-smoke-tests-ios.yml | 143 ++++ babel.config.tests.js | 2 + package.json | 2 + scripts/e2e/appium-smoke-tags.mjs | 37 + scripts/e2e/ensure-ffmpeg-ci.sh | 76 +++ scripts/e2e/ios-simulator-lib.mjs | 149 ++++ scripts/e2e/prebuild-wda.mjs | 9 + scripts/e2e/prepare-ios-appium-runner.mjs | 118 ++++ .../e2e/resolve-xcuitest-driver-version.mjs | 28 + scripts/e2e/warm-up-ios-appium-wda.mjs | 135 ++++ scripts/e2e/wda-lib.mjs | 241 +++++++ tests/flows/accounts.flow.ts | 43 +- tests/flows/wallet.flow.ts | 24 +- tests/framework/GestureStrategy.ts | 28 +- tests/framework/Matchers.ts | 6 + tests/framework/PlaywrightGestures.ts | 12 +- tests/framework/PlaywrightUtilities.test.ts | 5 +- tests/framework/PlaywrightUtilities.ts | 9 +- tests/framework/UnifiedGestures.ts | 27 +- .../currentDeviceDetails.fixture.ts | 14 +- .../fixtures/playwright/driver.fixture.ts | 27 + tests/framework/fixtures/playwright/types.ts | 3 +- tests/framework/index.ts | 1 + .../services/appium/AppiumServer.test.ts | 111 +++ .../framework/services/appium/AppiumServer.ts | 167 ++++- .../services/appium/EmulatorHelpers.test.ts | 75 +++ .../services/appium/EmulatorHelpers.ts | 635 +++++++++++++++++- .../services/appium/ScreenRecording.test.ts | 156 +++++ .../services/appium/ScreenRecording.ts | 403 +++++++++++ tests/framework/services/appium/index.ts | 16 +- .../DeviceCommandHandler.test.ts | 2 +- .../IOSDeviceCommandHandler.ts | 11 +- .../emulator/EmulatorConfigBuilder.ts | 93 ++- .../providers/emulator/EmulatorProvider.ts | 129 +++- .../emulator/reinstallLocalBuildFromPath.ts | 25 +- tests/jest.e2e.detox.config.js | 4 + tests/page-objects/AccountMenu/AccountMenu.ts | 5 +- .../RevealSecretRecoveryPhrase.ts | 117 +++- .../SecurityAndPrivacyView.ts | 5 +- .../SecurityAndPrivacy/SrpQuizModal.ts | 26 +- tests/page-objects/Settings/SettingsView.ts | 5 +- tests/page-objects/wallet/LoginView.ts | 7 +- tests/page-objects/wallet/TabBarComponent.ts | 196 +++--- tests/playwright.smoke-appium.config.ts | 100 +++ .../github-step-summary-reporter.mjs | 139 ++++ .../reveal-secret-recovery-phrase.spec.ts | 49 ++ tests/tags.js | 114 ++-- 53 files changed, 3980 insertions(+), 317 deletions(-) create mode 100644 .github/workflows/prebuild-wda-ios.yml create mode 100644 .github/workflows/run-appium-e2e-workflow.yml create mode 100644 .github/workflows/run-appium-smoke-tests-android.yml create mode 100644 .github/workflows/run-appium-smoke-tests-ios.yml create mode 100644 scripts/e2e/appium-smoke-tags.mjs create mode 100755 scripts/e2e/ensure-ffmpeg-ci.sh create mode 100644 scripts/e2e/ios-simulator-lib.mjs create mode 100644 scripts/e2e/prebuild-wda.mjs create mode 100644 scripts/e2e/prepare-ios-appium-runner.mjs create mode 100644 scripts/e2e/resolve-xcuitest-driver-version.mjs create mode 100644 scripts/e2e/warm-up-ios-appium-wda.mjs create mode 100644 scripts/e2e/wda-lib.mjs create mode 100644 tests/framework/services/appium/AppiumServer.test.ts create mode 100644 tests/framework/services/appium/EmulatorHelpers.test.ts create mode 100644 tests/framework/services/appium/ScreenRecording.test.ts create mode 100644 tests/framework/services/appium/ScreenRecording.ts create mode 100644 tests/playwright.smoke-appium.config.ts create mode 100644 tests/reporters/github-step-summary-reporter.mjs create mode 100644 tests/smoke-appium/accounts/reveal-secret-recovery-phrase.spec.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 21686b90367..0e649cc87a6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -374,6 +374,10 @@ tests/websocket/ @MetaMask/qa .github/actions/smart-e2e-selection/ @MetaMask/qa .github/workflows/ai-pr-risk-analysis.yml @MetaMask/qa .github/workflows/auto-label-not-ready-for-e2e.yml @MetaMask/qa +.github/workflows/run-appium-e2e-workflow.yml @MetaMask/qa +.github/workflows/run-appium-smoke-tests-android.yml @MetaMask/qa +.github/workflows/run-appium-smoke-tests-ios.yml @MetaMask/qa +.github/workflows/prebuild-wda-ios.yml @MetaMask/qa .github/workflows/run-e2e-workflow.yml @MetaMask/qa .github/workflows/run-e2e-api-specs.yml @MetaMask/qa .github/workflows/run-e2e-regression-tests-android.yml @MetaMask/qa diff --git a/.github/actions/setup-e2e-env/action.yml b/.github/actions/setup-e2e-env/action.yml index 8eaf56a1e9c..998011a5059 100644 --- a/.github/actions/setup-e2e-env/action.yml +++ b/.github/actions/setup-e2e-env/action.yml @@ -109,6 +109,12 @@ inputs: — Detox does not require the Pods directory to run tests. required: false default: 'false' + install-applesimutils: + description: >- + Whether to install applesimutils (iOS only). Required by Detox but not + by Appium — set to 'false' for Appium test jobs + required: false + default: 'true' runs: using: 'composite' @@ -176,7 +182,9 @@ runs: run: | set -euo pipefail sudo mkdir -p "$CACHE_PATH" /opt/android-sdk/.temp - sudo chown -R "$(id -u):$(id -g)" "$CACHE_PATH" /opt/android-sdk/.temp + # chown the parent dir too so sdkmanager can create x86_64.backup + # next to x86_64 during installation (requires write on the parent). + sudo chown -R "$(id -u):$(id -g)" "$(dirname "$CACHE_PATH")" /opt/android-sdk/.temp shell: bash # Restore exact system image from cache (GitHub only — Namespace uses nscloud-cache-action in callers) @@ -444,7 +452,7 @@ runs: COCOAPODS_DISABLE_STATS: 'true' - name: Install applesimutils - if: ${{ inputs.platform == 'ios' }} + if: ${{ inputs.platform == 'ios' && inputs.install-applesimutils == 'true' }} run: | if ! brew list applesimutils &>/dev/null; then brew tap wix/brew @@ -455,6 +463,6 @@ runs: shell: bash - name: Check simutils - if: ${{ inputs.platform == 'ios' }} + if: ${{ inputs.platform == 'ios' && inputs.install-applesimutils == 'true' }} run: xcrun simctl list devices shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52fcd13f3c7..0a83b9938e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1116,6 +1116,64 @@ jobs: runner_provider: ${{ inputs.runner_provider }} secrets: inherit + appium-smoke-gate: + name: 'Appium Smoke Gate' + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} + needs: [smart-e2e-selection] + outputs: + enabled: ${{ steps.gate.outputs.enabled }} + steps: + - name: Checkout (Namespace) + uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 + if: ${{ inputs.runner_provider == 'namespace' }} + - name: Checkout + uses: actions/checkout@v6 + if: ${{ inputs.runner_provider != 'namespace' }} + with: + sparse-checkout: | + scripts/e2e/appium-smoke-tags.mjs + + - name: Evaluate whether Appium smoke should run + id: gate + env: + SELECTED_TAGS: >- + ${{ + (fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 85 && + needs.smart-e2e-selection.outputs.ai_e2e_test_tags) || + '["ALL"]' + }} + run: | + enabled=$(node scripts/e2e/appium-smoke-tags.mjs "$SELECTED_TAGS") + echo "enabled=${enabled}" >> "$GITHUB_OUTPUT" + + # Android Appium smoke (PR CI). iOS: run-appium-smoke-tests-ios-scheduled.yml. + appium-smoke-tests-android: + name: 'Appium Smoke Tests (Android)' + if: >- + ${{ + !cancelled() && + needs.appium-smoke-gate.outputs.enabled == 'true' && + needs.build-android-apks.result == 'success' + }} + permissions: + contents: read + checks: write + id-token: write + needs: [build-android-apks, smart-e2e-selection, appium-smoke-gate] + uses: ./.github/workflows/run-appium-smoke-tests-android.yml + with: + build_type: 'main' + metamask_environment: 'e2e' + # Appium Android on Cirrus/GitHub-hosted runners until Namespace parity is ready. + runner_provider: current + selected_tags: >- + ${{ + (fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 85 && + needs.smart-e2e-selection.outputs.ai_e2e_test_tags) || + '["ALL"]' + }} + secrets: inherit + # Fixture validation — ensures committed E2E fixtures match the live app state schema validate-e2e-fixtures: name: 'Validate E2E Fixtures' diff --git a/.github/workflows/prebuild-wda-ios.yml b/.github/workflows/prebuild-wda-ios.yml new file mode 100644 index 00000000000..b622b41d948 --- /dev/null +++ b/.github/workflows/prebuild-wda-ios.yml @@ -0,0 +1,63 @@ +# Prebuild WebDriverAgent for Appium iOS smoke tests. +# +# Called from run-appium-smoke-tests-ios-scheduled.yml alongside build-ios-e2e.yml. +# Writes DerivedData to ~/appium-wda for prepare-ios-appium-runner.mjs and actions/cache. + +name: Prebuild WDA (Appium iOS) + +on: + workflow_call: + inputs: + runner_provider: + description: 'Runner provider (namespace or current)' + required: false + type: string + default: 'current' + +permissions: + contents: read + +jobs: + prebuild-wda: + name: prebuild-wda-ios + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ios-e2e' || 'macos-latest' }} + + steps: + - name: Checkout (Namespace) + uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 + if: ${{ inputs.runner_provider == 'namespace' }} + - name: Checkout + uses: actions/checkout@v4 + if: ${{ inputs.runner_provider != 'namespace' }} + + - name: Setup E2E environment (WDA prebuild only) + uses: ./.github/actions/setup-e2e-env + with: + platform: ios + setup-simulator: 'false' + configure-keystores: 'false' + install-foundry: 'false' + skip-pod-install: 'true' + install-applesimutils: 'false' + runner_provider: ${{ inputs.runner_provider }} + + - name: Resolve XCUITest driver version for WDA cache key + id: xcuitest-version + run: echo "version=$(node scripts/e2e/resolve-xcuitest-driver-version.mjs)" >> "$GITHUB_OUTPUT" + + - name: Get Xcode version for WDA cache key + id: xcode-version + run: echo "version=$(xcodebuild -version 2>/dev/null | head -1 | tr ' ' '-')" >> "$GITHUB_OUTPUT" + + - name: Restore WDA DerivedData cache + uses: actions/cache@v4 + with: + path: ~/appium-wda + key: wda-derived-data-xcuitest-${{ steps.xcuitest-version.outputs.version }}-${{ steps.xcode-version.outputs.version }}-${{ runner.os }} + restore-keys: | + wda-derived-data-xcuitest-${{ steps.xcuitest-version.outputs.version }}-${{ steps.xcode-version.outputs.version }}- + + - name: Prebuild WDA if cache miss + run: node scripts/e2e/prebuild-wda.mjs + env: + IOS_SIMULATOR_NAME: iPhone 16 Pro diff --git a/.github/workflows/run-appium-e2e-workflow.yml b/.github/workflows/run-appium-e2e-workflow.yml new file mode 100644 index 00000000000..5b1b5d1864b --- /dev/null +++ b/.github/workflows/run-appium-e2e-workflow.yml @@ -0,0 +1,354 @@ +# Runs Appium smoke tests for one smoke tag on Android or iOS. +# Called from run-appium-smoke-tests-{android,ios}.yml (one job per tag/split). + +name: Run Appium E2E + +on: + workflow_call: + inputs: + test-suite-name: + description: 'Name of the test suite (used for artifacts and report paths)' + required: true + type: string + platform: + description: 'Platform to test (ios or android)' + required: true + type: string + test_suite_tag: + description: 'Smoke tag id to filter tests (e.g. SmokeAccounts matches describe titles)' + required: true + type: string + split_number: + description: 'Which shard to run (1-based index)' + required: false + type: number + default: 1 + total_splits: + description: 'Total number of shards for this tag' + required: false + type: number + default: 1 + test-timeout-minutes: + description: 'Timeout in minutes for the Playwright test step' + required: false + type: number + default: 25 + build_type: + description: 'Build type (main or flask)' + required: false + type: string + default: 'main' + metamask_environment: + description: 'MetaMask environment' + required: false + type: string + default: 'e2e' + runner_provider: + description: 'Runner provider (namespace or current)' + required: false + type: string + default: 'current' + +permissions: + contents: read + checks: write + +jobs: + test-appium-mobile: + name: ${{ inputs.test-suite-name }} + runs-on: >- + ${{ + inputs.runner_provider == 'namespace' && + (inputs.platform == 'ios' && 'namespace-profile-metamask-ios-e2e' || + 'namespace-profile-metamask-android-build') || + (inputs.platform == 'ios' && 'macos-latest' || + (startsWith(github.base_ref, 'release/') && + fromJSON('["ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg"]') || + fromJSON('["ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg", "low-priority"]'))) + }} + + steps: + - name: Checkout (Namespace) + uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1 + if: ${{ inputs.runner_provider == 'namespace' }} + + - name: Checkout + uses: actions/checkout@v4 + if: ${{ inputs.runner_provider != 'namespace' }} + + - name: Setup E2E environment (Android) + if: ${{ inputs.platform == 'android' }} + uses: ./.github/actions/setup-e2e-env + with: + platform: android + setup-simulator: 'true' + android-avd-name: appium_smoke_avd + android-api-level: '34' + android-tag: default + android-abi: x86_64 + android-device: pixel_5 + configure-keystores: 'false' + install-foundry: 'true' + runner_provider: ${{ inputs.runner_provider }} + + - name: Setup E2E environment (iOS) + if: ${{ inputs.platform == 'ios' }} + uses: ./.github/actions/setup-e2e-env + with: + platform: ios + setup-simulator: 'false' + configure-keystores: 'false' + install-foundry: 'true' + skip-pod-install: 'true' + install-applesimutils: 'false' + runner_provider: ${{ inputs.runner_provider }} + + - name: Cache ffmpeg (Homebrew) + if: ${{ inputs.platform == 'ios' }} + uses: actions/cache@v4 + with: + path: ~/.cache/mms-ffmpeg + key: brew-ffmpeg-${{ runner.os }}-v1 + restore-keys: | + brew-ffmpeg-${{ runner.os }}- + + - name: Install ffmpeg for XCUITest screen recording + if: ${{ inputs.platform == 'ios' }} + run: bash scripts/e2e/ensure-ffmpeg-ci.sh + shell: bash + + - name: Determine Android artifact paths + if: ${{ inputs.platform == 'android' }} + id: android-artifacts + run: | + if [[ "${{ inputs.build_type }}" == "flask" ]]; then + { + echo "apk-target-path=android/app/build/outputs/apk/flask/release" + echo "apk-file-name=app-flask-release.apk" + } >> "$GITHUB_OUTPUT" + else + { + echo "apk-target-path=android/app/build/outputs/apk/prod/release" + echo "apk-file-name=app-prod-release.apk" + } >> "$GITHUB_OUTPUT" + fi + + - name: Download Android build artifacts (Namespace) + if: ${{ inputs.platform == 'android' && inputs.runner_provider == 'namespace' }} + id: download-android-apk-namespace + continue-on-error: true + uses: namespace-actions/download-artifact@7cbad919e4b0e09f17e9d6311a444ff002992b5b # v2.0.1 + with: + name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.apk + path: ${{ steps.android-artifacts.outputs.apk-target-path }} + - name: Download Android build artifacts (GitHub — build may be on Cirrus) + if: >- + ${{ + inputs.platform == 'android' && + inputs.runner_provider == 'namespace' && + steps.download-android-apk-namespace.outcome != 'success' + }} + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.apk + path: ${{ steps.android-artifacts.outputs.apk-target-path }} + - name: Download Android build artifacts (current) + if: ${{ inputs.platform == 'android' && inputs.runner_provider != 'namespace' }} + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.apk + path: ${{ steps.android-artifacts.outputs.apk-target-path }} + + - name: Download iOS build artifacts (Namespace) + if: ${{ inputs.platform == 'ios' && inputs.runner_provider == 'namespace' }} + uses: namespace-actions/download-artifact@7cbad919e4b0e09f17e9d6311a444ff002992b5b # v2.0.1 + with: + name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app + path: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app + - name: Download iOS build artifacts (current) + if: ${{ inputs.platform == 'ios' && inputs.runner_provider != 'namespace' }} + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app + path: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app + + - name: Restore iOS bundle executable permissions + if: ${{ inputs.platform == 'ios' }} + env: + APP_PATH: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app + run: | + BUNDLE_EXEC=$(/usr/libexec/PlistBuddy -c "Print CFBundleExecutable" "$APP_PATH/Info.plist" 2>/dev/null) + if [ -z "$BUNDLE_EXEC" ]; then + echo "Could not read CFBundleExecutable from Info.plist" + exit 1 + fi + + ACTUAL_PATH=$(find "$APP_PATH" -maxdepth 1 -iname "$BUNDLE_EXEC" -type f | head -1) + if [ -z "$ACTUAL_PATH" ]; then + echo "Bundle executable not found: $BUNDLE_EXEC" + exit 1 + fi + + if [ "$(basename "$ACTUAL_PATH")" != "$BUNDLE_EXEC" ]; then + mv "$ACTUAL_PATH" "$APP_PATH/${BUNDLE_EXEC}_fix" + mv "$APP_PATH/${BUNDLE_EXEC}_fix" "$APP_PATH/$BUNDLE_EXEC" + fi + + chmod +x "$APP_PATH/$BUNDLE_EXEC" + + if [ -d "$APP_PATH/Frameworks" ]; then + find "$APP_PATH/Frameworks" -type d -name "*.framework" | while IFS= read -r fw; do + binary="$fw/$(basename "$fw" .framework)" + if [ -f "$binary" ]; then + chmod +x "$binary" + fi + done + find "$APP_PATH/Frameworks" -type f -name "*.dylib" -exec chmod +x {} \; + fi + echo "Restored execute permissions on main binary and all framework binaries" + shell: bash + + - name: Resolve XCUITest driver version for WDA cache key + if: ${{ inputs.platform == 'ios' }} + id: xcuitest-version + run: echo "version=$(node scripts/e2e/resolve-xcuitest-driver-version.mjs)" >> "$GITHUB_OUTPUT" + + - name: Get Xcode version for WDA cache key + if: ${{ inputs.platform == 'ios' }} + id: xcode-version + run: echo "version=$(xcodebuild -version 2>/dev/null | head -1 | tr ' ' '-')" >> "$GITHUB_OUTPUT" + + - name: Restore WDA DerivedData cache + if: ${{ inputs.platform == 'ios' }} + uses: actions/cache@v4 + with: + path: ~/appium-wda + key: wda-derived-data-xcuitest-${{ steps.xcuitest-version.outputs.version }}-${{ steps.xcode-version.outputs.version }}-${{ runner.os }} + restore-keys: | + wda-derived-data-xcuitest-${{ steps.xcuitest-version.outputs.version }}-${{ steps.xcode-version.outputs.version }}- + + - name: Check if WDA is prebuilt + if: ${{ inputs.platform == 'ios' }} + id: wda-prebuilt + run: | + WDA_APP=$(find ~/appium-wda/Build/Products -name 'WebDriverAgentRunner-Runner.app' -type d 2>/dev/null | head -1 || true) + XCTESTRUN=$(find ~/appium-wda/Build/Products -name '*.xctestrun' 2>/dev/null | head -1 || true) + if [ -n "$WDA_APP" ] && [ -n "$XCTESTRUN" ]; then + echo "ready=true" >> "$GITHUB_OUTPUT" + echo "WDA prebuilt artifacts found" + else + echo "ready=false" >> "$GITHUB_OUTPUT" + echo "WDA cache miss — prepare-ios-appium-runner will prebuild (parallel with sim boot)" + fi + shell: bash + + - name: Prepare iOS Appium runner + if: ${{ inputs.platform == 'ios' }} + id: prepare-ios-appium + timeout-minutes: 30 + run: node scripts/e2e/prepare-ios-appium-runner.mjs + env: + IOS_SIMULATOR_NAME: iPhone 16 Pro + IOS_APP_PATH: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app + IOS_BUNDLE_ID: io.metamask.MetaMask + SKIP_WDA_PREBUILD: ${{ steps.wda-prebuilt.outputs.ready == 'true' && 'true' || 'false' }} + + - name: Resolve Appium test grep pattern + id: appium-grep + run: echo "pattern=${{ inputs.test_suite_tag }}" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Run Appium smoke tests (Android) + id: run-tests-android + if: ${{ inputs.platform == 'android' }} + timeout-minutes: ${{ inputs.test-timeout-minutes }} + run: >- + yarn playwright test + --config tests/playwright.smoke-appium.config.ts + --project android-smoke + --grep "${{ steps.appium-grep.outputs.pattern }}" + --shard=${{ inputs.split_number }}/${{ inputs.total_splits }} + --pass-with-no-tests + env: + APPIUM_SMOKE_SUITE_NAME: ${{ inputs.test-suite-name }} + APPIUM_SMOKE_JOB_TITLE: Appium ${{ inputs.test-suite-name }} (android) + APPIUM_SMOKE_ARTIFACT_NAME: appium-smoke-report-${{ inputs.test-suite-name }} + APPIUM_SMOKE_VIDEOS_ARTIFACT_NAME: appium-smoke-videos-${{ inputs.test-suite-name }} + APPIUM_RECORD_VIDEO_ON_FAILURE: 'true' + SKIP_APPIUM_STOP: 'true' + ANDROID_APK_PATH: ${{ steps.android-artifacts.outputs.apk-target-path }}/${{ steps.android-artifacts.outputs.apk-file-name }} + ANDROID_AVD_NAME: appium_smoke_avd + ANDROID_APPIUM_USE_PACKAGE_ONLY: 'true' + ANDROID_EMULATOR_CI_CORES: '4' + + - name: Run Appium smoke tests (iOS) + id: run-tests-ios + if: ${{ inputs.platform == 'ios' }} + timeout-minutes: ${{ inputs.test-timeout-minutes }} + run: >- + yarn playwright test + --config tests/playwright.smoke-appium.config.ts + --project ios-smoke + --grep "${{ steps.appium-grep.outputs.pattern }}" + --shard=${{ inputs.split_number }}/${{ inputs.total_splits }} + --pass-with-no-tests + env: + APPIUM_SMOKE_SUITE_NAME: ${{ inputs.test-suite-name }} + APPIUM_SMOKE_JOB_TITLE: Appium ${{ inputs.test-suite-name }} (ios) + APPIUM_SMOKE_ARTIFACT_NAME: appium-smoke-report-${{ inputs.test-suite-name }} + APPIUM_SMOKE_VIDEOS_ARTIFACT_NAME: appium-smoke-videos-${{ inputs.test-suite-name }} + APPIUM_RECORD_VIDEO_ON_FAILURE: 'true' + SKIP_APPIUM_STOP: 'true' + IOS_APP_PATH: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app + IOS_SIMULATOR_NAME: iPhone 16 Pro + IOS_SIMULATOR_UDID: ${{ steps.prepare-ios-appium.outputs.ios-simulator-udid }} + IOS_WDA_PREINSTALLED: ${{ steps.prepare-ios-appium.outputs.ios-wda-preinstalled }} + IOS_WDA_BUNDLE_ID: ${{ steps.prepare-ios-appium.outputs.ios-wda-bundle-id }} + SKIP_DEVICE_BOOT: 'true' + SKIP_APP_REINSTALL: 'true' + USE_PREBUILT_WDA: 'true' + + - name: Upload Playwright HTML report (Namespace) + if: ${{ always() && inputs.runner_provider == 'namespace' }} + uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3 + with: + name: appium-smoke-report-${{ inputs.test-suite-name }} + path: tests/test-reports/appium-smoke-report/${{ inputs.test-suite-name }}/ + if-no-files-found: ignore + retention-days: 7 + - name: Upload Playwright HTML report (current) + if: ${{ always() && inputs.runner_provider != 'namespace' }} + uses: actions/upload-artifact@v4 + with: + name: appium-smoke-report-${{ inputs.test-suite-name }} + path: tests/test-reports/appium-smoke-report/${{ inputs.test-suite-name }}/ + if-no-files-found: ignore + retention-days: 7 + + - name: Upload failure screen recordings (Namespace) + if: ${{ (steps.run-tests-android.outcome == 'failure' || steps.run-tests-ios.outcome == 'failure') && inputs.runner_provider == 'namespace' }} + uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3 + with: + name: appium-smoke-videos-${{ inputs.test-suite-name }} + path: tests/test-reports/appium-smoke-videos/${{ inputs.test-suite-name }}/ + if-no-files-found: ignore + retention-days: 7 + - name: Upload failure screen recordings (current) + if: ${{ (steps.run-tests-android.outcome == 'failure' || steps.run-tests-ios.outcome == 'failure') && inputs.runner_provider != 'namespace' }} + uses: actions/upload-artifact@v4 + with: + name: appium-smoke-videos-${{ inputs.test-suite-name }} + path: tests/test-reports/appium-smoke-videos/${{ inputs.test-suite-name }}/ + if-no-files-found: ignore + retention-days: 7 + + - name: Publish test results to GitHub Checks + if: always() + uses: dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3 + with: + name: Appium ${{ inputs.test-suite-name }} (${{ inputs.platform }}) + path: tests/test-reports/appium-smoke-junit/${{ inputs.test-suite-name }}.xml + reporter: java-junit + fail-on-error: false + list-suites: all + list-tests: failed diff --git a/.github/workflows/run-appium-smoke-tests-android.yml b/.github/workflows/run-appium-smoke-tests-android.yml new file mode 100644 index 00000000000..c41dd2a6b4d --- /dev/null +++ b/.github/workflows/run-appium-smoke-tests-android.yml @@ -0,0 +1,75 @@ +# Appium Smoke Tests — Android +# +# Fans out one reusable job per Appium smoke tag. +# Register tags in scripts/e2e/appium-smoke-tags.mjs and add a matching job below. +# Invoked from ci.yml when appium-smoke-gate and build-android-apks succeed. + +name: Appium Smoke Tests (Android) + +on: + workflow_call: + inputs: + build_type: + description: 'Build type (main or flask)' + required: false + type: string + default: 'main' + metamask_environment: + description: 'MetaMask environment' + required: false + type: string + default: 'e2e' + runner_provider: + description: 'Runner provider (namespace or current)' + required: false + type: string + default: 'current' + selected_tags: + description: 'JSON array of tags from smart E2E selector (e.g. ["SmokeAccounts"] or ["ALL"])' + required: false + type: string + default: '["ALL"]' + workflow_dispatch: + inputs: + build_type: + description: 'Build type (main or flask)' + required: false + type: string + default: 'main' + metamask_environment: + description: 'MetaMask environment' + required: false + type: string + default: 'e2e' + runner_provider: + description: 'Runner provider (namespace or current)' + required: false + type: string + default: 'current' + selected_tags: + description: 'JSON array of tags from smart E2E selector' + required: false + type: string + default: '["ALL"]' + +permissions: + contents: read + checks: write + +jobs: + appium-accounts-android-smoke: + if: >- + ${{ + !cancelled() && + (contains(fromJson(inputs.selected_tags), 'ALL') || + contains(fromJson(inputs.selected_tags), 'SmokeAccounts')) + }} + uses: ./.github/workflows/run-appium-e2e-workflow.yml + with: + test-suite-name: appium-accounts-android-smoke + platform: android + test_suite_tag: SmokeAccounts + build_type: ${{ inputs.build_type }} + metamask_environment: ${{ inputs.metamask_environment }} + runner_provider: ${{ inputs.runner_provider }} + secrets: inherit diff --git a/.github/workflows/run-appium-smoke-tests-ios.yml b/.github/workflows/run-appium-smoke-tests-ios.yml new file mode 100644 index 00000000000..f56614c1415 --- /dev/null +++ b/.github/workflows/run-appium-smoke-tests-ios.yml @@ -0,0 +1,143 @@ +# Appium Smoke Tests — iOS +# +# Fans out one reusable job per Appium smoke tag. +# Register tags in scripts/e2e/appium-smoke-tags.mjs and add a matching job below. +# +# Trigger behaviour: +# schedule / workflow_dispatch → builds the iOS E2E artifact + prebuilds WDA, then runs tests. +# workflow_call → skips build (assumed done upstream), runs tests only. + +name: Appium Smoke Tests (iOS) + +on: + schedule: + # 00:00, 06:00, 12:00, 18:00 UTC — four runs per day on main. + - cron: '0 */6 * * *' + workflow_call: + inputs: + build_type: + description: 'Build type (main or flask)' + required: false + type: string + default: 'main' + metamask_environment: + description: 'MetaMask environment' + required: false + type: string + default: 'e2e' + runner_provider: + description: 'Runner provider (namespace or current)' + required: false + type: string + default: 'current' + selected_tags: + description: 'JSON array of tags from smart E2E selector (e.g. ["SmokeAccounts"] or ["ALL"])' + required: false + type: string + default: '["ALL"]' + workflow_dispatch: + inputs: + build_type: + description: 'Build type (main or flask)' + required: false + type: string + default: 'main' + metamask_environment: + description: 'MetaMask environment' + required: false + type: string + default: 'e2e' + runner_provider: + description: 'Runner provider (namespace or current)' + required: false + type: choice + options: + - current + - namespace + default: current + selected_tags: + description: 'JSON array of smoke tags (e.g. ["SmokeAccounts"] or ["ALL"])' + required: false + type: string + default: '["ALL"]' + +permissions: + contents: read + checks: write + id-token: write + actions: read + statuses: read + +jobs: + # ── Build jobs (schedule / workflow_dispatch only) ────────────────────────── + + native-build-fingerprint: + name: Compute native build fingerprint + if: github.event_name != 'workflow_call' + runs-on: ubuntu-latest + outputs: + fingerprint: ${{ steps.publish.outputs.fingerprint }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + cache: yarn + - name: Install Yarn dependencies with retry + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: yarn install --immutable + - name: Compute native build fingerprint and post build-source-hash + id: publish + uses: ./.github/actions/post-build-source-hash + with: + github-token: ${{ github.token }} + post-status: ${{ (inputs.runner_provider || 'current') != 'namespace' }} + target-sha: ${{ github.sha }} + + build-ios-apps: + name: Build iOS Apps + if: github.event_name != 'workflow_call' + needs: [native-build-fingerprint] + uses: ./.github/workflows/build-ios-e2e.yml + with: + build_type: ${{ inputs.build_type || 'main' }} + metamask_environment: ${{ inputs.metamask_environment || 'e2e' }} + source-fingerprint: ${{ needs.native-build-fingerprint.outputs.fingerprint }} + runner_provider: ${{ inputs.runner_provider || 'current' }} + secrets: inherit + + prebuild-wda-for-appium-ios: + name: Prebuild WDA (Appium iOS) + if: github.event_name != 'workflow_call' + uses: ./.github/workflows/prebuild-wda-ios.yml + with: + runner_provider: ${{ inputs.runner_provider || 'current' }} + secrets: inherit + + # ── Test fan-out ───────────────────────────────────────────────────────────── + + appium-accounts-ios-smoke: + if: >- + ${{ + !cancelled() && + (needs.build-ios-apps.result == 'success' || needs.build-ios-apps.result == 'skipped') && + (needs.prebuild-wda-for-appium-ios.result == 'success' || + needs.prebuild-wda-for-appium-ios.result == 'skipped' || + needs.prebuild-wda-for-appium-ios.result == 'failure') && + (contains(fromJson(inputs.selected_tags || '["ALL"]'), 'ALL') || + contains(fromJson(inputs.selected_tags || '["ALL"]'), 'SmokeAccounts')) + }} + needs: [build-ios-apps, prebuild-wda-for-appium-ios] + uses: ./.github/workflows/run-appium-e2e-workflow.yml + with: + test-suite-name: appium-accounts-ios-smoke + platform: ios + test_suite_tag: SmokeAccounts + build_type: ${{ inputs.build_type || 'main' }} + metamask_environment: ${{ inputs.metamask_environment || 'e2e' }} + runner_provider: ${{ inputs.runner_provider || 'current' }} + secrets: inherit diff --git a/babel.config.tests.js b/babel.config.tests.js index e74a1f70f79..0e1ef5d5ed4 100644 --- a/babel.config.tests.js +++ b/babel.config.tests.js @@ -71,6 +71,8 @@ const newOverrides = [ 'app/util/networks/customNetworks.tsx', 'tests/framework/playwrightLogger.ts', 'tests/framework/services/providers/emulator/reinstallLocalBuildFromPath.ts', + 'tests/framework/services/appium/ScreenRecording.ts', + 'tests/framework/services/appium/AppiumServer.ts', '.yarn/plugins/plugin-usage-tracking.cjs', '.yarn/plugins/plugin-usage-tracking.test.ts', ], diff --git a/package.json b/package.json index 90931a91f71..00a348a9607 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,8 @@ "run-system-tests:android-onboarding-emu": "yarn playwright test --project system-android-onboarding-emu --config tests/playwright.system-emulator.config.ts", "run-system-tests:ios-login-sim": "yarn playwright test --project system-ios-login-sim --config tests/playwright.system-emulator.config.ts", "run-system-tests:ios-onboarding-sim": "yarn playwright test --project system-ios-onboarding-sim --config tests/playwright.system-emulator.config.ts", + "appium-smoke:android": "yarn playwright test --config tests/playwright.smoke-appium.config.ts --project android-smoke", + "appium-smoke:ios": "yarn playwright test --config tests/playwright.smoke-appium.config.ts --project ios-smoke", "capture-visual-baselines:android": "CAPTURE_BASELINES=true AI_VISUAL_TESTING_ENABLED=true yarn playwright test --project system-android-login-emu --config tests/playwright.system-emulator.config.ts", "capture-visual-baselines:ios": "CAPTURE_BASELINES=true AI_VISUAL_TESTING_ENABLED=true yarn playwright test --project system-ios-login-sim --config tests/playwright.system-emulator.config.ts", "test:depcheck": "yarn depcheck", diff --git a/scripts/e2e/appium-smoke-tags.mjs b/scripts/e2e/appium-smoke-tags.mjs new file mode 100644 index 00000000000..113b7be02e4 --- /dev/null +++ b/scripts/e2e/appium-smoke-tags.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** + * Smoke tags executed by Appium CI. + * Android: run-appium-smoke-tests-android.yml (ci.yml). + * iOS: run-appium-smoke-tests-ios.yml (run-appium-smoke-tests-ios-scheduled.yml). + * Append a tag here and add a matching job to each orchestrator. + */ +export const APPIUM_SMOKE_TAGS = ['SmokeAccounts']; + +/** + * @param {string | string[]} selectedTags JSON array string or parsed tags + * @returns {boolean} + */ +export function shouldRunAppiumSmoke(selectedTags) { + const tags = + typeof selectedTags === 'string' ? JSON.parse(selectedTags) : selectedTags; + + if (!Array.isArray(tags)) { + return false; + } + + if (tags.includes('ALL')) { + return true; + } + + return APPIUM_SMOKE_TAGS.some((tag) => tags.includes(tag)); +} + +const isMain = + process.argv[1] && + (process.argv[1].endsWith('appium-smoke-tags.mjs') || + process.argv[1].endsWith('appium-smoke-tags')); + +if (isMain) { + const selectedTagsJson = process.argv[2] ?? '["ALL"]'; + process.stdout.write(shouldRunAppiumSmoke(selectedTagsJson) ? 'true' : 'false'); +} diff --git a/scripts/e2e/ensure-ffmpeg-ci.sh b/scripts/e2e/ensure-ffmpeg-ci.sh new file mode 100755 index 00000000000..a5912aabd09 --- /dev/null +++ b/scripts/e2e/ensure-ffmpeg-ci.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Install ffmpeg for Appium XCUITest screen recording in CI. +# Uses a cached Homebrew bottle dir + Cellar snapshot under ~/.cache/mms-ffmpeg +# so subsequent runs skip downloading and compiling ffmpeg dependencies. +set -euo pipefail + +CACHE_ROOT="${HOME}/.cache/mms-ffmpeg" +BREW_CACHE_DIR="${CACHE_ROOT}/brew" +CELLAR_CACHE_DIR="${CACHE_ROOT}/cellar" + +export HOMEBREW_NO_AUTO_UPDATE=1 +export HOMEBREW_NO_INSTALL_CLEANUP=1 +export HOMEBREW_CACHE="${BREW_CACHE_DIR}" + +mkdir -p "${BREW_CACHE_DIR}" "${CELLAR_CACHE_DIR}" + +if ! command -v brew >/dev/null 2>&1; then + echo "Homebrew is required to install ffmpeg" >&2 + exit 1 +fi + +restore_cached_cellars() { + local formula cellar_path + shopt -s nullglob + for cellar_path in "${CELLAR_CACHE_DIR}"/*; do + formula="$(basename "${cellar_path}")" + [[ "${formula}" == "ffmpeg" ]] && continue + mkdir -p "$(brew --cellar "${formula}")" + rsync -a "${cellar_path}/" "$(brew --cellar "${formula}")/" + brew link "${formula}" >/dev/null 2>&1 || true + done + if [[ -d "${CELLAR_CACHE_DIR}/ffmpeg" ]]; then + mkdir -p "$(brew --cellar ffmpeg)" + rsync -a "${CELLAR_CACHE_DIR}/ffmpeg/" "$(brew --cellar ffmpeg)/" + brew link ffmpeg >/dev/null 2>&1 || true + fi + shopt -u nullglob +} + +cache_installed_cellars() { + local formula + for formula in ffmpeg $(brew deps --formula ffmpeg); do + if [[ ! -d "$(brew --cellar "${formula}")" ]]; then + continue + fi + mkdir -p "${CELLAR_CACHE_DIR}/${formula}" + rsync -a "$(brew --cellar "${formula}")/" "${CELLAR_CACHE_DIR}/${formula}/" + done +} + +if command -v ffmpeg >/dev/null 2>&1; then + echo "ffmpeg already on PATH: $(command -v ffmpeg)" + ffmpeg -version | head -1 + exit 0 +fi + +restore_cached_cellars + +if command -v ffmpeg >/dev/null 2>&1; then + echo "ffmpeg restored from Cellar cache: $(command -v ffmpeg)" + ffmpeg -version | head -1 + exit 0 +fi + +echo "Installing ffmpeg via Homebrew (bottles cached under ${BREW_CACHE_DIR})..." +brew install ffmpeg + +if ! command -v ffmpeg >/dev/null 2>&1; then + echo "ffmpeg install finished but binary is not on PATH" >&2 + exit 1 +fi + +cache_installed_cellars + +echo "ffmpeg installed: $(command -v ffmpeg)" +ffmpeg -version | head -1 diff --git a/scripts/e2e/ios-simulator-lib.mjs b/scripts/e2e/ios-simulator-lib.mjs new file mode 100644 index 00000000000..e48b335a56f --- /dev/null +++ b/scripts/e2e/ios-simulator-lib.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +/* eslint-disable import-x/no-nodejs-modules */ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +/** + * @param {string} deviceName + * @returns {Promise} + */ +export async function getIosSimulatorUdid(deviceName) { + const { stdout } = await execFileAsync('xcrun', [ + 'simctl', + 'list', + 'devices', + 'available', + '-j', + ]); + const list = JSON.parse(stdout); + + let firstMatch; + + for (const devices of Object.values(list.devices)) { + for (const device of devices) { + if (device.name !== deviceName) { + continue; + } + if (device.state === 'Booted') { + return device.udid; + } + firstMatch ??= device.udid; + } + } + + if (firstMatch) { + return firstMatch; + } + + throw new Error( + `iOS simulator "${deviceName}" not found. Run \`xcrun simctl list devices available\`.`, + ); +} + +/** + * @param {string} udid + * @returns {Promise} + */ +async function isIosSimulatorBooted(udid) { + try { + const { stdout } = await execFileAsync('xcrun', [ + 'simctl', + 'list', + 'devices', + 'available', + '-j', + ]); + const list = JSON.parse(stdout); + for (const devices of Object.values(list.devices)) { + const sim = devices.find((d) => d.udid === udid); + if (sim) { + return sim.state === 'Booted'; + } + } + } catch { + return false; + } + return false; +} + +/** + * @param {string} deviceName + * @returns {Promise} UDID of the booted simulator + */ +export async function bootIosSimulator(deviceName) { + const udid = await getIosSimulatorUdid(deviceName); + + if (await isIosSimulatorBooted(udid)) { + console.log( + `iOS simulator "${deviceName}" (${udid}) is already booted — skipping boot.`, + ); + return udid; + } + + console.log(`Booting iOS simulator: ${deviceName} (${udid})`); + + await execFileAsync('xcrun', ['simctl', 'boot', udid]).catch( + (err) => { + if (err.code !== 149) { + throw err; + } + }, + ); + + await execFileAsync('xcrun', ['simctl', 'bootstatus', udid, '-b']); + console.log(`iOS simulator "${deviceName}" is booted and ready.`); + return udid; +} + +/** + * Blocks until simctl reports the simulator fully booted (SpringBoard ready). + * Call before Appium session creation so XCUITest does not race sim boot. + * @param {string} udid + */ +export async function ensureIosSimulatorBooted(udid) { + await execFileAsync('xcrun', ['simctl', 'bootstatus', udid, '-b']); +} + +/** + * @param {string} udid + * @param {string} bundleId + * @returns {Promise} + */ +export async function isIosAppInstalled(udid, bundleId) { + try { + await execFileAsync('xcrun', ['simctl', 'get_app_container', udid, bundleId]); + return true; + } catch { + return false; + } +} + +/** + * @param {string} udid + * @param {string} bundleId + */ +export async function assertIosAppInstalled(udid, bundleId) { + if (!(await isIosAppInstalled(udid, bundleId))) { + throw new Error( + `App "${bundleId}" is not installed on simulator ${udid} after simctl install.`, + ); + } + console.log(`Verified ${bundleId} is installed on simulator ${udid}.`); +} + +/** + * @param {{ udid: string; bundleId: string; appPath: string }} options + */ +export async function installIosApp({ udid, bundleId, appPath }) { + try { + await execFileAsync('xcrun', ['simctl', 'uninstall', udid, bundleId]); + } catch { + // App may not be installed yet. + } + + console.log(`simctl install: ${appPath} → simulator ${udid}`); + await execFileAsync('xcrun', ['simctl', 'install', udid, appPath]); + await assertIosAppInstalled(udid, bundleId); +} diff --git a/scripts/e2e/prebuild-wda.mjs b/scripts/e2e/prebuild-wda.mjs new file mode 100644 index 00000000000..ded9c59b6a0 --- /dev/null +++ b/scripts/e2e/prebuild-wda.mjs @@ -0,0 +1,9 @@ +#!/usr/bin/env node +/* eslint-disable import-x/no-nodejs-modules */ +/** + * Prebuild WebDriverAgent for Appium XCUITest so session creation skips xcodebuild. + * Output is written to ~/appium-wda (must match appium:derivedDataPath in EmulatorConfigBuilder). + */ +import { ensureWdaPrebuilt } from './wda-lib.mjs'; + +await ensureWdaPrebuilt(); diff --git a/scripts/e2e/prepare-ios-appium-runner.mjs b/scripts/e2e/prepare-ios-appium-runner.mjs new file mode 100644 index 00000000000..d1f9c92c32d --- /dev/null +++ b/scripts/e2e/prepare-ios-appium-runner.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +/* eslint-disable import-x/no-nodejs-modules */ +/** + * Prepares the iOS Appium runner before Playwright tests: + * 1. Boot simulator (parallel with WDA prebuild unless SKIP_WDA_PREBUILD=true) + * 2. Prebuild WDA into ~/appium-wda on cache miss + * 3. simctl install WebDriverAgentRunner + MetaMask.app (sequential — same UDID) + * 4. Warm WDA via a throwaway Appium session; leaves Appium running for tests + * + * Sets GITHUB_OUTPUT: ios-simulator-udid, ios-wda-preinstalled, ios-wda-bundle-id. + * WDA simctl install failures fall back to the xcodebuild path in tests. + */ +import { spawnSync } from 'node:child_process'; +import { appendFileSync, existsSync } from 'node:fs'; +import { bootIosSimulator, installIosApp } from './ios-simulator-lib.mjs'; +import { + ensureWdaPrebuilt, + findWdaArtifacts, + getDerivedDataPath, + hasUsableWdaArtifacts, + installWdaOnSimulator, + toWdaBundleIdBase, +} from './wda-lib.mjs'; +import { warmUpIosAppiumWda } from './warm-up-ios-appium-wda.mjs'; + +const simulatorName = process.env.IOS_SIMULATOR_NAME ?? 'iPhone 16 Pro'; +const appPath = process.env.IOS_APP_PATH; +const bundleId = process.env.IOS_BUNDLE_ID ?? 'io.metamask.MetaMask'; +const skipWdaPrebuild = process.env.SKIP_WDA_PREBUILD === 'true'; + +spawnSync( + 'defaults', + ['write', 'com.apple.iphonesimulator', 'SlowAnimations', '-bool', 'false'], + { stdio: 'inherit' }, +); + +console.log('Preparing iOS Appium runner (sim boot ∥ WDA prebuild)…'); + +const [udid] = await Promise.all([ + bootIosSimulator(simulatorName), + skipWdaPrebuild + ? Promise.resolve().then(() => + console.log('SKIP_WDA_PREBUILD=true — skipping WDA prebuild'), + ) + : ensureWdaPrebuilt(), +]); + +if (appPath && !existsSync(appPath)) { + console.error(`IOS_APP_PATH does not exist: ${appPath}`); + process.exit(1); +} + +let iosWdaPreinstalled = 'false'; +let iosWdaBundleIdBase = ''; + +// simctl install must be sequential on the same UDID — parallel WDA + app installs race. +if (hasUsableWdaArtifacts()) { + const { wdaApp } = findWdaArtifacts(getDerivedDataPath()); + if (wdaApp) { + try { + const installedBundleId = await installWdaOnSimulator({ udid, wdaApp }); + iosWdaPreinstalled = 'true'; + iosWdaBundleIdBase = toWdaBundleIdBase(installedBundleId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn( + `WDA simctl install failed — tests will use xcodebuild path: ${message}`, + ); + } + } +} else { + console.log( + 'WDA artifacts not found — skipping sim WDA install (tests will use xcodebuild).', + ); +} + +if (appPath) { + await installIosApp({ udid, bundleId, appPath }); +} + +if (iosWdaPreinstalled === 'true' && iosWdaBundleIdBase) { + try { + await warmUpIosAppiumWda({ + udid, + wdaBundleIdBase: iosWdaBundleIdBase, + simulatorName, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn( + `WDA warm-up failed — prepare continues; first Playwright session will launch WDA: ${message}`, + ); + } +} + +console.log(`IOS_SIMULATOR_UDID=${udid}`); +if (iosWdaPreinstalled === 'true') { + console.log(`IOS_WDA_PREINSTALLED=true`); + console.log(`IOS_WDA_BUNDLE_ID=${iosWdaBundleIdBase}`); +} + +if (process.env.GITHUB_OUTPUT) { + appendFileSync( + process.env.GITHUB_OUTPUT, + `ios-simulator-udid=${udid}\nios-wda-preinstalled=${iosWdaPreinstalled}\n`, + ); + if (iosWdaBundleIdBase) { + appendFileSync( + process.env.GITHUB_OUTPUT, + `ios-wda-bundle-id=${iosWdaBundleIdBase}\n`, + ); + } +} + +console.log('iOS Appium runner ready.'); + +// Detached Appium + WebdriverIO can leave open handles; exit so GHA does not hang. +process.exit(0); diff --git a/scripts/e2e/resolve-xcuitest-driver-version.mjs b/scripts/e2e/resolve-xcuitest-driver-version.mjs new file mode 100644 index 00000000000..9e42dcb69d6 --- /dev/null +++ b/scripts/e2e/resolve-xcuitest-driver-version.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +/* eslint-disable import-x/no-nodejs-modules */ +/** + * Prints the appium-xcuitest-driver version for WDA cache keys. + * Source of truth: root package.json devDependencies (no yarn install required). + */ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '../..'); +const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + +const raw = + pkg.devDependencies?.['appium-xcuitest-driver'] ?? + pkg.dependencies?.['appium-xcuitest-driver']; + +if (!raw) { + console.error( + 'appium-xcuitest-driver not found in package.json dependencies', + ); + process.exit(1); +} + +// Normalize semver range prefixes (^, ~, >=) — we pin exact versions today but +// this keeps the cache key stable if a range is ever used. +const version = String(raw).replace(/^[\^~>=<]+/, ''); +process.stdout.write(version); diff --git a/scripts/e2e/warm-up-ios-appium-wda.mjs b/scripts/e2e/warm-up-ios-appium-wda.mjs new file mode 100644 index 00000000000..46a1b692dcd --- /dev/null +++ b/scripts/e2e/warm-up-ios-appium-wda.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +/* eslint-disable import-x/no-nodejs-modules */ +/** + * Launches WDA on a booted simulator before Playwright tests. + * Starts a detached Appium server (reused when SKIP_APPIUM_STOP=true) and opens a + * WDA-only WebDriverIO session (no app bundleId). useNewWDA:false keeps WDA alive + * after deleteSession so the test step attaches instead of cold-starting. + */ +import { spawn } from 'node:child_process'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { ensureIosSimulatorBooted } from './ios-simulator-lib.mjs'; + +const APPIUM_PORT = Number(process.env.APPIUM_PORT ?? 4723); +const APPIUM_HOST = process.env.APPIUM_HOST ?? '127.0.0.1'; +const APPIUM_STARTUP_TIMEOUT_MS = 60_000; +const WARMUP_SESSION_TIMEOUT_MS = 5 * 60 * 1000; +const WARMUP_MAX_ATTEMPTS = 2; +const WARMUP_RETRY_DELAY_MS = 5_000; + +async function isAppiumRunning() { + try { + const response = await fetch(`http://${APPIUM_HOST}:${APPIUM_PORT}/status`); + return response.ok; + } catch { + return false; + } +} + +async function waitForAppiumReady(timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await isAppiumRunning()) { + return; + } + await sleep(250); + } + throw new Error(`Appium did not start within ${timeoutMs}ms`); +} + +async function startAppiumServer() { + if (await isAppiumRunning()) { + console.log(`Appium already running at http://${APPIUM_HOST}:${APPIUM_PORT} — reusing.`); + return; + } + + console.log(`Starting Appium on http://${APPIUM_HOST}:${APPIUM_PORT} for WDA warm-up…`); + + // stdio: 'ignore' — piped stdio would keep Node's event loop alive after unref(). + const proc = spawn( + 'yarn', + [ + 'appium', + '--allow-insecure=chromedriver_autodownload', + '--port', + String(APPIUM_PORT), + '--address', + APPIUM_HOST, + ], + { stdio: 'ignore', detached: true }, + ); + + proc.on('error', (error) => { + console.error('Failed to spawn Appium:', error); + }); + + proc.unref(); + await waitForAppiumReady(APPIUM_STARTUP_TIMEOUT_MS); +} + +/** + * @param {{ udid: string; wdaBundleIdBase: string; simulatorName: string }} options + */ +async function createWarmUpSession({ udid, wdaBundleIdBase, simulatorName }) { + const { remote } = await import('webdriverio'); + console.log('Creating warm-up Appium session (preinstalled WDA, no app launch)…'); + + const driver = await remote({ + hostname: APPIUM_HOST, + port: APPIUM_PORT, + connectionRetryTimeout: WARMUP_SESSION_TIMEOUT_MS, + connectionRetryCount: 0, + capabilities: { + platformName: 'iOS', + 'appium:automationName': 'XCUITest', + 'appium:udid': udid, + 'appium:deviceName': simulatorName, + 'appium:usePreinstalledWDA': true, + 'appium:updatedWDABundleId': wdaBundleIdBase, + 'appium:useNewWDA': false, + 'appium:derivedDataPath': `${process.env.HOME}/appium-wda`, + 'appium:wdaLaunchTimeout': 120_000, + 'appium:wdaConnectionTimeout': 30_000, + 'appium:simulatorStartupTimeout': 180_000, + 'appium:noReset': true, + 'appium:skipLogCapture': true, + }, + }); + + await driver.deleteSession(); + if (typeof driver.close === 'function') { + await driver.close().catch(() => undefined); + } +} + +/** + * @param {{ udid: string; wdaBundleIdBase: string; simulatorName: string }} options + * @returns {Promise} true when warm-up succeeded + */ +export async function warmUpIosAppiumWda({ udid, wdaBundleIdBase, simulatorName }) { + await startAppiumServer(); + + let lastError; + + for (let attempt = 1; attempt <= WARMUP_MAX_ATTEMPTS; attempt += 1) { + try { + console.log( + `WDA warm-up attempt ${attempt}/${WARMUP_MAX_ATTEMPTS} (simulator ${udid})…`, + ); + await ensureIosSimulatorBooted(udid); + await createWarmUpSession({ udid, wdaBundleIdBase, simulatorName }); + console.log('WDA warm-up complete — Appium left running for Playwright.'); + await sleep(2000); + return true; + } catch (error) { + lastError = error; + const message = error instanceof Error ? error.message : String(error); + console.warn(`WDA warm-up attempt ${attempt} failed: ${message}`); + if (attempt < WARMUP_MAX_ATTEMPTS) { + await sleep(WARMUP_RETRY_DELAY_MS); + } + } + } + + throw lastError; +} diff --git a/scripts/e2e/wda-lib.mjs b/scripts/e2e/wda-lib.mjs new file mode 100644 index 00000000000..b5f409dcf57 --- /dev/null +++ b/scripts/e2e/wda-lib.mjs @@ -0,0 +1,241 @@ +#!/usr/bin/env node +/* eslint-disable import-x/no-nodejs-modules */ +import { execFile, spawnSync } from 'node:child_process'; +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +const execFileAsync = promisify(execFile); + +export const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../..'); + +export function getDerivedDataPath() { + return process.env.WDA_DERIVED_DATA_PATH ?? join(homedir(), 'appium-wda'); +} + +export function getSimulatorName() { + return process.env.IOS_SIMULATOR_NAME ?? 'iPhone 16 Pro'; +} + +/** + * @param {string} dir + * @param {(entryPath: string, name: string, isDirectory: boolean) => boolean | 'stop'} predicate + * @param {number} [maxDepth] + * @returns {string | undefined} + */ +export function findInTree(dir, predicate, maxDepth = 20) { + if (!existsSync(dir) || maxDepth < 0) { + return undefined; + } + + for (const name of readdirSync(dir)) { + if (name === '.git' || name === '.yarn') { + continue; + } + + const entryPath = join(dir, name); + let stat; + try { + stat = statSync(entryPath); + } catch { + continue; + } + + const isDirectory = stat.isDirectory(); + const result = predicate(entryPath, name, isDirectory); + if (result === true) { + return entryPath; + } + if (result === 'stop') { + return undefined; + } + + if (isDirectory) { + const nested = findInTree(entryPath, predicate, maxDepth - 1); + if (nested) { + return nested; + } + } + } + + return undefined; +} + +export function findWdaProject() { + const nodeModules = join(repoRoot, 'node_modules'); + if (!existsSync(nodeModules)) { + return undefined; + } + + return findInTree( + nodeModules, + (entryPath, name, isDirectory) => { + if ( + isDirectory && + name === 'WebDriverAgent.xcodeproj' && + entryPath.includes( + `${join('appium-webdriveragent', 'WebDriverAgent.xcodeproj')}`, + ) + ) { + return true; + } + return false; + }, + 12, + ); +} + +/** + * @param {string} derivedDataPath + * @returns {{ wdaApp?: string; xctestrun?: string }} + */ +export function findWdaArtifacts(derivedDataPath) { + const productsDir = join(derivedDataPath, 'Build', 'Products'); + if (!existsSync(productsDir)) { + return {}; + } + + let wdaApp; + let xctestrun; + + findInTree( + productsDir, + (entryPath, name, isDirectory) => { + if (!wdaApp && isDirectory && name === 'WebDriverAgentRunner-Runner.app') { + wdaApp = entryPath; + } + if (!xctestrun && !isDirectory && name.endsWith('.xctestrun')) { + xctestrun = entryPath; + } + if (wdaApp && xctestrun) { + return 'stop'; + } + return false; + }, + 8, + ); + + return { wdaApp, xctestrun }; +} + +export function hasUsableWdaArtifacts(derivedDataPath = getDerivedDataPath()) { + const artifacts = findWdaArtifacts(derivedDataPath); + return Boolean(artifacts.wdaApp && artifacts.xctestrun); +} + +/** + * @param {string} appPath Path to .app bundle + * @returns {string} CFBundleIdentifier from Info.plist + */ +export function readAppBundleId(appPath) { + const plistPath = join(appPath, 'Info.plist'); + const result = spawnSync( + '/usr/libexec/PlistBuddy', + ['-c', 'Print CFBundleIdentifier', plistPath], + { encoding: 'utf8' }, + ); + if (result.status !== 0) { + throw new Error( + `Could not read CFBundleIdentifier from ${plistPath}: ${result.stderr?.trim() ?? 'unknown error'}`, + ); + } + return result.stdout.trim(); +} + +/** + * Appium `updatedWDABundleId` expects the base id; the driver adds `.xctrunner`. + * @param {string} bundleId + */ +export function toWdaBundleIdBase(bundleId) { + return bundleId.replace(/\.xctrunner$/, ''); +} + +/** + * Installs prebuilt WebDriverAgentRunner onto a booted simulator. + * @param {{ udid: string; wdaApp: string }} options + * @returns {Promise} installed bundle id (may include `.xctrunner`) + */ +export async function installWdaOnSimulator({ udid, wdaApp }) { + if (!existsSync(wdaApp)) { + throw new Error(`WDA app not found: ${wdaApp}`); + } + + console.log(`Installing WebDriverAgent on simulator ${udid}…`); + console.log(` app: ${wdaApp}`); + await execFileAsync('xcrun', ['simctl', 'install', udid, wdaApp]); + + const bundleId = readAppBundleId(wdaApp); + console.log(`WebDriverAgent installed (bundleId=${bundleId}).`); + return bundleId; +} + +function logArtifacts(label, derivedDataPath, { wdaApp, xctestrun }) { + console.log(`${label} ${derivedDataPath}`); + if (wdaApp) { + console.log(` app: ${wdaApp}`); + } + if (xctestrun) { + console.log(` xctestrun: ${xctestrun}`); + } +} + +/** + * Ensures WDA is prebuilt. No-op when artifacts already exist. + * @returns {Promise<{ wdaApp: string; xctestrun: string }>} + */ +export async function ensureWdaPrebuilt() { + const derivedDataPath = getDerivedDataPath(); + const simulatorName = getSimulatorName(); + const productsDir = join(derivedDataPath, 'Build', 'Products'); + + let artifacts = findWdaArtifacts(derivedDataPath); + if (artifacts.wdaApp && artifacts.xctestrun) { + logArtifacts('WDA already prebuilt at', derivedDataPath, artifacts); + return { wdaApp: artifacts.wdaApp, xctestrun: artifacts.xctestrun }; + } + + const wdaProject = findWdaProject(); + if (!wdaProject) { + throw new Error( + 'Could not find appium-webdriveragent/WebDriverAgent.xcodeproj under node_modules. Run yarn install first.', + ); + } + + console.log(`Prebuilding WebDriverAgent for simulator "${simulatorName}"…`); + console.log(` project: ${wdaProject}`); + console.log(` derivedDataPath: ${derivedDataPath}`); + + const build = spawnSync( + 'xcodebuild', + [ + 'build-for-testing', + '-project', + wdaProject, + '-scheme', + 'WebDriverAgentRunner', + '-derivedDataPath', + derivedDataPath, + '-destination', + `platform=iOS Simulator,name=${simulatorName}`, + 'CODE_SIGNING_ALLOWED=NO', + ], + { stdio: 'inherit' }, + ); + + if (build.status !== 0) { + process.exit(build.status ?? 1); + } + + artifacts = findWdaArtifacts(derivedDataPath); + if (!artifacts.wdaApp || !artifacts.xctestrun) { + console.error( + `WDA prebuild finished but expected artifacts were not found under ${productsDir}`, + ); + process.exit(1); + } + + logArtifacts('WDA prebuild complete', derivedDataPath, artifacts); + return { wdaApp: artifacts.wdaApp, xctestrun: artifacts.xctestrun }; +} diff --git a/tests/flows/accounts.flow.ts b/tests/flows/accounts.flow.ts index 204542f34c3..80412d833b5 100644 --- a/tests/flows/accounts.flow.ts +++ b/tests/flows/accounts.flow.ts @@ -10,9 +10,42 @@ import TabBarComponent from '../page-objects/wallet/TabBarComponent'; import SettingsView from '../page-objects/Settings/SettingsView'; import SecurityAndPrivacyView from '../page-objects/Settings/SecurityAndPrivacy/SecurityAndPrivacyView'; import AccountDetails from '../page-objects/MultichainAccounts/AccountDetails'; +import { PlatformDetector } from '../framework/PlatformLocator'; +import { FrameworkDetector } from '../framework/FrameworkDetector'; +import PlaywrightAssertions from '../framework/PlaywrightAssertions'; +import { + asPlaywrightElement, + EncapsulatedElementType, +} from '../framework/EncapsulatedElement'; +import { AssertionOptions } from '../framework/types'; const PASSWORD = '123123123'; +async function expectElementVisible( + target: EncapsulatedElementType | DetoxElement, + options: AssertionOptions = {}, +): Promise { + if (FrameworkDetector.isAppium()) { + await PlaywrightAssertions.expectElementToBeVisible( + await asPlaywrightElement(target as EncapsulatedElementType), + options, + ); + return; + } + await Assertions.expectElementToBeVisible(target as DetoxElement, options); +} + +async function expectTextVisible( + text: string, + options: AssertionOptions = {}, +): Promise { + if (FrameworkDetector.isAppium()) { + await PlaywrightAssertions.expectTextDisplayed(text, options); + return; + } + await Assertions.expectTextDisplayed(text, options); +} + export const goToImportSrp = async () => { await WalletView.tapIdenticon(); await Assertions.expectElementToBeVisible(AccountListBottomSheet.accountList); @@ -48,19 +81,17 @@ export const completeSrpQuiz = async (expectedSrp: string) => { // Tap the blur overlay to reveal the SRP await RevealSecretRecoveryPhrase.tapToReveal(); - await Assertions.expectElementToBeVisible( - RevealSecretRecoveryPhrase.container, - ); + await expectElementVisible(RevealSecretRecoveryPhrase.container); // SRP is now displayed in grid format - verify first word is displayed const srpWords = expectedSrp.split(' '); - await Assertions.expectTextDisplayed(srpWords[0]); + await expectTextVisible(srpWords[0]); await RevealSecretRecoveryPhrase.scrollToCopyToClipboardButton(); await RevealSecretRecoveryPhrase.tapToRevealPrivateCredentialQRCode(); - if (device.getPlatform() === 'ios') { + if (PlatformDetector.isIOS()) { // For some reason, the QR code is visible on Android but detox cannot find it - await Assertions.expectElementToBeVisible( + await expectElementVisible( RevealSecretRecoveryPhrase.revealCredentialQRCodeImage, ); } diff --git a/tests/flows/wallet.flow.ts b/tests/flows/wallet.flow.ts index 8248070ffc4..59beded3863 100644 --- a/tests/flows/wallet.flow.ts +++ b/tests/flows/wallet.flow.ts @@ -421,12 +421,9 @@ export const loginToApp = async (password?: string): Promise => { await Assertions.expectElementToBeVisible(LoginView.container, { description: 'Login View container should be visible', }); - await Assertions.expectElementToBeVisible( - asDetoxElement(LoginView.passwordInput), - { - description: 'Login View password input should be visible', - }, - ); + await Assertions.expectElementToBeVisible(LoginView.passwordInput, { + description: 'Login View password input should be visible', + }); await LoginView.enterPassword(PASSWORD); @@ -502,6 +499,21 @@ export const loginToAppPlaywright = async ( ): Promise => { const { scenarioType = 'login' } = options; + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(LoginView.container), + { + description: 'Login view container', + timeout: 45_000, + }, + ); + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(LoginView.passwordInput), + { + description: 'Login password input', + timeout: 15_000, + }, + ); + const password = getPasswordForScenario(scenarioType); // Type password and unlock await LoginView.enterPassword(password ?? ''); diff --git a/tests/framework/GestureStrategy.ts b/tests/framework/GestureStrategy.ts index 564b8a34c4d..10269a3528a 100644 --- a/tests/framework/GestureStrategy.ts +++ b/tests/framework/GestureStrategy.ts @@ -22,7 +22,7 @@ export interface UnifiedGestureOptions { speed?: 'fast' | 'slow'; /** Swipe percentage (0–1) — Detox only; Appium ignores this */ percentage?: number; - /** Scroll direction — Detox only; used by scrollToElement */ + /** Scroll direction — used by scrollToElement (Detox default and Appium scrollIntoView) */ direction?: 'up' | 'down' | 'left' | 'right'; /** Scroll amount in px — Detox only; used by scrollToElement */ scrollAmount?: number; @@ -47,6 +47,9 @@ export interface UnifiedGestureOptions { export type TapAtIndexElement = EncapsulatedElementType | PlaywrightElement[]; export type ScrollViewMatcher = Promise; +/** Detox scroll container: matcher promise, or testID resolved inside UnifiedGestures. */ +export type ScrollContainer = ScrollViewMatcher | string; + /** * Strategy interface for framework-agnostic gesture execution. * @@ -85,7 +88,7 @@ export interface GestureStrategy { scrollToElement( target: EncapsulatedElementType, - scrollView: ScrollViewMatcher, + scrollView?: ScrollContainer, opts?: UnifiedGestureOptions, ): Promise; @@ -218,9 +221,21 @@ export class DetoxGestureStrategy implements GestureStrategy { */ async scrollToElement( target: EncapsulatedElementType, - scrollView: ScrollViewMatcher, + scrollView?: ScrollContainer, opts?: UnifiedGestureOptions, ): Promise { + if (!scrollView) { + throw new Error( + 'DetoxGestureStrategy.scrollToElement requires a scroll container testID or matcher.', + ); + } + + if (typeof scrollView === 'string') { + throw new Error( + 'DetoxGestureStrategy.scrollToElement received a testID string — resolve it in UnifiedGestures first.', + ); + } + const resolvedScrollView = await scrollView; if (this.isLikelyDetoxElement(resolvedScrollView)) { @@ -408,10 +423,13 @@ export class AppiumGestureStrategy implements GestureStrategy { */ async scrollToElement( target: EncapsulatedElementType, - _scrollView: ScrollViewMatcher, + _scrollView?: ScrollContainer, + opts?: UnifiedGestureOptions, ): Promise { const el = await asPlaywrightElement(target); - await PlaywrightGestures.scrollIntoView(el); + await PlaywrightGestures.scrollIntoView(el, { + scrollParams: { direction: opts?.direction ?? 'down' }, + }); } /** diff --git a/tests/framework/Matchers.ts b/tests/framework/Matchers.ts index 649b6f6f34d..46f4089f907 100644 --- a/tests/framework/Matchers.ts +++ b/tests/framework/Matchers.ts @@ -1,5 +1,6 @@ import { web, system } from 'detox'; import { type EncapsulatedElementType } from './EncapsulatedElement.ts'; +import { FrameworkDetector } from './FrameworkDetector.ts'; import { resolve } from './Selector.ts'; /** @@ -183,6 +184,11 @@ export default class Matchers { static async getIdentifier( selectorString: string, ): Promise { + if (FrameworkDetector.isAppium()) { + throw new Error( + 'Matchers.getIdentifier is Detox-only. Use scrollContainer(testId) for cross-framework scroll.', + ); + } return by.id(selectorString); } diff --git a/tests/framework/PlaywrightGestures.ts b/tests/framework/PlaywrightGestures.ts index 563bc064e70..5926ac34a4e 100644 --- a/tests/framework/PlaywrightGestures.ts +++ b/tests/framework/PlaywrightGestures.ts @@ -11,7 +11,7 @@ import { const logger = createPlaywrightLogger('PlaywrightGestures'); export interface ScrollOptions { - scrollParams?: { direction?: 'up' | 'down' }; + scrollParams?: { direction?: 'up' | 'down' | 'left' | 'right' }; from?: { x: number; y: number }; to?: { x: number; y: number }; percent?: number; @@ -482,7 +482,7 @@ export default class PlaywrightGestures { * Hide keyboard for both Android and iOS * @param keyName - The key to press on iOS keyboard (default: 'Done'). Common values: 'Done', 'Return', 'Search', 'Go', 'Next' */ - static async hideKeyboard(keyName: string = 'Done'): Promise { + static async hideKeyboard(): Promise { const drv = getDriver(); if (!drv) throw new Error('Driver is not available'); @@ -490,11 +490,13 @@ export default class PlaywrightGestures { if (PlatformDetector.isAndroid()) { await drv.hideKeyboard(); } else { - // iOS - tapOutside dismisses the keyboard by tapping outside the focused - // element, which works regardless of keyboard type (password, numeric, etc.) + // iOS — use 'tapOutside' to dismiss the keyboard without pressing a + // return key. 'pressKey: Done' would trigger onSubmitEditing on inputs + // that have returnKeyType='done', causing unintended form submissions + // before the test has a chance to interact with other elements. try { await drv.executeScript('mobile: hideKeyboard', [ - { strategy: 'pressKey', key: keyName }, + { strategy: 'tapOutside' }, ]); } catch { // Keyboard may already be hidden diff --git a/tests/framework/PlaywrightUtilities.test.ts b/tests/framework/PlaywrightUtilities.test.ts index 9a50800ad55..6b579265901 100644 --- a/tests/framework/PlaywrightUtilities.test.ts +++ b/tests/framework/PlaywrightUtilities.test.ts @@ -44,9 +44,10 @@ describe('PlaywrightUtilities.launchApp', () => { }, }); - await jest.advanceTimersByTimeAsync(1000); + await jest.advanceTimersByTimeAsync(2500); await launchPromise; + expect(terminateAppMock).toHaveBeenCalledWith('io.metamask'); expect(executeMock).toHaveBeenCalledWith( 'mobile: startActivity', expect.objectContaining({ @@ -87,7 +88,7 @@ describe('PlaywrightUtilities.launchApp', () => { }, }); - await jest.advanceTimersByTimeAsync(1000); + await jest.advanceTimersByTimeAsync(2500); await launchPromise; const startActivityCall = executeMock.mock.calls.find( diff --git a/tests/framework/PlaywrightUtilities.ts b/tests/framework/PlaywrightUtilities.ts index ade02b5ba48..5d236011406 100644 --- a/tests/framework/PlaywrightUtilities.ts +++ b/tests/framework/PlaywrightUtilities.ts @@ -26,7 +26,8 @@ const deviceMatrix: DeviceMatrix = require('../performance/device-matrix.json'); type AndroidIntentExtra = ['s', string, string]; -/** Appium `mobile: startActivity` options — not passed to the app via launch arguments. */ +/** Brief pause after force-stopping Android before startActivity (CI emulators). */ +const ANDROID_PRE_LAUNCH_SETTLE_MS = 1500; const APPIUM_START_ACTIVITY_CONTROL_KEYS = new Set([ 'stop', 'wait', @@ -435,6 +436,12 @@ class PlaywrightUtilities { const stop = launchArgs?.stop ?? true; const wait = launchArgs?.wait ?? true; + // Mirror iOS: terminate before launch so `-W -S` startActivity is not stuck on a hung process. + await drv.terminateApp(pkg).catch(() => undefined); + await new Promise((resolve) => + setTimeout(resolve, ANDROID_PRE_LAUNCH_SETTLE_MS), + ); + logger.debug(`Launching Android app ${pkg}/${activity}`); await drv.execute('mobile: startActivity', { component: `${pkg}/${activity}`, diff --git a/tests/framework/UnifiedGestures.ts b/tests/framework/UnifiedGestures.ts index ad2a30f61c1..3e4e1b53286 100644 --- a/tests/framework/UnifiedGestures.ts +++ b/tests/framework/UnifiedGestures.ts @@ -4,10 +4,11 @@ import { GestureStrategy, UnifiedGestureOptions, TapAtIndexElement, - ScrollViewMatcher, + type ScrollContainer, DetoxGestureStrategy, AppiumGestureStrategy, } from './GestureStrategy.ts'; +import Matchers from './Matchers.ts'; import { resolve, isSelector, type Selector } from './Selector.ts'; /** @@ -48,6 +49,26 @@ export default class UnifiedGestures { this._strategy = null; } + /** + * Resolve scroll container for scrollToElement. + * - `string` testID → Detox matcher via Matchers.getIdentifier; omitted under Appium. + * - `ScrollViewMatcher` → passed through (Detox-only; do not build with getIdentifier in page objects under Appium). + */ + private static resolveScrollContainer( + scrollView?: ScrollContainer, + ): ScrollContainer | undefined { + if (scrollView === undefined) { + return undefined; + } + if (typeof scrollView === 'string') { + if (FrameworkDetector.isAppium()) { + return undefined; + } + return Matchers.getIdentifier(scrollView); + } + return scrollView; + } + // ── Gesture Methods ───────────────────────────────────────── static async tap( @@ -105,12 +126,12 @@ export default class UnifiedGestures { static async scrollToElement( target: EncapsulatedElementType | Selector, - scrollView: ScrollViewMatcher, + scrollView?: ScrollContainer, opts?: UnifiedGestureOptions, ): Promise { await this.strategy.scrollToElement( isSelector(target) ? resolve(target) : target, - scrollView, + this.resolveScrollContainer(scrollView), opts, ); } diff --git a/tests/framework/fixtures/playwright/currentDeviceDetails.fixture.ts b/tests/framework/fixtures/playwright/currentDeviceDetails.fixture.ts index a87d8919f34..4cce0b64040 100644 --- a/tests/framework/fixtures/playwright/currentDeviceDetails.fixture.ts +++ b/tests/framework/fixtures/playwright/currentDeviceDetails.fixture.ts @@ -7,6 +7,7 @@ import { type WebDriverConfig, } from '../../types.ts'; import { applyResolvedAndroidAdbToDevice } from '../../services/providers/emulator/android/resolveAndroidAdbUdid.ts'; +import { getIosSimulatorUdid } from '../../services/appium/EmulatorHelpers.ts'; import { createPlaywrightLogger } from '../../playwrightLogger.ts'; import type { CurrentDeviceDetails } from './types.ts'; @@ -67,6 +68,17 @@ export const currentDeviceDetailsFixture = { ); } + // For iOS local simulators, resolve the UDID of the currently-booted device. + // CI sets IOS_SIMULATOR_UDID (and project device.udid) from prepare-ios-appium-runner; + // prefer those over name lookup when multiple simulators share a display name. + let resolvedIosUdid: string | undefined; + if (platform === Platform.IOS && isLocalEmulator && deviceNameField) { + const preferredUdid = + emulatorDevice?.udid?.trim() || process.env.IOS_SIMULATOR_UDID?.trim(); + resolvedIosUdid = + preferredUdid || (await getIosSimulatorUdid(deviceNameField)); + } + const displayName = deviceNameField ?? deviceUdid ?? 'unknown'; const providerLabel = isBrowserstack ? 'browserstack' @@ -74,7 +86,7 @@ export const currentDeviceDetailsFixture = { const deviceDetails: CurrentDeviceDetails = { platform: platform as 'android' | 'ios', deviceName: displayName, - udid: emulatorDevice?.udid, + udid: resolvedIosUdid ?? emulatorDevice?.udid, packageName, appId, launchableActivity, diff --git a/tests/framework/fixtures/playwright/driver.fixture.ts b/tests/framework/fixtures/playwright/driver.fixture.ts index d9e8c63aa90..36f7fec92d6 100644 --- a/tests/framework/fixtures/playwright/driver.fixture.ts +++ b/tests/framework/fixtures/playwright/driver.fixture.ts @@ -3,6 +3,11 @@ import type { WebDriverConfig } from '../../types.ts'; import { DEFAULT_IMPLICIT_WAIT_MS } from '../../Constants.ts'; import { setDeviceInfo } from '../../DeviceInfoCache.ts'; import type { TestLevelFixtures } from './types.ts'; +import { + isVideoRecordingOnFailureEnabled, + startFailureRecording, + stopFailureRecordingAndAttach, +} from '../../services/appium/ScreenRecording.ts'; import { createPlaywrightLogger } from '../../playwrightLogger.ts'; const logger = createPlaywrightLogger('driver'); @@ -14,7 +19,12 @@ export const driverFixture = { testInfo: TestInfo, ) => { let driver: WebdriverIO.Browser | undefined; + let recordingBackend: Awaited>; const project = testInfo.project as FullProject; + const platform = project.use.platform; + const recordVideoOnFailure = isVideoRecordingOnFailureEnabled( + project.use.device?.provider, + ); try { logger.info( @@ -81,6 +91,10 @@ export const driverFixture = { logger.error('Failed to sync pre-test details:', error); } + if (recordVideoOnFailure) { + recordingBackend = await startFailureRecording(driver, platform); + } + await use(driver); } finally { const testStatus = testInfo.status; @@ -90,6 +104,19 @@ export const driverFixture = { `Tearing down WebDriver session for "${testInfo.title}" (status: ${testStatus ?? 'unknown'})`, ); + try { + if (driver) { + await stopFailureRecordingAndAttach( + driver, + testInfo, + recordingBackend, + platform, + ); + } + } catch (error) { + console.error('Failed to stop/attach failure screen recording:', error); + } + try { await deviceProvider.syncTestDetails?.({ name: testInfo.title, diff --git a/tests/framework/fixtures/playwright/types.ts b/tests/framework/fixtures/playwright/types.ts index 708572f7822..5a2acc33e4b 100644 --- a/tests/framework/fixtures/playwright/types.ts +++ b/tests/framework/fixtures/playwright/types.ts @@ -5,7 +5,8 @@ export interface CurrentDeviceDetails { platform: 'android' | 'ios'; deviceName: string; /** - * Android: adb serial (e.g. `emulator-5554`) after AVD name resolution. Omitted on iOS. + * Android: adb serial (e.g. `emulator-5554`) after AVD name resolution. + * iOS: simulator UDID resolved from the display name at fixture time (prefers the Booted one). */ udid?: string; packageName?: string; diff --git a/tests/framework/index.ts b/tests/framework/index.ts index 511d11aa734..420be330a5c 100644 --- a/tests/framework/index.ts +++ b/tests/framework/index.ts @@ -79,4 +79,5 @@ export { type UnifiedGestureOptions, type TapAtIndexElement, type ScrollViewMatcher, + type ScrollContainer, } from './GestureStrategy.ts'; diff --git a/tests/framework/services/appium/AppiumServer.test.ts b/tests/framework/services/appium/AppiumServer.test.ts new file mode 100644 index 00000000000..2dcee500fd3 --- /dev/null +++ b/tests/framework/services/appium/AppiumServer.test.ts @@ -0,0 +1,111 @@ +import { + getAppiumHost, + getAppiumPort, + getAppiumServerUrl, + isAppiumServerRunning, + shouldSkipAppiumStop, +} from './AppiumServer.ts'; + +describe('AppiumServer', () => { + const hostKey = 'APPIUM_HOST'; + const portKey = 'APPIUM_PORT'; + const skipStopKey = 'SKIP_APPIUM_STOP'; + + let previousHost: string | undefined; + let previousPort: string | undefined; + let previousSkipStop: string | undefined; + let fetchMock: jest.SpiedFunction; + + beforeEach(() => { + previousHost = process.env[hostKey]; + previousPort = process.env[portKey]; + previousSkipStop = process.env[skipStopKey]; + delete process.env[hostKey]; + delete process.env[portKey]; + delete process.env[skipStopKey]; + fetchMock = jest.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + fetchMock.mockRestore(); + if (previousHost === undefined) { + delete process.env[hostKey]; + } else { + process.env[hostKey] = previousHost; + } + if (previousPort === undefined) { + delete process.env[portKey]; + } else { + process.env[portKey] = previousPort; + } + if (previousSkipStop === undefined) { + delete process.env[skipStopKey]; + } else { + process.env[skipStopKey] = previousSkipStop; + } + }); + + describe('getAppiumHost', () => { + it('defaults to 127.0.0.1', () => { + expect(getAppiumHost()).toBe('127.0.0.1'); + }); + + it('reads APPIUM_HOST', () => { + process.env[hostKey] = 'localhost'; + expect(getAppiumHost()).toBe('localhost'); + }); + }); + + describe('getAppiumPort', () => { + it('defaults to 4723', () => { + expect(getAppiumPort()).toBe(4723); + }); + + it('reads APPIUM_PORT', () => { + process.env[portKey] = '4725'; + expect(getAppiumPort()).toBe(4725); + }); + + it('throws for invalid APPIUM_PORT', () => { + process.env[portKey] = 'not-a-port'; + expect(() => getAppiumPort()).toThrow(/Invalid APPIUM_PORT/); + }); + }); + + describe('getAppiumServerUrl', () => { + it('builds url from host and port env vars', () => { + process.env[hostKey] = '127.0.0.1'; + process.env[portKey] = '4724'; + expect(getAppiumServerUrl()).toBe('http://127.0.0.1:4724'); + }); + }); + + describe('shouldSkipAppiumStop', () => { + it('returns false by default', () => { + expect(shouldSkipAppiumStop()).toBe(false); + }); + + it('returns true when SKIP_APPIUM_STOP is true', () => { + process.env[skipStopKey] = 'true'; + expect(shouldSkipAppiumStop()).toBe(true); + }); + }); + + describe('isAppiumServerRunning', () => { + it('returns true when /status responds ok', async () => { + fetchMock.mockResolvedValue({ ok: true } as Response); + await expect(isAppiumServerRunning()).resolves.toBe(true); + expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:4723/status'); + }); + + it('returns false when /status is unreachable', async () => { + fetchMock.mockRejectedValue(new Error('ECONNREFUSED')); + await expect(isAppiumServerRunning()).resolves.toBe(false); + }); + + it('returns false when /status responds with non-ok', async () => { + fetchMock.mockResolvedValue({ ok: false } as Response); + await expect(isAppiumServerRunning()).resolves.toBe(false); + }); + }); +}); diff --git a/tests/framework/services/appium/AppiumServer.ts b/tests/framework/services/appium/AppiumServer.ts index 38f2a44e3d0..c6acebaf30a 100644 --- a/tests/framework/services/appium/AppiumServer.ts +++ b/tests/framework/services/appium/AppiumServer.ts @@ -1,29 +1,90 @@ /* eslint-disable import-x/no-nodejs-modules */ -import { spawn, exec, type ChildProcess } from 'child_process'; +import { spawn, type ChildProcess } from 'child_process'; import { createLogger, LogLevel } from '../../logger'; const logger = createLogger({ name: 'AppiumServer', level: LogLevel.INFO }); +// Track the running Appium process so we can kill it by PID instead of pkill. +let appiumServerProcess: ChildProcess | null = null; + // Track the current exit handler to prevent listener accumulation let currentExitHandler: (() => void) | null = null; // Default timeout for Appium server startup (in milliseconds) const APPIUM_STARTUP_TIMEOUT_MS = 60_000; +const DEFAULT_APPIUM_HOST = '127.0.0.1'; +const DEFAULT_APPIUM_PORT = 4723; + +/** + * Resolve Appium host from env (default: 127.0.0.1). + */ +export function getAppiumHost(): string { + return process.env.APPIUM_HOST ?? DEFAULT_APPIUM_HOST; +} + +/** + * Resolve Appium port from env (default: 4723). + */ +export function getAppiumPort(): number { + const parsed = Number(process.env.APPIUM_PORT ?? DEFAULT_APPIUM_PORT); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error( + `Invalid APPIUM_PORT "${process.env.APPIUM_PORT}". Expected a positive integer.`, + ); + } + return parsed; +} + +/** + * Build the Appium server base URL for health checks and WebDriverIO. + */ +export function getAppiumServerUrl(): string { + return `http://${getAppiumHost()}:${getAppiumPort()}`; +} + +/** + * Whether the test runner should leave Appium running after the job. + * Set explicitly via SKIP_APPIUM_STOP (e.g. Android CI keeps one server per job). + */ +export function shouldSkipAppiumStop(): boolean { + return process.env.SKIP_APPIUM_STOP === 'true'; +} + +/** + * Check whether Appium is already listening on the configured host/port. + */ +export async function isAppiumServerRunning(): Promise { + try { + const response = await fetch(`${getAppiumServerUrl()}/status`); + return response.ok; + } catch { + return false; + } +} + /** * Start the Appium server * @param timeoutMs - Maximum time to wait for Appium to start (default: 60 seconds) */ export async function startAppiumServer( timeoutMs: number = APPIUM_STARTUP_TIMEOUT_MS, -): Promise { +): Promise { + if (await isAppiumServerRunning()) { + logger.info(`Reusing existing Appium server at ${getAppiumServerUrl()}.`); + return null; + } + + const host = getAppiumHost(); + const port = getAppiumPort(); + return new Promise((resolve, reject) => { let isSettled = false; let startupTimeout: NodeJS.Timeout | null = null; const settlePromise = ( settler: typeof resolve | typeof reject, - value: ChildProcess | Error, + value: ChildProcess | Error | null, ) => { if (isSettled) return; isSettled = true; @@ -31,14 +92,24 @@ export async function startAppiumServer( clearTimeout(startupTimeout); startupTimeout = null; } - settler(value as ChildProcess & Error); + settler(value as ChildProcess & Error & null); }; const appiumProcess = spawn( 'yarn', - ['appium', '--allow-insecure=chromedriver_autodownload'], + [ + 'appium', + '--allow-insecure=chromedriver_autodownload', + '--port', + String(port), + '--address', + host, + ], { stdio: 'pipe', + // detached: true puts appium in its own process group so we can + // kill the whole group (yarn + appium children) with -pid later. + detached: true, }, ); @@ -69,17 +140,26 @@ export async function startAppiumServer( logger.debug(output); if (output.includes('Error: listen EADDRINUSE')) { + if (await isAppiumServerRunning()) { + logger.info( + `Appium port ${port} already in use — reusing existing server.`, + ); + appiumProcess.kill(); + settlePromise(resolve, null); + return; + } logger.error(`Appium: ${data}`); settlePromise( reject, new Error( - 'Appium server is already running. Please stop the server before running tests.', + `Appium port ${port} is in use but /status is not reachable.`, ), ); } if (output.includes('Appium REST http interface listener started')) { logger.debug('Appium server is up and running.'); + appiumServerProcess = appiumProcess; settlePromise(resolve, appiumProcess); } }); @@ -96,8 +176,19 @@ export async function startAppiumServer( // Create and track the new exit handler currentExitHandler = () => { + if (shouldSkipAppiumStop()) { + return; + } logger.debug('Main process exiting. Killing Appium server...'); - appiumProcess.kill(); + if (appiumProcess.pid !== undefined) { + try { + process.kill(-appiumProcess.pid, 'SIGTERM'); + } catch { + appiumProcess.kill('SIGTERM'); + } + } else { + appiumProcess.kill('SIGTERM'); + } }; process.on('exit', currentExitHandler); @@ -116,35 +207,55 @@ export async function startAppiumServer( } /** - * Stop the Appium server - * - * Note: pkill exit codes: - * - 0: One or more processes matched and were signaled - * - 1: No processes matched (not an error - server wasn't running) - * - 2: Syntax error in command line - * - 3: Fatal error + * Stop the Appium server. + * Kills the tracked process by PID to avoid accidentally matching and killing + * the parent test-runner process (which also has "appium" in its command line). + * Skips stop when SKIP_APPIUM_STOP is set or when this process did not spawn Appium. */ -export function stopAppiumServer(): Promise { +export function stopAppiumServer(): Promise { + if (shouldSkipAppiumStop()) { + logger.debug('Skipping Appium server stop (SKIP_APPIUM_STOP).'); + return Promise.resolve(); + } + // Remove the exit handler since we're explicitly stopping the server if (currentExitHandler) { process.removeListener('exit', currentExitHandler); currentExitHandler = null; } - return new Promise((resolve, reject) => { - exec('pkill -f appium', (error, stdout) => { - if (error) { - // Exit code 1 means no processes matched - this is fine, server wasn't running - if ('code' in error && error.code === 1) { - logger.debug('No Appium server process found to stop.'); - return resolve(stdout); - } - // Actual error (syntax error, fatal error, or system error) - logger.error(`Error stopping Appium server: ${error.message}`); - return reject(error); - } + return new Promise((resolve) => { + const proc = appiumServerProcess; + appiumServerProcess = null; + + if (!proc || proc.exitCode !== null || proc.killed) { + logger.debug('No running Appium server process found to stop.'); + return resolve(); + } + + // Safety timeout: resolve after 10s even if 'close' never fires. + const fallbackTimer = setTimeout(() => { + logger.warn('Appium server did not exit within 10s; continuing anyway.'); + resolve(); + }, 10_000); + + proc.once('close', () => { + clearTimeout(fallbackTimer); logger.debug('Appium server stopped successfully.'); - resolve(stdout); + resolve(); }); + + // Kill the entire process group (-pid) so yarn AND the appium child + // process are both terminated. Falls back to killing just the direct + // child if process-group kill is unavailable (e.g. pid is undefined). + if (proc.pid !== undefined) { + try { + process.kill(-proc.pid, 'SIGTERM'); + } catch { + proc.kill('SIGTERM'); + } + } else { + proc.kill('SIGTERM'); + } }); } diff --git a/tests/framework/services/appium/EmulatorHelpers.test.ts b/tests/framework/services/appium/EmulatorHelpers.test.ts new file mode 100644 index 00000000000..661d9ec1423 --- /dev/null +++ b/tests/framework/services/appium/EmulatorHelpers.test.ts @@ -0,0 +1,75 @@ +import { + ANDROID_E2E_PACKAGES_TO_DISABLE, + findAnrDialogRecoveryTapPoint, + findAnrDialogWaitTapPoint, + shouldWaitForOfflineEmulator, +} from './EmulatorHelpers.ts'; + +describe('EmulatorHelpers', () => { + describe('shouldWaitForOfflineEmulator', () => { + it('returns true only when resolved AVD matches the request', () => { + expect( + shouldWaitForOfflineEmulator('appium_smoke_avd', 'appium_smoke_avd'), + ).toBe(true); + }); + + it('returns false when AVD name is unknown', () => { + expect(shouldWaitForOfflineEmulator('appium_smoke_avd', undefined)).toBe( + false, + ); + }); + + it('returns false when offline emulator belongs to a different AVD', () => { + expect(shouldWaitForOfflineEmulator('appium_smoke_avd', 'emulator')).toBe( + false, + ); + }); + }); + + describe('findAnrDialogWaitTapPoint', () => { + it('returns Wait button center when Pixel Launcher ANR is visible', () => { + const uiDump = ` + + + + `; + + expect(findAnrDialogWaitTapPoint(uiDump)).toEqual({ x: 780, y: 650 }); + }); + + it('returns undefined when no ANR dialog is present', () => { + const uiDump = + ''; + + expect(findAnrDialogWaitTapPoint(uiDump)).toBeUndefined(); + }); + + it('falls back to Close app when Wait is absent', () => { + const uiDump = ` + + + `; + + expect(findAnrDialogWaitTapPoint(uiDump)).toEqual({ x: 300, y: 650 }); + }); + }); + + describe('findAnrDialogRecoveryTapPoint', () => { + it('prefers Close app for Pixel Launcher ANR', () => { + const uiDump = ` + + + + `; + + expect(findAnrDialogRecoveryTapPoint(uiDump)).toEqual({ x: 300, y: 650 }); + }); + + it('lists Play Store and GMS in packages to disable', () => { + expect(ANDROID_E2E_PACKAGES_TO_DISABLE).toContain('com.android.vending'); + expect(ANDROID_E2E_PACKAGES_TO_DISABLE).toContain( + 'com.google.android.gms', + ); + }); + }); +}); diff --git a/tests/framework/services/appium/EmulatorHelpers.ts b/tests/framework/services/appium/EmulatorHelpers.ts index 2ecda109ebb..d7cc5956d5e 100644 --- a/tests/framework/services/appium/EmulatorHelpers.ts +++ b/tests/framework/services/appium/EmulatorHelpers.ts @@ -1,11 +1,523 @@ /* eslint-disable import-x/no-nodejs-modules */ -import { exec } from 'child_process'; +import { exec, spawn } from 'child_process'; import path from 'path'; import { Platform } from '../../types.ts'; import { createLogger } from '../../logger.ts'; const logger = createLogger({ name: 'EmulatorHelpers' }); +const ANDROID_BOOT_TIMEOUT_MS = 3 * 60 * 1000; +const ANDROID_BOOT_POLL_INTERVAL_MS = 2000; +const ANDROID_CI_INITIAL_SETTLE_MS = 15_000; +const ANDROID_ANR_DISMISS_INTERVAL_MS = 3000; +const ANDROID_ANR_CLEAR_STREAK_REQUIRED = 3; +const ANDROID_ANR_STABILIZE_TIMEOUT_MS = 90_000; +const ANDROID_EMULATOR_CI_CORES_DEFAULT = '8'; +const UI_AUTOMATOR_DUMP_PATH = '/sdcard/window_dump.xml'; + +/** Play Store / GMS packages disabled after cold boot — not needed for Appium E2E. */ +export const ANDROID_E2E_PACKAGES_TO_DISABLE = [ + 'com.android.vending', + 'com.google.android.gms', + 'com.google.android.gsf', + 'com.google.android.partnersetup', + 'com.google.android.setupwizard', + 'com.google.android.apps.restore', + 'com.google.android.apps.wellbeing', +] as const; + +/** Launcher packages force-stopped so ANR dialogs do not cover the test app. */ +export const ANDROID_E2E_LAUNCHER_PACKAGES = [ + 'com.google.android.apps.nexuslauncher', + 'com.android.launcher3', +] as const; + +interface AdbDevice { + serial: string; + state: string; +} + +function execAsync(cmd: string): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + exec(cmd, (error, stdout, stderr) => { + if (error) { + reject(Object.assign(error, { stdout, stderr })); + } else { + resolve({ stdout, stderr }); + } + }); + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function parseAdbDevices(stdout: string): AdbDevice[] { + return stdout + .trim() + .split('\n') + .slice(1) + .map((line) => { + const trimmed = line.trim(); + if (!trimmed) { + return null; + } + const tabIndex = trimmed.indexOf('\t'); + if (tabIndex === -1) { + return null; + } + const serial = trimmed.slice(0, tabIndex); + const state = trimmed.slice(tabIndex + 1).trim(); + if (!serial.startsWith('emulator-')) { + return null; + } + return { serial, state }; + }) + .filter((device): device is AdbDevice => device !== null); +} + +async function listAdbDevices(): Promise { + const { stdout } = await execAsync('adb devices'); + return parseAdbDevices(stdout); +} + +async function getEmulatorAvdName(serial: string): Promise { + try { + const { stdout } = await execAsync(`adb -s ${serial} emu avd name`); + return stdout.trim().split('\n')[0]?.trim(); + } catch { + return undefined; + } +} + +async function findEmulatorSerialForAvd( + avdName: string, + states: string[], +): Promise { + const devices = await listAdbDevices(); + for (const device of devices) { + if (!states.includes(device.state)) { + continue; + } + const name = await getEmulatorAvdName(device.serial); + if (name === avdName) { + return device.serial; + } + } + return undefined; +} + +/** + * Only reuse an offline/authorizing emulator when its AVD name is known and matches. + * If `adb emu avd name` fails, do not attach — another host's emulator may be starting. + */ +export function shouldWaitForOfflineEmulator( + requestedAvdName: string, + resolvedAvdName: string | undefined, +): boolean { + return resolvedAvdName === requestedAvdName; +} + +interface TapPoint { + x: number; + y: number; +} + +function parseUiNodeCenter(bounds: string): TapPoint | undefined { + const match = bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/); + if (!match) { + return undefined; + } + const left = Number.parseInt(match[1], 10); + const top = Number.parseInt(match[2], 10); + const right = Number.parseInt(match[3], 10); + const bottom = Number.parseInt(match[4], 10); + return { + x: Math.floor((left + right) / 2), + y: Math.floor((top + bottom) / 2), + }; +} + +/** + * Finds the tap target for an Android "app isn't responding" dialog. + * Prefers "Wait" over "Close app" so the launcher can recover. + */ +export function findAnrDialogWaitTapPoint( + uiDump: string, +): TapPoint | undefined { + return findAnrDialogRecoveryTapPoint(uiDump, { preferCloseApp: false }); +} + +/** + * Finds an ANR recovery tap target. Prefers "Close app" for launcher ANRs + * (Pixel/Nexus Launcher) since we launch MetaMask directly and do not need HOME. + */ +export function findAnrDialogRecoveryTapPoint( + uiDump: string, + options?: { preferCloseApp?: boolean }, +): TapPoint | undefined { + if (!/isn.t responding/i.test(uiDump)) { + return undefined; + } + + const preferCloseApp = + options?.preferCloseApp ?? + /Pixel Launcher|Nexus Launcher|launcher/i.test(uiDump); + + const waitNode = uiDump.match( + /text="Wait"[^>]*bounds="(\[[^\]]+\]\[[^\]]+\])"/, + ); + const closeNode = uiDump.match( + /text="Close app"[^>]*bounds="(\[[^\]]+\]\[[^\]]+\])"/, + ); + + if (preferCloseApp) { + if (closeNode) { + return parseUiNodeCenter(closeNode[1]); + } + if (waitNode) { + return parseUiNodeCenter(waitNode[1]); + } + return undefined; + } + + if (waitNode) { + return parseUiNodeCenter(waitNode[1]); + } + if (closeNode) { + return parseUiNodeCenter(closeNode[1]); + } + + return undefined; +} + +async function dumpUiHierarchy(serial: string): Promise { + await execAsync( + `adb -s ${serial} shell uiautomator dump ${UI_AUTOMATOR_DUMP_PATH}`, + ).catch(() => undefined); + const { stdout } = await execAsync( + `adb -s ${serial} shell cat ${UI_AUTOMATOR_DUMP_PATH}`, + ).catch(() => ({ stdout: '', stderr: '' })); + return stdout; +} + +async function disableAndroidAnimations(serial: string): Promise { + const settings = [ + 'settings put global window_animation_scale 0', + 'settings put global transition_animation_scale 0', + 'settings put global animator_duration_scale 0', + ]; + for (const command of settings) { + await execAsync(`adb -s ${serial} shell ${command}`).catch(() => undefined); + } +} + +async function runAdbShell(serial: string, command: string): Promise { + await execAsync(`adb -s ${serial} shell ${command}`).catch(() => undefined); +} + +async function trimAndroidSystemForE2e(serial: string): Promise { + logger.info( + 'Trimming Android system for E2E (skip setup wizard, disable bloat packages)...', + ); + + const setupWizardSettings = [ + 'settings put global device_provisioned 1', + 'settings put secure user_setup_complete 1', + 'settings put global setup_wizard_has_run 1', + ]; + for (const command of setupWizardSettings) { + await runAdbShell(serial, command); + } + + for (const packageName of ANDROID_E2E_PACKAGES_TO_DISABLE) { + await runAdbShell(serial, `pm disable-user --user 0 ${packageName}`); + } + + for (const packageName of ANDROID_E2E_LAUNCHER_PACKAGES) { + await runAdbShell(serial, `am force-stop ${packageName}`); + } +} + +async function dismissAndroidAnrDialogs(serial: string): Promise { + const uiDump = await dumpUiHierarchy(serial); + const tapPoint = findAnrDialogRecoveryTapPoint(uiDump); + if (!tapPoint) { + return false; + } + + logger.warn( + `Android system ANR dialog detected on ${serial} — tapping recovery action.`, + ); + await execAsync( + `adb -s ${serial} shell input tap ${tapPoint.x} ${tapPoint.y}`, + ); + return true; +} + +/** + * Poll until ANR dialogs are absent for several consecutive checks, dismissing + * any that appear. Falls through after timeout so boot does not hang forever. + */ +async function waitForAndroidSystemReady(serial: string): Promise { + const initialSettleMs = Number.parseInt( + process.env.ANDROID_EMULATOR_POST_BOOT_SETTLE_MS ?? + String(ANDROID_CI_INITIAL_SETTLE_MS), + 10, + ); + if (initialSettleMs > 0) { + logger.info( + `Waiting ${initialSettleMs / 1000}s before checking Android system readiness...`, + ); + await sleep(initialSettleMs); + } + + const deadline = Date.now() + ANDROID_ANR_STABILIZE_TIMEOUT_MS; + let clearStreak = 0; + + while (Date.now() < deadline) { + const hadAnr = await dismissAndroidAnrDialogs(serial); + if (hadAnr) { + clearStreak = 0; + } else { + clearStreak += 1; + if (clearStreak >= ANDROID_ANR_CLEAR_STREAK_REQUIRED) { + logger.info( + `Android system stable (${clearStreak} consecutive ANR-free checks).`, + ); + return; + } + } + await sleep(ANDROID_ANR_DISMISS_INTERVAL_MS); + } + + logger.warn( + `Android system did not stabilize within ${ANDROID_ANR_STABILIZE_TIMEOUT_MS / 1000}s — continuing.`, + ); +} + +/** + * After cold `-wipe-data` boot, system apps can ANR while indexing. + * Trim bloat, wait for ANR-free window, then force-stop the launcher. + */ +async function stabilizeAndroidEmulatorAfterBoot( + serial: string, +): Promise { + if (process.env.CI !== 'true') { + return; + } + + logger.info('Stabilizing Android emulator after cold boot...'); + await disableAndroidAnimations(serial); + await trimAndroidSystemForE2e(serial); + await execAsync(`adb -s ${serial} shell input keyevent 3`).catch(() => { + /* HOME — surface any remaining system dialogs */ + }); + + await waitForAndroidSystemReady(serial); + + for (const packageName of ANDROID_E2E_LAUNCHER_PACKAGES) { + await runAdbShell(serial, `am force-stop ${packageName}`); + } +} + +async function waitForEmulatorBoot(serial: string): Promise { + await execAsync(`adb -s ${serial} wait-for-device`); + + const deadline = Date.now() + ANDROID_BOOT_TIMEOUT_MS; + let booted = false; + + while (Date.now() < deadline) { + const devices = await listAdbDevices(); + const device = devices.find((entry) => entry.serial === serial); + if (device?.state === 'device') { + try { + const { stdout } = await execAsync( + `adb -s ${serial} shell getprop sys.boot_completed 2>/dev/null`, + ); + if (stdout.trim() === '1') { + booted = true; + break; + } + } catch { + // Device not yet ready — keep polling + } + } + await sleep(ANDROID_BOOT_POLL_INTERVAL_MS); + } + + if (!booted) { + throw new Error( + `Android emulator ${serial} did not complete booting within ${ANDROID_BOOT_TIMEOUT_MS / 1000}s.`, + ); + } + + await execAsync(`adb -s ${serial} shell input keyevent 82`).catch(() => { + /* screen may already be unlocked */ + }); + + await stabilizeAndroidEmulatorAfterBoot(serial); +} + +/** + * Check if any Android emulator is currently running and fully booted. + */ +export async function isAndroidEmulatorRunning(): Promise { + try { + const devices = await listAdbDevices(); + return devices.some((device) => device.state === 'device'); + } catch { + return false; + } +} + +/** + * Start the Android emulator and wait for it to fully boot. + * If the requested AVD is already running and booted, this is a no-op. + * @returns adb serial for the booted emulator (e.g. emulator-5554) + */ +export async function startAndroidEmulator(avdName: string): Promise { + const bootedSerial = await findEmulatorSerialForAvd(avdName, ['device']); + if (bootedSerial) { + logger.info( + `Android emulator "${avdName}" (${bootedSerial}) is already running — skipping boot.`, + ); + return bootedSerial; + } + + const startingSerial = await findEmulatorSerialForAvd(avdName, [ + 'offline', + 'authorizing', + ]); + if (startingSerial) { + logger.info( + `Android emulator "${avdName}" (${startingSerial}) is starting — waiting for boot.`, + ); + await waitForEmulatorBoot(startingSerial); + return startingSerial; + } + + const devices = await listAdbDevices(); + const offlineEmulator = devices.find( + (device) => device.state === 'offline' || device.state === 'authorizing', + ); + if (offlineEmulator) { + const offlineAvdName = await getEmulatorAvdName(offlineEmulator.serial); + if (shouldWaitForOfflineEmulator(avdName, offlineAvdName)) { + logger.info( + `Waiting for Android emulator ${offlineEmulator.serial} (${offlineAvdName}) to finish booting instead of spawning a duplicate.`, + ); + await waitForEmulatorBoot(offlineEmulator.serial); + return offlineEmulator.serial; + } + if (offlineAvdName) { + logger.info( + `Ignoring offline emulator ${offlineEmulator.serial} (AVD "${offlineAvdName}") — requested "${avdName}".`, + ); + } else { + logger.info( + `Ignoring offline emulator ${offlineEmulator.serial} (AVD unknown) — requested "${avdName}".`, + ); + } + } + + const androidHome = process.env.ANDROID_HOME; + if (!androidHome) { + throw new Error( + 'ANDROID_HOME is not set. Please set the ANDROID_HOME environment variable.', + ); + } + + const emulatorBin = path.join(androidHome, 'emulator', 'emulator'); + const isCI = process.env.CI === 'true'; + + logger.info(`Starting Android emulator: ${avdName}`); + + // Appium smoke CI uses AOSP (`default` image) — lighter cold boot than google_apis. + // RAM/CPU flags align with Detox CI where noted; cores overridable via env. + const args = ['-avd', avdName]; + if (isCI) { + const cores = + process.env.ANDROID_EMULATOR_CI_CORES?.trim() || + ANDROID_EMULATOR_CI_CORES_DEFAULT; + args.push( + '-skin', + '1080x2340', + '-memory', + '12288', + '-cores', + cores, + '-gpu', + 'swiftshader_indirect', + '-no-audio', + '-no-boot-anim', + '-partition-size', + '8192', + '-no-snapshot-save', + '-no-snapshot-load', + '-cache-size', + '2048', + '-accel', + 'on', + '-wipe-data', + '-read-only', + '-no-window', + ); + } else { + args.push('-no-snapshot-load'); + } + + const emulatorProcess = spawn(emulatorBin, args, { + stdio: 'ignore', + detached: true, + }); + emulatorProcess.unref(); + + logger.info('Waiting for Android emulator to appear in adb...'); + const deadline = Date.now() + ANDROID_BOOT_TIMEOUT_MS; + let serial: string | undefined; + + while (Date.now() < deadline) { + serial = await findEmulatorSerialForAvd(avdName, [ + 'offline', + 'authorizing', + 'device', + ]); + if (serial) { + break; + } + await sleep(ANDROID_BOOT_POLL_INTERVAL_MS); + } + + if (!serial) { + throw new Error( + `Android emulator for AVD "${avdName}" did not appear in adb within ${ANDROID_BOOT_TIMEOUT_MS / 1000}s.`, + ); + } + + await waitForEmulatorBoot(serial); + logger.info(`Android emulator "${avdName}" is booted and ready (${serial}).`); + return serial; +} + +/** + * Stop the running Android emulator gracefully. + */ +export async function stopAndroidEmulator(serial?: string): Promise { + logger.info('Stopping Android emulator...'); + try { + if (serial) { + await execAsync(`adb -s ${serial} emu kill`); + } else { + await execAsync('adb emu kill'); + } + logger.info('Android emulator stopped.'); + } catch (error) { + logger.warn(`Could not stop Android emulator: ${error}`); + } +} + /** * Check if emulator is installed for the given platform * Not in use for now @@ -46,40 +558,125 @@ export function isEmulatorInstalled(platform: Platform): Promise { } }); } else { - // iOS simulators - to be implemented resolve(true); } }); } /** - * Start Android emulator - to be implemented + * Per-process cache: deviceName → resolved UDID. */ -export function startAndroidEmulator(deviceName: string): void { +const iosSimulatorUdidCache = new Map(); + +export async function getIosSimulatorUdid(deviceName: string): Promise { + const cached = iosSimulatorUdidCache.get(deviceName); + if (cached && (await isIosSimulatorBooted(cached))) { + return cached; + } + if (cached) { + iosSimulatorUdidCache.delete(deviceName); + } + + const { stdout } = await execAsync('xcrun simctl list devices available -j'); + const list = JSON.parse(stdout) as { + devices: Record; + }; + + let firstMatch: string | undefined; + + for (const devices of Object.values(list.devices)) { + for (const d of devices) { + if (d.name !== deviceName) continue; + if (d.state === 'Booted') { + iosSimulatorUdidCache.set(deviceName, d.udid); + return d.udid; + } + firstMatch ??= d.udid; + } + } + + if (firstMatch) { + // Do not cache shutdown simulators — callers may boot a different UDID next. + return firstMatch; + } + throw new Error( - `Starting Android emulator ${deviceName} - Not implemented yet`, + `iOS simulator "${deviceName}" not found in available devices. ` + + 'Run `xcrun simctl list devices available` to see available simulators.', ); } -/** - * Stop Android emulator - to be implemented - */ -export function stopAndroidEmulator(deviceName: string): void { - throw new Error( - `Stopping Android emulator ${deviceName} - Not implemented yet`, +export async function isIosSimulatorBooted(udid: string): Promise { + try { + const { stdout } = await execAsync('xcrun simctl list devices -j'); + const list = JSON.parse(stdout) as { + devices: Record; + }; + for (const devices of Object.values(list.devices)) { + const sim = devices.find((d) => d.udid === udid); + if (sim) { + return sim.state === 'Booted'; + } + } + } catch { + return false; + } + return false; +} + +export async function bootIosSimulatorByUdid(udid: string): Promise { + if (await isIosSimulatorBooted(udid)) { + logger.info(`iOS simulator ${udid} is already booted — skipping boot.`); + return udid; + } + + logger.info(`Booting iOS simulator: ${udid}`); + + await execAsync(`xcrun simctl boot "${udid}"`).catch( + (err: { code?: number }) => { + if (err.code !== 149) { + throw err; + } + }, ); + + await execAsync(`xcrun simctl bootstatus "${udid}" -b`); + + logger.info(`iOS simulator ${udid} is booted and ready.`); + return udid; } -/** - * Start iOS simulator - to be implemented - */ -export function startIosSimulator(deviceName: string): void { - throw new Error(`Starting iOS simulator ${deviceName} - Not implemented yet`); +export async function startIosSimulator(deviceName: string): Promise { + const udid = await getIosSimulatorUdid(deviceName); + const bootedUdid = await bootIosSimulatorByUdid(udid); + iosSimulatorUdidCache.set(deviceName, bootedUdid); + return bootedUdid; } /** - * Stop iOS simulator - to be implemented + * Ensures an iOS simulator is booted before Appium session creation. + * Prefers `preferredUdid` or `IOS_SIMULATOR_UDID` (set by CI prepare step) + * so tests attach to the same sim that received the app install. */ -export function stopIosSimulator(deviceName: string): void { - throw new Error(`Stopping iOS simulator ${deviceName} - Not implemented yet`); +export async function ensureIosSimulatorReady( + deviceName: string, + preferredUdid?: string, +): Promise { + const udid = preferredUdid?.trim() || process.env.IOS_SIMULATOR_UDID?.trim(); + if (udid) { + const bootedUdid = await bootIosSimulatorByUdid(udid); + iosSimulatorUdidCache.set(deviceName, bootedUdid); + return bootedUdid; + } + return startIosSimulator(deviceName); +} + +export async function stopIosSimulator(udid: string): Promise { + logger.info(`Shutting down iOS simulator: ${udid}`); + try { + await execAsync(`xcrun simctl shutdown "${udid}"`); + logger.info(`iOS simulator ${udid} shut down.`); + } catch (error) { + logger.warn(`Could not stop iOS simulator: ${error}`); + } } diff --git a/tests/framework/services/appium/ScreenRecording.test.ts b/tests/framework/services/appium/ScreenRecording.test.ts new file mode 100644 index 00000000000..9f268a49484 --- /dev/null +++ b/tests/framework/services/appium/ScreenRecording.test.ts @@ -0,0 +1,156 @@ +import { + buildRecordingFileBaseName, + extractRecordingPayload, + isAndroidPlatform, + isLocalEmulatorProvider, + isVideoRecordingOnFailureEnabled, + sanitizeRecordingFileName, + shouldPersistRecordingAlways, +} from './ScreenRecording.ts'; +import { ProviderName } from '../../types.ts'; + +describe('ScreenRecording', () => { + const recordVideoKey = 'APPIUM_RECORD_VIDEO_ON_FAILURE'; + const recordVideoAlwaysKey = 'APPIUM_RECORD_VIDEO_ALWAYS'; + const ciKey = 'CI'; + let previousRecordVideo: string | undefined; + let previousRecordVideoAlways: string | undefined; + let previousCi: string | undefined; + + beforeEach(() => { + previousRecordVideo = process.env[recordVideoKey]; + previousRecordVideoAlways = process.env[recordVideoAlwaysKey]; + previousCi = process.env[ciKey]; + delete process.env[recordVideoKey]; + delete process.env[recordVideoAlwaysKey]; + delete process.env[ciKey]; + }); + + afterEach(() => { + if (previousRecordVideo === undefined) { + delete process.env[recordVideoKey]; + } else { + process.env[recordVideoKey] = previousRecordVideo; + } + if (previousRecordVideoAlways === undefined) { + delete process.env[recordVideoAlwaysKey]; + } else { + process.env[recordVideoAlwaysKey] = previousRecordVideoAlways; + } + if (previousCi === undefined) { + delete process.env[ciKey]; + } else { + process.env[ciKey] = previousCi; + } + }); + + describe('isVideoRecordingOnFailureEnabled', () => { + it('returns false for BrowserStack', () => { + process.env[ciKey] = 'true'; + expect(isVideoRecordingOnFailureEnabled(ProviderName.BROWSERSTACK)).toBe( + false, + ); + }); + + it('returns true on CI for local emulator by default', () => { + process.env[ciKey] = 'true'; + expect(isVideoRecordingOnFailureEnabled(ProviderName.EMULATOR)).toBe( + true, + ); + }); + + it('returns false on CI when explicitly disabled', () => { + process.env[ciKey] = 'true'; + process.env[recordVideoKey] = 'false'; + expect(isVideoRecordingOnFailureEnabled(ProviderName.SIMULATOR)).toBe( + false, + ); + }); + + it('returns false locally unless explicitly enabled', () => { + expect(isVideoRecordingOnFailureEnabled(ProviderName.EMULATOR)).toBe( + false, + ); + process.env[recordVideoKey] = 'true'; + expect(isVideoRecordingOnFailureEnabled(ProviderName.EMULATOR)).toBe( + true, + ); + }); + }); + + describe('isLocalEmulatorProvider', () => { + it('accepts emulator and simulator providers', () => { + expect(isLocalEmulatorProvider(ProviderName.EMULATOR)).toBe(true); + expect(isLocalEmulatorProvider(ProviderName.SIMULATOR)).toBe(true); + expect(isLocalEmulatorProvider(ProviderName.BROWSERSTACK)).toBe(false); + }); + }); + + describe('sanitizeRecordingFileName', () => { + it('replaces unsafe characters', () => { + expect(sanitizeRecordingFileName('should login')).toBe('should_login'); + }); + }); + + describe('buildRecordingFileBaseName', () => { + it('includes describe block and test title in the file name', () => { + expect( + buildRecordingFileBaseName({ + projectName: 'android-smoke', + titlePath: ['SmokeAccounts: Login to app', 'logs in successfully'], + }), + ).toBe('android-smoke-SmokeAccounts_Login_to_app__logs_in_successfully'); + }); + + it('adds a retry suffix when the test is retried', () => { + expect( + buildRecordingFileBaseName({ + projectName: 'ios-smoke', + titlePath: ['SmokeAccounts: Login to app', 'logs in successfully'], + retry: 1, + }), + ).toBe( + 'ios-smoke-SmokeAccounts_Login_to_app__logs_in_successfully-retry1', + ); + }); + }); + + describe('shouldPersistRecordingAlways', () => { + it('returns false by default', () => { + expect(shouldPersistRecordingAlways()).toBe(false); + }); + + it('returns true when APPIUM_RECORD_VIDEO_ALWAYS is true', () => { + process.env[recordVideoAlwaysKey] = 'true'; + expect(shouldPersistRecordingAlways()).toBe(true); + }); + }); + + describe('isAndroidPlatform', () => { + it('detects Android platform names case-insensitively', () => { + expect(isAndroidPlatform('Android')).toBe(true); + expect(isAndroidPlatform('android')).toBe(true); + expect(isAndroidPlatform('iOS')).toBe(false); + expect(isAndroidPlatform(undefined)).toBe(false); + }); + }); + + describe('extractRecordingPayload', () => { + it('reads a raw base64 string', () => { + expect(extractRecordingPayload('abc123')).toBe('abc123'); + }); + + it('ignores empty strings', () => { + expect(extractRecordingPayload('')).toBeUndefined(); + }); + + it('reads payload from object wrappers', () => { + expect(extractRecordingPayload({ payload: 'video-data' })).toBe( + 'video-data', + ); + expect(extractRecordingPayload({ media: 'video-data' })).toBe( + 'video-data', + ); + }); + }); +}); diff --git a/tests/framework/services/appium/ScreenRecording.ts b/tests/framework/services/appium/ScreenRecording.ts new file mode 100644 index 00000000000..1947f8184b9 --- /dev/null +++ b/tests/framework/services/appium/ScreenRecording.ts @@ -0,0 +1,403 @@ +/* eslint-disable import-x/no-nodejs-modules */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { TestInfo } from '@playwright/test'; +import { Platform, type ProviderName } from '../../types.ts'; +import { createLogger } from '../../logger.ts'; + +const logger = createLogger({ name: 'ScreenRecording' }); + +const RECORD_VIDEO_ON_FAILURE_ENV_KEY = 'APPIUM_RECORD_VIDEO_ON_FAILURE'; +const RECORD_VIDEO_ALWAYS_ENV_KEY = 'APPIUM_RECORD_VIDEO_ALWAYS'; +const RECORD_VIDEO_TIME_LIMIT_ENV_KEY = 'APPIUM_RECORD_VIDEO_TIME_LIMIT_SEC'; +const CI_ENV_KEY = 'CI'; + +/** Playwright Appium smoke tests are invoked from the repo root (`yarn appium-smoke:*`). */ +export const APPIUM_SMOKE_VIDEOS_DIR = join( + process.cwd(), + 'tests/test-reports/appium-smoke-videos', + process.env.APPIUM_SMOKE_SUITE_NAME?.trim() ?? '', +); + +const DEFAULT_TIME_LIMIT_SEC = 600; + +/** Which Appium recording API was started for the active session. */ +export type ScreenRecordingBackend = + | 'ios' + | 'android-screenrecord' + | 'android-media-projection'; + +/** + * Local emulator/simulator runs only — BrowserStack records server-side. + */ +export function isLocalEmulatorProvider( + provider: ProviderName | undefined, +): boolean { + return provider === 'emulator' || provider === 'simulator'; +} + +/** + * - `APPIUM_RECORD_VIDEO_ON_FAILURE=false` — disabled + * - unset / `true` — enabled on CI; enabled locally only when explicitly `true` + */ +export function isVideoRecordingOnFailureEnabled( + provider: ProviderName | undefined, +): boolean { + if (!isLocalEmulatorProvider(provider)) { + return false; + } + + const raw = + process.env[RECORD_VIDEO_ON_FAILURE_ENV_KEY]?.trim().toLowerCase(); + if (raw === 'false' || raw === '0' || raw === 'no') { + return false; + } + if (raw === 'true' || raw === '1' || raw === 'yes') { + return true; + } + + return process.env[CI_ENV_KEY] === 'true'; +} + +export function sanitizeRecordingFileName(title: string): string { + return title + .replace(/[^\w.-]+/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, '') + .slice(0, 120); +} + +/** @internal exported for unit tests */ +export function buildRecordingFileBaseName(options: { + projectName: string; + titlePath: string[]; + retry?: number; +}): string { + const scenarioName = options.titlePath + .map((segment) => sanitizeRecordingFileName(segment)) + .filter(Boolean) + .join('__'); + + const retrySuffix = + options.retry && options.retry > 0 ? `-retry${options.retry}` : ''; + + if (!scenarioName) { + return `${options.projectName}-unknown-test${retrySuffix}`; + } + + return `${options.projectName}-${scenarioName}${retrySuffix}`; +} + +function buildRecordingBaseName(testInfo: TestInfo): string { + return buildRecordingFileBaseName({ + projectName: testInfo.project.name, + titlePath: testInfo.titlePath, + retry: testInfo.retry, + }); +} + +/** @internal exported for unit tests */ +export function extractRecordingPayload(result: unknown): string | undefined { + if (typeof result === 'string' && result.length > 0) { + return result; + } + if (result && typeof result === 'object') { + const record = result as Record; + for (const key of ['payload', 'value', 'video', 'media'] as const) { + const candidate = record[key]; + if (typeof candidate === 'string' && candidate.length > 0) { + return candidate; + } + } + } + return undefined; +} + +function recordingTimeLimitSec(): number { + const raw = process.env[RECORD_VIDEO_TIME_LIMIT_ENV_KEY]; + if (!raw) { + return DEFAULT_TIME_LIMIT_SEC; + } + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : DEFAULT_TIME_LIMIT_SEC; +} + +/** @internal exported for unit tests */ +export function isAndroidPlatform(platformName: string | undefined): boolean { + return platformName?.trim().toLowerCase() === 'android'; +} + +function resolveIsAndroid( + browser: WebdriverIO.Browser, + platform?: Platform, +): boolean { + if (platform === Platform.ANDROID) { + return true; + } + if (platform === Platform.IOS) { + return false; + } + if (browser.isAndroid) { + return true; + } + if (browser.isIOS) { + return false; + } + return isAndroidPlatform(resolvePlatformName(browser)); +} + +function resolvePlatformName(browser: WebdriverIO.Browser): string | undefined { + const capabilities = browser.capabilities as Record; + const raw = capabilities.platformName ?? capabilities['appium:platformName']; + return typeof raw === 'string' ? raw : undefined; +} + +function formatRecordingError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function logRecordingIssue(message: string): void { + logger.warn(message); + // Visible in CI step logs without logger level tuning. + console.warn(`[ScreenRecording] ${message}`); +} + +type AppiumScreenRecorder = WebdriverIO.Browser & { + startRecordingScreen?: ( + options?: Record, + ) => Promise; + stopRecordingScreen?: (options?: Record) => Promise; +}; + +async function startAndroidScreenRecord( + browser: WebdriverIO.Browser, +): Promise { + const appiumDriver = browser as AppiumScreenRecorder; + if (typeof appiumDriver.startRecordingScreen === 'function') { + try { + await appiumDriver.startRecordingScreen({ + timeLimit: String(recordingTimeLimitSec()), + }); + logRecordingIssue('Android adb screenrecord started'); + return 'android-screenrecord'; + } catch (error) { + logRecordingIssue( + `adb screenrecord failed, trying media projection: ${formatRecordingError(error)}`, + ); + } + } + + try { + const started = await browser.execute( + 'mobile: startMediaProjectionRecording', + { + maxDurationSec: recordingTimeLimitSec(), + resolution: '1280x720', + }, + ); + logRecordingIssue( + started === false + ? 'Android media projection already running' + : 'Android media projection recording started', + ); + return 'android-media-projection'; + } catch (error) { + logRecordingIssue( + `Could not start Android recording: ${formatRecordingError(error)}`, + ); + return undefined; + } +} + +async function stopAndroidRecording( + browser: WebdriverIO.Browser, + backend: ScreenRecordingBackend, +): Promise { + const appiumDriver = browser as AppiumScreenRecorder; + const attempts: ScreenRecordingBackend[] = + backend === 'android-screenrecord' + ? ['android-screenrecord', 'android-media-projection'] + : ['android-media-projection', 'android-screenrecord']; + + let lastError: unknown; + for (const attempt of attempts) { + try { + const result = + attempt === 'android-screenrecord' && + typeof appiumDriver.stopRecordingScreen === 'function' + ? await appiumDriver.stopRecordingScreen() + : await browser.execute('mobile: stopMediaProjectionRecording'); + const payload = extractRecordingPayload(result); + if (payload) { + return payload; + } + } catch (error) { + lastError = error; + } + } + + if (lastError) { + throw lastError; + } + return undefined; +} + +async function startIosRecording( + browser: WebdriverIO.Browser, +): Promise { + const appiumDriver = browser as AppiumScreenRecorder; + if (typeof appiumDriver.startRecordingScreen !== 'function') { + logRecordingIssue( + 'startRecordingScreen is not available on this WebDriver session', + ); + return undefined; + } + + try { + await appiumDriver.startRecordingScreen({ + timeLimit: String(recordingTimeLimitSec()), + videoQuality: 'medium', + }); + logRecordingIssue('iOS screen recording started'); + return 'ios'; + } catch (error) { + logRecordingIssue( + `Could not start iOS recording: ${formatRecordingError(error)}`, + ); + return undefined; + } +} + +async function stopIosRecording( + browser: WebdriverIO.Browser, +): Promise { + const appiumDriver = browser as AppiumScreenRecorder; + if (typeof appiumDriver.stopRecordingScreen !== 'function') { + throw new Error( + 'stopRecordingScreen is not available on this WebDriver session', + ); + } + + const result = await appiumDriver.stopRecordingScreen(); + return extractRecordingPayload(result); +} + +/** + * Starts Appium device screen recording for the active session. + * Android tries adb screenrecord first (reliable on emulators), then media projection. + */ +export async function startFailureRecording( + browser: WebdriverIO.Browser, + platform?: Platform, +): Promise { + try { + if (resolveIsAndroid(browser, platform)) { + return await startAndroidScreenRecord(browser); + } + + return await startIosRecording(browser); + } catch (error) { + logRecordingIssue( + `Could not start screen recording: ${formatRecordingError(error)}`, + ); + return undefined; + } +} + +/** + * Optional override: set APPIUM_RECORD_VIDEO_ALWAYS=true to persist every recording + * (useful for local debugging). CI defaults to failure-only via shouldPersistRecording. + */ +export function shouldPersistRecordingAlways(): boolean { + const raw = process.env[RECORD_VIDEO_ALWAYS_ENV_KEY]?.trim().toLowerCase(); + return raw === 'true' || raw === '1' || raw === 'yes'; +} + +function shouldPersistRecording(testInfo: TestInfo): boolean { + if (shouldPersistRecordingAlways()) { + return true; + } + + return ( + testInfo.status === 'failed' || + testInfo.status === 'timedOut' || + testInfo.status === 'interrupted' + ); +} + +/** + * Stops recording. On failure, attaches MP4 to the Playwright report and writes + * a copy under {@link APPIUM_SMOKE_VIDEOS_DIR} for CI artifact upload. + * Always attempts stop when recording was started (even on pass) to free resources. + */ +export async function stopFailureRecordingAndAttach( + browser: WebdriverIO.Browser, + testInfo: TestInfo, + recordingBackend: ScreenRecordingBackend | undefined, + platform?: Platform, +): Promise { + if (!recordingBackend) { + return; + } + + let payload: string | undefined; + try { + if ( + recordingBackend === 'android-screenrecord' || + recordingBackend === 'android-media-projection' || + resolveIsAndroid(browser, platform) + ) { + payload = await stopAndroidRecording(browser, recordingBackend); + } else { + payload = await stopIosRecording(browser); + } + } catch (error) { + logRecordingIssue( + `Could not stop screen recording: ${formatRecordingError(error)}`, + ); + return; + } + + if (!payload) { + logRecordingIssue( + `Screen recording stopped but returned no payload (status=${testInfo.status ?? 'unknown'})`, + ); + return; + } + + if (!shouldPersistRecording(testInfo)) { + logger.debug( + `Discarding screen recording (status=${testInfo.status ?? 'unknown'})`, + ); + return; + } + + const videoBuffer = Buffer.from(payload, 'base64'); + const fileName = `${buildRecordingBaseName(testInfo)}.mp4`; + const filePath = join(APPIUM_SMOKE_VIDEOS_DIR, fileName); + + try { + mkdirSync(APPIUM_SMOKE_VIDEOS_DIR, { recursive: true }); + writeFileSync(filePath, videoBuffer); + logRecordingIssue( + `Saved recording (${videoBuffer.length} bytes): ${filePath}`, + ); + } catch (error) { + logRecordingIssue( + `Could not write recording to disk: ${formatRecordingError(error)}`, + ); + } + + try { + await testInfo.attach(buildRecordingBaseName(testInfo), { + body: videoBuffer, + contentType: 'video/mp4', + }); + } catch (error) { + logRecordingIssue( + `Could not attach recording to Playwright report: ${formatRecordingError(error)}`, + ); + } +} diff --git a/tests/framework/services/appium/index.ts b/tests/framework/services/appium/index.ts index 2194c09e6bd..33ca9ce6912 100644 --- a/tests/framework/services/appium/index.ts +++ b/tests/framework/services/appium/index.ts @@ -1 +1,15 @@ -export { startAppiumServer, stopAppiumServer } from './AppiumServer.ts'; +export { + getAppiumHost, + getAppiumPort, + getAppiumServerUrl, + isAppiumServerRunning, + shouldSkipAppiumStop, + startAppiumServer, + stopAppiumServer, +} from './AppiumServer.ts'; +export { + APPIUM_SMOKE_VIDEOS_DIR, + isVideoRecordingOnFailureEnabled, + startFailureRecording, + stopFailureRecordingAndAttach, +} from './ScreenRecording.ts'; diff --git a/tests/framework/services/device-commands/DeviceCommandHandler.test.ts b/tests/framework/services/device-commands/DeviceCommandHandler.test.ts index 64b05ee8e7c..83f278b54bf 100644 --- a/tests/framework/services/device-commands/DeviceCommandHandler.test.ts +++ b/tests/framework/services/device-commands/DeviceCommandHandler.test.ts @@ -375,7 +375,7 @@ describe('DeviceCommandHandler', () => { new DeviceCommandHandler({ currentDeviceDetails: iosDevice({ deviceName: '' }), }).installApp({ buildPath: '/tmp/MetaMask.app' }), - ).rejects.toThrow('currentDeviceDetails.deviceName'); + ).rejects.toThrow('currentDeviceDetails.udid or deviceName'); await expect( new DeviceCommandHandler({ diff --git a/tests/framework/services/device-commands/IOSDeviceCommandHandler.ts b/tests/framework/services/device-commands/IOSDeviceCommandHandler.ts index c70744b50b5..488458811b6 100644 --- a/tests/framework/services/device-commands/IOSDeviceCommandHandler.ts +++ b/tests/framework/services/device-commands/IOSDeviceCommandHandler.ts @@ -189,13 +189,20 @@ export class IOSDeviceCommandHandler implements PlatformDeviceCommandHandler { } /** - * Resolves the simulator name or UDID from current device details. + * Resolves the simulator identifier for simctl: prefers the UDID (set at + * fixture time by resolving the booted simulator) over the display name. + * Using a UDID avoids ambiguity when multiple simulators share the same name + * across iOS runtime versions. */ private resolveSimDevice(): string { + const udid = this.options.currentDeviceDetails.udid?.trim(); + if (udid) { + return udid; + } const deviceName = this.options.currentDeviceDetails.deviceName?.trim(); if (!deviceName) { throw new Error( - 'iOS device commands require currentDeviceDetails.deviceName (simctl device name or UDID).', + 'iOS device commands require currentDeviceDetails.udid or deviceName (simctl device name or UDID).', ); } return deviceName; diff --git a/tests/framework/services/providers/emulator/EmulatorConfigBuilder.ts b/tests/framework/services/providers/emulator/EmulatorConfigBuilder.ts index 5b9bd94a89a..6fa6da32d87 100644 --- a/tests/framework/services/providers/emulator/EmulatorConfigBuilder.ts +++ b/tests/framework/services/providers/emulator/EmulatorConfigBuilder.ts @@ -1,5 +1,6 @@ import { Platform, type EmulatorConfig } from '../../../types.ts'; import type { ProjectConfig } from '../../common/types.ts'; +import { getAppiumHost, getAppiumPort } from '../../appium/AppiumServer.ts'; /** * Builder for Emulator WebDriver configuration (local Android/iOS). @@ -38,10 +39,41 @@ export class EmulatorConfigBuilder { // - No `buildPath`: omit `appium:app` and target the existing install via // bundleId / package+activity. Install presence is enforced in // `EmulatorProvider.globalSetup()`. + // - iOS CI: globalSetup already simctl-installs from `buildPath`; set + // IOS_APPIUM_USE_BUNDLE_ID_ONLY=true to skip a redundant Appium-side install. + // - Android CI: globalSetup already adb-installs; set + // ANDROID_APPIUM_USE_PACKAGE_ONLY=true to avoid a second install at session start. const hasLocalApp = Boolean(buildPath); + const skipIosAppiumAppInstall = + platformName === Platform.IOS && + process.env.IOS_APPIUM_USE_BUNDLE_ID_ONLY === 'true'; + const skipAndroidAppiumAppInstall = + platformName === Platform.ANDROID && + process.env.ANDROID_APPIUM_USE_PACKAGE_ONLY === 'true'; + const usePreinstalledWda = + platformName === Platform.IOS && + process.env.IOS_WDA_PREINSTALLED === 'true'; + const usePrebuiltWda = + platformName === Platform.IOS && + process.env.USE_PREBUILT_WDA === 'true' && + !usePreinstalledWda; return { - port: 4723, + hostname: getAppiumHost(), + port: getAppiumPort(), + // XCUITest driver must build and install WDA on first run (3-4 min on + // local, up to 10 min on CI). Raise the WebDriverIO HTTP timeout so the + // session-creation POST doesn't time out before Appium responds. + // connectionRetryCount: 0 — no retries on session creation; a timeout + // here is not a transient error and retrying just doubles the wait. + // Preinstalled WDA: prepare-ios-appium-runner already launched WDA on CI. + // Prebuilt/cold paths still need minutes for xcodebuild or first launch. + connectionRetryTimeout: usePreinstalledWda + ? 90 * 1000 + : usePrebuiltWda + ? 5 * 60 * 1000 + : 12 * 60 * 1000, + connectionRetryCount: 0, capabilities: { 'appium:deviceName': emulatorDevice.name, 'appium:udid': emulatorDevice.udid, @@ -52,6 +84,9 @@ export class EmulatorConfigBuilder { ? { 'appium:appPackage': this.project.use.app?.packageName, 'appium:appActivity': this.project.use.app?.launchableActivity, + // Release E2E launches with many intent extras; default 20s adbExecTimeout + // is too low on CI after a prior test (see appium-accounts-android-smoke). + 'appium:adbExecTimeout': 120_000, } : { 'appium:bundleId': this.project.use.app?.appId, @@ -59,7 +94,11 @@ export class EmulatorConfigBuilder { platformName, 'appium:newCommandTimeout': 300, 'appium:deviceOrientation': emulatorDevice.orientation, - ...(hasLocalApp ? { 'appium:app': buildPath } : {}), + ...(hasLocalApp && + !skipIosAppiumAppInstall && + !skipAndroidAppiumAppInstall + ? { 'appium:app': buildPath } + : {}), 'appium:autoGrantPermissions': true, 'appium:autoAcceptAlerts': true, 'appium:fullReset': false, @@ -69,7 +108,55 @@ export class EmulatorConfigBuilder { 'appium:animationCoolOffTimeout': 0, // Skip animation wait 'appium:reduceMotion': true, // Reduce iOS animations 'appium:waitForIdleTimeout': 0, // Don't wait for idle - 'appium:wdaLaunchTimeout': 300_000, + ...(usePreinstalledWda + ? { + // WDA was simctl-installed in prepare-ios-appium-runner; launch only. + 'appium:usePreinstalledWDA': true, + 'appium:updatedWDABundleId': + process.env.IOS_WDA_BUNDLE_ID?.trim() || + 'com.facebook.WebDriverAgentRunner', + 'appium:wdaLaunchTimeout': 60_000, + 'appium:wdaConnectionTimeout': 10_000, + 'appium:simulatorStartupTimeout': 120_000, + } + : usePrebuiltWda + ? { + // Prebuilt WDA on CI: xcodebuild test-without-building (~minutes). + 'appium:wdaLaunchTimeout': 60_000, + 'appium:wdaConnectionTimeout': 10_000, + // Sim is booted in getDriver(); this covers XCUITest attach on loaded CI hosts. + 'appium:simulatorStartupTimeout': 180_000, + } + : { + // Cold WDA build (local dev / cache miss): allow up to 10 min. + 'appium:wdaLaunchTimeout': 10 * 60_000, + 'appium:simulatorStartupTimeout': 10 * 60_000, + }), + // Pin WDA's DerivedData to a fixed path so CI can cache and restore it. + // When USE_PREBUILT_WDA=true (set by CI after prebuild/cache hit), xcuitest-driver + // skips xcodebuild entirely and installs+launches the cached WDA binary + // directly — cutting ~8 min off CI per run. Without it, xcodebuild runs + // even when DerivedData is present because actions/cache restores files + // with current timestamps, causing a full rebuild. + ...(platformName === Platform.IOS + ? { + 'appium:derivedDataPath': `${process.env.HOME ?? '~'}/appium-wda`, + 'appium:skipLogCapture': true, + ...(usePreinstalledWda + ? { + 'appium:useNewWDA': false, + 'appium:showXcodeLog': false, + } + : usePrebuiltWda + ? { + 'appium:usePrebuiltWDA': true, + // Reuse a WDA instance already listening on the sim (retries / multi-test). + 'appium:useNewWDA': false, + 'appium:showXcodeLog': false, + } + : {}), + } + : {}), 'appium:includeSafariInWebviews': true, 'appium:settings[actionAcknowledgmentTimeout]': 3000, 'appium:settings[ignoreUnimportantViews]': true, diff --git a/tests/framework/services/providers/emulator/EmulatorProvider.ts b/tests/framework/services/providers/emulator/EmulatorProvider.ts index 2a1d25e5682..bd894b2d85f 100644 --- a/tests/framework/services/providers/emulator/EmulatorProvider.ts +++ b/tests/framework/services/providers/emulator/EmulatorProvider.ts @@ -10,7 +10,15 @@ import { applyResolvedAndroidAdbToDevice, resolveAndroidAdbUdidForDevice, } from './android/resolveAndroidAdbUdid'; -import { reinstallFromBuildPathForProject } from './reinstallLocalBuildFromPath'; +import { + reinstallFromBuildPathForProject, + shouldSkipAppReinstallFromEnv, +} from './reinstallLocalBuildFromPath'; +import { + startAndroidEmulator, + ensureIosSimulatorReady, + getIosSimulatorUdid, +} from '../../appium/EmulatorHelpers'; /** * Service provider for local emulator/simulator testing @@ -52,36 +60,48 @@ export class EmulatorProvider extends BaseServiceProvider { }); } + private async resolveIosSimulatorUdid(): Promise { + const emulatorDevice = this.project.use.device as EmulatorConfig; + const deviceName = emulatorDevice?.name; + const configuredUdid = + emulatorDevice?.udid?.trim() || process.env.IOS_SIMULATOR_UDID?.trim(); + if (configuredUdid) { + return configuredUdid; + } + if (!deviceName) { + return undefined; + } + return getIosSimulatorUdid(deviceName).catch(() => deviceName); + } + /** * Check if the iOS app is installed on the device * @returns True if the app is installed, false otherwise */ private async isIOSAppInstalled(): Promise { const bundleId = this.project.use.app?.appId; - const deviceName = this.project.use.device?.name; - if (!deviceName || !bundleId) { + const simId = await this.resolveIosSimulatorUdid(); + if (!simId || !bundleId) { this.logger.error( - 'No device name or bundle id specified in project config', + 'No simulator UDID or bundle id specified in project config', ); return false; } // eslint-disable-next-line @typescript-eslint/no-require-imports - const { exec } = require('child_process'); + const { execFile } = require('child_process'); return new Promise((resolve) => { - // List installed apps on the given simulator and look for the bundle id - exec( - `xcrun simctl get_app_container "${deviceName}" "${bundleId}"`, + execFile( + 'xcrun', + ['simctl', 'get_app_container', simId, bundleId], (error: Error | null, stdout: string) => { if (error) { - // If there's an error, assume app is not installed this.logger.debug( - `App with bundle id ${bundleId} NOT installed on simulator "${deviceName}". ${error.message}`, + `App with bundle id ${bundleId} NOT installed on simulator "${simId}". ${error.message}`, ); resolve(false); } else { - // If stdout contains a path, the app is installed this.logger.debug( - `App with bundle id ${bundleId} IS installed on simulator "${deviceName}". App container: ${stdout.trim()}`, + `App with bundle id ${bundleId} IS installed on simulator "${simId}". App container: ${stdout.trim()}`, ); resolve(true); } @@ -117,14 +137,60 @@ export class EmulatorProvider extends BaseServiceProvider { } /** - * Global setup: validates local build artifact path, or that the app is - * already installed when `buildPath` is unset. `SKIP_APP_REINSTALL` can skip - * adb/simctl uninstall+install when `buildPath` is set. See + * Boot the configured device (emulator/simulator) if it is not already + * running. Controlled by the SKIP_DEVICE_BOOT env var: + * + * - SKIP_DEVICE_BOOT=true — skip booting (device must already be running) + * - SKIP_DEVICE_BOOT=false — boot if needed (default) + * + * Booting must happen before any app-install steps because those use + * adb / simctl which require a running device. + */ + private async bootDevice(): Promise { + if (process.env.SKIP_DEVICE_BOOT === 'true') { + this.logger.info('SKIP_DEVICE_BOOT=true — skipping device boot.'); + return; + } + + if (this.project.use.platform === Platform.ANDROID) { + const avdName = (this.project.use.device as EmulatorConfig).name; + if (!avdName) { + throw new Error( + 'Android device boot requires `use.device.name` (AVD name) in the project config.', + ); + } + const serial = await startAndroidEmulator(avdName); + (this.project.use.device as EmulatorConfig).udid = serial; + } else if (this.project.use.platform === Platform.IOS) { + const deviceName = this.project.use.device?.name; + if (!deviceName) { + throw new Error( + 'iOS device boot requires `use.device.name` (simulator name) in the project config.', + ); + } + const udid = await ensureIosSimulatorReady( + deviceName, + (this.project.use.device as EmulatorConfig).udid, + ); + // Persist the UDID onto the device config so Appium's XCUITest driver + // targets this exact simulator (not a fresh one with the same display name). + (this.project.use.device as EmulatorConfig).udid = udid; + } + } + + /** + * Global setup: boots the device if needed, then validates the local build + * artifact path, or checks that the app is already installed when `buildPath` + * is unset. `SKIP_APP_REINSTALL` can skip adb/simctl uninstall+install when + * `buildPath` is set. See * [PLAYWRIGHT_LOCAL_EMULATOR.md](../../../../docs/PLAYWRIGHT_LOCAL_EMULATOR.md). */ async globalSetup(): Promise { await super.globalSetup?.(); + // Boot the device first — adb/simctl calls below require a running device + await this.bootDevice(); + if (this.project.use.app?.buildPath) { this.logger.debug( `Validating build path: ${this.project.use.app?.buildPath}`, @@ -139,6 +205,20 @@ export class EmulatorProvider extends BaseServiceProvider { buildPath, this.logger, ); + if ( + shouldSkipAppReinstallFromEnv() && + this.project.use.platform === Platform.IOS + ) { + const isInstalled = await this.isIOSAppInstalled(); + if (!isInstalled) { + throw new Error( + `SKIP_APP_REINSTALL is enabled but ${this.project.use.app?.appId} is not installed on the target simulator. The prepare step may have failed to install the .app.`, + ); + } + this.logger.info( + 'Verified MetaMask is installed on the target iOS simulator.', + ); + } } else { const isInstalled = await this.isAppInstalled(); if (!isInstalled) { @@ -158,8 +238,9 @@ export class EmulatorProvider extends BaseServiceProvider { async getDriver(): Promise { this.logger.debug('Creating driver for local emulator'); + const emulatorDevice = this.project.use.device as EmulatorConfig; + if (this.project.use.platform === Platform.ANDROID) { - const emulatorDevice = this.project.use.device as EmulatorConfig; if (!emulatorDevice.name && !emulatorDevice.udid) { throw new Error( 'Android local emulator: set `use.device.name` (AVD name) or `use.device.udid` (e.g. emulator-5554).', @@ -168,6 +249,22 @@ export class EmulatorProvider extends BaseServiceProvider { await applyResolvedAndroidAdbToDevice(emulatorDevice, { setAndroidSerialEnv: true, }); + } else if (this.project.use.platform === Platform.IOS) { + const deviceName = emulatorDevice.name; + if (!deviceName) { + throw new Error( + 'iOS local simulator: set `use.device.name` (simulator name) in the project config.', + ); + } + // Boot (or re-boot) before Appium session creation. CI sets IOS_SIMULATOR_UDID + // from prepare-ios-appium-runner so we attach to the sim that received the .app. + emulatorDevice.udid = await ensureIosSimulatorReady( + deviceName, + emulatorDevice.udid, + ); + this.logger.debug( + `iOS simulator ready for Appium (udid=${emulatorDevice.udid})`, + ); } // Start Appium server diff --git a/tests/framework/services/providers/emulator/reinstallLocalBuildFromPath.ts b/tests/framework/services/providers/emulator/reinstallLocalBuildFromPath.ts index d774dd69c9f..5084a45baf7 100644 --- a/tests/framework/services/providers/emulator/reinstallLocalBuildFromPath.ts +++ b/tests/framework/services/providers/emulator/reinstallLocalBuildFromPath.ts @@ -6,6 +6,7 @@ import type { Logger } from '../../../logger.ts'; import { Platform, type EmulatorConfig } from '../../../types.ts'; import type { ProjectConfig } from '../../common/types.ts'; import { resolveAndroidAdbUdidForDevice } from './android/resolveAndroidAdbUdid'; +import { getIosSimulatorUdid } from '../../appium/EmulatorHelpers'; const execFileAsync = promisify(execFile); @@ -83,13 +84,18 @@ export async function reinstallFromBuildPathForProject( 'iOS: set `use.device.name` to the target simulator to reinstall from `use.app.buildPath` in global setup.', ); } + // Prefer CI/prepare UDID so simctl targets the sim that received the .app. + const configuredUdid = + (project.use.device as EmulatorConfig | undefined)?.udid?.trim() || + process.env.IOS_SIMULATOR_UDID?.trim(); + const simUdid = configuredUdid || (await getIosSimulatorUdid(simDevice)); logger.info( 'Reinstalling iOS app from build path (simctl uninstall + install)…', ); await reinstallLocalIOSBuildArtifact({ buildPath, bundleId, - simDevice, + simDevice: simUdid, logger, }); } @@ -172,7 +178,7 @@ export async function reinstallLocalIOSBuildArtifact({ ); } - logger.info(`simctl install: ${absApp}`); + logger.info(`simctl install: ${absApp} → simulator ${simDevice}`); const { stdout, stderr } = await execFileAsync( 'xcrun', ['simctl', 'install', simDevice, absApp], @@ -182,4 +188,19 @@ export async function reinstallLocalIOSBuildArtifact({ if (out) { logger.info(out); } + + try { + await execFileAsync( + 'xcrun', + ['simctl', 'get_app_container', simDevice, bundleId], + { timeout: 120_000, maxBuffer: 2 * 1024 * 1024 }, + ); + logger.info(`Verified ${bundleId} is installed on simulator ${simDevice}.`); + } catch (error) { + throw new Error( + `App "${bundleId}" is not installed on simulator ${simDevice} after simctl install: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } } diff --git a/tests/jest.e2e.detox.config.js b/tests/jest.e2e.detox.config.js index 732a4167750..ed069dc1e0e 100644 --- a/tests/jest.e2e.detox.config.js +++ b/tests/jest.e2e.detox.config.js @@ -12,6 +12,10 @@ require('dotenv').config({ path: '.js.env' }); module.exports = { rootDir: '..', testMatch: ['/tests/**/*.spec.{js,ts}'], + testPathIgnorePatterns: [ + // Playwright + Appium smoke specs — run via tests/playwright.smoke-appium.config.ts + '/tests/smoke-appium/', + ], testTimeout: 300000, maxWorkers: 1, clearMocks: true, diff --git a/tests/page-objects/AccountMenu/AccountMenu.ts b/tests/page-objects/AccountMenu/AccountMenu.ts index f58989d915e..eb71c17d382 100644 --- a/tests/page-objects/AccountMenu/AccountMenu.ts +++ b/tests/page-objects/AccountMenu/AccountMenu.ts @@ -2,6 +2,7 @@ import { AccountsMenuSelectorsIDs } from '../../../app/components/Views/Accounts import Matchers from '../../../tests/framework/Matchers'; import Gestures from '../../../tests/framework/Gestures'; import { EncapsulatedElementType } from '../../framework'; +import UnifiedGestures from '../../framework/UnifiedGestures'; class AccountMenu { get container(): EncapsulatedElementType { @@ -53,8 +54,8 @@ class AccountMenu { } async tapSettings(): Promise { - await Gestures.waitAndTap(this.settingsButton, { - elemDescription: 'Settings button', + await UnifiedGestures.waitAndTap(this.settingsButton, { + description: 'Settings button', }); } diff --git a/tests/page-objects/Settings/SecurityAndPrivacy/RevealSecretRecoveryPhrase.ts b/tests/page-objects/Settings/SecurityAndPrivacy/RevealSecretRecoveryPhrase.ts index 2617249c153..2efed4c7558 100644 --- a/tests/page-objects/Settings/SecurityAndPrivacy/RevealSecretRecoveryPhrase.ts +++ b/tests/page-objects/Settings/SecurityAndPrivacy/RevealSecretRecoveryPhrase.ts @@ -5,7 +5,16 @@ import { import Matchers from '../../../framework/Matchers'; import Gestures from '../../../framework/Gestures'; import Utilities from '../../../framework/Utilities'; -import { EncapsulatedElementType } from '../../../framework'; +import { + EncapsulatedElementType, + asPlaywrightElement, + asDetoxElement, +} from '../../../framework'; +import { encapsulatedAction } from '../../../framework/encapsulatedAction'; +import PlaywrightAssertions from '../../../framework/PlaywrightAssertions'; +import PlaywrightGestures from '../../../framework/PlaywrightGestures'; +import UnifiedGestures from '../../../framework/UnifiedGestures'; +import { PlatformDetector } from '../../../framework/PlatformLocator'; class RevealSecretRecoveryPhrase { get container(): EncapsulatedElementType { @@ -37,6 +46,7 @@ class RevealSecretRecoveryPhrase { RevealSeedViewSelectorsIDs.TAB_SCROLL_VIEW_TEXT, ); } + get tabScrollViewQRCodeIdentifier(): Promise { return Matchers.getIdentifier( RevealSeedViewSelectorsIDs.TAB_SCROLL_VIEW_QR_CODE, @@ -80,20 +90,49 @@ class RevealSecretRecoveryPhrase { } async enterPasswordToRevealSecretCredential(password: string): Promise { - // Wait for password screen to be ready (e.g. after navigation or quiz on iOS/Android CI) - await Utilities.waitForElementToBeVisible( - this.passwordInputToRevealCredential, - 15000, - ); - await Gestures.typeText(this.passwordInputToRevealCredential, password, { - hideKeyboard: true, - elemDescription: 'Password input to reveal credential', + await encapsulatedAction({ + detox: async () => { + await Utilities.waitForElementToBeVisible( + asDetoxElement(this.passwordInputToRevealCredential), + 15000, + ); + await Gestures.typeText( + asDetoxElement(this.passwordInputToRevealCredential), + password, + { + hideKeyboard: true, + elemDescription: 'Password input to reveal credential', + }, + ); + }, + appium: async () => { + await PlaywrightAssertions.expectElementToBeVisible( + await asPlaywrightElement(this.passwordInputToRevealCredential), + { + timeout: 15000, + description: 'Password input to reveal credential', + }, + ); + const textToType = PlatformDetector.isIOS() + ? `${password}\n` + : password; + await UnifiedGestures.typeText( + this.passwordInputToRevealCredential, + textToType, + { + description: 'Password input to reveal credential', + }, + ); + if (PlatformDetector.isAndroid()) { + await PlaywrightGestures.hideKeyboard(); + } + }, }); } async tapConfirmButton(): Promise { - await Gestures.waitAndTap(this.confirmButton, { - elemDescription: 'Confirm button to reveal credential', + await UnifiedGestures.waitAndTap(this.confirmButton, { + description: 'Confirm button to reveal credential', }); } @@ -103,10 +142,20 @@ class RevealSecretRecoveryPhrase { */ async isUnlocked(): Promise { try { - await Utilities.waitForElementToBeVisible( - this.revealSecretRecoveryPhraseButton, - 3000, - ); + await encapsulatedAction({ + detox: async () => { + await Utilities.waitForElementToBeVisible( + asDetoxElement(this.revealSecretRecoveryPhraseButton), + 3000, + ); + }, + appium: async () => { + await PlaywrightAssertions.expectElementToBeVisible( + await asPlaywrightElement(this.revealSecretRecoveryPhraseButton), + { timeout: 3000 }, + ); + }, + }); return true; } catch { return false; @@ -114,51 +163,55 @@ class RevealSecretRecoveryPhrase { } async tapToReveal(): Promise { - await Gestures.waitAndTap(this.revealSecretRecoveryPhraseButton, { - elemDescription: 'Reveal secret recovery phrase button', + await UnifiedGestures.waitAndTap(this.revealSecretRecoveryPhraseButton, { + description: 'Reveal secret recovery phrase button', }); } async tapToCopyCredentialToClipboard() { - await Gestures.tap(this.revealCredentialCopyToClipboardButton, { - elemDescription: 'Reveal credential copy to clipboard button', + await UnifiedGestures.tap(this.revealCredentialCopyToClipboardButton, { + description: 'Reveal credential copy to clipboard button', }); } async tapToRevealPrivateCredentialQRCode(): Promise { - await Gestures.tap(this.revealCredentialQRCodeTab, { - elemDescription: 'Reveal credential QR code tab', + await UnifiedGestures.tap(this.revealCredentialQRCodeTab, { + description: 'Reveal credential QR code tab', }); } async scrollToDone(): Promise { - await Gestures.scrollToElement(this.doneButton, this.scrollViewIdentifier, { - elemDescription: 'Done button', - }); + await UnifiedGestures.scrollToElement( + this.doneButton, + RevealSeedViewSelectorsIDs.REVEAL_CREDENTIAL_SCROLL_ID, + { + description: 'Done button', + }, + ); } async tapDoneButton(): Promise { - await Gestures.waitAndTap(this.doneButton, { - elemDescription: 'Done button', + await UnifiedGestures.waitAndTap(this.doneButton, { + description: 'Done button', }); } async scrollToCopyToClipboardButton(): Promise { - await Gestures.scrollToElement( + await UnifiedGestures.scrollToElement( this.revealCredentialCopyToClipboardButton, - this.tabScrollViewTextIdentifier, + RevealSeedViewSelectorsIDs.TAB_SCROLL_VIEW_TEXT, { - elemDescription: 'Copy to clipboard button', + description: 'Copy to clipboard button', }, ); } async scrollToQR(): Promise { - await Gestures.scrollToElement( + await UnifiedGestures.scrollToElement( this.revealCredentialQRCodeImage, - this.tabScrollViewQRCodeIdentifier, + RevealSeedViewSelectorsIDs.TAB_SCROLL_VIEW_QR_CODE, { - elemDescription: 'QR code', + description: 'QR code', }, ); } diff --git a/tests/page-objects/Settings/SecurityAndPrivacy/SecurityAndPrivacyView.ts b/tests/page-objects/Settings/SecurityAndPrivacy/SecurityAndPrivacyView.ts index c6a3053c657..5205e7e7b44 100644 --- a/tests/page-objects/Settings/SecurityAndPrivacy/SecurityAndPrivacyView.ts +++ b/tests/page-objects/Settings/SecurityAndPrivacy/SecurityAndPrivacyView.ts @@ -5,6 +5,7 @@ import { import Matchers from '../../../framework/Matchers'; import Gestures from '../../../framework/Gestures'; import { EncapsulatedElementType } from '../../../framework'; +import UnifiedGestures from '../../../framework/UnifiedGestures'; class SecurityAndPrivacy { get changePasswordButton(): EncapsulatedElementType { @@ -111,8 +112,8 @@ class SecurityAndPrivacy { } async tapRevealSecretRecoveryPhraseButton(): Promise { - await Gestures.waitAndTap(this.revealSecretRecoveryPhraseButton, { - elemDescription: 'Reveal secret recovery phrase button', + await UnifiedGestures.waitAndTap(this.revealSecretRecoveryPhraseButton, { + description: 'Reveal secret recovery phrase button', }); } diff --git a/tests/page-objects/Settings/SecurityAndPrivacy/SrpQuizModal.ts b/tests/page-objects/Settings/SecurityAndPrivacy/SrpQuizModal.ts index 94eb70032b8..9fcdcca70e6 100644 --- a/tests/page-objects/Settings/SecurityAndPrivacy/SrpQuizModal.ts +++ b/tests/page-objects/Settings/SecurityAndPrivacy/SrpQuizModal.ts @@ -9,9 +9,9 @@ import { import Matchers from '../../../framework/Matchers'; import Gestures from '../../../framework/Gestures'; import { EncapsulatedElementType } from '../../../framework'; +import UnifiedGestures from '../../../framework/UnifiedGestures'; class SrpQuizModal { - // Getters for common elements get getStartedContainer(): EncapsulatedElementType { return Matchers.getElementByID(SrpQuizGetStartedSelectorsIDs.CONTAINER); } @@ -30,7 +30,6 @@ class SrpQuizModal { return Matchers.getElementByID(SrpQuizGetStartedSelectorsIDs.BUTTON); } - // Mapping question number to selectors getQuestionSelectors(questionNumber: number) { switch (questionNumber) { case 1: @@ -48,7 +47,6 @@ class SrpQuizModal { } } - // Getters for question elements getQuestionContainer(questionNumber: number) { const { ids } = this.getQuestionSelectors(questionNumber); return Matchers.getElementByID(ids.CONTAINER); @@ -84,7 +82,9 @@ class SrpQuizModal { return Matchers.getElementByID(ids.WRONG_ANSWER_TRY_AGAIN_BUTTON); } - getQuestionRightAnswerButton(questionNumber: number) { + getQuestionRightAnswerButton( + questionNumber: number, + ): EncapsulatedElementType { const { ids } = this.getQuestionSelectors(questionNumber); return Matchers.getElementByID(ids.RIGHT_ANSWER); } @@ -99,12 +99,13 @@ class SrpQuizModal { return Matchers.getElementByText(text.RIGHT_ANSWER_RESPONSE_DESCRIPTION); } - getQuestionRightContinueButton(questionNumber: number) { + getQuestionRightContinueButton( + questionNumber: number, + ): EncapsulatedElementType { const { ids } = this.getQuestionSelectors(questionNumber); return Matchers.getElementByID(ids.RIGHT_CONTINUE); } - // Methods for common actions async tapQuizGetStartedScreenDismiss(): Promise { await Gestures.waitAndTap(this.getStartedScreenDismiss, { elemDescription: 'Srp Quiz - Get Started Screen Dismiss', @@ -112,12 +113,11 @@ class SrpQuizModal { } async tapGetStartedButton(): Promise { - await Gestures.waitAndTap(this.getStartedButton, { - elemDescription: 'Srp Quiz - Get Started Button', + await UnifiedGestures.waitAndTap(this.getStartedButton, { + description: 'Srp Quiz - Get Started Button', }); } - // Methods for question actions async tapQuestionDismiss(questionNumber: number): Promise { await Gestures.waitAndTap(this.getQuestionDismiss(questionNumber), { elemDescription: `Srp Quiz - Question ${questionNumber} Dismiss`, @@ -142,19 +142,19 @@ class SrpQuizModal { } async tapQuestionRightAnswerButton(questionNumber: number): Promise { - await Gestures.waitAndTap( + await UnifiedGestures.waitAndTap( this.getQuestionRightAnswerButton(questionNumber), { - elemDescription: `Srp Quiz - Question ${questionNumber} Right Answer`, + description: `Srp Quiz - Question ${questionNumber} Right Answer`, }, ); } async tapQuestionContinueButton(questionNumber: number): Promise { - await Gestures.waitAndTap( + await UnifiedGestures.waitAndTap( this.getQuestionRightContinueButton(questionNumber), { - elemDescription: `Srp Quiz - Question ${questionNumber} Right Continue`, + description: `Srp Quiz - Question ${questionNumber} Right Continue`, }, ); } diff --git a/tests/page-objects/Settings/SettingsView.ts b/tests/page-objects/Settings/SettingsView.ts index 3ff60132d34..3d37923dbe1 100644 --- a/tests/page-objects/Settings/SettingsView.ts +++ b/tests/page-objects/Settings/SettingsView.ts @@ -6,6 +6,7 @@ import { } from '../../../app/components/Views/Settings/SettingsView.testIds'; import { CommonSelectorsText } from '../../../app/util/Common.testIds'; import { EncapsulatedElementType } from '../../framework'; +import UnifiedGestures from '../../framework/UnifiedGestures'; class SettingsView { get title(): EncapsulatedElementType { @@ -118,8 +119,8 @@ class SettingsView { } async tapSecurityAndPrivacy(): Promise { - await Gestures.waitAndTap(this.securityAndPrivacyButton, { - elemDescription: 'Settings - Security and Privacy Button', + await UnifiedGestures.waitAndTap(this.securityAndPrivacyButton, { + description: 'Settings - Security and Privacy Button', }); } diff --git a/tests/page-objects/wallet/LoginView.ts b/tests/page-objects/wallet/LoginView.ts index 7086ca7b5ea..bd433596c7b 100644 --- a/tests/page-objects/wallet/LoginView.ts +++ b/tests/page-objects/wallet/LoginView.ts @@ -9,7 +9,6 @@ import { } from '../../framework/EncapsulatedElement'; import { encapsulatedAction } from '../../framework/encapsulatedAction'; import PlaywrightMatchers from '../../framework/PlaywrightMatchers'; -import PlaywrightGestures from '../../framework/PlaywrightGestures'; import UnifiedGestures from '../../framework/UnifiedGestures'; import Utilities from '../../framework/Utilities'; @@ -83,7 +82,11 @@ class LoginView { await UnifiedGestures.typeText(this.passwordInput, password, { description: 'Password Input', }); - await PlaywrightGestures.hideKeyboard(); + // Do NOT call hideKeyboard here — the login button is above the + // keyboard (~184pt vs keyboard at ~574pt) and does not need dismissal. + // Both 'pressKey: Done' and 'tapOutside' strategies trigger navigation + // (either via onSubmitEditing or by tapping the login button itself) + // before tapLoginButton can find and tap the element. }, }); } diff --git a/tests/page-objects/wallet/TabBarComponent.ts b/tests/page-objects/wallet/TabBarComponent.ts index 6d753630eef..933c6a4dde5 100644 --- a/tests/page-objects/wallet/TabBarComponent.ts +++ b/tests/page-objects/wallet/TabBarComponent.ts @@ -1,13 +1,14 @@ import Matchers from '../../framework/Matchers'; -import Gestures from '../../framework/Gestures'; import UnifiedGestures from '../../framework/UnifiedGestures'; import { TabBarSelectorIDs } from '../../../app/components/Nav/Main/TabBar.testIds'; -import { Assertions, Utilities } from '../../framework'; import { - encapsulated, + Assertions, + PlaywrightAssertions, + Utilities, + resolve, EncapsulatedElementType, asPlaywrightElement, -} from '../../framework/EncapsulatedElement'; +} from '../../framework'; import { encapsulatedAction } from '../../framework/encapsulatedAction'; import PlaywrightMatchers from '../../framework/PlaywrightMatchers'; import PlaywrightGestures from '../../framework/PlaywrightGestures'; @@ -19,136 +20,84 @@ import TrendingView from '../Trending/TrendingView'; class TabBarComponent { get tabBarExploreButton(): EncapsulatedElementType { - return encapsulated({ - detox: () => Matchers.getElementByID(TabBarSelectorIDs.EXPLORE), - appium: { - android: () => - PlaywrightMatchers.getElementById(TabBarSelectorIDs.EXPLORE, { - exact: true, - }), - ios: () => - PlaywrightMatchers.getElementByAccessibilityId( - TabBarSelectorIDs.EXPLORE, - ), - }, - }); + return Matchers.getElementByID(TabBarSelectorIDs.EXPLORE); } get tabBarBrowserButton(): EncapsulatedElementType { - return encapsulated({ - detox: () => Matchers.getElementByID(TabBarSelectorIDs.BROWSER), - appium: { - android: () => - PlaywrightMatchers.getElementById(TabBarSelectorIDs.BROWSER, { - exact: true, - }), - ios: () => - PlaywrightMatchers.getElementByAccessibilityId( - TabBarSelectorIDs.BROWSER, - ), - }, - }); + return Matchers.getElementByID(TabBarSelectorIDs.BROWSER); } get tabBarWalletButton(): EncapsulatedElementType { - return encapsulated({ - detox: () => Matchers.getElementByID(TabBarSelectorIDs.WALLET), - appium: { - android: () => - PlaywrightMatchers.getElementById(TabBarSelectorIDs.WALLET, { - exact: true, - }), - ios: () => - PlaywrightMatchers.getElementByAccessibilityId( - TabBarSelectorIDs.WALLET, - ), - }, - }); + return Matchers.getElementByID(TabBarSelectorIDs.WALLET); } get tabBarActionButton(): EncapsulatedElementType { - return encapsulated({ - detox: () => Matchers.getElementByID(TabBarSelectorIDs.TRADE), - appium: { - android: () => - PlaywrightMatchers.getElementById(TabBarSelectorIDs.ACTIONS, { - exact: true, - }), - ios: () => - PlaywrightMatchers.getElementByAccessibilityId( - TabBarSelectorIDs.ACTIONS, - ), - }, + return resolve({ + detoxTestID: TabBarSelectorIDs.TRADE, + androidAppiumTestID: TabBarSelectorIDs.ACTIONS, + iosAppiumTestID: TabBarSelectorIDs.ACTIONS, }); } get tabBarTradeButton(): EncapsulatedElementType { - return encapsulated({ - detox: () => Matchers.getElementByID(TabBarSelectorIDs.TRADE), - appium: { - android: () => - PlaywrightMatchers.getElementById(TabBarSelectorIDs.TRADE, { - exact: true, - }), - ios: () => - PlaywrightMatchers.getElementByAccessibilityId( - TabBarSelectorIDs.TRADE, - ), - }, - }); + return Matchers.getElementByID(TabBarSelectorIDs.TRADE); } get tabBarSettingButton(): EncapsulatedElementType { - return encapsulated({ - detox: () => Matchers.getElementByID(TabBarSelectorIDs.SETTING), - appium: { - android: () => - PlaywrightMatchers.getElementById(TabBarSelectorIDs.SETTING, { - exact: true, - }), - ios: () => - PlaywrightMatchers.getElementByAccessibilityId( - TabBarSelectorIDs.SETTING, - ), - }, - }); + return Matchers.getElementByID(TabBarSelectorIDs.SETTING); } get tabBarActivityButton(): EncapsulatedElementType { - return encapsulated({ - detox: () => Matchers.getElementByID(TabBarSelectorIDs.ACTIVITY), - appium: { - android: () => - PlaywrightMatchers.getElementById(TabBarSelectorIDs.ACTIVITY, { - exact: true, - }), - ios: () => - PlaywrightMatchers.getElementByAccessibilityId( - TabBarSelectorIDs.ACTIVITY, - ), - }, - }); + return Matchers.getElementByID(TabBarSelectorIDs.ACTIVITY); } get tabBarRewardsButton(): EncapsulatedElementType { - return encapsulated({ - detox: () => Matchers.getElementByID(TabBarSelectorIDs.REWARDS), - appium: { - android: () => - PlaywrightMatchers.getElementById(TabBarSelectorIDs.REWARDS, { + return Matchers.getElementByID(TabBarSelectorIDs.REWARDS); + } + + get homeButton(): EncapsulatedElementType { + return resolve({ + custom: { + detox: () => Matchers.getElementByText('Home'), + appium: () => + PlaywrightMatchers.getElementById(TabBarSelectorIDs.WALLET, { exact: true, }), - ios: () => - PlaywrightMatchers.getElementByAccessibilityId( - TabBarSelectorIDs.REWARDS, - ), }, }); } async tapHome(): Promise { - const homeButton = Matchers.getElementByText('Home'); - await Gestures.waitAndTap(homeButton); + await Utilities.executeWithRetry( + async () => { + await encapsulatedAction({ + detox: async () => { + await UnifiedGestures.waitAndTap(this.homeButton, { + timeout: 2000, + }); + await Assertions.expectElementToBeVisible(WalletView.container, { + timeout: 500, + }); + }, + appium: async () => { + await PlaywrightGestures.waitAndTap( + await asPlaywrightElement(this.homeButton), + ); + await PlaywrightAssertions.expectElementToBeVisible( + await asPlaywrightElement(WalletView.container), + { + timeout: 500, + }, + ); + }, + }); + }, + { + maxRetries: 15, + timeout: 45000, + description: 'Tap Home Button with Validation', + }, + ); } async tapWallet(): Promise { @@ -191,10 +140,27 @@ class TabBarComponent { async tapAccountsMenu(): Promise { await Utilities.executeWithRetry( async () => { - await UnifiedGestures.waitAndTap(this.tabBarWalletButton); - await Assertions.expectElementToBeVisible(WalletView.container); - await Gestures.waitAndTap(WalletView.hamburgerMenuButton); - await Assertions.expectElementToBeVisible(AccountMenu.container); + await UnifiedGestures.waitAndTap(this.tabBarWalletButton, { + timeout: 2000, + }); + await encapsulatedAction({ + detox: async () => { + await Assertions.expectElementToBeVisible(WalletView.container); + await UnifiedGestures.waitAndTap(WalletView.hamburgerMenuButton); + await Assertions.expectElementToBeVisible(AccountMenu.container); + }, + appium: async () => { + await PlaywrightAssertions.expectElementToBeVisible( + await asPlaywrightElement(WalletView.container), + { timeout: 500 }, + ); + await UnifiedGestures.waitAndTap(WalletView.hamburgerMenuButton); + await PlaywrightAssertions.expectElementToBeVisible( + await asPlaywrightElement(AccountMenu.container), + { timeout: 500 }, + ); + }, + }); }, { timeout: 45000, @@ -206,7 +172,17 @@ class TabBarComponent { async tapSettings(): Promise { await this.tapAccountsMenu(); await AccountMenu.tapSettings(); - await Assertions.expectElementToBeVisible(SettingsView.title); + await encapsulatedAction({ + detox: async () => { + await Assertions.expectElementToBeVisible(SettingsView.title); + }, + appium: async () => { + await PlaywrightAssertions.expectElementToBeVisible( + await asPlaywrightElement(SettingsView.title), + { description: 'Settings view title' }, + ); + }, + }); } async tapExploreButton(): Promise { await Utilities.executeWithRetry( diff --git a/tests/playwright.smoke-appium.config.ts b/tests/playwright.smoke-appium.config.ts new file mode 100644 index 00000000000..ee6f719bab1 --- /dev/null +++ b/tests/playwright.smoke-appium.config.ts @@ -0,0 +1,100 @@ +import dotenv from 'dotenv'; +dotenv.config({ path: '.e2e.env' }); + +import { Platform, ProviderName } from './framework/types'; +import { defineConfig } from './framework/config'; + +// Requires HAS_TEST_OVERRIDES=true baked in at Metro bundle time so the app +// activates ReadOnlyNetworkStore and fetches fixture state from /state.json. +// Build with: CONFIGURATION=Debug yarn build:android:main:e2e +// (or add HAS_TEST_OVERRIDES=true + METAMASK_ENVIRONMENT=e2e to .js.env and +// run CONFIGURATION=Debug yarn build:android:main:e2e) +const DEFAULT_ANDROID_APK = + 'android/app/build/outputs/apk/prod/debug/app-prod-debug.apk'; +const DEFAULT_IOS_APP = + 'ios/build/Build/Products/Debug-iphonesimulator/MetaMask.app'; + +/** + * Playwright runner config for Appium smoke tests. + * + * Runs Appium smoke specs from tests/smoke-appium. Tags live in describe titles + * via tags.js (same convention as Detox); --grep uses the tag id (e.g. SmokeAccounts). + * + * IMPORTANT: Requires a debug build with HAS_TEST_OVERRIDES=true so the app + * fetches fixture state from /state.json on launch. Build with: + * CONFIGURATION=Debug yarn build:android:main:e2e (Android) + * CONFIGURATION=Debug yarn build:ios:main:e2e (iOS) + * + * Environment variables (all optional — defaults shown): + * - ANDROID_APK_PATH — path to the APK (default: prod debug APK) + * - IOS_APP_PATH — path to the .app (default: Debug-iphonesimulator/MetaMask.app) + * - ANDROID_AVD_NAME — AVD name (default: 'Pixel_5_Pro_API_34') + * - IOS_SIMULATOR_NAME — simulator name (default: 'iPhone 16 Pro') + * - IOS_SIMULATOR_UDID — booted sim UDID (CI sets this from prepare-ios-appium-runner) + * - APPIUM_SMOKE_SUITE_NAME — CI suite id for per-job report/video paths + * + * Usage: + * yarn appium-smoke:android + * yarn appium-smoke:ios + */ +const suiteName = process.env.APPIUM_SMOKE_SUITE_NAME?.trim(); +const htmlReportDir = suiteName + ? `./test-reports/appium-smoke-report/${suiteName}` + : './test-reports/appium-smoke-report'; +const junitReportPath = suiteName + ? `./test-reports/appium-smoke-junit/${suiteName}.xml` + : './test-reports/appium-smoke-junit.xml'; + +export default defineConfig({ + testDir: './smoke-appium', + fullyParallel: false, + // Per-test timeout: cold WDA build on CI can take up to 10 min plus test time. + timeout: 15 * 60 * 1000, + retries: 1, + reporter: [ + ['html', { open: 'never', outputFolder: htmlReportDir }], + ['junit', { outputFile: junitReportPath }], + ['list'], + // CI: step summary + JUnit (dorny/test-reporter). Skip the `github` reporter — + // it emits error annotations for failed retry attempts even when the test + // eventually passes, which makes passing jobs look failed in the UI. + ...(process.env.CI === 'true' + ? ([['./reporters/github-step-summary-reporter.mjs'] as const] as const) + : []), + ], + + projects: [ + { + name: 'android-smoke', + use: { + platform: Platform.ANDROID, + device: { + provider: ProviderName.EMULATOR, + name: process.env.ANDROID_AVD_NAME || 'Pixel_5_Pro_API_34', + }, + app: { + packageName: 'io.metamask', + launchableActivity: 'io.metamask.MainActivity', + buildPath: process.env.ANDROID_APK_PATH || DEFAULT_ANDROID_APK, + }, + }, + }, + { + name: 'ios-smoke', + use: { + platform: Platform.IOS, + device: { + provider: ProviderName.SIMULATOR, + name: process.env.IOS_SIMULATOR_NAME || 'iPhone 16 Pro', + ...(process.env.IOS_SIMULATOR_UDID?.trim() + ? { udid: process.env.IOS_SIMULATOR_UDID.trim() } + : {}), + }, + app: { + appId: 'io.metamask.MetaMask', + buildPath: process.env.IOS_APP_PATH || DEFAULT_IOS_APP, + }, + }, + }, + ], +}); diff --git a/tests/reporters/github-step-summary-reporter.mjs b/tests/reporters/github-step-summary-reporter.mjs new file mode 100644 index 00000000000..2aaf0c5eb9a --- /dev/null +++ b/tests/reporters/github-step-summary-reporter.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +/* eslint-disable import-x/no-nodejs-modules */ +/** + * Appends a Playwright run summary to $GITHUB_STEP_SUMMARY (GitHub Actions job summary). + * Complements the built-in `github` reporter, which writes annotations to the job log only. + */ +import { appendFileSync, existsSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const testsRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); + +/** @typedef {import('@playwright/test/reporter').FullConfig} FullConfig */ +/** @typedef {import('@playwright/test/reporter').FullResult} FullResult */ +/** @typedef {import('@playwright/test/reporter').Suite} Suite */ +/** @typedef {import('@playwright/test/reporter').TestCase} TestCase */ + +export default class GitHubStepSummaryReporter { + /** @type {Suite | undefined} */ + #rootSuite; + + /** @param {FullConfig} _config @param {Suite} suite */ + onBegin(_config, suite) { + this.#rootSuite = suite; + } + + /** @param {FullResult} result */ + onEnd(result) { + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + if (!summaryPath) { + return; + } + + const stats = this.#rootSuite + ? this.#collectStats(this.#rootSuite) + : { passed: 0, failed: 0, flaky: 0, skipped: 0, total: 0 }; + + const title = + process.env.APPIUM_SMOKE_JOB_TITLE ?? 'Appium Smoke Tests'; + const statusEmoji = result.status === 'passed' ? '✅' : '❌'; + const suiteName = process.env.APPIUM_SMOKE_SUITE_NAME?.trim(); + const reportDir = suiteName + ? join(testsRoot, 'test-reports/appium-smoke-report', suiteName) + : join(testsRoot, 'test-reports/appium-smoke-report'); + const videosDir = suiteName + ? join(testsRoot, 'test-reports/appium-smoke-videos', suiteName) + : join(testsRoot, 'test-reports/appium-smoke-videos'); + const videosArtifactName = + process.env.APPIUM_SMOKE_VIDEOS_ARTIFACT_NAME ?? + 'appium-smoke-videos'; + const artifactsUrl = + process.env.GITHUB_SERVER_URL && + process.env.GITHUB_REPOSITORY && + process.env.GITHUB_RUN_ID + ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}#artifacts` + : undefined; + const artifactName = + process.env.APPIUM_SMOKE_ARTIFACT_NAME ?? 'appium-smoke-report'; + + const lines = [ + `## ${title}`, + '', + '| Result | Tests | Passed | Failed | Flaky | Skipped |', + '|--------|------:|-------:|-------:|------:|--------:|', + `| ${statusEmoji} ${result.status} | ${stats.total} | ${stats.passed} | ${stats.failed} | ${stats.flaky} | ${stats.skipped} |`, + '', + '### Playwright report', + '', + ]; + + if (existsSync(join(reportDir, 'index.html'))) { + lines.push( + `Download the **${artifactName}** artifact, then open \`index.html\` locally.`, + ); + if (artifactsUrl) { + lines.push('', `[View run artifacts](${artifactsUrl})`); + } + } else { + lines.push('No HTML report was generated (run may have aborted early).'); + } + + lines.push( + '', + 'Failure details appear as inline annotations on this job (`github` reporter).', + ); + + const videoFiles = existsSync(videosDir) + ? readdirSync(videosDir).filter((name) => name.endsWith('.mp4')) + : []; + if (videoFiles.length > 0) { + lines.push('', '### Failure recordings', ''); + lines.push( + `Download the **${videosArtifactName}** artifact for MP4 screen recordings (${videoFiles.length} file(s)).`, + ); + if (artifactsUrl) { + lines.push('', `[View run artifacts](${artifactsUrl})`); + } + } + + appendFileSync(summaryPath, `${lines.join('\n')}\n`); + } + + /** @param {Suite} suite */ + #collectStats(suite) { + /** @type {TestCase[]} */ + const tests = suite.allTests(); + let passed = 0; + let failed = 0; + let flaky = 0; + let skipped = 0; + + for (const test of tests) { + switch (test.outcome()) { + case 'expected': + passed += 1; + break; + case 'unexpected': + failed += 1; + break; + case 'flaky': + flaky += 1; + break; + case 'skipped': + skipped += 1; + break; + default: + break; + } + } + + return { + passed, + failed, + flaky, + skipped, + total: tests.length, + }; + } +} diff --git a/tests/smoke-appium/accounts/reveal-secret-recovery-phrase.spec.ts b/tests/smoke-appium/accounts/reveal-secret-recovery-phrase.spec.ts new file mode 100644 index 00000000000..9c4eaa57f17 --- /dev/null +++ b/tests/smoke-appium/accounts/reveal-secret-recovery-phrase.spec.ts @@ -0,0 +1,49 @@ +import { test as appiumTest } from '../../framework/fixtures/playwright/index.js'; +import { SmokeAccounts } from '../../tags.js'; +import { loginToAppPlaywright } from '../../flows/wallet.flow.js'; +import { completeSrpQuiz } from '../../flows/accounts.flow.js'; +import TabBarComponent from '../../page-objects/wallet/TabBarComponent.js'; +import SettingsView from '../../page-objects/Settings/SettingsView.js'; +import SecurityAndPrivacy from '../../page-objects/Settings/SecurityAndPrivacy/SecurityAndPrivacyView.js'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder.js'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper.js'; +import { defaultGanacheOptions } from '../../framework/Constants.js'; +import { asPlaywrightElement } from '../../framework/EncapsulatedElement.js'; +import PlaywrightAssertions from '../../framework/PlaywrightAssertions.js'; + +appiumTest.describe( + SmokeAccounts('Secret Recovery Phrase Reveal from Settings'), + () => { + appiumTest( + 'navigate to reveal SRP screen and make the quiz', + async ({ + driver: _driver, // required: sets globalThis.driver for Appium page objects + currentDeviceDetails, + }) => { + await withFixtures( + { + fixture: new FixtureBuilder().build(), + restartDevice: true, + currentDeviceDetails, + }, + async () => { + await loginToAppPlaywright({ + scenarioType: 'e2e', + }); + await TabBarComponent.tapSettings(); + await SettingsView.tapSecurityAndPrivacy(); + await SecurityAndPrivacy.tapRevealSecretRecoveryPhraseButton(); + await completeSrpQuiz(defaultGanacheOptions.mnemonic); + + await PlaywrightAssertions.expectElementToBeVisible( + await asPlaywrightElement( + SecurityAndPrivacy.securityAndPrivacyHeading, + ), + { description: 'Security and privacy heading' }, + ); + }, + ); + }, + ); + }, +); diff --git a/tests/tags.js b/tests/tags.js index 3e4a4b72221..d4720520bad 100644 --- a/tests/tags.js +++ b/tests/tags.js @@ -5,6 +5,7 @@ * * Selection logic is defined in: tests/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts */ + const smokeTags = { smokeAccounts: { tag: 'SmokeAccounts:', @@ -101,55 +102,70 @@ const otherTags = { fixtureValidation: 'FixtureValidation:', }; -// Smoke test tag functions -const SmokeAccounts = (testName) => - `${smokeTags.smokeAccounts.tag} ${testName}`; -const SmokeConfirmations = (testName) => - `${smokeTags.smokeConfirmations.tag} ${testName}`; -const SmokeIdentity = (testName) => - `${smokeTags.smokeIdentity.tag} ${testName}`; -const SmokeNetworkAbstractions = (testName) => - `${smokeTags.smokeNetworkAbstractions.tag} ${testName}`; -const SmokeNetworkExpansion = (testName) => - `${smokeTags.smokeNetworkExpansion.tag} ${testName}`; -const SmokeSwap = (testName) => `${smokeTags.smokeSwap.tag} ${testName}`; -const SmokeStake = (testName) => `${smokeTags.smokeStake.tag} ${testName}`; -const SmokeWalletPlatform = (testName) => - `${smokeTags.smokeWalletPlatform.tag} ${testName}`; -const SmokeMoney = (testName) => `${smokeTags.smokeMoney.tag} ${testName}`; -const SmokePerps = (testName) => `${smokeTags.smokePerps.tag} ${testName}`; -const SmokeMultiChainAPI = (testName) => - `${smokeTags.smokeMultiChainAPI.tag} ${testName}`; -const SmokePredictions = (testName) => - `${smokeTags.smokePredictions.tag} ${testName}`; -const SmokeSeedlessOnboarding = (testName) => - `${smokeTags.smokeSeedlessOnboarding.tag} ${testName}`; -const SmokeBrowser = (testName) => `${smokeTags.smokeBrowser.tag} ${testName}`; -const SmokeSnaps = (testName) => `${smokeTags.smokeSnaps.tag} ${testName}`; -// Other test tags functions. -const RegressionAccounts = (testName) => - `${otherTags.regressionAccounts} ${testName}`; -const RegressionConfirmations = (testName) => - `${otherTags.regressionConfirmations} ${testName}`; -const RegressionIdentity = (testName) => - `${otherTags.regressionIdentity} ${testName}`; -const RegressionNetworkAbstractions = (testName) => - `${otherTags.regressionNetworkAbstractions} ${testName}`; -const RegressionWalletPlatform = (testName) => - `${otherTags.regressionWalletPlatform} ${testName}`; -const RegressionNetworkExpansion = (testName) => - `${otherTags.regressionNetworkExpansion} ${testName}`; -const RegressionAssets = (testName) => - `${otherTags.regressionAssets} ${testName}`; -const RegressionWalletUX = (testName) => - `${otherTags.regressionWalletUX} ${testName}`; -const RegressionTrade = (testName) => - `${otherTags.regressionTrade} ${testName}`; -const RegressionSampleFeature = (testName) => - `${otherTags.regressionSampleFeature} ${testName}`; -const SmokePerformance = (testName) => `${otherTags.performance} ${testName}`; -const FixtureValidation = (testName) => - `${otherTags.fixtureValidation} ${testName}`; +/** @param {string} tagPrefix Tag label including trailing colon, e.g. "SmokeAccounts:" */ +const tagDescribe = (tagPrefix) => (testName) => `${tagPrefix} ${testName}`; + +/** smokeAccounts → SmokeAccounts */ +const smokeExportName = (key) => `Smoke${key.slice('smoke'.length)}`; + +/** regressionAccounts → RegressionAccounts; performance → SmokePerformance (tag stays "Performance:") */ +const otherExportName = (key) => { + if (key === 'performance') { + return 'SmokePerformance'; + } + return key.charAt(0).toUpperCase() + key.slice(1); +}; + +/** @param {Record} tags */ +const createSmokeDescribeFunctions = (tags) => + Object.fromEntries( + Object.entries(tags).map(([key, { tag }]) => [ + smokeExportName(key), + tagDescribe(tag), + ]), + ); + +/** @param {Record} tags */ +const createOtherDescribeFunctions = (tags) => + Object.fromEntries( + Object.entries(tags).map(([key, tag]) => [ + otherExportName(key), + tagDescribe(tag), + ]), + ); + +const { + SmokeAccounts, + SmokeConfirmations, + SmokeIdentity, + SmokeNetworkAbstractions, + SmokeNetworkExpansion, + SmokeSwap, + SmokeStake, + SmokeWalletPlatform, + SmokeMoney, + SmokePerps, + SmokeMultiChainAPI, + SmokePredictions, + SmokeSeedlessOnboarding, + SmokeBrowser, + SmokeSnaps, +} = createSmokeDescribeFunctions(smokeTags); + +const { + RegressionAccounts, + RegressionConfirmations, + RegressionIdentity, + RegressionNetworkAbstractions, + RegressionWalletPlatform, + RegressionNetworkExpansion, + RegressionAssets, + RegressionWalletUX, + RegressionTrade, + RegressionSampleFeature, + SmokePerformance, + FixtureValidation, +} = createOtherDescribeFunctions(otherTags); export { smokeTags, From 0356eb44a2a4a546b8eac333375e879378b9298d Mon Sep 17 00:00:00 2001 From: Wei Sun Date: Wed, 10 Jun 2026 15:47:45 -0700 Subject: [PATCH 12/12] refactor(MainNavigator): Migrate ConfirmAddAsset and DeFiProtocolPositionDetails to HeaderStandard (#31443) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** 1. Migrates ConfirmAddAsset and DeFiProtocolPositionDetails from React Navigation stack headers to in-screen HeaderStandard components, aligning them with the ongoing header migration pattern. 2. Fixes ActivityView safe-area handling so the header no longer shifts when switching between back-button and default header states. ## **Changelog** CHANGELOG entry:null ## **Related issues** Fixes: Migrate ConfirmAddAsset and DeFiProtocolPositionDetails screens to HeaderStandard ## **Manual testing steps** ```gherkin Feature: HeaderStandard migration Scenario: Confirm add asset shows header and back works Given the user is on the confirm add asset screen Then the add asset title is displayed in the header When the user taps the back button Then the user returns to the previous screen Scenario: Confirm add asset import still works Given the user is on the confirm add asset screen with tokens selected When the user taps import Then the user is navigated to the wallet screen Scenario: DeFi protocol position details shows back button and content Given the user is on the DeFi protocol position details screen Then the protocol name is displayed And a back button is displayed When the user taps the back button Then the user returns to the wallet screen Scenario: Activity header is stable and respects safe area Given the user is on the Activity screen Then the activity title is displayed in the header And the header does not shift on load Scenario: Activity back button navigates home when Money account is enabled Given the Money account feature is enabled And the user is on the Activity screen When the user taps the back button Then the user is navigated to the home screen ``` ## **Screenshots/Recordings** iOS |BEFORE|AFTER| |---|---| |Before|After| Android https://github.com/user-attachments/assets/7fa36b5d-ca9d-404a-b680-372aaeb5ca32 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > UI and navigation presentation only; no auth, transactions, or data-layer changes, with unit test coverage for back navigation and safe areas. > > **Overview** > Continues the **HeaderStandard** migration by hiding stack headers for **ConfirmAddAsset** and **DeFiProtocolPositionDetails** and rendering headers inside each screen instead. > > **ConfirmAddAsset** drops `navigation.setOptions` / `getHeaderCompactStandardNavbarOptions` and adds an in-screen `HeaderStandard` with the add-asset title and `goBack`, wrapped in `SafeAreaView` with left/right/bottom edges only. > > **DeFiProtocolPositionDetails** replaces the stack navbar (`getDeFiProtocolPositionDetailsNavbarOptions`) with `HeaderStandard` (empty title, `navigation.pop` on back), `SafeAreaView` + `includesTopInset`, and a content wrapper style; the removed navbar helper and its tests are deleted from `Navbar`. > > **ActivityView** changes `SafeAreaView` from top additive edges to left/right/bottom only and sets **`includesTopInset`** on both `HeaderStandard` and `HeaderRoot` so top inset is handled by the header and the title no longer jumps when toggling back vs default header. > > Navigator and component tests are updated for `headerShown: false` and the new back/safe-area behavior. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 0170d1137980c638032645eebbf3e71a8b020278. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Cursor --- app/components/Nav/Main/MainNavigator.js | 13 +-- .../Nav/Main/MainNavigator.test.tsx | 6 ++ .../DeFiProtocolPositionDetails.styles.ts | 3 + .../DeFiProtocolPositionDetails.test.tsx | 47 +++++++++ .../DeFiProtocolPositionDetails.tsx | 95 +++++++++++-------- app/components/UI/Navbar/index.js | 23 ----- app/components/UI/Navbar/index.test.js | 11 --- app/components/UI/Navbar/index.test.jsx | 31 ------ app/components/Views/ActivityView/index.js | 4 +- .../Views/ActivityView/index.test.tsx | 10 ++ .../ConfirmAddAsset.test.tsx | 31 +++++- .../ConfirmAddTokenView/ConfirmAddAsset.tsx | 30 +++--- 12 files changed, 167 insertions(+), 137 deletions(-) diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 432afa468ce..75fbb3001f6 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -168,7 +168,6 @@ import SitesFullView from '../../Views/SitesFullView/SitesFullView'; import { TokenDetails } from '../../UI/TokenDetails/Views/TokenDetails'; import BenefitFullView from '../../UI/Rewards/Views/BenefitFullView'; import BenefitsFullView from '../../UI/Rewards/Views/BenefitsFullView'; -import { getDeFiProtocolPositionDetailsNavbarOptions } from '../../UI/Navbar'; import MoneyTabPressTracker from '../../UI/Money/components/MoneyTabPressTracker'; import { withMessenger } from '../../../messengers/helpers/route-messenger-helpers'; @@ -1052,7 +1051,7 @@ const MainNavigator = () => { { ({ - ...slideFromRightAnimation, - ...getDeFiProtocolPositionDetailsNavbarOptions(navigation), - headerStyle: { - backgroundColor: colors.background.default, - shadowColor: importedColors.transparent, - elevation: 0, - }, - })} + options={{ headerShown: false, ...slideFromRightAnimation }} /> { ///: BEGIN:ONLY_INCLUDE_IF(sample-feature) diff --git a/app/components/Nav/Main/MainNavigator.test.tsx b/app/components/Nav/Main/MainNavigator.test.tsx index 3f86e56bfdc..f98a7a936e1 100644 --- a/app/components/Nav/Main/MainNavigator.test.tsx +++ b/app/components/Nav/Main/MainNavigator.test.tsx @@ -981,6 +981,9 @@ describe('MainNavigator', () => { const screen = screenProps?.find((s) => s?.name === 'ConfirmAddAsset'); expect(screen).toBeDefined(); + expect(screen?.options?.headerShown).toBe(false); + expect(screen?.options?.animationEnabled).toBe(true); + expect(typeof screen?.options?.cardStyleInterpolator).toBe('function'); }); it('includes StakeScreens route', () => { @@ -1064,6 +1067,9 @@ describe('MainNavigator', () => { ); expect(screen).toBeDefined(); + expect(screen?.options?.headerShown).toBe(false); + expect(screen?.options?.animationEnabled).toBe(true); + expect(typeof screen?.options?.cardStyleInterpolator).toBe('function'); }); it('includes Asset screen', () => { diff --git a/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.styles.ts b/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.styles.ts index 81bc4b641b0..4f16f324ce8 100644 --- a/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.styles.ts +++ b/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.styles.ts @@ -18,6 +18,9 @@ const styleSheet = () => protocolPositionDetailsWrapper: { flex: 1, }, + protocolPositionDetailsContent: { + flex: 1, + }, }); export default styleSheet; diff --git a/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.test.tsx b/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.test.tsx index f797505b4cd..261cbd00ad9 100644 --- a/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.test.tsx +++ b/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.test.tsx @@ -1,9 +1,25 @@ import React from 'react'; +import { userEvent } from '@testing-library/react-native'; import renderWithProvider from '../../../util/test/renderWithProvider'; import { backgroundState } from '../../../util/test/initial-root-state'; import DeFiProtocolPositionDetails, { DEFI_PROTOCOL_POSITION_DETAILS_BALANCE_TEST_ID, } from './DeFiProtocolPositionDetails'; +import { CommonSelectorsIDs } from '../../../util/Common.testIds'; +// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog +import { WalletViewSelectorsIDs } from '../../Views/Wallet/WalletView.testIds'; + +const mockPop = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return { + ...actual, + useNavigation: () => ({ + pop: mockPop, + }), + }; +}); jest.mock('../../../util/navigation/navUtils', () => ({ ...jest.requireActual('../../../util/navigation/navUtils'), @@ -55,6 +71,10 @@ const mockInitialState = { }; describe('DeFiProtocolPositionDetails', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('renders the protocol name header and aggregated balance', async () => { const { findByText, findByTestId } = renderWithProvider( , @@ -93,4 +113,31 @@ describe('DeFiProtocolPositionDetails', () => { await findByTestId(DEFI_PROTOCOL_POSITION_DETAILS_BALANCE_TEST_ID), ).toHaveTextContent('•••••••••'); }); + + it('calls navigation.pop when the header back button is pressed', async () => { + const { getByTestId } = renderWithProvider( + , + { + state: mockInitialState, + }, + ); + + await userEvent.press(getByTestId(CommonSelectorsIDs.BACK_ARROW_BUTTON)); + + expect(mockPop).toHaveBeenCalledTimes(1); + }); + + it('renders SafeAreaView with left, right, and bottom edges only', () => { + const { getByTestId } = renderWithProvider( + , + { + state: mockInitialState, + }, + ); + + expect( + getByTestId(WalletViewSelectorsIDs.DEFI_POSITIONS_DETAILS_CONTAINER).props + .edges, + ).toEqual(['left', 'right', 'bottom']); + }); }); diff --git a/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.tsx b/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.tsx index f021054f39d..dd23ad9449b 100644 --- a/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.tsx +++ b/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.tsx @@ -1,8 +1,13 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { GroupedDeFiPositions } from '@metamask/assets-controllers'; import { ImageSourcePropType, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useNavigation, type ParamListBase } from '@react-navigation/native'; +import type { StackNavigationProp } from '@react-navigation/stack'; +import { HeaderStandard } from '@metamask/design-system-react-native'; import styleSheet from './DeFiProtocolPositionDetails.styles'; import { useParams } from '../../../util/navigation/navUtils'; +import { CommonSelectorsIDs } from '../../../util/Common.testIds'; import Text, { TextColor, TextVariant, @@ -30,54 +35,68 @@ interface DeFiProtocolPositionDetailsParams { const DeFiProtocolPositionDetails: React.FC = () => { const { styles } = useStyles(styleSheet, undefined); + const navigation = useNavigation>(); const { protocolAggregate, networkIconAvatar } = useParams(); const privacyMode = useSelector(selectPrivacyMode); + const handleBack = useCallback(() => { + navigation.pop(); + }, [navigation]); + return ( - - - - - {protocolAggregate.protocolDetails.name} - - - {formatWithThreshold( - protocolAggregate.aggregatedMarketValue, - 0.01, - I18n.locale, - { style: 'currency', currency: 'USD' }, - )} - - + + + + + + {protocolAggregate.protocolDetails.name} + + + {formatWithThreshold( + protocolAggregate.aggregatedMarketValue, + 0.01, + I18n.locale, + { style: 'currency', currency: 'USD' }, + )} + + - - + + + + + + + - - - - - + ); }; diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index df243d14255..07c609a4d4b 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -1433,29 +1433,6 @@ export function getStakingNavbar( }; } -/** - * Function that returns the navigation options for the DeFi Protocol Positions Details screen - * - * @param {Object} navigation - Navigation object required to push new views - * @returns {Object} - Corresponding navbar options - */ -export function getDeFiProtocolPositionDetailsNavbarOptions(navigation) { - return { - headerShown: true, - headerTitle: () => null, - headerLeft: () => ( - navigation.pop()} - testID={CommonSelectorsIDs.BACK_ARROW_BUTTON} - size={ButtonIconSize.Md} - iconName={IconName.ArrowLeft} - iconColor={IconColor.Default} - /> - ), - }; -} - export function getRampsOrderDetailsNavbarOptions( navigation, { title, showBack = true }, diff --git a/app/components/UI/Navbar/index.test.js b/app/components/UI/Navbar/index.test.js index d73dcb6561f..04fada348db 100644 --- a/app/components/UI/Navbar/index.test.js +++ b/app/components/UI/Navbar/index.test.js @@ -18,7 +18,6 @@ import { getBridgeNavbar, getBridgeTransactionDetailsNavbar, getStakingNavbar, - getDeFiProtocolPositionDetailsNavbarOptions, getRampsOrderDetailsNavbarOptions, getPaymentSelectorMethodNavbar, getPaymentMethodApplePayNavbar, @@ -489,16 +488,6 @@ describe('Navbar', () => { }); }); - describe('getDeFiProtocolPositionDetailsNavbarOptions', () => { - it('returns correct options', () => { - const options = - getDeFiProtocolPositionDetailsNavbarOptions(mockNavigation); - - expect(options).toHaveProperty('headerTitle'); - expect(options).toHaveProperty('headerLeft'); - }); - }); - describe('getRampsOrderDetailsNavbarOptions', () => { it('returns correct options', () => { const options = getRampsOrderDetailsNavbarOptions( diff --git a/app/components/UI/Navbar/index.test.jsx b/app/components/UI/Navbar/index.test.jsx index 33fad6de7b8..4bbb8aa48ca 100644 --- a/app/components/UI/Navbar/index.test.jsx +++ b/app/components/UI/Navbar/index.test.jsx @@ -668,37 +668,6 @@ describe('getEditAccountNameNavBarOptions', () => { }); }); -describe('getDeFiProtocolPositionDetailsNavbarOptions', () => { - const { getDeFiProtocolPositionDetailsNavbarOptions } = require('.'); - - const mockNavigation = { - pop: jest.fn(), - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('returns navbar options with back button', () => { - const options = getDeFiProtocolPositionDetailsNavbarOptions(mockNavigation); - - expect(options.headerTitle).toBeDefined(); - expect(options.headerLeft).toBeDefined(); - - const HeaderTitle = options.headerTitle(); - expect(HeaderTitle).toBeNull(); - }); - - it('calls navigation.pop when back button is pressed', () => { - const options = getDeFiProtocolPositionDetailsNavbarOptions(mockNavigation); - - const HeaderLeft = options.headerLeft(); - HeaderLeft.props.onPress(); - - expect(mockNavigation.pop).toHaveBeenCalledTimes(1); - }); -}); - describe('getRampsOrderDetailsNavbarOptions', () => { const { getRampsOrderDetailsNavbarOptions } = require('.'); diff --git a/app/components/Views/ActivityView/index.js b/app/components/Views/ActivityView/index.js index a02d9f87a88..fc607753aa7 100644 --- a/app/components/Views/ActivityView/index.js +++ b/app/components/Views/ActivityView/index.js @@ -217,7 +217,7 @@ const ActivityView = () => { return ( { ) : ( )} diff --git a/app/components/Views/ActivityView/index.test.tsx b/app/components/Views/ActivityView/index.test.tsx index 7d6b3d55482..0925538bf3a 100644 --- a/app/components/Views/ActivityView/index.test.tsx +++ b/app/components/Views/ActivityView/index.test.tsx @@ -533,6 +533,16 @@ describe('ActivityView', () => { ).toBeOnTheScreen(); }); + it('renders SafeAreaView with left, right, and bottom edges only', () => { + mockRoute.params = {}; + + const { getByTestId } = renderComponent(mockInitialState); + + expect( + getByTestId(ActivitiesViewSelectorsIDs.SAFE_AREA_VIEW).props.edges, + ).toEqual(['left', 'right', 'bottom']); + }); + it('renders HeaderRoot with Activity title when showBackButton is false', () => { mockRoute.params = { showBackButton: false }; diff --git a/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.test.tsx b/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.test.tsx index a08d59bbdb2..c3fe94981b8 100644 --- a/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.test.tsx +++ b/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.test.tsx @@ -20,8 +20,9 @@ import { } from '../../../../../component-library/components/BottomSheets/BottomSheetFooter/BottomSheetFooter.constants'; import Routes from '../../../../../constants/navigation/Routes'; import Logger from '../../../../../util/Logger'; +import { strings } from '../../../../../../locales/i18n'; +import { ImportTokenViewSelectorsIDs } from '../../ImportAssetView.testIds'; -const mockSetOptions = jest.fn(); const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); const mockAddTokenList = jest.fn().mockResolvedValue(undefined); @@ -32,7 +33,6 @@ jest.mock('@react-navigation/native', () => { ...actualReactNavigation, useNavigation: () => ({ navigate: mockNavigate, - setOptions: mockSetOptions, goBack: mockGoBack, }), }; @@ -131,6 +131,16 @@ describe('ConfirmAddAsset', () => { expect(getByText('USDC')).toBeOnTheScreen(); }); + it('calls goBack when HeaderStandard back button is pressed', async () => { + const { getByTestId } = renderWithProvider(, { + state: mockInitialState, + }); + + await userEvent.press(getByTestId('button-icon')); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + it('calls goBack when back button is pressed', async () => { const { getByTestId } = renderWithProvider(, { state: mockInitialState, @@ -252,11 +262,22 @@ describe('ConfirmAddAsset', () => { expect(getByText('USDT')).toBeOnTheScreen(); }); - it('sets navigation bar options on mount', () => { - renderWithProvider(, { + it('renders HeaderStandard with the add asset title', () => { + const { getByText } = renderWithProvider(, { + state: mockInitialState, + }); + + expect(getByText(strings('add_asset.title'))).toBeOnTheScreen(); + }); + + it('renders SafeAreaView with left, right, and bottom edges only', () => { + const { getByTestId } = renderWithProvider(, { state: mockInitialState, }); - expect(mockSetOptions).toHaveBeenCalledTimes(1); + expect( + getByTestId(ImportTokenViewSelectorsIDs.ADD_CONFIRM_CUSTOM_ASSET).props + .edges, + ).toEqual(['left', 'right', 'bottom']); }); }); diff --git a/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.tsx b/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.tsx index f777474687c..8bfc4d204af 100644 --- a/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.tsx +++ b/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.tsx @@ -1,11 +1,10 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { Platform } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { useParams } from '../../../../../util/navigation/navUtils'; import { strings } from '../../../../../../locales/i18n'; import { useNavigation } from '@react-navigation/native'; -import getHeaderCompactStandardNavbarOptions from '../../../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions'; import { ButtonSize, ButtonVariants, @@ -17,7 +16,12 @@ import ListItem from '../../../../../component-library/components/List/ListItem' import Routes from '../../../../../constants/navigation/Routes'; import { ImportTokenViewSelectorsIDs } from '../../ImportAssetView.testIds'; import { FlashList } from '@shopify/flash-list'; -import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; +import { + Box, + HeaderStandard, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; import { ImportAsset } from '../../utils/utils'; import AddAssetTokenRow from '../../components/AddAssetTokenRow/AddAssetTokenRow'; import Logger from '../../../../../util/Logger'; @@ -45,20 +49,6 @@ const ConfirmAddAsset = () => { }); }, [navigation]); - const updateNavBar = useCallback(() => { - navigation.setOptions( - getHeaderCompactStandardNavbarOptions({ - title: strings(`add_asset.title`), - onBack: () => navigation.goBack(), - includesTopInset: true, - }), - ); - }, [navigation]); - - useEffect(() => { - updateNavBar(); - }, [updateNavBar]); - const handleImport = useCallback(async () => { if (isImporting) { return; @@ -81,6 +71,12 @@ const ConfirmAddAsset = () => { style={tw.style('flex-1 bg-default')} testID={ImportTokenViewSelectorsIDs.ADD_CONFIRM_CUSTOM_ASSET} > + navigation.goBack()} + includesTopInset + /> + {selectedAsset.length > 1