diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 39f1b05384f..d54ce5cb39b 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -15,6 +15,11 @@ self-hosted-runner: - "ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg" - "ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-xl" - "low-priority" + # Namespace runner profile labels (INFRA-3592). Format: namespace-profile-. + - "namespace-profile-metamask-ci-linux" + - "namespace-profile-metamask-android-build" + - "namespace-profile-metamask-ios-build" + - "namespace-profile-metamask-ios-e2e" # Configuration variables in array of strings defined in your repository or # organization. `null` means disabling configuration variables check. diff --git a/.github/actions/setup-e2e-env/action.yml b/.github/actions/setup-e2e-env/action.yml index 07669962f93..e84b7cdda72 100644 --- a/.github/actions/setup-e2e-env/action.yml +++ b/.github/actions/setup-e2e-env/action.yml @@ -116,9 +116,10 @@ runs: if: ${{ inputs.platform == 'android' && inputs.setup-simulator == 'true' && runner.os == 'Linux' }} uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: - timeout_minutes: 3 + timeout_minutes: 5 max_attempts: 3 retry_wait_seconds: 30 + retry_on: error on_retry_command: sudo apt-get clean command: | set -euo pipefail diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index f39ecff8995..ee847f40a7b 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -25,11 +25,16 @@ on: required: false default: 'qa' type: string + runner_provider: + description: Runner provider forwarded from the caller + required: false + type: string + default: current jobs: build-android-apks: name: Build Android E2E APKs - runs-on: ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg # Optimized for lg runner (48GB) with conservative memory settings + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-android-build' || 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg' }} # Optimized for lg runner (48GB) with conservative memory settings timeout-minutes: 40 env: GRADLE_USER_HOME: /home/admin/_work/.gradle diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index aa2296a5406..c3fd9f4420a 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -17,6 +17,11 @@ on: required: false default: 'qa' type: string + runner_provider: + description: Runner provider forwarded from the caller + required: false + type: string + default: current permissions: contents: read @@ -25,7 +30,7 @@ permissions: jobs: build-ios-apps: name: Build iOS E2E Apps - runs-on: ghcr.io/cirruslabs/macos-runner:tahoe-xl + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ios-build' || 'ghcr.io/cirruslabs/macos-runner:tahoe-xl' }} outputs: artifacts-url: ${{ steps.set-artifacts-url.outputs.artifacts-url }} app-uploaded: ${{ steps.upload-app.outcome == 'success' }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4d48f758bed..b779012fce1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,6 +25,11 @@ on: required: false type: boolean default: false + runner_provider: + description: Runner provider forwarded from the caller + required: false + type: string + default: current outputs: build_name: description: 'build_name input passed to this workflow' @@ -81,6 +86,14 @@ on: required: false type: boolean default: false + runner_provider: + description: Runner provider for this manual trial run + required: true + type: choice + options: + - current + - namespace + default: current permissions: contents: read @@ -104,7 +117,7 @@ jobs: prepare: needs: [update-build-version] if: ${{ always() && !failure() && !cancelled() }} - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} outputs: github_environment: ${{ steps.config.outputs.github_environment }} secrets_json: ${{ steps.config.outputs.secrets_json }} @@ -117,7 +130,6 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 ref: ${{ !inputs.skip_version_bump && needs.update-build-version.outputs.commit-hash || (inputs.source_branch || github.ref_name) }} - name: Setup Node.js uses: actions/setup-node@v4 @@ -165,6 +177,7 @@ jobs: upload-artifact: true artifact-name: node-modules-${{ inputs.build_name }}-${{ matrix.platform }} artifact-retention-days: 1 + runner_provider: ${{ inputs.runner_provider }} # Build build: @@ -175,7 +188,7 @@ jobs: matrix: platform: ${{ inputs.platform == 'both' && fromJSON('["android", "ios"]') || fromJSON(format('["{0}"]', inputs.platform)) }} # Android: Cirrus lg (large) runner for 8GB Gradle heap; iOS: Cirrus macOS Tahoe (has Xcode 26.x) - runs-on: ${{ matrix.platform == 'ios' && 'ghcr.io/cirruslabs/macos-runner:tahoe-xl' || 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg' }} + runs-on: ${{ inputs.runner_provider == 'namespace' && (matrix.platform == 'ios' && 'namespace-profile-metamask-ios-build' || 'namespace-profile-metamask-android-build') || (matrix.platform == 'ios' && 'ghcr.io/cirruslabs/macos-runner:tahoe-xl' || 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg') }} environment: ${{ needs.prepare.outputs.github_environment }} steps: - name: Validate version-bump commit @@ -316,6 +329,16 @@ jobs: SECRETS_JSON: ${{ toJSON(secrets) }} run: node scripts/validate-secrets-from-config.js + - name: Restore CocoaPods specs cache (iOS) + if: matrix.platform == 'ios' + uses: actions/cache@v4 + with: + path: ~/.cocoapods/repos + key: ${{ runner.os }}-cocoapods-specs-${{ hashFiles('ios/Podfile.lock') }} + restore-keys: | + ${{ runner.os }}-cocoapods-specs- + continue-on-error: true + # iOS: Install Pods here so generated paths match this runner (setup-node-modules skips pod install with --no-install-pods). - name: Install CocoaPods dependencies (iOS) if: matrix.platform == 'ios' @@ -508,7 +531,7 @@ jobs: name: Emit build metadata needs: [prepare, build] if: ${{ !failure() && !cancelled() && needs.prepare.result == 'success' && needs.build.result == 'success' }} - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} outputs: checkout_ref: ${{ steps.meta.outputs.checkout_ref }} built_commit_sha: ${{ steps.meta.outputs.built_commit_sha }} @@ -518,7 +541,6 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-depth: 0 ref: ${{ needs.prepare.outputs.checkout_ref_for_setup }} - name: Setup Node.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 139886e7570..381086d45eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,16 @@ on: # Run the full suite "overnight," once every hour from 2:00am UTC until 6:00am UTC. # This helps to identy the flaky and failed tests on main branch - cron: '0 2-6 * * *' + workflow_dispatch: + inputs: + runner_provider: + description: Runner provider for this manual trial run + required: true + type: choice + options: + - current + - namespace + default: current concurrency: group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.sha || github.ref }} @@ -27,7 +37,7 @@ jobs: check-diff: name: Check diff - runs-on: macos-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ios-build' || 'macos-latest' }} if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }} needs: - get_requirements @@ -78,16 +88,25 @@ jobs: dedupe: name: Dedupe - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }} needs: - get_requirements steps: - uses: actions/checkout@v6 + - name: Configure Namespace cache + if: ${{ inputs.runner_provider == 'namespace' }} + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1 + with: + path: | + ~/.cache/yarn + .metamask + node_modules + .yarn/cache - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' - cache: yarn + cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }} - name: Install Yarn dependencies with retry uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: @@ -114,16 +133,25 @@ jobs: git-safe-dependencies: name: Run `@lavamoat/git-safe-dependencies` - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }} needs: - get_requirements steps: - uses: actions/checkout@v6 + - name: Configure Namespace cache + if: ${{ inputs.runner_provider == 'namespace' }} + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1 + with: + path: | + ~/.cache/yarn + .metamask + node_modules + .yarn/cache - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' - cache: yarn + cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }} - name: Install Yarn dependencies with retry uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: @@ -143,7 +171,7 @@ jobs: scripts: name: Run `${{ matrix.scripts }}` - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }} needs: - get_requirements @@ -160,10 +188,19 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 2 + - name: Configure Namespace cache + if: ${{ inputs.runner_provider == 'namespace' }} + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1 + with: + path: | + ~/.cache/yarn + .metamask + node_modules + .yarn/cache - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' - cache: yarn + cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }} - name: Install Yarn dependencies with retry uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: @@ -186,7 +223,7 @@ jobs: js-bundle-size-check: name: JS bundle size check - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }} needs: - get_requirements @@ -195,10 +232,19 @@ jobs: statuses: write steps: - uses: actions/checkout@v6 + - name: Configure Namespace cache + if: ${{ inputs.runner_provider == 'namespace' }} + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1 + with: + path: | + ~/.cache/yarn + .metamask + node_modules + .yarn/cache - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' - cache: yarn + cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }} - name: Install Yarn dependencies with retry uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: @@ -396,7 +442,7 @@ jobs: ship-js-bundle-size-check: name: Ship JS bundle size check - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} needs: [js-bundle-size-check] if: ${{ github.ref == 'refs/heads/main' }} steps: @@ -434,7 +480,7 @@ jobs: check-workflows: name: Check workflows - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }} needs: - get_requirements @@ -450,7 +496,7 @@ jobs: unit-tests: name: Unit tests (${{ matrix.shard }}) - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }} needs: - get_requirements @@ -459,10 +505,19 @@ jobs: shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] steps: - uses: actions/checkout@v6 + - name: Configure Namespace cache + if: ${{ inputs.runner_provider == 'namespace' }} + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1 + with: + path: | + ~/.cache/yarn + .metamask + node_modules + .yarn/cache - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' - cache: yarn + cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }} - name: Install Yarn dependencies with retry uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: @@ -478,7 +533,7 @@ jobs: # in sync with the length of matrix.shard - run: yarn test:unit --shard=${{ matrix.shard }}/10 --forceExit --silent --coverageReporters=json --json --outputFile=tests/results/unit-test-results-${{ matrix.shard }}.json env: - NODE_OPTIONS: --max_old_space_size=20480 + NODE_OPTIONS: ${{ inputs.runner_provider == 'namespace' && '--max_old_space_size=12288' || '--max_old_space_size=20480' }} - name: Rename coverage report and extract test count for this shard shell: bash run: | @@ -505,12 +560,22 @@ jobs: # We need to merge both unit and component view tests into a single coverage report so the PR coverage # threshold calculation is accurate. merge-unit-and-component-view-tests: - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} needs: [unit-tests, component-view-tests] if: ${{ !cancelled() && github.event_name != 'merge_group' }} steps: - uses: actions/checkout@v6 + - name: Configure Namespace cache + if: ${{ inputs.runner_provider == 'namespace' }} + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1 + with: + path: | + ~/.cache/yarn + .metamask + node_modules + .yarn/cache - name: Restore node_modules cache + if: ${{ inputs.runner_provider != 'namespace' }} id: cache-node-modules uses: actions/cache@v4 with: @@ -521,9 +586,9 @@ jobs: - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' - cache: yarn + cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }} - name: Install Yarn dependencies with retry - if: steps.cache-node-modules.outputs.cache-hit != 'true' + if: ${{ inputs.runner_provider == 'namespace' || steps.cache-node-modules.outputs.cache-hit != 'true' }} uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: timeout_minutes: 10 @@ -622,7 +687,7 @@ jobs: component-view-tests: name: Component view tests - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ needs.get_requirements.outputs.skip_everything != 'true' }} needs: - get_requirements @@ -631,7 +696,17 @@ jobs: shard: [1, 2] steps: - uses: actions/checkout@v6 + - name: Configure Namespace cache + if: ${{ inputs.runner_provider == 'namespace' }} + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1 + with: + path: | + ~/.cache/yarn + .metamask + node_modules + .yarn/cache - name: Restore node_modules cache + if: ${{ inputs.runner_provider != 'namespace' }} id: cache-node-modules uses: actions/cache@v4 with: @@ -642,9 +717,9 @@ jobs: - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' - cache: yarn + cache: ${{ inputs.runner_provider != 'namespace' && 'yarn' || '' }} - name: Install Yarn dependencies with retry - if: steps.cache-node-modules.outputs.cache-hit != 'true' + if: ${{ inputs.runner_provider == 'namespace' || steps.cache-node-modules.outputs.cache-hit != 'true' }} uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2 with: timeout_minutes: 10 @@ -661,7 +736,7 @@ jobs: --json \ --outputFile=tests/results/cv-test-results-${{ matrix.shard }}.json env: - NODE_OPTIONS: --max-old-space-size=20480 + NODE_OPTIONS: ${{ inputs.runner_provider == 'namespace' && '--max-old-space-size=12288' || '--max-old-space-size=20480' }} - name: Rename coverage report and extract test count for this shard shell: bash run: | @@ -678,7 +753,7 @@ jobs: smart-e2e-selection: name: 'Smart E2E Selection' - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ needs.get_requirements.outputs.run_smart_e2e_selection == 'true' }} needs: - get_requirements @@ -729,6 +804,7 @@ jobs: build_type: 'main' metamask_environment: 'e2e' keystore_target: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit e2e-smoke-tests-android: @@ -746,6 +822,7 @@ jobs: (fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) || '["ALL"]' }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit build-ios-apps: @@ -761,11 +838,13 @@ jobs: id-token: write needs: [get_requirements, smart-e2e-selection] uses: ./.github/workflows/build-ios-e2e.yml + with: + runner_provider: ${{ inputs.runner_provider }} secrets: inherit ios-tests-ready: name: 'iOS Tests Ready' - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ !cancelled() && needs.build-ios-apps.result == 'success' }} needs: [build-ios-apps] steps: @@ -787,6 +866,7 @@ jobs: (fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 80 && needs.smart-e2e-selection.outputs.ai_e2e_test_tags) || '["ALL"]' }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit # Fixture validation — ensures committed E2E fixtures match the live app state schema @@ -806,11 +886,12 @@ jobs: total_splits: 1 build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit report-fixture-validation: name: 'Report Fixture Validation' - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ !cancelled() && needs.validate-e2e-fixtures.result != 'skipped' }} needs: [validate-e2e-fixtures] permissions: @@ -837,13 +918,22 @@ jobs: sonar-cloud: name: SonarCloud analysis - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} needs: merge-unit-and-component-view-tests if: ${{ !cancelled() && github.event_name != 'merge_group' && !github.event.pull_request.head.repo.fork }} steps: - uses: actions/checkout@v6 with: fetch-depth: 0 # SonarCloud needs a full checkout to perform necessary analysis + - name: Configure Namespace cache + if: ${{ inputs.runner_provider == 'namespace' }} + uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1 + with: + path: | + ~/.cache/yarn + .metamask + node_modules + .yarn/cache - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' @@ -882,7 +972,7 @@ jobs: sonar-cloud-quality-gate-status: name: SonarCloud quality gate status - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} needs: sonar-cloud if: ${{ !cancelled() && github.event_name != 'merge_group' && !github.event.pull_request.head.repo.fork }} steps: @@ -942,7 +1032,7 @@ jobs: # Run the aggregate gate even when optional dependencies are skipped. # The composite action decides which skipped jobs are acceptable. if: ${{ always() && !cancelled() }} - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} needs: - get_requirements - check-diff @@ -975,7 +1065,7 @@ jobs: log-merge-group-failure: name: Log merge group failure - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} # Only run this job if the merge group event fails, skip on forks if: ${{ github.event_name == 'merge_group' && failure() }} needs: diff --git a/.github/workflows/expo-dev-build.yml b/.github/workflows/expo-dev-build.yml index c558ff1297e..1c078efa825 100644 --- a/.github/workflows/expo-dev-build.yml +++ b/.github/workflows/expo-dev-build.yml @@ -19,6 +19,15 @@ on: branches: - main workflow_dispatch: + inputs: + runner_provider: + description: Runner provider for this manual trial run + required: true + type: choice + options: + - current + - namespace + default: current permissions: contents: write @@ -32,4 +41,5 @@ jobs: build_name: main-dev-expo platform: both skip_version_bump: true + runner_provider: ${{ inputs.runner_provider }} secrets: inherit diff --git a/.github/workflows/run-e2e-regression-tests-android.yml b/.github/workflows/run-e2e-regression-tests-android.yml index fb0a2a7b7b9..a39a83f7a68 100644 --- a/.github/workflows/run-e2e-regression-tests-android.yml +++ b/.github/workflows/run-e2e-regression-tests-android.yml @@ -7,6 +7,14 @@ on: description: 'Send Slack notification even when all tests pass' type: boolean default: false + runner_provider: + description: Runner provider for this manual trial run + required: true + type: choice + options: + - current + - namespace + default: current permissions: contents: read @@ -25,6 +33,7 @@ jobs: build_type: 'main' metamask_environment: 'e2e' keystore_target: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-confirmations-android: @@ -41,6 +50,7 @@ jobs: test_suite_tag: 'RegressionConfirmations' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-trade-android: @@ -57,6 +67,7 @@ jobs: test_suite_tag: 'RegressionTrade' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-wallet-platform-android: @@ -73,6 +84,7 @@ jobs: test_suite_tag: 'RegressionWalletPlatform' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-accounts-android: @@ -89,6 +101,7 @@ jobs: test_suite_tag: 'RegressionAccounts' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-network-abstraction-android: @@ -105,6 +118,7 @@ jobs: test_suite_tag: 'RegressionNetworkAbstractions' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-network-expansion-android: @@ -121,6 +135,7 @@ jobs: test_suite_tag: 'RegressionNetworkExpansion' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-assets-android: @@ -137,6 +152,7 @@ jobs: test_suite_tag: 'RegressionAssets' split_number: ${{ matrix.split }} total_splits: 2 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-ux-android: @@ -153,11 +169,12 @@ jobs: test_suite_tag: 'RegressionWalletUX' split_number: ${{ matrix.split }} total_splits: 1 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit report-android-regression-tests: name: Report Android Regression Tests - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: always() needs: - build-android-apks diff --git a/.github/workflows/run-e2e-regression-tests-ios.yml b/.github/workflows/run-e2e-regression-tests-ios.yml index 29343a469b7..295069cc952 100644 --- a/.github/workflows/run-e2e-regression-tests-ios.yml +++ b/.github/workflows/run-e2e-regression-tests-ios.yml @@ -9,6 +9,14 @@ on: description: 'Send Slack notification even when all tests pass' type: boolean default: false + runner_provider: + description: Runner provider for this manual trial run + required: true + type: choice + options: + - current + - namespace + default: current permissions: contents: read @@ -23,6 +31,8 @@ jobs: contents: read id-token: write uses: ./.github/workflows/build-ios-e2e.yml + with: + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-confirmations-ios: @@ -39,6 +49,7 @@ jobs: test_suite_tag: 'RegressionConfirmations' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-trade-ios: @@ -55,6 +66,7 @@ jobs: test_suite_tag: 'RegressionTrade' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-wallet-platform-ios: @@ -71,6 +83,7 @@ jobs: test_suite_tag: 'RegressionWalletPlatform' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-accounts-ios: @@ -87,6 +100,7 @@ jobs: test_suite_tag: 'RegressionAccounts' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-network-abstraction-ios: @@ -103,6 +117,7 @@ jobs: test_suite_tag: 'RegressionNetworkAbstractions' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-network-expansion-ios: @@ -119,6 +134,7 @@ jobs: test_suite_tag: 'RegressionNetworkExpansion' split_number: ${{ matrix.split }} total_splits: 4 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-assets-ios: @@ -135,6 +151,7 @@ jobs: test_suite_tag: 'RegressionAssets' split_number: ${{ matrix.split }} total_splits: 2 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit regression-ux-ios: @@ -151,11 +168,12 @@ jobs: test_suite_tag: 'RegressionWalletUX' split_number: ${{ matrix.split }} total_splits: 1 + runner_provider: ${{ inputs.runner_provider }} secrets: inherit report-ios-regression-tests: name: Report iOS Regression Tests - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: always() needs: - regression-confirmations-ios diff --git a/.github/workflows/run-e2e-smoke-tests-android.yml b/.github/workflows/run-e2e-smoke-tests-android.yml index a8dc3575fbc..f40ccf3d1e9 100644 --- a/.github/workflows/run-e2e-smoke-tests-android.yml +++ b/.github/workflows/run-e2e-smoke-tests-android.yml @@ -13,6 +13,11 @@ on: required: false type: string default: '' + runner_provider: + description: Runner provider forwarded from the caller + required: false + type: string + default: current permissions: contents: read @@ -33,6 +38,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 2 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit stake-android-smoke: @@ -49,6 +55,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 1 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit perps-android-smoke: @@ -65,6 +72,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 1 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit wallet-platform-android-smoke: @@ -81,6 +89,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 3 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit identity-android-smoke: @@ -97,6 +106,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 2 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit accounts-android-smoke: @@ -113,6 +123,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 1 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit network-abstraction-android-smoke: @@ -129,6 +140,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 2 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit network-expansion-android-smoke: @@ -145,6 +157,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 2 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit confirmations-android-smoke: @@ -161,6 +174,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 4 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit prediction-market-android-smoke: @@ -177,6 +191,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 1 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit money-android-smoke: @@ -193,6 +208,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 1 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit multichain-api-android-smoke: @@ -209,6 +225,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 1 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit seedless-onboarding-android-smoke: @@ -225,6 +242,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 1 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit browser-android-smoke: @@ -241,6 +259,7 @@ jobs: split_number: ${{ matrix.split }} total_splits: 1 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit snaps-android-smoke: @@ -257,11 +276,12 @@ jobs: split_number: ${{ matrix.split }} total_splits: 4 changed_files: ${{ inputs.changed_files }} + runner_provider: ${{ inputs.runner_provider }} secrets: inherit report-android-smoke-tests: name: Report Android Smoke Tests - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ !cancelled() && inputs.selected_tags != '[]' && inputs.selected_tags != '["FlaskBuildTests"]' }} needs: - swap-android-smoke diff --git a/.github/workflows/run-e2e-smoke-tests-ios.yml b/.github/workflows/run-e2e-smoke-tests-ios.yml index 05b82e8bf05..19fb3ee5d7c 100644 --- a/.github/workflows/run-e2e-smoke-tests-ios.yml +++ b/.github/workflows/run-e2e-smoke-tests-ios.yml @@ -13,6 +13,11 @@ on: required: false type: string default: '' + runner_provider: + description: Runner provider forwarded from the caller + required: false + type: string + default: current permissions: contents: read @@ -35,6 +40,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit swap-ios-smoke: @@ -53,6 +59,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit stake-ios-smoke: @@ -71,6 +78,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit perps-ios-smoke: @@ -89,6 +97,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit wallet-platform-ios-smoke: @@ -107,6 +116,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit identity-ios-smoke: @@ -125,6 +135,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit accounts-ios-smoke: @@ -143,6 +154,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit network-abstraction-ios-smoke: @@ -161,6 +173,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit network-expansion-ios-smoke: @@ -179,6 +192,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit prediction-market-ios-smoke: @@ -197,6 +211,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit money-ios-smoke: @@ -215,6 +230,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit multichain-api-ios-smoke: @@ -233,6 +249,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit seedless-onboarding-ios-smoke: @@ -251,6 +268,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit browser-ios-smoke: @@ -269,6 +287,7 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit snaps-ios-smoke: @@ -287,11 +306,12 @@ jobs: changed_files: ${{ inputs.changed_files }} build_type: 'main' metamask_environment: 'qa' + runner_provider: ${{ inputs.runner_provider }} secrets: inherit report-ios-smoke-tests: name: Report iOS Smoke Tests - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }} if: ${{ !cancelled() && inputs.selected_tags != '[]' && inputs.selected_tags != '["FlaskBuildTests"]' }} needs: - confirmations-ios-smoke diff --git a/.github/workflows/run-e2e-workflow.yml b/.github/workflows/run-e2e-workflow.yml index 1cc5a894949..efea199b9dd 100644 --- a/.github/workflows/run-e2e-workflow.yml +++ b/.github/workflows/run-e2e-workflow.yml @@ -53,11 +53,16 @@ on: required: false type: string default: 'main-' + runner_provider: + description: Runner provider forwarded from the caller + required: false + type: string + default: current jobs: test-e2e-mobile: name: ${{ inputs.test-suite-name }} - runs-on: ${{ inputs.platform == 'ios' && 'ghcr.io/cirruslabs/macos-runner:tahoe' || 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg' }} + runs-on: ${{ inputs.runner_provider == 'namespace' && (inputs.platform == 'ios' && 'namespace-profile-metamask-ios-e2e' || 'namespace-profile-metamask-android-build') || (inputs.platform == 'ios' && 'ghcr.io/cirruslabs/macos-runner:tahoe' || 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg') }} outputs: apk-target-path: ${{ steps.determine-target-paths.outputs.apk-target-path }} test-apk-target-path: ${{ steps.determine-target-paths.outputs.test-apk-target-path }} diff --git a/.github/workflows/setup-node-modules.yml b/.github/workflows/setup-node-modules.yml index f87f6145ee4..d63574630f1 100644 --- a/.github/workflows/setup-node-modules.yml +++ b/.github/workflows/setup-node-modules.yml @@ -58,6 +58,11 @@ on: required: false type: number default: 1 + runner_provider: + description: Runner provider forwarded from the caller + required: false + type: string + default: current outputs: artifact-name: description: 'The actual artifact name used' @@ -71,7 +76,7 @@ jobs: setup: name: Setup Node Modules ${{ inputs.platform && format('({0})', inputs.platform) || '' }} # Platform-specific runner to match consumer (build needs same OS for native deps/symlinks) - runs-on: ${{ inputs.platform == 'ios' && 'ghcr.io/cirruslabs/macos-runner:tahoe-xl' || (inputs.platform == 'android' && 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg' || 'ubuntu-latest') }} + runs-on: ${{ inputs.runner_provider == 'namespace' && (inputs.platform == 'ios' && 'namespace-profile-metamask-ios-build' || (inputs.platform == 'android' && 'namespace-profile-metamask-android-build' || 'namespace-profile-metamask-ci-linux')) || (inputs.platform == 'ios' && 'ghcr.io/cirruslabs/macos-runner:tahoe-xl' || (inputs.platform == 'android' && 'ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg' || 'ubuntu-latest')) }} permissions: contents: read id-token: write diff --git a/.js.env.example b/.js.env.example index 5cfe0c32564..67940dfec48 100644 --- a/.js.env.example +++ b/.js.env.example @@ -154,20 +154,6 @@ export E2E_MOCK_OAUTH='false' export E2E_BYOA_AUTH_SECRET='' export E2E_MOCK_OAUTH_EMAIL='' -# env for seedless onboarding main-dev -export ANDROID_APPLE_CLIENT_ID='io.metamask.appleloginclient.dev' -export ANDROID_GOOGLE_CLIENT_ID='8615965109465-i8oeh9kuvl1n6lk1ffkobpvth27bmi41.apps.googleusercontent.com' -export ANDROID_GOOGLE_SERVER_CLIENT_ID='615965109465-i8oeh9kuvl1n6lk1ffkobpvth27bmi41.apps.googleusercontent.com' -export IOS_GOOGLE_CLIENT_ID='615965109465-h6tp2h3crls6hbggispcgovbvk4vabu3.apps.googleusercontent.com' -export IOS_GOOGLE_REDIRECT_URI='com.googleusercontent.apps.615965109465-h6tp2h3crls6hbggispcgovbvk4vabu3:/oauth2redirect/google' - -# env for seedless onboarding flask-dev -#export ANDROID_APPLE_CLIENT_ID="io.metamask.appleloginclient.flask.dev" -#export ANDROID_GOOGLE_CLIENT_ID="615965109465-ab20kuqbls6fj5s50fvmvbnket8nv1sh.apps.googleusercontent.com" -#export ANDROID_GOOGLE_SERVER_CLIENT_ID="615965109465-ab20kuqbls6fj5s50fvmvbnket8nv1sh.apps.googleusercontent.com" -#export IOS_GOOGLE_CLIENT_ID="615965109465-89b2lmqgm5ka8j8t403qhooouv57id9b.apps.googleusercontent.com" -#export IOS_GOOGLE_REDIRECT_URI="com.googleusercontent.apps.615965109465-89b2lmqgm5ka8j8t403qhooouv57id9b:/oauth2redirect/google" - # Enable send re-designs locally export MM_SEND_REDESIGN_ENABLED="true" diff --git a/app/components/UI/Assets/components/AssetLogo/AssetLogo.test.tsx b/app/components/UI/Assets/components/AssetLogo/AssetLogo.test.tsx index d0005df6b91..fd0b6752538 100644 --- a/app/components/UI/Assets/components/AssetLogo/AssetLogo.test.tsx +++ b/app/components/UI/Assets/components/AssetLogo/AssetLogo.test.tsx @@ -4,8 +4,15 @@ import AssetLogo from './AssetLogo'; import AvatarToken from '../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; import NetworkAssetLogo from '../../../NetworkAssetLogo'; +import { getAssetImageUrl } from '../../../Bridge/hooks/useAssetMetadata/utils'; + +jest.mock('../../../Bridge/hooks/useAssetMetadata/utils', () => ({ + getAssetImageUrl: jest.fn(), +})); describe('AssetLogo', () => { + const mockedGetAssetImageUrl = jest.mocked(getAssetImageUrl); + const mockState = { engine: { backgroundState: { @@ -19,6 +26,10 @@ describe('AssetLogo', () => { }, }; + beforeEach(() => { + mockedGetAssetImageUrl.mockReset(); + }); + it('renders asset logo for non-native assets', () => { const asset = { decimals: 18, @@ -87,4 +98,77 @@ describe('AssetLogo', () => { }), ); }); + + it('uses fallback image URL when image is an empty string', () => { + const fallbackImageUrl = 'https://example.com/fallback.png'; + mockedGetAssetImageUrl.mockReturnValue(fallbackImageUrl); + + const asset = { + decimals: 18, + address: '0x456', + chainId: '0x1', + symbol: 'TEST', + name: 'Test Token', + balance: '1.23', + balanceFiat: '$123.00', + isNative: false, + isETH: false, + image: '', + logo: 'https://example.com/logo.png', + aggregators: [], + }; + + const { UNSAFE_getByType } = renderWithProvider( + , + { + state: mockState, + }, + ); + + expect(mockedGetAssetImageUrl).toHaveBeenCalledWith('0x456', '0x1'); + + const assetAvatar = UNSAFE_getByType(AvatarToken); + expect(assetAvatar.props).toStrictEqual({ + name: 'TEST', + imageSource: { + uri: fallbackImageUrl, + }, + size: AvatarSize.Lg, + }); + }); + + it('does not call fallback image utility for unsupported chainId', () => { + const asset = { + decimals: 18, + address: '0x456', + chainId: '1', + symbol: 'TEST', + name: 'Test Token', + balance: '1.23', + balanceFiat: '$123.00', + isNative: false, + isETH: false, + image: '', + logo: 'https://example.com/logo.png', + aggregators: [], + }; + + const { UNSAFE_getByType } = renderWithProvider( + , + { + state: mockState, + }, + ); + + expect(mockedGetAssetImageUrl).not.toHaveBeenCalled(); + + const assetAvatar = UNSAFE_getByType(AvatarToken); + expect(assetAvatar.props).toStrictEqual({ + name: 'TEST', + imageSource: { + uri: undefined, + }, + size: AvatarSize.Lg, + }); + }); }); diff --git a/app/components/UI/Assets/components/AssetLogo/AssetLogo.tsx b/app/components/UI/Assets/components/AssetLogo/AssetLogo.tsx index 09a82712f0f..20f1078c604 100644 --- a/app/components/UI/Assets/components/AssetLogo/AssetLogo.tsx +++ b/app/components/UI/Assets/components/AssetLogo/AssetLogo.tsx @@ -1,11 +1,25 @@ import React from 'react'; +import { isCaipChainId, isStrictHexString } from '@metamask/utils'; import { AvatarSize } from '../../../../../component-library/components/Avatars/Avatar'; import NetworkAssetLogo from '../../../NetworkAssetLogo'; import AvatarToken from '../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken'; import { TokenI } from '../../../Tokens/types'; import { useStyles } from '../../../../../component-library/hooks/useStyles'; +import { getAssetImageUrl } from '../../../Bridge/hooks/useAssetMetadata/utils'; import styleSheet from './AssetLogo.styles'; +const getFallbackAssetImageUrl = (asset: TokenI): string | undefined => { + if (!asset.chainId) { + return undefined; + } + + if (!isCaipChainId(asset.chainId) && !isStrictHexString(asset.chainId)) { + return undefined; + } + + return getAssetImageUrl(asset.address, asset.chainId); +}; + const AssetLogo = ({ asset }: { asset: TokenI }) => { const { styles } = useStyles(styleSheet, {}); @@ -22,10 +36,12 @@ const AssetLogo = ({ asset }: { asset: TokenI }) => { ); } + const imageUri = asset.image || getFallbackAssetImageUrl(asset); + return ( ); diff --git a/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.view.test.tsx b/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.view.test.tsx new file mode 100644 index 00000000000..b0c917f077b --- /dev/null +++ b/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.view.test.tsx @@ -0,0 +1,139 @@ +import '../../../../tests/component-view/mocks'; +import React from 'react'; +import { FlatList } from 'react-native'; +import { act, fireEvent } from '@testing-library/react-native'; +import type { GroupedDeFiPositions } from '@metamask/assets-controllers'; + +import DeFiProtocolPositionDetails, { + DEFI_PROTOCOL_POSITION_DETAILS_BALANCE_TEST_ID, +} from './DeFiProtocolPositionDetails'; +import { WalletViewSelectorsIDs } from '../../Views/Wallet/WalletView.testIds'; +import { renderComponentViewScreen } from '../../../../tests/component-view/render'; +import { describeForPlatforms } from '../../../../tests/component-view/platform'; +import { backgroundState } from '../../../util/test/initial-root-state'; + +/** + * Mirrors smoke `view-defi-details`: tap Aave V3 → read-only position details with + * Supplied tokens and fiat balances (no transaction). + */ +const aaveV3PositionAggregate: GroupedDeFiPositions['protocols'][number] = { + protocolDetails: { + name: 'Aave V3', + iconUrl: '', + }, + aggregatedMarketValue: 14.74, + positionTypes: { + supply: { + aggregatedMarketValue: 14.74, + positions: [ + [ + { + address: '0x23878914efe38d27c4d67ab83ed1b93a74d4086a', + name: 'Aave Ethereum USDT', + symbol: 'aEthUSDT', + decimals: 6, + balance: 0.300112, + balanceRaw: '300112', + marketValue: 14.74, + type: 'protocol', + tokens: [ + { + address: '0xdac17f958d2ee523a2206206994597c13d831ec7', + name: 'Tether USD', + symbol: 'USDT', + decimals: 6, + balance: 0.300112, + balanceRaw: '300112', + marketValue: 14.74, + price: 0.99994, + type: 'underlying', + iconUrl: '', + }, + ], + }, + ], + [ + { + address: '0xfa1fdbbd71b0aa16162d76914d69cd8cb3ef92da', + name: 'Aave Ethereum Lido WETH', + symbol: 'aEthLidoWETH', + decimals: 18, + balance: 1e-5, + balanceRaw: '9030902767263172', + marketValue: 0.3, + type: 'protocol', + tokens: [ + { + address: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + balance: 1e-5, + balanceRaw: '10000000000000', + marketValue: 0.3, + price: 1599.45, + type: 'underlying', + iconUrl: '', + }, + ], + }, + ], + ], + }, + }, +}; + +const defiDetailsState = { + engine: { + backgroundState: { + ...backgroundState, + PreferencesController: { + ...backgroundState.PreferencesController, + privacyMode: false, + }, + }, + }, +}; + +describeForPlatforms('DeFi position details (read-only)', () => { + it('shows Aave V3 supplied assets with token symbols and fiat amounts', () => { + const { getByTestId, getByText, getAllByText, UNSAFE_getByType } = + renderComponentViewScreen( + DeFiProtocolPositionDetails, + { name: 'DeFiProtocolPositionDetails' }, + { state: defiDetailsState }, + { + protocolAggregate: aaveV3PositionAggregate, + networkIconAvatar: undefined, + }, + ); + + expect( + getByTestId(WalletViewSelectorsIDs.DEFI_POSITIONS_DETAILS_CONTAINER), + ).toBeOnTheScreen(); + + expect(getByText('Aave V3')).toBeOnTheScreen(); + expect( + getByTestId(DEFI_PROTOCOL_POSITION_DETAILS_BALANCE_TEST_ID), + ).toHaveTextContent('$14.74'); + + // Smoke parity for details checks: Supplied + USDT + WETH + $14.74 + $0.30. + expect(getAllByText('Supplied')).toHaveLength(2); + expect(getAllByText('USDT')).toHaveLength(1); + expect(getAllByText('$14.74').length).toBeGreaterThanOrEqual(2); + + const list = UNSAFE_getByType(FlatList); + act(() => { + fireEvent.scroll(list, { + nativeEvent: { + contentOffset: { y: 150 }, + contentSize: { height: 500, width: 400 }, + layoutMeasurement: { height: 400, width: 400 }, + }, + }); + }); + + expect(getByText('WETH')).toBeOnTheScreen(); + expect(getByText('$0.30')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.view.test.tsx b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.view.test.tsx new file mode 100644 index 00000000000..45ad955674f --- /dev/null +++ b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.view.test.tsx @@ -0,0 +1,182 @@ +/** + * Component view tests for token (non-Perps) MarketInsightsView: content, + * swap/buy navigation, trend sources sheet, thumbs up. + * Mirrors smoke: tests/smoke/assets/market-insights/view-market-insights.spec.ts + * (cases 7, 10, 11, 12). Entry card visibility cases (8, 9) are covered by + * AssetOverviewContent.view.test.tsx. + * Run: yarn test:view:one MarketInsightsView.view.test.tsx + */ +import '../../../../../../tests/component-view/mocks'; +import { fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { + MOCK_PERPS_MARKET_INSIGHTS_REPORT, + setupMarketInsightsEngineMock, +} from '../../../../../../tests/component-view/fixtures/perpsMarketInsights'; +import { renderMarketInsightsViewWithNavigation } from '../../../../../../tests/component-view/renderers/marketInsights'; +import { describeForPlatforms } from '../../../../../../tests/component-view/platform'; +import { BuildQuoteSelectors } from '../../../Ramp/Aggregator/Views/BuildQuote/BuildQuote.testIds'; +import { MarketInsightsSelectorsIDs } from '../../MarketInsights.testIds'; +import { analytics } from '../../../../../util/analytics/analytics'; +import { resetFeedbackCache } from './MarketInsightsView'; + +const ETH_MAINNET_ROUTE_PARAMS = { + assetSymbol: 'ETH', + assetIdentifier: 'eip155:1/slip44:60', + tokenAddress: '0x0000000000000000000000000000000000000000', + tokenDecimals: 18, + tokenName: 'Ethereum', + tokenChainId: CHAIN_IDS.MAINNET, + token: { + address: '0x123', + symbol: 'ETH', + decimals: 18, + name: 'Ethereum', + chainId: '0x1', + image: 'https://example.com/eth.png', + balance: '0', + logo: undefined, + }, +}; + +describeForPlatforms('MarketInsightsView (token flow)', () => { + beforeEach(() => { + setupMarketInsightsEngineMock(MOCK_PERPS_MARKET_INSIGHTS_REPORT); + }); + + afterEach(() => { + resetFeedbackCache(); + }); + + it('displays market insights content and navigates to swap', async () => { + renderMarketInsightsViewWithNavigation({ + initialParams: ETH_MAINNET_ROUTE_PARAMS, + overrides: { + engine: { + backgroundState: { + TokensController: { + allTokens: { + '0x1': { + '0x0000000000000000000000000000000000000001': [ + { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + decimals: 6, + symbol: 'USDC', + name: 'USD Coin', + image: '', + }, + ], + }, + }, + allIgnoredTokens: {}, + }, + TokenBalancesController: { + tokenBalances: { + '0x0000000000000000000000000000000000000001': { + '0x1': { + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': '0x3b9aca00', + }, + }, + }, + }, + TokenRatesController: { + marketData: { + '0x1': { + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { + tokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + currency: 'ETH', + price: 0.0005, + }, + }, + }, + }, + }, + }, + }, + }); + + expect( + await screen.findByTestId(MarketInsightsSelectorsIDs.VIEW_CONTAINER), + ).toBeOnTheScreen(); + expect( + await screen.findByText( + 'Ethereum shows strong momentum amid institutional demand', + ), + ).toBeOnTheScreen(); + expect( + await screen.findByText( + 'Ethereum continues to attract institutional interest with increasing on-chain activity and a healthy DeFi ecosystem.', + ), + ).toBeOnTheScreen(); + expect(await screen.findByText('Institutional Adoption')).toBeOnTheScreen(); + expect(await screen.findByText('DeFi Activity Surge')).toBeOnTheScreen(); + + fireEvent.press( + await screen.findByTestId(MarketInsightsSelectorsIDs.SWAP_BUTTON), + ); + + expect(await screen.findByTestId('route-BridgeView')).toBeOnTheScreen(); + }); + + it('navigates to buy flow when tapping Buy button', async () => { + renderMarketInsightsViewWithNavigation({ + initialParams: ETH_MAINNET_ROUTE_PARAMS, + }); + + await screen.findByTestId(MarketInsightsSelectorsIDs.VIEW_CONTAINER); + + fireEvent.press( + await screen.findByTestId(MarketInsightsSelectorsIDs.BUY_BUTTON), + ); + + expect( + await screen.findByTestId(BuildQuoteSelectors.CONTINUE_BUTTON), + ).toBeOnTheScreen(); + }); + + it('shows sources bottom sheet when tapping a trend item', async () => { + renderMarketInsightsViewWithNavigation({ + initialParams: ETH_MAINNET_ROUTE_PARAMS, + }); + + await screen.findByTestId(MarketInsightsSelectorsIDs.VIEW_CONTAINER); + + fireEvent.press( + await screen.findByTestId(`${MarketInsightsSelectorsIDs.TREND_ITEM}-0`), + ); + + expect( + await screen.findByText('Spot Ethereum ETFs See Record Weekly Inflows'), + ).toBeOnTheScreen(); + }); + + it('can tap thumbs up feedback button', async () => { + const trackEventSpy = jest.spyOn(analytics, 'trackEvent'); + try { + renderMarketInsightsViewWithNavigation({ + initialParams: ETH_MAINNET_ROUTE_PARAMS, + }); + + await screen.findByTestId(MarketInsightsSelectorsIDs.VIEW_CONTAINER); + + const thumbsUp = await screen.findByTestId( + MarketInsightsSelectorsIDs.THUMBS_UP_BUTTON, + ); + trackEventSpy.mockClear(); + fireEvent.press(thumbsUp); + + await waitFor(() => { + expect(trackEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Market Insights Interaction', + properties: expect.objectContaining({ + interaction_type: 'thumbs_up', + }), + }), + ); + }); + } finally { + trackEventSpy.mockRestore(); + } + }); +}); diff --git a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx index e0018003b4b..d78f8e0b638 100644 --- a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx +++ b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.test.tsx @@ -149,7 +149,7 @@ describe('MoneyAddMoneySheet', () => { ); expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1); - expect(mockInitiateDeposit).toHaveBeenCalledWith(BigInt(0)); + expect(mockInitiateDeposit).toHaveBeenCalledWith(); }); it('closes the sheet when Move mUSD is pressed (interim, no flow wired yet)', () => { diff --git a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx index c62a4bbda98..824a3a1fc1d 100644 --- a/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx +++ b/app/components/UI/Money/components/MoneyAddMoneySheet/MoneyAddMoneySheet.tsx @@ -53,12 +53,9 @@ const MoneyAddMoneySheet: React.FC = () => { navigation.goBack(); }, [navigation]); - // TODO(MUSD-478/MUSD-516): point to the MM Pay "Add money" amount-entry - // screen (Figma 2547:8887). Amount is collected by the MM Pay UI; the - // placeholder 0n keeps the deposit pipeline wired until that lands. const handleConvertCrypto = useCallback(() => { closeAndNavigate(() => { - initiateDeposit(BigInt(0)).catch(() => undefined); + initiateDeposit().catch(() => undefined); }); }, [closeAndNavigate, initiateDeposit]); diff --git a/app/components/UI/Money/hooks/useMoneyAccount.test.ts b/app/components/UI/Money/hooks/useMoneyAccount.test.ts index a0d33349326..a87f90f67a6 100644 --- a/app/components/UI/Money/hooks/useMoneyAccount.test.ts +++ b/app/components/UI/Money/hooks/useMoneyAccount.test.ts @@ -158,7 +158,7 @@ describe('useMoneyAccountDeposit', () => { await expect( act(async () => { - await result.current.initiateDeposit(BigInt(1_000_000)); + await result.current.initiateDeposit(); }), ).rejects.toThrow('Missing vault config'); @@ -173,7 +173,7 @@ describe('useMoneyAccountDeposit', () => { await expect( act(async () => { - await result.current.initiateDeposit(BigInt(1_000_000)); + await result.current.initiateDeposit(); }), ).rejects.toThrow('No provider available'); @@ -184,12 +184,12 @@ describe('useMoneyAccountDeposit', () => { const { result } = renderHook(() => useMoneyAccountDeposit()); await act(async () => { - await result.current.initiateDeposit(BigInt(1_000_000)); + await result.current.initiateDeposit(); }); expect(mockBuildDepositBatch).toHaveBeenCalledWith( expect.objectContaining({ - amount: BigInt(1_000_000), + amount: BigInt(0), chainId: MOCK_VAULT_CONFIG.chainId, boringVault: MOCK_VAULT_CONFIG.boringVault, }), @@ -220,7 +220,7 @@ describe('useMoneyAccountDeposit', () => { let caught: Error | undefined; await act(async () => { try { - await result.current.initiateDeposit(BigInt(1_000_000)); + await result.current.initiateDeposit(); } catch (error) { caught = error as Error; } @@ -242,7 +242,7 @@ describe('useMoneyAccountDeposit', () => { await expect( act(async () => { - await result.current.initiateDeposit(BigInt(1_000_000)); + await result.current.initiateDeposit(); }), ).rejects.toThrow('Network client not found'); diff --git a/app/components/UI/Money/hooks/useMoneyAccount.ts b/app/components/UI/Money/hooks/useMoneyAccount.ts index 18ad80bc3e7..6eb6b77f74c 100644 --- a/app/components/UI/Money/hooks/useMoneyAccount.ts +++ b/app/components/UI/Money/hooks/useMoneyAccount.ts @@ -12,6 +12,7 @@ import { selectPrimaryMoneyAccount } from '../../../../selectors/moneyAccountCon import { buildMoneyAccountDepositBatch, buildMoneyAccountWithdraw, + getMoneyAccountDepositAssetAddress, } from '../utils/moneyAccountTransactions'; import { getProviderByChainId } from '../../../../util/notifications/methods/common'; import Logger from '../../../../util/Logger'; @@ -36,74 +37,71 @@ export function useMoneyAccountDeposit() { const primaryMoneyAccount = useSelector(selectPrimaryMoneyAccount); const { navigateToConfirmation } = useConfirmNavigation(); - const initiateDeposit = useCallback( - // TODO: remove the account parameter and instead of directly building approve and deposit transactions - // we need to implemend a hook from `addTransactionBatch` from which we can get the user inputed amount - // and then use that to build the approve and deposit transactions. This is because user inputs the amount - // in the MM pay UI and we need to use that amount. - async (amount: bigint) => { - if (!vaultConfig) { - throw new Error(`${LOG_TAG} Missing vault config`); - } - if (!primaryMoneyAccount?.address) { - throw new Error(`${LOG_TAG} Missing money account address`); - } - - const { - chainId, - boringVault, - tellerAddress, - accountantAddress, - lensAddress, - } = vaultConfig; - - const chainIdHex = chainId as Hex; - const provider = getProviderByChainId(chainIdHex); - if (!provider) { - throw new Error( - `${LOG_TAG} No provider available for chain ${chainId}`, - ); - } - - const networkClientId = resolveNetworkClientId(chainIdHex); - - // TODO: as mentioned above this should move into hook from `addTransactionBatch`. - const { approveTx, depositTx } = await buildMoneyAccountDepositBatch({ - amount, - chainId: chainIdHex, - boringVault, - tellerAddress, - accountantAddress, - lensAddress, - provider, - }); - - // Navigate early for better UX; recover on failure below. - navigateToConfirmation({ - loader: ConfirmationLoader.CustomAmount, - stack: Routes.MONEY.ROOT, + const initiateDeposit = useCallback(async () => { + if (!vaultConfig) { + throw new Error(`${LOG_TAG} Missing vault config`); + } + if (!primaryMoneyAccount?.address) { + throw new Error(`${LOG_TAG} Missing money account address`); + } + + const { + chainId, + boringVault, + tellerAddress, + accountantAddress, + lensAddress, + } = vaultConfig; + + const chainIdHex = chainId as Hex; + const provider = getProviderByChainId(chainIdHex); + if (!provider) { + throw new Error(`${LOG_TAG} No provider available for chain ${chainId}`); + } + + const networkClientId = resolveNetworkClientId(chainIdHex); + + const { approveTx, depositTx } = await buildMoneyAccountDepositBatch({ + amount: BigInt(0), + chainId: chainIdHex, + boringVault, + tellerAddress, + accountantAddress, + lensAddress, + provider, + }); + + // Navigate early for better UX; recover on failure below. + navigateToConfirmation({ + loader: ConfirmationLoader.CustomAmount, + stack: Routes.MONEY.ROOT, + }); + + try { + // We only set the transaction from the money account perspective. + // MM Pay selects the user's account and moves funds to the money account, + // so `from` must be the money account and `networkClientId` its chain. + await addTransactionBatch({ + from: primaryMoneyAccount.address as Hex, + networkClientId, + origin: ORIGIN_METAMASK, + disableHook: true, + disableSequential: true, + transactions: [approveTx, depositTx], + requiredAssets: [ + { + address: getMoneyAccountDepositAssetAddress(chainIdHex), + amount: '0x0' as Hex, + standard: 'erc20', + }, + ], }); - - try { - // We only set the transaction from the money account perspective. - // MM Pay selects the user's account and moves funds to the money account, - // so `from` must be the money account and `networkClientId` its chain. - await addTransactionBatch({ - from: primaryMoneyAccount.address as Hex, - networkClientId, - origin: ORIGIN_METAMASK, - disableHook: true, - disableSequential: true, - transactions: [approveTx, depositTx], - }); - } catch (error) { - Logger.error(error as Error, `${LOG_TAG} Deposit transaction failed`); - // Rethrow so the caller can roll back navigation / surface a toast. - throw error; - } - }, - [navigateToConfirmation, primaryMoneyAccount, vaultConfig], - ); + } catch (error) { + Logger.error(error as Error, `${LOG_TAG} Deposit transaction failed`); + // Rethrow so the caller can roll back navigation / surface a toast. + throw error; + } + }, [navigateToConfirmation, primaryMoneyAccount, vaultConfig]); return { initiateDeposit }; } diff --git a/app/components/UI/Money/utils/moneyAccountTransactions.test.ts b/app/components/UI/Money/utils/moneyAccountTransactions.test.ts index 194ff65e887..7a1ce5f6d6a 100644 --- a/app/components/UI/Money/utils/moneyAccountTransactions.test.ts +++ b/app/components/UI/Money/utils/moneyAccountTransactions.test.ts @@ -14,6 +14,12 @@ import { updateMoneyAccountDepositTokenAmount, updateMoneyAccountWithdrawTokenAmount, } from './moneyAccountTransactions'; +import { + type MoneyAccountVaultConfig, + selectMoneyAccountVaultConfig, +} from '../../../../selectors/featureFlagController/moneyAccount'; +import { getProviderByChainId } from '../../../../util/notifications/methods/common'; +import ReduxService from '../../../../core/redux'; jest.mock('../../Earn/constants/musd', () => ({ MUSD_TOKEN_ADDRESS_BY_CHAIN: {} as Record, @@ -26,6 +32,10 @@ jest.mock('../../../../core/AppConstants', () => ({ }, })); +jest.mock('../../../../util/notifications/methods/common'); +jest.mock('../../../../core/redux'); +jest.mock('../../../../selectors/featureFlagController/moneyAccount'); + const mockPreviewDeposit = jest.fn(); const mockGetRate = jest.fn(); @@ -50,6 +60,11 @@ jest.mock('ethers', () => { }; }); +const mockGetProviderByChainId = jest.mocked(getProviderByChainId); +const mockSelectMoneyAccountVaultConfig = jest.mocked( + selectMoneyAccountVaultConfig, +); + const MOCK_CHAIN_ID = '0x1' as Hex; const MOCK_MUSD_ADDRESS = '0xaca92e438df0b2401ff60da7e4337b687a2435da' as Hex; const MOCK_BORING_VAULT = '0xB5F07d769dD60fE54c97dd53101181073DDf21b2' as Hex; @@ -59,6 +74,14 @@ const MOCK_LENS = '0x846a7832022350434B5cC006d07cc9c782469660' as Hex; const MOCK_TO_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678' as Hex; const MOCK_PROVIDER = {} as ethers.providers.Provider; +const MOCK_VAULT_CONFIG: MoneyAccountVaultConfig = { + chainId: '0xa4b1', + boringVault: MOCK_BORING_VAULT, + tellerAddress: MOCK_TELLER, + accountantAddress: MOCK_ACCOUNTANT, + lensAddress: MOCK_LENS, +}; + describe('moneyAccountTransactions', () => { beforeEach(() => { jest.clearAllMocks(); @@ -178,28 +201,83 @@ describe('moneyAccountTransactions', () => { }); describe('updateMoneyAccountDepositTokenAmount', () => { - it('resolves to an empty array (stub implementation)', async () => { - const transactionMeta = { - id: 'tx-1', - nestedTransactions: [], - } as unknown as TransactionMeta; + const mockTransactionMeta = { + id: 'tx-1', + chainId: MOCK_VAULT_CONFIG.chainId as Hex, + } as unknown as TransactionMeta; + + beforeEach(() => { + // Default: vault config present, provider present + mockGetProviderByChainId.mockReturnValue(MOCK_PROVIDER as never); + mockSelectMoneyAccountVaultConfig.mockReturnValue(MOCK_VAULT_CONFIG); + ( + jest.mocked(ReduxService) as unknown as { + store: { getState: jest.Mock }; + } + ).store = { getState: jest.fn().mockReturnValue({}) }; + }); - await expect( - updateMoneyAccountDepositTokenAmount(transactionMeta, '1.23'), - ).resolves.toEqual([]); + it('returns indexed approve and deposit calls for a valid amount', async () => { + mockPreviewDeposit.mockResolvedValue(ethers.BigNumber.from('1000000')); + + const result = await updateMoneyAccountDepositTokenAmount( + mockTransactionMeta, + '1.0', + ); + + expect(result).toHaveLength(2); + expect(result[0].nestedTransactionIndex).toBe(0); + expect(result[0].transactionData).toMatch(/^0x/); + expect(result[1].nestedTransactionIndex).toBe(1); + expect(result[1].transactionData).toMatch(/^0x/); }); - it('resolves to an array regardless of transactionMeta shape', async () => { - const transactionMeta = { - id: 'tx-2', - } as unknown as TransactionMeta; + it('calls previewDeposit with the converted amount', async () => { + mockPreviewDeposit.mockResolvedValue(ethers.BigNumber.from('1000000')); + + await updateMoneyAccountDepositTokenAmount(mockTransactionMeta, '1.0'); + + // 1.0 USDC with 6 decimals = 1_000_000 + expect(mockPreviewDeposit).toHaveBeenCalledWith( + expect.any(String), + '1000000', + MOCK_VAULT_CONFIG.boringVault, + MOCK_VAULT_CONFIG.accountantAddress, + ); + }); + + it('returns [] when vault config is missing', async () => { + mockSelectMoneyAccountVaultConfig.mockReturnValue(undefined); const result = await updateMoneyAccountDepositTokenAmount( - transactionMeta, - '1.23', + mockTransactionMeta, + '1.0', ); - expect(Array.isArray(result)).toBe(true); + expect(result).toEqual([]); + expect(mockGetProviderByChainId).not.toHaveBeenCalled(); + expect(mockPreviewDeposit).not.toHaveBeenCalled(); + }); + + it('returns [] when provider is missing', async () => { + mockGetProviderByChainId.mockReturnValue(undefined as never); + + const result = await updateMoneyAccountDepositTokenAmount( + mockTransactionMeta, + '1.0', + ); + + expect(result).toEqual([]); + expect(mockPreviewDeposit).not.toHaveBeenCalled(); + }); + + it('rejects when previewDeposit fails so the dispatcher can log the error', async () => { + const rpcError = new Error('RPC connection refused'); + mockPreviewDeposit.mockRejectedValue(rpcError); + + await expect( + updateMoneyAccountDepositTokenAmount(mockTransactionMeta, '1.0'), + ).rejects.toThrow('RPC connection refused'); }); }); diff --git a/app/components/UI/Money/utils/moneyAccountTransactions.ts b/app/components/UI/Money/utils/moneyAccountTransactions.ts index 9dc8f777a02..76fef2d8388 100644 --- a/app/components/UI/Money/utils/moneyAccountTransactions.ts +++ b/app/components/UI/Money/utils/moneyAccountTransactions.ts @@ -1,4 +1,5 @@ import { ethers } from 'ethers'; +import { BigNumber } from 'bignumber.js'; import { TransactionMeta, TransactionType, @@ -8,6 +9,11 @@ import { Hex } from '@metamask/utils'; import { UpdateTransactionPayAmountCall } from '../../../Views/confirmations/types/transactions'; import { MUSD_TOKEN_ADDRESS_BY_CHAIN } from '../../Earn/constants/musd'; import AppConstants from '../../../../core/AppConstants'; +import { calcTokenValue } from '../../../../util/transactions'; +import { getProviderByChainId } from '../../../../util/notifications/methods/common'; +import { selectMoneyAccountVaultConfig } from '../../../../selectors/featureFlagController/moneyAccount'; +import ReduxService from '../../../../core/redux'; +import type { RootState } from '../../../../reducers'; const LENS_ABI = [ 'function previewDeposit(address depositAsset, uint256 depositAmount, address boringVault, address accountant) view returns (uint256 shares)', @@ -95,6 +101,24 @@ function buildDepositData( ]) as Hex; } +/** + * Single source of truth for the deposit asset so both calldata encoding + * (`buildMoneyAccountDepositBatch`) and Pay's `requiredAssets` agree. + * @param _chainId - The chain ID to get the deposit asset address for. + * @returns The deposit asset address for the given chain ID. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function getMoneyAccountDepositAssetAddress(chainId: Hex): Hex { + // TODO: uncomment when mUSD is deployed + // const musdAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[_chainId]; + // if (!musdAddress) { + // throw new Error(`mUSD not deployed on chain ${_chainId}`); + // } + // return musdAddress; + // TODO: remove when mUSD is deployed - temporarily hardcoded USDC + return '0xaf88d065e77c8cC2239327C5EDb3A432268e5831'; +} + export interface MoneyAccountDepositBatchResult { approveTx: MoneyAccountTxParams; depositTx: MoneyAccountTxParams; @@ -125,24 +149,23 @@ export async function buildMoneyAccountDepositBatch({ lensAddress: string; provider: ethers.providers.Provider; }): Promise { - // TODO: uncomment when mUSD is deployed - // const musdAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[chainId]; - // if (!musdAddress) { - // throw new Error(`mUSD not deployed on chain ${chainId}`); - // } - // TODO: remove when mUSD is deployed - temporarily hardcoded USDC - const musdAddress = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831'; + const musdAddress = getMoneyAccountDepositAssetAddress(chainId); - const expectedShares = await getExpectedDepositShares({ - lensAddress, - boringVault, - accountantAddress, - musdAddress, - amount, - provider, - }); + // Skip the RPC call for zero-amount placeholder batches (e.g. initial deposit submission). + const minimumMint = + amount === 0n + ? 0n + : applySlippage( + await getExpectedDepositShares({ + lensAddress, + boringVault, + accountantAddress, + musdAddress, + amount, + provider, + }), + ); - const minimumMint = applySlippage(expectedShares); const approveData = buildApproveData(boringVault, amount); const depositData = buildDepositData(musdAddress, amount, minimumMint); @@ -166,20 +189,54 @@ export async function buildMoneyAccountDepositBatch({ }; } +/** Decimals for USDC (the deposit asset). */ +const USDC_DECIMALS = 6; + /** * Returns the per-nested-call data updates required when the user changes * the deposit amount on a Money Account deposit confirmation. * - * Stub implementation — real encoding will replace this once the deposit - * batch re-encoding logic is wired in. + * Reads vault config from the Redux store, calls `previewDeposit` on the + * lens contract to derive an accurate `minimumMint`, and returns the + * re-encoded approve + deposit calldata ready for `updateAtomicBatchData`. + * + * Returns `[]` (no-op) if vault config or provider is unavailable. + * Lets `buildMoneyAccountDepositBatch` errors propagate so the dispatcher + * can log them via its prep-error handler. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars export async function updateMoneyAccountDepositTokenAmount( - _transactionMeta: TransactionMeta, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _amountHuman: string, + transactionMeta: TransactionMeta, + amountHuman: string, ): Promise { - return []; + const vaultConfig = selectMoneyAccountVaultConfig( + ReduxService.store.getState() as RootState, + ); + if (!vaultConfig) return []; + + const chainIdHex = transactionMeta.chainId as Hex; + const provider = getProviderByChainId(chainIdHex); + if (!provider) return []; + + const amount = BigInt( + calcTokenValue(amountHuman, USDC_DECIMALS) + .decimalPlaces(0, BigNumber.ROUND_UP) + .toFixed(0), + ); + + const { approveTx, depositTx } = await buildMoneyAccountDepositBatch({ + amount, + chainId: chainIdHex, + boringVault: vaultConfig.boringVault, + tellerAddress: vaultConfig.tellerAddress, + accountantAddress: vaultConfig.accountantAddress, + lensAddress: vaultConfig.lensAddress, + provider, + }); + + return [ + { nestedTransactionIndex: 0, transactionData: approveTx.params.data }, + { nestedTransactionIndex: 1, transactionData: depositTx.params.data }, + ]; } /** diff --git a/app/components/UI/TokenDetails/components/AssetOverviewContent.view.test.tsx b/app/components/UI/TokenDetails/components/AssetOverviewContent.view.test.tsx new file mode 100644 index 00000000000..0c3bc858d9e --- /dev/null +++ b/app/components/UI/TokenDetails/components/AssetOverviewContent.view.test.tsx @@ -0,0 +1,231 @@ +import '../../../../../tests/component-view/mocks'; +import React from 'react'; +import { Text } from 'react-native'; +import { createStackNavigator } from '@react-navigation/stack'; +import { merge } from 'lodash'; +import { fireEvent, screen, waitFor } from '@testing-library/react-native'; + +import renderWithProvider, { + type ProviderValues, +} from '../../../../util/test/renderWithProvider'; + +import AssetOverviewContent, { + type AssetOverviewContentProps, +} from './AssetOverviewContent'; +import { TokenI } from '../../Tokens/types'; +import { TimePeriod } from '../../../hooks/useTokenHistoricalPrices'; +import { TokenOverviewSelectorsIDs } from '../../AssetOverview/TokenOverview.testIds'; +import { MarketInsightsSelectorsIDs } from '../../MarketInsights/MarketInsights.testIds'; +import { remoteFeatureFlagMarketInsightsEnabled } from '../../../../../tests/api-mocking/mock-responses/feature-flags-mocks'; +import { + MOCK_PERPS_MARKET_INSIGHTS_REPORT, + setupMarketInsightsEngineMock, +} from '../../../../../tests/component-view/fixtures/perpsMarketInsights'; +import { initialStateAssetDetails } from '../../../../../tests/component-view/presets/assetDetails'; +import { + fiatOrdersRampRoutingSupported, + initialStateMarketInsightsView, +} from '../../../../../tests/component-view/presets/marketInsightsView'; +import { describeForPlatforms } from '../../../../../tests/component-view/platform'; +import Routes from '../../../../constants/navigation/Routes'; +import MarketInsightsView from '../../MarketInsights/Views/MarketInsightsView/MarketInsightsView'; +import { AccessRestrictedProvider } from '../../Compliance'; + +const ETH_NATIVE = '0x0000000000000000000000000000000000000000'; + +const ethMainnetToken: TokenI = { + address: ETH_NATIVE, + chainId: '0x1', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + balance: '1', + balanceFiat: '$2000', + logo: '', + image: '', + isETH: true, + isNative: true, + hasBalanceError: false, + aggregators: [], +}; + +const baseOverviewProps: AssetOverviewContentProps = { + token: ethMainnetToken, + balance: '1', + mainBalance: '$2,000.00', + secondaryBalance: '1 ETH', + currentPrice: 2000, + priceDiff: 0, + comparePrice: 2000, + prices: [], + isLoading: false, + timePeriod: '1d' as TimePeriod, + setTimePeriod: () => undefined, + chartNavigationButtons: ['1d', '1w', '1m', '3m', '1y', '3y'], + isPerpsEnabled: false, + currentCurrency: 'USD', + onBuy: () => undefined, + onSend: async () => undefined, + onReceive: () => undefined, +}; + +function AssetOverviewContentHarness() { + return ; +} + +function renderAssetOverviewMarketInsightsStack( + extraRoutes: { + name: string; + Component?: React.ComponentType; + }[], + providerValues: ProviderValues, +) { + const Stack = createStackNavigator(); + + const DefaultRouteProbe = + (routeName: string): React.FC => + () => {routeName}; + + return renderWithProvider( + + + + {extraRoutes.map(({ name, Component: Extra }) => ( + + ))} + + , + providerValues, + ); +} + +/** + * Bridge + ramps + multichain balances (MarketInsightsView preset) merged with Asset + * Details preset so `AssetOverviewContent` selectors (Earn, staking, etc.) resolve. + */ +function buildTokenDetailsMarketInsightsState( + marketInsightsFlagEnabled: boolean, +) { + return merge( + {}, + initialStateAssetDetails({ deterministicFiat: true }).build(), + initialStateMarketInsightsView() + .withOverrides(fiatOrdersRampRoutingSupported) + .build(), + { + engine: { + backgroundState: { + TokenListController: { + tokensChainsCache: {}, + }, + RemoteFeatureFlagController: { + remoteFeatureFlags: remoteFeatureFlagMarketInsightsEnabled( + marketInsightsFlagEnabled, + ), + }, + EarnController: { + pooled_staking: { isEligible: false }, + lending: { positions: [], markets: [] }, + }, + }, + }, + }, + ); +} + +describeForPlatforms( + 'AssetOverviewContent (Market Insights entry card)', + () => { + it('does not show entry card or skeleton after fetch when API returns no report', async () => { + setupMarketInsightsEngineMock(null); + + renderAssetOverviewMarketInsightsStack( + [ + { + name: Routes.MARKET_INSIGHTS.VIEW, + Component: + MarketInsightsView as unknown as React.ComponentType, + }, + ], + { state: buildTokenDetailsMarketInsightsState(true) }, + ); + + await waitFor( + () => { + expect( + screen.queryByTestId( + MarketInsightsSelectorsIDs.ENTRY_CARD_SKELETON, + ), + ).toBeNull(); + }, + { timeout: 15000 }, + ); + expect( + screen.queryByTestId(MarketInsightsSelectorsIDs.ENTRY_CARD), + ).toBeNull(); + }); + + it('does not show entry card when market insights feature flag is off', async () => { + setupMarketInsightsEngineMock(MOCK_PERPS_MARKET_INSIGHTS_REPORT); + + renderAssetOverviewMarketInsightsStack( + [ + { + name: Routes.MARKET_INSIGHTS.VIEW, + Component: + MarketInsightsView as unknown as React.ComponentType, + }, + ], + { state: buildTokenDetailsMarketInsightsState(false) }, + ); + + expect( + await screen.findByTestId(TokenOverviewSelectorsIDs.CONTAINER), + ).toBeOnTheScreen(); + + await waitFor(() => { + expect( + screen.queryByTestId(MarketInsightsSelectorsIDs.ENTRY_CARD_SKELETON), + ).toBeNull(); + }); + expect( + screen.queryByTestId(MarketInsightsSelectorsIDs.ENTRY_CARD), + ).toBeNull(); + }); + + it('shows entry card when report exists and opens Market Insights on press', async () => { + setupMarketInsightsEngineMock(MOCK_PERPS_MARKET_INSIGHTS_REPORT); + + renderAssetOverviewMarketInsightsStack( + [ + { + name: Routes.MARKET_INSIGHTS.VIEW, + Component: + MarketInsightsView as unknown as React.ComponentType, + }, + { name: Routes.BRIDGE.ROOT }, + { name: Routes.RAMP.TOKEN_SELECTION }, + ], + { state: buildTokenDetailsMarketInsightsState(true) }, + ); + + const entryCard = await screen.findByTestId( + MarketInsightsSelectorsIDs.ENTRY_CARD, + {}, + { timeout: 15000 }, + ); + fireEvent.press(entryCard); + + expect( + await screen.findByTestId(MarketInsightsSelectorsIDs.VIEW_CONTAINER), + ).toBeOnTheScreen(); + }); + }, +); diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.test.tsx b/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.test.tsx index 3b75c3cb825..06c51c82086 100644 --- a/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.test.tsx +++ b/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.test.tsx @@ -3,8 +3,16 @@ import { screen, fireEvent } from '@testing-library/react-native'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import WhatsHappeningSection from './WhatsHappeningSection'; import Routes from '../../../../../constants/navigation/Routes'; +import { MetaMetricsEvents } from '../../../../../core/Analytics/MetaMetrics.events'; const mockNavigate = jest.fn(); +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn((eventName: string) => ({ + addProperties: jest.fn((properties: Record) => ({ + build: jest.fn(() => ({ category: eventName, properties })), + })), + build: jest.fn(() => ({ category: eventName })), +})); jest.mock('@react-navigation/native', () => { const actual = jest.requireActual('@react-navigation/native'); @@ -30,6 +38,13 @@ jest.mock('./hooks', () => ({ })), })); +jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + const mockUseWhatsHappening = jest.requireMock('./hooks').useWhatsHappening; const mockSelectWhatsHappeningEnabled = jest.requireMock( '../../../../../selectors/featureFlagController/whatsHappening', @@ -175,4 +190,50 @@ describe('WhatsHappeningSection', () => { initialIndex: 1, }); }); + + it('tracks Whats Happening Opened with entry_point=card when a card is pressed', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [mockItem], + isLoading: false, + error: null, + refresh: jest.fn(), + }); + renderWithProvider(); + fireEvent.press(screen.getByText(mockItem.title)); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.WHATS_HAPPENING_OPENED, + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.WHATS_HAPPENING_OPENED, + properties: expect.objectContaining({ + entry_point: 'card', + event_id: mockItem.id, + card_index: 0, + category: 'macro', + impact: 'positive', + asset_symbols: [], + }), + }), + ); + }); + + it('tracks Whats Happening Opened with entry_point=view_all when View More is pressed', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [mockItem], + isLoading: false, + error: null, + refresh: jest.fn(), + }); + renderWithProvider(); + fireEvent.press(screen.getByText(/view more/i)); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.WHATS_HAPPENING_OPENED, + properties: expect.objectContaining({ + entry_point: 'view_all', + }), + }), + ); + }); }); diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.tsx b/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.tsx index 1fa9d6fdeca..1a2507d663e 100644 --- a/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.tsx +++ b/app/components/Views/Homepage/Sections/WhatsHappening/WhatsHappeningSection.tsx @@ -16,7 +16,7 @@ import { SectionRefreshHandle } from '../../types'; import { selectWhatsHappeningEnabled } from '../../../../../selectors/featureFlagController/whatsHappening'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; -import { MAX_ITEMS_DISPLAYED } from './constants'; +import { MAX_ITEMS_DISPLAYED, WhatsHappeningEntryPoint } from './constants'; import { useWhatsHappening } from './hooks'; import { WhatsHappeningCard, WhatsHappeningCardSkeleton } from './components'; import useHomeViewedEvent, { @@ -24,6 +24,9 @@ import useHomeViewedEvent, { } from '../../hooks/useHomeViewedEvent'; import { useSectionPerformance } from '../../hooks/useSectionPerformance'; import { WalletViewSelectorsIDs } from '../../../Wallet/WalletView.testIds'; +import { MetaMetricsEvents } from '../../../../../core/Analytics'; +import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; +import { getWhatsHappeningEventProps } from './eventProperties'; const CARD_WIDTH = 280; const GAP = 12; @@ -56,6 +59,7 @@ const WhatsHappeningSection = forwardRef< const navigation = useNavigation(); const isEnabled = useSelector(selectWhatsHappeningEnabled); const title = strings('homepage.sections.whats_happening'); + const { trackEvent, createEventBuilder } = useAnalytics(); const { items, isLoading, error, refresh } = useWhatsHappening(MAX_ITEMS_DISPLAYED); @@ -98,14 +102,30 @@ const WhatsHappeningSection = forwardRef< ); const handleViewAll = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_OPENED) + .addProperties({ entry_point: WhatsHappeningEntryPoint.ViewAll }) + .build(), + ); navigateToDetail(0); - }, [navigateToDetail]); + }, [navigateToDetail, trackEvent, createEventBuilder]); const handleCardPress = useCallback( (index: number) => { + const item = items[index]; + if (item) { + trackEvent( + createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_OPENED) + .addProperties({ + ...getWhatsHappeningEventProps(item, index), + entry_point: WhatsHappeningEntryPoint.Card, + }) + .build(), + ); + } navigateToDetail(index); }, - [navigateToDetail], + [items, navigateToDetail, trackEvent, createEventBuilder], ); if (!isEnabled) { @@ -161,6 +181,7 @@ const WhatsHappeningSection = forwardRef< handleCardPress(index)} /> ))} diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx index b2decb5c8bf..ab46f9cd626 100644 --- a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx +++ b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.test.tsx @@ -3,6 +3,30 @@ import { screen, fireEvent } from '@testing-library/react-native'; import renderWithProvider from '../../../../../../util/test/renderWithProvider'; import WhatsHappeningCard from './WhatsHappeningCard'; import type { WhatsHappeningItem } from '../types'; +import { MetaMetricsEvents } from '../../../../../../core/Analytics/MetaMetrics.events'; + +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn((eventName: string) => ({ + addProperties: jest.fn((properties: Record) => ({ + build: jest.fn(() => ({ category: eventName, properties })), + })), + build: jest.fn(() => ({ category: eventName })), +})); + +let capturedOnVisible: (() => void) | null = null; +jest.mock('../../../../../UI/MarketInsights/hooks/useViewportTracking', () => ({ + useViewportTracking: (onVisible: () => void) => { + capturedOnVisible = onVisible; + return { ref: { current: null }, onLayout: jest.fn() }; + }, +})); + +jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); const mockRelatedAsset = { sourceAssetId: 'btc-mainnet', @@ -23,63 +47,68 @@ const baseItem: WhatsHappeningItem = { }; describe('WhatsHappeningCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + capturedOnVisible = null; + }); + it('renders title and description', () => { - renderWithProvider(); + renderWithProvider(); expect(screen.getByText(baseItem.title)).toBeOnTheScreen(); expect(screen.getByText(baseItem.description)).toBeOnTheScreen(); }); it('renders category badge when category is provided', () => { - renderWithProvider(); + renderWithProvider(); expect(screen.getByText('Macro')).toBeOnTheScreen(); }); it('does not render category badge when category is absent', () => { const item = { ...baseItem, category: undefined }; - renderWithProvider(); + renderWithProvider(); expect(screen.queryByText('Macro')).toBeNull(); }); it('renders Bullish impact badge for positive impact', () => { const item = { ...baseItem, impact: 'positive' as const }; - renderWithProvider(); + renderWithProvider(); expect(screen.getByText('Bullish')).toBeOnTheScreen(); }); it('renders Bearish impact badge for negative impact', () => { const item = { ...baseItem, impact: 'negative' as const }; - renderWithProvider(); + renderWithProvider(); expect(screen.getByText('Bearish')).toBeOnTheScreen(); }); it('renders Neutral impact badge for neutral impact', () => { const item = { ...baseItem, impact: 'neutral' as const }; - renderWithProvider(); + renderWithProvider(); expect(screen.getByText('Neutral')).toBeOnTheScreen(); }); it('does not render impact badge when impact is absent', () => { const item = { ...baseItem, impact: undefined }; - renderWithProvider(); + renderWithProvider(); expect(screen.queryByText('Bullish')).toBeNull(); expect(screen.queryByText('Bearish')).toBeNull(); expect(screen.queryByText('Neutral')).toBeNull(); }); it('renders impact badge alongside category badge', () => { - renderWithProvider(); + renderWithProvider(); expect(screen.getByText('Bullish')).toBeOnTheScreen(); expect(screen.getByText('Macro')).toBeOnTheScreen(); }); it('renders related asset symbol pills', () => { - renderWithProvider(); + renderWithProvider(); expect(screen.getByText('BTC')).toBeOnTheScreen(); }); it('does not render asset pills when relatedAssets is empty', () => { const item = { ...baseItem, relatedAssets: [] }; - renderWithProvider(); + renderWithProvider(); expect(screen.queryByText('BTC')).toBeNull(); }); @@ -91,26 +120,26 @@ describe('WhatsHappeningCard', () => { caip19: ['eip155:1/slip44:60'], }; const item = { ...baseItem, relatedAssets: [mockRelatedAsset, ethAsset] }; - renderWithProvider(); + renderWithProvider(); expect(screen.getByText('BTC')).toBeOnTheScreen(); expect(screen.getByText('ETH')).toBeOnTheScreen(); }); it('renders formatted date when date is valid', () => { - renderWithProvider(); + renderWithProvider(); expect(screen.getByText('Mar 15, 2026')).toBeOnTheScreen(); }); it('does not render date when date string is invalid', () => { const item = { ...baseItem, date: 'not-a-date' }; - renderWithProvider(); + renderWithProvider(); expect(screen.queryByText('not-a-date')).toBeNull(); }); it('calls onPress with the item when tapped', () => { const onPress = jest.fn(); renderWithProvider( - , + , ); fireEvent.press(screen.getByText(baseItem.title)); expect(onPress).toHaveBeenCalledTimes(1); @@ -118,9 +147,30 @@ describe('WhatsHappeningCard', () => { }); it('does not throw when onPress is not provided', () => { - renderWithProvider(); + renderWithProvider(); expect(() => fireEvent.press(screen.getByText(baseItem.title)), ).not.toThrow(); }); + + it('tracks Whats Happening Card Scrolled to View when card becomes visible', () => { + renderWithProvider(); + expect(capturedOnVisible).not.toBeNull(); + capturedOnVisible?.(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.WHATS_HAPPENING_CARD_SCROLLED_TO_VIEW, + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.WHATS_HAPPENING_CARD_SCROLLED_TO_VIEW, + properties: expect.objectContaining({ + event_id: 'trend-0', + card_index: 2, + category: 'macro', + impact: 'positive', + asset_symbols: ['BTC'], + }), + }), + ); + }); }); diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx index f8d8f42975d..e1c7065fa69 100644 --- a/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx +++ b/app/components/Views/Homepage/Sections/WhatsHappening/components/WhatsHappeningCard.tsx @@ -1,5 +1,5 @@ -import React, { useMemo } from 'react'; -import { TouchableOpacity } from 'react-native'; +import React, { useCallback, useMemo } from 'react'; +import { TouchableOpacity, View } from 'react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { Box, @@ -18,118 +18,143 @@ import { getImpactBackgroundClass, getImpactTextColor, } from '../util/impact'; +import { MetaMetricsEvents } from '../../../../../../core/Analytics'; +import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics'; +import { useViewportTracking } from '../../../../../UI/MarketInsights/hooks/useViewportTracking'; +import { getWhatsHappeningEventProps } from '../eventProperties'; interface WhatsHappeningCardProps { item: WhatsHappeningItem; + cardIndex: number; onPress?: (item: WhatsHappeningItem) => void; } const WhatsHappeningCard: React.FC = ({ item, + cardIndex, onPress, }) => { const tw = useTailwind(); const formattedDate = useMemo(() => formatShortDate(item.date), [item.date]); + const { trackEvent, createEventBuilder } = useAnalytics(); const handlePress = () => onPress?.(item); + const handleVisible = useCallback(() => { + trackEvent( + createEventBuilder( + MetaMetricsEvents.WHATS_HAPPENING_CARD_SCROLLED_TO_VIEW, + ) + .addProperties(getWhatsHappeningEventProps(item, cardIndex)) + .build(), + ); + }, [trackEvent, createEventBuilder, item, cardIndex]); + + const { ref: cardRef, onLayout: onVisibilityLayout } = + useViewportTracking(handleVisible); + return ( - - - {/* Impact + Category badges */} - {(item.impact || item.category) && ( - - {item.impact && ( - - - {getImpactLabel(item.impact)} - - - )} - {item.category && ( - - - {strings( - `homepage.sections.whats_happening_categories.${item.category}`, - )} - - - )} - + + + + {/* Impact + Category badges */} + {(item.impact || item.category) && ( + + {item.impact && ( + + + {getImpactLabel(item.impact)} + + + )} + {item.category && ( + + + {strings( + `homepage.sections.whats_happening_categories.${item.category}`, + )} + + + )} + + )} - {/* Title */} - - {item.title} - - - {/* Description */} - - {item.description} - - + {/* Title */} + + {item.title} + - {/* Footer: asset pills + date */} - - {item.relatedAssets.length > 0 && ( - - {item.relatedAssets.map((asset) => ( - - + + + {/* Footer: asset pills + date */} + + {item.relatedAssets.length > 0 && ( + + {item.relatedAssets.map((asset) => ( + - {asset.symbol} - - - ))} - - )} + + {asset.symbol} + + + ))} + + )} - {formattedDate && ( - - {formattedDate} - - )} - - + {formattedDate && ( + + {formattedDate} + + )} + + + ); }; diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/constants.ts b/app/components/Views/Homepage/Sections/WhatsHappening/constants.ts index 62ac194b310..05d74115589 100644 --- a/app/components/Views/Homepage/Sections/WhatsHappening/constants.ts +++ b/app/components/Views/Homepage/Sections/WhatsHappening/constants.ts @@ -1 +1,12 @@ export const MAX_ITEMS_DISPLAYED = 5; + +export const WhatsHappeningEntryPoint = { + Card: 'card', + ViewAll: 'view_all', +} as const; + +export const WhatsHappeningInteractionType = { + SourceClick: 'source_click', + BuyPressed: 'buy_pressed', + TradePressed: 'trade_pressed', +} as const; diff --git a/app/components/Views/Homepage/Sections/WhatsHappening/eventProperties.ts b/app/components/Views/Homepage/Sections/WhatsHappening/eventProperties.ts new file mode 100644 index 00000000000..ae75f319382 --- /dev/null +++ b/app/components/Views/Homepage/Sections/WhatsHappening/eventProperties.ts @@ -0,0 +1,31 @@ +import type { WhatsHappeningItem } from './types'; + +/** + * Shared properties bag for Whats Happening analytics events. + * Used by CARD_SCROLLED_TO_VIEW, OPENED, VIEWED, INTERACTION, and CLOSED. + * + * Optional fields (`category`, `impact`) are stripped when undefined so + * the resulting payload only includes keys that have a real value. + * + * The shape is widened with `Record` so the result can be + * passed directly to `AnalyticsEventBuilder.addProperties`, which expects + * an index-signature-compatible bag. + */ +export type WhatsHappeningEventProps = { + event_id: string; + card_index: number; + category?: WhatsHappeningItem['category']; + impact?: WhatsHappeningItem['impact']; + asset_symbols: string[]; +} & Record; + +export const getWhatsHappeningEventProps = ( + item: WhatsHappeningItem, + cardIndex: number, +): WhatsHappeningEventProps => ({ + event_id: item.id, + card_index: cardIndex, + ...(item.category ? { category: item.category } : {}), + ...(item.impact ? { impact: item.impact } : {}), + asset_symbols: item.relatedAssets.map((asset) => asset.symbol), +}); diff --git a/app/components/Views/NftDetails/NftDetails.test.ts b/app/components/Views/NftDetails/NftDetails.test.ts index c62a4cacea8..752958b9330 100644 --- a/app/components/Views/NftDetails/NftDetails.test.ts +++ b/app/components/Views/NftDetails/NftDetails.test.ts @@ -205,6 +205,7 @@ describe('NftDetails', () => { ); expect(getByText(TEST_COLLECTIBLE.name)).toBeOnTheScreen(); + expect(getByText(TEST_COLLECTIBLE.collection.name)).toBeOnTheScreen(); }); it('tracks NFT Details Opened event with mobile-nft-list source', () => { diff --git a/app/components/Views/NftDetails/NftDetails.tsx b/app/components/Views/NftDetails/NftDetails.tsx index e89661cd859..129c204e7ab 100644 --- a/app/components/Views/NftDetails/NftDetails.tsx +++ b/app/components/Views/NftDetails/NftDetails.tsx @@ -528,6 +528,12 @@ const NftDetails = () => { ) : null} + + Notification Details + + {params?.notification?.id ?? ''} + + navigation.goBack()} + > + Back + + + ); +} + +function renderNotificationsScreen( + notifications: typeof MOCK_NOTIFICATIONS = MOCK_NOTIFICATIONS, +) { + return renderScreenWithRoutes( + NotificationsView as unknown as React.ComponentType, + { name: 'NotificationsView' }, + [ + { + name: Routes.NOTIFICATIONS.DETAILS, + Component: NotificationsDetailsProbe, + }, + ], + { state: buildNotificationsState({ notifications }) }, + ); +} + +describeForPlatforms('Notifications view (list + details flow)', () => { + /** + * The smoke spec inspects the rendered list of notifications. In jest, + * `FlatList` never receives layout metrics so it only renders the first row + * — instead of fighting virtualization we assert on the FlatList `data` + * prop, which is the same source the device-rendered list reads from. + */ + it('exposes the full seeded notifications list to the FlatList data source', () => { + const result = renderNotificationsScreen(); + const flatList = result.UNSAFE_getByType(FlatList); + + expect( + result.getByTestId(NotificationsViewSelectorsIDs.NOTIFICATIONS_CONTAINER), + ).toBeOnTheScreen(); + + const data = (flatList.props as { data?: typeof MOCK_NOTIFICATIONS }).data; + expect(data).toHaveLength(MOCK_NOTIFICATIONS.length); + + const seededIds = new Set(MOCK_NOTIFICATIONS.map((n) => n.id)); + data?.forEach((n) => { + expect(seededIds.has(n.id)).toBe(true); + }); + }); + + /** + * Seed only the feature announcement so it's the first (and only) row in + * the FlatList's initial render window — proves the same tap → details → + * back path the smoke spec asserts on, without depending on virtualization. + */ + it('opens details for a feature announcement and returns on back', async () => { + const featureAnnouncement = MOCK_FEATURE_ANNOUNCEMENT_NOTIFICATIONS[0]; + const result = renderNotificationsScreen([featureAnnouncement]); + + fireEvent.press( + await result.findByTestId( + NotificationMenuViewSelectorsIDs.ITEM(featureAnnouncement.id), + ), + ); + + await waitFor(() => { + expect( + result.getByTestId(NOTIFICATIONS_DETAILS_PROBE_TEST_ID), + ).toBeOnTheScreen(); + }); + expect( + result.getByTestId(NOTIFICATIONS_DETAILS_PROBE_ID_TEST_ID), + ).toHaveTextContent(featureAnnouncement.id); + + fireEvent.press(result.getByTestId(NOTIFICATIONS_DETAILS_BACK_TEST_ID)); + + await waitFor(() => { + expect( + result.getByTestId( + NotificationsViewSelectorsIDs.NOTIFICATIONS_CONTAINER, + ), + ).toBeOnTheScreen(); + }); + }); + + it('opens details for a wallet notification and returns on back', async () => { + const walletNotification = MOCK_ON_CHAIN_NOTIFICATIONS[0]; + const result = renderNotificationsScreen([walletNotification]); + + fireEvent.press( + await result.findByTestId( + NotificationMenuViewSelectorsIDs.ITEM(walletNotification.id), + ), + ); + + await waitFor(() => { + expect( + result.getByTestId(NOTIFICATIONS_DETAILS_PROBE_TEST_ID), + ).toBeOnTheScreen(); + }); + expect( + result.getByTestId(NOTIFICATIONS_DETAILS_PROBE_ID_TEST_ID), + ).toHaveTextContent(walletNotification.id); + + fireEvent.press(result.getByTestId(NOTIFICATIONS_DETAILS_BACK_TEST_ID)); + + await waitFor(() => { + expect( + result.getByTestId( + NotificationsViewSelectorsIDs.NOTIFICATIONS_CONTAINER, + ), + ).toBeOnTheScreen(); + }); + }); +}); diff --git a/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.view.test.tsx b/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.view.test.tsx new file mode 100644 index 00000000000..df67c1d30e5 --- /dev/null +++ b/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.view.test.tsx @@ -0,0 +1,200 @@ +import '../../../../../tests/component-view/mocks'; +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react-native'; + +import { renderComponentViewScreen } from '../../../../../tests/component-view/render'; +import { describeForPlatforms } from '../../../../../tests/component-view/platform'; +import { + buildNotificationsState, + NOTIFICATIONS_ACCOUNT_ADDRESS, +} from '../../../../../tests/component-view/presets/notifications'; +import NotificationsSettings from './'; +import { + NotificationSettingsViewSelectorsIDs, + NotificationSettingsViewSelectorsText, +} from './NotificationSettingsView.testIds'; +import Engine from '../../../../core/Engine'; + +/** + * Component-view coverage for smoke `notification-settings-flow`. + * + * Smoke spec: tests/smoke/notifications/notification-settings-flow.spec.ts + * + * No hooks, selectors or services are mocked here — the per-account toggle + * resolves from real `AccountTreeController` + `AccountsController` state + * seeded by `buildNotificationsState`. The feature flag check resolves true + * via `IS_TEST=true` (set at config-load time in `jest.config.view.js`). + */ + +function renderSettings( + stateOverrides?: Parameters[0], +) { + return renderComponentViewScreen( + NotificationsSettings as unknown as React.ComponentType, + { name: 'NotificationsSettings' }, + { state: buildNotificationsState(stateOverrides) }, + { isFullScreenModal: false }, + ); +} + +describeForPlatforms('Notifications settings (toggles + visibility)', () => { + it('renders all sub-toggles when notifications are enabled', async () => { + const { getByTestId, findByText } = renderSettings(); + + expect( + getByTestId(NotificationSettingsViewSelectorsIDs.NOTIFICATIONS_TOGGLE), + ).toBeOnTheScreen(); + + expect( + await waitFor(() => + getByTestId( + NotificationSettingsViewSelectorsIDs.PUSH_NOTIFICATIONS_TOGGLE, + ), + ), + ).toBeOnTheScreen(); + + expect( + getByTestId( + NotificationSettingsViewSelectorsIDs.FEATURE_ANNOUNCEMENTS_TOGGLE, + ), + ).toBeOnTheScreen(); + + expect( + await findByText( + NotificationSettingsViewSelectorsText.ACCOUNT_ACTIVITY_SECTION, + ), + ).toBeOnTheScreen(); + + expect( + getByTestId( + NotificationSettingsViewSelectorsIDs.ACCOUNT_NOTIFICATION_TOGGLE( + NOTIFICATIONS_ACCOUNT_ADDRESS, + ), + ), + ).toBeOnTheScreen(); + }); + + it('hides push, feature announcements and account section when main toggle is off', async () => { + const { getByTestId, queryByTestId, queryByText } = renderSettings({ + notificationsEnabled: false, + }); + + expect( + getByTestId(NotificationSettingsViewSelectorsIDs.NOTIFICATIONS_TOGGLE), + ).toBeOnTheScreen(); + + await waitFor(() => { + expect( + queryByTestId( + NotificationSettingsViewSelectorsIDs.PUSH_NOTIFICATIONS_TOGGLE, + ), + ).toBeNull(); + }); + expect( + queryByTestId( + NotificationSettingsViewSelectorsIDs.FEATURE_ANNOUNCEMENTS_TOGGLE, + ), + ).toBeNull(); + expect( + queryByText( + NotificationSettingsViewSelectorsText.ACCOUNT_ACTIVITY_SECTION, + ), + ).toBeNull(); + }); + + it('invokes the disable controller path when the main toggle is pressed (on -> off)', async () => { + const disableSpy = jest + .spyOn( + Engine.context.NotificationServicesController as unknown as { + disableNotificationServices: () => Promise; + }, + 'disableNotificationServices', + ) + .mockResolvedValue(undefined); + + try { + const { getByTestId } = renderSettings(); + + fireEvent( + getByTestId(NotificationSettingsViewSelectorsIDs.NOTIFICATIONS_TOGGLE), + 'onChange', + { nativeEvent: { value: false } }, + ); + + await waitFor(() => { + expect(disableSpy).toHaveBeenCalled(); + }); + } finally { + disableSpy.mockRestore(); + } + }); + + it('invokes setFeatureAnnouncementsEnabled(false) when the feature announcements toggle is pressed', async () => { + const toggleSpy = jest + .spyOn( + Engine.context.NotificationServicesController as unknown as { + setFeatureAnnouncementsEnabled: (val: boolean) => Promise; + }, + 'setFeatureAnnouncementsEnabled', + ) + .mockResolvedValue(undefined); + + try { + const { getByTestId } = renderSettings(); + + fireEvent( + getByTestId( + NotificationSettingsViewSelectorsIDs.FEATURE_ANNOUNCEMENTS_TOGGLE, + ), + 'onChange', + { nativeEvent: { value: false } }, + ); + + await waitFor(() => { + expect(toggleSpy).toHaveBeenCalledWith(false); + }); + } finally { + toggleSpy.mockRestore(); + } + }); + + /** + * The per-account toggle's initial state comes from + * `Engine.NotificationServicesController.checkAccountsPresence`, which our + * Engine stub resolves to `{}` by default → toggle starts OFF. Pressing it + * therefore calls `enableAccounts` (off → on); the inverse direction is + * symmetrical. We assert the wiring through the press, not the direction. + */ + it('invokes enableAccounts with the account address when the per-account toggle is pressed', async () => { + const enableAccountsSpy = jest + .spyOn( + Engine.context.NotificationServicesController as unknown as { + enableAccounts: (addresses: string[]) => Promise; + }, + 'enableAccounts', + ) + .mockResolvedValue(undefined); + + try { + const { getByTestId } = renderSettings(); + + fireEvent( + getByTestId( + NotificationSettingsViewSelectorsIDs.ACCOUNT_NOTIFICATION_TOGGLE( + NOTIFICATIONS_ACCOUNT_ADDRESS, + ), + ), + 'onChange', + { nativeEvent: { value: true } }, + ); + + await waitFor(() => { + expect(enableAccountsSpy).toHaveBeenCalledWith([ + NOTIFICATIONS_ACCOUNT_ADDRESS, + ]); + }); + } finally { + enableAccountsSpy.mockRestore(); + } + }); +}); diff --git a/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.test.tsx b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.test.tsx index 8da2db4bd1f..ce2c2c3f5b7 100644 --- a/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.test.tsx +++ b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.test.tsx @@ -1,10 +1,23 @@ import React from 'react'; import { screen, fireEvent } from '@testing-library/react-native'; import renderWithProvider from '../../../util/test/renderWithProvider'; -import WhatsHappeningDetailView from './WhatsHappeningDetailView'; +import WhatsHappeningDetailView, { + CARD_WIDTH, +} from './WhatsHappeningDetailView'; +import { MetaMetricsEvents } from '../../../core/Analytics/MetaMetrics.events'; + +const GAP = 12; +const SNAP_INTERVAL_FOR_TEST = CARD_WIDTH + GAP; const mockGoBack = jest.fn(); const mockRefresh = jest.fn(); +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn((eventName: string) => ({ + addProperties: jest.fn((properties: Record) => ({ + build: jest.fn(() => ({ category: eventName, properties })), + })), + build: jest.fn(() => ({ category: eventName })), +})); jest.mock('@react-navigation/native', () => { const actual = jest.requireActual('@react-navigation/native'); @@ -15,6 +28,13 @@ jest.mock('@react-navigation/native', () => { }; }); +jest.mock('../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + jest.mock('../Homepage/Sections/WhatsHappening/hooks', () => ({ useWhatsHappening: jest.fn(() => ({ items: [], @@ -135,8 +155,133 @@ describe('WhatsHappeningDetailView', () => { }); it('calls navigation.goBack when back button is pressed', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [mockItem], + isLoading: false, + error: null, + refresh: mockRefresh, + }); renderWithProvider(); fireEvent.press(screen.getByTestId('whats-happening-detail-back-button')); expect(mockGoBack).toHaveBeenCalledTimes(1); }); + + it('tracks Whats Happening Viewed once for the initial card on mount', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [mockItem], + isLoading: false, + error: null, + refresh: mockRefresh, + }); + renderWithProvider(); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.WHATS_HAPPENING_VIEWED, + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.WHATS_HAPPENING_VIEWED, + properties: expect.objectContaining({ + event_id: mockItem.id, + card_index: 0, + category: 'macro', + impact: 'positive', + asset_symbols: [], + }), + }), + ); + }); + + it('does not fire Viewed more than once for the initial card across re-renders', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [mockItem], + isLoading: false, + error: null, + refresh: mockRefresh, + }); + const { rerender } = renderWithProvider(); + rerender(); + const viewedCalls = mockCreateEventBuilder.mock.calls.filter( + ([name]) => + name === + (MetaMetricsEvents.WHATS_HAPPENING_VIEWED as unknown as string), + ); + expect(viewedCalls).toHaveLength(1); + }); + + it('tracks Whats Happening Viewed when scrolling to a new card', () => { + const secondItem = { + ...mockItem, + id: 'trend-1', + title: 'Second trend', + category: 'social' as const, + impact: 'negative' as const, + }; + mockUseWhatsHappening.mockReturnValue({ + items: [mockItem, secondItem], + isLoading: false, + error: null, + refresh: mockRefresh, + }); + renderWithProvider(); + mockTrackEvent.mockClear(); + mockCreateEventBuilder.mockClear(); + const carousel = screen.getByTestId('whats-happening-detail-carousel'); + fireEvent(carousel, 'onMomentumScrollEnd', { + nativeEvent: { contentOffset: { x: SNAP_INTERVAL_FOR_TEST } }, + }); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.WHATS_HAPPENING_VIEWED, + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.WHATS_HAPPENING_VIEWED, + properties: expect.objectContaining({ + event_id: 'trend-1', + card_index: 1, + category: 'social', + impact: 'negative', + }), + }), + ); + }); + + it('does not track Viewed when scroll resolves to same index', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [mockItem], + isLoading: false, + error: null, + refresh: mockRefresh, + }); + renderWithProvider(); + mockTrackEvent.mockClear(); + mockCreateEventBuilder.mockClear(); + const carousel = screen.getByTestId('whats-happening-detail-carousel'); + fireEvent(carousel, 'onMomentumScrollEnd', { + nativeEvent: { contentOffset: { x: 0 } }, + }); + expect(mockCreateEventBuilder).not.toHaveBeenCalled(); + }); + + it('tracks Whats Happening Closed with the visible card when back is pressed', () => { + mockUseWhatsHappening.mockReturnValue({ + items: [mockItem], + isLoading: false, + error: null, + refresh: mockRefresh, + }); + renderWithProvider(); + fireEvent.press(screen.getByTestId('whats-happening-detail-back-button')); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.WHATS_HAPPENING_CLOSED, + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.WHATS_HAPPENING_CLOSED, + properties: expect.objectContaining({ + event_id: mockItem.id, + card_index: 0, + }), + }), + ); + }); }); diff --git a/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx index 562223eb544..84c15607c1b 100644 --- a/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx +++ b/app/components/Views/WhatsHappeningDetailView/WhatsHappeningDetailView.tsx @@ -24,9 +24,12 @@ import { strings } from '../../../../locales/i18n'; import { useWhatsHappening } from '../Homepage/Sections/WhatsHappening/hooks'; import { WhatsHappeningCardSkeleton } from '../Homepage/Sections/WhatsHappening/components'; import { MAX_ITEMS_DISPLAYED } from '../Homepage/Sections/WhatsHappening/constants'; +import { getWhatsHappeningEventProps } from '../Homepage/Sections/WhatsHappening/eventProperties'; import ErrorState from '../Homepage/components/ErrorState/ErrorState'; import WhatsHappeningExpandedCard from './components/WhatsHappeningExpandedCard'; import PageIndicator from './components/PageIndicator'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); @@ -58,6 +61,9 @@ const WhatsHappeningDetailView = () => { const [currentIndex, setCurrentIndex] = useState(initialIndex); const [cardHeight, setCardHeight] = useState(0); const scrollViewRef = useRef(null); + const hasTrackedViewRef = useRef(false); + const previousIndexRef = useRef(initialIndex); + const { trackEvent, createEventBuilder } = useAnalytics(); const handleCarouselLayout = useCallback((e: LayoutChangeEvent) => { const { height } = e.nativeEvent.layout; @@ -78,17 +84,60 @@ const WhatsHappeningDetailView = () => { } }, [initialIndex, isLoading, cardHeight]); + useEffect(() => { + if ( + !isLoading && + !hasTrackedViewRef.current && + items.length > 0 && + items[initialIndex] + ) { + hasTrackedViewRef.current = true; + trackEvent( + createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_VIEWED) + .addProperties( + getWhatsHappeningEventProps(items[initialIndex], initialIndex), + ) + .build(), + ); + } + }, [isLoading, items, initialIndex, trackEvent, createEventBuilder]); + const handleBackPress = useCallback(() => { + const visible = items[currentIndex]; + if (visible) { + trackEvent( + createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_CLOSED) + .addProperties(getWhatsHappeningEventProps(visible, currentIndex)) + .build(), + ); + } navigation.goBack(); - }, [navigation]); + }, [navigation, items, currentIndex, trackEvent, createEventBuilder]); const handleScrollEnd = useCallback( (event: NativeSyntheticEvent) => { const offsetX = event.nativeEvent.contentOffset.x; - const index = Math.round(offsetX / SNAP_INTERVAL); - setCurrentIndex(Math.max(0, Math.min(index, items.length - 1))); + const index = Math.max( + 0, + Math.min(Math.round(offsetX / SNAP_INTERVAL), items.length - 1), + ); + + const prev = previousIndexRef.current; + if (index !== prev) { + const newItem = items[index]; + if (newItem) { + trackEvent( + createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_VIEWED) + .addProperties(getWhatsHappeningEventProps(newItem, index)) + .build(), + ); + } + previousIndexRef.current = index; + } + + setCurrentIndex(index); }, - [items.length], + [items, trackEvent, createEventBuilder], ); const hasError = !isLoading && items.length === 0 && !!error; @@ -151,10 +200,11 @@ const WhatsHappeningDetailView = () => { testID="whats-happening-detail-carousel" > {cardHeight > 0 && - items.map((item) => ( + items.map((item, index) => ( diff --git a/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.test.tsx index d5cc96b1dcd..15504e180f3 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.test.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.test.tsx @@ -4,8 +4,17 @@ import renderWithProvider from '../../../../util/test/renderWithProvider'; import PerpsRow from './PerpsRow'; import Routes from '../../../../constants/navigation/Routes'; import type { RelatedAsset } from '@metamask/ai-controllers'; +import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; +import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events'; const mockNavigate = jest.fn(); +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn((eventName: string) => ({ + addProperties: jest.fn((properties: Record) => ({ + build: jest.fn(() => ({ category: eventName, properties })), + })), + build: jest.fn(() => ({ category: eventName })), +})); jest.mock('@react-navigation/native', () => { const actual = jest.requireActual('@react-navigation/native'); @@ -19,6 +28,13 @@ jest.mock('../utils/getRelatedAssetImageSource', () => ({ getRelatedAssetImageSource: jest.fn(() => undefined), })); +jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + const perpsOnlyAsset: RelatedAsset = { sourceAssetId: 'tsla', symbol: 'TSLA', @@ -35,23 +51,40 @@ const dualAsset: RelatedAsset = { hlPerpsMarket: ['BTC'], }; +const mockItem: WhatsHappeningItem = { + id: 'trend-3', + title: 'TSLA earnings', + description: '...', + date: '2026-03-15T10:00:00.000Z', + category: 'macro', + impact: 'positive', + relatedAssets: [perpsOnlyAsset], + articles: [], +}; + describe('PerpsRow', () => { beforeEach(() => { jest.clearAllMocks(); }); it('renders the asset symbol', () => { - renderWithProvider(); + renderWithProvider( + , + ); expect(screen.getByText('TSLA')).toBeOnTheScreen(); }); it('renders the Trade button', () => { - renderWithProvider(); + renderWithProvider( + , + ); expect(screen.getByText('Trade')).toBeOnTheScreen(); }); it('navigates to PerpsMarketDetails with minimal market payload on Trade press', () => { - renderWithProvider(); + renderWithProvider( + , + ); fireEvent.press(screen.getByText('Trade')); expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, @@ -62,7 +95,9 @@ describe('PerpsRow', () => { }); it('uses first hlPerpsMarket entry as the market symbol', () => { - renderWithProvider(); + renderWithProvider( + , + ); fireEvent.press(screen.getByText('Trade')); expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, @@ -77,8 +112,46 @@ describe('PerpsRow', () => { ...perpsOnlyAsset, hlPerpsMarket: [], }; - renderWithProvider(); + renderWithProvider( + , + ); fireEvent.press(screen.getByText('Trade')); expect(mockNavigate).not.toHaveBeenCalled(); }); + + it('tracks Whats Happening Interaction with interaction_type=trade_pressed and asset details on Trade press', () => { + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('Trade')); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.WHATS_HAPPENING_INTERACTION, + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.WHATS_HAPPENING_INTERACTION, + properties: expect.objectContaining({ + interaction_type: 'trade_pressed', + asset_symbol: 'TSLA', + perps_market: 'xyz:TSLA', + event_id: 'trend-3', + card_index: 1, + category: 'macro', + impact: 'positive', + }), + }), + ); + }); + + it('does not track Interaction when hlPerpsMarket is empty', () => { + const assetNoPerps: RelatedAsset = { + ...perpsOnlyAsset, + hlPerpsMarket: [], + }; + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('Trade')); + expect(mockCreateEventBuilder).not.toHaveBeenCalled(); + }); }); diff --git a/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx index 6a25fd84b10..6996cee0154 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/PerpsRow.tsx @@ -1,11 +1,18 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import type { RelatedAsset } from '@metamask/ai-controllers'; import { strings } from '../../../../../locales/i18n'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; +import { WhatsHappeningInteractionType } from '../../Homepage/Sections/WhatsHappening/constants'; +import { getWhatsHappeningEventProps } from '../../Homepage/Sections/WhatsHappening/eventProperties'; +import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; import AssetRow from './AssetRow'; import useTradeNavigation from '../hooks/useTradeNavigation'; interface PerpsRowProps { asset: RelatedAsset; + item: WhatsHappeningItem; + cardIndex: number; } /** @@ -14,15 +21,39 @@ interface PerpsRowProps { * the Perps market details view. Extracted as its own component so hooks can * be called per-asset (hooks cannot be called inside a loop). */ -const PerpsRow: React.FC = ({ asset }) => { +const PerpsRow: React.FC = ({ asset, item, cardIndex }) => { const { handleTrade } = useTradeNavigation(asset); + const { trackEvent, createEventBuilder } = useAnalytics(); + + const handleTradeWithTracking = useCallback(() => { + if (!asset.hlPerpsMarket?.[0]) return; + trackEvent( + createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_INTERACTION) + .addProperties({ + ...getWhatsHappeningEventProps(item, cardIndex), + interaction_type: WhatsHappeningInteractionType.TradePressed, + asset_symbol: asset.symbol, + perps_market: asset.hlPerpsMarket?.[0], + }) + .build(), + ); + handleTrade(); + }, [ + handleTrade, + asset.symbol, + asset.hlPerpsMarket, + item, + cardIndex, + trackEvent, + createEventBuilder, + ]); return ( ); }; diff --git a/app/components/Views/WhatsHappeningDetailView/components/TokenRow.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.test.tsx index 33197ca9ec8..9f2ae4a4df0 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/TokenRow.test.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.test.tsx @@ -3,9 +3,19 @@ import { screen, fireEvent } from '@testing-library/react-native'; import renderWithProvider from '../../../../util/test/renderWithProvider'; import TokenRow from './TokenRow'; import type { RelatedAsset } from '@metamask/ai-controllers'; +import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; +import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events'; import Routes from '../../../../constants/navigation/Routes'; const mockGoToBuy = jest.fn(); +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn((eventName: string) => ({ + addProperties: jest.fn((properties: Record) => ({ + build: jest.fn(() => ({ category: eventName, properties })), + })), + build: jest.fn(() => ({ category: eventName })), +})); + const mockNavigate = jest.fn(); jest.mock('../../../UI/Ramp/hooks/useRampNavigation', () => ({ @@ -24,6 +34,13 @@ jest.mock('../utils/getRelatedAssetImageSource', () => ({ getRelatedAssetImageSource: jest.fn(() => undefined), })); +jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + const btcAsset: RelatedAsset = { sourceAssetId: 'bitcoin', symbol: 'BTC', @@ -39,6 +56,24 @@ const dualAsset: RelatedAsset = { hlPerpsMarket: ['ETH'], }; +const perpsOnlyAsset: RelatedAsset = { + sourceAssetId: 'tsla', + symbol: 'TSLA', + name: 'Tesla', + caip19: [], +}; + +const mockItem: WhatsHappeningItem = { + id: 'trend-2', + title: 'BTC ETF inflows', + description: '...', + date: '2026-03-15T10:00:00.000Z', + category: 'macro', + impact: 'positive', + relatedAssets: [btcAsset], + articles: [], +}; + describe('TokenRow', () => { beforeEach(() => { jest.clearAllMocks(); @@ -46,17 +81,23 @@ describe('TokenRow', () => { describe('when asset has only caip19 (no hlPerpsMarket)', () => { it('renders the asset symbol', () => { - renderWithProvider(); + renderWithProvider( + , + ); expect(screen.getByText('BTC')).toBeOnTheScreen(); }); it('renders the Buy button', () => { - renderWithProvider(); + renderWithProvider( + , + ); expect(screen.getByText('Buy')).toBeOnTheScreen(); }); it('calls goToBuy with the first caip19 identifier on Buy press', () => { - renderWithProvider(); + renderWithProvider( + , + ); fireEvent.press(screen.getByText('Buy')); expect(mockGoToBuy).toHaveBeenCalledWith({ assetId: 'eip155:1/slip44:0', @@ -64,15 +105,27 @@ describe('TokenRow', () => { }); }); + it('calls goToBuy with assetId undefined when caip19 is empty', () => { + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('Buy')); + expect(mockGoToBuy).toHaveBeenCalledWith({ assetId: undefined }); + }); + describe('when asset has hlPerpsMarket (dual asset)', () => { it('renders the Trade button instead of Buy', () => { - renderWithProvider(); + renderWithProvider( + , + ); expect(screen.getByText('Trade')).toBeOnTheScreen(); expect(screen.queryByText('Buy')).toBeNull(); }); it('navigates to Perps market details on Trade press', () => { - renderWithProvider(); + renderWithProvider( + , + ); fireEvent.press(screen.getByText('Trade')); expect(mockNavigate).toHaveBeenCalledWith(Routes.PERPS.ROOT, { screen: Routes.PERPS.MARKET_DETAILS, @@ -83,9 +136,50 @@ describe('TokenRow', () => { }); it('does not call goToBuy when Trade is pressed', () => { - renderWithProvider(); + renderWithProvider( + , + ); fireEvent.press(screen.getByText('Trade')); expect(mockGoToBuy).not.toHaveBeenCalled(); }); }); + + it('tracks Whats Happening Interaction with interaction_type=buy_pressed and asset details on Buy press', () => { + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('Buy')); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.WHATS_HAPPENING_INTERACTION, + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.WHATS_HAPPENING_INTERACTION, + properties: expect.objectContaining({ + interaction_type: 'buy_pressed', + asset_symbol: 'BTC', + asset_caip19: 'eip155:1/slip44:0', + event_id: 'trend-2', + card_index: 2, + category: 'macro', + impact: 'positive', + }), + }), + ); + }); + + it('tracks Interaction without asset_caip19 when caip19 is empty', () => { + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('Buy')); + const addPropertiesCall = mockCreateEventBuilder.mock.results[0]?.value + ?.addProperties as jest.Mock | undefined; + const builtProperties = addPropertiesCall?.mock?.calls?.[0]?.[0] as + | Record + | undefined; + expect(builtProperties?.interaction_type).toBe('buy_pressed'); + expect(builtProperties?.asset_symbol).toBe('TSLA'); + expect(builtProperties).not.toHaveProperty('asset_caip19'); + }); }); diff --git a/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx index 26394755c5b..4a29ff051fc 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/TokenRow.tsx @@ -2,11 +2,18 @@ import React, { useCallback } from 'react'; import type { RelatedAsset } from '@metamask/ai-controllers'; import { strings } from '../../../../../locales/i18n'; import { useRampNavigation } from '../../../UI/Ramp/hooks/useRampNavigation'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; +import { WhatsHappeningInteractionType } from '../../Homepage/Sections/WhatsHappening/constants'; +import { getWhatsHappeningEventProps } from '../../Homepage/Sections/WhatsHappening/eventProperties'; +import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; import AssetRow from './AssetRow'; import useTradeNavigation from '../hooks/useTradeNavigation'; interface TokenRowProps { asset: RelatedAsset; + item: WhatsHappeningItem; + cardIndex: number; } /** @@ -16,14 +23,55 @@ interface TokenRowProps { * Ramp buy flow. Extracted as its own component so hooks can be called * per-asset (hooks cannot be called inside a loop). */ -const TokenRow: React.FC = ({ asset }) => { +const TokenRow: React.FC = ({ asset, item, cardIndex }) => { const { goToBuy } = useRampNavigation(); + const { trackEvent, createEventBuilder } = useAnalytics(); const { handleTrade, canTrade } = useTradeNavigation(asset); + const handleTradeWithTracking = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_INTERACTION) + .addProperties({ + ...getWhatsHappeningEventProps(item, cardIndex), + interaction_type: WhatsHappeningInteractionType.TradePressed, + asset_symbol: asset.symbol, + perps_market: asset.hlPerpsMarket?.[0], + }) + .build(), + ); + handleTrade(); + }, [ + handleTrade, + asset.symbol, + asset.hlPerpsMarket, + item, + cardIndex, + trackEvent, + createEventBuilder, + ]); + const handleBuy = useCallback(() => { const assetId = asset.caip19?.[0]; + trackEvent( + createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_INTERACTION) + .addProperties({ + ...getWhatsHappeningEventProps(item, cardIndex), + interaction_type: WhatsHappeningInteractionType.BuyPressed, + asset_symbol: asset.symbol, + ...(assetId ? { asset_caip19: assetId } : {}), + }) + .build(), + ); goToBuy({ assetId }); - }, [goToBuy, asset.caip19]); + }, [ + goToBuy, + asset.caip19, + asset.symbol, + item, + cardIndex, + trackEvent, + createEventBuilder, + ]); if (canTrade) { return ( @@ -31,7 +79,7 @@ const TokenRow: React.FC = ({ asset }) => { asset={asset} actionLabel={strings('bottom_nav.trade')} accessibilityLabel={`${strings('bottom_nav.trade')} ${asset.symbol}`} - onAction={handleTrade} + onAction={handleTradeWithTracking} /> ); } diff --git a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.test.tsx index 321367265c8..88c2498617c 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.test.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.test.tsx @@ -8,6 +8,18 @@ import Routes from '../../../../constants/navigation/Routes'; const mockNavigate = jest.fn(); const mockGoToBuy = jest.fn(); +jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: jest.fn(), + createEventBuilder: jest.fn((eventName: string) => ({ + addProperties: jest.fn(() => ({ + build: jest.fn(() => ({ category: eventName })), + })), + build: jest.fn(() => ({ category: eventName })), + })), + }), +})); + jest.mock('@react-navigation/native', () => { const actual = jest.requireActual('@react-navigation/native'); return { @@ -81,6 +93,7 @@ describe('WhatsHappeningExpandedCard', () => { renderWithProvider( , @@ -93,6 +106,7 @@ describe('WhatsHappeningExpandedCard', () => { renderWithProvider( , @@ -105,6 +119,7 @@ describe('WhatsHappeningExpandedCard', () => { renderWithProvider( , @@ -117,6 +132,7 @@ describe('WhatsHappeningExpandedCard', () => { renderWithProvider( , @@ -131,6 +147,7 @@ describe('WhatsHappeningExpandedCard', () => { renderWithProvider( , @@ -145,6 +162,7 @@ describe('WhatsHappeningExpandedCard', () => { renderWithProvider( , @@ -158,6 +176,7 @@ describe('WhatsHappeningExpandedCard', () => { renderWithProvider( , @@ -172,6 +191,7 @@ describe('WhatsHappeningExpandedCard', () => { renderWithProvider( , @@ -185,6 +205,7 @@ describe('WhatsHappeningExpandedCard', () => { renderWithProvider( , @@ -200,6 +221,7 @@ describe('WhatsHappeningExpandedCard', () => { renderWithProvider( , @@ -214,6 +236,7 @@ describe('WhatsHappeningExpandedCard', () => { renderWithProvider( , @@ -227,6 +250,7 @@ describe('WhatsHappeningExpandedCard', () => { renderWithProvider( , diff --git a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx index 8359a15efcf..c86dcafef7a 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningExpandedCard.tsx @@ -30,6 +30,7 @@ import WhatsHappeningSourcesBottomSheet from './WhatsHappeningSourcesBottomSheet interface WhatsHappeningExpandedCardProps { item: WhatsHappeningItem; + cardIndex: number; cardWidth: number; /** Height of the carousel container — used to give every card the same fixed height. */ cardHeight: number; @@ -37,6 +38,7 @@ interface WhatsHappeningExpandedCardProps { const WhatsHappeningExpandedCard: React.FC = ({ item, + cardIndex, cardWidth, cardHeight, }) => { @@ -114,7 +116,12 @@ const WhatsHappeningExpandedCard: React.FC = ({ {item.relatedAssets .filter((asset) => asset.caip19?.length) .map((asset) => ( - + ))} )} @@ -138,7 +145,12 @@ const WhatsHappeningExpandedCard: React.FC = ({ asset.hlPerpsMarket?.length && !asset.caip19?.length, ) .map((asset) => ( - + ))} )} @@ -195,6 +207,8 @@ const WhatsHappeningExpandedCard: React.FC = ({ setSourcesVisible(false)} articles={item.articles} + item={item} + cardIndex={cardIndex} /> )} diff --git a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.test.tsx b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.test.tsx index b86e82ede8c..9be1cf1efa9 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.test.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.test.tsx @@ -3,6 +3,23 @@ import { Linking } from 'react-native'; import { screen, fireEvent } from '@testing-library/react-native'; import renderWithProvider from '../../../../util/test/renderWithProvider'; import WhatsHappeningSourcesBottomSheet from './WhatsHappeningSourcesBottomSheet'; +import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; +import { MetaMetricsEvents } from '../../../../core/Analytics/MetaMetrics.events'; + +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn((eventName: string) => ({ + addProperties: jest.fn((properties: Record) => ({ + build: jest.fn(() => ({ category: eventName, properties })), + })), + build: jest.fn(() => ({ category: eventName })), +})); + +jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({ + useAnalytics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); jest.mock( '../../../../component-library/components/BottomSheets/BottomSheet', @@ -64,6 +81,24 @@ const articles = [ }, ]; +const mockItem: WhatsHappeningItem = { + id: 'trend-7', + title: 'Fed pauses rates', + description: '...', + date: '2026-03-15T10:00:00.000Z', + category: 'macro', + impact: 'positive', + relatedAssets: [ + { + sourceAssetId: 'btc', + symbol: 'BTC', + name: 'Bitcoin', + caip19: ['eip155:1/slip44:0'], + }, + ], + articles: articles as never, +}; + describe('WhatsHappeningSourcesBottomSheet', () => { beforeEach(() => { jest.clearAllMocks(); @@ -75,6 +110,8 @@ describe('WhatsHappeningSourcesBottomSheet', () => { , ); expect(screen.getByText('coindesk.com')).toBeOnTheScreen(); @@ -86,6 +123,8 @@ describe('WhatsHappeningSourcesBottomSheet', () => { , ); fireEvent.press(screen.getByText('coindesk.com')); @@ -98,6 +137,8 @@ describe('WhatsHappeningSourcesBottomSheet', () => { , ); fireEvent.press(screen.getByText('coindesk.com')); @@ -109,6 +150,8 @@ describe('WhatsHappeningSourcesBottomSheet', () => { , ); expect(screen.getByText('News sources')).toBeOnTheScreen(); @@ -116,8 +159,58 @@ describe('WhatsHappeningSourcesBottomSheet', () => { it('renders no article rows when articles array is empty', () => { renderWithProvider( - , + , ); expect(screen.queryByText('coindesk.com')).toBeNull(); }); + + it('tracks Whats Happening Interaction (source_click) with the article URL on row press', () => { + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('coindesk.com')); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.WHATS_HAPPENING_INTERACTION, + ); + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + category: MetaMetricsEvents.WHATS_HAPPENING_INTERACTION, + properties: expect.objectContaining({ + interaction_type: 'source_click', + source: 'https://coindesk.com/fed-pauses', + event_id: 'trend-7', + card_index: 3, + category: 'macro', + impact: 'positive', + asset_symbols: ['BTC'], + }), + }), + ); + }); + + it('still tracks the source_click interaction even when the URL is unsafe', () => { + mockIsSafeUrl.mockReturnValue(false); + renderWithProvider( + , + ); + fireEvent.press(screen.getByText('coindesk.com')); + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.WHATS_HAPPENING_INTERACTION, + ); + }); }); diff --git a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.tsx b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.tsx index 9f6a48a63e4..d9c2561cd6c 100644 --- a/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.tsx +++ b/app/components/Views/WhatsHappeningDetailView/components/WhatsHappeningSourcesBottomSheet.tsx @@ -15,23 +15,43 @@ import BottomSheetHeader from '../../../../component-library/components/BottomSh import { strings } from '../../../../../locales/i18n'; import ArticleRow from '../../../UI/MarketInsights/components/ArticleRow'; import { isSafeUrl } from '../../../UI/MarketInsights/utils/marketInsightsFormatting'; +import { MetaMetricsEvents } from '../../../../core/Analytics'; +import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics'; +import { WhatsHappeningInteractionType } from '../../Homepage/Sections/WhatsHappening/constants'; +import { getWhatsHappeningEventProps } from '../../Homepage/Sections/WhatsHappening/eventProperties'; +import type { WhatsHappeningItem } from '../../Homepage/Sections/WhatsHappening/types'; interface WhatsHappeningSourcesBottomSheetProps { onClose: () => void; articles: Article[]; + item: WhatsHappeningItem; + cardIndex: number; } const WhatsHappeningSourcesBottomSheet: React.FC< WhatsHappeningSourcesBottomSheetProps -> = ({ onClose, articles }) => { +> = ({ onClose, articles, item, cardIndex }) => { const tw = useTailwind(); const bottomSheetRef = useRef(null); + const { trackEvent, createEventBuilder } = useAnalytics(); - const handleSourcePress = useCallback((url: string) => { - if (isSafeUrl(url)) { - Linking.openURL(url); - } - }, []); + const handleSourcePress = useCallback( + (url: string) => { + trackEvent( + createEventBuilder(MetaMetricsEvents.WHATS_HAPPENING_INTERACTION) + .addProperties({ + ...getWhatsHappeningEventProps(item, cardIndex), + interaction_type: WhatsHappeningInteractionType.SourceClick, + source: url, + }) + .build(), + ); + if (isSafeUrl(url)) { + Linking.openURL(url); + } + }, + [item, cardIndex, trackEvent, createEventBuilder], + ); return ( + + + ); +} + +describeForPlatforms( + 'EIP-7702 sponsored send — relay API failure (activity / details status)', + () => { + it('maps failed transaction in state to Failed label and error tooltip (transaction details)', async () => { + const { getByText, getByTestId } = renderComponentViewScreen( + TransactionDetailsStatusRow, + { name: 'Eip7702RelayApiFailureActivity' }, + { state: relayFailedActivityState() }, + { transactionId: STAKING_TX_ID }, + ); + + await waitFor(() => + expect(getByText(strings('transaction.failed'))).toBeOnTheScreen(), + ); + + fireEvent.press(getByTestId(STATUS_ICON_TOOLTIP_OPEN_BUTTON_TEST_ID)); + + expect(getByText('Relay submission failed')).toBeOnTheScreen(); + }); + }, +); + +describeForPlatforms('EIP-7702 sponsored send — review (network fee)', () => { + const sponsoredGasFeeState = () => { + const state = cloneDeep(stakingDepositConfirmationState); + const tx = state.engine.backgroundState.TransactionController + .transactions[0] as { isGasFeeSponsored?: boolean }; + tx.isGasFeeSponsored = true; + return state; + }; + + beforeEach(() => { + setupSentinelNetworksRelayEnabledMock(); + }); + + afterEach(() => { + clearSentinelNetworksMocks(); + }); + + it('shows Paid by MetaMask on the gas row when sponsorship is allowed (smoke review step)', async () => { + const { getByTestId, getByText } = renderComponentViewScreen( + SponsoredGasFeeRowHarness, + { name: 'Eip7702SponsoredGasFee' }, + { state: sponsoredGasFeeState() }, + ); + + await waitFor( + () => { + expect( + getByTestId(ConfirmationRowComponentIDs.PAID_BY_METAMASK), + ).toBeOnTheScreen(); + }, + { timeout: 8000 }, + ); + expect(getByText('Paid by MetaMask')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/Views/confirmations/components/alert-banner/alert-system-security-failed.view.test.tsx b/app/components/Views/confirmations/components/alert-banner/alert-system-security-failed.view.test.tsx new file mode 100644 index 00000000000..0fa96b13b6a --- /dev/null +++ b/app/components/Views/confirmations/components/alert-banner/alert-system-security-failed.view.test.tsx @@ -0,0 +1,88 @@ +import '../../../../../../tests/component-view/mocks'; +import React from 'react'; +import { merge } from 'lodash'; +import { fireEvent, waitFor } from '@testing-library/react-native'; + +import { renderComponentViewScreen } from '../../../../../../tests/component-view/render'; +import { describeForPlatforms } from '../../../../../../tests/component-view/platform'; +import { typedSignV1ConfirmationState } from '../../../../../util/test/confirm-data-helpers'; +import { + ConfirmationTopSheetSelectorsIDs, + ConfirmationTopSheetSelectorsText, +} from '../../ConfirmationView.testIds'; +import { AlertsContextProvider } from '../../context/alert-system-context'; +import useBlockaidAlerts from '../../hooks/alerts/useBlockaidAlerts'; +import AlertBanner from './alert-banner'; +import { Reason, ResultType } from '../blockaid-banner/BlockaidBanner.types'; + +/** Matches `messageParams.requestId` on the typed-sign fixture (ppom / security alert key). */ +const TYPED_SIGN_SECURITY_ALERT_KEY = '2453610887'; + +/** No `req`/`chainId` so `BlockaidAlertContent` skips gzip (view env has no native gzip). */ +const securityValidationFailedResponse = { + result_type: ResultType.Failed, + reason: Reason.failed, + features: [] as string[], +}; + +/** + * Blockaid alerts only (same source as the first slice of `useConfirmationAlerts` for this state). + * Skips transaction-alert hooks that need QueryClient, hardware wallet, etc. + */ +function BlockaidAlertBannerHarness() { + const alerts = useBlockaidAlerts(); + return ( + + + + ); +} + +describeForPlatforms('Alert system (signatures)', () => { + /** + * Smoke `alert-system`: security validation API error shows the redesigned banner + * and failed-state copy (no signing success path). + */ + it('shows security alert when validation request fails', async () => { + const state = merge({}, typedSignV1ConfirmationState, { + securityAlerts: { + alerts: { + [TYPED_SIGN_SECURITY_ALERT_KEY]: securityValidationFailedResponse, + }, + }, + engine: { + backgroundState: { + PreferencesController: { + securityAlertsEnabled: true, + }, + }, + }, + }); + + const { getByTestId, getByText } = renderComponentViewScreen( + BlockaidAlertBannerHarness, + { name: 'SecurityValidationFailed' }, + { state }, + ); + + const bannerTestId = + ConfirmationTopSheetSelectorsIDs.SECURITY_ALERT_BANNER_REDESIGNED; + + await waitFor(() => { + expect(getByTestId(bannerTestId)).toBeOnTheScreen(); + }); + + expect( + getByText(ConfirmationTopSheetSelectorsText.BANNER_FAILED_TITLE), + ).toBeOnTheScreen(); + expect( + getByText(ConfirmationTopSheetSelectorsText.BANNER_FAILED_DESCRIPTION), + ).toBeOnTheScreen(); + + fireEvent.press(getByTestId(bannerTestId)); + + await waitFor(() => { + expect(getByTestId(bannerTestId)).toBeOnTheScreen(); + }); + }); +}); diff --git a/app/components/Views/confirmations/components/alert-banner/alert-system-siwe-inline-mismatch.view.test.tsx b/app/components/Views/confirmations/components/alert-banner/alert-system-siwe-inline-mismatch.view.test.tsx new file mode 100644 index 00000000000..3d4523ab038 --- /dev/null +++ b/app/components/Views/confirmations/components/alert-banner/alert-system-siwe-inline-mismatch.view.test.tsx @@ -0,0 +1,133 @@ +import '../../../../../../tests/component-view/mocks'; +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react-native'; +import { merge } from 'lodash'; + +import ExtendedKeyringTypes from '../../../../../constants/keyringTypes'; +import { HardwareWalletProvider } from '../../../../../core/HardwareWallet/HardwareWalletProvider'; +import { renderComponentViewScreen } from '../../../../../../tests/component-view/render'; +import { describeForPlatforms } from '../../../../../../tests/component-view/platform'; +import { siweSignatureConfirmationState } from '../../../../../util/test/confirm-data-helpers'; +import { + AlertModalSelectorsIDs, + AlertModalSelectorsText, + AlertTypeIDs, + ConfirmationFooterSelectorIDs, + ConfirmAlertModalSelectorsIDs, +} from '../../ConfirmationView.testIds'; +import { AlertsContextProvider } from '../../context/alert-system-context'; +import { ConfirmationContextProvider } from '../../context/confirmation-context'; +import { QRHardwareContextProvider } from '../../context/qr-hardware-context'; +import useDomainMismatchAlerts from '../../hooks/alerts/useDomainMismatchAlerts'; +import { NetworkAndOriginRow } from '../rows/transactions/network-and-origin-row/network-and-origin-row'; +import { Footer } from '../footer/footer'; + +/** + * `meta.url` origin must differ from the SIWE message domain so + * `isValidSIWEOrigin` fails (same idea as SIWE “bad domain” E2E). + */ +const SIWE_BAD_DOMAIN_STATE = merge({}, siweSignatureConfirmationState, { + engine: { + backgroundState: { + PreferencesController: { + securityAlertsEnabled: true, + }, + ApprovalController: { + pendingApprovals: { + '72424261-e22f-11ef-8e59-bf627a5d8354': { + requestData: { + meta: { + url: 'https://malicious.example.test/fake-dapp/', + }, + }, + }, + }, + }, + }, + }, +}); + +const SIWE_SIGNER_ADDRESS = + '0x8eeee1781fd885ff5ddef7789486676961873d12' as const; + +function seedEngineKeyringWithSiweSigner(): void { + const engineMock = jest.requireMock( + '../../../../../../app/core/Engine', + ) as unknown as { + default: { + context: { + KeyringController: { state: { keyrings: unknown[] } }; + }; + }; + }; + engineMock.default.context.KeyringController.state.keyrings = [ + { + type: ExtendedKeyringTypes.hd, + accounts: [SIWE_SIGNER_ADDRESS], + }, + ]; +} + +function SiweDomainMismatchInlineFlowHarness() { + const alerts = useDomainMismatchAlerts(); + return ( + + + + + +