diff --git a/app/components/UI/Card/components/CardAssetItem/CardAssetItem.styles.ts b/app/components/UI/Card/components/CardAssetItem/CardAssetItem.styles.ts new file mode 100644 index 00000000000..7c4c64f5cea --- /dev/null +++ b/app/components/UI/Card/components/CardAssetItem/CardAssetItem.styles.ts @@ -0,0 +1,29 @@ +import { StyleSheet } from 'react-native'; + +const styleSheet = () => + StyleSheet.create({ + balances: { + flex: 1, + justifyContent: 'center', + marginLeft: 20, + }, + assetName: { + flexDirection: 'row', + }, + allowanceStatusContainer: { + flexDirection: 'row', + alignItems: 'center', + alignContent: 'center', + }, + ethLogo: { + width: 32, + height: 32, + borderRadius: 16, + overflow: 'hidden', + }, + badge: { + marginTop: 12, + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Card/components/CardAssetItem/CardAssetItem.test.tsx b/app/components/UI/Card/components/CardAssetItem/CardAssetItem.test.tsx new file mode 100644 index 00000000000..33b4cc2ea46 --- /dev/null +++ b/app/components/UI/Card/components/CardAssetItem/CardAssetItem.test.tsx @@ -0,0 +1,245 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import CardAssetItem from './CardAssetItem'; +import { renderScreen } from '../../../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../../../util/test/initial-root-state'; +import { TokenI } from '../../../Tokens/types'; +import { AllowanceState, CardTokenAllowance } from '../../types'; +import { ethers } from 'ethers'; + +// Mock dependencies +jest.mock('../../hooks/useAssetBalance'); +jest.mock('../../../../../util/networks'); +jest.mock('../../../../../util/networks/customNetworks'); +jest.mock( + '../../../Tokens/TokenList/TokenListItem/CustomNetworkNativeImgMapping', +); +jest.mock('../../../../Base/RemoteImage', () => 'RemoteImage'); + +import { useAssetBalance } from '../../hooks/useAssetBalance'; +import { + isTestNet, + getDefaultNetworkByChainId, + getTestNetImageByChainId, +} from '../../../../../util/networks'; + +const mockUseAssetBalance = useAssetBalance as jest.MockedFunction< + typeof useAssetBalance +>; +const mockIsTestNet = isTestNet as jest.MockedFunction; +const mockGetDefaultNetworkByChainId = + getDefaultNetworkByChainId as jest.MockedFunction< + typeof getDefaultNetworkByChainId + >; +const mockGetTestNetImageByChainId = + getTestNetImageByChainId as jest.MockedFunction< + typeof getTestNetImageByChainId + >; + +function renderWithProvider( + component: React.ComponentType | (() => React.ReactElement | null), +) { + return renderScreen( + component, + { + name: 'CardAssetItem', + }, + { + state: { + engine: { + backgroundState, + }, + }, + }, + ); +} + +describe('CardAssetItem Component', () => { + const mockOnPress = jest.fn(); + + const mockAsset: TokenI = { + name: 'Ethereum', + symbol: 'ETH', + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + image: 'https://example.com/eth.png', + isNative: true, + ticker: 'ETH', + aggregators: [], + balance: '1000000000000000000', + logo: undefined, + isETH: true, + }; + + const mockAssetKey: CardTokenAllowance = { + chainId: '0x1', + address: '0x0000000000000000000000000000000000000000', + isStaked: false, + allowanceState: AllowanceState.NotActivated, + allowance: ethers.BigNumber.from('1000000000000000000'), + decimals: 18, + symbol: 'ETH', + name: 'Ethereum', + }; + + const mockAssetBalance = { + asset: mockAsset, + mainBalance: '1.5 ETH', + secondaryBalance: '$3,000.00', + balanceFiat: '$3,000.00', + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseAssetBalance.mockReturnValue(mockAssetBalance); + mockIsTestNet.mockReturnValue(false); + mockGetDefaultNetworkByChainId.mockReturnValue(undefined); + }); + + it('renders with required props and matches snapshot', () => { + const { toJSON } = renderWithProvider(() => ( + + )); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with all props and matches snapshot', () => { + const { toJSON } = renderWithProvider(() => ( + + )); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with privacy mode enabled and matches snapshot', () => { + const { toJSON } = renderWithProvider(() => ( + + )); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders non-native token and matches snapshot', () => { + const nonNativeAsset = { + ...mockAsset, + name: 'USD Coin', + symbol: 'USDC', + isNative: false, + address: '0xa0b86a33e6c8e2c3c5b5f7ae5f7c5b5f7ae5f7c5b5f', + }; + mockUseAssetBalance.mockReturnValue({ + ...mockAssetBalance, + asset: nonNativeAsset, + }); + + const { toJSON } = renderWithProvider(() => ( + + )); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('calls onPress when pressed', () => { + const { getByTestId } = renderWithProvider(() => ( + + )); + + const assetElement = getByTestId('asset-ETH'); + fireEvent.press(assetElement); + + expect(mockOnPress).toHaveBeenCalledTimes(1); + expect(mockOnPress).toHaveBeenCalledWith(mockAsset); + }); + + it('returns null when chainId is missing', () => { + const assetKeyWithoutChainId = { + ...mockAssetKey, + chainId: undefined as string | undefined, + }; + + const { toJSON } = render( + , + ); + + expect(toJSON()).toBeNull(); + }); + + it('returns null when asset is undefined', () => { + mockUseAssetBalance.mockReturnValue({ + ...mockAssetBalance, + asset: undefined, + }); + + const { toJSON } = render( + , + ); + + expect(toJSON()).toBeNull(); + }); + + it('renders with disabled state', () => { + const { toJSON } = renderWithProvider(() => ( + + )); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('handles test network correctly', () => { + mockIsTestNet.mockReturnValue(true); + mockGetTestNetImageByChainId.mockReturnValue({ + uri: 'https://example.com/testnet.png', + }); + + const { toJSON } = renderWithProvider(() => ( + + )); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('displays asset name when available', () => { + const { getByText } = renderWithProvider(() => ( + + )); + + expect(getByText('Ethereum')).toBeTruthy(); + }); + + it('displays asset symbol when name is not available', () => { + const assetWithoutName = { + ...mockAsset, + name: '', + }; + mockUseAssetBalance.mockReturnValue({ + ...mockAssetBalance, + asset: assetWithoutName, + }); + + const { getByText } = renderWithProvider(() => ( + + )); + + expect(getByText('ETH')).toBeTruthy(); + }); +}); diff --git a/app/components/UI/Card/components/CardAssetItem/CardAssetItem.tsx b/app/components/UI/Card/components/CardAssetItem/CardAssetItem.tsx new file mode 100644 index 00000000000..0b076e1e3be --- /dev/null +++ b/app/components/UI/Card/components/CardAssetItem/CardAssetItem.tsx @@ -0,0 +1,167 @@ +import AssetElement from '../../../AssetElement'; +import React, { useCallback } from 'react'; +import { TokenI } from '../../../Tokens/types'; +import BadgeWrapper, { + BadgePosition, +} from '../../../../../component-library/components/Badges/BadgeWrapper'; +import { useStyles } from '../../../../hooks/useStyles'; +import styleSheet from './CardAssetItem.styles'; +import Badge, { + BadgeVariant, +} from '../../../../../component-library/components/Badges/Badge'; +import { Hex, isCaipChainId } from '@metamask/utils'; +import { + getDefaultNetworkByChainId, + getTestNetImageByChainId, + isTestNet, +} from '../../../../../util/networks'; +import NetworkAssetLogo from '../../../NetworkAssetLogo'; +import Text, { + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import { + CustomNetworkImgMapping, + getNonEvmNetworkImageSourceByChainId, + PopularList, + UnpopularNetworkList, +} from '../../../../../util/networks/customNetworks'; +import { CustomNetworkNativeImgMapping } from '../../../Tokens/TokenList/TokenListItem/CustomNetworkNativeImgMapping'; +import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; +import AvatarToken from '../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; +import { View } from 'react-native'; +import { CardTokenAllowance } from '../../types'; +import { useAssetBalance } from '../../hooks/useAssetBalance'; + +interface CardAssetItemProps { + assetKey: CardTokenAllowance; + privacyMode: boolean; + disabled?: boolean; + onPress?: (asset: TokenI) => void; +} + +const CardAssetItem: React.FC = ({ + assetKey, + onPress, + disabled = false, + privacyMode, +}) => { + const { styles } = useStyles(styleSheet, {}); + const chainId = assetKey.chainId as Hex; + + const { asset, mainBalance, secondaryBalance } = useAssetBalance(assetKey); + + const networkBadgeSource = useCallback( + (currentChainId: Hex) => { + if (!currentChainId) { + return null; + } + + if (isTestNet(currentChainId)) + return getTestNetImageByChainId(currentChainId); + const defaultNetwork = getDefaultNetworkByChainId(currentChainId) as + | { + imageSource: string; + } + | undefined; + + if (defaultNetwork) { + return defaultNetwork.imageSource; + } + + const unpopularNetwork = UnpopularNetworkList.find( + (networkConfig) => networkConfig.chainId === currentChainId, + ); + + const customNetworkImg = CustomNetworkImgMapping[currentChainId]; + + const popularNetwork = PopularList.find( + (networkConfig) => networkConfig.chainId === currentChainId, + ); + + const network = unpopularNetwork || popularNetwork; + if (network) { + return network.rpcPrefs.imageSource; + } + if (isCaipChainId(chainId)) { + return getNonEvmNetworkImageSourceByChainId(chainId); + } + if (customNetworkImg) { + return customNetworkImg; + } + }, + [chainId], + ); + + const renderNetworkAvatar = useCallback(() => { + if (asset?.isNative) { + const isCustomNetwork = CustomNetworkNativeImgMapping[chainId]; + + if (isCustomNetwork) { + return ( + + ); + } + + return ( + + ); + } + + return ( + + ); + }, [asset, styles.ethLogo, chainId]); + + // Return null if chainId is missing + if (!chainId || !asset) { + return null; + } + + return ( + + + } + > + {renderNetworkAvatar()} + + + + + {asset.name || asset.symbol} + + + + + ); +}; + +export default CardAssetItem; diff --git a/app/components/UI/Card/components/CardAssetItem/__snapshots__/CardAssetItem.test.tsx.snap b/app/components/UI/Card/components/CardAssetItem/__snapshots__/CardAssetItem.test.tsx.snap new file mode 100644 index 00000000000..5650e9110a6 --- /dev/null +++ b/app/components/UI/Card/components/CardAssetItem/__snapshots__/CardAssetItem.test.tsx.snap @@ -0,0 +1,3214 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CardAssetItem Component handles test network correctly 1`] = ` + + + + + + + + + + + + + CardAssetItem + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Ethereum + + + + + + 1.5 ETH + + + $3,000.00 + + + + + + + + + + + + + +`; + +exports[`CardAssetItem Component renders non-native token and matches snapshot 1`] = ` + + + + + + + + + + + + + CardAssetItem + + + + + + + + + + + + + + + + + + + + + + + + + + + + ? + + + + + + + + + USD Coin + + + + + + 1.5 ETH + + + $3,000.00 + + + + + + + + + + + + + +`; + +exports[`CardAssetItem Component renders with all props and matches snapshot 1`] = ` + + + + + + + + + + + + + CardAssetItem + + + + + + + + + + + + + + + + + + + + + + + + + + ? + + + + + + + + + Ethereum + + + + + + 1.5 ETH + + + $3,000.00 + + + + + + + + + + + + + +`; + +exports[`CardAssetItem Component renders with disabled state 1`] = ` + + + + + + + + + + + + + CardAssetItem + + + + + + + + + + + + + + + + + + + + + + + + + + ? + + + + + + + + + Ethereum + + + + + + 1.5 ETH + + + $3,000.00 + + + + + + + + + + + + + +`; + +exports[`CardAssetItem Component renders with privacy mode enabled and matches snapshot 1`] = ` + + + + + + + + + + + + + CardAssetItem + + + + + + + + + + + + + + + + + + + + + + + + + + ? + + + + + + + + + Ethereum + + + + + + ••••••••• + + + •••••• + + + + + + + + + + + + + +`; + +exports[`CardAssetItem Component renders with required props and matches snapshot 1`] = ` + + + + + + + + + + + + + CardAssetItem + + + + + + + + + + + + + + + + + + + + + + + + + + ? + + + + + + + + + Ethereum + + + + + + 1.5 ETH + + + $3,000.00 + + + + + + + + + + + + + +`; diff --git a/app/components/UI/Card/components/CardAssetItem/index.ts b/app/components/UI/Card/components/CardAssetItem/index.ts new file mode 100644 index 00000000000..c80d35f92e6 --- /dev/null +++ b/app/components/UI/Card/components/CardAssetItem/index.ts @@ -0,0 +1 @@ +export { default } from './CardAssetItem'; diff --git a/app/components/UI/Card/components/CardImage/CardImage.test.tsx b/app/components/UI/Card/components/CardImage/CardImage.test.tsx new file mode 100644 index 00000000000..2db11ce4698 --- /dev/null +++ b/app/components/UI/Card/components/CardImage/CardImage.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import CardImage from './CardImage'; +import { renderScreen } from '../../../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../../../util/test/initial-root-state'; + +function renderWithProvider(component: React.ComponentType) { + return renderScreen( + component, + { + name: 'CardImage', + }, + { + state: { + engine: { + backgroundState, + }, + }, + }, + ); +} + +describe('CardImage Component', () => { + it('renders correctly and matches snapshot', () => { + const { toJSON } = renderWithProvider(() => ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with custom props and matches snapshot', () => { + const { toJSON } = renderWithProvider(() => ( + + )); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with SVG properties', () => { + const { toJSON } = renderWithProvider(() => ( + + )); + + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/UI/Card/components/CardImage/CardImage.tsx b/app/components/UI/Card/components/CardImage/CardImage.tsx new file mode 100644 index 00000000000..f36e9141aa2 --- /dev/null +++ b/app/components/UI/Card/components/CardImage/CardImage.tsx @@ -0,0 +1,94 @@ +/* eslint-disable @metamask/design-tokens/color-no-hex */ +import React from 'react'; +import { View } from 'react-native'; +import Svg, { SvgProps, G, Rect, Path, Defs, ClipPath } from 'react-native-svg'; + +const originalWidth = 338; +const originalHeight = 194.688; +const aspectRatio = originalWidth / originalHeight; + +const SvgComponent = (props: SvgProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + +); +export default SvgComponent; diff --git a/app/components/UI/Card/components/CardImage/__snapshots__/CardImage.test.tsx.snap b/app/components/UI/Card/components/CardImage/__snapshots__/CardImage.test.tsx.snap new file mode 100644 index 00000000000..ed773278c18 --- /dev/null +++ b/app/components/UI/Card/components/CardImage/__snapshots__/CardImage.test.tsx.snap @@ -0,0 +1,1843 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CardImage Component renders correctly and matches snapshot 1`] = ` + + + + + + + + + + + + + CardImage + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`CardImage Component renders with SVG properties 1`] = ` + + + + + + + + + + + + + CardImage + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`CardImage Component renders with custom props and matches snapshot 1`] = ` + + + + + + + + + + + + + CardImage + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/app/components/UI/Card/components/CardImage/index.ts b/app/components/UI/Card/components/CardImage/index.ts new file mode 100644 index 00000000000..d38f6bb570d --- /dev/null +++ b/app/components/UI/Card/components/CardImage/index.ts @@ -0,0 +1 @@ +export { default } from './CardImage'; diff --git a/app/components/UI/Card/components/ManageCardListItem/ManageCardListItem.styles.ts b/app/components/UI/Card/components/ManageCardListItem/ManageCardListItem.styles.ts new file mode 100644 index 00000000000..9088891cb6e --- /dev/null +++ b/app/components/UI/Card/components/ManageCardListItem/ManageCardListItem.styles.ts @@ -0,0 +1,22 @@ +import { StyleSheet } from 'react-native'; +import { Colors } from 'app/util/theme/models'; + +const createStyles = ( + colors: Colors, + descriptionOrientation: 'row' | 'column', +) => + StyleSheet.create({ + root: { + backgroundColor: colors.background.default, + }, + action: { + paddingLeft: 16, + }, + description: { + justifyContent: 'space-between', + flexDirection: descriptionOrientation, + alignItems: descriptionOrientation === 'row' ? 'center' : 'flex-start', + }, + }); + +export default createStyles; diff --git a/app/components/UI/Card/components/ManageCardListItem/ManageCardListItem.test.tsx b/app/components/UI/Card/components/ManageCardListItem/ManageCardListItem.test.tsx new file mode 100644 index 00000000000..635f6a0e1e9 --- /dev/null +++ b/app/components/UI/Card/components/ManageCardListItem/ManageCardListItem.test.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import ManageCardListItem from './ManageCardListItem'; +import { renderScreen } from '../../../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../../../util/test/initial-root-state'; +import { IconName } from '../../../../../component-library/components/Icons/Icon'; +import { View } from 'react-native'; + +function renderWithProvider(component: React.ComponentType) { + return renderScreen( + component, + { + name: 'ManageCardListItem', + }, + { + state: { + engine: { + backgroundState, + }, + }, + }, + ); +} + +describe('ManageCardListItem Component', () => { + const mockOnPress = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders with required props and matches snapshot', () => { + const { toJSON } = renderWithProvider(() => ( + + )); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with all props and matches snapshot', () => { + const { toJSON } = renderWithProvider(() => ( + + )); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with React.ReactNode description and matches snapshot', () => { + const customDescription = ( + + Custom + + ); + + const { toJSON } = renderWithProvider(() => ( + + )); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('calls onPress when item is pressed', () => { + const { getByTestId } = renderWithProvider(() => ( + + )); + + const item = getByTestId('pressable-item'); + fireEvent.press(item); + + expect(mockOnPress).toHaveBeenCalledTimes(1); + }); + + it('renders with default right icon when rightIcon is not provided', () => { + const { toJSON } = renderWithProvider(() => ( + + )); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with custom right icon when rightIcon is provided', () => { + const { toJSON } = renderWithProvider(() => ( + + )); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('uses default testID when testID is not provided', () => { + const { getByTestId } = renderWithProvider(() => ( + + )); + + const item = getByTestId('manage-card-list-item'); + fireEvent.press(item); + + expect(mockOnPress).toHaveBeenCalledTimes(1); + }); + + it('does not crash when onPress is not provided', () => { + const { getByTestId } = renderWithProvider(() => ( + + )); + + const item = getByTestId('no-onpress-item'); + + expect(() => fireEvent.press(item)).not.toThrow(); + }); +}); diff --git a/app/components/UI/Card/components/ManageCardListItem/ManageCardListItem.tsx b/app/components/UI/Card/components/ManageCardListItem/ManageCardListItem.tsx new file mode 100644 index 00000000000..c1a6571e759 --- /dev/null +++ b/app/components/UI/Card/components/ManageCardListItem/ManageCardListItem.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { TouchableOpacity, Platform } from 'react-native'; +import { useTheme } from '../../../../../util/theme'; +import generateTestId from '../../../../../../wdio/utils/generateTestId'; +import Icon, { + IconName, + IconSize, +} from '../../../../../component-library/components/Icons/Icon'; +import ListItem from '../../../../../component-library/components/List/ListItem/ListItem'; +import ListItemColumn, { + WidthType, +} from '../../../../../component-library/components/List/ListItemColumn'; +import Text, { + TextVariant, + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import createStyles from './ManageCardListItem.styles'; + +export interface ManageCardListItemProps { + title: string; + description: string | React.ReactNode; + descriptionOrientation?: 'row' | 'column'; + rightIcon?: IconName; + testID?: string; + onPress?: () => void; +} + +const ManageCardListItem: React.FC = ({ + title, + onPress, + description, + descriptionOrientation = 'column', + rightIcon = IconName.ArrowRight, + testID = 'manage-card-list-item', +}) => { + const { colors } = useTheme(); + const styles = createStyles(colors, descriptionOrientation); + + return ( + + + + {title} + {typeof description === 'string' ? ( + + {description} + + ) : ( + description + )} + + + + + + + ); +}; + +export default ManageCardListItem; diff --git a/app/components/UI/Card/components/ManageCardListItem/__snapshots__/ManageCardListItem.test.tsx.snap b/app/components/UI/Card/components/ManageCardListItem/__snapshots__/ManageCardListItem.test.tsx.snap new file mode 100644 index 00000000000..d0f7e999837 --- /dev/null +++ b/app/components/UI/Card/components/ManageCardListItem/__snapshots__/ManageCardListItem.test.tsx.snap @@ -0,0 +1,2091 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ManageCardListItem Component renders with React.ReactNode description and matches snapshot 1`] = ` + + + + + + + + + + + + + ManageCardListItem + + + + + + + + + + + + + + + + + + + + + + Title with React Node + + + Custom + + + + + + + + + + + + + + + + + + + +`; + +exports[`ManageCardListItem Component renders with all props and matches snapshot 1`] = ` + + + + + + + + + + + + + ManageCardListItem + + + + + + + + + + + + + + + + + + + + + + Custom Title + + + Custom description + + + + + + + + + + + + + + + + + + + +`; + +exports[`ManageCardListItem Component renders with custom right icon when rightIcon is provided 1`] = ` + + + + + + + + + + + + + ManageCardListItem + + + + + + + + + + + + + + + + + + + + + + Custom Icon Test + + + Should use Edit icon + + + + + + + + + + + + + + + + + + + +`; + +exports[`ManageCardListItem Component renders with default right icon when rightIcon is not provided 1`] = ` + + + + + + + + + + + + + ManageCardListItem + + + + + + + + + + + + + + + + + + + + + + Default Icon Test + + + Should use ArrowRight icon + + + + + + + + + + + + + + + + + + + +`; + +exports[`ManageCardListItem Component renders with required props and matches snapshot 1`] = ` + + + + + + + + + + + + + ManageCardListItem + + + + + + + + + + + + + + + + + + + + + + Test Title + + + Test description + + + + + + + + + + + + + + + + + + + +`; diff --git a/app/components/UI/Card/components/ManageCardListItem/index.ts b/app/components/UI/Card/components/ManageCardListItem/index.ts new file mode 100644 index 00000000000..37f1cc169a4 --- /dev/null +++ b/app/components/UI/Card/components/ManageCardListItem/index.ts @@ -0,0 +1 @@ +export { default } from './ManageCardListItem'; diff --git a/app/components/UI/Card/hooks/useAssetBalance.test.ts b/app/components/UI/Card/hooks/useAssetBalance.test.ts index 11156ac9b21..661fdcb3a6a 100644 --- a/app/components/UI/Card/hooks/useAssetBalance.test.ts +++ b/app/components/UI/Card/hooks/useAssetBalance.test.ts @@ -1,12 +1,13 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { renderHook } from '@testing-library/react-hooks'; import { useSelector } from 'react-redux'; -import useAssetBalance from './useAssetBalance'; -import { FlashListAssetKey } from '../../Tokens/TokenList'; +import { useAssetBalance } from './useAssetBalance'; +import { CardTokenAllowance } from '../types'; import { TOKEN_RATE_UNDEFINED } from '../../Tokens/constants'; import { deriveBalanceFromAssetMarketDetails } from '../../Tokens/util'; import { formatWithThreshold } from '../../../../util/assets'; import { isTestNet } from '../../../../util/networks'; +import { buildTokenIconUrl } from '../util/buildTokenIconUrl'; jest.mock('react-redux', () => ({ useSelector: jest.fn() })); jest.mock('../../Tokens/util', () => ({ @@ -16,6 +17,9 @@ jest.mock('../../../../util/assets', () => ({ formatWithThreshold: jest.fn(), })); jest.mock('../../../../util/networks', () => ({ isTestNet: jest.fn() })); +jest.mock('../util/buildTokenIconUrl', () => ({ + buildTokenIconUrl: jest.fn(), +})); jest.mock('../../../../selectors/multichain', () => ({ makeSelectAssetByAddressAndChainId: jest.fn(() => jest.fn()), makeSelectNonEvmAssetById: jest.fn(() => jest.fn()), @@ -60,12 +64,20 @@ const mockFormatWithThreshold = formatWithThreshold as jest.MockedFunction< typeof formatWithThreshold >; const mockIsTestNet = isTestNet as jest.MockedFunction; +const mockBuildTokenIconUrl = buildTokenIconUrl as jest.MockedFunction< + typeof buildTokenIconUrl +>; describe('useAssetBalance', () => { - const mockToken: FlashListAssetKey = { + const mockToken: CardTokenAllowance = { address: '0x1234567890123456789012345678901234567890', chainId: '0x1', isStaked: false, + decimals: 18, + symbol: 'TEST', + name: 'Test Token', + allowanceState: 'enabled' as any, + allowance: {} as any, }; const mockEvmAsset = { @@ -156,6 +168,7 @@ describe('useAssetBalance', () => { value ? value.toFixed(2) : '0', ); mockIsTestNet.mockReturnValue(false); + mockBuildTokenIconUrl.mockReturnValue('https://example.com/token-icon.png'); }); describe('null/undefined token handling', () => { @@ -201,9 +214,9 @@ describe('useAssetBalance', () => { [nullResult, undefinedResult].forEach((result) => { expect(result.current.asset).toBeUndefined(); - expect(result.current.balanceFiat).toBeUndefined(); + expect(result.current.balanceFiat).toBe(''); expect(result.current.mainBalance).toBe(''); - expect(result.current.secondaryBalance).toBeUndefined(); + expect(result.current.secondaryBalance).toBe(''); }); }); @@ -253,9 +266,9 @@ describe('useAssetBalance', () => { // let asset = token && isEvmNetworkSelected ? evmAsset : nonEvmAsset; [nullResult, undefinedResult].forEach((result) => { expect(result.current.asset).toBeUndefined(); - expect(result.current.balanceFiat).toBeUndefined(); + expect(result.current.balanceFiat).toBe(''); expect(result.current.mainBalance).toBe(''); - expect(result.current.secondaryBalance).toBeUndefined(); + expect(result.current.secondaryBalance).toBe(''); }); // Verify that the selector was called with the expected parameters @@ -364,7 +377,7 @@ describe('useAssetBalance', () => { return '0xtest'; if (selector.toString().includes('selectIsEvmNetworkSelected')) return true; - if (selector.toString().includes('primaryCurrency')) return 'ETH'; + if (selector.toString().includes('primaryCurrency')) return 'USD'; if (selector.toString().includes('selectCurrentCurrency')) return 'USD'; if (selector.toString().includes('selectShowFiatInTestnets')) @@ -391,78 +404,40 @@ describe('useAssetBalance', () => { balanceFiat: undefined, balanceValueFormatted: '', }); + mockBuildTokenIconUrl.mockReturnValue( + 'https://example.com/token-icon.png', + ); - const { result } = renderHook(() => useAssetBalance(mockToken)); - - expect(result.current.asset).toBeUndefined(); - expect(result.current.mainBalance).toBe(''); - expect(result.current.secondaryBalance).toBeUndefined(); - }); - }); - - describe('Non-EVM assets', () => { - it('should handle non-EVM asset', () => { - const mockSelectorImplementation = jest - .fn() - .mockImplementation((selector: any) => { - if ( - selector.toString().includes('selectSelectedInternalAccountAddress') - ) - return '0xtest'; - if (selector.toString().includes('selectIsEvmNetworkSelected')) - return false; // Non-EVM network - if (selector.toString().includes('primaryCurrency')) return 'ETH'; - if (selector.toString().includes('selectCurrentCurrency')) - return 'USD'; - if (selector.toString().includes('selectShowFiatInTestnets')) - return true; - if (selector.toString().includes('selectSelectedInternalAccount')) - return { id: 'account1' }; - if (selector.toString().includes('selectSingleTokenPriceMarketData')) - return { price: 2000 }; - if (selector.toString().includes('selectSingleTokenBalance')) - return {}; - if (selector.toString().includes('selectCurrencyRateForChainId')) - return 0; - - // Handle the dynamic selector that should return the non-EVM asset - if (typeof selector === 'function') { - return mockNonEvmAsset; - } - - return { price: 2000 }; - }); - - mockUseSelector.mockImplementation(mockSelectorImplementation); - - mockDeriveBalanceFromAssetMarketDetails.mockReturnValue({ - balanceFiat: '$50000.00', - balanceValueFormatted: '1.0', - }); - + // Mock formatWithThreshold for mapped asset scenario mockFormatWithThreshold.mockImplementation( ( - value: number | null, + _value: number | null, _threshold: number, _locale: string, - _options: any, + options: any, ) => { - if (!value) return '0'; - if (_options?.style === 'currency') return `$${value.toFixed(2)}`; - return value.toFixed(5); + if (options?.style === 'currency') { + return '$0.00'; + } + return '0'; }, ); const { result } = renderHook(() => useAssetBalance(mockToken)); + // When no asset is found but token exists, a mapped asset is created expect(result.current.asset).toBeDefined(); - expect(result.current.asset?.symbol).toBe('BTC'); - expect(result.current.asset?.balanceFiat).toBe('$50000.00'); + expect(result.current.asset?.address).toBe(mockToken.address); + expect(result.current.asset?.symbol).toBe(mockToken.symbol); + expect(result.current.asset?.balance).toBe('0'); + expect(result.current.asset?.balanceFiat).toBe('$0.00'); + expect(result.current.mainBalance).toBe('$0.00'); + expect(result.current.secondaryBalance).toBe('0 TEST'); }); - it('should handle non-EVM asset with missing selected account', () => { - // Mock selector to return undefined for nonEvmAsset when selectedAccount is missing - const mockSelectorForMissingAccount = jest + it('should handle mapped asset when no asset found but token exists', () => { + // Mock selector to return undefined for both EVM and non-EVM assets + const mockSelectorForMappedAsset = jest .fn() .mockImplementation((selector: any) => { if ( @@ -470,14 +445,14 @@ describe('useAssetBalance', () => { ) return '0xtest'; if (selector.toString().includes('selectIsEvmNetworkSelected')) - return false; // Non-EVM network - if (selector.toString().includes('primaryCurrency')) return 'ETH'; + return true; + if (selector.toString().includes('primaryCurrency')) return 'USD'; if (selector.toString().includes('selectCurrentCurrency')) return 'USD'; if (selector.toString().includes('selectShowFiatInTestnets')) return true; if (selector.toString().includes('selectSelectedInternalAccount')) - return undefined; // Missing selected account + return { id: 'account1' }; if (selector.toString().includes('selectSingleTokenPriceMarketData')) return undefined; if (selector.toString().includes('selectSingleTokenBalance')) @@ -485,7 +460,7 @@ describe('useAssetBalance', () => { if (selector.toString().includes('selectCurrencyRateForChainId')) return 0; - // Return undefined for nonEvmAsset when selectedAccount is missing + // Return undefined for both asset selectors to trigger mapped asset creation if (typeof selector === 'function') { return undefined; } @@ -493,17 +468,42 @@ describe('useAssetBalance', () => { return undefined; }); - mockUseSelector.mockImplementation(mockSelectorForMissingAccount); - mockDeriveBalanceFromAssetMarketDetails.mockReturnValue({ - balanceFiat: undefined, - balanceValueFormatted: '', - }); + mockUseSelector.mockImplementation(mockSelectorForMappedAsset); + mockBuildTokenIconUrl.mockReturnValue( + 'https://example.com/token-icon.png', + ); + + // Mock formatWithThreshold for mapped asset scenario + mockFormatWithThreshold.mockImplementation( + ( + _value: number | null, + _threshold: number, + _locale: string, + options: any, + ) => { + if (options?.style === 'currency') { + return '$0.00'; + } + return '0'; + }, + ); const { result } = renderHook(() => useAssetBalance(mockToken)); - expect(result.current.asset).toBeUndefined(); - expect(result.current.mainBalance).toBe(''); - expect(result.current.secondaryBalance).toBeUndefined(); + expect(result.current.asset).toBeDefined(); + expect(result.current.asset?.address).toBe(mockToken.address); + expect(result.current.asset?.symbol).toBe(mockToken.symbol); + expect(result.current.asset?.balance).toBe('0'); + expect(result.current.asset?.balanceFiat).toBe('$0.00'); + expect(result.current.asset?.image).toBe( + 'https://example.com/token-icon.png', + ); + expect(result.current.mainBalance).toBe('$0.00'); + expect(result.current.secondaryBalance).toBe('0 TEST'); + expect(mockBuildTokenIconUrl).toHaveBeenCalledWith( + mockToken.chainId, + mockToken.address, + ); }); }); @@ -829,8 +829,10 @@ describe('useAssetBalance', () => { const { result } = renderHook(() => useAssetBalance(mockToken)); - // When selectedAccount is null, nonEvmAsset should be undefined - expect(result.current.asset).toBeUndefined(); + // When selectedAccount is null and no nonEvmAsset is found, but token exists, a mapped asset is created + expect(result.current.asset).toBeDefined(); + expect(result.current.asset?.address).toBe(mockToken.address); + expect(result.current.asset?.symbol).toBe(mockToken.symbol); }); }); diff --git a/app/components/UI/Card/hooks/useAssetBalance.tsx b/app/components/UI/Card/hooks/useAssetBalance.tsx index 486bae2022c..64a890b3801 100644 --- a/app/components/UI/Card/hooks/useAssetBalance.tsx +++ b/app/components/UI/Card/hooks/useAssetBalance.tsx @@ -1,18 +1,11 @@ import { useSelector } from 'react-redux'; -import { FlashListAssetKey } from '../../Tokens/TokenList'; import { RootState } from '../../../../reducers'; import { useMemo } from 'react'; -import { - makeSelectAssetByAddressAndChainId, - makeSelectNonEvmAssetById, -} from '../../../../selectors/multichain'; +import { makeSelectAssetByAddressAndChainId } from '../../../../selectors/multichain'; import { selectIsEvmNetworkSelected } from '../../../../selectors/multichainNetworkController'; import { deriveBalanceFromAssetMarketDetails } from '../../Tokens/util'; -import { - selectSelectedInternalAccount, - selectSelectedInternalAccountAddress, -} from '../../../../selectors/accountsController'; -import { CaipAssetId, Hex } from '@metamask/utils'; +import { selectSelectedInternalAccountAddress } from '../../../../selectors/accountsController'; +import { Hex } from '@metamask/utils'; import { selectCurrencyRateForChainId, selectCurrentCurrency, @@ -28,15 +21,17 @@ import { import I18n, { strings } from '../../../../../locales/i18n'; import { isTestNet } from '../../../../util/networks'; import { TokenI } from '../../Tokens/types'; +import { CardTokenAllowance } from '../types'; +import { buildTokenIconUrl } from '../util/buildTokenIconUrl'; // This hook retrieves the asset balance and related information for a given token and account. -const useAssetBalance = ( - token: FlashListAssetKey | null | undefined, +export const useAssetBalance = ( + token: CardTokenAllowance | null | undefined, ): { asset: TokenI | undefined; balanceFiat: string | undefined; - mainBalance: string; - secondaryBalance: string; + mainBalance: string | undefined; + secondaryBalance: string | undefined; } => { const isEvmNetworkSelected = useSelector(selectIsEvmNetworkSelected); const selectedInternalAccountAddress = useSelector( @@ -58,21 +53,23 @@ const useAssetBalance = ( : undefined, ); - ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) - const selectedAccount = useSelector(selectSelectedInternalAccount); - const selectNonEvmAsset = useMemo(() => makeSelectNonEvmAssetById(), []); - - const nonEvmAsset = useSelector((state: RootState) => - token && selectedAccount?.id - ? selectNonEvmAsset(state, { - accountId: selectedAccount.id, - assetId: token.address as CaipAssetId, - }) - : undefined, - ); - ///: END:ONLY_INCLUDE_IF - - let asset = token && isEvmNetworkSelected ? evmAsset : nonEvmAsset; + let asset = token && isEvmNetworkSelected ? evmAsset : undefined; + let isMappedAsset = false; + + if (!asset && token) { + const iconUrl = buildTokenIconUrl(token.chainId, token.address); + + asset = { + ...token, + image: iconUrl, + logo: iconUrl, + isETH: false, + aggregators: [], + balance: '0', + balanceFiat: '0', + } as TokenI; + isMappedAsset = true; + } const primaryCurrency = useSelector( (state: RootState) => state.settings.primaryCurrency, @@ -113,46 +110,51 @@ const useAssetBalance = ( const oneHundredThousandths = 0.00001; const { balanceFiat, balanceValueFormatted } = useMemo(() => { - if (!token) { + if (!asset || !token) { return { - balanceFiat: undefined, + balanceFiat: '', balanceValueFormatted: '', }; } - if (!asset) { + if (isMappedAsset) { return { - balanceFiat: undefined, - balanceValueFormatted: '', + balanceFiat: formatWithThreshold(0, oneHundredths, I18n.locale, { + style: 'currency', + currency: currentCurrency, + }), + balanceValueFormatted: `0 ${asset.symbol}`, }; } - return isEvmNetworkSelected && asset - ? deriveBalanceFromAssetMarketDetails( - asset, - exchangeRates || {}, - tokenBalances || {}, - conversionRate || 0, - currentCurrency || '', - ) - : { - balanceFiat: asset?.balanceFiat - ? formatWithThreshold( - parseFloat(asset.balanceFiat), - oneHundredths, - I18n.locale, - { style: 'currency', currency: currentCurrency }, - ) - : TOKEN_BALANCE_LOADING, - balanceValueFormatted: asset?.balance - ? formatWithThreshold( - parseFloat(asset.balance), - oneHundredThousandths, - I18n.locale, - { minimumFractionDigits: 0, maximumFractionDigits: 5 }, - ) - : TOKEN_BALANCE_LOADING, - }; + if (isEvmNetworkSelected && asset) { + return deriveBalanceFromAssetMarketDetails( + asset, + exchangeRates || {}, + tokenBalances || {}, + conversionRate || 0, + currentCurrency || '', + ); + } + + return { + balanceFiat: asset?.balanceFiat + ? formatWithThreshold( + parseFloat(asset.balanceFiat), + oneHundredths, + I18n.locale, + { style: 'currency', currency: currentCurrency }, + ) + : TOKEN_BALANCE_LOADING, + balanceValueFormatted: asset?.balance + ? formatWithThreshold( + parseFloat(asset.balance), + oneHundredThousandths, + I18n.locale, + { minimumFractionDigits: 0, maximumFractionDigits: 5 }, + ) + : TOKEN_BALANCE_LOADING, + }; }, [ token, isEvmNetworkSelected, @@ -161,31 +163,26 @@ const useAssetBalance = ( tokenBalances, conversionRate, currentCurrency, + isMappedAsset, ]); - // render balances according to primary currency let mainBalance; let secondaryBalance; const shouldNotShowBalanceOnTestnets = isTestNet(asset?.chainId as Hex) && !showFiatOnTestnets; - // Set main and secondary balances based on the primary currency and asset type. if (primaryCurrency === 'ETH') { - // TECH_DEBT: this should not be primary currency for multichain, not ETH - // Default to displaying the formatted balance value and its fiat equivalent. - mainBalance = balanceValueFormatted?.toUpperCase(); - secondaryBalance = balanceFiat?.toUpperCase(); - // For ETH as a native currency, adjust display based on network safety. + mainBalance = balanceValueFormatted; + secondaryBalance = balanceFiat; + if (asset?.isETH) { - // Main balance always shows the formatted balance value for ETH. - mainBalance = balanceValueFormatted?.toUpperCase(); - // Display fiat value as secondary balance only for original native tokens on safe networks. + mainBalance = balanceValueFormatted; secondaryBalance = shouldNotShowBalanceOnTestnets ? undefined - : balanceFiat?.toUpperCase(); + : balanceFiat; } } else { - secondaryBalance = balanceValueFormatted?.toUpperCase(); + secondaryBalance = balanceValueFormatted; if (shouldNotShowBalanceOnTestnets && !balanceFiat) { mainBalance = undefined; } else { @@ -213,5 +210,3 @@ const useAssetBalance = ( secondaryBalance, }; }; - -export default useAssetBalance; diff --git a/app/components/UI/Card/util/buildTokenIconUrl.test.ts b/app/components/UI/Card/util/buildTokenIconUrl.test.ts new file mode 100644 index 00000000000..3bdb5d2b7ff --- /dev/null +++ b/app/components/UI/Card/util/buildTokenIconUrl.test.ts @@ -0,0 +1,81 @@ +import { buildTokenIconUrl } from './buildTokenIconUrl'; + +describe('buildTokenIconUrl', () => { + it('should return empty string when chainId is not provided', () => { + const result = buildTokenIconUrl(undefined, '0x1234567890abcdef'); + expect(result).toBe(''); + }); + + it('should return empty string when address is not provided', () => { + const result = buildTokenIconUrl('1', undefined); + expect(result).toBe(''); + }); + + it('should return empty string when both chainId and address are not provided', () => { + const result = buildTokenIconUrl(undefined, undefined); + expect(result).toBe(''); + }); + + it('should return correct URL when chainId is a decimal string', () => { + const chainId = '1'; + const address = '0x1234567890abcdef'; + const expected = `https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/${address}.png`; + + const result = buildTokenIconUrl(chainId, address); + expect(result).toBe(expected); + }); + + it('should convert hex chainId to decimal and return correct URL', () => { + const chainId = '0x1'; // hex for 1 + const address = '0x1234567890abcdef'; + const expected = `https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/${address}.png`; + + const result = buildTokenIconUrl(chainId, address); + expect(result).toBe(expected); + }); + + it('should handle larger hex chainId values', () => { + const chainId = '0x89'; // hex for 137 (Polygon) + const address = '0xabcdef1234567890'; + const expected = `https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/137/erc20/${address}.png`; + + const result = buildTokenIconUrl(chainId, address); + expect(result).toBe(expected); + }); + + it('should handle chainId with leading zeros', () => { + const chainId = '0x01'; // hex for 1 with leading zero + const address = '0x1234567890abcdef'; + const expected = `https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/${address}.png`; + + const result = buildTokenIconUrl(chainId, address); + expect(result).toBe(expected); + }); + + it('should handle different address formats', () => { + const chainId = '1'; + const address = '0xA0b86a33E6441C8bbA8418Db9f4aD4d0d0e01a23'; + const expected = `https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/${address.toLowerCase()}.png`; + + const result = buildTokenIconUrl(chainId, address); + expect(result).toBe(expected); + }); + + it('should work with BSC mainnet', () => { + const chainId = '56'; // BSC mainnet + const address = '0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82'; + const expected = `https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/56/erc20/${address.toLowerCase()}.png`; + + const result = buildTokenIconUrl(chainId, address); + expect(result).toBe(expected); + }); + + it('should work with Arbitrum One', () => { + const chainId = '0xa4b1'; // hex for 42161 (Arbitrum One) + const address = '0x912CE59144191C1204E64559FE8253a0e49E6548'; + const expected = `https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/42161/erc20/${address.toLowerCase()}.png`; + + const result = buildTokenIconUrl(chainId, address); + expect(result).toBe(expected); + }); +}); diff --git a/app/components/UI/Card/util/buildTokenIconUrl.ts b/app/components/UI/Card/util/buildTokenIconUrl.ts new file mode 100644 index 00000000000..f4d242f7930 --- /dev/null +++ b/app/components/UI/Card/util/buildTokenIconUrl.ts @@ -0,0 +1,15 @@ +import { hexToDecimal } from '../../../../util/conversions'; + +export const buildTokenIconUrl = ( + chainId?: string, + address?: string, +): string => { + if (!chainId || !address) { + return ''; + } + + const chainIdDecimal = chainId.includes('0x') + ? hexToDecimal(chainId) + : chainId; + return `https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/${chainIdDecimal}/erc20/${address.toLowerCase()}.png`; +}; diff --git a/app/core/SnapKeyring/utils/sendMultichainTransaction.ts b/app/core/SnapKeyring/utils/sendMultichainTransaction.ts index 36383d7853f..aa4c2f484bc 100644 --- a/app/core/SnapKeyring/utils/sendMultichainTransaction.ts +++ b/app/core/SnapKeyring/utils/sendMultichainTransaction.ts @@ -26,7 +26,7 @@ export async function sendMultichainTransaction( params: { account, scope, - assetId, + ...(assetId !== undefined ? { assetId } : {}), }, }, }); diff --git a/e2e/selectors/Browser/TestSnaps.selectors.ts b/e2e/selectors/Browser/TestSnaps.selectors.ts index e4d68f4a3dd..51df53ef99d 100644 --- a/e2e/selectors/Browser/TestSnaps.selectors.ts +++ b/e2e/selectors/Browser/TestSnaps.selectors.ts @@ -15,6 +15,7 @@ export const TestSnapViewSelectorWebIDS = { connectNetworkAccessButton: 'connectnetwork-access', connectEthereumProviderButton: 'connectethereum-provider', connectStateButton: 'connectstate', + connectWasmButton: 'connectwasm', getPreferencesButton: 'getPreferences', getPublicKeyBip44Button: 'sendBip44Test', signMessageBip44Button: 'signBip44Message', @@ -39,6 +40,7 @@ export const TestSnapViewSelectorWebIDS = { getChainIdButton: 'sendEthprovider', getAccountsButton: 'sendEthproviderAccounts', personalSignButton: 'signPersonalSignMessage', + sendWasmMessageButton: 'sendWasmMessage', signTypedDataButton: 'signTypedDataButton', }; @@ -59,6 +61,7 @@ export const TestSnapInputSelectorWebIDS = { webSocketUrlInput: 'webSocketUrl', personalSignMessageInput: 'personalSignMessage', signTypedDataMessageInput: 'signTypedData', + wasmInput: 'wasmInput', }; export const EntropyDropDownSelectorWebIDS = { @@ -94,6 +97,7 @@ export const TestSnapResultSelectorWebIDS = { sendUnencryptedManageStateResultSpan: 'sendUnencryptedManageStateResult', signTypedDataResultSpan: 'signTypedDataResult', unencryptedStateResultSpan: 'unencryptedStateResult', + wasmResultSpan: 'wasmResult', }; export const TestSnapBottomSheetSelectorWebIDS = { diff --git a/e2e/specs/snaps/test-snap-wasm.spec.ts b/e2e/specs/snaps/test-snap-wasm.spec.ts new file mode 100644 index 00000000000..7b2fcee4b98 --- /dev/null +++ b/e2e/specs/snaps/test-snap-wasm.spec.ts @@ -0,0 +1,39 @@ +import { FlaskBuildTests } from '../../tags'; +import { loginToApp } from '../../viewHelper'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import TabBarComponent from '../../pages/wallet/TabBarComponent'; +import TestSnaps from '../../pages/Browser/TestSnaps'; + +jest.setTimeout(150_000); + +describe(FlaskBuildTests('WASM Snap Tests'), () => { + it('can connect to the WASM Snap', async () => { + await withFixtures( + { + fixture: new FixtureBuilder().build(), + restartDevice: true, + }, + async () => { + await loginToApp(); + await TabBarComponent.tapBrowser(); + await TestSnaps.navigateToTestSnap(); + + await TestSnaps.installSnap('connectWasmButton'); + }, + ); + }); + + it('return a response for the given number', async () => { + await withFixtures( + { + fixture: new FixtureBuilder().build(), + }, + async () => { + await TestSnaps.fillMessage('wasmInput', '23'); + await TestSnaps.tapButton('sendWasmMessageButton'); + await TestSnaps.checkResultSpan('wasmResultSpan', '28657'); + }, + ); + }); +}); diff --git a/patches/@metamask+snaps-controllers+14.1.0.patch b/patches/@metamask+snaps-controllers+14.1.0.patch new file mode 100644 index 00000000000..7a5571a39dc --- /dev/null +++ b/patches/@metamask+snaps-controllers+14.1.0.patch @@ -0,0 +1,18 @@ +diff --git a/node_modules/@metamask/snaps-controllers/dist/snaps/SnapController.cjs b/node_modules/@metamask/snaps-controllers/dist/snaps/SnapController.cjs +index 908f4e7..8f66c3f 100644 +--- a/node_modules/@metamask/snaps-controllers/dist/snaps/SnapController.cjs ++++ b/node_modules/@metamask/snaps-controllers/dist/snaps/SnapController.cjs +@@ -963,12 +963,7 @@ class SnapController extends base_controller_1.BaseController { + */ + async clearState() { + const snapIds = Object.keys(this.state.snaps); +- if (this.#closeAllConnections) { +- snapIds.forEach((snapId) => { +- this.#closeAllConnections?.(snapId); +- }); +- } +- await this.messagingSystem.call('ExecutionService:terminateAllSnaps'); ++ await this.stopAllSnaps(); + snapIds.forEach((snapId) => this.#revokeAllSnapPermissions(snapId)); + this.update((state) => { + state.snaps = {}; diff --git a/scripts/build.sh b/scripts/build.sh index 1053d200b03..4cb575bcb2a 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -368,13 +368,11 @@ buildAndroidRunFlask(){ buildIosSimulator(){ remapEnvVariableLocal prebuild_ios + device_args=() if [ -n "$IOS_SIMULATOR" ]; then - SIM_OPTION="--device \"$IOS_SIMULATOR\"" - else - SIM_OPTION="" + device_args=(--device "$IOS_SIMULATOR") fi - #react-native run-ios --port=$WATCHER_PORT $SIM_OPTION - npx expo run:ios --no-install --configuration Debug --port $WATCHER_PORT $SIM_OPTION + npx expo run:ios --no-install --configuration Debug --port $WATCHER_PORT "${device_args[@]}" } buildIosSimulatorQA(){