diff --git a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx index f669718c1b1..e0018003b4b 100644 --- a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx +++ b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx @@ -5,7 +5,7 @@ import MoneyAddMoneySheet from './MoneyAddMoneySheet'; import { MoneyAddMoneySheetTestIds } from './MoneyAddMoneySheet.testIds'; import { useMusdConversionFlowData } from '../../../Earn/hooks/useMusdConversionFlowData'; import { useRampNavigation } from '../../../Ramp/hooks/useRampNavigation'; -import { useMusdBalance } from '../../../Earn/hooks/useMusdBalance'; +import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; import { useMoneyAccountDeposit } from '../../hooks/useMoneyAccount'; import { MUSD_CONVERSION_DEFAULT_CHAIN_ID, @@ -38,8 +38,9 @@ jest.mock('../../../Ramp/hooks/useRampNavigation', () => ({ useRampNavigation: jest.fn(), })); -jest.mock('../../../Earn/hooks/useMusdBalance', () => ({ - useMusdBalance: jest.fn(), +jest.mock('../../hooks/useMoneyAccountBalance', () => ({ + __esModule: true, + default: jest.fn(), })); jest.mock('../../hooks/useMoneyAccount', () => ({ @@ -86,8 +87,8 @@ describe('MoneyAddMoneySheet', () => { (useRampNavigation as jest.Mock).mockReturnValue({ goToBuy: mockGoToBuy, }); - (useMusdBalance as jest.Mock).mockReturnValue({ - fiatBalanceAggregatedFormatted: '$1,203.89', + (useMoneyAccountBalance as jest.Mock).mockReturnValue({ + totalFiatFormatted: '$1,203.89', }); (useMoneyAccountDeposit as jest.Mock).mockReturnValue({ initiateDeposit: mockInitiateDeposit, @@ -110,8 +111,8 @@ describe('MoneyAddMoneySheet', () => { }); it('preserves the locale fiat prefix in the Move mUSD row', () => { - (useMusdBalance as jest.Mock).mockReturnValue({ - fiatBalanceAggregatedFormatted: 'CA$1,500.00', + (useMoneyAccountBalance as jest.Mock).mockReturnValue({ + totalFiatFormatted: 'CA$1,500.00', }); const { getByText } = renderWithProvider(); @@ -119,8 +120,8 @@ describe('MoneyAddMoneySheet', () => { }); it('falls back to the no-amount copy when the mUSD balance is unavailable', () => { - (useMusdBalance as jest.Mock).mockReturnValue({ - fiatBalanceAggregatedFormatted: undefined, + (useMoneyAccountBalance as jest.Mock).mockReturnValue({ + totalFiatFormatted: undefined, }); const { getByText } = renderWithProvider(); diff --git a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx index 6b559e9982d..c62a4bbda98 100644 --- a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx +++ b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx @@ -17,8 +17,8 @@ import { import Tag from '../../../../../component-library/components/Tags/Tag'; import { strings } from '../../../../../../locales/i18n'; import { useStyles } from '../../../../../component-library/hooks'; -import { useMusdBalance } from '../../../Earn/hooks/useMusdBalance'; import { useMusdConversionFlowData } from '../../../Earn/hooks/useMusdConversionFlowData'; +import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; import { MUSD_CONVERSION_DEFAULT_CHAIN_ID, MUSD_TOKEN_ASSET_ID_BY_CHAIN, @@ -40,7 +40,7 @@ const MoneyAddMoneySheet: React.FC = () => { const navigation = useNavigation(); const { styles } = useStyles(styleSheet, {}); - const { fiatBalanceAggregatedFormatted } = useMusdBalance(); + const { totalFiatFormatted } = useMoneyAccountBalance(); const { getChainIdForBuyFlow } = useMusdConversionFlowData(); const { goToBuy } = useRampNavigation(); const { initiateDeposit } = useMoneyAccountDeposit(); @@ -94,9 +94,9 @@ const MoneyAddMoneySheet: React.FC = () => { testID: MoneyAddMoneySheetTestIds.DEPOSIT_FUNDS_OPTION, }, { - label: fiatBalanceAggregatedFormatted + label: totalFiatFormatted ? strings('money.add_money_sheet.move_musd', { - amount: fiatBalanceAggregatedFormatted, + amount: totalFiatFormatted, }) : strings('money.add_money_sheet.move_musd_no_amount'), icon: IconName.Add, diff --git a/app/components/UI/Rewards/RewardsNavigator.test.tsx b/app/components/UI/Rewards/RewardsNavigator.test.tsx index e903bafb8ec..b824e326b1c 100644 --- a/app/components/UI/Rewards/RewardsNavigator.test.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.test.tsx @@ -282,6 +282,8 @@ jest.mock('./hooks/useRewardsToast', () => ({ loading: jest.fn(), entriesClosed: jest.fn(), enableNotificationsNudge: jest.fn(), + outcomeWinner: jest.fn(), + outcomeNonWinner: jest.fn(), }, })), })); diff --git a/app/components/UI/Rewards/RewardsNavigator.tsx b/app/components/UI/Rewards/RewardsNavigator.tsx index f9aabffa47a..77ab123834e 100644 --- a/app/components/UI/Rewards/RewardsNavigator.tsx +++ b/app/components/UI/Rewards/RewardsNavigator.tsx @@ -37,9 +37,9 @@ import { useReferralDetails } from './hooks/useReferralDetails'; import { useRewardsNotificationsNudge } from './hooks/useRewardsNotificationsNudge'; import useRewardsToast from './hooks/useRewardsToast'; import { strings } from '../../../../locales/i18n'; +import PerpsTradingCampaignWinningView from './Views/PerpsTradingCampaignWinningView'; let sessionNotificationsNudgeShown = false; - const Stack = createStackNavigator(); const RewardsNavigator: React.FC = () => { @@ -296,6 +296,11 @@ const RewardsNavigator: React.FC = () => { component={PerpsTradingCampaignStatsView} options={{ headerShown: false }} /> + ) : null} diff --git a/app/components/UI/Rewards/Views/CampaignWinningView.test.tsx b/app/components/UI/Rewards/Views/CampaignWinningView.test.tsx new file mode 100644 index 00000000000..afbc489f176 --- /dev/null +++ b/app/components/UI/Rewards/Views/CampaignWinningView.test.tsx @@ -0,0 +1,297 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { Linking } from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; +import CampaignWinningView, { + CampaignWinningViewProps, +} from './CampaignWinningView'; +import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; + +jest.mock('../../../../images/rewards/campaign_winning.png', () => ({ + __esModule: true, + default: 1, +})); + +const mockGoBack = jest.fn(); +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate }), +})); + +jest.mock('@metamask/design-system-twrnc-preset', () => { + const tw = (...args: unknown[]) => args; + tw.style = (...args: unknown[]) => args; + return { useTailwind: () => tw }; +}); + +jest.mock('react-native/Libraries/Utilities/useWindowDimensions', () => ({ + default: () => ({ width: 390, height: 844 }), +})); + +jest.mock('react-native-safe-area-context', () => { + const actual = jest.requireActual('react-native-safe-area-context'); + return { + ...actual, + useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }), + }; +}); + +jest.mock('../../../Views/ErrorBoundary', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => + ReactActual.createElement(View, null, children), + }; +}); + +jest.mock('../hooks/useTrackRewardsPageView', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('../../../../core/Analytics', () => ({ + MetaMetricsEvents: { + REWARDS_PAGE_BUTTON_CLICKED: 'REWARDS_PAGE_BUTTON_CLICKED', + }, +})); + +jest.mock('../utils', () => ({ + RewardsMetricsButtons: { + COPY_WINNER_VERIFICATION_CODE: 'copy_winner_verification_code', + }, +})); + +const mockTrackEvent = jest.fn(); +const mockBuild = jest.fn(() => ({})); +jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: () => ({ + addProperties: () => ({ build: mockBuild }), + }), + }), +})); + +jest.mock('../components/ReferralDetails/CopyableField', () => { + const ReactActual = jest.requireActual('react'); + const { View, Text, Pressable } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ + label, + value, + onCopy, + }: { + label: string; + value?: string | null; + onCopy?: () => void; + }) => + ReactActual.createElement( + View, + { testID: 'copyable-field' }, + ReactActual.createElement(Text, null, label), + ReactActual.createElement( + Text, + { testID: 'copyable-value' }, + value ?? '', + ), + ReactActual.createElement(Pressable, { + testID: 'copyable-trigger', + onPress: onCopy, + }), + ), + }; +}); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn( + ( + key: string, + params?: { + code?: string; + campaignName?: string; + email?: string; + }, + ) => { + if ( + key === 'rewards.campaign_winning.mail_subject' && + params?.campaignName + ) + return `${params.campaignName} prize claim`; + if (key === 'rewards.campaign_winning.mail_body' && params?.code) + return `My winning code: ${params.code}`; + if ( + key === 'rewards.campaign_winning.email_instructions' && + params?.email + ) + return `Email ${params.email} with your code`; + const map: Record = { + 'rewards.campaign_winning.you_won': 'You won', + 'rewards.campaign_winning.open_mail': 'Open mail', + 'rewards.campaign_winning.skip_for_now': 'Skip for now', + 'rewards.campaign_winning.winning_code': 'Winning code', + 'rewards.campaign_winning.close_a11y': 'Close', + }; + return map[key] ?? key; + }, + ), +})); + +const PRIZE_EMAIL = 'test@consensys.net'; +const CAMPAIGN_NAME = 'Test Campaign'; +const CAMPAIGN_ID = 'campaign-test-1'; +const WINNING_CODE = 'WIN-123'; +const mockUseTrackRewardsPageView = + useTrackRewardsPageView as jest.MockedFunction< + typeof useTrackRewardsPageView + >; + +const defaultProps: CampaignWinningViewProps = { + testID: 'test-winning-view', + viewName: 'TestWinningView', + prizeEmail: PRIZE_EMAIL, + campaignName: CAMPAIGN_NAME, + campaignId: CAMPAIGN_ID, + analyticsPageType: 'test_campaign_winning', + winningCode: WINNING_CODE, + hasOutcomeLoaded: true, + isLoading: false, + rankDisplay: null, +}; + +describe('CampaignWinningView', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the main container with the provided testID', () => { + const { getByTestId } = render(); + expect(getByTestId('test-winning-view')).toBeTruthy(); + }); + + it('renders "You won" text', () => { + const { getByText } = render(); + expect(getByText('You won')).toBeTruthy(); + }); + + it('renders email instructions with the prizeEmail', () => { + const { getByText } = render(); + expect(getByText(`Email ${PRIZE_EMAIL} with your code`)).toBeTruthy(); + }); + + it('tracks page view with the campaign id', () => { + render(); + expect(mockUseTrackRewardsPageView).toHaveBeenCalledWith({ + page_type: 'test_campaign_winning', + campaign_id: CAMPAIGN_ID, + }); + }); + + it('renders rank and result display when provided', () => { + const { getByText } = render( + , + ); + expect(getByText('3rd')).toBeTruthy(); + expect(getByText('+12.34%')).toBeTruthy(); + }); + + it('calls goBack when Skip for now is pressed', () => { + const { getByText } = render(); + fireEvent.press(getByText('Skip for now')); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('calls goBack when Close button is pressed', () => { + const { getByLabelText } = render( + , + ); + fireEvent.press(getByLabelText('Close')); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('copies winning code and fires analytics when copy is triggered', () => { + const setStringSpy = jest.spyOn(Clipboard, 'setString'); + const { getByTestId } = render(); + fireEvent.press(getByTestId('copyable-trigger')); + expect(setStringSpy).toHaveBeenCalledWith(WINNING_CODE); + expect(mockTrackEvent).toHaveBeenCalled(); + setStringSpy.mockRestore(); + }); + + it('opens mailto with the correct email and code when Open mail is pressed', async () => { + const openSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined); + const { getByText } = render(); + fireEvent.press(getByText('Open mail')); + expect(openSpy).toHaveBeenCalled(); + const url = openSpy.mock.calls[0][0] as string; + expect(url).toContain(`mailto:${PRIZE_EMAIL}`); + expect(url).toContain(encodeURIComponent(WINNING_CODE)); + openSpy.mockRestore(); + }); + + it('navigates to fallback route when outcome loads without a winning code', () => { + const fallbackRoute = { + route: 'CampaignDetails', + params: { campaignId: CAMPAIGN_ID }, + }; + + render( + , + ); + + expect(mockNavigate).toHaveBeenCalledWith( + fallbackRoute.route, + fallbackRoute.params, + ); + expect(mockGoBack).not.toHaveBeenCalled(); + }); + + it('falls back to goBack when outcome loads without a winning code and no fallback route is provided', () => { + render( + , + ); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('does not call goBack before outcome has loaded', () => { + render( + , + ); + expect(mockGoBack).not.toHaveBeenCalled(); + }); + + it('does not call goBack while still loading', () => { + render( + , + ); + expect(mockGoBack).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/UI/Rewards/Views/CampaignWinningView.tsx b/app/components/UI/Rewards/Views/CampaignWinningView.tsx new file mode 100644 index 00000000000..8eb65c54a01 --- /dev/null +++ b/app/components/UI/Rewards/Views/CampaignWinningView.tsx @@ -0,0 +1,267 @@ +import React, { useCallback, useEffect } from 'react'; +import { Image, Linking, ScrollView, useWindowDimensions } from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; +import { + useNavigation, + type NavigationProp, + type ParamListBase, +} from '@react-navigation/native'; +import { + SafeAreaView, + useSafeAreaInsets, +} from 'react-native-safe-area-context'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { + Box, + BoxFlexDirection, + Button, + ButtonSize, + ButtonVariant, + ButtonIcon, + ButtonIconSize, + IconName, + Skeleton, + Text, + TextColor, + TextVariant, + FontWeight, +} from '@metamask/design-system-react-native'; +import ErrorBoundary from '../../../Views/ErrorBoundary'; +import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; +import { strings } from '../../../../../locales/i18n'; +import CopyableField from '../components/ReferralDetails/CopyableField'; +import { RewardsMetricsButtons } from '../utils'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import campaignWinningHero from '../../../../images/rewards/campaign_winning.png'; + +const HERO_HEIGHT_RATIO = 0.5; + +export interface CampaignWinningViewProps { + testID: string; + viewName: string; + prizeEmail: string; + campaignName: string; + campaignId: string; + analyticsPageType: string; + winningCode: string | null; + hasOutcomeLoaded: boolean; + isLoading: boolean; + rankDisplay: string | null; + resultDisplay?: string | null; + isRankLoading?: boolean; + isResultLoading?: boolean; + fallbackRoute?: { + route: string; + params?: object; + }; +} + +const CampaignWinningView: React.FC = ({ + testID, + viewName, + prizeEmail, + campaignName, + campaignId, + analyticsPageType, + winningCode, + hasOutcomeLoaded, + isLoading, + rankDisplay, + resultDisplay = null, + isRankLoading = false, + isResultLoading = false, + fallbackRoute, +}) => { + const tw = useTailwind(); + const { height: windowHeight } = useWindowDimensions(); + const heroHeight = windowHeight * HERO_HEIGHT_RATIO; + const insets = useSafeAreaInsets(); + const navigation = useNavigation>(); + const { trackEvent, createEventBuilder } = useAnalytics(); + + useTrackRewardsPageView({ + page_type: analyticsPageType, + campaign_id: campaignId, + }); + + useEffect(() => { + if (!isLoading && hasOutcomeLoaded && winningCode === null) { + if (fallbackRoute) { + navigation.navigate(fallbackRoute.route, fallbackRoute.params); + return; + } + navigation.goBack(); + } + }, [isLoading, hasOutcomeLoaded, winningCode, fallbackRoute, navigation]); + + const onDismiss = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const handleCopyWinningCode = useCallback(() => { + if (winningCode) { + Clipboard.setString(winningCode); + trackEvent( + createEventBuilder(MetaMetricsEvents.REWARDS_PAGE_BUTTON_CLICKED) + .addProperties({ + button_type: RewardsMetricsButtons.COPY_WINNER_VERIFICATION_CODE, + }) + .build(), + ); + } + }, [winningCode, trackEvent, createEventBuilder]); + + const handleOpenMail = useCallback(async () => { + const baseSubject = strings('rewards.campaign_winning.mail_subject', { + campaignName, + }); + const subject = winningCode + ? `${baseSubject} - ${winningCode}` + : baseSubject; + const body = strings('rewards.campaign_winning.mail_body', { + code: winningCode || '—', + }); + const url = `mailto:${prizeEmail}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; + try { + await Linking.openURL(url); + } catch { + // no-op: device may not have a mail handler + } + }, [winningCode, prizeEmail, campaignName]); + + return ( + + + + + + + + + + + + + + {strings('rewards.campaign_winning.you_won')} + + + + {rankDisplay !== null ? ( + + {rankDisplay} + + ) : isRankLoading ? ( + + ) : null} + + {resultDisplay !== null ? ( + + {resultDisplay} + + ) : isResultLoading ? ( + + ) : null} + + + + {strings('rewards.campaign_winning.email_instructions', { + email: prizeEmail, + })} + + + + + + + + + + + + + + + + + ); +}; + +export default CampaignWinningView; diff --git a/app/components/UI/Rewards/Views/CampaignsView.test.tsx b/app/components/UI/Rewards/Views/CampaignsView.test.tsx index 0bca5c0bfef..a811a188841 100644 --- a/app/components/UI/Rewards/Views/CampaignsView.test.tsx +++ b/app/components/UI/Rewards/Views/CampaignsView.test.tsx @@ -6,6 +6,8 @@ import { CampaignType, } from '../../../../core/Engine/controllers/rewards-controller/types'; import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; +import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; +import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast'; import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; const mockGoBack = jest.fn(); @@ -30,11 +32,18 @@ const mockUseRewardCampaigns = useRewardCampaigns as jest.MockedFunction< jest.mock('../hooks/useOndoOutcomeToast', () => ({ useOndoOutcomeToast: jest.fn(), })); -import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; const mockUseOndoOutcomeToast = useOndoOutcomeToast as jest.MockedFunction< typeof useOndoOutcomeToast >; +jest.mock('../hooks/usePerpsTradingCampaignEndedOutcomeToast', () => ({ + usePerpsTradingCampaignEndedOutcomeToast: jest.fn(), +})); +const mockUsePerpsTradingCampaignEndedOutcomeToast = + usePerpsTradingCampaignEndedOutcomeToast as jest.MockedFunction< + typeof usePerpsTradingCampaignEndedOutcomeToast + >; + jest.mock('../components/Campaigns/CampaignsGroup', () => { const ReactActual = jest.requireActual('react'); const { View, Text } = jest.requireActual('react-native'); @@ -173,7 +182,6 @@ describe('CampaignsView', () => { beforeEach(() => { jest.clearAllMocks(); mockUseRewardCampaigns.mockReturnValue(hookDefaults); - mockUseOndoOutcomeToast.mockReturnValue(undefined); }); it('renders the header with the correct title', () => { @@ -185,6 +193,15 @@ describe('CampaignsView', () => { expect(getByText('Campaigns')).toBeOnTheScreen(); }); + it('mounts campaign outcome toast hooks on render', () => { + render(); + + expect(mockUseOndoOutcomeToast).toHaveBeenCalledTimes(1); + expect(mockUsePerpsTradingCampaignEndedOutcomeToast).toHaveBeenCalledTimes( + 1, + ); + }); + it('navigates back when the back button is pressed', () => { const { getByTestId } = render(); @@ -374,11 +391,4 @@ describe('CampaignsView', () => { expect(queryByText('Refreshing...')).toBeNull(); }); }); - - describe('hook integration', () => { - it('calls useOndoOutcomeToast on render', () => { - render(); - expect(mockUseOndoOutcomeToast).toHaveBeenCalledTimes(1); - }); - }); }); diff --git a/app/components/UI/Rewards/Views/CampaignsView.tsx b/app/components/UI/Rewards/Views/CampaignsView.tsx index b4b89d1ec1d..14e5324ba17 100644 --- a/app/components/UI/Rewards/Views/CampaignsView.tsx +++ b/app/components/UI/Rewards/Views/CampaignsView.tsx @@ -16,11 +16,12 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import ErrorBoundary from '../../../Views/ErrorBoundary'; import HeaderCompactStandard from '../../../../component-library/components-temp/HeaderCompactStandard'; import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; -import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; import RewardsErrorBanner from '../components/RewardsErrorBanner'; import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; import CampaignsGroup from '../components/Campaigns/CampaignsGroup'; import { strings } from '../../../../../locales/i18n'; +import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; +import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast'; /** * CampaignsView displays all campaigns organized by status: @@ -31,9 +32,10 @@ import { strings } from '../../../../../locales/i18n'; const CampaignsView: React.FC = () => { const tw = useTailwind(); const navigation = useNavigation(); - useOndoOutcomeToast(); const { categorizedCampaigns, isLoading, hasError, fetchCampaigns } = useRewardCampaigns(); + useOndoOutcomeToast(); + usePerpsTradingCampaignEndedOutcomeToast(); useTrackRewardsPageView({ page_type: 'campaigns_overview' }); diff --git a/app/components/UI/Rewards/Views/OndoCampaignStatsView.test.tsx b/app/components/UI/Rewards/Views/OndoCampaignStatsView.test.tsx index 0a84edb2cbd..d1d1915730b 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignStatsView.test.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignStatsView.test.tsx @@ -445,7 +445,9 @@ describe('OndoCampaignStatsView', () => { hasError: false, }); const { getByText } = render(); - const title = getByText('rewards.ondo_outcome_banner.winner_pending.title'); + const title = getByText( + 'rewards.campaign_outcome_banner.winner_pending.title', + ); fireEvent.press(title); expect(mockNavigate).toHaveBeenCalledWith( Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, @@ -475,7 +477,7 @@ describe('OndoCampaignStatsView', () => { }); const { queryByText } = render(); expect( - queryByText('rewards.ondo_outcome_banner.winner_pending.title'), + queryByText('rewards.campaign_outcome_banner.winner_pending.title'), ).toBeNull(); }); @@ -1024,7 +1026,9 @@ describe('OndoCampaignStatsView', () => { hasError: false, }); const { getByText } = render(); - const title = getByText('rewards.ondo_outcome_banner.winner_pending.title'); + const title = getByText( + 'rewards.campaign_outcome_banner.winner_pending.title', + ); fireEvent.press(title); expect(mockNavigate).toHaveBeenCalledWith( Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, diff --git a/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx b/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx index b17b28efa50..520d10dfafd 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignStatsView.tsx @@ -14,7 +14,7 @@ import { TextColor, TextVariant, } from '@metamask/design-system-react-native'; -import { OndoGmCampaignOutcomeBanner } from '../components/Campaigns/OndoCampaignOutcomeBanners'; +import { CampaignOutcomeBanner } from '../components/Campaigns/CampaignOutcomeBanners'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { SafeAreaView } from 'react-native-safe-area-context'; import ErrorBoundary from '../../../Views/ErrorBoundary'; @@ -284,7 +284,7 @@ const OndoCampaignStatsView: React.FC = () => { {/* ── Outcome banner (campaign ended) ── */} {isCampaignComplete && participantOutcome && ( - ({ - __esModule: true, - default: 1, -})); - -const mockGoBack = jest.fn(); - -const mockNavigate = jest.fn(); - -jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate }), - useRoute: () => ({ - params: { campaignId: 'campaign-ondo-1', campaignName: 'Ondo Campaign' }, - }), -})); - -jest.mock('@metamask/design-system-twrnc-preset', () => { - const tw = (...args: unknown[]) => args; - tw.style = (...args: unknown[]) => args; - return { useTailwind: () => tw }; -}); - -jest.mock('react-native-safe-area-context', () => { - const actual = jest.requireActual('react-native-safe-area-context'); - return { - ...actual, - useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }), - }; -}); - -jest.mock('../hooks/useOndoCampaignParticipantOutcome', () => ({ - useOndoCampaignParticipantOutcome: jest.fn(), -})); - -const mockUseOndoCampaignParticipantOutcome = - useOndoCampaignParticipantOutcome as jest.MockedFunction< - typeof useOndoCampaignParticipantOutcome - >; - -jest.mock('../../../Views/ErrorBoundary', () => { +jest.mock('./CampaignWinningView', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); return { __esModule: true, - default: ({ children }: { children: React.ReactNode }) => - ReactActual.createElement(View, null, children), + default: jest.fn(({ testID }: { testID: string }) => + ReactActual.createElement(View, { testID }), + ), }; }); -jest.mock('../hooks/useTrackRewardsPageView', () => ({ - __esModule: true, - default: jest.fn(), -})); - -jest.mock('../../../../core/Analytics', () => ({ - MetaMetricsEvents: { - REWARDS_PAGE_BUTTON_CLICKED: 'REWARDS_PAGE_BUTTON_CLICKED', - }, -})); - -jest.mock('../utils', () => ({ - RewardsMetricsButtons: { - COPY_REFERRAL_CODE: 'copy_referral_code', - }, -})); - -const mockTrackEvent = jest.fn(); -const mockBuild = jest.fn(() => ({})); -jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ - useAnalytics: () => ({ - trackEvent: mockTrackEvent, - createEventBuilder: () => ({ - addProperties: () => ({ build: mockBuild }), - }), - }), +jest.mock('../hooks/useOndoCampaignParticipantOutcome', () => ({ + useOndoCampaignParticipantOutcome: jest.fn(), })); -const mockPosition = { - projectedTier: 'MID', - rank: 3, - totalInTier: 100, - rateOfReturn: 0.2823, - currentUsdValue: 2000, - totalUsdDeposited: 1000, - netDeposit: 900, - qualifiedDays: 10, - qualified: true, - neighbors: [], - computedAt: '2024-01-01T00:00:00.000Z', -}; - jest.mock('../hooks/useGetOndoLeaderboardPosition', () => ({ useGetOndoLeaderboardPosition: jest.fn(), })); -const mockUseGetOndoLeaderboardPosition = - useGetOndoLeaderboardPosition as jest.MockedFunction< - typeof useGetOndoLeaderboardPosition - >; +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: jest.fn(), navigate: jest.fn() }), + useRoute: () => ({ + params: { campaignId: 'campaign-ondo-1', campaignName: 'Ondo Campaign' }, + }), +})); -jest.mock('../components/ReferralDetails/CopyableField', () => { - const ReactActual = jest.requireActual('react'); - const { View, Text, Pressable } = jest.requireActual('react-native'); - return { - __esModule: true, - default: ({ - label, - value, - onCopy, - }: { - label: string; - value?: string | null; - onCopy?: () => void; - }) => - ReactActual.createElement( - View, - { testID: 'copyable-field' }, - ReactActual.createElement(Text, null, label), - ReactActual.createElement( - Text, - { testID: 'copyable-value' }, - value ?? '', - ), - ReactActual.createElement(Pressable, { - testID: 'copyable-trigger', - onPress: onCopy, - }), - ), - }; +jest.mock('@metamask/design-system-twrnc-preset', () => { + const tw = (...args: unknown[]) => args; + tw.style = (...args: unknown[]) => args; + return { useTailwind: () => tw }; }); -jest.mock('../../../../../locales/i18n', () => ({ - strings: jest.fn( - (key: string, params?: { place?: string; code?: string }) => { - const map: Record = { - 'rewards.ondo_campaign_winning.you_won': 'You won', - 'rewards.ondo_campaign_winning.email_instructions': - 'Email ondocampaign@consensys.net with your code to claim your prize.', - 'rewards.ondo_campaign_winning.open_mail': 'Open mail', - 'rewards.ondo_campaign_winning.skip_for_now': 'Skip for now', - 'rewards.ondo_campaign_winning.mail_subject': - 'Ondo campaign prize claim', - 'rewards.ondo_campaign_winning.mail_body': `My winning code: ${params?.code ?? ''}`, - 'rewards.ondo_campaign_winning.winning_code': 'Winning code', - 'rewards.ondo_campaign_winning.close_a11y': 'Close', - 'rewards.ondo_campaign_winning.error_title': - 'Could not load your winning code', - 'rewards.ondo_campaign_winning.error_description': - 'Something went wrong while fetching your code. Please try again later or contact support.', - 'rewards.ondo_campaign_winning.error_retry': 'Try again', - }; - if (key === 'rewards.ondo_campaign_winning.rank_label' && params?.place) { - return `${params.place} place`; - } - return map[key] ?? key; - }, - ), -})); +const mockUseOutcome = useOndoCampaignParticipantOutcome as jest.MockedFunction< + typeof useOndoCampaignParticipantOutcome +>; +const mockUsePosition = useGetOndoLeaderboardPosition as jest.MockedFunction< + typeof useGetOndoLeaderboardPosition +>; +const mockCampaignWinningView = CampaignWinningView as jest.MockedFunction< + typeof CampaignWinningView +>; describe('OndoCampaignWinningView', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseGetOndoLeaderboardPosition.mockReturnValue({ - position: mockPosition, - isLoading: false, - hasError: false, - hasFetched: true, - refetch: jest.fn(), - }); - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ + mockUseOutcome.mockReturnValue({ outcome: { subscriptionId: 'sub-1', outcomeStatus: 'pending', - winnerVerificationCode: 'LVL346', + winnerVerificationCode: 'ONDO-WIN-99', }, isLoading: false, hasError: false, }); + mockUsePosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); }); - it('renders the main container', () => { + it('renders the container with the Ondo testID', () => { const { getByTestId } = render(); expect( getByTestId(ONDO_CAMPAIGN_WINNING_VIEW_TEST_IDS.CONTAINER), ).toBeTruthy(); }); - it('shows you won, rank place, and rate from leaderboard position', () => { - const { getByText } = render(); - expect(getByText('You won')).toBeTruthy(); - expect(getByText('3rd place')).toBeTruthy(); - expect(getByText('+28.23%')).toBeTruthy(); - }); - - it('calls goBack when Skip for now is pressed', () => { - const { getByText } = render(); - fireEvent.press(getByText('Skip for now')); - expect(mockGoBack).toHaveBeenCalledTimes(1); - }); - - it('calls goBack when close is pressed', () => { - const { getByLabelText } = render(); - fireEvent.press(getByLabelText('Close')); - expect(mockGoBack).toHaveBeenCalledTimes(1); - }); - - it('copies referral code and tracks analytics when copy is triggered', () => { - const setStringSpy = jest.spyOn(Clipboard, 'setString'); - const { getByTestId } = render(); - fireEvent.press(getByTestId('copyable-trigger')); - expect(setStringSpy).toHaveBeenCalledWith('LVL346'); - expect(mockTrackEvent).toHaveBeenCalled(); - }); - - it('opens mailto when Open mail is pressed', async () => { - const openSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined); - const { getByText } = render(); - fireEvent.press(getByText('Open mail')); - expect(openSpy).toHaveBeenCalled(); - const url = openSpy.mock.calls[0][0] as string; - expect(url).toContain('mailto:ondocampaign@consensys.net'); - expect(url).toContain(encodeURIComponent('LVL346')); - openSpy.mockRestore(); - }); - - describe('auto-redirect when user is not a winner', () => { - it('navigates to details view when outcome loaded but has no winner code', () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome: { - subscriptionId: 'sub-1', - outcomeStatus: 'pending', - winnerVerificationCode: null, - }, - isLoading: false, - hasError: false, - }); - render(); - expect(mockNavigate).toHaveBeenCalledWith( - Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, - { campaignId: 'campaign-ondo-1' }, - ); - }); - - it('does not navigate while outcome is still loading', () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome: null, - isLoading: true, - hasError: false, - }); - render(); - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it('does not navigate when outcome is null after load', () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome: null, - isLoading: false, - hasError: false, - }); - render(); - expect(mockNavigate).not.toHaveBeenCalled(); - }); - }); - - describe('loading states', () => { - it('shows CopyableField once winning code has loaded', () => { - const { getByTestId } = render(); - expect(getByTestId('copyable-field')).toBeTruthy(); - }); - - it('shows the primary CTA in loading state while outcome is loading', () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome: null, - isLoading: true, - hasError: false, - }); - const openSpy = jest - .spyOn(Linking, 'openURL') - .mockResolvedValue(undefined); - const { getByText } = render(); - fireEvent.press(getByText('Open mail')); - expect(openSpy).not.toHaveBeenCalled(); - openSpy.mockRestore(); - }); - - it('does not show the primary CTA in loading state once code has loaded', () => { - const openSpy = jest - .spyOn(Linking, 'openURL') - .mockResolvedValue(undefined); - const { getByText } = render(); - fireEvent.press(getByText('Open mail')); - expect(openSpy).toHaveBeenCalledTimes(1); - openSpy.mockRestore(); - }); - - it('hides rank and rate text while position is loading', () => { - mockUseGetOndoLeaderboardPosition.mockReturnValue({ - position: null, - isLoading: true, - hasError: false, - hasFetched: false, - refetch: jest.fn(), - }); - const { queryByText } = render(); - expect(queryByText('3rd place')).toBeNull(); - expect(queryByText('+28.23%')).toBeNull(); - }); - }); - - describe('error states', () => { - it('hides the rank/rate section entirely when position fails to load', () => { - mockUseGetOndoLeaderboardPosition.mockReturnValue({ - position: null, + it('passes correct Ondo-specific props to CampaignWinningView', () => { + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + testID: ONDO_CAMPAIGN_WINNING_VIEW_TEST_IDS.CONTAINER, + prizeEmail: 'ondocampaign@consensys.net', + campaignName: 'Ondo Campaign', + campaignId: 'campaign-ondo-1', + analyticsPageType: 'ondo_campaign_winning', + winningCode: 'ONDO-WIN-99', + hasOutcomeLoaded: true, isLoading: false, - hasError: true, - hasFetched: true, - refetch: jest.fn(), - }); - const { queryByText } = render(); - expect(queryByText('3rd place')).toBeNull(); - expect(queryByText('+28.23%')).toBeNull(); - }); - - it('hides the rank/rate section when position is null and not loading', () => { - mockUseGetOndoLeaderboardPosition.mockReturnValue({ - position: null, - isLoading: false, - hasError: false, - hasFetched: true, - refetch: jest.fn(), - }); - const { queryByText } = render(); - expect(queryByText('3rd place')).toBeNull(); - expect(queryByText('+28.23%')).toBeNull(); - }); + rankDisplay: null, + resultDisplay: null, + isRankLoading: false, + isResultLoading: false, + fallbackRoute: { + route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: 'campaign-ondo-1' }, + }, + }), + {}, + ); }); - it('does not throw when mailto openURL rejects', async () => { - const openSpy = jest - .spyOn(Linking, 'openURL') - .mockRejectedValue(new Error('no mail app')); - const { getByText } = render(); - await act(async () => { - fireEvent.press(getByText('Open mail')); + it('passes winningCode as null when outcome has no code', () => { + mockUseOutcome.mockReturnValue({ + outcome: { + subscriptionId: 'sub-1', + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }, + isLoading: false, + hasError: false, }); - expect(openSpy).toHaveBeenCalled(); - openSpy.mockRestore(); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + winningCode: null, + hasOutcomeLoaded: true, + }), + {}, + ); }); - describe('mailto URL construction', () => { - it('appends the winning code to the mail subject', async () => { - const openSpy = jest - .spyOn(Linking, 'openURL') - .mockResolvedValue(undefined); - const { getByText } = render(); - fireEvent.press(getByText('Open mail')); - const url = openSpy.mock.calls[0][0] as string; - expect(url).toContain( - encodeURIComponent('Ondo campaign prize claim - LVL346'), - ); - openSpy.mockRestore(); - }); - - it('uses base subject without code when winningCode is null', async () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome: { - subscriptionId: 'sub-1', - outcomeStatus: 'pending', - winnerVerificationCode: null, - }, - isLoading: false, - hasError: false, - }); - const openSpy = jest - .spyOn(Linking, 'openURL') - .mockResolvedValue(undefined); - const { getByText } = render(); - fireEvent.press(getByText('Open mail')); - const url = openSpy.mock.calls[0][0] as string; - expect(url).toContain(encodeURIComponent('Ondo campaign prize claim')); - expect(url).not.toContain(' - '); - openSpy.mockRestore(); + it('does not mark outcome as loaded until the outcome exists', () => { + mockUseOutcome.mockReturnValue({ + outcome: null, + isLoading: false, + hasError: false, }); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + winningCode: null, + hasOutcomeLoaded: false, + }), + {}, + ); }); - it('does not copy to clipboard when winning code is null', () => { - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ + it('passes rank and result display when position is available', () => { + mockUseOutcome.mockReturnValue({ outcome: { subscriptionId: 'sub-1', outcomeStatus: 'pending', - winnerVerificationCode: null, + winnerVerificationCode: 'ONDO-WIN-99', + tierRank: 3, }, isLoading: false, hasError: false, }); - const setStringSpy = jest.spyOn(Clipboard, 'setString'); - const { getByTestId } = render(); - fireEvent.press(getByTestId('copyable-trigger')); - expect(setStringSpy).not.toHaveBeenCalled(); + mockUsePosition.mockReturnValue({ + position: { rank: 9, rateOfReturn: 0.1234 } as never, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + rankDisplay: '3rd', + resultDisplay: '+12.34%', + isRankLoading: false, + isResultLoading: false, + }), + {}, + ); }); }); diff --git a/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx b/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx index 59b3434babe..7e40d530493 100644 --- a/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx +++ b/app/components/UI/Rewards/Views/OndoCampaignWinningView.tsx @@ -1,45 +1,13 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; -import { Image, Linking, ScrollView, StyleSheet } from 'react-native'; -import Clipboard from '@react-native-clipboard/clipboard'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { - SafeAreaView, - useSafeAreaInsets, -} from 'react-native-safe-area-context'; -import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import { - Box, - BoxFlexDirection, - Button, - ButtonSize, - ButtonVariant, - ButtonIcon, - ButtonIconSize, - IconName, - Skeleton, - Text, - TextColor, - TextVariant, -} from '@metamask/design-system-react-native'; -import ErrorBoundary from '../../../Views/ErrorBoundary'; -import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; +import React, { useMemo } from 'react'; +import { useRoute, RouteProp } from '@react-navigation/native'; import { useOndoCampaignParticipantOutcome } from '../hooks/useOndoCampaignParticipantOutcome'; -import Routes from '../../../../constants/navigation/Routes'; -import { strings } from '../../../../../locales/i18n'; -import CopyableField from '../components/ReferralDetails/CopyableField'; import { formatOrdinalRank, formatPercentChange } from '../utils/formatUtils'; -import { RewardsMetricsButtons } from '../utils'; -import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; -import { MetaMetricsEvents } from '../../../../core/Analytics'; import { useGetOndoLeaderboardPosition } from '../hooks/useGetOndoLeaderboardPosition'; -import campaignWinningHero from '../../../../images/rewards/campaign_winning.png'; +import CampaignWinningView from './CampaignWinningView'; +import Routes from '../../../../constants/navigation/Routes'; const PRIZE_EMAIL = 'ondocampaign@consensys.net'; -const styles = StyleSheet.create({ - heroBox: { aspectRatio: 1 }, -}); - // ParamListBase requires an index signature, which interfaces don't support // eslint-disable-next-line @typescript-eslint/consistent-type-definitions type OndoCampaignWinningRouteParams = { @@ -51,15 +19,11 @@ export const ONDO_CAMPAIGN_WINNING_VIEW_TEST_IDS = { } as const; const OndoCampaignWinningView: React.FC = () => { - const tw = useTailwind(); - const insets = useSafeAreaInsets(); - const navigation = useNavigation(); - const { trackEvent, createEventBuilder } = useAnalytics(); const route = useRoute< RouteProp >(); - const { campaignId } = route.params; + const { campaignId, campaignName = '' } = route.params; const { position, isLoading: positionLoading } = useGetOndoLeaderboardPosition(campaignId); @@ -68,186 +32,41 @@ const OndoCampaignWinningView: React.FC = () => { useOndoCampaignParticipantOutcome(campaignId); const winningCode = outcome?.winnerVerificationCode ?? null; - useEffect(() => { - if (!isOutcomeLoading && outcome && !winningCode) { - navigation.navigate(Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, { - campaignId, - }); - } - }, [isOutcomeLoading, outcome, winningCode, campaignId, navigation]); - - useTrackRewardsPageView({ - page_type: 'ondo_campaign_winning', - campaign_id: campaignId, - }); - - const onDismiss = () => navigation.goBack(); - - const handleCopyWinningCode = useCallback(() => { - if (winningCode) { - Clipboard.setString(winningCode); - trackEvent( - createEventBuilder(MetaMetricsEvents.REWARDS_PAGE_BUTTON_CLICKED) - .addProperties({ - button_type: RewardsMetricsButtons.COPY_REFERRAL_CODE, - }) - .build(), - ); - } - }, [winningCode, trackEvent, createEventBuilder]); - - const handleOpenMail = useCallback(async () => { - const baseSubject = strings('rewards.ondo_campaign_winning.mail_subject'); - const subject = winningCode - ? `${baseSubject} - ${winningCode}` - : baseSubject; - const body = strings('rewards.ondo_campaign_winning.mail_body', { - code: winningCode || '—', - }); - const url = `mailto:${PRIZE_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; - try { - await Linking.openURL(url); - } catch { - // no-op: device may not have a mail handler - } - }, [winningCode]); - const rankDisplay = useMemo(() => { - if (!position) return null; - return strings('rewards.ondo_campaign_winning.rank_label', { - place: formatOrdinalRank(position.rank), - }); - }, [position]); + if (!outcome?.tierRank) return null; + return formatOrdinalRank(outcome.tierRank); + }, [outcome]); - const rateDisplay = useMemo(() => { + const resultDisplay = useMemo(() => { if (!position) return null; return formatPercentChange(position.rateOfReturn); }, [position]); - return ( - - - - - - - - - - - - - {strings('rewards.ondo_campaign_winning.you_won')} - - - {(positionLoading || position) && ( - - {rankDisplay !== null ? ( - - {rankDisplay} - - ) : ( - - )} - - {rateDisplay !== null ? ( - - {rateDisplay} - - ) : ( - - )} - - )} - - - {strings('rewards.ondo_campaign_winning.email_instructions')} - - - - - - - - - + const fallbackRoute = useMemo( + () => ({ + route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, + params: { campaignId }, + }), + [campaignId], + ); - - - - - + return ( + ); }; diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx index 54fe6b66bee..5ab6a0e70aa 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.test.tsx @@ -2,18 +2,21 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import PerpsTradingCampaignDetailsView, { PERPS_CAMPAIGN_DETAILS_TEST_IDS, + resetPerpsTradingCampaignDetailsSessionAutoNavigationForTests, } from './PerpsTradingCampaignDetailsView'; import { type CampaignDto, CampaignType, type PerpsTradingCampaignLeaderboardEntry, type PerpsTradingCampaignLeaderboardPositionDto, + type PerpsTradingCampaignParticipantOutcomeDto, } from '../../../../core/Engine/controllers/rewards-controller/types'; import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard'; import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; import { useGetPerpsTradingCampaignVolume } from '../hooks/useGetPerpsTradingCampaignVolume'; +import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; import Routes from '../../../../constants/navigation/Routes'; const mockGoBack = jest.fn(); @@ -24,6 +27,7 @@ const mockRouteState: { params: { campaignId?: string } } = { }; jest.mock('@react-navigation/native', () => ({ + useFocusEffect: (callback: () => void) => callback(), useNavigation: () => ({ goBack: mockGoBack, navigate: mockNavigate, @@ -135,15 +139,61 @@ jest.mock('../components/Campaigns/CampaignHowItWorks', () => { }; }); +jest.mock('../components/Campaigns/CampaignOutcomeBanners', () => { + const ReactActual = jest.requireActual('react'); + const { Pressable, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + CampaignOutcomeBanner: ({ + outcomeStatus, + winnerVerificationCode, + onWinnerPress, + }: { + outcomeStatus: string; + winnerVerificationCode: string | null | undefined; + onWinnerPress: () => void; + }) => + ReactActual.createElement( + Pressable, + { + testID: `campaign-outcome-banner-${outcomeStatus}-${winnerVerificationCode ?? 'null'}`, + onPress: onWinnerPress, + }, + ReactActual.createElement(Text, null, 'Campaign outcome'), + ), + }; +}); + jest.mock('../components/Campaigns/PerpsCampaignStatsSummary', () => { const ReactActual = jest.requireActual('react'); - const { View } = jest.requireActual('react-native'); + const { Pressable, Text, View } = jest.requireActual('react-native'); return { __esModule: true, - default: () => - ReactActual.createElement(View, { - testID: 'perps-campaign-stats-summary-container', - }), + default: ({ + outcomeStatus, + winnerVerificationCode, + onWinnerPress, + }: { + outcomeStatus?: string; + winnerVerificationCode?: string | null; + onWinnerPress?: () => void; + }) => + ReactActual.createElement( + View, + { + testID: 'perps-campaign-stats-summary-container', + }, + outcomeStatus && + onWinnerPress && + ReactActual.createElement( + Pressable, + { + testID: `campaign-outcome-banner-${outcomeStatus}-${winnerVerificationCode ?? 'null'}`, + onPress: onWinnerPress, + }, + ReactActual.createElement(Text, null, 'Campaign outcome'), + ), + ), }; }); @@ -157,6 +207,18 @@ jest.mock('../components/Campaigns/PerpsTradingCampaignPrizePool', () => { }; }); +jest.mock('../components/Campaigns/PerpsTradingCampaignEndedStats', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: () => + ReactActual.createElement(View, { + testID: 'perps-campaign-ended-stats', + }), + }; +}); + jest.mock('../components/Campaigns/PerpsTradingCampaignLeaderboard', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); @@ -247,6 +309,12 @@ const mockUseGetPerpsTradingCampaignVolume = typeof useGetPerpsTradingCampaignVolume >; +jest.mock('../hooks/usePerpsTradingCampaignParticipantOutcome'); +const mockUsePerpsTradingCampaignParticipantOutcome = + usePerpsTradingCampaignParticipantOutcome as jest.MockedFunction< + typeof usePerpsTradingCampaignParticipantOutcome + >; + import { useSelector } from 'react-redux'; import { selectReferralCode } from '../../../../reducers/rewards/selectors'; @@ -316,7 +384,9 @@ function setupHooks( hasCampaignsError?: boolean; participant?: { optedIn: boolean }; position?: { rank: number; neighbors: unknown[] } | null; + isPositionLoading?: boolean; totalParticipants?: number; + outcome?: PerpsTradingCampaignParticipantOutcomeDto | null; } = {}, ) { const { @@ -325,7 +395,9 @@ function setupHooks( hasCampaignsError = false, participant = { optedIn: false }, position = null, + isPositionLoading = false, totalParticipants: totalParticipantsOverride, + outcome = null, } = overrides; mockUseRewardCampaigns.mockReturnValue({ @@ -361,7 +433,7 @@ function setupHooks( mockUseGetPerpsTradingCampaignLeaderboardPosition.mockReturnValue({ position: toMockLeaderboardPosition(position), - isLoading: false, + isLoading: isPositionLoading, hasError: false, hasFetched: true, refetch: jest.fn(), @@ -370,6 +442,12 @@ function setupHooks( mockUseGetPerpsTradingCampaignVolume.mockReturnValue({ ...defaultVolumeHook, } as ReturnType); + + mockUsePerpsTradingCampaignParticipantOutcome.mockReturnValue({ + outcome, + isLoading: false, + hasError: false, + } as ReturnType); } jest.mock('../../../../../locales/i18n', () => ({ @@ -398,6 +476,7 @@ describe('PerpsTradingCampaignDetailsView', () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2025-08-15T12:00:00.000Z')); jest.clearAllMocks(); + resetPerpsTradingCampaignDetailsSessionAutoNavigationForTests(); mockRouteState.params = { campaignId: 'perps-campaign-1' }; mockUseSelector.mockImplementation((selector) => { if (selector === selectReferralCode) { @@ -434,10 +513,10 @@ describe('PerpsTradingCampaignDetailsView', () => { expect(mockFetchCampaigns).toHaveBeenCalledTimes(1); }); - it('renders header, campaign status, prize pool, leaderboard, and CTA for active campaign', () => { - const { getByTestId, getByText } = render( - , - ); + it('renders header, campaign status, prize pool, leaderboard, and CTA for active opted-in campaign', () => { + setupHooks({ participant: { optedIn: true } }); + + const { getByTestId } = render(); expect( getByTestId(PERPS_CAMPAIGN_DETAILS_TEST_IDS.CONTAINER), @@ -449,7 +528,12 @@ describe('PerpsTradingCampaignDetailsView', () => { expect(getByTestId('perps-trading-cta')).toBeDefined(); }); - it('hides How it works when the user has a leaderboard position', () => { + it('shows the prize pool section for active non-opted-in users', () => { + const { getByTestId } = render(); + expect(getByTestId('perps-prize-pool')).toBeDefined(); + }); + + it('hides How it works when the user is opted in and has a leaderboard position', () => { setupHooks({ campaigns: [ buildPerpsCampaign({ @@ -470,7 +554,50 @@ describe('PerpsTradingCampaignDetailsView', () => { expect(queryByTestId('campaign-how-it-works')).toBeNull(); }); - it('shows How it works when active, user has no leaderboard position, and details include howItWorks', () => { + it('shows How it works when opted in, no position, and position not loading', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + details: { + howItWorks: { + title: 'How it works', + description: 'Test description', + steps: [], + }, + }, + }), + ], + participant: { optedIn: true }, + position: null, + }); + + const { getByTestId } = render(); + expect(getByTestId('campaign-how-it-works')).toBeDefined(); + }); + + it('hides How it works while the leaderboard position is still loading for an opted-in user', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + details: { + howItWorks: { + title: 'How it works', + description: 'Test description', + steps: [], + }, + }, + }), + ], + participant: { optedIn: true }, + position: null, + isPositionLoading: true, + }); + + const { queryByTestId } = render(); + expect(queryByTestId('campaign-how-it-works')).toBeNull(); + }); + + it('shows How it works when active, user is not opted in, and details include howItWorks', () => { setupHooks({ campaigns: [ buildPerpsCampaign({ @@ -532,7 +659,7 @@ describe('PerpsTradingCampaignDetailsView', () => { ); }); - it('complete campaign shows leaderboard without stats row and hides CTA', () => { + it('complete campaign for non-opted-in user shows leaderboard, prize pool, and ended stats and hides CTA', () => { setupHooks({ campaigns: [ buildPerpsCampaign({ @@ -548,10 +675,149 @@ describe('PerpsTradingCampaignDetailsView', () => { expect(getByTestId('perps-leaderboard')).toBeDefined(); expect(queryByTestId('perps-campaign-stats-summary-container')).toBeNull(); - expect(queryByTestId('perps-prize-pool')).toBeNull(); + expect(getByTestId('perps-prize-pool')).toBeDefined(); + expect(getByTestId('perps-campaign-ended-stats')).toBeDefined(); expect(queryByTestId('perps-trading-cta')).toBeNull(); }); + it('complete campaign for opted-in user (no leaderboard position) shows ended stats and prize pool', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + participant: { optedIn: true }, + position: null, + }); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('perps-campaign-ended-stats')).toBeDefined(); + expect(getByTestId('perps-prize-pool')).toBeDefined(); + expect(queryByTestId('perps-campaign-stats-summary-container')).toBeNull(); + }); + + it('shows outcome banner for completed opted-in participants and navigates winners to winning view', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + participant: { optedIn: true }, + position: { rank: 3, neighbors: [] }, + outcome: { + subscriptionId: 'subscription-id', + outcomeStatus: 'pending', + winnerVerificationCode: 'PERPS-WINNER-123', + rank: 3, + }, + }); + + const { getByTestId } = render(); + + fireEvent.press( + getByTestId('campaign-outcome-banner-pending-PERPS-WINNER-123'), + ); + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, + { + campaignId: 'perps-campaign-1', + campaignName: 'Perps Trading', + }, + ); + }); + + it('auto-navigates once to winning view for a completed pending winner outcome', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + participant: { optedIn: true }, + position: { rank: 3, neighbors: [] }, + outcome: { + subscriptionId: 'subscription-id', + outcomeStatus: 'pending', + winnerVerificationCode: 'PERPS-WINNER-123', + rank: 3, + }, + }); + + const { rerender } = render(); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, + { + campaignId: 'perps-campaign-1', + campaignName: 'Perps Trading', + }, + ); + + mockNavigate.mockClear(); + rerender(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('does not auto-navigate for finalized outcomes', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + participant: { optedIn: true }, + position: { rank: 3, neighbors: [] }, + outcome: { + subscriptionId: 'subscription-id', + outcomeStatus: 'finalized', + winnerVerificationCode: 'PERPS-WINNER-123', + rank: 3, + }, + }); + + render(); + + expect(mockNavigate).not.toHaveBeenCalledWith( + Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, + expect.any(Object), + ); + }); + + it('shows outcome banner inside the ended stats section for opted-in users with no leaderboard position', () => { + setupHooks({ + campaigns: [ + buildPerpsCampaign({ + startDate: '2024-01-01T00:00:00.000Z', + endDate: '2025-01-01T00:00:00.000Z', + }), + ], + participant: { optedIn: true }, + position: null, + outcome: { + subscriptionId: 'subscription-id', + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }, + }); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(queryByTestId('perps-campaign-stats-summary-container')).toBeNull(); + expect(getByTestId('perps-campaign-ended-stats')).toBeDefined(); + expect(getByTestId('campaign-outcome-banner-finalized-null')).toBeDefined(); + }); + it('displays total participant count when the leaderboard reports participants', () => { setupHooks({ totalParticipants: 1500 }); diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx index 0310b69d8ea..14032c9d1db 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignDetailsView.tsx @@ -1,6 +1,11 @@ import React, { useCallback, useMemo } from 'react'; import { Pressable, ScrollView } from 'react-native'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { + useFocusEffect, + useNavigation, + useRoute, + RouteProp, +} from '@react-navigation/native'; import { useSelector } from 'react-redux'; import { Box, @@ -28,11 +33,14 @@ import PerpsTradingCampaignLeaderboard, { import PerpsTradingCampaignPrizePool from '../components/Campaigns/PerpsTradingCampaignPrizePool'; import PerpsTradingCampaignCTA from '../components/Campaigns/PerpsTradingCampaignCTA'; import PerpsCampaignStatsSummary from '../components/Campaigns/PerpsCampaignStatsSummary'; +import PerpsTradingCampaignEndedStats from '../components/Campaigns/PerpsTradingCampaignEndedStats'; +import { CampaignOutcomeBanner } from '../components/Campaigns/CampaignOutcomeBanners'; import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; import { useGetPerpsTradingCampaignLeaderboard } from '../hooks/useGetPerpsTradingCampaignLeaderboard'; import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; import { useGetPerpsTradingCampaignVolume } from '../hooks/useGetPerpsTradingCampaignVolume'; +import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; import { useRewardCampaigns } from '../hooks/useRewardCampaigns'; import { strings } from '../../../../../locales/i18n'; import Routes from '../../../../constants/navigation/Routes'; @@ -53,6 +61,11 @@ export const PERPS_CAMPAIGN_DETAILS_TEST_IDS = { CONTAINER: 'perps-campaign-details-container', } as const; +const sessionWinningViewAutoNavCampaignIds = new Set(); +export function resetPerpsTradingCampaignDetailsSessionAutoNavigationForTests(): void { + sessionWinningViewAutoNavCampaignIds.clear(); +} + const PerpsTradingCampaignDetailsView: React.FC = () => { const tw = useTailwind(); const navigation = useNavigation(); @@ -103,9 +116,14 @@ const PerpsTradingCampaignDetailsView: React.FC = () => { refetch: refetchLeaderboard, } = useGetPerpsTradingCampaignLeaderboard(effectiveCampaignId || undefined); - const { position } = useGetPerpsTradingCampaignLeaderboardPosition( - isOptedIn ? effectiveCampaignId || undefined : undefined, - ); + const { position, isLoading: isPositionLoading } = + useGetPerpsTradingCampaignLeaderboardPosition( + isOptedIn ? effectiveCampaignId || undefined : undefined, + ); + const { outcome: participantOutcome } = + usePerpsTradingCampaignParticipantOutcome( + isComplete && isOptedIn ? effectiveCampaignId || undefined : undefined, + ); const { volume, @@ -130,6 +148,7 @@ const PerpsTradingCampaignDetailsView: React.FC = () => { showStatsSummarySection, showPrizePoolSection, showLeaderboardSection, + showCampaignEndedStats, } = useMemo(() => { if (!campaign) { return { @@ -137,17 +156,32 @@ const PerpsTradingCampaignDetailsView: React.FC = () => { showStatsSummarySection: false, showPrizePoolSection: false, showLeaderboardSection: false, + showCampaignEndedStats: false, }; } + const showEndedStats = + isComplete && !isParticipantStatusLoading && (!isOptedIn || !hasPosition); + return { showHowItWorksSection: - Boolean(campaign.details?.howItWorks) && isActive && !hasPosition, + Boolean(campaign.details?.howItWorks) && + isActive && + (!isOptedIn || (!hasPosition && !isPositionLoading)), showStatsSummarySection: hasPosition, - showPrizePoolSection: isActive, + showPrizePoolSection: isActive || isComplete, showLeaderboardSection: true, + showCampaignEndedStats: showEndedStats, }; - }, [campaign, isActive, hasPosition]); + }, [ + campaign, + isActive, + isComplete, + isOptedIn, + isParticipantStatusLoading, + hasPosition, + isPositionLoading, + ]); const navigateToLeaderboard = useCallback(() => { if (!effectiveCampaignId) return; @@ -163,6 +197,36 @@ const PerpsTradingCampaignDetailsView: React.FC = () => { }); }, [navigation, effectiveCampaignId]); + const navigateToWinningView = useCallback(() => { + if (!effectiveCampaignId) return; + navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, { + campaignId: effectiveCampaignId, + campaignName: campaign?.name ?? '', + }); + }, [navigation, effectiveCampaignId, campaign]); + + useFocusEffect( + useCallback(() => { + if ( + !sessionWinningViewAutoNavCampaignIds.has(effectiveCampaignId) && + campaign && + isComplete && + participantOutcome?.winnerVerificationCode && + participantOutcome?.outcomeStatus === 'pending' && + effectiveCampaignId + ) { + sessionWinningViewAutoNavCampaignIds.add(effectiveCampaignId); + navigateToWinningView(); + } + }, [ + campaign, + effectiveCampaignId, + isComplete, + navigateToWinningView, + participantOutcome, + ]), + ); + const navigateToMechanics = useCallback(() => { if (!effectiveCampaignId) return; navigation.navigate(Routes.REWARDS_CAMPAIGN_MECHANICS, { @@ -233,6 +297,30 @@ const PerpsTradingCampaignDetailsView: React.FC = () => { )} + {showCampaignEndedStats && ( + + + {isOptedIn && participantOutcome?.outcomeStatus != null && ( + + )} + + )} + {showStatsSummarySection && ( @@ -258,6 +346,11 @@ const PerpsTradingCampaignDetailsView: React.FC = () => { leaderboardPosition={position} leaderboard={leaderboard} isCampaignComplete={isComplete} + outcomeStatus={participantOutcome?.outcomeStatus} + winnerVerificationCode={ + participantOutcome?.winnerVerificationCode ?? null + } + onWinnerPress={navigateToWinningView} /> )} diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx index a62780a64e2..b601329431d 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignLeaderboardView.tsx @@ -113,6 +113,7 @@ const PerpsTradingCampaignLeaderboardView: React.FC = () => { diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx index 179886d186f..64fbed6e9ad 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.test.tsx @@ -6,6 +6,7 @@ import PerpsTradingCampaignStatsView, { } from './PerpsTradingCampaignStatsView'; import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; import { useGetCampaignParticipantStatus } from '../hooks/useGetCampaignParticipantStatus'; +import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; import { CampaignType, type PerpsTradingCampaignLeaderboardPositionDto, @@ -142,6 +143,13 @@ jest.mock('../components/RewardsErrorBanner', () => { jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboardPosition'); jest.mock('../hooks/useGetCampaignParticipantStatus'); +jest.mock('../hooks/usePerpsTradingCampaignParticipantOutcome', () => ({ + usePerpsTradingCampaignParticipantOutcome: jest.fn(() => ({ + outcome: null, + isLoading: false, + hasError: false, + })), +})); jest.mock('../../../../../locales/i18n', () => ({ strings: (key: string) => key, @@ -156,6 +164,10 @@ const mockUseGetParticipant = useGetCampaignParticipantStatus as jest.MockedFunction< typeof useGetCampaignParticipantStatus >; +const mockUsePerpsTradingCampaignParticipantOutcome = + usePerpsTradingCampaignParticipantOutcome as jest.MockedFunction< + typeof usePerpsTradingCampaignParticipantOutcome + >; const basePosition: PerpsTradingCampaignLeaderboardPositionDto = { rank: 4, @@ -197,6 +209,11 @@ describe('PerpsTradingCampaignStatsView', () => { hasError: false, refetch: jest.fn(), }); + mockUsePerpsTradingCampaignParticipantOutcome.mockReturnValue({ + outcome: null, + isLoading: false, + hasError: false, + }); mockUseGetPosition.mockReturnValue({ position: basePosition, isLoading: false, @@ -310,6 +327,30 @@ describe('PerpsTradingCampaignStatsView', () => { ).toBeNull(); }); + it('hides volume and margin StatCells when campaign is complete (only PnL remains)', () => { + const completeCampaign = { + ...mockCampaign, + endDate: '2020-01-01T00:00:00Z', + }; + mockUseSelector.mockImplementation((selector: (s: unknown) => unknown) => + selector({ + rewards: { campaigns: [completeCampaign] }, + }), + ); + const { getByTestId, queryByTestId } = render( + , + ); + expect( + getByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_PNL), + ).toBeDefined(); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME), + ).toBeNull(); + expect( + queryByTestId(PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARGIN), + ).toBeNull(); + }); + it('hides qualification cards when campaign is complete and shows last-computed after performance when position exists', () => { const completeCampaign = { ...mockCampaign, diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx index 8e6213ed14d..73df3b431f5 100644 --- a/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignStatsView.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { ScrollView } from 'react-native'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; @@ -35,6 +35,8 @@ import { formatUsd, } from '../utils/formatUtils'; import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; +import { CampaignOutcomeBanner } from '../components/Campaigns/CampaignOutcomeBanners'; +import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions type PerpsTradingCampaignStatsRouteParams = { @@ -113,6 +115,18 @@ const PerpsTradingCampaignStatsView: React.FC = () => { const positionError = hasError && !position; + const { outcome: participantOutcome } = + usePerpsTradingCampaignParticipantOutcome( + isCampaignComplete && isOptedIn ? campaignId : undefined, + ); + + const navigateToWinningView = useCallback(() => { + navigation.navigate(Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, { + campaignId, + campaignName: campaign?.name ?? '', + }); + }, [navigation, campaignId, campaign]); + return ( { isLoading={isLoading} showComputedAt={false} showPnl={false} + isCampaignComplete={isCampaignComplete} /> @@ -164,22 +179,24 @@ const PerpsTradingCampaignStatsView: React.FC = () => { - - : undefined} - testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME} - /> - : undefined} - testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARGIN} - /> - + {!isCampaignComplete && ( + + : undefined} + testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_VOLUME} + /> + : undefined} + testID={PERPS_CAMPAIGN_STATS_VIEW_TEST_IDS.PERFORMANCE_MARGIN} + /> + + )} {showQualifiedCard && ( { )} + {/* ── Outcome banner (campaign ended) ── */} + {isCampaignComplete && participantOutcome && ( + + )} + {/* ── Error banner ── */} {positionError && ( { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + return { + __esModule: true, + default: jest.fn(({ testID }: { testID: string }) => + ReactActual.createElement(View, { testID }), + ), + }; +}); + +jest.mock('../hooks/usePerpsTradingCampaignParticipantOutcome', () => ({ + usePerpsTradingCampaignParticipantOutcome: jest.fn(), +})); + +jest.mock('../hooks/useGetPerpsTradingCampaignLeaderboardPosition', () => ({ + useGetPerpsTradingCampaignLeaderboardPosition: jest.fn(), +})); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: jest.fn(), navigate: jest.fn() }), + useRoute: () => ({ + params: { campaignId: 'campaign-perps-1', campaignName: 'Perps Campaign' }, + }), +})); + +jest.mock('@metamask/design-system-twrnc-preset', () => { + const tw = (...args: unknown[]) => args; + tw.style = (...args: unknown[]) => args; + return { useTailwind: () => tw }; +}); + +const mockUseOutcome = + usePerpsTradingCampaignParticipantOutcome as jest.MockedFunction< + typeof usePerpsTradingCampaignParticipantOutcome + >; +const mockUsePosition = + useGetPerpsTradingCampaignLeaderboardPosition as jest.MockedFunction< + typeof useGetPerpsTradingCampaignLeaderboardPosition + >; +const mockCampaignWinningView = CampaignWinningView as jest.MockedFunction< + typeof CampaignWinningView +>; + +describe('PerpsTradingCampaignWinningView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseOutcome.mockReturnValue({ + outcome: { + subscriptionId: 'sub-1', + outcomeStatus: 'pending', + winnerVerificationCode: 'PERPS-WIN-99', + rank: 3, + }, + isLoading: false, + hasError: false, + }); + mockUsePosition.mockReturnValue({ + position: { + rank: 3, + pnl: 1500.25, + notionalVolume: 30000, + marginDeployed: 1200, + qualified: true, + neighbors: [], + computedAt: '2025-08-15T12:00:00.000Z', + }, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + }); + + it('renders the container with the Perps testID', () => { + const { getByTestId } = render(); + expect( + getByTestId(PERPS_TRADING_CAMPAIGN_WINNING_VIEW_TEST_IDS.CONTAINER), + ).toBeTruthy(); + }); + + it('passes correct Perps-specific props to CampaignWinningView', () => { + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + testID: PERPS_TRADING_CAMPAIGN_WINNING_VIEW_TEST_IDS.CONTAINER, + prizeEmail: 'perpscampaign@consensys.net', + campaignName: 'Perps Campaign', + campaignId: 'campaign-perps-1', + analyticsPageType: 'perps_trading_campaign_winning', + winningCode: 'PERPS-WIN-99', + hasOutcomeLoaded: true, + isLoading: false, + rankDisplay: '3rd', + resultDisplay: '+$1,500.25', + isRankLoading: false, + isResultLoading: false, + fallbackRoute: { + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: 'campaign-perps-1' }, + }, + }), + {}, + ); + }); + + it('passes winningCode as null when outcome has no code', () => { + mockUseOutcome.mockReturnValue({ + outcome: { + subscriptionId: 'sub-1', + outcomeStatus: 'finalized', + winnerVerificationCode: null, + rank: 21, + }, + isLoading: false, + hasError: false, + }); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + winningCode: null, + hasOutcomeLoaded: true, + }), + {}, + ); + }); + + it('does not mark outcome as loaded until the outcome exists', () => { + mockUseOutcome.mockReturnValue({ + outcome: null, + isLoading: false, + hasError: false, + }); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + winningCode: null, + hasOutcomeLoaded: false, + }), + {}, + ); + }); + + it('passes rankDisplay when rank is available', () => { + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + rankDisplay: '3rd', + isRankLoading: false, + isResultLoading: false, + }), + {}, + ); + }); + + it('passes rank from outcome and no result when position is unavailable', () => { + mockUsePosition.mockReturnValue({ + position: null, + isLoading: false, + hasError: false, + hasFetched: true, + refetch: jest.fn(), + }); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + rankDisplay: '3rd', + resultDisplay: null, + isRankLoading: false, + isResultLoading: false, + }), + {}, + ); + }); + + it('does not pass rankDisplay when outcome has no rank', () => { + mockUseOutcome.mockReturnValue({ + outcome: { + subscriptionId: 'sub-1', + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + rank: null, + }, + isLoading: false, + hasError: false, + }); + render(); + expect(mockCampaignWinningView).toHaveBeenCalledWith( + expect.objectContaining({ + rankDisplay: null, + isRankLoading: false, + }), + {}, + ); + }); +}); diff --git a/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx b/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx new file mode 100644 index 00000000000..f2a95fbd700 --- /dev/null +++ b/app/components/UI/Rewards/Views/PerpsTradingCampaignWinningView.tsx @@ -0,0 +1,80 @@ +import React, { useMemo } from 'react'; +import { useRoute, RouteProp } from '@react-navigation/native'; +import { usePerpsTradingCampaignParticipantOutcome } from '../hooks/usePerpsTradingCampaignParticipantOutcome'; +import { formatOrdinalRank, formatSignedUsd } from '../utils/formatUtils'; +import { useGetPerpsTradingCampaignLeaderboardPosition } from '../hooks/useGetPerpsTradingCampaignLeaderboardPosition'; +import CampaignWinningView from './CampaignWinningView'; +import Routes from '../../../../constants/navigation/Routes'; + +const PRIZE_EMAIL = 'perpscampaign@consensys.net'; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type PerpsTradingCampaignWinningRouteParams = { + RewardsPerpsTradingCampaignWinning: { + campaignId: string; + campaignName: string; + }; +}; + +export const PERPS_TRADING_CAMPAIGN_WINNING_VIEW_TEST_IDS = { + CONTAINER: 'perps-trading-campaign-winning-view-container', +} as const; + +const PerpsTradingCampaignWinningView: React.FC = () => { + const route = + useRoute< + RouteProp< + PerpsTradingCampaignWinningRouteParams, + 'RewardsPerpsTradingCampaignWinning' + > + >(); + const { campaignId, campaignName } = route.params; + + const { outcome, isLoading: isOutcomeLoading } = + usePerpsTradingCampaignParticipantOutcome(campaignId); + const winningCode = outcome?.winnerVerificationCode ?? null; + + const { position, isLoading: positionLoading } = + useGetPerpsTradingCampaignLeaderboardPosition(campaignId); + + const rankDisplay = useMemo(() => { + if (!outcome?.rank) { + return null; + } + return formatOrdinalRank(outcome.rank); + }, [outcome]); + + const resultDisplay = useMemo(() => { + if (!position) return null; + return formatSignedUsd(position.pnl); + }, [position]); + + const fallbackRoute = useMemo( + () => ({ + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW, + params: { campaignId }, + }), + [campaignId], + ); + + return ( + + ); +}; + +export default PerpsTradingCampaignWinningView; diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx index 98fb1ab3dfa..27ccd54e53f 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.test.tsx @@ -4,6 +4,8 @@ import { useSelector } from 'react-redux'; import RewardsDashboard from './RewardsDashboard'; import Routes from '../../../../constants/navigation/Routes'; import { REWARDS_VIEW_SELECTORS } from './RewardsView.constants'; +import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; +import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast'; // Mock dependencies jest.mock('react-redux', () => ({ @@ -170,6 +172,10 @@ jest.mock('../hooks/useOndoOutcomeToast', () => ({ useOndoOutcomeToast: jest.fn(), })); +jest.mock('../hooks/usePerpsTradingCampaignEndedOutcomeToast', () => ({ + usePerpsTradingCampaignEndedOutcomeToast: jest.fn(), +})); + // Import mocked hooks import { useRewardOptinSummary } from '../hooks/useRewardOptinSummary'; import { useRewardDashboardModals } from '../hooks/useRewardDashboardModals'; @@ -186,6 +192,13 @@ const mockUseRewardDashboardModals = const mockUseBulkLinkState = useBulkLinkState as jest.MockedFunction< typeof useBulkLinkState >; +const mockUseOndoOutcomeToast = useOndoOutcomeToast as jest.MockedFunction< + typeof useOndoOutcomeToast +>; +const mockUsePerpsTradingCampaignEndedOutcomeToast = + usePerpsTradingCampaignEndedOutcomeToast as jest.MockedFunction< + typeof usePerpsTradingCampaignEndedOutcomeToast + >; describe('RewardsDashboard', () => { const mockShowUnlinkedAccountsModal = jest.fn(); @@ -320,6 +333,15 @@ describe('RewardsDashboard', () => { expect(getByText('Rewards')).toBeTruthy(); }); + it('mounts campaign outcome toast hooks on render', () => { + render(); + + expect(mockUseOndoOutcomeToast).toHaveBeenCalledTimes(1); + expect( + mockUsePerpsTradingCampaignEndedOutcomeToast, + ).toHaveBeenCalledTimes(1); + }); + it('renders all child components', () => { // Act const { getByTestId } = render(); diff --git a/app/components/UI/Rewards/Views/RewardsDashboard.tsx b/app/components/UI/Rewards/Views/RewardsDashboard.tsx index ded32bc79da..3613bd3a6d5 100644 --- a/app/components/UI/Rewards/Views/RewardsDashboard.tsx +++ b/app/components/UI/Rewards/Views/RewardsDashboard.tsx @@ -21,7 +21,6 @@ import { RewardsDashboardModalType, } from '../hooks/useRewardDashboardModals'; import { useBulkLinkState } from '../hooks/useBulkLinkState'; -import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; import { MetaMetricsEvents } from '../../../../core/Analytics'; import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; import useTrackRewardsPageView from '../hooks/useTrackRewardsPageView'; @@ -30,6 +29,8 @@ import CampaignsPreview from '../components/Campaigns/CampaignsPreview'; import EarnRewardsPreview from '../components/EarnRewards/EarnRewardsPreview'; import BenefitsPreview from '../components/Benefits/BenefitsPreview.tsx'; import { ScrollView } from 'react-native'; +import { useOndoOutcomeToast } from '../hooks/useOndoOutcomeToast'; +import { usePerpsTradingCampaignEndedOutcomeToast } from '../hooks/usePerpsTradingCampaignEndedOutcomeToast'; const RewardsDashboard: React.FC = () => { const tw = useTailwind(); @@ -41,6 +42,8 @@ const RewardsDashboard: React.FC = () => { useTrackRewardsPageView({ page_type: 'home' }); useOndoOutcomeToast(); + usePerpsTradingCampaignEndedOutcomeToast(); + const hideUnlinkedAccountsBanner = useSelector( selectHideUnlinkedAccountsBanner, ); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.test.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.test.tsx similarity index 67% rename from app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.test.tsx rename to app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.test.tsx index 0b623fd0e53..56d7c49a5ba 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.test.tsx @@ -5,8 +5,8 @@ import { WinnerFinalizedBanner, ParticipantFinalizedBanner, ParticipantPendingBanner, - OndoGmCampaignOutcomeBanner, -} from './OndoCampaignOutcomeBanners'; + CampaignOutcomeBanner, +} from './CampaignOutcomeBanners'; jest.mock('../../../../../../locales/i18n', () => ({ strings: (key: string) => key, @@ -28,13 +28,13 @@ jest.mock('../RewardsInfoBanner', () => { }); describe('WinnerPendingBanner', () => { - it('renders title and description', () => { + it('renders title and description using consolidated locale keys', () => { const { getByText } = render(); expect( - getByText('rewards.ondo_outcome_banner.winner_pending.title'), + getByText('rewards.campaign_outcome_banner.winner_pending.title'), ).toBeDefined(); expect( - getByText('rewards.ondo_outcome_banner.winner_pending.description'), + getByText('rewards.campaign_outcome_banner.winner_pending.description'), ).toBeDefined(); }); @@ -43,7 +43,7 @@ describe('WinnerPendingBanner', () => { , ); expect( - getByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'), + getByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'), ).toBeDefined(); }); @@ -53,51 +53,53 @@ describe('WinnerPendingBanner', () => { , ); fireEvent.press( - getByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'), + getByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'), ); expect(onPress).toHaveBeenCalledTimes(1); }); }); describe('WinnerFinalizedBanner', () => { - it('renders with winner_finalized strings', () => { + it('renders with consolidated winner_finalized strings', () => { const { getByText } = render(); expect( - getByText('rewards.ondo_outcome_banner.winner_finalized.title'), + getByText('rewards.campaign_outcome_banner.winner_finalized.title'), ).toBeDefined(); expect( - getByText('rewards.ondo_outcome_banner.winner_finalized.description'), + getByText('rewards.campaign_outcome_banner.winner_finalized.description'), ).toBeDefined(); }); }); describe('ParticipantFinalizedBanner', () => { - it('renders with participant_finalized strings', () => { + it('renders with consolidated participant_finalized strings', () => { const { getByText } = render(); expect( - getByText('rewards.ondo_outcome_banner.participant_finalized.title'), + getByText('rewards.campaign_outcome_banner.participant_finalized.title'), ).toBeDefined(); expect( getByText( - 'rewards.ondo_outcome_banner.participant_finalized.description', + 'rewards.campaign_outcome_banner.participant_finalized.description', ), ).toBeDefined(); }); }); describe('ParticipantPendingBanner', () => { - it('renders with participant_pending strings', () => { + it('renders with consolidated participant_pending strings', () => { const { getByText } = render(); expect( - getByText('rewards.ondo_outcome_banner.participant_pending.title'), + getByText('rewards.campaign_outcome_banner.participant_pending.title'), ).toBeDefined(); expect( - getByText('rewards.ondo_outcome_banner.participant_pending.description'), + getByText( + 'rewards.campaign_outcome_banner.participant_pending.description', + ), ).toBeDefined(); }); }); -describe('OndoGmCampaignOutcomeBanner', () => { +describe('CampaignOutcomeBanner', () => { const onWinnerPress = jest.fn(); beforeEach(() => { @@ -106,83 +108,83 @@ describe('OndoGmCampaignOutcomeBanner', () => { it('renders WinnerPendingBanner when winner code is present and status is pending', () => { const { getByLabelText } = render( - , ); expect( - getByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'), + getByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'), ).toBeDefined(); }); it('renders WinnerFinalizedBanner when winner code is present and status is finalized', () => { const { getByText, queryByLabelText } = render( - , ); expect( - getByText('rewards.ondo_outcome_banner.winner_finalized.title'), + getByText('rewards.campaign_outcome_banner.winner_finalized.title'), ).toBeDefined(); expect( - queryByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'), + queryByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'), ).toBeNull(); }); it('renders ParticipantFinalizedBanner when no code and status is finalized', () => { const { getByText } = render( - , ); expect( - getByText('rewards.ondo_outcome_banner.participant_finalized.title'), + getByText('rewards.campaign_outcome_banner.participant_finalized.title'), ).toBeDefined(); }); it('renders ParticipantPendingBanner when no code and status is pending', () => { const { getByText } = render( - , ); expect( - getByText('rewards.ondo_outcome_banner.participant_pending.title'), + getByText('rewards.campaign_outcome_banner.participant_pending.title'), ).toBeDefined(); }); it('renders ParticipantPendingBanner when winnerVerificationCode is undefined', () => { const { getByText } = render( - , ); expect( - getByText('rewards.ondo_outcome_banner.participant_pending.title'), + getByText('rewards.campaign_outcome_banner.participant_pending.title'), ).toBeDefined(); }); it('calls onWinnerPress when WinnerPendingBanner is pressed', () => { const mockOnWinnerPress = jest.fn(); const { getByLabelText } = render( - , ); fireEvent.press( - getByLabelText('rewards.ondo_outcome_banner.winner_pending.a11y'), + getByLabelText('rewards.campaign_outcome_banner.winner_pending.a11y'), ); expect(mockOnWinnerPress).toHaveBeenCalledTimes(1); }); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.tsx b/app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.tsx similarity index 53% rename from app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.tsx rename to app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.tsx index 5127253bbb7..3402bca2d41 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoCampaignOutcomeBanners.tsx +++ b/app/components/UI/Rewards/components/Campaigns/CampaignOutcomeBanners.tsx @@ -15,7 +15,7 @@ import { } from '@metamask/design-system-react-native'; import { strings } from '../../../../../../locales/i18n'; import RewardsInfoBanner from '../RewardsInfoBanner'; -import type { OndoGmCampaignParticipantOutcomeStatus } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import type { CampaignParticipantOutcomeStatus } from '../../../../../core/Engine/controllers/rewards-controller/types'; export interface WinnerPendingBannerProps { onPress: () => void; @@ -25,7 +25,7 @@ export const WinnerPendingBanner = React.memo( ({ onPress }) => ( @@ -36,10 +36,12 @@ export const WinnerPendingBanner = React.memo( > - {strings('rewards.ondo_outcome_banner.winner_pending.title')} + {strings('rewards.campaign_outcome_banner.winner_pending.title')} - {strings('rewards.ondo_outcome_banner.winner_pending.description')} + {strings( + 'rewards.campaign_outcome_banner.winner_pending.description', + )} ( export const WinnerFinalizedBanner = React.memo(() => ( )); export const ParticipantFinalizedBanner = React.memo(() => ( )); export const ParticipantPendingBanner = React.memo(() => ( )); -export interface OndoGmCampaignOutcomeBannerProps { - outcomeStatus: OndoGmCampaignParticipantOutcomeStatus; +export interface CampaignOutcomeBannerProps { + outcomeStatus: CampaignParticipantOutcomeStatus; winnerVerificationCode: string | null | undefined; onWinnerPress: () => void; } -export const OndoGmCampaignOutcomeBanner = - React.memo( - ({ outcomeStatus, winnerVerificationCode, onWinnerPress }) => { - const hasCode = Boolean(winnerVerificationCode); - const isFinalized = outcomeStatus === 'finalized'; - if (hasCode && !isFinalized) - return ; - if (hasCode && isFinalized) return ; - if (isFinalized) return ; - return ; - }, - ); +export const CampaignOutcomeBanner = React.memo( + ({ outcomeStatus, winnerVerificationCode, onWinnerPress }) => { + const hasCode = Boolean(winnerVerificationCode); + const isFinalized = outcomeStatus === 'finalized'; + if (hasCode && !isFinalized) + return ; + if (hasCode && isFinalized) return ; + if (isFinalized) return ; + return ; + }, +); diff --git a/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx index 2ba425c2bf6..9926b195451 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.test.tsx @@ -28,12 +28,12 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ useTailwind: () => ({ style: (...args: unknown[]) => args }), })); -jest.mock('./OndoCampaignOutcomeBanners', () => { +jest.mock('./CampaignOutcomeBanners', () => { const ReactActual = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); return { __esModule: true, - OndoGmCampaignOutcomeBanner: ({ + CampaignOutcomeBanner: ({ outcomeStatus, winnerVerificationCode, }: { diff --git a/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.tsx b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.tsx index 0b9fa4b54cf..fd21c22ee46 100644 --- a/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.tsx +++ b/app/components/UI/Rewards/components/Campaigns/OndoCampaignStatsSummary.tsx @@ -24,7 +24,7 @@ import { formatPercentChange, formatUsd } from '../../utils/formatUtils'; import { ONDO_GM_REQUIRED_QUALIFIED_DAYS } from '../../utils/ondoCampaignConstants'; import { formatTierDisplayName } from './OndoLeaderboard.utils'; import RewardsErrorBanner from '../RewardsErrorBanner'; -import { OndoGmCampaignOutcomeBanner } from './OndoCampaignOutcomeBanners'; +import { CampaignOutcomeBanner } from './CampaignOutcomeBanners'; const CELL_STYLE = { flex: 1 } as const; @@ -244,7 +244,7 @@ const OndoCampaignStatsSummary: React.FC = ({ {/* Outcome banner (campaign ended) */} {isCampaignComplete && outcomeStatus != null && onWinnerPress != null && ( - ({ strings: (key: string) => key, })); +jest.mock('./CampaignOutcomeBanners', () => { + const ReactActual = jest.requireActual('react'); + const { Pressable, Text } = jest.requireActual('react-native'); + return { + CampaignOutcomeBanner: ({ + outcomeStatus, + winnerVerificationCode, + onWinnerPress, + }: { + outcomeStatus: string; + winnerVerificationCode?: string | null; + onWinnerPress: () => void; + }) => + ReactActual.createElement( + Pressable, + { + testID: `campaign-outcome-banner-${outcomeStatus}-${winnerVerificationCode ?? 'null'}`, + onPress: onWinnerPress, + }, + ReactActual.createElement(Text, null, 'Campaign outcome'), + ), + }; +}); + const TEST_IDS = PERPS_CAMPAIGN_STATS_SUMMARY_TEST_IDS; const mockLeaderboard = { @@ -160,6 +184,20 @@ describe('PerpsCampaignStatsSummary', () => { expect(queryByTestId(TEST_IDS.QUALIFY_FOR_RANK_CARD)).toBeNull(); }); + it('hides volume and margin StatCells when campaign is complete (only rank and PnL remain)', () => { + const { queryByTestId, getByTestId } = render( + , + ); + expect(getByTestId(TEST_IDS.RANK)).toBeDefined(); + expect(getByTestId(TEST_IDS.PNL)).toBeDefined(); + expect(queryByTestId(TEST_IDS.NOTIONAL_VOLUME)).toBeNull(); + expect(queryByTestId(TEST_IDS.MARGIN_DEPLOYED)).toBeNull(); + }); + it("hides You're qualified card when campaign is complete", () => { const { queryByTestId } = render( { ); expect(queryByTestId(TEST_IDS.QUALIFY_FOR_RANK_CARD)).toBeNull(); }); + + it('shows outcome banner for complete campaigns and handles winner press', () => { + const onWinnerPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press( + getByTestId('campaign-outcome-banner-pending-PERPS-WINNER-123'), + ); + expect(onWinnerPress).toHaveBeenCalledTimes(1); + }); + + it('does not show outcome banner before campaign completion', () => { + const { queryByTestId } = render( + , + ); + + expect( + queryByTestId('campaign-outcome-banner-pending-PERPS-WINNER-123'), + ).toBeNull(); + }); }); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx index 16a2608dfa0..7c3c7a2fe49 100644 --- a/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx +++ b/app/components/UI/Rewards/components/Campaigns/PerpsCampaignStatsSummary.tsx @@ -13,6 +13,7 @@ import { TextVariant, } from '@metamask/design-system-react-native'; import type { + CampaignParticipantOutcomeStatus, PerpsTradingCampaignLeaderboardDto, PerpsTradingCampaignLeaderboardPositionDto, } from '../../../../../core/Engine/controllers/rewards-controller/types'; @@ -20,6 +21,7 @@ import { strings } from '../../../../../../locales/i18n'; import { formatSignedUsd, formatUsd } from '../../utils/formatUtils'; import { PERPS_QUALIFICATION_NOTIONAL_USD } from '../../utils/perpsCampaignConstants'; import { PendingTag, StatCell } from './OndoCampaignStatsSummary'; +import { CampaignOutcomeBanner } from './CampaignOutcomeBanners'; const PERPS_NOTIONAL_THRESHOLD_LABEL = formatUsd( PERPS_QUALIFICATION_NOTIONAL_USD, @@ -43,12 +45,18 @@ export interface PerpsCampaignStatsSummaryProps { leaderboard: PerpsTradingCampaignLeaderboardDto | null; /** When false, pending (not yet qualified) users see a {@link PendingTag} next to rank. */ isCampaignComplete?: boolean; + outcomeStatus?: CampaignParticipantOutcomeStatus; + winnerVerificationCode?: string | null; + onWinnerPress?: () => void; } const PerpsCampaignStatsSummary: React.FC = ({ leaderboardPosition, leaderboard: _leaderboard, isCampaignComplete = false, + outcomeStatus, + winnerVerificationCode, + onWinnerPress, }) => { const isPending = leaderboardPosition != null && !leaderboardPosition.qualified; @@ -125,18 +133,20 @@ const PerpsCampaignStatsSummary: React.FC = ({ testID={PERPS_CAMPAIGN_STATS_SUMMARY_TEST_IDS.PNL} /> - - - - + {!isCampaignComplete && ( + + + + + )} {showQualifiedCard && ( = ({ )} + + {isCampaignComplete && outcomeStatus != null && onWinnerPress != null && ( + + )} ); }; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.test.tsx new file mode 100644 index 00000000000..f3cb469d9ea --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.test.tsx @@ -0,0 +1,276 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import PerpsTradingCampaignEndedStats, { + PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS, +} from './PerpsTradingCampaignEndedStats'; +import type { + PerpsTradingCampaignLeaderboardDto, + PerpsTradingCampaignLeaderboardEntry, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('../RewardsErrorBanner', () => { + const ReactActual = jest.requireActual('react'); + const RN = jest.requireActual('react-native'); + return { + __esModule: true, + default: (props: { title: string; onConfirm: () => void }) => + ReactActual.createElement( + RN.View, + { testID: 'rewards-error-banner' }, + ReactActual.createElement(RN.Text, null, props.title), + ReactActual.createElement(RN.TouchableOpacity, { + testID: 'rewards-error-banner-retry', + onPress: props.onConfirm, + }), + ), + }; +}); + +jest.mock('@metamask/design-system-react-native', () => { + const actual = jest.requireActual('@metamask/design-system-react-native'); + const ReactActual = jest.requireActual('react'); + const RN = jest.requireActual('react-native'); + return { + ...actual, + Text: (props: Record) => + ReactActual.createElement(RN.Text, props, props.children), + Skeleton: (props: Record) => + ReactActual.createElement(RN.View, { testID: 'skeleton', ...props }), + }; +}); + +jest.mock('@metamask/design-system-twrnc-preset', () => ({ + useTailwind: () => ({ style: (...args: unknown[]) => args }), +})); + +jest.mock('../../../../../../locales/i18n', () => ({ + strings: (key: string) => key, +})); + +jest.mock('../../utils/formatUtils', () => ({ + formatCompactUsd: (value: number) => `$${(value / 1_000_000).toFixed(1)}M`, + formatSignedUsd: (value: number) => { + const sign = value >= 0 ? '+' : '-'; + const abs = Math.abs(value).toLocaleString(); + return `${sign}$${abs}`; + }, +})); + +const makeEntry = ( + rank: number, + pnl: number, + qualified = true, +): PerpsTradingCampaignLeaderboardEntry => ({ + rank, + referralCode: `T-${rank}`, + pnl, + qualified, +}); + +const makeLeaderboard = ( + entriesCount: number, + totalParticipants?: number, + topPnl = 50_000, +): PerpsTradingCampaignLeaderboardDto => { + const entries = Array.from({ length: entriesCount }, (_, i) => + makeEntry(i + 1, topPnl - i * 1000), + ); + return { + campaignId: 'perps-1', + computedAt: '2026-01-01T00:00:00Z', + totalParticipants: totalParticipants ?? entriesCount, + entries, + }; +}; + +describe('PerpsTradingCampaignEndedStats', () => { + it('renders all four stat cells with correct values when leaderboard has 20+ entries', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.CONTAINER), + ).toBeTruthy(); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_PARTICIPANTS).props + .children, + ).toBe((200).toLocaleString()); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_VOLUME).props + .children, + ).toBe('$27.5M'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, + ).toBe('+$80,000'); + // Leaderboard has 25 entries (>= 20) → fixed 20 winners + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, + ).toBe('20'); + }); + + it('shows dash for winners when leaderboard has fewer than 20 entries', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, + ).toBe('-'); + }); + + it('shows dashes when leaderboard and volume are null', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_PARTICIPANTS).props + .children, + ).toBe('-'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_VOLUME).props + .children, + ).toBe('-'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, + ).toBe('-'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, + ).toBe('-'); + }); + + it('renders skeletons while data is loading', () => { + const { getAllByTestId } = render( + , + ); + + const skeletons = getAllByTestId('skeleton'); + expect(skeletons.length).toBeGreaterThanOrEqual(3); + }); + + it('handles a leaderboard with no entries (no top PnL)', () => { + const empty: PerpsTradingCampaignLeaderboardDto = { + campaignId: 'perps-1', + computedAt: '2026-01-01T00:00:00Z', + totalParticipants: 0, + entries: [], + }; + + const { getByTestId } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_PARTICIPANTS).props + .children, + ).toBe('0'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, + ).toBe('-'); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.WINNERS).props.children, + ).toBe('-'); + }); + + it('shows error banner when both sources fail and triggers both retries', () => { + const onRetryLeaderboard = jest.fn(); + const onRetryVolume = jest.fn(); + + const { getByTestId } = render( + , + ); + + expect(getByTestId('rewards-error-banner')).toBeTruthy(); + fireEvent.press(getByTestId('rewards-error-banner-retry')); + expect(onRetryLeaderboard).toHaveBeenCalledTimes(1); + expect(onRetryVolume).toHaveBeenCalledTimes(1); + }); + + it('shows error banner when only leaderboard fails; volume still renders', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId('rewards-error-banner')).toBeTruthy(); + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOTAL_VOLUME).props + .children, + ).toBe('$27.5M'); + }); + + it('does not render error banner when there are no errors', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('rewards-error-banner')).toBeNull(); + }); + + it('renders negative top PnL with error color and a minus sign', () => { + const negativeTop: PerpsTradingCampaignLeaderboardDto = { + campaignId: 'perps-1', + computedAt: '2026-01-01T00:00:00Z', + totalParticipants: 1, + entries: [makeEntry(1, -5_000)], + }; + + const { getByTestId } = render( + , + ); + + expect( + getByTestId(PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS.TOP_PNL).props.children, + ).toBe('-$5,000'); + }); +}); diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx new file mode 100644 index 00000000000..af3a1616288 --- /dev/null +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignEndedStats.tsx @@ -0,0 +1,153 @@ +import React, { useMemo } from 'react'; +import { + Box, + BoxFlexDirection, + FontWeight, + Text, + TextColor, + TextVariant, +} from '@metamask/design-system-react-native'; +import type { PerpsTradingCampaignLeaderboardDto } from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { StatCell } from './OndoCampaignStatsSummary'; +import RewardsErrorBanner from '../RewardsErrorBanner'; +import { strings } from '../../../../../../locales/i18n'; +import { formatCompactUsd, formatSignedUsd } from '../../utils/formatUtils'; + +const PERPS_WINNERS_CAP = 20; + +export const PERPS_CAMPAIGN_ENDED_STATS_TEST_IDS = { + CONTAINER: 'perps-campaign-ended-stats-container', + TOTAL_PARTICIPANTS: 'perps-campaign-ended-stats-total-participants', + TOTAL_VOLUME: 'perps-campaign-ended-stats-total-volume', + TOP_PNL: 'perps-campaign-ended-stats-top-pnl', + WINNERS: 'perps-campaign-ended-stats-winners', +} as const; + +interface PerpsTradingCampaignEndedStatsProps { + leaderboard: PerpsTradingCampaignLeaderboardDto | null; + totalNotionalVolume: string | null; + isLeaderboardLoading: boolean; + isVolumeLoading: boolean; + hasLeaderboardError?: boolean; + hasVolumeError?: boolean; + onRetryLeaderboard?: () => void; + onRetryVolume?: () => void; +} + +const PerpsTradingCampaignEndedStats: React.FC< + PerpsTradingCampaignEndedStatsProps +> = ({ + leaderboard, + totalNotionalVolume, + isLeaderboardLoading, + isVolumeLoading, + hasLeaderboardError, + hasVolumeError, + onRetryLeaderboard, + onRetryVolume, +}) => { + const stats = useMemo(() => { + if (!leaderboard) return null; + + const entries = leaderboard.entries ?? []; + const totalParticipants = leaderboard.totalParticipants; + const topPnl = + entries.length > 0 ? Math.max(...entries.map((e) => e.pnl)) : null; + const hasFullLeaderboard = entries.length >= PERPS_WINNERS_CAP; + return { totalParticipants, topPnl, hasFullLeaderboard }; + }, [leaderboard]); + + const isLeaderboardSkeletonVisible = isLeaderboardLoading && !leaderboard; + const isVolumeSkeletonVisible = isVolumeLoading && !totalNotionalVolume; + + const hasError = + (hasLeaderboardError && !leaderboard) || + (hasVolumeError && !totalNotionalVolume); + + const totalParticipantsValue = stats + ? stats.totalParticipants.toLocaleString() + : '-'; + + const totalVolumeValue = totalNotionalVolume + ? formatCompactUsd(parseFloat(totalNotionalVolume)) + : '-'; + + const topPnlValue = + stats?.topPnl != null ? formatSignedUsd(stats.topPnl) : '-'; + + const topPnlColor = + stats?.topPnl != null && stats.topPnl >= 0 + ? TextColor.SuccessDefault + : TextColor.ErrorDefault; + + const winnersValue = stats?.hasFullLeaderboard + ? String(PERPS_WINNERS_CAP) + : '-'; + + return ( + + + {strings('rewards.perps_trading_campaign.stats_title')} + + {hasError && ( + { + onRetryLeaderboard?.(); + onRetryVolume?.(); + }} + confirmButtonLabel={strings( + 'rewards.perps_trading_campaign.stats_retry', + )} + /> + )} + + + + + + + + + + ); +}; + +export default PerpsTradingCampaignEndedStats; diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx index d103b8e8c58..2894eaf9e28 100644 --- a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.test.tsx @@ -104,6 +104,16 @@ describe('PerpsTradingCampaignStatsHeader', () => { expect(queryByTestId(TEST_IDS.QUALIFIED_ICON)).toBeNull(); }); + it('hides the pending tag when the campaign is complete', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId(TEST_IDS.PENDING_TAG)).toBeNull(); + }); + it('shows em dashes for rank and PnL when position is null', () => { const { getByTestId } = render( , diff --git a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx index 63c4bdee9a7..31d3623c69b 100644 --- a/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx +++ b/app/components/UI/Rewards/components/Campaigns/PerpsTradingCampaignStatsHeader.tsx @@ -38,6 +38,8 @@ interface PerpsTradingCampaignStatsHeaderProps { showPnl?: boolean; /** When true, shows formatted `computedAt` time on the same row as PnL, right-aligned in alternative text color. */ showComputedAt?: boolean; + /** When true, suppresses the "Pending" tag — qualification is locked once the campaign ends. */ + isCampaignComplete?: boolean; } const PerpsTradingCampaignStatsHeader: React.FC< @@ -47,6 +49,7 @@ const PerpsTradingCampaignStatsHeader: React.FC< isLoading = false, showPnl = true, showComputedAt = true, + isCampaignComplete = false, }) => { const tw = useTailwind(); @@ -80,7 +83,7 @@ const PerpsTradingCampaignStatsHeader: React.FC< {strings('rewards.perps_trading_campaign.label_your_rank')} - {isPending && ( + {isPending && !isCampaignComplete && ( )} {isQualified && ( diff --git a/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx b/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx index 7023d15b341..6bf5b7a0cc9 100644 --- a/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx +++ b/app/components/UI/Rewards/components/ReferralDetails/CopyableField.tsx @@ -14,7 +14,7 @@ import { import { Skeleton } from '../../../../../component-library/components-temp/Skeleton'; interface CopyableFieldProps { - label: string; + label?: string; value?: string | null; onCopy?: () => void; valueLoading?: boolean; @@ -40,9 +40,11 @@ const CopyableField: React.FC = ({ return ( - - {label} - + {label && ( + + {label} + + )} ({ + ToastContext: { Consumer: jest.fn(), Provider: jest.fn() }, +})); + +jest.mock('../../../../component-library/components/Toast/Toast.types', () => ({ + ToastVariants: { Icon: 'Icon', App: 'App', Plain: 'Plain' }, + ButtonIconVariant: { Icon: 'Icon' }, +})); + +jest.mock('../../../../component-library/components/Icons/Icon', () => ({ + IconName: { Close: 'Close', Confirmation: 'Confirmation', Star: 'Star' }, +})); + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn(), + useCallback: jest.fn((fn) => fn), + useMemo: jest.fn((fn) => fn()), +})); + +jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), + useSelector: jest.fn(), +})); + +jest.mock('@react-navigation/native', () => ({ + useFocusEffect: jest.fn(), + useNavigation: jest.fn(), +})); + +jest.mock('../../../../util/haptics', () => ({ + playNotification: jest.fn(), + NotificationMoment: { + Success: 'Success', + Warning: 'Warning', + Error: 'Error', + }, +})); + +jest.mock('../../../../../locales/i18n', () => ({ + strings: jest.fn((key: string, params?: Record) => { + if (params?.campaignName) return `${key}:${params.campaignName}`; + return key; + }), +})); + +jest.mock('../../../../util/theme', () => { + const actual = jest.requireActual('../../../../util/theme'); + return { + ...actual, + useAppThemeFromContext: () => actual.mockTheme, + }; +}); + +jest.mock('../../../../reducers/rewards', () => ({ + dismissCampaignOutcomeToast: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaigns: jest.fn(), + selectDismissedCampaignOutcomeToasts: jest.fn(), +})); + +jest.mock('../../../../selectors/rewards', () => ({ + selectRewardsSubscriptionId: jest.fn(), +})); + +jest.mock('../components/Campaigns/CampaignTile.utils', () => ({ + getCampaignStatus: jest.fn(() => 'complete'), +})); + +const mockDispatch = jest.fn(); +const mockNavigate = jest.fn(); +const mockShowToast = jest.fn(); +const mockCloseToast = jest.fn(); +const mockToastRef = { + current: { showToast: mockShowToast, closeToast: mockCloseToast }, +}; + +const mockUseDispatch = useDispatch as jest.MockedFunction; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseFocusEffect = useFocusEffect as jest.MockedFunction< + typeof useFocusEffect +>; +const mockUseNavigation = useNavigation as jest.MockedFunction< + typeof useNavigation +>; +const mockDismissCampaignOutcomeToast = + dismissCampaignOutcomeToast as jest.MockedFunction< + typeof dismissCampaignOutcomeToast + >; + +const SUBSCRIPTION_ID = 'sub-123'; +const CAMPAIGN_ID = 'campaign-456'; +const CAMPAIGN_NAME = 'Test Campaign'; + +const mockUseOutcome = jest.fn(); + +const makeCompletedCampaign = (id = CAMPAIGN_ID, endDate = '2025-01-01') => ({ + id, + name: CAMPAIGN_NAME, + type: CampaignType.ONDO_HOLDING, + endDate, + startDate: '2024-01-01', +}); + +const WINNER_NAV = { + route: 'WinnerRoute', + params: { campaignId: CAMPAIGN_ID }, +}; +const NON_WINNER_NAV = { + route: 'NonWinnerRoute', + params: { campaignId: CAMPAIGN_ID }, +}; + +const mockConfig = { + campaignType: CampaignType.ONDO_HOLDING, + useOutcome: mockUseOutcome, + getWinnerNavigation: jest.fn(() => WINNER_NAV), + getNonWinnerNavigation: jest.fn(() => NON_WINNER_NAV), +}; + +function setupDefaults({ + campaigns = [makeCompletedCampaign()], + dismissed = {}, + subscriptionId = SUBSCRIPTION_ID, + outcome = null, +}: { + campaigns?: ReturnType[]; + dismissed?: Record; + subscriptionId?: string | null; + outcome?: BaseCampaignParticipantOutcomeDto | null; +} = {}) { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectCampaigns) return campaigns; + if (selector === selectDismissedCampaignOutcomeToasts) return dismissed; + if (selector === selectRewardsSubscriptionId) return subscriptionId; + return undefined; + }); + mockUseOutcome.mockReturnValue({ + outcome, + isLoading: false, + hasError: false, + }); +} + +describe('useCampaignOutcomeToast', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); + mockUseNavigation.mockReturnValue({ navigate: mockNavigate } as never); + (useContext as jest.Mock).mockReturnValue({ toastRef: mockToastRef }); + mockUseFocusEffect.mockImplementation((cb) => { + cb(); + }); + mockDismissCampaignOutcomeToast.mockReturnValue({ + type: 'rewards/dismissCampaignOutcomeToast', + } as never); + mockConfig.getWinnerNavigation.mockReturnValue(WINNER_NAV); + mockConfig.getNonWinnerNavigation.mockReturnValue(NON_WINNER_NAV); + }); + + describe('does not show toast when', () => { + it('outcome is null', () => { + setupDefaults({ outcome: null }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('no campaigns match the campaignType', () => { + setupDefaults({ campaigns: [] }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('subscriptionId is missing', () => { + setupDefaults({ + subscriptionId: null, + outcome: { + subscriptionId: '', + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('winner toast was already dismissed', () => { + const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:winner`; + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + dismissed: { [key]: true }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('non_winner toast was already dismissed', () => { + const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:non_winner`; + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }, + dismissed: { [key]: true }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + it('outcome is finalized with a verification code (neither variant)', () => { + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'finalized', + winnerVerificationCode: 'CODE', + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + }); + + describe('winner toast', () => { + const winnerOutcome: BaseCampaignParticipantOutcomeDto = { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'WINNER-XYZ', + }; + + it('shows Plain variant toast with trophy startAccessory', () => { + setupDefaults({ outcome: winnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: ToastVariants.Plain, + hasNoTimeout: true, + startAccessory: expect.anything(), + }), + ); + }); + + it('uses consolidated winner locale keys', () => { + setupDefaults({ outcome: winnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + labelOptions: [ + { + label: 'rewards.campaign_outcome_toast.winner.title', + isBold: true, + }, + ], + descriptionOptions: { + description: `rewards.campaign_outcome_toast.winner.description:${CAMPAIGN_NAME}`, + }, + linkButtonOptions: expect.objectContaining({ + label: 'rewards.campaign_outcome_toast.winner.cta', + }), + }), + ); + }); + + it('shows close button with correct config', () => { + setupDefaults({ outcome: winnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + closeButtonOptions: expect.objectContaining({ + variant: ButtonIconVariant.Icon, + iconName: IconName.Close, + }), + }), + ); + }); + + it('fires success haptic via playNotification', () => { + setupDefaults({ outcome: winnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(playNotification).toHaveBeenCalledWith(NotificationMoment.Success); + }); + }); + + describe('non-winner toast', () => { + const nonWinnerOutcome: BaseCampaignParticipantOutcomeDto = { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }; + + it('shows Icon variant toast with Confirmation icon', () => { + setupDefaults({ outcome: nonWinnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: ToastVariants.Icon, + iconName: IconName.Confirmation, + backgroundColor: 'transparent', + hasNoTimeout: true, + }), + ); + }); + + it('uses consolidated non_winner locale keys', () => { + setupDefaults({ outcome: nonWinnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + labelOptions: [ + { + label: 'rewards.campaign_outcome_toast.non_winner.title', + isBold: true, + }, + ], + descriptionOptions: { + description: `rewards.campaign_outcome_toast.non_winner.description:${CAMPAIGN_NAME}`, + }, + linkButtonOptions: expect.objectContaining({ + label: 'rewards.campaign_outcome_toast.non_winner.cta', + }), + }), + ); + }); + + it('fires warning haptic via playNotification', () => { + setupDefaults({ outcome: nonWinnerOutcome }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + expect(playNotification).toHaveBeenCalledWith(NotificationMoment.Warning); + }); + }); + + describe('handleDismiss', () => { + it('dispatches dismissCampaignOutcomeToast with variant winner and closes toast', () => { + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + const closeButtonOptions = + mockShowToast.mock.calls[0][0].closeButtonOptions; + closeButtonOptions.onPress(); + + expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({ + campaignId: CAMPAIGN_ID, + subscriptionId: SUBSCRIPTION_ID, + variant: 'winner', + }); + expect(mockCloseToast).toHaveBeenCalled(); + }); + + it('dispatches dismissCampaignOutcomeToast with variant non_winner', () => { + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + const closeButtonOptions = + mockShowToast.mock.calls[0][0].closeButtonOptions; + closeButtonOptions.onPress(); + + expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({ + campaignId: CAMPAIGN_ID, + subscriptionId: SUBSCRIPTION_ID, + variant: 'non_winner', + }); + }); + }); + + describe('handleCta', () => { + it('navigates to winner route and dismisses for winner variant', () => { + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + const { onPress } = mockShowToast.mock.calls[0][0].linkButtonOptions; + onPress(); + + expect(mockNavigate).toHaveBeenCalledWith( + WINNER_NAV.route as never, + WINNER_NAV.params as never, + ); + expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ variant: 'winner' }), + ); + }); + + it('navigates to non-winner route and dismisses for non_winner variant', () => { + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'finalized', + winnerVerificationCode: null, + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + const { onPress } = mockShowToast.mock.calls[0][0].linkButtonOptions; + onPress(); + + expect(mockNavigate).toHaveBeenCalledWith( + NON_WINNER_NAV.route as never, + NON_WINNER_NAV.params as never, + ); + expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ variant: 'non_winner' }), + ); + }); + }); + + describe('cleanup on blur', () => { + it('calls closeToast in cleanup when screen blurs', () => { + let cleanupFn: (() => void) | undefined; + mockUseFocusEffect.mockImplementation((cb) => { + const cleanup = cb(); + if (typeof cleanup === 'function') cleanupFn = cleanup; + }); + + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + expect(cleanupFn).toBeDefined(); + cleanupFn?.(); + expect(mockCloseToast).toHaveBeenCalled(); + }); + + it('does not return cleanup when variant is null', () => { + let cleanupFn: (() => void) | undefined; + mockUseFocusEffect.mockImplementation((cb) => { + const result = cb(); + if (typeof result === 'function') cleanupFn = result; + }); + + setupDefaults({ outcome: null }); + renderHook(() => useCampaignOutcomeToast(mockConfig)); + + expect(cleanupFn).toBeUndefined(); + }); + }); + + describe('edge cases', () => { + it('handles null toastRef gracefully', () => { + (useContext as jest.Mock).mockReturnValue({ toastRef: null }); + setupDefaults({ + outcome: { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'CODE', + }, + }); + expect(() => + renderHook(() => useCampaignOutcomeToast(mockConfig)), + ).not.toThrow(); + }); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.ts b/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.ts new file mode 100644 index 00000000000..4ea89f6df6e --- /dev/null +++ b/app/components/UI/Rewards/hooks/useCampaignOutcomeToast.ts @@ -0,0 +1,174 @@ +import { useCallback, useContext, useMemo } from 'react'; +import { + useFocusEffect, + useNavigation, + type NavigationProp, + type ParamListBase, +} from '@react-navigation/native'; +import { useDispatch, useSelector } from 'react-redux'; +import { strings } from '../../../../../locales/i18n'; +import { ToastContext } from '../../../../component-library/components/Toast'; +import type { + BaseCampaignParticipantOutcomeDto, + CampaignType, + CampaignDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; +import { dismissCampaignOutcomeToast } from '../../../../reducers/rewards'; +import { + selectCampaigns, + selectDismissedCampaignOutcomeToasts, +} from '../../../../reducers/rewards/selectors'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import useRewardsToast from './useRewardsToast'; + +export interface CampaignOutcomeToastConfig { + campaignType: CampaignType; + useOutcome: (id: string | undefined) => { + outcome: BaseCampaignParticipantOutcomeDto | null; + }; + getWinnerNavigation: (campaign: CampaignDto) => { + route: string; + params: object; + }; + getNonWinnerNavigation: (campaign: CampaignDto) => { + route: string; + params: object; + }; +} + +export function useCampaignOutcomeToast( + config: CampaignOutcomeToastConfig, +): void { + const { + campaignType, + useOutcome, + getWinnerNavigation, + getNonWinnerNavigation, + } = config; + + const dispatch = useDispatch(); + const { toastRef } = useContext(ToastContext); + const { showToast, RewardsToastOptions } = useRewardsToast(); + const navigation = useNavigation>(); + + const subscriptionId = useSelector(selectRewardsSubscriptionId); + const campaigns = useSelector(selectCampaigns); + const dismissed = useSelector(selectDismissedCampaignOutcomeToasts); + + const targetCampaign = useMemo(() => { + const completed = campaigns + .filter( + (c) => c.type === campaignType && getCampaignStatus(c) === 'complete', + ) + .sort( + (a, b) => new Date(b.endDate).getTime() - new Date(a.endDate).getTime(), + ); + return completed[0] ?? null; + }, [campaigns, campaignType]); + + const { outcome } = useOutcome(targetCampaign?.id); + + // Standardized variant derivation: winner = has code and not yet finalized + const variant = useMemo((): 'winner' | 'non_winner' | null => { + if (!outcome) return null; + if ( + outcome.winnerVerificationCode && + outcome.outcomeStatus !== 'finalized' + ) { + return 'winner'; + } + if ( + outcome.outcomeStatus === 'finalized' && + !outcome.winnerVerificationCode + ) { + return 'non_winner'; + } + return null; + }, [outcome]); + + const isDismissed = useMemo(() => { + if (!variant || !targetCampaign || !subscriptionId) return true; + const key = `${targetCampaign.id}:${subscriptionId}:${variant}`; + return dismissed[key] === true; + }, [variant, targetCampaign, subscriptionId, dismissed]); + + const handleDismiss = useCallback(() => { + if (!variant || !targetCampaign || !subscriptionId) return; + dispatch( + dismissCampaignOutcomeToast({ + campaignId: targetCampaign.id, + subscriptionId, + variant, + }), + ); + toastRef?.current?.closeToast(); + }, [variant, targetCampaign, subscriptionId, dispatch, toastRef]); + + const handleCta = useCallback(() => { + if (!targetCampaign || !variant) return; + handleDismiss(); + const nav = + variant === 'winner' + ? getWinnerNavigation(targetCampaign) + : getNonWinnerNavigation(targetCampaign); + navigation.navigate(nav.route, nav.params); + }, [ + variant, + targetCampaign, + handleDismiss, + navigation, + getWinnerNavigation, + getNonWinnerNavigation, + ]); + + useFocusEffect( + useCallback(() => { + if (!variant || isDismissed || !targetCampaign) return; + + const isWinner = variant === 'winner'; + if (isWinner) { + showToast( + RewardsToastOptions.outcomeWinner({ + title: strings('rewards.campaign_outcome_toast.winner.title'), + description: strings( + 'rewards.campaign_outcome_toast.winner.description', + { campaignName: targetCampaign.name ?? '' }, + ), + ctaLabel: strings('rewards.campaign_outcome_toast.winner.cta'), + onCtaPress: handleCta, + onClosePress: handleDismiss, + }), + ); + } else { + showToast( + RewardsToastOptions.outcomeNonWinner({ + title: strings('rewards.campaign_outcome_toast.non_winner.title'), + description: strings( + 'rewards.campaign_outcome_toast.non_winner.description', + { campaignName: targetCampaign.name ?? '' }, + ), + ctaLabel: strings('rewards.campaign_outcome_toast.non_winner.cta'), + onCtaPress: handleCta, + onClosePress: handleDismiss, + }), + ); + } + + return () => { + toastRef?.current?.closeToast(); + }; + }, [ + variant, + isDismissed, + targetCampaign, + toastRef, + showToast, + RewardsToastOptions, + handleCta, + handleDismiss, + ]), + ); +} + +export default useCampaignOutcomeToast; diff --git a/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts new file mode 100644 index 00000000000..012cbe4549a --- /dev/null +++ b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.test.ts @@ -0,0 +1,173 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { selectCampaignParticipantOptedIn } from '../../../../reducers/rewards/selectors'; +import { useCampaignParticipantOutcome } from './useCampaignParticipantOutcome'; +import type { BaseCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../core/Engine', () => ({ + controllerMessenger: { call: jest.fn() }, +})); + +jest.mock('../../../../selectors/rewards', () => ({ + selectRewardsSubscriptionId: jest.fn(), +})); + +jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantOptedIn: jest.fn(), +})); + +const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< + typeof Engine.controllerMessenger.call +>; +const mockUseSelector = useSelector as jest.MockedFunction; +const mockSelectCampaignParticipantOptedIn = + selectCampaignParticipantOptedIn as jest.MockedFunction< + typeof selectCampaignParticipantOptedIn + >; + +const CAMPAIGN_ID = 'campaign-123'; +const SUBSCRIPTION_ID = 'sub-456'; +const MESSENGER_ACTION = 'RewardsController:getOndoCampaignParticipantOutcome'; +const CONFIG = { messengerAction: MESSENGER_ACTION }; + +const MOCK_OUTCOME: BaseCampaignParticipantOutcomeDto = { + subscriptionId: SUBSCRIPTION_ID, + outcomeStatus: 'pending', + winnerVerificationCode: 'WINNER-XYZ', +}; + +function setupSelectors({ + subscriptionId = SUBSCRIPTION_ID, + isOptedIn = true, +}: { + subscriptionId?: string | null; + isOptedIn?: boolean; +} = {}) { + const participantOptedInSelector = jest.fn().mockReturnValue(isOptedIn); + mockSelectCampaignParticipantOptedIn.mockReturnValue( + participantOptedInSelector, + ); + mockUseSelector.mockImplementation((selector) => { + if (selector === selectRewardsSubscriptionId) return subscriptionId; + if (selector === participantOptedInSelector) return isOptedIn; + return undefined; + }); +} + +describe('useCampaignParticipantOutcome', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fetches and returns outcome when subscriptionId, campaignId, and isOptedIn are truthy', async () => { + setupSelectors(); + mockCall.mockResolvedValue(MOCK_OUTCOME); + + const { result, waitForNextUpdate } = renderHook(() => + useCampaignParticipantOutcome(CAMPAIGN_ID, CONFIG), + ); + + await act(async () => { + await waitForNextUpdate(); + }); + + expect(mockCall).toHaveBeenCalledWith( + MESSENGER_ACTION, + CAMPAIGN_ID, + SUBSCRIPTION_ID, + ); + expect(result.current.outcome).toEqual(MOCK_OUTCOME); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); + + it('returns null outcome when campaignId is undefined', async () => { + setupSelectors(); + const { result, waitForNextUpdate } = renderHook(() => + useCampaignParticipantOutcome(undefined, CONFIG), + ); + await act(async () => { + await waitForNextUpdate().catch(() => undefined); + }); + expect(result.current.outcome).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + expect(mockCall).not.toHaveBeenCalled(); + }); + + it('returns null outcome when subscriptionId is missing', async () => { + setupSelectors({ subscriptionId: null }); + const { result, waitForNextUpdate } = renderHook(() => + useCampaignParticipantOutcome(CAMPAIGN_ID, CONFIG), + ); + await act(async () => { + await waitForNextUpdate().catch(() => undefined); + }); + expect(result.current.outcome).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + expect(mockCall).not.toHaveBeenCalled(); + }); + + it('returns null outcome when user is not opted in', async () => { + setupSelectors({ isOptedIn: false }); + const { result, waitForNextUpdate } = renderHook(() => + useCampaignParticipantOutcome(CAMPAIGN_ID, CONFIG), + ); + await act(async () => { + await waitForNextUpdate().catch(() => undefined); + }); + expect(result.current.outcome).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + expect(mockCall).not.toHaveBeenCalled(); + }); + + it('sets hasError and clears outcome when the fetch throws', async () => { + setupSelectors(); + mockCall.mockRejectedValue(new Error('fetch failed')); + + const { result, waitForNextUpdate } = renderHook(() => + useCampaignParticipantOutcome(CAMPAIGN_ID, CONFIG), + ); + + await act(async () => { + await waitForNextUpdate(); + }); + + expect(result.current.outcome).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(true); + }); + + it('resets state when campaignId changes to undefined', async () => { + setupSelectors(); + mockCall.mockResolvedValue(MOCK_OUTCOME); + + const initialProps: { id: string | undefined } = { id: CAMPAIGN_ID }; + const { result, waitForNextUpdate, rerender } = renderHook( + ({ id }: { id: string | undefined }) => + useCampaignParticipantOutcome(id, CONFIG), + { initialProps }, + ); + + await act(async () => { + await waitForNextUpdate(); + }); + expect(result.current.outcome).toEqual(MOCK_OUTCOME); + + rerender({ id: undefined }); + await act(async () => { + await waitForNextUpdate().catch(() => undefined); + }); + expect(result.current.outcome).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); +}); diff --git a/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.ts b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.ts new file mode 100644 index 00000000000..1f10639c665 --- /dev/null +++ b/app/components/UI/Rewards/hooks/useCampaignParticipantOutcome.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { selectCampaignParticipantOptedIn } from '../../../../reducers/rewards/selectors'; +import type { BaseCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; + +export interface UseCampaignParticipantOutcomeResult< + T extends BaseCampaignParticipantOutcomeDto, +> { + outcome: T | null; + isLoading: boolean; + hasError: boolean; +} + +export interface CampaignOutcomeFetchConfig { + messengerAction: string; +} + +export function useCampaignParticipantOutcome< + T extends BaseCampaignParticipantOutcomeDto, +>( + campaignId: string | undefined, + config: CampaignOutcomeFetchConfig, +): UseCampaignParticipantOutcomeResult { + const subscriptionId = useSelector(selectRewardsSubscriptionId); + const isOptedIn = useSelector( + selectCampaignParticipantOptedIn(subscriptionId, campaignId), + ); + const [outcome, setOutcome] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [hasError, setHasError] = useState(false); + + const fetchOutcome = useCallback(async (): Promise => { + if (!subscriptionId || !campaignId || !isOptedIn) { + setOutcome(null); + setIsLoading(false); + setHasError(false); + return; + } + + try { + setIsLoading(true); + setHasError(false); + const result = await Engine.controllerMessenger.call( + config.messengerAction as Parameters< + typeof Engine.controllerMessenger.call + >[0], + campaignId, + subscriptionId, + ); + setOutcome(result as T); + } catch { + setOutcome(null); + setHasError(true); + } finally { + setIsLoading(false); + } + }, [campaignId, subscriptionId, isOptedIn, config.messengerAction]); + + useEffect(() => { + fetchOutcome(); + }, [fetchOutcome]); + + return { outcome, isLoading, hasError }; +} + +export default useCampaignParticipantOutcome; diff --git a/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.test.ts b/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.test.ts index c0bed6ad4a6..f424932ae16 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.test.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.test.ts @@ -3,11 +3,11 @@ import { waitFor } from '@testing-library/react-native'; import { useSelector, useDispatch } from 'react-redux'; import { useGetOndoCampaignActivity } from './useGetOndoCampaignActivity'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignActivityById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignActivityById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignActivity } from '../../../../reducers/rewards'; import type { OndoGmActivityEntryDto } from '../../../../core/Engine/controllers/rewards-controller/types'; @@ -22,10 +22,10 @@ jest.mock('../../../../core/Engine', () => ({ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), - selectCampaignParticipantOptedIn: jest.fn(), })); jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantOptedIn: jest.fn(), selectOndoCampaignActivityById: jest.fn(), })); diff --git a/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.ts b/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.ts index 3c28894340a..92faacd5610 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoCampaignActivity.ts @@ -1,11 +1,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignActivityById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignActivityById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignActivity } from '../../../../reducers/rewards'; import type { OndoGmActivityEntryDto } from '../../../../core/Engine/controllers/rewards-controller/types'; diff --git a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts index f3170271b60..2910af46256 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.test.ts @@ -2,11 +2,11 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useSelector, useDispatch } from 'react-redux'; import { useGetOndoLeaderboardPosition } from './useGetOndoLeaderboardPosition'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignLeaderboardPositionById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignLeaderboardPosition } from '../../../../reducers/rewards'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; import type { CampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; @@ -26,10 +26,10 @@ jest.mock('./useInvalidateByRewardEvents', () => ({ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), - selectCampaignParticipantOptedIn: jest.fn(), })); jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantOptedIn: jest.fn(), selectOndoCampaignLeaderboardPositionById: jest.fn(), })); @@ -83,16 +83,18 @@ interface SelectorState { function setupSelectors(state: SelectorState) { const isOptedIn = state.isOptedIn ?? true; const mockPositionSelector = jest.fn().mockReturnValue(state.position); - const mockOptedInSelector = jest.fn().mockReturnValue(isOptedIn); + const mockParticipantOptedInSelector = jest.fn().mockReturnValue(isOptedIn); mockSelectCampaignLeaderboardPositionById.mockReturnValue( mockPositionSelector, ); - mockSelectCampaignParticipantOptedIn.mockReturnValue(mockOptedInSelector); + mockSelectCampaignParticipantOptedIn.mockReturnValue( + mockParticipantOptedInSelector, + ); mockUseSelector.mockImplementation((selector) => { if (selector === selectRewardsSubscriptionId) return state.subscriptionId; if (selector === mockPositionSelector) return state.position; - if (selector === mockOptedInSelector) return isOptedIn; + if (selector === mockParticipantOptedInSelector) return isOptedIn; return undefined; }); } diff --git a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts index be4f0b96cc3..9b0bccab4f3 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoLeaderboardPosition.ts @@ -1,11 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignLeaderboardPositionById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignLeaderboardPosition } from '../../../../reducers/rewards'; import type { CampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; diff --git a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts index 80300f1f62c..332ad4b7415 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.test.ts @@ -3,11 +3,11 @@ import { waitFor } from '@testing-library/react-native'; import { useSelector, useDispatch } from 'react-redux'; import { useGetOndoPortfolioPosition } from './useGetOndoPortfolioPosition'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignPortfolioById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignPortfolioById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignPortfolioPosition } from '../../../../reducers/rewards'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; import type { OndoGmPortfolioDto } from '../../../../core/Engine/controllers/rewards-controller/types'; @@ -27,10 +27,10 @@ jest.mock('./useInvalidateByRewardEvents', () => ({ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), - selectCampaignParticipantOptedIn: jest.fn(), })); jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantOptedIn: jest.fn(), selectOndoCampaignPortfolioById: jest.fn(), })); diff --git a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts index 2270b597a59..e0f960916cc 100644 --- a/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts +++ b/app/components/UI/Rewards/hooks/useGetOndoPortfolioPosition.ts @@ -1,11 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectOndoCampaignPortfolioById } from '../../../../reducers/rewards/selectors'; + selectOndoCampaignPortfolioById, +} from '../../../../reducers/rewards/selectors'; import { setOndoCampaignPortfolioPosition } from '../../../../reducers/rewards'; import type { OndoGmPortfolioDto } from '../../../../core/Engine/controllers/rewards-controller/types'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts index 320a3921c75..d85a3b027cf 100644 --- a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.test.ts @@ -2,11 +2,11 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useSelector, useDispatch } from 'react-redux'; import { useGetPerpsTradingCampaignLeaderboardPosition } from './useGetPerpsTradingCampaignLeaderboardPosition'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectPerpsTradingCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors'; + selectPerpsTradingCampaignLeaderboardPositionById, +} from '../../../../reducers/rewards/selectors'; import { setPerpsTradingCampaignLeaderboardPosition } from '../../../../reducers/rewards'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; @@ -26,10 +26,10 @@ jest.mock('./useInvalidateByRewardEvents', () => ({ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), - selectCampaignParticipantOptedIn: jest.fn(), })); jest.mock('../../../../reducers/rewards/selectors', () => ({ + selectCampaignParticipantOptedIn: jest.fn(), selectPerpsTradingCampaignLeaderboardPositionById: jest.fn(), })); @@ -79,14 +79,16 @@ interface SelectorState { function setupSelectors(state: SelectorState) { const isOptedIn = state.isOptedIn ?? true; const mockPositionSelector = jest.fn().mockReturnValue(state.position); - const mockOptedInSelector = jest.fn().mockReturnValue(isOptedIn); + const mockParticipantOptedInSelector = jest.fn().mockReturnValue(isOptedIn); mockSelectPositionById.mockReturnValue(mockPositionSelector); - mockSelectCampaignParticipantOptedIn.mockReturnValue(mockOptedInSelector); + mockSelectCampaignParticipantOptedIn.mockReturnValue( + mockParticipantOptedInSelector, + ); mockUseSelector.mockImplementation((selector) => { if (selector === selectRewardsSubscriptionId) return state.subscriptionId; if (selector === mockPositionSelector) return state.position; - if (selector === mockOptedInSelector) return isOptedIn; + if (selector === mockParticipantOptedInSelector) return isOptedIn; return undefined; }); } diff --git a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts index aaa206cc69f..aedd1ff533a 100644 --- a/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts +++ b/app/components/UI/Rewards/hooks/useGetPerpsTradingCampaignLeaderboardPosition.ts @@ -1,11 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; +import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; import { - selectRewardsSubscriptionId, selectCampaignParticipantOptedIn, -} from '../../../../selectors/rewards'; -import { selectPerpsTradingCampaignLeaderboardPositionById } from '../../../../reducers/rewards/selectors'; + selectPerpsTradingCampaignLeaderboardPositionById, +} from '../../../../reducers/rewards/selectors'; import { setPerpsTradingCampaignLeaderboardPosition } from '../../../../reducers/rewards'; import type { PerpsTradingCampaignLeaderboardPositionDto } from '../../../../core/Engine/controllers/rewards-controller/types'; import { useInvalidateByRewardEvents } from './useInvalidateByRewardEvents'; diff --git a/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts b/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts index f41f2351164..b614232da40 100644 --- a/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts +++ b/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts @@ -89,6 +89,14 @@ describe('useLinkAccountAddress', () => { loading: jest.fn().mockReturnValue({ variant: 'loading', }), + outcomeWinner: jest.fn().mockReturnValue({ + variant: 'plain', + hapticsType: 'success', + }), + outcomeNonWinner: jest.fn().mockReturnValue({ + variant: 'icon', + hapticsType: 'warning', + }), }; const mockAccount: InternalAccount = { diff --git a/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts b/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts index 5e99c8b0f20..7c5c8603ec7 100644 --- a/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts +++ b/app/components/UI/Rewards/hooks/useLinkAccountGroup.test.ts @@ -110,6 +110,14 @@ describe('useLinkAccountGroup', () => { loading: jest.fn().mockReturnValue({ variant: 'loading', }), + outcomeWinner: jest.fn().mockReturnValue({ + variant: 'plain', + hapticsType: 'success', + }), + outcomeNonWinner: jest.fn().mockReturnValue({ + variant: 'icon', + hapticsType: 'warning', + }), }; // Mock account data diff --git a/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.test.ts b/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.test.ts index 5dc86d8617a..6a4189dfcfb 100644 --- a/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.test.ts +++ b/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.test.ts @@ -1,149 +1,65 @@ -import { renderHook, act } from '@testing-library/react-hooks'; -import { useSelector } from 'react-redux'; -import Engine from '../../../../core/Engine'; -import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; -import { selectCampaignParticipantStatus } from '../../../../reducers/rewards/selectors'; +import { renderHook } from '@testing-library/react-hooks'; import { useOndoCampaignParticipantOutcome } from './useOndoCampaignParticipantOutcome'; -import type { OndoGmCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; +import { useCampaignParticipantOutcome } from './useCampaignParticipantOutcome'; -jest.mock('react-redux', () => ({ - useSelector: jest.fn(), +jest.mock('./useCampaignParticipantOutcome', () => ({ + useCampaignParticipantOutcome: jest.fn(), })); -jest.mock('../../../../core/Engine', () => ({ - controllerMessenger: { call: jest.fn() }, -})); - -jest.mock('../../../../selectors/rewards', () => ({ - selectRewardsSubscriptionId: jest.fn(), -})); - -jest.mock('../../../../reducers/rewards/selectors', () => ({ - selectCampaignParticipantStatus: jest.fn(), -})); - -const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< - typeof Engine.controllerMessenger.call ->; -const mockUseSelector = useSelector as jest.MockedFunction; -const mockSelectCampaignParticipantStatus = - selectCampaignParticipantStatus as jest.MockedFunction< - typeof selectCampaignParticipantStatus +const mockUseCampaignParticipantOutcome = + useCampaignParticipantOutcome as jest.MockedFunction< + typeof useCampaignParticipantOutcome >; const CAMPAIGN_ID = 'campaign-123'; -const SUBSCRIPTION_ID = 'sub-456'; - -const MOCK_OUTCOME: OndoGmCampaignParticipantOutcomeDto = { - subscriptionId: SUBSCRIPTION_ID, - outcomeStatus: 'pending', - winnerVerificationCode: 'WINNER-XYZ', -}; - -function setupSelectors({ - subscriptionId = SUBSCRIPTION_ID, - isOptedIn = true, -}: { - subscriptionId?: string | null; - isOptedIn?: boolean; -} = {}) { - const participantStatusSelector = jest - .fn() - .mockReturnValue(isOptedIn ? { optedIn: true } : null); - mockSelectCampaignParticipantStatus.mockReturnValue( - participantStatusSelector, - ); - mockUseSelector.mockImplementation((selector) => { - if (selector === selectRewardsSubscriptionId) return subscriptionId; - if (selector === participantStatusSelector) - return isOptedIn ? { optedIn: true } : null; - return undefined; - }); -} describe('useOndoCampaignParticipantOutcome', () => { beforeEach(() => { jest.clearAllMocks(); - }); - - it('returns null outcome and no loading when campaignId is undefined', async () => { - setupSelectors(); - const { result, waitForNextUpdate } = renderHook(() => - useOndoCampaignParticipantOutcome(undefined), - ); - await act(async () => { - await waitForNextUpdate().catch(() => undefined); + mockUseCampaignParticipantOutcome.mockReturnValue({ + outcome: null, + isLoading: false, + hasError: false, }); - expect(result.current.outcome).toBeNull(); - expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(false); - expect(mockCall).not.toHaveBeenCalled(); }); - it('returns null outcome when subscriptionId is missing', async () => { - setupSelectors({ subscriptionId: null }); - const { result, waitForNextUpdate } = renderHook(() => - useOndoCampaignParticipantOutcome(CAMPAIGN_ID), - ); - await act(async () => { - await waitForNextUpdate().catch(() => undefined); - }); - expect(result.current.outcome).toBeNull(); - expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(false); - expect(mockCall).not.toHaveBeenCalled(); - }); + it('delegates to useCampaignParticipantOutcome with the Ondo messenger action', () => { + renderHook(() => useOndoCampaignParticipantOutcome(CAMPAIGN_ID)); - it('returns null outcome when user is not opted in', async () => { - setupSelectors({ isOptedIn: false }); - const { result, waitForNextUpdate } = renderHook(() => - useOndoCampaignParticipantOutcome(CAMPAIGN_ID), + expect(mockUseCampaignParticipantOutcome).toHaveBeenCalledWith( + CAMPAIGN_ID, + { + messengerAction: 'RewardsController:getOndoCampaignParticipantOutcome', + }, ); - await act(async () => { - await waitForNextUpdate().catch(() => undefined); - }); - expect(result.current.outcome).toBeNull(); - expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(false); - expect(mockCall).not.toHaveBeenCalled(); }); - it('fetches outcome and returns it when all conditions are met', async () => { - setupSelectors(); - mockCall.mockResolvedValue(MOCK_OUTCOME); - - const { result, waitForNextUpdate } = renderHook(() => - useOndoCampaignParticipantOutcome(CAMPAIGN_ID), - ); + it('passes undefined campaignId through to the generic hook', () => { + renderHook(() => useOndoCampaignParticipantOutcome(undefined)); - await act(async () => { - await waitForNextUpdate(); + expect(mockUseCampaignParticipantOutcome).toHaveBeenCalledWith(undefined, { + messengerAction: 'RewardsController:getOndoCampaignParticipantOutcome', }); - - expect(mockCall).toHaveBeenCalledWith( - 'RewardsController:getOndoCampaignParticipantOutcome', - CAMPAIGN_ID, - SUBSCRIPTION_ID, - ); - expect(result.current.outcome).toEqual(MOCK_OUTCOME); - expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(false); }); - it('sets hasError and clears outcome when the fetch throws', async () => { - setupSelectors(); - mockCall.mockRejectedValue(new Error('fetch failed')); + it('returns the result from the generic hook', () => { + const mockOutcome = { + subscriptionId: 'sub-1', + outcomeStatus: 'pending' as const, + winnerVerificationCode: 'CODE', + }; + mockUseCampaignParticipantOutcome.mockReturnValue({ + outcome: mockOutcome, + isLoading: false, + hasError: false, + }); - const { result, waitForNextUpdate } = renderHook(() => + const { result } = renderHook(() => useOndoCampaignParticipantOutcome(CAMPAIGN_ID), ); - await act(async () => { - await waitForNextUpdate(); - }); - - expect(result.current.outcome).toBeNull(); + expect(result.current.outcome).toEqual(mockOutcome); expect(result.current.isLoading).toBe(false); - expect(result.current.hasError).toBe(true); + expect(result.current.hasError).toBe(false); }); }); diff --git a/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.ts b/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.ts index eaabab7f48b..017c62bfc41 100644 --- a/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.ts +++ b/app/components/UI/Rewards/hooks/useOndoCampaignParticipantOutcome.ts @@ -1,58 +1,19 @@ -import { useCallback, useEffect, useState } from 'react'; -import { useSelector } from 'react-redux'; -import Engine from '../../../../core/Engine'; -import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; -import { selectCampaignParticipantStatus } from '../../../../reducers/rewards/selectors'; import type { OndoGmCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; +import { + useCampaignParticipantOutcome, + type UseCampaignParticipantOutcomeResult, +} from './useCampaignParticipantOutcome'; -export interface UseOndoCampaignParticipantOutcomeResult { - outcome: OndoGmCampaignParticipantOutcomeDto | null; - isLoading: boolean; - hasError: boolean; -} +export type UseOndoCampaignParticipantOutcomeResult = + UseCampaignParticipantOutcomeResult; export function useOndoCampaignParticipantOutcome( campaignId: string | undefined, ): UseOndoCampaignParticipantOutcomeResult { - const subscriptionId = useSelector(selectRewardsSubscriptionId); - const isOptedIn = - useSelector(selectCampaignParticipantStatus(subscriptionId, campaignId)) - ?.optedIn === true; - const [outcome, setOutcome] = - useState(null); - const [isLoading, setIsLoading] = useState(false); - const [hasError, setHasError] = useState(false); - - const fetchOutcome = useCallback(async (): Promise => { - if (!subscriptionId || !campaignId || !isOptedIn) { - setOutcome(null); - setIsLoading(false); - setHasError(false); - return; - } - - try { - setIsLoading(true); - setHasError(false); - const result = await Engine.controllerMessenger.call( - 'RewardsController:getOndoCampaignParticipantOutcome', - campaignId, - subscriptionId, - ); - setOutcome(result); - } catch { - setOutcome(null); - setHasError(true); - } finally { - setIsLoading(false); - } - }, [campaignId, subscriptionId, isOptedIn]); - - useEffect(() => { - fetchOutcome(); - }, [fetchOutcome]); - - return { outcome, isLoading, hasError }; + return useCampaignParticipantOutcome( + campaignId, + { messengerAction: 'RewardsController:getOndoCampaignParticipantOutcome' }, + ); } export default useOndoCampaignParticipantOutcome; diff --git a/app/components/UI/Rewards/hooks/useOndoOutcomeToast.test.ts b/app/components/UI/Rewards/hooks/useOndoOutcomeToast.test.ts index 7eff188438e..217d284cb64 100644 --- a/app/components/UI/Rewards/hooks/useOndoOutcomeToast.test.ts +++ b/app/components/UI/Rewards/hooks/useOndoOutcomeToast.test.ts @@ -1,554 +1,98 @@ import { renderHook } from '@testing-library/react-hooks'; -import { useContext } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { useFocusEffect, useNavigation } from '@react-navigation/native'; -import { - playSuccessNotification, - playWarningNotification, -} from '../../../../util/haptics'; import { useOndoOutcomeToast } from './useOndoOutcomeToast'; -import { dismissCampaignOutcomeToast } from '../../../../reducers/rewards'; -import { - selectCampaigns, - selectDismissedCampaignOutcomeToasts, -} from '../../../../reducers/rewards/selectors'; -import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { useCampaignOutcomeToast } from './useCampaignOutcomeToast'; import { useOndoCampaignParticipantOutcome } from './useOndoCampaignParticipantOutcome'; import { CampaignType, - type OndoGmCampaignParticipantOutcomeDto, + type CampaignDto, } from '../../../../core/Engine/controllers/rewards-controller/types'; import Routes from '../../../../constants/navigation/Routes'; -import { - ToastVariants, - ButtonIconVariant, -} from '../../../../component-library/components/Toast/Toast.types'; -import { IconName } from '../../../../component-library/components/Icons/Icon'; - -jest.mock('react', () => ({ - ...jest.requireActual('react'), - useContext: jest.fn(), - useCallback: jest.fn((fn) => fn), - useMemo: jest.fn((fn) => fn()), -})); - -jest.mock('react-redux', () => ({ - useDispatch: jest.fn(), - useSelector: jest.fn(), -})); - -jest.mock('@react-navigation/native', () => ({ - useFocusEffect: jest.fn(), - useNavigation: jest.fn(), -})); - -jest.mock('../../../../util/haptics', () => ({ - playSuccessNotification: jest.fn(), - playWarningNotification: jest.fn(), -})); - -jest.mock('../../../../../locales/i18n', () => ({ - strings: jest.fn((key: string, params?: Record) => { - if (params?.campaignName) return `${key}:${params.campaignName}`; - return key; - }), -})); - -jest.mock('../../../../util/theme', () => { - const actual = jest.requireActual('../../../../util/theme'); - return { - ...actual, - useAppThemeFromContext: () => actual.mockTheme, - }; -}); - -jest.mock('../../../../reducers/rewards', () => ({ - dismissCampaignOutcomeToast: jest.fn(), -})); - -jest.mock('../../../../reducers/rewards/selectors', () => ({ - selectCampaigns: jest.fn(), - selectDismissedCampaignOutcomeToasts: jest.fn(), -})); -jest.mock('../../../../selectors/rewards', () => ({ - selectRewardsSubscriptionId: jest.fn(), +jest.mock('./useCampaignOutcomeToast', () => ({ + useCampaignOutcomeToast: jest.fn(), })); jest.mock('./useOndoCampaignParticipantOutcome', () => ({ useOndoCampaignParticipantOutcome: jest.fn(), })); -const mockDispatch = jest.fn(); -const mockNavigate = jest.fn(); -const mockShowToast = jest.fn(); -const mockCloseToast = jest.fn(); -const mockToastRef = { - current: { showToast: mockShowToast, closeToast: mockCloseToast }, -}; - -const mockUseDispatch = useDispatch as jest.MockedFunction; -const mockUseSelector = useSelector as jest.MockedFunction; -const mockUseFocusEffect = useFocusEffect as jest.MockedFunction< - typeof useFocusEffect ->; -const mockUseNavigation = useNavigation as jest.MockedFunction< - typeof useNavigation ->; -const mockUseOndoCampaignParticipantOutcome = - useOndoCampaignParticipantOutcome as jest.MockedFunction< - typeof useOndoCampaignParticipantOutcome - >; -const mockDismissCampaignOutcomeToast = - dismissCampaignOutcomeToast as jest.MockedFunction< - typeof dismissCampaignOutcomeToast +const mockUseCampaignOutcomeToast = + useCampaignOutcomeToast as jest.MockedFunction< + typeof useCampaignOutcomeToast >; -const SUBSCRIPTION_ID = 'sub-123'; -const CAMPAIGN_ID = 'campaign-456'; -const CAMPAIGN_NAME = 'Ondo Test Campaign'; - -function makeParticipantOutcome( - options: Pick & { - winnerVerificationCode?: string | null; - subscriptionId?: string; - }, -): OndoGmCampaignParticipantOutcomeDto { - return { - subscriptionId: options.subscriptionId ?? SUBSCRIPTION_ID, - outcomeStatus: options.outcomeStatus, - winnerVerificationCode: options.winnerVerificationCode, - }; -} +const CAMPAIGN_ID = 'campaign-123'; +const CAMPAIGN_NAME = 'Ondo Campaign'; -const makeCompletedOndoCampaign = ( - id = CAMPAIGN_ID, - endDate = '2025-01-01T00:00:00Z', -) => ({ +const makeCampaign = (id = CAMPAIGN_ID): CampaignDto => ({ id, name: CAMPAIGN_NAME, type: CampaignType.ONDO_HOLDING, - endDate, - startDate: '2024-01-01T00:00:00Z', + endDate: '2025-01-01', + startDate: '2024-01-01', + termsAndConditions: null, + excludedRegions: [], + details: null, + featured: false, + showUpcomingDate: false, }); -function setupDefaults({ - campaigns = [], - dismissed = {}, - subscriptionId = SUBSCRIPTION_ID, - outcome = null, -}: { - campaigns?: ReturnType[]; - dismissed?: Record; - subscriptionId?: string | null; - outcome?: OndoGmCampaignParticipantOutcomeDto | null; -} = {}) { - mockUseSelector.mockImplementation((selector) => { - if (selector === selectCampaigns) return campaigns; - if (selector === selectDismissedCampaignOutcomeToasts) return dismissed; - if (selector === selectRewardsSubscriptionId) return subscriptionId; - return undefined; - }); - mockUseOndoCampaignParticipantOutcome.mockReturnValue({ - outcome, - isLoading: false, - hasError: false, - }); -} - describe('useOndoOutcomeToast', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseDispatch.mockReturnValue(mockDispatch); - mockUseNavigation.mockReturnValue({ navigate: mockNavigate } as never); - (useContext as jest.Mock).mockReturnValue({ toastRef: mockToastRef }); - mockUseFocusEffect.mockImplementation((cb) => { - cb(); - }); - mockDismissCampaignOutcomeToast.mockReturnValue({ - type: 'rewards/dismissCampaignOutcomeToast', - } as never); - }); - - describe('targetCampaign selection', () => { - it('passes undefined to useOndoCampaignParticipantOutcome when no campaigns', () => { - setupDefaults({ campaigns: [] }); - renderHook(() => useOndoOutcomeToast()); - expect(mockUseOndoCampaignParticipantOutcome).toHaveBeenCalledWith( - undefined, - ); - }); - - it('passes completed ONDO campaign id to useOndoCampaignParticipantOutcome', () => { - const campaign = makeCompletedOndoCampaign(); - setupDefaults({ campaigns: [campaign] }); - renderHook(() => useOndoOutcomeToast()); - expect(mockUseOndoCampaignParticipantOutcome).toHaveBeenCalledWith( - CAMPAIGN_ID, - ); - }); - - it('selects the most recently ended campaign when multiple exist', () => { - const older = makeCompletedOndoCampaign( - 'campaign-old', - '2024-06-01T00:00:00Z', - ); - const newer = makeCompletedOndoCampaign( - 'campaign-new', - '2025-01-01T00:00:00Z', - ); - setupDefaults({ campaigns: [older, newer] }); - renderHook(() => useOndoOutcomeToast()); - expect(mockUseOndoCampaignParticipantOutcome).toHaveBeenCalledWith( - 'campaign-new', - ); - }); - }); - - describe('variant derivation', () => { - it('does not show toast when outcome is null', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: null, - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('derives winner_verify when outcome has verification code and is not finalized', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'WINNER-XYZ', - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).toHaveBeenCalledWith( - expect.objectContaining({ iconName: IconName.Star }), - ); - }); - - it('derives participant_no_winner when outcome is finalized with no verification code', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: null, - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).toHaveBeenCalledWith( - expect.objectContaining({ iconName: IconName.Info }), - ); - }); - - it('does not show toast when outcome is finalized with a verification code', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: 'WINNER-XYZ', - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('does not show toast when outcome is pending with no verification code', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: null, - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); }); - describe('dismissal check', () => { - it('does not show toast when winner_verify toast was previously dismissed', () => { - const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:winner_verify`; - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - dismissed: { [key]: true }, - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('does not show toast when participant_no_winner toast was previously dismissed', () => { - const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:participant_no_winner`; - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: null, - }), - dismissed: { [key]: true }, - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('shows toast when a different variant was dismissed', () => { - const key = `${CAMPAIGN_ID}:${SUBSCRIPTION_ID}:participant_no_winner`; - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - dismissed: { [key]: true }, - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).toHaveBeenCalled(); - }); - }); - - describe('toast configuration', () => { - it('shows winner_verify toast with correct config', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - renderHook(() => useOndoOutcomeToast()); - - expect(mockShowToast).toHaveBeenCalledWith( - expect.objectContaining({ - variant: ToastVariants.Icon, - iconName: IconName.Star, - backgroundColor: 'transparent', - hasNoTimeout: true, - labelOptions: [ - { - label: 'rewards.ondo_outcome_toast.winner_verify.title', - isBold: true, - }, - ], - descriptionOptions: { - description: `rewards.ondo_outcome_toast.winner_verify.description:${CAMPAIGN_NAME}`, - }, - linkButtonOptions: expect.objectContaining({ - label: 'rewards.ondo_outcome_toast.winner_verify.cta', - }), - closeButtonOptions: expect.objectContaining({ - variant: ButtonIconVariant.Icon, - iconName: IconName.Close, - }), - }), - ); - }); - - it('shows participant_no_winner toast with correct config', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: null, - }), - }); - renderHook(() => useOndoOutcomeToast()); - - expect(mockShowToast).toHaveBeenCalledWith( - expect.objectContaining({ - variant: ToastVariants.Icon, - iconName: IconName.Info, - backgroundColor: 'transparent', - hasNoTimeout: true, - labelOptions: [ - { - label: 'rewards.ondo_outcome_toast.participant_no_winner.title', - isBold: true, - }, - ], - descriptionOptions: { - description: `rewards.ondo_outcome_toast.participant_no_winner.description:${CAMPAIGN_NAME}`, - }, - linkButtonOptions: expect.objectContaining({ - label: 'rewards.ondo_outcome_toast.participant_no_winner.cta', - }), - }), - ); - }); - - it('fires Success haptic for winner_verify', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(playSuccessNotification).toHaveBeenCalled(); - }); - - it('fires Warning haptic for participant_no_winner', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: null, - }), - }); - renderHook(() => useOndoOutcomeToast()); - expect(playWarningNotification).toHaveBeenCalled(); - }); + it('calls useCampaignOutcomeToast with ONDO_HOLDING campaign type', () => { + renderHook(() => useOndoOutcomeToast()); + expect(mockUseCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ + campaignType: CampaignType.ONDO_HOLDING, + }), + ); }); - describe('cleanup on blur', () => { - it('calls closeToast in the cleanup function when screen blurs', () => { - let cleanupFn: (() => void) | undefined; - mockUseFocusEffect.mockImplementation((cb) => { - const cleanup = cb(); - if (typeof cleanup === 'function') cleanupFn = cleanup; - }); - - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - renderHook(() => useOndoOutcomeToast()); - - expect(cleanupFn).toBeDefined(); - cleanupFn?.(); - expect(mockCloseToast).toHaveBeenCalledTimes(1); - }); - - it('does not return a cleanup function when variant is null', () => { - let cleanupFn: (() => void) | undefined; - mockUseFocusEffect.mockImplementation((cb) => { - const result = cb(); - if (typeof result === 'function') cleanupFn = result; - }); - - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: null, - }); - renderHook(() => useOndoOutcomeToast()); - - expect(cleanupFn).toBeUndefined(); - expect(mockCloseToast).not.toHaveBeenCalled(); - }); + it('passes useOndoCampaignParticipantOutcome as the useOutcome function', () => { + renderHook(() => useOndoOutcomeToast()); + expect(mockUseCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ + useOutcome: useOndoCampaignParticipantOutcome, + }), + ); }); - describe('handleDismiss', () => { - it('dispatches dismissCampaignOutcomeToast and closes toast when close button is pressed', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - renderHook(() => useOndoOutcomeToast()); - - const closeButtonOptions = - mockShowToast.mock.calls[0][0].closeButtonOptions; - closeButtonOptions.onPress(); - - expect(mockDispatch).toHaveBeenCalledWith( - mockDismissCampaignOutcomeToast.mock.results[0]?.value, - ); - expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({ - campaignId: CAMPAIGN_ID, - subscriptionId: SUBSCRIPTION_ID, - variant: 'winner_verify', - }); - expect(mockCloseToast).toHaveBeenCalled(); + it('getWinnerNavigation returns ONDO winning view route with campaignId and campaignName', () => { + renderHook(() => useOndoOutcomeToast()); + const { getWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getWinnerNavigation(makeCampaign()); + expect(nav).toEqual({ + route: Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, + params: { campaignId: CAMPAIGN_ID, campaignName: CAMPAIGN_NAME }, }); }); - describe('handleCta', () => { - it('navigates to winning view and dismisses for winner_verify', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - renderHook(() => useOndoOutcomeToast()); - - const linkButtonOptions = - mockShowToast.mock.calls[0][0].linkButtonOptions; - linkButtonOptions.onPress(); - - expect(mockNavigate).toHaveBeenCalledWith( - Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, - { campaignId: CAMPAIGN_ID }, - ); - expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({ - campaignId: CAMPAIGN_ID, - subscriptionId: SUBSCRIPTION_ID, - variant: 'winner_verify', - }); + it('getWinnerNavigation uses empty string for campaignName when name is null', () => { + renderHook(() => useOndoOutcomeToast()); + const { getWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getWinnerNavigation({ + ...makeCampaign(), + name: null as unknown as string, }); - - it('navigates to campaign details view and dismisses for participant_no_winner', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'finalized', - winnerVerificationCode: null, - }), - }); - renderHook(() => useOndoOutcomeToast()); - - const linkButtonOptions = - mockShowToast.mock.calls[0][0].linkButtonOptions; - linkButtonOptions.onPress(); - - expect(mockNavigate).toHaveBeenCalledWith( - Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, - { campaignId: CAMPAIGN_ID }, - ); - expect(mockDismissCampaignOutcomeToast).toHaveBeenCalledWith({ - campaignId: CAMPAIGN_ID, - subscriptionId: SUBSCRIPTION_ID, - variant: 'participant_no_winner', - }); + expect(nav.params).toEqual({ + campaignId: CAMPAIGN_ID, + campaignName: '', }); }); - describe('edge cases', () => { - it('does not show toast when subscriptionId is missing', () => { - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - subscriptionId: null, - }); - renderHook(() => useOndoOutcomeToast()); - expect(mockShowToast).not.toHaveBeenCalled(); - }); - - it('handles null toastRef gracefully', () => { - (useContext as jest.Mock).mockReturnValue({ toastRef: null }); - setupDefaults({ - campaigns: [makeCompletedOndoCampaign()], - outcome: makeParticipantOutcome({ - outcomeStatus: 'pending', - winnerVerificationCode: 'CODE', - }), - }); - expect(() => renderHook(() => useOndoOutcomeToast())).not.toThrow(); + it('getNonWinnerNavigation returns ONDO campaign details route', () => { + renderHook(() => useOndoOutcomeToast()); + const { getNonWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getNonWinnerNavigation(makeCampaign()); + expect(nav).toEqual({ + route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: CAMPAIGN_ID }, }); }); }); diff --git a/app/components/UI/Rewards/hooks/useOndoOutcomeToast.ts b/app/components/UI/Rewards/hooks/useOndoOutcomeToast.ts index 87c295cf1f7..2a736f931c8 100644 --- a/app/components/UI/Rewards/hooks/useOndoOutcomeToast.ts +++ b/app/components/UI/Rewards/hooks/useOndoOutcomeToast.ts @@ -1,160 +1,21 @@ -import { useCallback, useContext, useMemo } from 'react'; -import { useFocusEffect, useNavigation } from '@react-navigation/native'; -import { useDispatch, useSelector } from 'react-redux'; -import { - playSuccessNotification, - playWarningNotification, -} from '../../../../util/haptics'; -import Routes from '../../../../constants/navigation/Routes'; -import { strings } from '../../../../../locales/i18n'; -import { ToastContext } from '../../../../component-library/components/Toast'; -import { - ButtonIconVariant, - ToastVariants, -} from '../../../../component-library/components/Toast/Toast.types'; -import { IconName } from '../../../../component-library/components/Icons/Icon'; -import { useAppThemeFromContext } from '../../../../util/theme'; import { CampaignType } from '../../../../core/Engine/controllers/rewards-controller/types'; -import { getCampaignStatus } from '../components/Campaigns/CampaignTile.utils'; -import { dismissCampaignOutcomeToast } from '../../../../reducers/rewards'; -import { - selectCampaigns, - selectDismissedCampaignOutcomeToasts, -} from '../../../../reducers/rewards/selectors'; -import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import Routes from '../../../../constants/navigation/Routes'; +import { useCampaignOutcomeToast } from './useCampaignOutcomeToast'; import { useOndoCampaignParticipantOutcome } from './useOndoCampaignParticipantOutcome'; -export type OutcomeToastVariant = 'winner_verify' | 'participant_no_winner'; - export function useOndoOutcomeToast(): void { - const dispatch = useDispatch(); - const { toastRef } = useContext(ToastContext); - const theme = useAppThemeFromContext(); - const navigation = useNavigation(); - - const subscriptionId = useSelector(selectRewardsSubscriptionId); - const campaigns = useSelector(selectCampaigns); - const dismissed = useSelector(selectDismissedCampaignOutcomeToasts); - - const targetCampaign = useMemo(() => { - const completed = campaigns - .filter( - (c) => - c.type === CampaignType.ONDO_HOLDING && - getCampaignStatus(c) === 'complete', - ) - .sort( - (a, b) => new Date(b.endDate).getTime() - new Date(a.endDate).getTime(), - ); - return completed[0] ?? null; - }, [campaigns]); - - const { outcome } = useOndoCampaignParticipantOutcome(targetCampaign?.id); - - const variant = useMemo((): OutcomeToastVariant | null => { - if (!outcome) return null; - if ( - outcome.winnerVerificationCode && - outcome.outcomeStatus !== 'finalized' - ) { - return 'winner_verify'; - } - if ( - outcome.outcomeStatus === 'finalized' && - !outcome.winnerVerificationCode - ) { - return 'participant_no_winner'; - } - return null; - }, [outcome]); - - const isDismissed = useMemo(() => { - if (!variant || !targetCampaign || !subscriptionId) return true; - const key = `${targetCampaign.id}:${subscriptionId}:${variant}`; - return dismissed[key] === true; - }, [variant, targetCampaign, subscriptionId, dismissed]); - - const handleDismiss = useCallback(() => { - if (!variant || !targetCampaign || !subscriptionId) return; - dispatch( - dismissCampaignOutcomeToast({ - campaignId: targetCampaign.id, - subscriptionId, - variant, - }), - ); - toastRef?.current?.closeToast(); - }, [variant, targetCampaign, subscriptionId, dispatch, toastRef]); - - const handleCta = useCallback(() => { - if (!targetCampaign || !variant) return; - handleDismiss(); - if (variant === 'winner_verify') { - navigation.navigate(Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, { - campaignId: targetCampaign.id, - }); - } else { - navigation.navigate(Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, { - campaignId: targetCampaign.id, - }); - } - }, [variant, targetCampaign, handleDismiss, navigation]); - - useFocusEffect( - useCallback(() => { - if (!variant || isDismissed || !targetCampaign) return; - - const isWinner = variant === 'winner_verify'; - toastRef?.current?.showToast({ - variant: ToastVariants.Icon, - iconName: isWinner ? IconName.Star : IconName.Info, - iconColor: isWinner - ? theme.colors.warning.default - : theme.colors.success.default, - backgroundColor: 'transparent', - hasNoTimeout: true, - labelOptions: [ - { - label: strings(`rewards.ondo_outcome_toast.${variant}.title`), - isBold: true, - }, - ], - descriptionOptions: { - description: strings( - `rewards.ondo_outcome_toast.${variant}.description`, - { campaignName: targetCampaign.name }, - ), - }, - linkButtonOptions: { - label: strings(`rewards.ondo_outcome_toast.${variant}.cta`), - onPress: handleCta, - }, - closeButtonOptions: { - variant: ButtonIconVariant.Icon, - iconName: IconName.Close, - onPress: handleDismiss, - }, - }); - if (isWinner) { - playSuccessNotification(); - } else { - playWarningNotification(); - } - - return () => { - toastRef?.current?.closeToast(); - }; - }, [ - variant, - isDismissed, - targetCampaign, - toastRef, - theme.colors.warning.default, - theme.colors.success.default, - handleCta, - handleDismiss, - ]), - ); + useCampaignOutcomeToast({ + campaignType: CampaignType.ONDO_HOLDING, + useOutcome: useOndoCampaignParticipantOutcome, + getWinnerNavigation: (campaign) => ({ + route: Routes.REWARDS_ONDO_CAMPAIGN_WINNING_VIEW, + params: { campaignId: campaign.id, campaignName: campaign.name ?? '' }, + }), + getNonWinnerNavigation: (campaign) => ({ + route: Routes.REWARDS_ONDO_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: campaign.id }, + }), + }); } export default useOndoOutcomeToast; diff --git a/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts b/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts index c94087723b0..e97add67a7e 100644 --- a/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts +++ b/app/components/UI/Rewards/hooks/useOptInToCampaign.test.ts @@ -1,10 +1,12 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { useOptInToCampaign } from './useOptInToCampaign'; import Engine from '../../../../core/Engine'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { setCampaignParticipantStatus } from '../../../../reducers/rewards'; jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), useSelector: jest.fn(), })); @@ -16,10 +18,22 @@ jest.mock('../../../../selectors/rewards', () => ({ selectRewardsSubscriptionId: jest.fn(), })); +jest.mock('../../../../reducers/rewards', () => ({ + setCampaignParticipantStatus: jest.fn((payload) => ({ + type: 'rewards/setCampaignParticipantStatus', + payload, + })), +})); + const mockCall = Engine.controllerMessenger.call as jest.MockedFunction< typeof Engine.controllerMessenger.call >; const mockUseSelector = useSelector as jest.MockedFunction; +const mockUseDispatch = useDispatch as jest.MockedFunction; +const mockSetCampaignParticipantStatus = + setCampaignParticipantStatus as unknown as jest.MockedFunction< + typeof setCampaignParticipantStatus + >; const SUB_ID = 'sub-123'; const CAMPAIGN_ID = 'camp-456'; @@ -33,8 +47,11 @@ function setupSelectors(subscriptionId: string | null) { } describe('useOptInToCampaign', () => { + const mockDispatch = jest.fn(); + beforeEach(() => { jest.clearAllMocks(); + mockUseDispatch.mockReturnValue(mockDispatch); }); it('returns null when subscriptionId is missing', async () => { @@ -63,6 +80,18 @@ describe('useOptInToCampaign', () => { CAMPAIGN_ID, SUB_ID, ); + expect(mockDispatch).toHaveBeenCalledWith( + setCampaignParticipantStatus({ + subscriptionId: SUB_ID, + campaignId: CAMPAIGN_ID, + status: STATUS, + }), + ); + expect(mockSetCampaignParticipantStatus).toHaveBeenCalledWith({ + subscriptionId: SUB_ID, + campaignId: CAMPAIGN_ID, + status: STATUS, + }); expect(returnValue).toEqual(STATUS); expect(result.current.isOptingIn).toBe(false); expect(result.current.optInError).toBeUndefined(); diff --git a/app/components/UI/Rewards/hooks/useOptInToCampaign.ts b/app/components/UI/Rewards/hooks/useOptInToCampaign.ts index b3852646997..a0533e7889f 100644 --- a/app/components/UI/Rewards/hooks/useOptInToCampaign.ts +++ b/app/components/UI/Rewards/hooks/useOptInToCampaign.ts @@ -1,7 +1,8 @@ import { useCallback, useState } from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import Engine from '../../../../core/Engine'; import { selectRewardsSubscriptionId } from '../../../../selectors/rewards'; +import { setCampaignParticipantStatus } from '../../../../reducers/rewards'; import type { CampaignParticipantStatusDto } from '../../../../core/Engine/controllers/rewards-controller/types'; export interface UseOptInToCampaignResult { @@ -22,6 +23,7 @@ export interface UseOptInToCampaignResult { */ export const useOptInToCampaign = (): UseOptInToCampaignResult => { const subscriptionId = useSelector(selectRewardsSubscriptionId); + const dispatch = useDispatch(); const [isOptingIn, setIsOptingIn] = useState(false); const [optInError, setOptInError] = useState(undefined); @@ -36,11 +38,19 @@ export const useOptInToCampaign = (): UseOptInToCampaignResult => { try { setIsOptingIn(true); setOptInError(undefined); - return await Engine.controllerMessenger.call( + const result = await Engine.controllerMessenger.call( 'RewardsController:optInToCampaign', campaignId, subscriptionId, ); + dispatch( + setCampaignParticipantStatus({ + subscriptionId, + campaignId, + status: result, + }), + ); + return result; } catch (error) { const message = error instanceof Error ? error.message : 'Opt-in failed'; @@ -50,7 +60,7 @@ export const useOptInToCampaign = (): UseOptInToCampaignResult => { setIsOptingIn(false); } }, - [subscriptionId], + [dispatch, subscriptionId], ); const clearOptInError = useCallback(() => setOptInError(undefined), []); diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts new file mode 100644 index 00000000000..44c7d775a5c --- /dev/null +++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.test.ts @@ -0,0 +1,98 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { usePerpsTradingCampaignEndedOutcomeToast } from './usePerpsTradingCampaignEndedOutcomeToast'; +import { useCampaignOutcomeToast } from './useCampaignOutcomeToast'; +import { usePerpsTradingCampaignParticipantOutcome } from './usePerpsTradingCampaignParticipantOutcome'; +import { + CampaignType, + type CampaignDto, +} from '../../../../core/Engine/controllers/rewards-controller/types'; +import Routes from '../../../../constants/navigation/Routes'; + +jest.mock('./useCampaignOutcomeToast', () => ({ + useCampaignOutcomeToast: jest.fn(), +})); + +jest.mock('./usePerpsTradingCampaignParticipantOutcome', () => ({ + usePerpsTradingCampaignParticipantOutcome: jest.fn(), +})); + +const mockUseCampaignOutcomeToast = + useCampaignOutcomeToast as jest.MockedFunction< + typeof useCampaignOutcomeToast + >; + +const CAMPAIGN_ID = 'campaign-xyz'; +const CAMPAIGN_NAME = 'Perps Campaign'; + +const makeCampaign = (id = CAMPAIGN_ID, name = CAMPAIGN_NAME): CampaignDto => ({ + id, + name, + type: CampaignType.PERPS_TRADING, + endDate: '2025-01-01', + startDate: '2024-01-01', + termsAndConditions: null, + excludedRegions: [], + details: null, + featured: false, + showUpcomingDate: false, +}); + +describe('usePerpsTradingCampaignEndedOutcomeToast', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls useCampaignOutcomeToast with PERPS_TRADING campaign type', () => { + renderHook(() => usePerpsTradingCampaignEndedOutcomeToast()); + expect(mockUseCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ + campaignType: CampaignType.PERPS_TRADING, + }), + ); + }); + + it('passes usePerpsTradingCampaignParticipantOutcome as the useOutcome function', () => { + renderHook(() => usePerpsTradingCampaignEndedOutcomeToast()); + expect(mockUseCampaignOutcomeToast).toHaveBeenCalledWith( + expect.objectContaining({ + useOutcome: usePerpsTradingCampaignParticipantOutcome, + }), + ); + }); + + it('getWinnerNavigation returns Perps winning view route with campaignId and campaignName', () => { + renderHook(() => usePerpsTradingCampaignEndedOutcomeToast()); + const { getWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getWinnerNavigation(makeCampaign()); + expect(nav).toEqual({ + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, + params: { campaignId: CAMPAIGN_ID, campaignName: CAMPAIGN_NAME }, + }); + }); + + it('getWinnerNavigation uses empty string for campaignName when name is null', () => { + renderHook(() => usePerpsTradingCampaignEndedOutcomeToast()); + const { getWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getWinnerNavigation({ + ...makeCampaign(), + name: null as unknown as string, + }); + expect(nav.params).toEqual({ + campaignId: CAMPAIGN_ID, + campaignName: '', + }); + }); + + it('getNonWinnerNavigation returns Perps details view route', () => { + renderHook(() => usePerpsTradingCampaignEndedOutcomeToast()); + const { getNonWinnerNavigation } = + mockUseCampaignOutcomeToast.mock.calls[0][0]; + const nav = getNonWinnerNavigation(makeCampaign()); + expect(nav).toEqual({ + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: CAMPAIGN_ID }, + }); + }); +}); diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.ts new file mode 100644 index 00000000000..ae99229b1ba --- /dev/null +++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignEndedOutcomeToast.ts @@ -0,0 +1,21 @@ +import { CampaignType } from '../../../../core/Engine/controllers/rewards-controller/types'; +import Routes from '../../../../constants/navigation/Routes'; +import { useCampaignOutcomeToast } from './useCampaignOutcomeToast'; +import { usePerpsTradingCampaignParticipantOutcome } from './usePerpsTradingCampaignParticipantOutcome'; + +export function usePerpsTradingCampaignEndedOutcomeToast(): void { + useCampaignOutcomeToast({ + campaignType: CampaignType.PERPS_TRADING, + useOutcome: usePerpsTradingCampaignParticipantOutcome, + getWinnerNavigation: (campaign) => ({ + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW, + params: { campaignId: campaign.id, campaignName: campaign.name ?? '' }, + }), + getNonWinnerNavigation: (campaign) => ({ + route: Routes.REWARDS_PERPS_TRADING_CAMPAIGN_DETAILS_VIEW, + params: { campaignId: campaign.id }, + }), + }); +} + +export default usePerpsTradingCampaignEndedOutcomeToast; diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.test.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.test.ts new file mode 100644 index 00000000000..35795726f9f --- /dev/null +++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.test.ts @@ -0,0 +1,68 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { usePerpsTradingCampaignParticipantOutcome } from './usePerpsTradingCampaignParticipantOutcome'; +import { useCampaignParticipantOutcome } from './useCampaignParticipantOutcome'; + +jest.mock('./useCampaignParticipantOutcome', () => ({ + useCampaignParticipantOutcome: jest.fn(), +})); + +const mockUseCampaignParticipantOutcome = + useCampaignParticipantOutcome as jest.MockedFunction< + typeof useCampaignParticipantOutcome + >; + +const CAMPAIGN_ID = 'campaign-xyz'; + +describe('usePerpsTradingCampaignParticipantOutcome', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseCampaignParticipantOutcome.mockReturnValue({ + outcome: null, + isLoading: false, + hasError: false, + }); + }); + + it('delegates to useCampaignParticipantOutcome with the Perps messenger action', () => { + renderHook(() => usePerpsTradingCampaignParticipantOutcome(CAMPAIGN_ID)); + + expect(mockUseCampaignParticipantOutcome).toHaveBeenCalledWith( + CAMPAIGN_ID, + { + messengerAction: + 'RewardsController:getPerpsTradingCampaignParticipantOutcome', + }, + ); + }); + + it('passes undefined campaignId through to the generic hook', () => { + renderHook(() => usePerpsTradingCampaignParticipantOutcome(undefined)); + + expect(mockUseCampaignParticipantOutcome).toHaveBeenCalledWith(undefined, { + messengerAction: + 'RewardsController:getPerpsTradingCampaignParticipantOutcome', + }); + }); + + it('returns the result from the generic hook', () => { + const mockOutcome = { + subscriptionId: 'sub-1', + outcomeStatus: 'pending' as const, + winnerVerificationCode: 'CODE', + rank: 3, + }; + mockUseCampaignParticipantOutcome.mockReturnValue({ + outcome: mockOutcome, + isLoading: false, + hasError: false, + }); + + const { result } = renderHook(() => + usePerpsTradingCampaignParticipantOutcome(CAMPAIGN_ID), + ); + + expect(result.current.outcome).toEqual(mockOutcome); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasError).toBe(false); + }); +}); diff --git a/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.ts b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.ts new file mode 100644 index 00000000000..255885f2823 --- /dev/null +++ b/app/components/UI/Rewards/hooks/usePerpsTradingCampaignParticipantOutcome.ts @@ -0,0 +1,22 @@ +import type { PerpsTradingCampaignParticipantOutcomeDto } from '../../../../core/Engine/controllers/rewards-controller/types'; +import { + useCampaignParticipantOutcome, + type UseCampaignParticipantOutcomeResult, +} from './useCampaignParticipantOutcome'; + +export type UsePerpsTradingCampaignParticipantOutcomeResult = + UseCampaignParticipantOutcomeResult; + +export function usePerpsTradingCampaignParticipantOutcome( + campaignId: string | undefined, +): UsePerpsTradingCampaignParticipantOutcomeResult { + return useCampaignParticipantOutcome( + campaignId, + { + messengerAction: + 'RewardsController:getPerpsTradingCampaignParticipantOutcome', + }, + ); +} + +export default usePerpsTradingCampaignParticipantOutcome; diff --git a/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx b/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx index 4f3ec45b6c7..aa29b8cbfb2 100644 --- a/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx +++ b/app/components/UI/Rewards/hooks/useRewardsToast.test.tsx @@ -390,6 +390,68 @@ describe('useRewardsToast', () => { expect(mockCloseToast).toHaveBeenCalledTimes(1); }); + + it('returns outcomeWinner configuration with CTA and close handlers', () => { + const { result } = renderHook(() => useRewardsToast()); + const onCta = jest.fn(); + const onClose = jest.fn(); + const config = result.current.RewardsToastOptions.outcomeWinner({ + title: 'Winner title', + description: 'Winner body', + ctaLabel: 'Next', + onCtaPress: onCta, + onClosePress: onClose, + }); + + expect(config).toMatchObject({ + variant: ToastVariants.Plain, + hasNoTimeout: true, + hapticsType: NotificationMoment.Success, + descriptionOptions: { description: 'Winner body' }, + linkButtonOptions: { label: 'Next' }, + }); + expect(config.labelOptions).toEqual([ + { label: 'Winner title', isBold: true }, + ]); + expect(config.startAccessory).toBeDefined(); + const { getByTestId } = render(config.startAccessory as ReactElement); + expect(getByTestId('rewards-nudge-start-accessory-box')).toBeDefined(); + config.linkButtonOptions?.onPress?.(); + config.closeButtonOptions?.onPress?.(); + expect(onCta).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('returns outcomeNonWinner configuration with CTA and close handlers', () => { + const { result } = renderHook(() => useRewardsToast()); + const onCta = jest.fn(); + const onClose = jest.fn(); + const config = result.current.RewardsToastOptions.outcomeNonWinner({ + title: 'Thanks title', + description: 'Thanks body', + ctaLabel: 'Done', + onCtaPress: onCta, + onClosePress: onClose, + }); + + expect(config).toMatchObject({ + variant: ToastVariants.Icon, + iconName: IconName.Confirmation, + iconColor: mockTheme.colors.success.default, + backgroundColor: 'transparent', + hasNoTimeout: true, + hapticsType: NotificationMoment.Warning, + descriptionOptions: { description: 'Thanks body' }, + linkButtonOptions: { label: 'Done' }, + }); + expect(config.labelOptions).toEqual([ + { label: 'Thanks title', isBold: true }, + ]); + config.linkButtonOptions?.onPress?.(); + config.closeButtonOptions?.onPress?.(); + expect(onCta).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); }); describe('edge cases and error handling', () => { diff --git a/app/components/UI/Rewards/hooks/useRewardsToast.tsx b/app/components/UI/Rewards/hooks/useRewardsToast.tsx index db14b667bd8..48fa2bfe57c 100644 --- a/app/components/UI/Rewards/hooks/useRewardsToast.tsx +++ b/app/components/UI/Rewards/hooks/useRewardsToast.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useContext, useMemo } from 'react'; import { ActivityIndicator } from 'react-native'; +import { Box } from '@metamask/design-system-react-native'; import { ToastContext } from '../../../../component-library/components/Toast'; import { ButtonIconVariant, @@ -18,12 +19,20 @@ import { } from '../../../../util/haptics'; import { strings } from '../../../../../locales/i18n'; import RewardsNotificationIcon from '../../../../images/rewards/notification.svg'; -import { Box } from '@metamask/design-system-react-native'; +import RewardsTrophyIcon from '../../../../images/rewards/trophy.svg'; export type RewardsToastOptions = ToastOptions & { hapticsType: HapticNotificationMoment; }; +export interface OutcomeCtaToastParams { + title: string; + description: string; + ctaLabel: string; + onCtaPress: () => void; + onClosePress: () => void; +} + export interface RewardsToastConfig { success: (title: string, subtitle?: string) => RewardsToastOptions; error: (title: string, subtitle?: string) => RewardsToastOptions; @@ -32,6 +41,8 @@ export interface RewardsToastConfig { enableNotificationsNudge: ( linkButtonOptions: ToastLinkButtonOptions, ) => RewardsToastOptions; + outcomeWinner: (params: OutcomeCtaToastParams) => RewardsToastOptions; + outcomeNonWinner: (params: OutcomeCtaToastParams) => RewardsToastOptions; } const getRewardsToastLabels = (title: string): ToastLabelOptions => { @@ -183,6 +194,64 @@ const useRewardsToast = (): { }, }, }), + outcomeWinner: ({ + title, + description, + ctaLabel, + onCtaPress, + onClosePress, + }: OutcomeCtaToastParams) => ({ + ...(REWARDS_TOASTS_DEFAULT_OPTIONS as RewardsToastOptions), + variant: ToastVariants.Plain, + hasNoTimeout: true, + hapticsType: NotificationMoment.Success, + startAccessory: ( + + + + ), + labelOptions: getRewardsToastLabels(title), + descriptionOptions: { description }, + linkButtonOptions: { + label: ctaLabel, + onPress: onCtaPress, + }, + closeButtonOptions: { + variant: ButtonIconVariant.Icon, + iconName: IconName.Close, + onPress: onClosePress, + }, + }), + outcomeNonWinner: ({ + title, + description, + ctaLabel, + onCtaPress, + onClosePress, + }: OutcomeCtaToastParams) => ({ + variant: ToastVariants.Icon, + iconName: IconName.Confirmation, + iconColor: theme.colors.success.default, + backgroundColor: 'transparent', + hasNoTimeout: true, + hapticsType: NotificationMoment.Warning, + labelOptions: getRewardsToastLabels(title), + descriptionOptions: { description }, + linkButtonOptions: { + label: ctaLabel, + onPress: onCtaPress, + }, + closeButtonOptions: { + variant: ButtonIconVariant.Icon, + iconName: IconName.Close, + onPress: onClosePress, + }, + }), }), [ theme.colors.success.default, diff --git a/app/components/UI/Rewards/utils.ts b/app/components/UI/Rewards/utils.ts index cf902295145..fff1df35443 100644 --- a/app/components/UI/Rewards/utils.ts +++ b/app/components/UI/Rewards/utils.ts @@ -106,6 +106,7 @@ export enum RewardsMetricsButtons { VISIT_APP_STORE = 'visit_app_store', BUY_MUSD = 'buy_musd', SWAP_TO_MUSD = 'swap_to_musd', + COPY_WINNER_VERIFICATION_CODE = 'copy_winner_verification_code', } export const deriveAccountMetricProps = (account?: InternalAccount) => { diff --git a/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx b/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx index f3aa0ed469c..d029711cd68 100644 --- a/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx +++ b/app/components/UI/SecurityTrust/components/SecurityTrustEntryCard/SecurityTrustEntryCard.tsx @@ -124,9 +124,9 @@ const SecurityTrustEntryCard: React.FC = ({ )} {config.label} diff --git a/app/components/Views/confirmations/components/recipient-input/recipient-input.test.tsx b/app/components/Views/confirmations/components/recipient-input/recipient-input.test.tsx index 95faf9246de..d91a1ce1a23 100644 --- a/app/components/Views/confirmations/components/recipient-input/recipient-input.test.tsx +++ b/app/components/Views/confirmations/components/recipient-input/recipient-input.test.tsx @@ -80,7 +80,6 @@ describe('RecipientInput', () => { loading: false, resolvedAddress: undefined, toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: undefined, }); diff --git a/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx b/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx index 7a8aae5cbca..75220003613 100644 --- a/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx +++ b/app/components/Views/confirmations/components/send/recipient/recipient.test.tsx @@ -10,6 +10,7 @@ import { useContacts } from '../../../hooks/send/useContacts'; import { useToAddressValidation } from '../../../hooks/send/useToAddressValidation'; import { useRecipientSelectionMetrics } from '../../../hooks/send/metrics/useRecipientSelectionMetrics'; import { useSendActions } from '../../../hooks/send/useSendActions'; +import { useSendAlerts } from '../../../hooks/send/alerts/useSendAlerts'; import { useSendType } from '../../../hooks/send/useSendType'; import { RecipientType } from '../../UI/recipient'; import { Recipient } from './recipient'; @@ -65,6 +66,10 @@ jest.mock('../../../hooks/send/useToAddressValidation', () => ({ useToAddressValidation: jest.fn(), })); +jest.mock('../../../hooks/send/alerts/useSendAlerts', () => ({ + useSendAlerts: jest.fn(), +})); + jest.mock('../../../hooks/send/metrics/useRecipientSelectionMetrics', () => ({ useRecipientSelectionMetrics: jest.fn(), })); @@ -149,21 +154,20 @@ jest.mock('../send-alert-modal', () => ({ isOpen, onAcknowledge, onClose, - title, - errorMessage, + alerts, }: { isOpen: boolean; onAcknowledge: () => void; onClose: () => void; - title: string; - errorMessage: string; + alerts: { title: string; message: string }[]; }) => { const { View, Text, Pressable } = jest.requireActual('react-native'); - if (!isOpen) return null; + if (!isOpen || !alerts?.length) return null; + const first = alerts[0]; return ( - {title} - {errorMessage} + {first.title} + {first.message} Acknowledge @@ -199,6 +203,7 @@ const mockUseRecipientSelectionMetrics = jest.mocked( useRecipientSelectionMetrics, ); const mockUseSendActions = jest.mocked(useSendActions); +const mockUseSendAlerts = jest.mocked(useSendAlerts); const mockUseSendType = jest.mocked(useSendType); function createMockUseSendType( @@ -239,11 +244,17 @@ describe('Recipient', () => { loading: false, resolvedAddress: undefined, toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: undefined, }); + mockUseSendAlerts.mockReturnValue({ + alerts: [], + hasUnacknowledgedAlerts: false, + acknowledgeAlerts: jest.fn(), + isAlertCheckPending: false, + }); + mockUseRecipientSelectionMetrics.mockReturnValue({ captureRecipientSelected: mockCaptureRecipientSelected, }); @@ -342,7 +353,6 @@ describe('Recipient', () => { loading: false, resolvedAddress: 'some_dummy_address', toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: undefined, }); @@ -435,7 +445,6 @@ describe('Recipient', () => { loading: false, resolvedAddress: undefined, toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: 'Warning', }); @@ -463,7 +472,6 @@ describe('Recipient', () => { loading: false, resolvedAddress: undefined, toAddressError: 'Error', - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: 'Warning', }); @@ -501,7 +509,6 @@ describe('Recipient', () => { loading: true, resolvedAddress: undefined, toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: 'Warning', }); @@ -569,7 +576,6 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => { loading: false, resolvedAddress: '0xresolved', toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: '0xother', toAddressWarning: undefined, }); @@ -585,7 +591,6 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => { loading: false, resolvedAddress: '0xresolved', toAddressError: 'Invalid address', - toAddressErrorAllowAcknowledge: false, toAddressValidated: '0xvalid', toAddressWarning: undefined, }); @@ -601,7 +606,6 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => { loading: false, resolvedAddress: '0xresolved', toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: '0xvalid', toAddressWarning: 'Warning', }); @@ -617,7 +621,6 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => { loading: true, resolvedAddress: '0xresolved', toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: '0xvalid', toAddressWarning: undefined, }); @@ -634,7 +637,6 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => { loading: false, resolvedAddress: undefined, toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: undefined, }); @@ -668,7 +670,6 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => { loading: false, resolvedAddress: undefined, toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: undefined, }); @@ -706,7 +707,6 @@ describe('Recipient pastedRecipient effect gating (lines 96-101)', () => { loading: false, resolvedAddress: undefined, toAddressError: 'Error', - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: undefined, }); @@ -744,7 +744,10 @@ describe('SendAlertModal integration', () => { let mockHandleSubmitPressLocal: jest.Mock; const setupTokenContractScenario = ( - overrides: Partial> = {}, + validationOverrides: Partial< + ReturnType + > = {}, + sendAlertsOverrides: Partial> = {}, ) => { mockHandleSubmitPressLocal = jest.fn(); mockUseSendActions.mockReturnValue({ @@ -771,11 +774,24 @@ describe('SendAlertModal integration', () => { mockUseToAddressValidation.mockReturnValue({ loading: false, resolvedAddress: undefined, - toAddressError: 'Token contract warning', - toAddressErrorAllowAcknowledge: true, + toAddressError: undefined, toAddressValidated: '0x1234567890123456789012345678901234567890', toAddressWarning: undefined, - ...overrides, + ...validationOverrides, + }); + + mockUseSendAlerts.mockReturnValue({ + alerts: [ + { + key: 'tokenContract', + title: 'Smart contract address', + message: 'You are sending to a smart contract address', + }, + ], + hasUnacknowledgedAlerts: true, + acknowledgeAlerts: jest.fn(), + isAlertCheckPending: false, + ...sendAlertsOverrides, }); mockUseRecipientSelectionMetrics.mockReturnValue({ @@ -793,7 +809,7 @@ describe('SendAlertModal integration', () => { jest.clearAllMocks(); }); - it('opens alert modal when review pressed and toAddressErrorAllowAcknowledge is true', () => { + it('opens alert modal when review pressed and has unacknowledged send alerts', () => { setupTokenContractScenario(); const { getByTestId } = renderWithProvider(); @@ -841,11 +857,16 @@ describe('SendAlertModal integration', () => { ); }); - it('does not show alert modal when toAddressErrorAllowAcknowledge is false', () => { - setupTokenContractScenario({ - toAddressError: 'Some error', - toAddressErrorAllowAcknowledge: false, - }); + it('does not show alert modal when there are no unacknowledged send alerts', () => { + setupTokenContractScenario( + { + toAddressError: 'Some error', + }, + { + alerts: [], + hasUnacknowledgedAlerts: false, + }, + ); const { getByTestId, queryByTestId } = renderWithProvider(); @@ -892,11 +913,17 @@ describe('SendAlertModal integration', () => { loading: false, resolvedAddress: undefined, toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: '0xvalid', toAddressWarning: undefined, }); + mockUseSendAlerts.mockReturnValue({ + alerts: [], + hasUnacknowledgedAlerts: false, + acknowledgeAlerts: jest.fn(), + isAlertCheckPending: false, + }); + mockUseAccounts.mockReturnValue(mockAccounts); mockUseContacts.mockReturnValue(mockContacts); mockUseSendType.mockReturnValue({ diff --git a/app/components/Views/confirmations/components/send/recipient/recipient.tsx b/app/components/Views/confirmations/components/send/recipient/recipient.tsx index 3fce15c9076..178ff99b9ac 100644 --- a/app/components/Views/confirmations/components/send/recipient/recipient.tsx +++ b/app/components/Views/confirmations/components/send/recipient/recipient.tsx @@ -15,6 +15,7 @@ import Banner, { } from '../../../../../../component-library/components/Banners/Banner'; import { useSendContext } from '../../../context/send-context/send-context'; import { RecipientInputMethod } from '../../../context/send-context/send-metrics-context'; +import { useSendAlerts } from '../../../hooks/send/alerts/useSendAlerts'; import { useRecipientSelectionMetrics } from '../../../hooks/send/metrics/useRecipientSelectionMetrics'; import { useAccounts } from '../../../hooks/send/useAccounts'; import { useContacts } from '../../../hooks/send/useContacts'; @@ -41,15 +42,20 @@ export const Recipient = () => { const styles = styleSheet(); const { toAddressError, - toAddressErrorAllowAcknowledge, toAddressWarning, toAddressValidated, loading, resolvedAddress, } = useToAddressValidation(); - const hasBlockingError = - Boolean(toAddressError) && !toAddressErrorAllowAcknowledge; + const { + alerts, + hasUnacknowledgedAlerts, + acknowledgeAlerts, + isAlertCheckPending, + } = useSendAlerts(); + + const hasBlockingError = Boolean(toAddressError); // This hook needs to be called to update ERC721 NFTs in send flow // because that flow is triggered directly from the asset details page and user is redirected to the recipient page useRouteParams(); @@ -98,15 +104,16 @@ export const Recipient = () => { const handleAlertModalAcknowledge = useCallback(async () => { setIsAlertModalOpen(false); + acknowledgeAlerts(); await proceedWithSubmit(false); - }, [proceedWithSubmit]); + }, [acknowledgeAlerts, proceedWithSubmit]); const handleReview = useCallback( async (isPasted?: boolean) => { if (hasBlockingError || isSubmittingTransaction) { return; } - if (toAddressErrorAllowAcknowledge) { + if (hasUnacknowledgedAlerts) { setIsAlertModalOpen(true); return; } @@ -114,7 +121,7 @@ export const Recipient = () => { }, [ hasBlockingError, - toAddressErrorAllowAcknowledge, + hasUnacknowledgedAlerts, isSubmittingTransaction, proceedWithSubmit, ], @@ -126,7 +133,9 @@ export const Recipient = () => { pastedRecipient === toAddressValidated && !toAddressError && !toAddressWarning && - !loading + !loading && + !isAlertCheckPending && + !hasUnacknowledgedAlerts ) { handleReview(true); } @@ -137,6 +146,8 @@ export const Recipient = () => { toAddressValidated, toAddressWarning, loading, + isAlertCheckPending, + hasUnacknowledgedAlerts, ]); const onRecipientSelected = useCallback( @@ -240,7 +251,10 @@ export const Recipient = () => { twClassName="w-full" isDanger={!loading && hasBlockingError} disabled={ - hasBlockingError || isSubmittingTransaction || loading + hasBlockingError || + isSubmittingTransaction || + loading || + isAlertCheckPending } isLoading={isSubmittingTransaction || loading} > @@ -250,8 +264,7 @@ export const Recipient = () => { )} diff --git a/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.test.tsx b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.test.tsx index 4327e5fbe17..e01f8ab64b0 100644 --- a/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.test.tsx +++ b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { fireEvent } from '@testing-library/react-native'; import renderWithProvider from '../../../../../../util/test/renderWithProvider'; +import type { SendAlert } from '../../../hooks/send/alerts/types'; import { SendAlertModal } from './send-alert-modal'; jest.mock('../../../../../../../locales/i18n', () => ({ @@ -9,6 +10,8 @@ jest.mock('../../../../../../../locales/i18n', () => ({ const mockStrings: Record = { 'send.cancel': 'Cancel', 'send.i_understand': 'I understand', + 'send.alert_navigation_previous': 'Previous alert', + 'send.alert_navigation_next': 'Next alert', }; return mockStrings[key] || key; }), @@ -70,11 +73,32 @@ jest.mock( }, ); +const singleAlert: SendAlert[] = [ + { + key: 'tokenContract', + title: 'Token Contract Address', + message: 'Sending to a token contract may result in lost tokens.', + }, +]; + +const twoAlerts: SendAlert[] = [ + { + key: 'tokenContract', + title: 'Smart contract address', + message: 'Token contract warning text.', + }, + { + key: 'firstTimeInteraction', + title: 'New address', + message: 'First time message', + acknowledgeButtonLabel: 'Continue', + }, +]; + describe('SendAlertModal', () => { const defaultProps = { isOpen: true, - title: 'Token Contract Address', - errorMessage: 'Sending to a token contract may result in lost tokens.', + alerts: singleAlert, onAcknowledge: jest.fn(), onClose: jest.fn(), }; @@ -91,6 +115,14 @@ describe('SendAlertModal', () => { expect(toJSON()).toBeNull(); }); + it('returns null when alerts is empty', () => { + const { toJSON } = renderWithProvider( + , + ); + + expect(toJSON()).toBeNull(); + }); + it('renders modal content when isOpen is true', () => { const { getByText } = renderWithProvider( , @@ -102,22 +134,6 @@ describe('SendAlertModal', () => { ).toBeOnTheScreen(); }); - it('displays the title text', () => { - const { getByText } = renderWithProvider( - , - ); - - expect(getByText('Custom Title')).toBeOnTheScreen(); - }); - - it('displays the error message text', () => { - const { getByText } = renderWithProvider( - , - ); - - expect(getByText('Custom error message')).toBeOnTheScreen(); - }); - it('calls onClose when cancel button is pressed', () => { const onClose = jest.fn(); const { getByTestId } = renderWithProvider( @@ -129,7 +145,7 @@ describe('SendAlertModal', () => { expect(onClose).toHaveBeenCalledTimes(1); }); - it('calls onAcknowledge when acknowledge button is pressed', () => { + it('calls onAcknowledge when acknowledge is pressed on last alert', () => { const onAcknowledge = jest.fn(); const { getByTestId } = renderWithProvider( , @@ -140,37 +156,22 @@ describe('SendAlertModal', () => { expect(onAcknowledge).toHaveBeenCalledTimes(1); }); - it('does not call onAcknowledge when cancel is pressed', () => { + it('advances to second alert when multiple alerts and first acknowledge', () => { const onAcknowledge = jest.fn(); - const onClose = jest.fn(); - const { getByTestId } = renderWithProvider( + const { getByText, getByTestId } = renderWithProvider( , ); - fireEvent.press(getByTestId('send-alert-modal-cancel-button')); - + expect(getByText('Smart contract address')).toBeOnTheScreen(); + fireEvent.press(getByTestId('send-alert-modal-acknowledge-button')); + expect(getByText('New address')).toBeOnTheScreen(); expect(onAcknowledge).not.toHaveBeenCalled(); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('does not call onClose when acknowledge is pressed', () => { - const onAcknowledge = jest.fn(); - const onClose = jest.fn(); - const { getByTestId } = renderWithProvider( - , - ); fireEvent.press(getByTestId('send-alert-modal-acknowledge-button')); - - expect(onClose).not.toHaveBeenCalled(); expect(onAcknowledge).toHaveBeenCalledTimes(1); }); }); diff --git a/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.tsx b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.tsx index 5fa80651a23..aa65df44b5a 100644 --- a/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.tsx +++ b/app/components/Views/confirmations/components/send/send-alert-modal/send-alert-modal.tsx @@ -1,8 +1,10 @@ -import React, { useRef } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Box, BoxAlignItems, BoxFlexDirection, + BoxJustifyContent, + ButtonIcon, Icon, IconColor, IconName, @@ -20,38 +22,138 @@ import { ButtonSize, ButtonVariants, } from '../../../../../../component-library/components/Buttons/Button'; +import type { SendAlert } from '../../../hooks/send/alerts/types'; import { SendAlertModalProps } from './send-alert-modal.types'; +function PageNavigation({ + alerts, + selectedIndex, + onBack, + onForward, +}: { + alerts: SendAlert[]; + selectedIndex: number; + onBack: () => void; + onForward: () => void; +}) { + if (alerts.length <= 1) { + return null; + } + + return ( + + + {selectedIndex > 0 ? ( + + ) : ( + + )} + + + {selectedIndex < alerts.length - 1 ? ( + + ) : ( + + )} + + + ); +} + export const SendAlertModal = ({ isOpen, - title, - errorMessage, + alerts, onAcknowledge, onClose, }: SendAlertModalProps) => { const bottomSheetRef = useRef(null); + const [currentIndex, setCurrentIndex] = useState(0); + + const alertKeys = alerts.map((a) => a.key).join('|'); + + useEffect(() => { + setCurrentIndex(0); + }, [isOpen, alertKeys]); + + const safeIndex = Math.min(currentIndex, Math.max(alerts.length - 1, 0)); + const currentAlert = alerts[safeIndex]; + + const goToPrevious = useCallback(() => { + setCurrentIndex((prev) => Math.max(prev - 1, 0)); + }, []); + + const goToNext = useCallback(() => { + setCurrentIndex((prev) => Math.min(prev + 1, alerts.length - 1)); + }, [alerts.length]); + + const isOnLastAlert = safeIndex >= Math.max(alerts.length - 1, 0); + + const handleAcknowledgeStep = useCallback(() => { + if (isOnLastAlert) { + onAcknowledge(); + return; + } + goToNext(); + }, [goToNext, isOnLastAlert, onAcknowledge]); if (!isOpen) { return null; } + if (!currentAlert) { + return null; + } + + const acknowledgeLabel = + currentAlert.acknowledgeButtonLabel ?? strings('send.i_understand'); + return ( + - {title} - - {errorMessage} - + {currentAlert.title} + + {typeof currentAlert.message === 'string' ? ( + + {currentAlert.message} + + ) : ( + currentAlert.message + )} + void; onClose: () => void; } diff --git a/app/components/Views/confirmations/hooks/send/alerts/types.ts b/app/components/Views/confirmations/hooks/send/alerts/types.ts new file mode 100644 index 00000000000..a82e82b60f9 --- /dev/null +++ b/app/components/Views/confirmations/hooks/send/alerts/types.ts @@ -0,0 +1,8 @@ +import type { ReactNode } from 'react'; + +export interface SendAlert { + key: string; + title: string; + message: ReactNode; + acknowledgeButtonLabel?: string; +} diff --git a/app/components/Views/confirmations/hooks/send/alerts/useFirstTimeInteractionSendAlert.test.tsx b/app/components/Views/confirmations/hooks/send/alerts/useFirstTimeInteractionSendAlert.test.tsx new file mode 100644 index 00000000000..b8e97de0d16 --- /dev/null +++ b/app/components/Views/confirmations/hooks/send/alerts/useFirstTimeInteractionSendAlert.test.tsx @@ -0,0 +1,217 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; + +import { checkFirstTimeInteraction } from '../../../../../../util/transaction-controller'; +import { useAsyncResult } from '../../../../../hooks/useAsyncResult'; +import { useSendContext } from '../../../context/send-context/send-context'; +import { TrustSignalDisplayState } from '../../../types/trustSignals'; +import { useAddressTrustSignal } from '../../useAddressTrustSignals'; +import { useFirstTimeInteractionSendAlert } from './useFirstTimeInteractionSendAlert'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../context/send-context/send-context', () => ({ + useSendContext: jest.fn(), +})); + +jest.mock('../../useAddressTrustSignals', () => ({ + useAddressTrustSignal: jest.fn(), +})); + +jest.mock('../../../../../hooks/useAsyncResult', () => ({ + useAsyncResult: jest.fn(), +})); + +jest.mock('../../../../../../util/transaction-controller', () => ({ + checkFirstTimeInteraction: jest.fn(), +})); + +jest.mock('../../../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const map: Record = { + 'send.new_address_title': 'New address', + 'send.new_address_message': 'First time message', + 'send.continue': 'Continue', + }; + return map[key] || key; + }, +})); + +const mockUseSendContext = jest.mocked(useSendContext); +const mockUseSelector = jest.mocked(useSelector); +const mockUseAddressTrustSignal = jest.mocked(useAddressTrustSignal); +const mockUseAsyncResult = jest.mocked(useAsyncResult); + +describe('useFirstTimeInteractionSendAlert', () => { + const TO = '0xRecipientAddress'; + const FROM = '0xSenderAddress'; + const CHAIN_ID = '0x1'; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseSendContext.mockReturnValue({ + to: TO, + from: FROM, + chainId: CHAIN_ID, + } as unknown as ReturnType); + + mockUseSelector.mockReturnValue([]); + + mockUseAddressTrustSignal.mockReturnValue({ + state: TrustSignalDisplayState.Unknown, + label: null, + }); + + mockUseAsyncResult.mockReturnValue({ pending: false, value: true }); + }); + + it('returns alert when first-time interaction is detected', () => { + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.alert).not.toBeNull(); + expect(result.current.alert?.key).toBe('firstTimeInteraction'); + expect(result.current.alert?.title).toBe('New address'); + expect(result.current.alert?.acknowledgeButtonLabel).toBe('Continue'); + expect(result.current.isPending).toBe(false); + }); + + it('returns null alert when to is missing', () => { + mockUseSendContext.mockReturnValue({ + to: undefined, + from: FROM, + chainId: CHAIN_ID, + } as unknown as ReturnType); + + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.alert).toBeNull(); + expect(result.current.isPending).toBe(false); + }); + + it('returns null alert when from is missing', () => { + mockUseSendContext.mockReturnValue({ + to: TO, + from: undefined, + chainId: CHAIN_ID, + } as unknown as ReturnType); + + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.alert).toBeNull(); + }); + + it('returns null alert when chainId is missing', () => { + mockUseSendContext.mockReturnValue({ + to: TO, + from: FROM, + chainId: undefined, + } as unknown as ReturnType); + + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.alert).toBeNull(); + }); + + it('returns null alert when to is an internal account', () => { + mockUseSelector.mockReturnValue([{ address: TO.toLowerCase() }]); + + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.alert).toBeNull(); + }); + + it('returns null alert when address is verified', () => { + mockUseAddressTrustSignal.mockReturnValue({ + state: TrustSignalDisplayState.Verified, + label: null, + }); + + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.alert).toBeNull(); + }); + + it('returns isPending true when trust signal is loading', () => { + mockUseAddressTrustSignal.mockReturnValue({ + state: TrustSignalDisplayState.Loading, + label: null, + }); + + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.alert).toBeNull(); + expect(result.current.isPending).toBe(true); + }); + + it('returns isPending true when async check is pending', () => { + mockUseAsyncResult.mockReturnValue({ pending: true }); + + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.alert).toBeNull(); + expect(result.current.isPending).toBe(true); + }); + + it('returns null alert when isFirstTime is false', () => { + mockUseAsyncResult.mockReturnValue({ pending: false, value: false }); + + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.alert).toBeNull(); + expect(result.current.isPending).toBe(false); + }); + + it('returns null alert when isFirstTime is undefined', () => { + mockUseAsyncResult.mockReturnValue({ pending: false, value: undefined }); + + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.alert).toBeNull(); + }); + + it('passes correct arguments to checkFirstTimeInteraction via useAsyncResult', () => { + renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(mockUseAsyncResult).toHaveBeenCalled(); + const asyncFn = mockUseAsyncResult.mock.calls[0][0]; + + asyncFn(); + + expect(checkFirstTimeInteraction).toHaveBeenCalledWith({ + from: FROM, + to: TO, + chainId: 1, + }); + }); + + it('skips the async call when shouldSkip is true', async () => { + mockUseSendContext.mockReturnValue({ + to: undefined, + from: FROM, + chainId: CHAIN_ID, + } as unknown as ReturnType); + + renderHook(() => useFirstTimeInteractionSendAlert()); + + const asyncFn = mockUseAsyncResult.mock.calls[0][0]; + + await expect(asyncFn()).resolves.toBeUndefined(); + expect(checkFirstTimeInteraction).not.toHaveBeenCalled(); + }); + + it('returns isPending false when shouldSkip is true even if async is pending', () => { + mockUseSendContext.mockReturnValue({ + to: undefined, + from: FROM, + chainId: CHAIN_ID, + } as unknown as ReturnType); + mockUseAsyncResult.mockReturnValue({ pending: true }); + + const { result } = renderHook(() => useFirstTimeInteractionSendAlert()); + + expect(result.current.isPending).toBe(false); + }); +}); diff --git a/app/components/Views/confirmations/hooks/send/alerts/useFirstTimeInteractionSendAlert.tsx b/app/components/Views/confirmations/hooks/send/alerts/useFirstTimeInteractionSendAlert.tsx new file mode 100644 index 00000000000..4f3a21f1828 --- /dev/null +++ b/app/components/Views/confirmations/hooks/send/alerts/useFirstTimeInteractionSendAlert.tsx @@ -0,0 +1,102 @@ +import { + Box, + BoxFlexDirection, + BoxFlexWrap, + FontWeight, + Text, + TextVariant, +} from '@metamask/design-system-react-native'; +import { InternalAccount } from '@metamask/keyring-internal-api'; +import { Hex, hexToNumber } from '@metamask/utils'; +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; + +import { strings } from '../../../../../../../locales/i18n'; +import { selectInternalAccounts } from '../../../../../../selectors/accountsController'; +import { checkFirstTimeInteraction } from '../../../../../../util/transaction-controller'; +import { useAsyncResult } from '../../../../../hooks/useAsyncResult'; +import { useSendContext } from '../../../context/send-context/send-context'; +import { TrustSignalDisplayState } from '../../../types/trustSignals'; +import { useAddressTrustSignal } from '../../useAddressTrustSignals'; +import type { SendAlert } from './types'; + +export function useFirstTimeInteractionSendAlert(): { + alert: SendAlert | null; + isPending: boolean; +} { + const { to, from, chainId } = useSendContext(); + const internalAccounts = useSelector( + selectInternalAccounts, + ) as InternalAccount[]; + + const trustSignalResult = useAddressTrustSignal(to ?? '', chainId ?? ''); + + const isInternalAccount = useMemo(() => { + if (!to) { + return false; + } + return internalAccounts.some( + (account) => account.address?.toLowerCase() === to.toLowerCase(), + ); + }, [internalAccounts, to]); + + const isVerifiedAddress = + trustSignalResult.state === TrustSignalDisplayState.Verified; + + const isTrustSignalLoading = + trustSignalResult.state === TrustSignalDisplayState.Loading; + + const shouldSkip = + !to || + !from || + !chainId || + isInternalAccount || + isVerifiedAddress || + isTrustSignalLoading; + + const { pending, value: isFirstTime } = useAsyncResult(async () => { + if (shouldSkip) { + return undefined; + } + const chainIdNum = hexToNumber(chainId as Hex); + return checkFirstTimeInteraction({ from, to, chainId: chainIdNum }); + }, [to, from, chainId, shouldSkip]); + + const isPending = isTrustSignalLoading || (!shouldSkip && pending); + + if (shouldSkip || pending || isFirstTime !== true) { + return { alert: null, isPending }; + } + + const message = ( + + + {strings('send.new_address_message')}{' '} + + + {to} + + + ); + + return { + alert: { + key: 'firstTimeInteraction', + title: strings('send.new_address_title'), + message, + acknowledgeButtonLabel: strings('send.continue'), + }, + isPending: false, + }; +} diff --git a/app/components/Views/confirmations/hooks/send/alerts/useSendAlerts.test.ts b/app/components/Views/confirmations/hooks/send/alerts/useSendAlerts.test.ts new file mode 100644 index 00000000000..261e34fd501 --- /dev/null +++ b/app/components/Views/confirmations/hooks/send/alerts/useSendAlerts.test.ts @@ -0,0 +1,174 @@ +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useSendContext } from '../../../context/send-context/send-context'; +import { useFirstTimeInteractionSendAlert } from './useFirstTimeInteractionSendAlert'; +import { useTokenContractSendAlert } from './useTokenContractSendAlert'; +import { useSendAlerts } from './useSendAlerts'; + +jest.mock('../../../context/send-context/send-context', () => ({ + useSendContext: jest.fn(), +})); + +jest.mock('./useFirstTimeInteractionSendAlert', () => ({ + useFirstTimeInteractionSendAlert: jest.fn(), +})); + +jest.mock('./useTokenContractSendAlert', () => ({ + useTokenContractSendAlert: jest.fn(), +})); + +const mockUseSendContext = jest.mocked(useSendContext); +const mockUseFirstTimeInteraction = jest.mocked( + useFirstTimeInteractionSendAlert, +); +const mockUseTokenContract = jest.mocked(useTokenContractSendAlert); + +const TOKEN_ALERT = { + key: 'tokenContract', + title: 'Smart contract address', + message: 'Token contract warning', +}; + +const FIRST_TIME_ALERT = { + key: 'firstTimeInteraction', + title: 'New address', + message: 'First time message', + acknowledgeButtonLabel: 'Continue', +}; + +describe('useSendAlerts', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseSendContext.mockReturnValue({ + to: '0xRecipient', + } as unknown as ReturnType); + + mockUseTokenContract.mockReturnValue({ + alert: null, + isPending: false, + }); + + mockUseFirstTimeInteraction.mockReturnValue({ + alert: null, + isPending: false, + }); + }); + + it('returns empty alerts when no sub-hooks produce alerts', () => { + const { result } = renderHook(() => useSendAlerts()); + + expect(result.current.alerts).toEqual([]); + expect(result.current.hasUnacknowledgedAlerts).toBe(false); + expect(result.current.isAlertCheckPending).toBe(false); + }); + + it('collects token contract alert', () => { + mockUseTokenContract.mockReturnValue({ + alert: TOKEN_ALERT, + isPending: false, + }); + + const { result } = renderHook(() => useSendAlerts()); + + expect(result.current.alerts).toEqual([TOKEN_ALERT]); + expect(result.current.hasUnacknowledgedAlerts).toBe(true); + }); + + it('collects first-time interaction alert', () => { + mockUseFirstTimeInteraction.mockReturnValue({ + alert: FIRST_TIME_ALERT, + isPending: false, + }); + + const { result } = renderHook(() => useSendAlerts()); + + expect(result.current.alerts).toEqual([FIRST_TIME_ALERT]); + expect(result.current.hasUnacknowledgedAlerts).toBe(true); + }); + + it('collects both alerts in order: token contract first', () => { + mockUseTokenContract.mockReturnValue({ + alert: TOKEN_ALERT, + isPending: false, + }); + mockUseFirstTimeInteraction.mockReturnValue({ + alert: FIRST_TIME_ALERT, + isPending: false, + }); + + const { result } = renderHook(() => useSendAlerts()); + + expect(result.current.alerts).toEqual([TOKEN_ALERT, FIRST_TIME_ALERT]); + expect(result.current.hasUnacknowledgedAlerts).toBe(true); + }); + + it('reports isAlertCheckPending when token contract check is pending', () => { + mockUseTokenContract.mockReturnValue({ + alert: null, + isPending: true, + }); + + const { result } = renderHook(() => useSendAlerts()); + + expect(result.current.isAlertCheckPending).toBe(true); + }); + + it('reports isAlertCheckPending when first-time check is pending', () => { + mockUseFirstTimeInteraction.mockReturnValue({ + alert: null, + isPending: true, + }); + + const { result } = renderHook(() => useSendAlerts()); + + expect(result.current.isAlertCheckPending).toBe(true); + }); + + it('acknowledgeAlerts sets hasUnacknowledgedAlerts to false', () => { + mockUseTokenContract.mockReturnValue({ + alert: TOKEN_ALERT, + isPending: false, + }); + + const { result } = renderHook(() => useSendAlerts()); + + expect(result.current.hasUnacknowledgedAlerts).toBe(true); + + act(() => { + result.current.acknowledgeAlerts(); + }); + + expect(result.current.hasUnacknowledgedAlerts).toBe(false); + }); + + it('resets acknowledged state when to changes', () => { + mockUseTokenContract.mockReturnValue({ + alert: TOKEN_ALERT, + isPending: false, + }); + + const { result, rerender } = renderHook(() => useSendAlerts()); + + act(() => { + result.current.acknowledgeAlerts(); + }); + + expect(result.current.hasUnacknowledgedAlerts).toBe(false); + + mockUseSendContext.mockReturnValue({ + to: '0xNewRecipient', + } as unknown as ReturnType); + + rerender(); + + expect(result.current.hasUnacknowledgedAlerts).toBe(true); + }); + + it('returns hasUnacknowledgedAlerts false when alerts are empty even without acknowledging', () => { + const { result } = renderHook(() => useSendAlerts()); + + expect(result.current.alerts).toEqual([]); + expect(result.current.hasUnacknowledgedAlerts).toBe(false); + }); +}); diff --git a/app/components/Views/confirmations/hooks/send/alerts/useSendAlerts.ts b/app/components/Views/confirmations/hooks/send/alerts/useSendAlerts.ts new file mode 100644 index 00000000000..7eca5ff4de2 --- /dev/null +++ b/app/components/Views/confirmations/hooks/send/alerts/useSendAlerts.ts @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useSendContext } from '../../../context/send-context/send-context'; +import { useFirstTimeInteractionSendAlert } from './useFirstTimeInteractionSendAlert'; +import { useTokenContractSendAlert } from './useTokenContractSendAlert'; +import type { SendAlert } from './types'; + +export function useSendAlerts(): { + alerts: SendAlert[]; + hasUnacknowledgedAlerts: boolean; + acknowledgeAlerts: () => void; + isAlertCheckPending: boolean; +} { + const { to } = useSendContext(); + const { alert: tokenContractAlert, isPending: tokenContractPending } = + useTokenContractSendAlert(); + const { alert: firstTimeAlert, isPending: firstTimePending } = + useFirstTimeInteractionSendAlert(); + const [acknowledged, setAcknowledged] = useState(false); + + const isAlertCheckPending = tokenContractPending || firstTimePending; + + const alerts = useMemo(() => { + const result: SendAlert[] = []; + if (tokenContractAlert) { + result.push(tokenContractAlert); + } + if (firstTimeAlert) { + result.push(firstTimeAlert); + } + return result; + }, [tokenContractAlert, firstTimeAlert]); + + useEffect(() => { + setAcknowledged(false); + }, [to]); + + const acknowledgeAlerts = useCallback(() => { + setAcknowledged(true); + }, []); + + const hasUnacknowledgedAlerts = alerts.length > 0 && !acknowledged; + + return { + alerts, + hasUnacknowledgedAlerts, + acknowledgeAlerts, + isAlertCheckPending, + }; +} diff --git a/app/components/Views/confirmations/hooks/send/alerts/useTokenContractSendAlert.test.ts b/app/components/Views/confirmations/hooks/send/alerts/useTokenContractSendAlert.test.ts new file mode 100644 index 00000000000..89f494a76d8 --- /dev/null +++ b/app/components/Views/confirmations/hooks/send/alerts/useTokenContractSendAlert.test.ts @@ -0,0 +1,207 @@ +import { renderHook, act } from '@testing-library/react-hooks'; + +import { + memoizedGetTokenStandardAndDetails, + type TokenDetails, +} from '../../../utils/token'; +import { useSendContext } from '../../../context/send-context/send-context'; +import { useSendType } from '../useSendType'; +import { useTokenContractSendAlert } from './useTokenContractSendAlert'; + +type TokenResult = TokenDetails | Record; + +jest.mock('../../../context/send-context/send-context', () => ({ + useSendContext: jest.fn(), +})); + +jest.mock('../useSendType', () => ({ + useSendType: jest.fn(), +})); + +jest.mock('../../../utils/token', () => ({ + memoizedGetTokenStandardAndDetails: jest.fn(), +})); + +jest.mock('../../../../../../core/Engine', () => ({ + context: { + NetworkController: { + findNetworkClientIdByChainId: jest.fn().mockReturnValue('mainnet'), + }, + }, +})); + +jest.mock('../../../../../../util/address', () => ({ + isValidHexAddress: jest.fn((addr: string) => addr.startsWith('0x')), + toChecksumAddress: jest.fn((addr: string) => addr), +})); + +jest.mock('../../../../../../../locales/i18n', () => ({ + strings: (key: string) => { + const map: Record = { + 'send.smart_contract_address': 'Smart contract address', + 'send.smart_contract_address_warning': 'Token contract warning', + }; + return map[key] || key; + }, +})); + +const mockUseSendContext = jest.mocked(useSendContext); +const mockUseSendType = jest.mocked(useSendType); +const mockGetTokenDetails = jest.mocked(memoizedGetTokenStandardAndDetails); + +describe('useTokenContractSendAlert', () => { + const TOKEN_CONTRACT = '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477'; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseSendContext.mockReturnValue({ + to: TOKEN_CONTRACT, + chainId: '0x1', + asset: { address: '0xDifferentAddress' }, + } as unknown as ReturnType); + + mockUseSendType.mockReturnValue({ + isEvmSendType: true, + } as unknown as ReturnType); + + mockGetTokenDetails.mockResolvedValue({}); + }); + + it('returns null alert when to is missing', () => { + mockUseSendContext.mockReturnValue({ + to: undefined, + chainId: '0x1', + asset: {}, + } as unknown as ReturnType); + + const { result } = renderHook(() => useTokenContractSendAlert()); + + expect(result.current.alert).toBeNull(); + expect(result.current.isPending).toBe(false); + }); + + it('returns null alert when chainId is missing', () => { + mockUseSendContext.mockReturnValue({ + to: TOKEN_CONTRACT, + chainId: undefined, + asset: {}, + } as unknown as ReturnType); + + const { result } = renderHook(() => useTokenContractSendAlert()); + + expect(result.current.alert).toBeNull(); + }); + + it('returns null alert when not an EVM send type', () => { + mockUseSendType.mockReturnValue({ + isEvmSendType: false, + } as unknown as ReturnType); + + const { result } = renderHook(() => useTokenContractSendAlert()); + + expect(result.current.alert).toBeNull(); + }); + + it('returns null alert when to address equals asset address', () => { + mockUseSendContext.mockReturnValue({ + to: TOKEN_CONTRACT, + chainId: '0x1', + asset: { address: TOKEN_CONTRACT }, + } as unknown as ReturnType); + + const { result } = renderHook(() => useTokenContractSendAlert()); + + expect(result.current.alert).toBeNull(); + }); + + it('returns alert when address is a token contract', async () => { + mockGetTokenDetails.mockResolvedValue({ + standard: 'ERC20', + } as Awaited>); + + const { result, waitForNextUpdate } = renderHook(() => + useTokenContractSendAlert(), + ); + + await waitForNextUpdate(); + + expect(result.current.alert).not.toBeNull(); + expect(result.current.alert?.key).toBe('tokenContract'); + expect(result.current.alert?.title).toBe('Smart contract address'); + expect(result.current.alert?.message).toBe('Token contract warning'); + expect(result.current.isPending).toBe(false); + }); + + it('returns null alert when address is not a token contract', async () => { + mockGetTokenDetails.mockResolvedValue({}); + + const { result, waitForNextUpdate } = renderHook(() => + useTokenContractSendAlert(), + ); + + await waitForNextUpdate(); + + expect(result.current.alert).toBeNull(); + expect(result.current.isPending).toBe(false); + }); + + it('returns null alert when token details lookup throws', async () => { + mockGetTokenDetails.mockRejectedValue(new Error('lookup failed')); + + const { result, waitForNextUpdate } = renderHook(() => + useTokenContractSendAlert(), + ); + + await waitForNextUpdate(); + + expect(result.current.alert).toBeNull(); + expect(result.current.isPending).toBe(false); + }); + + it('reports isPending while the token check is in progress', async () => { + let resolvePromise: (v: TokenResult) => void = () => undefined; + mockGetTokenDetails.mockReturnValue( + new Promise((resolve) => { + resolvePromise = resolve; + }), + ); + + const { result } = renderHook(() => useTokenContractSendAlert()); + + expect(result.current.isPending).toBe(true); + + await act(async () => { + resolvePromise({}); + }); + }); + + it('cancels in-flight check when to changes', async () => { + let resolveFirst: (v: TokenResult) => void = () => undefined; + mockGetTokenDetails.mockReturnValueOnce( + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + + const { result, rerender } = renderHook(() => useTokenContractSendAlert()); + + expect(result.current.isPending).toBe(true); + + mockUseSendContext.mockReturnValue({ + to: '0xNewAddress', + chainId: '0x1', + asset: { address: '0xDifferentAddress' }, + } as unknown as ReturnType); + + mockGetTokenDetails.mockResolvedValueOnce({}); + + rerender(); + + await act(async () => { + resolveFirst({ standard: 'ERC20' } as TokenResult); + }); + + expect(result.current.alert).toBeNull(); + }); +}); diff --git a/app/components/Views/confirmations/hooks/send/alerts/useTokenContractSendAlert.ts b/app/components/Views/confirmations/hooks/send/alerts/useTokenContractSendAlert.ts new file mode 100644 index 00000000000..6e45d92fbc4 --- /dev/null +++ b/app/components/Views/confirmations/hooks/send/alerts/useTokenContractSendAlert.ts @@ -0,0 +1,85 @@ +import { Hex } from '@metamask/utils'; +import { useEffect, useState } from 'react'; + +import { strings } from '../../../../../../../locales/i18n'; +import Engine from '../../../../../../core/Engine'; +import { + isValidHexAddress, + toChecksumAddress, +} from '../../../../../../util/address'; +import { memoizedGetTokenStandardAndDetails } from '../../../utils/token'; +import { useSendContext } from '../../../context/send-context/send-context'; +import { useSendType } from '../useSendType'; +import type { SendAlert } from './types'; + +export function useTokenContractSendAlert(): { + alert: SendAlert | null; + isPending: boolean; +} { + const { to, chainId, asset } = useSendContext(); + const { isEvmSendType } = useSendType(); + const [isTokenContract, setIsTokenContract] = useState(false); + const [checkComplete, setCheckComplete] = useState(true); + + useEffect(() => { + let cancelled = false; + setIsTokenContract(false); + + if (!to || !chainId || !isEvmSendType || !isValidHexAddress(to)) { + setCheckComplete(true); + return undefined; + } + + if (to?.toLowerCase() === asset?.address?.toLowerCase()) { + setCheckComplete(true); + return undefined; + } + + setCheckComplete(false); + + const checksummedAddress = toChecksumAddress(to); + const { NetworkController } = Engine.context; + const networkClientId = NetworkController.findNetworkClientIdByChainId( + chainId as Hex, + ); + + memoizedGetTokenStandardAndDetails({ + tokenAddress: checksummedAddress, + tokenId: undefined, + userAddress: undefined, + networkClientId, + }) + .then((token) => { + if (!cancelled && token?.standard) { + setIsTokenContract(true); + } + }) + .catch(() => { + // Not a token address + }) + .finally(() => { + if (!cancelled) { + setCheckComplete(true); + } + }); + + return () => { + cancelled = true; + }; + }, [to, chainId, isEvmSendType, asset?.address]); + + const isPending = !checkComplete; + + if (!isTokenContract) { + return { alert: null, isPending }; + } + + return { + alert: { + key: 'tokenContract', + title: strings('send.smart_contract_address'), + message: strings('send.smart_contract_address_warning'), + }, + isPending: false, + }; +} diff --git a/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts b/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts index 560cbcfeb55..342f69961db 100644 --- a/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts +++ b/app/components/Views/confirmations/hooks/send/useToAddressValidation.test.ts @@ -46,7 +46,6 @@ describe('useToAddressValidation', () => { loading: false, resolvedAddress: undefined, toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: undefined, }); @@ -64,7 +63,6 @@ describe('useToAddressValidation', () => { loading: false, resolvedAddress: undefined, toAddressError: undefined, - toAddressErrorAllowAcknowledge: false, toAddressValidated: undefined, toAddressWarning: undefined, }); @@ -92,7 +90,6 @@ describe('useToAddressValidation', () => { loading: false, resolvedAddress: undefined, toAddressError: 'Invalid address', - toAddressErrorAllowAcknowledge: false, toAddressValidated: '0x123', toAddressWarning: undefined, }); @@ -114,7 +111,6 @@ describe('useToAddressValidation', () => { loading: false, resolvedAddress: undefined, toAddressError: 'Invalid address', - toAddressErrorAllowAcknowledge: false, toAddressValidated: 'dummy', toAddressWarning: undefined, }); @@ -143,7 +139,6 @@ describe('useToAddressValidation', () => { loading: false, resolvedAddress: undefined, toAddressError: 'Invalid address', - toAddressErrorAllowAcknowledge: false, toAddressValidated: 'dummy', toAddressWarning: undefined, }); diff --git a/app/components/Views/confirmations/hooks/send/useToAddressValidation.ts b/app/components/Views/confirmations/hooks/send/useToAddressValidation.ts index bdd9eca301d..40b4a22ee91 100644 --- a/app/components/Views/confirmations/hooks/send/useToAddressValidation.ts +++ b/app/components/Views/confirmations/hooks/send/useToAddressValidation.ts @@ -20,7 +20,6 @@ interface ValidationResult { error?: string; warning?: string; resolvedAddress?: string; - allowAcknowledge?: boolean; } export const useToAddressValidation = () => { @@ -113,14 +112,12 @@ export const useToAddressValidation = () => { error, warning: toAddressWarning, resolvedAddress, - allowAcknowledge, } = result ?? {}; return { loading, resolvedAddress, toAddressError: error, - toAddressErrorAllowAcknowledge: allowAcknowledge === true, toAddressValidated, toAddressWarning, }; diff --git a/app/components/Views/confirmations/utils/send-address-validations.test.ts b/app/components/Views/confirmations/utils/send-address-validations.test.ts index 48d3c05ae5d..99604d29ffe 100644 --- a/app/components/Views/confirmations/utils/send-address-validations.test.ts +++ b/app/components/Views/confirmations/utils/send-address-validations.test.ts @@ -7,8 +7,6 @@ import { validateSolanaAddress, validateTronAddress, } from './send-address-validations'; -import { memoizedGetTokenStandardAndDetails, TokenDetailsERC20 } from './token'; - jest.mock('./token', () => ({ memoizedGetTokenStandardAndDetails: jest.fn().mockResolvedValue(undefined), })); @@ -21,10 +19,6 @@ jest.mock('../../../../core/Engine', () => ({ }, })); -const mockMemoizedGetTokenStandardAndDetails = jest.mocked( - memoizedGetTokenStandardAndDetails, -); - describe('validateHexAddress', () => { it('returns error if address is burn address', async () => { expect( @@ -70,20 +64,13 @@ describe('validateHexAddress', () => { ).toStrictEqual({}); }); - it('returns warning if address is contract address', async () => { - mockMemoizedGetTokenStandardAndDetails.mockResolvedValue({ - standard: 'ERC20', - } as unknown as TokenDetailsERC20); + it('does not flag token contract addresses (handled in send flow alerts)', async () => { expect( await validateHexAddress( '0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477', '0x1', ), - ).toStrictEqual({ - allowAcknowledge: true, - error: - 'This address is a token contract address. If you send tokens to this address, you will lose them.', - }); + ).toStrictEqual({}); }); }); diff --git a/app/components/Views/confirmations/utils/send-address-validations.ts b/app/components/Views/confirmations/utils/send-address-validations.ts index d3cb04483c8..e9a4dff7645 100644 --- a/app/components/Views/confirmations/utils/send-address-validations.ts +++ b/app/components/Views/confirmations/utils/send-address-validations.ts @@ -2,14 +2,11 @@ import { Hex } from '@metamask/utils'; import { isAddress as isSolanaAddress } from '@solana/addresses'; import { strings } from '../../../../../locales/i18n'; -import Engine from '../../../../core/Engine'; -import { toChecksumAddress } from '../../../../util/address'; import { collectConfusables, getConfusablesExplanations, hasZeroWidthPoints, } from '../../../../util/confusables'; -import { memoizedGetTokenStandardAndDetails } from './token'; import { isBtcMainnetAddress, isTronAddress, @@ -38,7 +35,6 @@ export const validateHexAddress = async ( ): Promise<{ error?: string; warning?: string; - allowAcknowledge?: boolean; }> => { if (LOWER_CASED_BURN_ADDRESSES.includes(toAddress?.toLowerCase())) { return { @@ -52,30 +48,6 @@ export const validateHexAddress = async ( }; } - const checksummedAddress = toChecksumAddress(toAddress); - if (chainId) { - const { NetworkController } = Engine.context; - - try { - const networkClientId = NetworkController.findNetworkClientIdByChainId( - chainId as Hex, - ); - const token = await memoizedGetTokenStandardAndDetails({ - tokenAddress: checksummedAddress, - tokenId: undefined, - userAddress: undefined, - networkClientId, - }); - if (token?.standard) { - return { - error: strings('send.token_contract_warning'), - allowAcknowledge: true, - }; - } - } catch { - // Not a token address - } - } return {}; }; diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 323016d1752..c3851369f74 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -111,6 +111,8 @@ const Routes = { REWARDS_SEASON_ONE_CAMPAIGN_DETAILS_VIEW: 'RewardsSeasonOneCampaignDetails', REWARDS_CAMPAIGN_MECHANICS: 'RewardsCampaignMechanics', REWARDS_ONDO_CAMPAIGN_LEADERBOARD: 'RewardsOndoCampaignLeaderboard', + REWARDS_PERPS_TRADING_CAMPAIGN_WINNING_VIEW: + 'RewardsPerpsTradingCampaignWinning', REWARDS_ONDO_CAMPAIGN_RWA_ASSET_SELECTOR: 'RewardsOndoRwaAssetSelector', REWARDS_ONDO_CAMPAIGN_PORTFOLIO_VIEW: 'RewardsOndoCampaignPortfolioView', REWARDS_ONDO_CAMPAIGN_STATS: 'RewardsOndoCampaignStats', diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts b/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts index be1f9d95265..05948debe06 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController-method-action-types.ts @@ -514,6 +514,19 @@ export type RewardsControllerGetOndoCampaignDepositsAction = { handler: RewardsController['getOndoCampaignDeposits']; }; +/** + * Fetch the participant outcome for the current user in a completed Perps Trading campaign. + * Results are cached for 10 minutes using a private in-memory Map. + * + * @param campaignId - The campaign ID. + * @param subscriptionId - The subscription ID for authentication. + * @returns The participant outcome DTO, or null if unavailable. + */ +export type RewardsControllerGetPerpsTradingCampaignParticipantOutcomeAction = { + type: `RewardsController:getPerpsTradingCampaignParticipantOutcome`; + handler: RewardsController['getPerpsTradingCampaignParticipantOutcome']; +}; + /** * Get the current user's position on the campaign leaderboard. * This is an authenticated endpoint. @@ -797,6 +810,7 @@ export type RewardsControllerMethodActions = | RewardsControllerGetCampaignParticipantStatusAction | RewardsControllerGetOndoCampaignLeaderboardAction | RewardsControllerGetOndoCampaignDepositsAction + | RewardsControllerGetPerpsTradingCampaignParticipantOutcomeAction | RewardsControllerGetOndoCampaignLeaderboardPositionAction | RewardsControllerGetOndoCampaignParticipantOutcomeAction | RewardsControllerGetOndoCampaignPortfolioPositionAction diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index ae1187bb7ad..f9b379bbe7a 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -20489,4 +20489,159 @@ describe('RewardsController', () => { ); }); }); + + describe('getPerpsTradingCampaignParticipantOutcome', () => { + let perpsParticipantOutcomeMessenger: jest.Mocked; + const mockCampaignId = 'perps-outcome-campaign-1'; + const mockSubscriptionId = 'sub-perps-outcome-1'; + const mockOutcome = { + subscriptionId: mockSubscriptionId, + outcomeStatus: 'pending' as const, + winnerVerificationCode: 'VERIFY-123', + rank: 1, + }; + + beforeEach(() => { + perpsParticipantOutcomeMessenger = { + subscribe: jest.fn(), + call: jest.fn(), + registerActionHandler: jest.fn(), + registerMethodActionHandlers: jest.fn(), + unregisterActionHandler: jest.fn(), + publish: jest.fn(), + clearEventSubscriptions: jest.fn(), + registerInitialEventPayload: jest.fn(), + unsubscribe: jest.fn(), + } as unknown as jest.Mocked; + }); + + it('returns null when rewards feature flag is disabled', async () => { + const disabledController = new RewardsController({ + messenger: perpsParticipantOutcomeMessenger, + state: getRewardsControllerDefaultState(), + isDisabled: () => true, + }); + + const result = + await disabledController.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toBeNull(); + expect(perpsParticipantOutcomeMessenger.call).not.toHaveBeenCalled(); + }); + + it('fetches outcome from API and caches result', async () => { + const ctrl = new RewardsController({ + messenger: perpsParticipantOutcomeMessenger, + state: getRewardsControllerDefaultState(), + }); + + perpsParticipantOutcomeMessenger.call.mockResolvedValue(mockOutcome); + + const result = await ctrl.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + expect(perpsParticipantOutcomeMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:getPerpsTradingCampaignParticipantOutcome', + mockCampaignId, + mockSubscriptionId, + ); + expect(result).toEqual(mockOutcome); + }); + + it('returns cached outcome on second call within TTL', async () => { + const ctrl = new RewardsController({ + messenger: perpsParticipantOutcomeMessenger, + state: getRewardsControllerDefaultState(), + }); + + perpsParticipantOutcomeMessenger.call.mockResolvedValue(mockOutcome); + + await ctrl.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + perpsParticipantOutcomeMessenger.call.mockClear(); + + const result = await ctrl.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toEqual(mockOutcome); + expect(perpsParticipantOutcomeMessenger.call).not.toHaveBeenCalled(); + }); + + it('returns null when API returns null and does not cache', async () => { + const ctrl = new RewardsController({ + messenger: perpsParticipantOutcomeMessenger, + state: getRewardsControllerDefaultState(), + }); + + perpsParticipantOutcomeMessenger.call.mockResolvedValue(null); + + const result = await ctrl.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toBeNull(); + + perpsParticipantOutcomeMessenger.call.mockClear(); + perpsParticipantOutcomeMessenger.call.mockResolvedValue(mockOutcome); + const second = await ctrl.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + expect(perpsParticipantOutcomeMessenger.call).toHaveBeenCalledTimes(1); + expect(second).toEqual(mockOutcome); + }); + + it('returns null and logs on API error', async () => { + const ctrl = new RewardsController({ + messenger: perpsParticipantOutcomeMessenger, + state: getRewardsControllerDefaultState(), + }); + + perpsParticipantOutcomeMessenger.call.mockRejectedValue( + new Error('Perps API error'), + ); + mockLogger.error.mockClear(); + + const result = await ctrl.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + expect(result).toBeNull(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.any(Error), + 'RewardsController: Failed to fetch Perps Trading participant outcome', + ); + }); + + it('logs when fetching fresh outcome', async () => { + const ctrl = new RewardsController({ + messenger: perpsParticipantOutcomeMessenger, + state: getRewardsControllerDefaultState(), + }); + + perpsParticipantOutcomeMessenger.call.mockResolvedValue(mockOutcome); + mockLogger.log.mockClear(); + + await ctrl.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + expect(mockLogger.log).toHaveBeenCalledWith( + 'RewardsController: Fetching Perps Trading campaign participant outcome', + ); + }); + }); }); diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index 33aa24818e1..65056cd9adb 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -33,6 +33,7 @@ import { type PerpsTradingCampaignLeaderboardPositionDto, type PerpsTradingCampaignVolumeDto, type PaginatedOndoGmActivityDto, + type PerpsTradingCampaignParticipantOutcomeDto, type OndoGmActivityState, type PointsEstimateHistoryEntry, ClaimRewardDto, @@ -154,6 +155,9 @@ const PERPS_TRADING_CAMPAIGN_LEADERBOARD_POSITION_CACHE_THRESHOLD_MS = // Perps Trading Campaign volume cache threshold const PERPS_TRADING_CAMPAIGN_VOLUME_CACHE_THRESHOLD_MS = 1000 * 60 * 1; // 1 minute +// Perps Trading participant outcome cache threshold +const PERPS_TRADING_PARTICIPANT_OUTCOME_CACHE_THRESHOLD_MS = 1000 * 60 * 10; // 10 minutes + // Opt-in status stale threshold for not opted-in accounts to force a fresh check const NOT_OPTED_IN_OIS_STALE_CACHE_THRESHOLD_MS = 1000 * 60 * 60; // 1 hour @@ -447,6 +451,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getPerpsTradingCampaignLeaderboardPosition', 'getPerpsTradingCampaignVolume', 'getOptInStatus', + 'getPerpsTradingCampaignParticipantOutcome', 'getPerpsDiscountForAccount', 'getPointsEvents', 'getPointsEventsIfChanged', @@ -503,6 +508,13 @@ export class RewardsController extends BaseController< #isBitcoinOptinEnabled: () => boolean; #isTronOptinEnabled: () => boolean; #reauthPromises: Map> = new Map(); + #perpsTradingParticipantOutcomeCache: Map< + string, + { + payload: PerpsTradingCampaignParticipantOutcomeDto | null; + lastFetched: number; + } + > = new Map(); /** * Calculate tier status and next tier information @@ -3695,6 +3707,58 @@ export class RewardsController extends BaseController< return result; } + /** + * Fetch the participant outcome for the current user in a completed Perps Trading campaign. + * Results are cached for 10 minutes using a private in-memory Map. + * @param campaignId - The campaign ID. + * @param subscriptionId - The subscription ID for authentication. + * @returns The participant outcome DTO, or null if unavailable. + */ + async getPerpsTradingCampaignParticipantOutcome( + campaignId: string, + subscriptionId: string, + ): Promise { + if (!this.isRewardsFeatureEnabled()) { + return null; + } + const key = `${subscriptionId}:${campaignId}`; + try { + return await wrapWithCache( + { + key, + ttl: PERPS_TRADING_PARTICIPANT_OUTCOME_CACHE_THRESHOLD_MS, + readCache: (k) => + this.#perpsTradingParticipantOutcomeCache.get(k) ?? undefined, + fetchFresh: async () => + this.#withAuthRetry(async () => { + Logger.log( + 'RewardsController: Fetching Perps Trading campaign participant outcome', + ); + return this.messenger.call( + 'RewardsDataService:getPerpsTradingCampaignParticipantOutcome', + campaignId, + subscriptionId, + ); + }, subscriptionId), + writeCache: (k, payload) => { + if (payload !== null) { + this.#perpsTradingParticipantOutcomeCache.set(k, { + payload, + lastFetched: Date.now(), + }); + } + }, + }, + ); + } catch (error) { + Logger.error( + error as Error, + 'RewardsController: Failed to fetch Perps Trading participant outcome', + ); + return null; + } + } + /** * Get the current user's position on the campaign leaderboard. * This is an authenticated endpoint. diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts index a7c8b574492..03f5e90af95 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts @@ -5088,6 +5088,59 @@ describe('RewardsDataService', () => { }); }); + describe('getPerpsTradingCampaignParticipantOutcome', () => { + const mockCampaignId = 'perps-outcome-campaign-1'; + const mockSubscriptionId = 'sub-perps-outcome-1'; + const mockToken = 'test-bearer-token'; + const mockOutcome = { + subscriptionId: mockSubscriptionId, + outcomeStatus: 'finalized' as const, + winnerVerificationCode: null, + rank: 3, + }; + + beforeEach(() => { + mockGetSubscriptionToken.mockResolvedValue({ + success: true, + token: mockToken, + }); + }); + + it('calls the authenticated perps outcome endpoint and returns data', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockOutcome), + } as unknown as Response); + + const result = await service.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ); + + expect(mockFetch).toHaveBeenCalledWith( + `https://uat.rewards.test/perps-trading/${mockCampaignId}/outcome/me`, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'rewards-access-token': mockToken, + }), + }), + ); + expect(result).toEqual(mockOutcome); + }); + + it('throws when response is not ok', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 401 } as Response); + + await expect( + service.getPerpsTradingCampaignParticipantOutcome( + mockCampaignId, + mockSubscriptionId, + ), + ).rejects.toThrow('Get Perps Trading participant outcome failed: 401'); + }); + }); + describe('getPerpsTradingCampaignLeaderboard', () => { const mockCampaignId = 'perps-campaign-api-1'; const mockLeaderboard = { diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts index 1170e963d60..28b22224532 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts @@ -38,6 +38,7 @@ import type { PerpsTradingCampaignLeaderboardDto, PerpsTradingCampaignLeaderboardPositionDto, PerpsTradingCampaignVolumeDto, + PerpsTradingCampaignParticipantOutcomeDto, } from '../types'; import { getSubscriptionToken } from '../utils/multi-subscription-token-vault'; import Logger from '../../../../../util/Logger'; @@ -280,6 +281,10 @@ export interface RewardsDataServiceGetPerpsTradingCampaignVolumeAction { type: `${typeof SERVICE_NAME}:getPerpsTradingCampaignVolume`; handler: RewardsDataService['getPerpsTradingCampaignVolume']; } +export interface RewardsDataServiceGetPerpsTradingCampaignParticipantOutcomeAction { + type: `${typeof SERVICE_NAME}:getPerpsTradingCampaignParticipantOutcome`; + handler: RewardsDataService['getPerpsTradingCampaignParticipantOutcome']; +} export interface RewardsDataServiceGetRewardsEnvUrlAction { type: `${typeof SERVICE_NAME}:getRewardsEnvUrl`; @@ -355,7 +360,8 @@ export type RewardsDataServiceActions = | RewardsDataServiceGetOndoCampaignParticipantOutcomeAction | RewardsDataServiceGetPerpsTradingCampaignLeaderboardAction | RewardsDataServiceGetPerpsTradingCampaignLeaderboardPositionAction - | RewardsDataServiceGetPerpsTradingCampaignVolumeAction; + | RewardsDataServiceGetPerpsTradingCampaignVolumeAction + | RewardsDataServiceGetPerpsTradingCampaignParticipantOutcomeAction; export type RewardsDataServiceMessenger = Messenger< typeof SERVICE_NAME, @@ -542,6 +548,10 @@ export class RewardsDataService { `${SERVICE_NAME}:getPerpsTradingCampaignVolume`, this.getPerpsTradingCampaignVolume.bind(this), ); + this.#messenger.registerActionHandler( + `${SERVICE_NAME}:getPerpsTradingCampaignParticipantOutcome`, + this.getPerpsTradingCampaignParticipantOutcome.bind(this), + ); this.#messenger.registerActionHandler( `${SERVICE_NAME}:getRewardsEnvUrl`, this.getRewardsEnvUrl.bind(this), @@ -1827,4 +1837,22 @@ export class RewardsDataService { return (await response.json()) as PerpsTradingCampaignVolumeDto; } + + async getPerpsTradingCampaignParticipantOutcome( + campaignId: string, + subscriptionId: string, + ): Promise { + const response = await this.makeRequest( + `/perps-trading/${campaignId}/outcome/me`, + { method: 'GET' }, + subscriptionId, + ); + if (!response.ok) { + throw new Error( + `Get Perps Trading participant outcome failed: ${response.status}`, + ); + } + + return (await response.json()) as PerpsTradingCampaignParticipantOutcomeDto; + } } diff --git a/app/core/Engine/controllers/rewards-controller/types.ts b/app/core/Engine/controllers/rewards-controller/types.ts index 16cd347e36f..be6feb66841 100644 --- a/app/core/Engine/controllers/rewards-controller/types.ts +++ b/app/core/Engine/controllers/rewards-controller/types.ts @@ -93,8 +93,8 @@ export interface ApplyBonusCodeDto { */ export enum CampaignType { ONDO_HOLDING = 'ONDO_HOLDING', - SEASON_1 = 'SEASON_1', PERPS_TRADING = 'PERPS_TRADING', + SEASON_1 = 'SEASON_1', } /** @@ -567,16 +567,33 @@ export type OndoGmCampaignDepositsDto = { totalUsdDeposited: string; }; -export type OndoGmCampaignParticipantOutcomeStatus = 'pending' | 'finalized'; +export type CampaignParticipantOutcomeStatus = 'pending' | 'finalized'; -export interface OndoGmCampaignParticipantOutcomeDto { +export interface BaseCampaignParticipantOutcomeDto { subscriptionId: string; - outcomeStatus: OndoGmCampaignParticipantOutcomeStatus; + outcomeStatus: CampaignParticipantOutcomeStatus; winnerVerificationCode?: string | null; +} + +/** @deprecated Use CampaignParticipantOutcomeStatus */ +export type OndoGmCampaignParticipantOutcomeStatus = + CampaignParticipantOutcomeStatus; + +export interface OndoGmCampaignParticipantOutcomeDto + extends BaseCampaignParticipantOutcomeDto { tierRank?: number; tier?: string; } +/** @deprecated Use CampaignParticipantOutcomeStatus */ +export type PerpsTradingCampaignParticipantOutcomeStatus = + CampaignParticipantOutcomeStatus; + +export interface PerpsTradingCampaignParticipantOutcomeDto + extends BaseCampaignParticipantOutcomeDto { + rank?: number | null; +} + /** * Cached portfolio payload (explicit shape for Json / StateConstraint compatibility). */ diff --git a/app/core/Engine/messengers/rewards-controller-messenger/index.ts b/app/core/Engine/messengers/rewards-controller-messenger/index.ts index 043c69b146c..d6e48449142 100644 --- a/app/core/Engine/messengers/rewards-controller-messenger/index.ts +++ b/app/core/Engine/messengers/rewards-controller-messenger/index.ts @@ -71,6 +71,7 @@ import { RewardsDataServiceGetPerpsTradingCampaignLeaderboardAction, RewardsDataServiceGetPerpsTradingCampaignLeaderboardPositionAction, RewardsDataServiceGetPerpsTradingCampaignVolumeAction, + RewardsDataServiceGetPerpsTradingCampaignParticipantOutcomeAction, } from '../../controllers/rewards-controller/services/rewards-data-service'; import { RootMessenger } from '../../types'; @@ -126,7 +127,8 @@ type AllowedActions = | RewardsDataServiceGetOndoCampaignParticipantOutcomeAction | RewardsDataServiceGetPerpsTradingCampaignLeaderboardAction | RewardsDataServiceGetPerpsTradingCampaignLeaderboardPositionAction - | RewardsDataServiceGetPerpsTradingCampaignVolumeAction; + | RewardsDataServiceGetPerpsTradingCampaignVolumeAction + | RewardsDataServiceGetPerpsTradingCampaignParticipantOutcomeAction; // Don't reexport as per guidelines type AllowedEvents = @@ -217,6 +219,7 @@ export function getRewardsControllerMessenger( 'RewardsDataService:getPerpsTradingCampaignLeaderboard', 'RewardsDataService:getPerpsTradingCampaignLeaderboardPosition', 'RewardsDataService:getPerpsTradingCampaignVolume', + 'RewardsDataService:getPerpsTradingCampaignParticipantOutcome', ], events: [ 'AccountTreeController:selectedAccountGroupChange', diff --git a/app/reducers/rewards/index.test.ts b/app/reducers/rewards/index.test.ts index 9ee33ec2753..59079e538a4 100644 --- a/app/reducers/rewards/index.test.ts +++ b/app/reducers/rewards/index.test.ts @@ -5857,35 +5857,33 @@ describe('ondoCampaignDeposits', () => { }); describe('dismissCampaignOutcomeToast', () => { - it('records winner_verify variant as dismissed', () => { + it('records winner variant as dismissed', () => { const state = rewardsReducer( initialState, dismissCampaignOutcomeToast({ - campaignId: 'campaign-1', - subscriptionId: 'sub-1', - variant: 'winner_verify', + campaignId: 'perps-c-1', + subscriptionId: 'sub-9', + variant: 'winner', }), ); expect( - state.dismissedCampaignOutcomeToasts['campaign-1:sub-1:winner_verify'], + state.dismissedCampaignOutcomeToasts['perps-c-1:sub-9:winner'], ).toBe(true); }); - it('records participant_no_winner variant as dismissed', () => { + it('records non_winner variant as dismissed', () => { const state = rewardsReducer( initialState, dismissCampaignOutcomeToast({ - campaignId: 'campaign-2', - subscriptionId: 'sub-2', - variant: 'participant_no_winner', + campaignId: 'ondo-c-1', + subscriptionId: 'sub-8', + variant: 'non_winner', }), ); expect( - state.dismissedCampaignOutcomeToasts[ - 'campaign-2:sub-2:participant_no_winner' - ], + state.dismissedCampaignOutcomeToasts['ondo-c-1:sub-8:non_winner'], ).toBe(true); }); @@ -5895,7 +5893,7 @@ describe('ondoCampaignDeposits', () => { dismissCampaignOutcomeToast({ campaignId: 'c1', subscriptionId: 's1', - variant: 'winner_verify', + variant: 'winner', }), ); state = rewardsReducer( @@ -5903,16 +5901,14 @@ describe('ondoCampaignDeposits', () => { dismissCampaignOutcomeToast({ campaignId: 'c2', subscriptionId: 's2', - variant: 'participant_no_winner', + variant: 'non_winner', }), ); - expect(state.dismissedCampaignOutcomeToasts['c1:s1:winner_verify']).toBe( + expect(state.dismissedCampaignOutcomeToasts['c1:s1:winner']).toBe(true); + expect(state.dismissedCampaignOutcomeToasts['c2:s2:non_winner']).toBe( true, ); - expect( - state.dismissedCampaignOutcomeToasts['c2:s2:participant_no_winner'], - ).toBe(true); }); it('starts with empty dismissedCampaignOutcomeToasts in initial state', () => { @@ -5925,7 +5921,7 @@ describe('ondoCampaignDeposits', () => { const persisted: RewardsState = { ...initialState, dismissedCampaignOutcomeToasts: { - 'campaign-1:sub-1:winner_verify': true, + 'campaign-1:sub-1:winner': true, }, }; @@ -5935,7 +5931,7 @@ describe('ondoCampaignDeposits', () => { }); expect(state.dismissedCampaignOutcomeToasts).toEqual({ - 'campaign-1:sub-1:winner_verify': true, + 'campaign-1:sub-1:winner': true, }); }); @@ -5958,7 +5954,7 @@ describe('ondoCampaignDeposits', () => { ...initialState, candidateSubscriptionId: 'old-sub', dismissedCampaignOutcomeToasts: { - 'campaign-1:old-sub:winner_verify': true, + 'campaign-1:old-sub:winner': true, }, }; @@ -5968,7 +5964,7 @@ describe('ondoCampaignDeposits', () => { ); expect(state.dismissedCampaignOutcomeToasts).toEqual({ - 'campaign-1:old-sub:winner_verify': true, + 'campaign-1:old-sub:winner': true, }); }); }); diff --git a/app/reducers/rewards/index.ts b/app/reducers/rewards/index.ts index b733c5c10d8..e35e437aaa3 100644 --- a/app/reducers/rewards/index.ts +++ b/app/reducers/rewards/index.ts @@ -866,7 +866,7 @@ const rewardsSlice = createSlice({ action: PayloadAction<{ campaignId: string; subscriptionId: string; - variant: 'winner_verify' | 'participant_no_winner'; + variant: 'winner' | 'non_winner'; }>, ) => { const key = `${action.payload.campaignId}:${action.payload.subscriptionId}:${action.payload.variant}`; diff --git a/app/reducers/rewards/selectors.test.ts b/app/reducers/rewards/selectors.test.ts index 9bac9897bab..9519b4cb778 100644 --- a/app/reducers/rewards/selectors.test.ts +++ b/app/reducers/rewards/selectors.test.ts @@ -51,6 +51,7 @@ import { selectCampaignsError, selectCampaignParticipantStatuses, selectCampaignParticipantStatus, + selectCampaignParticipantOptedIn, selectCampaignParticipantCount, selectIsRewardsVersionBlocked, selectVersionGuardMinimumMobileVersion, @@ -3285,6 +3286,39 @@ describe('Rewards selectors', () => { }); }); + describe('selectCampaignParticipantOptedIn', () => { + it('returns true when participant status is opted in', () => { + const state = createMockRootState({ + campaignParticipantStatuses: { + 'sub-1:campaign-1': { optedIn: true, participantCount: 42 }, + }, + }); + expect( + selectCampaignParticipantOptedIn('sub-1', 'campaign-1')(state), + ).toBe(true); + }); + + it('returns false when participant status is not opted in', () => { + const state = createMockRootState({ + campaignParticipantStatuses: { + 'sub-1:campaign-1': { optedIn: false, participantCount: 0 }, + }, + }); + expect( + selectCampaignParticipantOptedIn('sub-1', 'campaign-1')(state), + ).toBe(false); + }); + + it('returns false when status is missing', () => { + const state = createMockRootState({ + campaignParticipantStatuses: {}, + }); + expect( + selectCampaignParticipantOptedIn('sub-1', 'campaign-1')(state), + ).toBe(false); + }); + }); + describe('selectCampaignParticipantCount', () => { it('returns null when subscriptionId is undefined', () => { const state = createMockRootState({ @@ -3829,8 +3863,8 @@ describe('Rewards selectors', () => { it('returns the dismissed toasts map', () => { const dismissed = { - 'campaign-1:sub-1:winner_verify': true, - 'campaign-2:sub-1:participant_no_winner': true, + 'campaign-1:sub-1:winner': true, + 'campaign-2:sub-1:non_winner': true, }; const state = createMockRootState({ dismissedCampaignOutcomeToasts: dismissed, @@ -3841,11 +3875,11 @@ describe('Rewards selectors', () => { it('returns true for a dismissed toast key', () => { const state = createMockRootState({ dismissedCampaignOutcomeToasts: { - 'campaign-1:sub-1:winner_verify': true, + 'campaign-1:sub-1:winner': true, }, }); const result = selectDismissedCampaignOutcomeToasts(state); - expect(result['campaign-1:sub-1:winner_verify']).toBe(true); + expect(result['campaign-1:sub-1:winner']).toBe(true); }); it('returns undefined for a key that has not been dismissed', () => { @@ -3853,7 +3887,7 @@ describe('Rewards selectors', () => { dismissedCampaignOutcomeToasts: {}, }); const result = selectDismissedCampaignOutcomeToasts(state); - expect(result['campaign-1:sub-1:winner_verify']).toBeUndefined(); + expect(result['campaign-1:sub-1:winner']).toBeUndefined(); }); }); }); diff --git a/app/reducers/rewards/selectors.ts b/app/reducers/rewards/selectors.ts index 78fc6bc1d76..83c12f18cc9 100644 --- a/app/reducers/rewards/selectors.ts +++ b/app/reducers/rewards/selectors.ts @@ -188,6 +188,15 @@ export const selectCampaignParticipantStatus = return state.rewards.campaignParticipantStatuses?.[key] ?? null; }; +export const selectCampaignParticipantOptedIn = + ( + subscriptionId: string | undefined | null, + campaignId: string | undefined | null, + ) => + (state: RootState): boolean => + selectCampaignParticipantStatus(subscriptionId, campaignId)(state) + ?.optedIn === true; + export const selectCampaignParticipantCount = (subscriptionId: string | undefined, campaignId: string | undefined) => (state: RootState) => { diff --git a/app/selectors/rewards/index.ts b/app/selectors/rewards/index.ts index c4475cbb113..ca99456407f 100644 --- a/app/selectors/rewards/index.ts +++ b/app/selectors/rewards/index.ts @@ -43,17 +43,6 @@ export const selectRewardsSubscriptionId = createSelector( }, ); -export const selectCampaignParticipantOptedIn = - (subscriptionId: string | null, campaignId: string | undefined) => - (state: RootState): boolean => { - if (!subscriptionId || !campaignId) return false; - return ( - state.engine.backgroundState.RewardsController.campaignParticipantStatus[ - `${subscriptionId}:${campaignId}` - ]?.optedIn === true - ); - }; - export const selectRewardsActiveAccountAddress = createSelector( selectRewardsControllerState, (rewardsControllerState): string | null => { diff --git a/app/store/migrations/134.test.ts b/app/store/migrations/134.test.ts new file mode 100644 index 00000000000..e485c9ca363 --- /dev/null +++ b/app/store/migrations/134.test.ts @@ -0,0 +1,168 @@ +import { captureException } from '@sentry/react-native'; +import migrate, { migrationVersion } from './134'; +import { ensureValidState } from './util'; + +jest.mock('@sentry/react-native', () => ({ + captureException: jest.fn(), +})); + +jest.mock('./util', () => ({ + ensureValidState: jest.fn(), +})); + +const mockedEnsureValidState = jest.mocked(ensureValidState); +const mockedCaptureException = jest.mocked(captureException); + +const SEI_MAINNET_CHAIN_ID = '0x531'; + +interface SeiNetworkConfiguration { + blockExplorerUrls: string[]; + chainId: string; + defaultBlockExplorerUrlIndex?: number; + defaultRpcEndpointIndex: number; + name: string; + nativeCurrency: string; + rpcEndpoints: { + networkClientId: string; + url: string; + type: string; + failoverUrls?: string[]; + }[]; +} + +interface TestState { + engine: { + backgroundState: { + NetworkController?: { + networkConfigurationsByChainId?: Record< + string, + SeiNetworkConfiguration + >; + selectedNetworkClientId?: string; + [key: string]: unknown; + }; + [key: string]: unknown; + }; + }; + [key: string]: unknown; +} + +function buildSeiConfig(blockExplorerUrls: string[]): SeiNetworkConfiguration { + return { + blockExplorerUrls, + chainId: SEI_MAINNET_CHAIN_ID, + defaultBlockExplorerUrlIndex: 0, + defaultRpcEndpointIndex: 0, + name: 'Sei', + nativeCurrency: 'SEI', + rpcEndpoints: [ + { + networkClientId: 'sei-mainnet', + url: 'https://sei-mainnet.infura.io/v3/fake', + type: 'custom', + }, + ], + }; +} + +function buildState(seiConfig?: SeiNetworkConfiguration): TestState { + return { + engine: { + backgroundState: { + NetworkController: { + networkConfigurationsByChainId: seiConfig + ? { [SEI_MAINNET_CHAIN_ID]: seiConfig } + : {}, + selectedNetworkClientId: 'mainnet', + }, + }, + }, + }; +} + +describe(`Migration ${migrationVersion}: Replace Seitrace with Seiscan for Sei Mainnet`, () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedEnsureValidState.mockReturnValue(true); + }); + + it('reports the expected migration version', () => { + expect(migrationVersion).toBe(134); + }); + + it('rewrites Seitrace block explorer URL to Seiscan for Sei Mainnet', () => { + const state = buildState(buildSeiConfig(['https://seitrace.com'])); + + const result = migrate(state) as TestState; + + const seiConfig = + result.engine.backgroundState.NetworkController + ?.networkConfigurationsByChainId?.[SEI_MAINNET_CHAIN_ID]; + expect(seiConfig?.blockExplorerUrls).toStrictEqual(['https://seiscan.io/']); + expect(mockedCaptureException).not.toHaveBeenCalled(); + }); + + it('leaves state unchanged when Sei Mainnet is not configured', () => { + const state: TestState = { + engine: { + backgroundState: { + NetworkController: { + networkConfigurationsByChainId: { + '0x1': buildSeiConfig(['https://etherscan.io']), + }, + selectedNetworkClientId: 'mainnet', + }, + }, + }, + }; + const snapshotBefore = JSON.stringify(state); + + const result = migrate(state); + + expect(JSON.stringify(result)).toBe(snapshotBefore); + expect(mockedCaptureException).not.toHaveBeenCalled(); + }); + + it('silently skips when NetworkController is missing (upgrade-from-old-version)', () => { + const state = { + engine: { + backgroundState: { + SomeOtherController: { foo: 'bar' }, + }, + }, + }; + const snapshotBefore = JSON.stringify(state); + + const result = migrate(state); + + expect(JSON.stringify(result)).toBe(snapshotBefore); + expect(mockedCaptureException).not.toHaveBeenCalled(); + }); + + it('does not touch user-customized block explorer URLs', () => { + const state = buildState(buildSeiConfig(['https://seistream.app'])); + + const result = migrate(state) as TestState; + + const seiConfig = + result.engine.backgroundState.NetworkController + ?.networkConfigurationsByChainId?.[SEI_MAINNET_CHAIN_ID]; + expect(seiConfig?.blockExplorerUrls).toStrictEqual([ + 'https://seistream.app', + ]); + expect(mockedCaptureException).not.toHaveBeenCalled(); + }); + + it('does not rewrite a URL whose host only starts with seitrace.com', () => { + const lookalike = 'https://seitrace.com.attacker.example/path'; + const state = buildState(buildSeiConfig([lookalike])); + + const result = migrate(state) as TestState; + + const seiConfig = + result.engine.backgroundState.NetworkController + ?.networkConfigurationsByChainId?.[SEI_MAINNET_CHAIN_ID]; + expect(seiConfig?.blockExplorerUrls).toStrictEqual([lookalike]); + expect(mockedCaptureException).not.toHaveBeenCalled(); + }); +}); diff --git a/app/store/migrations/134.ts b/app/store/migrations/134.ts new file mode 100644 index 00000000000..c332a0e1b1b --- /dev/null +++ b/app/store/migrations/134.ts @@ -0,0 +1,176 @@ +import { captureException } from '@sentry/react-native'; +import { + getErrorMessage, + hasProperty, + Hex, + isHexString, + isObject, +} from '@metamask/utils'; + +import { ensureValidState } from './util'; + +/** + * Migration 134: replace the deprecated Seitrace block explorer URL + * (`seitrace.com`, being decommissioned) with its replacement Seiscan + * (`seiscan.io`) for Sei Mainnet on existing user installs. + * + * Users without Sei Mainnet configured: no-op (silent). + * Users who customized the explorer URL away from Seitrace: no-op + * (only entries that still point at `seitrace.com` are rewritten). + * Users missing `NetworkController` entirely: no-op (silent) — expected + * during upgrade-from-old-version. + */ +export const migrationVersion = 134; + +const SEI_MAINNET_CHAIN_ID: Hex = '0x531'; // 1329 +const OLD_HOSTNAME = 'seitrace.com'; +const NEW_HOSTNAME = 'seiscan.io'; + +interface RpcEndpoint { + failoverUrls?: string[]; + name?: string; + networkClientId: string; + url: string; + type: string; +} + +interface NetworkConfiguration { + blockExplorerUrls: string[]; + chainId: Hex; + defaultBlockExplorerUrlIndex?: number; + defaultRpcEndpointIndex: number; + name: string; + nativeCurrency: string; + rpcEndpoints: RpcEndpoint[]; +} + +const migration = (state: unknown): unknown => { + if (!ensureValidState(state, migrationVersion)) { + return state; + } + + try { + const networkControllerState = validateNetworkController(state); + if (networkControllerState === undefined) { + return state; + } + + const { networkConfigurationsByChainId } = networkControllerState; + if (!hasProperty(networkConfigurationsByChainId, SEI_MAINNET_CHAIN_ID)) { + return state; + } + + const seiConfig = networkConfigurationsByChainId[SEI_MAINNET_CHAIN_ID]; + if (!isValidNetworkConfiguration(seiConfig)) { + return state; + } + + const rewritten = seiConfig.blockExplorerUrls.map((url) => { + try { + const parsed = new URL(url); + if (parsed.hostname === OLD_HOSTNAME) { + parsed.hostname = NEW_HOSTNAME; + return parsed.toString(); + } + } catch { + // not a valid URL, leave as-is + } + return url; + }); + const didChange = rewritten.some( + (url, index) => url !== seiConfig.blockExplorerUrls[index], + ); + + if (didChange) { + seiConfig.blockExplorerUrls = rewritten; + } + } catch (error) { + captureException( + new Error( + `Migration ${migrationVersion}: Failed to rewrite Sei Mainnet block explorer URL: ${getErrorMessage( + error, + )}`, + ), + ); + } + + return state; +}; + +export default migration; + +// Sentry logging is intentionally omitted — expected-missing states +// (NetworkController absent, Sei not configured) are not errors. +function validateNetworkController(state: { + engine: { backgroundState: Record }; +}): + | { + networkConfigurationsByChainId: Record; + selectedNetworkClientId: string; + } + | undefined { + if (!hasProperty(state.engine.backgroundState, 'NetworkController')) { + // Expected during upgrade-from-old-version — don't log. + return undefined; + } + + const networkControllerState = state.engine.backgroundState.NetworkController; + + if (!isValidNetworkControllerState(networkControllerState)) { + return undefined; + } + + return networkControllerState; +} + +function isValidNetworkControllerState(value: unknown): value is { + networkConfigurationsByChainId: Record; + selectedNetworkClientId: string; +} { + if (!isObject(value)) { + return false; + } + + if ( + !hasProperty(value, 'networkConfigurationsByChainId') || + !isValidNetworkConfigurationsByChainId(value.networkConfigurationsByChainId) + ) { + return false; + } + + if ( + !hasProperty(value, 'selectedNetworkClientId') || + typeof value.selectedNetworkClientId !== 'string' + ) { + return false; + } + + return true; +} + +function isValidNetworkConfigurationsByChainId( + value: unknown, +): value is Record { + return ( + isObject(value) && + Object.entries(value).every( + ([chainId]) => typeof chainId === 'string' && isHexString(chainId), + ) + ); +} + +// Minimal validator — only chainId + blockExplorerUrls need to be sound +// for this migration. +function isValidNetworkConfiguration( + object: unknown, +): object is NetworkConfiguration { + return ( + isObject(object) && + hasProperty(object, 'chainId') && + typeof object.chainId === 'string' && + isHexString(object.chainId) && + hasProperty(object, 'blockExplorerUrls') && + Array.isArray(object.blockExplorerUrls) && + object.blockExplorerUrls.every((url) => typeof url === 'string') + ); +} diff --git a/app/store/migrations/index.ts b/app/store/migrations/index.ts index 43daf523d7a..947dbb5bdec 100644 --- a/app/store/migrations/index.ts +++ b/app/store/migrations/index.ts @@ -134,6 +134,7 @@ import migration130 from './130'; import migration131 from './131'; import migration132 from './132'; import migration133 from './133'; +import migration134 from './134'; // Add migrations above this line import { ControllerStorage } from '../persistConfig'; @@ -287,6 +288,7 @@ export const migrationList: MigrationsList = { 131: migration131, 132: migration132, 133: migration133, + 134: migration134, }; // Enable both synchronous and asynchronous migrations diff --git a/app/util/networks/customNetworks.tsx b/app/util/networks/customNetworks.tsx index a53aaf86221..7c2aaa208d2 100644 --- a/app/util/networks/customNetworks.tsx +++ b/app/util/networks/customNetworks.tsx @@ -159,7 +159,7 @@ export const PopularList = [ ticker: 'SEI', warning: true, rpcPrefs: { - blockExplorerUrl: 'https://seitrace.com/', + blockExplorerUrl: 'https://seiscan.io/', imageUrl: 'SEI', imageSource: require('../../images/sei.png'), }, diff --git a/app/util/test/initial-background-state.json b/app/util/test/initial-background-state.json index b6c21b31582..72358bb8306 100644 --- a/app/util/test/initial-background-state.json +++ b/app/util/test/initial-background-state.json @@ -421,7 +421,26 @@ "AssetsController": { "assetPreferences": {}, "assetsBalance": {}, - "assetsInfo": {}, + "assetsInfo": { + "eip155:1/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da": { + "decimals": 6, + "name": "MetaMask USD", + "symbol": "mUSD", + "type": "erc20" + }, + "eip155:143/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da": { + "decimals": 6, + "name": "MetaMask USD", + "symbol": "mUSD", + "type": "erc20" + }, + "eip155:59144/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da": { + "decimals": 6, + "name": "MetaMask USD", + "symbol": "mUSD", + "type": "erc20" + } + }, "assetsPrice": {}, "customAssets": {}, "selectedCurrency": "usd" diff --git a/app/util/transaction-controller/index.test.ts b/app/util/transaction-controller/index.test.ts index 3fa73204871..64827debf4f 100644 --- a/app/util/transaction-controller/index.test.ts +++ b/app/util/transaction-controller/index.test.ts @@ -4,6 +4,7 @@ import { type TransactionMeta, TransactionEnvelopeType, IsAtomicBatchSupportedRequest, + getAccountAddressRelationship, } from '@metamask/transaction-controller'; import { cloneDeep, omit } from 'lodash'; //eslint-disable-next-line import-x/no-namespace @@ -27,9 +28,19 @@ const { estimateGasFee, getPreviousGasFromController, getChainIdFromNetworkClientId, + checkFirstTimeInteraction, ...proxyMethods } = TransactionControllerUtils; +jest.mock('@metamask/transaction-controller', () => ({ + ...jest.requireActual('@metamask/transaction-controller'), + getAccountAddressRelationship: jest.fn(), +})); + +const mockGetAccountAddressRelationship = jest.mocked( + getAccountAddressRelationship, +); + jest.mock('../../store', () => ({ store: { getState: jest.fn(() => ({ @@ -868,4 +879,37 @@ describe('Transaction Controller Util', () => { expect(result).toBe(mockResult); }); }); + + describe('checkFirstTimeInteraction', () => { + const request = { from: '0xabc', to: '0xdef', chainId: 1 }; + + it('returns true when count is 0 (first time)', async () => { + mockGetAccountAddressRelationship.mockResolvedValueOnce({ count: 0 }); + const result = await checkFirstTimeInteraction(request); + expect(result).toBe(true); + expect(mockGetAccountAddressRelationship).toHaveBeenCalledWith(request); + }); + + it('returns false when count is greater than 0', async () => { + mockGetAccountAddressRelationship.mockResolvedValueOnce({ count: 5 }); + const result = await checkFirstTimeInteraction(request); + expect(result).toBe(false); + }); + + it('returns undefined when count is undefined', async () => { + mockGetAccountAddressRelationship.mockResolvedValueOnce({ + count: undefined, + }); + const result = await checkFirstTimeInteraction(request); + expect(result).toBeUndefined(); + }); + + it('returns undefined when API call throws', async () => { + mockGetAccountAddressRelationship.mockRejectedValueOnce( + new Error('network error'), + ); + const result = await checkFirstTimeInteraction(request); + expect(result).toBeUndefined(); + }); + }); }); diff --git a/app/util/transaction-controller/index.ts b/app/util/transaction-controller/index.ts index 50a7b31f042..247c46bed62 100644 --- a/app/util/transaction-controller/index.ts +++ b/app/util/transaction-controller/index.ts @@ -6,6 +6,7 @@ import { TransactionController as BaseTransactionController, IsAtomicBatchSupportedRequest, IsAtomicBatchSupportedResult, + getAccountAddressRelationship, Result, } from '@metamask/transaction-controller'; import { NetworkClientId } from '@metamask/network-controller'; @@ -330,6 +331,23 @@ export async function isAtomicBatchSupported( return TransactionController?.isAtomicBatchSupported(request); } +/** + * Returns whether the sender has no prior on-chain interaction with `to` on `chainId`, + * or `undefined` when the relationship cannot be determined (API error or unknown count). + */ +export async function checkFirstTimeInteraction(request: { + from: string; + to: string; + chainId: number; +}): Promise { + try { + const result = await getAccountAddressRelationship(request); + return result.count === undefined ? undefined : result.count === 0; + } catch { + return undefined; + } +} + function sanitizeTransactionParamsGasValues( transactionId: string, requestedTransactionParamsToUpdate: Partial, diff --git a/locales/languages/de.json b/locales/languages/de.json index 46be6a42eed..28b98585ba4 100644 --- a/locales/languages/de.json +++ b/locales/languages/de.json @@ -8658,29 +8658,18 @@ "retry_button": "Erneut versuchen", "refreshing": "Aktualisierung ..." }, - "ondo_campaign_winning": { - "you_won": "Sie haben gewonnen", - "rank_label": "{{place}} Ort", - "email_instructions": "Senden Sie eine E-Mail an ondocampaign@consensys.net mit Ihrem Code, um Ihren Preis zu erhalten.", - "open_mail": "E-Mail öffnen", - "skip_for_now": "Vorläufig überspringen", - "mail_subject": "Ondo-Kampagnenpreis einfordern", - "mail_body": "Mein Gewinncode: {{code}}", - "winning_code": "Gewinncode", - "close_a11y": "Schließen" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Sie haben gewonnen", - "description": "Fordern Sie Ihren Preis noch heute ein.", - "a11y": "Gewinnerdetails öffnen" + "title": "Sie haben gewonnen!", + "description": "Überprüfen Sie Ihren Gewinncode und fordern Sie Ihren Preis noch heute ein.", + "a11y": "Details anzeigen" }, "participant_pending": { "title": "Kampagne ist beendet.", "description": "Wir ermitteln gerade die Ergebnisse. Schauen Sie bald wieder vorbei." }, "participant_finalized": { - "title": "Kampagnenergebnisse sind verfügbar", + "title": "Die Kampagnenergebnisse liegen vor.", "description": "Sie haben dieses Mal nicht gewonnen. Überprüfen Sie die Rangliste, um Ihre Platzierung einzusehen." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Ihre Belohnung ist unterwegs – wir werden uns in Kürze bei Ihnen melden." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Sie haben gewonnen 💰", - "description": "Fordern Sie Ihren Preis aus der {{campaignName}} ein", + "description": "Fordern Sie Ihren Preis aus der {{campaignName}} noch heute ein.", "cta": "Details anzeigen" }, - "participant_no_winner": { - "title": "Die Ergebnisse sind verfügbar. 🏅", - "description": "Sehen Sie Ihre Platzierung in der {{campaignName}} ein", + "non_winner": { + "title": "Die Ergebnisse liegen vor 🏅", + "description": "Sehen Sie Ihre Platzierung in der {{campaignName}}.", "cta": "Ansehen" } + }, + "campaign_winning": { + "you_won": "Sie haben gewonnen", + "rank_label": "{{place}} Ort", + "email_instructions": "Senden Sie eine E-Mail an {{email}} mit Ihrem Code, um Ihren Preis zu erhalten.", + "open_mail": "E-Mail öffnen", + "skip_for_now": "Vorläufig überspringen", + "mail_subject": "{{campaignName}} – Preis einfordern", + "mail_body": "Mein Gewinncode: {{code}}", + "winning_code": "Gewinncode", + "close_a11y": "Schließen" } }, "time": { diff --git a/locales/languages/el.json b/locales/languages/el.json index 8df088c6d71..13ec23005cd 100644 --- a/locales/languages/el.json +++ b/locales/languages/el.json @@ -8658,29 +8658,18 @@ "retry_button": "Επανάληψη", "refreshing": "Ανανεώνεται..." }, - "ondo_campaign_winning": { - "you_won": "Κερδίσατε", - "rank_label": "θέση {{place}}", - "email_instructions": "Στείλτε email στο ondocampaign@consensys.net με τον κωδικό σας για να διεκδικήσετε το βραβείο σας.", - "open_mail": "Άνοιγμα αλληλογραφίας", - "skip_for_now": "Παράλειψη για τώρα", - "mail_subject": "Διεκδίκηση βραβείου στην καμπάνια του Ondo", - "mail_body": "Κωδικός νίκης: {{code}}", - "winning_code": "Κωδικός νίκης", - "close_a11y": "Κλείσιμο" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Κερδίσατε", - "description": "Διεκδικήστε το βραβείο σας σήμερα.", - "a11y": "Άνοιγμα λεπτομερειών νικητή" + "title": "Κερδίσατε!", + "description": "Επαληθεύστε τον κωδικό νίκης σας και διεκδικήστε το βραβείο σας σήμερα.", + "a11y": "Προβολή λεπτομερειών" }, "participant_pending": { "title": "Η καμπάνια έχει λήξει.", "description": "Επεξεργαζόμαστε τα αποτελέσματα. Ελέγξτε ξανά σύντομα." }, "participant_finalized": { - "title": "Τα αποτελέσματα της καμπάνιας είναι έτοιμα", + "title": "Τα αποτελέσματα της καμπάνιας είναι έτοιμα.", "description": "Δεν κερδίσατε αυτή τη φορά. Δείτε τον πίνακα κατάταξης για να δείτε σε ποια θέση τερματίσατε." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Το βραβείο σας είναι καθ’ οδόν — θα επικοινωνήσουμε μαζί σας σύντομα." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Κερδίσατε 💰", - "description": "Διεκδικήστε το βραβείο σας από την {{campaignName}}", + "description": "Διεκδικήστε το βραβείο σας από την {{campaignName}} σήμερα.", "cta": "Δείτε λεπτομέρειες" }, - "participant_no_winner": { - "title": "Τα αποτελέσματα είναι έτοιμα. 🏅", - "description": "Δείτε την κατάταξή σας στην {{campaignName}}", + "non_winner": { + "title": "Τα αποτελέσματα είναι έτοιμα 🏅", + "description": "Δείτε την κατάταξή σας στην {{campaignName}}.", "cta": "Προβολή" } + }, + "campaign_winning": { + "you_won": "Κερδίσατε", + "rank_label": "θέση {{place}}", + "email_instructions": "Στείλτε email στο {{email}} με τον κωδικό σας για να διεκδικήσετε το βραβείο σας.", + "open_mail": "Άνοιγμα αλληλογραφίας", + "skip_for_now": "Παράλειψη για τώρα", + "mail_subject": "{{campaignName}} — διεκδίκηση βραβείου", + "mail_body": "Κωδικός νίκης: {{code}}", + "winning_code": "Κωδικός νίκης", + "close_a11y": "Κλείσιμο" } }, "time": { diff --git a/locales/languages/en.json b/locales/languages/en.json index aeb7ba9d85e..7ef2b7ed478 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -709,7 +709,11 @@ "smart_contract_address": "Smart contract address", "smart_contract_address_warning": "The recipient address may not support direct token transfers, which could result in fund loss. Only continue if you're certain this contract can receive your transfer.", "i_understand": "I understand", - "cancel": "Cancel" + "cancel": "Cancel", + "new_address_title": "New address", + "new_address_message": "You're sending to this address for the first time. Make sure that it's correct before you continue:", + "alert_navigation_previous": "Previous alert", + "alert_navigation_next": "Next alert" }, "unified_ramp": { "networks_filter_bar": { @@ -8641,7 +8645,11 @@ "stats_qualify_for_rank_description": "Trade {{notionalRemaining}} more in notional volume to become eligible.", "stats_error_title": "Unable to load stats", "stats_error_description": "We had a problem loading your stats. Please try again.", - "stats_retry": "Retry" + "stats_retry": "Retry", + "completed_label_total_participants": "Total participants", + "completed_label_total_volume": "Total volume", + "completed_label_top_pnl": "Top PnL", + "completed_label_winners": "Winners" }, "campaigns_preview": { "title": "Campaigns", @@ -8667,29 +8675,29 @@ "retry_button": "Retry", "refreshing": "Refreshing..." }, - "ondo_campaign_winning": { + "campaign_winning": { "you_won": "You won", "rank_label": "{{place}} place", - "email_instructions": "Email ondocampaign@consensys.net with your code to claim your prize.", + "email_instructions": "Email {{email}} with your code to claim your prize.", "open_mail": "Open mail", "skip_for_now": "Skip for now", - "mail_subject": "Ondo campaign prize claim", + "mail_subject": "{{campaignName}} prize claim", "mail_body": "My winning code: {{code}}", "winning_code": "Winning code", "close_a11y": "Close" }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "You won", - "description": "Claim your prize today.", - "a11y": "Open winner details" + "title": "You won!", + "description": "Verify your winning code and claim your prize today.", + "a11y": "View details" }, "participant_pending": { "title": "Campaign has ended.", "description": "We're determining the results. Check back soon." }, "participant_finalized": { - "title": "Campaign results are in", + "title": "Campaign results are in.", "description": "You didn't win this time. Check the leaderboard to see where you finished." }, "winner_finalized": { @@ -8697,15 +8705,15 @@ "description": "Your reward is on its way — we'll be in touch shortly." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "You won 💰", - "description": "Claim your prize from the {{campaignName}}", + "description": "Claim your prize from the {{campaignName}} today.", "cta": "View details" }, - "participant_no_winner": { - "title": "The results are in. 🏅", - "description": "See your ranking in the {{campaignName}}", + "non_winner": { + "title": "The results are in 🏅", + "description": "See your ranking in the {{campaignName}}.", "cta": "View" } } diff --git a/locales/languages/es.json b/locales/languages/es.json index f228b7929ee..7a1d9ed8af8 100644 --- a/locales/languages/es.json +++ b/locales/languages/es.json @@ -8658,29 +8658,18 @@ "retry_button": "Reintentar", "refreshing": "Actualizando..." }, - "ondo_campaign_winning": { - "you_won": "Tú ganas", - "rank_label": "{{place}} lugar", - "email_instructions": "Para reclamar tu premio, envía un correo electrónico a ondocampaign@consensys.net con tu código.", - "open_mail": "Abrir correo", - "skip_for_now": "Omitir por ahora", - "mail_subject": "Reclamación de premio de la campaña de Ondo", - "mail_body": "Mi código ganador: {{code}}", - "winning_code": "Código ganador", - "close_a11y": "Cerrar" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Tú ganas", - "description": "Reclama tu premio hoy mismo.", - "a11y": "Abrir detalles del ganador" + "title": "¡Has ganado!", + "description": "Verifica tu código ganador y reclama tu premio hoy.", + "a11y": "Ver detalles" }, "participant_pending": { "title": "La campaña terminó.", "description": "Estamos determinando los resultados. Vuelve a consultar pronto." }, "participant_finalized": { - "title": "Los resultados de la campaña están en", + "title": "Ya hay resultados de la campaña.", "description": "Esta vez no has ganado. Consulta la clasificación para ver en qué posición has quedado." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Tu recompensa está en camino; nos pondremos en contacto contigo en breve." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Tú ganas 💰", - "description": "Reclama tu premio en la {{campaignName}}", + "description": "Reclama tu premio de la {{campaignName}} hoy.", "cta": "Ver detalles" }, - "participant_no_winner": { - "title": "Ya tenemos los resultados. 🏅", - "description": "Consulta tu clasificación en {{campaignName}}", + "non_winner": { + "title": "Ya hay resultados 🏅", + "description": "Consulta tu posición en la {{campaignName}}.", "cta": "Ver" } + }, + "campaign_winning": { + "you_won": "Tú ganas", + "rank_label": "{{place}} lugar", + "email_instructions": "Para reclamar tu premio, envía un correo electrónico a {{email}} con tu código.", + "open_mail": "Abrir correo", + "skip_for_now": "Omitir por ahora", + "mail_subject": "{{campaignName}} – reclamación de premio", + "mail_body": "Mi código ganador: {{code}}", + "winning_code": "Código ganador", + "close_a11y": "Cerrar" } }, "time": { diff --git a/locales/languages/fr.json b/locales/languages/fr.json index 5d699bb6f69..22621832f2d 100644 --- a/locales/languages/fr.json +++ b/locales/languages/fr.json @@ -8658,29 +8658,18 @@ "retry_button": "Réessayer", "refreshing": "Actualisation en cours…" }, - "ondo_campaign_winning": { - "you_won": "Vous avez gagné", - "rank_label": "{{place}} place", - "email_instructions": "Envoyez votre code par e-mail à ondocampaign@consensys.net pour réclamer votre récompense.", - "open_mail": "Ouvrir l’e-mail", - "skip_for_now": "Ignorer pour l’instant", - "mail_subject": "Réclamation du prix que vous avez gagné en participant à la campagne Ondo", - "mail_body": "Mon code gagnant : {{code}}", - "winning_code": "Code gagnant", - "close_a11y": "Fermer" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Vous avez gagné", - "description": "Réclamez votre prix dès aujourd’hui.", - "a11y": "Afficher les détails du gagnant" + "title": "Vous avez gagné !", + "description": "Vérifiez votre code gagnant et réclamez votre récompense aujourd’hui.", + "a11y": "Voir les détails" }, "participant_pending": { "title": "La campagne est terminée.", "description": "Nous sommes en train d’évaluer les résultats. Revenez bientôt." }, "participant_finalized": { - "title": "Les résultats de la campagne sont disponibles", + "title": "Les résultats de la campagne sont disponibles.", "description": "Vous n’avez pas gagné cette fois-ci. Découvrez quelle place vous occupez au classement final." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Votre prix est en route. Nous vous contacterons bientôt." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Vous avez gagné 💰", - "description": "Réclamez le prix que vous avez gagné en participant à la campagne {{campaignName}}", + "description": "Réclamez votre récompense de la {{campaignName}} aujourd’hui.", "cta": "Voir les détails" }, - "participant_no_winner": { - "title": "Les résultats sont disponibles. 🏅", - "description": "Découvrez votre classement dans la campagne {{campaignName}}", + "non_winner": { + "title": "Les résultats sont disponibles 🏅", + "description": "Consultez votre classement dans la {{campaignName}}.", "cta": "Afficher" } + }, + "campaign_winning": { + "you_won": "Vous avez gagné", + "rank_label": "{{place}} place", + "email_instructions": "Envoyez votre code par e-mail à {{email}} pour réclamer votre récompense.", + "open_mail": "Ouvrir l’e-mail", + "skip_for_now": "Ignorer pour l’instant", + "mail_subject": "{{campaignName}} – réclamation de prix", + "mail_body": "Mon code gagnant : {{code}}", + "winning_code": "Code gagnant", + "close_a11y": "Fermer" } }, "time": { diff --git a/locales/languages/hi.json b/locales/languages/hi.json index 962fd14ccc8..ced9d00e771 100644 --- a/locales/languages/hi.json +++ b/locales/languages/hi.json @@ -8658,29 +8658,18 @@ "retry_button": "फिर से प्रयास करें", "refreshing": "रिफ्रेश हो रहा है..." }, - "ondo_campaign_winning": { - "you_won": "आप जीत गए", - "rank_label": "{{place}} जगह", - "email_instructions": "अपना इनाम पाने के लिए ondocampaign@consensys.net पर अपना कोड ईमेल करें।", - "open_mail": "मेल खोलें", - "skip_for_now": "अभी के लिए स्किप करें", - "mail_subject": "Ondo कैंपेन प्राइज़ क्लेम", - "mail_body": "मेरा विनिंग कोड: {{code}}", - "winning_code": "विनिंग कोड", - "close_a11y": "बंद करें" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "आप जीत गए", - "description": "आज ही अपना प्राइज़ क्लेम करें।", - "a11y": "विनर की जानकारी खोलें" + "title": "आप जीत गए!", + "description": "अपना विजेता कोड सत्यापित करें और आज ही अपना इनाम दावा करें।", + "a11y": "विवरण देखें" }, "participant_pending": { "title": "कैंपेन खत्म हो गया है।", "description": "हम रिज़ल्ट तय कर रहे हैं। जल्द ही वापस आकर देखें।" }, "participant_finalized": { - "title": "कैंपेन के रिज़ल्ट आ गए हैं", + "title": "कैंपेन के परिणाम आ गए हैं।", "description": "आप इस बार नहीं जीते। लीडरबोर्ड देखें कि आप कहाँ पर हैं।" }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "आपका रिवॉर्ड आने वाला है — हम जल्द ही आपसे कॉन्टैक्ट करेंगे।" } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "आप जीत गए 💰", - "description": "{{campaignName}} से अपना प्राइज़ क्लेम करें", + "description": "आज ही {{campaignName}} से अपना इनाम दावा करें।", "cta": "विवरण देखें" }, - "participant_no_winner": { - "title": "रिज़ल्ट आ गए हैं। 🏅", - "description": "{{campaignName}} में अपनी रैंकिंग देखें", + "non_winner": { + "title": "परिणाम आ गए हैं 🏅", + "description": "{{campaignName}} में अपनी रैंकिंग देखें।", "cta": "देखें" } + }, + "campaign_winning": { + "you_won": "आप जीत गए", + "rank_label": "{{place}} जगह", + "email_instructions": "अपना इनाम पाने के लिए {{email}} पर अपना कोड ईमेल करें।", + "open_mail": "मेल खोलें", + "skip_for_now": "अभी के लिए स्किप करें", + "mail_subject": "{{campaignName}} – पुरस्कार दावा", + "mail_body": "मेरा विनिंग कोड: {{code}}", + "winning_code": "विनिंग कोड", + "close_a11y": "बंद करें" } }, "time": { diff --git a/locales/languages/id.json b/locales/languages/id.json index 487c8399e66..b9384ae959a 100644 --- a/locales/languages/id.json +++ b/locales/languages/id.json @@ -8658,29 +8658,18 @@ "retry_button": "Coba lagi", "refreshing": "Menyegarkan..." }, - "ondo_campaign_winning": { - "you_won": "Anda menang", - "rank_label": "{{place}} tempat", - "email_instructions": "Kirimkan kode Anda ke ondocampaign@consensys.net untuk mengklaim hadiah Anda.", - "open_mail": "Buka surat", - "skip_for_now": "Lewati untuk saat ini", - "mail_subject": "Klaim hadiah kampanye Ondo", - "mail_body": "Kode pemenang: {{code}}", - "winning_code": "Kode pemenang", - "close_a11y": "Tutup" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Anda menang", - "description": "Klaim hadiah Anda hari ini.", - "a11y": "Buka detail pemenang" + "title": "Anda menang!", + "description": "Verifikasi kode kemenangan Anda dan klaim hadiah Anda hari ini.", + "a11y": "Lihat detail" }, "participant_pending": { "title": "Kampanye telah berakhir.", "description": "Kami sedang menentukan hasilnya. Periksa kembali sesaat lagi." }, "participant_finalized": { - "title": "Hasil kampanye telah keluar", + "title": "Hasil kampanye sudah tersedia.", "description": "Anda tidak menang kali ini. Periksa papan peringkat untuk melihat posisi Anda." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Reward sedang dikirim — kami akan segera menghubungi Anda." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Anda menang 💰", - "description": "Klaim hadiah dari {{campaignName}}", + "description": "Klaim hadiah Anda dari {{campaignName}} hari ini.", "cta": "Lihat detail" }, - "participant_no_winner": { - "title": "Hasilnya sudah keluar. 🏅", - "description": "Lihat peringkat Anda di {{campaignName}}", + "non_winner": { + "title": "Hasil sudah tersedia 🏅", + "description": "Lihat peringkat Anda di {{campaignName}}.", "cta": "Lihat" } + }, + "campaign_winning": { + "you_won": "Anda menang", + "rank_label": "{{place}} tempat", + "email_instructions": "Kirimkan kode Anda ke {{email}} untuk mengklaim hadiah Anda.", + "open_mail": "Buka surat", + "skip_for_now": "Lewati untuk saat ini", + "mail_subject": "Klaim hadiah {{campaignName}}", + "mail_body": "Kode pemenang: {{code}}", + "winning_code": "Kode pemenang", + "close_a11y": "Tutup" } }, "time": { diff --git a/locales/languages/ja.json b/locales/languages/ja.json index 9950f780af3..62c3a10836a 100644 --- a/locales/languages/ja.json +++ b/locales/languages/ja.json @@ -8658,29 +8658,18 @@ "retry_button": "再試行", "refreshing": "更新中..." }, - "ondo_campaign_winning": { - "you_won": "予測的中", - "rank_label": "{{place}}位", - "email_instructions": "賞品を請求するには、ondocampaign@consensys.netにメールでコードを送信してください。", - "open_mail": "メールを開く", - "skip_for_now": "今はスキップ", - "mail_subject": "Ondoキャンペーン賞品の請求", - "mail_body": "勝利コード: {{code}}", - "winning_code": "勝利コード", - "close_a11y": "閉じる" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "予測的中", - "description": "今すぐ賞品を請求しましょう。", - "a11y": "勝者の詳細を開く" + "title": "当選しました!", + "description": "当選コードを確認し、本日中に賞品を請求してください。", + "a11y": "詳細を表示" }, "participant_pending": { "title": "キャンペーンが終了しました。", "description": "結果を判断しています。近々またご確認ください。" }, "participant_finalized": { - "title": "キャンペーンの結果が出ました", + "title": "キャンペーンの結果が出ました。", "description": "今回は勝ちませんでした。リーダーボードで順位を確認しましょう。" }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "間もなく報酬を受け取れます。近々ご連絡します。" } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "予測的中 💰", - "description": "{{campaignName}}の賞品を請求", + "description": "本日、{{campaignName}}の賞品を請求してください。", "cta": "詳細を表示" }, - "participant_no_winner": { - "title": "結果が出ました。🏅", - "description": "{{campaignName}}でのランキングをご覧ください", + "non_winner": { + "title": "結果が出ました 🏅", + "description": "{{campaignName}}での順位を確認してください。", "cta": "表示" } + }, + "campaign_winning": { + "you_won": "予測的中", + "rank_label": "{{place}}位", + "email_instructions": "賞品を請求するには、{{email}}にメールでコードを送信してください。", + "open_mail": "メールを開く", + "skip_for_now": "今はスキップ", + "mail_subject": "{{campaignName}} 賞品の請求", + "mail_body": "勝利コード: {{code}}", + "winning_code": "勝利コード", + "close_a11y": "閉じる" } }, "time": { diff --git a/locales/languages/ko.json b/locales/languages/ko.json index 7406260871f..01372f65546 100644 --- a/locales/languages/ko.json +++ b/locales/languages/ko.json @@ -8658,29 +8658,18 @@ "retry_button": "다시 시도", "refreshing": "새로 고침 중..." }, - "ondo_campaign_winning": { - "you_won": "승리하셨습니다", - "rank_label": "{{place}} 장소", - "email_instructions": "상품을 받으려면 당첨 코드를 ondocampaign@consensys.net으로 이메일로 보내세요.", - "open_mail": "메일 열기", - "skip_for_now": "지금은 건너뛰기", - "mail_subject": "Ondo 캠페인 상품 청구", - "mail_body": "당첨 코드: {{code}}", - "winning_code": "당첨 코드", - "close_a11y": "닫기" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "승리하셨습니다", - "description": "오늘 상품을 청구하세요.", - "a11y": "당첨자 세부 정보 열기" + "title": "당첨되셨습니다!", + "description": "당첨 코드를 확인하고 오늘 상품을 청구하세요.", + "a11y": "세부정보 보기" }, "participant_pending": { "title": "캠페인이 종료되었습니다.", "description": "결과를 확인 중입니다. 곧 다시 확인해 주세요." }, "participant_finalized": { - "title": "캠페인 결과가 발표되었습니다", + "title": "캠페인 결과가 나왔습니다.", "description": "이번에는 당첨되지 않았습니다. 리더보드에서 순위를 확인해 보세요." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "보상이 지급될 예정입니다. 곧 연락드리겠습니다." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "승리하셨습니다 💰", - "description": "{{campaignName}} 상품을 청구하세요", + "description": "오늘 {{campaignName}}에서 상품을 청구하세요.", "cta": "세부 정보 보기" }, - "participant_no_winner": { - "title": "결과가 발표되었습니다. 🏅", - "description": "{{campaignName}}에서 내 순위 보기", + "non_winner": { + "title": "결과가 나왔습니다 🏅", + "description": "{{campaignName}}에서 내 순위를 확인하세요.", "cta": "보기" } + }, + "campaign_winning": { + "you_won": "승리하셨습니다", + "rank_label": "{{place}} 장소", + "email_instructions": "상품을 받으려면 당첨 코드를 {{email}}으로 이메일로 보내세요.", + "open_mail": "메일 열기", + "skip_for_now": "지금은 건너뛰기", + "mail_subject": "{{campaignName}} 상품 청구", + "mail_body": "당첨 코드: {{code}}", + "winning_code": "당첨 코드", + "close_a11y": "닫기" } }, "time": { diff --git a/locales/languages/pt.json b/locales/languages/pt.json index bc8a465f6fa..9e606f5776b 100644 --- a/locales/languages/pt.json +++ b/locales/languages/pt.json @@ -8658,29 +8658,18 @@ "retry_button": "Tentar novamente", "refreshing": "Atualizando..." }, - "ondo_campaign_winning": { - "you_won": "Você ganhou", - "rank_label": "{{place}} lugar", - "email_instructions": "Envie um e-mail para ondocampaign@consensys.net com o seu código para reivindicar seu prêmio.", - "open_mail": "Abrir e-mail", - "skip_for_now": "Ignorar por enquanto", - "mail_subject": "Reivindicação de prêmio da campanha Ondo", - "mail_body": "Meu código ganhador: {{code}}", - "winning_code": "Código ganhador", - "close_a11y": "Fechar" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Você ganhou", - "description": "Reivindique seu prêmio hoje mesmo.", - "a11y": "Ver detalhes dos ganhadores" + "title": "Você ganhou!", + "description": "Verifique seu código vencedor e reivindique seu prêmio hoje.", + "a11y": "Ver detalhes" }, "participant_pending": { "title": "A campanha terminou.", "description": "Estamos analisando os resultados. Volte em breve." }, "participant_finalized": { - "title": "Os resultados da campanha foram divulgados", + "title": "Os resultados da campanha estão disponíveis.", "description": "Você não ganhou desta vez. Confira o placar de classificação para ver sua posição final." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Sua recompensa está a caminho. Entraremos em contato em breve." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Você ganhou 💰", - "description": "Reivindique seu prêmio da {{campaignName}}", + "description": "Reivindique seu prêmio da {{campaignName}} hoje.", "cta": "Ver detalhes" }, - "participant_no_winner": { - "title": "Os resultados foram divulgados.🏅", - "description": "Veja sua classificação na {{campaignName}}", + "non_winner": { + "title": "Os resultados estão disponíveis 🏅", + "description": "Veja sua classificação na {{campaignName}}.", "cta": "Ver" } + }, + "campaign_winning": { + "you_won": "Você ganhou", + "rank_label": "{{place}} lugar", + "email_instructions": "Envie um e-mail para {{email}} com o seu código para reivindicar seu prêmio.", + "open_mail": "Abrir e-mail", + "skip_for_now": "Ignorar por enquanto", + "mail_subject": "{{campaignName}} – reivindicação de prêmio", + "mail_body": "Meu código ganhador: {{code}}", + "winning_code": "Código ganhador", + "close_a11y": "Fechar" } }, "time": { diff --git a/locales/languages/ru.json b/locales/languages/ru.json index ef6b6f31ae2..f292c81431e 100644 --- a/locales/languages/ru.json +++ b/locales/languages/ru.json @@ -8658,29 +8658,18 @@ "retry_button": "Повтор", "refreshing": "Обновление..." }, - "ondo_campaign_winning": { - "you_won": "Вы выиграли", - "rank_label": "{{place}} место", - "email_instructions": "Отправьте письмо со своим кодом на адрес ondocampaign@consensys.net, чтобы получить приз.", - "open_mail": "Открыть почту", - "skip_for_now": "Пока пропустить", - "mail_subject": "Получение приза кампании Ondo", - "mail_body": "Мой выигрышный код: {{code}}", - "winning_code": "Выигрышный код", - "close_a11y": "Закрыть" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Вы выиграли", - "description": "Получите свой приз сегодня.", - "a11y": "Открыть детали победителя" + "title": "Вы выиграли!", + "description": "Подтвердите выигрышный код и получите приз сегодня.", + "a11y": "Подробнее" }, "participant_pending": { "title": "Кампания завершена.", "description": "Мы определяем результаты. Загляните сюда позже." }, "participant_finalized": { - "title": "Результаты кампании подведены", + "title": "Результаты кампании готовы.", "description": "В этот раз вы не выиграли. Проверьте таблицу лидеров, чтобы узнать свое место." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Ваша награда уже в пути — мы скоро свяжемся с вами." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Вы выиграли 💰", - "description": "Получите свой приз в кампании {{campaignName}}", + "description": "Получите приз из {{campaignName}} сегодня.", "cta": "Просмотр подробностей" }, - "participant_no_winner": { - "title": "Результаты готовы. 🏅", - "description": "Посмотрите свой рейтинг в кампании {{campaignName}}", + "non_winner": { + "title": "Результаты готовы 🏅", + "description": "Посмотрите своё место в {{campaignName}}.", "cta": "Просмотр" } + }, + "campaign_winning": { + "you_won": "Вы выиграли", + "rank_label": "{{place}} место", + "email_instructions": "Отправьте письмо со своим кодом на адрес {{email}}, чтобы получить приз.", + "open_mail": "Открыть почту", + "skip_for_now": "Пока пропустить", + "mail_subject": "{{campaignName}} – получение приза", + "mail_body": "Мой выигрышный код: {{code}}", + "winning_code": "Выигрышный код", + "close_a11y": "Закрыть" } }, "time": { diff --git a/locales/languages/tl.json b/locales/languages/tl.json index a1cc9eb084e..c5b4425bcbd 100644 --- a/locales/languages/tl.json +++ b/locales/languages/tl.json @@ -8658,29 +8658,18 @@ "retry_button": "Subukang muli", "refreshing": "Nire-refresh..." }, - "ondo_campaign_winning": { - "you_won": "Nanalo ka", - "rank_label": "{{place}} lugar", - "email_instructions": "I-email sa ondocampaign@consensys.net ang code mo para kunin ang premyo mo.", - "open_mail": "Buksan ang mail", - "skip_for_now": "Laktawan muna", - "mail_subject": "Pagkuha ng premyo ng campaign ng Ondo", - "mail_body": "Ang nanalong code: {{code}}", - "winning_code": "Nanalong code", - "close_a11y": "Isara" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Nanalo ka", - "description": "Kunin ang premyo mo ngayon.", - "a11y": "Ipakita ang mga detalye ng nanalo" + "title": "Nanalo ka!", + "description": "I-verify ang winning code mo at kunin ang premyo mo ngayon.", + "a11y": "Tingnan ang mga detalye" }, "participant_pending": { "title": "Tapos na ang campaign.", "description": "Inaalam na namin ang resulta. Bumalik sa lalong madaling panahon." }, "participant_finalized": { - "title": "Lumabas na ang resulta ng campaign", + "title": "Lumabas na ang resulta ng campaign.", "description": "Hindi ka nanalo sa pagkakataong ito. Tingnan ang leaderboard para malaman kung ano ang rank mo." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Parating na ang reward mo — makikipag-ugnayan kami sa iyo sa lalong madaling panahon." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Nanalo ka 💰", - "description": "Kunin ang premyo mo mula sa {{campaignName}}", + "description": "Kunin ang premyo mo mula sa {{campaignName}} ngayon.", "cta": "Tingnan ang mga detalye" }, - "participant_no_winner": { - "title": "Lumabas na ang resulta. 🏅", - "description": "Tingnan ang ranking mo sa {{campaignName}}", + "non_winner": { + "title": "Lumabas na ang resulta 🏅", + "description": "Tingnan ang ranking mo sa {{campaignName}}.", "cta": "Tingnan" } + }, + "campaign_winning": { + "you_won": "Nanalo ka", + "rank_label": "{{place}} lugar", + "email_instructions": "I-email sa {{email}} ang code mo para kunin ang premyo mo.", + "open_mail": "Buksan ang mail", + "skip_for_now": "Laktawan muna", + "mail_subject": "{{campaignName}} – pagkuha ng premyo", + "mail_body": "Ang nanalong code: {{code}}", + "winning_code": "Nanalong code", + "close_a11y": "Isara" } }, "time": { diff --git a/locales/languages/tr.json b/locales/languages/tr.json index 3fc8b5216ac..2a71fe184a4 100644 --- a/locales/languages/tr.json +++ b/locales/languages/tr.json @@ -8658,29 +8658,18 @@ "retry_button": "Tekrar Dene", "refreshing": "Yenileniyor..." }, - "ondo_campaign_winning": { - "you_won": "Kazancınız", - "rank_label": "{{place}}. sıra", - "email_instructions": "Ödülünüzü almak için kodunuzla ondocampaign@consensys.net adresine e-posta gönderin.", - "open_mail": "Postayı aç", - "skip_for_now": "Şimdilik atla", - "mail_subject": "Ondo kampanyası ödül talebi", - "mail_body": "Kazanma kodum: {{code}}", - "winning_code": "Kazanma kodu", - "close_a11y": "Kapat" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Kazancınız", - "description": "Ödülünüzü bugün alın.", - "a11y": "Kazanan bilgilerini aç" + "title": "Kazandınız!", + "description": "Kazanan kodunuzu doğrulayın ve ödülünüzü bugün talep edin.", + "a11y": "Ayrıntıları görüntüle" }, "participant_pending": { "title": "Kampanya sona erdi.", "description": "Sonuçları belirliyoruz. Kısa bir süre tekrar kontrol edin." }, "participant_finalized": { - "title": "Kampanya sonuçları açıklandı", + "title": "Kampanya sonuçları hazır.", "description": "Bu kez kazanamadınız. Nerede bitirdiğinizi görmek için liderlik tablosunu kontrol edin." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Ödülünüz yolda - kısa bir süre sonra iletişime geçeceğiz." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Kazancınız 💰", - "description": "{{campaignName}} adlı kampanyadan ödülünüzü alın", + "description": "Ödülünüzü bugün {{campaignName}} üzerinden talep edin.", "cta": "Ayrıntıları görüntüle" }, - "participant_no_winner": { - "title": "Sonuçlar belli oldu. 🏅", - "description": "{{campaignName}} adlı kampanyadaki sıralamanızı görün", + "non_winner": { + "title": "Sonuçlar hazır 🏅", + "description": "{{campaignName}} içinde sıralamanızı görün.", "cta": "Görüntüle" } + }, + "campaign_winning": { + "you_won": "Kazancınız", + "rank_label": "{{place}}. sıra", + "email_instructions": "Ödülünüzü almak için kodunuzla {{email}} adresine e-posta gönderin.", + "open_mail": "Postayı aç", + "skip_for_now": "Şimdilik atla", + "mail_subject": "{{campaignName}} – ödül talebi", + "mail_body": "Kazanma kodum: {{code}}", + "winning_code": "Kazanma kodu", + "close_a11y": "Kapat" } }, "time": { diff --git a/locales/languages/vi.json b/locales/languages/vi.json index a3e14e0a16b..6fc67e4c02d 100644 --- a/locales/languages/vi.json +++ b/locales/languages/vi.json @@ -8658,29 +8658,18 @@ "retry_button": "Thử lại", "refreshing": "Đang làm mới..." }, - "ondo_campaign_winning": { - "you_won": "Bạn đã thắng", - "rank_label": "Hạng {{place}}", - "email_instructions": "Gửi email đến ondocampaign@consensys.net kèm mã của bạn để nhận thưởng.", - "open_mail": "Mở thư", - "skip_for_now": "Bỏ qua bây giờ", - "mail_subject": "Nhận thưởng chiến dịch Ondo", - "mail_body": "Mã trúng thưởng của tôi: {{code}}", - "winning_code": "Mã trúng thưởng", - "close_a11y": "Đóng" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "Bạn đã thắng", - "description": "Nhận giải thưởng của bạn ngay hôm nay.", - "a11y": "Mở thông tin người chiến thắng" + "title": "Bạn đã thắng!", + "description": "Xác minh mã trúng thưởng và nhận giải thưởng hôm nay.", + "a11y": "Xem chi tiết" }, "participant_pending": { "title": "Chiến dịch đã kết thúc.", "description": "Chúng tôi đang xác định kết quả. Hãy quay lại sau." }, "participant_finalized": { - "title": "Kết quả chiến dịch đã có", + "title": "Đã có kết quả chiến dịch.", "description": "Lần này bạn đã không giành chiến thắng. Hãy kiểm tra bảng xếp hạng để xem bạn đứng ở vị trí nào." }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "Phần thưởng của bạn đang được gửi — chúng tôi sẽ sớm liên hệ với bạn." } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "Bạn đã thắng 💰", - "description": "Nhận giải thưởng của bạn từ {{campaignName}}", + "description": "Nhận thưởng từ {{campaignName}} hôm nay.", "cta": "Xem chi tiết" }, - "participant_no_winner": { - "title": "Kết quả đã có. 🏅", - "description": "Xem thứ hạng của bạn trong {{campaignName}}", + "non_winner": { + "title": "Đã có kết quả 🏅", + "description": "Xem thứ hạng của bạn trong {{campaignName}}.", "cta": "Xem" } + }, + "campaign_winning": { + "you_won": "Bạn đã thắng", + "rank_label": "Hạng {{place}}", + "email_instructions": "Gửi email đến {{email}} kèm mã của bạn để nhận thưởng.", + "open_mail": "Mở thư", + "skip_for_now": "Bỏ qua bây giờ", + "mail_subject": "{{campaignName}} – nhận thưởng", + "mail_body": "Mã trúng thưởng của tôi: {{code}}", + "winning_code": "Mã trúng thưởng", + "close_a11y": "Đóng" } }, "time": { diff --git a/locales/languages/zh.json b/locales/languages/zh.json index bee8f24db28..f7a0a2db289 100644 --- a/locales/languages/zh.json +++ b/locales/languages/zh.json @@ -8658,29 +8658,18 @@ "retry_button": "重试", "refreshing": "正在刷新……" }, - "ondo_campaign_winning": { - "you_won": "您已获胜", - "rank_label": "第 {{place}} 名", - "email_instructions": "请将您的代码发送至 ondocampaign@consensys.net 领取奖品。", - "open_mail": "打开邮件", - "skip_for_now": "暂时跳过", - "mail_subject": "领取 Ondo 活动奖品", - "mail_body": "我的获奖码:{{code}}", - "winning_code": "获奖码", - "close_a11y": "关闭" - }, - "ondo_outcome_banner": { + "campaign_outcome_banner": { "winner_pending": { - "title": "您已获胜", - "description": "立即领取您的奖品。", - "a11y": "打开获奖者详情" + "title": "您中奖了!", + "description": "验证您的中奖代码,并在今天领取奖品。", + "a11y": "查看详情" }, "participant_pending": { "title": "活动已结束。", "description": "我们正在确定结果。请稍后再来查看。" }, "participant_finalized": { - "title": "活动结果已出炉", + "title": "活动结果已公布。", "description": "您这次未中奖。查看排行榜了解您的最终排名。" }, "winner_finalized": { @@ -8688,17 +8677,28 @@ "description": "您的奖励正在发放中——我们将很快联系您。" } }, - "ondo_outcome_toast": { - "winner_verify": { + "campaign_outcome_toast": { + "winner": { "title": "您已获胜 💰", - "description": "领取您在 {{campaignName}} 中的奖品", + "description": "今天从 {{campaignName}} 领取您的奖品。", "cta": "查看详情" }, - "participant_no_winner": { - "title": "结果已揭晓。🏅", - "description": "查看您在 {{campaignName}} 中的排名", + "non_winner": { + "title": "结果已公布 🏅", + "description": "查看您在 {{campaignName}} 中的排名。", "cta": "查看" } + }, + "campaign_winning": { + "you_won": "您已获胜", + "rank_label": "第 {{place}} 名", + "email_instructions": "请将您的代码发送至 {{email}} 领取奖品。", + "open_mail": "打开邮件", + "skip_for_now": "暂时跳过", + "mail_subject": "{{campaignName}} 奖品领取", + "mail_body": "我的获奖码:{{code}}", + "winning_code": "获奖码", + "close_a11y": "关闭" } }, "time": { diff --git a/package.json b/package.json index 2aa61ed46e5..ae9cdbd7feb 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,10 @@ "run-system-tests:ios-login": "yarn playwright test --project system-ios-login --config tests/playwright.system.config.ts", "run-system-tests:ios-onboarding": "yarn playwright test --project system-ios-onboarding --config tests/playwright.system.config.ts", "run-system-tests:all": "yarn playwright test --config tests/playwright.system.config.ts", + "run-system-tests:android-login-emu": "yarn playwright test --project system-android-login-emu --config tests/playwright.system-emulator.config.ts", + "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", "test:depcheck": "yarn depcheck", "test:tgz-check": "./scripts/tgz-check.sh", "test:attribution-check": "./scripts/attributions-check.sh", @@ -339,7 +343,7 @@ "@metamask/superstruct": "^3.2.1", "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/transaction-controller": "^65.0.0", - "@metamask/transaction-pay-controller": "^20.0.0", + "@metamask/transaction-pay-controller": "^21.0.0", "@metamask/tron-wallet-snap": "^1.25.3", "@metamask/utils": "^11.11.0", "@myx-trade/sdk": "^0.1.265", diff --git a/tests/api-mocking/mock-e2e-allowlist.ts b/tests/api-mocking/mock-e2e-allowlist.ts index 13e69847e37..9749085bc6c 100644 --- a/tests/api-mocking/mock-e2e-allowlist.ts +++ b/tests/api-mocking/mock-e2e-allowlist.ts @@ -6,16 +6,9 @@ export const ALLOWLISTED_HOSTS = [ '127.0.0.1', 'localhost', '10.0.2.2', // Android emulator host - 'api.tenderly.co', - 'rpc.tenderly.co', - 'virtual.mainnet.rpc.tenderly.co', - 'virtual.linea.rpc.tenderly.co', 'gamma-api.polymarket.com', '*.polymarket.com', 'metamask.github.io', // Test-snaps and test-dapp pages loaded in browser ]; -export const ALLOWLISTED_URLS = [ - // Temporarily allow existing live requests during migration - 'https://metamask.github.io/test-dapp/metamask-fox.svg', -]; +export const ALLOWLISTED_URLS: string[] = []; diff --git a/tests/api-mocking/mock-responses/defaults/rpc-endpoints.ts b/tests/api-mocking/mock-responses/defaults/rpc-endpoints.ts index 93b11ef3525..c22266761b0 100644 --- a/tests/api-mocking/mock-responses/defaults/rpc-endpoints.ts +++ b/tests/api-mocking/mock-responses/defaults/rpc-endpoints.ts @@ -42,5 +42,23 @@ export const DEFAULT_RPC_ENDPOINT_MOCKS: MockEventsObject = { result: '0x0', }, }, + { + urlEndpoint: /^https:\/\/virtual\.mainnet\.rpc\.tenderly\.co\/.+$/, + responseCode: 200, + response: { + jsonrpc: '2.0', + id: 1, + result: '0x0', + }, + }, + { + urlEndpoint: 'https://testnet-rpc.monad.xyz/', + responseCode: 200, + response: { + jsonrpc: '2.0', + id: 1, + result: '0x0', + }, + }, ], }; diff --git a/tests/api-mocking/mock-responses/defaults/static-assets.ts b/tests/api-mocking/mock-responses/defaults/static-assets.ts index 939cc679b9a..377bf3ac28f 100644 --- a/tests/api-mocking/mock-responses/defaults/static-assets.ts +++ b/tests/api-mocking/mock-responses/defaults/static-assets.ts @@ -23,5 +23,11 @@ export const STATIC_ASSETS_MOCKS: MockEventsObject = { responseCode: 200, response: MINIMAL_SVG, }, + { + urlEndpoint: + /^https:\/\/metamask\.github\.io\/test-dapp\/metamask-fox\.svg$/, + responseCode: 200, + response: MINIMAL_SVG, + }, ], }; diff --git a/tests/api-mocking/mock-responses/tx-sentinel-networks-map.ts b/tests/api-mocking/mock-responses/tx-sentinel-networks-map.ts index 0a28e014b13..ce1085fd0dd 100644 --- a/tests/api-mocking/mock-responses/tx-sentinel-networks-map.ts +++ b/tests/api-mocking/mock-responses/tx-sentinel-networks-map.ts @@ -64,7 +64,7 @@ export const TX_SENTINEL_NETWORKS_MAP = { decimals: 18, }, network: 'sei-mainnet', - explorer: 'https://seitrace.com', + explorer: 'https://seiscan.io', confirmations: true, smartTransactions: false, relayTransactions: false, diff --git a/tests/helpers/tenderly/tenderly.js b/tests/helpers/tenderly/tenderly.js deleted file mode 100644 index db5ca8a501a..00000000000 --- a/tests/helpers/tenderly/tenderly.js +++ /dev/null @@ -1,26 +0,0 @@ -import axios from 'axios'; - -export default class Tenderly { - static async addFunds(rpcURL, account, amount = '0xDE0B6B3A764000000') { - const data = { - jsonrpc: '2.0', - method: 'tenderly_setBalance', - params: [[account], amount], - id: '1234', - }; - - const response = await axios.post(rpcURL, data, { - headers: { - 'Content-Type': 'application/json', - }, - }); - - if (response.data.error) { - // eslint-disable-next-line no-console - console.log( - `ERROR: Failed to add funds to Tenderly VirtualTestNet\n${response.data.error}`, - ); - return null; - } - } -} diff --git a/tests/performance/README.md b/tests/performance/README.md index 4e5a6ae53ae..4190b2967a1 100644 --- a/tests/performance/README.md +++ b/tests/performance/README.md @@ -218,7 +218,22 @@ npx playwright test --grep "@PerformanceLogin.*@PerformanceLaunch" --project and ## Test Tags -Tests are tagged with area-specific tags defined in `tests/tags.performance.js`. These tags allow for selective test execution based on which areas of the app are affected by code changes. +Tags are defined in `tests/tags.performance.js` and embedded in `test.describe()` names. They are runner-agnostic — any runner with `--grep` support can filter by them. + +### Test Type Tags + +These tags control which Playwright config picks up a test: + +| Tag | Description | Config that filters for it | +| -------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `@Performance` | Test measures performance (uses `TimerHelper`, quality gates enforced) | `playwright.config.ts` (`grep: /@Performance/`) | +| `@System` | Test verifies functionality (no quality gates or metrics) | `playwright.system.config.ts` / `playwright.system-emulator.config.ts` (`grep: /@System/`) | + +Most existing tests are tagged with **both** `@Performance @System` — they measure perf and also serve as system smoke tests. A test can use just one tag if it should only run in one suite. + +### Area Tags + +These tags categorize tests by feature area and can be used with `--grep` for ad-hoc filtering: | Tag | Description | | -------------------------- | ------------------------------------------------------------- | @@ -232,18 +247,41 @@ Tests are tagged with area-specific tags defined in `tests/tags.performance.js`. | `@PerformancePredict` | Predict market performance (market list, details, deposits) | | `@PerformancePreps` | Perpetuals trading performance (positions, add funds, orders) | -Tags are imported into test files from `tests/tags.performance.js`: +### Tagging Convention + +Import type tags and area tags from `tests/tags.performance.js`: ```typescript -import { PerformanceLogin, PerformanceSwaps } from '../../tags.performance.js'; +import { + Performance, + System, + PerformanceLogin, + PerformanceSwaps, +} from '../../tags.performance.js'; -perfTest.describe(`${PerformanceLogin} ${PerformanceSwaps}`, () => { - perfTest( - 'Swap flow performance', - async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => { - // test implementation - }, - ); +// Both perf and system test (most common): +perfTest.describe( + `${Performance} ${System} ${PerformanceLogin} ${PerformanceSwaps}`, + () => { + perfTest( + 'Swap flow performance', + async ( + { currentDeviceDetails, driver, performanceTracker }, + testInfo, + ) => { + // test implementation with TimerHelper and thresholds + }, + ); + }, +); + +// System-only test (functional verification, no perf measurement): +test.describe(`${System} ${PerformanceLogin}`, () => { + test('Verify wallet loads after login', async ({ driver }) => { + // No TimerHelper — pure functional check + await loginToAppPlaywright(); + await WalletView.waitForAccountName('Account 1'); + }); }); ``` diff --git a/tests/performance/login/asset-balances.spec.ts b/tests/performance/login/asset-balances.spec.ts index 40f65f13d5a..e1266b2b634 100644 --- a/tests/performance/login/asset-balances.spec.ts +++ b/tests/performance/login/asset-balances.spec.ts @@ -6,12 +6,14 @@ import WalletView from '../../page-objects/wallet/WalletView'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import { + Performance, + System, PerformanceLogin, PerformanceAssetLoading, } from '../../tags.performance.js'; /* Scenario: Aggregated Balance Loading Time, SRP 1 + SRP 2 + SRP 3 */ -test.describe(`${PerformanceLogin} ${PerformanceAssetLoading}`, () => { +test.describe(`${Performance} ${System} ${PerformanceLogin} ${PerformanceAssetLoading}`, () => { test( 'Aggregated Balance Loading Time, SRP 1 + SRP 2 + SRP 3', { tag: '@assets-dev-team' }, diff --git a/tests/performance/login/asset-view.spec.ts b/tests/performance/login/asset-view.spec.ts index 9d1eb1dffe6..588245772d5 100644 --- a/tests/performance/login/asset-view.spec.ts +++ b/tests/performance/login/asset-view.spec.ts @@ -5,45 +5,52 @@ import { asPlaywrightElement, PlaywrightAssertions } from '../../framework'; import WalletView from '../../page-objects/wallet/WalletView'; import TokenOverview from '../../page-objects/wallet/TokenOverview'; import { + Performance, PerformanceLogin, PerformanceAssetLoading, } from '../../tags.performance.js'; /* Scenario 8: Asset View, SRP 1 + SRP 2 + SRP 3 */ -perfTest.describe(`${PerformanceLogin} ${PerformanceAssetLoading}`, () => { - perfTest( - 'Asset View, SRP 1 + SRP 2 + SRP 3', - { tag: '@assets-dev-team' }, - async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => { - await loginToAppPlaywright(); +perfTest.describe( + `${Performance} ${PerformanceLogin} ${PerformanceAssetLoading}`, + () => { + perfTest( + 'Asset View, SRP 1 + SRP 2 + SRP 3', + { tag: '@assets-dev-team' }, + async ( + { currentDeviceDetails, driver, performanceTracker }, + testInfo, + ) => { + await loginToAppPlaywright(); - const assetViewScreen = new TimerHelper( - 'Time since the user clicks on the asset view button until the user sees the token overview screen', - { ios: 600, android: 4500 }, - currentDeviceDetails.platform, - ); + const assetViewScreen = new TimerHelper( + 'Time since the user clicks on the asset view button until the user sees the token overview screen', + { ios: 600, android: 4500 }, + currentDeviceDetails.platform, + ); - await WalletView.tapOnTokensSection(); - await WalletView.tapOnToken('USDC'); + await WalletView.tapOnTokensSection(); + await WalletView.tapOnToken('USDC'); - await assetViewScreen.measure(async () => { - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(TokenOverview.container), - ); - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(TokenOverview.sendButton), - ); - // Replicating the logic of the old spec to wait for the todays change to be visible isTodaysChangeVisible method in the TokenOverview wdio screen object - await PlaywrightAssertions.expectElementToBeVisibleWithSettle( - asPlaywrightElement(TokenOverview.todaysChange), - { - timeout: 10000, - settleMs: 500, - }, - ); - }); + await assetViewScreen.measure(async () => { + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(TokenOverview.container), + ); + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(TokenOverview.sendButton), + ); + // Replicating the logic of the old spec to wait for the todays change to be visible isTodaysChangeVisible method in the TokenOverview wdio screen object + await PlaywrightAssertions.expectElementToBeVisibleWithSettle( + asPlaywrightElement(TokenOverview.todaysChange), + { + timeout: 10000, + settleMs: 500, + }, + ); + }); - performanceTracker.addTimer(assetViewScreen); - }, - ); -}); + performanceTracker.addTimer(assetViewScreen); + }, + ); + }, +); diff --git a/tests/performance/login/cross-chain-swap-flow.spec.ts b/tests/performance/login/cross-chain-swap-flow.spec.ts index edd434c2a84..8d30e4f2dd3 100644 --- a/tests/performance/login/cross-chain-swap-flow.spec.ts +++ b/tests/performance/login/cross-chain-swap-flow.spec.ts @@ -1,12 +1,17 @@ import { test } from '../../framework/fixture'; import TimerHelper from '../../framework/TimerHelper.js'; -import { PerformanceLogin, PerformanceSwaps } from '../../tags.performance.js'; +import { + Performance, + System, + PerformanceLogin, + PerformanceSwaps, +} from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow.js'; import WalletView from '../../page-objects/wallet/WalletView.js'; import QuoteView from '../../page-objects/swaps/QuoteView.js'; /* Scenario 7: Cross-chain swap flow - ETH to SOL - 50+ accounts, SRP 1 + SRP 2 + SRP 3 */ -test.describe(`${PerformanceLogin} ${PerformanceSwaps}`, () => { +test.describe(`${Performance} ${System} ${PerformanceLogin} ${PerformanceSwaps}`, () => { test( 'Cross-chain swap flow - ETH to SOL - 50+ accounts, SRP 1 + SRP 2 + SRP 3', { tag: '@swap-bridge-dev-team' }, diff --git a/tests/performance/login/eth-swap-flow.spec.ts b/tests/performance/login/eth-swap-flow.spec.ts index 1e1db2e94af..98250f5034b 100644 --- a/tests/performance/login/eth-swap-flow.spec.ts +++ b/tests/performance/login/eth-swap-flow.spec.ts @@ -1,12 +1,17 @@ import { test } from '../../framework/fixture'; import TimerHelper from '../../framework/TimerHelper'; -import { PerformanceLogin, PerformanceSwaps } from '../../tags.performance.js'; +import { + Performance, + System, + PerformanceLogin, + PerformanceSwaps, +} from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import WalletView from '../../page-objects/wallet/WalletView'; import QuoteView from '../../page-objects/swaps/QuoteView'; /* Scenario 6: Swap flow - ETH to LINK, SRP 1 + SRP 2 + SRP 3 */ -test.describe(`${PerformanceLogin} ${PerformanceSwaps}`, () => { +test.describe(`${Performance} ${System} ${PerformanceLogin} ${PerformanceSwaps}`, () => { test( 'Swap flow - ETH to LINK, SRP 1 + SRP 2 + SRP 3', { tag: '@swap-bridge-dev-team' }, diff --git a/tests/performance/login/import-multiple-srps.spec.ts b/tests/performance/login/import-multiple-srps.spec.ts index ff1b94c7a2d..24942bd68ae 100644 --- a/tests/performance/login/import-multiple-srps.spec.ts +++ b/tests/performance/login/import-multiple-srps.spec.ts @@ -7,95 +7,100 @@ import AddAccountBottomSheet from '../../page-objects/wallet/AddAccountBottomShe import AccountListBottomSheet from '../../page-objects/wallet/AccountListBottomSheet'; import WalletView from '../../page-objects/wallet/WalletView'; import { + Performance, + System, PerformanceAccountList, PerformanceLogin, } from '../../tags.performance.js'; import PlaywrightGestures from '../../framework/PlaywrightGestures'; /* Scenario 4: Import SRP with +50 accounts, SRP 1, SRP 2, SRP 3 */ -perfTest.describe(`${PerformanceLogin} ${PerformanceAccountList}`, () => { - perfTest.setTimeout(30 * 60 * 1000); +perfTest.describe( + `${Performance} ${System} ${PerformanceLogin} ${PerformanceAccountList}`, + () => { + perfTest.setTimeout(30 * 60 * 1000); - perfTest( - 'Import SRP with +50 accounts, SRP 1, SRP 2, SRP 3', - { tag: '@accounts-team' }, - async ({ currentDeviceDetails, driver, performanceTracker }) => { - const importedSrp = process.env.TEST_SRP_2; + perfTest( + 'Import SRP with +50 accounts, SRP 1, SRP 2, SRP 3', + { tag: '@accounts-team' }, + async ({ currentDeviceDetails, driver, performanceTracker }) => { + const importedSrp = process.env.TEST_SRP_2; - if (!importedSrp) { - throw new Error( - 'TEST_SRP_2 environment variable is required for this performance test.', - ); - } - - await loginToAppPlaywright(); + if (!importedSrp) { + throw new Error( + 'TEST_SRP_2 environment variable is required for this performance test.', + ); + } - const accountListTimer = new TimerHelper( - 'Time since the user clicks on "Account list" button until the account list is visible', - { ios: 2500, android: 3000 }, - currentDeviceDetails.platform, - ); - const addAccountTimer = new TimerHelper( - 'Time since the user clicks on "Add account" button until the next modal is visible', - { ios: 1000, android: 1700 }, - currentDeviceDetails.platform, - ); - const importSrpTimer = new TimerHelper( - 'Time since the user clicks on "Import SRP" button until SRP field is displayed', - { ios: 1700, android: 1700 }, - currentDeviceDetails.platform, - ); - const walletReadyTimer = new TimerHelper( - 'Time since the user clicks on "Continue" button on SRP screen until Wallet main screen is visible', - { ios: 5000, android: 2000 }, - currentDeviceDetails.platform, - ); + await loginToAppPlaywright(); - await WalletView.tapIdenticon(); - await accountListTimer.measure(async () => { - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(AccountListBottomSheet.accountList), - { - description: 'Account list should be visible', - }, + const accountListTimer = new TimerHelper( + 'Time since the user clicks on "Account list" button until the account list is visible', + { ios: 2500, android: 3000 }, + currentDeviceDetails.platform, ); - }); - - await AccountListBottomSheet.waitForAccountSyncToComplete(); - await AccountListBottomSheet.tapAddAccountButton(); - await addAccountTimer.measure(async () => { - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(AddAccountBottomSheet.importSrpButton), - { - description: 'Add account bottom sheet should be visible', - }, + const addAccountTimer = new TimerHelper( + 'Time since the user clicks on "Add account" button until the next modal is visible', + { ios: 1000, android: 1700 }, + currentDeviceDetails.platform, + ); + const importSrpTimer = new TimerHelper( + 'Time since the user clicks on "Import SRP" button until SRP field is displayed', + { ios: 1700, android: 1700 }, + currentDeviceDetails.platform, + ); + const walletReadyTimer = new TimerHelper( + 'Time since the user clicks on "Continue" button on SRP screen until Wallet main screen is visible', + { ios: 5000, android: 2000 }, + currentDeviceDetails.platform, ); - }); - await AddAccountBottomSheet.tapImportSrp(); - await importSrpTimer.measure(async () => { - await ImportWalletView.isScreenTitleVisible(false); - }); + await WalletView.tapIdenticon(); + await accountListTimer.measure(async () => { + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(AccountListBottomSheet.accountList), + { + description: 'Account list should be visible', + }, + ); + }); - await ImportWalletView.typeSecretRecoveryPhrase(importedSrp, false); - await PlaywrightGestures.hideKeyboard(); - await ImportWalletView.tapContinueButton(false); + await AccountListBottomSheet.waitForAccountSyncToComplete(); + await AccountListBottomSheet.tapAddAccountButton(); + await addAccountTimer.measure(async () => { + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(AddAccountBottomSheet.importSrpButton), + { + description: 'Add account bottom sheet should be visible', + }, + ); + }); - await walletReadyTimer.measure(async () => { - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(WalletView.accountIcon), - { - description: - 'Wallet main screen should be visible after importing SRP', - }, - ); - }); + await AddAccountBottomSheet.tapImportSrp(); + await importSrpTimer.measure(async () => { + await ImportWalletView.isScreenTitleVisible(false); + }); + + await ImportWalletView.typeSecretRecoveryPhrase(importedSrp, false); + await PlaywrightGestures.hideKeyboard(); + await ImportWalletView.tapContinueButton(false); - performanceTracker.addTimers( - accountListTimer, - addAccountTimer, - importSrpTimer, - walletReadyTimer, - ); - }, - ); -}); + await walletReadyTimer.measure(async () => { + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(WalletView.accountIcon), + { + description: + 'Wallet main screen should be visible after importing SRP', + }, + ); + }); + + performanceTracker.addTimers( + accountListTimer, + addAccountTimer, + importSrpTimer, + walletReadyTimer, + ); + }, + ); + }, +); diff --git a/tests/performance/login/launch-times/cold-start-to-login.spec.ts b/tests/performance/login/launch-times/cold-start-to-login.spec.ts index 3630db824a9..08c59502c1d 100644 --- a/tests/performance/login/launch-times/cold-start-to-login.spec.ts +++ b/tests/performance/login/launch-times/cold-start-to-login.spec.ts @@ -9,6 +9,7 @@ import { loginToAppPlaywright } from '../../../flows/wallet.flow'; import LoginView from '../../../page-objects/wallet/LoginView'; import WalletView from '../../../page-objects/wallet/WalletView'; import { + Performance, PerformanceLogin, PerformanceLaunch, } from '../../../tags.performance.js'; @@ -25,43 +26,50 @@ import { * The test measures: * 1. Time to relaunch the app and display the login screen */ -perfTest.describe(`${PerformanceLogin} ${PerformanceLaunch}`, () => { - perfTest( - 'Cold Start: Measure ColdStart To Login Screen', - { tag: '@metamask-mobile-platform' }, - async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => { - await loginToAppPlaywright(); - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(WalletView.accountIcon), - { - timeout: 15000, - description: 'Wallet account icon should be visible before relaunch', - }, - ); - await WalletView.waitForBalanceToStabilize(); - await PlaywrightGestures.terminateApp(currentDeviceDetails); - - const timer1 = new TimerHelper( - 'Time since the the app is launched, until login screen appears', - { ios: 3000, android: 4000 }, - currentDeviceDetails.platform, - ); - - await PlaywrightGestures.activateApp(currentDeviceDetails); - await timer1.measure(async () => { +perfTest.describe( + `${Performance} ${PerformanceLogin} ${PerformanceLaunch}`, + () => { + perfTest( + 'Cold Start: Measure ColdStart To Login Screen', + { tag: '@metamask-mobile-platform' }, + async ( + { currentDeviceDetails, driver, performanceTracker }, + testInfo, + ) => { + await loginToAppPlaywright(); await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(LoginView.container), + asPlaywrightElement(WalletView.accountIcon), { - description: 'Login title should be visible', + timeout: 15000, + description: + 'Wallet account icon should be visible before relaunch', }, ); - }); + await WalletView.waitForBalanceToStabilize(); + await PlaywrightGestures.terminateApp(currentDeviceDetails); + + const timer1 = new TimerHelper( + 'Time since the the app is launched, until login screen appears', + { ios: 3000, android: 4000 }, + currentDeviceDetails.platform, + ); + + await PlaywrightGestures.activateApp(currentDeviceDetails); + await timer1.measure(async () => { + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(LoginView.container), + { + description: 'Login title should be visible', + }, + ); + }); - performanceTracker.addTimers(timer1); + performanceTracker.addTimers(timer1); - console.log('Cold Start to Login Screen Performance Test completed'); - console.log(`Cold Start to Login Screen: ${timer1.getDuration()}ms`); - console.log(`Total Time: ${timer1.getDuration() ?? 0}ms`); - }, - ); -}); + console.log('Cold Start to Login Screen Performance Test completed'); + console.log(`Cold Start to Login Screen: ${timer1.getDuration()}ms`); + console.log(`Total Time: ${timer1.getDuration() ?? 0}ms`); + }, + ); + }, +); diff --git a/tests/performance/login/launch-times/warm-start-login-to-wallet.spec.ts b/tests/performance/login/launch-times/warm-start-login-to-wallet.spec.ts index bd5deae3b07..a0a610340dc 100644 --- a/tests/performance/login/launch-times/warm-start-login-to-wallet.spec.ts +++ b/tests/performance/login/launch-times/warm-start-login-to-wallet.spec.ts @@ -10,6 +10,7 @@ import { getPasswordForScenario } from '../../../framework/utils/TestConstants.j import LoginView from '../../../page-objects/wallet/LoginView'; import WalletView from '../../../page-objects/wallet/WalletView'; import { + Performance, PerformanceLogin, PerformanceLaunch, } from '../../../tags.performance.js'; @@ -26,53 +27,59 @@ import { * The test measures: * 1. Time to tap Unlock and display the wallet screen again */ -perfTest.describe(`${PerformanceLogin} ${PerformanceLaunch}`, () => { - perfTest( - 'Measure Warm Start: Login To Wallet Screen', - { tag: '@metamask-mobile-platform' }, - async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => { - await loginToAppPlaywright(); - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(WalletView.totalBalance), - { - description: - 'Wallet account icon should be visible before warm start', - }, - ); - - await PlaywrightGestures.backgroundApp(35); - await PlaywrightGestures.activateApp(currentDeviceDetails); - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(LoginView.passwordInput), - { - description: 'Login title should be visible', - }, - ); - const loginPassword = getPasswordForScenario('login'); - await LoginView.enterPassword(loginPassword); - - const timer1 = new TimerHelper( - 'Time since the user clicks on unlock button, until the app unlocks', - { ios: 2500, android: 2500 }, - currentDeviceDetails.platform, - ); +perfTest.describe( + `${Performance} ${PerformanceLogin} ${PerformanceLaunch}`, + () => { + perfTest( + 'Measure Warm Start: Login To Wallet Screen', + { tag: '@metamask-mobile-platform' }, + async ( + { currentDeviceDetails, driver, performanceTracker }, + testInfo, + ) => { + await loginToAppPlaywright(); + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(WalletView.totalBalance), + { + description: + 'Wallet account icon should be visible before warm start', + }, + ); - await LoginView.tapLoginButton(); - await timer1.measure(async () => { + await PlaywrightGestures.backgroundApp(35); + await PlaywrightGestures.activateApp(currentDeviceDetails); await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(WalletView.container), + asPlaywrightElement(LoginView.passwordInput), { - description: 'Wallet balance should be visible', + description: 'Login title should be visible', }, ); - // await WalletView.waitForBalanceToStabilize(); - }); + const loginPassword = getPasswordForScenario('login'); + await LoginView.enterPassword(loginPassword); + + const timer1 = new TimerHelper( + 'Time since the user clicks on unlock button, until the app unlocks', + { ios: 2500, android: 2500 }, + currentDeviceDetails.platform, + ); + + await LoginView.tapLoginButton(); + await timer1.measure(async () => { + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(WalletView.container), + { + description: 'Wallet balance should be visible', + }, + ); + // await WalletView.waitForBalanceToStabilize(); + }); - performanceTracker.addTimers(timer1); + performanceTracker.addTimers(timer1); - console.log('Warm Start Login to Wallet Performance Test completed'); - console.log(`Warm Start Login to Wallet: ${timer1.getDuration()}ms`); - console.log(`Total Time: ${timer1.getDuration() ?? 0}ms`); - }, - ); -}); + console.log('Warm Start Login to Wallet Performance Test completed'); + console.log(`Warm Start Login to Wallet: ${timer1.getDuration()}ms`); + console.log(`Total Time: ${timer1.getDuration() ?? 0}ms`); + }, + ); + }, +); diff --git a/tests/performance/login/launch-times/warm-start-to-login.spec.ts b/tests/performance/login/launch-times/warm-start-to-login.spec.ts index e9189ed8cb9..b8f09a0a95f 100644 --- a/tests/performance/login/launch-times/warm-start-to-login.spec.ts +++ b/tests/performance/login/launch-times/warm-start-to-login.spec.ts @@ -9,6 +9,7 @@ import { loginToAppPlaywright } from '../../../flows/wallet.flow'; import LoginView from '../../../page-objects/wallet/LoginView'; import WalletView from '../../../page-objects/wallet/WalletView'; import { + Performance, PerformanceLogin, PerformanceLaunch, } from '../../../tags.performance.js'; @@ -25,43 +26,49 @@ import { * The test measures: * 1. Time to foreground the app and display the login screen */ -perfTest.describe(`${PerformanceLogin} ${PerformanceLaunch}`, () => { - perfTest( - 'Measure Warm Start: Warm Start to Login Screen', - { tag: '@metamask-mobile-platform' }, - async ({ currentDeviceDetails, driver, performanceTracker }, testInfo) => { - await loginToAppPlaywright(); - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(WalletView.accountIcon), - { - description: - 'Wallet account icon should be visible before warm start', - }, - ); - - const timer1 = new TimerHelper( - 'Time since the user open the app again and the login screen appears', - { ios: 2500, android: 3000 }, - currentDeviceDetails.platform, - ); - - await PlaywrightGestures.backgroundApp(35); - await PlaywrightGestures.activateApp(currentDeviceDetails); - - await timer1.measure(async () => { +perfTest.describe( + `${Performance} ${PerformanceLogin} ${PerformanceLaunch}`, + () => { + perfTest( + 'Measure Warm Start: Warm Start to Login Screen', + { tag: '@metamask-mobile-platform' }, + async ( + { currentDeviceDetails, driver, performanceTracker }, + testInfo, + ) => { + await loginToAppPlaywright(); await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(LoginView.container), + asPlaywrightElement(WalletView.accountIcon), { - description: 'Login title should be visible', + description: + 'Wallet account icon should be visible before warm start', }, ); - }); - performanceTracker.addTimers(timer1); + const timer1 = new TimerHelper( + 'Time since the user open the app again and the login screen appears', + { ios: 2500, android: 3000 }, + currentDeviceDetails.platform, + ); + + await PlaywrightGestures.backgroundApp(35); + await PlaywrightGestures.activateApp(currentDeviceDetails); + + await timer1.measure(async () => { + await PlaywrightAssertions.expectElementToBeVisible( + asPlaywrightElement(LoginView.container), + { + description: 'Login title should be visible', + }, + ); + }); + + performanceTracker.addTimers(timer1); - console.log('Warm Start to Login Screen Performance Test completed'); - console.log(`Warm Start to Login Screen: ${timer1.getDuration()}ms`); - console.log(`Total Time: ${timer1.getDuration() ?? 0}ms`); - }, - ); -}); + console.log('Warm Start to Login Screen Performance Test completed'); + console.log(`Warm Start to Login Screen: ${timer1.getDuration()}ms`); + console.log(`Total Time: ${timer1.getDuration() ?? 0}ms`); + }, + ); + }, +); diff --git a/tests/performance/login/perps-add-funds.spec.ts b/tests/performance/login/perps-add-funds.spec.ts index d85c6d22195..90076f244f8 100644 --- a/tests/performance/login/perps-add-funds.spec.ts +++ b/tests/performance/login/perps-add-funds.spec.ts @@ -1,6 +1,6 @@ import { test } from '../../framework/fixture'; import TimerHelper from '../../framework/TimerHelper'; -import { PerformancePreps } from '../../tags.performance.js'; +import { Performance, PerformancePreps } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import TabBarComponent from '../../page-objects/wallet/TabBarComponent'; import PerpsOnboarding from '../../page-objects/Perps/PerpsOnboarding'; @@ -10,7 +10,7 @@ import PlaywrightAssertions from '../../framework/PlaywrightAssertions'; import { asPlaywrightElement } from '../../framework/EncapsulatedElement'; /* Scenario 5: Perps add funds */ -test.describe(PerformancePreps, () => { +test.describe(`${Performance} ${PerformancePreps}`, () => { test( 'Perps add funds', { tag: '@mm-perps-engineering-team' }, diff --git a/tests/performance/login/perps-position-management.spec.ts b/tests/performance/login/perps-position-management.spec.ts index e04159d5b39..2e0622a0893 100644 --- a/tests/performance/login/perps-position-management.spec.ts +++ b/tests/performance/login/perps-position-management.spec.ts @@ -1,7 +1,7 @@ import { test } from '../../framework/fixture'; import TimerHelper from '../../framework/TimerHelper'; -import { PerformancePreps } from '../../tags.performance.js'; +import { Performance, PerformancePreps } from '../../tags.performance.js'; import { loginToAppPlaywright, selectAccountByDevice, @@ -21,7 +21,7 @@ import PlaywrightAssertions from '../../framework/PlaywrightAssertions'; import { asPlaywrightElement } from '../../framework/EncapsulatedElement'; /* Scenario 5: Perps onboarding + add funds 10 USD ARB.USDC + Open Position + Close Position */ -test.describe(PerformancePreps, () => { +test.describe(`${Performance} ${PerformancePreps}`, () => { test( 'Perps open position and close it', { tag: '@mm-perps-engineering-team' }, diff --git a/tests/performance/login/predict/predict-available-balance.spec.ts b/tests/performance/login/predict/predict-available-balance.spec.ts index 0e44e0a7da0..3d23de4dbac 100644 --- a/tests/performance/login/predict/predict-available-balance.spec.ts +++ b/tests/performance/login/predict/predict-available-balance.spec.ts @@ -5,7 +5,7 @@ import { asPlaywrightElement, PlaywrightAssertions } from '../../../framework'; import TabBarComponent from '../../../page-objects/wallet/TabBarComponent'; import WalletActionsBottomSheet from '../../../page-objects/wallet/WalletActionsBottomSheet'; import PredictMarketList from '../../../page-objects/Predict/PredictMarketList'; -import { PerformancePredict } from '../../../tags.performance.js'; +import { Performance, PerformancePredict } from '../../../tags.performance.js'; /* * Scenario: Predict Available Balance Performance Test @@ -21,7 +21,7 @@ import { PerformancePredict } from '../../../tags.performance.js'; * 1. Time to navigate to Predict tab * 2. Time to verify available balance info is displayed */ -perfTest.describe(PerformancePredict, () => { +perfTest.describe(`${Performance} ${PerformancePredict}`, () => { perfTest( 'Predict Available Balance - Complete Flow Performance', { tag: '@team-predict' }, diff --git a/tests/performance/login/predict/predict-deposit.spec.ts b/tests/performance/login/predict/predict-deposit.spec.ts index cbb84b92072..64720f868d1 100644 --- a/tests/performance/login/predict/predict-deposit.spec.ts +++ b/tests/performance/login/predict/predict-deposit.spec.ts @@ -6,7 +6,7 @@ import TabBarComponent from '../../../page-objects/wallet/TabBarComponent'; import WalletActionsBottomSheet from '../../../page-objects/wallet/WalletActionsBottomSheet'; import TransactionPayConfirmation from '../../../page-objects/Confirmation/TransactionPayConfirmation'; import PredictMarketList from '../../../page-objects/Predict/PredictMarketList'; -import { PerformancePredict } from '../../../tags.performance.js'; +import { Performance, PerformancePredict } from '../../../tags.performance.js'; /* * Scenario: Predict Deposit Performance Test @@ -24,7 +24,7 @@ import { PerformancePredict } from '../../../tags.performance.js'; * 4. Time to proceed to confirmation screen * 5. Time to verify deposit info (fees, amount) appears */ -perfTest.describe(PerformancePredict, () => { +perfTest.describe(`${Performance} ${PerformancePredict}`, () => { perfTest( 'Predict Deposit - Complete Flow Performance', { tag: '@team-predict' }, diff --git a/tests/performance/login/predict/predict-market-details.spec.ts b/tests/performance/login/predict/predict-market-details.spec.ts index 4d4cdfa951b..7d28502496d 100644 --- a/tests/performance/login/predict/predict-market-details.spec.ts +++ b/tests/performance/login/predict/predict-market-details.spec.ts @@ -6,7 +6,7 @@ import TabBarComponent from '../../../page-objects/wallet/TabBarComponent'; import WalletActionsBottomSheet from '../../../page-objects/wallet/WalletActionsBottomSheet'; import PredictMarketList from '../../../page-objects/Predict/PredictMarketList'; import PredictDetailsPage from '../../../page-objects/Predict/PredictDetailsPage'; -import { PerformancePredict } from '../../../tags.performance.js'; +import { Performance, PerformancePredict } from '../../../tags.performance.js'; /* * Scenario: Predict Market Details Performance Test @@ -23,7 +23,7 @@ import { PerformancePredict } from '../../../tags.performance.js'; * 3. Time to open About tab content * 4. Time to open Outcomes tab content when available */ -perfTest.describe(PerformancePredict, () => { +perfTest.describe(`${Performance} ${PerformancePredict}`, () => { perfTest.setTimeout(10 * 60 * 1000); perfTest( diff --git a/tests/performance/mm-connect/connection-evm-account.spec.ts b/tests/performance/mm-connect/connection-evm-account.spec.ts index 960106682a0..c7747a5abd4 100644 --- a/tests/performance/mm-connect/connection-evm-account.spec.ts +++ b/tests/performance/mm-connect/connection-evm-account.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import WalletView from '../../page-objects/wallet/WalletView'; @@ -38,151 +39,153 @@ const playgroundServer = new DappServer({ dappVariant: DappVariants.BROWSER_PLAYGROUND, }); -test.beforeAll(async () => { - playgroundServer.setServerPort(DAPP_PORT); - await playgroundServer.start(); - await waitForDappServerReady(DAPP_PORT); - setupAdbReverse(DAPP_PORT); -}); - -test.afterAll(async () => { - cleanupAdbReverse(DAPP_PORT); - await playgroundServer.stop(); -}); - -// Test steps (in order): -// -// 1. LOGIN AND NAVIGATE TO DAPP -// - Login to app, ensure account groups finished loading -// - Launch mobile browser and navigate to the playground dapp -// -// 2. CONNECT VIA LEGACY EVM (WITH ACCOUNT 3 ADDED) -// - Tap Connect (Legacy) -// - In MetaMask: tap Edit Accounts, add Account 3, tap Update, tap Connect (cooldown 2s) -// Account 3 must be authorized so MetaMask can switch to it later -// - Assert: connected true, chainId '0x1', active account is Account 1 -// (0x19a7Ad8256ab119655f1D758348501d598fC1C94) -// -// 3. SWITCH TO ACCOUNT 3 IN METAMASK -// - In MetaMask: tap identicon → select Account 3 from the account list -// - Assert: dapp reflects Account 3 as the active account -// (0xE2bEca5CaDC60b61368987728b4229822e6CDa83) -// -// 4. REFRESH BROWSER AND VERIFY ACCOUNT PERSISTS -// - Refresh mobile browser (native action) -// - Assert: connected true, chainId '0x1', active account is still Account 3 -// (0xE2bEca5CaDC60b61368987728b4229822e6CDa83) -// -// 5. PERSONAL SIGN TO VERIFY WALLET-SIDE ACCOUNT -// - Tap personal sign -// - In MetaMask: tap Cancel (cooldown 2s) -// Canceling verifies Account 3 appears as the signer in the modal (wallet-side check) -// - Assert: response value 'rejected' -// -// 6. CLEANUP -// - Tap disconnect to reset dapp state - -test('@metamask/connect-evm - Account switching and wallet-side verification', async ({ - currentDeviceDetails, - driver, -}) => { - const platform = currentDeviceDetails.platform; - const useBrowserStackLocal = - process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; - const DAPP_URL = useBrowserStackLocal - ? `http://bs-local.com:${DAPP_PORT}` - : getDappUrlForBrowser(platform); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await loginToAppPlaywright(); - await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - await launchMobileBrowser(); - await navigateToDapp(DAPP_URL); - }); - await sleep(5000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapConnectLegacy(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await unlockIfLockScreenVisible(); - await DappConnectionModal.tapEditAccountsButton(); - await DappConnectionModal.tapAccountButton('Account 3'); - await DappConnectionModal.tapUpdateAccountsButton(); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, - }); +test.describe(Performance, () => { + test.beforeAll(async () => { + playgroundServer.setServerPort(DAPP_PORT); + await playgroundServer.start(); + await waitForDappServerReady(DAPP_PORT); + setupAdbReverse(DAPP_PORT); }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + test.afterAll(async () => { + cleanupAdbReverse(DAPP_PORT); + await playgroundServer.stop(); + }); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS); - }, DAPP_URL); + // Test steps (in order): + // + // 1. LOGIN AND NAVIGATE TO DAPP + // - Login to app, ensure account groups finished loading + // - Launch mobile browser and navigate to the playground dapp + // + // 2. CONNECT VIA LEGACY EVM (WITH ACCOUNT 3 ADDED) + // - Tap Connect (Legacy) + // - In MetaMask: tap Edit Accounts, add Account 3, tap Update, tap Connect (cooldown 2s) + // Account 3 must be authorized so MetaMask can switch to it later + // - Assert: connected true, chainId '0x1', active account is Account 1 + // (0x19a7Ad8256ab119655f1D758348501d598fC1C94) + // + // 3. SWITCH TO ACCOUNT 3 IN METAMASK + // - In MetaMask: tap identicon → select Account 3 from the account list + // - Assert: dapp reflects Account 3 as the active account + // (0xE2bEca5CaDC60b61368987728b4229822e6CDa83) + // + // 4. REFRESH BROWSER AND VERIFY ACCOUNT PERSISTS + // - Refresh mobile browser (native action) + // - Assert: connected true, chainId '0x1', active account is still Account 3 + // (0xE2bEca5CaDC60b61368987728b4229822e6CDa83) + // + // 5. PERSONAL SIGN TO VERIFY WALLET-SIDE ACCOUNT + // - Tap personal sign + // - In MetaMask: tap Cancel (cooldown 2s) + // Canceling verifies Account 3 appears as the signer in the modal (wallet-side check) + // - Assert: response value 'rejected' + // + // 6. CLEANUP + // - Tap disconnect to reset dapp state + + test('@metamask/connect-evm - Account switching and wallet-side verification', async ({ + currentDeviceDetails, + driver, + }) => { + const platform = currentDeviceDetails.platform; + const useBrowserStackLocal = + process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; + const DAPP_URL = useBrowserStackLocal + ? `http://bs-local.com:${DAPP_PORT}` + : getDappUrlForBrowser(platform); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await loginToAppPlaywright(); + await ensureAccountGroupsFinishedLoading(currentDeviceDetails); + await launchMobileBrowser(); + await navigateToDapp(DAPP_URL); + }); + await sleep(5000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapConnectLegacy(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await unlockIfLockScreenVisible(); + await DappConnectionModal.tapEditAccountsButton(); + await DappConnectionModal.tapAccountButton('Account 3'); + await DappConnectionModal.tapUpdateAccountsButton(); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + }); - await PlaywrightContextHelpers.withNativeAction(async () => { - // Wait here to make sure UI is visible before attempted interaction await sleep(1000); - // We're only using Android for now - await PlaywrightUtilities.launchApp({ - packageName: APP_PACKAGE_IDS.ANDROID, - }); - await unlockIfLockScreenVisible(); + await switchToMobileBrowser(); + await sleep(1000); - // Change selected account to Account 3 in MetaMask - await WalletView.tapIdenticon(); - await AccountListBottomSheet.tapAccountByName('Account 3'); - }); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + // Wait here to make sure UI is visible before attempted interaction + await sleep(1000); + // We're only using Android for now + await PlaywrightUtilities.launchApp({ + packageName: APP_PACKAGE_IDS.ANDROID, + }); + await unlockIfLockScreenVisible(); + + // Change selected account to Account 3 in MetaMask + await WalletView.tapIdenticon(); + await AccountListBottomSheet.tapAccountByName('Account 3'); + }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - // Verify account changed to Account 3 - await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_3_ADDRESS); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + // Verify account changed to Account 3 + await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_3_ADDRESS); + }, DAPP_URL); - await PlaywrightContextHelpers.withNativeAction(async () => { - await refreshMobileBrowser(); - }); - await sleep(2000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_3_ADDRESS); - await BrowserPlaygroundDapp.tapPersonalSign(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapCancelButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await PlaywrightContextHelpers.withNativeAction(async () => { + await refreshMobileBrowser(); + }); + await sleep(2000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_3_ADDRESS); + await BrowserPlaygroundDapp.tapPersonalSign(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapCancelButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertResponseValue('rejected'); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertResponseValue('rejected'); + }, DAPP_URL); - // - // Reset dapp state - // + // + // Reset dapp state + // - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - }, DAPP_URL); -}); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + }, DAPP_URL); + }); +}); // end describe diff --git a/tests/performance/mm-connect/connection-evm-rejection.spec.ts b/tests/performance/mm-connect/connection-evm-rejection.spec.ts index eab3bcae747..22a6014a1df 100644 --- a/tests/performance/mm-connect/connection-evm-rejection.spec.ts +++ b/tests/performance/mm-connect/connection-evm-rejection.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp'; @@ -32,197 +33,199 @@ const playgroundServer = new DappServer({ dappVariant: DappVariants.BROWSER_PLAYGROUND, }); -test.beforeAll(async () => { - playgroundServer.setServerPort(DAPP_PORT); - await playgroundServer.start(); - await waitForDappServerReady(DAPP_PORT); - setupAdbReverse(DAPP_PORT); -}); - -test.afterAll(async () => { - cleanupAdbReverse(DAPP_PORT); - await playgroundServer.stop(); -}); - -// Test steps (in order): -// -// 1. LOGIN AND NAVIGATE TO DAPP -// - Login to app, ensure account groups finished loading -// - Launch mobile browser and navigate to the playground dapp -// -// 2. CONNECT VIA LEGACY EVM -// - Tap Connect (Legacy) -// - In MetaMask: tap Connect (cooldown 2s) -// - Assert: connected true, chainId '0x1', active account is Account 1 -// (0x19a7Ad8256ab119655f1D758348501d598fC1C94) -// -// 3. INITIAL REJECTION -// - Tap personal sign -// - In MetaMask: tap Cancel (cooldown 2s) -// - Assert: response value 'rejected' -// -// 4. DISCONNECT AND RECONNECT — FIRST CYCLE -// - Disconnect, assert connected false, tap Connect (Legacy) -// - In MetaMask: tap Connect (cooldown 2s) -// - Assert: connected true, chainId '0x1', active account is Account 1 -// - Tap personal sign; tap Cancel in MetaMask (cooldown 2s) -// - Assert: response value 'rejected' -// -// 5. DISCONNECT AND RECONNECT — SECOND CYCLE -// - Disconnect (no connected false assertion), tap Connect (Legacy) -// - In MetaMask: tap Connect (cooldown 2s) -// - Assert: connected true, chainId '0x1', active account is Account 1 -// - Tap personal sign; tap Cancel in MetaMask (cooldown 2s) -// - Assert: response value 'rejected' -// -// 6. CLEANUP -// - Tap disconnect to reset dapp state - -test('@metamask/connect-evm - Rejection response value verification', async ({ - currentDeviceDetails, - driver, -}) => { - const platform = currentDeviceDetails.platform; - const useBrowserStackLocal = - process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; - const DAPP_URL = useBrowserStackLocal - ? `http://bs-local.com:${DAPP_PORT}` - : getDappUrlForBrowser(platform); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await loginToAppPlaywright(); - await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - await launchMobileBrowser(); - await navigateToDapp(DAPP_URL); - }); - await sleep(5000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapConnectLegacy(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await unlockIfLockScreenVisible(); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, - }); +test.describe(Performance, () => { + test.beforeAll(async () => { + playgroundServer.setServerPort(DAPP_PORT); + await playgroundServer.start(); + await waitForDappServerReady(DAPP_PORT); + setupAdbReverse(DAPP_PORT); }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS); - await BrowserPlaygroundDapp.tapPersonalSign(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapCancelButton({ - shouldCooldown: true, - timeToCooldown: 2000, - }); + test.afterAll(async () => { + cleanupAdbReverse(DAPP_PORT); + await playgroundServer.stop(); }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertResponseValue('rejected'); - }, DAPP_URL); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - await BrowserPlaygroundDapp.assertConnected(false); - await BrowserPlaygroundDapp.tapConnectLegacy(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, + // Test steps (in order): + // + // 1. LOGIN AND NAVIGATE TO DAPP + // - Login to app, ensure account groups finished loading + // - Launch mobile browser and navigate to the playground dapp + // + // 2. CONNECT VIA LEGACY EVM + // - Tap Connect (Legacy) + // - In MetaMask: tap Connect (cooldown 2s) + // - Assert: connected true, chainId '0x1', active account is Account 1 + // (0x19a7Ad8256ab119655f1D758348501d598fC1C94) + // + // 3. INITIAL REJECTION + // - Tap personal sign + // - In MetaMask: tap Cancel (cooldown 2s) + // - Assert: response value 'rejected' + // + // 4. DISCONNECT AND RECONNECT — FIRST CYCLE + // - Disconnect, assert connected false, tap Connect (Legacy) + // - In MetaMask: tap Connect (cooldown 2s) + // - Assert: connected true, chainId '0x1', active account is Account 1 + // - Tap personal sign; tap Cancel in MetaMask (cooldown 2s) + // - Assert: response value 'rejected' + // + // 5. DISCONNECT AND RECONNECT — SECOND CYCLE + // - Disconnect (no connected false assertion), tap Connect (Legacy) + // - In MetaMask: tap Connect (cooldown 2s) + // - Assert: connected true, chainId '0x1', active account is Account 1 + // - Tap personal sign; tap Cancel in MetaMask (cooldown 2s) + // - Assert: response value 'rejected' + // + // 6. CLEANUP + // - Tap disconnect to reset dapp state + + test('@metamask/connect-evm - Rejection response value verification', async ({ + currentDeviceDetails, + driver, + }) => { + const platform = currentDeviceDetails.platform; + const useBrowserStackLocal = + process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; + const DAPP_URL = useBrowserStackLocal + ? `http://bs-local.com:${DAPP_PORT}` + : getDappUrlForBrowser(platform); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await loginToAppPlaywright(); + await ensureAccountGroupsFinishedLoading(currentDeviceDetails); + await launchMobileBrowser(); + await navigateToDapp(DAPP_URL); }); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS); - await BrowserPlaygroundDapp.tapPersonalSign(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapCancelButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await sleep(5000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapConnectLegacy(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await unlockIfLockScreenVisible(); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS); + await BrowserPlaygroundDapp.tapPersonalSign(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapCancelButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + }); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertResponseValue('rejected'); - }, DAPP_URL); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertResponseValue('rejected'); + }, DAPP_URL); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + await BrowserPlaygroundDapp.assertConnected(false); + await BrowserPlaygroundDapp.tapConnectLegacy(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + }); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - await BrowserPlaygroundDapp.tapConnectLegacy(); - }, DAPP_URL); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS); + await BrowserPlaygroundDapp.tapPersonalSign(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapCancelButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + }); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertResponseValue('rejected'); + }, DAPP_URL); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + await BrowserPlaygroundDapp.tapConnectLegacy(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS); - await BrowserPlaygroundDapp.tapPersonalSign(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapCancelButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS); + await BrowserPlaygroundDapp.tapPersonalSign(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapCancelButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertResponseValue('rejected'); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertResponseValue('rejected'); + }, DAPP_URL); - // - // Reset dapp state - // + // + // Reset dapp state + // - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - }, DAPP_URL); -}); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + }, DAPP_URL); + }); +}); // end describe diff --git a/tests/performance/mm-connect/connection-evm-session-timeout.spec.ts b/tests/performance/mm-connect/connection-evm-session-timeout.spec.ts index 52c8d803ecc..b66b9169904 100644 --- a/tests/performance/mm-connect/connection-evm-session-timeout.spec.ts +++ b/tests/performance/mm-connect/connection-evm-session-timeout.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp'; @@ -35,158 +36,160 @@ const playgroundServer = new DappServer({ dappVariant: DappVariants.BROWSER_PLAYGROUND, }); -test.beforeAll(async () => { - playgroundServer.setServerPort(DAPP_PORT); - await playgroundServer.start(); - await waitForDappServerReady(DAPP_PORT); - setupAdbReverse(DAPP_PORT); -}); - -test.afterAll(async () => { - cleanupAdbReverse(DAPP_PORT); - await playgroundServer.stop(); -}); - -// Test steps (in order): -// -// 1. LOGIN AND NAVIGATE TO DAPP -// - Login to app, ensure account groups finished loading -// - Launch mobile browser and navigate to the playground dapp -// -// 2. CONNECT VIA LEGACY EVM -// - Tap Connect (Legacy) -// - In MetaMask: tap Connect (cooldown 2s) -// - Assert: connected true, chainId '0x1' -// -// 3. INCOMPLETE SESSION — NOT INTERACTING WITH MODAL -// - Tap disconnect, then tap Connect (Legacy) again -// - In MetaMask: open approval modal but purposely do NOT interact (sleep 2s) -// - Switch to browser (sleep 2s), refresh mobile browser, wait 2s -// - Assert: connected false (incomplete session started timing out) -// - Sleep 10s to let session fully time out -// - Assert: still connected false -// -// 4. RECONNECT AFTER SESSION TIMEOUT -// - Tap Connect (Legacy) -// - In MetaMask: tap Connect (cooldown 2s) -// - Assert: connected true, chainId '0x1' -// -// 5. READ-ONLY METHOD WITH APP TERMINATED -// - Terminate the MetaMask app (PlaywrightGestures.terminateApp) -// - Tap getBalance in the dapp; sleep 10s for RPC response -// - Assert: response value contains 'Balance:' prefix -// (confirms read-only calls go directly to the RPC endpoint, not the wallet) -// -// 6. CLEANUP -// - Tap disconnect to reset dapp state - -// This test is currently being skipped as the mobile app displays a double prompt. -test.skip('@metamask/connect-evm - Incomplete session timeout and read-only methods', async ({ - currentDeviceDetails, - driver, -}) => { - const platform = currentDeviceDetails.platform; - const useBrowserStackLocal = - process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; - const DAPP_URL = useBrowserStackLocal - ? `http://bs-local.com:${DAPP_PORT}` - : getDappUrlForBrowser(platform); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await loginToAppPlaywright(); - await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - await launchMobileBrowser(); - await navigateToDapp(DAPP_URL); +test.describe(Performance, () => { + test.beforeAll(async () => { + playgroundServer.setServerPort(DAPP_PORT); + await playgroundServer.start(); + await waitForDappServerReady(DAPP_PORT); + setupAdbReverse(DAPP_PORT); }); - await sleep(5000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapConnectLegacy(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await unlockIfLockScreenVisible(); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, - }); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - }, DAPP_URL); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - await BrowserPlaygroundDapp.tapConnectLegacy(); - }, DAPP_URL); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - // Purposely not interacting with the approval but we still spend some time - // on the app - await sleep(2000); - }); - - await sleep(2000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await refreshMobileBrowser(); + test.afterAll(async () => { + cleanupAdbReverse(DAPP_PORT); + await playgroundServer.stop(); }); - await sleep(2000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertConnected(false); - }, DAPP_URL); + // Test steps (in order): + // + // 1. LOGIN AND NAVIGATE TO DAPP + // - Login to app, ensure account groups finished loading + // - Launch mobile browser and navigate to the playground dapp + // + // 2. CONNECT VIA LEGACY EVM + // - Tap Connect (Legacy) + // - In MetaMask: tap Connect (cooldown 2s) + // - Assert: connected true, chainId '0x1' + // + // 3. INCOMPLETE SESSION — NOT INTERACTING WITH MODAL + // - Tap disconnect, then tap Connect (Legacy) again + // - In MetaMask: open approval modal but purposely do NOT interact (sleep 2s) + // - Switch to browser (sleep 2s), refresh mobile browser, wait 2s + // - Assert: connected false (incomplete session started timing out) + // - Sleep 10s to let session fully time out + // - Assert: still connected false + // + // 4. RECONNECT AFTER SESSION TIMEOUT + // - Tap Connect (Legacy) + // - In MetaMask: tap Connect (cooldown 2s) + // - Assert: connected true, chainId '0x1' + // + // 5. READ-ONLY METHOD WITH APP TERMINATED + // - Terminate the MetaMask app (PlaywrightGestures.terminateApp) + // - Tap getBalance in the dapp; sleep 10s for RPC response + // - Assert: response value contains 'Balance:' prefix + // (confirms read-only calls go directly to the RPC endpoint, not the wallet) + // + // 6. CLEANUP + // - Tap disconnect to reset dapp state + + // This test is currently being skipped as the mobile app displays a double prompt. + test.skip('@metamask/connect-evm - Incomplete session timeout and read-only methods', async ({ + currentDeviceDetails, + driver, + }) => { + const platform = currentDeviceDetails.platform; + const useBrowserStackLocal = + process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; + const DAPP_URL = useBrowserStackLocal + ? `http://bs-local.com:${DAPP_PORT}` + : getDappUrlForBrowser(platform); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await loginToAppPlaywright(); + await ensureAccountGroupsFinishedLoading(currentDeviceDetails); + await launchMobileBrowser(); + await navigateToDapp(DAPP_URL); + }); + await sleep(5000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapConnectLegacy(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await unlockIfLockScreenVisible(); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + }); - await sleep(10000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + }, DAPP_URL); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + await BrowserPlaygroundDapp.tapConnectLegacy(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + // Purposely not interacting with the approval but we still spend some time + // on the app + await sleep(2000); + }); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertConnected(false); - await BrowserPlaygroundDapp.tapConnectLegacy(); - }, DAPP_URL); + await sleep(2000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await PlaywrightContextHelpers.withNativeAction(async () => { + await refreshMobileBrowser(); }); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(2000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertConnected(false); + }, DAPP_URL); - // - // Read-only method should hit rpc endpoint instead of wallet - // - await PlaywrightGestures.terminateApp(currentDeviceDetails); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapGetBalance(); await sleep(10000); - // Balance response should contain "Balance:" prefix - await BrowserPlaygroundDapp.assertResponseValue('Balance:'); - }, DAPP_URL); - // - // Reset dapp state - // + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertConnected(false); + await BrowserPlaygroundDapp.tapConnectLegacy(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + }); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - }, DAPP_URL); -}); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + }, DAPP_URL); + + // + // Read-only method should hit rpc endpoint instead of wallet + // + await PlaywrightGestures.terminateApp(currentDeviceDetails); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapGetBalance(); + await sleep(10000); + // Balance response should contain "Balance:" prefix + await BrowserPlaygroundDapp.assertResponseValue('Balance:'); + }, DAPP_URL); + + // + // Reset dapp state + // + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + }, DAPP_URL); + }); +}); // end describe diff --git a/tests/performance/mm-connect/connection-evm-sign.spec.ts b/tests/performance/mm-connect/connection-evm-sign.spec.ts index 2f5a098e558..81d98dd8c23 100644 --- a/tests/performance/mm-connect/connection-evm-sign.spec.ts +++ b/tests/performance/mm-connect/connection-evm-sign.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp'; @@ -33,197 +34,199 @@ const playgroundServer = new DappServer({ dappVariant: DappVariants.BROWSER_PLAYGROUND, }); -test.beforeAll(async () => { - playgroundServer.setServerPort(DAPP_PORT); - await playgroundServer.start(); - await waitForDappServerReady(DAPP_PORT); - setupAdbReverse(DAPP_PORT); -}); - -test.afterAll(async () => { - cleanupAdbReverse(DAPP_PORT); - await playgroundServer.stop(); -}); +test.describe(Performance, () => { + test.beforeAll(async () => { + playgroundServer.setServerPort(DAPP_PORT); + await playgroundServer.start(); + await waitForDappServerReady(DAPP_PORT); + setupAdbReverse(DAPP_PORT); + }); -// Test steps (in order): -// -// 1. LOGIN AND NAVIGATE TO DAPP -// - Login to app, ensure account groups finished loading -// - Launch mobile browser and navigate to the playground dapp -// -// 2. CONNECT VIA LEGACY EVM (WITH ACCOUNT 3) -// - Tap Connect (Legacy) -// - In MetaMask: tap Edit Accounts, add Account 3, tap Update, tap Connect (cooldown 2s) -// - Assert: connected true, chainId '0x1', active account is Account 1 -// (0x19a7Ad8256ab119655f1D758348501d598fC1C94) -// -// 3. SIGN MESSAGE ON ETHEREUM — CONFIRM -// - Tap personal sign -// - In MetaMask: tap Confirm (cooldown 2s) -// - Assert: response value matches Account 1 signature hash -// (0x361c13288b4ab02d50974efddf9e4e7ca651b81c298b614be908c4754abb1dd8 -// 328224645a1a8d0fab561c4b855c7bdcebea15db5ae8d1778a1ea791dbd05c2a1b) -// -// 4. SEND TRANSACTION ON ETHEREUM — CANCEL -// - Tap send transaction -// - In MetaMask: assert network text 'Ethereum', tap Cancel (cooldown 2s) -// - Assert: response value 'denied' -// -// 5. SWITCH TO POLYGON AND SEND TRANSACTION — CANCEL -// - Assert response 'denied', tap switch to Polygon -// - In MetaMask: assert network text 'Polygon', tap Confirm chain switch (cooldown 2s) -// - Assert: chainId '0x89' -// - Tap send transaction -// - In MetaMask: assert network text 'Polygon', tap Cancel (cooldown 2s) -// -// 6. SWITCH TO MAINNET AND SEND TRANSACTION — CANCEL -// - Tap switch to Mainnet, assert chainId '0x1' -// - Tap send transaction -// - In MetaMask: assert network text 'Ethereum', tap Cancel (cooldown 2s) -// -// 7. CLEANUP -// - Tap disconnect to reset dapp state - -test('@metamask/connect-evm - Sign and transaction cancel flows', async ({ - currentDeviceDetails, - driver, -}) => { - const platform = currentDeviceDetails.platform; - const useBrowserStackLocal = - process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; - const DAPP_URL = useBrowserStackLocal - ? `http://bs-local.com:${DAPP_PORT}` - : getDappUrlForBrowser(platform); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await loginToAppPlaywright(); - await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - await launchMobileBrowser(); - await navigateToDapp(DAPP_URL); + test.afterAll(async () => { + cleanupAdbReverse(DAPP_PORT); + await playgroundServer.stop(); }); - await sleep(5000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapConnectLegacy(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await unlockIfLockScreenVisible(); - await DappConnectionModal.tapEditAccountsButton(); - await DappConnectionModal.tapAccountButton('Account 3'); - await DappConnectionModal.tapUpdateAccountsButton(); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, + + // Test steps (in order): + // + // 1. LOGIN AND NAVIGATE TO DAPP + // - Login to app, ensure account groups finished loading + // - Launch mobile browser and navigate to the playground dapp + // + // 2. CONNECT VIA LEGACY EVM (WITH ACCOUNT 3) + // - Tap Connect (Legacy) + // - In MetaMask: tap Edit Accounts, add Account 3, tap Update, tap Connect (cooldown 2s) + // - Assert: connected true, chainId '0x1', active account is Account 1 + // (0x19a7Ad8256ab119655f1D758348501d598fC1C94) + // + // 3. SIGN MESSAGE ON ETHEREUM — CONFIRM + // - Tap personal sign + // - In MetaMask: tap Confirm (cooldown 2s) + // - Assert: response value matches Account 1 signature hash + // (0x361c13288b4ab02d50974efddf9e4e7ca651b81c298b614be908c4754abb1dd8 + // 328224645a1a8d0fab561c4b855c7bdcebea15db5ae8d1778a1ea791dbd05c2a1b) + // + // 4. SEND TRANSACTION ON ETHEREUM — CANCEL + // - Tap send transaction + // - In MetaMask: assert network text 'Ethereum', tap Cancel (cooldown 2s) + // - Assert: response value 'denied' + // + // 5. SWITCH TO POLYGON AND SEND TRANSACTION — CANCEL + // - Assert response 'denied', tap switch to Polygon + // - In MetaMask: assert network text 'Polygon', tap Confirm chain switch (cooldown 2s) + // - Assert: chainId '0x89' + // - Tap send transaction + // - In MetaMask: assert network text 'Polygon', tap Cancel (cooldown 2s) + // + // 6. SWITCH TO MAINNET AND SEND TRANSACTION — CANCEL + // - Tap switch to Mainnet, assert chainId '0x1' + // - Tap send transaction + // - In MetaMask: assert network text 'Ethereum', tap Cancel (cooldown 2s) + // + // 7. CLEANUP + // - Tap disconnect to reset dapp state + + test('@metamask/connect-evm - Sign and transaction cancel flows', async ({ + currentDeviceDetails, + driver, + }) => { + const platform = currentDeviceDetails.platform; + const useBrowserStackLocal = + process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; + const DAPP_URL = useBrowserStackLocal + ? `http://bs-local.com:${DAPP_PORT}` + : getDappUrlForBrowser(platform); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await loginToAppPlaywright(); + await ensureAccountGroupsFinishedLoading(currentDeviceDetails); + await launchMobileBrowser(); + await navigateToDapp(DAPP_URL); + }); + await sleep(5000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapConnectLegacy(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await unlockIfLockScreenVisible(); + await DappConnectionModal.tapEditAccountsButton(); + await DappConnectionModal.tapAccountButton('Account 3'); + await DappConnectionModal.tapUpdateAccountsButton(); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS); - await BrowserPlaygroundDapp.tapPersonalSign(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapConfirmButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_ADDRESS); + await BrowserPlaygroundDapp.tapPersonalSign(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapConfirmButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertResponseValue( - // Account 1 signed the message - '0x361c13288b4ab02d50974efddf9e4e7ca651b81c298b614be908c4754abb1dd8328224645a1a8d0fab561c4b855c7bdcebea15db5ae8d1778a1ea791dbd05c2a1b', - ); - await BrowserPlaygroundDapp.tapSendTransaction(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.assertNetworkText('Ethereum'); - await SignModal.tapCancelButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertResponseValue( + // Account 1 signed the message + '0x361c13288b4ab02d50974efddf9e4e7ca651b81c298b614be908c4754abb1dd8328224645a1a8d0fab561c4b855c7bdcebea15db5ae8d1778a1ea791dbd05c2a1b', + ); + await BrowserPlaygroundDapp.tapSendTransaction(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.assertNetworkText('Ethereum'); + await SignModal.tapCancelButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - // Note: Error message may differ slightly in browser playground - await BrowserPlaygroundDapp.assertResponseValue('denied'); - await BrowserPlaygroundDapp.tapSwitchToPolygon(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SwitchChainModal.assertNetworkText('Polygon'); - await SwitchChainModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + // Note: Error message may differ slightly in browser playground + await BrowserPlaygroundDapp.assertResponseValue('denied'); + await BrowserPlaygroundDapp.tapSwitchToPolygon(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SwitchChainModal.assertNetworkText('Polygon'); + await SwitchChainModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertChainIdValue('0x89'); - await BrowserPlaygroundDapp.tapSendTransaction(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.assertNetworkText('Polygon'); - await SignModal.tapCancelButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertChainIdValue('0x89'); + await BrowserPlaygroundDapp.tapSendTransaction(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.assertNetworkText('Polygon'); + await SignModal.tapCancelButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapSwitchToMainnet(); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - await BrowserPlaygroundDapp.tapSendTransaction(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.assertNetworkText('Ethereum'); - await SignModal.tapCancelButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapSwitchToMainnet(); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + await BrowserPlaygroundDapp.tapSendTransaction(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.assertNetworkText('Ethereum'); + await SignModal.tapCancelButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - // - // Reset dapp state - // + // + // Reset dapp state + // - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - }, DAPP_URL); -}); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + }, DAPP_URL); + }); +}); // end describe diff --git a/tests/performance/mm-connect/connection-multichain.spec.ts b/tests/performance/mm-connect/connection-multichain.spec.ts index cc3971c759a..86869e11af0 100644 --- a/tests/performance/mm-connect/connection-multichain.spec.ts +++ b/tests/performance/mm-connect/connection-multichain.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import TimerHelper from '../../framework/TimerHelper'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp'; @@ -35,76 +36,80 @@ const playgroundServer = new DappServer({ dappVariant: DappVariants.BROWSER_PLAYGROUND, }); -// Start local playground server before all tests -test.beforeAll(async () => { - playgroundServer.setServerPort(DAPP_PORT); - await playgroundServer.start(); - await waitForDappServerReady(DAPP_PORT); - setupAdbReverse(DAPP_PORT); -}); +test.describe(Performance, () => { + // Start local playground server before all tests + test.beforeAll(async () => { + playgroundServer.setServerPort(DAPP_PORT); + await playgroundServer.start(); + await waitForDappServerReady(DAPP_PORT); + setupAdbReverse(DAPP_PORT); + }); -// Stop local playground server after all tests -test.afterAll(async () => { - cleanupAdbReverse(DAPP_PORT); - await playgroundServer.stop(); -}); + // Stop local playground server after all tests + test.afterAll(async () => { + cleanupAdbReverse(DAPP_PORT); + await playgroundServer.stop(); + }); -test('@metamask/connect-multichain - Connect via Multichain API to Local Browser Playground', async ({ - currentDeviceDetails, - driver, -}) => { - const useBrowserStackLocal = - process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; - const DAPP_URL = useBrowserStackLocal - ? `http://bs-local.com:${DAPP_PORT}` - : getDappUrlForBrowser(currentDeviceDetails.platform); + test('@metamask/connect-multichain - Connect via Multichain API to Local Browser Playground', async ({ + currentDeviceDetails, + driver, + }) => { + const useBrowserStackLocal = + process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; + const DAPP_URL = useBrowserStackLocal + ? `http://bs-local.com:${DAPP_PORT}` + : getDappUrlForBrowser(currentDeviceDetails.platform); - // - // Login and navigate to dapp - // + // + // Login and navigate to dapp + // - await PlaywrightContextHelpers.withNativeAction(async () => { - await loginToAppPlaywright(); - await launchMobileBrowser(); - await navigateToDapp(DAPP_URL); - }); + await PlaywrightContextHelpers.withNativeAction(async () => { + await loginToAppPlaywright(); + await launchMobileBrowser(); + await navigateToDapp(DAPP_URL); + }); - // - // Connect via Multichain API - // - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.waitForConnectButtonVisible(15000); - await BrowserPlaygroundDapp.tapConnect(); - }, DAPP_URL); + // + // Connect via Multichain API + // + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.waitForConnectButtonVisible(15000); + await BrowserPlaygroundDapp.tapConnect(); + }, DAPP_URL); - // Handle connection approval in MetaMask - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await unlockIfLockScreenVisible(); - await DappConnectionModal.tapConnectButton(); - }); + // Handle connection approval in MetaMask + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await unlockIfLockScreenVisible(); + await DappConnectionModal.tapConnectButton(); + }); - // Switch back to browser - await switchToMobileBrowser(); - await sleep(500); + // Switch back to browser + await switchToMobileBrowser(); + await sleep(500); - // - // Verify connection - // + // + // Verify connection + // - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertMultichainConnected(true); - await PlaywrightGestures.scrollIntoView( - await asPlaywrightElement(BrowserPlaygroundDapp.getScopeCard('eip155:1')), - ); - await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertMultichainConnected(true); + await PlaywrightGestures.scrollIntoView( + await asPlaywrightElement( + BrowserPlaygroundDapp.getScopeCard('eip155:1'), + ), + ); + await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); + }, DAPP_URL); - // - // Cleanup - disconnect - // + // + // Cleanup - disconnect + // - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - }, DAPP_URL); -}); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + }, DAPP_URL); + }); +}); // end describe diff --git a/tests/performance/mm-connect/connection-multiclient-resilience.spec.ts b/tests/performance/mm-connect/connection-multiclient-resilience.spec.ts index 502b8e73f21..53ef06eafd5 100644 --- a/tests/performance/mm-connect/connection-multiclient-resilience.spec.ts +++ b/tests/performance/mm-connect/connection-multiclient-resilience.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp'; @@ -47,248 +48,250 @@ const playgroundServer = new DappServer({ dappVariant: DappVariants.BROWSER_PLAYGROUND, }); -// Start local playground server before all tests -test.beforeAll(async () => { - // Set port and start the server directly (bypassing Detox-specific utilities) - playgroundServer.setServerPort(DAPP_PORT); - await playgroundServer.start(); - await waitForDappServerReady(DAPP_PORT); +test.describe(Performance, () => { + // Start local playground server before all tests + test.beforeAll(async () => { + // Set port and start the server directly (bypassing Detox-specific utilities) + playgroundServer.setServerPort(DAPP_PORT); + await playgroundServer.start(); + await waitForDappServerReady(DAPP_PORT); - // Set up adb reverse for Android emulator access - setupAdbReverse(DAPP_PORT); -}); - -// Stop local playground server after all tests -test.afterAll(async () => { - cleanupAdbReverse(DAPP_PORT); - await playgroundServer.stop(); -}); + // Set up adb reverse for Android emulator access + setupAdbReverse(DAPP_PORT); + }); -// Test steps (in order): -// -// 1. DISCONNECT SOLANA, VERIFY EVM PERSISTS -// - Tap Solana disconnect -// - Assert: solana scope gone, Solana disconnected -// - Assert: eip155:1 scope still visible, legacy EVM connected, wagmi connected -// - Wagmi personal sign -> confirm -> assert signature starts with 0x -// -// 2. RECONNECT SOLANA, VERIFY EVM PERSISTS -// - Tap Solana connect -> approve in MetaMask -// - Assert: Solana scope visible, Solana connected with account 1 -// - Solana sign message -> confirm -> assert correct signed result -// - Assert: eip155:1 scope still visible, legacy EVM connected, wagmi connected -// - Wagmi personal sign -> confirm -> assert signature starts with 0x -// -// 3. CONCURRENT CONNECT: PENDING APPROVAL + KILL APP RESILIENCE -// - Disconnect both Solana and wagmi, then tap Solana connect (initiates approval) -// - In MetaMask: terminate app without accepting approval, relaunch and log in -// - Tap wagmi connect -> approve in MetaMask -// - Assert: eip155:1 connected, legacy EVM connected, wagmi connected -// - Assert: Solana connected (the pending Solana session from step 7 was fulfilled) -// -// 4. CLEANUP -// - Tap Solana disconnect and legacy EVM disconnect -test('@metamask/connect-multichain (multiple clients) - Disconnect, reconnect, and resilience via Multichain API', async ({ - currentDeviceDetails, - driver, -}) => { - const platform = currentDeviceDetails.platform; - const useBrowserStackLocal = - process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; - const DAPP_URL = useBrowserStackLocal - ? `http://bs-local.com:${DAPP_PORT}` - : getDappUrlForBrowser(platform); + // Stop local playground server after all tests + test.afterAll(async () => { + cleanupAdbReverse(DAPP_PORT); + await playgroundServer.stop(); + }); + // Test steps (in order): + // + // 1. DISCONNECT SOLANA, VERIFY EVM PERSISTS + // - Tap Solana disconnect + // - Assert: solana scope gone, Solana disconnected + // - Assert: eip155:1 scope still visible, legacy EVM connected, wagmi connected + // - Wagmi personal sign -> confirm -> assert signature starts with 0x // - // Login and navigate to dapp - // (relies on connection state established by the preceding multiclient test) + // 2. RECONNECT SOLANA, VERIFY EVM PERSISTS + // - Tap Solana connect -> approve in MetaMask + // - Assert: Solana scope visible, Solana connected with account 1 + // - Solana sign message -> confirm -> assert correct signed result + // - Assert: eip155:1 scope still visible, legacy EVM connected, wagmi connected + // - Wagmi personal sign -> confirm -> assert signature starts with 0x // + // 3. CONCURRENT CONNECT: PENDING APPROVAL + KILL APP RESILIENCE + // - Disconnect both Solana and wagmi, then tap Solana connect (initiates approval) + // - In MetaMask: terminate app without accepting approval, relaunch and log in + // - Tap wagmi connect -> approve in MetaMask + // - Assert: eip155:1 connected, legacy EVM connected, wagmi connected + // - Assert: Solana connected (the pending Solana session from step 7 was fulfilled) + // + // 4. CLEANUP + // - Tap Solana disconnect and legacy EVM disconnect + test('@metamask/connect-multichain (multiple clients) - Disconnect, reconnect, and resilience via Multichain API', async ({ + currentDeviceDetails, + driver, + }) => { + const platform = currentDeviceDetails.platform; + const useBrowserStackLocal = + process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; + const DAPP_URL = useBrowserStackLocal + ? `http://bs-local.com:${DAPP_PORT}` + : getDappUrlForBrowser(platform); + + // + // Login and navigate to dapp + // (relies on connection state established by the preceding multiclient test) + // + + await PlaywrightContextHelpers.withNativeAction(async () => { + await loginToAppPlaywright(); + await ensureAccountGroupsFinishedLoading(currentDeviceDetails); + await launchMobileBrowser(); + await navigateToDapp(DAPP_URL); + }); - await PlaywrightContextHelpers.withNativeAction(async () => { - await loginToAppPlaywright(); - await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - await launchMobileBrowser(); - await navigateToDapp(DAPP_URL); - }); + await sleep(1000); - await sleep(1000); - - // Tap the Connect button (multichain API - default scopes) - await PlaywrightContextHelpers.withWebAction(async () => { - // Note: the Solana wallet standard provider itself has an issue where it does not - // listen for wallet_sessionChanged events, so we need to use the Solana's connect button - // as the entrypoint for now. - await BrowserPlaygroundDapp.tapSolanaConnect(); - }, DAPP_URL); - - // Handle connection approval in MetaMask - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await unlockIfLockScreenVisible(); - await DappConnectionModal.tapConnectButton(); - }); + // Tap the Connect button (multichain API - default scopes) + await PlaywrightContextHelpers.withWebAction(async () => { + // Note: the Solana wallet standard provider itself has an issue where it does not + // listen for wallet_sessionChanged events, so we need to use the Solana's connect button + // as the entrypoint for now. + await BrowserPlaygroundDapp.tapSolanaConnect(); + }, DAPP_URL); + + // Handle connection approval in MetaMask + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await unlockIfLockScreenVisible(); + await DappConnectionModal.tapConnectButton(); + }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - // - // Step 1: Disconnect Solana, verify EVM persists - // - await PlaywrightContextHelpers.withWebAction(async () => { - // Disconnect Solana - await BrowserPlaygroundDapp.tapSolanaDisconnect(); - - await BrowserPlaygroundDapp.assertScopeCardNotVisible( - SOLANA_MAINNET_CAIP_CHAIN_ID, - ); - await BrowserPlaygroundDapp.assertSolanaConnected(false); - - // Make sure EVM is still connected - await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertWagmiConnected(true); - // Verify wagmi personal sign works when wagmi is connected - await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask'); - await PlaywrightGestures.hideKeyboard(); - await BrowserPlaygroundDapp.tapWagmiSignMessage(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapConfirmButton(); - }); + // + // Step 1: Disconnect Solana, verify EVM persists + // + await PlaywrightContextHelpers.withWebAction(async () => { + // Disconnect Solana + await BrowserPlaygroundDapp.tapSolanaDisconnect(); + + await BrowserPlaygroundDapp.assertScopeCardNotVisible( + SOLANA_MAINNET_CAIP_CHAIN_ID, + ); + await BrowserPlaygroundDapp.assertSolanaConnected(false); + + // Make sure EVM is still connected + await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertWagmiConnected(true); + // Verify wagmi personal sign works when wagmi is connected + await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask'); + await PlaywrightGestures.hideKeyboard(); + await BrowserPlaygroundDapp.tapWagmiSignMessage(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapConfirmButton(); + }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x'); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x'); - // Reconnect Solana - await BrowserPlaygroundDapp.tapSolanaConnect(); - }, DAPP_URL); + // Reconnect Solana + await BrowserPlaygroundDapp.tapSolanaConnect(); + }, DAPP_URL); - // Reconnecting Solana takes a bit of time to trigger the deeplink, so we need to wait for it to complete - await sleep(3500); + // Reconnecting Solana takes a bit of time to trigger the deeplink, so we need to wait for it to complete + await sleep(3500); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - // Reconnecting Solana takes a bit of time, so we need to wait for it to complete - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 4000, + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + // Reconnecting Solana takes a bit of time, so we need to wait for it to complete + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 4000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertScopeCardVisible( - SOLANA_MAINNET_CAIP_CHAIN_ID, - ); - await BrowserPlaygroundDapp.assertSolanaConnected(true); - await BrowserPlaygroundDapp.assertSolanaActiveAccount( - ACCOUNT_1_SOLANA_ADDRESS, - ); - // Verify solana sign works when solana is connected - await PlaywrightGestures.scrollIntoView( - await asPlaywrightElement(BrowserPlaygroundDapp.solanaCard), - { scrollParams: { direction: 'down' } }, - ); - await BrowserPlaygroundDapp.tapSolanaSignMessage(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SnapSignModal.tapConfirmButton(); - }); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertSolanaSignedMessageResult( - ACCOUNT_1_SOLANA_SIGNED_MESSAGE_RESULT, - ); - - // Make sure EVM is still connected - await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertWagmiConnected(true); - // Verify wagmi personal sign works when wagmi is connected - await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask'); - await PlaywrightGestures.hideKeyboard(); - await BrowserPlaygroundDapp.tapWagmiSignMessage(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapConfirmButton(); - }); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertScopeCardVisible( + SOLANA_MAINNET_CAIP_CHAIN_ID, + ); + await BrowserPlaygroundDapp.assertSolanaConnected(true); + await BrowserPlaygroundDapp.assertSolanaActiveAccount( + ACCOUNT_1_SOLANA_ADDRESS, + ); + // Verify solana sign works when solana is connected + await PlaywrightGestures.scrollIntoView( + await asPlaywrightElement(BrowserPlaygroundDapp.solanaCard), + { scrollParams: { direction: 'down' } }, + ); + await BrowserPlaygroundDapp.tapSolanaSignMessage(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SnapSignModal.tapConfirmButton(); + }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x'); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertSolanaSignedMessageResult( + ACCOUNT_1_SOLANA_SIGNED_MESSAGE_RESULT, + ); + + // Make sure EVM is still connected + await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertWagmiConnected(true); + // Verify wagmi personal sign works when wagmi is connected + await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask'); + await PlaywrightGestures.hideKeyboard(); + await BrowserPlaygroundDapp.tapWagmiSignMessage(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapConfirmButton(); + }); - // Setup for concurrent connect test + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await BrowserPlaygroundDapp.tapSolanaDisconnect(); - await BrowserPlaygroundDapp.tapWagmiDisconnect(); - await BrowserPlaygroundDapp.tapSolanaConnect(); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x'); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + // Setup for concurrent connect test - // Purposely terminate the app without accepting the approval - await PlaywrightGestures.terminateApp(currentDeviceDetails); - await PlaywrightGestures.activateApp(currentDeviceDetails); - await loginToAppPlaywright(); - await sleep(1000); - }); + await BrowserPlaygroundDapp.tapSolanaDisconnect(); + await BrowserPlaygroundDapp.tapWagmiDisconnect(); + await BrowserPlaygroundDapp.tapSolanaConnect(); + }, DAPP_URL); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapConnectWagmi(); - }, DAPP_URL); + // Purposely terminate the app without accepting the approval + await PlaywrightGestures.terminateApp(currentDeviceDetails); + await PlaywrightGestures.activateApp(currentDeviceDetails); + await loginToAppPlaywright(); + await sleep(1000); + }); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await DappConnectionModal.tapConnectButton(); - }); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapConnectWagmi(); + }, DAPP_URL); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertWagmiConnected(true); - // Currently this is only possible if the solana connection attempt (the first one that initiated) was successful. - await BrowserPlaygroundDapp.assertSolanaConnected(true); - }, DAPP_URL); + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await DappConnectionModal.tapConnectButton(); + }); - // - // Cleanup - disconnect - // + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - // Note: the Solana wallet standard provider itself has an issue where it does not - // listen for wallet_sessionChanged events, so we need to use the Solana's disconnect button - // to ensure the solana react hook state is reset correctly. - await BrowserPlaygroundDapp.tapSolanaDisconnect(); - await BrowserPlaygroundDapp.tapDisconnect(); - }, DAPP_URL); -}); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertWagmiConnected(true); + // Currently this is only possible if the solana connection attempt (the first one that initiated) was successful. + await BrowserPlaygroundDapp.assertSolanaConnected(true); + }, DAPP_URL); + + // + // Cleanup - disconnect + // + + await PlaywrightContextHelpers.withWebAction(async () => { + // Note: the Solana wallet standard provider itself has an issue where it does not + // listen for wallet_sessionChanged events, so we need to use the Solana's disconnect button + // to ensure the solana react hook state is reset correctly. + await BrowserPlaygroundDapp.tapSolanaDisconnect(); + await BrowserPlaygroundDapp.tapDisconnect(); + }, DAPP_URL); + }); +}); // end describe diff --git a/tests/performance/mm-connect/connection-multiclient.spec.ts b/tests/performance/mm-connect/connection-multiclient.spec.ts index 626a2e23856..178d712e1b6 100644 --- a/tests/performance/mm-connect/connection-multiclient.spec.ts +++ b/tests/performance/mm-connect/connection-multiclient.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp'; @@ -47,273 +48,279 @@ const playgroundServer = new DappServer({ dappVariant: DappVariants.BROWSER_PLAYGROUND, }); -// Start local playground server before all tests -test.beforeAll(async () => { - // Set port and start the server directly (bypassing Detox-specific utilities) - playgroundServer.setServerPort(DAPP_PORT); - await playgroundServer.start(); - await waitForDappServerReady(DAPP_PORT); +test.describe(Performance, () => { + // Start local playground server before all tests + test.beforeAll(async () => { + // Set port and start the server directly (bypassing Detox-specific utilities) + playgroundServer.setServerPort(DAPP_PORT); + await playgroundServer.start(); + await waitForDappServerReady(DAPP_PORT); - // Set up adb reverse for Android emulator access - setupAdbReverse(DAPP_PORT); -}); - -// Stop local playground server after all tests -test.afterAll(async () => { - cleanupAdbReverse(DAPP_PORT); - await playgroundServer.stop(); -}); + // Set up adb reverse for Android emulator access + setupAdbReverse(DAPP_PORT); + }); -// Test steps (in order): -// -// 1. Login and navigate to the local browser playground dapp -// -// 2. INITIAL MULTICHAIN CONNECTION -// - Tap Solana connect (used as multichain entrypoint due to wallet_sessionChanged limitation) -// - Approve connection in MetaMask -// - Assert: multichain connected, eip155:1 scope visible, solana mainnet scope visible -// - Assert: legacy EVM connected (chainId 0x1, account 1), wagmi connected (chainId 1, account 1) -// - Wagmi personal sign -> confirm -> assert signature starts with 0x -// - Assert: Solana connected with account 1 -// - Solana sign message -> confirm via SnapSignModal -> assert correct signed result -// - Legacy EVM personal sign -> confirm -> assert correct signature -// -// 3. DISCONNECT EVM, VERIFY SOLANA PERSISTS -// - Tap wagmi disconnect -// - Assert: multichain still connected, eip155:1 scope gone, solana scope still visible -// - Assert: legacy EVM disconnected, wagmi disconnected, Solana still connected -// -// 4. RECONNECT EVM, VERIFY SOLANA PERSISTS -// - Tap wagmi connect -> approve in MetaMask -// - Assert: eip155:1 scope visible, legacy EVM connected, wagmi connected -// - Wagmi personal sign -> confirm -> assert signature starts with 0x -// - Assert: Solana scope still visible, Solana still connected -// - Solana sign message -> confirm -> assert correct signed result -test('@metamask/connect-multichain (multiple clients) - Connect multiple clients via Multichain API to Local Browser Playground', async ({ - currentDeviceDetails, -}) => { - // Get platform-specific URL - const platform = currentDeviceDetails.platform; - const useBrowserStackLocal = - process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; - const DAPP_URL = useBrowserStackLocal - ? `http://bs-local.com:${DAPP_PORT}` - : getDappUrlForBrowser(platform); + // Stop local playground server after all tests + test.afterAll(async () => { + cleanupAdbReverse(DAPP_PORT); + await playgroundServer.stop(); + }); + // Test steps (in order): // - // Login and navigate to dapp + // 1. Login and navigate to the local browser playground dapp // - - await PlaywrightContextHelpers.withNativeAction(async () => { - await loginToAppPlaywright(); - await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - await launchMobileBrowser(); - await navigateToDapp(DAPP_URL); - }); - - await sleep(1000); - + // 2. INITIAL MULTICHAIN CONNECTION + // - Tap Solana connect (used as multichain entrypoint due to wallet_sessionChanged limitation) + // - Approve connection in MetaMask + // - Assert: multichain connected, eip155:1 scope visible, solana mainnet scope visible + // - Assert: legacy EVM connected (chainId 0x1, account 1), wagmi connected (chainId 1, account 1) + // - Wagmi personal sign -> confirm -> assert signature starts with 0x + // - Assert: Solana connected with account 1 + // - Solana sign message -> confirm via SnapSignModal -> assert correct signed result + // - Legacy EVM personal sign -> confirm -> assert correct signature // - // Connect via Multichain API + // 3. DISCONNECT EVM, VERIFY SOLANA PERSISTS + // - Tap wagmi disconnect + // - Assert: multichain still connected, eip155:1 scope gone, solana scope still visible + // - Assert: legacy EVM disconnected, wagmi disconnected, Solana still connected // - - // Tap the Connect button (multichain API - default scopes) - await PlaywrightContextHelpers.withWebAction(async () => { - // Note: the Solana wallet standard provider itself has an issue where it does not - // listen for wallet_sessionChanged events, so we need to use the Solana's connect button - // as the entrypoint for now. - await BrowserPlaygroundDapp.tapSolanaConnect(); - }, DAPP_URL); - - // Handle connection approval in MetaMask - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await unlockIfLockScreenVisible(); - await DappConnectionModal.tapConnectButton(); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertMultichainConnected(true); - await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); - await BrowserPlaygroundDapp.assertScopeCardVisible( - SOLANA_MAINNET_CAIP_CHAIN_ID, - ); - - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_EVM_ADDRESS); - - await BrowserPlaygroundDapp.assertWagmiConnected(true); - await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); - await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_EVM_ADDRESS); - // Verify wagmi personal sign works when wagmi is connected - await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask'); - await PlaywrightGestures.hideKeyboard(); - await BrowserPlaygroundDapp.tapWagmiSignMessage(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapConfirmButton(); + // 4. RECONNECT EVM, VERIFY SOLANA PERSISTS + // - Tap wagmi connect -> approve in MetaMask + // - Assert: eip155:1 scope visible, legacy EVM connected, wagmi connected + // - Wagmi personal sign -> confirm -> assert signature starts with 0x + // - Assert: Solana scope still visible, Solana still connected + // - Solana sign message -> confirm -> assert correct signed result + test('@metamask/connect-multichain (multiple clients) - Connect multiple clients via Multichain API to Local Browser Playground', async ({ + currentDeviceDetails, + }) => { + // Get platform-specific URL + const platform = currentDeviceDetails.platform; + const useBrowserStackLocal = + process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; + const DAPP_URL = useBrowserStackLocal + ? `http://bs-local.com:${DAPP_PORT}` + : getDappUrlForBrowser(platform); + + // + // Login and navigate to dapp + // + + await PlaywrightContextHelpers.withNativeAction(async () => { + await loginToAppPlaywright(); + await ensureAccountGroupsFinishedLoading(currentDeviceDetails); + await launchMobileBrowser(); + await navigateToDapp(DAPP_URL); + }); + + await sleep(1000); + + // + // Connect via Multichain API + // + + // Tap the Connect button (multichain API - default scopes) + await PlaywrightContextHelpers.withWebAction(async () => { + // Note: the Solana wallet standard provider itself has an issue where it does not + // listen for wallet_sessionChanged events, so we need to use the Solana's connect button + // as the entrypoint for now. + await BrowserPlaygroundDapp.tapSolanaConnect(); + }, DAPP_URL); + + // Handle connection approval in MetaMask + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await unlockIfLockScreenVisible(); + await DappConnectionModal.tapConnectButton(); + }); + + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertMultichainConnected(true); + await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); + await BrowserPlaygroundDapp.assertScopeCardVisible( + SOLANA_MAINNET_CAIP_CHAIN_ID, + ); + + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_EVM_ADDRESS); + + await BrowserPlaygroundDapp.assertWagmiConnected(true); + await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); + await BrowserPlaygroundDapp.assertWagmiActiveAccount( + ACCOUNT_1_EVM_ADDRESS, + ); + // Verify wagmi personal sign works when wagmi is connected + await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask'); + await PlaywrightGestures.hideKeyboard(); + await BrowserPlaygroundDapp.tapWagmiSignMessage(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapConfirmButton(); + }); + + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x'); + + await BrowserPlaygroundDapp.assertSolanaConnected(true); + await BrowserPlaygroundDapp.assertSolanaActiveAccount( + ACCOUNT_1_SOLANA_ADDRESS, + ); + // Verify solana sign works when solana is connected + await PlaywrightGestures.scrollIntoView( + await asPlaywrightElement(BrowserPlaygroundDapp.solanaCard), + ); + await BrowserPlaygroundDapp.tapSolanaSignMessage(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SnapSignModal.tapConfirmButton(); + }); + + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertSolanaSignedMessageResult( + ACCOUNT_1_SOLANA_SIGNED_MESSAGE_RESULT, + ); + + await PlaywrightGestures.scrollIntoView( + await asPlaywrightElement(BrowserPlaygroundDapp.legacyEvmCard), + { scrollParams: { direction: 'down' } }, + ); + // Test EVM sign (legacy personal sign) when EVM is connected + await BrowserPlaygroundDapp.tapPersonalSign(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapConfirmButton(); + }); + + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertResponseValue( + '0x361c13288b4ab02d50974efddf9e4e7ca651b81c298b614be908c4754abb1dd8328224645a1a8d0fab561c4b855c7bdcebea15db5ae8d1778a1ea791dbd05c2a1b', + ); + + // Disconnect EVM + await PlaywrightGestures.scrollIntoView( + await asPlaywrightElement(BrowserPlaygroundDapp.wagmiDisconnectButton), + ); + await BrowserPlaygroundDapp.tapWagmiDisconnect(); + + await PlaywrightGestures.scrollIntoView( + await asPlaywrightElement(BrowserPlaygroundDapp.connectedScopesSection), + { scrollParams: { direction: 'down' } }, + ); + await BrowserPlaygroundDapp.assertMultichainConnected(true); + await BrowserPlaygroundDapp.assertScopeCardNotVisible('eip155:1'); + await BrowserPlaygroundDapp.assertScopeCardVisible( + SOLANA_MAINNET_CAIP_CHAIN_ID, + ); + + await BrowserPlaygroundDapp.assertConnected(false); + await BrowserPlaygroundDapp.assertWagmiConnected(false); + await BrowserPlaygroundDapp.assertSolanaConnected(true); + + // Reconnect EVM + await PlaywrightGestures.scrollIntoView( + await asPlaywrightElement(BrowserPlaygroundDapp.connectWagmiButton), + { scrollParams: { direction: 'down' } }, + ); + await BrowserPlaygroundDapp.tapConnectWagmi(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await DappConnectionModal.tapConnectButton({ shouldCooldown: true }); + }); + + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await PlaywrightGestures.scrollIntoView( + await asPlaywrightElement(BrowserPlaygroundDapp.wagmiCard), + { scrollParams: { direction: 'up' } }, + ); + + await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); + + await BrowserPlaygroundDapp.assertConnected(true); + await BrowserPlaygroundDapp.assertChainIdValue('0x1'); + await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_EVM_ADDRESS); + + await BrowserPlaygroundDapp.assertWagmiConnected(true); + await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); + await BrowserPlaygroundDapp.assertWagmiActiveAccount( + ACCOUNT_1_EVM_ADDRESS, + ); + // Verify wagmi personal sign works when wagmi is connected + await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask'); + await PlaywrightGestures.hideKeyboard(); + await BrowserPlaygroundDapp.tapWagmiSignMessage(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapConfirmButton(); + }); + + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x'); + + // Make sure solana is still connected + await BrowserPlaygroundDapp.assertScopeCardVisible( + SOLANA_MAINNET_CAIP_CHAIN_ID, + ); + await BrowserPlaygroundDapp.assertSolanaConnected(true); + await BrowserPlaygroundDapp.assertSolanaActiveAccount( + ACCOUNT_1_SOLANA_ADDRESS, + ); + // Verify solana sign works when solana is connected + await BrowserPlaygroundDapp.tapSolanaSignMessage(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SnapSignModal.tapConfirmButton(); + }); + + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await PlaywrightGestures.scrollIntoView( + await asPlaywrightElement( + BrowserPlaygroundDapp.solanaSignedMessageResult, + ), + ); + await BrowserPlaygroundDapp.assertSolanaSignedMessageResult( + ACCOUNT_1_SOLANA_SIGNED_MESSAGE_RESULT, + ); + }, DAPP_URL); }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x'); - - await BrowserPlaygroundDapp.assertSolanaConnected(true); - await BrowserPlaygroundDapp.assertSolanaActiveAccount( - ACCOUNT_1_SOLANA_ADDRESS, - ); - // Verify solana sign works when solana is connected - await PlaywrightGestures.scrollIntoView( - await asPlaywrightElement(BrowserPlaygroundDapp.solanaCard), - ); - await BrowserPlaygroundDapp.tapSolanaSignMessage(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SnapSignModal.tapConfirmButton(); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertSolanaSignedMessageResult( - ACCOUNT_1_SOLANA_SIGNED_MESSAGE_RESULT, - ); - - await PlaywrightGestures.scrollIntoView( - await asPlaywrightElement(BrowserPlaygroundDapp.legacyEvmCard), - { scrollParams: { direction: 'down' } }, - ); - // Test EVM sign (legacy personal sign) when EVM is connected - await BrowserPlaygroundDapp.tapPersonalSign(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapConfirmButton(); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertResponseValue( - '0x361c13288b4ab02d50974efddf9e4e7ca651b81c298b614be908c4754abb1dd8328224645a1a8d0fab561c4b855c7bdcebea15db5ae8d1778a1ea791dbd05c2a1b', - ); - - // Disconnect EVM - await PlaywrightGestures.scrollIntoView( - await asPlaywrightElement(BrowserPlaygroundDapp.wagmiDisconnectButton), - ); - await BrowserPlaygroundDapp.tapWagmiDisconnect(); - - await PlaywrightGestures.scrollIntoView( - await asPlaywrightElement(BrowserPlaygroundDapp.connectedScopesSection), - { scrollParams: { direction: 'down' } }, - ); - await BrowserPlaygroundDapp.assertMultichainConnected(true); - await BrowserPlaygroundDapp.assertScopeCardNotVisible('eip155:1'); - await BrowserPlaygroundDapp.assertScopeCardVisible( - SOLANA_MAINNET_CAIP_CHAIN_ID, - ); - - await BrowserPlaygroundDapp.assertConnected(false); - await BrowserPlaygroundDapp.assertWagmiConnected(false); - await BrowserPlaygroundDapp.assertSolanaConnected(true); - - // Reconnect EVM - await PlaywrightGestures.scrollIntoView( - await asPlaywrightElement(BrowserPlaygroundDapp.connectWagmiButton), - { scrollParams: { direction: 'down' } }, - ); - await BrowserPlaygroundDapp.tapConnectWagmi(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await DappConnectionModal.tapConnectButton({ shouldCooldown: true }); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await PlaywrightGestures.scrollIntoView( - await asPlaywrightElement(BrowserPlaygroundDapp.wagmiCard), - { scrollParams: { direction: 'up' } }, - ); - - await BrowserPlaygroundDapp.assertScopeCardVisible('eip155:1'); - - await BrowserPlaygroundDapp.assertConnected(true); - await BrowserPlaygroundDapp.assertChainIdValue('0x1'); - await BrowserPlaygroundDapp.assertActiveAccount(ACCOUNT_1_EVM_ADDRESS); - - await BrowserPlaygroundDapp.assertWagmiConnected(true); - await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); - await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_EVM_ADDRESS); - // Verify wagmi personal sign works when wagmi is connected - await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask'); - await PlaywrightGestures.hideKeyboard(); - await BrowserPlaygroundDapp.tapWagmiSignMessage(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapConfirmButton(); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x'); - - // Make sure solana is still connected - await BrowserPlaygroundDapp.assertScopeCardVisible( - SOLANA_MAINNET_CAIP_CHAIN_ID, - ); - await BrowserPlaygroundDapp.assertSolanaConnected(true); - await BrowserPlaygroundDapp.assertSolanaActiveAccount( - ACCOUNT_1_SOLANA_ADDRESS, - ); - // Verify solana sign works when solana is connected - await BrowserPlaygroundDapp.tapSolanaSignMessage(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SnapSignModal.tapConfirmButton(); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await PlaywrightGestures.scrollIntoView( - await asPlaywrightElement( - BrowserPlaygroundDapp.solanaSignedMessageResult, - ), - ); - await BrowserPlaygroundDapp.assertSolanaSignedMessageResult( - ACCOUNT_1_SOLANA_SIGNED_MESSAGE_RESULT, - ); - }, DAPP_URL); -}); +}); // end describe diff --git a/tests/performance/mm-connect/connection-wagmi-chains.spec.ts b/tests/performance/mm-connect/connection-wagmi-chains.spec.ts index ea88ce63c57..3ac86fe3e59 100644 --- a/tests/performance/mm-connect/connection-wagmi-chains.spec.ts +++ b/tests/performance/mm-connect/connection-wagmi-chains.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import WalletView from '../../page-objects/wallet/WalletView'; @@ -45,236 +46,238 @@ const playgroundServer = new DappServer({ dappVariant: DappVariants.BROWSER_PLAYGROUND, }); -// Start local playground server before all tests -test.beforeAll(async () => { - playgroundServer.setServerPort(DAPP_PORT); - await playgroundServer.start(); - await waitForDappServerReady(DAPP_PORT); - setupAdbReverse(DAPP_PORT); -}); - -// Stop local playground server after all tests -test.afterAll(async () => { - cleanupAdbReverse(DAPP_PORT); - await playgroundServer.stop(); -}); +test.describe(Performance, () => { + // Start local playground server before all tests + test.beforeAll(async () => { + playgroundServer.setServerPort(DAPP_PORT); + await playgroundServer.start(); + await waitForDappServerReady(DAPP_PORT); + setupAdbReverse(DAPP_PORT); + }); -// Test steps (in order): -// -// 1. LOGIN AND NAVIGATE TO DAPP -// - Login to app, ensure account groups finished loading -// - Launch mobile browser and navigate to the playground dapp -// -// 2. CONNECT VIA WAGMI -// - Tap Connect (Wagmi) -// - In MetaMask: add Account 3, unselect OP Mainnet from networks, tap Connect -// - Assert: wagmi connected, chainId 1, active account is Account 1 -// -// 3. SWITCH TO SEPOLIA AND SIGN -// - Switch chain to Sepolia (11155111), assert chainId 11155111 -// - Type 'Hello Sepolia' and tap sign -// - In MetaMask: cancel sign request on Sepolia network -// -// 4. SWITCH TO OP MAINNET (requires approval) AND CHANGE ACCOUNT -// - Tap switch chain to OP Mainnet (10) -// - In MetaMask: approve chain switch modal showing 'OP' -// - Assert: chainId 10 -// - Type 'Hello OP' and tap sign; cancel in MetaMask -// - In MetaMask: navigate to account list and select Account 3 -// -// 5. VERIFY ACCOUNT CHANGE AND ADD CELO CHAIN -// - Assert: wagmi active account is now Account 3 -// - Tap switch chain to Celo (42220) — triggers add chain flow -// - In MetaMask: assert chain shows '42220' and 'Celo', confirm add chain -// - Assert: chainId 42220 -// - Type 'Hello Celo' and tap sign; cancel in MetaMask -// -// 6. CLEANUP -// - Tap disconnect to clean up -test('@metamask/connect-evm (wagmi) - Chain switching via Wagmi', async ({ - currentDeviceDetails, - driver, -}) => { - const platform = currentDeviceDetails.platform; - const useBrowserStackLocal = - process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; - const DAPP_URL = useBrowserStackLocal - ? `http://bs-local.com:${DAPP_PORT}` - : getDappUrlForBrowser(platform); + // Stop local playground server after all tests + test.afterAll(async () => { + cleanupAdbReverse(DAPP_PORT); + await playgroundServer.stop(); + }); + // Test steps (in order): // - // Login and navigate to dapp + // 1. LOGIN AND NAVIGATE TO DAPP + // - Login to app, ensure account groups finished loading + // - Launch mobile browser and navigate to the playground dapp // - await PlaywrightContextHelpers.withNativeAction(async () => { - await loginToAppPlaywright(); - await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - await launchMobileBrowser(); - await navigateToDapp(DAPP_URL); - }); - - await sleep(5000); - + // 2. CONNECT VIA WAGMI + // - Tap Connect (Wagmi) + // - In MetaMask: add Account 3, unselect OP Mainnet from networks, tap Connect + // - Assert: wagmi connected, chainId 1, active account is Account 1 // - // Connect via WAGMI + // 3. SWITCH TO SEPOLIA AND SIGN + // - Switch chain to Sepolia (11155111), assert chainId 11155111 + // - Type 'Hello Sepolia' and tap sign + // - In MetaMask: cancel sign request on Sepolia network // - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapConnectWagmi(); - }, DAPP_URL); - - // Handle connection approval in MetaMask - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await unlockIfLockScreenVisible(); - await DappConnectionModal.tapEditAccountsButton(); - // Select account 3 in addition to Account 1 - await DappConnectionModal.tapAccountButton('Account 3'); - await DappConnectionModal.tapUpdateAccountsButton(); - await DappConnectionModal.tapPermissionsTabButton(); - // Unselect OP Mainnet - await DappConnectionModal.tapEditNetworksButton(); - await DappConnectionModal.tapNetworkButton('OP'); - await DappConnectionModal.tapUpdateNetworksButton(); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, - }); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - + // 4. SWITCH TO OP MAINNET (requires approval) AND CHANGE ACCOUNT + // - Tap switch chain to OP Mainnet (10) + // - In MetaMask: approve chain switch modal showing 'OP' + // - Assert: chainId 10 + // - Type 'Hello OP' and tap sign; cancel in MetaMask + // - In MetaMask: navigate to account list and select Account 3 // - // Verify connection and switch to Sepolia + // 5. VERIFY ACCOUNT CHANGE AND ADD CELO CHAIN + // - Assert: wagmi active account is now Account 3 + // - Tap switch chain to Celo (42220) — triggers add chain flow + // - In MetaMask: assert chain shows '42220' and 'Celo', confirm add chain + // - Assert: chainId 42220 + // - Type 'Hello Celo' and tap sign; cancel in MetaMask // - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiConnected(true); - await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); - await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS); - // Switch to Sepolia - await BrowserPlaygroundDapp.tapWagmiSwitchChain(11155111); - await BrowserPlaygroundDapp.assertWagmiChainIdValue('11155111'); - // Sign a message on Sepolia - await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello Sepolia'); - await PlaywrightGestures.hideKeyboard(); - await BrowserPlaygroundDapp.tapWagmiSignMessage({ - shouldCooldown: true, - timeToCooldown: 2000, + // 6. CLEANUP + // - Tap disconnect to clean up + test('@metamask/connect-evm (wagmi) - Chain switching via Wagmi', async ({ + currentDeviceDetails, + driver, + }) => { + const platform = currentDeviceDetails.platform; + const useBrowserStackLocal = + process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; + const DAPP_URL = useBrowserStackLocal + ? `http://bs-local.com:${DAPP_PORT}` + : getDappUrlForBrowser(platform); + + // + // Login and navigate to dapp + // + await PlaywrightContextHelpers.withNativeAction(async () => { + await loginToAppPlaywright(); + await ensureAccountGroupsFinishedLoading(currentDeviceDetails); + await launchMobileBrowser(); + await navigateToDapp(DAPP_URL); }); - }, DAPP_URL); - - // Cancel sign request - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.assertNetworkText('Sepolia'); - await SignModal.tapCancelButton(); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - // - // Switch to OP Mainnet (requires approval since unselected earlier) - // + await sleep(5000); + + // + // Connect via WAGMI + // + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapConnectWagmi(); + }, DAPP_URL); + + // Handle connection approval in MetaMask + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await unlockIfLockScreenVisible(); + await DappConnectionModal.tapEditAccountsButton(); + // Select account 3 in addition to Account 1 + await DappConnectionModal.tapAccountButton('Account 3'); + await DappConnectionModal.tapUpdateAccountsButton(); + await DappConnectionModal.tapPermissionsTabButton(); + // Unselect OP Mainnet + await DappConnectionModal.tapEditNetworksButton(); + await DappConnectionModal.tapNetworkButton('OP'); + await DappConnectionModal.tapUpdateNetworksButton(); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + }); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapWagmiSwitchChain(10); // OP Mainnet - }, DAPP_URL); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SwitchChainModal.assertNetworkText('OP'); - await SwitchChainModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, + // + // Verify connection and switch to Sepolia + // + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiConnected(true); + await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); + await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS); + // Switch to Sepolia + await BrowserPlaygroundDapp.tapWagmiSwitchChain(11155111); + await BrowserPlaygroundDapp.assertWagmiChainIdValue('11155111'); + // Sign a message on Sepolia + await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello Sepolia'); + await PlaywrightGestures.hideKeyboard(); + await BrowserPlaygroundDapp.tapWagmiSignMessage({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + }, DAPP_URL); + + // Cancel sign request + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.assertNetworkText('Sepolia'); + await SignModal.tapCancelButton(); }); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiChainIdValue('10'); - await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello OP'); - await PlaywrightGestures.hideKeyboard(); - await BrowserPlaygroundDapp.tapWagmiSignMessage(); - }, DAPP_URL); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.assertNetworkText('OP'); - await SignModal.tapCancelButton(); + // + // Switch to OP Mainnet (requires approval since unselected earlier) + // + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapWagmiSwitchChain(10); // OP Mainnet + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SwitchChainModal.assertNetworkText('OP'); + await SwitchChainModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + }); - // Wait here to make sure UI is visible before attempted interaction + await sleep(1000); + await switchToMobileBrowser(); await sleep(1000); - // Change selected account to Account 3 in MetaMask - await WalletView.tapIdenticon(); - await AccountListBottomSheet.tapAccountByName('Account 3'); - // Forcefully waiting for accounts to be synced - await sleep(2500); - }); - - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiChainIdValue('10'); + await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello OP'); + await PlaywrightGestures.hideKeyboard(); + await BrowserPlaygroundDapp.tapWagmiSignMessage(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.assertNetworkText('OP'); + await SignModal.tapCancelButton(); + + // Wait here to make sure UI is visible before attempted interaction + await sleep(1000); + + // Change selected account to Account 3 in MetaMask + await WalletView.tapIdenticon(); + await AccountListBottomSheet.tapAccountByName('Account 3'); + // Forcefully waiting for accounts to be synced + await sleep(2500); + }); - // - // Verify account change and add CELO chain - // + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_3_ADDRESS); - // Try to switch to Celo (will trigger add chain) - await BrowserPlaygroundDapp.tapWagmiSwitchChain(42220); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await AddChainModal.assertText('42220'); - await AddChainModal.assertText('Celo'); - await AddChainModal.tapConfirmButton({ - shouldCooldown: true, - timeToCooldown: 2000, + // + // Verify account change and add CELO chain + // + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_3_ADDRESS); + // Try to switch to Celo (will trigger add chain) + await BrowserPlaygroundDapp.tapWagmiSwitchChain(42220); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await AddChainModal.assertText('42220'); + await AddChainModal.assertText('Celo'); + await AddChainModal.tapConfirmButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiChainIdValue('42220'); - await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello Celo'); - await PlaywrightGestures.hideKeyboard(); - await BrowserPlaygroundDapp.tapWagmiSignMessage({ - shouldCooldown: true, - timeToCooldown: 2000, - }); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.assertNetworkText('Celo'); - await SignModal.tapCancelButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiChainIdValue('42220'); + await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello Celo'); + await PlaywrightGestures.hideKeyboard(); + await BrowserPlaygroundDapp.tapWagmiSignMessage({ + shouldCooldown: true, + timeToCooldown: 2000, + }); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.assertNetworkText('Celo'); + await SignModal.tapCancelButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - // - // Reset dapp state - // + // + // Reset dapp state + // - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - }, DAPP_URL); -}); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + }, DAPP_URL); + }); +}); // end describe diff --git a/tests/performance/mm-connect/connection-wagmi.spec.ts b/tests/performance/mm-connect/connection-wagmi.spec.ts index 3f3d410e455..0d32aeea7b3 100644 --- a/tests/performance/mm-connect/connection-wagmi.spec.ts +++ b/tests/performance/mm-connect/connection-wagmi.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import BrowserPlaygroundDapp from '../../page-objects/MMConnect/BrowserPlaygroundDapp'; @@ -41,235 +42,237 @@ const playgroundServer = new DappServer({ dappVariant: DappVariants.BROWSER_PLAYGROUND, }); -// Start local playground server before all tests -test.beforeAll(async () => { - playgroundServer.setServerPort(DAPP_PORT); - await playgroundServer.start(); - await waitForDappServerReady(DAPP_PORT); - setupAdbReverse(DAPP_PORT); -}); - -// Stop local playground server after all tests -test.afterAll(async () => { - cleanupAdbReverse(DAPP_PORT); - await playgroundServer.stop(); -}); +test.describe(Performance, () => { + // Start local playground server before all tests + test.beforeAll(async () => { + playgroundServer.setServerPort(DAPP_PORT); + await playgroundServer.start(); + await waitForDappServerReady(DAPP_PORT); + setupAdbReverse(DAPP_PORT); + }); -// Test steps (in order): -// -// 1. LOGIN AND NAVIGATE TO DAPP -// - Login to app, ensure account groups finished loading -// - Launch mobile browser and navigate to the playground dapp -// -// 2. CONNECT VIA WAGMI -// - Tap Connect (Wagmi) -// - In MetaMask: approve connection -// - Assert: wagmi connected, chainId 1, active account is Account 1 -// -// 3. SIGN MESSAGE ON ETHEREUM -// - Type 'Hello MetaMask' and tap sign -// - In MetaMask: confirm sign request on Ethereum network -// - Assert: signature result starts with '0x' -// -// 4. REFRESH BROWSER RECONNECT -// - Refresh mobile browser -// - Assert: wagmi still connected, chainId 1, active account is Account 1 -// - Type 'After refresh' and tap sign; cancel in MetaMask -// -// 5. DISCONNECT AND RECONNECT -// - Tap disconnect, assert wagmi disconnected -// - Tap Connect (Wagmi), approve in MetaMask -// - Assert: wagmi connected, chainId 1, active account is Account 1 -// -// 6. INCOMPLETE SESSION TIMEOUT -// - Disconnect, then tap Connect (Wagmi) -// - In MetaMask: open approval but do NOT interact -// - Refresh mobile browser, wait 10s for session to time out -// - Assert: wagmi disconnected -// - Tap Connect (Wagmi) again, approve in MetaMask -// - Assert: wagmi connected, chainId 1, active account is Account 1 -// -// 7. RESET DAPP STATE -// - Tap disconnect to clean up - -// This test is currently failing. See 250. -test.skip('@metamask/connect-evm (wagmi) - Session stability via Wagmi', async ({ - currentDeviceDetails, - driver, -}) => { - const platform = currentDeviceDetails.platform; - const useBrowserStackLocal = - process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; - const DAPP_URL = useBrowserStackLocal - ? `http://bs-local.com:${DAPP_PORT}` - : getDappUrlForBrowser(platform); + // Stop local playground server after all tests + test.afterAll(async () => { + cleanupAdbReverse(DAPP_PORT); + await playgroundServer.stop(); + }); + // Test steps (in order): // - // Login and navigate to dapp + // 1. LOGIN AND NAVIGATE TO DAPP + // - Login to app, ensure account groups finished loading + // - Launch mobile browser and navigate to the playground dapp // - await PlaywrightContextHelpers.withNativeAction(async () => { - await loginToAppPlaywright(); - await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - await launchMobileBrowser(); - await navigateToDapp(DAPP_URL); - }); - - await sleep(5000); - + // 2. CONNECT VIA WAGMI + // - Tap Connect (Wagmi) + // - In MetaMask: approve connection + // - Assert: wagmi connected, chainId 1, active account is Account 1 + // + // 3. SIGN MESSAGE ON ETHEREUM + // - Type 'Hello MetaMask' and tap sign + // - In MetaMask: confirm sign request on Ethereum network + // - Assert: signature result starts with '0x' + // + // 4. REFRESH BROWSER RECONNECT + // - Refresh mobile browser + // - Assert: wagmi still connected, chainId 1, active account is Account 1 + // - Type 'After refresh' and tap sign; cancel in MetaMask // - // Connect via WAGMI + // 5. DISCONNECT AND RECONNECT + // - Tap disconnect, assert wagmi disconnected + // - Tap Connect (Wagmi), approve in MetaMask + // - Assert: wagmi connected, chainId 1, active account is Account 1 // + // 6. INCOMPLETE SESSION TIMEOUT + // - Disconnect, then tap Connect (Wagmi) + // - In MetaMask: open approval but do NOT interact + // - Refresh mobile browser, wait 10s for session to time out + // - Assert: wagmi disconnected + // - Tap Connect (Wagmi) again, approve in MetaMask + // - Assert: wagmi connected, chainId 1, active account is Account 1 + // + // 7. RESET DAPP STATE + // - Tap disconnect to clean up + + // This test is currently failing. See 250. + test.skip('@metamask/connect-evm (wagmi) - Session stability via Wagmi', async ({ + currentDeviceDetails, + driver, + }) => { + const platform = currentDeviceDetails.platform; + const useBrowserStackLocal = + process.env.BROWSERSTACK_LOCAL?.toLowerCase() === 'true'; + const DAPP_URL = useBrowserStackLocal + ? `http://bs-local.com:${DAPP_PORT}` + : getDappUrlForBrowser(platform); + + // + // Login and navigate to dapp + // + await PlaywrightContextHelpers.withNativeAction(async () => { + await loginToAppPlaywright(); + await ensureAccountGroupsFinishedLoading(currentDeviceDetails); + await launchMobileBrowser(); + await navigateToDapp(DAPP_URL); + }); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapConnectWagmi(); - }, DAPP_URL); + await sleep(5000); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await unlockIfLockScreenVisible(); - await DappConnectionModal.tapConnectButton(); - }); + // + // Connect via WAGMI + // - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapConnectWagmi(); + }, DAPP_URL); - // - // Verify connection and sign message on Ethereum - // + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await unlockIfLockScreenVisible(); + await DappConnectionModal.tapConnectButton(); + }); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiConnected(true); - await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); - await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS); - await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask'); - await PlaywrightGestures.hideKeyboard(); - await BrowserPlaygroundDapp.tapWagmiSignMessage(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.assertNetworkText('Ethereum'); - await SignModal.tapConfirmButton(); - }); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); + + // + // Verify connection and sign message on Ethereum + // + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiConnected(true); + await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); + await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS); + await BrowserPlaygroundDapp.typeWagmiSignMessage('Hello MetaMask'); + await PlaywrightGestures.hideKeyboard(); + await BrowserPlaygroundDapp.tapWagmiSignMessage(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.assertNetworkText('Ethereum'); + await SignModal.tapConfirmButton(); + }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x'); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiSignatureResult('0x'); + }, DAPP_URL); - // - // Resume from refresh - // + // + // Resume from refresh + // - await PlaywrightContextHelpers.withNativeAction(async () => { - await refreshMobileBrowser(); - }); - await sleep(2000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiConnected(true); - // Note: Chain may reset to 1 after refresh - await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); - await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS); - await BrowserPlaygroundDapp.typeWagmiSignMessage('After refresh'); - await PlaywrightGestures.hideKeyboard(); - await BrowserPlaygroundDapp.tapWagmiSignMessage(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await SignModal.tapCancelButton(); - }); + await PlaywrightContextHelpers.withNativeAction(async () => { + await refreshMobileBrowser(); + }); + await sleep(2000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiConnected(true); + // Note: Chain may reset to 1 after refresh + await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); + await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS); + await BrowserPlaygroundDapp.typeWagmiSignMessage('After refresh'); + await PlaywrightGestures.hideKeyboard(); + await BrowserPlaygroundDapp.tapWagmiSignMessage(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await SignModal.tapCancelButton(); + }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - // - // Terminate and connect - // + // + // Terminate and connect + // - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - await BrowserPlaygroundDapp.assertWagmiConnected(false); - await BrowserPlaygroundDapp.tapConnectWagmi(); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + await BrowserPlaygroundDapp.assertWagmiConnected(false); + await BrowserPlaygroundDapp.tapConnectWagmi(); + }, DAPP_URL); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - await DappConnectionModal.tapConnectButton(); - }); + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + await DappConnectionModal.tapConnectButton(); + }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiConnected(true); - await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); - await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiConnected(true); + await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); + await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS); + }, DAPP_URL); - // - // Wait for incomplete session timeout on refresh and reconnect after - // + // + // Wait for incomplete session timeout on refresh and reconnect after + // - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - await BrowserPlaygroundDapp.tapConnectWagmi(); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + await BrowserPlaygroundDapp.tapConnectWagmi(); + }, DAPP_URL); - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - // Purposely not interacting with the approval - }); + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + // Purposely not interacting with the approval + }); - await sleep(2000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(2000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withNativeAction(async () => { - await refreshMobileBrowser(); - }); - await sleep(2000); - - // After timeout, should be disconnected - await sleep(10000); - - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiConnected(false); - await BrowserPlaygroundDapp.tapConnectWagmi(); - }, DAPP_URL); - - await PlaywrightContextHelpers.withNativeAction(async () => { - await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); - // TODO: We're having a double connect prompt. After approving the - // connection, a second prompt with empty accounts is shown. - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 2000, + await PlaywrightContextHelpers.withNativeAction(async () => { + await refreshMobileBrowser(); + }); + await sleep(2000); + + // After timeout, should be disconnected + await sleep(10000); + + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiConnected(false); + await BrowserPlaygroundDapp.tapConnectWagmi(); + }, DAPP_URL); + + await PlaywrightContextHelpers.withNativeAction(async () => { + await AndroidScreenHelpers.tapOpenDeeplinkWithMetaMask(); + // TODO: We're having a double connect prompt. After approving the + // connection, a second prompt with empty accounts is shown. + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 2000, + }); }); - }); - await sleep(1000); - await switchToMobileBrowser(); - await sleep(1000); + await sleep(1000); + await switchToMobileBrowser(); + await sleep(1000); - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.assertWagmiConnected(true); - await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); - await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS); - }, DAPP_URL); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.assertWagmiConnected(true); + await BrowserPlaygroundDapp.assertWagmiChainIdValue('1'); + await BrowserPlaygroundDapp.assertWagmiActiveAccount(ACCOUNT_1_ADDRESS); + }, DAPP_URL); - // - // Reset dapp state - // + // + // Reset dapp state + // - await PlaywrightContextHelpers.withWebAction(async () => { - await BrowserPlaygroundDapp.tapDisconnect(); - }, DAPP_URL); -}); + await PlaywrightContextHelpers.withWebAction(async () => { + await BrowserPlaygroundDapp.tapDisconnect(); + }, DAPP_URL); + }); +}); // end describe diff --git a/tests/performance/mm-connect/legacy-evm-rn-connect.spec.ts b/tests/performance/mm-connect/legacy-evm-rn-connect.spec.ts index 55c04a0bd22..2561487efb2 100644 --- a/tests/performance/mm-connect/legacy-evm-rn-connect.spec.ts +++ b/tests/performance/mm-connect/legacy-evm-rn-connect.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import RNPlaygroundDapp from '../../page-objects/MMConnect/RNPlaygroundDapp'; @@ -17,160 +18,162 @@ async function returnToPlayground() { await RNPlaygroundDapp.ensureInPlayground(); } -test('@metamask/connect-legacy-evm-rn - Connect via Legacy EVM, sign, send transaction, and switch chains', async ({ - currentDeviceDetails, - driver, -}) => { - // When running on BrowserStack we skip the test if the RN playground is not installed - test.skip( - currentDeviceDetails.isBrowserstack && - !process.env.BROWSERSTACK_RN_PLAYGROUND_URL, - 'Skipped: BROWSERSTACK_RN_PLAYGROUND_URL is not set', - ); - - // handle local installs of the RN playground - if (!currentDeviceDetails.isBrowserstack) { - ensurePlaygroundInstalled(currentDeviceDetails); - } - - // - // 1. Login to MetaMask wallet - // - await loginToAppPlaywright(); - - // - // 2. Switch to the RN playground and connect via Legacy EVM - // - await RNPlaygroundDapp.switchToPlayground(); - await RNPlaygroundDapp.waitForPlaygroundReady(); - - await RNPlaygroundDapp.tapConnectLegacy(); - await sleep(3000); - - await unlockIfLockScreenVisible(); - await sleep(5000); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 3000, - }); - - // - // 3. Verify accountsChanged — Legacy EVM card visible with accounts - // - - await returnToPlayground(); - await sleep(2000); - - await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.appTitle, { - scrollParams: { direction: 'down' }, - }); - await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.legacyEvmCard); - await RNPlaygroundDapp.assertLegacyEvmConnected(); - await RNPlaygroundDapp.assertLegacyEvmHasAccounts(); - await RNPlaygroundDapp.assertLegacyEvmActiveAccount(); - - const initialChainId = await RNPlaygroundDapp.getLegacyEvmChainId(); - console.log(`Initial chain ID: ${initialChainId}`); - - // - // 4. personal_sign — request, approve, verify result - // - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.legacyEvmBtnPersonalSign, - ); - await RNPlaygroundDapp.tapLegacyEvmButton( - RNPlaygroundDapp.legacyEvmBtnPersonalSign, - ); - await sleep(3000); - - await unlockIfLockScreenVisible(); - await sleep(1000); - await SignModal.tapConfirmButton({ - shouldCooldown: true, - timeToCooldown: 3000, - }); - - await returnToPlayground(); - await sleep(1000); - - // Verify signature was returned (hex string starting with 0x) - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.legacyEvmResponseText, - ); - const signResponse = await RNPlaygroundDapp.getLegacyEvmResponseText(); - console.log(`personal_sign response: ${signResponse}`); - console.log(`personal_sign contains 0x: ${signResponse.includes('0x')}`); - - // - // 5. eth_sendTransaction — request, cancel (to avoid spending funds) - // - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.legacyEvmBtnSendTransaction, - ); - await RNPlaygroundDapp.tapLegacyEvmButton( - RNPlaygroundDapp.legacyEvmBtnSendTransaction, - ); - await sleep(3000); - - await unlockIfLockScreenVisible(); - await sleep(1000); - - // Cancel the transaction to avoid spending real funds - await SignModal.tapCancelButton({ - shouldCooldown: true, - timeToCooldown: 3000, - }); - - await returnToPlayground(); - await sleep(1000); - - // The dapp should show an error (user rejected) in the response - - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.legacyEvmResponseText, - { +test.describe(Performance, () => { + test('@metamask/connect-legacy-evm-rn - Connect via Legacy EVM, sign, send transaction, and switch chains', async ({ + currentDeviceDetails, + driver, + }) => { + // When running on BrowserStack we skip the test if the RN playground is not installed + test.skip( + currentDeviceDetails.isBrowserstack && + !process.env.BROWSERSTACK_RN_PLAYGROUND_URL, + 'Skipped: BROWSERSTACK_RN_PLAYGROUND_URL is not set', + ); + + // handle local installs of the RN playground + if (!currentDeviceDetails.isBrowserstack) { + ensurePlaygroundInstalled(currentDeviceDetails); + } + + // + // 1. Login to MetaMask wallet + // + await loginToAppPlaywright(); + + // + // 2. Switch to the RN playground and connect via Legacy EVM + // + await RNPlaygroundDapp.switchToPlayground(); + await RNPlaygroundDapp.waitForPlaygroundReady(); + + await RNPlaygroundDapp.tapConnectLegacy(); + await sleep(3000); + + await unlockIfLockScreenVisible(); + await sleep(5000); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 3000, + }); + + // + // 3. Verify accountsChanged — Legacy EVM card visible with accounts + // + + await returnToPlayground(); + await sleep(2000); + + await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.appTitle, { scrollParams: { direction: 'down' }, - percent: 0.5, - }, - ); - - const txResponse = await RNPlaygroundDapp.getLegacyEvmResponseText(); - console.log(`eth_sendTransaction (cancelled) response: ${txResponse}`); - console.log( - `eth_sendTransaction contains denied: ${txResponse.toLowerCase().includes('denied')}`, - ); - - // - // 6. Chain switching from the dapp — wallet_switchEthereumChain - // Switch to Polygon from the dapp, verify the chain ID updates. - // - - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.legacyEvmBtnSwitchPolygon, - ); - await RNPlaygroundDapp.tapLegacyEvmButton( - RNPlaygroundDapp.legacyEvmBtnSwitchPolygon, - ); - await sleep(3000); - - // The switch opens MetaMask with a network approval dialog. - // The SwitchChainApproval dialog uses "connect-button" as its confirm testID. - await unlockIfLockScreenVisible(); - await sleep(1000); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 3000, + }); + await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.legacyEvmCard); + await RNPlaygroundDapp.assertLegacyEvmConnected(); + await RNPlaygroundDapp.assertLegacyEvmHasAccounts(); + await RNPlaygroundDapp.assertLegacyEvmActiveAccount(); + + const initialChainId = await RNPlaygroundDapp.getLegacyEvmChainId(); + console.log(`Initial chain ID: ${initialChainId}`); + + // + // 4. personal_sign — request, approve, verify result + // + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.legacyEvmBtnPersonalSign, + ); + await RNPlaygroundDapp.tapLegacyEvmButton( + RNPlaygroundDapp.legacyEvmBtnPersonalSign, + ); + await sleep(3000); + + await unlockIfLockScreenVisible(); + await sleep(1000); + await SignModal.tapConfirmButton({ + shouldCooldown: true, + timeToCooldown: 3000, + }); + + await returnToPlayground(); + await sleep(1000); + + // Verify signature was returned (hex string starting with 0x) + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.legacyEvmResponseText, + ); + const signResponse = await RNPlaygroundDapp.getLegacyEvmResponseText(); + console.log(`personal_sign response: ${signResponse}`); + console.log(`personal_sign contains 0x: ${signResponse.includes('0x')}`); + + // + // 5. eth_sendTransaction — request, cancel (to avoid spending funds) + // + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.legacyEvmBtnSendTransaction, + ); + await RNPlaygroundDapp.tapLegacyEvmButton( + RNPlaygroundDapp.legacyEvmBtnSendTransaction, + ); + await sleep(3000); + + await unlockIfLockScreenVisible(); + await sleep(1000); + + // Cancel the transaction to avoid spending real funds + await SignModal.tapCancelButton({ + shouldCooldown: true, + timeToCooldown: 3000, + }); + + await returnToPlayground(); + await sleep(1000); + + // The dapp should show an error (user rejected) in the response + + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.legacyEvmResponseText, + { + scrollParams: { direction: 'down' }, + percent: 0.5, + }, + ); + + const txResponse = await RNPlaygroundDapp.getLegacyEvmResponseText(); + console.log(`eth_sendTransaction (cancelled) response: ${txResponse}`); + console.log( + `eth_sendTransaction contains denied: ${txResponse.toLowerCase().includes('denied')}`, + ); + + // + // 6. Chain switching from the dapp — wallet_switchEthereumChain + // Switch to Polygon from the dapp, verify the chain ID updates. + // + + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.legacyEvmBtnSwitchPolygon, + ); + await RNPlaygroundDapp.tapLegacyEvmButton( + RNPlaygroundDapp.legacyEvmBtnSwitchPolygon, + ); + await sleep(3000); + + // The switch opens MetaMask with a network approval dialog. + // The SwitchChainApproval dialog uses "connect-button" as its confirm testID. + await unlockIfLockScreenVisible(); + await sleep(1000); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 3000, + }); + + await returnToPlayground(); + await sleep(2000); + + // Verify chain ID updated to Polygon (0x89) + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.legacyEvmChainIdValue, + { scrollParams: { direction: 'down' } }, + ); + const polygonChainId = await RNPlaygroundDapp.getLegacyEvmChainId(); + console.log(`Chain ID after dapp switch to Polygon: ${polygonChainId}`); + console.log(`Chain ID contains 0x89: ${polygonChainId.includes('0x89')}`); }); - - await returnToPlayground(); - await sleep(2000); - - // Verify chain ID updated to Polygon (0x89) - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.legacyEvmChainIdValue, - { scrollParams: { direction: 'down' } }, - ); - const polygonChainId = await RNPlaygroundDapp.getLegacyEvmChainId(); - console.log(`Chain ID after dapp switch to Polygon: ${polygonChainId}`); - console.log(`Chain ID contains 0x89: ${polygonChainId.includes('0x89')}`); -}); +}); // end describe diff --git a/tests/performance/mm-connect/multichain-rn-evm.spec.ts b/tests/performance/mm-connect/multichain-rn-evm.spec.ts index 6576f35939f..250be3e0f8d 100644 --- a/tests/performance/mm-connect/multichain-rn-evm.spec.ts +++ b/tests/performance/mm-connect/multichain-rn-evm.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import RNPlaygroundDapp from '../../page-objects/MMConnect/RNPlaygroundDapp'; @@ -77,145 +78,153 @@ async function returnToPlayground() { // - Assert session is disconnected // - Switch to MetaMask and unlock if needed to confirm no active session -test('@metamask/connect-multichain-rn-evm - Connect across 3 EVM chains, invoke read/write methods, and disconnect', async ({ - currentDeviceDetails, - driver, -}) => { - // When running on BrowserStack we skip the test if the RN playground is not installed - test.skip( - currentDeviceDetails.isBrowserstack && - !process.env.BROWSERSTACK_RN_PLAYGROUND_URL, - 'Skipped: BROWSERSTACK_RN_PLAYGROUND_URL is not set', - ); - - // handle local installs of the RN playground - if (!currentDeviceDetails.isBrowserstack) { - ensurePlaygroundInstalled(currentDeviceDetails); - } - - // - // 1. Login to MetaMask wallet - // - await loginToAppPlaywright(); - await PlaywrightAssertions.expectElementToBeVisible( - await asPlaywrightElement(WalletView.container), - { timeout: 15000 }, - ); - - await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - - // - // 2. Switch to the RN playground and select networks - // - await RNPlaygroundDapp.switchToPlayground(); - await RNPlaygroundDapp.waitForPlaygroundReady(); - - // Ethereum (eip155:1) is selected by default; add two more EVM networks - await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.LINEA); - await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.POLYGON); - - // - // 3. Connect via Multichain API - // - await RNPlaygroundDapp.tapConnect(); - await sleep(3000); - - await unlockIfLockScreenVisible(); - await sleep(5000); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 3000, - }); - - // - // 4. Return to playground, verify EVM connections, and invoke read requests - // - await returnToPlayground(); - await RNPlaygroundDapp.assertConnected(); - - for (const chain of EVM_CHAINS) { - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.getInvokeButton(chain), - { - percent: 0.5, - }, +test.describe(Performance, () => { + test('@metamask/connect-multichain-rn-evm - Connect across 3 EVM chains, invoke read/write methods, and disconnect', async ({ + currentDeviceDetails, + driver, + }) => { + // When running on BrowserStack we skip the test if the RN playground is not installed + test.skip( + currentDeviceDetails.isBrowserstack && + !process.env.BROWSERSTACK_RN_PLAYGROUND_URL, + 'Skipped: BROWSERSTACK_RN_PLAYGROUND_URL is not set', ); - await RNPlaygroundDapp.tapInvoke(chain); - await sleep(5000); - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.getResultCode(chain, 'eth_blockNumber'), - { - percent: 0.5, - }, - ); - await RNPlaygroundDapp.assertResultCodeContains( - chain, - 'eth_blockNumber', - '0x', + // handle local installs of the RN playground + if (!currentDeviceDetails.isBrowserstack) { + ensurePlaygroundInstalled(currentDeviceDetails); + } + + // + // 1. Login to MetaMask wallet + // + await loginToAppPlaywright(); + await PlaywrightAssertions.expectElementToBeVisible( + await asPlaywrightElement(WalletView.container), + { timeout: 15000 }, ); - // Test the write request - await RNPlaygroundDapp.selectMethod(chain, 'personal_sign', 10, 2, 'down'); + await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.getInvokeButton(chain), - { - percent: 0.5, - }, - ); - await RNPlaygroundDapp.tapInvoke(chain); + // + // 2. Switch to the RN playground and select networks + // + await RNPlaygroundDapp.switchToPlayground(); + await RNPlaygroundDapp.waitForPlaygroundReady(); + + // Ethereum (eip155:1) is selected by default; add two more EVM networks + await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.LINEA); + await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.POLYGON); + + // + // 3. Connect via Multichain API + // + await RNPlaygroundDapp.tapConnect(); await sleep(3000); - // Handle MetaMask sign approval await unlockIfLockScreenVisible(); - await sleep(1000); - - // Verify request was routed to the correct network - const networkName = NETWORK_DISPLAY_NAMES[chain]; - if (networkName) { - try { - await SignModal.assertNetworkText(networkName); - } catch { - // Network label may not appear for all signing modals; continue - } - } - - await SignModal.tapConfirmButton({ + await sleep(5000); + await DappConnectionModal.tapConnectButton({ shouldCooldown: true, timeToCooldown: 3000, }); + + // + // 4. Return to playground, verify EVM connections, and invoke read requests + // await returnToPlayground(); + await RNPlaygroundDapp.assertConnected(); + + for (const chain of EVM_CHAINS) { + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.getInvokeButton(chain), + { + percent: 0.5, + }, + ); + await RNPlaygroundDapp.tapInvoke(chain); + await sleep(5000); + + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.getResultCode(chain, 'eth_blockNumber'), + { + percent: 0.5, + }, + ); + await RNPlaygroundDapp.assertResultCodeContains( + chain, + 'eth_blockNumber', + '0x', + ); + + // Test the write request + await RNPlaygroundDapp.selectMethod( + chain, + 'personal_sign', + 10, + 2, + 'down', + ); + + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.getInvokeButton(chain), + { + percent: 0.5, + }, + ); + await RNPlaygroundDapp.tapInvoke(chain); + await sleep(3000); + + // Handle MetaMask sign approval + await unlockIfLockScreenVisible(); + await sleep(1000); + + // Verify request was routed to the correct network + const networkName = NETWORK_DISPLAY_NAMES[chain]; + if (networkName) { + try { + await SignModal.assertNetworkText(networkName); + } catch { + // Network label may not appear for all signing modals; continue + } + } - // Verify a signature was returned (hex string starting with 0x) - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.getResultCode(chain, 'personal_sign'), - { - percent: 0.5, - scrollParams: { direction: 'up' }, - }, - ); - await RNPlaygroundDapp.assertResultCodeContains( - chain, - 'personal_sign', - '0x', - ); - } - - // Eager swipe up as the disconnect button sits at the top of the Dapp - await PlaywrightGestures.swipe({ - scrollParams: { direction: 'down' }, - duration: 100, - from: { x: 100, y: 300 }, - to: { x: 100, y: 1700 }, - percent: 0.5, - }); + await SignModal.tapConfirmButton({ + shouldCooldown: true, + timeToCooldown: 3000, + }); + await returnToPlayground(); + + // Verify a signature was returned (hex string starting with 0x) + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.getResultCode(chain, 'personal_sign'), + { + percent: 0.5, + scrollParams: { direction: 'up' }, + }, + ); + await RNPlaygroundDapp.assertResultCodeContains( + chain, + 'personal_sign', + '0x', + ); + } - // Scroll back to the top where the disconnect button lives - await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.disconnectButton, { - scrollParams: { direction: 'down' }, - percent: 0.5, + // Eager swipe up as the disconnect button sits at the top of the Dapp + await PlaywrightGestures.swipe({ + scrollParams: { direction: 'down' }, + duration: 100, + from: { x: 100, y: 300 }, + to: { x: 100, y: 1700 }, + percent: 0.5, + }); + + // Scroll back to the top where the disconnect button lives + await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.disconnectButton, { + scrollParams: { direction: 'down' }, + percent: 0.5, + }); + await RNPlaygroundDapp.tapDisconnect(); + await RNPlaygroundDapp.assertDisconnected(); }); - await RNPlaygroundDapp.tapDisconnect(); - await RNPlaygroundDapp.assertDisconnected(); -}); +}); // end describe diff --git a/tests/performance/mm-connect/multichain-rn-solana.spec.ts b/tests/performance/mm-connect/multichain-rn-solana.spec.ts index 7b7f48be8a2..3db661cff79 100644 --- a/tests/performance/mm-connect/multichain-rn-solana.spec.ts +++ b/tests/performance/mm-connect/multichain-rn-solana.spec.ts @@ -1,4 +1,5 @@ import { test } from '../../framework/fixture'; +import { Performance } from '../../tags.performance.js'; import { loginToAppPlaywright } from '../../flows/wallet.flow'; import RNPlaygroundDapp from '../../page-objects/MMConnect/RNPlaygroundDapp'; @@ -65,122 +66,124 @@ async function returnToPlayground() { // - Assert session is disconnected // - Switch to MetaMask and unlock if needed to confirm no active session -test('@metamask/connect-multichain-rn-solana - Connect with Solana, invoke signMessage, and disconnect', async ({ - currentDeviceDetails, - driver, -}) => { - // When running on BrowserStack we skip the test if the RN playground is not installed - test.skip( - currentDeviceDetails.isBrowserstack && - !process.env.BROWSERSTACK_RN_PLAYGROUND_URL, - 'Skipped: BROWSERSTACK_RN_PLAYGROUND_URL is not set', - ); - - // handle local installs of the RN playground - if (!currentDeviceDetails.isBrowserstack) { - ensurePlaygroundInstalled(currentDeviceDetails); - } - - // - // 1. Login to MetaMask wallet - // - await loginToAppPlaywright(); - await PlaywrightAssertions.expectElementToBeVisible( - await asPlaywrightElement(WalletView.container), - { timeout: 15000 }, - ); - - await ensureAccountGroupsFinishedLoading(currentDeviceDetails); - - // - // 2. Switch to the RN playground and select networks - // - await RNPlaygroundDapp.switchToPlayground(); - await RNPlaygroundDapp.waitForPlaygroundReady(); - - // Ethereum (eip155:1) is selected by default; add three more networks - await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.LINEA); - await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.POLYGON); - await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.SOLANA); - - // - // 3. Connect via Multichain API - // - await RNPlaygroundDapp.tapConnect(); - await sleep(3000); - - await unlockIfLockScreenVisible(); - await sleep(5000); - await DappConnectionModal.tapConnectButton({ - shouldCooldown: true, - timeToCooldown: 3000, - }); - - // - // 4. Return to playground and verify Solana connection is active - // - await returnToPlayground(); - - await RNPlaygroundDapp.assertConnected(); - - await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.appTitle, { - scrollParams: { direction: 'down' }, - percent: 0.5, - }); - - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.getScopeCard(CHAINS.SOLANA), - { +test.describe(Performance, () => { + test('@metamask/connect-multichain-rn-solana - Connect with Solana, invoke signMessage, and disconnect', async ({ + currentDeviceDetails, + driver, + }) => { + // When running on BrowserStack we skip the test if the RN playground is not installed + test.skip( + currentDeviceDetails.isBrowserstack && + !process.env.BROWSERSTACK_RN_PLAYGROUND_URL, + 'Skipped: BROWSERSTACK_RN_PLAYGROUND_URL is not set', + ); + + // handle local installs of the RN playground + if (!currentDeviceDetails.isBrowserstack) { + ensurePlaygroundInstalled(currentDeviceDetails); + } + + // + // 1. Login to MetaMask wallet + // + await loginToAppPlaywright(); + await PlaywrightAssertions.expectElementToBeVisible( + await asPlaywrightElement(WalletView.container), + { timeout: 15000 }, + ); + + await ensureAccountGroupsFinishedLoading(currentDeviceDetails); + + // + // 2. Switch to the RN playground and select networks + // + await RNPlaygroundDapp.switchToPlayground(); + await RNPlaygroundDapp.waitForPlaygroundReady(); + + // Ethereum (eip155:1) is selected by default; add three more networks + await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.LINEA); + await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.POLYGON); + await RNPlaygroundDapp.tapNetworkCheckbox(CHAINS.SOLANA); + + // + // 3. Connect via Multichain API + // + await RNPlaygroundDapp.tapConnect(); + await sleep(3000); + + await unlockIfLockScreenVisible(); + await sleep(5000); + await DappConnectionModal.tapConnectButton({ + shouldCooldown: true, + timeToCooldown: 3000, + }); + + // + // 4. Return to playground and verify Solana connection is active + // + await returnToPlayground(); + + await RNPlaygroundDapp.assertConnected(); + + await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.appTitle, { + scrollParams: { direction: 'down' }, percent: 0.5, - }, - ); - await RNPlaygroundDapp.assertScopeCardVisible(CHAINS.SOLANA); - - // - // 5. Solana write request — signMessage - // - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.getMethodSelect(CHAINS.SOLANA), - { - percent: 0.5, - }, - ); - await RNPlaygroundDapp.selectMethod(CHAINS.SOLANA, 'signMessage'); - - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.getInvokeButton(CHAINS.SOLANA), - { - percent: 0.5, - }, - ); - await RNPlaygroundDapp.tapInvoke(CHAINS.SOLANA); - await sleep(3000); - - await unlockIfLockScreenVisible(); - await sleep(1000); - await SnapSignModal.tapConfirmButton({ - shouldCooldown: true, - timeToCooldown: 3000, - }); - await returnToPlayground(); - - await RNPlaygroundDapp.scrollToElement( - RNPlaygroundDapp.getResultCode(CHAINS.SOLANA, 'signMessage'), - { + }); + + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.getScopeCard(CHAINS.SOLANA), + { + percent: 0.5, + }, + ); + await RNPlaygroundDapp.assertScopeCardVisible(CHAINS.SOLANA); + + // + // 5. Solana write request — signMessage + // + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.getMethodSelect(CHAINS.SOLANA), + { + percent: 0.5, + }, + ); + await RNPlaygroundDapp.selectMethod(CHAINS.SOLANA, 'signMessage'); + + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.getInvokeButton(CHAINS.SOLANA), + { + percent: 0.5, + }, + ); + await RNPlaygroundDapp.tapInvoke(CHAINS.SOLANA); + await sleep(3000); + + await unlockIfLockScreenVisible(); + await sleep(1000); + await SnapSignModal.tapConfirmButton({ + shouldCooldown: true, + timeToCooldown: 3000, + }); + await returnToPlayground(); + + await RNPlaygroundDapp.scrollToElement( + RNPlaygroundDapp.getResultCode(CHAINS.SOLANA, 'signMessage'), + { + percent: 0.5, + }, + ); + await RNPlaygroundDapp.waitForResult(CHAINS.SOLANA, 'signMessage'); + + // + // 6. Disconnect (wallet_revokeSession) and verify session termination + // + + // Scroll back to the top where the disconnect button lives + await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.disconnectButton, { + scrollParams: { direction: 'down' }, percent: 0.5, - }, - ); - await RNPlaygroundDapp.waitForResult(CHAINS.SOLANA, 'signMessage'); - - // - // 6. Disconnect (wallet_revokeSession) and verify session termination - // - - // Scroll back to the top where the disconnect button lives - await RNPlaygroundDapp.scrollToElement(RNPlaygroundDapp.disconnectButton, { - scrollParams: { direction: 'down' }, - percent: 0.5, + }); + await RNPlaygroundDapp.tapDisconnect(); + await RNPlaygroundDapp.assertDisconnected(); }); - await RNPlaygroundDapp.tapDisconnect(); - await RNPlaygroundDapp.assertDisconnected(); -}); +}); // end describe diff --git a/tests/performance/onboarding/import-wallet.spec.ts b/tests/performance/onboarding/import-wallet.spec.ts index 13c05aacd0c..7ffd6b10762 100644 --- a/tests/performance/onboarding/import-wallet.spec.ts +++ b/tests/performance/onboarding/import-wallet.spec.ts @@ -1,7 +1,7 @@ import { test } from '../../framework/fixture'; import TimerHelper from '../../framework/TimerHelper'; import { getPasswordForScenario } from '../../framework/utils/TestConstants.js'; -import { PerformanceOnboarding } from '../../tags.performance.js'; +import { Performance, PerformanceOnboarding } from '../../tags.performance.js'; import OnboardingView from '../../page-objects/Onboarding/OnboardingView'; import { asPlaywrightElement, @@ -21,7 +21,7 @@ import { fetchProductionFeatureFlags } from '../feature-flag-helper'; const testEnvironment = process.env.E2E_PERFORMANCE_BUILD_VARIANT || ''; /* Scenario 4: Imported wallet with +50 accounts */ -test.describe(PerformanceOnboarding, () => { +test.describe(`${Performance} ${PerformanceOnboarding}`, () => { test.setTimeout(240000); test( 'Onboarding Import SRP with +50 accounts, SRP 3', diff --git a/tests/performance/onboarding/imported-wallet-account-creation.spec.ts b/tests/performance/onboarding/imported-wallet-account-creation.spec.ts index 686d434b373..c0d00d04aef 100644 --- a/tests/performance/onboarding/imported-wallet-account-creation.spec.ts +++ b/tests/performance/onboarding/imported-wallet-account-creation.spec.ts @@ -1,5 +1,6 @@ import { test } from '../../framework/fixture'; import { + Performance, PerformanceOnboarding, PerformanceAccountList, } from '../../tags.performance.js'; @@ -10,7 +11,7 @@ import TimerHelper from '../../framework/TimerHelper'; import { onboardingFlowImportSRPPlaywright } from '../../flows/wallet.flow'; /* Scenario 1: Imported wallet with 50+ accounts + account creation */ -test.describe(`${PerformanceOnboarding} ${PerformanceAccountList}`, () => { +test.describe(`${Performance} ${PerformanceOnboarding} ${PerformanceAccountList}`, () => { test.skip( 'Account creation with 50+ accounts, SRP 1 + SRP 2 + SRP 3', { tag: '@metamask-onboarding-team' }, diff --git a/tests/performance/onboarding/launch-times/cold-start-after-wallet-import.spec.ts b/tests/performance/onboarding/launch-times/cold-start-after-wallet-import.spec.ts index bc87a9bbbfd..c6bcb582327 100644 --- a/tests/performance/onboarding/launch-times/cold-start-after-wallet-import.spec.ts +++ b/tests/performance/onboarding/launch-times/cold-start-after-wallet-import.spec.ts @@ -1,5 +1,6 @@ import { test } from '../../../framework/fixture'; import { + Performance, PerformanceOnboarding, PerformanceLaunch, } from '../../../tags.performance.js'; @@ -14,7 +15,7 @@ import { import TimerHelper from '../../../framework/TimerHelper'; import WalletView from '../../../page-objects/wallet/WalletView'; -test.describe(`${PerformanceOnboarding} ${PerformanceLaunch}`, () => { +test.describe(`${Performance} ${PerformanceOnboarding} ${PerformanceLaunch}`, () => { test( 'Cold Start after importing a wallet', { tag: '@metamask-mobile-platform' }, diff --git a/tests/performance/onboarding/launch-times/cold-start-to-onboarding.spec.ts b/tests/performance/onboarding/launch-times/cold-start-to-onboarding.spec.ts index 11a741806d0..14f9a750018 100644 --- a/tests/performance/onboarding/launch-times/cold-start-to-onboarding.spec.ts +++ b/tests/performance/onboarding/launch-times/cold-start-to-onboarding.spec.ts @@ -1,6 +1,7 @@ import { test } from '../../../framework/fixture'; import TimerHelper from '../../../framework/TimerHelper.js'; import { + Performance, PerformanceOnboarding, PerformanceLaunch, } from '../../../tags.performance.js'; @@ -8,7 +9,7 @@ import PlaywrightAssertions from '../../../framework/PlaywrightAssertions'; import OnboardingView from '../../../page-objects/Onboarding/OnboardingView'; import { asPlaywrightElement } from '../../../framework/EncapsulatedElement'; -test.describe(`${PerformanceOnboarding} ${PerformanceLaunch}`, () => { +test.describe(`${Performance} ${PerformanceOnboarding} ${PerformanceLaunch}`, () => { test( 'Measure Cold Start To Onboarding Screen', { tag: '@metamask-mobile-platform' }, diff --git a/tests/performance/onboarding/new-wallet-account-creation.spec.ts b/tests/performance/onboarding/new-wallet-account-creation.spec.ts index c2181fe7718..1651c43953b 100644 --- a/tests/performance/onboarding/new-wallet-account-creation.spec.ts +++ b/tests/performance/onboarding/new-wallet-account-creation.spec.ts @@ -2,6 +2,8 @@ import { test } from '../../framework/fixture'; import TimerHelper from '../../framework/TimerHelper'; import { getPasswordForScenario } from '../../framework/utils/TestConstants.js'; import { + Performance, + System, PerformanceOnboarding, PerformanceAccountList, } from '../../tags.performance.js'; @@ -25,7 +27,7 @@ import PredictModalView from '../../page-objects/Predict/PredictModalView.js'; const testEnvironment = process.env.E2E_PERFORMANCE_BUILD_VARIANT || ''; /* Scenario 2: Account creation after fresh install */ -test.describe(`${PerformanceOnboarding} ${PerformanceAccountList}`, () => { +test.describe(`${Performance} ${System} ${PerformanceOnboarding} ${PerformanceAccountList}`, () => { test( 'Account creation after fresh install', { tag: '@metamask-onboarding-team' }, diff --git a/tests/performance/onboarding/seedless-apple-onboarding.spec.ts b/tests/performance/onboarding/seedless-apple-onboarding.spec.ts index 762e5dcd47f..7a884cb78aa 100644 --- a/tests/performance/onboarding/seedless-apple-onboarding.spec.ts +++ b/tests/performance/onboarding/seedless-apple-onboarding.spec.ts @@ -3,7 +3,11 @@ import TimerHelper from '../../framework/TimerHelper'; import { asPlaywrightElement, PlaywrightAssertions } from '../../framework'; import { getPasswordForScenario } from '../../framework/utils/TestConstants.js'; import { dismisspredictionsModalPlaywright } from '../../flows/wallet.flow'; -import { PerformanceOnboarding } from '../../tags.performance.js'; +import { + Performance, + System, + PerformanceOnboarding, +} from '../../tags.performance.js'; import OnboardingView from '../../page-objects/Onboarding/OnboardingView'; import OnboardingSheet from '../../page-objects/Onboarding/OnboardingSheet'; import SocialLoginView from '../../page-objects/Onboarding/SocialLoginView'; @@ -28,7 +32,7 @@ const waitForFirstSuccessful = async (promises: Promise[]): Promise => }); /* Seedless Onboarding: Apple Login */ -test.describe(PerformanceOnboarding, () => { +test.describe(`${Performance} ${System} ${PerformanceOnboarding}`, () => { test.setTimeout(240000); test( diff --git a/tests/performance/onboarding/seedless-google-onboarding.spec.ts b/tests/performance/onboarding/seedless-google-onboarding.spec.ts index 809471d5faf..18086dd9f4b 100644 --- a/tests/performance/onboarding/seedless-google-onboarding.spec.ts +++ b/tests/performance/onboarding/seedless-google-onboarding.spec.ts @@ -3,7 +3,11 @@ import TimerHelper from '../../framework/TimerHelper'; import { asPlaywrightElement, PlaywrightAssertions } from '../../framework'; import { getPasswordForScenario } from '../../framework/utils/TestConstants.js'; import { dismisspredictionsModalPlaywright } from '../../flows/wallet.flow'; -import { PerformanceOnboarding } from '../../tags.performance.js'; +import { + Performance, + System, + PerformanceOnboarding, +} from '../../tags.performance.js'; import OnboardingView from '../../page-objects/Onboarding/OnboardingView'; import OnboardingSheet from '../../page-objects/Onboarding/OnboardingSheet'; import SocialLoginView from '../../page-objects/Onboarding/SocialLoginView'; @@ -28,7 +32,7 @@ const waitForFirstSuccessful = async (promises: Promise[]): Promise => }); /* Seedless Onboarding: Google Login */ -test.describe(PerformanceOnboarding, () => { +test.describe(`${Performance} ${System} ${PerformanceOnboarding}`, () => { test.setTimeout(240000); test( diff --git a/tests/playwright.config.ts b/tests/playwright.config.ts index d69cbd58723..180221ba3d5 100644 --- a/tests/playwright.config.ts +++ b/tests/playwright.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ testDir: './', fullyParallel: false, timeout: 7 * 60 * 1000, //7 minutes until we introduce fixtures + grep: /@Performance/, reporter: [ [ 'html', diff --git a/tests/playwright.system-emulator.config.ts b/tests/playwright.system-emulator.config.ts new file mode 100644 index 00000000000..0a07e60f663 --- /dev/null +++ b/tests/playwright.system-emulator.config.ts @@ -0,0 +1,127 @@ +import dotenv from 'dotenv'; +dotenv.config({ path: '.e2e.env' }); + +import { Platform, ProviderName } from './framework/types'; +import { defineConfig } from './framework/config'; + +// Activate system test mode — disables quality gates, Sentry, and perf reporting +process.env.SYSTEM_TEST_MODE = 'true'; + +// ---------- Default build paths (match Detox / standard build output) ---------- +const DEFAULT_ANDROID_APK = + 'android/app/build/outputs/apk/prod/debug/app-prod-debug.apk'; +const DEFAULT_IOS_APP = + 'ios/build/Build/Products/Release-iphonesimulator/MetaMask.app'; + +/** + * System test config for local emulators / simulators. + * + * Same test specs and SYSTEM_TEST_MODE as playwright.system.config.ts (BrowserStack), + * but runs on a local Android emulator or iOS simulator via Appium. + * + * All environment variables have sensible defaults so you can run system tests + * right after a standard debug build (`yarn build:android:main:e2e` / `yarn build:ios:main:e2e`). + * + * Environment variables (all optional — defaults shown): + * - ANDROID_APK_PATH — Path to APK for login tests (default: prod debug APK) + * - ANDROID_CLEAN_APK_PATH — Path to clean APK for onboarding (default: same as ANDROID_APK_PATH) + * - IOS_APP_PATH — Path to .app for login tests (default: Release-iphonesimulator/MetaMask.app) + * - IOS_CLEAN_APP_PATH — Path to clean .app for onboarding (default: same as IOS_APP_PATH) + * - ANDROID_AVD_NAME — AVD name (default: 'Pixel_5_Pro_API_34') + * - IOS_SIMULATOR_NAME — Simulator name (default: 'iPhone 15 Pro') + * + * Usage: + * yarn run-system-tests:android-login-emu + * ANDROID_APK_PATH=/path/to/app.apk yarn run-system-tests:android-login-emu + * yarn run-system-tests:ios-login-sim + */ +export default defineConfig({ + testDir: './', + fullyParallel: false, + timeout: 7 * 60 * 1000, + retries: 1, + grep: /@System/, + reporter: [ + [ + 'html', + { open: 'never', outputFolder: './test-reports/system-test-report' }, + ], + ['list'], + ], + use: { + trace: 'on-first-retry', + }, + + projects: [ + { + name: 'system-android-login-emu', + testMatch: '**/performance/login/**/*.spec.ts', + 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: 'system-android-onboarding-emu', + testMatch: '**/performance/onboarding/**/*.spec.ts', + testIgnore: '**/performance/onboarding/seedless-*.spec.ts', + 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_CLEAN_APK_PATH || + process.env.ANDROID_APK_PATH || + DEFAULT_ANDROID_APK, + }, + }, + }, + { + name: 'system-ios-login-sim', + testMatch: '**/performance/login/**/*.spec.ts', + use: { + platform: Platform.IOS, + device: { + provider: ProviderName.SIMULATOR, + name: process.env.IOS_SIMULATOR_NAME || 'iPhone 15 Pro', + }, + app: { + appId: 'io.metamask.MetaMask', + buildPath: process.env.IOS_APP_PATH || DEFAULT_IOS_APP, + }, + }, + }, + { + name: 'system-ios-onboarding-sim', + testMatch: '**/performance/onboarding/**/*.spec.ts', + testIgnore: '**/performance/onboarding/seedless-*.spec.ts', + use: { + platform: Platform.IOS, + device: { + provider: ProviderName.SIMULATOR, + name: process.env.IOS_SIMULATOR_NAME || 'iPhone 15 Pro', + }, + app: { + appId: 'io.metamask.MetaMask', + buildPath: + process.env.IOS_CLEAN_APP_PATH || + process.env.IOS_APP_PATH || + DEFAULT_IOS_APP, + }, + }, + }, + ], +}); diff --git a/tests/playwright.system.config.ts b/tests/playwright.system.config.ts index 9068705b705..55ae90be3fa 100644 --- a/tests/playwright.system.config.ts +++ b/tests/playwright.system.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ fullyParallel: false, timeout: 7 * 60 * 1000, retries: 1, + grep: /@System/, reporter: [ [ 'html', diff --git a/tests/resources/blacklistURLs.json b/tests/resources/blacklistURLs.json index 7458fdfd8e1..6241cb3c04c 100644 --- a/tests/resources/blacklistURLs.json +++ b/tests/resources/blacklistURLs.json @@ -6,7 +6,6 @@ ".*api.etherscan.io/.*", ".*static.metafi.codefi.network/.*", ".*static.cx.metamask.io/.*", - ".*rpc.tenderly.co/.*", ".*api-goerli.etherscan.io/.*", ".*gateway.pinata.cloud/.*", ".*stale.*", diff --git a/tests/resources/networks.e2e.js b/tests/resources/networks.e2e.js index 745b0f0c691..3994bec42d3 100644 --- a/tests/resources/networks.e2e.js +++ b/tests/resources/networks.e2e.js @@ -192,7 +192,7 @@ const CustomNetworks = { rpcUrl: 'https://sei-mainnet.infura.io', nickname: 'Sei Testnet', ticker: 'SEI', - BlockExplorerUrl: 'https://seitrace.com/', + BlockExplorerUrl: 'https://seiscan.io/', }, }, }; diff --git a/tests/tags.performance.js b/tests/tags.performance.js index b6afd002229..11898a85efa 100644 --- a/tests/tags.performance.js +++ b/tests/tags.performance.js @@ -5,20 +5,36 @@ * Use these tags to categorize and filter performance tests. * * Usage in tests: - * import { PerformanceLogin, PerformanceSwaps } from '../../tags.js'; + * import { Performance, System, PerformanceLogin, PerformanceSwaps } from '../../tags.performance.js'; * - * test.describe(PerformanceLogin, () => { - * test('My login test', async ({ device }) => { ... }); - * }); + * // Both perf and system test (most common): + * test.describe(`${Performance} ${System} ${PerformanceLogin} ${PerformanceSwaps}`, () => { ... }); * - * Or with multiple tags: - * test.describe(`${PerformanceLogin} ${PerformanceSwaps}`, () => { ... }); + * // System-only test (functional verification, no perf measurement): + * test.describe(`${System} ${PerformanceLogin}`, () => { ... }); + * + * // Perf-only test (not run in system test suite): + * test.describe(`${Performance} ${PerformanceLaunch}`, () => { ... }); * * Running tests with tags: - * yarn playwright test --grep "@PerformanceLogin" + * yarn playwright test --grep "@Performance" # All performance tests + * yarn playwright test --grep "@System" # All system tests + * yarn playwright test --grep "@PerformanceLogin" # By area * yarn playwright test --grep "@PerformanceSwaps|@PerformanceOnboarding" */ +// ---------- Test type tags ---------- +// Runner-agnostic tags that control which config/runner picks up a test. +// Used in test.describe() names and filtered via --grep / config grep. + +/** Tag for performance tests (measured with TimerHelper, quality gates enforced). */ +export const Performance = '@Performance'; + +/** Tag for system tests (functional verification, no quality gates or metrics). */ +export const System = '@System'; + +// ---------- Area tags ---------- + export const PerformanceAccountList = '@PerformanceAccountList'; export const PerformanceOnboarding = '@PerformanceOnboarding'; export const PerformanceLogin = '@PerformanceLogin'; diff --git a/yarn.lock b/yarn.lock index 9d5d1b0345b..787872a8cd3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7882,28 +7882,28 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controller@npm:^6.1.0, @metamask/assets-controller@npm:^6.2.0, @metamask/assets-controller@npm:^6.2.1": - version: 6.2.1 - resolution: "@metamask/assets-controller@npm:6.2.1" +"@metamask/assets-controller@npm:^6.2.1, @metamask/assets-controller@npm:^6.3.0": + version: 6.3.0 + resolution: "@metamask/assets-controller@npm:6.3.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/account-tree-controller": "npm:^7.1.0" "@metamask/accounts-controller": "npm:^37.2.0" - "@metamask/assets-controllers": "npm:^105.0.0" + "@metamask/assets-controllers": "npm:^105.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/client-controller": "npm:^1.0.1" "@metamask/controller-utils": "npm:^11.20.0" "@metamask/core-backend": "npm:^6.2.1" - "@metamask/keyring-api": "npm:^23.0.1" - "@metamask/keyring-controller": "npm:^25.2.0" - "@metamask/keyring-internal-api": "npm:^10.1.1" - "@metamask/keyring-snap-client": "npm:^9.0.1" - "@metamask/messenger": "npm:^1.1.1" - "@metamask/network-controller": "npm:^30.0.1" + "@metamask/keyring-api": "npm:^23.1.0" + "@metamask/keyring-controller": "npm:^25.3.0" + "@metamask/keyring-internal-api": "npm:^11.0.1" + "@metamask/keyring-snap-client": "npm:^9.0.2" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/network-controller": "npm:^30.1.0" "@metamask/network-enablement-controller": "npm:^5.0.2" - "@metamask/permission-controller": "npm:^12.3.0" + "@metamask/permission-controller": "npm:^13.0.0" "@metamask/phishing-controller": "npm:^17.1.1" "@metamask/polling-controller": "npm:^16.0.4" "@metamask/preferences-controller": "npm:^23.1.0" @@ -7915,13 +7915,13 @@ __metadata: bignumber.js: "npm:^9.1.2" lodash: "npm:^4.17.21" p-limit: "npm:^3.1.0" - checksum: 10/32645ec9dc88199a93d72498cf8a00a0b8fc5a34c889229a80d7ec972acbb20eafbfe978a5ee98619f003a8a671de75ca96ed8e283cffb2208b2fc0fd893ee71 + checksum: 10/a1c2511ec5f954b78778684f116d09b177fa1a38458f83c8ac2b155b925724917c038a9b1dbc57b1e4092cb64df2464af39314d897139474c398bab88cd755f7 languageName: node linkType: hard -"@metamask/assets-controllers@npm:^104.3.0": - version: 104.3.0 - resolution: "@metamask/assets-controllers@npm:104.3.0" +"@metamask/assets-controllers@npm:^105.0.0, @metamask/assets-controllers@npm:^105.1.0": + version: 105.1.0 + resolution: "@metamask/assets-controllers@npm:105.1.0" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" @@ -7938,70 +7938,14 @@ __metadata: "@metamask/controller-utils": "npm:^11.20.0" "@metamask/core-backend": "npm:^6.2.1" "@metamask/eth-query": "npm:^4.0.0" - "@metamask/keyring-api": "npm:^23.0.1" - "@metamask/keyring-controller": "npm:^25.2.0" - "@metamask/messenger": "npm:^1.1.1" - "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-account-service": "npm:^8.0.1" - "@metamask/network-controller": "npm:^30.0.1" - "@metamask/network-enablement-controller": "npm:^5.0.2" - "@metamask/permission-controller": "npm:^12.3.0" - "@metamask/phishing-controller": "npm:^17.1.1" - "@metamask/polling-controller": "npm:^16.0.4" - "@metamask/preferences-controller": "npm:^23.1.0" - "@metamask/profile-sync-controller": "npm:^28.0.2" - "@metamask/rpc-errors": "npm:^7.0.2" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/snaps-sdk": "npm:^11.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/storage-service": "npm:^1.0.1" - "@metamask/transaction-controller": "npm:^64.3.0" - "@metamask/utils": "npm:^11.9.0" - "@types/bn.js": "npm:^5.1.5" - "@types/uuid": "npm:^8.3.0" - async-mutex: "npm:^0.5.0" - bitcoin-address-validation: "npm:^2.2.3" - bn.js: "npm:^5.2.1" - immer: "npm:^9.0.6" - lodash: "npm:^4.17.21" - multiformats: "npm:^9.9.0" - reselect: "npm:^5.1.1" - single-call-balance-checker-abi: "npm:^1.0.0" - uuid: "npm:^8.3.2" - peerDependencies: - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/9dbab56816788a66ca1dacf1aa476455115c3f1bb53ac2b809c096dee0b43e3dd641a15ea6ec192dc088cfbeb0d84c2bddd4d19d4f0b732aa7c3e7befe64a1ea - languageName: node - linkType: hard - -"@metamask/assets-controllers@npm:^105.0.0": - version: 105.0.0 - resolution: "@metamask/assets-controllers@npm:105.0.0" - dependencies: - "@ethereumjs/util": "npm:^9.1.0" - "@ethersproject/abi": "npm:^5.7.0" - "@ethersproject/address": "npm:^5.7.0" - "@ethersproject/bignumber": "npm:^5.7.0" - "@ethersproject/contracts": "npm:^5.7.0" - "@ethersproject/providers": "npm:^5.7.0" - "@metamask/abi-utils": "npm:^2.0.3" - "@metamask/account-tree-controller": "npm:^7.1.0" - "@metamask/accounts-controller": "npm:^37.2.0" - "@metamask/approval-controller": "npm:^9.0.1" - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/contract-metadata": "npm:^2.4.0" - "@metamask/controller-utils": "npm:^11.20.0" - "@metamask/core-backend": "npm:^6.2.1" - "@metamask/eth-query": "npm:^4.0.0" - "@metamask/keyring-api": "npm:^23.0.1" - "@metamask/keyring-controller": "npm:^25.2.0" - "@metamask/messenger": "npm:^1.1.1" + "@metamask/keyring-api": "npm:^23.1.0" + "@metamask/keyring-controller": "npm:^25.3.0" + "@metamask/messenger": "npm:^1.2.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/multichain-account-service": "npm:^8.0.1" - "@metamask/network-controller": "npm:^30.0.1" + "@metamask/network-controller": "npm:^30.1.0" "@metamask/network-enablement-controller": "npm:^5.0.2" - "@metamask/permission-controller": "npm:^12.3.0" + "@metamask/permission-controller": "npm:^13.0.0" "@metamask/phishing-controller": "npm:^17.1.1" "@metamask/polling-controller": "npm:^16.0.4" "@metamask/preferences-controller": "npm:^23.1.0" @@ -8027,7 +7971,7 @@ __metadata: peerDependencies: "@metamask/providers": ^22.0.0 webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/5b0d0b5e96f0e34bef7607574490db28cc5d844f5f57277c5b895626d13f842fceaa714c2a4b52400d0f4d333a1f1af95b5d4db34d81d2906a5d0c7bfa189727 + checksum: 10/206289f0fe122f228c7669a3a11c54ffbc07739a359193b8f8a10e0223a6e58b49c3a435fae4e18ab78ad9d2a03a9cb1758ba5ec2c8b08c618ff28c4dafd5e02 languageName: node linkType: hard @@ -8126,39 +8070,6 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-controller@npm:^70.2.0": - version: 70.2.0 - resolution: "@metamask/bridge-controller@npm:70.2.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:^37.2.0" - "@metamask/assets-controller": "npm:^6.1.0" - "@metamask/assets-controllers": "npm:^104.3.0" - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/controller-utils": "npm:^11.20.0" - "@metamask/gas-fee-controller": "npm:^26.1.1" - "@metamask/keyring-api": "npm:^23.0.1" - "@metamask/messenger": "npm:^1.1.1" - "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-network-controller": "npm:^3.0.6" - "@metamask/network-controller": "npm:^30.0.1" - "@metamask/polling-controller": "npm:^16.0.4" - "@metamask/profile-sync-controller": "npm:^28.0.2" - "@metamask/remote-feature-flag-controller": "npm:^4.2.0" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/transaction-controller": "npm:^64.3.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/5e3ff900bbcdbe2bee0e143cfb06076fb0a27d1c582d88dff9eba57458207dc2c76a47b178403ae4c36ec8a48e5fb1ddbefc310f73a4baaf24b4528a488514b1 - languageName: node - linkType: hard - "@metamask/bridge-controller@npm:^71.0.0": version: 71.0.0 resolution: "@metamask/bridge-controller@npm:71.0.0" @@ -8192,7 +8103,7 @@ __metadata: languageName: node linkType: hard -"@metamask/bridge-status-controller@npm:71.1.0": +"@metamask/bridge-status-controller@npm:71.1.0, @metamask/bridge-status-controller@npm:^71.1.0": version: 71.1.0 resolution: "@metamask/bridge-status-controller@npm:71.1.0" dependencies: @@ -8960,17 +8871,18 @@ __metadata: languageName: node linkType: hard -"@metamask/json-rpc-engine@npm:^10.0.0, @metamask/json-rpc-engine@npm:^10.0.2, @metamask/json-rpc-engine@npm:^10.0.3, @metamask/json-rpc-engine@npm:^10.1.0, @metamask/json-rpc-engine@npm:^10.1.1, @metamask/json-rpc-engine@npm:^10.2.3, @metamask/json-rpc-engine@npm:^10.2.4": - version: 10.2.4 - resolution: "@metamask/json-rpc-engine@npm:10.2.4" +"@metamask/json-rpc-engine@npm:^10.0.0, @metamask/json-rpc-engine@npm:^10.0.2, @metamask/json-rpc-engine@npm:^10.0.3, @metamask/json-rpc-engine@npm:^10.1.0, @metamask/json-rpc-engine@npm:^10.1.1, @metamask/json-rpc-engine@npm:^10.2.3, @metamask/json-rpc-engine@npm:^10.2.4, @metamask/json-rpc-engine@npm:^10.3.0": + version: 10.3.0 + resolution: "@metamask/json-rpc-engine@npm:10.3.0" dependencies: + "@metamask/messenger": "npm:^1.2.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/safe-event-emitter": "npm:^3.0.0" "@metamask/utils": "npm:^11.9.0" "@types/deep-freeze-strict": "npm:^1.1.0" deep-freeze-strict: "npm:^1.1.1" klona: "npm:^2.0.6" - checksum: 10/b207dd2a9a44674c141c2e027c082974464a37beada98a27e80fe59c9bd44e2c2a992edf8a8d7e3ed461fa27ed372c95d4e27df18752b558c10bf540b7fe7bcd + checksum: 10/8d4da5d933e4be2a85783871b6f1282763cbb5bc559e3228da099c75517530e3ac42a040109f17a4d4ff768f1c8cbcc4358f5e06b820b893af29a13f95180bd6 languageName: node linkType: hard @@ -9024,7 +8936,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-controller@npm:^25.1.0, @metamask/keyring-controller@npm:^25.1.1, @metamask/keyring-controller@npm:^25.2.0, @metamask/keyring-controller@npm:^25.4.0": +"@metamask/keyring-controller@npm:^25.1.0, @metamask/keyring-controller@npm:^25.1.1, @metamask/keyring-controller@npm:^25.2.0, @metamask/keyring-controller@npm:^25.3.0, @metamask/keyring-controller@npm:^25.4.0": version: 25.4.0 resolution: "@metamask/keyring-controller@npm:25.4.0" dependencies: @@ -9118,7 +9030,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-snap-client@npm:^9.0.1, @metamask/keyring-snap-client@npm:^9.0.2": +"@metamask/keyring-snap-client@npm:^9.0.2": version: 9.0.2 resolution: "@metamask/keyring-snap-client@npm:9.0.2" dependencies: @@ -9533,6 +9445,25 @@ __metadata: languageName: node linkType: hard +"@metamask/permission-controller@npm:^13.0.0": + version: 13.0.0 + resolution: "@metamask/permission-controller@npm:13.0.0" + dependencies: + "@metamask/approval-controller": "npm:^9.0.1" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/controller-utils": "npm:^11.20.0" + "@metamask/json-rpc-engine": "npm:^10.3.0" + "@metamask/messenger": "npm:^1.2.0" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/utils": "npm:^11.9.0" + "@types/deep-freeze-strict": "npm:^1.1.0" + deep-freeze-strict: "npm:^1.1.1" + immer: "npm:^9.0.6" + nanoid: "npm:^3.3.8" + checksum: 10/e4062076f7dd7da7acf890f66ee7df1a0309bbb9d9adb221f28eefb203318c2675707754b884a0d4f49892608a8771443a97e743c2c68f7c75f123ec7fafbf49 + languageName: node + linkType: hard + "@metamask/phishing-controller@npm:^17.1.1": version: 17.1.1 resolution: "@metamask/phishing-controller@npm:17.1.1" @@ -10329,7 +10260,7 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^64.0.0, @metamask/transaction-controller@npm:^64.2.0, @metamask/transaction-controller@npm:^64.3.0": +"@metamask/transaction-controller@npm:^64.0.0, @metamask/transaction-controller@npm:^64.2.0": version: 64.4.0 resolution: "@metamask/transaction-controller@npm:64.4.0" dependencies: @@ -10405,23 +10336,23 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-pay-controller@npm:^20.0.0": - version: 20.0.0 - resolution: "@metamask/transaction-pay-controller@npm:20.0.0" +"@metamask/transaction-pay-controller@npm:^21.0.0": + version: 21.0.0 + resolution: "@metamask/transaction-pay-controller@npm:21.0.0" dependencies: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/assets-controller": "npm:^6.2.0" - "@metamask/assets-controllers": "npm:^104.3.0" + "@metamask/assets-controller": "npm:^6.3.0" + "@metamask/assets-controllers": "npm:^105.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/bridge-controller": "npm:^70.2.0" - "@metamask/bridge-status-controller": "npm:^71.0.0" + "@metamask/bridge-controller": "npm:^71.0.0" + "@metamask/bridge-status-controller": "npm:^71.1.0" "@metamask/controller-utils": "npm:^11.20.0" "@metamask/gas-fee-controller": "npm:^26.1.1" - "@metamask/messenger": "npm:^1.1.1" + "@metamask/messenger": "npm:^1.2.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/network-controller": "npm:^30.0.1" + "@metamask/network-controller": "npm:^30.1.0" "@metamask/ramps-controller": "npm:^13.2.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.0" "@metamask/transaction-controller": "npm:^65.0.0" @@ -10430,7 +10361,7 @@ __metadata: bn.js: "npm:^5.2.1" immer: "npm:^9.0.6" lodash: "npm:^4.17.21" - checksum: 10/c67e9e911711dda45973f053ef1c4dd3c825f72749263d694a6a01279f06bfbe958c9b1f82ea2c798fc555adae3ca5936c66a63c141e355d588f692752f17807 + checksum: 10/090dc5efad84ceb2f956b30cec3544125080dc6f9ff5b6030f6446ceca59e9ea0c085309a780f9ffbaa75d3263c067e89fdc7f50ea3354f031ccb465c630ec48 languageName: node linkType: hard @@ -35812,7 +35743,7 @@ __metadata: "@metamask/test-dapp-multichain": "npm:^0.17.1" "@metamask/test-dapp-solana": "npm:^0.3.0" "@metamask/transaction-controller": "npm:^65.0.0" - "@metamask/transaction-pay-controller": "npm:^20.0.0" + "@metamask/transaction-pay-controller": "npm:^21.0.0" "@metamask/tron-wallet-snap": "npm:^1.25.3" "@metamask/utils": "npm:^11.11.0" "@myx-trade/sdk": "npm:^0.1.265"