diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3edbae370cb..f64a6ef5215 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -42,6 +42,7 @@ app/core/Engine/types.ts @MetaMask/mobile-pla app/core/Engine/controllers/remote-feature-flag-controller/ @MetaMask/mobile-platform app/core/DeeplinkManager @MetaMask/mobile-platform scripts/build.sh @MetaMask/mobile-platform +scripts/update-expo-channel.js @MetaMask/mobile-admins # Platform & Snaps Code Fencing File metro.transform.js @MetaMask/mobile-platform @MetaMask/core-platform diff --git a/.js.env.example b/.js.env.example index 9b60cf2789a..80a050df90d 100644 --- a/.js.env.example +++ b/.js.env.example @@ -44,6 +44,9 @@ export WALLET_CONNECT_PROJECT_ID="" # Default PORT for metro export WATCHER_PORT=8081 +# Expo Project ID for OTA updates +export EXPO_PROJECT_ID="" + # Environment: "production", "pre-release" or "dev" export METAMASK_ENVIRONMENT="dev" diff --git a/app.config.js b/app.config.js index 8c4a31c76b4..57570311150 100644 --- a/app.config.js +++ b/app.config.js @@ -1,3 +1,5 @@ +const { RUNTIME_VERSION, PROJECT_ID, UPDATE_URL } = require('./ota.config.js'); + module.exports = { name: 'MetaMask', displayName: 'MetaMask', @@ -15,7 +17,9 @@ module.exports = { '../../node_modules/@notifee/react-native/android/libs', ], }, - ios: {}, + ios: { + jsEngine: 'hermes', + }, }, ], [ @@ -36,5 +40,28 @@ module.exports = { ios: { bundleIdentifier: 'io.metamask.MetaMask', usesAppleSignIn: true, + jsEngine: 'hermes', + }, + expo: { + owner: 'metamask-test', + runtimeVersion: RUNTIME_VERSION, + updates: { + url: UPDATE_URL, + // Channel is set by requestHeaders, will be overridden with build script + requestHeaders: { + 'expo-channel-name': 'preview', + }, + }, + extra: { + eas: { + projectId: PROJECT_ID, + }, + }, + android: { + package: 'io.metamask', + }, + ios: { + bundleIdentifier: 'io.metamask.MetaMask', + }, }, }; diff --git a/app/__mocks__/expo-updates.ts b/app/__mocks__/expo-updates.ts new file mode 100644 index 00000000000..ca5c0d9c3cc --- /dev/null +++ b/app/__mocks__/expo-updates.ts @@ -0,0 +1,34 @@ +// Mock for expo-updates module +export const channel = 'test-channel'; +export const runtimeVersion = '1.0.0'; +export const isEmbeddedLaunch = true; +export const isEnabled = true; + +export const checkForUpdateAsync = jest.fn(); +export const fetchUpdateAsync = jest.fn(); +export const reloadAsync = jest.fn(); +export const useUpdates = jest.fn(); + +export const UpdateEventType = { + ERROR: 'error', + NO_UPDATE_AVAILABLE: 'noUpdateAvailable', + UPDATE_AVAILABLE: 'updateAvailable', +}; + +export const UpdateCheckResult = { + isAvailable: false, + manifest: null, +}; + +export default { + channel, + runtimeVersion, + isEmbeddedLaunch, + isEnabled, + checkForUpdateAsync, + fetchUpdateAsync, + reloadAsync, + useUpdates, + UpdateEventType, + UpdateCheckResult, +}; diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index 9904e414231..0fa1e02dbf3 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -2041,105 +2041,13 @@ export function getDepositNavbarOptions( navigation.dangerouslyGetParent()?.pop(); onClose?.(); }} + testID="deposit-close-navbar-button" /> ) : null, }; } -export function getFiatOnRampAggNavbar( - navigation, - { title = '', showBack = true, showCancel = true, showNetwork = false } = {}, - themeColors, - onCancel, -) { - const innerStyles = StyleSheet.create({ - headerButtonText: { - color: themeColors.primary.default, - fontSize: scale(11), - ...fontStyles.normal, - }, - headerStyle: { - backgroundColor: themeColors.background.default, - shadowColor: importedColors.transparent, - elevation: 0, - }, - headerTitleStyle: { - fontSize: 18, - ...fontStyles.normal, - color: themeColors.text.default, - ...(!showBack && { textAlign: 'center' }), - }, - }); - - const leftActionText = strings('navigation.back'); - - const leftAction = () => navigation.pop(); - - const navigationCancelText = strings('navigation.cancel'); - - const disableNetwork = !showNetwork; - const showSelectedNetwork = showNetwork; - - return { - headerTitle: () => ( - - ), - headerLeft: () => { - if (!showBack) return ; - - return Device.isAndroid() ? ( - - - - ) : ( - - {leftActionText} - - ); - }, - headerRight: () => { - if (!showCancel) return ; - return ( - { - navigation.dangerouslyGetParent()?.pop(); - onCancel?.(); - }} - style={styles.closeButton} - accessibilityRole="button" - accessible - > - - {navigationCancelText} - - - ); - }, - headerStyle: innerStyles.headerStyle, - headerTitleStyle: innerStyles.headerTitleStyle, - }; -} - export const getEditAccountNameNavBarOptions = (goBack, themeColors) => { const innerStyles = StyleSheet.create({ headerStyle: { diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx index 39da5070297..11a081dc46e 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.test.tsx @@ -430,9 +430,9 @@ describe('BuildQuote View', () => { it('navigates and tracks event on cancel button press', async () => { render(BuildQuote); - fireEvent.press(screen.getByRole('button', { name: 'Cancel' })); + fireEvent.press(screen.getByTestId('deposit-close-navbar-button')); expect(mockPop).toHaveBeenCalled(); - expect(mockTrackEvent).toBeCalledWith('ONRAMP_CANCELED', { + expect(mockTrackEvent).toHaveBeenCalledWith('ONRAMP_CANCELED', { chain_id_destination: '1', location: 'Amount to Buy Screen', }); @@ -444,9 +444,9 @@ describe('BuildQuote View', () => { mockUseRampSDKValues.isSell = true; mockUseRampSDKValues.rampType = RampType.SELL; render(BuildQuote); - fireEvent.press(screen.getByRole('button', { name: 'Cancel' })); + fireEvent.press(screen.getByTestId('deposit-close-navbar-button')); expect(mockPop).toHaveBeenCalled(); - expect(mockTrackEvent).toBeCalledWith('OFFRAMP_CANCELED', { + expect(mockTrackEvent).toHaveBeenCalledWith('OFFRAMP_CANCELED', { chain_id_source: '1', location: 'Amount to Sell Screen', }); diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx index 644b7065880..7613c0bfa11 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/BuildQuote.tsx @@ -52,7 +52,7 @@ import BadgeWrapper, { import BadgeNetwork from '../../../../../../component-library/components/Badges/Badge/variants/BadgeNetwork'; import { NATIVE_ADDRESS } from '../../../../../../constants/on-ramp'; -import { getFiatOnRampAggNavbar } from '../../../../Navbar'; +import { getDepositNavbarOptions } from '../../../../Navbar'; import { strings } from '../../../../../../../locales/i18n'; import { createNavigationDetails, @@ -115,10 +115,8 @@ export const createBuildQuoteNavDetails = const BuildQuote = () => { const navigation = useNavigation(); const params = useParams(); - const { - styles, - theme: { colors, themeAppearance }, - } = useStyles(styleSheet, {}); + const { styles, theme } = useStyles(styleSheet, {}); + const { colors, themeAppearance } = theme; const trackEvent = useAnalytics(); const [amountFocused, setAmountFocused] = useState(false); const [amount, setAmount] = useState('0'); @@ -448,20 +446,19 @@ const BuildQuote = () => { useEffect(() => { navigation.setOptions( - getFiatOnRampAggNavbar( + getDepositNavbarOptions( navigation, { title: isBuy ? strings('fiat_on_ramp_aggregator.amount_to_buy') : strings('fiat_on_ramp_aggregator.amount_to_sell'), showBack: params.showBack, - showNetwork: false, }, - colors, + theme, handleCancelPress, ), ); - }, [navigation, colors, handleCancelPress, params.showBack, isBuy]); + }, [navigation, theme, handleCancelPress, params.showBack, isBuy]); /** * * Keypad style, handlers and effects diff --git a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap index 7abea18246b..93ec78c967a 100644 --- a/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/BuildQuote/__snapshots__/BuildQuote.test.tsx.snap @@ -58,12 +58,12 @@ exports[`BuildQuote View Balance display displays balance from useBalance for no "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -116,29 +116,87 @@ exports[`BuildQuote View Balance display displays balance from useBalance for no } } > - - - Back - - + + + - - - Cancel - - + + + @@ -2801,12 +2917,12 @@ exports[`BuildQuote View Crypto Currency Data renders a special error page if cr "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -2859,29 +2975,87 @@ exports[`BuildQuote View Crypto Currency Data renders a special error page if cr } } > - - - Back - - + + + - - - Cancel - - + + + @@ -3445,12 +3677,12 @@ exports[`BuildQuote View Crypto Currency Data renders a special error page if cr "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -3503,29 +3735,87 @@ exports[`BuildQuote View Crypto Currency Data renders a special error page if cr } } > - - - Back - - + + + - - - Cancel - - + + + @@ -4089,12 +4437,12 @@ exports[`BuildQuote View Crypto Currency Data renders an error page when there i "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -4147,29 +4495,87 @@ exports[`BuildQuote View Crypto Currency Data renders an error page when there i } } > - - - Back - - + + + - - - Cancel - - + + + @@ -4733,12 +5197,12 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -4791,29 +5255,87 @@ exports[`BuildQuote View Crypto Currency Data renders the loading page when cryp } } > - - - Back - - + + + - - - Cancel - - + + + @@ -7180,12 +7760,12 @@ exports[`BuildQuote View Fiat Currency Data renders an error page when there is "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -7238,29 +7818,87 @@ exports[`BuildQuote View Fiat Currency Data renders an error page when there is } } > - - - Back - - + + + - - - Cancel - - + + + @@ -7824,12 +8520,12 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -7882,29 +8578,87 @@ exports[`BuildQuote View Fiat Currency Data renders the loading page when fiats } } > - - - Back - - + + + - - - Cancel - - + + + @@ -10180,12 +10992,12 @@ exports[`BuildQuote View Payment Method Data renders an error page when there is "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -10238,29 +11050,87 @@ exports[`BuildQuote View Payment Method Data renders an error page when there is } } > - - - Back - - + + + - - - Cancel - - + + + @@ -10824,12 +11752,12 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -10882,29 +11810,87 @@ exports[`BuildQuote View Payment Method Data renders no icons if there are no pa } } > - - - Back - - + + + - - - Cancel - - + + + @@ -13524,12 +14568,12 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -13582,29 +14626,87 @@ exports[`BuildQuote View Payment Method Data renders the loading page when payme } } > - - - Back - - + + + - - - Cancel - - + + + @@ -16146,12 +17306,12 @@ exports[`BuildQuote View Regions data renders an error page when there is a regi "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -16204,29 +17364,87 @@ exports[`BuildQuote View Regions data renders an error page when there is a regi } } > - - - Back - - + + + - - - Cancel - - + + + @@ -16790,12 +18066,12 @@ exports[`BuildQuote View Regions data renders the loading page when regions are "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -16848,29 +18124,87 @@ exports[`BuildQuote View Regions data renders the loading page when regions are } } > - - - Back - - + + + - - - Cancel - - + + + @@ -19110,12 +20502,12 @@ exports[`BuildQuote View renders correctly 1`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -19168,29 +20560,87 @@ exports[`BuildQuote View renders correctly 1`] = ` } } > - - - Back - - + + + - - - Cancel - - + + + @@ -21853,12 +23361,12 @@ exports[`BuildQuote View renders correctly 2`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -21911,29 +23419,87 @@ exports[`BuildQuote View renders correctly 2`] = ` } } > - - - Back - - + + + - - - Cancel - - + + + @@ -24576,12 +26200,12 @@ exports[`BuildQuote View renders correctly when sdkError is present 1`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -24634,29 +26258,87 @@ exports[`BuildQuote View renders correctly when sdkError is present 1`] = ` } } > - - - Back - - + + + - - - Cancel - - + + + @@ -25220,12 +26960,12 @@ exports[`BuildQuote View renders correctly when sdkError is present 2`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -25278,29 +27018,87 @@ exports[`BuildQuote View renders correctly when sdkError is present 2`] = ` } } > - - - Back - - + + + - - - Cancel - - + + + diff --git a/app/components/UI/Ramp/Aggregator/Views/Checkout.test.tsx b/app/components/UI/Ramp/Aggregator/Views/Checkout.test.tsx deleted file mode 100644 index 2ba79ff2f23..00000000000 --- a/app/components/UI/Ramp/Aggregator/Views/Checkout.test.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import { Provider } from '@consensys/on-ramp-sdk'; -import { renderScreen } from '../../../../../util/test/renderWithProvider'; -import Checkout from './Checkout'; -import { RampSDK } from '../sdk'; -import Routes from '../../../../../constants/navigation/Routes'; -import { backgroundState } from '../../../../../util/test/initial-root-state'; -import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../../../util/test/accountsControllerTestUtils'; -import { createCustomOrderIdData } from '../orderProcessor/customOrderId'; -import { Network } from '@consensys/on-ramp-sdk/dist/API'; -import Logger from '../../../../../util/Logger'; - -const mockDispatch = jest.fn(); -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useDispatch: () => mockDispatch, -})); - -const mockTrackEvent = jest.fn(); -jest.mock('../../hooks/useAnalytics', () => () => mockTrackEvent); - -const mockHandleSuccessfulOrder = jest.fn(); -jest.mock( - '../hooks/useHandleSuccessfulOrder', - () => () => mockHandleSuccessfulOrder, -); - -const mockSetOptions = jest.fn(); -const mockNavigation = { - setOptions: mockSetOptions, - dangerouslyGetParent: () => ({ pop: jest.fn() }), -}; -jest.mock('@react-navigation/native', () => ({ - ...jest.requireActual('@react-navigation/native'), - useNavigation: () => mockNavigation, -})); - -jest.mock('../orderProcessor/customOrderId'); - -const mockUseRampSDKInitialValues: Partial = { - selectedAddress: '0x123', - selectedAsset: undefined, - sdkError: undefined, - callbackBaseUrl: 'https://callback.test', - isBuy: true, -}; - -let mockUseRampSDKValues: Partial = { - ...mockUseRampSDKInitialValues, -}; - -jest.mock('../sdk', () => ({ - ...jest.requireActual('../sdk'), - useRampSDK: () => mockUseRampSDKValues, - SDK: { - orders: jest.fn().mockResolvedValue({ - getOrderFromCallback: jest.fn(), - }), - }, -})); - -const mockUseParams = jest.fn(() => ({ - url: 'https://test.url', - customOrderId: 'test-order-id', - provider: { id: 'test-provider', name: 'Test Provider' } as Provider, -})); - -jest.mock('../../../../../util/navigation/navUtils', () => ({ - ...jest.requireActual('../../../../../util/navigation/navUtils'), - useParams: () => mockUseParams(), -})); - -function render() { - return renderScreen( - Checkout, - { - name: Routes.RAMP.CHECKOUT, - }, - { - state: { - engine: { - backgroundState: { - ...backgroundState, - AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, - }, - }, - }, - }, - ); -} - -describe('Checkout', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockUseRampSDKValues = { - ...mockUseRampSDKInitialValues, - }; - }); - - it('uses selectedAsset network chainId when available', () => { - mockUseRampSDKValues.selectedAsset = { - id: '1', - idv2: '2', - legacyId: 'legacy-1', - network: { - active: true, - chainId: '137', - chainName: 'Polygon', - shortName: 'Polygon', - }, - symbol: 'USDC', - logo: 'logo', - decimals: 6, - address: '0x123', - name: 'USD Coin', - limits: ['1', '1000'], - sellEnabled: true, - assetId: 'asset-1', - } as const; - - render(); - - expect(createCustomOrderIdData).toHaveBeenCalledWith( - 'test-order-id', - '137', - '0x123', - 'BUY', - ); - }); - - it('uses selectedAsset network chainId when chainId is 1', () => { - mockUseRampSDKValues.selectedAsset = { - id: '1', - idv2: '2', - legacyId: 'legacy-1', - network: { - active: true, - chainId: '1', - chainName: 'Test', - shortName: 'Test', - }, - symbol: 'USDC', - logo: 'logo', - decimals: 6, - address: '0x123', - name: 'USD Coin', - limits: ['1', '1000'], - sellEnabled: true, - assetId: 'asset-1', - } as const; - - render(); - - expect(createCustomOrderIdData).toHaveBeenCalledWith( - 'test-order-id', - '1', - '0x123', - 'BUY', - ); - }); - - it('handles undefined selectedAsset gracefully', () => { - mockUseRampSDKValues.selectedAsset = undefined; - - render(); - - expect(createCustomOrderIdData).not.toHaveBeenCalled(); - }); - - it('returns early from handleCancelPress when chainId is not available', () => { - mockUseRampSDKValues.selectedAsset = { - id: '1', - idv2: '2', - legacyId: 'legacy-1', - network: { - active: true, - chainId: undefined as unknown as string, // No chainId - chainName: 'Test', - shortName: 'Test', - }, - symbol: 'USDC', - logo: 'logo', - decimals: 6, - address: '0x123', - name: 'USD Coin', - limits: ['1', '1000'], - sellEnabled: true, - assetId: 'asset-1', - } as const; - - render(); - - expect(mockTrackEvent).not.toHaveBeenCalled(); - }); - - it('returns early from handleCancelPress when selectedAsset network is undefined', () => { - mockUseRampSDKValues.selectedAsset = { - id: '1', - idv2: '2', - legacyId: 'legacy-1', - network: undefined as unknown as Network, // No network - symbol: 'USDC', - logo: 'logo', - decimals: 6, - address: '0x123', - name: 'USD Coin', - limits: ['1', '1000'], - sellEnabled: true, - assetId: 'asset-1', - } as const; - - render(); - - expect(mockTrackEvent).not.toHaveBeenCalled(); - }); - - it('handles navigation state change when selectedAddress is undefined', () => { - mockUseRampSDKValues.selectedAddress = undefined; - - render(); - - expect(mockHandleSuccessfulOrder).not.toHaveBeenCalled(); - }); - - it('logs error when selectedAddress is undefined during navigation state change', async () => { - mockUseRampSDKValues.selectedAddress = undefined; - - const mockLoggerError = jest.spyOn(Logger, 'error'); - - const { getByTestId } = render(); - - const webView = getByTestId('checkout-webview'); - if (webView?.props?.onNavigationStateChange) { - await webView.props.onNavigationStateChange({ - url: 'https://callback.test?success=true', - loading: false, - title: '', - canGoBack: false, - canGoForward: false, - }); - } - - expect(mockLoggerError).toHaveBeenCalledWith( - new Error('No address available for selected asset'), - ); - }); -}); diff --git a/app/components/UI/Ramp/Aggregator/Views/Checkout.tsx b/app/components/UI/Ramp/Aggregator/Views/Checkout.tsx deleted file mode 100644 index 8e1002283a5..00000000000 --- a/app/components/UI/Ramp/Aggregator/Views/Checkout.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { View } from 'react-native'; -import { useDispatch } from 'react-redux'; -import { parseUrl } from 'query-string'; -import { WebView, WebViewNavigation } from '@metamask/react-native-webview'; -import { useNavigation } from '@react-navigation/native'; -import { Provider } from '@consensys/on-ramp-sdk'; -import { OrderOrderTypeEnum } from '@consensys/on-ramp-sdk/dist/API'; -import { baseStyles } from '../../../../../styles/common'; -import { useTheme } from '../../../../../util/theme'; -import { getFiatOnRampAggNavbar } from '../../../Navbar'; -import { useRampSDK, SDK } from '../sdk'; -import { - addFiatCustomIdData, - removeFiatCustomIdData, -} from '../../../../../reducers/fiatOrders'; -import { CustomIdData } from '../../../../../reducers/fiatOrders/types'; -import { - createNavigationDetails, - useParams, -} from '../../../../../util/navigation/navUtils'; -import { aggregatorOrderToFiatOrder } from '../orderProcessor/aggregator'; -import { createCustomOrderIdData } from '../orderProcessor/customOrderId'; -import ScreenLayout from '../components/ScreenLayout'; -import ErrorView from '../components/ErrorView'; -import ErrorViewWithReporting from '../components/ErrorViewWithReporting'; -import useAnalytics from '../../hooks/useAnalytics'; -import { strings } from '../../../../../../locales/i18n'; -import Routes from '../../../../../constants/navigation/Routes'; -import useHandleSuccessfulOrder from '../hooks/useHandleSuccessfulOrder'; -import Logger from '../../../../../util/Logger'; - -interface CheckoutParams { - url: string; - customOrderId?: string; - provider: Provider; -} - -export const createCheckoutNavDetails = createNavigationDetails( - Routes.RAMP.CHECKOUT, -); - -const CheckoutWebView = () => { - const { selectedAsset, selectedAddress, sdkError, callbackBaseUrl, isBuy } = - useRampSDK(); - const dispatch = useDispatch(); - const trackEvent = useAnalytics(); - const [error, setError] = useState(''); - const [customIdData, setCustomIdData] = useState(); - const [isRedirectionHandled, setIsRedirectionHandled] = useState(false); - const [key, setKey] = useState(0); - const navigation = useNavigation(); - const params = useParams(); - const { colors } = useTheme(); - const handleSuccessfulOrder = useHandleSuccessfulOrder(); - - const { url: uri, customOrderId, provider } = params; - - const handleCancelPress = useCallback(() => { - const chainId = selectedAsset?.network?.chainId; - if (!chainId) return; - - if (isBuy) { - trackEvent('ONRAMP_CANCELED', { - location: 'Provider Webview', - chain_id_destination: chainId, - provider_onramp: provider.name, - }); - } else { - trackEvent('OFFRAMP_CANCELED', { - location: 'Provider Webview', - chain_id_source: chainId, - provider_offramp: provider.name, - }); - } - }, [isBuy, provider.name, selectedAsset?.network?.chainId, trackEvent]); - - useEffect(() => { - navigation.setOptions( - getFiatOnRampAggNavbar( - navigation, - { title: provider.name, showNetwork: false }, - colors, - handleCancelPress, - ), - ); - }, [navigation, colors, handleCancelPress, provider.name]); - - useEffect(() => { - if ( - !customOrderId || - !selectedAsset?.network?.chainId || - !selectedAddress - ) { - return; - } - const customOrderIdData = createCustomOrderIdData( - customOrderId, - selectedAsset.network.chainId, - selectedAddress, - isBuy ? OrderOrderTypeEnum.Buy : OrderOrderTypeEnum.Sell, - ); - setCustomIdData(customOrderIdData); - dispatch(addFiatCustomIdData(customOrderIdData)); - }, [customOrderId, dispatch, isBuy, selectedAsset, selectedAddress]); - - const handleNavigationStateChange = async (navState: WebViewNavigation) => { - if ( - !isRedirectionHandled && - navState.url.startsWith(callbackBaseUrl) && - navState.loading === false - ) { - setIsRedirectionHandled(true); - try { - const parsedUrl = parseUrl(navState.url); - if (Object.keys(parsedUrl.query).length === 0) { - // There was no query params in the URL to parse - // Most likely the user clicked the X in Wyre widget - // @ts-expect-error navigation prop mismatch - navigation.dangerouslyGetParent()?.pop(); - return; - } - if (!selectedAddress) { - Logger.error(new Error('No address available for selected asset')); - return; - } - const orders = await SDK.orders(); - const getOrderFromCallbackMethod = isBuy - ? 'getOrderFromCallback' - : 'getSellOrderFromCallback'; - const order = await orders[getOrderFromCallbackMethod]( - provider.id, - navState?.url, - selectedAddress, - ); - - if (!order) { - throw new Error( - `Order could not be retrieved. Callback was ${navState?.url}`, - ); - } - - if (customIdData) { - dispatch(removeFiatCustomIdData(customIdData)); - } - - const transformedOrder = { - ...aggregatorOrderToFiatOrder(order), - account: selectedAddress, - }; - - handleSuccessfulOrder(transformedOrder); - } catch (navStateError) { - setError((navStateError as Error)?.message); - } - } - }; - - if (sdkError) { - return ( - - - - - - ); - } - - if (error) { - return ( - - - { - setKey((prevKey) => prevKey + 1); - setError(''); - setIsRedirectionHandled(false); - }} - location={'Provider Webview'} - /> - - - ); - } - - if (uri) { - return ( - - { - const { nativeEvent } = syntheticEvent; - if ( - nativeEvent.url === uri || - nativeEvent.url.startsWith(callbackBaseUrl) - ) { - const webviewHttpError = strings( - 'fiat_on_ramp_aggregator.webview_received_error', - { code: nativeEvent.statusCode }, - ); - setError(webviewHttpError); - } - }} - allowsInlineMediaPlayback - enableApplePay - paymentRequestEnabled - mediaPlaybackRequiresUserAction={false} - onNavigationStateChange={handleNavigationStateChange} - userAgent={provider?.features?.buy?.userAgent ?? undefined} - testID="checkout-webview" - /> - - ); - } - - return null; -}; - -export default CheckoutWebView; diff --git a/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.styles.ts b/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.styles.ts new file mode 100644 index 00000000000..df5fea79316 --- /dev/null +++ b/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.styles.ts @@ -0,0 +1,14 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../../../util/theme/models'; + +const styleSheet = (params: { theme: Theme }) => + StyleSheet.create({ + headerWithoutPadding: { + paddingVertical: 0, + }, + webview: { + backgroundColor: params.theme.colors.background.default, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.test.tsx b/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.test.tsx new file mode 100644 index 00000000000..5ab4a0f2025 --- /dev/null +++ b/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.test.tsx @@ -0,0 +1,400 @@ +import { CryptoCurrency, Order, Provider } from '@consensys/on-ramp-sdk'; +import { fireEvent, act } from '@testing-library/react-native'; +import { + DeepPartial, + renderScreen, +} from '../../../../../../util/test/renderWithProvider'; +import { RampSDK, SDK } from '../../sdk'; +import Checkout from '.'; +import Routes from '../../../../../../constants/navigation/Routes'; +import { aggregatorOrderToFiatOrder } from '../../orderProcessor/aggregator'; + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => mockDispatch, +})); + +const mockTrackEvent = jest.fn(); +jest.mock('../../../hooks/useAnalytics', () => () => mockTrackEvent); + +const mockHandleSuccessfulOrder = jest.fn(); +jest.mock( + '../../hooks/useHandleSuccessfulOrder', + () => () => mockHandleSuccessfulOrder, +); + +const mockSetOptions = jest.fn(); +const mockPop = jest.fn(); +const mockNavigation = { + goBack: jest.fn(), + setOptions: mockSetOptions, + dangerouslyGetParent: () => ({ pop: mockPop }), +}; +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => mockNavigation, +})); + +const mockedSelectedAsset = { + id: '1', + idv2: '2', + legacyId: 'legacy-1', + network: { + active: true, + chainId: '137', + chainName: 'Polygon', + shortName: 'Polygon', + }, + symbol: 'USDC', + logo: 'logo', + decimals: 6, + address: '0x123', + name: 'USD Coin', + limits: ['1', '1000'], + sellEnabled: true, + assetId: 'asset-1', +} as CryptoCurrency; + +const mockUseRampSDKInitialValues: DeepPartial = { + selectedAddress: '0x123', + selectedAsset: mockedSelectedAsset, + sdkError: undefined, + callbackBaseUrl: 'https://callback.test', + isBuy: true, +}; + +let mockUseRampSDKValues: DeepPartial = { + ...mockUseRampSDKInitialValues, +}; + +jest.mock('../../sdk', () => ({ + ...jest.requireActual('../../sdk'), + useRampSDK: () => mockUseRampSDKValues, + SDK: { + orders: jest.fn().mockResolvedValue({ + getOrderFromCallback: jest.fn(), + }), + }, +})); + +const mockUseParams = jest.fn(() => ({ + url: 'https://test.url', + customOrderId: 'test-order-id', + provider: { id: 'test-provider', name: 'Test Provider' } as Provider, +})); + +jest.mock('../../../../../../util/navigation/navUtils', () => ({ + ...jest.requireActual('../../../../../../util/navigation/navUtils'), + useParams: () => mockUseParams(), +})); + +function render() { + return renderScreen(Checkout, { + name: Routes.RAMP.CHECKOUT, + }); +} + +describe('Checkout', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRampSDKValues = { + ...mockUseRampSDKInitialValues, + }; + }); + + it('displays WebView when url is present and no errors', () => { + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('displays sell WebView when url is present and no errors', () => { + mockUseRampSDKValues.isBuy = false; + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('displays sdkError when present', () => { + mockUseRampSDKValues.sdkError = new Error('SDK Error'); + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('displays and tracks error if no url or errors', () => { + mockUseRampSDKValues.selectedAsset = undefined; + mockUseParams.mockReturnValueOnce({ + url: '', + customOrderId: 'test-order-id', + provider: { id: 'test-provider', name: 'Test Provider' } as Provider, + }); + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + expect(mockTrackEvent).toHaveBeenCalledWith( + 'ONRAMP_ERROR', + expect.any(Object), + ); + }); + + it('closes and tracks buy cancel event on bottom sheet close', () => { + const { getByTestId } = render(); + const closeButton = getByTestId('checkout-close-button'); + act(() => { + closeButton.props.onPress(); + }); + expect(mockTrackEvent).toHaveBeenCalledWith('ONRAMP_CANCELED', { + chain_id_destination: '137', + location: 'Provider Webview', + provider_onramp: 'Test Provider', + }); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + expect(mockNavigation.goBack).toHaveBeenCalled(); + }); + + it('closes and tracks sell cancel event on bottom sheet close', () => { + mockUseRampSDKValues.isBuy = false; + const { getByTestId } = render(); + const closeButton = getByTestId('checkout-close-button'); + act(() => { + closeButton.props.onPress(); + }); + expect(mockTrackEvent).toHaveBeenCalledWith('OFFRAMP_CANCELED', { + chain_id_source: '137', + location: 'Provider Webview', + provider_offramp: 'Test Provider', + }); + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + expect(mockNavigation.goBack).toHaveBeenCalled(); + }); + + it('closes and tracks buy cancel event on error bottom sheet close', () => { + mockUseRampSDKValues.selectedAsset = undefined; + mockUseParams.mockReturnValueOnce({ + url: '', + customOrderId: 'test-order-id', + provider: { + id: 'test-provider', + name: 'Test Provider', + } as Provider, + }); + const { getByTestId } = render(); + const closeButton = getByTestId('checkout-close-button'); + act(() => { + closeButton.props.onPress(); + }); + expect(mockTrackEvent).toHaveBeenCalledWith('ONRAMP_CANCELED', { + chain_id_destination: '', + location: 'Provider Webview', + provider_onramp: 'Test Provider', + }); + expect(mockNavigation.goBack).toHaveBeenCalled(); + }); + + it('sets and displays error on http error in WebView', async () => { + const { getByTestId, toJSON, getByText } = render(); + const webView = getByTestId('checkout-webview'); + await act(async () => { + await webView.props.onHttpError({ + nativeEvent: { + statusCode: 500, + description: 'Server Error', + url: 'https://test.url', + }, + }); + }); + expect(toJSON()).toMatchSnapshot(); + + const tryAgainButton = getByText('Try again'); + expect(tryAgainButton).toBeDefined(); + act(() => { + fireEvent.press(tryAgainButton); + }); + }); + + it('sets and displays error on http error in WebView for callback url', async () => { + const { getByTestId, toJSON } = render(); + const webView = getByTestId('checkout-webview'); + await act(async () => { + await webView.props.onHttpError({ + nativeEvent: { + statusCode: 500, + description: 'Server Error', + url: 'https://callback.test', + }, + }); + }); + expect(toJSON()).toMatchSnapshot(); + }); + + it('ignores irrelevant error on http error in WebView for callback url', async () => { + const { getByTestId, toJSON } = render(); + const webView = getByTestId('checkout-webview'); + await act(async () => { + await webView.props.onHttpError({ + nativeEvent: { + statusCode: 500, + description: 'Server Error', + url: 'https://irrelevant.url', + }, + }); + }); + expect(toJSON()).toMatchSnapshot(); + }); + + it('ignores irrelevant url navigation state changes', async () => { + const { getByTestId } = render(); + const webView = getByTestId('checkout-webview'); + await act(async () => { + await webView.props.onNavigationStateChange({ + url: 'https://irrelevant.url', + loading: false, + }); + }); + expect(mockHandleSuccessfulOrder).not.toHaveBeenCalled(); + }); + + it('ignores url navigation state changes when is loading', async () => { + const { getByTestId } = render(); + const webView = getByTestId('checkout-webview'); + await act(async () => { + await webView.props.onNavigationStateChange({ + url: 'https://callback.test?success=true', + loading: true, + }); + }); + expect(mockHandleSuccessfulOrder).not.toHaveBeenCalled(); + }); + + it('closes webview when url has no query params', async () => { + const { getByTestId } = render(); + const webView = getByTestId('checkout-webview'); + await act(async () => { + await webView.props.onNavigationStateChange({ + url: 'https://callback.test', + loading: false, + }); + }); + + expect(mockHandleSuccessfulOrder).not.toHaveBeenCalled(); + expect(mockPop).toHaveBeenCalled(); + }); + + it('sets error when handling url navigation state change and selectedAddress is undefined', async () => { + mockUseRampSDKValues.selectedAddress = undefined; + const { getByTestId, toJSON, getByText } = render(); + const webView = getByTestId('checkout-webview'); + await act(async () => { + await webView.props.onNavigationStateChange({ + url: 'https://callback.test?success=true', + loading: false, + }); + }); + + expect(toJSON()).toMatchSnapshot(); + expect( + getByText('No wallet address was provided to continue'), + ).toBeDefined(); + }); + + it('handles successful buy order on url navigation state change', async () => { + const mockOrder = { + id: 'order-1', + status: 'COMPLETED', + }; + const mockGetOrderFromCallback = jest.fn().mockResolvedValue(mockOrder); + (SDK.orders as jest.Mock).mockResolvedValueOnce({ + getOrderFromCallback: mockGetOrderFromCallback, + }); + const { getByTestId } = render(); + const webView = getByTestId('checkout-webview'); + await act(async () => { + await webView.props.onNavigationStateChange({ + url: 'https://callback.test?success=true', + loading: false, + }); + }); + + expect(mockGetOrderFromCallback).toHaveBeenCalledWith( + 'test-provider', + 'https://callback.test?success=true', + '0x123', + ); + expect(mockHandleSuccessfulOrder).toHaveBeenCalledWith({ + ...aggregatorOrderToFiatOrder(mockOrder as Order), + account: '0x123', + }); + }); + + it('handles successful sell order on url navigation state change', async () => { + mockUseRampSDKValues.isBuy = false; + mockUseParams.mockReturnValueOnce({ + url: 'https://test.url', + customOrderId: '', + provider: { id: 'test-provider', name: 'Test Provider' } as Provider, + }); + + const mockSellOrder = { + id: 'order-1', + status: 'COMPLETED', + }; + const mockGetSellOrderFromCallback = jest + .fn() + .mockResolvedValue(mockSellOrder); + (SDK.orders as jest.Mock).mockResolvedValueOnce({ + getSellOrderFromCallback: mockGetSellOrderFromCallback, + }); + const { getByTestId } = render(); + const webView = getByTestId('checkout-webview'); + await act(async () => { + await webView.props.onNavigationStateChange({ + url: 'https://callback.test?success=true', + loading: false, + }); + }); + + expect(mockGetSellOrderFromCallback).toHaveBeenCalledWith( + 'test-provider', + 'https://callback.test?success=true', + '0x123', + ); + expect(mockHandleSuccessfulOrder).toHaveBeenCalledWith({ + ...aggregatorOrderToFiatOrder(mockSellOrder as Order), + account: '0x123', + }); + }); + + it('handles get order error gracefully', async () => { + const mockGetOrderFromCallback = jest + .fn() + .mockRejectedValue(new Error('Get order error')); + (SDK.orders as jest.Mock).mockResolvedValueOnce({ + getOrderFromCallback: mockGetOrderFromCallback, + }); + const { getByTestId, toJSON } = render(); + const webView = getByTestId('checkout-webview'); + await act(async () => { + await webView.props.onNavigationStateChange({ + url: 'https://callback.test?success=true', + loading: false, + }); + }); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('handles undefined order gracefully', async () => { + const mockGetOrderFromCallback = jest.fn().mockResolvedValue(undefined); + (SDK.orders as jest.Mock).mockResolvedValueOnce({ + getOrderFromCallback: mockGetOrderFromCallback, + }); + const { getByTestId, toJSON } = render(); + const webView = getByTestId('checkout-webview'); + await act(async () => { + await webView.props.onNavigationStateChange({ + url: 'https://callback.test?success=true', + loading: false, + }); + }); + + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.tsx b/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.tsx new file mode 100644 index 00000000000..239b5f58d22 --- /dev/null +++ b/app/components/UI/Ramp/Aggregator/Views/Checkout/Checkout.tsx @@ -0,0 +1,335 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { parseUrl } from 'query-string'; +import { WebView, WebViewNavigation } from '@metamask/react-native-webview'; +import { useNavigation } from '@react-navigation/native'; +import { Provider } from '@consensys/on-ramp-sdk'; +import { OrderOrderTypeEnum } from '@consensys/on-ramp-sdk/dist/API'; +import { useTheme } from '../../../../../../util/theme'; +import { getDepositNavbarOptions } from '../../../../Navbar'; +import { useRampSDK, SDK } from '../../sdk'; +import { + addFiatCustomIdData, + removeFiatCustomIdData, +} from '../../../../../../reducers/fiatOrders'; +import { CustomIdData } from '../../../../../../reducers/fiatOrders/types'; +import { + createNavigationDetails, + useParams, +} from '../../../../../../util/navigation/navUtils'; +import { aggregatorOrderToFiatOrder } from '../../orderProcessor/aggregator'; +import { createCustomOrderIdData } from '../../orderProcessor/customOrderId'; +import ScreenLayout from '../../components/ScreenLayout'; +import ErrorView from '../../components/ErrorView'; +import ErrorViewWithReporting from '../../components/ErrorViewWithReporting'; +import useAnalytics from '../../../hooks/useAnalytics'; +import { strings } from '../../../../../../../locales/i18n'; +import Routes from '../../../../../../constants/navigation/Routes'; +import useHandleSuccessfulOrder from '../../hooks/useHandleSuccessfulOrder'; +import Logger from '../../../../../../util/Logger'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import ButtonIcon, { + ButtonIconSizes, +} from '../../../../../../component-library/components/Buttons/ButtonIcon'; +import { + IconColor, + IconName, +} from '../../../../../../component-library/components/Icons/Icon'; +import { useStyles } from '../../../../../../component-library/hooks'; +import styleSheet from './Checkout.styles'; + +interface CheckoutParams { + url: string; + customOrderId?: string; + provider: Provider; +} + +export const createCheckoutNavDetails = createNavigationDetails( + Routes.RAMP.CHECKOUT, +); + +const CheckoutWebView = () => { + const { selectedAsset, selectedAddress, sdkError, callbackBaseUrl, isBuy } = + useRampSDK(); + const sheetRef = useRef(null); + const dispatch = useDispatch(); + const trackEvent = useAnalytics(); + const [error, setError] = useState(''); + const [customIdData, setCustomIdData] = useState(); + const [isRedirectionHandled, setIsRedirectionHandled] = useState(false); + const [key, setKey] = useState(0); + const navigation = useNavigation(); + const params = useParams(); + const theme = useTheme(); + const handleSuccessfulOrder = useHandleSuccessfulOrder(); + + const { styles } = useStyles(styleSheet, {}); + + const { url: uri, customOrderId, provider } = params; + + const handleCancelPress = useCallback(() => { + const chainId = selectedAsset?.network?.chainId || ''; + if (isBuy) { + trackEvent('ONRAMP_CANCELED', { + location: 'Provider Webview', + chain_id_destination: chainId, + provider_onramp: provider.name, + }); + } else { + trackEvent('OFFRAMP_CANCELED', { + location: 'Provider Webview', + chain_id_source: chainId, + provider_offramp: provider.name, + }); + } + }, [isBuy, provider.name, selectedAsset?.network?.chainId, trackEvent]); + + const handleClosePress = useCallback(() => { + handleCancelPress(); + sheetRef.current?.onCloseBottomSheet(); + }, [handleCancelPress]); + + useEffect(() => { + navigation.setOptions( + getDepositNavbarOptions( + navigation, + { title: provider.name }, + theme, + handleCancelPress, + ), + ); + }, [navigation, theme, handleCancelPress, provider.name]); + + useEffect(() => { + if ( + !customOrderId || + !selectedAsset?.network?.chainId || + !selectedAddress + ) { + return; + } + const customOrderIdData = createCustomOrderIdData( + customOrderId, + selectedAsset.network.chainId, + selectedAddress, + isBuy ? OrderOrderTypeEnum.Buy : OrderOrderTypeEnum.Sell, + ); + setCustomIdData(customOrderIdData); + dispatch(addFiatCustomIdData(customOrderIdData)); + }, [customOrderId, dispatch, isBuy, selectedAsset, selectedAddress]); + + const handleNavigationStateChange = async (navState: WebViewNavigation) => { + if ( + !isRedirectionHandled && + navState.url.startsWith(callbackBaseUrl) && + navState.loading === false + ) { + setIsRedirectionHandled(true); + try { + const parsedUrl = parseUrl(navState.url); + if (Object.keys(parsedUrl.query).length === 0) { + // There was no query params in the URL to parse + // Most likely the user clicked the X in Wyre widget + // @ts-expect-error navigation prop mismatch + navigation.dangerouslyGetParent()?.pop(); + return; + } + if (!selectedAddress) { + Logger.error(new Error('No address available for selected asset')); + setError( + strings( + 'fiat_on_ramp_aggregator.webview_error_no_address_provided', + ), + ); + return; + } + const orders = await SDK.orders(); + const getOrderFromCallbackMethod = isBuy + ? 'getOrderFromCallback' + : 'getSellOrderFromCallback'; + const order = await orders[getOrderFromCallbackMethod]( + provider.id, + navState?.url, + selectedAddress, + ); + + if (!order) { + const noOrderError = new Error( + `Order could not be retrieved. Callback was ${navState?.url}`, + ); + Logger.error(noOrderError); + throw noOrderError; + } + + if (customIdData) { + dispatch(removeFiatCustomIdData(customIdData)); + } + + const transformedOrder = { + ...aggregatorOrderToFiatOrder(order), + account: selectedAddress, + }; + + handleSuccessfulOrder(transformedOrder); + } catch (navStateError) { + setError((navStateError as Error)?.message); + } + } + }; + + if (sdkError) { + return ( + + + } + style={styles.headerWithoutPadding} + /> + + + + + + + ); + } + + if (error) { + return ( + + + } + style={styles.headerWithoutPadding} + /> + + + + { + setKey((prevKey) => prevKey + 1); + setError(''); + setIsRedirectionHandled(false); + }} + location={'Provider Webview'} + /> + + + + ); + } + + if (uri) { + return ( + + + } + style={styles.headerWithoutPadding} + /> + { + const { nativeEvent } = syntheticEvent; + if ( + nativeEvent.url === uri || + nativeEvent.url.startsWith(callbackBaseUrl) + ) { + const webviewHttpError = strings( + 'fiat_on_ramp_aggregator.webview_received_error', + { code: nativeEvent.statusCode }, + ); + setError(webviewHttpError); + } + }} + allowsInlineMediaPlayback + enableApplePay + paymentRequestEnabled + mediaPlaybackRequiresUserAction={false} + onNavigationStateChange={handleNavigationStateChange} + userAgent={provider?.features?.buy?.userAgent ?? undefined} + testID="checkout-webview" + /> + + ); + } + + return ( + + + } + style={styles.headerWithoutPadding} + /> + + + + + + + ); +}; + +export default CheckoutWebView; diff --git a/app/components/UI/Ramp/Aggregator/Views/Checkout/__snapshots__/Checkout.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/Checkout/__snapshots__/Checkout.test.tsx.snap new file mode 100644 index 00000000000..356b0cfd1aa --- /dev/null +++ b/app/components/UI/Ramp/Aggregator/Views/Checkout/__snapshots__/Checkout.test.tsx.snap @@ -0,0 +1,6866 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Checkout displays WebView when url is present and no errors 1`] = ` + + + + + + + + + + + + + Checkout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Checkout displays and tracks error if no url or errors 1`] = ` + + + + + + + + + + + + + Checkout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 󰅚 + + + + + Error + + + + + No URL was provided to continue + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Checkout displays sdkError when present 1`] = ` + + + + + + + + + + + + + Checkout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 󰅚 + + + + + Oops, something went wrong + + + + + SDK Error + + + + + + Return to Home Screen + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Checkout displays sell WebView when url is present and no errors 1`] = ` + + + + + + + + + + + + + Checkout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Checkout handles get order error gracefully 1`] = ` + + + + + + + + + + + + + Checkout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 󰅚 + + + + + Error + + + + + Get order error + + + + + + Try again + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Checkout handles undefined order gracefully 1`] = ` + + + + + + + + + + + + + Checkout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 󰅚 + + + + + Error + + + + + Order could not be retrieved. Callback was https://callback.test?success=true + + + + + + Try again + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Checkout ignores irrelevant error on http error in WebView for callback url 1`] = ` + + + + + + + + + + + + + Checkout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Checkout sets and displays error on http error in WebView 1`] = ` + + + + + + + + + + + + + Checkout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 󰅚 + + + + + Error + + + + + WebView received error status code: 500 + + + + + + Try again + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Checkout sets and displays error on http error in WebView for callback url 1`] = ` + + + + + + + + + + + + + Checkout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 󰅚 + + + + + Error + + + + + WebView received error status code: 500 + + + + + + Try again + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`Checkout sets error when handling url navigation state change and selectedAddress is undefined 1`] = ` + + + + + + + + + + + + + Checkout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 󰅚 + + + + + Error + + + + + No wallet address was provided to continue + + + + + + Try again + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/app/components/UI/Ramp/Aggregator/Views/Checkout/index.ts b/app/components/UI/Ramp/Aggregator/Views/Checkout/index.ts new file mode 100644 index 00000000000..959886b6735 --- /dev/null +++ b/app/components/UI/Ramp/Aggregator/Views/Checkout/index.ts @@ -0,0 +1 @@ +export { default } from './Checkout'; diff --git a/app/components/UI/Ramp/Aggregator/Views/GetStarted/GetStarted.test.tsx b/app/components/UI/Ramp/Aggregator/Views/GetStarted/GetStarted.test.tsx index a1944830271..03d04f8ffbe 100644 --- a/app/components/UI/Ramp/Aggregator/Views/GetStarted/GetStarted.test.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/GetStarted/GetStarted.test.tsx @@ -156,9 +156,9 @@ describe('GetStarted', () => { it('navigates and tracks event on cancel button press', async () => { render(GetStarted); - fireEvent.press(screen.getByRole('button', { name: 'Cancel' })); + fireEvent.press(screen.getByTestId('deposit-close-navbar-button')); expect(mockPop).toHaveBeenCalled(); - expect(mockTrackEvent).toBeCalledWith('ONRAMP_CANCELED', { + expect(mockTrackEvent).toHaveBeenCalledWith('ONRAMP_CANCELED', { chain_id_destination: '1', location: 'Get Started Screen', }); @@ -171,8 +171,8 @@ describe('GetStarted', () => { rampType: RampType.SELL, }; render(GetStarted); - fireEvent.press(screen.getByRole('button', { name: 'Cancel' })); - expect(mockTrackEvent).toBeCalledWith('OFFRAMP_CANCELED', { + fireEvent.press(screen.getByTestId('deposit-close-navbar-button')); + expect(mockTrackEvent).toHaveBeenCalledWith('OFFRAMP_CANCELED', { chain_id_source: '1', location: 'Get Started Screen', }); @@ -190,7 +190,7 @@ describe('GetStarted', () => { }; render(GetStarted); expect(mockReset).toBeCalledTimes(1); - expect(mockReset).toBeCalledWith({ + expect(mockReset).toHaveBeenCalledWith({ index: 0, routes: [ { diff --git a/app/components/UI/Ramp/Aggregator/Views/GetStarted/GetStarted.tsx b/app/components/UI/Ramp/Aggregator/Views/GetStarted/GetStarted.tsx index 6d1a7671733..4fc6aa23bc5 100644 --- a/app/components/UI/Ramp/Aggregator/Views/GetStarted/GetStarted.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/GetStarted/GetStarted.tsx @@ -4,7 +4,7 @@ import { useNavigation } from '@react-navigation/native'; import Text from '../../../../../Base/Text'; import StyledButton from '../../../../StyledButton'; import ScreenLayout from '../../components/ScreenLayout'; -import { getFiatOnRampAggNavbar } from '../../../../Navbar'; +import { getDepositNavbarOptions } from '../../../../Navbar'; import { strings } from '../../../../../../../locales/i18n'; import { useTheme } from '../../../../../../util/theme'; import { useRampSDK } from '../../sdk'; @@ -34,8 +34,7 @@ const GetStarted: React.FC = () => { const [isNetworkRampSupported] = useRampNetwork(); const trackEvent = useAnalytics(); const params = useParams(); - - const { colors } = useTheme(); + const theme = useTheme(); const handleCancelPress = useCallback(() => { const chainId = selectedAsset?.network?.chainId; @@ -62,17 +61,17 @@ const GetStarted: React.FC = () => { useEffect(() => { navigation.setOptions( - getFiatOnRampAggNavbar( + getDepositNavbarOptions( navigation, { title: strings('fiat_on_ramp_aggregator.onboarding.what_to_expect'), showBack: false, }, - colors, + theme, handleCancelPress, ), ); - }, [navigation, colors, handleCancelPress]); + }, [navigation, theme, handleCancelPress]); const handleOnPress = useCallback(() => { trackEvent( diff --git a/app/components/UI/Ramp/Aggregator/Views/GetStarted/__snapshots__/GetStarted.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/GetStarted/__snapshots__/GetStarted.test.tsx.snap index 96d826c3e5d..9b59d20cabc 100644 --- a/app/components/UI/Ramp/Aggregator/Views/GetStarted/__snapshots__/GetStarted.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/GetStarted/__snapshots__/GetStarted.test.tsx.snap @@ -58,12 +58,12 @@ exports[`GetStarted renders correctly 1`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -106,24 +106,7 @@ exports[`GetStarted renders correctly 1`] = ` pointerEvents="box-none" style={ { - "alignItems": "flex-start", - "bottom": 0, - "justifyContent": "center", - "left": 0, - "opacity": 1, - "position": "absolute", - "top": 0, - } - } - > - - - - - - Cancel - - + + + @@ -695,12 +736,12 @@ exports[`GetStarted renders correctly 2`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -743,24 +784,7 @@ exports[`GetStarted renders correctly 2`] = ` pointerEvents="box-none" style={ { - "alignItems": "flex-start", - "bottom": 0, - "justifyContent": "center", - "left": 0, - "opacity": 1, - "position": "absolute", - "top": 0, - } - } - > - - - - - - Cancel - - + + + @@ -1332,12 +1414,12 @@ exports[`GetStarted renders correctly when getStarted is true 1`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -1380,24 +1462,7 @@ exports[`GetStarted renders correctly when getStarted is true 1`] = ` pointerEvents="box-none" style={ { - "alignItems": "flex-start", - "bottom": 0, - "justifyContent": "center", - "left": 0, - "opacity": 1, - "position": "absolute", - "top": 0, - } - } - > - - - - - - Cancel - - + + + @@ -1750,12 +1873,12 @@ exports[`GetStarted renders correctly when sdkError is present 1`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -1798,24 +1921,7 @@ exports[`GetStarted renders correctly when sdkError is present 1`] = ` pointerEvents="box-none" style={ { - "alignItems": "flex-start", - "bottom": 0, - "justifyContent": "center", - "left": 0, - "opacity": 1, - "position": "absolute", - "top": 0, - } - } - > - - - - - - Cancel - - + + + diff --git a/app/components/UI/Ramp/Aggregator/Views/OrderDetails/OrderDetails.tsx b/app/components/UI/Ramp/Aggregator/Views/OrderDetails/OrderDetails.tsx index 514d967fd32..813842ced1a 100644 --- a/app/components/UI/Ramp/Aggregator/Views/OrderDetails/OrderDetails.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/OrderDetails/OrderDetails.tsx @@ -17,7 +17,7 @@ import { updateFiatOrder, } from '../../../../../../reducers/fiatOrders'; import { strings } from '../../../../../../../locales/i18n'; -import { getFiatOnRampAggNavbar } from '../../../../Navbar'; +import { getDepositNavbarOptions } from '../../../../Navbar'; import Routes from '../../../../../../constants/navigation/Routes'; import { processFiatOrder } from '../../../index'; import { @@ -55,7 +55,8 @@ const OrderDetails = () => { order?.state === FIAT_ORDER_STATES.CREATED, ); const [error, setError] = useState(null); - const { colors } = useTheme(); + const theme = useTheme(); + const { colors } = theme; const navigation = useNavigation(); const dispatch = useDispatch(); const dispatchThunk = useThunkDispatch(); @@ -66,17 +67,16 @@ const OrderDetails = () => { useEffect(() => { navigation.setOptions( - getFiatOnRampAggNavbar( + getDepositNavbarOptions( navigation, { title: strings('fiat_on_ramp_aggregator.order_details.details_main'), - showCancel: false, - showNetwork: false, + showClose: false, }, - colors, + theme, ), ); - }, [colors, navigation]); + }, [theme, navigation]); const navigateToSendTransaction = useCallback(() => { if (order?.id) { diff --git a/app/components/UI/Ramp/Aggregator/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap index 8f5f3b380b6..59e706e47db 100644 --- a/app/components/UI/Ramp/Aggregator/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/OrderDetails/__snapshots__/OrderDetails.test.tsx.snap @@ -58,12 +58,12 @@ exports[`OrderDetails renders a cancelled order 1`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -116,29 +116,87 @@ exports[`OrderDetails renders a cancelled order 1`] = ` } } > - - - Back - - + + + - - - @@ -1510,12 +1551,12 @@ exports[`OrderDetails renders a completed order 1`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -1568,29 +1609,87 @@ exports[`OrderDetails renders a completed order 1`] = ` } } > - - - Back - - + + + - - - @@ -2980,12 +3062,12 @@ exports[`OrderDetails renders a created order 1`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -3038,29 +3120,87 @@ exports[`OrderDetails renders a created order 1`] = ` } } > - - - Back - - + + + - - - @@ -4413,12 +4536,12 @@ exports[`OrderDetails renders a failed order 1`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -4471,29 +4594,87 @@ exports[`OrderDetails renders a failed order 1`] = ` } } > - - - Back - - + + + - - - @@ -5865,12 +6029,12 @@ exports[`OrderDetails renders a pending order 1`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -5923,29 +6087,87 @@ exports[`OrderDetails renders a pending order 1`] = ` } } > - - - Back - - + + + - - - @@ -7298,12 +7503,12 @@ exports[`OrderDetails renders an empty screen layout if there is no order 1`] = "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -7356,29 +7561,87 @@ exports[`OrderDetails renders an empty screen layout if there is no order 1`] = } } > - - - Back - - + + + - - - @@ -7716,12 +7962,12 @@ exports[`OrderDetails renders an error screen if a CREATED order cannot be polle "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -7774,29 +8020,87 @@ exports[`OrderDetails renders an error screen if a CREATED order cannot be polle } } > - - - Back - - + + + - - - @@ -8338,12 +8625,12 @@ exports[`OrderDetails renders non-transacted orders 1`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -8396,29 +8683,87 @@ exports[`OrderDetails renders non-transacted orders 1`] = ` } } > - - - Back - - + + + - - - @@ -9851,12 +10179,12 @@ exports[`OrderDetails renders the support links if the provider has them 1`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -9909,29 +10237,87 @@ exports[`OrderDetails renders the support links if the provider has them 1`] = ` } } > - - - Back - - + + + - - - @@ -11367,12 +11736,12 @@ exports[`OrderDetails renders transacted orders that do not have timeDescription "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -11425,29 +11794,87 @@ exports[`OrderDetails renders transacted orders that do not have timeDescription } } > - - - Back - - + + + - - - @@ -12800,12 +13210,12 @@ exports[`OrderDetails renders transacted orders that have timeDescriptionPending "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -12858,29 +13268,87 @@ exports[`OrderDetails renders transacted orders that have timeDescriptionPending } } > - - - Back - - + + + - - - diff --git a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.test.tsx b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.test.tsx index c18db76c438..d9fad040ee6 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.test.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.test.tsx @@ -232,9 +232,9 @@ describe('Quotes', () => { it('navigates and tracks event on cancel button press', async () => { render(Quotes); - fireEvent.press(screen.getByRole('button', { name: 'Cancel' })); + fireEvent.press(screen.getByTestId('deposit-close-navbar-button')); expect(mockPop).toHaveBeenCalled(); - expect(mockTrackEvent).toBeCalledWith('ONRAMP_CANCELED', { + expect(mockTrackEvent).toHaveBeenCalledWith('ONRAMP_CANCELED', { chain_id_destination: '1', location: 'Quotes Screen', results_count: @@ -251,8 +251,8 @@ describe('Quotes', () => { mockUseRampSDKValues.isSell = true; mockUseRampSDKValues.isBuy = false; render(Quotes); - fireEvent.press(screen.getByRole('button', { name: 'Cancel' })); - expect(mockTrackEvent).toBeCalledWith('OFFRAMP_CANCELED', { + fireEvent.press(screen.getByTestId('deposit-close-navbar-button')); + expect(mockTrackEvent).toHaveBeenCalledWith('OFFRAMP_CANCELED', { chain_id_source: '1', location: 'Quotes Screen', results_count: @@ -604,12 +604,12 @@ describe('Quotes', () => { }; await simulateCustomActionCtaPress(); - expect(createWidgetMock).toBeCalledWith( + expect(createWidgetMock).toHaveBeenCalledWith( mockUseRampSDKValues.callbackBaseUrl, ); expect(mockNavigate).toBeCalledTimes(1); - expect(mockNavigate).toBeCalledWith(Routes.RAMP.CHECKOUT, { + expect(mockNavigate).toHaveBeenCalledWith(Routes.RAMP.CHECKOUT, { provider: mockCustomAction.buy.provider, customOrderId: 'test-order-id', url: 'https://test-url.on-ramp.metamask', @@ -630,7 +630,7 @@ describe('Quotes', () => { await simulateCustomActionCtaPress(); - expect(mockRenderInAppBrowser).toBeCalledWith( + expect(mockRenderInAppBrowser).toHaveBeenCalledWith( mockedBuyAction, mockCustomAction.buy.provider, 50, @@ -646,7 +646,7 @@ describe('Quotes', () => { ProviderBuyFeatureBrowserEnum.AppBrowser, ); expect(mockNavigate).toBeCalledTimes(1); - expect(mockNavigate).toBeCalledWith(Routes.RAMP.CHECKOUT, { + expect(mockNavigate).toHaveBeenCalledWith(Routes.RAMP.CHECKOUT, { provider: mockedRecommendedQuote.provider, customOrderId: 'test-order-id', url: 'https://test-url.on-ramp.metamask', @@ -720,7 +720,7 @@ describe('Quotes', () => { ProviderBuyFeatureBrowserEnum.InAppOsBrowser, ); - expect(mockRenderInAppBrowser).toBeCalledWith( + expect(mockRenderInAppBrowser).toHaveBeenCalledWith( mockedBuyAction, mockedRecommendedQuote.provider, mockedRecommendedQuote.amountIn, diff --git a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.tsx b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.tsx index 4f3bccd9386..3ae77471264 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/Quotes/Quotes.tsx @@ -29,7 +29,7 @@ import Row from '../../components/Row'; import Quote from '../../components/Quote'; import CustomAction from '../../components/CustomAction'; import InfoAlert from '../../components/InfoAlert'; -import { getFiatOnRampAggNavbar } from '../../../../Navbar'; +import { getDepositNavbarOptions } from '../../../../Navbar'; import { ButtonSize, ButtonVariants, @@ -55,7 +55,7 @@ import { strings } from '../../../../../../../locales/i18n'; import LoadingAnimation from '../../components/LoadingAnimation'; import useInterval from '../../../../../hooks/useInterval'; import useInAppBrowser from '../../hooks/useInAppBrowser'; -import { createCheckoutNavDetails } from '../Checkout'; +import { createCheckoutNavDetails } from '../Checkout/Checkout'; import { PROVIDER_LINKS, ScreenLocation } from '../../types'; import Logger from '../../../../../../util/Logger'; import { isBuyQuote } from '../../utils'; @@ -579,14 +579,14 @@ function Quotes() { useEffect(() => { navigation.setOptions( - getFiatOnRampAggNavbar( + getDepositNavbarOptions( navigation, { title: strings('fiat_on_ramp_aggregator.select_a_quote') }, - theme.colors, + theme, handleCancelPress, ), ); - }, [navigation, theme.colors, handleCancelPress]); + }, [navigation, theme, handleCancelPress]); useEffect(() => { if (isFetchingQuotes) return; diff --git a/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap index 3231e1726a2..ca10793fccf 100644 --- a/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/Quotes/__snapshots__/Quotes.test.tsx.snap @@ -582,12 +582,12 @@ exports[`Quotes custom action renders correctly after animation with the recomme "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -640,29 +640,87 @@ exports[`Quotes custom action renders correctly after animation with the recomme } } > - - - Back - - + + + - - - Cancel - - + + + @@ -1588,12 +1704,12 @@ exports[`Quotes renders animation on first fetching 1`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -1646,29 +1762,87 @@ exports[`Quotes renders animation on first fetching 1`] = ` } } > - - - Back - - + + + - - - Cancel - - + + + @@ -2664,12 +2896,12 @@ exports[`Quotes renders correctly after animation with expanded quotes 2`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -2722,29 +2954,87 @@ exports[`Quotes renders correctly after animation with expanded quotes 2`] = ` } } > - - - Back - - + + + - - - Cancel - - + + + @@ -4454,12 +4802,12 @@ exports[`Quotes renders correctly after animation with the recommended quote 1`] "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -4512,29 +4860,87 @@ exports[`Quotes renders correctly after animation with the recommended quote 1`] } } > - - - Back - - + + + - - - Cancel - - + + + @@ -5610,12 +6074,12 @@ exports[`Quotes renders correctly after animation without quotes 1`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -5668,29 +6132,87 @@ exports[`Quotes renders correctly after animation without quotes 1`] = ` } } > - - - Back - - + + + - - - Cancel - - + + + @@ -6335,12 +6915,12 @@ exports[`Quotes renders correctly when fetching quotes errors 1`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -6393,29 +6973,87 @@ exports[`Quotes renders correctly when fetching quotes errors 1`] = ` } } > - - - Back - - + + + - - - Cancel - - + + + @@ -7060,12 +7756,12 @@ exports[`Quotes renders correctly with sdkError 1`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -7118,29 +7814,87 @@ exports[`Quotes renders correctly with sdkError 1`] = ` } } > - - - Back - - + + + - - - Cancel - - + + + @@ -7785,12 +8597,12 @@ exports[`Quotes renders quotes expired screen 1`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -7843,29 +8655,87 @@ exports[`Quotes renders quotes expired screen 1`] = ` } } > - - - Back - - + + + - - - Cancel - - + + + diff --git a/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.styles.ts b/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.styles.ts index 5b8583660c0..6727e76c893 100644 --- a/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.styles.ts +++ b/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.styles.ts @@ -6,6 +6,10 @@ const styleSheet = (_params: { theme: Theme }) => centered: { textAlign: 'center', }, + textRow: { + flexDirection: 'row', + gap: 4, + }, content: { flex: 1, justifyContent: 'center', diff --git a/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.tsx b/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.tsx index fb74e1aa383..f23f37f9699 100644 --- a/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/SendTransaction/SendTransaction.tsx @@ -44,7 +44,7 @@ import { getProviderName, setFiatSellTxHash, } from '../../../../../../reducers/fiatOrders'; -import { getFiatOnRampAggNavbar } from '../../../../Navbar'; +import { getDepositNavbarOptions } from '../../../../Navbar'; import { useParams } from '../../../../../../util/navigation/navUtils'; import { addHexPrefix, @@ -81,10 +81,8 @@ function SendTransaction() { const [isConfirming, setIsConfirming] = useState(false); - const { - styles, - theme: { colors, themeAppearance }, - } = useStyles(styleSheet, {}); + const { styles, theme } = useStyles(styleSheet, {}); + const { colors, themeAppearance } = theme; const orderData = order?.data as SellOrder; @@ -105,19 +103,18 @@ function SendTransaction() { useEffect(() => { navigation.setOptions( - getFiatOnRampAggNavbar( + getDepositNavbarOptions( navigation, { title: strings( 'fiat_on_ramp_aggregator.send_transaction.sell_crypto', ), - showCancel: false, - showNetwork: false, + showClose: false, }, - colors, + theme, ), ); - }, [colors, navigation]); + }, [theme, navigation]); const transactionAnalyticsPayload = useMemo( () => ({ @@ -255,11 +252,9 @@ function SendTransaction() { - + - {strings( - 'fiat_on_ramp_aggregator.send_transaction.send', - )}{' '} + {strings('fiat_on_ramp_aggregator.send_transaction.send')} {fromTokenMinimalUnitString( @@ -268,18 +263,18 @@ function SendTransaction() { orderData.cryptoCurrency.decimals, ).toString(), orderData.cryptoCurrency.decimals, - )}{' '} + )} {' '} + /> {order.cryptocurrency} - + diff --git a/app/components/UI/Ramp/Aggregator/Views/SendTransaction/__snapshots__/SendTransaction.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/SendTransaction/__snapshots__/SendTransaction.test.tsx.snap index 5eb1c70101c..622768e4403 100644 --- a/app/components/UI/Ramp/Aggregator/Views/SendTransaction/__snapshots__/SendTransaction.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/SendTransaction/__snapshots__/SendTransaction.test.tsx.snap @@ -58,12 +58,12 @@ exports[`SendTransaction View renders correctly 1`] = ` "borderBottomColor": "rgb(216, 216, 216)", "elevation": 0, "flex": 1, - "shadowColor": "transparent", + "shadowColor": "rgb(216, 216, 216)", "shadowOffset": { "height": 0.5, "width": 0, }, - "shadowOpacity": 0.85, + "shadowOpacity": 0, "shadowRadius": 0, } } @@ -116,29 +116,87 @@ exports[`SendTransaction View renders correctly 1`] = ` } } > - - - Back - - + + + - - - @@ -449,16 +490,11 @@ exports[`SendTransaction View renders correctly 1`] = ` ] } > - @@ -476,7 +512,6 @@ exports[`SendTransaction View renders correctly 1`] = ` } > Send - 0.012361263 - - ETH - + - - - Back - - + + + - - - @@ -1233,16 +1307,11 @@ exports[`SendTransaction View renders correctly for custom action payment method ] } > - @@ -1260,7 +1329,6 @@ exports[`SendTransaction View renders correctly for custom action payment method } > Send - 0.0123456 - - USDC - + - - - Back - - + + + - - - @@ -1935,16 +2042,11 @@ exports[`SendTransaction View renders correctly for token 1`] = ` ] } > - @@ -1962,7 +2064,6 @@ exports[`SendTransaction View renders correctly for token 1`] = ` } > Send - 0.0123456 - - USDC - + ( detachPreviousScreen: false, }} /> - + ); diff --git a/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/__snapshots__/AdditionalVerification.test.tsx.snap b/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/__snapshots__/AdditionalVerification.test.tsx.snap index 9959d379ea1..fe54a783c20 100644 --- a/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/__snapshots__/AdditionalVerification.test.tsx.snap +++ b/app/components/UI/Ramp/Deposit/Views/AdditionalVerification/__snapshots__/AdditionalVerification.test.tsx.snap @@ -313,7 +313,7 @@ exports[`AdditionalVerification Component render matches snapshot 1`] = ` }, ] } - testID="button-icon" + testID="deposit-close-navbar-button" > StyleSheet.create({ @@ -184,6 +191,11 @@ export default class AppInformation extends PureComponent { const colors = this.context.colors || mockTheme.colors; const styles = createStyles(colors); + const otaUpdateMessage = + __DEV__ || isEmbeddedLaunch + ? 'This app is running from built-in code or in development mode' + : 'This app is running an update'; + return ( - {this.state.appInfo} + + {getFullVersion(this.state.appInfo)} + {isQa ? ( {`Branch: ${process.env['GIT_BRANCH']}`} ) : null} - {this.state.showEnvironmentInfo ? ( + {this.state.showEnvironmentInfo && ( <> {`Environment: ${process.env.METAMASK_ENVIRONMENT}`} - {`Remote Feature Flag Env: ${getFeatureFlagAppEnvironment()}`} {`Remote Feature Flag Distribution: ${getFeatureFlagAppDistribution()}`} + + {`OTA Updates enabled: ${String(isOTAUpdatesEnabled)}`} + + {isOTAUpdatesEnabled && ( + <> + + {`OTA Update Channel: ${channel}`} + + + {`OTA Update runtime version: ${runtimeVersion}`} + + + {`OTA Update status: ${otaUpdateMessage}`} + + + )} - ) : null} + )} {strings('app_information.links')} diff --git a/app/constants/deeplinks.ts b/app/constants/deeplinks.ts index 1fc6c939323..d1cc02049b8 100644 --- a/app/constants/deeplinks.ts +++ b/app/constants/deeplinks.ts @@ -38,6 +38,7 @@ export enum ACTIONS { PERPS_MARKETS = 'perps-markets', PERPS_ASSET = 'perps-asset', REWARDS = 'rewards', + PREDICT = 'predict', ONBOARDING = 'onboarding', } @@ -63,6 +64,7 @@ export const PREFIXES = { [ACTIONS.PERPS_MARKETS]: '', [ACTIONS.PERPS_ASSET]: '', [ACTIONS.REWARDS]: '', + [ACTIONS.PREDICT]: '', [ACTIONS.ONBOARDING]: '', [ACTIONS.ENABLE_CARD_BUTTON]: '', METAMASK: 'metamask://', diff --git a/app/constants/ota.ts b/app/constants/ota.ts new file mode 100644 index 00000000000..2a43a7d6a30 --- /dev/null +++ b/app/constants/ota.ts @@ -0,0 +1,21 @@ +/** + * OTA (Over-The-Air) Update Version Tracking + * + * Re-exports from the root ota.config.js file (single source of truth). + * To update versions, edit ota.config.js at the project root. + */ + +import otaConfig from '../../ota.config.js'; + +export const OTA_VERSION = otaConfig.OTA_VERSION; +export const RUNTIME_VERSION = otaConfig.RUNTIME_VERSION; + +/** + * Gets the full version string including OTA version + * @param appVersion - The app version from package.json/device info + * @returns Full version string (e.g., "7.58.0 OTA Version: v3") + */ +export const getFullVersion = (appVersion: string): string => + process.env.METAMASK_ENVIRONMENT !== 'production' && OTA_VERSION !== 'v0' + ? `${appVersion} OTA: ${OTA_VERSION}` + : `${appVersion}`; diff --git a/app/core/DeeplinkManager/DeeplinkManager.ts b/app/core/DeeplinkManager/DeeplinkManager.ts index 1663638462c..ed3dfc6bb4f 100644 --- a/app/core/DeeplinkManager/DeeplinkManager.ts +++ b/app/core/DeeplinkManager/DeeplinkManager.ts @@ -24,6 +24,7 @@ import { handleDeeplink } from './Handlers/handleDeeplink'; import SharedDeeplinkManager from './SharedDeeplinkManager'; import FCMService from '../../util/notifications/services/FCMService'; import { handleRewardsUrl } from './Handlers/handleRewardsUrl'; +import { handlePredictUrl } from './Handlers/handlePredictUrl'; import handleFastOnboarding from './Handlers/handleFastOnboarding'; import { handleEnableCardButton } from './Handlers/handleEnableCardButton'; @@ -138,6 +139,12 @@ class DeeplinkManager { }); } + _handlePredict(predictPath: string) { + handlePredictUrl({ + predictPath, + }); + } + // NOTE: keeping this for backwards compatibility _handleOpenSwap() { this.navigation.navigate(Routes.SWAPS); diff --git a/app/core/DeeplinkManager/Handlers/handlePredictUrl.test.ts b/app/core/DeeplinkManager/Handlers/handlePredictUrl.test.ts new file mode 100644 index 00000000000..626a6d8cc79 --- /dev/null +++ b/app/core/DeeplinkManager/Handlers/handlePredictUrl.test.ts @@ -0,0 +1,303 @@ +import { handlePredictUrl } from './handlePredictUrl'; +import NavigationService from '../../NavigationService'; +import Routes from '../../../constants/navigation/Routes'; +import DevLogger from '../../SDKConnect/utils/DevLogger'; + +// Mock dependencies +jest.mock('../../NavigationService'); +jest.mock('../../SDKConnect/utils/DevLogger'); + +describe('handlePredictUrl', () => { + let mockNavigate: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup navigation mocks + mockNavigate = jest.fn(); + NavigationService.navigation = { + navigate: mockNavigate, + } as unknown as typeof NavigationService.navigation; + + // Mock DevLogger + (DevLogger.log as jest.Mock) = jest.fn(); + }); + + describe('with market parameter', () => { + it('navigates to market details when market parameter is provided', async () => { + await handlePredictUrl({ predictPath: '?market=23246' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: '23246', + entryPoint: 'deeplink', + }, + }); + }); + + it('navigates to market details when marketId parameter is provided', async () => { + await handlePredictUrl({ predictPath: '?marketId=12345' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: '12345', + entryPoint: 'deeplink', + }, + }); + }); + + it('prioritizes market parameter over marketId when both are provided', async () => { + await handlePredictUrl({ + predictPath: '?market=23246&marketId=99999', + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: '23246', + entryPoint: 'deeplink', + }, + }); + }); + + it('extracts market ID from full URL path', async () => { + await handlePredictUrl({ predictPath: 'predict?market=abc789' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: 'abc789', + entryPoint: 'deeplink', + }, + }); + }); + + it('handles multiple URL parameters', async () => { + await handlePredictUrl({ + predictPath: '?market=xyz123&utm_source=campaign&debug=true', + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: 'xyz123', + entryPoint: 'deeplink', + }, + }); + }); + + it('handles numeric market IDs', async () => { + await handlePredictUrl({ predictPath: '?market=9876543210' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: '9876543210', + entryPoint: 'deeplink', + }, + }); + }); + + it('handles alphanumeric market IDs', async () => { + await handlePredictUrl({ predictPath: '?market=abc-123-xyz' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: 'abc-123-xyz', + entryPoint: 'deeplink', + }, + }); + }); + }); + + describe('without market parameter', () => { + it('navigates to market list when no parameters provided', async () => { + await handlePredictUrl({ predictPath: '' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + }); + }); + + it('navigates to market list when URL has no query parameters', async () => { + await handlePredictUrl({ predictPath: 'predict' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + }); + }); + + it('navigates to market list when market parameter is empty', async () => { + await handlePredictUrl({ predictPath: '?market=' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + }); + }); + + it('navigates to market list when marketId parameter is empty', async () => { + await handlePredictUrl({ predictPath: '?marketId=' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + }); + }); + + it('navigates to market list when only other parameters provided', async () => { + await handlePredictUrl({ predictPath: '?utm_source=campaign' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + }); + }); + }); + + describe('error handling', () => { + it('falls back to market list when navigation fails', async () => { + mockNavigate.mockImplementationOnce(() => { + throw new Error('Navigation error'); + }); + + await handlePredictUrl({ predictPath: '?market=23246' }); + + expect(mockNavigate).toHaveBeenCalledTimes(2); + expect(mockNavigate).toHaveBeenLastCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + }); + }); + + it('falls back to market list when market details navigation throws', async () => { + mockNavigate.mockImplementationOnce(() => { + throw new Error('Market details navigation error'); + }); + + await handlePredictUrl({ predictPath: '?marketId=invalid' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + }); + }); + + it('handles malformed URL parameters gracefully', async () => { + await handlePredictUrl({ + predictPath: 'predict?invalid¶ms&here', + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + }); + }); + + it('logs error details when handling fails', async () => { + const testError = new Error('Test error'); + mockNavigate.mockImplementationOnce(() => { + throw testError; + }); + + await handlePredictUrl({ predictPath: '?market=23246' }); + + expect(DevLogger.log).toHaveBeenCalledWith( + 'Failed to handle predict deeplink:', + testError, + ); + }); + }); + + describe('logging', () => { + it('logs start of deeplink handling with path', async () => { + await handlePredictUrl({ predictPath: '?market=23246' }); + + expect(DevLogger.log).toHaveBeenCalledWith( + '[handlePredictUrl] Starting predict deeplink handling with path:', + '?market=23246', + ); + }); + + it('logs parsed navigation parameters', async () => { + await handlePredictUrl({ predictPath: '?market=23246' }); + + expect(DevLogger.log).toHaveBeenCalledWith( + '[handlePredictUrl] Parsed navigation parameters:', + { market: '23246' }, + ); + }); + + it('logs navigation to market details', async () => { + await handlePredictUrl({ predictPath: '?market=23246' }); + + expect(DevLogger.log).toHaveBeenCalledWith( + '[handlePredictUrl] Navigating to market details for market:', + '23246', + ); + }); + + it('logs navigation to market list when no market provided', async () => { + await handlePredictUrl({ predictPath: '' }); + + expect(DevLogger.log).toHaveBeenCalledWith( + '[handlePredictUrl] No market parameter, showing list', + ); + }); + + it('logs fallback when market ID is empty', async () => { + await handlePredictUrl({ predictPath: '?market=' }); + + expect(DevLogger.log).toHaveBeenCalledWith( + '[handlePredictUrl] No market parameter, showing list', + ); + }); + }); + + describe('URL parsing edge cases', () => { + it('handles URL with question mark but no parameters', async () => { + await handlePredictUrl({ predictPath: 'predict?' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + }); + }); + + it('handles URL with multiple question marks', async () => { + await handlePredictUrl({ predictPath: 'predict?market=123?extra=param' }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: '123', + entryPoint: 'deeplink', + }, + }); + }); + + it('handles URL with special characters in market ID', async () => { + await handlePredictUrl({ + predictPath: '?market=test_market-123', + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: 'test_market-123', + entryPoint: 'deeplink', + }, + }); + }); + + it('handles URL with encoded parameters', async () => { + await handlePredictUrl({ + predictPath: '?market=test%20market', + }); + + expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.MODALS.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId: 'test market', + entryPoint: 'deeplink', + }, + }); + }); + }); +}); diff --git a/app/core/DeeplinkManager/Handlers/handlePredictUrl.ts b/app/core/DeeplinkManager/Handlers/handlePredictUrl.ts new file mode 100644 index 00000000000..01bafafe3a9 --- /dev/null +++ b/app/core/DeeplinkManager/Handlers/handlePredictUrl.ts @@ -0,0 +1,116 @@ +import NavigationService from '../../NavigationService'; +import Routes from '../../../constants/navigation/Routes'; +import DevLogger from '../../SDKConnect/utils/DevLogger'; + +interface HandlePredictUrlParams { + predictPath: string; +} + +/** + * Interface for parsed predict navigation parameters + */ +interface PredictNavigationParams { + market?: string; // Market ID +} + +/** + * Parse URL parameters into typed navigation parameters + * @param predictPath The predict URL path with query parameters + * @returns Typed navigation parameters + */ +const parsePredictNavigationParams = ( + predictPath: string, +): PredictNavigationParams => { + const urlParams = new URLSearchParams( + predictPath.includes('?') ? predictPath.split('?')[1] : '', + ); + + // Support both 'market' and 'marketId' parameter names + const marketId = urlParams.get('market') || urlParams.get('marketId'); + + return { + market: marketId || undefined, + }; +}; + +/** + * Handle market-specific navigation + * @param marketId The market ID to navigate to + */ +const handleMarketNavigation = (marketId: string) => { + DevLogger.log( + '[handlePredictUrl] Navigating to market details for market:', + marketId, + ); + + if (!marketId) { + DevLogger.log( + '[handlePredictUrl] No market ID provided, fallback to market list', + ); + NavigationService.navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + }); + return; + } + + // Navigate to market details with the market ID + // Note: Market details is under MODALS.ROOT, not the main ROOT + NavigationService.navigation.navigate(Routes.PREDICT.MODALS.ROOT, { + screen: Routes.PREDICT.MARKET_DETAILS, + params: { + marketId, + entryPoint: 'deeplink', + }, + }); +}; + +/** + * Predict deeplink handler + * + * @param params Object containing the predict path + * + * Supported URL formats: + * - https://metamask.app.link/predict + * - https://metamask.app.link/predict?market=23246 + * - https://metamask.app.link/predict?marketId=23246 + * - https://link.metamask.io/predict?market=23246 + * - https://link.metamask.io/predict?marketId=23246 + * + * Navigation behavior: + * - No market param: Navigate to market list + * - market=X or marketId=X: Navigate directly to market details for market X + */ +export const handlePredictUrl = async ({ + predictPath, +}: HandlePredictUrlParams) => { + DevLogger.log( + '[handlePredictUrl] Starting predict deeplink handling with path:', + predictPath, + ); + + try { + // Parse navigation parameters from URL + const navParams = parsePredictNavigationParams(predictPath); + DevLogger.log( + '[handlePredictUrl] Parsed navigation parameters:', + navParams, + ); + + // If market ID is provided, navigate to market details + if (navParams.market) { + handleMarketNavigation(navParams.market); + } else { + // Default to market list + DevLogger.log('[handlePredictUrl] No market parameter, showing list'); + NavigationService.navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + }); + } + } catch (error) { + DevLogger.log('Failed to handle predict deeplink:', error); + // Fallback to market list on error + NavigationService.navigation.navigate(Routes.PREDICT.ROOT, { + screen: Routes.PREDICT.MARKET_LIST, + }); + } +}; diff --git a/app/core/DeeplinkManager/ParseManager/handleUniversalLink.test.ts b/app/core/DeeplinkManager/ParseManager/handleUniversalLink.test.ts index 5eba45b5f6e..663a2bc429c 100644 --- a/app/core/DeeplinkManager/ParseManager/handleUniversalLink.test.ts +++ b/app/core/DeeplinkManager/ParseManager/handleUniversalLink.test.ts @@ -49,6 +49,7 @@ describe('handleUniversalLinks', () => { const mockHandleCreateAccount = jest.fn(); const mockHandlePerps = jest.fn(); const mockHandleRewards = jest.fn(); + const mockHandlePredict = jest.fn(); const mockHandleFastOnboarding = jest.fn(); const mockHandleEnableCardButton = jest.fn(); const mockConnectToChannel = jest.fn(); @@ -77,6 +78,7 @@ describe('handleUniversalLinks', () => { _handleCreateAccount: mockHandleCreateAccount, _handlePerps: mockHandlePerps, _handleRewards: mockHandleRewards, + _handlePredict: mockHandlePredict, _handleFastOnboarding: mockHandleFastOnboarding, _handleEnableCardButton: mockHandleEnableCardButton, } as unknown as DeeplinkManager; @@ -581,6 +583,101 @@ describe('handleUniversalLinks', () => { }); }); + describe('ACTIONS.PREDICT', () => { + it('calls _handlePredict when action is PREDICT without market parameter', async () => { + const predictUrl = `${PROTOCOLS.HTTPS}://${AppConstants.MM_UNIVERSAL_LINK_HOST}/${ACTIONS.PREDICT}`; + const predictUrlObj = { + ...urlObj, + hostname: AppConstants.MM_UNIVERSAL_LINK_HOST, + href: predictUrl, + pathname: `/${ACTIONS.PREDICT}`, + }; + + await handleUniversalLink({ + instance, + handled, + urlObj: predictUrlObj, + browserCallBack: mockBrowserCallBack, + url: predictUrl, + source: 'test-source', + }); + + expect(handled).toHaveBeenCalled(); + expect(mockHandlePredict).toHaveBeenCalledWith(''); + }); + + it('calls _handlePredict when action is PREDICT with market parameter', async () => { + const predictUrl = `${PROTOCOLS.HTTPS}://${AppConstants.MM_UNIVERSAL_LINK_HOST}/${ACTIONS.PREDICT}?market=23246`; + const predictUrlObj = { + ...urlObj, + hostname: AppConstants.MM_UNIVERSAL_LINK_HOST, + href: predictUrl, + pathname: `/${ACTIONS.PREDICT}`, + search: '?market=23246', + }; + + await handleUniversalLink({ + instance, + handled, + urlObj: predictUrlObj, + browserCallBack: mockBrowserCallBack, + url: predictUrl, + source: 'test-source', + }); + + expect(handled).toHaveBeenCalled(); + expect(mockHandlePredict).toHaveBeenCalledWith('?market=23246'); + }); + + it('calls _handlePredict when action is PREDICT with marketId parameter', async () => { + const predictUrl = `${PROTOCOLS.HTTPS}://${AppConstants.MM_UNIVERSAL_LINK_HOST}/${ACTIONS.PREDICT}?marketId=12345`; + const predictUrlObj = { + ...urlObj, + hostname: AppConstants.MM_UNIVERSAL_LINK_HOST, + href: predictUrl, + pathname: `/${ACTIONS.PREDICT}`, + search: '?marketId=12345', + }; + + await handleUniversalLink({ + instance, + handled, + urlObj: predictUrlObj, + browserCallBack: mockBrowserCallBack, + url: predictUrl, + source: 'test-source', + }); + + expect(handled).toHaveBeenCalled(); + expect(mockHandlePredict).toHaveBeenCalledWith('?marketId=12345'); + }); + + it('calls _handlePredict with full query string when multiple parameters present', async () => { + const predictUrl = `${PROTOCOLS.HTTPS}://${AppConstants.MM_UNIVERSAL_LINK_HOST}/${ACTIONS.PREDICT}?market=23246&utm_source=campaign`; + const predictUrlObj = { + ...urlObj, + hostname: AppConstants.MM_UNIVERSAL_LINK_HOST, + href: predictUrl, + pathname: `/${ACTIONS.PREDICT}`, + search: '?market=23246&utm_source=campaign', + }; + + await handleUniversalLink({ + instance, + handled, + urlObj: predictUrlObj, + browserCallBack: mockBrowserCallBack, + url: predictUrl, + source: 'test-source', + }); + + expect(handled).toHaveBeenCalled(); + expect(mockHandlePredict).toHaveBeenCalledWith( + '?market=23246&utm_source=campaign', + ); + }); + }); + describe('ACTIONS.WC', () => { const testCases = [ { diff --git a/app/core/DeeplinkManager/ParseManager/handleUniversalLink.ts b/app/core/DeeplinkManager/ParseManager/handleUniversalLink.ts index 26837323915..ce44e0c1612 100644 --- a/app/core/DeeplinkManager/ParseManager/handleUniversalLink.ts +++ b/app/core/DeeplinkManager/ParseManager/handleUniversalLink.ts @@ -36,6 +36,7 @@ enum SUPPORTED_ACTIONS { PERPS_MARKETS = ACTIONS.PERPS_MARKETS, PERPS_ASSET = ACTIONS.PERPS_ASSET, REWARDS = ACTIONS.REWARDS, + PREDICT = ACTIONS.PREDICT, WC = ACTIONS.WC, ONBOARDING = ACTIONS.ONBOARDING, ENABLE_CARD_BUTTON = ACTIONS.ENABLE_CARD_BUTTON, @@ -257,6 +258,9 @@ async function handleUniversalLink({ } else if (action === SUPPORTED_ACTIONS.REWARDS) { const rewardsPath = urlObj.href.replace(BASE_URL_ACTION, ''); instance._handleRewards(rewardsPath); + } else if (action === SUPPORTED_ACTIONS.PREDICT) { + const predictPath = urlObj.href.replace(BASE_URL_ACTION, ''); + instance._handlePredict(predictPath); } else if (action === SUPPORTED_ACTIONS.WC) { const { params } = extractURLParams(urlObj.href); const wcURL = params?.uri; diff --git a/appwright/tests/performance/login/asset-balances.spec.js b/appwright/tests/performance/login/asset-balances.spec.js new file mode 100644 index 00000000000..b94bda82ce0 --- /dev/null +++ b/appwright/tests/performance/login/asset-balances.spec.js @@ -0,0 +1,134 @@ +import { test } from '../../../fixtures/performance-test.js'; + +import TimerHelper from '../../../utils/TimersHelper.js'; +import WalletMainScreen from '../../../../wdio/screen-objects/WalletMainScreen.js'; +import TabBarModal from '../../../../wdio/screen-objects/Modals/TabBarModal.js'; +import LoginScreen from '../../../../wdio/screen-objects/LoginScreen.js'; +import { login } from '../../../utils/Flows.js'; + +/** + * Scenario: Aggregated Balance Loading Time + * + * This test measures the time it takes for the aggregated balance to load and stabilize + * on the wallet home screen. As tokens are progressively loaded, the balance updates: + * Example: $0.00 → $2.15 → $30.25 → stable + * + * Method: Poll the balance text directly from the UI until it stops changing + * Measured: Multiple timing metrics: + * 1. Total time: navigation to wallet tab → final stable balance + * 2. Render time: first UI render → final stable balance + * 3. Time to first non-zero balance + * + * Stability: Balance is considered stable after showing the same value for 3 consecutive checks (100ms apart) + */ +test('Asset Balances - Aggregated Balance Loading Time', async ({ + device, + performanceTracker, +}, testInfo) => { + // Set device for screen objects + WalletMainScreen.device = device; + TabBarModal.device = device; + LoginScreen.device = device; + + // Login to the wallet - this triggers balance loading + await login(device); + + // Create timer that will track the entire balance loading process + const balanceLoadingTimer = new TimerHelper( + 'Aggregated balance loading time (navigation to stable value)', + ); + + // Navigate to wallet tab and START TIMING + balanceLoadingTimer.start(); + const navigationStartTime = Date.now(); + await TabBarModal.tapWalletButton(); + + // Get the balance container element + const balanceContainer = await WalletMainScreen.balanceContainer; + + // Helper function to get the current balance text + const getBalanceText = async () => { + try { + const text = await balanceContainer.getText(); + return text || '$0.00'; + } catch { + return '$0.00'; + } + }; + + const balanceHistory = []; + let stableCount = 0; + let previousBalance = ''; + const maxWaitTime = 30000; // 30 seconds max + const pollInterval = 100; // Check every 100ms for better granularity + + // Wait for balance container to be visible (Playwright auto-waits on getText) + // Just mark when we start polling as the first render time + const firstRenderTime = Date.now(); + + // Poll until balance stabilizes (same value for 3 consecutive checks) + while (stableCount < 3) { + if (Date.now() - navigationStartTime > maxWaitTime) { + throw new Error('Timeout waiting for balance to stabilize'); + } + + const currentBalance = await getBalanceText(); + const now = Date.now(); + const elapsedSinceNavigation = now - navigationStartTime; + const elapsedSinceFirstRender = now - firstRenderTime; + + // Track all balance values we see + if (currentBalance !== previousBalance) { + balanceHistory.push({ + balance: currentBalance, + timestamp: now, + elapsedSinceNavigation, + elapsedSinceFirstRender, + }); + stableCount = 0; // Reset stability counter + } else { + stableCount++; + } + + previousBalance = currentBalance; + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + // STOP TIMING when balance stabilizes + balanceLoadingTimer.stop(); + + // Calculate different timing metrics + const firstUpdate = balanceHistory[0]; + const lastUpdate = balanceHistory[balanceHistory.length - 1]; + + // Time from navigation to final stable value + const totalLoadingDuration = lastUpdate.elapsedSinceNavigation; + + // Time from first render to final stable value + const renderToStableDuration = lastUpdate.elapsedSinceFirstRender; + + // Find first non-zero balance update + const firstNonZeroUpdate = balanceHistory.find( + (entry) => entry.balance !== '$0.00' && entry.balance !== '', + ); + const timeToFirstNonZero = firstNonZeroUpdate + ? firstNonZeroUpdate.elapsedSinceNavigation + : 0; + + // Add timer to performance tracker + performanceTracker.addTimer(balanceLoadingTimer); + + // Attach balance history as custom metrics + testInfo.annotations.push({ + type: 'balance-progression', + description: JSON.stringify({ + updates: balanceHistory, + timeToFirstNonZero, + totalLoadingDuration, + renderToStableDuration, + }), + }); + + // Attach performance metrics to test + await performanceTracker.attachToTest(testInfo); +}); diff --git a/index.js b/index.js index 04fe4d23286..8bc48024541 100644 --- a/index.js +++ b/index.js @@ -8,6 +8,9 @@ import 'react-native-gesture-handler'; // why-did-you-render must run as early as possible (after gesture-handler) in dev import './wdyr'; +// Required for EAS Updates to resolve assets (.riv, .png, etc.) from OTA bundles +import 'expo-asset'; + import * as Sentry from '@sentry/react-native'; // eslint-disable-line import/no-namespace import { setupSentry } from './app/util/sentry/utils'; import { AppRegistry, LogBox } from 'react-native'; diff --git a/ios/Expo.plist b/ios/Expo.plist new file mode 100644 index 00000000000..bddb3405b67 --- /dev/null +++ b/ios/Expo.plist @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index fed78a99e09..7e5f4e4ca7a 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -88,6 +88,9 @@ CF9895782A3B49BE00B4C9B5 /* RCTMinimizer.m in Sources */ = {isa = PBXBuildFile; fileRef = CF9895762A3B49BE00B4C9B5 /* RCTMinimizer.m */; }; CF98DA9C28D9FEB700096782 /* RCTScreenshotDetect.m in Sources */ = {isa = PBXBuildFile; fileRef = CF98DA9B28D9FEB700096782 /* RCTScreenshotDetect.m */; }; CFD8DFC828EDD4C800CC75F6 /* RCTScreenshotDetect.m in Sources */ = {isa = PBXBuildFile; fileRef = CF98DA9B28D9FEB700096782 /* RCTScreenshotDetect.m */; }; + E4B580722E32F462008165E1 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = E4B580712E32F462008165E1 /* Expo.plist */; }; + E4B580732E32F462008165E1 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = E4B580712E32F462008165E1 /* Expo.plist */; }; + E4B580742E32F462008165E1 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = E4B580712E32F462008165E1 /* Expo.plist */; }; E83DB5522BBDF2AA00536063 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = E83DB5392BBDB14700536063 /* PrivacyInfo.xcprivacy */; }; E83DB5532BBDF2AE00536063 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = E83DB5392BBDB14700536063 /* PrivacyInfo.xcprivacy */; }; E83DB5542BBDF2AF00536063 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = E83DB5392BBDB14700536063 /* PrivacyInfo.xcprivacy */; }; @@ -240,6 +243,7 @@ D2632307C64595BE1B8ABEAF /* libPods-MetaMask.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-MetaMask.a"; sourceTree = BUILT_PRODUCTS_DIR; }; D3350113F0764105B1E60002 /* MM Sans Bold.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = "MM Sans Bold.otf"; path = "../app/fonts/MM Sans Bold.otf"; sourceTree = ""; }; DCB5FECA5557491AB06DBCBE /* Geist Bold.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = "Geist Bold.otf"; path = "../app/fonts/Geist Bold.otf"; sourceTree = ""; }; + E4B580712E32F462008165E1 /* Expo.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; E7EEA32C976A46B991D55FD4 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-MetaMask-QA/ExpoModulesProvider.swift"; sourceTree = ""; }; E83DB5392BBDB14700536063 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = MetaMask/PrivacyInfo.xcprivacy; sourceTree = SOURCE_ROOT; }; E9629905BA1940ADA4189921 /* Feather.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = Feather.ttf; path = "../node_modules/react-native-vector-icons/Fonts/Feather.ttf"; sourceTree = ""; }; @@ -321,6 +325,7 @@ 13B07FAE1A68108700A75B9A /* MetaMask */ = { isa = PBXGroup; children = ( + E4B580712E32F462008165E1 /* Expo.plist */, E83DB5392BBDB14700536063 /* PrivacyInfo.xcprivacy */, B339FEA72899852C001B89FB /* MetaMask-QA-Info.plist */, AA9EDF17249955C7005D89EE /* MetaMaskDebug.entitlements */, @@ -693,6 +698,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + E4B580742E32F462008165E1 /* Expo.plist in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 158B063B211A72F500DF3C74 /* InpageBridgeWeb3.js in Resources */, 15D158ED210BD912006982B5 /* Metamask.ttf in Resources */, @@ -721,6 +727,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + E4B580722E32F462008165E1 /* Expo.plist in Resources */, 2EF2826A2B0FF86900D7B4B1 /* Images.xcassets in Resources */, 2EF2826B2B0FF86900D7B4B1 /* InpageBridgeWeb3.js in Resources */, 2EF2826C2B0FF86900D7B4B1 /* Metamask.ttf in Resources */, @@ -738,6 +745,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + E4B580732E32F462008165E1 /* Expo.plist in Resources */, B339FF10289ABD70001B89FB /* Images.xcassets in Resources */, B339FF11289ABD70001B89FB /* InpageBridgeWeb3.js in Resources */, B339FF12289ABD70001B89FB /* Metamask.ttf in Resources */, diff --git a/ios/Podfile b/ios/Podfile index 9ee9351c1a2..26590b1aec2 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -101,6 +101,7 @@ def common_target_logic # An absolute path to your application root. :app_path => "#{Pod::Config.instance.installation_root}/..", :privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false', + :hermes_enabled => true ) end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 618aad0540d..e47705a0baa 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -7,6 +7,8 @@ PODS: - React-Core - CocoaAsyncSocket (7.6.5) - DoubleConversion (1.1.6) + - EASClient (0.13.3): + - ExpoModulesCore - EXApplication (6.0.2): - ExpoModulesCore - EXConstants (17.0.8): @@ -272,6 +274,34 @@ PODS: - ExpoModulesCore - ExpoWebBrowser (14.0.2): - ExpoModulesCore + - EXStructuredHeaders (4.0.0) + - EXUpdates (0.27.4): + - DoubleConversion + - EASClient + - EXManifests + - ExpoModulesCore + - EXStructuredHeaders + - EXUpdatesInterface + - glog + - hermes-engine + - RCT-Folly (= 2024.10.14.00) + - RCTRequired + - RCTTypeSafety + - ReachabilitySwift + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - EXUpdatesInterface (1.0.0): - ExpoModulesCore - fast_float (6.1.4) @@ -473,6 +503,7 @@ PODS: - FBLazyVector (= 0.76.9) - RCTRequired (= 0.76.9) - React-Core (= 0.76.9) + - ReachabilitySwift (5.2.4) - React (0.76.9): - React-Core (= 0.76.9) - React-Core/DevSupport (= 0.76.9) @@ -2867,6 +2898,7 @@ DEPENDENCIES: - boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`) - BVLinearGradient (from `../node_modules/react-native-linear-gradient`) - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) + - EASClient (from `../node_modules/expo-eas-client/ios`) - EXApplication (from `../node_modules/expo-application/ios`) - EXConstants (from `../node_modules/expo-constants/ios`) - EXJSONUtils (from `../node_modules/expo-json-utils/ios`) @@ -2888,6 +2920,8 @@ DEPENDENCIES: - ExpoModulesCore (from `../node_modules/expo-modules-core`) - ExpoSensors (from `../node_modules/expo-sensors/ios`) - ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`) + - EXStructuredHeaders (from `../node_modules/expo-structured-headers/ios`) + - EXUpdates (from `../node_modules/expo-updates/ios`) - EXUpdatesInterface (from `../node_modules/expo-updates-interface/ios`) - fast_float (from `../node_modules/react-native/third-party-podspecs/fast_float.podspec`) - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) @@ -3043,6 +3077,7 @@ SPEC REPOS: - nanopb - OpenSSL-Universal - PromisesObjC + - ReachabilitySwift - React-Codegen - RiveRuntime - SDWebImage @@ -3058,6 +3093,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-linear-gradient" DoubleConversion: :podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" + EASClient: + :path: "../node_modules/expo-eas-client/ios" EXApplication: :path: "../node_modules/expo-application/ios" EXConstants: @@ -3100,6 +3137,10 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-sensors/ios" ExpoWebBrowser: :path: "../node_modules/expo-web-browser/ios" + EXStructuredHeaders: + :path: "../node_modules/expo-structured-headers/ios" + EXUpdates: + :path: "../node_modules/expo-updates/ios" EXUpdatesInterface: :path: "../node_modules/expo-updates-interface/ios" fast_float: @@ -3360,6 +3401,7 @@ SPEC CHECKSUMS: BVLinearGradient: cb006ba232a1f3e4f341bb62c42d1098c284da70 CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 + EASClient: 88b5fd19d0787186a0c7e6ba76deb2d4f96395ce EXApplication: 4c72f6017a14a65e338c5e74fca418f35141e819 EXConstants: fcfc75800824ac2d5c592b5bc74130bad17b146b EXJSONUtils: 01fc7492b66c234e395dcffdd5f53439c5c29c93 @@ -3381,6 +3423,8 @@ SPEC CHECKSUMS: ExpoModulesCore: c25d77625038b1968ea1afefc719862c0d8dd993 ExpoSensors: 02a52ddab1e3a8a1438258c3d87d1ee5f721743a ExpoWebBrowser: a212e6b480d8857d3e441fba51e0c968333803b3 + EXStructuredHeaders: 09c70347b282e3d2507e25fb4c747b1b885f87f6 + EXUpdates: 4fc73950af0af03388063823e75eb2f7566a48c9 EXUpdatesInterface: 7c977640bdd8b85833c19e3959ba46145c5719db fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6 FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45 @@ -3414,6 +3458,7 @@ SPEC CHECKSUMS: RCTRequired: ca91e5dd26b64f577b528044c962baf171c6b716 RCTSearchApi: 5fc36140c598a74fd831dca924a28ed53bc7aa18 RCTTypeSafety: e7678bd60850ca5a41df9b8dc7154638cb66871f + ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda React: 4641770499c39f45d4e7cde1eba30e081f9d8a3d React-callinvoker: 4bef67b5c7f3f68db5929ab6a4d44b8a002998ea React-Codegen: 4b8b4817cea7a54b83851d4c1f91f79aa73de30a @@ -3531,6 +3576,6 @@ SPEC CHECKSUMS: VisionCamera: f56eaedde0d3fa095143b78374d29e89e71735f9 Yoga: feb4910aba9742cfedc059e2b2902e22ffe9954a -PODFILE CHECKSUM: aba718cdf38f663bc54e8d01365528918eea2b5a +PODFILE CHECKSUM: 3debf6fbed3b6fecab16001c02c028737e11786c COCOAPODS: 1.16.2 diff --git a/ios/Podfile.properties.json b/ios/Podfile.properties.json index 42a5063d776..5dffd8bfae7 100644 --- a/ios/Podfile.properties.json +++ b/ios/Podfile.properties.json @@ -1,4 +1,5 @@ { "EX_DEV_CLIENT_NETWORK_INSPECTOR": "true", - "newArchEnabled": "true" - } + "newArchEnabled": "true", + "expo.jsEngine": "hermes" +} diff --git a/jest.config.js b/jest.config.js index 6fcc95ac404..ed306b27cce 100644 --- a/jest.config.js +++ b/jest.config.js @@ -71,6 +71,7 @@ const config = { '/app/__mocks__/expo-apple-authentication.js', '^expo-haptics(/.*)?$': '/app/__mocks__/expo-haptics.js', '^expo-image$': '/app/__mocks__/expo-image.js', + '^expo-updates(/.*)?$': '/app/__mocks__/expo-updates.ts', '^@metamask/design-system-react-native/dist/components/temp-components/Spinner/index.cjs$': '/app/__mocks__/spinnerMock.js', '^rive-react-native$': '/app/__mocks__/rive-react-native.tsx', diff --git a/locales/languages/en.json b/locales/languages/en.json index eb84e8b3b63..d4f5aa56eda 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -4421,6 +4421,8 @@ "order_status_completed": "Completed", "order_status_failed": "Failed", "order_status_cancelled": "Cancelled", + "webview_no_url_provided": "No URL was provided to continue", + "webview_error_no_address_provided": "No wallet address was provided to continue", "onboarding": { "what_to_expect": "What to Expect", "quotes": "Our buy crypto feature aggregates quotes from integrated vendors, providing quotes from those sources to get crypto directly into your wallet with no waiting period.", diff --git a/ota.config.js b/ota.config.js new file mode 100644 index 00000000000..7bc1cae2d84 --- /dev/null +++ b/ota.config.js @@ -0,0 +1,51 @@ +/** + * OTA (Over-The-Air) Update Version Configuration + * + * SINGLE SOURCE OF TRUTH for all OTA and runtime versions. + * + * Used by: + * - app.config.js (Expo configuration) + * - scripts/update-expo-channel.js (Build script) + * - app/constants/ota.ts (App runtime - TypeScript re-exports) + * + * Workflow: + * 1. For OTA updates (JS-only): Increment OTA_VERSION only + * 2. For native releases: Bump version in package.json and reset OTA_VERSION to v0 + */ + +/* eslint-disable import/no-commonjs */ +const packageJson = require('./package.json'); + +/** + * Current OTA update version + * Increment with each OTA update: v0 -> v1 -> v2 -> v3 etc. + * Reset to v0 when releasing a new native build + */ +const OTA_VERSION = 'v0'; + +/** + * Runtime version for native compatibility + * Automatically derived from package.json version + * Only changes when you bump the version in package.json (native releases) + */ +const RUNTIME_VERSION = packageJson.version; + +/** + * Expo Project ID + * The unique identifier for the Expo project + * Loaded from environment variables + */ +const PROJECT_ID = process.env.EXPO_PROJECT_ID || ''; + +/** + * Expo Updates URL + * The URL endpoint for fetching OTA updates + */ +const UPDATE_URL = `https://u.expo.dev/${PROJECT_ID}`; + +module.exports = { + OTA_VERSION, + RUNTIME_VERSION, + PROJECT_ID, + UPDATE_URL, +}; diff --git a/package.json b/package.json index 1ce8cf93ede..6626d2d1ca4 100644 --- a/package.json +++ b/package.json @@ -362,6 +362,7 @@ "events": "3.0.0", "expo": "~52.0.47", "expo-apple-authentication": "~7.1.3", + "expo-asset": "~11.0.5", "expo-auth-session": "~6.0.3", "expo-build-properties": "~0.13.2", "expo-dev-client": "~5.0.18", @@ -369,6 +370,7 @@ "expo-haptics": "~14.0.1", "expo-image": "~2.0.7", "expo-sensors": "~14.0.2", + "expo-updates": "~0.27.4", "fast-equals": "^5.2.2", "fuse.js": "3.4.4", "he": "^1.2.0", diff --git a/scripts/build.sh b/scripts/build.sh index d6fe322c5b9..84be116b565 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -799,7 +799,11 @@ if [ -z "$METAMASK_ENVIRONMENT" ]; then exit 1 else echo "METAMASK_ENVIRONMENT is set to: $METAMASK_ENVIRONMENT" + fi + # Update Expo channel configuration based on environment + echo "Updating Expo channel configuration..." + node "${__DIRNAME__}/update-expo-channel.js" if [ "$PLATFORM" == "ios" ]; then # we don't care about env file in CI diff --git a/scripts/update-expo-channel.js b/scripts/update-expo-channel.js new file mode 100755 index 00000000000..00095da0612 --- /dev/null +++ b/scripts/update-expo-channel.js @@ -0,0 +1,286 @@ +#!/usr/bin/env node + +/** + * Updates expo updates enable flag in Android and iOS plists + * based on the METAMASK_ENVIRONMENT environment variable. + * + * Usage: node scripts/update-expo-channel.js + * Or set as a prebuild step in your build process + */ + +const fs = require('fs'); +const path = require('path'); +const { RUNTIME_VERSION, UPDATE_URL } = require('../ota.config.js'); + +const VALID_ENVIRONMENTS = ['beta', 'rc', 'exp', 'test', 'e2e', 'dev', 'production']; + +const ANDROID_MANIFEST_PATH = path.join(__dirname, '..', 'android', 'app', 'src', 'main', 'AndroidManifest.xml'); +const IOS_EXPO_PLIST_PATH = path.join(__dirname, '..', 'ios', 'Expo.plist'); + +//TODO: add production channel when it's ready +const CONFIG_MAP = { + rc: { + channel: 'preview', + runtimeVersion: RUNTIME_VERSION, + updatesEnabled: false, + updateUrl: UPDATE_URL, + }, +}; + +// Official Expo Updates configuration keys +// Reference: https://docs.expo.dev/versions/latest/sdk/updates/#configuration +const EXPO_CONFIG_MAP = { + enabled: { + ios: 'EXUpdatesEnabled', + android: 'expo.modules.updates.ENABLED' + }, + url: { + ios: 'EXUpdatesURL', + android: 'expo.modules.updates.EXPO_UPDATE_URL' + }, + runtimeVersion: { + ios: 'EXUpdatesRuntimeVersion', + android: 'expo.modules.updates.EXPO_RUNTIME_VERSION' + }, + requestHeaders: { + ios: 'EXUpdatesRequestHeaders', + android: 'expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY' + }, + checkAutomatically: { + ios: 'EXUpdatesCheckOnLaunch', + android: 'expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH' + } +}; + +/** + * Gets the configuration for a given environment + * @returns {Object} - The configuration object with channel, runtimeVersion, and updatesEnabled + */ +function getConfigForEnvironment() { + // For now, all environments use RC configuration + return CONFIG_MAP.rc; +} + +/** + * Only toggles EXPO_UPDATES_CONFIGURATION_ENABLED in AndroidManifest.xml + * @param {string} filePath + * @param {string} channelName + * @param {string} runtimeVersion + * @param {boolean} updatesEnabled + * @param {string} updateUrl + */ +function updateAndroidManifest(filePath, channelName, runtimeVersion, updatesEnabled, updateUrl) { + let content = fs.readFileSync(filePath, 'utf8'); + + // Update or insert UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY (JSON header with channel) + const requestHeadersKey = EXPO_CONFIG_MAP.requestHeaders.android; + if (content.includes(requestHeadersKey)) { + content = content.replace( + //g, + `` + ); + } else { + content = content.replace( + /(\s*)<\/application>/, + `\n\t\t\n\t\t$1` + ); + } + + // Update or insert EXPO_RUNTIME_VERSION + const runtimeVersionKey = EXPO_CONFIG_MAP.runtimeVersion.android; + if (content.includes(runtimeVersionKey)) { + content = content.replace( + //g, + `` + ); + } else { + content = content.replace( + /(\s*)<\/application>/, + `\n\t\t$1` + ); + } + + // Update or insert EXPO_UPDATE_URL + const updateUrlKey = EXPO_CONFIG_MAP.url.android; + if (content.includes(updateUrlKey)) { + content = content.replace( + //g, + `` + ); + } else { + content = content.replace( + /(\s*)<\/application>/, + `\n\t\t$1` + ); + } + + // Only toggle expo.modules.updates.ENABLED; rely on defaults for the rest + const enabledKey = EXPO_CONFIG_MAP.enabled.android; + const enabledValue = updatesEnabled ? 'true' : 'false'; + if (content.includes(enabledKey)) { + content = content.replace( + //g, + `` + ); + } else { + content = content.replace( + /(\s*)<\/application>/, + `\n\t\t$1` + ); + } + + fs.writeFileSync(filePath, content, 'utf8'); + console.log('✓ AndroidManifest.xml updated successfully'); +} + +/** + * Only toggles EXUpdatesEnabled in plist file + * @param {string} filePath + * @param {string} channelName + * @param {string} runtimeVersion + * @param {string} fileName + * @param {boolean} updatesEnabled + * @param {string} updateUrl + */ +function updatePlistFile(filePath, channelName, runtimeVersion, fileName, updatesEnabled, updateUrl) { + console.log(`Updating ${fileName}: channel=${channelName}, runtime=${runtimeVersion}, EXUpdatesEnabled=${updatesEnabled}, updateUrl=${updateUrl}`); + + let content = fs.readFileSync(filePath, 'utf8'); + + // Update or insert EXUpdatesRuntimeVersion + const runtimeVersionKey = EXPO_CONFIG_MAP.runtimeVersion.ios; + if (content.includes(`${runtimeVersionKey}`)) { + content = content.replace( + new RegExp(`(${runtimeVersionKey}<\\/key>\\s*)[^<]*(<\\/string>)`), + `$1${runtimeVersion}$2` + ); + } else { + content = content.replace( + /(\s*)<\/dict>\s*<\/plist>/, + `\n\t${runtimeVersionKey}\n\t${runtimeVersion}$1\n` + ); + } + + // Update or insert EXUpdatesURL + const updateUrlKey = EXPO_CONFIG_MAP.url.ios; + if (content.includes(`${updateUrlKey}`)) { + content = content.replace( + new RegExp(`(${updateUrlKey}<\\/key>\\s*)[^<]*(<\\/string>)`), + `$1${updateUrl}$2` + ); + } else { + content = content.replace( + /(\s*)<\/dict>\s*<\/plist>/, + `\n\t${updateUrlKey}\n\t${updateUrl}$1\n` + ); + } + + // Update or insert EXUpdatesRequestHeaders.expo-channel-name + const requestHeadersKey = EXPO_CONFIG_MAP.requestHeaders.ios; + if (content.includes(`${requestHeadersKey}`)) { + if (content.includes('expo-channel-name')) { + content = content.replace( + /(expo-channel-name<\/key>\s*)[^<]*(<\/string>)/, + `$1${channelName}$2` + ); + } else { + content = content.replace( + new RegExp(`(${requestHeadersKey}<\\/key>\\s*)`), + `$1\n\t\texpo-channel-name\n\t\t${channelName}` + ); + } + } else { + content = content.replace( + /(\s*)<\/dict>\s*<\/plist>/, + `\n\t${requestHeadersKey}\n\t\n\t\texpo-channel-name\n\t\t${channelName}\n\t$1\n` + ); + } + + // Only toggle EXUpdatesEnabled; do not modify CheckOnLaunch or LaunchWaitMs + const enabledKey = EXPO_CONFIG_MAP.enabled.ios; + if (content.includes(`${enabledKey}`)) { + content = content.replace( + new RegExp(`${enabledKey}<\\/key>\\s*<(true|false)\\/>`), + `${enabledKey}\n\t<${updatesEnabled ? 'true' : 'false'}/>` + ); + } else { + content = content.replace( + /(\s*)<\/dict>\s*<\/plist>/, + `\n\t${enabledKey}\n\t<${updatesEnabled ? 'true' : 'false'}/>\n$1\n` + ); + } + + fs.writeFileSync(filePath, content, 'utf8'); + console.log(`✓ ${fileName} updated successfully`); +} + +/** + * Main function + */ +function main() { + const environment = process.env.METAMASK_ENVIRONMENT; + + console.log('======================================'); + console.log(' Updating Expo Updates Configuration'); + console.log('======================================'); + console.log(''); + + // Validate environment variable + if (!environment) { + console.error('❌ Error: METAMASK_ENVIRONMENT is not set'); + console.error(' Please set it to one of:', VALID_ENVIRONMENTS.join(', ')); + process.exit(1); + } + + if (!VALID_ENVIRONMENTS.includes(environment)) { + console.error(`❌ Error: Invalid METAMASK_ENVIRONMENT: ${environment}`); + console.error(' Valid values:', VALID_ENVIRONMENTS.join(', ')); + process.exit(1); + } + + console.log(`Environment: ${environment}`); + + // Skip configuration for production environment + if (environment === 'production') { + console.log('ℹ️ Production environment detected - skipping Expo Updates configuration'); + console.log('✓ No configuration changes made'); + return; + } + + // Get configuration for this environment + const { channel, runtimeVersion, updatesEnabled, updateUrl } = getConfigForEnvironment(environment); + + // Check if files exist + if (!fs.existsSync(ANDROID_MANIFEST_PATH)) { + console.error(`❌ Error: AndroidManifest.xml not found at ${ANDROID_MANIFEST_PATH}`); + process.exit(1); + } + + if (!fs.existsSync(IOS_EXPO_PLIST_PATH)) { + console.error(`❌ Error: Expo.plist not found at ${IOS_EXPO_PLIST_PATH}`); + process.exit(1); + } + + + try { + updateAndroidManifest(ANDROID_MANIFEST_PATH, channel, runtimeVersion, updatesEnabled, updateUrl); + updatePlistFile(IOS_EXPO_PLIST_PATH, channel, runtimeVersion, 'Expo.plist', updatesEnabled, updateUrl); + + console.log('✓ All files updated successfully!'); + } catch (error) { + console.error('❌ Error updating files:', error.message); + process.exit(1); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + getConfigForEnvironment, + updateAndroidManifest, + updatePlistFile, + CONFIG_MAP, +}; + diff --git a/wdio/screen-objects/WalletMainScreen.js b/wdio/screen-objects/WalletMainScreen.js index 872711a6c38..0fec6530257 100644 --- a/wdio/screen-objects/WalletMainScreen.js +++ b/wdio/screen-objects/WalletMainScreen.js @@ -127,6 +127,30 @@ class WalletMainScreen { return Selectors.getXpathElementByText('Localhost 8545 now active.'); } + get totalBalanceText() { + if (!this._device) { + return Selectors.getXpathElementByResourceId(WalletViewSelectorsIDs.TOTAL_BALANCE_TEXT); + } else { + return AppwrightSelectors.getElementByID(this._device, WalletViewSelectorsIDs.TOTAL_BALANCE_TEXT); + } + } + + get balanceContainer() { + if (!this._device) { + return Selectors.getXpathElementByResourceId('balance-container'); + } else { + return AppwrightSelectors.getElementByID(this._device, 'balance-container'); + } + } + + get tokenBalancesLoadedMarker() { + if (!this._device) { + return Selectors.getXpathElementByResourceId('token-balances-loaded-marker'); + } else { + return AppwrightSelectors.getElementByID(this._device, 'token-balances-loaded-marker'); + } + } + async tapImportTokensButton() { const importToken = await this.ImportToken; await importToken.waitForDisplayed(); @@ -148,7 +172,23 @@ class WalletMainScreen { } async tapNFTTab() { - await Gestures.tapTextByXpath('NFTs'); + if (!this._device) { + await Gestures.tapTextByXpath('NFTs'); + } else { + // For Appwright, tap by text + const nftTabText = AppwrightSelectors.getElementByText(this._device, 'NFTs'); + await AppwrightGestures.tap(nftTabText); + } + } + + async tapTokensTab() { + if (!this._device) { + await Gestures.tapTextByXpath('Tokens'); + } else { + // For Appwright, tap by text + const tokensTabText = AppwrightSelectors.getElementByText(this._device, 'Tokens'); + await AppwrightGestures.tap(tokensTabText); + } } async tapOnToken(token) { diff --git a/yarn.lock b/yarn.lock index 7ca213d0d9a..f1059d1eaa3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3728,7 +3728,7 @@ __metadata: languageName: node linkType: hard -"@expo/code-signing-certificates@npm:^0.0.5": +"@expo/code-signing-certificates@npm:0.0.5, @expo/code-signing-certificates@npm:^0.0.5": version: 0.0.5 resolution: "@expo/code-signing-certificates@npm:0.0.5" dependencies: @@ -20899,6 +20899,13 @@ __metadata: languageName: node linkType: hard +"arg@npm:4.1.0": + version: 4.1.0 + resolution: "arg@npm:4.1.0" + checksum: 10/dc0e1ea7f0adee7871c456bd57f06fb9f8c2ccd91fd0537c73b66f3fa0c9697ccdfc25b358a417a3ab263c062aac0ef2df3a5523433861fe6277cb2ff769a9bc + languageName: node + linkType: hard + "arg@npm:^4.1.0": version: 4.1.3 resolution: "arg@npm:4.1.3" @@ -27815,6 +27822,13 @@ __metadata: languageName: node linkType: hard +"expo-eas-client@npm:~0.13.3": + version: 0.13.3 + resolution: "expo-eas-client@npm:0.13.3" + checksum: 10/6f37a14364a746915e4766d87924ae1c2c817a4115e16c5ae2d8f637d7780cabe297627a4d87ba60dd644352d874de961244363bd97fb65114415043c4ab4f88 + languageName: node + linkType: hard + "expo-file-system@npm:~18.0.12, expo-file-system@npm:~18.0.7": version: 18.0.12 resolution: "expo-file-system@npm:18.0.12" @@ -27893,7 +27907,7 @@ __metadata: languageName: node linkType: hard -"expo-manifests@npm:~0.15.8": +"expo-manifests@npm:~0.15.7, expo-manifests@npm:~0.15.8": version: 0.15.8 resolution: "expo-manifests@npm:0.15.8" dependencies: @@ -27944,6 +27958,13 @@ __metadata: languageName: node linkType: hard +"expo-structured-headers@npm:~4.0.0": + version: 4.0.0 + resolution: "expo-structured-headers@npm:4.0.0" + checksum: 10/1a98dded51678155606f92af27d5fab6afe35d342ee961ad9bf669f66126b6ff3d321100b684ef0cf1552470682ab4e52ed93ef53d2d5a28299611ddfbf25417 + languageName: node + linkType: hard + "expo-updates-interface@npm:~1.0.0": version: 1.0.0 resolution: "expo-updates-interface@npm:1.0.0" @@ -27953,6 +27974,33 @@ __metadata: languageName: node linkType: hard +"expo-updates@npm:~0.27.4": + version: 0.27.4 + resolution: "expo-updates@npm:0.27.4" + dependencies: + "@expo/code-signing-certificates": "npm:0.0.5" + "@expo/config": "npm:~10.0.11" + "@expo/config-plugins": "npm:~9.0.17" + "@expo/spawn-async": "npm:^1.7.2" + arg: "npm:4.1.0" + chalk: "npm:^4.1.2" + expo-eas-client: "npm:~0.13.3" + expo-manifests: "npm:~0.15.7" + expo-structured-headers: "npm:~4.0.0" + expo-updates-interface: "npm:~1.0.0" + fast-glob: "npm:^3.3.2" + fbemitter: "npm:^3.0.0" + ignore: "npm:^5.3.1" + resolve-from: "npm:^5.0.0" + peerDependencies: + expo: "*" + react: "*" + bin: + expo-updates: bin/cli.js + checksum: 10/ea72f84525305ed0c32cafdec60b215bfd769236ecba1646bed1a647e3eb78a752853ffbe128b6365b6d7d87ddde738e377e3c9396a1688da36d7ccc1e1a182f + languageName: node + linkType: hard + "expo-web-browser@npm:~14.0.2": version: 14.0.2 resolution: "expo-web-browser@npm:14.0.2" @@ -34483,6 +34531,7 @@ __metadata: execa: "npm:^8.0.1" expo: "npm:~52.0.47" expo-apple-authentication: "npm:~7.1.3" + expo-asset: "npm:~11.0.5" expo-auth-session: "npm:~6.0.3" expo-build-properties: "npm:~0.13.2" expo-dev-client: "npm:~5.0.18" @@ -34490,6 +34539,7 @@ __metadata: expo-haptics: "npm:~14.0.1" expo-image: "npm:~2.0.7" expo-sensors: "npm:~14.0.2" + expo-updates: "npm:~0.27.4" fast-equals: "npm:^5.2.2" fs-extra: "npm:^10.1.0" fuse.js: "npm:3.4.4"