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**
## **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`.
## **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**
### **After**
## **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**
### **After**
## **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**
## **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|
|---|---|
|
|
|
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