diff --git a/.github/workflows/run-performance-e2e.yml b/.github/workflows/run-performance-e2e.yml index 25510b23154..e95664724cd 100644 --- a/.github/workflows/run-performance-e2e.yml +++ b/.github/workflows/run-performance-e2e.yml @@ -8,6 +8,14 @@ on: description: 'Optional description for this test run' required: false type: string + browserstack_app_url_android: + description: 'BrowserStack Android App URL (bs://...)' + required: false + type: string + browserstack_app_url_ios: + description: 'BrowserStack iOS App URL (bs://...)' + required: false + type: string permissions: contents: read id-token: write @@ -66,6 +74,8 @@ jobs: name: Trigger QA Builds and Extract BrowserStack URLs runs-on: ubuntu-latest needs: read-device-matrix + # Only run if BrowserStack URLs are not provided + if: ${{ !inputs.browserstack_app_url_android || !inputs.browserstack_app_url_ios }} env: BITRISE_APP_ID: ${{ secrets.BITRISE_APP_ID }} BITRISE_BUILD_TRIGGER_TOKEN: ${{ secrets.BITRISE_BUILD_TRIGGER_TOKEN }} @@ -334,8 +344,10 @@ jobs: android-tests: name: Android Tests runs-on: ubuntu-latest - timeout-minutes: 45 - needs: [trigger-qa-builds-and-upload, read-device-matrix] + timeout-minutes: 60 + needs: [read-device-matrix, trigger-qa-builds-and-upload] + # Always run after device matrix is ready, regardless of trigger job status + if: always() && !failure() && !cancelled() strategy: fail-fast: false matrix: @@ -389,12 +401,35 @@ jobs: - name: Set Android Test Environment run: | echo "Setting test environment for device: ${{ matrix.device.name }}" + + # Use BrowserStack URL from trigger job if it ran, otherwise from input + ANDROID_APP_URL="${{ needs.trigger-qa-builds-and-upload.outputs.browserstack-android-url }}" + if [ -z "$ANDROID_APP_URL" ]; then + ANDROID_APP_URL="${{ inputs.browserstack_app_url_android }}" + echo "ℹ️ Using Android URL from input (trigger job was skipped)" + else + echo "ℹ️ Using Android URL from trigger job" + fi + + # Validate that we have a BrowserStack URL + if [ -z "$ANDROID_APP_URL" ]; then + echo "❌ Error: No Android BrowserStack URL available" + echo "Either provide browserstack_app_url_android as input or ensure trigger-qa-builds-and-upload job runs successfully" + exit 1 + fi + + # Use app version from trigger job if available, otherwise default + APP_VERSION="${{ needs.trigger-qa-builds-and-upload.outputs.android-version }}" + if [ -z "$APP_VERSION" ]; then + APP_VERSION="Manual-Input" + fi + { echo "BROWSERSTACK_DEVICE=${{ matrix.device.name }}" echo "BROWSERSTACK_OS_VERSION=${{ matrix.device.os_version }}" - echo "BROWSERSTACK_ANDROID_APP_URL=${{ needs.trigger-qa-builds-and-upload.outputs.browserstack-android-url }}" + echo "BROWSERSTACK_ANDROID_APP_URL=$ANDROID_APP_URL" echo "TEST_PLATFORM=android" - echo "QA_APP_VERSION=${{ needs.trigger-qa-builds-and-upload.outputs.android-version }}" + echo "QA_APP_VERSION=$APP_VERSION" echo "BROWSERSTACK_BUILD_NAME=Android-Performance-${{ github.ref_name }}-Branch" } >> "$GITHUB_ENV" @@ -407,8 +442,8 @@ jobs: echo "OS Version: ${{ matrix.device.os_version }}" echo "Category: ${{ matrix.device.category }}" echo "Branch: ${{ github.ref_name }}" - echo "QA App Version: ${{ needs.trigger-qa-builds-and-upload.outputs.android-version }}" - echo "BrowserStack Android App URL: ${{ needs.trigger-qa-builds-and-upload.outputs.browserstack-android-url }}" + echo "QA App Version: $QA_APP_VERSION" + echo "BrowserStack Android App URL: $BROWSERSTACK_ANDROID_APP_URL" yarn run-appwright:android-bs @@ -426,8 +461,10 @@ jobs: ios-tests: name: iOS Tests runs-on: ubuntu-latest - timeout-minutes: 45 - needs: [trigger-qa-builds-and-upload, read-device-matrix] + timeout-minutes: 60 + needs: [read-device-matrix, trigger-qa-builds-and-upload] + # Always run after device matrix is ready, regardless of trigger job status + if: always() && !failure() && !cancelled() strategy: fail-fast: false matrix: @@ -481,11 +518,34 @@ jobs: - name: Set iOS Test Environment run: | echo "Setting test environment for device: ${{ matrix.device.name }}" + + # Use BrowserStack URL from trigger job if it ran, otherwise from input + IOS_APP_URL="${{ needs.trigger-qa-builds-and-upload.outputs.browserstack-ios-url }}" + if [ -z "$IOS_APP_URL" ]; then + IOS_APP_URL="${{ inputs.browserstack_app_url_ios }}" + echo "ℹ️ Using iOS URL from input (trigger job was skipped)" + else + echo "ℹ️ Using iOS URL from trigger job" + fi + + # Validate that we have a BrowserStack URL + if [ -z "$IOS_APP_URL" ]; then + echo "❌ Error: No iOS BrowserStack URL available" + echo "Either provide browserstack_app_url_ios as input or ensure trigger-qa-builds-and-upload job runs successfully" + exit 1 + fi + + # Use app version from trigger job if available, otherwise default + APP_VERSION="${{ needs.trigger-qa-builds-and-upload.outputs.ios-version }}" + if [ -z "$APP_VERSION" ]; then + APP_VERSION="Manual-Input" + fi + { echo "BROWSERSTACK_DEVICE=${{ matrix.device.name }}" echo "BROWSERSTACK_OS_VERSION=${{ matrix.device.os_version }}" - echo "BROWSERSTACK_IOS_APP_URL=${{ needs.trigger-qa-builds-and-upload.outputs.browserstack-ios-url }}" - echo "QA_APP_VERSION=${{ needs.trigger-qa-builds-and-upload.outputs.ios-version }}" + echo "BROWSERSTACK_IOS_APP_URL=$IOS_APP_URL" + echo "QA_APP_VERSION=$APP_VERSION" echo "BROWSERSTACK_BUILD_NAME=iOS-Performance-${{ github.ref_name }}-Branch" } >> "$GITHUB_ENV" @@ -498,8 +558,8 @@ jobs: echo "OS Version: ${{ matrix.device.os_version }}" echo "Category: ${{ matrix.device.category }}" echo "Branch: ${{ github.ref_name }}" - echo "QA App Version: ${{ needs.trigger-qa-builds-and-upload.outputs.ios-version }}" - echo "BrowserStack iOS App URL: ${{ needs.trigger-qa-builds-and-upload.outputs.browserstack-ios-url }}" + echo "QA App Version: $QA_APP_VERSION" + echo "BrowserStack iOS App URL: $BROWSERSTACK_IOS_APP_URL" if [ "${{ matrix.device.os_version }}" == "13" ] || [ "${{ matrix.device.os_version }}" == "11" ]; then echo "Warning: iOS ${{ matrix.device.os_version }} may not be supported by MetaMask app" fi @@ -521,14 +581,330 @@ jobs: gather-results: name: Gather Test Results runs-on: ubuntu-latest - needs: [trigger-qa-builds-and-upload, android-tests, ios-tests] - if: always() + needs: [android-tests, ios-tests] + if: always() # Always run, even if previous jobs failed steps: - name: Download All Test Results uses: actions/download-artifact@v4 with: path: appwright/test-reports/ + continue-on-error: true # Continue even if some artifacts are missing due to failed tests + + - name: Aggregate Performance Reports + run: | + echo "🔍 Searching for JSON performance reports..." + echo "📊 Job status context:" + echo " Android tests: ${{ needs.android-tests.result }}" + echo " iOS tests: ${{ needs.ios-tests.result }}" + + # Set environment variables for job results + export ANDROID_JOB_STATUS="${{ needs.android-tests.result }}" + export IOS_JOB_STATUS="${{ needs.ios-tests.result }}" + + # Create output directory + mkdir -p appwright/aggregated-reports + + # Create aggregated report structure + cat > aggregate_reports.js << 'EOF' + const fs = require('fs'); + const path = require('path'); + + function findJsonFiles(dir, jsonFiles = []) { + if (!fs.existsSync(dir)) return jsonFiles; + + const files = fs.readdirSync(dir); + for (const file of files) { + const fullPath = path.join(dir, file); + if (fs.statSync(fullPath).isDirectory()) { + findJsonFiles(fullPath, jsonFiles); + } else if (file.endsWith('.json') && file.includes('performance-metrics')) { + jsonFiles.push(fullPath); + } + } + return jsonFiles; + } + + function aggregateReports() { + try { + console.log('🔍 Looking for performance JSON reports...'); + + const jsonFiles = findJsonFiles('appwright/test-reports'); + console.log(`📊 Found ${jsonFiles.length} JSON report files:`); + + if (jsonFiles.length === 0) { + console.log('❌ No performance JSON files found - creating empty report structure'); + + // Create empty report structure when no files are found (in requested format) + const emptyReport = { + Android: {}, + iOS: {} + }; + + const outputPath = 'appwright/aggregated-reports/aggregated-performance-report.json'; + fs.writeFileSync(outputPath, JSON.stringify(emptyReport, null, 2)); + + // Create empty summary + const emptySummary = { + totalTests: 0, + platforms: { android: 0, ios: 0 }, + testsByPlatform: { android: 0, ios: 0 }, + devices: [], + platformDevices: { Android: [], iOS: [] }, + generatedAt: new Date().toISOString(), + branch: process.env.GITHUB_REF_NAME || 'unknown', + commit: process.env.GITHUB_SHA || 'unknown', + warning: 'No test results found' + }; + + fs.writeFileSync('appwright/aggregated-reports/summary.json', JSON.stringify(emptySummary, null, 2)); + + console.log('✅ Empty report structure created successfully'); + console.log(`📄 Empty report saved to: ${outputPath}`); + console.log('📋 Empty summary saved to: appwright/aggregated-reports/summary.json'); + return; + } + + // Create the new grouped structure + const groupedResults = { + Android: {}, + iOS: {} + }; + + const metadata = { + generatedAt: new Date().toISOString(), + totalReports: jsonFiles.length, + platforms: { + android: 0, + ios: 0 + }, + jobResults: { + android: process.env.ANDROID_JOB_STATUS || 'unknown', + ios: process.env.IOS_JOB_STATUS || 'unknown' + }, + branch: process.env.GITHUB_REF_NAME || 'unknown', + commit: process.env.GITHUB_SHA || 'unknown', + workflowRun: process.env.GITHUB_RUN_ID || 'unknown' + }; + + jsonFiles.forEach(filePath => { + try { + const content = fs.readFileSync(filePath, 'utf8'); + const reportData = JSON.parse(content); + + // Extract platform and device info from path or content + const pathParts = filePath.split('/'); + let platform = 'unknown'; + let platformKey = 'Unknown'; + let deviceInfo = 'unknown'; + let deviceKey = 'Unknown Device'; + + // Try to determine platform from path + if (filePath.includes('android-test-results')) { + platform = 'android'; + platformKey = 'Android'; + metadata.platforms.android++; + } else if (filePath.includes('ios-test-results')) { + platform = 'ios'; + platformKey = 'iOS'; + metadata.platforms.ios++; + } + + // Extract device info from path (e.g., android-test-results-Samsung Galaxy S21-11) + const deviceMatch = pathParts.find(part => part.includes('-test-results-')); + if (deviceMatch) { + const parts = deviceMatch.split('-'); + if (parts.length >= 4) { + deviceInfo = parts.slice(3).join('-'); + // Create device key in format "DeviceName+OSVersion" + const deviceParts = deviceInfo.split('-'); + if (deviceParts.length >= 2) { + const osVersion = deviceParts[deviceParts.length - 1]; + const deviceName = deviceParts.slice(0, -1).join(' '); + deviceKey = `${deviceName}+${osVersion}`; + } else { + deviceKey = deviceInfo; + } + } + } else { + // Fallback: try to extract from full path or use device info from report data + if (Array.isArray(reportData) && reportData.length > 0 && reportData[0].device) { + const device = reportData[0].device; + deviceKey = `${device.name}+${device.osVersion}`; + } else if (reportData.device) { + const device = reportData.device; + deviceKey = `${device.name}+${device.osVersion}`; + } + } + + // Initialize platform and device arrays if they don't exist + if (!groupedResults[platformKey]) { + groupedResults[platformKey] = {}; + } + if (!groupedResults[platformKey][deviceKey]) { + groupedResults[platformKey][deviceKey] = []; + } + + // Process the report data (array of test results) + const processTestReport = (testReport) => { + const cleanedReport = { + testName: testReport.testName, + steps: testReport.steps || [], + totalTime: testReport.total, + videoURL: testReport.videoURL || null + }; + + // Add failure info if test failed (optional - include if you want failure info in final output) + if (testReport.testFailed) { + cleanedReport.testFailed = true; + cleanedReport.failureReason = testReport.failureReason; + } + + groupedResults[platformKey][deviceKey].push(cleanedReport); + }; + + if (Array.isArray(reportData)) { + reportData.forEach(processTestReport); + } else { + // Single test report + processTestReport(reportData); + } + } catch (error) { + console.error(`❌ Error processing ${filePath}: ${error.message}`); + return; + } + }); + + // Create final report in the exact format requested (just Android and iOS at root level) + const finalReport = { + Android: groupedResults.Android || {}, + iOS: groupedResults.iOS || {} + }; + + // Write the aggregated report in the requested format + const outputPath = 'appwright/aggregated-reports/aggregated-performance-report.json'; + fs.writeFileSync(outputPath, JSON.stringify(finalReport, null, 2)); + + // Also create a pretty-formatted version for easier viewing + const prettyOutputPath = 'appwright/aggregated-reports/performance-results.json'; + fs.writeFileSync(prettyOutputPath, JSON.stringify(finalReport, null, 4)); + + // Count total tests across all platforms and devices + let totalTests = 0; + const devices = []; + + Object.keys(groupedResults).forEach(platform => { + Object.keys(groupedResults[platform]).forEach(device => { + devices.push(`${platform}-${device}`); + totalTests += groupedResults[platform][device].length; + }); + }); + + console.log(`✅ Aggregated report saved: ${totalTests} tests across ${devices.length} device configurations`); + console.log(`📊 Platform summary:`); + console.log(` Android: ${metadata.platforms.android} reports (job: ${metadata.jobResults.android})`); + console.log(` iOS: ${metadata.platforms.ios} reports (job: ${metadata.jobResults.ios})`); + + // Show which platforms had data + const androidHasData = Object.keys(groupedResults.Android).length > 0; + const iosHasData = Object.keys(groupedResults.iOS).length > 0; + + if (!androidHasData && metadata.jobResults.android === 'failure') { + console.log(`⚠️ Android tests failed - no Android performance data available`); + } + if (!iosHasData && metadata.jobResults.ios === 'failure') { + console.log(`⚠️ iOS tests failed - no iOS performance data available`); + } + if (androidHasData || iosHasData) { + console.log(`✅ Successfully aggregated data from available platforms`); + } + + // Create summary file with metadata + const summary = { + totalTests, + platforms: metadata.platforms, + testsByPlatform: { + android: 0, + ios: 0 + }, + devices: [], + platformDevices: { + Android: Object.keys(groupedResults.Android || {}), + iOS: Object.keys(groupedResults.iOS || {}) + }, + metadata, + generatedAt: metadata.generatedAt, + branch: metadata.branch, + commit: metadata.commit + }; + + // Count tests by platform and collect device info + Object.keys(groupedResults).forEach(platform => { + Object.keys(groupedResults[platform]).forEach(device => { + const testsCount = groupedResults[platform][device].length; + if (platform === 'Android') { + summary.testsByPlatform.android += testsCount; + } else if (platform === 'iOS') { + summary.testsByPlatform.ios += testsCount; + } + summary.devices.push({ platform, device, testCount: testsCount }); + }); + }); + + fs.writeFileSync('appwright/aggregated-reports/summary.json', JSON.stringify(summary, null, 2)); + console.log('📋 Summary report saved to: appwright/aggregated-reports/summary.json'); + + } catch (error) { + console.error('❌ Error during aggregation:', error.message); + console.log('🔄 Creating fallback empty report due to aggregation error...'); + + // Create fallback empty report structure (in requested format) + const fallbackReport = { + Android: {}, + iOS: {} + }; + + try { + fs.writeFileSync('appwright/aggregated-reports/aggregated-performance-report.json', JSON.stringify(fallbackReport, null, 2)); + fs.writeFileSync('appwright/aggregated-reports/summary.json', JSON.stringify({ + totalTests: 0, + platforms: { android: 0, ios: 0 }, + error: error.message, + generatedAt: new Date().toISOString() + }, null, 2)); + console.log('✅ Fallback reports created successfully'); + } catch (writeError) { + console.error('❌ Failed to create fallback reports:', writeError.message); + } + } + } + + aggregateReports(); + EOF + + # Run the aggregation script (ensure it doesn't fail the step) + echo "" + echo "🔄 Running aggregation script..." + node aggregate_reports.js || { + echo "⚠️ Aggregation script had issues, but continuing to ensure reports are available" + echo "This is normal when some test jobs failed - we'll aggregate whatever data is available" + } + + # Ensure output directory exists even if script failed + mkdir -p appwright/aggregated-reports + + # List generated files and show structure + echo "📁 Generated files:" + ls -la appwright/aggregated-reports/ || echo "📁 No aggregated reports directory found" + + # Show a sample of the aggregated report structure if it exists + if [ -f "appwright/aggregated-reports/aggregated-performance-report.json" ]; then + echo "" + echo "📋 Sample of aggregated report structure:" + echo "=========================================" + head -50 appwright/aggregated-reports/aggregated-performance-report.json || echo "Could not display file contents" + echo "=========================================" + fi - name: Upload Combined Reports if: always() @@ -538,31 +914,36 @@ jobs: path: | appwright/test-reports/appwright-report/ appwright/reporters/reports + appwright/aggregated-reports/ if-no-files-found: ignore retention-days: 14 - - name: Check Test Results + - name: Check Test Results id: test-results run: | - if [ "${{ needs.android-tests.result }}" == "failure" ] || [ "${{ needs.ios-tests.result }}" == "failure" ]; then - echo "Some tests failed. Check the individual job results above." - echo "Note: iOS 13 and iOS 11 failures are expected due to MetaMask app compatibility." - echo "overall_status=failure" >> "$GITHUB_OUTPUT" - exit 1 + ANDROID_STATUS="${{ needs.android-tests.result }}" + IOS_STATUS="${{ needs.ios-tests.result }}" + + echo "📊 Test Results Summary:" + echo " Android tests: $ANDROID_STATUS" + echo " iOS tests: $IOS_STATUS" + + # Check if we have any performance data available + if [ -f "appwright/aggregated-reports/aggregated-performance-report.json" ]; then + echo "✅ Performance data aggregated successfully from available test results" + + if [ "$ANDROID_STATUS" == "failure" ] || [ "$IOS_STATUS" == "failure" ]; then + echo "⚠️ Some tests failed, but performance data was collected from successful tests" + echo "Note: This is expected behavior - we collect data from passing tests even when others fail" + echo "overall_status=partial_success" >> "$GITHUB_OUTPUT" + else + echo "🎉 All test jobs completed successfully with full performance data!" + echo "overall_status=success" >> "$GITHUB_OUTPUT" + fi else - echo "All test jobs completed successfully!" - echo "overall_status=success" >> "$GITHUB_OUTPUT" + echo "❌ No performance data could be aggregated - all tests may have failed" + echo "overall_status=failure" >> "$GITHUB_OUTPUT" fi - - - name: Display QA Build Info - run: | - echo "=== QA Build Information ===" - echo "Android Version: ${{ needs.trigger-qa-builds-and-upload.outputs.android-version }}" - echo "iOS Version: ${{ needs.trigger-qa-builds-and-upload.outputs.ios-version }}" - echo "" - echo "=== BrowserStack App URLs ===" - echo "Android App URL: ${{ needs.trigger-qa-builds-and-upload.outputs.browserstack-android-url }}" - echo "iOS App URL: ${{ needs.trigger-qa-builds-and-upload.outputs.browserstack-ios-url }}" outputs: overall_status: ${{ steps.test-results.outputs.overall_status }} @@ -594,16 +975,27 @@ jobs: echo "- android-tests: $ANDROID_RESULT" echo "- ios-tests: $IOS_RESULT" + # Get gather-results status + GATHER_RESULT="${{ needs.gather-results.outputs.overall_status }}" + echo "- gather-results: $GATHER_RESULT" + # Determine overall status if [ "$TRIGGER_RESULT" = "failure" ]; then OVERALL_STATUS="❌ FAILED" echo "Overall Status: FAILED (QA builds failed)" - elif [ "$ANDROID_RESULT" = "failure" ] || [ "$IOS_RESULT" = "failure" ]; then + elif [ "$GATHER_RESULT" = "failure" ]; then OVERALL_STATUS="❌ FAILED" - echo "Overall Status: FAILED (tests failed)" + echo "Overall Status: FAILED (No performance data collected - all tests failed)" + elif [ "$GATHER_RESULT" = "partial_success" ] || [ "$ANDROID_RESULT" = "failure" ] || [ "$IOS_RESULT" = "failure" ]; then + OVERALL_STATUS="⚠️ PARTIAL SUCCESS" + echo "Overall Status: PARTIAL SUCCESS (Some tests failed, but performance data was collected)" else OVERALL_STATUS="✅ PASSED" - echo "Overall Status: PASSED (all tests passed)" + if [ "$TRIGGER_RESULT" = "skipped" ]; then + echo "Overall Status: PASSED (All tests passed with performance data - used provided BrowserStack URLs)" + else + echo "Overall Status: PASSED (All tests passed with performance data)" + fi fi # Set outputs diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.tsx b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.tsx index 48b8baa56ef..08d077d9a44 100644 --- a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.tsx +++ b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/MultichainAccountSelectorList.tsx @@ -151,6 +151,20 @@ const MultichainAccountSelectorList = ({ return items; }, [filteredWalletSections]); + // Reset scroll to top when search text changes + useEffect(() => { + if (listRefToUse.current) { + // Use requestAnimationFrame to ensure the list has finished re-rendering + const animationFrameId = requestAnimationFrame(() => { + listRefToUse.current?.scrollToOffset({ offset: 0, animated: false }); + }); + + return () => { + cancelAnimationFrame(animationFrameId); + }; + } + }, [debouncedSearchText, listRefToUse]); + // Listen for account creation and scroll to new account useEffect(() => { if (lastCreatedAccountId && listRefToUse.current) { diff --git a/app/component-library/components/Skeleton/Skeleton.tsx b/app/component-library/components/Skeleton/Skeleton.tsx index d15565b67a4..7aa7b3c8f55 100644 --- a/app/component-library/components/Skeleton/Skeleton.tsx +++ b/app/component-library/components/Skeleton/Skeleton.tsx @@ -30,10 +30,6 @@ const Skeleton: React.FC = ({ }); const startAnimation = () => { - // On E2E, we don't want to animate the skeleton otherwise recurring timers will be ON. - if (isE2E) { - return; - } Animated.sequence([ Animated.timing(opacityAnim, { toValue: 0.1, @@ -56,7 +52,7 @@ const Skeleton: React.FC = ({ useEffect(() => { // Only start animation if no children are present or if children should be hidden - if (!children || hideChildren) { + if (!isE2E && (!children || hideChildren)) { startAnimation(); } diff --git a/app/components/UI/AddCustomCollectible/__snapshots__/index.test.tsx.snap b/app/components/UI/AddCustomCollectible/__snapshots__/index.test.tsx.snap index d7aa66d46ed..58c9cfdb76b 100644 --- a/app/components/UI/AddCustomCollectible/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/AddCustomCollectible/__snapshots__/index.test.tsx.snap @@ -27,6 +27,11 @@ exports[`AddCustomCollectible should render correctly 1`] = ` } } > - + `; diff --git a/app/components/UI/AddCustomCollectible/index.test.tsx b/app/components/UI/AddCustomCollectible/index.test.tsx index 9290c24ada9..6ff1760f26e 100644 --- a/app/components/UI/AddCustomCollectible/index.test.tsx +++ b/app/components/UI/AddCustomCollectible/index.test.tsx @@ -5,10 +5,11 @@ import configureMockStore from 'redux-mock-store'; import { Provider } from 'react-redux'; import initialRootState from '../../../util/test/initial-root-state'; import renderWithProvider from '../../../util/test/renderWithProvider'; -import { act, fireEvent } from '@testing-library/react-native'; +import { act, fireEvent, waitFor } from '@testing-library/react-native'; import Engine from '../../../core/Engine'; // eslint-disable-next-line import/no-namespace import * as utilsTransactions from '../../../util/transactions'; +import { Alert } from 'react-native'; jest.mock('../../../core/Engine', () => ({ context: { @@ -33,20 +34,47 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn().mockImplementation(() => ''), })); +jest.mock('react-native', () => { + const RN = jest.requireActual('react-native'); + return { + ...RN, + Alert: { + alert: jest.fn(), + }, + }; +}); + +jest.mock('../../../../locales/i18n', () => ({ + strings: jest.fn((key) => key), +})); + describe('AddCustomCollectible', () => { it('should render correctly', () => { const wrapper = shallow( - + , ); expect(wrapper).toMatchSnapshot(); }); it('handles address input changes', () => { - const { getByTestId } = renderWithProvider(, { - state: initialRootState, - }); + const { getByTestId } = renderWithProvider( + , + { + state: initialRootState, + }, + ); const textfield = getByTestId('input-collectible-address'); fireEvent.changeText(textfield, '0xtestAddress'); @@ -54,9 +82,17 @@ describe('AddCustomCollectible', () => { }); it('handles tokenId input changes', () => { - const { getByTestId } = renderWithProvider(, { - state: initialRootState, - }); + const { getByTestId } = renderWithProvider( + , + { + state: initialRootState, + }, + ); const textfield = getByTestId('input-collectible-identifier'); fireEvent.changeText(textfield, '55'); @@ -79,6 +115,10 @@ describe('AddCustomCollectible', () => { const { getByTestId } = renderWithProvider( , { state: initialRootState }, ); @@ -100,4 +140,600 @@ describe('AddCustomCollectible', () => { expect(spyOnAddNft).toHaveBeenCalledTimes(1); }); + + describe('Validation Functions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows warning when address is empty and input loses focus', async () => { + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + const addressInput = getByTestId('input-collectible-address'); + fireEvent(addressInput, 'onBlur'); + + await waitFor(() => { + const warningText = getByTestId('collectible-address-warning'); + expect(warningText.props.children).toBe( + 'collectible.address_cant_be_empty', + ); + }); + }); + + it('shows warning when address is invalid and input loses focus', async () => { + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + const addressInput = getByTestId('input-collectible-address'); + fireEvent.changeText(addressInput, 'invalid-address'); + fireEvent(addressInput, 'onBlur'); + + await waitFor(() => { + const warningText = getByTestId('collectible-address-warning'); + expect(warningText.props.children).toBe( + 'collectible.address_must_be_valid', + ); + }); + }); + + it('shows warning when address is not a smart contract and input loses focus', async () => { + jest + .spyOn(utilsTransactions, 'isSmartContractAddress') + .mockResolvedValue(false); + + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + const addressInput = getByTestId('input-collectible-address'); + fireEvent.changeText( + addressInput, + '0x1a92f7381b9f03921564a437210bb9396471050c', + ); + fireEvent(addressInput, 'onBlur'); + + await waitFor(() => { + const warningText = getByTestId('collectible-address-warning'); + expect(warningText.props.children).toBe( + 'collectible.address_must_be_smart_contract', + ); + }); + }); + + it('clears address warning when valid smart contract address is entered', async () => { + jest + .spyOn(utilsTransactions, 'isSmartContractAddress') + .mockResolvedValue(true); + + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + const addressInput = getByTestId('input-collectible-address'); + fireEvent.changeText( + addressInput, + '0x1a92f7381b9f03921564a437210bb9396471050c', + ); + fireEvent(addressInput, 'onBlur'); + + await waitFor(() => { + const warningText = getByTestId('collectible-address-warning'); + expect(warningText.props.children).toBe(''); + }); + }); + + it('shows warning when token ID is empty and input loses focus', () => { + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + const tokenIdInput = getByTestId('input-collectible-identifier'); + fireEvent(tokenIdInput, 'onBlur'); + + const warningText = getByTestId('collectible-identifier-warning'); + expect(warningText.props.children).toBe( + 'collectible.token_id_cant_be_empty', + ); + }); + + it('clears token ID warning when value is entered', () => { + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + const tokenIdInput = getByTestId('input-collectible-identifier'); + fireEvent.changeText(tokenIdInput, '123'); + fireEvent(tokenIdInput, 'onBlur'); + + const warningText = getByTestId('collectible-identifier-warning'); + expect(warningText.props.children).toBe(''); + }); + }); + + describe('Ownership Validation', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest + .spyOn(utilsTransactions, 'isSmartContractAddress') + .mockResolvedValue(true); + }); + + it('shows alert when user is not the owner of NFT', async () => { + jest + .spyOn(Engine.context.NftController, 'isNftOwner') + .mockResolvedValue(false); + const alertSpy = jest.spyOn(Alert, 'alert'); + + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + const addressInput = getByTestId('input-collectible-address'); + fireEvent.changeText( + addressInput, + '0x1a92f7381b9f03921564a437210bb9396471050c', + ); + + const tokenIdInput = getByTestId('input-collectible-identifier'); + fireEvent.changeText(tokenIdInput, '55'); + + const button = getByTestId('add-collectible-button'); + + await act(async () => { + fireEvent.press(button); + }); + + expect(alertSpy).toHaveBeenCalledWith( + 'collectible.not_owner_error_title', + 'collectible.not_owner_error', + ); + }); + + it('shows alert when ownership verification fails', async () => { + jest + .spyOn(Engine.context.NftController, 'isNftOwner') + .mockRejectedValue(new Error('Network error')); + const alertSpy = jest.spyOn(Alert, 'alert'); + + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + const addressInput = getByTestId('input-collectible-address'); + fireEvent.changeText( + addressInput, + '0x1a92f7381b9f03921564a437210bb9396471050c', + ); + + const tokenIdInput = getByTestId('input-collectible-identifier'); + fireEvent.changeText(tokenIdInput, '55'); + + const button = getByTestId('add-collectible-button'); + + await act(async () => { + fireEvent.press(button); + }); + + expect(alertSpy).toHaveBeenCalledWith( + 'collectible.ownership_verification_error_title', + 'collectible.ownership_verification_error', + ); + }); + }); + + describe('State Management', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('manages loading state during NFT addition', async () => { + jest + .spyOn(Engine.context.NftController, 'isNftOwner') + .mockResolvedValue(true); + jest + .spyOn(Engine.context.NftController, 'addNft') + .mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 100)), + ); + jest + .spyOn(utilsTransactions, 'isSmartContractAddress') + .mockResolvedValue(true); + + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + const addressInput = getByTestId('input-collectible-address'); + fireEvent.changeText( + addressInput, + '0x1a92f7381b9f03921564a437210bb9396471050c', + ); + + const tokenIdInput = getByTestId('input-collectible-identifier'); + fireEvent.changeText(tokenIdInput, '55'); + + const button = getByTestId('add-collectible-button'); + + act(() => { + fireEvent.press(button); + }); + + // Button should be disabled during loading + expect(button.props.disabled).toBeTruthy(); + }); + + it('sets address from collectibleContract prop on mount', () => { + const collectibleContract = { + address: '0x1a92f7381b9f03921564a437210bb9396471050c', + }; + + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + const addressInput = getByTestId('input-collectible-address'); + expect(addressInput.props.value).toBe(collectibleContract.address); + }); + + it('confirms button is disabled when required fields are missing', () => { + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + const button = getByTestId('add-collectible-button'); + expect(button.props.disabled).toBeTruthy(); + }); + + it('enables confirm button when all required fields are filled', () => { + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + const addressInput = getByTestId('input-collectible-address'); + fireEvent.changeText( + addressInput, + '0x1a92f7381b9f03921564a437210bb9396471050c', + ); + + const tokenIdInput = getByTestId('input-collectible-identifier'); + fireEvent.changeText(tokenIdInput, '55'); + + const button = getByTestId('add-collectible-button'); + expect(button.props.disabled).toBeFalsy(); + }); + + it('disables confirm button when network is not selected', () => { + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + const addressInput = getByTestId('input-collectible-address'); + fireEvent.changeText( + addressInput, + '0x1a92f7381b9f03921564a437210bb9396471050c', + ); + + const tokenIdInput = getByTestId('input-collectible-identifier'); + fireEvent.changeText(tokenIdInput, '55'); + + const button = getByTestId('add-collectible-button'); + expect(button.props.disabled).toBeTruthy(); + }); + }); + + describe('Navigation Interactions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls navigation.goBack when cancel is pressed', () => { + const mockGoBack = jest.fn(); + const navigation = { navigate: jest.fn(), goBack: mockGoBack }; + + const { getByText } = renderWithProvider( + , + { state: initialRootState }, + ); + + const cancelButton = getByText( + 'add_asset.collectibles.cancel_add_collectible', + ); + fireEvent.press(cancelButton); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('calls navigation.goBack after successful NFT addition', async () => { + const mockGoBack = jest.fn(); + const navigation = { navigate: jest.fn(), goBack: mockGoBack }; + + jest + .spyOn(Engine.context.NftController, 'isNftOwner') + .mockResolvedValue(true); + jest + .spyOn(Engine.context.NftController, 'addNft') + .mockResolvedValue(undefined); + jest + .spyOn(utilsTransactions, 'isSmartContractAddress') + .mockResolvedValue(true); + + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + const addressInput = getByTestId('input-collectible-address'); + fireEvent.changeText( + addressInput, + '0x1a92f7381b9f03921564a437210bb9396471050c', + ); + + const tokenIdInput = getByTestId('input-collectible-identifier'); + fireEvent.changeText(tokenIdInput, '55'); + + const button = getByTestId('add-collectible-button'); + + await act(async () => { + fireEvent.press(button); + }); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('opens network selector when network selector button is pressed', () => { + const mockSetOpenNetworkSelector = jest.fn(); + + const { getAllByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + const networkButtons = getAllByTestId('select-network-button'); + const networkButton = networkButtons[1]; // Get the TouchableOpacity button + fireEvent.press(networkButton); + + expect(mockSetOpenNetworkSelector).toHaveBeenCalledWith(true); + }); + }); + + describe('Input Interactions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('focuses token ID input when address input is submitted', () => { + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + const addressInput = getByTestId('input-collectible-address'); + + fireEvent(addressInput, 'onSubmitEditing'); + + // Since we can't easily test focus in React Native Testing Library, + // we verify the onSubmitEditing handler exists + expect(addressInput.props.onSubmitEditing).toBeDefined(); + }); + + it('calls addNft when token ID input is submitted', () => { + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + const tokenIdInput = getByTestId('input-collectible-identifier'); + + // Verify the onSubmitEditing handler exists for token ID input + expect(tokenIdInput.props.onSubmitEditing).toBeDefined(); + expect(tokenIdInput.props.returnKeyType).toBe('done'); + }); + }); + + describe('Props Variations', () => { + it('renders without navigation prop', () => { + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + expect(getByTestId('import-nft-screen')).toBeTruthy(); + }); + + it('renders with empty selectedNetwork', () => { + const { getByText } = renderWithProvider( + , + { state: initialRootState }, + ); + + expect(getByText('networks.select_network')).toBeTruthy(); + }); + + it('renders network avatar when selectedNetwork is provided', () => { + const { getAllByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + const networkButtons = getAllByTestId('select-network-button'); + expect(networkButtons.length).toBeGreaterThan(0); + }); + }); + + describe('Analytics and Error Handling', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('handles analytics parameter generation error gracefully', () => { + // Mock console.error to prevent error output during test + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + // This tests the getAnalyticsParams function when it encounters an error + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + expect(getByTestId('import-nft-screen')).toBeTruthy(); + + consoleSpy.mockRestore(); + }); + + it('stops loading and returns early when validation fails', async () => { + const mockAddNft = jest.spyOn(Engine.context.NftController, 'addNft'); + + const { getByTestId } = renderWithProvider( + , + { state: initialRootState }, + ); + + const button = getByTestId('add-collectible-button'); + + await act(async () => { + fireEvent.press(button); + }); + + // Should not call addNft when validation fails + expect(mockAddNft).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/components/UI/AddCustomCollectible/index.tsx b/app/components/UI/AddCustomCollectible/index.tsx index 6fbb661921b..1312b0db090 100644 --- a/app/components/UI/AddCustomCollectible/index.tsx +++ b/app/components/UI/AddCustomCollectible/index.tsx @@ -1,6 +1,13 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useSelector } from 'react-redux'; -import { Alert, Text, TextInput, View, StyleSheet } from 'react-native'; +import { + Alert, + Text, + TextInput, + View, + StyleSheet, + TouchableOpacity, +} from 'react-native'; import { fontStyles } from '../../../styles/common'; import Engine from '../../../core/Engine'; import { strings } from '../../../../locales/i18n'; @@ -17,10 +24,23 @@ import { selectSelectedNetworkClientId, } from '../../../selectors/networkController'; import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; -import { getDecimalChainId } from '../../../util/networks'; +import { + getDecimalChainId, + getNetworkImageSource, +} from '../../../util/networks'; import { useMetrics } from '../../../components/hooks/useMetrics'; import Logger from '../../../util/Logger'; import { TraceName, endTrace, trace } from '../../../util/trace'; +import { + IconColor, + IconName, +} from '../../../component-library/components/Icons/Icon'; +import { ImportTokenViewSelectorsIDs } from '../../../../e2e/selectors/wallet/ImportTokenView.selectors'; +import ButtonIcon from '../../../component-library/components/Buttons/ButtonIcon'; +import Avatar, { + AvatarSize, + AvatarVariant, +} from '../../../component-library/components/Avatars/Avatar'; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -57,6 +77,29 @@ const createStyles = (colors: any) => // eslint-disable-next-line @typescript-eslint/no-explicit-any ...(fontStyles.normal as any), }, + networkSelectorContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 16, + borderWidth: 1, + borderColor: colors.border.default, + borderRadius: 8, + marginBottom: 16, + }, + networkSelectorText: { + ...fontStyles.normal, + color: colors.text.default, + fontSize: 16, + }, + overlappingAvatarsContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + buttonIcon: { + marginLeft: 16, + }, }); interface AddCustomCollectibleProps { @@ -66,11 +109,19 @@ interface AddCustomCollectibleProps { collectibleContract?: { address: string; }; + setOpenNetworkSelector: (open: boolean) => void; + networkId: string; + selectedNetwork: string | null; + networkClientId: string | null; } const AddCustomCollectible = ({ navigation, collectibleContract, + setOpenNetworkSelector, + networkId, + selectedNetwork, + networkClientId, }: AddCustomCollectibleProps) => { const [mounted, setMounted] = useState(true); const [address, setAddress] = useState(''); @@ -122,13 +173,14 @@ const AddCustomCollectible = ({ const validateCustomCollectibleAddress = async (): Promise => { let validated = true; const isValidEthAddress = isValidAddress(address); + const clientId = networkClientId || selectedNetworkClientId; if (address.length === 0) { setWarningAddress(strings('collectible.address_cant_be_empty')); validated = false; } else if (!isValidEthAddress) { setWarningAddress(strings('collectible.address_must_be_valid')); validated = false; - } else if (!(await isSmartContractAddress(address, chainId))) { + } else if (!(await isSmartContractAddress(address, chainId, clientId))) { setWarningAddress(strings('collectible.address_must_be_smart_contract')); validated = false; } else { @@ -168,6 +220,7 @@ const AddCustomCollectible = ({ selectedAddress, address, tokenId, + networkClientId, ); if (!isOwner) @@ -204,7 +257,7 @@ const AddCustomCollectible = ({ trace({ name: TraceName.ImportNfts }); - await NftController.addNft(address, tokenId, selectedNetworkClientId); + await NftController.addNft(address, tokenId, networkClientId); endTrace({ name: TraceName.ImportNfts }); @@ -244,12 +297,46 @@ const AddCustomCollectible = ({ confirmText={strings('add_asset.collectibles.add_collectible')} onCancelPress={cancelAddCollectible} onConfirmPress={addNft} - confirmDisabled={!address || !tokenId} + confirmDisabled={!address || !tokenId || !selectedNetwork} loading={loading} confirmTestID={'add-collectible-button'} > + setOpenNetworkSelector(true)} + onLongPress={() => setOpenNetworkSelector(true)} + > + + {selectedNetwork || strings('networks.select_network')} + + + + {selectedNetwork ? ( + + ) : null} + + setOpenNetworkSelector(true)} + accessibilityRole="button" + style={styles.buttonIcon} + /> + + + {strings('collectible.collectible_address')} diff --git a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/BridgeSourceNetworkSelector.test.tsx b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/BridgeSourceNetworkSelector.test.tsx index 368dab629d8..2a67ec8be02 100644 --- a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/BridgeSourceNetworkSelector.test.tsx +++ b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/BridgeSourceNetworkSelector.test.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { initialState } from '../../_mocks_/initialState'; import { fireEvent, waitFor } from '@testing-library/react-native'; import { renderScreen } from '../../../../../util/test/renderWithProvider'; @@ -6,6 +7,8 @@ import Routes from '../../../../../constants/navigation/Routes'; import { Hex } from '@metamask/utils'; import { setSelectedSourceChainIds } from '../../../../../core/redux/slices/bridge'; import { BridgeSourceNetworkSelectorSelectorsIDs } from '../../../../../../e2e/selectors/Bridge/BridgeSourceNetworkSelector.selectors'; +import { cloneDeep } from 'lodash'; +import { MultichainNetwork } from '@metamask/multichain-transactions-controller'; const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); @@ -228,4 +231,54 @@ describe('BridgeSourceNetworkSelector', () => { expect(networkItems[0].props.testID).toBe(`chain-${mockChainId}`); expect(networkItems[1].props.testID).toBe(`chain-${optimismChainId}`); }); + + it('renders non-EVM networks', async () => { + const state = cloneDeep(initialState); + + state.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2.chains[ + MultichainNetwork.Solana + ] = { + isActiveSrc: true, + isActiveDest: true, + isUnifiedUIEnabled: true, + }; + + const { getByText } = renderScreen( + BridgeSourceNetworkSelector, + { + name: Routes.BRIDGE.MODALS.SOURCE_NETWORK_SELECTOR, + }, + { state }, + ); + + await waitFor(() => { + expect(getByText('Solana')).toBeTruthy(); + expect(getByText('$30012.75599')).toBeTruthy(); + }); + }); + + it('does not render non-EVM networks if isEvmOnly set', async () => { + const state = cloneDeep(initialState); + + state.engine.backgroundState.RemoteFeatureFlagController.remoteFeatureFlags.bridgeConfigV2.chains[ + MultichainNetwork.Solana + ] = { + isActiveSrc: true, + isActiveDest: true, + isUnifiedUIEnabled: true, + }; + + const { queryByText } = renderScreen( + () => , + { + name: Routes.BRIDGE.MODALS.SOURCE_NETWORK_SELECTOR, + }, + { state }, + ); + + await waitFor(() => { + expect(queryByText('Solana')).toBeNull(); + expect(queryByText('$30012.75599')).toBeNull(); + }); + }); }); diff --git a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/index.tsx b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/index.tsx index 19740107803..6ade797e427 100644 --- a/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/index.tsx +++ b/app/components/UI/Bridge/components/BridgeSourceNetworkSelector/index.tsx @@ -31,6 +31,7 @@ import { useNetworkInfo } from '../../../../../selectors/selectedNetworkControll import { useSwitchNetworks } from '../../../../Views/NetworkSelector/useSwitchNetworks'; import { CaipChainId, Hex } from '@metamask/utils'; import { selectEvmNetworkConfigurationsByChainId } from '../../../../../selectors/networkController'; +import { isSolanaChainId } from '@metamask/bridge-controller'; const createStyles = () => StyleSheet.create({ @@ -52,23 +53,38 @@ const createStyles = () => }); export interface BridgeSourceNetworkSelectorProps { + isEvmOnly?: boolean; onApply?: (selectedChainIds: Hex[]) => void; } export const BridgeSourceNetworkSelector: React.FC< BridgeSourceNetworkSelectorProps -> = ({ onApply }) => { +> = ({ isEvmOnly, onApply }) => { const { styles } = useStyles(createStyles, {}); const navigation = useNavigation(); const dispatch = useDispatch(); const enabledSourceChains = useSelector(selectEnabledSourceChains); - const enabledSourceChainIds = useMemo( - () => enabledSourceChains.map((chain) => chain.chainId), - [enabledSourceChains], - ); const selectedSourceChainIds = useSelector(selectSelectedSourceChainIds); const currentCurrency = useSelector(selectCurrentCurrency); - const { sortedSourceNetworks } = useSortedSourceNetworks(); + const { sortedSourceNetworks: sortedSourceNetworksRaw } = + useSortedSourceNetworks(); + + const enabledSourceChainIds = useMemo( + () => + enabledSourceChains + .filter((chain) => (isEvmOnly ? !isSolanaChainId(chain.chainId) : true)) + .map((chain) => chain.chainId), + [enabledSourceChains, isEvmOnly], + ); + + const sortedSourceNetworks = useMemo( + () => + sortedSourceNetworksRaw.filter((chain) => + enabledSourceChainIds.includes(chain.chainId), + ), + [enabledSourceChainIds, sortedSourceNetworksRaw], + ); + const evmNetworkConfigurations = useSelector( selectEvmNetworkConfigurationsByChainId, ); @@ -95,29 +111,35 @@ export const BridgeSourceNetworkSelector: React.FC< }); const handleApply = useCallback(async () => { + const newSelectedSourceChainids = candidateSourceChainIds.filter((id) => + enabledSourceChainIds.includes(id as CaipChainId), + ); + if (onApply) { - onApply(candidateSourceChainIds as Hex[]); + onApply(newSelectedSourceChainids as Hex[]); return; } // Update the Redux state with the candidate selections dispatch( setSelectedSourceChainIds( - candidateSourceChainIds as (Hex | CaipChainId)[], + newSelectedSourceChainids as (Hex | CaipChainId)[], ), ); // If there's only 1 network selected, set the source token to native token of that chain and switch chains - if (candidateSourceChainIds.length === 1) { + if (newSelectedSourceChainids.length === 1) { const evmNetworkConfiguration = - evmNetworkConfigurations[candidateSourceChainIds[0] as Hex]; + evmNetworkConfigurations[newSelectedSourceChainids[0] as Hex]; if (evmNetworkConfiguration) { await onSetRpcTarget(evmNetworkConfiguration); } ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) if (!evmNetworkConfiguration) { - await onNonEvmNetworkChange(candidateSourceChainIds[0] as CaipChainId); + await onNonEvmNetworkChange( + newSelectedSourceChainids[0] as CaipChainId, + ); } ///: END:ONLY_INCLUDE_IF @@ -131,6 +153,7 @@ export const BridgeSourceNetworkSelector: React.FC< navigation, dispatch, candidateSourceChainIds, + enabledSourceChainIds, evmNetworkConfigurations, onSetRpcTarget, ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) diff --git a/app/components/UI/Bridge/components/DestinationAccountSelector.tsx/DestinationAccountSelector.test.tsx b/app/components/UI/Bridge/components/DestinationAccountSelector.tsx/DestinationAccountSelector.test.tsx index 951749bee1f..d964df0bedf 100644 --- a/app/components/UI/Bridge/components/DestinationAccountSelector.tsx/DestinationAccountSelector.test.tsx +++ b/app/components/UI/Bridge/components/DestinationAccountSelector.tsx/DestinationAccountSelector.test.tsx @@ -155,25 +155,32 @@ jest.mock('../../../../../selectors/bridge', () => ({ // Mock the account tree controller selectors jest.mock( '../../../../../selectors/multichainAccounts/accountTreeController', - () => ({ - selectAccountGroups: (state: MockState) => - state.engine?.backgroundState?.AccountTreeController?.accountTree - ?.accountGroups - ? Object.values( - state.engine.backgroundState.AccountTreeController.accountTree - .accountGroups, - ) - : [], - selectSelectedAccountGroup: (state: MockState) => { - const selectedId = - state.engine?.backgroundState?.AccountTreeController?.accountTree - ?.selectedAccountGroupId; - const accountGroups = + () => { + const actual = jest.requireActual( + '../../../../../selectors/multichainAccounts/accountTreeController', + ); + + return { + ...actual, + selectAccountGroups: (state: MockState) => state.engine?.backgroundState?.AccountTreeController?.accountTree - ?.accountGroups; - return selectedId && accountGroups ? accountGroups[selectedId] : null; - }, - }), + ?.accountGroups + ? Object.values( + state.engine.backgroundState.AccountTreeController.accountTree + .accountGroups, + ) + : [], + selectSelectedAccountGroup: (state: MockState) => { + const selectedId = + state.engine?.backgroundState?.AccountTreeController?.accountTree + ?.selectedAccountGroupId; + const accountGroups = + state.engine?.backgroundState?.AccountTreeController?.accountTree + ?.accountGroups; + return selectedId && accountGroups ? accountGroups[selectedId] : null; + }, + }; + }, ); // Mock the feature flag selector diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx index 6ace793c5e4..fadfe848fa8 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx @@ -19,7 +19,6 @@ import { } from '../../../../../selectors/featureFlagController/deposit'; import { selectChainId } from '../../../../../selectors/networkController'; import { selectCardholderAccounts } from '../../../../../core/redux/slices/card'; -import { selectSelectedInternalAccount } from '../../../../../selectors/accountsController'; const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); const mockSetNavigationOptions = jest.fn(); @@ -52,6 +51,10 @@ const mockPriorityToken = { const mockCurrentAddress = '0x789'; +const mockSelectedInternalAccount = { + address: mockCurrentAddress, +}; + // Mock hooks const mockFetchPriorityToken = jest.fn().mockResolvedValue(mockPriorityToken); const mockNavigateToCardPage = jest.fn(); @@ -355,27 +358,8 @@ describe('CardHome Component', () => { if (selector === selectCardholderAccounts) { return [mockCurrentAddress]; } - if (selector === selectSelectedInternalAccount) { - return { - address: mockCurrentAddress, - id: 'account-id', - type: 'eip155:eoa', - options: {}, - metadata: { - name: 'Test Account', - importTime: Date.now(), - keyring: { type: 'HD Key Tree' }, - }, - scopes: [], - methods: [], - }; - } - if ( - selector - .toString() - .includes('selectSelectedInternalAccountFormattedAddress') - ) { - return mockCurrentAddress; + if (selector.toString().includes('selectSelectedInternalAccount')) { + return mockSelectedInternalAccount; } if (selector.toString().includes('selectChainId')) { return '0xe708'; // Linea chain ID - fallback for string matching @@ -422,27 +406,8 @@ describe('CardHome Component', () => { if (selector === selectCardholderAccounts) { return [mockCurrentAddress]; } - if (selector === selectSelectedInternalAccount) { - return { - address: mockCurrentAddress, - id: 'account-id', - type: 'eip155:eoa', - options: {}, - metadata: { - name: 'Test Account', - importTime: Date.now(), - keyring: { type: 'HD Key Tree' }, - }, - scopes: [], - methods: [], - }; - } - if ( - selector - .toString() - .includes('selectSelectedInternalAccountFormattedAddress') - ) { - return mockCurrentAddress; + if (selector.toString().includes('selectSelectedInternalAccount')) { + return mockSelectedInternalAccount; } if (selector.toString().includes('selectChainId')) { return '0xe708'; // Linea chain ID - fallback @@ -547,7 +512,6 @@ describe('CardHome Component', () => { expect(mockTrackEvent).toHaveBeenCalled(); expect(mockOpenSwaps).toHaveBeenCalledWith({ chainId: '0xe708', - cardholderAddress: mockCurrentAddress, }); }); }); @@ -719,156 +683,6 @@ describe('CardHome Component', () => { expect(navigationOptions.headerTitle).toBeDefined(); }); - it('switches to Linea network on focus if not already on Linea', async () => { - // Override the mock to allow network switching for this test - mockFindNetworkClientIdByChainId.mockReturnValue('linea-network-id'); - - // Mock being on a different chain initially - mockUseSelector.mockImplementation((selector) => { - if (selector === selectChainId) { - return '0x1'; // Ethereum mainnet - } - if (selector === selectPrivacyMode) { - return false; - } - if (selector === selectDepositActiveFlag) { - return true; - } - if (selector === selectDepositMinimumVersionFlag) { - return '0.9.0'; - } - if (selector === selectCardholderAccounts) { - return [mockCurrentAddress]; - } - if (selector === selectSelectedInternalAccount) { - return { - address: mockCurrentAddress, - id: 'account-id', - type: 'eip155:eoa', - options: {}, - metadata: { - name: 'Test Account', - importTime: Date.now(), - keyring: { type: 'HD Key Tree' }, - }, - scopes: [], - methods: [], - }; - } - if (selector.toString().includes('selectChainId')) { - return '0x1'; // Ethereum mainnet - fallback - } - if (selector.toString().includes('selectPrivacyMode')) { - return false; - } - if (selector.toString().includes('selectCardholderAccounts')) { - return [mockCurrentAddress]; - } - if (selector.toString().includes('selectEvmTokens')) { - return [mockPriorityToken]; - } - if (selector.toString().includes('selectEvmTokenFiatBalances')) { - return ['1000.00']; - } - return []; - }); - - // Mock useFocusEffect to call the callbacks when they're registered - const focusCallbacks: (() => void)[] = []; - jest.mocked(useFocusEffect).mockImplementation((callback: () => void) => { - focusCallbacks.push(callback); - }); - - render(); - - // Execute all focus effect callbacks (network change first, then account change) - await waitFor(async () => { - for (const callback of focusCallbacks) { - callback(); - } - }); - - await waitFor(() => { - expect(mockFindNetworkClientIdByChainId).toHaveBeenCalledWith('0xe708'); - expect(mockSetActiveNetwork).toHaveBeenCalledWith('linea-network-id'); - }); - }); - - it('handles network switching errors gracefully', async () => { - // Override the mock to allow network switching for this test - mockFindNetworkClientIdByChainId.mockReturnValue('linea-network-id'); - mockSetActiveNetwork.mockRejectedValueOnce(new Error('Network error')); - - // Mock being on a different chain initially - mockUseSelector.mockImplementation((selector) => { - if (selector === selectChainId) { - return '0x1'; // Ethereum mainnet - } - if (selector === selectPrivacyMode) { - return false; - } - if (selector === selectDepositActiveFlag) { - return true; - } - if (selector === selectDepositMinimumVersionFlag) { - return '0.9.0'; - } - if (selector === selectCardholderAccounts) { - return [mockCurrentAddress]; - } - if (selector === selectSelectedInternalAccount) { - return { - address: mockCurrentAddress, - id: 'account-id', - type: 'eip155:eoa', - options: {}, - metadata: { - name: 'Test Account', - importTime: Date.now(), - keyring: { type: 'HD Key Tree' }, - }, - scopes: [], - methods: [], - }; - } - if (selector.toString().includes('selectChainId')) { - return '0x1'; // Ethereum mainnet - fallback - } - if (selector.toString().includes('selectPrivacyMode')) { - return false; - } - if (selector.toString().includes('selectCardholderAccounts')) { - return [mockCurrentAddress]; - } - if (selector.toString().includes('selectEvmTokens')) { - return [mockPriorityToken]; - } - if (selector.toString().includes('selectEvmTokenFiatBalances')) { - return ['1000.00']; - } - return []; - }); - - // Mock useFocusEffect to call the callbacks when they're registered - const focusCallbacks: (() => void)[] = []; - jest.mocked(useFocusEffect).mockImplementation((callback: () => void) => { - focusCallbacks.push(callback); - }); - - render(); - - // Execute all focus effect callbacks (network change first, then account change) - await waitFor(async () => { - for (const callback of focusCallbacks) { - callback(); - } - }); - - await waitFor(() => { - expect(mockSetActiveNetwork).toHaveBeenCalled(); - }); - }); - it('dispatches bridge tokens when opening swaps with non-USDC token', async () => { // Reset useFocusEffect to default mock for this test jest.mocked(useFocusEffect).mockImplementation(jest.fn()); @@ -902,7 +716,6 @@ describe('CardHome Component', () => { expect(mockTrackEvent).toHaveBeenCalled(); expect(mockOpenSwaps).toHaveBeenCalledWith({ chainId: '0xe708', - cardholderAddress: mockCurrentAddress, }); }); }); diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx index 2e8ef0bb026..d34a49381e6 100644 --- a/app/components/UI/Card/Views/CardHome/CardHome.tsx +++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx @@ -12,7 +12,6 @@ import Text, { import { NavigationProp, ParamListBase, - useFocusEffect, useNavigation, } from '@react-navigation/native'; import ButtonIcon, { @@ -39,9 +38,6 @@ import { AllowanceState } from '../../types'; import CardAssetItem from '../../components/CardAssetItem'; import ManageCardListItem from '../../components/ManageCardListItem'; import CardImage from '../../components/CardImage'; -import { LINEA_CHAIN_ID } from '@metamask/swaps-controller/dist/constants'; -import { selectCardholderAccounts } from '../../../../../core/redux/slices/card'; -import Logger from '../../../../../util/Logger'; import { selectChainId } from '../../../../../selectors/networkController'; import { CardHomeSelectors } from '../../../../../../e2e/selectors/Card/CardHome.selectors'; import { @@ -54,8 +50,6 @@ import AddFundsBottomSheet from '../../components/AddFundsBottomSheet'; import { useOpenSwaps } from '../../hooks/useOpenSwaps'; import { MetaMetricsEvents, useMetrics } from '../../../../hooks/useMetrics'; import { SUPPORTED_BOTTOMSHEET_TOKENS_SYMBOLS } from '../../constants'; -import { selectSelectedInternalAccount } from '../../../../../selectors/accountsController'; -import { useIsCardholder } from '../../hooks/useIsCardholder'; import { Skeleton, SkeletonProps, @@ -80,10 +74,7 @@ const SkeletonLoading = (props: SkeletonProps) => { * @returns JSX element representing the card home screen */ const CardHome = () => { - const { PreferencesController, NetworkController, AccountsController } = - Engine.context; - const [error, setError] = useState(false); - const [isLoadingNetworkChange, setIsLoadingNetworkChange] = useState(true); + const { PreferencesController } = Engine.context; const [openAddFundsBottomSheet, setOpenAddFundsBottomSheet] = useState(false); const [retries, setRetries] = useState(0); const sheetRef = useRef(null); @@ -96,68 +87,13 @@ const CardHome = () => { const privacyMode = useSelector(selectPrivacyMode); const selectedChainId = useSelector(selectChainId); - const cardholderAddresses = useSelector(selectCardholderAccounts); - const selectedAccount = useSelector(selectSelectedInternalAccount); - const isCardholder = useIsCardholder(); - - // Handle network and account changes - useFocusEffect( - useCallback(() => { - const handleNetworkAndAccountChanges = async () => { - // Handle network change first - if (selectedChainId !== LINEA_CHAIN_ID) { - const networkClientId = - NetworkController.findNetworkClientIdByChainId(LINEA_CHAIN_ID); - - try { - if (networkClientId) { - await NetworkController.setActiveNetwork(networkClientId); - } - } catch (err) { - const mappedError = - err instanceof Error ? err : new Error(String(err)); - Logger.error(mappedError, 'CardHome::Error setting active network'); - setError(true); - setIsLoadingNetworkChange(false); - return; - } - } - - setIsLoadingNetworkChange(false); - - // Handle account change after network is correct - if (!isCardholder) { - const account = AccountsController.getAccountByAddress( - cardholderAddresses?.[0], - ); - - if (!account) { - setError(true); - } else { - AccountsController.setSelectedAccount(account.id); - } - } - }; - - handleNetworkAndAccountChanges(); - }, [ - NetworkController, - AccountsController, - selectedChainId, - cardholderAddresses, - isCardholder, - ]), - ); const { priorityToken, fetchPriorityToken, isLoading: isLoadingPriorityToken, - error: errorPriorityToken, - } = useGetPriorityCardToken( - selectedAccount?.address, - selectedChainId === LINEA_CHAIN_ID, - ); + error, + } = useGetPriorityCardToken(); const { balanceFiat, mainBalance } = useAssetBalance(priorityToken); const { navigateToCardPage } = useNavigateToCardPage(navigation); const { openSwaps } = useOpenSwaps({ @@ -176,11 +112,6 @@ const CardHome = () => { [priorityToken], ); - const hasError = useMemo( - () => error || errorPriorityToken, - [error, errorPriorityToken], - ); - const balanceAmount = useMemo(() => { if (!balanceFiat || balanceFiat === TOKEN_RATE_UNDEFINED) { return mainBalance; @@ -196,7 +127,6 @@ const CardHome = () => { setOpenAddFundsBottomSheet={setOpenAddFundsBottomSheet} priorityToken={priorityToken ?? undefined} chainId={selectedChainId} - cardholderAddresses={cardholderAddresses} navigate={navigation.navigate} /> ), @@ -204,7 +134,6 @@ const CardHome = () => { sheetRef, setOpenAddFundsBottomSheet, priorityToken, - cardholderAddresses, selectedChainId, navigation, ], @@ -223,7 +152,6 @@ const CardHome = () => { } else if (priorityToken) { openSwaps({ chainId: selectedChainId, - cardholderAddress: cardholderAddresses?.[0], }); } }, [ @@ -232,18 +160,9 @@ const CardHome = () => { priorityToken, openSwaps, selectedChainId, - cardholderAddresses, ]); - const isLoading = useMemo( - () => - isLoadingPriorityToken || - isLoadingNetworkChange || - (!priorityToken && !hasError), - [isLoadingPriorityToken, isLoadingNetworkChange, priorityToken, hasError], - ); - - if (hasError) { + if (error) { return ( { length={SensitiveTextLength.Long} variant={TextVariant.HeadingLG} > - {isLoading || + {isLoadingPriorityToken || balanceAmount === TOKEN_BALANCE_LOADING || balanceAmount === TOKEN_BALANCE_LOADING_UPPERCASE ? ( { styles.defaultHorizontalPadding, ]} > - {isLoading || !priorityToken ? ( + {isLoadingPriorityToken || !priorityToken ? ( { styles.defaultHorizontalPadding, ]} > - {isLoading ? ( + {isLoadingPriorityToken ? ( { size={ButtonSize.Sm} onPress={addFundsAction} width={ButtonWidthTypes.Full} - loading={isLoading} + loading={isLoadingPriorityToken} testID={CardHomeSelectors.ADD_FUNDS_BUTTON} /> )} diff --git a/app/components/UI/Card/components/AddFundsBottomSheet/AddFundsBottomSheet.test.tsx b/app/components/UI/Card/components/AddFundsBottomSheet/AddFundsBottomSheet.test.tsx index d134d3769e8..bccdd51fd24 100644 --- a/app/components/UI/Card/components/AddFundsBottomSheet/AddFundsBottomSheet.test.tsx +++ b/app/components/UI/Card/components/AddFundsBottomSheet/AddFundsBottomSheet.test.tsx @@ -108,7 +108,6 @@ describe('AddFundsBottomSheet', () => { sheetRef: mockSheetRef, priorityToken: mockPriorityToken, chainId: '0xe708', - cardholderAddresses: ['0xcardholder'], navigate: mockNavigate, }; @@ -221,7 +220,6 @@ describe('AddFundsBottomSheet', () => { expect(mockOpenSwaps).toHaveBeenCalledWith({ chainId: '0xe708', - cardholderAddress: '0xcardholder', beforeNavigate: expect.any(Function), }); }); diff --git a/app/components/UI/Card/components/AddFundsBottomSheet/AddFundsBottomSheet.tsx b/app/components/UI/Card/components/AddFundsBottomSheet/AddFundsBottomSheet.tsx index e7c4184c42d..6c7a793785c 100644 --- a/app/components/UI/Card/components/AddFundsBottomSheet/AddFundsBottomSheet.tsx +++ b/app/components/UI/Card/components/AddFundsBottomSheet/AddFundsBottomSheet.tsx @@ -36,7 +36,6 @@ export interface AddFundsBottomSheetProps { sheetRef: React.RefObject; priorityToken?: CardTokenAllowance; chainId: string; - cardholderAddresses?: string[]; navigate: (route: string) => void; } @@ -45,7 +44,6 @@ const AddFundsBottomSheet: React.FC = ({ sheetRef, priorityToken, chainId, - cardholderAddresses, navigate, }) => { const { isDepositEnabled } = useDepositEnabled(); @@ -67,16 +65,9 @@ const AddFundsBottomSheet: React.FC = ({ if (!priorityToken) return; openSwaps({ chainId, - cardholderAddress: cardholderAddresses?.[0], beforeNavigate: (nav) => closeBottomSheetAndNavigate(nav), }); - }, [ - priorityToken, - openSwaps, - chainId, - cardholderAddresses, - closeBottomSheetAndNavigate, - ]); + }, [priorityToken, openSwaps, chainId, closeBottomSheetAndNavigate]); const openDeposit = useCallback(() => { closeBottomSheetAndNavigate(() => { diff --git a/app/components/UI/Card/hooks/useAssetBalance.test.ts b/app/components/UI/Card/hooks/useAssetBalance.test.ts index 661fdcb3a6a..643ba10ac1d 100644 --- a/app/components/UI/Card/hooks/useAssetBalance.test.ts +++ b/app/components/UI/Card/hooks/useAssetBalance.test.ts @@ -44,6 +44,17 @@ jest.mock('../../../../selectors/tokenRatesController', () => ({ jest.mock('../../../../selectors/tokenBalancesController', () => ({ selectSingleTokenBalance: jest.fn(), })); +jest.mock('../../Bridge/hooks/useTokensWithBalance', () => ({ + useTokensWithBalance: jest.fn(() => []), +})); +jest.mock('../../../../selectors/networkController', () => ({ + selectAllPopularNetworkConfigurations: jest.fn(() => ({ + mainnet: { chainId: '0x1' }, + polygon: { chainId: '0x89' }, + optimism: { chainId: '0xa' }, + arbitrum: { chainId: '0xa4b1' }, + })), +})); jest.mock('../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => { const translations: { [key: string]: string } = { @@ -117,6 +128,12 @@ describe('useAssetBalance', () => { showFiatOnTestnets: true, selectedAccount: { id: 'account1' }, exchangeRates: { price: 2000 }, + popularNetworks: { + mainnet: { chainId: '0x1' }, + polygon: { chainId: '0x89' }, + optimism: { chainId: '0xa' }, + arbitrum: { chainId: '0xa4b1' }, + }, ...overrides, }; @@ -140,6 +157,8 @@ describe('useAssetBalance', () => { return overrides.tokenBalances; if (selector.toString().includes('selectCurrencyRateForChainId')) return overrides.conversionRate; + if (selector.toString().includes('selectAllPopularNetworkConfigurations')) + return defaults.popularNetworks; // This catches the dynamically created selectors from makeSelectAssetByAddressAndChainId // and makeSelectNonEvmAssetById @@ -193,9 +212,20 @@ describe('useAssetBalance', () => { if (selector.toString().includes('selectSingleTokenPriceMarketData')) return { price: 2000 }; if (selector.toString().includes('selectSingleTokenBalance')) - return {}; + return undefined; if (selector.toString().includes('selectCurrencyRateForChainId')) - return 0; + return undefined; + if ( + selector + .toString() + .includes('selectAllPopularNetworkConfigurations') + ) + return { + mainnet: { chainId: '0x1' }, + polygon: { chainId: '0x89' }, + optimism: { chainId: '0xa' }, + arbitrum: { chainId: '0xa4b1' }, + }; // For null/undefined token, the selector should return undefined if (typeof selector === 'function') { @@ -243,6 +273,17 @@ describe('useAssetBalance', () => { return {}; if (selector.toString().includes('selectCurrencyRateForChainId')) return 0; + if ( + selector + .toString() + .includes('selectAllPopularNetworkConfigurations') + ) + return { + mainnet: { chainId: '0x1' }, + polygon: { chainId: '0x89' }, + optimism: { chainId: '0xa' }, + arbitrum: { chainId: '0xa4b1' }, + }; // When token is falsy, the evmAsset selector should return undefined if (typeof selector === 'function') { @@ -344,6 +385,17 @@ describe('useAssetBalance', () => { return {}; if (selector.toString().includes('selectCurrencyRateForChainId')) return 0; + if ( + selector + .toString() + .includes('selectAllPopularNetworkConfigurations') + ) + return { + mainnet: { chainId: '0x1' }, + polygon: { chainId: '0x89' }, + optimism: { chainId: '0xa' }, + arbitrum: { chainId: '0xa4b1' }, + }; // Handle the dynamic selector that should return the error asset if (typeof selector === 'function') { @@ -390,6 +442,17 @@ describe('useAssetBalance', () => { return {}; if (selector.toString().includes('selectCurrencyRateForChainId')) return 0; + if ( + selector + .toString() + .includes('selectAllPopularNetworkConfigurations') + ) + return { + mainnet: { chainId: '0x1' }, + polygon: { chainId: '0x89' }, + optimism: { chainId: '0xa' }, + arbitrum: { chainId: '0xa4b1' }, + }; // Return undefined for asset selector if (typeof selector === 'function') { @@ -459,6 +522,17 @@ describe('useAssetBalance', () => { return {}; if (selector.toString().includes('selectCurrencyRateForChainId')) return 0; + if ( + selector + .toString() + .includes('selectAllPopularNetworkConfigurations') + ) + return { + mainnet: { chainId: '0x1' }, + polygon: { chainId: '0x89' }, + optimism: { chainId: '0xa' }, + arbitrum: { chainId: '0xa4b1' }, + }; // Return undefined for both asset selectors to trigger mapped asset creation if (typeof selector === 'function') { @@ -604,6 +678,17 @@ describe('useAssetBalance', () => { return {}; if (selector.toString().includes('selectCurrencyRateForChainId')) return 0; + if ( + selector + .toString() + .includes('selectAllPopularNetworkConfigurations') + ) + return { + mainnet: { chainId: '0x1' }, + polygon: { chainId: '0x89' }, + optimism: { chainId: '0xa' }, + arbitrum: { chainId: '0xa4b1' }, + }; if (typeof selector === 'function') { return stakedAsset; @@ -816,6 +901,17 @@ describe('useAssetBalance', () => { return {}; if (selector.toString().includes('selectCurrencyRateForChainId')) return 0; + if ( + selector + .toString() + .includes('selectAllPopularNetworkConfigurations') + ) + return { + mainnet: { chainId: '0x1' }, + polygon: { chainId: '0x89' }, + optimism: { chainId: '0xa' }, + arbitrum: { chainId: '0xa4b1' }, + }; // Return undefined for nonEvmAsset when selectedAccount is null if (typeof selector === 'function') { diff --git a/app/components/UI/Card/hooks/useAssetBalance.tsx b/app/components/UI/Card/hooks/useAssetBalance.tsx index 64a890b3801..d1d628039df 100644 --- a/app/components/UI/Card/hooks/useAssetBalance.tsx +++ b/app/components/UI/Card/hooks/useAssetBalance.tsx @@ -23,6 +23,8 @@ import { isTestNet } from '../../../../util/networks'; import { TokenI } from '../../Tokens/types'; import { CardTokenAllowance } from '../types'; import { buildTokenIconUrl } from '../util/buildTokenIconUrl'; +import { useTokensWithBalance } from '../../Bridge/hooks/useTokensWithBalance'; +import { selectAllPopularNetworkConfigurations } from '../../../../selectors/networkController'; // This hook retrieves the asset balance and related information for a given token and account. export const useAssetBalance = ( @@ -37,6 +39,13 @@ export const useAssetBalance = ( const selectedInternalAccountAddress = useSelector( selectSelectedInternalAccountAddress, ); + const popularNetworks = useSelector(selectAllPopularNetworkConfigurations); + const chainIds = Object.entries(popularNetworks || {}) + .map((network) => network[1]?.chainId) + .filter(Boolean); + const tokensWithBalance = useTokensWithBalance({ + chainIds, + }); const selectEvmAsset = useMemo( () => makeSelectAssetByAddressAndChainId(), @@ -53,11 +62,16 @@ export const useAssetBalance = ( : undefined, ); - let asset = token && isEvmNetworkSelected ? evmAsset : undefined; + let asset = evmAsset; let isMappedAsset = false; if (!asset && token) { const iconUrl = buildTokenIconUrl(token.chainId, token.address); + const filteredToken = tokensWithBalance.find( + (t) => + t.address.toLowerCase() === token.address.toLowerCase() && + t.chainId === token.chainId, + ); asset = { ...token, @@ -65,8 +79,8 @@ export const useAssetBalance = ( logo: iconUrl, isETH: false, aggregators: [], - balance: '0', - balanceFiat: '0', + balance: filteredToken?.balance ?? '0', + balanceFiat: filteredToken?.balanceFiat ?? '0', } as TokenI; isMappedAsset = true; } @@ -118,12 +132,28 @@ export const useAssetBalance = ( } if (isMappedAsset) { + const zeroBalanceFiat = formatWithThreshold( + 0, + oneHundredths, + I18n.locale, + { style: 'currency', currency: currentCurrency }, + ); + const zeroBalanceFormatted = `0 ${asset.symbol}`; + return { - balanceFiat: formatWithThreshold(0, oneHundredths, I18n.locale, { - style: 'currency', - currency: currentCurrency, - }), - balanceValueFormatted: `0 ${asset.symbol}`, + balanceFiat: + asset.balanceFiat && asset.balanceFiat !== '0' + ? asset.balanceFiat + : zeroBalanceFiat, + balanceValueFormatted: + asset.balance && asset.balance !== '0' + ? formatWithThreshold( + parseFloat(asset.balance), + oneHundredThousandths, + I18n.locale, + { minimumFractionDigits: 0, maximumFractionDigits: 5 }, + ) + : zeroBalanceFormatted, }; } diff --git a/app/components/UI/Card/hooks/useCardholderCheck.ts b/app/components/UI/Card/hooks/useCardholderCheck.ts index a2f802bd28b..b1ffb41b0fb 100644 --- a/app/components/UI/Card/hooks/useCardholderCheck.ts +++ b/app/components/UI/Card/hooks/useCardholderCheck.ts @@ -7,24 +7,31 @@ import { selectAppServicesReady, selectUserLoggedIn, } from '../../../../reducers/user'; -import { selectInternalAccountsWithCaipAccountId } from '../../../../selectors/accountsController'; +import { selectInternalAccountsByScope } from '../../../../selectors/accountsController'; import Logger from '../../../../util/Logger'; import { isEthAccount } from '../../../../core/Multichain/utils'; +import { RootState } from '../../../../reducers'; /** * Hook that automatically checks for cardholder accounts when conditions are met */ export const useCardholderCheck = () => { + const lineaScope = 'eip155:59144'; const dispatch = useThunkDispatch(); const userLoggedIn = useSelector(selectUserLoggedIn); const appServicesReady = useSelector(selectAppServicesReady); const cardFeatureFlag = useSelector(selectCardFeatureFlag); - const accounts = useSelector(selectInternalAccountsWithCaipAccountId); + const internalAccounts = useSelector((state: RootState) => + selectInternalAccountsByScope(state, lineaScope), + ); const checkCardholderAccounts = useCallback(() => { - const caipAccountIds = accounts + const caipAccountIds = internalAccounts ?.filter((account) => isEthAccount(account)) - .map((account) => account.caipAccountId); + .map( + (account) => + `eip155:0:${account.address}` as `${string}:${string}:${string}`, + ); if (!caipAccountIds?.length) { return; @@ -36,14 +43,14 @@ export const useCardholderCheck = () => { cardFeatureFlag, }), ); - }, [cardFeatureFlag, dispatch, accounts]); + }, [cardFeatureFlag, dispatch, internalAccounts]); useEffect(() => { if ( userLoggedIn && appServicesReady && cardFeatureFlag && - accounts?.length + internalAccounts?.length ) { try { checkCardholderAccounts(); @@ -59,6 +66,6 @@ export const useCardholderCheck = () => { appServicesReady, cardFeatureFlag, checkCardholderAccounts, - accounts, + internalAccounts, ]); }; diff --git a/app/components/UI/Card/hooks/useGetPriorityCardToken.test.ts b/app/components/UI/Card/hooks/useGetPriorityCardToken.test.ts index edb52fc37bb..4cc649dd25d 100644 --- a/app/components/UI/Card/hooks/useGetPriorityCardToken.test.ts +++ b/app/components/UI/Card/hooks/useGetPriorityCardToken.test.ts @@ -1,10 +1,12 @@ import { renderHook, act } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; import { useCardSDK } from '../sdk'; import { CardToken, CardTokenAllowance, AllowanceState } from '../types'; import { useGetPriorityCardToken } from './useGetPriorityCardToken'; import Logger from '../../../../util/Logger'; import { strings } from '../../../../../locales/i18n'; -import { LINEA_CHAIN_ID } from '@metamask/swaps-controller/dist/constants'; + +const mockUseSelector = useSelector as jest.MockedFunction; jest.mock('../sdk', () => ({ useCardSDK: jest.fn(), @@ -15,6 +17,25 @@ jest.mock('react-redux', () => ({ useDispatch: jest.fn(), })); +jest.mock('../../../../selectors/tokenBalancesController', () => ({ + selectAllTokenBalances: jest.fn(), +})); + +jest.mock('../../../../selectors/multichainAccounts/accounts', () => ({ + selectSelectedInternalAccountByScope: jest.fn(), +})); + +import { selectAllTokenBalances } from '../../../../selectors/tokenBalancesController'; +import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; +import { LINEA_CHAIN_ID } from '@metamask/swaps-controller/dist/constants'; + +const mockSelectAllTokenBalances = + selectAllTokenBalances as jest.MockedFunction; +const mockSelectSelectedInternalAccountByScope = + selectSelectedInternalAccountByScope as jest.MockedFunction< + typeof selectSelectedInternalAccountByScope + >; + jest.mock('../../../../util/Logger', () => ({ error: jest.fn(), })); @@ -71,47 +92,33 @@ describe('useGetPriorityCardToken', () => { symbol: 'TKN2', name: 'Token 2', }, - { - address: '0xToken3', - decimals: 18, - symbol: 'TKN3', - name: 'Token 3', - }, - { - address: '0xSupportedToken', - decimals: 18, - symbol: 'SUPP', - name: 'Supported Token', - }, ], }; const mockAddress = '0x1234567890123456789012345678901234567890'; - // Create mock data that matches the SDK response format - simplified version - const createMockSDKTokenData = (address: string, allowanceAmount: string) => { - const numAmount = Number(allowanceAmount); - return { - address, - usAllowance: { - gt: (other: number) => numAmount > other, - toString: () => allowanceAmount, - isZero: () => allowanceAmount === '0', - lt: (other: number) => numAmount < other, - }, - globalAllowance: { - gt: (other: number) => numAmount > other, - toString: () => allowanceAmount, - isZero: () => allowanceAmount === '0', - lt: (other: number) => numAmount < other, - }, - }; - }; + const createMockSDKTokenData = ( + address: string, + allowanceAmount: string, + ) => ({ + address, + usAllowance: { + gt: (other: number) => Number(allowanceAmount) > other, + toString: () => allowanceAmount, + isZero: () => allowanceAmount === '0', + lt: (other: number) => Number(allowanceAmount) < other, + }, + globalAllowance: { + gt: (other: number) => Number(allowanceAmount) > other, + toString: () => allowanceAmount, + isZero: () => allowanceAmount === '0', + lt: (other: number) => Number(allowanceAmount) < other, + }, + }); const mockSDKTokensData = [ - createMockSDKTokenData('0xToken1', '1000000000000'), // > ARBITRARY_ALLOWANCE for Enabled state - createMockSDKTokenData('0xToken2', '500000000000'), // > ARBITRARY_ALLOWANCE for Enabled state - createMockSDKTokenData('0xToken3', '0'), + createMockSDKTokenData('0xToken1', '1000000000000'), + createMockSDKTokenData('0xToken2', '500000000000'), ]; const mockCardToken: CardToken = { @@ -134,62 +141,89 @@ describe('useGetPriorityCardToken', () => { mockPriorityToken = null; mockLastFetched = null; - // Create simplified stable references for Engine controllers - const tokensController = { - state: { - allTokens: { - [LINEA_CHAIN_ID]: { - [mockAddress.toLowerCase()]: [], // Empty by default for most tests - }, + const mockTokenBalances = { + [mockAddress.toLowerCase()]: { + '0x1': { + '0xToken1': '1000000000000000000', + '0xToken2': '500000000000000000', }, }, addToken: jest.fn().mockResolvedValue(undefined), }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockSelectAllTokenBalances.mockReturnValue(mockTokenBalances as any); - const networkController = { - findNetworkClientIdByChainId: jest.fn().mockReturnValue('linea-mainnet'), - }; - - // Update the mocked Engine context with stable references - const mockEngine = jest.requireMock('../../../../core/Engine'); - mockEngine.context.TokensController = tokensController; - mockEngine.context.NetworkController = networkController; - - (useCardSDK as jest.Mock).mockReturnValue({ sdk: mockSDK }); - - // Simplified dispatch mock - mockDispatch.mockImplementation((action) => action); - - // Simplified selector mock with basic return values - const { useSelector, useDispatch } = jest.requireMock('react-redux'); - - useSelector.mockImplementation((selector: (state: unknown) => unknown) => { - const selectorStr = selector.toString(); - if (selectorStr.includes('selectAllTokenBalances')) { + const mockAccountSelector = (scope: string) => { + if (scope === 'eip155:0') { return { - [mockAddress.toLowerCase()]: { - [LINEA_CHAIN_ID]: { - '0xToken1': '1000000000000000000', - '0xToken2': '500000000000000000', - '0xToken3': '0', - }, - }, + address: mockAddress, + id: 'test-account-id', + type: 'eip155:eoa' as const, + options: {}, + metadata: {}, + methods: [], + scopes: [], }; } - if (selectorStr.includes('selectCardPriorityTokenLastFetched')) { - return null; + return undefined; + }; + mockSelectSelectedInternalAccountByScope.mockReturnValue( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockAccountSelector as any, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockUseSelector.mockImplementation((selector: any) => { + if (selector === selectAllTokenBalances) { + return mockTokenBalances; + } + if (selector === selectSelectedInternalAccountByScope) { + return mockAccountSelector; } - if (selectorStr.includes('selectCardPriorityToken')) { - return null; + const selectorString = selector.toString(); + if ( + selectorString.includes('selectCardPriorityToken') && + !selectorString.includes('LastFetched') + ) { + return mockPriorityToken; + } + if (selectorString.includes('selectCardPriorityTokenLastFetched')) { + return mockLastFetched; + } + // Fallback: try invoking the selector with a minimal state shape + try { + return selector({ + card: { + cardholderAccounts: [], + isLoaded: true, + priorityTokensByAddress: { + [mockAddress.toLowerCase()]: mockPriorityToken, + }, + lastFetchedByAddress: { + [mockAddress.toLowerCase()]: mockLastFetched, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as unknown as any); + } catch (_e) { + // no-op } return null; }); + (useCardSDK as jest.Mock).mockReturnValue({ sdk: mockSDK }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { useDispatch } = jest.requireMock('react-redux'); + + mockDispatch.mockImplementation( + (action: { type?: string; payload?: unknown }) => action, + ); + useDispatch.mockReturnValue(mockDispatch); (strings as jest.Mock).mockReturnValue('Error occurred'); - // Mock trace utilities const { trace, endTrace } = jest.requireMock('../../../../util/trace'); trace.mockImplementation(mockTrace); endTrace.mockImplementation(mockEndTrace); @@ -198,16 +232,13 @@ describe('useGetPriorityCardToken', () => { it('should initialize with correct default state', async () => { (useCardSDK as jest.Mock).mockReturnValue({ sdk: null }); - const { result } = renderHook(() => - useGetPriorityCardToken(mockAddress, false), - ); + const { result } = renderHook(() => useGetPriorityCardToken()); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(false); expect(result.current.priorityToken).toBe(null); }); @@ -215,9 +246,8 @@ describe('useGetPriorityCardToken', () => { mockFetchAllowances.mockResolvedValue(mockSDKTokensData); mockGetPriorityToken.mockResolvedValue(mockCardToken); - const { result } = renderHook(() => useGetPriorityCardToken(mockAddress)); + const { result } = renderHook(() => useGetPriorityCardToken()); - // Wait for the useEffect to complete await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); @@ -265,45 +295,12 @@ describe('useGetPriorityCardToken', () => { }); }); - it('should handle null response from API', async () => { - mockFetchAllowances.mockResolvedValue(mockSDKTokensData); - mockGetPriorityToken.mockResolvedValue(null); - - const { result } = renderHook(() => useGetPriorityCardToken(mockAddress)); - - // Wait for the hook to complete its async operations - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - // Verify that dispatch was called with the correct token (fallback to first valid allowance) - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: expect.stringContaining('setCardPriorityToken'), - payload: expect.objectContaining({ - address: '0x1234567890123456789012345678901234567890', - token: expect.objectContaining({ - address: '0xToken1', - symbol: 'TKN1', - name: 'Token 1', - allowanceState: AllowanceState.Enabled, - }), - }), - }), - ); - - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(false); - expect(mockGetPriorityToken).toHaveBeenCalledTimes(1); - }); - it('should handle API errors gracefully', async () => { const mockError = new Error('Failed to fetch priority token'); mockFetchAllowances.mockRejectedValueOnce(mockError); - const { result } = renderHook(() => useGetPriorityCardToken(mockAddress)); + const { result } = renderHook(() => useGetPriorityCardToken()); - // Wait for the hook to complete its async operations await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); @@ -321,9 +318,8 @@ describe('useGetPriorityCardToken', () => { it('should not fetch when SDK is not available', async () => { (useCardSDK as jest.Mock).mockReturnValue({ sdk: null }); - const { result } = renderHook(() => useGetPriorityCardToken(mockAddress)); + const { result } = renderHook(() => useGetPriorityCardToken()); - // Wait for useEffect to complete await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -334,20 +330,34 @@ describe('useGetPriorityCardToken', () => { expect(mockGetPriorityToken).not.toHaveBeenCalled(); }); - it('should not fetch when address is not provided', async () => { - const { result } = renderHook(() => - useGetPriorityCardToken(undefined, false), - ); + it('should handle null response from API and fallback to first valid allowance', async () => { + mockFetchAllowances.mockResolvedValue(mockSDKTokensData); + mockGetPriorityToken.mockResolvedValue(null); + + const { result } = renderHook(() => useGetPriorityCardToken()); - // Wait for useEffect to complete await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 100)); }); - expect(result.current.priorityToken).toBeNull(); + expect(mockDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: expect.stringContaining('setCardPriorityToken'), + payload: expect.objectContaining({ + address: mockAddress, + token: expect.objectContaining({ + address: '0xToken1', + symbol: 'TKN1', + name: 'Token 1', + allowanceState: AllowanceState.Enabled, + }), + }), + }), + ); + expect(result.current.isLoading).toBe(false); expect(result.current.error).toBe(false); - expect(mockGetPriorityToken).not.toHaveBeenCalled(); + expect(mockGetPriorityToken).toHaveBeenCalledTimes(1); }); it('should use cached token when cache is valid', async () => { @@ -363,114 +373,18 @@ describe('useGetPriorityCardToken', () => { chainId: LINEA_CHAIN_ID, } as CardTokenAllowance; - jest.resetAllMocks(); - - const testMockFetchAllowances = jest.fn(); - const testMockGetPriorityToken = jest.fn(); - const testMockDispatch = jest.fn(); - - const useReduxMocks = jest.requireMock('react-redux'); - useReduxMocks.useDispatch.mockReturnValue(testMockDispatch); - - // Mock useSelector to consistently return cached values - // We need to track which selector is being called and return appropriate values - let selectorCallCount = 0; - const expectedSelectorCalls = [ - 'selectAllTokenBalances', // First call for token balances - 'selectCardPriorityToken', // Second call for cached priority token - 'selectCardPriorityTokenLastFetched', // Third call for last fetched timestamp - ]; - - useReduxMocks.useSelector.mockImplementation( - (selector: (state: unknown) => unknown) => { - const selectorString = selector.toString(); - - if (selectorString.includes('selectAllTokenBalances')) { - return { - [mockAddress.toLowerCase()]: { - '0x1': { - '0xCachedToken': '1000000000000000000', - }, - }, - }; - } - - // For the address-based selectors, they will contain references to the state structure - // Let's be more explicit about what we return - if (selectorString.includes('priorityTokensByAddress')) { - return cachedToken; - } - - if (selectorString.includes('lastFetchedByAddress')) { - return recentTimestamp; - } - - // Fallback: if we can't identify the selector, assume it's asking for cached data - const currentCall = - expectedSelectorCalls[ - selectorCallCount % expectedSelectorCalls.length - ]; - selectorCallCount++; - - switch (currentCall) { - case 'selectCardPriorityToken': - return cachedToken; - case 'selectCardPriorityTokenLastFetched': - return recentTimestamp; - default: - return null; - } - }, - ); - - // Mock Engine with token that already exists - const mockEngine = jest.requireMock('../../../../core/Engine'); - mockEngine.context.TokensController.state.allTokens = { - [LINEA_CHAIN_ID]: { - [mockAddress.toLowerCase()]: [ - { - address: '0xCachedToken', - symbol: 'CACHED', - name: 'Cached Token', - decimals: 18, - }, - ], - }, - }; - - const sdkMocks = jest.requireMock('../sdk'); - sdkMocks.useCardSDK.mockReturnValue({ - sdk: { - getSupportedTokensAllowances: testMockFetchAllowances, - getPriorityToken: testMockGetPriorityToken, - supportedTokens: [ - { - address: '0xCachedToken', - symbol: 'CACHED', - name: 'Cached Token', - decimals: 18, - }, - ], - }, - }); + mockPriorityToken = cachedToken; + mockLastFetched = recentTimestamp; - const { result } = renderHook(() => useGetPriorityCardToken(mockAddress)); + const { result } = renderHook(() => useGetPriorityCardToken()); - // Give the hook time to stabilize await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 50)); }); - // The key assertion: no API calls should have been made - expect(testMockFetchAllowances).not.toHaveBeenCalled(); - expect(testMockGetPriorityToken).not.toHaveBeenCalled(); - - // No new dispatch calls should have been made since cache is valid - expect(testMockDispatch).not.toHaveBeenCalled(); + expect(mockGetPriorityToken).not.toHaveBeenCalled(); - // Hook should not be in loading or error state expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(false); // And the cached token should be surfaced by the hook expect(result.current.priorityToken).toEqual( expect.objectContaining({ @@ -481,46 +395,21 @@ describe('useGetPriorityCardToken', () => { }); it('should fetch new data when cache is stale', async () => { - // Set up a stale cached token (older than 5 minutes) - const staleTimestamp = new Date(Date.now() - 10 * 60 * 1000); // 10 minutes ago - - // Override the selector to return stale cache - const { useSelector } = jest.requireMock('react-redux'); - useSelector.mockImplementation((selector: (state: unknown) => unknown) => { - const selectorString = selector.toString(); - if (selectorString.includes('selectAllTokenBalances')) { - return { - [mockAddress.toLowerCase()]: { - [LINEA_CHAIN_ID]: { - '0xToken1': '1000000000000000000', - }, - }, - }; - } - if (selectorString.includes('selectCardPriorityToken')) { - return null; // No cached token - } - if (selectorString.includes('selectCardPriorityTokenLastFetched')) { - return staleTimestamp; // Stale timestamp - } - return null; - }); + const staleTimestamp = new Date(Date.now() - 10 * 60 * 1000); + mockLastFetched = staleTimestamp; mockFetchAllowances.mockResolvedValue(mockSDKTokensData); mockGetPriorityToken.mockResolvedValue(mockCardToken); - const { result } = renderHook(() => useGetPriorityCardToken(mockAddress)); + const { result } = renderHook(() => useGetPriorityCardToken()); - // Wait for the fetch to complete await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); - // Should have called the API since cache is stale expect(mockFetchAllowances).toHaveBeenCalledTimes(1); expect(mockGetPriorityToken).toHaveBeenCalledTimes(1); - // Should have dispatched new actions expect(mockDispatch).toHaveBeenCalledWith( expect.objectContaining({ type: expect.stringContaining('setCardPriorityToken'), @@ -531,125 +420,162 @@ describe('useGetPriorityCardToken', () => { expect(result.current.error).toBe(false); }); - it('should handle multiple consecutive fetch calls', async () => { - // Test multiple fetch calls with valid address - each should make fresh API calls - const expectedSDKToken1 = createMockSDKTokenData('0xToken1', '1000000'); + it('should not fetch when no address is available', async () => { + const noAddressSelector = () => undefined; + mockSelectSelectedInternalAccountByScope.mockReturnValue(noAddressSelector); - mockFetchAllowances.mockResolvedValueOnce([expectedSDKToken1]); - mockGetPriorityToken.mockResolvedValueOnce({ - address: '0xToken1', - symbol: 'TKN1', - name: 'Token 1', - decimals: 18, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockUseSelector.mockImplementation((selector: any) => { + if (selector === selectAllTokenBalances) { + return {}; + } + if (selector === selectSelectedInternalAccountByScope) { + return noAddressSelector; + } + return null; + }); + + const { result } = renderHook(() => useGetPriorityCardToken()); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + expect(mockFetchAllowances).not.toHaveBeenCalled(); + expect(mockGetPriorityToken).not.toHaveBeenCalled(); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(false); + expect(result.current.priorityToken).toBeNull(); + }); + + it('should handle manual fetchPriorityToken call', async () => { + mockFetchAllowances.mockResolvedValue(mockSDKTokensData); + mockGetPriorityToken.mockResolvedValue(mockCardToken); + + const { result } = renderHook(() => useGetPriorityCardToken()); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + mockFetchAllowances.mockClear(); + mockGetPriorityToken.mockClear(); + mockDispatch.mockClear(); + + let manualResult: CardTokenAllowance | null | undefined; + await act(async () => { + manualResult = await result.current.fetchPriorityToken(); }); - const { result } = renderHook(() => useGetPriorityCardToken(mockAddress)); + expect(manualResult).toEqual( + expect.objectContaining({ + address: '0xToken1', + symbol: 'TKN1', + name: 'Token 1', + allowanceState: AllowanceState.Enabled, + }), + ); + expect(mockFetchAllowances).toHaveBeenCalledTimes(1); + expect(mockGetPriorityToken).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledTimes(2); + }); + + it('should return fallback token when no allowances are returned', async () => { + mockFetchAllowances.mockResolvedValue([]); + + const { result } = renderHook(() => useGetPriorityCardToken()); - // Wait for automatic fetch from useEffect to complete await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); - // Verify that setCardPriorityToken was called with the expected token expect(mockDispatch).toHaveBeenCalledWith( expect.objectContaining({ - type: 'card/setCardPriorityToken', + type: expect.stringContaining('setCardPriorityToken'), payload: expect.objectContaining({ - address: expect.any(String), + address: mockAddress, token: expect.objectContaining({ address: '0xToken1', symbol: 'TKN1', name: 'Token 1', + allowanceState: AllowanceState.NotEnabled, + isStaked: false, + chainId: '0xe708', }), }), }), ); - // Set up second fetch with different data - const expectedSDKToken2 = createMockSDKTokenData('0xToken2', '500000'); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(false); + expect(mockGetPriorityToken).not.toHaveBeenCalled(); + }); - mockFetchAllowances.mockResolvedValueOnce([expectedSDKToken2]); - mockGetPriorityToken.mockResolvedValueOnce({ - address: '0xToken2', - symbol: 'TKN2', - name: 'Token 2', - decimals: 18, + it('should return null when no allowances and no supported tokens exist', async () => { + mockFetchAllowances.mockResolvedValue([]); + (useCardSDK as jest.Mock).mockReturnValue({ + sdk: { + ...mockSDK, + supportedTokens: [], + }, }); - // Manual fetch should get fresh data - let priorityToken2: CardTokenAllowance | null | undefined; + const { result } = renderHook(() => useGetPriorityCardToken()); + await act(async () => { - priorityToken2 = await result.current.fetchPriorityToken(); + await new Promise((resolve) => setTimeout(resolve, 100)); }); - expect(priorityToken2).toEqual( + + expect(mockDispatch).toHaveBeenCalledWith( expect.objectContaining({ - address: '0xToken2', - symbol: 'TKN2', - name: 'Token 2', + type: expect.stringContaining('setCardPriorityToken'), + payload: expect.objectContaining({ + address: mockAddress, + token: null, + }), }), ); - expect(mockGetPriorityToken).toHaveBeenCalledTimes(2); // Called for both fetches expect(result.current.isLoading).toBe(false); expect(result.current.error).toBe(false); + expect(mockGetPriorityToken).not.toHaveBeenCalled(); }); - it('should make fresh API calls on each fetchPriorityToken invocation', async () => { - // The fetchPriorityToken function doesn't cache - it always makes fresh API calls - mockFetchAllowances.mockResolvedValue(mockSDKTokensData); - mockGetPriorityToken.mockResolvedValue(mockCardToken); + it('should return first token when all allowances have zero balance', async () => { + const zeroAllowanceTokens = [ + createMockSDKTokenData('0xToken1', '0'), + createMockSDKTokenData('0xToken2', '0'), + ]; - const { result } = renderHook(() => useGetPriorityCardToken(mockAddress)); + mockFetchAllowances.mockResolvedValue(zeroAllowanceTokens); + + const { result } = renderHook(() => useGetPriorityCardToken()); - // Wait for the automatic fetch from useEffect await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); - // Verify that setCardPriorityToken was called with the expected token expect(mockDispatch).toHaveBeenCalledWith( expect.objectContaining({ - type: 'card/setCardPriorityToken', + type: expect.stringContaining('setCardPriorityToken'), payload: expect.objectContaining({ - address: expect.any(String), + address: mockAddress, token: expect.objectContaining({ address: '0xToken1', symbol: 'TKN1', name: 'Token 1', - allowanceState: AllowanceState.Enabled, + allowanceState: AllowanceState.NotEnabled, }), }), }), ); - expect(mockFetchAllowances).toHaveBeenCalledTimes(1); - - // Reset the call count to track subsequent calls - mockFetchAllowances.mockClear(); - mockGetPriorityToken.mockClear(); - - // Manual fetch should make fresh API calls - let priorityToken2: CardTokenAllowance | null | undefined; - await act(async () => { - priorityToken2 = await result.current.fetchPriorityToken(); - }); - - expect(priorityToken2).toEqual( - expect.objectContaining({ - address: '0xToken1', - symbol: 'TKN1', - name: 'Token 1', - allowanceState: AllowanceState.Enabled, - }), - ); - expect(mockFetchAllowances).toHaveBeenCalledTimes(1); // Fresh API call made - expect(mockGetPriorityToken).toHaveBeenCalledTimes(1); // Fresh API call made - // Verify that dispatch was called again after manual fetch - expect(mockDispatch).toHaveBeenCalledTimes(4); // 2 for auto-fetch (token + timestamp), 2 for manual fetch (token + timestamp) + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(false); + expect(mockGetPriorityToken).not.toHaveBeenCalled(); }); it('should fallback to token with positive balance when suggested token has zero balance', async () => { - // Create SDK format allowances where suggested token has zero balance const tokenWithZeroBalanceSDK = createMockSDKTokenData( '0xZeroBalance', '1000000000000', @@ -670,25 +596,44 @@ describe('useGetPriorityCardToken', () => { decimals: 18, }; - // Mock balances where suggested token has zero balance - const { useSelector } = jest.requireMock('react-redux'); - useSelector.mockImplementation((selector: (state: unknown) => unknown) => { - // Mock different selectors based on what they're selecting - if (selector.toString().includes('selectAllTokenBalances')) { - return { - [mockAddress.toLowerCase()]: { - '0x1': { - '0xZeroBalance': '0', // Zero balance (exact case match with suggested token) - '0xToken2': '500000000000000000', // Positive balance (exact case match with allowance) - }, - }, + const customTokenBalances = { + [mockAddress.toLowerCase()]: { + '0x1': { + '0xZeroBalance': '0', + '0xToken2': '500000000000000000', + }, + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockUseSelector.mockImplementation((selector: any) => { + if (selector === selectAllTokenBalances) { + return customTokenBalances; + } + if (selector === selectSelectedInternalAccountByScope) { + return (scope: string) => { + if (scope === 'eip155:0') { + return { + address: mockAddress, + id: 'test-account-id', + type: 'eip155:eoa' as const, + options: {}, + metadata: {}, + methods: [], + }; + } + return undefined; }; } - if (selector.toString().includes('selectCardPriorityToken')) { - return mockPriorityToken; // Use the shared mock state + const selectorString = selector.toString(); + if ( + selectorString.includes('selectCardPriorityToken') && + !selectorString.includes('LastFetched') + ) { + return mockPriorityToken; } - if (selector.toString().includes('selectCardPriorityTokenLastFetched')) { - return mockLastFetched; // Use the shared mock state + if (selectorString.includes('selectCardPriorityTokenLastFetched')) { + return mockLastFetched; } return null; }); @@ -696,7 +641,7 @@ describe('useGetPriorityCardToken', () => { mockFetchAllowances.mockResolvedValue(allowancesWithZeroBalance); mockGetPriorityToken.mockResolvedValue(suggestedTokenWithZeroBalance); - const { result } = renderHook(() => useGetPriorityCardToken(mockAddress)); + const { result } = renderHook(() => useGetPriorityCardToken()); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); @@ -704,7 +649,7 @@ describe('useGetPriorityCardToken', () => { expect(mockDispatch).toHaveBeenCalledWith( expect.objectContaining({ - type: 'card/setCardPriorityToken', + type: expect.stringContaining('setCardPriorityToken'), payload: expect.objectContaining({ address: expect.any(String), token: expect.objectContaining({ @@ -721,7 +666,6 @@ describe('useGetPriorityCardToken', () => { }); it('should return suggested token even with zero balance if no other token has positive balance', async () => { - // Create SDK format allowances where all tokens have zero balance const tokenWithZeroBalance1SDK = createMockSDKTokenData( '0xToken1', '1000000000000', @@ -742,25 +686,44 @@ describe('useGetPriorityCardToken', () => { decimals: 18, }; - // Mock balances where all tokens have zero balance - const { useSelector } = jest.requireMock('react-redux'); - useSelector.mockImplementation((selector: (state: unknown) => unknown) => { - // Mock different selectors based on what they're selecting - if (selector.toString().includes('selectAllTokenBalances')) { - return { - [mockAddress.toLowerCase()]: { - '0x1': { - '0xToken1': '0', - '0xToken2': '0', - }, - }, + const zeroBalanceTokens = { + [mockAddress.toLowerCase()]: { + '0x1': { + '0xToken1': '0', + '0xToken2': '0', + }, + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockUseSelector.mockImplementation((selector: any) => { + if (selector === selectAllTokenBalances) { + return zeroBalanceTokens; + } + if (selector === selectSelectedInternalAccountByScope) { + return (scope: string) => { + if (scope === 'eip155:0') { + return { + address: mockAddress, + id: 'test-account-id', + type: 'eip155:eoa' as const, + options: {}, + metadata: {}, + methods: [], + }; + } + return undefined; }; } - if (selector.toString().includes('selectCardPriorityToken')) { - return mockPriorityToken; // Use the shared mock state + const selectorString = selector.toString(); + if ( + selectorString.includes('selectCardPriorityToken') && + !selectorString.includes('LastFetched') + ) { + return mockPriorityToken; } - if (selector.toString().includes('selectCardPriorityTokenLastFetched')) { - return mockLastFetched; // Use the shared mock state + if (selectorString.includes('selectCardPriorityTokenLastFetched')) { + return mockLastFetched; } return null; }); @@ -768,7 +731,7 @@ describe('useGetPriorityCardToken', () => { mockFetchAllowances.mockResolvedValue(allowancesWithZeroBalance); mockGetPriorityToken.mockResolvedValue(suggestedTokenWithZeroBalance); - const { result } = renderHook(() => useGetPriorityCardToken(mockAddress)); + const { result } = renderHook(() => useGetPriorityCardToken()); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); @@ -776,7 +739,7 @@ describe('useGetPriorityCardToken', () => { expect(mockDispatch).toHaveBeenCalledWith( expect.objectContaining({ - type: 'card/setCardPriorityToken', + type: expect.stringContaining('setCardPriorityToken'), payload: expect.objectContaining({ address: expect.any(String), token: expect.objectContaining({ @@ -792,18 +755,7 @@ describe('useGetPriorityCardToken', () => { expect(result.current.error).toBe(false); }); - it('should maintain loading state correctly during fetch', async () => { - // Start with no address to avoid automatic fetching - const { result, rerender } = renderHook( - ({ address }: { address?: string }) => useGetPriorityCardToken(address), - { - initialProps: { address: undefined as string | undefined }, - }, - ); - - // Initially, loading should be false with no address - expect(result.current.isLoading).toBe(false); - + it('should handle loading state correctly during fetch', async () => { let resolvePromise: (value: CardToken) => void; const mockPromise = new Promise((resolve) => { resolvePromise = resolve; @@ -812,482 +764,76 @@ describe('useGetPriorityCardToken', () => { mockFetchAllowances.mockResolvedValue(mockSDKTokensData); mockGetPriorityToken.mockReturnValue(mockPromise); - // Now provide an address which will trigger useEffect and start loading - rerender({ address: mockAddress }); + const { result } = renderHook(() => useGetPriorityCardToken()); - // Wait a tick for the useEffect to trigger and set loading to true await act(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); }); - // Loading should now be true as the fetch is in progress expect(result.current.isLoading).toBe(true); - // Resolve the promise which should set loading back to false await act(async () => { - resolvePromise?.(mockCardToken); - await new Promise((resolve) => setTimeout(resolve, 10)); // Let the promise resolve + resolvePromise(mockCardToken); + await new Promise((resolve) => setTimeout(resolve, 10)); }); expect(result.current.isLoading).toBe(false); }); - it('should refetch when SDK becomes available', async () => { - mockFetchAllowances.mockResolvedValue(mockSDKTokensData); - mockGetPriorityToken.mockResolvedValue(mockCardToken); - - (useCardSDK as jest.Mock).mockReturnValue({ sdk: null }); - const { result } = renderHook(() => useGetPriorityCardToken(mockAddress)); - - // Wait for initial mount with no SDK - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - expect(result.current.priorityToken).toBeNull(); - expect(mockGetPriorityToken).not.toHaveBeenCalled(); - - // Enable SDK and create a new hook instance to trigger fetch - (useCardSDK as jest.Mock).mockReturnValue({ sdk: mockSDK }); - - renderHook(() => useGetPriorityCardToken(mockAddress)); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'card/setCardPriorityToken', - payload: expect.objectContaining({ - address: expect.any(String), - token: expect.objectContaining({ - address: '0xToken1', - symbol: 'TKN1', - name: 'Token 1', - allowanceState: AllowanceState.Enabled, - }), - }), - }), - ); - expect(mockGetPriorityToken).toHaveBeenCalledTimes(1); - }); + it('should handle tokens with limited allowance', async () => { + const limitedAllowanceTokens = [ + createMockSDKTokenData('0xToken1', '1000'), + createMockSDKTokenData('0xToken2', '2000'), + ]; - it('should automatically fetch priority token on mount when address is provided', async () => { - mockFetchAllowances.mockResolvedValue(mockSDKTokensData); + mockFetchAllowances.mockResolvedValue(limitedAllowanceTokens); mockGetPriorityToken.mockResolvedValue(mockCardToken); - renderHook(() => useGetPriorityCardToken(mockAddress)); + const { result } = renderHook(() => useGetPriorityCardToken()); - // Wait for the useEffect to trigger the fetch await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); expect(mockDispatch).toHaveBeenCalledWith( expect.objectContaining({ - type: 'card/setCardPriorityToken', - payload: expect.objectContaining({ - address: expect.any(String), - token: expect.objectContaining({ - address: '0xToken1', - symbol: 'TKN1', - name: 'Token 1', - allowanceState: AllowanceState.Enabled, - }), - }), - }), - ); - expect(mockFetchAllowances).toHaveBeenCalledTimes(1); - expect(mockGetPriorityToken).toHaveBeenCalledTimes(1); - }); - - it('should handle concurrent fetch calls correctly', async () => { - let resolvePromise1: (value: CardToken) => void; - let resolvePromise2: (value: CardToken) => void; - - const mockPromise1 = new Promise((resolve) => { - resolvePromise1 = resolve; - }); - const mockPromise2 = new Promise((resolve) => { - resolvePromise2 = resolve; - }); - - const mockToken1 = { - address: '0xToken1', - symbol: 'TKN1', - name: 'Token 1', - decimals: 18, - }; - const mockToken2 = { - address: '0xToken2', - symbol: 'TKN2', - name: 'Token 2', - decimals: 18, - }; - const mockSDKAllowance1 = createMockSDKTokenData( - '0xToken1', - '1000000000000', - ); // Large allowance for enabled state - const mockSDKAllowance2 = createMockSDKTokenData( - '0xToken2', - '500000000000', - ); // Large allowance for enabled state - - const { result } = renderHook(() => useGetPriorityCardToken(mockAddress)); - - // Wait for the automatic fetch from useEffect to complete - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - mockFetchAllowances.mockResolvedValueOnce([mockSDKAllowance1]); - mockFetchAllowances.mockResolvedValueOnce([mockSDKAllowance2]); - mockGetPriorityToken.mockReturnValueOnce(mockPromise1); - mockGetPriorityToken.mockReturnValueOnce(mockPromise2); - - let priorityToken1: CardTokenAllowance | null | undefined; - let priorityToken2: CardTokenAllowance | null | undefined; - - // Test concurrent calls by calling both functions within act - let fetch1: Promise; - let fetch2: Promise; - - await act(async () => { - fetch1 = result.current.fetchPriorityToken(); - fetch2 = result.current.fetchPriorityToken(); - }); - - // Resolve both promises in an act block - await act(async () => { - resolvePromise1?.(mockToken1); - resolvePromise2?.(mockToken2); - }); - - // Wait for both fetches to complete - await act(async () => { - priorityToken1 = await fetch1; - priorityToken2 = await fetch2; - }); - - expect(priorityToken1).toEqual( - expect.objectContaining({ - address: '0xToken1', - symbol: 'TKN1', - name: 'Token 1', - allowanceState: AllowanceState.Enabled, - }), - ); - expect(priorityToken2).toEqual( - expect.objectContaining({ - address: '0xToken2', - symbol: 'TKN2', - name: 'Token 2', - allowanceState: AllowanceState.Enabled, - }), - ); - expect(mockGetPriorityToken).toHaveBeenCalledTimes(2); - expect(result.current.isLoading).toBe(false); - }); - - it('should return fallback token when no allowances are returned', async () => { - // Mock empty allowances array - mockFetchAllowances.mockResolvedValue([]); - - const { result } = renderHook(() => useGetPriorityCardToken(mockAddress)); - - // Wait for the hook to complete its async operations - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - // Should dispatch the first supported token as fallback - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'card/setCardPriorityToken', - payload: expect.objectContaining({ - address: expect.any(String), - token: expect.objectContaining({ - address: '0xToken1', - symbol: 'TKN1', - name: 'Token 1', - allowanceState: AllowanceState.NotEnabled, - isStaked: false, - chainId: LINEA_CHAIN_ID, - }), - }), - }), - ); - - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'card/setCardPriorityTokenLastFetched', - payload: expect.objectContaining({ - address: '0x1234567890123456789012345678901234567890', - lastFetched: expect.any(Date), - }), - }), - ); - - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(false); - expect(mockGetPriorityToken).not.toHaveBeenCalled(); - }); - - it('should handle address change and refetch', async () => { - const address1 = '0x1111111111111111111111111111111111111111'; - const address2 = '0x2222222222222222222222222222222222222222'; - const mockSDKAllowance1 = createMockSDKTokenData( - '0xToken1', - '1000000000000', - ); // Large allowance for enabled state - const mockSDKAllowance2 = createMockSDKTokenData( - '0xToken2', - '500000000000', - ); // Large allowance for enabled state - const mockToken1 = { - address: '0xToken1', - symbol: 'TKN1', - name: 'Token 1', - decimals: 18, - }; - const mockToken2 = { - address: '0xToken2', - symbol: 'TKN2', - name: 'Token 2', - decimals: 18, - }; - - // Set up proper token balances for both addresses - const { useSelector } = jest.requireMock('react-redux'); - useSelector.mockImplementation((selector: (state: unknown) => unknown) => { - // Mock different selectors based on what they're selecting - if (selector.toString().includes('selectAllTokenBalances')) { - return { - [address1.toLowerCase()]: { - '0x1': { - '0xToken1': '1000000000000000000', - }, - }, - [address2.toLowerCase()]: { - '0x1': { - '0xToken2': '500000000000000000', - }, - }, - }; - } - if (selector.toString().includes('selectCardPriorityToken')) { - return mockPriorityToken; // Use the shared mock state - } - if (selector.toString().includes('selectCardPriorityTokenLastFetched')) { - return mockLastFetched; // Use the shared mock state - } - return null; - }); - - // Use mockResolvedValue instead of mockResolvedValueOnce for multiple calls - mockFetchAllowances.mockResolvedValue([mockSDKAllowance1]); - mockGetPriorityToken.mockResolvedValue(mockToken1); - - const { rerender } = renderHook( - ({ address }) => useGetPriorityCardToken(address), - { - initialProps: { address: address1 }, - }, - ); - - // Wait for the useEffect to trigger the fetch - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'card/setCardPriorityToken', + type: expect.stringContaining('setCardPriorityToken'), payload: expect.objectContaining({ - address: expect.any(String), + address: mockAddress, token: expect.objectContaining({ address: '0xToken1', symbol: 'TKN1', name: 'Token 1', - allowanceState: AllowanceState.Enabled, + allowanceState: AllowanceState.Limited, }), }), }), ); - expect(mockGetPriorityToken).toHaveBeenCalledWith(address1, ['0xToken1']); - - // Reset mocks for second address - mockFetchAllowances.mockClear(); - mockGetPriorityToken.mockClear(); - mockFetchAllowances.mockResolvedValue([mockSDKAllowance2]); - mockGetPriorityToken.mockResolvedValue(mockToken2); - - // Change address and verify refetch - rerender({ address: address2 }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'card/setCardPriorityToken', - payload: expect.objectContaining({ - address: expect.any(String), - token: expect.objectContaining({ - address: '0xToken2', - symbol: 'TKN2', - name: 'Token 2', - allowanceState: AllowanceState.Enabled, - }), - }), - }), - ); - expect(mockGetPriorityToken).toHaveBeenLastCalledWith(address2, [ - '0xToken2', - ]); - expect(mockGetPriorityToken).toHaveBeenCalledTimes(1); // Only once since we cleared - }); - - it('should return null when no allowances and no supported tokens exist', async () => { - // Mock empty allowances and no supported tokens - mockFetchAllowances.mockResolvedValue([]); - - // Override SDK to have no supported tokens - (useCardSDK as jest.Mock).mockReturnValue({ - sdk: { - ...mockSDK, - supportedTokens: [], - }, - }); - - const { result } = renderHook(() => useGetPriorityCardToken(mockAddress)); - - // Wait for the hook to complete its async operations - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - // Should dispatch null when no fallback tokens available - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'card/setCardPriorityToken', - payload: expect.objectContaining({ - address: '0x1234567890123456789012345678901234567890', - token: null, - }), - }), - ); - - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'card/setCardPriorityTokenLastFetched', - payload: expect.objectContaining({ - address: '0x1234567890123456789012345678901234567890', - lastFetched: expect.any(Date), - }), - }), - ); expect(result.current.isLoading).toBe(false); expect(result.current.error).toBe(false); - expect(mockGetPriorityToken).not.toHaveBeenCalled(); - }); - - it('should handle error when getSupportedTokensAllowances returns null', async () => { - // Mock null response from getSupportedTokensAllowances (which causes fetchAllowances to throw) - mockFetchAllowances.mockResolvedValue(null); - - const { result } = renderHook(() => useGetPriorityCardToken(mockAddress)); - - // Wait for the hook to complete its async operations - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - // This should trigger an error since fetchAllowances tries to map over null - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(true); - expect(result.current.priorityToken).toBeNull(); - expect(mockGetPriorityToken).not.toHaveBeenCalled(); - expect(Logger.error).toHaveBeenCalledWith( - expect.any(Error), - 'useGetPriorityCardToken::error fetching priority token', - ); }); - it('should return first token when all allowances have zero balance', async () => { - // Create allowances where all have zero allowance amounts - const zeroAllowanceTokens = [ - createMockSDKTokenData('0xToken1', '0'), // Zero allowance - createMockSDKTokenData('0xToken2', '0'), // Zero allowance - createMockSDKTokenData('0xToken3', '0'), // Zero allowance + it('should handle unsupported token filtering', async () => { + const unsupportedTokenAllowances = [ + createMockSDKTokenData('0xUnsupportedToken', '1000000000000'), ]; - mockFetchAllowances.mockResolvedValue(zeroAllowanceTokens); + mockFetchAllowances.mockResolvedValue(unsupportedTokenAllowances); - const { result } = renderHook(() => useGetPriorityCardToken(mockAddress)); + const { result } = renderHook(() => useGetPriorityCardToken()); - // Wait for the hook to complete its async operations await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }); - // Should dispatch the first token even though it has zero allowance expect(mockDispatch).toHaveBeenCalledWith( expect.objectContaining({ - type: 'card/setCardPriorityToken', + type: expect.stringContaining('setCardPriorityToken'), payload: expect.objectContaining({ - address: expect.any(String), + address: mockAddress, token: expect.objectContaining({ address: '0xToken1', - symbol: 'TKN1', - name: 'Token 1', - allowanceState: AllowanceState.NotEnabled, // Zero allowance = NotEnabled - }), - }), - }), - ); - - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'card/setCardPriorityTokenLastFetched', - payload: expect.objectContaining({ - address: '0x1234567890123456789012345678901234567890', - lastFetched: expect.any(Date), - }), - }), - ); - - expect(result.current.isLoading).toBe(false); - expect(result.current.error).toBe(false); - expect(mockGetPriorityToken).not.toHaveBeenCalled(); // Should not call getPriorityToken if no valid allowances - }); - - it('should handle case when all tokens have zero allowance and no supported tokens', async () => { - // Create allowances where all have zero allowance amounts - const zeroAllowanceTokens = [ - createMockSDKTokenData('0xUnknownToken', '0'), // Zero allowance, not in supported tokens - ]; - - mockFetchAllowances.mockResolvedValue(zeroAllowanceTokens); - - const { result } = renderHook(() => useGetPriorityCardToken(mockAddress)); - - // Wait for the hook to complete its async operations - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); - }); - - // Should dispatch null since the token is not in supported tokens (filtered out) - // and falls back to the fallback token logic - expect(mockDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'card/setCardPriorityToken', - payload: expect.objectContaining({ - address: expect.any(String), - token: expect.objectContaining({ - address: '0xToken1', // First supported token as fallback allowanceState: AllowanceState.NotEnabled, }), }), @@ -1374,8 +920,9 @@ describe('useGetPriorityCardToken', () => { mockDispatch.mockImplementation((action) => action); // Simplified selector implementation - const { useSelector, useDispatch } = jest.requireMock('react-redux'); - useSelector.mockImplementation( + const { useSelector: useSelectorMock, useDispatch: useDispatchMock } = + jest.requireMock('react-redux'); + useSelectorMock.mockImplementation( (selector: (state: unknown) => unknown) => { const selectorStr = selector.toString(); // Detect by internal state keys to be resilient to createSelector wrappers @@ -1409,7 +956,7 @@ describe('useGetPriorityCardToken', () => { }, ); - useDispatch.mockReturnValue(mockDispatch); + useDispatchMock.mockReturnValue(mockDispatch); }); afterEach(() => { @@ -1421,7 +968,7 @@ describe('useGetPriorityCardToken', () => { }); it('should add token when it does not exist in TokensController', async () => { - const { result } = renderHook(() => useGetPriorityCardToken(mockAddress)); + const { result } = renderHook(() => useGetPriorityCardToken()); // Wait for the addToken effect to run await act(async () => { @@ -1449,7 +996,7 @@ describe('useGetPriorityCardToken', () => { }, }; - const { result } = renderHook(() => useGetPriorityCardToken(mockAddress)); + const { result } = renderHook(() => useGetPriorityCardToken()); // Wait for the addToken effect to run await act(async () => { @@ -1466,7 +1013,7 @@ describe('useGetPriorityCardToken', () => { new Error('Add token failed'), ); - const { result } = renderHook(() => useGetPriorityCardToken(mockAddress)); + const { result } = renderHook(() => useGetPriorityCardToken()); // Wait for the addToken effect to run await act(async () => { @@ -1484,18 +1031,40 @@ describe('useGetPriorityCardToken', () => { it('should not add token when priorityToken is null', async () => { // Mock no priority token - const { useSelector } = jest.requireMock('react-redux'); - useSelector.mockImplementation( + const { useSelector: useSelectorMock2 } = jest.requireMock('react-redux'); + useSelectorMock2.mockImplementation( (selector: (state: unknown) => unknown) => { - const selectorString = selector.toString(); - if (selectorString.includes('selectCardPriorityToken')) { + const selectorStr = selector.toString(); + if ( + selector === selectAllTokenBalances || + selectorStr.includes('selectAllTokenBalances') + ) { + return STATIC_TOKEN_BALANCES; + } + if ( + selector === selectSelectedInternalAccountByScope || + selectorStr.includes('selectSelectedInternalAccountByScope') + ) { + return (scope: string) => { + if (scope === 'eip155:0') { + return { + address: mockAddress, + }; + } + return undefined; + }; + } + if (selectorStr.includes('selectCardPriorityToken')) { return null; // No priority token } + if (selectorStr.includes('selectCardPriorityTokenLastFetched')) { + return new Date(); + } return null; }, ); - const { result } = renderHook(() => useGetPriorityCardToken(mockAddress)); + const { result } = renderHook(() => useGetPriorityCardToken()); // Wait for effects to run await act(async () => { diff --git a/app/components/UI/Card/hooks/useGetPriorityCardToken.tsx b/app/components/UI/Card/hooks/useGetPriorityCardToken.tsx index 675d65d77cf..12e67c33172 100644 --- a/app/components/UI/Card/hooks/useGetPriorityCardToken.tsx +++ b/app/components/UI/Card/hooks/useGetPriorityCardToken.tsx @@ -16,6 +16,7 @@ import { LINEA_CHAIN_ID } from '@metamask/swaps-controller/dist/constants'; import { selectAllTokenBalances } from '../../../../selectors/tokenBalancesController'; import { CardSDK } from '../sdk/CardSDK'; import { ARBITRARY_ALLOWANCE } from '../constants'; +import { selectSelectedInternalAccountByScope } from '../../../../selectors/multichainAccounts/accounts'; import { selectCardPriorityToken, selectCardPriorityTokenLastFetched, @@ -106,8 +107,6 @@ const fetchAllowances = async ( * This hook implements a caching strategy where if the priority token was fetched less than 5 minutes ago, * it returns the cached value. Otherwise, it fetches a new priority token from the Card SDK. * - * @param {string} [address] - Ethereum address of the user whose card token priority is to be fetched. - * @param {boolean} [shouldFetch=true] - Whether the hook should attempt to fetch if cache is stale. * @returns {{ * fetchPriorityToken: () => Promise, * isLoading: boolean, @@ -120,10 +119,7 @@ const fetchAllowances = async ( * - error: any error encountered while fetching * - priorityToken: the cached or newly fetched priority token */ -export const useGetPriorityCardToken = ( - selectedAddress?: string, - shouldFetch: boolean = true, -) => { +export const useGetPriorityCardToken = () => { const dispatch = useDispatch(); const { TokensController, NetworkController } = Engine.context; const { sdk } = useCardSDK(); @@ -133,6 +129,9 @@ export const useGetPriorityCardToken = ( // Extract controller state const allTokenBalances = useSelector(selectAllTokenBalances); + const selectedAddress = useSelector(selectSelectedInternalAccountByScope)( + 'eip155:0', + )?.address; const priorityToken = useSelector(selectCardPriorityToken(selectedAddress)); const lastFetched = useSelector( selectCardPriorityTokenLastFetched(selectedAddress), @@ -343,7 +342,7 @@ export const useGetPriorityCardToken = ( }, [sdk, selectedAddress, getBalancesForChain, dispatch]); useEffect(() => { - if (!selectedAddress || !shouldFetch) { + if (!selectedAddress) { return; } @@ -356,7 +355,6 @@ export const useGetPriorityCardToken = ( fetchPriorityToken(); }, [ selectedAddress, - shouldFetch, cacheIsValid, priorityToken, fetchPriorityToken, diff --git a/app/components/UI/Card/hooks/useOpenSwaps.test.ts b/app/components/UI/Card/hooks/useOpenSwaps.test.ts index f4e526b2dad..2a650789f5d 100644 --- a/app/components/UI/Card/hooks/useOpenSwaps.test.ts +++ b/app/components/UI/Card/hooks/useOpenSwaps.test.ts @@ -156,7 +156,6 @@ describe('useOpenSwaps', () => { act(() => { result.current.openSwaps({ chainId: '0xe708', - cardholderAddress: '0xcard', }); }); @@ -194,7 +193,6 @@ describe('useOpenSwaps', () => { act(() => { result.current.openSwaps({ chainId: '0xe708', - cardholderAddress: '0xcard', }); }); @@ -222,7 +220,6 @@ describe('useOpenSwaps', () => { act(() => { result.current.openSwaps({ chainId: '0xe708', - cardholderAddress: '0xcard', beforeNavigate, }); }); @@ -276,7 +273,6 @@ describe('useOpenSwaps', () => { act(() => { result.current.openSwaps({ chainId: '0xe708', - cardholderAddress: '0xcard', }); }); diff --git a/app/components/UI/Card/hooks/useOpenSwaps.ts b/app/components/UI/Card/hooks/useOpenSwaps.ts index 7c5d7cbc1ab..2b0c6e48642 100644 --- a/app/components/UI/Card/hooks/useOpenSwaps.ts +++ b/app/components/UI/Card/hooks/useOpenSwaps.ts @@ -18,7 +18,6 @@ import { useTokensWithBalance } from '../../Bridge/hooks/useTokensWithBalance'; export interface OpenSwapsParams { chainId: string; - cardholderAddress?: string; beforeNavigate?: (navigate: () => void) => void; } diff --git a/app/components/UI/Card/sdk/CardSDK.test.ts b/app/components/UI/Card/sdk/CardSDK.test.ts index 26824f7d59e..9cb63b574e1 100644 --- a/app/components/UI/Card/sdk/CardSDK.test.ts +++ b/app/components/UI/Card/sdk/CardSDK.test.ts @@ -112,7 +112,6 @@ describe('CardSDK', () => { cardSDK = new CardSDK({ cardFeatureFlag: mockCardFeatureFlag, - rawChainId: '0xe708', }); }); @@ -143,7 +142,6 @@ describe('CardSDK', () => { const disabledCardholderSDK = new CardSDK({ cardFeatureFlag: disabledCardFeatureFlag, - rawChainId: '0xe708', }); expect(disabledCardholderSDK.isCardEnabled).toBe(false); @@ -154,7 +152,6 @@ describe('CardSDK', () => { const noChainCardholderSDK = new CardSDK({ cardFeatureFlag: emptyCardFeatureFlag, - rawChainId: '0xe708', }); expect(noChainCardholderSDK.isCardEnabled).toBe(false); @@ -181,7 +178,6 @@ describe('CardSDK', () => { const disabledCardholderSDK = new CardSDK({ cardFeatureFlag: disabledCardFeatureFlag, - rawChainId: '0xe708', }); expect(disabledCardholderSDK.supportedTokens).toEqual([]); @@ -202,7 +198,6 @@ describe('CardSDK', () => { const noTokensCardSDK = new CardSDK({ cardFeatureFlag: noTokensCardFeatureFlag, - rawChainId: '0xe708', }); expect(noTokensCardSDK.supportedTokens).toEqual([]); @@ -226,7 +221,6 @@ describe('CardSDK', () => { const missingFoxConnectSDK = new CardSDK({ cardFeatureFlag: missingFoxConnectFeatureFlag, - rawChainId: '0xe708', }); // This should throw an error when trying to access foxConnectAddresses @@ -258,7 +252,6 @@ describe('CardSDK', () => { const missingBalanceScannerSDK = new CardSDK({ cardFeatureFlag: missingBalanceScannerFeatureFlag, - rawChainId: '0xe708', }); // This should throw an error when trying to access balanceScannerAddress @@ -304,7 +297,6 @@ describe('CardSDK', () => { const disabledCardholderSDK = new CardSDK({ cardFeatureFlag: disabledCardFeatureFlag, - rawChainId: '0xe708', }); const result = await disabledCardholderSDK.isCardHolder([ @@ -469,7 +461,6 @@ describe('CardSDK', () => { const missingAccountsApiSDK = new CardSDK({ cardFeatureFlag: missingAccountsApiFeatureFlag, - rawChainId: '0xe708', }); const result = await missingAccountsApiSDK.isCardHolder([ @@ -567,7 +558,6 @@ describe('CardSDK', () => { const disabledCardholderSDK = new CardSDK({ cardFeatureFlag: disabledCardFeatureFlag, - rawChainId: '0xe708', }); await expect( @@ -592,7 +582,6 @@ describe('CardSDK', () => { const emptyTokensCardSDK = new CardSDK({ cardFeatureFlag: emptyTokensCardFeatureFlag, - rawChainId: '0xe708', }); const result = await emptyTokensCardSDK.getSupportedTokensAllowances( @@ -645,7 +634,6 @@ describe('CardSDK', () => { const disabledCardholderSDK = new CardSDK({ cardFeatureFlag: disabledCardFeatureFlag, - rawChainId: '0xe708', }); await expect( @@ -780,7 +768,6 @@ describe('CardSDK', () => { const emptyCardholderSDK = new CardSDK({ cardFeatureFlag: emptyCardFeatureFlag, - rawChainId: '0xe708', }); mockProvider.getLogs.mockResolvedValue([]); diff --git a/app/components/UI/Card/sdk/CardSDK.ts b/app/components/UI/Card/sdk/CardSDK.ts index 72c82c5d0ee..17e8580616e 100644 --- a/app/components/UI/Card/sdk/CardSDK.ts +++ b/app/components/UI/Card/sdk/CardSDK.ts @@ -3,12 +3,12 @@ import { CardFeatureFlag, SupportedToken, } from '../../../../selectors/featureFlagController/card'; -import { SupportedCaipChainId } from '@metamask/multichain-network-controller'; import { getDecimalChainId } from '../../../../util/networks'; import { LINEA_DEFAULT_RPC_URL } from '../../../../constants/urls'; import { BALANCE_SCANNER_ABI } from '../constants'; import Logger from '../../../../util/Logger'; import { CardToken } from '../types'; +import { LINEA_CHAIN_ID } from '@metamask/swaps-controller/dist/constants'; // The CardSDK class provides methods to interact with the Card feature // and check if an address is a card holder, get supported tokens, and more. @@ -21,15 +21,13 @@ export class CardSDK { constructor({ cardFeatureFlag, - rawChainId, enableLogs = false, }: { cardFeatureFlag: CardFeatureFlag; - rawChainId: `0x${string}` | SupportedCaipChainId; enableLogs?: boolean; }) { this.cardFeatureFlag = cardFeatureFlag; - this.chainId = getDecimalChainId(rawChainId); + this.chainId = getDecimalChainId(LINEA_CHAIN_ID); this.enableLogs = enableLogs; } diff --git a/app/components/UI/Card/sdk/index.test.tsx b/app/components/UI/Card/sdk/index.test.tsx index 4dcf6efbdc8..392fef89866 100644 --- a/app/components/UI/Card/sdk/index.test.tsx +++ b/app/components/UI/Card/sdk/index.test.tsx @@ -15,6 +15,7 @@ import { } from '../../../../selectors/featureFlagController/card'; import { selectChainId } from '../../../../selectors/networkController'; import { useCardholderCheck } from '../hooks/useCardholderCheck'; +import { View } from 'react-native'; jest.mock('./CardSDK', () => ({ CardSDK: jest.fn().mockImplementation(() => ({ @@ -98,7 +99,7 @@ describe('CardSDK Context', () => { it('should render children without crashing', () => { setupMockUseSelector(mockCardFeatureFlag); - const TestComponent = () =>
Test Child
; + const TestComponent = () => Test Child; render( @@ -108,14 +109,13 @@ describe('CardSDK Context', () => { expect(MockedCardholderSDK).toHaveBeenCalledWith({ cardFeatureFlag: mockCardFeatureFlag, - rawChainId: '0xe708', }); }); it('should not initialize SDK when card feature flag is missing', () => { setupMockUseSelector(null); - const TestComponent = () =>
Test Child
; + const TestComponent = () => Test Child; render( @@ -136,7 +136,7 @@ describe('CardSDK Context', () => { const TestComponent = () => { const context = useCardSDK(); expect(context).toEqual(providedValue); - return
Test Child
; + return Test Child; }; render( @@ -223,7 +223,7 @@ describe('CardSDK Context', () => { it('should handle undefined card feature flag gracefully', () => { setupMockUseSelector(undefined); - const TestComponent = () =>
Test Child
; + const TestComponent = () => Test Child; render( @@ -237,7 +237,7 @@ describe('CardSDK Context', () => { it('should handle empty card feature flag gracefully', () => { setupMockUseSelector({}); - const TestComponent = () =>
Test Child
; + const TestComponent = () => Test Child; render( @@ -247,7 +247,6 @@ describe('CardSDK Context', () => { expect(MockedCardholderSDK).toHaveBeenCalledWith({ cardFeatureFlag: {}, - rawChainId: '0xe708', }); }); }); diff --git a/app/components/UI/Card/sdk/index.tsx b/app/components/UI/Card/sdk/index.tsx index f30d267561a..bd95e9e4bf4 100644 --- a/app/components/UI/Card/sdk/index.tsx +++ b/app/components/UI/Card/sdk/index.tsx @@ -10,7 +10,6 @@ import { useSelector } from 'react-redux'; import { CardSDK } from './CardSDK'; import { selectCardFeatureFlag } from '../../../../selectors/featureFlagController/card'; import { useCardholderCheck } from '../hooks/useCardholderCheck'; -import { LINEA_CHAIN_ID } from '@metamask/swaps-controller/dist/constants'; export interface ICardSDK { sdk: CardSDK | null; @@ -36,7 +35,6 @@ export const CardSDKProvider = ({ if (cardFeatureFlag) { const cardSDK = new CardSDK({ cardFeatureFlag, - rawChainId: LINEA_CHAIN_ID, }); setSdk(cardSDK); } else { diff --git a/app/components/UI/Card/util/getCardholder.test.ts b/app/components/UI/Card/util/getCardholder.test.ts index ebc9afae7f8..457c2ccacdb 100644 --- a/app/components/UI/Card/util/getCardholder.test.ts +++ b/app/components/UI/Card/util/getCardholder.test.ts @@ -2,11 +2,7 @@ import { getCardholder } from './getCardholder'; import { CardSDK } from '../sdk/CardSDK'; import Logger from '../../../../util/Logger'; import { CardFeatureFlag } from '../../../../selectors/featureFlagController/card'; -import { - isValidHexAddress, - safeToChecksumAddress, -} from '../../../../util/address'; -import { LINEA_CHAIN_ID } from '@metamask/swaps-controller/dist/constants'; +import { isValidHexAddress } from '../../../../util/address'; // Mock dependencies jest.mock('../sdk/CardSDK'); @@ -21,8 +17,6 @@ const mockedLogger = Logger as jest.Mocked; const mockedIsValidHexAddress = isValidHexAddress as jest.MockedFunction< typeof isValidHexAddress >; -const mockedSafeToChecksumAddress = - safeToChecksumAddress as jest.MockedFunction; describe('getCardholder', () => { const mockCardFeatureFlag: CardFeatureFlag = { @@ -69,9 +63,6 @@ describe('getCardholder', () => { // Mock address utilities mockedIsValidHexAddress.mockReturnValue(true); - mockedSafeToChecksumAddress.mockImplementation( - (address) => address as `0x${string}`, - ); }); describe('successful scenarios', () => { @@ -94,7 +85,6 @@ describe('getCardholder', () => { ]); expect(MockedCardSDK).toHaveBeenCalledWith({ cardFeatureFlag: mockCardFeatureFlag, - rawChainId: LINEA_CHAIN_ID, }); expect(mockCardSDKInstance.isCardHolder).toHaveBeenCalledWith( mockFormattedAccounts, @@ -288,7 +278,6 @@ describe('getCardholder', () => { '0x2222222222222222222222222222222222222222', '0x3333333333333333333333333333333333333333', ]); - expect(mockedSafeToChecksumAddress).toHaveBeenCalledTimes(3); }); it('should handle invalid CAIP-10 format and log errors', async () => { diff --git a/app/components/UI/Card/util/getCardholder.ts b/app/components/UI/Card/util/getCardholder.ts index b283c0db119..7af2d58cf94 100644 --- a/app/components/UI/Card/util/getCardholder.ts +++ b/app/components/UI/Card/util/getCardholder.ts @@ -1,11 +1,7 @@ -import { LINEA_CHAIN_ID } from '@metamask/swaps-controller/dist/constants'; import { CardFeatureFlag } from '../../../../selectors/featureFlagController/card'; import { CardSDK } from '../sdk/CardSDK'; import Logger from '../../../../util/Logger'; -import { - isValidHexAddress, - safeToChecksumAddress, -} from '../../../../util/address'; +import { isValidHexAddress } from '../../../../util/address'; import { isCaipAccountId, parseCaipAccountId } from '@metamask/utils'; export const getCardholder = async ({ @@ -22,7 +18,6 @@ export const getCardholder = async ({ const cardSDK = new CardSDK({ cardFeatureFlag, - rawChainId: LINEA_CHAIN_ID, }); const cardCaipAccountIds = await cardSDK.isCardHolder(caipAccountIds); @@ -34,7 +29,7 @@ export const getCardholder = async ({ if (!isValidHexAddress(address)) return null; - return safeToChecksumAddress(address); + return address.toLowerCase(); }); return cardholderAddresses.filter(Boolean) as string[]; diff --git a/app/components/UI/Perps/Views/PerpsBalanceModal/PerpsBalanceModal.tsx b/app/components/UI/Perps/Views/PerpsBalanceModal/PerpsBalanceModal.tsx index f1f8f707319..a023d0ca6aa 100644 --- a/app/components/UI/Perps/Views/PerpsBalanceModal/PerpsBalanceModal.tsx +++ b/app/components/UI/Perps/Views/PerpsBalanceModal/PerpsBalanceModal.tsx @@ -20,6 +20,7 @@ import Routes from '../../../../../constants/navigation/Routes'; import type { PerpsNavigationParamList } from '../../controllers/types'; import { usePerpsTrading, usePerpsNetworkManagement } from '../../hooks'; import createStyles from './PerpsBalanceModal.styles'; +import { PerpsTabViewSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; interface PerpsBalanceModalProps {} @@ -89,6 +90,7 @@ const PerpsBalanceModal: React.FC = () => { onPress={handleAddFunds} style={styles.actionButton} startIconName={IconName.Add} + testID={PerpsTabViewSelectorsIDs.ADD_FUNDS_BUTTON} />