diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 4b554ad7a6d7..0e649cc87a65 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -264,7 +264,6 @@ app/components/UI/CollectibleContractOverview @MetaMask/metamask-assets
app/components/UI/CollectibleContracts @MetaMask/metamask-assets
app/components/UI/CollectibleDetectionModal @MetaMask/metamask-assets
app/components/UI/CollectibleMedia @MetaMask/metamask-assets
-app/components/UI/CollectibleModal @MetaMask/metamask-assets
app/components/UI/CollectibleOverview @MetaMask/metamask-assets
app/components/UI/ConfirmAddAsset @MetaMask/metamask-assets
app/components/UI/DeFiPositions @MetaMask/metamask-assets
@@ -375,6 +374,10 @@ tests/websocket/ @MetaMask/qa
.github/actions/smart-e2e-selection/ @MetaMask/qa
.github/workflows/ai-pr-risk-analysis.yml @MetaMask/qa
.github/workflows/auto-label-not-ready-for-e2e.yml @MetaMask/qa
+.github/workflows/run-appium-e2e-workflow.yml @MetaMask/qa
+.github/workflows/run-appium-smoke-tests-android.yml @MetaMask/qa
+.github/workflows/run-appium-smoke-tests-ios.yml @MetaMask/qa
+.github/workflows/prebuild-wda-ios.yml @MetaMask/qa
.github/workflows/run-e2e-workflow.yml @MetaMask/qa
.github/workflows/run-e2e-api-specs.yml @MetaMask/qa
.github/workflows/run-e2e-regression-tests-android.yml @MetaMask/qa
diff --git a/.github/actions/setup-e2e-env/action.yml b/.github/actions/setup-e2e-env/action.yml
index 8eaf56a1e9c5..998011a50599 100644
--- a/.github/actions/setup-e2e-env/action.yml
+++ b/.github/actions/setup-e2e-env/action.yml
@@ -109,6 +109,12 @@ inputs:
— Detox does not require the Pods directory to run tests.
required: false
default: 'false'
+ install-applesimutils:
+ description: >-
+ Whether to install applesimutils (iOS only). Required by Detox but not
+ by Appium — set to 'false' for Appium test jobs
+ required: false
+ default: 'true'
runs:
using: 'composite'
@@ -176,7 +182,9 @@ runs:
run: |
set -euo pipefail
sudo mkdir -p "$CACHE_PATH" /opt/android-sdk/.temp
- sudo chown -R "$(id -u):$(id -g)" "$CACHE_PATH" /opt/android-sdk/.temp
+ # chown the parent dir too so sdkmanager can create x86_64.backup
+ # next to x86_64 during installation (requires write on the parent).
+ sudo chown -R "$(id -u):$(id -g)" "$(dirname "$CACHE_PATH")" /opt/android-sdk/.temp
shell: bash
# Restore exact system image from cache (GitHub only — Namespace uses nscloud-cache-action in callers)
@@ -444,7 +452,7 @@ runs:
COCOAPODS_DISABLE_STATS: 'true'
- name: Install applesimutils
- if: ${{ inputs.platform == 'ios' }}
+ if: ${{ inputs.platform == 'ios' && inputs.install-applesimutils == 'true' }}
run: |
if ! brew list applesimutils &>/dev/null; then
brew tap wix/brew
@@ -455,6 +463,6 @@ runs:
shell: bash
- name: Check simutils
- if: ${{ inputs.platform == 'ios' }}
+ if: ${{ inputs.platform == 'ios' && inputs.install-applesimutils == 'true' }}
run: xcrun simctl list devices
shell: bash
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 52fcd13f3c7a..0a83b9938e69 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1116,6 +1116,64 @@ jobs:
runner_provider: ${{ inputs.runner_provider }}
secrets: inherit
+ appium-smoke-gate:
+ name: 'Appium Smoke Gate'
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ci-linux' || 'ubuntu-latest' }}
+ needs: [smart-e2e-selection]
+ outputs:
+ enabled: ${{ steps.gate.outputs.enabled }}
+ steps:
+ - name: Checkout (Namespace)
+ uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1
+ if: ${{ inputs.runner_provider == 'namespace' }}
+ - name: Checkout
+ uses: actions/checkout@v6
+ if: ${{ inputs.runner_provider != 'namespace' }}
+ with:
+ sparse-checkout: |
+ scripts/e2e/appium-smoke-tags.mjs
+
+ - name: Evaluate whether Appium smoke should run
+ id: gate
+ env:
+ SELECTED_TAGS: >-
+ ${{
+ (fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 85 &&
+ needs.smart-e2e-selection.outputs.ai_e2e_test_tags) ||
+ '["ALL"]'
+ }}
+ run: |
+ enabled=$(node scripts/e2e/appium-smoke-tags.mjs "$SELECTED_TAGS")
+ echo "enabled=${enabled}" >> "$GITHUB_OUTPUT"
+
+ # Android Appium smoke (PR CI). iOS: run-appium-smoke-tests-ios-scheduled.yml.
+ appium-smoke-tests-android:
+ name: 'Appium Smoke Tests (Android)'
+ if: >-
+ ${{
+ !cancelled() &&
+ needs.appium-smoke-gate.outputs.enabled == 'true' &&
+ needs.build-android-apks.result == 'success'
+ }}
+ permissions:
+ contents: read
+ checks: write
+ id-token: write
+ needs: [build-android-apks, smart-e2e-selection, appium-smoke-gate]
+ uses: ./.github/workflows/run-appium-smoke-tests-android.yml
+ with:
+ build_type: 'main'
+ metamask_environment: 'e2e'
+ # Appium Android on Cirrus/GitHub-hosted runners until Namespace parity is ready.
+ runner_provider: current
+ selected_tags: >-
+ ${{
+ (fromJSON(needs.smart-e2e-selection.outputs.ai_confidence || '0') >= 85 &&
+ needs.smart-e2e-selection.outputs.ai_e2e_test_tags) ||
+ '["ALL"]'
+ }}
+ secrets: inherit
+
# Fixture validation — ensures committed E2E fixtures match the live app state schema
validate-e2e-fixtures:
name: 'Validate E2E Fixtures'
diff --git a/.github/workflows/prebuild-wda-ios.yml b/.github/workflows/prebuild-wda-ios.yml
new file mode 100644
index 000000000000..b622b41d948f
--- /dev/null
+++ b/.github/workflows/prebuild-wda-ios.yml
@@ -0,0 +1,63 @@
+# Prebuild WebDriverAgent for Appium iOS smoke tests.
+#
+# Called from run-appium-smoke-tests-ios-scheduled.yml alongside build-ios-e2e.yml.
+# Writes DerivedData to ~/appium-wda for prepare-ios-appium-runner.mjs and actions/cache.
+
+name: Prebuild WDA (Appium iOS)
+
+on:
+ workflow_call:
+ inputs:
+ runner_provider:
+ description: 'Runner provider (namespace or current)'
+ required: false
+ type: string
+ default: 'current'
+
+permissions:
+ contents: read
+
+jobs:
+ prebuild-wda:
+ name: prebuild-wda-ios
+ runs-on: ${{ inputs.runner_provider == 'namespace' && 'namespace-profile-metamask-ios-e2e' || 'macos-latest' }}
+
+ steps:
+ - name: Checkout (Namespace)
+ uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1
+ if: ${{ inputs.runner_provider == 'namespace' }}
+ - name: Checkout
+ uses: actions/checkout@v4
+ if: ${{ inputs.runner_provider != 'namespace' }}
+
+ - name: Setup E2E environment (WDA prebuild only)
+ uses: ./.github/actions/setup-e2e-env
+ with:
+ platform: ios
+ setup-simulator: 'false'
+ configure-keystores: 'false'
+ install-foundry: 'false'
+ skip-pod-install: 'true'
+ install-applesimutils: 'false'
+ runner_provider: ${{ inputs.runner_provider }}
+
+ - name: Resolve XCUITest driver version for WDA cache key
+ id: xcuitest-version
+ run: echo "version=$(node scripts/e2e/resolve-xcuitest-driver-version.mjs)" >> "$GITHUB_OUTPUT"
+
+ - name: Get Xcode version for WDA cache key
+ id: xcode-version
+ run: echo "version=$(xcodebuild -version 2>/dev/null | head -1 | tr ' ' '-')" >> "$GITHUB_OUTPUT"
+
+ - name: Restore WDA DerivedData cache
+ uses: actions/cache@v4
+ with:
+ path: ~/appium-wda
+ key: wda-derived-data-xcuitest-${{ steps.xcuitest-version.outputs.version }}-${{ steps.xcode-version.outputs.version }}-${{ runner.os }}
+ restore-keys: |
+ wda-derived-data-xcuitest-${{ steps.xcuitest-version.outputs.version }}-${{ steps.xcode-version.outputs.version }}-
+
+ - name: Prebuild WDA if cache miss
+ run: node scripts/e2e/prebuild-wda.mjs
+ env:
+ IOS_SIMULATOR_NAME: iPhone 16 Pro
diff --git a/.github/workflows/run-appium-e2e-workflow.yml b/.github/workflows/run-appium-e2e-workflow.yml
new file mode 100644
index 000000000000..5b1b5d1864b4
--- /dev/null
+++ b/.github/workflows/run-appium-e2e-workflow.yml
@@ -0,0 +1,354 @@
+# Runs Appium smoke tests for one smoke tag on Android or iOS.
+# Called from run-appium-smoke-tests-{android,ios}.yml (one job per tag/split).
+
+name: Run Appium E2E
+
+on:
+ workflow_call:
+ inputs:
+ test-suite-name:
+ description: 'Name of the test suite (used for artifacts and report paths)'
+ required: true
+ type: string
+ platform:
+ description: 'Platform to test (ios or android)'
+ required: true
+ type: string
+ test_suite_tag:
+ description: 'Smoke tag id to filter tests (e.g. SmokeAccounts matches describe titles)'
+ required: true
+ type: string
+ split_number:
+ description: 'Which shard to run (1-based index)'
+ required: false
+ type: number
+ default: 1
+ total_splits:
+ description: 'Total number of shards for this tag'
+ required: false
+ type: number
+ default: 1
+ test-timeout-minutes:
+ description: 'Timeout in minutes for the Playwright test step'
+ required: false
+ type: number
+ default: 25
+ build_type:
+ description: 'Build type (main or flask)'
+ required: false
+ type: string
+ default: 'main'
+ metamask_environment:
+ description: 'MetaMask environment'
+ required: false
+ type: string
+ default: 'e2e'
+ runner_provider:
+ description: 'Runner provider (namespace or current)'
+ required: false
+ type: string
+ default: 'current'
+
+permissions:
+ contents: read
+ checks: write
+
+jobs:
+ test-appium-mobile:
+ name: ${{ inputs.test-suite-name }}
+ runs-on: >-
+ ${{
+ inputs.runner_provider == 'namespace' &&
+ (inputs.platform == 'ios' && 'namespace-profile-metamask-ios-e2e' ||
+ 'namespace-profile-metamask-android-build') ||
+ (inputs.platform == 'ios' && 'macos-latest' ||
+ (startsWith(github.base_ref, 'release/') &&
+ fromJSON('["ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg"]') ||
+ fromJSON('["ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg", "low-priority"]')))
+ }}
+
+ steps:
+ - name: Checkout (Namespace)
+ uses: namespacelabs/nscloud-checkout-action@938f5d2d403d6224d9a0c0dc559b1dae09c2ede4 # v8.1.1
+ if: ${{ inputs.runner_provider == 'namespace' }}
+
+ - name: Checkout
+ uses: actions/checkout@v4
+ if: ${{ inputs.runner_provider != 'namespace' }}
+
+ - name: Setup E2E environment (Android)
+ if: ${{ inputs.platform == 'android' }}
+ uses: ./.github/actions/setup-e2e-env
+ with:
+ platform: android
+ setup-simulator: 'true'
+ android-avd-name: appium_smoke_avd
+ android-api-level: '34'
+ android-tag: default
+ android-abi: x86_64
+ android-device: pixel_5
+ configure-keystores: 'false'
+ install-foundry: 'true'
+ runner_provider: ${{ inputs.runner_provider }}
+
+ - name: Setup E2E environment (iOS)
+ if: ${{ inputs.platform == 'ios' }}
+ uses: ./.github/actions/setup-e2e-env
+ with:
+ platform: ios
+ setup-simulator: 'false'
+ configure-keystores: 'false'
+ install-foundry: 'true'
+ skip-pod-install: 'true'
+ install-applesimutils: 'false'
+ runner_provider: ${{ inputs.runner_provider }}
+
+ - name: Cache ffmpeg (Homebrew)
+ if: ${{ inputs.platform == 'ios' }}
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/mms-ffmpeg
+ key: brew-ffmpeg-${{ runner.os }}-v1
+ restore-keys: |
+ brew-ffmpeg-${{ runner.os }}-
+
+ - name: Install ffmpeg for XCUITest screen recording
+ if: ${{ inputs.platform == 'ios' }}
+ run: bash scripts/e2e/ensure-ffmpeg-ci.sh
+ shell: bash
+
+ - name: Determine Android artifact paths
+ if: ${{ inputs.platform == 'android' }}
+ id: android-artifacts
+ run: |
+ if [[ "${{ inputs.build_type }}" == "flask" ]]; then
+ {
+ echo "apk-target-path=android/app/build/outputs/apk/flask/release"
+ echo "apk-file-name=app-flask-release.apk"
+ } >> "$GITHUB_OUTPUT"
+ else
+ {
+ echo "apk-target-path=android/app/build/outputs/apk/prod/release"
+ echo "apk-file-name=app-prod-release.apk"
+ } >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Download Android build artifacts (Namespace)
+ if: ${{ inputs.platform == 'android' && inputs.runner_provider == 'namespace' }}
+ id: download-android-apk-namespace
+ continue-on-error: true
+ uses: namespace-actions/download-artifact@7cbad919e4b0e09f17e9d6311a444ff002992b5b # v2.0.1
+ with:
+ name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.apk
+ path: ${{ steps.android-artifacts.outputs.apk-target-path }}
+ - name: Download Android build artifacts (GitHub — build may be on Cirrus)
+ if: >-
+ ${{
+ inputs.platform == 'android' &&
+ inputs.runner_provider == 'namespace' &&
+ steps.download-android-apk-namespace.outcome != 'success'
+ }}
+ uses: actions/download-artifact@v4
+ with:
+ name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.apk
+ path: ${{ steps.android-artifacts.outputs.apk-target-path }}
+ - name: Download Android build artifacts (current)
+ if: ${{ inputs.platform == 'android' && inputs.runner_provider != 'namespace' }}
+ uses: actions/download-artifact@v4
+ with:
+ name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-release.apk
+ path: ${{ steps.android-artifacts.outputs.apk-target-path }}
+
+ - name: Download iOS build artifacts (Namespace)
+ if: ${{ inputs.platform == 'ios' && inputs.runner_provider == 'namespace' }}
+ uses: namespace-actions/download-artifact@7cbad919e4b0e09f17e9d6311a444ff002992b5b # v2.0.1
+ with:
+ name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app
+ path: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app
+ - name: Download iOS build artifacts (current)
+ if: ${{ inputs.platform == 'ios' && inputs.runner_provider != 'namespace' }}
+ uses: actions/download-artifact@v4
+ with:
+ name: ${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app
+ path: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app
+
+ - name: Restore iOS bundle executable permissions
+ if: ${{ inputs.platform == 'ios' }}
+ env:
+ APP_PATH: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app
+ run: |
+ BUNDLE_EXEC=$(/usr/libexec/PlistBuddy -c "Print CFBundleExecutable" "$APP_PATH/Info.plist" 2>/dev/null)
+ if [ -z "$BUNDLE_EXEC" ]; then
+ echo "Could not read CFBundleExecutable from Info.plist"
+ exit 1
+ fi
+
+ ACTUAL_PATH=$(find "$APP_PATH" -maxdepth 1 -iname "$BUNDLE_EXEC" -type f | head -1)
+ if [ -z "$ACTUAL_PATH" ]; then
+ echo "Bundle executable not found: $BUNDLE_EXEC"
+ exit 1
+ fi
+
+ if [ "$(basename "$ACTUAL_PATH")" != "$BUNDLE_EXEC" ]; then
+ mv "$ACTUAL_PATH" "$APP_PATH/${BUNDLE_EXEC}_fix"
+ mv "$APP_PATH/${BUNDLE_EXEC}_fix" "$APP_PATH/$BUNDLE_EXEC"
+ fi
+
+ chmod +x "$APP_PATH/$BUNDLE_EXEC"
+
+ if [ -d "$APP_PATH/Frameworks" ]; then
+ find "$APP_PATH/Frameworks" -type d -name "*.framework" | while IFS= read -r fw; do
+ binary="$fw/$(basename "$fw" .framework)"
+ if [ -f "$binary" ]; then
+ chmod +x "$binary"
+ fi
+ done
+ find "$APP_PATH/Frameworks" -type f -name "*.dylib" -exec chmod +x {} \;
+ fi
+ echo "Restored execute permissions on main binary and all framework binaries"
+ shell: bash
+
+ - name: Resolve XCUITest driver version for WDA cache key
+ if: ${{ inputs.platform == 'ios' }}
+ id: xcuitest-version
+ run: echo "version=$(node scripts/e2e/resolve-xcuitest-driver-version.mjs)" >> "$GITHUB_OUTPUT"
+
+ - name: Get Xcode version for WDA cache key
+ if: ${{ inputs.platform == 'ios' }}
+ id: xcode-version
+ run: echo "version=$(xcodebuild -version 2>/dev/null | head -1 | tr ' ' '-')" >> "$GITHUB_OUTPUT"
+
+ - name: Restore WDA DerivedData cache
+ if: ${{ inputs.platform == 'ios' }}
+ uses: actions/cache@v4
+ with:
+ path: ~/appium-wda
+ key: wda-derived-data-xcuitest-${{ steps.xcuitest-version.outputs.version }}-${{ steps.xcode-version.outputs.version }}-${{ runner.os }}
+ restore-keys: |
+ wda-derived-data-xcuitest-${{ steps.xcuitest-version.outputs.version }}-${{ steps.xcode-version.outputs.version }}-
+
+ - name: Check if WDA is prebuilt
+ if: ${{ inputs.platform == 'ios' }}
+ id: wda-prebuilt
+ run: |
+ WDA_APP=$(find ~/appium-wda/Build/Products -name 'WebDriverAgentRunner-Runner.app' -type d 2>/dev/null | head -1 || true)
+ XCTESTRUN=$(find ~/appium-wda/Build/Products -name '*.xctestrun' 2>/dev/null | head -1 || true)
+ if [ -n "$WDA_APP" ] && [ -n "$XCTESTRUN" ]; then
+ echo "ready=true" >> "$GITHUB_OUTPUT"
+ echo "WDA prebuilt artifacts found"
+ else
+ echo "ready=false" >> "$GITHUB_OUTPUT"
+ echo "WDA cache miss — prepare-ios-appium-runner will prebuild (parallel with sim boot)"
+ fi
+ shell: bash
+
+ - name: Prepare iOS Appium runner
+ if: ${{ inputs.platform == 'ios' }}
+ id: prepare-ios-appium
+ timeout-minutes: 30
+ run: node scripts/e2e/prepare-ios-appium-runner.mjs
+ env:
+ IOS_SIMULATOR_NAME: iPhone 16 Pro
+ IOS_APP_PATH: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app
+ IOS_BUNDLE_ID: io.metamask.MetaMask
+ SKIP_WDA_PREBUILD: ${{ steps.wda-prebuilt.outputs.ready == 'true' && 'true' || 'false' }}
+
+ - name: Resolve Appium test grep pattern
+ id: appium-grep
+ run: echo "pattern=${{ inputs.test_suite_tag }}" >> "$GITHUB_OUTPUT"
+ shell: bash
+
+ - name: Run Appium smoke tests (Android)
+ id: run-tests-android
+ if: ${{ inputs.platform == 'android' }}
+ timeout-minutes: ${{ inputs.test-timeout-minutes }}
+ run: >-
+ yarn playwright test
+ --config tests/playwright.smoke-appium.config.ts
+ --project android-smoke
+ --grep "${{ steps.appium-grep.outputs.pattern }}"
+ --shard=${{ inputs.split_number }}/${{ inputs.total_splits }}
+ --pass-with-no-tests
+ env:
+ APPIUM_SMOKE_SUITE_NAME: ${{ inputs.test-suite-name }}
+ APPIUM_SMOKE_JOB_TITLE: Appium ${{ inputs.test-suite-name }} (android)
+ APPIUM_SMOKE_ARTIFACT_NAME: appium-smoke-report-${{ inputs.test-suite-name }}
+ APPIUM_SMOKE_VIDEOS_ARTIFACT_NAME: appium-smoke-videos-${{ inputs.test-suite-name }}
+ APPIUM_RECORD_VIDEO_ON_FAILURE: 'true'
+ SKIP_APPIUM_STOP: 'true'
+ ANDROID_APK_PATH: ${{ steps.android-artifacts.outputs.apk-target-path }}/${{ steps.android-artifacts.outputs.apk-file-name }}
+ ANDROID_AVD_NAME: appium_smoke_avd
+ ANDROID_APPIUM_USE_PACKAGE_ONLY: 'true'
+ ANDROID_EMULATOR_CI_CORES: '4'
+
+ - name: Run Appium smoke tests (iOS)
+ id: run-tests-ios
+ if: ${{ inputs.platform == 'ios' }}
+ timeout-minutes: ${{ inputs.test-timeout-minutes }}
+ run: >-
+ yarn playwright test
+ --config tests/playwright.smoke-appium.config.ts
+ --project ios-smoke
+ --grep "${{ steps.appium-grep.outputs.pattern }}"
+ --shard=${{ inputs.split_number }}/${{ inputs.total_splits }}
+ --pass-with-no-tests
+ env:
+ APPIUM_SMOKE_SUITE_NAME: ${{ inputs.test-suite-name }}
+ APPIUM_SMOKE_JOB_TITLE: Appium ${{ inputs.test-suite-name }} (ios)
+ APPIUM_SMOKE_ARTIFACT_NAME: appium-smoke-report-${{ inputs.test-suite-name }}
+ APPIUM_SMOKE_VIDEOS_ARTIFACT_NAME: appium-smoke-videos-${{ inputs.test-suite-name }}
+ APPIUM_RECORD_VIDEO_ON_FAILURE: 'true'
+ SKIP_APPIUM_STOP: 'true'
+ IOS_APP_PATH: artifacts/${{ inputs.build_type }}-${{ inputs.metamask_environment }}-MetaMask.app
+ IOS_SIMULATOR_NAME: iPhone 16 Pro
+ IOS_SIMULATOR_UDID: ${{ steps.prepare-ios-appium.outputs.ios-simulator-udid }}
+ IOS_WDA_PREINSTALLED: ${{ steps.prepare-ios-appium.outputs.ios-wda-preinstalled }}
+ IOS_WDA_BUNDLE_ID: ${{ steps.prepare-ios-appium.outputs.ios-wda-bundle-id }}
+ SKIP_DEVICE_BOOT: 'true'
+ SKIP_APP_REINSTALL: 'true'
+ USE_PREBUILT_WDA: 'true'
+
+ - name: Upload Playwright HTML report (Namespace)
+ if: ${{ always() && inputs.runner_provider == 'namespace' }}
+ uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3
+ with:
+ name: appium-smoke-report-${{ inputs.test-suite-name }}
+ path: tests/test-reports/appium-smoke-report/${{ inputs.test-suite-name }}/
+ if-no-files-found: ignore
+ retention-days: 7
+ - name: Upload Playwright HTML report (current)
+ if: ${{ always() && inputs.runner_provider != 'namespace' }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: appium-smoke-report-${{ inputs.test-suite-name }}
+ path: tests/test-reports/appium-smoke-report/${{ inputs.test-suite-name }}/
+ if-no-files-found: ignore
+ retention-days: 7
+
+ - name: Upload failure screen recordings (Namespace)
+ if: ${{ (steps.run-tests-android.outcome == 'failure' || steps.run-tests-ios.outcome == 'failure') && inputs.runner_provider == 'namespace' }}
+ uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3
+ with:
+ name: appium-smoke-videos-${{ inputs.test-suite-name }}
+ path: tests/test-reports/appium-smoke-videos/${{ inputs.test-suite-name }}/
+ if-no-files-found: ignore
+ retention-days: 7
+ - name: Upload failure screen recordings (current)
+ if: ${{ (steps.run-tests-android.outcome == 'failure' || steps.run-tests-ios.outcome == 'failure') && inputs.runner_provider != 'namespace' }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: appium-smoke-videos-${{ inputs.test-suite-name }}
+ path: tests/test-reports/appium-smoke-videos/${{ inputs.test-suite-name }}/
+ if-no-files-found: ignore
+ retention-days: 7
+
+ - name: Publish test results to GitHub Checks
+ if: always()
+ uses: dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3
+ with:
+ name: Appium ${{ inputs.test-suite-name }} (${{ inputs.platform }})
+ path: tests/test-reports/appium-smoke-junit/${{ inputs.test-suite-name }}.xml
+ reporter: java-junit
+ fail-on-error: false
+ list-suites: all
+ list-tests: failed
diff --git a/.github/workflows/run-appium-smoke-tests-android.yml b/.github/workflows/run-appium-smoke-tests-android.yml
new file mode 100644
index 000000000000..c41dd2a6b4df
--- /dev/null
+++ b/.github/workflows/run-appium-smoke-tests-android.yml
@@ -0,0 +1,75 @@
+# Appium Smoke Tests — Android
+#
+# Fans out one reusable job per Appium smoke tag.
+# Register tags in scripts/e2e/appium-smoke-tags.mjs and add a matching job below.
+# Invoked from ci.yml when appium-smoke-gate and build-android-apks succeed.
+
+name: Appium Smoke Tests (Android)
+
+on:
+ workflow_call:
+ inputs:
+ build_type:
+ description: 'Build type (main or flask)'
+ required: false
+ type: string
+ default: 'main'
+ metamask_environment:
+ description: 'MetaMask environment'
+ required: false
+ type: string
+ default: 'e2e'
+ runner_provider:
+ description: 'Runner provider (namespace or current)'
+ required: false
+ type: string
+ default: 'current'
+ selected_tags:
+ description: 'JSON array of tags from smart E2E selector (e.g. ["SmokeAccounts"] or ["ALL"])'
+ required: false
+ type: string
+ default: '["ALL"]'
+ workflow_dispatch:
+ inputs:
+ build_type:
+ description: 'Build type (main or flask)'
+ required: false
+ type: string
+ default: 'main'
+ metamask_environment:
+ description: 'MetaMask environment'
+ required: false
+ type: string
+ default: 'e2e'
+ runner_provider:
+ description: 'Runner provider (namespace or current)'
+ required: false
+ type: string
+ default: 'current'
+ selected_tags:
+ description: 'JSON array of tags from smart E2E selector'
+ required: false
+ type: string
+ default: '["ALL"]'
+
+permissions:
+ contents: read
+ checks: write
+
+jobs:
+ appium-accounts-android-smoke:
+ if: >-
+ ${{
+ !cancelled() &&
+ (contains(fromJson(inputs.selected_tags), 'ALL') ||
+ contains(fromJson(inputs.selected_tags), 'SmokeAccounts'))
+ }}
+ uses: ./.github/workflows/run-appium-e2e-workflow.yml
+ with:
+ test-suite-name: appium-accounts-android-smoke
+ platform: android
+ test_suite_tag: SmokeAccounts
+ build_type: ${{ inputs.build_type }}
+ metamask_environment: ${{ inputs.metamask_environment }}
+ runner_provider: ${{ inputs.runner_provider }}
+ secrets: inherit
diff --git a/.github/workflows/run-appium-smoke-tests-ios.yml b/.github/workflows/run-appium-smoke-tests-ios.yml
new file mode 100644
index 000000000000..f56614c14152
--- /dev/null
+++ b/.github/workflows/run-appium-smoke-tests-ios.yml
@@ -0,0 +1,143 @@
+# Appium Smoke Tests — iOS
+#
+# Fans out one reusable job per Appium smoke tag.
+# Register tags in scripts/e2e/appium-smoke-tags.mjs and add a matching job below.
+#
+# Trigger behaviour:
+# schedule / workflow_dispatch → builds the iOS E2E artifact + prebuilds WDA, then runs tests.
+# workflow_call → skips build (assumed done upstream), runs tests only.
+
+name: Appium Smoke Tests (iOS)
+
+on:
+ schedule:
+ # 00:00, 06:00, 12:00, 18:00 UTC — four runs per day on main.
+ - cron: '0 */6 * * *'
+ workflow_call:
+ inputs:
+ build_type:
+ description: 'Build type (main or flask)'
+ required: false
+ type: string
+ default: 'main'
+ metamask_environment:
+ description: 'MetaMask environment'
+ required: false
+ type: string
+ default: 'e2e'
+ runner_provider:
+ description: 'Runner provider (namespace or current)'
+ required: false
+ type: string
+ default: 'current'
+ selected_tags:
+ description: 'JSON array of tags from smart E2E selector (e.g. ["SmokeAccounts"] or ["ALL"])'
+ required: false
+ type: string
+ default: '["ALL"]'
+ workflow_dispatch:
+ inputs:
+ build_type:
+ description: 'Build type (main or flask)'
+ required: false
+ type: string
+ default: 'main'
+ metamask_environment:
+ description: 'MetaMask environment'
+ required: false
+ type: string
+ default: 'e2e'
+ runner_provider:
+ description: 'Runner provider (namespace or current)'
+ required: false
+ type: choice
+ options:
+ - current
+ - namespace
+ default: current
+ selected_tags:
+ description: 'JSON array of smoke tags (e.g. ["SmokeAccounts"] or ["ALL"])'
+ required: false
+ type: string
+ default: '["ALL"]'
+
+permissions:
+ contents: read
+ checks: write
+ id-token: write
+ actions: read
+ statuses: read
+
+jobs:
+ # ── Build jobs (schedule / workflow_dispatch only) ──────────────────────────
+
+ native-build-fingerprint:
+ name: Compute native build fingerprint
+ if: github.event_name != 'workflow_call'
+ runs-on: ubuntu-latest
+ outputs:
+ fingerprint: ${{ steps.publish.outputs.fingerprint }}
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-node@v6
+ with:
+ node-version-file: '.nvmrc'
+ cache: yarn
+ - name: Install Yarn dependencies with retry
+ uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2
+ with:
+ timeout_minutes: 10
+ max_attempts: 3
+ retry_wait_seconds: 30
+ command: yarn install --immutable
+ - name: Compute native build fingerprint and post build-source-hash
+ id: publish
+ uses: ./.github/actions/post-build-source-hash
+ with:
+ github-token: ${{ github.token }}
+ post-status: ${{ (inputs.runner_provider || 'current') != 'namespace' }}
+ target-sha: ${{ github.sha }}
+
+ build-ios-apps:
+ name: Build iOS Apps
+ if: github.event_name != 'workflow_call'
+ needs: [native-build-fingerprint]
+ uses: ./.github/workflows/build-ios-e2e.yml
+ with:
+ build_type: ${{ inputs.build_type || 'main' }}
+ metamask_environment: ${{ inputs.metamask_environment || 'e2e' }}
+ source-fingerprint: ${{ needs.native-build-fingerprint.outputs.fingerprint }}
+ runner_provider: ${{ inputs.runner_provider || 'current' }}
+ secrets: inherit
+
+ prebuild-wda-for-appium-ios:
+ name: Prebuild WDA (Appium iOS)
+ if: github.event_name != 'workflow_call'
+ uses: ./.github/workflows/prebuild-wda-ios.yml
+ with:
+ runner_provider: ${{ inputs.runner_provider || 'current' }}
+ secrets: inherit
+
+ # ── Test fan-out ─────────────────────────────────────────────────────────────
+
+ appium-accounts-ios-smoke:
+ if: >-
+ ${{
+ !cancelled() &&
+ (needs.build-ios-apps.result == 'success' || needs.build-ios-apps.result == 'skipped') &&
+ (needs.prebuild-wda-for-appium-ios.result == 'success' ||
+ needs.prebuild-wda-for-appium-ios.result == 'skipped' ||
+ needs.prebuild-wda-for-appium-ios.result == 'failure') &&
+ (contains(fromJson(inputs.selected_tags || '["ALL"]'), 'ALL') ||
+ contains(fromJson(inputs.selected_tags || '["ALL"]'), 'SmokeAccounts'))
+ }}
+ needs: [build-ios-apps, prebuild-wda-for-appium-ios]
+ uses: ./.github/workflows/run-appium-e2e-workflow.yml
+ with:
+ test-suite-name: appium-accounts-ios-smoke
+ platform: ios
+ test_suite_tag: SmokeAccounts
+ build_type: ${{ inputs.build_type || 'main' }}
+ metamask_environment: ${{ inputs.metamask_environment || 'e2e' }}
+ runner_provider: ${{ inputs.runner_provider || 'current' }}
+ secrets: inherit
diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js
index b743f5766150..75fbb3001f68 100644
--- a/app/components/Nav/Main/MainNavigator.js
+++ b/app/components/Nav/Main/MainNavigator.js
@@ -64,7 +64,6 @@ import { ExploreFeed } from '../../Views/TrendingView/TrendingView';
import WhatsHappeningDetailView from '../../Views/WhatsHappeningDetailView';
import ExploreSearchScreen from '../../Views/TrendingView/Views/ExploreSearchScreen/ExploreSearchScreen';
import TrendingFeedSessionManager from '../../UI/Trending/services/TrendingFeedSessionManager';
-import CollectiblesDetails from '../../UI/CollectibleModal';
import OptinMetrics from '../../UI/OptinMetrics';
import RampRoutes from '../../UI/Ramp/Aggregator/routes';
@@ -169,7 +168,6 @@ import SitesFullView from '../../Views/SitesFullView/SitesFullView';
import { TokenDetails } from '../../UI/TokenDetails/Views/TokenDetails';
import BenefitFullView from '../../UI/Rewards/Views/BenefitFullView';
import BenefitsFullView from '../../UI/Rewards/Views/BenefitsFullView';
-import { getDeFiProtocolPositionDetailsNavbarOptions } from '../../UI/Navbar';
import MoneyTabPressTracker from '../../UI/Money/components/MoneyTabPressTracker';
import { withMessenger } from '../../../messengers/helpers/route-messenger-helpers';
@@ -1020,20 +1018,6 @@ const MainNavigator = () => {
initialRouteName={'Home'}
>
- ({
- overlayStyle: {
- opacity: 0,
- },
- }),
- }}
- />
{
{
({
- ...slideFromRightAnimation,
- ...getDeFiProtocolPositionDetailsNavbarOptions(navigation),
- headerStyle: {
- backgroundColor: colors.background.default,
- shadowColor: importedColors.transparent,
- elevation: 0,
- },
- })}
+ options={{ headerShown: false, ...slideFromRightAnimation }}
/>
{
///: BEGIN:ONLY_INCLUDE_IF(sample-feature)
@@ -1406,7 +1382,7 @@ const MainNavigator = () => {
({
}),
TransitionPresets: {
ModalSlideFromBottomIOS: {},
- ModalPresentationIOS: {},
},
}));
@@ -857,19 +856,6 @@ describe('MainNavigator', () => {
})) as ScreenChild[];
};
- it('includes CollectiblesDetails screen', () => {
- const container = renderWithProvider(, {
- state: initialRootState,
- });
-
- const screenProps = getScreenProps(container);
- const screen = screenProps?.find(
- (s) => s?.name === 'CollectiblesDetails',
- );
-
- expect(screen).toBeDefined();
- });
-
it('includes DeprecatedNetworkDetails screen', () => {
const container = renderWithProvider(, {
state: initialRootState,
@@ -995,6 +981,9 @@ describe('MainNavigator', () => {
const screen = screenProps?.find((s) => s?.name === 'ConfirmAddAsset');
expect(screen).toBeDefined();
+ expect(screen?.options?.headerShown).toBe(false);
+ expect(screen?.options?.animationEnabled).toBe(true);
+ expect(typeof screen?.options?.cardStyleInterpolator).toBe('function');
});
it('includes StakeScreens route', () => {
@@ -1078,6 +1067,9 @@ describe('MainNavigator', () => {
);
expect(screen).toBeDefined();
+ expect(screen?.options?.headerShown).toBe(false);
+ expect(screen?.options?.animationEnabled).toBe(true);
+ expect(typeof screen?.options?.cardStyleInterpolator).toBe('function');
});
it('includes Asset screen', () => {
diff --git a/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.test.tsx b/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.test.tsx
index af9356e4a413..cac3ec6af0fe 100644
--- a/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.test.tsx
+++ b/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.test.tsx
@@ -282,15 +282,13 @@ const mockFormatAddressToAssetId = jest.fn(
);
const mockIsNonEvmChainId = jest.fn(() => false);
jest.mock('@metamask/bridge-controller', () => ({
+ ...jest.requireActual('@metamask/bridge-controller'),
formatAddressToAssetId: (address: string, chainId: string) =>
mockFormatAddressToAssetId(address, chainId),
formatChainIdToCaip: jest.fn(
(chainId: string) => `eip155:${parseInt(chainId, 16)}`,
),
isNonEvmChainId: (chainId: string) => mockIsNonEvmChainId(chainId),
- UnifiedSwapBridgeEventName: {
- AssetDetailTooltipClicked: 'AssetDetailTooltipClicked',
- },
}));
jest.mock('../../../../../core/Multichain/utils', () => ({
diff --git a/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.tsx b/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.tsx
index dbbc3a93a8dc..9de89432910f 100644
--- a/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.tsx
+++ b/app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.tsx
@@ -32,6 +32,7 @@ import {
setTokenSelectorNetworkFilter,
} from '../../../../../core/redux/slices/bridge';
import {
+ FeatureId,
formatChainIdToCaip,
UnifiedSwapBridgeEventName,
} from '@metamask/bridge-controller';
@@ -361,6 +362,7 @@ export const BridgeTokenSelector: React.FC = () => {
token_contract: item.address,
chain_name: networkName,
chain_id: item.chainId,
+ feature_id: FeatureId.UNIFIED_SWAP_BRIDGE,
},
);
},
diff --git a/app/components/UI/Bridge/components/GaslessQuickPickOptions/index.tsx b/app/components/UI/Bridge/components/GaslessQuickPickOptions/index.tsx
index 90b6b6313812..86bf31faa3fd 100644
--- a/app/components/UI/Bridge/components/GaslessQuickPickOptions/index.tsx
+++ b/app/components/UI/Bridge/components/GaslessQuickPickOptions/index.tsx
@@ -1,5 +1,8 @@
import React, { useCallback, useMemo } from 'react';
-import { UnifiedSwapBridgeEventName } from '@metamask/bridge-controller';
+import {
+ FeatureId,
+ UnifiedSwapBridgeEventName,
+} from '@metamask/bridge-controller';
import { QuickPickButtonOption } from '../SwapsKeypad/types';
import { QuickPickButtons } from '../SwapsKeypad/QuickPickButtons';
import { useShouldRenderMaxOption } from '../../hooks/useShouldRenderMaxOption';
@@ -45,6 +48,7 @@ export const GaslessQuickPickOptions = ({
{
input: 'token_amount_source',
input_value: inputValue,
+ feature_id: FeatureId.UNIFIED_SWAP_BRIDGE,
...(preset && { input_amount_preset: preset }),
// This Bridge-specific event bypasses the shared analytics wrappers,
// so its A/B context still needs to be attached manually here.
diff --git a/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/index.ts b/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/index.ts
index 6cdb86f8e5d9..b78ae91577bb 100644
--- a/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/index.ts
+++ b/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/index.ts
@@ -3,6 +3,7 @@ import { useSelector } from 'react-redux';
import { debounce } from 'lodash';
import BigNumber from 'bignumber.js';
import {
+ FeatureId,
formatAddressToAssetId,
formatAddressToCaipReference,
} from '@metamask/bridge-controller';
@@ -165,6 +166,7 @@ export function buildBatchSellQuoteRequestData({
sourceToken,
sourceAmount,
),
+ feature_id: FeatureId.BATCH_SELL,
},
});
diff --git a/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/useBatchSellQuoteRequest.test.ts b/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/useBatchSellQuoteRequest.test.ts
index b32846f5fcd4..09eeba5c9864 100644
--- a/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/useBatchSellQuoteRequest.test.ts
+++ b/app/components/UI/Bridge/hooks/useBatchSellQuoteRequest/useBatchSellQuoteRequest.test.ts
@@ -1,6 +1,8 @@
import { act } from '@testing-library/react-native';
import { CaipAssetType, Hex } from '@metamask/utils';
+import { FeatureId } from '@metamask/bridge-controller';
+
import Engine from '../../../../../core/Engine';
import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider';
import { createBridgeTestState } from '../../testUtils';
@@ -212,6 +214,7 @@ describe('useBatchSellQuoteRequest', () => {
token_symbol_destination: 'USDC',
token_security_type_destination: null,
usd_amount_source: 1500,
+ feature_id: FeatureId.BATCH_SELL,
}),
}),
]);
@@ -293,6 +296,7 @@ describe('useBatchSellQuoteRequest', () => {
token_symbol_destination: 'USDC',
token_security_type_destination: null,
usd_amount_source: 1500,
+ feature_id: FeatureId.BATCH_SELL,
}),
);
expect(
@@ -304,6 +308,7 @@ describe('useBatchSellQuoteRequest', () => {
token_symbol_destination: 'USDC',
token_security_type_destination: null,
usd_amount_source: 250,
+ feature_id: FeatureId.BATCH_SELL,
}),
);
});
diff --git a/app/components/UI/Bridge/hooks/useBridgeQuoteEvents/useBridgeQuoteEvents.test.tsx b/app/components/UI/Bridge/hooks/useBridgeQuoteEvents/useBridgeQuoteEvents.test.tsx
index fb607661b6d3..f3ef369cd013 100644
--- a/app/components/UI/Bridge/hooks/useBridgeQuoteEvents/useBridgeQuoteEvents.test.tsx
+++ b/app/components/UI/Bridge/hooks/useBridgeQuoteEvents/useBridgeQuoteEvents.test.tsx
@@ -115,6 +115,7 @@ describe('useBridgeQuoteEvents', () => {
).toHaveBeenCalledWith('Unified SwapBridge Quotes Received', {
best_quote_provider: 'lifi_jupiter',
can_submit: true,
+ feature_id: 'unified_swap_bridge',
gas_included: false,
gas_included_7702: false,
has_sufficient_gas_for_quote: null,
diff --git a/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.test.ts b/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.test.ts
index 67194901d4af..7cf328218ded 100644
--- a/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.test.ts
+++ b/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.test.ts
@@ -2,6 +2,7 @@ import { renderHook } from '@testing-library/react-native';
import { useTrackAllQuotesSortedEvent } from './index';
import Engine from '../../../../../core/Engine';
import {
+ FeatureId,
SortOrder,
UnifiedSwapBridgeEventName,
type Quote,
@@ -201,6 +202,7 @@ describe('useTrackAllQuotesSortedEvent', () => {
token_symbol_source: 'ETH',
token_symbol_destination: 'USDC',
stx_enabled: true,
+ feature_id: FeatureId.UNIFIED_SWAP_BRIDGE,
sort_order: SortOrder.COST_ASC,
best_quote_provider: 'lifi',
});
diff --git a/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.ts b/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.ts
index ffb9106181f9..9c2312b55179 100644
--- a/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.ts
+++ b/app/components/UI/Bridge/hooks/useTrackAllQuotesSortedEvent/index.ts
@@ -1,4 +1,5 @@
import {
+ FeatureId,
formatProviderLabel,
getNativeAssetForChainId,
Quote,
@@ -48,6 +49,7 @@ export const useTrackAllQuotesSortedEvent = (
: ' '),
token_symbol_destination: destToken?.symbol ?? null,
stx_enabled: smartTransactionsEnabled,
+ feature_id: FeatureId.UNIFIED_SWAP_BRIDGE,
...(isBridge && {
sort_order: SortOrder.COST_ASC,
best_quote_provider: formatProviderLabel(quote),
diff --git a/app/components/UI/Bridge/hooks/useUnifiedSwapBridgeContext/index.ts b/app/components/UI/Bridge/hooks/useUnifiedSwapBridgeContext/index.ts
index dfeb9452a9e1..aea430c90430 100644
--- a/app/components/UI/Bridge/hooks/useUnifiedSwapBridgeContext/index.ts
+++ b/app/components/UI/Bridge/hooks/useUnifiedSwapBridgeContext/index.ts
@@ -1,3 +1,4 @@
+import { FeatureId } from '@metamask/bridge-controller';
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import {
@@ -66,6 +67,7 @@ export const useUnifiedSwapBridgeContext = () => {
security_warnings: getSecurityWarnings(toToken),
warnings: [], // TODO
usd_amount_source: usdAmountSource,
+ feature_id: FeatureId.UNIFIED_SWAP_BRIDGE,
}),
[smartTransactionsEnabled, fromToken, toToken, usdAmountSource],
);
diff --git a/app/components/UI/Bridge/hooks/useUnifiedSwapBridgeContext/useUnifiedSwapBridgeContext.test.ts b/app/components/UI/Bridge/hooks/useUnifiedSwapBridgeContext/useUnifiedSwapBridgeContext.test.ts
index 23f170f3166d..7f03f1927226 100644
--- a/app/components/UI/Bridge/hooks/useUnifiedSwapBridgeContext/useUnifiedSwapBridgeContext.test.ts
+++ b/app/components/UI/Bridge/hooks/useUnifiedSwapBridgeContext/useUnifiedSwapBridgeContext.test.ts
@@ -1,4 +1,5 @@
import '../../_mocks_/initialState';
+import { FeatureId } from '@metamask/bridge-controller';
import { createBridgeTestState } from '../../testUtils';
import { useUnifiedSwapBridgeContext } from '.';
import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider';
@@ -95,6 +96,7 @@ describe('useUnifiedSwapBridgeContext', () => {
security_warnings: [],
warnings: [],
usd_amount_source: 0,
+ feature_id: FeatureId.UNIFIED_SWAP_BRIDGE,
});
});
@@ -199,6 +201,7 @@ describe('useUnifiedSwapBridgeContext', () => {
security_warnings: [],
warnings: [],
usd_amount_source: 0,
+ feature_id: FeatureId.UNIFIED_SWAP_BRIDGE,
});
});
diff --git a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx
index 192f42223429..3ae85264f6ba 100644
--- a/app/components/UI/Card/Views/CardHome/CardHome.test.tsx
+++ b/app/components/UI/Card/Views/CardHome/CardHome.test.tsx
@@ -509,6 +509,7 @@ import Engine from '../../../../../core/Engine';
import { CardHomeSelectors } from './CardHome.testIds';
import { CARD_SUPPORT_EMAIL } from '../../constants';
import { isSolanaChainId } from '@metamask/bridge-controller';
+import { CardEntryPoint } from '../../util/metrics';
// Get references to the mocked functions
const mockSetActiveNetwork = Engine.context.NetworkController
@@ -6368,6 +6369,7 @@ describe('CardHome Component', () => {
expect(mockStartMoneyAccountLinkFlow).toHaveBeenCalledWith({
screen: Routes.CARD.HOME,
+ entrypoint: CardEntryPoint.CARD_HOME_MONEY_ACCOUNT_CARD,
});
});
@@ -6383,6 +6385,7 @@ describe('CardHome Component', () => {
expect(mockStartMoneyAccountLinkFlow).toHaveBeenCalledWith({
screen: Routes.CARD.HOME,
+ entrypoint: CardEntryPoint.CARD_HOME_MONEY_ACCOUNT_CARD,
});
});
diff --git a/app/components/UI/Card/Views/CardHome/CardHome.tsx b/app/components/UI/Card/Views/CardHome/CardHome.tsx
index 5eef8dbd1611..302162808f56 100644
--- a/app/components/UI/Card/Views/CardHome/CardHome.tsx
+++ b/app/components/UI/Card/Views/CardHome/CardHome.tsx
@@ -38,6 +38,7 @@ import { strings } from '../../../../../../locales/i18n';
import {
selectIsCardAuthenticated,
selectCardUserLocation,
+ selectCardHomeDataStatus,
} from '../../../../../selectors/cardController';
import {
CardStatus,
@@ -72,6 +73,7 @@ import CardHomeFooter from './components/CardHomeFooter';
import { useCardHomeActions } from './hooks/useCardHomeActions';
import { useCardHomeAnalytics } from './hooks/useCardHomeAnalytics';
import { useCardProvisioning } from './hooks/useCardProvisioning';
+import { CardEntryPoint, CardFlow, CardScreens } from '../../util/metrics';
interface CardHomeRouteParams {
showDeeplinkToast?: boolean;
@@ -124,8 +126,15 @@ const CardHome = () => {
} = useMoneyAccountCardLinkage();
const { apyPercent: moneyAccountApyPercent } = useMoneyAccountBalance();
const hasMetalCard = data?.card?.type === CardType.METAL;
+ const cardHomeDataStatus = useSelector(selectCardHomeDataStatus);
+ const isCardAnalyticsReady =
+ cardHomeDataStatus === 'success' || cardHomeDataStatus === 'error';
const handleLinkMoneyAccountCard = useCallback(
- () => startMoneyAccountLink({ screen: Routes.CARD.HOME }),
+ () =>
+ startMoneyAccountLink({
+ screen: Routes.CARD.HOME,
+ entrypoint: CardEntryPoint.CARD_HOME_MONEY_ACCOUNT_CARD,
+ }),
[startMoneyAccountLink],
);
@@ -427,6 +436,13 @@ const CardHome = () => {
onGetNowPress={handleLinkMoneyAccountCard}
onHeaderPress={handleLinkMoneyAccountCard}
onLinkPress={handleLinkMoneyAccountCard}
+ analyticsScreen={CardScreens.HOME}
+ analyticsEntryPoint={
+ CardEntryPoint.CARD_HOME_MONEY_ACCOUNT_CARD
+ }
+ analyticsFlow={CardFlow.MONEY_ACCOUNT_LINKAGE}
+ analyticsCardState="unlinked_card"
+ analyticsReady={isCardAnalyticsReady}
/>
({ name: 'built-event' }));
+const mockAddProperties = jest.fn(() => ({ build: mockBuild }));
+const mockCreateEventBuilder = jest.fn((_eventName?: unknown) => ({
+ addProperties: mockAddProperties,
+ build: mockBuild,
+}));
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
@@ -30,6 +37,13 @@ jest.mock('@react-navigation/native', () => ({
jest.mock('react-native-linear-gradient', () => 'LinearGradient');
+jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
+}));
+
// Mock useCardHomeData hook (SpendingLimit now reads from it)
jest.mock('../../hooks/useCardHomeData', () => ({
useCardHomeData: jest.fn(() => ({
diff --git a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx
index b7057417a890..46a9b8dec4e3 100644
--- a/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx
+++ b/app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx
@@ -34,6 +34,7 @@ import useSpendingLimitData from '../../hooks/useSpendingLimitData';
import { buildTokenIconUrl } from '../../util/buildTokenIconUrl';
import { mapCaipChainIdToChainName } from '../../util/mapCaipChainIdToChainName';
import { LINEA_CAIP_CHAIN_ID } from '../../util/buildTokenList';
+import { CardEntryPoint, CardFlow, CardScreens } from '../../util/metrics';
import AccountRow from './components/AccountRow';
import TokenRow from './components/TokenRow';
import SpendAndEarnPromoCard from './components/SpendAndEarnPromoCard';
@@ -327,6 +328,11 @@ const SpendingLimit: React.FC = ({ route }) => {
)}
diff --git a/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.test.tsx b/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.test.tsx
index b7fd738fbe0c..2aa872226e05 100644
--- a/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.test.tsx
+++ b/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.test.tsx
@@ -1,9 +1,31 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react-native';
import SpendAndEarnPromoCard from './SpendAndEarnPromoCard';
+import { MetaMetricsEvents } from '../../../../../../core/Analytics';
+import {
+ CardActions,
+ CardEntryPoint,
+ CardFlow,
+ CardScreens,
+} from '../../../util/metrics';
+
+const mockTrackEvent = jest.fn();
+const mockBuild = jest.fn(() => ({ name: 'built-event' }));
+const mockAddProperties = jest.fn(() => ({ build: mockBuild }));
+const mockCreateEventBuilder = jest.fn((_eventName?: unknown) => ({
+ addProperties: mockAddProperties,
+ build: mockBuild,
+}));
jest.mock('react-native-linear-gradient', () => 'LinearGradient');
+jest.mock('../../../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
+}));
+
jest.mock('../../../../../../../locales/i18n', () => ({
strings: (key: string, params?: Record) => {
if (key === 'card.card_spending_limit.spend_and_earn_description_apy') {
@@ -30,6 +52,11 @@ describe('SpendAndEarnPromoCard', () => {
cashbackPercent: 1,
onPress: jest.fn(),
};
+ const analytics = {
+ screen: CardScreens.SPENDING_LIMIT,
+ entrypoint: CardEntryPoint.SPENDING_LIMIT_SPEND_AND_EARN_PROMO,
+ flow: CardFlow.MONEY_ACCOUNT_LINKAGE,
+ };
beforeEach(() => {
jest.clearAllMocks();
@@ -81,4 +108,43 @@ describe('SpendAndEarnPromoCard', () => {
expect(screen.getByTestId('custom-promo')).toBeOnTheScreen();
});
+
+ it('tracks Card Viewed when analytics props are provided', () => {
+ render();
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_VIEWED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ screen: CardScreens.SPENDING_LIMIT,
+ entrypoint: CardEntryPoint.SPENDING_LIMIT_SPEND_AND_EARN_PROMO,
+ flow: CardFlow.MONEY_ACCOUNT_LINKAGE,
+ });
+ });
+
+ it('tracks the Use Money account CTA click before invoking onPress', () => {
+ const onPress = jest.fn();
+
+ render(
+ ,
+ );
+ jest.clearAllMocks();
+
+ fireEvent.press(screen.getByTestId('use-money-account-cta'));
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_BUTTON_CLICKED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ screen: CardScreens.SPENDING_LIMIT,
+ entrypoint: CardEntryPoint.SPENDING_LIMIT_SPEND_AND_EARN_PROMO,
+ flow: CardFlow.MONEY_ACCOUNT_LINKAGE,
+ action: CardActions.SPENDING_LIMIT_USE_MONEY_ACCOUNT_BUTTON,
+ });
+ expect(onPress).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.tsx b/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.tsx
index c3a19ee49d05..81530849356a 100644
--- a/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.tsx
+++ b/app/components/UI/Card/Views/SpendingLimit/components/SpendAndEarnPromoCard.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useCallback, useEffect, useRef } from 'react';
import { TouchableOpacity } from 'react-native';
import {
Box,
@@ -13,12 +13,24 @@ import {
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import { strings } from '../../../../../../../locales/i18n';
import ShimmerOverlay from './ShimmerOverlay';
+import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
+import { MetaMetricsEvents } from '../../../../../../core/Analytics';
+import {
+ CardActions,
+ CardEntryPoint,
+ CardScreens,
+} from '../../../util/metrics';
export interface SpendAndEarnPromoCardProps {
apyPercent?: number;
onPress: () => void;
testID?: string;
accessibilityLabel?: string;
+ analytics?: {
+ screen: CardScreens | string;
+ entrypoint: CardEntryPoint;
+ flow?: string;
+ };
}
// Pronounced dark sweep that reads against the white Primary button surface.
@@ -45,16 +57,51 @@ const SpendAndEarnPromoCard: React.FC = ({
onPress,
testID = 'use-money-account-cta',
accessibilityLabel,
+ analytics,
}) => {
const tw = useTailwind();
+ const { trackEvent, createEventBuilder } = useAnalytics();
+ const hasTrackedViewRef = useRef(false);
const resolvedAccessibilityLabel =
accessibilityLabel ??
strings('card.card_spending_limit.use_money_account_cta');
+ useEffect(() => {
+ if (hasTrackedViewRef.current || !analytics) return;
+ hasTrackedViewRef.current = true;
+
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.CARD_VIEWED)
+ .addProperties({
+ screen: analytics.screen,
+ entrypoint: analytics.entrypoint,
+ flow: analytics.flow,
+ })
+ .build(),
+ );
+ }, [analytics, trackEvent, createEventBuilder]);
+
+ const handlePress = useCallback(() => {
+ if (analytics) {
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED)
+ .addProperties({
+ screen: analytics.screen,
+ entrypoint: analytics.entrypoint,
+ flow: analytics.flow,
+ action: CardActions.SPENDING_LIMIT_USE_MONEY_ACCOUNT_BUTTON,
+ })
+ .build(),
+ );
+ }
+
+ onPress();
+ }, [analytics, trackEvent, createEventBuilder, onPress]);
+
return (
= ({
diff --git a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx
index c287f23b59f9..07461bbdbadd 100644
--- a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx
+++ b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.test.tsx
@@ -24,6 +24,12 @@ import Routes from '../../../../constants/navigation/Routes';
import { BAANX_MAX_LIMIT } from '../constants';
import { FundingStatus } from '../types';
import { useMoneyAccountCardLinkage } from './useMoneyAccountCardLinkage';
+import { MetaMetricsEvents } from '../../../../core/Analytics';
+import {
+ CardEntryPoint,
+ CardFlow,
+ CardLinkingFailureReason,
+} from '../util/metrics';
const mockDispatch = jest.fn();
jest.mock('react-redux', () => ({
@@ -114,6 +120,20 @@ jest.mock('../../../../util/theme', () => {
const mockUseSelector = useSelector as unknown as jest.Mock;
const mockResolveMoneyAccountCardToken =
resolveMoneyAccountCardToken as jest.Mock;
+const mockTrackEvent = jest.fn();
+const mockBuild = jest.fn(() => ({ name: 'built-event' }));
+const mockAddProperties = jest.fn(() => ({ build: mockBuild }));
+const mockCreateEventBuilder = jest.fn((_eventName?: unknown) => ({
+ addProperties: mockAddProperties,
+ build: mockBuild,
+}));
+
+jest.mock('../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
+}));
const MONEY_ACCOUNT_ADDRESS = '0x1234567890123456789012345678901234567890';
@@ -139,7 +159,7 @@ const buildSelectors = (
isCardholder?: boolean;
delegationSettings?: unknown;
isAlreadyDelegated?: boolean;
- pendingMoneyAccountCardLink?: boolean;
+ pendingMoneyAccountCardLink?: CardEntryPoint | null;
cardHomeDataStatus?: CardHomeDataStatusMock;
isMonadSponsorshipEnabled?: boolean;
moneyAccountCardLinkInProgress?: boolean;
@@ -152,7 +172,7 @@ const buildSelectors = (
isCardholder: false,
delegationSettings: { ok: true },
isAlreadyDelegated: false,
- pendingMoneyAccountCardLink: false,
+ pendingMoneyAccountCardLink: null,
cardHomeDataStatus: 'success' as CardHomeDataStatusMock,
isMonadSponsorshipEnabled: true,
moneyAccountCardLinkInProgress: false,
@@ -187,6 +207,12 @@ const applySelectorMocks = (state: ReturnType) => {
describe('useMoneyAccountCardLinkage', () => {
let mockShowToast: jest.Mock;
let mockToastRef: { current: { showToast: jest.Mock } };
+ const expectedLinkCardSheetRoute = (
+ entrypoint: CardEntryPoint | string = CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ ) => ({
+ screen: Routes.MONEY.MODALS.LINK_CARD_SHEET,
+ params: { entrypoint },
+ });
const renderLinkageHook = () =>
renderHook(() => useMoneyAccountCardLinkage(), {
@@ -327,7 +353,7 @@ describe('useMoneyAccountCardLinkage', () => {
expect(mockNavigate).toHaveBeenCalledTimes(1);
expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, {
- screen: Routes.MONEY.MODALS.LINK_CARD_SHEET,
+ ...expectedLinkCardSheetRoute(),
});
expect(mockLinkMoneyAccountCard).not.toHaveBeenCalled();
expect(mockShowToast).not.toHaveBeenCalled();
@@ -403,7 +429,7 @@ describe('useMoneyAccountCardLinkage', () => {
expect(mockNavigate).toHaveBeenCalledTimes(1);
expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, {
- screen: Routes.MONEY.MODALS.LINK_CARD_SHEET,
+ ...expectedLinkCardSheetRoute(),
});
expect(mockDispatch).not.toHaveBeenCalled();
expect(mockShowToast).not.toHaveBeenCalled();
@@ -460,7 +486,7 @@ describe('useMoneyAccountCardLinkage', () => {
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(mockDispatch).toHaveBeenCalledWith(
- setPendingMoneyAccountCardLink(true),
+ setPendingMoneyAccountCardLink(CardEntryPoint.MONEY_LINK_CARD_SHEET),
);
expect(mockNavigate).toHaveBeenCalledTimes(1);
expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, {
@@ -473,6 +499,26 @@ describe('useMoneyAccountCardLinkage', () => {
expect(mockShowToast).not.toHaveBeenCalled();
});
+ it('stores the origin entrypoint for the post-auth sheet resume', () => {
+ applySelectorMocks(
+ buildSelectors({ isCardAuthenticated: false, isCardholder: true }),
+ );
+ const { result } = renderLinkageHook();
+
+ act(() => {
+ result.current.startLinkFlow({
+ ...ORIGIN,
+ entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
+ });
+ });
+
+ expect(mockDispatch).toHaveBeenCalledWith(
+ setPendingMoneyAccountCardLink(
+ CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
+ ),
+ );
+ });
+
it('routes a not-authenticated non-cardholder to Card Onboarding without arming the sheet-resume flag', () => {
applySelectorMocks(
buildSelectors({ isCardAuthenticated: false, isCardholder: false }),
@@ -507,7 +553,7 @@ describe('useMoneyAccountCardLinkage', () => {
});
expect(mockDispatch).toHaveBeenCalledWith(
- setPendingMoneyAccountCardLink(true),
+ setPendingMoneyAccountCardLink(CardEntryPoint.MONEY_LINK_CARD_SHEET),
);
expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, {
screen: Routes.CARD.HOME,
@@ -544,28 +590,50 @@ describe('useMoneyAccountCardLinkage', () => {
describe('resume effect (pendingMoneyAccountCardLink)', () => {
it('opens the Link Card sheet and clears the flag when authenticated and canLink', () => {
- applySelectorMocks(buildSelectors({ pendingMoneyAccountCardLink: true }));
+ applySelectorMocks(
+ buildSelectors({
+ pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ }),
+ );
renderLinkageHook();
expect(mockDispatch).toHaveBeenCalledWith(
- setPendingMoneyAccountCardLink(false),
+ setPendingMoneyAccountCardLink(null),
);
expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, {
- screen: Routes.MONEY.MODALS.LINK_CARD_SHEET,
+ ...expectedLinkCardSheetRoute(),
});
});
+ it('preserves the pending entrypoint when opening the sheet after auth', () => {
+ applySelectorMocks(
+ buildSelectors({
+ pendingMoneyAccountCardLink:
+ CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
+ }),
+ );
+ renderLinkageHook();
+
+ expect(mockDispatch).toHaveBeenCalledWith(
+ setPendingMoneyAccountCardLink(null),
+ );
+ expect(mockNavigate).toHaveBeenCalledWith(
+ Routes.MONEY.MODALS.ROOT,
+ expectedLinkCardSheetRoute(CardEntryPoint.MONEY_HOME_ONBOARDING_CARD),
+ );
+ });
+
it('clears the flag silently when authenticated but already delegated', () => {
applySelectorMocks(
buildSelectors({
- pendingMoneyAccountCardLink: true,
+ pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET,
isAlreadyDelegated: true,
}),
);
renderLinkageHook();
expect(mockDispatch).toHaveBeenCalledWith(
- setPendingMoneyAccountCardLink(false),
+ setPendingMoneyAccountCardLink(null),
);
expect(mockNavigate).not.toHaveBeenCalled();
expect(mockShowToast).not.toHaveBeenCalled();
@@ -574,14 +642,14 @@ describe('useMoneyAccountCardLinkage', () => {
it('clears the flag silently when authenticated but requirements are missing', () => {
applySelectorMocks(
buildSelectors({
- pendingMoneyAccountCardLink: true,
+ pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET,
vaultConfig: undefined,
}),
);
renderLinkageHook();
expect(mockDispatch).toHaveBeenCalledWith(
- setPendingMoneyAccountCardLink(false),
+ setPendingMoneyAccountCardLink(null),
);
expect(mockNavigate).not.toHaveBeenCalled();
expect(mockShowToast).not.toHaveBeenCalled();
@@ -590,7 +658,7 @@ describe('useMoneyAccountCardLinkage', () => {
it('does nothing while the user is not yet authenticated', () => {
applySelectorMocks(
buildSelectors({
- pendingMoneyAccountCardLink: true,
+ pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET,
isCardAuthenticated: false,
}),
);
@@ -604,7 +672,7 @@ describe('useMoneyAccountCardLinkage', () => {
mockResolveMoneyAccountCardToken.mockReturnValue(null);
applySelectorMocks(
buildSelectors({
- pendingMoneyAccountCardLink: true,
+ pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET,
cardHomeDataStatus: 'loading',
}),
);
@@ -619,7 +687,7 @@ describe('useMoneyAccountCardLinkage', () => {
mockResolveMoneyAccountCardToken.mockReturnValue(null);
applySelectorMocks(
buildSelectors({
- pendingMoneyAccountCardLink: true,
+ pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET,
cardHomeDataStatus: 'idle',
}),
);
@@ -634,14 +702,14 @@ describe('useMoneyAccountCardLinkage', () => {
mockResolveMoneyAccountCardToken.mockReturnValue(null);
applySelectorMocks(
buildSelectors({
- pendingMoneyAccountCardLink: true,
+ pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET,
cardHomeDataStatus: 'success',
}),
);
renderLinkageHook();
expect(mockDispatch).toHaveBeenCalledWith(
- setPendingMoneyAccountCardLink(false),
+ setPendingMoneyAccountCardLink(null),
);
expect(mockNavigate).not.toHaveBeenCalled();
expect(mockShowToast).not.toHaveBeenCalled();
@@ -651,14 +719,14 @@ describe('useMoneyAccountCardLinkage', () => {
mockResolveMoneyAccountCardToken.mockReturnValue(null);
applySelectorMocks(
buildSelectors({
- pendingMoneyAccountCardLink: true,
+ pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET,
cardHomeDataStatus: 'error',
}),
);
renderLinkageHook();
expect(mockDispatch).toHaveBeenCalledWith(
- setPendingMoneyAccountCardLink(false),
+ setPendingMoneyAccountCardLink(null),
);
expect(mockNavigate).not.toHaveBeenCalled();
expect(mockShowToast).not.toHaveBeenCalled();
@@ -668,7 +736,7 @@ describe('useMoneyAccountCardLinkage', () => {
mockResolveMoneyAccountCardToken.mockReturnValue(null);
applySelectorMocks(
buildSelectors({
- pendingMoneyAccountCardLink: true,
+ pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET,
cardHomeDataStatus: 'loading',
}),
);
@@ -681,17 +749,17 @@ describe('useMoneyAccountCardLinkage', () => {
mockResolveMoneyAccountCardToken.mockReturnValue(MOCK_TOKEN);
applySelectorMocks(
buildSelectors({
- pendingMoneyAccountCardLink: true,
+ pendingMoneyAccountCardLink: CardEntryPoint.MONEY_LINK_CARD_SHEET,
cardHomeDataStatus: 'success',
}),
);
rerender();
expect(mockDispatch).toHaveBeenCalledWith(
- setPendingMoneyAccountCardLink(false),
+ setPendingMoneyAccountCardLink(null),
);
expect(mockNavigate).toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, {
- screen: Routes.MONEY.MODALS.LINK_CARD_SHEET,
+ ...expectedLinkCardSheetRoute(),
});
});
});
@@ -715,6 +783,17 @@ describe('useMoneyAccountCardLinkage', () => {
moneyAccountAddress: MONEY_ACCOUNT_ADDRESS,
delegationAmountHuman: BAANX_MAX_LIMIT,
});
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_STARTED,
+ );
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_COMPLETED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ flow: CardFlow.MONEY_ACCOUNT_LINKAGE,
+ entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ is_revoke: false,
+ });
});
it('does NOT navigate to the sheet (sheet is the caller, not the callee)', async () => {
@@ -810,6 +889,16 @@ describe('useMoneyAccountCardLinkage', () => {
expect(result.current.status).toBe('error');
expect(result.current.error?.message).toBe('boom');
expect(Logger.error).toHaveBeenCalled();
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ flow: CardFlow.MONEY_ACCOUNT_LINKAGE,
+ entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ reason: CardLinkingFailureReason.CONTROLLER_FAILED,
+ error_name: 'Error',
+ is_revoke: false,
+ });
const errorCall = mockShowToast.mock.calls.at(-1)?.[0];
expect(errorCall).toMatchObject({
@@ -833,6 +922,15 @@ describe('useMoneyAccountCardLinkage', () => {
expect(returned).toBe(false);
expect(result.current.status).toBe('cancelled');
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ flow: CardFlow.MONEY_ACCOUNT_LINKAGE,
+ entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ reason: CardLinkingFailureReason.USER_CANCELLED,
+ is_revoke: false,
+ });
// Only the pending toast should have fired — no error toast.
const lastCall = mockShowToast.mock.calls.at(-1)?.[0];
@@ -851,6 +949,18 @@ describe('useMoneyAccountCardLinkage', () => {
expect(returned).toBe(false);
expect(mockLinkMoneyAccountCard).not.toHaveBeenCalled();
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED,
+ );
+ expect(mockCreateEventBuilder).not.toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_STARTED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ flow: CardFlow.MONEY_ACCOUNT_LINKAGE,
+ entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ reason: CardLinkingFailureReason.PRECONDITION_FAILED,
+ is_revoke: false,
+ });
expect(mockShowToast).toHaveBeenCalledTimes(1);
expect(mockShowToast.mock.calls[0][0]).toMatchObject({
labelOptions: [{ label: 'Something went wrong linking your card' }],
@@ -973,6 +1083,7 @@ describe('useMoneyAccountCardLinkage', () => {
expect(returned).toBe(false);
expect(mockShowToast).not.toHaveBeenCalled();
expect(mockLinkMoneyAccountCard).not.toHaveBeenCalled();
+ expect(mockCreateEventBuilder).not.toHaveBeenCalled();
expect(result.current.status).toBe('idle');
expect(result.current.error).toBeNull();
expect(Logger.error).not.toHaveBeenCalled();
@@ -1010,6 +1121,17 @@ describe('useMoneyAccountCardLinkage', () => {
expect(result.current.status).toBe('idle');
expect(result.current.isLinking).toBe(false);
expect(result.current.error).toBeNull();
+ // Must emit a FAILED event to balance the STARTED already emitted,
+ // otherwise MetaMetrics is left with an orphan started event.
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith(
+ expect.objectContaining({
+ reason: CardLinkingFailureReason.CONTROLLER_FAILED,
+ error_name: 'CardLinkageInProgressError',
+ }),
+ );
});
});
});
diff --git a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx
index 388f1bc57310..0c23a7a9acae 100644
--- a/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx
+++ b/app/components/UI/Card/hooks/useMoneyAccountCardLinkage.tsx
@@ -49,6 +49,16 @@ import { CardLinkageInProgressError } from '../../../../core/Engine/controllers/
import { BAANX_MAX_LIMIT } from '../constants';
import { CardFundingToken } from '../types';
import { UserCancelledError } from './useCardDelegation';
+import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics';
+import {
+ IMetaMetricsEvent,
+ MetaMetricsEvents,
+} from '../../../../core/Analytics';
+import {
+ CardEntryPoint,
+ CardFlow,
+ CardLinkingFailureReason,
+} from '../util/metrics';
export type LinkageStatus =
| 'idle'
@@ -60,6 +70,7 @@ export type LinkageStatus =
export interface LinkFlowOrigin {
screen: string;
params?: object;
+ entrypoint?: CardEntryPoint;
}
export interface UseMoneyAccountCardLinkageReturn {
@@ -75,9 +86,10 @@ export interface UseMoneyAccountCardLinkageReturn {
error: Error | null;
startLinkFlow: (origin: LinkFlowOrigin) => void;
- openLinkCardSheet: () => void;
+ openLinkCardSheet: (entrypoint?: CardEntryPoint | string) => void;
confirmLinkInBackground: (options?: {
delegationAmountHuman?: string;
+ entrypoint?: CardEntryPoint | string;
}) => Promise;
reset: () => void;
}
@@ -96,6 +108,7 @@ export const useMoneyAccountCardLinkage =
const theme = useTheme();
const navigation = useNavigation();
const dispatch = useDispatch();
+ const { trackEvent, createEventBuilder } = useAnalytics();
const primaryMoneyAccount = useSelector(selectPrimaryMoneyAccount);
const vaultConfig = useSelector(selectMoneyAccountVaultConfig);
@@ -109,7 +122,7 @@ export const useMoneyAccountCardLinkage =
const isAlreadyDelegated = useSelector(
selectIsMoneyAccountDelegatedForCard,
);
- const pendingMoneyAccountCardLink = useSelector(
+ const pendingMoneyAccountCardLinkEntryPoint = useSelector(
selectPendingMoneyAccountCardLink,
);
const linkInProgress = useSelector(selectIsMoneyAccountCardLinkInProgress);
@@ -227,24 +240,47 @@ export const useMoneyAccountCardLinkage =
[theme.colors.error.default, toastRef],
);
- const openLinkCardSheet = useCallback((): void => {
- if (linkInProgress) {
- return;
- }
- if (!canLink || !primaryMoneyAccount?.address) {
- showErrorToast();
- return;
- }
- navigation.navigate(Routes.MONEY.MODALS.ROOT, {
- screen: Routes.MONEY.MODALS.LINK_CARD_SHEET,
- });
- }, [
- linkInProgress,
- canLink,
- primaryMoneyAccount?.address,
- navigation,
- showErrorToast,
- ]);
+ const trackMoneyAccountLinkingEvent = useCallback(
+ (
+ eventName: IMetaMetricsEvent,
+ properties: Record = {},
+ ) => {
+ trackEvent(
+ createEventBuilder(eventName)
+ .addProperties({
+ flow: CardFlow.MONEY_ACCOUNT_LINKAGE,
+ ...properties,
+ })
+ .build(),
+ );
+ },
+ [trackEvent, createEventBuilder],
+ );
+
+ const openLinkCardSheet = useCallback(
+ (entrypoint?: CardEntryPoint | string): void => {
+ if (linkInProgress) {
+ return;
+ }
+ if (!canLink || !primaryMoneyAccount?.address) {
+ showErrorToast();
+ return;
+ }
+ navigation.navigate(Routes.MONEY.MODALS.ROOT, {
+ screen: Routes.MONEY.MODALS.LINK_CARD_SHEET,
+ params: {
+ entrypoint: entrypoint ?? CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ },
+ });
+ },
+ [
+ linkInProgress,
+ canLink,
+ primaryMoneyAccount?.address,
+ navigation,
+ showErrorToast,
+ ],
+ );
const startLinkFlow = useCallback(
(origin: LinkFlowOrigin): void => {
@@ -266,12 +302,16 @@ export const useMoneyAccountCardLinkage =
return;
}
- openLinkCardSheet();
+ openLinkCardSheet(origin.entrypoint);
return;
}
if (isCardholder) {
- dispatch(setPendingMoneyAccountCardLink(true));
+ dispatch(
+ setPendingMoneyAccountCardLink(
+ origin.entrypoint ?? CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ ),
+ );
navigation.navigate(Routes.CARD.ROOT, {
screen: Routes.CARD.HOME,
params: {
@@ -306,16 +346,16 @@ export const useMoneyAccountCardLinkage =
);
useEffect(() => {
- if (!pendingMoneyAccountCardLink) return;
+ if (!pendingMoneyAccountCardLinkEntryPoint) return;
if (!isCardAuthenticated) return;
if (!hasRequirements || !primaryMoneyAccount?.address) {
- dispatch(setPendingMoneyAccountCardLink(false));
+ dispatch(setPendingMoneyAccountCardLink(null));
return;
}
if (isAlreadyDelegated) {
- dispatch(setPendingMoneyAccountCardLink(false));
+ dispatch(setPendingMoneyAccountCardLink(null));
return;
}
@@ -324,15 +364,16 @@ export const useMoneyAccountCardLinkage =
cardHomeDataStatus === 'success' ||
cardHomeDataStatus === 'error'
) {
- dispatch(setPendingMoneyAccountCardLink(false));
+ dispatch(setPendingMoneyAccountCardLink(null));
}
return;
}
- dispatch(setPendingMoneyAccountCardLink(false));
- openLinkCardSheet();
+ const entrypoint = pendingMoneyAccountCardLinkEntryPoint;
+ dispatch(setPendingMoneyAccountCardLink(null));
+ openLinkCardSheet(entrypoint);
}, [
- pendingMoneyAccountCardLink,
+ pendingMoneyAccountCardLinkEntryPoint,
isCardAuthenticated,
hasRequirements,
moneyAccountCardToken,
@@ -346,12 +387,23 @@ export const useMoneyAccountCardLinkage =
const confirmLinkInBackground = useCallback(
async (options?: {
delegationAmountHuman?: string;
+ entrypoint?: CardEntryPoint | string;
}): Promise => {
+ const entrypoint =
+ options?.entrypoint ?? CardEntryPoint.MONEY_LINK_CARD_SHEET;
const isRevoke =
options?.delegationAmountHuman !== undefined &&
parseFloat(options.delegationAmountHuman) === 0;
if (!canSubmitDelegation || !primaryMoneyAccount?.address) {
+ trackMoneyAccountLinkingEvent(
+ MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED,
+ {
+ entrypoint,
+ reason: CardLinkingFailureReason.PRECONDITION_FAILED,
+ is_revoke: isRevoke,
+ },
+ );
showErrorToast(isRevoke);
return false;
}
@@ -365,11 +417,25 @@ export const useMoneyAccountCardLinkage =
showPendingToast(isRevoke);
try {
+ trackMoneyAccountLinkingEvent(
+ MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_STARTED,
+ {
+ entrypoint,
+ is_revoke: isRevoke,
+ },
+ );
await Engine.context.CardController.linkMoneyAccountCard({
moneyAccountAddress: primaryMoneyAccount.address,
delegationAmountHuman:
options?.delegationAmountHuman ?? BAANX_MAX_LIMIT,
});
+ trackMoneyAccountLinkingEvent(
+ MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_COMPLETED,
+ {
+ entrypoint,
+ is_revoke: isRevoke,
+ },
+ );
setStatus('success');
showSuccessToast(isRevoke);
return true;
@@ -378,16 +444,45 @@ export const useMoneyAccountCardLinkage =
caught instanceof Error ? caught : new Error(String(caught));
if (linkageError instanceof UserCancelledError) {
+ trackMoneyAccountLinkingEvent(
+ MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED,
+ {
+ entrypoint,
+ reason: CardLinkingFailureReason.USER_CANCELLED,
+ is_revoke: isRevoke,
+ },
+ );
setStatus('cancelled');
return false;
}
if (linkageError instanceof CardLinkageInProgressError) {
+ trackMoneyAccountLinkingEvent(
+ MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED,
+ {
+ entrypoint,
+ reason: CardLinkingFailureReason.CONTROLLER_FAILED,
+ error_name: linkageError.name,
+ is_revoke: isRevoke,
+ },
+ );
setStatus('idle');
setError(null);
return false;
}
+ trackMoneyAccountLinkingEvent(
+ MetaMetricsEvents.CARD_MONEY_ACCOUNT_LINKING_FAILED,
+ {
+ entrypoint,
+ reason:
+ caught instanceof Error
+ ? CardLinkingFailureReason.CONTROLLER_FAILED
+ : CardLinkingFailureReason.UNKNOWN,
+ error_name: linkageError.name,
+ is_revoke: isRevoke,
+ },
+ );
Logger.error(linkageError, 'useMoneyAccountCardLinkage failed');
setError(linkageError);
setStatus('error');
@@ -401,6 +496,7 @@ export const useMoneyAccountCardLinkage =
showErrorToast,
showPendingToast,
showSuccessToast,
+ trackMoneyAccountLinkingEvent,
],
);
diff --git a/app/components/UI/Card/util/metrics.ts b/app/components/UI/Card/util/metrics.ts
index e79ee7584c91..be47f0305fb6 100644
--- a/app/components/UI/Card/util/metrics.ts
+++ b/app/components/UI/Card/util/metrics.ts
@@ -1,4 +1,7 @@
enum CardScreens {
+ HOME = 'HOME',
+ MONEY_HOME = 'MONEY_HOME',
+ MONEY_LINK_CARD_SHEET = 'MONEY_LINK_CARD_SHEET',
WELCOME = 'WELCOME',
AUTHENTICATION = 'AUTHENTICATION',
OTP_AUTHENTICATION = 'OTP_AUTHENTICATION',
@@ -57,6 +60,17 @@ enum CardActions {
UNFREEZE_CARD_BUTTON = 'UNFREEZE_CARD_BUTTON',
VIEW_PIN_BUTTON = 'VIEW_PIN_BUTTON',
CASHBACK_BUTTON = 'CASHBACK_BUTTON',
+ MONEY_ACCOUNT_CARD_ACTION_ROW_BUTTON = 'MONEY_ACCOUNT_CARD_ACTION_ROW_BUTTON',
+ MONEY_ACCOUNT_ONBOARDING_CARD_PRIMARY_BUTTON = 'MONEY_ACCOUNT_ONBOARDING_CARD_PRIMARY_BUTTON',
+ MONEY_ACCOUNT_ONBOARDING_CARD_SKIP_BUTTON = 'MONEY_ACCOUNT_ONBOARDING_CARD_SKIP_BUTTON',
+ MONEY_ACCOUNT_METAMASK_CARD_HEADER = 'MONEY_ACCOUNT_METAMASK_CARD_HEADER',
+ MONEY_ACCOUNT_METAMASK_CARD_GET_NOW_BUTTON = 'MONEY_ACCOUNT_METAMASK_CARD_GET_NOW_BUTTON',
+ MONEY_ACCOUNT_METAMASK_CARD_LINK_BUTTON = 'MONEY_ACCOUNT_METAMASK_CARD_LINK_BUTTON',
+ MONEY_ACCOUNT_METAMASK_CARD_MANAGE_BUTTON = 'MONEY_ACCOUNT_METAMASK_CARD_MANAGE_BUTTON',
+ MONEY_ACCOUNT_METAMASK_CARD_MANAGE_METAL_BUTTON = 'MONEY_ACCOUNT_METAMASK_CARD_MANAGE_METAL_BUTTON',
+ MONEY_LINK_CARD_SHEET_CONFIRM_BUTTON = 'MONEY_LINK_CARD_SHEET_CONFIRM_BUTTON',
+ MONEY_LINK_CARD_SHEET_CLOSE_BUTTON = 'MONEY_LINK_CARD_SHEET_CLOSE_BUTTON',
+ SPENDING_LIMIT_USE_MONEY_ACCOUNT_BUTTON = 'SPENDING_LIMIT_USE_MONEY_ACCOUNT_BUTTON',
}
enum CardDeeplinkActions {
@@ -64,4 +78,50 @@ enum CardDeeplinkActions {
CARD_HOME = 'CARD_HOME',
}
-export { CardScreens, CardActions, CardDeeplinkActions };
+enum CardEntryPoint {
+ MONEY_HOME_ACTION_ROW = 'MONEY_HOME_ACTION_ROW',
+ MONEY_HOME_ONBOARDING_CARD = 'MONEY_HOME_ONBOARDING_CARD',
+ MONEY_HOME_METAMASK_CARD = 'MONEY_HOME_METAMASK_CARD',
+ CARD_HOME_MONEY_ACCOUNT_CARD = 'CARD_HOME_MONEY_ACCOUNT_CARD',
+ MONEY_LINK_CARD_SHEET = 'MONEY_LINK_CARD_SHEET',
+ SPENDING_LIMIT_SPEND_AND_EARN_PROMO = 'SPENDING_LIMIT_SPEND_AND_EARN_PROMO',
+}
+
+enum CardFlow {
+ MONEY_ACCOUNT_LINKAGE = 'money_account_linkage',
+}
+
+enum CardLinkingFailureReason {
+ PRECONDITION_FAILED = 'PRECONDITION_FAILED',
+ USER_CANCELLED = 'USER_CANCELLED',
+ CONTROLLER_FAILED = 'CONTROLLER_FAILED',
+ UNKNOWN = 'UNKNOWN',
+}
+
+type CardState = 'non_cardholder' | 'no_card' | 'unlinked_card' | 'linked_card';
+
+const deriveCardState = ({
+ isCardholder,
+ isCardAuthenticated,
+ isCardLinkedToMoneyAccount,
+}: {
+ isCardholder: boolean;
+ isCardAuthenticated: boolean;
+ isCardLinkedToMoneyAccount: boolean;
+}): CardState => {
+ if (!isCardholder) return 'non_cardholder';
+ if (isCardLinkedToMoneyAccount) return 'linked_card';
+ if (isCardAuthenticated) return 'unlinked_card';
+ return 'no_card';
+};
+
+export {
+ CardScreens,
+ CardActions,
+ CardDeeplinkActions,
+ CardEntryPoint,
+ CardFlow,
+ CardLinkingFailureReason,
+ deriveCardState,
+};
+export type { CardState };
diff --git a/app/components/UI/CollectibleModal/CollectibleModal.styles.ts b/app/components/UI/CollectibleModal/CollectibleModal.styles.ts
deleted file mode 100644
index 57ef73bcb6ac..000000000000
--- a/app/components/UI/CollectibleModal/CollectibleModal.styles.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { StyleSheet } from 'react-native';
-import Device from '../../../util/device';
-import { colors } from '../../../styles/common';
-
-const styles = StyleSheet.create({
- bottomModal: {
- justifyContent: 'flex-end',
- margin: 0,
- },
- round: {
- borderRadius: 12,
- },
- collectibleMediaWrapper: {
- position: 'absolute',
- top: 32,
- left: 0,
- right: 0,
- marginHorizontal: 16,
- marginTop: Device.hasNotch() ? 36 : 16,
- borderRadius: 12,
- backgroundColor: colors.transparent,
- },
-});
-
-export default styles;
diff --git a/app/components/UI/CollectibleModal/CollectibleModal.test.tsx b/app/components/UI/CollectibleModal/CollectibleModal.test.tsx
deleted file mode 100644
index f2774b31f366..000000000000
--- a/app/components/UI/CollectibleModal/CollectibleModal.test.tsx
+++ /dev/null
@@ -1,208 +0,0 @@
-import React from 'react';
-import { CHAIN_IDS } from '@metamask/transaction-controller';
-
-import CollectibleModal from './CollectibleModal';
-
-import renderWithProvider from '../../../util/test/renderWithProvider';
-import { backgroundState } from '../../../util/test/initial-root-state';
-import { collectiblesSelector } from '../../../reducers/collectibles';
-import {
- selectDisplayNftMedia,
- selectIsIpfsGatewayEnabled,
-} from '../../../selectors/preferencesController';
-import { useSelector } from 'react-redux';
-import { selectChainId } from '../../../selectors/networkController';
-import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../util/test/accountsControllerTestUtils';
-import { mockNetworkState } from '../../../util/test/network';
-
-const mockInitialState = {
- engine: {
- backgroundState: {
- ...backgroundState,
- AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE,
- NetworkController: {
- ...mockNetworkState({
- chainId: CHAIN_IDS.MAINNET,
- id: 'mainnet',
- nickname: 'Ethereum Mainnet',
- ticker: 'ETH',
- }),
- },
- },
- },
-};
-
-// Set two collectibles with the same address
-const collectibles = [
- { name: 'Lion', tokenId: 6903, address: '0x123' },
- { name: 'Leopard', tokenId: 6904, address: '0x123' },
-];
-
-jest.mock('react-redux', () => ({
- ...jest.requireActual('react-redux'),
- useSelector: jest.fn(),
-}));
-
-const mockTrackEvent = jest.fn();
-const mockCreateEventBuilder = jest.fn(() => ({
- addProperties: jest.fn().mockReturnThis(),
- build: jest.fn().mockReturnValue({
- properties: { chain_id: 1 },
- }),
-}));
-
-jest.mock('../../hooks/useAnalytics/useAnalytics', () => ({
- useAnalytics: () => ({
- trackEvent: mockTrackEvent,
- createEventBuilder: mockCreateEventBuilder,
- }),
-}));
-
-const mockedNavigate = jest.fn();
-const mockedReplace = jest.fn();
-const mockUseRoute = jest.fn();
-
-jest.mock('@react-navigation/native', () => ({
- ...jest.requireActual('@react-navigation/native'),
- useNavigation: () => ({
- navigate: mockedNavigate,
- replace: mockedReplace,
- }),
- useRoute: () => mockUseRoute(),
-}));
-
-describe('CollectibleModal', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- // Default route params without source
- mockUseRoute.mockReturnValue({
- params: {
- contractName: 'Opensea',
- collectible: { name: 'Leopard', tokenId: 6904, address: '0x123' },
- },
- });
- });
-
- afterEach(() => {
- (useSelector as jest.Mock).mockClear();
- });
- it('renders correctly', async () => {
- (useSelector as jest.Mock).mockImplementation((selector) => {
- if (selector === collectiblesSelector) return collectibles;
- if (selector === selectIsIpfsGatewayEnabled) return false;
- if (selector === selectDisplayNftMedia) return false;
- if (selector === selectChainId) return '0x1';
- return undefined;
- });
- const { getByText } = renderWithProvider(, {
- state: mockInitialState,
- });
-
- expect(getByText('Leopard')).toBeOnTheScreen();
- });
-
- it('renders the correct token name and ID', async () => {
- (useSelector as jest.Mock).mockImplementation((selector) => {
- if (selector === collectiblesSelector) return collectibles;
- if (selector === selectIsIpfsGatewayEnabled) return true;
- if (selector === selectDisplayNftMedia) return true;
- if (selector === selectChainId) return '0x1';
- return undefined;
- });
-
- const { findAllByText } = renderWithProvider(, {
- state: mockInitialState,
- });
-
- // eslint-disable-next-line @metamask/design-tokens/color-no-hex -- false positive: '#6904' is the NFT token ID text, not a color literal
- expect(await findAllByText('#6904')).toBeDefined();
- expect(await findAllByText('Leopard')).toBeDefined();
- });
-
- it('tracks NFT Details Opened event', () => {
- (useSelector as jest.Mock).mockImplementation((selector) => {
- if (selector === collectiblesSelector) return collectibles;
- if (selector === selectIsIpfsGatewayEnabled) return false;
- if (selector === selectDisplayNftMedia) return false;
- if (selector === selectChainId) return '0x1';
- return undefined;
- });
-
- renderWithProvider(, {
- state: mockInitialState,
- });
-
- expect(mockCreateEventBuilder).toHaveBeenCalled();
- expect(mockTrackEvent).toHaveBeenCalled();
- });
-
- it('tracks NFT Details Opened event with mobile-nft-list source', () => {
- const mockAddProperties = jest.fn().mockReturnThis();
- const mockBuild = jest.fn();
- mockCreateEventBuilder.mockReturnValue({
- addProperties: mockAddProperties,
- build: mockBuild,
- });
-
- mockUseRoute.mockReturnValue({
- params: {
- contractName: 'Opensea',
- collectible: { name: 'Leopard', tokenId: 6904, address: '0x123' },
- source: 'mobile-nft-list',
- },
- });
-
- (useSelector as jest.Mock).mockImplementation((selector) => {
- if (selector === collectiblesSelector) return collectibles;
- if (selector === selectIsIpfsGatewayEnabled) return false;
- if (selector === selectDisplayNftMedia) return false;
- if (selector === selectChainId) return '0x1';
- return undefined;
- });
-
- renderWithProvider(, {
- state: mockInitialState,
- });
-
- expect(mockAddProperties).toHaveBeenCalledWith(
- expect.objectContaining({
- source: 'mobile-nft-list',
- }),
- );
- });
-
- it('tracks NFT Details Opened event with mobile-nft-list-page source', () => {
- const mockAddProperties = jest.fn().mockReturnThis();
- const mockBuild = jest.fn();
- mockCreateEventBuilder.mockReturnValue({
- addProperties: mockAddProperties,
- build: mockBuild,
- });
-
- mockUseRoute.mockReturnValue({
- params: {
- contractName: 'Opensea',
- collectible: { name: 'Leopard', tokenId: 6904, address: '0x123' },
- source: 'mobile-nft-list-page',
- },
- });
-
- (useSelector as jest.Mock).mockImplementation((selector) => {
- if (selector === collectiblesSelector) return collectibles;
- if (selector === selectIsIpfsGatewayEnabled) return false;
- if (selector === selectDisplayNftMedia) return false;
- if (selector === selectChainId) return '0x1';
- return undefined;
- });
-
- renderWithProvider(, {
- state: mockInitialState,
- });
-
- expect(mockAddProperties).toHaveBeenCalledWith(
- expect.objectContaining({
- source: 'mobile-nft-list-page',
- }),
- );
- });
-});
diff --git a/app/components/UI/CollectibleModal/CollectibleModal.tsx b/app/components/UI/CollectibleModal/CollectibleModal.tsx
deleted file mode 100644
index 97108048c106..000000000000
--- a/app/components/UI/CollectibleModal/CollectibleModal.tsx
+++ /dev/null
@@ -1,164 +0,0 @@
-/* eslint-disable react/prop-types */
-import React, {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from 'react';
-import { View } from 'react-native';
-import { useSelector } from 'react-redux';
-import CollectibleMedia from '../CollectibleMedia';
-import { baseStyles } from '../../../styles/common';
-import ReusableModal, { ReusableModalRef } from '../ReusableModal';
-import Routes from '../../../constants/navigation/Routes';
-import CollectibleOverview from '../../UI/CollectibleOverview';
-import { collectiblesSelector } from '../../../reducers/collectibles';
-import {
- selectDisplayNftMedia,
- selectIsIpfsGatewayEnabled,
-} from '../../../selectors/preferencesController';
-import styles from './CollectibleModal.styles';
-import { CollectibleModalParams } from './CollectibleModal.types';
-import { useNavigation } from '@react-navigation/native';
-import { useParams } from '../../../util/navigation/navUtils';
-import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics';
-import { MetaMetricsEvents } from '../../../core/Analytics';
-import { selectChainId } from '../../../selectors/networkController';
-import { getDecimalChainId } from '../../../util/networks';
-import { Nft } from '@metamask/assets-controllers';
-import { EXTERNAL_LINK_TYPE } from '../../../constants/browser';
-import { InitSendLocation } from '../../Views/confirmations/constants/send';
-import { useSendNavigation } from '../../Views/confirmations/hooks/useSendNavigation';
-
-const CollectibleModal = () => {
- const navigation = useNavigation();
- const { trackEvent, createEventBuilder } = useAnalytics();
- const chainId = useSelector(selectChainId);
-
- const { contractName, collectible, source } =
- useParams();
-
- const modalRef = useRef(null);
-
- const [mediaZIndex, setMediaZIndex] = useState(20);
- const [overviewZIndex, setOverviewZIndex] = useState(10);
-
- const [updatedCollectible, setUpdatedCollectible] = useState(collectible);
-
- const collectibles: Nft[] = useSelector(collectiblesSelector);
- const isIpfsGatewatEnabled = useSelector(selectIsIpfsGatewayEnabled);
- const displayNftMedia = useSelector(selectDisplayNftMedia);
- const { navigateToSendPage } = useSendNavigation();
-
- const handleUpdateCollectible = useCallback(() => {
- if (isIpfsGatewatEnabled || displayNftMedia) {
- const newUpdatedCollectible = collectibles.find(
- (nft: Nft) =>
- nft.address === collectible.address &&
- nft.tokenId === collectible.tokenId,
- );
-
- if (newUpdatedCollectible) {
- setUpdatedCollectible(newUpdatedCollectible);
- }
- }
- }, [isIpfsGatewatEnabled, collectibles, collectible, displayNftMedia]);
-
- useEffect(() => {
- handleUpdateCollectible();
- }, [handleUpdateCollectible]);
-
- useEffect(() => {
- trackEvent(
- createEventBuilder(MetaMetricsEvents.NFT_DETAILS_OPENED)
- .addProperties({
- chain_id: getDecimalChainId(chainId),
- ...(source && { source }),
- })
- .build(),
- );
- // The linter wants `trackEvent` to be added as a dependency,
- // But the event fires twice if I do that.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [chainId, source]);
-
- const onSend = useCallback(async () => {
- navigateToSendPage({
- location: InitSendLocation.CollectibleModal,
- asset: collectible,
- });
- }, [collectible, navigateToSendPage]);
-
- const isTradable = useCallback(
- () => collectible.standard === 'ERC721',
- [collectible],
- );
-
- const openLink = useCallback(
- (url: string) => {
- navigation.navigate(Routes.BROWSER_TAB_HOME, {
- screen: Routes.BROWSER_VIEW,
- params: {
- newTabUrl: url,
- linkType: EXTERNAL_LINK_TYPE,
- timestamp: Date.now(),
- },
- });
- },
- [navigation],
- );
-
- const onCollectibleOverviewTranslation = (moveUp: boolean) => {
- if (moveUp) {
- setTimeout(() => {
- setMediaZIndex(20);
- setOverviewZIndex(10);
- }, 250);
- } else {
- setMediaZIndex(0);
- setOverviewZIndex(10);
- }
- };
- const collectibleData = useMemo(
- () => ({ ...collectible, ...updatedCollectible, contractName }),
- [collectible, contractName, updatedCollectible],
- );
- return (
-
- <>
-
- modalRef.current?.dismissModal()}
- cover
- renderAnimation
- collectible={collectibleData}
- style={styles.round}
- />
-
-
-
-
- >
-
- );
-};
-
-export default CollectibleModal;
diff --git a/app/components/UI/CollectibleModal/CollectibleModal.types.ts b/app/components/UI/CollectibleModal/CollectibleModal.types.ts
deleted file mode 100644
index dd942afca120..000000000000
--- a/app/components/UI/CollectibleModal/CollectibleModal.types.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { Nft } from '@metamask/assets-controllers';
-
-export interface CollectibleModalParams {
- contractName: string;
- collectible: Nft;
- source?: 'mobile-nft-list' | 'mobile-nft-list-page';
-}
-
-export interface ReusableModalRef {
- dismissModal: () => void;
-}
diff --git a/app/components/UI/CollectibleModal/index.ts b/app/components/UI/CollectibleModal/index.ts
deleted file mode 100644
index 0149213ef96f..000000000000
--- a/app/components/UI/CollectibleModal/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './CollectibleModal';
diff --git a/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.styles.ts b/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.styles.ts
index 81bc4b641b02..4f16f324ce82 100644
--- a/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.styles.ts
+++ b/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.styles.ts
@@ -18,6 +18,9 @@ const styleSheet = () =>
protocolPositionDetailsWrapper: {
flex: 1,
},
+ protocolPositionDetailsContent: {
+ flex: 1,
+ },
});
export default styleSheet;
diff --git a/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.test.tsx b/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.test.tsx
index f797505b4cd0..261cbd00ad99 100644
--- a/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.test.tsx
+++ b/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.test.tsx
@@ -1,9 +1,25 @@
import React from 'react';
+import { userEvent } from '@testing-library/react-native';
import renderWithProvider from '../../../util/test/renderWithProvider';
import { backgroundState } from '../../../util/test/initial-root-state';
import DeFiProtocolPositionDetails, {
DEFI_PROTOCOL_POSITION_DETAILS_BALANCE_TEST_ID,
} from './DeFiProtocolPositionDetails';
+import { CommonSelectorsIDs } from '../../../util/Common.testIds';
+// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): route-isolation backlog
+import { WalletViewSelectorsIDs } from '../../Views/Wallet/WalletView.testIds';
+
+const mockPop = jest.fn();
+
+jest.mock('@react-navigation/native', () => {
+ const actual = jest.requireActual('@react-navigation/native');
+ return {
+ ...actual,
+ useNavigation: () => ({
+ pop: mockPop,
+ }),
+ };
+});
jest.mock('../../../util/navigation/navUtils', () => ({
...jest.requireActual('../../../util/navigation/navUtils'),
@@ -55,6 +71,10 @@ const mockInitialState = {
};
describe('DeFiProtocolPositionDetails', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
it('renders the protocol name header and aggregated balance', async () => {
const { findByText, findByTestId } = renderWithProvider(
,
@@ -93,4 +113,31 @@ describe('DeFiProtocolPositionDetails', () => {
await findByTestId(DEFI_PROTOCOL_POSITION_DETAILS_BALANCE_TEST_ID),
).toHaveTextContent('•••••••••');
});
+
+ it('calls navigation.pop when the header back button is pressed', async () => {
+ const { getByTestId } = renderWithProvider(
+ ,
+ {
+ state: mockInitialState,
+ },
+ );
+
+ await userEvent.press(getByTestId(CommonSelectorsIDs.BACK_ARROW_BUTTON));
+
+ expect(mockPop).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders SafeAreaView with left, right, and bottom edges only', () => {
+ const { getByTestId } = renderWithProvider(
+ ,
+ {
+ state: mockInitialState,
+ },
+ );
+
+ expect(
+ getByTestId(WalletViewSelectorsIDs.DEFI_POSITIONS_DETAILS_CONTAINER).props
+ .edges,
+ ).toEqual(['left', 'right', 'bottom']);
+ });
});
diff --git a/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.tsx b/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.tsx
index f021054f39d3..dd23ad9449b8 100644
--- a/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.tsx
+++ b/app/components/UI/DeFiPositions/DeFiProtocolPositionDetails.tsx
@@ -1,8 +1,13 @@
-import React from 'react';
+import React, { useCallback } from 'react';
import { GroupedDeFiPositions } from '@metamask/assets-controllers';
import { ImageSourcePropType, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useNavigation, type ParamListBase } from '@react-navigation/native';
+import type { StackNavigationProp } from '@react-navigation/stack';
+import { HeaderStandard } from '@metamask/design-system-react-native';
import styleSheet from './DeFiProtocolPositionDetails.styles';
import { useParams } from '../../../util/navigation/navUtils';
+import { CommonSelectorsIDs } from '../../../util/Common.testIds';
import Text, {
TextColor,
TextVariant,
@@ -30,54 +35,68 @@ interface DeFiProtocolPositionDetailsParams {
const DeFiProtocolPositionDetails: React.FC = () => {
const { styles } = useStyles(styleSheet, undefined);
+ const navigation = useNavigation>();
const { protocolAggregate, networkIconAvatar } =
useParams();
const privacyMode = useSelector(selectPrivacyMode);
+ const handleBack = useCallback(() => {
+ navigation.pop();
+ }, [navigation]);
+
return (
-
-
-
-
- {protocolAggregate.protocolDetails.name}
-
-
- {formatWithThreshold(
- protocolAggregate.aggregatedMarketValue,
- 0.01,
- I18n.locale,
- { style: 'currency', currency: 'USD' },
- )}
-
-
+
+
+
+
+
+ {protocolAggregate.protocolDetails.name}
+
+
+ {formatWithThreshold(
+ protocolAggregate.aggregatedMarketValue,
+ 0.01,
+ I18n.locale,
+ { style: 'currency', currency: 'USD' },
+ )}
+
+
-
-
+
+
+
+
+
+
+
-
-
-
-
-
+
);
};
diff --git a/app/components/UI/FoxLoader/FoxLoader.tsx b/app/components/UI/FoxLoader/FoxLoader.tsx
index f98366e08b02..087d07d115f8 100644
--- a/app/components/UI/FoxLoader/FoxLoader.tsx
+++ b/app/components/UI/FoxLoader/FoxLoader.tsx
@@ -30,21 +30,6 @@ const ANIMATION_TIMEOUT_MS = 3_000;
let animationStarted = false;
let animationComplete = false;
-// Use Canvas renderer on Android — the default Rive SurfaceView causes geometry distortion
-if (Platform.OS === 'android') {
- try {
- RiveRenderer.defaultRenderer(
- RiveRendererIOS.Rive,
- RiveRendererAndroid.Canvas,
- );
- } catch (error) {
- Logger.error(
- error as Error,
- 'Failed to set Rive Canvas renderer on Android',
- );
- }
-}
-
interface FoxLoaderProps {
appServicesReady?: boolean;
onAnimationComplete?: () => void;
diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx
index 3d15475cc937..b207b00a8d00 100644
--- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx
+++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx
@@ -28,6 +28,7 @@ import type { CardTransaction } from '../../types/moneyActivity';
import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance';
import useMoneyAccountInfo from '../../hooks/useMoneyAccountInfo';
import {
+ selectCardHomeDataStatus,
selectHasMetalCard,
selectIsCardholder,
} from '../../../../../selectors/cardController';
@@ -35,11 +36,24 @@ import { useMoneyAccountCardLinkage } from '../../../Card/hooks/useMoneyAccountC
import { MONEY_HOME_CARD_ORIGIN } from '../../../Card/hooks/useCardPostAuthRedirect';
import { moneyFormatFiat } from '../../utils/moneyFormatFiat';
import { useMusdBalance } from '../../../Earn/hooks/useMusdBalance';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import {
+ CardActions,
+ CardEntryPoint,
+ CardScreens,
+} from '../../../Card/util/metrics';
const mockGoBack = jest.fn();
const mockNavigate = jest.fn();
const mockInitiateDeposit = jest.fn();
const mockRefetchBalance = jest.fn();
+const mockTrackEvent = jest.fn();
+const mockBuild = jest.fn(() => ({ name: 'built-event' }));
+const mockAddProperties = jest.fn(() => ({ build: mockBuild }));
+const mockCreateEventBuilder = jest.fn((_eventName?: unknown) => ({
+ addProperties: mockAddProperties,
+ build: mockBuild,
+}));
const mockMoneyFormatFiat = moneyFormatFiat as jest.MockedFunction<
typeof moneyFormatFiat
>;
@@ -108,6 +122,21 @@ jest.mock('../../hooks/useMoneyAccountInfo', () => ({
default: jest.fn(),
}));
+jest.mock('../../../Earn/hooks/useMusdConversion', () => ({
+ useMusdConversion: jest.fn(),
+}));
+
+jest.mock('../../../Earn/hooks/useMusdBalance', () => ({
+ useMusdBalance: jest.fn(),
+}));
+
+jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
+}));
+
jest.mock('../../../../../core/NavigationService', () => ({
__esModule: true,
default: {
@@ -126,6 +155,7 @@ jest.mock('../../../../../selectors/cardController', () => ({
...jest.requireActual('../../../../../selectors/cardController'),
selectIsCardholder: jest.fn(),
selectHasMetalCard: jest.fn(),
+ selectCardHomeDataStatus: jest.fn(() => 'idle'),
selectIsMoneyAccountDelegatedForCard: jest.fn(() => false),
}));
@@ -199,6 +229,7 @@ jest.mock('../../hooks/useOnboardingStep', () => ({
const mockSelectIsCardholder = jest.mocked(selectIsCardholder);
const mockSelectHasMetalCard = jest.mocked(selectHasMetalCard);
+const mockSelectCardHomeDataStatus = jest.mocked(selectCardHomeDataStatus);
const mockUseMoneyAccountCardLinkage = jest.mocked(useMoneyAccountCardLinkage);
const mockOpenLinkCardSheet = jest.fn();
const mockStartLinkFlow = jest.fn();
@@ -292,6 +323,7 @@ describe('MoneyHomeView', () => {
mockSelectIsCardholder.mockReturnValue(false);
mockSelectHasMetalCard.mockReturnValue(false);
+ mockSelectCardHomeDataStatus.mockReturnValue('idle');
mockOpenLinkCardSheet.mockReset();
mockStartLinkFlow.mockReset();
@@ -873,15 +905,63 @@ describe('MoneyHomeView', () => {
it('navigates to Card root when Card button is pressed', () => {
const { getByTestId } = renderWithProvider();
+ jest.clearAllMocks();
fireEvent.press(getByTestId(MoneyActionButtonRowTestIds.CARD_BUTTON));
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_BUTTON_CLICKED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ screen: CardScreens.MONEY_HOME,
+ entrypoint: CardEntryPoint.MONEY_HOME_ACTION_ROW,
+ action: CardActions.MONEY_ACCOUNT_CARD_ACTION_ROW_BUTTON,
+ });
expect(mockNavigate).toHaveBeenCalledWith(Routes.CARD.ROOT, {
screen: Routes.CARD.HOME,
params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN },
});
});
+ it('tracks Card Viewed for the Card action row on render', () => {
+ renderWithProvider();
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_VIEWED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ screen: CardScreens.MONEY_HOME,
+ entrypoint: CardEntryPoint.MONEY_HOME_ACTION_ROW,
+ });
+ });
+
+ it('does not track the MetaMask Card impression while card home data is unsettled (idle status)', () => {
+ mockSelectCardHomeDataStatus.mockReturnValue('idle');
+
+ renderWithProvider();
+
+ expect(mockAddProperties).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD,
+ }),
+ );
+ });
+
+ it('tracks the MetaMask Card impression once the card home data fetch has settled', () => {
+ mockSelectCardHomeDataStatus.mockReturnValue('success');
+
+ renderWithProvider();
+
+ expect(mockAddProperties).toHaveBeenCalledWith(
+ expect.objectContaining({
+ screen: CardScreens.MONEY_HOME,
+ entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD,
+ mode: 'upsell',
+ card_state: 'non_cardholder',
+ }),
+ );
+ });
+
it('opens the APY info sheet when the APY info button is pressed', () => {
const { getByTestId } = renderWithProvider();
@@ -1249,7 +1329,11 @@ describe('MoneyHomeView', () => {
fireEvent.press(getByTestId(MoneyMetaMaskCardTestIds.LINK_BUTTON));
expect(mockStartLinkFlow).toHaveBeenCalledTimes(1);
- expect(mockStartLinkFlow).toHaveBeenCalledWith(MONEY_HOME_CARD_ORIGIN);
+ expect(mockStartLinkFlow).toHaveBeenCalledWith({
+ screen: Routes.MONEY.ROOT,
+ params: { screen: Routes.MONEY.HOME },
+ entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD,
+ });
expect(mockNavigate).not.toHaveBeenCalledWith(Routes.CARD.ROOT, {
screen: Routes.CARD.HOME,
});
@@ -1282,7 +1366,11 @@ describe('MoneyHomeView', () => {
});
expect(mockStartLinkFlow).toHaveBeenCalledTimes(1);
- expect(mockStartLinkFlow).toHaveBeenCalledWith(MONEY_HOME_CARD_ORIGIN);
+ expect(mockStartLinkFlow).toHaveBeenCalledWith({
+ screen: Routes.MONEY.ROOT,
+ params: { screen: Routes.MONEY.HOME },
+ entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD,
+ });
expect(mockNavigate).not.toHaveBeenCalledWith(Routes.CARD.ROOT, {
screen: Routes.CARD.HOME,
});
diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx
index 6e646425bb0e..cc3e6df02439 100644
--- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx
+++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.tsx
@@ -1,4 +1,10 @@
-import React, { useCallback, useMemo, useState } from 'react';
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
import { Linking, RefreshControl, ScrollView } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
@@ -38,6 +44,7 @@ import { TokenDetailsSource } from '../../../TokenDetails/constants/constants';
import AppConstants from '../../../../../core/AppConstants';
import NavigationService from '../../../../../core/NavigationService';
import {
+ selectCardHomeDataStatus,
selectHasMetalCard,
selectIsCardholder,
} from '../../../../../selectors/cardController';
@@ -49,6 +56,16 @@ import { useTheme } from '../../../../../util/theme';
import { MoneyBalanceDisplayState } from '../../types';
import { Hex } from '@metamask/utils';
import { AssetType } from '../../../../Views/confirmations/types/token';
+import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import {
+ CardActions,
+ CardEntryPoint,
+ CardFlow,
+ CardScreens,
+ deriveCardState,
+} from '../../../Card/util/metrics';
+
import { useMoneyAccountDeposit } from '../../hooks/useMoneyAccount';
import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics';
import useMountEffect from '../../hooks/useMountEffect';
@@ -74,6 +91,8 @@ const MoneyHomeView = () => {
const { styles } = useStyles(styleSheet, {});
const currentCurrency = useSelector(selectCurrentCurrency);
const { colors } = useTheme();
+ const { trackEvent, createEventBuilder } = useAnalytics();
+ const hasTrackedCardActionRowViewRef = useRef(false);
const {
trackButtonClicked,
@@ -137,6 +156,7 @@ const MoneyHomeView = () => {
);
const isCardholder = useSelector(selectIsCardholder);
+ const cardHomeDataStatus = useSelector(selectCardHomeDataStatus);
const hasMetalCard = useSelector(selectHasMetalCard);
const {
startLinkFlow,
@@ -260,17 +280,21 @@ const MoneyHomeView = () => {
});
}, [navigation, trackButtonClicked]);
+ const navigateToCardHome = useCallback(() => {
+ navigation.navigate(Routes.CARD.ROOT, {
+ screen: Routes.CARD.HOME,
+ params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN },
+ });
+ }, [navigation]);
+
const handleCardHeaderPress = useCallback(() => {
trackSurfaceClicked({
component_name: COMPONENT_NAMES.MONEY_CARD_SECTION_HEADER,
redirect_target: SCREEN_NAMES.CARD_HOME,
});
- navigation.navigate(Routes.CARD.ROOT, {
- screen: Routes.CARD.HOME,
- params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN },
- });
- }, [navigation, trackSurfaceClicked]);
+ navigateToCardHome();
+ }, [navigateToCardHome, trackSurfaceClicked]);
const handleActionButtonCardPress = useCallback(() => {
trackButtonClicked({
@@ -283,23 +307,41 @@ const MoneyHomeView = () => {
button_row_button_count: ACTION_BUTTON_ROW_BUTTON_COUNT,
});
- navigation.navigate(Routes.CARD.ROOT, {
- screen: Routes.CARD.HOME,
- params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN },
- });
- }, [navigation, trackButtonClicked]);
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED)
+ .addProperties({
+ screen: CardScreens.MONEY_HOME,
+ entrypoint: CardEntryPoint.MONEY_HOME_ACTION_ROW,
+ action: CardActions.MONEY_ACCOUNT_CARD_ACTION_ROW_BUTTON,
+ })
+ .build(),
+ );
- const handleCardPress = useCallback(() => {
- navigation.navigate(Routes.CARD.ROOT, {
- screen: Routes.CARD.HOME,
- params: { postAuthRedirect: MONEY_HOME_CARD_ORIGIN },
- });
- }, [navigation]);
+ navigateToCardHome();
+ }, [trackButtonClicked, trackEvent, createEventBuilder, navigateToCardHome]);
const handleLinkCardPress = useCallback(() => {
- startLinkFlow(MONEY_HOME_CARD_ORIGIN);
+ startLinkFlow({
+ screen: Routes.MONEY.ROOT,
+ params: { screen: Routes.MONEY.HOME },
+ entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD,
+ });
}, [startLinkFlow]);
+ useEffect(() => {
+ if (hasTrackedCardActionRowViewRef.current) return;
+ hasTrackedCardActionRowViewRef.current = true;
+
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.CARD_VIEWED)
+ .addProperties({
+ screen: CardScreens.MONEY_HOME,
+ entrypoint: CardEntryPoint.MONEY_HOME_ACTION_ROW,
+ })
+ .build(),
+ );
+ }, [trackEvent, createEventBuilder]);
+
const handleApyInfoPress = useCallback(() => {
trackTooltipClicked({
tooltip_name: MONEY_TOOLTIP_NAMES.APY,
@@ -514,6 +556,13 @@ const MoneyHomeView = () => {
const { primaryToken: cardPrimaryToken } = useCardHomeData();
const cardBalance = cardPrimaryToken?.balanceFiat ?? formattedZero;
+ const cardState = deriveCardState({
+ isCardholder,
+ isCardAuthenticated,
+ isCardLinkedToMoneyAccount,
+ });
+ const isCardAnalyticsReady =
+ cardHomeDataStatus === 'success' || cardHomeDataStatus === 'error';
return (
{
)}
{isFunded && (
diff --git a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.test.tsx b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.test.tsx
index 8d588fd3dffb..0fefec6fee58 100644
--- a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.test.tsx
+++ b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.test.tsx
@@ -6,13 +6,30 @@ import { MoneyLinkCardSheetTestIds } from './MoneyLinkCardSheet.testIds';
import { strings } from '../../../../../../locales/i18n';
import { useMoneyAccountCardLinkage } from '../../../Card/hooks/useMoneyAccountCardLinkage';
import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance';
-import { selectCardHomeData } from '../../../../../selectors/cardController';
+import {
+ selectCardHomeData,
+ selectCardHomeDataStatus,
+} from '../../../../../selectors/cardController';
import { CardType } from '../../../Card/types';
import mmCardRegular from '../../../../../images/mm_card_regular.png';
import mmCardMetal from '../../../../../images/mm_card_metal.png';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import {
+ CardActions,
+ CardEntryPoint,
+ CardScreens,
+} from '../../../Card/util/metrics';
const mockOnCloseBottomSheet = jest.fn((cb?: () => void) => cb?.());
const mockGoBack = jest.fn();
+let mockRouteParams: { entrypoint?: CardEntryPoint | string } | undefined;
+const mockTrackEvent = jest.fn();
+const mockBuild = jest.fn(() => ({ name: 'built-event' }));
+const mockAddProperties = jest.fn(() => ({ build: mockBuild }));
+const mockCreateEventBuilder = jest.fn((_eventName?: unknown) => ({
+ addProperties: mockAddProperties,
+ build: mockBuild,
+}));
jest.mock('@react-navigation/native', () => {
const actualReactNavigation = jest.requireActual('@react-navigation/native');
@@ -21,6 +38,9 @@ jest.mock('@react-navigation/native', () => {
useNavigation: () => ({
goBack: mockGoBack,
}),
+ useRoute: () => ({
+ params: mockRouteParams,
+ }),
};
});
@@ -35,6 +55,14 @@ jest.mock('../../hooks/useMoneyAccountBalance', () => ({
jest.mock('../../../../../selectors/cardController', () => ({
selectCardHomeData: jest.fn(),
+ selectCardHomeDataStatus: jest.fn(),
+}));
+
+jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
}));
jest.mock('@metamask/design-system-react-native', () => {
@@ -68,12 +96,15 @@ const mockUseMoneyAccountCardLinkage =
const mockUseMoneyAccountBalance =
useMoneyAccountBalance as jest.MockedFunction;
const mockSelectCardHomeData = selectCardHomeData as unknown as jest.Mock;
+const mockSelectCardHomeDataStatus =
+ selectCardHomeDataStatus as unknown as jest.Mock;
describe('MoneyLinkCardSheet', () => {
let mockConfirmLinkInBackground: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
+ mockRouteParams = undefined;
mockConfirmLinkInBackground = jest.fn().mockResolvedValue(true);
mockUseMoneyAccountCardLinkage.mockReturnValue({
confirmLinkInBackground: mockConfirmLinkInBackground,
@@ -84,6 +115,7 @@ describe('MoneyLinkCardSheet', () => {
mockSelectCardHomeData.mockReturnValue({
card: { type: CardType.VIRTUAL },
});
+ mockSelectCardHomeDataStatus.mockReturnValue('success');
});
it('renders the container', () => {
@@ -92,6 +124,71 @@ describe('MoneyLinkCardSheet', () => {
expect(getByTestId(MoneyLinkCardSheetTestIds.CONTAINER)).toBeOnTheScreen();
});
+ it('tracks Card Viewed on mount with generic sheet entrypoint and origin', () => {
+ mockRouteParams = {
+ entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
+ };
+
+ renderWithProvider();
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_VIEWED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ screen: CardScreens.MONEY_LINK_CARD_SHEET,
+ entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ origin_entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
+ card_type: 'virtual',
+ });
+ });
+
+ it('does not emit Card Viewed while the card home data fetch is still loading', () => {
+ mockSelectCardHomeData.mockReturnValue(null);
+ mockSelectCardHomeDataStatus.mockReturnValue('loading');
+
+ renderWithProvider();
+
+ expect(mockCreateEventBuilder).not.toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_VIEWED,
+ );
+ });
+
+ it('emits the resolved card_type once the card home data fetch has succeeded', () => {
+ mockSelectCardHomeData.mockReturnValue({
+ card: { type: CardType.METAL },
+ });
+ mockSelectCardHomeDataStatus.mockReturnValue('success');
+
+ renderWithProvider();
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_VIEWED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ screen: CardScreens.MONEY_LINK_CARD_SHEET,
+ entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ origin_entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ card_type: 'metal',
+ });
+ });
+
+ it('still emits Card Viewed (virtual fallback) when card home data fails to load', () => {
+ mockSelectCardHomeData.mockReturnValue(null);
+ mockSelectCardHomeDataStatus.mockReturnValue('error');
+
+ renderWithProvider();
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_VIEWED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ screen: CardScreens.MONEY_LINK_CARD_SHEET,
+ entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ origin_entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ card_type: 'virtual',
+ });
+ });
+
it('renders the illustration, title, description, and CTA', () => {
const { getByTestId, getByText } = renderWithProvider(
,
@@ -198,20 +295,48 @@ describe('MoneyLinkCardSheet', () => {
});
it('dismisses the sheet and dispatches confirmLinkInBackground when the CTA is pressed', () => {
+ mockRouteParams = {
+ entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD,
+ };
const { getByTestId } = renderWithProvider();
+ jest.clearAllMocks();
fireEvent.press(getByTestId(MoneyLinkCardSheetTestIds.CTA_BUTTON));
expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1);
expect(mockConfirmLinkInBackground).toHaveBeenCalledTimes(1);
+ expect(mockConfirmLinkInBackground).toHaveBeenCalledWith({
+ entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD,
+ });
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_BUTTON_CLICKED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ screen: CardScreens.MONEY_LINK_CARD_SHEET,
+ entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ origin_entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD,
+ action: CardActions.MONEY_LINK_CARD_SHEET_CONFIRM_BUTTON,
+ card_type: 'virtual',
+ });
});
it('dismisses the sheet without dispatching the linkage when the close button is pressed', () => {
const { getByTestId } = renderWithProvider();
+ jest.clearAllMocks();
fireEvent.press(getByTestId(MoneyLinkCardSheetTestIds.CLOSE_BUTTON));
expect(mockOnCloseBottomSheet).toHaveBeenCalledTimes(1);
expect(mockConfirmLinkInBackground).not.toHaveBeenCalled();
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_BUTTON_CLICKED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ screen: CardScreens.MONEY_LINK_CARD_SHEET,
+ entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ origin_entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ action: CardActions.MONEY_LINK_CARD_SHEET_CLOSE_BUTTON,
+ card_type: 'virtual',
+ });
});
});
diff --git a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.tsx b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.tsx
index 907791982562..9a37854159f4 100644
--- a/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.tsx
+++ b/app/components/UI/Money/components/MoneyLinkCardSheet/MoneyLinkCardSheet.tsx
@@ -1,6 +1,6 @@
-import React, { useCallback, useRef } from 'react';
+import React, { useCallback, useEffect, useRef } from 'react';
import { Image } from 'react-native';
-import { useNavigation } from '@react-navigation/native';
+import { useNavigation, useRoute } from '@react-navigation/native';
import { useSelector } from 'react-redux';
import {
BottomSheet,
@@ -18,7 +18,10 @@ import {
} from '@metamask/design-system-react-native';
import { strings } from '../../../../../../locales/i18n';
import { useStyles } from '../../../../../component-library/hooks';
-import { selectCardHomeData } from '../../../../../selectors/cardController';
+import {
+ selectCardHomeData,
+ selectCardHomeDataStatus,
+} from '../../../../../selectors/cardController';
import { useMoneyAccountCardLinkage } from '../../../Card/hooks/useMoneyAccountCardLinkage';
import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance';
import { CardType } from '../../../Card/types';
@@ -27,6 +30,17 @@ import mmCardMetal from '../../../../../images/mm_card_metal.png';
import styleSheet from './MoneyLinkCardSheet.styles';
import { MoneyLinkCardSheetTestIds } from './MoneyLinkCardSheet.testIds';
import { useElevatedSurface } from '../../../../../util/theme/themeUtils';
+import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import {
+ CardActions,
+ CardEntryPoint,
+ CardScreens,
+} from '../../../Card/util/metrics';
+
+interface MoneyLinkCardSheetRouteParams {
+ entrypoint?: CardEntryPoint | string;
+}
/**
* "Spend and earn" confirmation bottom sheet shown before the Money Account ↔
@@ -39,27 +53,91 @@ import { useElevatedSurface } from '../../../../../util/theme/themeUtils';
*/
const MoneyLinkCardSheet = () => {
const sheetRef = useRef(null);
+ const hasTrackedViewRef = useRef(false);
const navigation = useNavigation();
+ const route = useRoute();
const { styles } = useStyles(styleSheet, {});
const { confirmLinkInBackground } = useMoneyAccountCardLinkage();
const { apyPercent } = useMoneyAccountBalance();
+ const { trackEvent, createEventBuilder } = useAnalytics();
const cardHomeData = useSelector(selectCardHomeData);
+ const cardHomeDataStatus = useSelector(selectCardHomeDataStatus);
const surfaceClass = useElevatedSurface();
const isMetalCard = cardHomeData?.card?.type === CardType.METAL;
+ const routeParams = route.params as MoneyLinkCardSheetRouteParams | undefined;
+ const originEntryPoint =
+ routeParams?.entrypoint ?? CardEntryPoint.MONEY_LINK_CARD_SHEET;
+ const cardType = isMetalCard ? 'metal' : 'virtual';
+ const isCardDataReady =
+ cardHomeDataStatus === 'success' || cardHomeDataStatus === 'error';
+
+ useEffect(() => {
+ if (hasTrackedViewRef.current || !isCardDataReady) return;
+ hasTrackedViewRef.current = true;
+
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.CARD_VIEWED)
+ .addProperties({
+ screen: CardScreens.MONEY_LINK_CARD_SHEET,
+ entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ origin_entrypoint: originEntryPoint,
+ card_type: cardType,
+ })
+ .build(),
+ );
+ }, [
+ trackEvent,
+ createEventBuilder,
+ originEntryPoint,
+ cardType,
+ isCardDataReady,
+ ]);
const handleGoBack = useCallback(() => {
navigation.goBack();
}, [navigation]);
const handleClose = useCallback(() => {
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED)
+ .addProperties({
+ screen: CardScreens.MONEY_LINK_CARD_SHEET,
+ entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ origin_entrypoint: originEntryPoint,
+ action: CardActions.MONEY_LINK_CARD_SHEET_CLOSE_BUTTON,
+ card_type: cardType,
+ })
+ .build(),
+ );
+
sheetRef.current?.onCloseBottomSheet();
- }, []);
+ }, [trackEvent, createEventBuilder, originEntryPoint, cardType]);
const handleConfirm = useCallback(() => {
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED)
+ .addProperties({
+ screen: CardScreens.MONEY_LINK_CARD_SHEET,
+ entrypoint: CardEntryPoint.MONEY_LINK_CARD_SHEET,
+ origin_entrypoint: originEntryPoint,
+ action: CardActions.MONEY_LINK_CARD_SHEET_CONFIRM_BUTTON,
+ card_type: cardType,
+ })
+ .build(),
+ );
+
sheetRef.current?.onCloseBottomSheet(() => {
- confirmLinkInBackground().catch(() => undefined);
+ confirmLinkInBackground({ entrypoint: originEntryPoint }).catch(
+ () => undefined,
+ );
});
- }, [confirmLinkInBackground]);
+ }, [
+ trackEvent,
+ createEventBuilder,
+ originEntryPoint,
+ cardType,
+ confirmLinkInBackground,
+ ]);
const description: React.ReactNode =
apyPercent === undefined ? (
diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx
index ccd3c56fe85b..ef021ebcba15 100644
--- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx
+++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.test.tsx
@@ -4,8 +4,41 @@ import MoneyMetaMaskCard from './MoneyMetaMaskCard';
import { MoneyMetaMaskCardTestIds } from './MoneyMetaMaskCard.testIds';
import { MoneySectionHeaderTestIds } from '../MoneySectionHeader/MoneySectionHeader.testIds';
import { strings } from '../../../../../../locales/i18n';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import {
+ CardActions,
+ CardEntryPoint,
+ CardFlow,
+ CardScreens,
+} from '../../../Card/util/metrics';
+
+const mockTrackEvent = jest.fn();
+const mockBuild = jest.fn(() => ({ name: 'built-event' }));
+const mockAddProperties = jest.fn(() => ({ build: mockBuild }));
+const mockCreateEventBuilder = jest.fn((_eventName?: unknown) => ({
+ addProperties: mockAddProperties,
+ build: mockBuild,
+}));
+
+jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
+}));
describe('MoneyMetaMaskCard', () => {
+ const analyticsProps = {
+ analyticsScreen: CardScreens.MONEY_HOME,
+ analyticsEntryPoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD,
+ analyticsFlow: CardFlow.MONEY_ACCOUNT_LINKAGE,
+ analyticsCardState: 'unlinked_card',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
it('renders the section title and subtitle', () => {
const { getByText } = render(
,
@@ -431,4 +464,195 @@ describe('MoneyMetaMaskCard', () => {
).not.toBeOnTheScreen();
});
});
+
+ describe('analytics', () => {
+ it('does not track when analytics props are omitted', () => {
+ render();
+
+ expect(mockCreateEventBuilder).not.toHaveBeenCalled();
+ expect(mockTrackEvent).not.toHaveBeenCalled();
+ });
+
+ it('tracks Card Viewed once when analytics props are provided', () => {
+ const { rerender } = render(
+ ,
+ );
+
+ rerender(
+ ,
+ );
+
+ expect(
+ mockCreateEventBuilder.mock.calls.filter(
+ ([eventName]) => eventName === MetaMetricsEvents.CARD_VIEWED,
+ ),
+ ).toHaveLength(1);
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ screen: CardScreens.MONEY_HOME,
+ entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD,
+ mode: 'upsell',
+ card_type: 'virtual',
+ flow: CardFlow.MONEY_ACCOUNT_LINKAGE,
+ card_state: 'unlinked_card',
+ action: undefined,
+ });
+ });
+
+ it('defers Card Viewed while analyticsReady is false', () => {
+ render(
+ ,
+ );
+
+ expect(
+ mockCreateEventBuilder.mock.calls.filter(
+ ([eventName]) => eventName === MetaMetricsEvents.CARD_VIEWED,
+ ),
+ ).toHaveLength(0);
+ });
+
+ it('tracks Card Viewed with settled properties once analyticsReady flips to true', () => {
+ const { rerender } = render(
+ ,
+ );
+
+ // Async cardholder/auth data settles: mode + card_state change and the
+ // gate opens. Only the post-load values should be recorded.
+ rerender(
+ ,
+ );
+
+ const cardViewedCalls = mockCreateEventBuilder.mock.calls.filter(
+ ([eventName]) => eventName === MetaMetricsEvents.CARD_VIEWED,
+ );
+ expect(cardViewedCalls).toHaveLength(1);
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ screen: CardScreens.MONEY_HOME,
+ entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD,
+ mode: 'manage',
+ card_type: 'virtual',
+ flow: CardFlow.MONEY_ACCOUNT_LINKAGE,
+ card_state: 'linked_card',
+ action: undefined,
+ });
+ });
+
+ it('tracks Card Viewed only once even if properties keep changing after the gate opens', () => {
+ const { rerender } = render(
+ ,
+ );
+
+ rerender(
+ ,
+ );
+
+ expect(
+ mockCreateEventBuilder.mock.calls.filter(
+ ([eventName]) => eventName === MetaMetricsEvents.CARD_VIEWED,
+ ),
+ ).toHaveLength(1);
+ });
+
+ it('tracks Get now clicks before calling the handler', () => {
+ const mockGetNow = jest.fn();
+ const { getByText } = render(
+ ,
+ );
+ jest.clearAllMocks();
+
+ fireEvent.press(getByText(strings('money.metamask_card.get_now')));
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_BUTTON_CLICKED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ screen: CardScreens.MONEY_HOME,
+ entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD,
+ mode: 'upsell',
+ card_type: 'virtual',
+ flow: CardFlow.MONEY_ACCOUNT_LINKAGE,
+ card_state: 'unlinked_card',
+ action: CardActions.MONEY_ACCOUNT_METAMASK_CARD_GET_NOW_BUTTON,
+ });
+ expect(mockGetNow).toHaveBeenCalledTimes(1);
+ });
+
+ it('tracks Link card clicks before calling the handler', () => {
+ const mockLink = jest.fn();
+ const { getByTestId } = render(
+ ,
+ );
+ jest.clearAllMocks();
+
+ fireEvent.press(getByTestId(MoneyMetaMaskCardTestIds.LINK_BUTTON));
+
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ screen: CardScreens.MONEY_HOME,
+ entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD,
+ mode: 'link',
+ card_type: 'virtual',
+ flow: CardFlow.MONEY_ACCOUNT_LINKAGE,
+ card_state: 'unlinked_card',
+ action: CardActions.MONEY_ACCOUNT_METAMASK_CARD_LINK_BUTTON,
+ });
+ expect(mockLink).toHaveBeenCalledTimes(1);
+ });
+
+ it('tracks Manage clicks before calling the handler', () => {
+ const mockManage = jest.fn();
+ const { getByTestId } = render(
+ ,
+ );
+ jest.clearAllMocks();
+
+ fireEvent.press(getByTestId(MoneyMetaMaskCardTestIds.MANAGE_BUTTON));
+
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ screen: CardScreens.MONEY_HOME,
+ entrypoint: CardEntryPoint.MONEY_HOME_METAMASK_CARD,
+ mode: 'manage',
+ card_type: 'virtual',
+ flow: CardFlow.MONEY_ACCOUNT_LINKAGE,
+ card_state: 'unlinked_card',
+ action: CardActions.MONEY_ACCOUNT_METAMASK_CARD_MANAGE_BUTTON,
+ });
+ expect(mockManage).toHaveBeenCalledTimes(1);
+ });
+ });
});
diff --git a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx
index 0fb09b84885a..cba26431f33a 100644
--- a/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx
+++ b/app/components/UI/Money/components/MoneyMetaMaskCard/MoneyMetaMaskCard.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useEffect, useRef } from 'react';
import { Image, ImageSourcePropType } from 'react-native';
import {
Box,
@@ -23,6 +23,13 @@ import { strings } from '../../../../../../locales/i18n';
import MoneySectionHeader from '../MoneySectionHeader';
import { MoneyMetaMaskCardTestIds } from './MoneyMetaMaskCard.testIds';
import styles from './MoneyMetaMaskCard.styles';
+import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import {
+ CardActions,
+ CardEntryPoint,
+ CardScreens,
+} from '../../../Card/util/metrics';
import mmCardRegular from '../../../../../images/mm_card_regular.png';
import mmCardMetal from '../../../../../images/mm_card_metal.png';
@@ -55,6 +62,11 @@ interface MoneyMetaMaskCardProps {
* (drops the APY clause from the subtitle and omits the APY bullet).
*/
apy?: number;
+ analyticsScreen?: CardScreens | string;
+ analyticsEntryPoint?: CardEntryPoint;
+ analyticsFlow?: string;
+ analyticsCardState?: string;
+ analyticsReady?: boolean;
/**
* Link mode only: when true, the card image is omitted and the bullets are
* stacked vertically. Used by Card Home where the card image is already
@@ -317,13 +329,104 @@ const MoneyMetaMaskCard = ({
cardBalance,
apy,
hideCardImage = false,
+ analyticsScreen,
+ analyticsEntryPoint,
+ analyticsFlow,
+ analyticsCardState,
+ analyticsReady = true,
}: MoneyMetaMaskCardProps) => {
- const handleLinkPress = useCallback(() => onLinkPress?.(), [onLinkPress]);
- const handleManagePress = useCallback(
- () => onManagePress?.(),
- [onManagePress],
+ const { trackEvent, createEventBuilder } = useAnalytics();
+ const hasTrackedViewRef = useRef(false);
+ const cardType = showMetalCard ? 'metal' : 'virtual';
+
+ const buildAnalyticsProperties = useCallback(
+ (action?: CardActions) => ({
+ screen: analyticsScreen,
+ entrypoint: analyticsEntryPoint,
+ mode,
+ card_type: cardType,
+ flow: analyticsFlow,
+ card_state: analyticsCardState,
+ action,
+ }),
+ [
+ analyticsScreen,
+ analyticsEntryPoint,
+ mode,
+ cardType,
+ analyticsFlow,
+ analyticsCardState,
+ ],
);
+ const trackCardButtonClick = useCallback(
+ (action: CardActions) => {
+ if (!analyticsScreen || !analyticsEntryPoint) return;
+
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED)
+ .addProperties(buildAnalyticsProperties(action))
+ .build(),
+ );
+ },
+ [
+ analyticsScreen,
+ analyticsEntryPoint,
+ trackEvent,
+ createEventBuilder,
+ buildAnalyticsProperties,
+ ],
+ );
+
+ useEffect(() => {
+ if (
+ hasTrackedViewRef.current ||
+ !analyticsReady ||
+ !analyticsScreen ||
+ !analyticsEntryPoint
+ ) {
+ return;
+ }
+
+ hasTrackedViewRef.current = true;
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.CARD_VIEWED)
+ .addProperties(buildAnalyticsProperties())
+ .build(),
+ );
+ }, [
+ analyticsReady,
+ analyticsScreen,
+ analyticsEntryPoint,
+ trackEvent,
+ createEventBuilder,
+ buildAnalyticsProperties,
+ ]);
+
+ const handleLinkPress = useCallback(() => {
+ trackCardButtonClick(CardActions.MONEY_ACCOUNT_METAMASK_CARD_LINK_BUTTON);
+ onLinkPress?.();
+ }, [trackCardButtonClick, onLinkPress]);
+
+ const handleGetNowPress = useCallback(() => {
+ trackCardButtonClick(
+ CardActions.MONEY_ACCOUNT_METAMASK_CARD_GET_NOW_BUTTON,
+ );
+ onGetNowPress();
+ }, [trackCardButtonClick, onGetNowPress]);
+
+ const handleManagePress = useCallback(() => {
+ trackCardButtonClick(CardActions.MONEY_ACCOUNT_METAMASK_CARD_MANAGE_BUTTON);
+ onManagePress?.();
+ }, [trackCardButtonClick, onManagePress]);
+
+ const handleHeaderPress = useCallback(() => {
+ trackCardButtonClick(CardActions.MONEY_ACCOUNT_METAMASK_CARD_HEADER);
+ onHeaderPress?.();
+ }, [trackCardButtonClick, onHeaderPress]);
+
+ const resolvedHeaderPress = onHeaderPress ? handleHeaderPress : undefined;
+
let content: React.ReactNode = null;
if (mode === 'link') {
content = (
@@ -357,7 +460,7 @@ const MoneyMetaMaskCard = ({
imageSource={mmCardRegular}
cardName={strings('money.metamask_card.virtual_card')}
cashbackPercentage="1"
- onPress={onGetNowPress}
+ onPress={handleGetNowPress}
testID={MoneyMetaMaskCardTestIds.VIRTUAL_CARD_ROW}
/>
>
@@ -380,7 +483,9 @@ const MoneyMetaMaskCard = ({
>
{content}
diff --git a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.test.tsx b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.test.tsx
index 1191166a5626..666384fb1b21 100644
--- a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.test.tsx
+++ b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.test.tsx
@@ -10,6 +10,12 @@ import { useMoneyAccountCardLinkage } from '../../../Card/hooks/useMoneyAccountC
import { MONEY_HOME_CARD_ORIGIN } from '../../../Card/hooks/useCardPostAuthRedirect';
import { strings } from '../../../../../../locales/i18n';
import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import {
+ CardActions,
+ CardEntryPoint,
+ CardScreens,
+} from '../../../Card/util/metrics';
import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics';
import {
BOTTOM_SHEET_NAMES,
@@ -18,6 +24,14 @@ import {
SCREEN_NAMES,
} from '../../constants/moneyEvents';
+const mockTrackEvent = jest.fn();
+const mockBuild = jest.fn(() => ({ name: 'built-event' }));
+const mockAddProperties = jest.fn(() => ({ build: mockBuild }));
+const mockCreateEventBuilder = jest.fn((_eventName?: unknown) => ({
+ addProperties: mockAddProperties,
+ build: mockBuild,
+}));
+
const mockTrackOnboardingEvent = jest.fn();
jest.mock('../../hooks/useMoneyAnalytics', () => ({
@@ -49,6 +63,23 @@ jest.mock('../../../Card/hooks/useMoneyAccountCardLinkage', () => ({
useMoneyAccountCardLinkage: jest.fn(),
}));
+jest.mock('../../../../hooks/useAnalytics/useAnalytics', () => ({
+ useAnalytics: () => ({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ }),
+}));
+
+const mockIsCardholder = jest.fn(() => true);
+const mockCardHomeDataStatus = jest.fn(() => 'success');
+jest.mock('react-redux', () => ({
+ useSelector: (selector: (state: unknown) => unknown) => selector(undefined),
+}));
+jest.mock('../../../../../selectors/cardController', () => ({
+ selectIsCardholder: () => mockIsCardholder(),
+ selectCardHomeDataStatus: () => mockCardHomeDataStatus(),
+}));
+
const mockUseOnboardingStep = useOnboardingStep as jest.MockedFunction<
typeof useOnboardingStep
>;
@@ -235,16 +266,27 @@ describe('MoneyOnboardingCard', () => {
setupDefaultMocks({ currentStep: 1, isCardAuthenticated: false });
const { getByTestId } = render();
+ jest.clearAllMocks();
fireEvent.press(getByTestId('money-onboarding-card-cta-button'));
expect(mockStartLinkFlow).toHaveBeenCalledTimes(1);
expect(mockStartLinkFlow).toHaveBeenCalledWith(MONEY_HOME_CARD_ORIGIN);
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_BUTTON_CLICKED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ screen: CardScreens.MONEY_HOME,
+ entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
+ action: CardActions.MONEY_ACCOUNT_ONBOARDING_CARD_PRIMARY_BUTTON,
+ card_state: 'no_card',
+ });
});
it('calls incrementStep when Skip CTA is pressed', () => {
setupDefaultMocks({ currentStep: 1, isCardAuthenticated: false });
const { getByText } = render();
+ jest.clearAllMocks();
fireEvent.press(
getByText(
strings('money.onboarding.step_2.no_card_account.cta_secondary'),
@@ -252,6 +294,89 @@ describe('MoneyOnboardingCard', () => {
);
expect(mockIncrementStep).toHaveBeenCalledTimes(1);
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_BUTTON_CLICKED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ screen: CardScreens.MONEY_HOME,
+ entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
+ action: CardActions.MONEY_ACCOUNT_ONBOARDING_CARD_SKIP_BUTTON,
+ card_state: 'no_card',
+ });
+ });
+
+ it('tracks Card view when the Card step is rendered', () => {
+ setupDefaultMocks({ currentStep: 1, isCardAuthenticated: false });
+
+ render();
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_VIEWED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ screen: CardScreens.MONEY_HOME,
+ entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
+ card_state: 'no_card',
+ });
+ });
+
+ it('emits card_state="non_cardholder" when account is not a cardholder', () => {
+ mockIsCardholder.mockReturnValue(false);
+ setupDefaultMocks({ currentStep: 1, isCardAuthenticated: false });
+
+ render();
+
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ screen: CardScreens.MONEY_HOME,
+ entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
+ card_state: 'non_cardholder',
+ });
+
+ mockIsCardholder.mockReturnValue(true);
+ });
+
+ it('does not emit Card view while card home data is still loading', () => {
+ mockCardHomeDataStatus.mockReturnValue('loading');
+ setupDefaultMocks({ currentStep: 1, isCardAuthenticated: false });
+
+ render();
+
+ expect(mockCreateEventBuilder).not.toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_VIEWED,
+ );
+
+ mockCardHomeDataStatus.mockReturnValue('success');
+ });
+
+ it('defers Card view tracking until card flags settle and emits the resolved card_state', () => {
+ // Initial render mirrors a rehydrating store: card data is loading and the
+ // cardholder flag has not been restored yet (would derive non_cardholder).
+ mockIsCardholder.mockReturnValue(false);
+ mockCardHomeDataStatus.mockReturnValue('loading');
+ setupDefaultMocks({ currentStep: 1, isCardAuthenticated: false });
+
+ const { rerender } = render();
+
+ expect(mockCreateEventBuilder).not.toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_VIEWED,
+ );
+
+ // Card data settles: the account is a cardholder without an active card.
+ mockIsCardholder.mockReturnValue(true);
+ mockCardHomeDataStatus.mockReturnValue('success');
+ rerender();
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.CARD_VIEWED,
+ );
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ screen: CardScreens.MONEY_HOME,
+ entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
+ card_state: 'no_card',
+ });
+
+ mockIsCardholder.mockReturnValue(true);
+ mockCardHomeDataStatus.mockReturnValue('success');
});
});
@@ -312,10 +437,17 @@ describe('MoneyOnboardingCard', () => {
});
const { getByTestId } = render();
+ jest.clearAllMocks();
fireEvent.press(getByTestId('money-onboarding-card-cta-button'));
expect(mockStartLinkFlow).toHaveBeenCalledTimes(1);
expect(mockStartLinkFlow).toHaveBeenCalledWith(MONEY_HOME_CARD_ORIGIN);
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ screen: CardScreens.MONEY_HOME,
+ entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
+ action: CardActions.MONEY_ACCOUNT_ONBOARDING_CARD_PRIMARY_BUTTON,
+ card_state: 'unlinked_card',
+ });
});
it('calls incrementStep when Skip CTA is pressed', () => {
@@ -326,6 +458,7 @@ describe('MoneyOnboardingCard', () => {
});
const { getByText } = render();
+ jest.clearAllMocks();
fireEvent.press(
getByText(
strings(
@@ -335,6 +468,12 @@ describe('MoneyOnboardingCard', () => {
);
expect(mockIncrementStep).toHaveBeenCalledTimes(1);
+ expect(mockAddProperties).toHaveBeenCalledWith({
+ screen: CardScreens.MONEY_HOME,
+ entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
+ action: CardActions.MONEY_ACCOUNT_ONBOARDING_CARD_SKIP_BUTTON,
+ card_state: 'unlinked_card',
+ });
});
});
diff --git a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx
index e74c2c69d1a4..032b7d237408 100644
--- a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx
+++ b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useMemo } from 'react';
+import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { Box } from '@metamask/design-system-react-native';
import moneyOnboardingStepperStep1 from '../../../../../images/money-onboarding-stepper-step-1.png';
import moneyOnboardingStepperStep2 from '../../../../../images/money-onboarding-stepper-step-2.png';
@@ -11,6 +11,19 @@ import StepperCard, {
} from '../../../../../component-library/components-temp/StepperCard';
import { useMoneyAccountDeposit } from '../../hooks/useMoneyAccount';
import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance';
+import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics';
+import { MetaMetricsEvents } from '../../../../../core/Analytics';
+import {
+ CardActions,
+ CardEntryPoint,
+ CardScreens,
+ deriveCardState,
+} from '../../../Card/util/metrics';
+import { useSelector } from 'react-redux';
+import {
+ selectIsCardholder,
+ selectCardHomeDataStatus,
+} from '../../../../../selectors/cardController';
import { useMoneyAnalytics } from '../../hooks/useMoneyAnalytics';
import {
COMPONENT_NAMES,
@@ -23,6 +36,9 @@ import {
export const MONEY_ONBOARDING_TOTAL_STEPS = 2;
const MoneyOnboardingCard = () => {
+ const { trackEvent, createEventBuilder } = useAnalytics();
+ const hasTrackedCardStepViewRef = useRef(false);
+
const {
currentStep,
incrementStep,
@@ -45,6 +61,20 @@ const MoneyOnboardingCard = () => {
isCardLinkedToMoneyAccount,
isLinking,
} = useMoneyAccountCardLinkage();
+ const isCardholder = useSelector(selectIsCardholder);
+ const cardHomeDataStatus = useSelector(selectCardHomeDataStatus);
+
+ const isMoneyAccountFunded = Boolean(
+ !isAggregatedBalanceLoading && tokenTotal?.isGreaterThan(0),
+ );
+ const isCardAnalyticsReady =
+ cardHomeDataStatus === 'success' || cardHomeDataStatus === 'error';
+
+ const cardState = deriveCardState({
+ isCardholder,
+ isCardAuthenticated,
+ isCardLinkedToMoneyAccount,
+ });
const handleRedirectToCryptoDeposit = useCallback(async () => {
await initiateDeposit().catch(() => undefined);
@@ -72,6 +102,17 @@ const MoneyOnboardingCard = () => {
});
}
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED)
+ .addProperties({
+ screen: CardScreens.MONEY_HOME,
+ entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
+ action: CardActions.MONEY_ACCOUNT_ONBOARDING_CARD_PRIMARY_BUTTON,
+ card_state: cardState,
+ })
+ .build(),
+ );
+
startLinkFlow(MONEY_HOME_CARD_ORIGIN);
},
[
@@ -80,6 +121,9 @@ const MoneyOnboardingCard = () => {
isCardLinkedToMoneyAccount,
startLinkFlow,
trackOnboardingEvent,
+ trackEvent,
+ createEventBuilder,
+ cardState,
],
);
@@ -93,16 +137,32 @@ const MoneyOnboardingCard = () => {
step_action: MONEY_ONBOARDING_STEP_ACTIONS.SKIPPED,
});
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.CARD_BUTTON_CLICKED)
+ .addProperties({
+ screen: CardScreens.MONEY_HOME,
+ entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
+ action: CardActions.MONEY_ACCOUNT_ONBOARDING_CARD_SKIP_BUTTON,
+ card_state: cardState,
+ })
+ .build(),
+ );
+
incrementStep();
},
- [currentStep, incrementStep, trackOnboardingEvent],
+ [
+ currentStep,
+ incrementStep,
+ trackOnboardingEvent,
+ trackEvent,
+ createEventBuilder,
+ cardState,
+ ],
);
const targetStepFromCompletion = useMemo(() => {
// Step 1 completion is based on having a non-zero balance (after loading).
- const isStep1Complete = Boolean(
- !isAggregatedBalanceLoading && tokenTotal?.isGreaterThan(0),
- );
+ const isStep1Complete = isMoneyAccountFunded;
// Step 2 completion can be evaluated if either:
// - persisted progress is already at step index ≥ 1 (auto-advanced on a
@@ -117,8 +177,7 @@ const MoneyOnboardingCard = () => {
return 0;
}, [
currentStep,
- isAggregatedBalanceLoading,
- tokenTotal,
+ isMoneyAccountFunded,
isCardAuthenticated,
isCardLinkedToMoneyAccount,
]);
@@ -135,6 +194,39 @@ const MoneyOnboardingCard = () => {
}
}, [currentStep, targetStepFromCompletion, incrementStep]);
+ useEffect(() => {
+ if (
+ hasTrackedCardStepViewRef.current ||
+ isAggregatedBalanceLoading ||
+ !isCardAnalyticsReady ||
+ !isOnboardingCardVisible ||
+ !isVisibleAfterAutoSkip ||
+ effectiveCurrentStep !== 1
+ ) {
+ return;
+ }
+
+ hasTrackedCardStepViewRef.current = true;
+ trackEvent(
+ createEventBuilder(MetaMetricsEvents.CARD_VIEWED)
+ .addProperties({
+ screen: CardScreens.MONEY_HOME,
+ entrypoint: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
+ card_state: cardState,
+ })
+ .build(),
+ );
+ }, [
+ trackEvent,
+ createEventBuilder,
+ effectiveCurrentStep,
+ isAggregatedBalanceLoading,
+ isCardAnalyticsReady,
+ isOnboardingCardVisible,
+ isVisibleAfterAutoSkip,
+ cardState,
+ ]);
+
const handleStep1CtaPressed = useCallback(() => {
trackOnboardingEvent({
step: currentStep + 1, // Use 1-based index for event tracking to match total_steps count.
diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js
index cc25acf5b685..07c609a4d4bf 100644
--- a/app/components/UI/Navbar/index.js
+++ b/app/components/UI/Navbar/index.js
@@ -37,9 +37,6 @@ import Icon, {
} from '../../../component-library/components/Icons/Icon';
import { AddContactViewSelectorsIDs } from '../../Views/Settings/Contacts/AddContactView.testIds';
import { SettingsViewSelectorsIDs } from '../../Views/Settings/SettingsView.testIds';
-import HeaderBase, {
- HeaderBaseVariant,
-} from '../../../component-library/components/HeaderBase';
import getHeaderCompactStandardNavbarOptions from '../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions';
import BottomSheetHeader from '../../../component-library/components/BottomSheets/BottomSheetHeader';
import { AnalyticsEventBuilder } from '../../../util/analytics/AnalyticsEventBuilder';
@@ -52,6 +49,7 @@ import {
BadgeWrapper,
ButtonIcon,
ButtonIconSize,
+ HeaderBase,
IconColor as MMDSIconColor,
} from '@metamask/design-system-react-native';
@@ -1435,29 +1433,6 @@ export function getStakingNavbar(
};
}
-/**
- * Function that returns the navigation options for the DeFi Protocol Positions Details screen
- *
- * @param {Object} navigation - Navigation object required to push new views
- * @returns {Object} - Corresponding navbar options
- */
-export function getDeFiProtocolPositionDetailsNavbarOptions(navigation) {
- return {
- headerShown: true,
- headerTitle: () => null,
- headerLeft: () => (
- navigation.pop()}
- testID={CommonSelectorsIDs.BACK_ARROW_BUTTON}
- size={ButtonIconSize.Md}
- iconName={IconName.ArrowLeft}
- iconColor={IconColor.Default}
- />
- ),
- };
-}
-
export function getRampsOrderDetailsNavbarOptions(
navigation,
{ title, showBack = true },
diff --git a/app/components/UI/Navbar/index.test.js b/app/components/UI/Navbar/index.test.js
index d73dcb6561f1..04fada348db4 100644
--- a/app/components/UI/Navbar/index.test.js
+++ b/app/components/UI/Navbar/index.test.js
@@ -18,7 +18,6 @@ import {
getBridgeNavbar,
getBridgeTransactionDetailsNavbar,
getStakingNavbar,
- getDeFiProtocolPositionDetailsNavbarOptions,
getRampsOrderDetailsNavbarOptions,
getPaymentSelectorMethodNavbar,
getPaymentMethodApplePayNavbar,
@@ -489,16 +488,6 @@ describe('Navbar', () => {
});
});
- describe('getDeFiProtocolPositionDetailsNavbarOptions', () => {
- it('returns correct options', () => {
- const options =
- getDeFiProtocolPositionDetailsNavbarOptions(mockNavigation);
-
- expect(options).toHaveProperty('headerTitle');
- expect(options).toHaveProperty('headerLeft');
- });
- });
-
describe('getRampsOrderDetailsNavbarOptions', () => {
it('returns correct options', () => {
const options = getRampsOrderDetailsNavbarOptions(
diff --git a/app/components/UI/Navbar/index.test.jsx b/app/components/UI/Navbar/index.test.jsx
index 33fad6de7b8e..4bbb8aa48ca1 100644
--- a/app/components/UI/Navbar/index.test.jsx
+++ b/app/components/UI/Navbar/index.test.jsx
@@ -668,37 +668,6 @@ describe('getEditAccountNameNavBarOptions', () => {
});
});
-describe('getDeFiProtocolPositionDetailsNavbarOptions', () => {
- const { getDeFiProtocolPositionDetailsNavbarOptions } = require('.');
-
- const mockNavigation = {
- pop: jest.fn(),
- };
-
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- it('returns navbar options with back button', () => {
- const options = getDeFiProtocolPositionDetailsNavbarOptions(mockNavigation);
-
- expect(options.headerTitle).toBeDefined();
- expect(options.headerLeft).toBeDefined();
-
- const HeaderTitle = options.headerTitle();
- expect(HeaderTitle).toBeNull();
- });
-
- it('calls navigation.pop when back button is pressed', () => {
- const options = getDeFiProtocolPositionDetailsNavbarOptions(mockNavigation);
-
- const HeaderLeft = options.headerLeft();
- HeaderLeft.props.onPress();
-
- expect(mockNavigation.pop).toHaveBeenCalledTimes(1);
- });
-});
-
describe('getRampsOrderDetailsNavbarOptions', () => {
const { getRampsOrderDetailsNavbarOptions } = require('.');
diff --git a/app/components/UI/Predict/routes/index.test.tsx b/app/components/UI/Predict/routes/index.test.tsx
index 6378480ef71b..4abb756732b0 100644
--- a/app/components/UI/Predict/routes/index.test.tsx
+++ b/app/components/UI/Predict/routes/index.test.tsx
@@ -69,6 +69,11 @@ jest.mock('../views/PredictWorldCup', () => {
return () => ;
});
+jest.mock('../views/PredictFeedView', () => {
+ const { View } = jest.requireActual('react-native');
+ return () => ;
+});
+
jest.mock('../views/PredictMarketDetails', () => {
const { View } = jest.requireActual('react-native');
return () => ;
@@ -196,6 +201,18 @@ describe('PredictScreenStack', () => {
expect(screen.getByTestId('predict-world-cup')).toBeOnTheScreen();
});
+ it('navigates to FEED screen', async () => {
+ renderWithNavigation();
+
+ await act(async () => {
+ navigationRef.current?.navigate(Routes.PREDICT.FEED, {
+ feedId: 'sports',
+ });
+ });
+
+ expect(screen.getByTestId('predict-feed-view')).toBeOnTheScreen();
+ });
+
it('navigates to POSITIONS screen when portfolio flag is enabled', async () => {
mockPredictPortfolioEnabled = true;
renderWithNavigation();
diff --git a/app/components/UI/Predict/routes/index.tsx b/app/components/UI/Predict/routes/index.tsx
index cead671f0a17..3981950189a1 100644
--- a/app/components/UI/Predict/routes/index.tsx
+++ b/app/components/UI/Predict/routes/index.tsx
@@ -18,6 +18,7 @@ import PredictAddFundsModal from '../views/PredictAddFundsModal/PredictAddFundsM
import PredictPositionsView from '../views/PredictPositionsView';
import PredictMarketListRoute from './PredictMarketListRoute';
import PredictWorldCup from '../views/PredictWorldCup';
+import PredictFeedView from '../views/PredictFeedView';
import PredictGTMModal from '../components/PredictGTMModal';
import { useSelector } from 'react-redux';
import { PredictPreviewSheetProvider } from '../contexts';
@@ -98,6 +99,8 @@ const PredictScreenStack = () => {
component={PredictWorldCup}
/>
+
+
({
+ ...jest.requireActual('@react-navigation/native'),
+ useNavigation: () => ({ navigate: mockNavigate }),
+}));
+
// Mock only the data boundary: the section's own data hook. The hook's own
// logic (params, filtering, interleave, loading) is covered by
// usePredictLiveNowSection.test.ts. Here we exercise the real PredictMarket /
@@ -189,18 +198,26 @@ describe('PredictLiveNowSection', () => {
).not.toBeOnTheScreen();
});
- it('renders a non-pressable "Live now" header without a navigation chevron', () => {
+ it('renders a pressable "Live now" header that navigates to the live feed', () => {
setSection({ items: [createLiveMarket('L1')] });
- const { getByTestId, queryByTestId, getByText } = renderSection();
+ const { getByTestId, getByText } = renderSection();
- expect(
- getByTestId(PREDICT_LIVE_NOW_SECTION_TEST_IDS.HEADER),
- ).toBeOnTheScreen();
+ const header = getByTestId(PREDICT_LIVE_NOW_SECTION_TEST_IDS.HEADER);
+ expect(header).toBeOnTheScreen();
expect(getByText(strings('predict.home.live_now_title'))).toBeOnTheScreen();
- // The chevron only renders when the header is pressable (onPress set); the
- // "See all" target route does not exist yet, so it must be absent.
- expect(queryByTestId('section-header-arrow-icon')).not.toBeOnTheScreen();
+ // The chevron renders only when the header is pressable (onPress set).
+ expect(getByTestId('section-header-arrow-icon')).toBeOnTheScreen();
+
+ fireEvent.press(header);
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.FEED,
+ params: {
+ feedId: 'live',
+ entryPoint: PredictEventValues.ENTRY_POINT.HOME_SECTION,
+ },
+ });
});
it('renders pagination dots when there are 2+ items after load', () => {
diff --git a/app/components/UI/Predict/views/PredictHome/components/PredictLiveNowSection/PredictLiveNowSection.tsx b/app/components/UI/Predict/views/PredictHome/components/PredictLiveNowSection/PredictLiveNowSection.tsx
index 7e7c1403871b..f4c3d983cffd 100644
--- a/app/components/UI/Predict/views/PredictHome/components/PredictLiveNowSection/PredictLiveNowSection.tsx
+++ b/app/components/UI/Predict/views/PredictHome/components/PredictLiveNowSection/PredictLiveNowSection.tsx
@@ -7,13 +7,16 @@ import {
import { Box, BoxBorderColor } from '@metamask/design-system-react-native';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import { FlashList, type ListRenderItem } from '@shopify/flash-list';
+import { useNavigation, type NavigationProp } from '@react-navigation/native';
import { strings } from '../../../../../../../../locales/i18n';
+import Routes from '../../../../../../../constants/navigation/Routes';
import SectionHeader from '../../../../../../../component-library/components-temp/SectionHeader';
import PredictMarket from '../../../../components/PredictMarket';
import PredictMarketSkeleton from '../../../../components/PredictMarketSkeleton';
import { PaginationDots } from '../../../../components/PaginationDots/PaginationDots';
import { PredictEventValues } from '../../../../constants/eventNames';
import type { PredictMarket as PredictMarketType } from '../../../../types';
+import type { PredictNavigationParamList } from '../../../../types/navigation';
import { PREDICT_LIVE_NOW_SECTION_TEST_IDS } from './PredictLiveNowSection.testIds';
import { usePredictLiveNowSection } from './usePredictLiveNowSection';
@@ -43,6 +46,8 @@ const PredictLiveNowSection: React.FC = ({
testID = PREDICT_LIVE_NOW_SECTION_TEST_IDS.SECTION,
}) => {
const tw = useTailwind();
+ const navigation =
+ useNavigation>();
const { width: screenWidth } = useWindowDimensions();
const cardWidth = useMemo(
() => screenWidth * CARD_WIDTH_RATIO,
@@ -54,6 +59,16 @@ const PredictLiveNowSection: React.FC = ({
const { items, isLoading, isEmpty } = usePredictLiveNowSection();
const [activeIndex, setActiveIndex] = useState(0);
+ const handleSeeAll = useCallback(() => {
+ navigation.navigate(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.FEED,
+ params: {
+ feedId: 'live',
+ entryPoint: PredictEventValues.ENTRY_POINT.HOME_SECTION,
+ },
+ });
+ }, [navigation]);
+
useEffect(() => {
const lastIndex = items.length - 1;
setActiveIndex((prev) =>
@@ -124,14 +139,14 @@ const PredictLiveNowSection: React.FC = ({
return (
- {/* Static (non-pressable) header for now: the "See all" target — a generic
- PredictFeedView with feedId 'live' — does not exist yet, so we render
- plain header text rather than a dead control. Pass `onPress` (and the
- chevron returns) once Routes.PREDICT.FEED lands. See
- predict-home-redesign.md (Upcoming Tickets A + B). */}
+ {/* "See all" navigates to the generic PredictFeedView (feedId 'live').
+ Passing `onPress` is what renders the chevron + touchable (see
+ SectionHeader). This is the reference pattern other home sections
+ (Trending / Popular Today / Categories) follow with their own feedId. */}
diff --git a/app/components/UI/UpdateNeeded/UpdateNeeded.tsx b/app/components/UI/UpdateNeeded/UpdateNeeded.tsx
index 66368c063f91..c491d243852b 100644
--- a/app/components/UI/UpdateNeeded/UpdateNeeded.tsx
+++ b/app/components/UI/UpdateNeeded/UpdateNeeded.tsx
@@ -12,7 +12,6 @@ import Button, {
ButtonWidthTypes,
} from '../../../component-library/components/Buttons/Button';
import ButtonIcon from '../../../component-library/components/Buttons/ButtonIcon';
-import HeaderBase from '../../../component-library/components/HeaderBase';
import {
IconColor,
IconName,
@@ -23,7 +22,11 @@ import { ScrollView } from 'react-native-gesture-handler';
import generateDeviceAnalyticsMetaData from '../../../util/metrics';
import { useAnalytics } from '../../../components/hooks/useAnalytics/useAnalytics';
-import { Text, TextVariant } from '@metamask/design-system-react-native';
+import {
+ HeaderBase,
+ Text,
+ TextVariant,
+} from '@metamask/design-system-react-native';
/* eslint-disable import-x/no-commonjs, @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */
const foxLogo = require('../../../images/branding/fox.png');
diff --git a/app/components/Views/ActivityView/index.js b/app/components/Views/ActivityView/index.js
index a02d9f87a88d..fc607753aa74 100644
--- a/app/components/Views/ActivityView/index.js
+++ b/app/components/Views/ActivityView/index.js
@@ -217,7 +217,7 @@ const ActivityView = () => {
return (
{
) : (
)}
diff --git a/app/components/Views/ActivityView/index.test.tsx b/app/components/Views/ActivityView/index.test.tsx
index 7d6b3d55482e..0925538bf3a9 100644
--- a/app/components/Views/ActivityView/index.test.tsx
+++ b/app/components/Views/ActivityView/index.test.tsx
@@ -533,6 +533,16 @@ describe('ActivityView', () => {
).toBeOnTheScreen();
});
+ it('renders SafeAreaView with left, right, and bottom edges only', () => {
+ mockRoute.params = {};
+
+ const { getByTestId } = renderComponent(mockInitialState);
+
+ expect(
+ getByTestId(ActivitiesViewSelectorsIDs.SAFE_AREA_VIEW).props.edges,
+ ).toEqual(['left', 'right', 'bottom']);
+ });
+
it('renders HeaderRoot with Activity title when showBackButton is false', () => {
mockRoute.params = { showBackButton: false };
diff --git a/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.test.tsx b/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.test.tsx
index a08d59bbdb2f..c3fe94981b86 100644
--- a/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.test.tsx
+++ b/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.test.tsx
@@ -20,8 +20,9 @@ import {
} from '../../../../../component-library/components/BottomSheets/BottomSheetFooter/BottomSheetFooter.constants';
import Routes from '../../../../../constants/navigation/Routes';
import Logger from '../../../../../util/Logger';
+import { strings } from '../../../../../../locales/i18n';
+import { ImportTokenViewSelectorsIDs } from '../../ImportAssetView.testIds';
-const mockSetOptions = jest.fn();
const mockNavigate = jest.fn();
const mockGoBack = jest.fn();
const mockAddTokenList = jest.fn().mockResolvedValue(undefined);
@@ -32,7 +33,6 @@ jest.mock('@react-navigation/native', () => {
...actualReactNavigation,
useNavigation: () => ({
navigate: mockNavigate,
- setOptions: mockSetOptions,
goBack: mockGoBack,
}),
};
@@ -131,6 +131,16 @@ describe('ConfirmAddAsset', () => {
expect(getByText('USDC')).toBeOnTheScreen();
});
+ it('calls goBack when HeaderStandard back button is pressed', async () => {
+ const { getByTestId } = renderWithProvider(, {
+ state: mockInitialState,
+ });
+
+ await userEvent.press(getByTestId('button-icon'));
+
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
+ });
+
it('calls goBack when back button is pressed', async () => {
const { getByTestId } = renderWithProvider(, {
state: mockInitialState,
@@ -252,11 +262,22 @@ describe('ConfirmAddAsset', () => {
expect(getByText('USDT')).toBeOnTheScreen();
});
- it('sets navigation bar options on mount', () => {
- renderWithProvider(, {
+ it('renders HeaderStandard with the add asset title', () => {
+ const { getByText } = renderWithProvider(, {
+ state: mockInitialState,
+ });
+
+ expect(getByText(strings('add_asset.title'))).toBeOnTheScreen();
+ });
+
+ it('renders SafeAreaView with left, right, and bottom edges only', () => {
+ const { getByTestId } = renderWithProvider(, {
state: mockInitialState,
});
- expect(mockSetOptions).toHaveBeenCalledTimes(1);
+ expect(
+ getByTestId(ImportTokenViewSelectorsIDs.ADD_CONFIRM_CUSTOM_ASSET).props
+ .edges,
+ ).toEqual(['left', 'right', 'bottom']);
});
});
diff --git a/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.tsx b/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.tsx
index f777474687c7..8bfc4d204af6 100644
--- a/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.tsx
+++ b/app/components/Views/AddAsset/Views/ConfirmAddTokenView/ConfirmAddAsset.tsx
@@ -1,11 +1,10 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useCallback, useState } from 'react';
import { Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import { useParams } from '../../../../../util/navigation/navUtils';
import { strings } from '../../../../../../locales/i18n';
import { useNavigation } from '@react-navigation/native';
-import getHeaderCompactStandardNavbarOptions from '../../../../../component-library/components-temp/HeaderCompactStandard/getHeaderCompactStandardNavbarOptions';
import {
ButtonSize,
ButtonVariants,
@@ -17,7 +16,12 @@ import ListItem from '../../../../../component-library/components/List/ListItem'
import Routes from '../../../../../constants/navigation/Routes';
import { ImportTokenViewSelectorsIDs } from '../../ImportAssetView.testIds';
import { FlashList } from '@shopify/flash-list';
-import { Box, Text, TextVariant } from '@metamask/design-system-react-native';
+import {
+ Box,
+ HeaderStandard,
+ Text,
+ TextVariant,
+} from '@metamask/design-system-react-native';
import { ImportAsset } from '../../utils/utils';
import AddAssetTokenRow from '../../components/AddAssetTokenRow/AddAssetTokenRow';
import Logger from '../../../../../util/Logger';
@@ -45,20 +49,6 @@ const ConfirmAddAsset = () => {
});
}, [navigation]);
- const updateNavBar = useCallback(() => {
- navigation.setOptions(
- getHeaderCompactStandardNavbarOptions({
- title: strings(`add_asset.title`),
- onBack: () => navigation.goBack(),
- includesTopInset: true,
- }),
- );
- }, [navigation]);
-
- useEffect(() => {
- updateNavBar();
- }, [updateNavBar]);
-
const handleImport = useCallback(async () => {
if (isImporting) {
return;
@@ -81,6 +71,12 @@ const ConfirmAddAsset = () => {
style={tw.style('flex-1 bg-default')}
testID={ImportTokenViewSelectorsIDs.ADD_CONFIRM_CUSTOM_ASSET}
>
+ navigation.goBack()}
+ includesTopInset
+ />
+
{selectedAsset.length > 1
diff --git a/app/components/Views/QRTabSwitcher/QRTabSwitcher.tsx b/app/components/Views/QRTabSwitcher/QRTabSwitcher.tsx
index 5b74f95a98e4..6fefe93d2b2a 100644
--- a/app/components/Views/QRTabSwitcher/QRTabSwitcher.tsx
+++ b/app/components/Views/QRTabSwitcher/QRTabSwitcher.tsx
@@ -11,7 +11,7 @@ import ButtonIcon, {
ButtonIconSizes,
} from '../../../component-library/components/Buttons/ButtonIcon';
import { IconName } from '../../../component-library/components/Icons/Icon';
-import HeaderBase from '../../../component-library/components/HeaderBase';
+import { HeaderBase } from '@metamask/design-system-react-native';
import { endTrace, trace, TraceName } from '../../../util/trace';
export enum QRTabSwitcherScreens {
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.test.tsx
index 2eadc24736dc..93bb31e61d8e 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.test.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyContext.test.tsx
@@ -62,6 +62,7 @@ const buildController = (
estimatedReceiveAmount: undefined,
sourceBalanceFiat: '$0.00',
sourceBalanceDisplay: undefined,
+ destBalanceFiat: undefined,
formattedNetworkFee: '-',
formattedSlippage: '-',
formattedMinimumReceived: '-',
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyReceiveScreen.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyReceiveScreen.test.tsx
index 1d4845499e0f..34ed895373c4 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyReceiveScreen.test.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyReceiveScreen.test.tsx
@@ -19,8 +19,15 @@ jest.mock('../../../../../../../locales/i18n', () => ({
}));
jest.mock('@metamask/bridge-controller', () => ({
- formatChainIdToHex: () => '0x1',
- isNonEvmChainId: () => false,
+ formatChainIdToHex: (caipChainId: string) => {
+ const [namespace, reference] = caipChainId.split(':');
+ if (namespace !== 'eip155') {
+ throw new Error(`unsupported chain ${caipChainId}`);
+ }
+ return `0x${parseInt(reference, 10).toString(16)}`;
+ },
+ isNonEvmChainId: (chainId: string) =>
+ !chainId.startsWith('0x') && !chainId.startsWith('eip155:'),
isNativeAddress: () => false,
getNativeAssetForChainId: () => undefined,
}));
@@ -39,12 +46,33 @@ const createToken = (overrides: Partial = {}): BridgeToken => ({
...overrides,
});
+const SOLANA_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp';
+const TRON_CHAIN_ID = 'tron:728126428';
+const BITCOIN_CHAIN_ID = 'bip122:000000000019d6689c085ae165831e93';
+
const usdcToken = createToken({ symbol: 'USDC', chainId: '0x1' });
const usdtToken = createToken({
symbol: 'USDT',
chainId: '0x1',
address: '0xdac17f958d2ee523a2206206994597c13d831ec7',
});
+const solanaUsdcToken = createToken({
+ symbol: 'USDC',
+ chainId: SOLANA_CHAIN_ID,
+ address: `${SOLANA_CHAIN_ID}/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`,
+});
+const tronNativeToken = createToken({
+ symbol: 'TRX',
+ name: 'Tron',
+ chainId: TRON_CHAIN_ID,
+ address: `${TRON_CHAIN_ID}/slip44:195`,
+});
+const bitcoinNativeToken = createToken({
+ symbol: 'BTC',
+ name: 'Bitcoin',
+ chainId: BITCOIN_CHAIN_ID,
+ address: `${BITCOIN_CHAIN_ID}/slip44:0`,
+});
const buildContext = (overrides: Record = {}) => ({
tradeMode: 'sell',
@@ -121,4 +149,53 @@ describe('QuickBuyReceiveScreen', () => {
screen.getByText('social_leaderboard.quick_buy.receive_with_no_tokens'),
).toBeOnTheScreen();
});
+
+ it('renders Solana, Tron and Bitcoin network chips when receive options exist on those chains', () => {
+ (useQuickBuyContext as jest.Mock).mockReturnValue(
+ buildContext({
+ sellDestTokenOptions: [
+ usdcToken,
+ solanaUsdcToken,
+ tronNativeToken,
+ bitcoinNativeToken,
+ ],
+ handleSelectDestStable,
+ setActiveScreen,
+ }),
+ );
+
+ render();
+
+ expect(
+ screen.getByTestId(`quick-buy-chain-filter-${SOLANA_CHAIN_ID}`),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByTestId(`quick-buy-chain-filter-${TRON_CHAIN_ID}`),
+ ).toBeOnTheScreen();
+ expect(
+ screen.getByTestId(`quick-buy-chain-filter-${BITCOIN_CHAIN_ID}`),
+ ).toBeOnTheScreen();
+ });
+
+ it('defaults the chain filter to Solana when the position is on Solana', () => {
+ (useQuickBuyContext as jest.Mock).mockReturnValue(
+ buildContext({
+ target: {
+ chain: SOLANA_CHAIN_ID,
+ tokenAddress: `${SOLANA_CHAIN_ID}/slip44:501`,
+ tokenSymbol: 'SOL',
+ tokenName: 'Solana',
+ },
+ sellDestTokenOptions: [usdcToken, solanaUsdcToken],
+ selectedDestStable: solanaUsdcToken,
+ handleSelectDestStable,
+ setActiveScreen,
+ }),
+ );
+
+ render();
+
+ expect(screen.getByTestId(getRowTestId(solanaUsdcToken))).toBeOnTheScreen();
+ expect(screen.queryByTestId(getRowTestId(usdcToken))).toBeNull();
+ });
});
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyReceiveScreen.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyReceiveScreen.tsx
index a64c6e79c26e..644e26f18be1 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyReceiveScreen.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyReceiveScreen.tsx
@@ -30,15 +30,20 @@ const QuickBuyReceiveScreen: React.FC = () => {
// `target.chain` is already a CAIP id — `positionToQuickBuyTarget` does the
// chain-name → CAIP conversion when the target is built.
const caip = target.chain as CaipChainId;
- if (isNonEvmChainId(caip)) return null;
- let hexChainId: string;
- try {
- hexChainId = formatChainIdToHex(caip);
- } catch {
- return null;
+ // Receive candidates carry hex chain ids on EVM and CAIP ids on non-EVM
+ // (e.g. Solana), so the filter id must match the candidate format.
+ let chainFilterId: string;
+ if (isNonEvmChainId(caip)) {
+ chainFilterId = caip;
+ } else {
+ try {
+ chainFilterId = formatChainIdToHex(caip);
+ } catch {
+ return null;
+ }
}
- return sellDestTokenOptions.some((t) => t.chainId === hexChainId)
- ? hexChainId
+ return sellDestTokenOptions.some((t) => t.chainId === chainFilterId)
+ ? chainFilterId
: null;
}, [target.chain, sellDestTokenOptions]);
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.test.tsx
index 3b73a4703bd4..5dcbd2f262ab 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.test.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuyRoot.test.tsx
@@ -177,6 +177,7 @@ const buildHookResult = (
estimatedReceiveAmount: undefined,
sourceBalanceFiat: '$0.00',
sourceBalanceDisplay: undefined,
+ destBalanceFiat: undefined,
formattedNetworkFee: '-',
formattedSlippage: '-',
formattedMinimumReceived: '-',
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySheet.test.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySheet.test.tsx
index b10871b1cdd9..371501754451 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySheet.test.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/QuickBuySheet.test.tsx
@@ -194,6 +194,7 @@ const buildHookResult = (
estimatedReceiveAmount: undefined,
sourceBalanceFiat: '$0.00',
sourceBalanceDisplay: undefined,
+ destBalanceFiat: undefined,
formattedNetworkFee: '-',
formattedSlippage: '-',
formattedMinimumReceived: '-',
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx
index 0e03a0fd615a..07f9706a627c 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/components/QuickBuyActionFooter.tsx
@@ -43,6 +43,7 @@ const QuickBuyActionFooter: React.FC = () => {
sourceToken,
sourceChainId,
sourceBalanceFiat,
+ destBalanceFiat,
destToken,
selectedDestStable,
features,
@@ -56,10 +57,12 @@ const QuickBuyActionFooter: React.FC = () => {
| import('@metamask/utils').Hex
| undefined)
: sourceChainId;
+ // Both balances are driven by live, selector-backed state (TSA-632):
+ // `sourceBalanceFiat` from `useLatestBalance` re-keyed off the live cached
+ // balance, and `destBalanceFiat` resynced from the reactive receive-token
+ // list. Either updates the pill the moment the underlying balance changes.
const pickerBalanceFiat =
- tradeMode === 'sell'
- ? (selectedDestStable?.balanceFiat ?? undefined)
- : sourceBalanceFiat;
+ tradeMode === 'sell' ? destBalanceFiat : sourceBalanceFiat;
const networkImage = pickerChainId
? getNetworkImageSource({ chainId: pickerChainId })
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.test.ts
index 44d8e9bc2d8e..b633a8426108 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.test.ts
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.test.ts
@@ -232,4 +232,99 @@ describe('enrichTokenBalance', () => {
});
});
});
+
+ describe('Tron and Bitcoin', () => {
+ const trxAssetId = 'tron:728126428/slip44:195';
+ const tronScope = 'tron:728126428';
+ const btcAssetId = 'bip122:000000000019d6689c085ae165831e93/slip44:0';
+ const bitcoinScope = 'bip122:000000000019d6689c085ae165831e93';
+ const tronAccount = { id: 'tron-account-id' };
+ const bitcoinAccount = { id: 'bitcoin-account-id' };
+
+ it('prices a held TRX balance using the Tron account multichain data', () => {
+ const deps = baseDeps({
+ tronAccount,
+ multichainBalances: {
+ [tronAccount.id]: { [trxAssetId]: { amount: '100' } },
+ },
+ multichainRates: { [trxAssetId]: { rate: '0.25' } },
+ });
+
+ const result = enrichTokenBalance(
+ token({ address: trxAssetId, chainId: tronScope, symbol: 'TRX' }),
+ deps,
+ );
+
+ expect(result).toEqual({
+ balance: '100',
+ balanceFiat: '$25.00',
+ tokenFiatAmount: 25,
+ currencyExchangeRate: 0.25,
+ });
+ });
+
+ it('prices a held BTC balance using the Bitcoin account multichain data', () => {
+ const deps = baseDeps({
+ bitcoinAccount,
+ multichainBalances: {
+ [bitcoinAccount.id]: { [btcAssetId]: { amount: '0.5' } },
+ },
+ multichainRates: { [btcAssetId]: { rate: '100000' } },
+ });
+
+ const result = enrichTokenBalance(
+ token({ address: btcAssetId, chainId: bitcoinScope, symbol: 'BTC' }),
+ deps,
+ );
+
+ expect(result).toEqual({
+ balance: '0.5',
+ balanceFiat: '$50000.00',
+ tokenFiatAmount: 50000,
+ currencyExchangeRate: 100000,
+ });
+ });
+
+ it('returns null when there is no account for the candidate chain (strict)', () => {
+ const result = enrichTokenBalance(
+ token({ address: trxAssetId, chainId: tronScope, symbol: 'TRX' }),
+ baseDeps(),
+ );
+
+ expect(result).toBeNull();
+ });
+
+ it('returns a zero enrichment when there is no account for the chain and lenient', () => {
+ const result = enrichTokenBalance(
+ token({ address: btcAssetId, chainId: bitcoinScope, symbol: 'BTC' }),
+ baseDeps(),
+ { includeZeroBalance: true },
+ );
+
+ expect(result).toEqual({
+ balance: '0',
+ balanceFiat: '$0.00',
+ tokenFiatAmount: 0,
+ currencyExchangeRate: undefined,
+ });
+ });
+
+ it('does not read another chain account for a non-EVM candidate (Solana account does not price TRX)', () => {
+ const solanaAccount = { id: 'solana-account-id' };
+ const deps = baseDeps({
+ solanaAccount,
+ multichainBalances: {
+ [solanaAccount.id]: { [trxAssetId]: { amount: '100' } },
+ },
+ multichainRates: { [trxAssetId]: { rate: '0.25' } },
+ });
+
+ const result = enrichTokenBalance(
+ token({ address: trxAssetId, chainId: tronScope, symbol: 'TRX' }),
+ deps,
+ );
+
+ expect(result).toBeNull();
+ });
+ });
});
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.ts
index 9dcf8bc88c7a..cdab597c5e1d 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.ts
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/enrichTokenBalance.ts
@@ -1,6 +1,11 @@
import type { Hex } from '@metamask/utils';
import { formatUnits } from 'ethers/lib/utils';
-import { isSolanaChainId, isNativeAddress } from '@metamask/bridge-controller';
+import {
+ isNonEvmChainId,
+ isSolanaChainId,
+ isNativeAddress,
+} from '@metamask/bridge-controller';
+import { BtcScope, TrxScope } from '@metamask/keyring-api';
import type { BridgeToken } from '../../../../../../UI/Bridge/types';
import { addCurrencySymbol } from '../../../../../../../util/number/bigint';
import type { selectTokenMarketData } from '../../../../../../../selectors/tokenRatesController';
@@ -41,6 +46,8 @@ export interface TokenBalanceDeps {
currencyRates: ReturnType;
allNetworkConfigs?: Record;
solanaAccount?: { id: string };
+ tronAccount?: { id: string };
+ bitcoinAccount?: { id: string };
multichainBalances?: Record<
string,
Record | undefined
@@ -173,20 +180,36 @@ const enrichEvmTokenBalance = (
return priced(displayBalance, exchangeRate, balanceNum);
};
-const enrichSolanaTokenBalance = (
+/**
+ * Resolves the non-EVM account whose multichain balances cover the candidate's
+ * chain. Multichain balances/rates are keyed by account id + CAIP asset id, so
+ * each non-EVM chain needs the matching account from the selected group.
+ */
+const getNonEvmAccount = (
+ chainId: BridgeToken['chainId'],
+ deps: TokenBalanceDeps,
+): { id: string } | undefined => {
+ if (isSolanaChainId(chainId)) return deps.solanaAccount;
+ if (chainId === TrxScope.Mainnet) return deps.tronAccount;
+ if (chainId === BtcScope.Mainnet) return deps.bitcoinAccount;
+ return undefined;
+};
+
+const enrichNonEvmTokenBalance = (
candidate: BridgeToken,
deps: TokenBalanceDeps,
options: EnrichTokenBalanceOptions,
): TokenBalanceEnrichment | null => {
const { includeZeroBalance } = options;
- const { solanaAccount, multichainBalances, multichainRates } = deps;
+ const { multichainBalances, multichainRates } = deps;
const dropOrZero = () => (includeZeroBalance ? zeroEnrichment() : null);
- if (!solanaAccount) return dropOrZero();
+ const nonEvmAccount = getNonEvmAccount(candidate.chainId, deps);
+ if (!nonEvmAccount) return dropOrZero();
const amountStr =
- multichainBalances?.[solanaAccount.id]?.[candidate.address]?.amount;
+ multichainBalances?.[nonEvmAccount.id]?.[candidate.address]?.amount;
if (!amountStr) return dropOrZero();
const balanceNum = parseFloat(amountStr);
@@ -207,14 +230,15 @@ const enrichSolanaTokenBalance = (
/**
* Prices a single token candidate from cached Redux balances, returning the
* shared balance fields (or `null` when the token should be omitted). Handles
- * EVM natives, EVM ERC-20s, and Solana assets, keeping the USD exchange-rate
- * semantics QuickBuy's amount math depends on.
+ * EVM natives, EVM ERC-20s, and non-EVM assets (Solana, Tron, Bitcoin) via the
+ * multichain balance/rate controllers, keeping the USD exchange-rate semantics
+ * QuickBuy's amount math depends on.
*/
export const enrichTokenBalance = (
candidate: BridgeToken,
deps: TokenBalanceDeps,
options: EnrichTokenBalanceOptions = {},
): TokenBalanceEnrichment | null =>
- isSolanaChainId(candidate.chainId)
- ? enrichSolanaTokenBalance(candidate, deps, options)
+ isNonEvmChainId(candidate.chainId)
+ ? enrichNonEvmTokenBalance(candidate, deps, options)
: enrichEvmTokenBalance(candidate, deps, options);
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/liveSelectedTokenBalance.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/liveSelectedTokenBalance.test.ts
new file mode 100644
index 000000000000..2695c04943a0
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/liveSelectedTokenBalance.test.ts
@@ -0,0 +1,104 @@
+import type { BridgeToken } from '../../../../../../UI/Bridge/types';
+import { resolveLiveTokenBalance } from './liveSelectedTokenBalance';
+
+const token = (overrides: Partial = {}): BridgeToken =>
+ ({
+ address: '0xABC',
+ chainId: '0x1',
+ decimals: 18,
+ symbol: 'ETH',
+ name: 'Ethereum',
+ balance: '1.0',
+ balanceFiat: '$2000.00',
+ tokenFiatAmount: 2000,
+ currencyExchangeRate: 2000,
+ ...overrides,
+ }) as BridgeToken;
+
+describe('resolveLiveTokenBalance', () => {
+ it('returns undefined when no token is selected', () => {
+ // Arrange / Act
+ const result = resolveLiveTokenBalance(undefined, [token()]);
+
+ // Assert
+ expect(result).toBeUndefined();
+ });
+
+ it('returns the matching live option balance over the selection snapshot', () => {
+ // Arrange — the selected snapshot is stale; the live option holds fresh
+ // balance fields for the same address:chainId.
+ const selected = token({ balance: '1.0', balanceFiat: '$2000.00' });
+ const liveOption = token({
+ balance: '0.5',
+ balanceFiat: '$1000.00',
+ tokenFiatAmount: 1000,
+ currencyExchangeRate: 2000,
+ });
+
+ // Act
+ const result = resolveLiveTokenBalance(selected, [liveOption]);
+
+ // Assert
+ expect(result).toEqual({
+ balance: '0.5',
+ balanceFiat: '$1000.00',
+ tokenFiatAmount: 1000,
+ currencyExchangeRate: 2000,
+ });
+ });
+
+ it('matches by stable key regardless of address casing', () => {
+ // Arrange
+ const selected = token({ address: '0xAbC' });
+ const liveOption = token({ address: '0xabc', balance: '0.25' });
+
+ // Act
+ const result = resolveLiveTokenBalance(selected, [liveOption]);
+
+ // Assert
+ expect(result?.balance).toBe('0.25');
+ });
+
+ it('does not match a token on a different chain', () => {
+ // Arrange — same address, different chain → not the same holding.
+ const selected = token({ chainId: '0x1', balance: '1.0' });
+ const otherChain = token({ chainId: '0x89', balance: '5.0' });
+
+ // Act
+ const result = resolveLiveTokenBalance(selected, [otherChain]);
+
+ // Assert — falls back to the snapshot's own fields.
+ expect(result?.balance).toBe('1.0');
+ });
+
+ it('falls back to the snapshot fields when the token is absent from the options', () => {
+ // Arrange
+ const selected = token({ balance: '1.0', balanceFiat: '$2000.00' });
+
+ // Act
+ const result = resolveLiveTokenBalance(selected, []);
+
+ // Assert
+ expect(result).toEqual({
+ balance: '1.0',
+ balanceFiat: '$2000.00',
+ tokenFiatAmount: 2000,
+ currencyExchangeRate: 2000,
+ });
+ });
+
+ it('surfaces only balance fields, never identity fields', () => {
+ // Arrange
+ const selected = token();
+
+ // Act
+ const result = resolveLiveTokenBalance(selected, [selected]);
+
+ // Assert — identity fields must not leak so callers keep their stable token
+ // reference for quote fetching.
+ expect(result).not.toHaveProperty('address');
+ expect(result).not.toHaveProperty('chainId');
+ expect(result).not.toHaveProperty('decimals');
+ expect(result).not.toHaveProperty('symbol');
+ });
+});
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/liveSelectedTokenBalance.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/liveSelectedTokenBalance.ts
new file mode 100644
index 000000000000..a5e160f1651c
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/liveSelectedTokenBalance.ts
@@ -0,0 +1,62 @@
+import type { BridgeToken } from '../../../../../../UI/Bridge/types';
+import { getTokenKey } from '../tokenKey';
+
+/**
+ * The live balance fields QuickBuy re-reads from the reactive option lists
+ * (`usePayWithTokens` / `useReceiveTokens`), which are themselves driven by
+ * `useSelector` subscriptions to `TokenBalancesController` /
+ * `AccountTrackerController` (EVM) and the multichain balances state (Solana).
+ *
+ * Only the balance-shaped fields are surfaced — never identity fields
+ * (`address`, `chainId`, `decimals`, `symbol`) — so callers can refresh a
+ * selected token's displayed balance without swapping the reference-stable
+ * token used for quote fetching.
+ */
+export interface LiveTokenBalanceFields {
+ balance?: string;
+ balanceFiat?: string;
+ tokenFiatAmount?: number;
+ currencyExchangeRate?: number;
+}
+
+/**
+ * Resolves the live balance fields for a selected token by matching it (by
+ * stable `address:chainId` key) against the reactive option list it was
+ * originally picked from.
+ *
+ * QuickBuy stores the chosen pay-with / receive token as a `useState` snapshot,
+ * so its cached `balance` / `balanceFiat` freeze at selection time. The option
+ * lists, by contrast, recompute on every balance-state change (a swap settling,
+ * an external incoming transfer, a send in another flow, …). Re-reading the
+ * matching option's balance here makes the displayed available balance track
+ * the underlying state regardless of *what* changed it — see TSA-632.
+ *
+ * Falls back to the snapshot's own fields when no matching option is found
+ * (e.g. the user spent the entire balance and the held-token list dropped it),
+ * so the row degrades to the last known value rather than blanking out.
+ *
+ * @param selected - The selected token snapshot (or `undefined`).
+ * @param liveOptions - The reactive option list the token was selected from.
+ * @returns The live balance fields, or `undefined` when `selected` is absent.
+ */
+export const resolveLiveTokenBalance = (
+ selected: BridgeToken | undefined,
+ liveOptions: BridgeToken[],
+): LiveTokenBalanceFields | undefined => {
+ if (!selected) {
+ return undefined;
+ }
+
+ const selectedKey = getTokenKey(selected);
+ const liveMatch = liveOptions.find(
+ (option) => getTokenKey(option) === selectedKey,
+ );
+ const source = liveMatch ?? selected;
+
+ return {
+ balance: source.balance,
+ balanceFiat: source.balanceFiat,
+ tokenFiatAmount: source.tokenFiatAmount,
+ currencyExchangeRate: source.currencyExchangeRate,
+ };
+};
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts
index 3d5ba9fae1a5..cdbcdb2147f8 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyController.ts
@@ -101,6 +101,7 @@ import {
endQuickBuySubmission,
} from '../quickBuyTradeTracker';
import { resolveQuickBuyTerminalToast } from '../resolveQuickBuyTerminalToast';
+import { resolveLiveTokenBalance } from './liveSelectedTokenBalance';
export type QuickBuyButtonError =
| 'insufficient_balance'
@@ -160,6 +161,12 @@ export interface UseQuickBuyControllerResult {
estimatedReceiveAmount: string | undefined;
sourceBalanceFiat: string;
sourceBalanceDisplay: string | undefined;
+ /**
+ * Live fiat balance of the sell-mode "Receive" token, resynced from the
+ * reactive receive-token list so it tracks underlying balance changes.
+ * `undefined` when no receive token is selected or its price is unresolved.
+ */
+ destBalanceFiat: string | undefined;
formattedNetworkFee: string;
formattedSlippage: string;
formattedMinimumReceived: string;
@@ -436,15 +443,72 @@ export function useQuickBuyController(
const hasInitializedRecipient = useRef(false);
useRecipientInitialization(hasInitializedRecipient);
+ // ─── Live selected-token balances (TSA-632) ────────────────────────────
+ // The selected pay-with token (`selectedSourceToken`, buy mode) and receive
+ // token (`selectedDestStable`, sell mode) are `useState` snapshots, so their
+ // cached `balance` / `balanceFiat` freeze at selection time. The option lists
+ // they were picked from — `usePayWithTokens` / `useReceiveTokens` — recompute
+ // on every balance-state change because they subscribe (via `useSelector`) to
+ // `TokenBalancesController` / `AccountTrackerController` (EVM) and the
+ // multichain balances state (Solana). Re-reading the matching option here
+ // makes both displayed balances track the underlying state whatever changed
+ // it: a QuickBuy swap settling, an external incoming transfer, a send in
+ // another flow, etc.
+ //
+ // The other side of each trade is already selector-driven and needs no
+ // resync: sell-mode `positionToken` comes from `usePositionTokenBalance`, and
+ // buy-mode `destToken` (`positionTokenFromSetup`) carries no displayed
+ // balance in the footer.
+ //
+ // Only the balance fields are pulled from the live option — never the token
+ // reference passed to quote fetching — so market-data ticks can't churn quote
+ // requests (see `destTokenForRate` below for the same invariant).
+ const liveSelectedSourceBalance = resolveLiveTokenBalance(
+ selectedSourceToken,
+ sourceTokenOptions,
+ );
+ const liveSelectedDestBalance = resolveLiveTokenBalance(
+ selectedDestStable,
+ sellDestTokenOptions,
+ );
+
+ // In buy mode, read the live exchange rate from the reactive option list so
+ // the displayed fiat balance and slider cap stay in sync with the option list
+ // when `usePayWithTokens` refreshes rates without a balance string change
+ // (Bugbot: "Stale rate with live balance"). In sell mode `sourceToken` is
+ // `positionToken` (already selector-driven), so the frozen value is live.
+ //
+ // Intentionally NOT used in `sourceTokenAmount` (the quote pipeline): a rate
+ // tick must not churn quote requests — only balance changes do.
+ const liveSourceCurrencyExchangeRate =
+ tradeMode === 'buy'
+ ? (liveSelectedSourceBalance?.currencyExchangeRate ??
+ sourceToken?.currencyExchangeRate)
+ : sourceToken?.currencyExchangeRate;
+
const hasSourcePrice = Boolean(
- sourceToken?.currencyExchangeRate && sourceToken.currencyExchangeRate > 0,
+ liveSourceCurrencyExchangeRate && liveSourceCurrencyExchangeRate > 0,
);
+ // The live balance for whichever token is the *source* this mode: the
+ // resynced pay-with token in buy mode, or the already-live position token in
+ // sell mode.
+ const liveSourceBalance =
+ tradeMode === 'buy'
+ ? liveSelectedSourceBalance?.balance
+ : positionToken?.balance;
+
const latestSourceBalance = useLatestBalance({
address: sourceToken?.address,
decimals: sourceToken?.decimals,
chainId: sourceToken?.chainId,
- balance: sourceToken?.balance,
+ balance: liveSourceBalance,
+ // `useLatestBalance` does a one-shot on-chain RPC fetch that shadows the
+ // cached value until its token identity or this key changes. Keying it off
+ // the live balance itself means any change to the underlying balance — for
+ // ANY reason — triggers a fresh on-chain read and re-render, independent of
+ // QuickBuy's own state.
+ refreshKey: liveSourceBalance ?? '',
});
const sourceTokenAmount = useMemo(() => {
@@ -502,8 +566,13 @@ export function useQuickBuyController(
return Number.isFinite(v) ? v : 0;
}, [usdAmount]);
const quotesAnalyticsContext = useMemo(
- () => ({ traderAddress, caip19, amountUsd: quotedUsdAmountNumber }),
- [traderAddress, caip19, quotedUsdAmountNumber],
+ () => ({
+ traderAddress,
+ caip19,
+ amountUsd: quotedUsdAmountNumber,
+ source: analyticsContext?.source,
+ }),
+ [traderAddress, caip19, quotedUsdAmountNumber, analyticsContext?.source],
);
const {
@@ -655,15 +724,15 @@ export function useQuickBuyController(
const sourceBalanceFiatUsd = useMemo(() => {
if (
!latestSourceBalance?.displayBalance ||
- !sourceToken?.currencyExchangeRate
+ !liveSourceCurrencyExchangeRate
) {
return 0;
}
const balance = parseFloat(latestSourceBalance.displayBalance);
if (!Number.isFinite(balance)) return 0;
- const fiat = balance * sourceToken.currencyExchangeRate;
+ const fiat = balance * liveSourceCurrencyExchangeRate;
return Number.isFinite(fiat) && fiat > 0 ? fiat : 0;
- }, [latestSourceBalance?.displayBalance, sourceToken?.currencyExchangeRate]);
+ }, [latestSourceBalance?.displayBalance, liveSourceCurrencyExchangeRate]);
const sourceBalanceFiat = useMemo(
() => formatCurrency(sourceBalanceFiatUsd, currentCurrency),
@@ -683,6 +752,12 @@ export function useQuickBuyController(
return `${formatted} ${sourceToken.symbol}`;
}, [latestSourceBalance?.displayBalance, sourceToken?.symbol]);
+ // Live fiat balance for the sell-mode "Receive" token, resynced from the
+ // reactive `useReceiveTokens` list so the footer pill tracks balance changes
+ // (TSA-632). `enrichTokenBalance` already formats this as a fiat string, so we
+ // pass it straight through rather than re-deriving it from a token amount.
+ const destBalanceFiat = liveSelectedDestBalance?.balanceFiat;
+
const maxSpendUsd = sourceBalanceFiatUsd;
// Token-amount-based gate: used for sources we can't price. Mirrors how the
@@ -893,15 +968,15 @@ export function useQuickBuyController(
if (
Number.isFinite(tokens) &&
tokens > 0 &&
- sourceToken?.currencyExchangeRate
+ liveSourceCurrencyExchangeRate
) {
- const usd = (tokens * sourceToken.currencyExchangeRate).toFixed(2);
+ const usd = (tokens * liveSourceCurrencyExchangeRate).toFixed(2);
setUsdAmount(usd);
setQuotedUsdAmount(usd);
lastCommittedUsdRef.current = usd;
}
}
- }, [hasSourcePrice, sourceAmountTokens, sourceToken?.currencyExchangeRate]);
+ }, [hasSourcePrice, sourceAmountTokens, liveSourceCurrencyExchangeRate]);
const handleSelectSourceToken = useCallback(
(token: BridgeToken) => {
@@ -1350,6 +1425,7 @@ export function useQuickBuyController(
estimatedReceiveAmount,
sourceBalanceFiat,
sourceBalanceDisplay,
+ destBalanceFiat,
formattedNetworkFee,
formattedSlippage,
formattedMinimumReceived,
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyQuotes.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyQuotes.ts
index 2ca7bd8d60c0..6ed1176875e7 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyQuotes.ts
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useQuickBuyQuotes.ts
@@ -35,9 +35,11 @@ import { buildSocialLoggerErrorOptions } from '../../../../../../../util/social/
import {
SocialLeaderboardEventProperties,
useSocialLeaderboardAnalytics,
+ type QuickBuySheetSource,
} from '../../../../analytics';
import { MetaMetricsEvents } from '../../../../../../../core/Analytics';
import { getQuoteRefreshRate } from '../../../../../../UI/Bridge/utils/quoteUtils';
+import { getQuickBuyFeatureId } from '../utils/getQuickBuyFeatureId';
export type QuickBuyQuote = QuoteResponse & L1GasFees & NonEvmFees;
@@ -48,6 +50,8 @@ export interface QuickBuyQuotesAnalyticsContext {
caip19?: string;
/** USD amount the user has selected; used as `amount_usd`. */
amountUsd?: number;
+ /** Entry surface for FeatureId mapping on fetchQuotes. */
+ source?: QuickBuySheetSource;
}
export type EnrichedQuickBuyQuote = ReturnType<
@@ -319,9 +323,8 @@ export function useQuickBuyQuotes({
try {
const result = await Engine.context.BridgeController.fetchQuotes(
params,
+ getQuickBuyFeatureId(analyticsContext?.source),
controller.signal,
- // @ts-expect-error quickBuy has not been added as a FeatureId yet
- 'quickBuy',
);
if (controller.signal.aborted) {
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.test.ts
index 28d59088cc9e..d2fa6deb951d 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.test.ts
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.test.ts
@@ -1,16 +1,45 @@
import { renderHook } from '@testing-library/react-native';
+import { useSelector } from 'react-redux';
+import { BtcScope, SolScope, TrxScope } from '@metamask/keyring-api';
+import type { RootState } from '../../../../../../../reducers';
+import { selectSelectedInternalAccountByScope } from '../../../../../../../selectors/multichainAccounts/accounts';
import { useReceiveTokens } from './useReceiveTokens';
import { enrichTokenBalance } from './enrichTokenBalance';
import { useNetworkEnabledPredicate } from './useNetworkEnabledPredicate';
jest.mock('react-redux', () => ({
- useSelector: jest.fn(() => undefined),
+ useSelector: jest.fn(),
}));
jest.mock('./useNetworkEnabledPredicate', () => ({
useNetworkEnabledPredicate: jest.fn(),
}));
+jest.mock('../../../../../../../selectors/multichainAccounts/accounts', () => ({
+ selectSelectedInternalAccountByScope: jest.fn(),
+}));
+
+jest.mock('../../../../../../../selectors/accountTrackerController', () => ({
+ selectAccountsByChainId: jest.fn(() => ({})),
+}));
+
+jest.mock('../../../../../../../selectors/tokenBalancesController', () => ({
+ selectTokensBalances: jest.fn(() => ({})),
+}));
+
+jest.mock('../../../../../../../selectors/tokenRatesController', () => ({
+ selectTokenMarketData: jest.fn(() => ({})),
+}));
+
+jest.mock('../../../../../../../selectors/currencyRateController', () => ({
+ selectCurrencyRates: jest.fn(() => ({})),
+}));
+
+jest.mock('../../../../../../../selectors/multichain/multichain', () => ({
+ selectMultichainBalances: jest.fn(() => ({})),
+ selectMultichainAssetsRates: jest.fn(() => ({})),
+}));
+
jest.mock(
'../../../../../../UI/Bridge/constants/default-swap-dest-tokens',
() => ({
@@ -36,6 +65,21 @@ jest.mock(
decimals: 18,
name: 'Wrapped Ether',
},
+ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': {
+ symbol: 'USDC',
+ address:
+ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
+ chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
+ decimals: 6,
+ name: 'USD Coin',
+ },
+ 'tron:728126428': {
+ symbol: 'USDT',
+ address: 'tron:728126428/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
+ chainId: 'tron:728126428',
+ decimals: 6,
+ name: 'Tether USD',
+ },
},
}),
);
@@ -64,17 +108,89 @@ jest.mock('../../../../../../UI/Bridge/utils/tokenUtils', () => ({
chainId: '0x89',
};
}
+ if (chainId === 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp') {
+ return {
+ symbol: 'SOL',
+ name: 'Solana',
+ address: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501',
+ decimals: 9,
+ chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
+ };
+ }
+ if (chainId === 'tron:728126428') {
+ return {
+ symbol: 'TRX',
+ name: 'Tron',
+ address: 'tron:728126428/slip44:195',
+ decimals: 6,
+ chainId: 'tron:728126428',
+ };
+ }
+ if (chainId === 'bip122:000000000019d6689c085ae165831e93') {
+ return {
+ symbol: 'BTC',
+ name: 'Bitcoin',
+ address: 'bip122:000000000019d6689c085ae165831e93/slip44:0',
+ decimals: 8,
+ chainId: 'bip122:000000000019d6689c085ae165831e93',
+ };
+ }
throw new Error(`unsupported chain ${chainId}`);
}),
}));
const mockEnrich = enrichTokenBalance as jest.Mock;
const mockUseNetworkEnabledPredicate = useNetworkEnabledPredicate as jest.Mock;
+const mockUseSelector = useSelector as unknown as jest.Mock;
+const mockSelectSelectedInternalAccountByScope =
+ selectSelectedInternalAccountByScope as unknown as jest.Mock;
+
+const SOLANA_CHAIN_ID = SolScope.Mainnet;
+const TRON_CHAIN_ID = TrxScope.Mainnet;
+const BITCOIN_CHAIN_ID = BtcScope.Mainnet;
+
+const MOCK_STATE = {
+ engine: {
+ backgroundState: {
+ NetworkController: { networkConfigurationsByChainId: {} },
+ },
+ },
+} as unknown as RootState;
+
+const NON_EVM_ACCOUNTS: Record = {
+ [SOLANA_CHAIN_ID]: {
+ id: 'solana-account-id',
+ address: 'So1anaAddre55111111111111111111111111111111',
+ },
+ [TRON_CHAIN_ID]: {
+ id: 'tron-account-id',
+ address: 'TTronAddre55xxxxxxxxxxxxxxxxxxxxxx',
+ },
+ [BITCOIN_CHAIN_ID]: {
+ id: 'bitcoin-account-id',
+ address: 'bc1qbitcoinaddre55xxxxxxxxxxxxxxxxxxxxx',
+ },
+};
+
+/** Makes the selected account group expose addresses for the given scopes. */
+const givenAccountsForScopes = (scopes: string[]) => {
+ mockSelectSelectedInternalAccountByScope.mockReturnValue((scope: string) =>
+ scopes.includes(scope) ? NON_EVM_ACCOUNTS[scope] : undefined,
+ );
+};
+
+/** Makes the selected account group expose a Solana address. */
+const givenSolanaAccount = () => givenAccountsForScopes([SOLANA_CHAIN_ID]);
describe('useReceiveTokens', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseNetworkEnabledPredicate.mockReturnValue(() => true);
+ // No account in the selected group resolves for any scope by default.
+ mockSelectSelectedInternalAccountByScope.mockReturnValue(() => undefined);
+ mockUseSelector.mockImplementation(
+ (selector: (state: RootState) => unknown) => selector(MOCK_STATE),
+ );
mockEnrich.mockReturnValue({
balance: '0',
balanceFiat: '$0.00',
@@ -160,4 +276,135 @@ describe('useReceiveTokens', () => {
expect(chainIds).toContain('0x1');
expect(chainIds).not.toContain('0x89');
});
+
+ describe('Solana candidates', () => {
+ it('includes Solana stablecoin and native candidates when the account has a Solana address', () => {
+ givenSolanaAccount();
+
+ const { result } = renderHook(() => useReceiveTokens(undefined));
+
+ const solanaTokens = result.current.filter(
+ (t) => t.chainId === SOLANA_CHAIN_ID,
+ );
+ expect(solanaTokens.map((t) => t.symbol)).toEqual(
+ expect.arrayContaining(['USDC', 'SOL']),
+ );
+ });
+
+ it('omits Solana candidates when the account has no Solana address', () => {
+ const { result } = renderHook(() => useReceiveTokens(undefined));
+
+ const chainIds = result.current.map((t) => t.chainId);
+ expect(chainIds).not.toContain(SOLANA_CHAIN_ID);
+ });
+
+ it('drops Solana candidates when the Solana network is not enabled', () => {
+ givenSolanaAccount();
+ mockUseNetworkEnabledPredicate.mockReturnValue(
+ (chainId: string | undefined) => chainId !== SOLANA_CHAIN_ID,
+ );
+
+ const { result } = renderHook(() => useReceiveTokens(undefined));
+
+ const chainIds = result.current.map((t) => t.chainId);
+ expect(chainIds).not.toContain(SOLANA_CHAIN_ID);
+ expect(chainIds).toContain('0x1');
+ });
+
+ it('sorts Solana candidates to the front when the preferred chain is Solana', () => {
+ givenSolanaAccount();
+
+ const { result } = renderHook(() => useReceiveTokens(SOLANA_CHAIN_ID));
+
+ expect(result.current[0].chainId).toBe(SOLANA_CHAIN_ID);
+ expect(result.current[0].symbol).toBe('USDC');
+ });
+
+ it('passes the Solana account to balance enrichment so Solana holdings are priced', () => {
+ givenSolanaAccount();
+
+ renderHook(() => useReceiveTokens(undefined));
+
+ expect(mockEnrich).toHaveBeenCalledWith(
+ expect.objectContaining({ chainId: SOLANA_CHAIN_ID }),
+ expect.objectContaining({
+ solanaAccount: expect.objectContaining({
+ id: NON_EVM_ACCOUNTS[SOLANA_CHAIN_ID].id,
+ }),
+ }),
+ { includeZeroBalance: true },
+ );
+ });
+ });
+
+ describe('Tron and Bitcoin candidates', () => {
+ it('offers native TRX when the account has a Tron address, but never TRC-20 stablecoins', () => {
+ givenAccountsForScopes([TRON_CHAIN_ID]);
+
+ const { result } = renderHook(() => useReceiveTokens(undefined));
+
+ const tronTokens = result.current.filter(
+ (t) => t.chainId === TRON_CHAIN_ID,
+ );
+ // Native-only: useAssetMetadata cannot resolve Tron token addresses
+ // (e.g. TRC-20 USDT), so only the native asset is offered.
+ expect(tronTokens.map((t) => t.symbol)).toEqual(['TRX']);
+ });
+
+ it('offers native BTC when the account has a Bitcoin address', () => {
+ givenAccountsForScopes([BITCOIN_CHAIN_ID]);
+
+ const { result } = renderHook(() => useReceiveTokens(undefined));
+
+ const bitcoinTokens = result.current.filter(
+ (t) => t.chainId === BITCOIN_CHAIN_ID,
+ );
+ expect(bitcoinTokens.map((t) => t.symbol)).toEqual(['BTC']);
+ });
+
+ it('omits Tron and Bitcoin candidates when the account lacks those addresses', () => {
+ givenSolanaAccount();
+
+ const { result } = renderHook(() => useReceiveTokens(undefined));
+
+ const chainIds = result.current.map((t) => t.chainId);
+ expect(chainIds).toContain(SOLANA_CHAIN_ID);
+ expect(chainIds).not.toContain(TRON_CHAIN_ID);
+ expect(chainIds).not.toContain(BITCOIN_CHAIN_ID);
+ });
+
+ it('drops Tron and Bitcoin candidates when those networks are not enabled', () => {
+ givenAccountsForScopes([TRON_CHAIN_ID, BITCOIN_CHAIN_ID]);
+ mockUseNetworkEnabledPredicate.mockReturnValue(
+ (chainId: string | undefined) =>
+ chainId !== TRON_CHAIN_ID && chainId !== BITCOIN_CHAIN_ID,
+ );
+
+ const { result } = renderHook(() => useReceiveTokens(undefined));
+
+ const chainIds = result.current.map((t) => t.chainId);
+ expect(chainIds).not.toContain(TRON_CHAIN_ID);
+ expect(chainIds).not.toContain(BITCOIN_CHAIN_ID);
+ expect(chainIds).toContain('0x1');
+ });
+
+ it('passes the Tron and Bitcoin accounts to balance enrichment so their holdings are priced', () => {
+ givenAccountsForScopes([TRON_CHAIN_ID, BITCOIN_CHAIN_ID]);
+
+ renderHook(() => useReceiveTokens(undefined));
+
+ expect(mockEnrich).toHaveBeenCalledWith(
+ expect.objectContaining({ chainId: TRON_CHAIN_ID }),
+ expect.objectContaining({
+ tronAccount: expect.objectContaining({
+ id: NON_EVM_ACCOUNTS[TRON_CHAIN_ID].id,
+ }),
+ bitcoinAccount: expect.objectContaining({
+ id: NON_EVM_ACCOUNTS[BITCOIN_CHAIN_ID].id,
+ }),
+ }),
+ { includeZeroBalance: true },
+ );
+ });
+ });
});
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.ts
index b94f300fa2c3..5f53854b6cd5 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.ts
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/hooks/useReceiveTokens.ts
@@ -1,5 +1,8 @@
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
+import { isNonEvmChainId, isSolanaChainId } from '@metamask/bridge-controller';
+import { BtcScope, SolScope, TrxScope } from '@metamask/keyring-api';
+import type { CaipChainId } from '@metamask/utils';
import type { BridgeToken } from '../../../../../../UI/Bridge/types';
import type { RootState } from '../../../../../../../reducers';
import { selectAccountsByChainId } from '../../../../../../../selectors/accountTrackerController';
@@ -7,6 +10,10 @@ import { selectSelectedInternalAccountByScope } from '../../../../../../../selec
import { selectTokensBalances } from '../../../../../../../selectors/tokenBalancesController';
import { selectTokenMarketData } from '../../../../../../../selectors/tokenRatesController';
import { selectCurrencyRates } from '../../../../../../../selectors/currencyRateController';
+import {
+ selectMultichainBalances,
+ selectMultichainAssetsRates,
+} from '../../../../../../../selectors/multichain/multichain';
import { DefaultSwapDestTokens } from '../../../../../../UI/Bridge/constants/default-swap-dest-tokens';
import { EVM_SCOPE } from '../../../../../../UI/Earn/constants/networks';
import { getNativeSourceToken } from '../../../../../../UI/Bridge/utils/tokenUtils';
@@ -17,22 +24,29 @@ import { RECEIVE_STABLECOIN_CANDIDATES } from './receiveStablecoinCandidates';
import { useNetworkEnabledPredicate } from './useNetworkEnabledPredicate';
/**
- * Static EVM stablecoin candidates for the Sell "Receive" picker.
+ * Static stablecoin candidates for the Sell "Receive" picker (EVM + Solana).
*
* `DefaultSwapDestTokens` carries a single stablecoin per chain (which sets the
- * per-chain default selection — e.g. mUSD on mainnet/Linea), so we keep those
- * as the leading entries and then append the canonical USDC/USDT set from
- * `RECEIVE_STABLECOIN_CANDIDATES`. The append guarantees both major stablecoins
- * show on every supported chain (previously USDT was missing on Optimism,
- * USDC on Polygon, etc.) without disturbing the existing default ordering.
- * Duplicates are removed by stable token identity (`address:chainId`).
+ * per-chain default selection — e.g. mUSD on mainnet/Linea, USDC on Solana), so
+ * we keep those as the leading entries and then append the canonical USDC/USDT
+ * set from `RECEIVE_STABLECOIN_CANDIDATES`. The append guarantees both major
+ * stablecoins show on every supported chain (previously USDT was missing on
+ * Optimism, USDC on Polygon, etc.) without disturbing the existing default
+ * ordering. Duplicates are removed by stable token identity (`address:chainId`).
+ *
+ * Stablecoin candidates are limited to the chains whose token addresses
+ * QuickBuy can actually resolve as destinations: EVM (hex chain ids) and
+ * Solana. `useAssetMetadata` only resolves Solana base58 / EVM hex token
+ * addresses, so non-native tokens on other non-EVM chains (e.g. TRC-20 USDT on
+ * Tron) cannot be quoted and are excluded — those chains are offered as
+ * native-only via `NATIVE_ONLY_NON_EVM_CHAINS` instead.
*/
const STABLECOIN_CANDIDATES: BridgeToken[] = (() => {
const primaries = Object.values(DefaultSwapDestTokens).filter(
(token) =>
isStablecoinSymbol(token.symbol) &&
typeof token.chainId === 'string' &&
- token.chainId.startsWith('0x'),
+ (token.chainId.startsWith('0x') || isSolanaChainId(token.chainId)),
);
const seen = new Set(primaries.map(getTokenKey));
const extras = RECEIVE_STABLECOIN_CANDIDATES.filter(
@@ -42,14 +56,29 @@ const STABLECOIN_CANDIDATES: BridgeToken[] = (() => {
})();
/**
- * Native token candidates for the Sell "Receive" picker, one per chain already
- * covered by `STABLECOIN_CANDIDATES`. Built via `getNativeSourceToken` so each
- * native uses the bridge-expected address (zero address on EVM). Chains the
- * helper can't resolve are skipped. Together with the stablecoins, these are
- * the tokens a user can receive when selling a position.
+ * Non-EVM chains offered as native-asset-only receive candidates (TRX, BTC).
+ * Their native assets resolve through the bridge native-asset registry
+ * (`getNativeSourceToken`), unlike their non-native tokens which
+ * `useAssetMetadata` cannot resolve (see `STABLECOIN_CANDIDATES`).
+ */
+const NATIVE_ONLY_NON_EVM_CHAINS: CaipChainId[] = [
+ TrxScope.Mainnet,
+ BtcScope.Mainnet,
+];
+
+/**
+ * Native token candidates for the Sell "Receive" picker: one per chain already
+ * covered by `STABLECOIN_CANDIDATES`, plus the native-only non-EVM chains
+ * (TRX, BTC). Built via `getNativeSourceToken` so each native uses the
+ * bridge-expected address (zero address on EVM, CAIP asset id on non-EVM).
+ * Chains the helper can't resolve are skipped. Together with the stablecoins,
+ * these are the tokens a user can receive when selling a position.
*/
const NATIVE_CANDIDATES: BridgeToken[] = Array.from(
- new Set(STABLECOIN_CANDIDATES.map((token) => token.chainId)),
+ new Set([
+ ...STABLECOIN_CANDIDATES.map((token) => token.chainId),
+ ...NATIVE_ONLY_NON_EVM_CHAINS,
+ ]),
).reduce((acc, chainId) => {
try {
acc.push(getNativeSourceToken(chainId));
@@ -85,27 +114,58 @@ const getReceiveTokenCandidates = (
* a token they don't yet hold). Held tokens are enriched with balance + fiat;
* unheld ones show "$0.00". Candidates on `preferredChainId` are sorted to the
* top, stablecoins before natives.
+ *
+ * Non-EVM candidates (Solana, Tron, Bitcoin) are only offered when the
+ * selected account actually has an address on that chain (multichain account
+ * groups may lack one), mirroring how the rest of the app gates non-EVM
+ * visibility via `selectSelectedInternalAccountByScope(scope)`.
*/
export const useReceiveTokens = (
preferredChainId: string | undefined,
): BridgeToken[] => {
const isChainEnabled = useNetworkEnabledPredicate();
+
+ const selectAccountByScope = useSelector(
+ selectSelectedInternalAccountByScope,
+ );
+ const solanaAccount = selectAccountByScope(SolScope.Mainnet);
+ const tronAccount = selectAccountByScope(TrxScope.Mainnet);
+ const bitcoinAccount = selectAccountByScope(BtcScope.Mainnet);
+
+ // Non-EVM accounts in the selected group, keyed by the CAIP chain id the
+ // receive candidates carry. A missing entry means the user has no address on
+ // that chain and cannot receive there.
+ const nonEvmAccountByChainId = useMemo(
+ () => ({
+ [SolScope.Mainnet]: solanaAccount,
+ [TrxScope.Mainnet]: tronAccount,
+ [BtcScope.Mainnet]: bitcoinAccount,
+ }),
+ [solanaAccount, tronAccount, bitcoinAccount],
+ );
+
const candidates = useMemo(
() =>
- getReceiveTokenCandidates(preferredChainId).filter((candidate) =>
- isChainEnabled(candidate.chainId),
- ),
- [preferredChainId, isChainEnabled],
+ getReceiveTokenCandidates(preferredChainId).filter((candidate) => {
+ if (!isChainEnabled(candidate.chainId)) return false;
+ if (!isNonEvmChainId(candidate.chainId)) return true;
+ // Without an address on the non-EVM chain the user cannot receive there.
+ return Boolean(
+ nonEvmAccountByChainId[
+ candidate.chainId as keyof typeof nonEvmAccountByChainId
+ ],
+ );
+ }),
+ [preferredChainId, isChainEnabled, nonEvmAccountByChainId],
);
- const accountAddress = useSelector(
- (state: RootState) =>
- selectSelectedInternalAccountByScope(state)(EVM_SCOPE)?.address,
- );
+ const accountAddress = selectAccountByScope(EVM_SCOPE)?.address;
const accountsByChainId = useSelector(selectAccountsByChainId);
const tokenBalances = useSelector(selectTokensBalances);
const tokenMarketData = useSelector(selectTokenMarketData);
const currencyRates = useSelector(selectCurrencyRates);
+ const multichainBalances = useSelector(selectMultichainBalances);
+ const multichainRates = useSelector(selectMultichainAssetsRates);
const allNetworkConfigs = useSelector(
(state: RootState) =>
state.engine.backgroundState.NetworkController
@@ -124,6 +184,14 @@ export const useReceiveTokens = (
tokenMarketData,
currencyRates,
allNetworkConfigs,
+ solanaAccount: solanaAccount ?? undefined,
+ tronAccount: tronAccount ?? undefined,
+ bitcoinAccount: bitcoinAccount ?? undefined,
+ multichainBalances,
+ multichainRates: multichainRates as Record<
+ string,
+ { rate?: string } | undefined
+ >,
},
{
includeZeroBalance: true,
@@ -139,6 +207,11 @@ export const useReceiveTokens = (
tokenMarketData,
currencyRates,
allNetworkConfigs,
+ solanaAccount,
+ tronAccount,
+ bitcoinAccount,
+ multichainBalances,
+ multichainRates,
],
);
};
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts
index 2b2d50b50356..aee01e86f536 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyController.test.ts
@@ -1517,6 +1517,243 @@ describe('useQuickBuyController', () => {
});
});
+ describe('live available balances (TSA-632)', () => {
+ interface LatestBalanceArgs {
+ address?: string;
+ decimals?: number;
+ chainId?: string;
+ balance?: string;
+ refreshKey?: string | number;
+ }
+
+ const lastLatestBalanceArgs = (): LatestBalanceArgs =>
+ (useLatestBalance as jest.Mock).mock.calls.at(
+ -1,
+ )?.[0] as LatestBalanceArgs;
+
+ // `useLatestBalance` is mocked to echo the `balance` it is fed, so the
+ // displayed source fiat tracks whatever balance the controller resolves.
+ const echoLatestBalance = () => {
+ (useLatestBalance as jest.Mock).mockImplementation(
+ (args: LatestBalanceArgs) => ({
+ displayBalance: args.balance,
+ atomicBalance: undefined,
+ }),
+ );
+ };
+
+ const createReceiveToken = (
+ overrides: Partial = {},
+ ): BridgeToken =>
+ ({
+ address: '0xUSDC',
+ chainId: '0x1',
+ decimals: 6,
+ symbol: 'USDC',
+ name: 'USD Coin',
+ currencyExchangeRate: 1,
+ balance: '100',
+ balanceFiat: '$100.00',
+ tokenFiatAmount: 100,
+ ...overrides,
+ }) as BridgeToken;
+
+ describe('source (Pay with) balance', () => {
+ it('updates the displayed source balance when the underlying balance changes (external transfer)', () => {
+ // Arrange — sheet opens; ETH auto-selected with a live 1.0 balance.
+ echoLatestBalance();
+ (usePayWithTokens as jest.Mock).mockReturnValue({
+ options: [createSourceToken({ balance: '1.0' })],
+ isLoading: false,
+ });
+ const { result, rerender } = renderHook(() =>
+ useQuickBuyController(createTarget(), jest.fn()),
+ );
+ // 1.0 ETH * $2000 rate.
+ expect(result.current.sourceBalanceFiat).toBe('$2,000.00');
+
+ // Act — an external transfer (NOT via QuickBuy) lands: the reactive
+ // usePayWithTokens list recomputes with a higher balance while the
+ // selected token snapshot is unchanged.
+ (usePayWithTokens as jest.Mock).mockReturnValue({
+ options: [createSourceToken({ balance: '1.5' })],
+ isLoading: false,
+ });
+ rerender(undefined);
+
+ // Assert — the displayed available balance reflects the new state.
+ expect(lastLatestBalanceArgs().balance).toBe('1.5');
+ expect(result.current.sourceBalanceFiat).toBe('$3,000.00');
+ });
+
+ it('re-keys the on-chain fetch off the live balance so any change re-fetches', () => {
+ // Arrange
+ echoLatestBalance();
+ (usePayWithTokens as jest.Mock).mockReturnValue({
+ options: [createSourceToken({ balance: '1.0' })],
+ isLoading: false,
+ });
+ const { rerender } = renderHook(() =>
+ useQuickBuyController(createTarget(), jest.fn()),
+ );
+ // The key is derived from the live balance itself, NOT from any
+ // QuickBuy-specific state.
+ const initialRefreshKey = lastLatestBalanceArgs().refreshKey;
+ expect(initialRefreshKey).toBe('1.0');
+
+ // Act — any balance change (external or QuickBuy) updates the live value.
+ (usePayWithTokens as jest.Mock).mockReturnValue({
+ options: [createSourceToken({ balance: '0.75' })],
+ isLoading: false,
+ });
+ rerender(undefined);
+
+ // Assert — the key changes, forcing a fresh on-chain read.
+ expect(lastLatestBalanceArgs().refreshKey).toBe('0.75');
+ expect(lastLatestBalanceArgs().balance).toBe('0.75');
+ });
+
+ it('falls back to the snapshot balance when the selected token leaves the options list', () => {
+ // Arrange
+ echoLatestBalance();
+ (usePayWithTokens as jest.Mock).mockReturnValue({
+ options: [createSourceToken({ balance: '1.0' })],
+ isLoading: false,
+ });
+ const { result, rerender } = renderHook(() =>
+ useQuickBuyController(createTarget(), jest.fn()),
+ );
+
+ // Act — the user spent the whole balance, so the held-token list no
+ // longer contains the selected token.
+ (usePayWithTokens as jest.Mock).mockReturnValue({
+ options: [],
+ isLoading: false,
+ });
+ rerender(undefined);
+
+ // Assert — no live match: degrade to the snapshot value rather than
+ // blanking out.
+ expect(lastLatestBalanceArgs().balance).toBe('1.0');
+ expect(result.current.sourceBalanceFiat).toBe('$2,000.00');
+ });
+
+ it('updates the displayed fiat balance when the exchange rate refreshes without a balance change', () => {
+ echoLatestBalance();
+ (usePayWithTokens as jest.Mock).mockReturnValue({
+ options: [
+ createSourceToken({ balance: '1.0', currencyExchangeRate: 2000 }),
+ ],
+ isLoading: false,
+ });
+ const { result, rerender } = renderHook(() =>
+ useQuickBuyController(createTarget(), jest.fn()),
+ );
+
+ expect(result.current.sourceBalanceFiat).toBe('$2,000.00');
+
+ (usePayWithTokens as jest.Mock).mockReturnValue({
+ options: [
+ createSourceToken({ balance: '1.0', currencyExchangeRate: 2500 }),
+ ],
+ isLoading: false,
+ });
+ rerender(undefined);
+
+ expect(result.current.sourceBalanceFiat).toBe('$2,500.00');
+ });
+
+ it('updates the source balance when a QuickBuy swap settles (existing behaviour preserved)', () => {
+ // Arrange
+ echoLatestBalance();
+ (usePayWithTokens as jest.Mock).mockReturnValue({
+ options: [createSourceToken({ balance: '1.0' })],
+ isLoading: false,
+ });
+ const { result, rerender } = renderHook(() =>
+ useQuickBuyController(createTarget(), jest.fn()),
+ );
+ expect(result.current.sourceBalanceFiat).toBe('$2,000.00');
+
+ // Act — a QuickBuy swap settles: TokenBalancesController updates Redux
+ // and usePayWithTokens recomputes the same token with a lower balance.
+ (usePayWithTokens as jest.Mock).mockReturnValue({
+ options: [createSourceToken({ balance: '0.75' })],
+ isLoading: false,
+ });
+ rerender(undefined);
+
+ // Assert
+ expect(result.current.sourceBalanceFiat).toBe('$1,500.00');
+ });
+ });
+
+ describe('dest (Receive) balance', () => {
+ const renderSellMode = () => {
+ const utils = renderHook(() =>
+ useQuickBuyController(createTarget(), jest.fn()),
+ );
+ act(() => {
+ utils.result.current.setTradeMode('sell');
+ });
+ return utils;
+ };
+
+ it('updates the displayed dest balance when the underlying balance changes (external transfer)', () => {
+ // Arrange — sell mode; USDC receive token auto-selected at $100.
+ (useReceiveTokens as jest.Mock).mockReturnValue([
+ createReceiveToken({ balanceFiat: '$100.00' }),
+ ]);
+ const { result, rerender } = renderSellMode();
+ expect(result.current.destBalanceFiat).toBe('$100.00');
+
+ // Act — an external transfer lands: the reactive useReceiveTokens list
+ // recomputes with a higher balance while the selection is unchanged.
+ (useReceiveTokens as jest.Mock).mockReturnValue([
+ createReceiveToken({ balance: '150', balanceFiat: '$150.00' }),
+ ]);
+ rerender(undefined);
+
+ // Assert — the displayed receive balance reflects the new state.
+ expect(result.current.destBalanceFiat).toBe('$150.00');
+ });
+
+ it('updates the dest balance when a QuickBuy swap settles (existing behaviour preserved)', () => {
+ // Arrange
+ (useReceiveTokens as jest.Mock).mockReturnValue([
+ createReceiveToken({ balanceFiat: '$100.00' }),
+ ]);
+ const { result, rerender } = renderSellMode();
+ expect(result.current.destBalanceFiat).toBe('$100.00');
+
+ // Act — a QuickBuy sell settles into the receive token, raising its
+ // balance; the reactive list recomputes.
+ (useReceiveTokens as jest.Mock).mockReturnValue([
+ createReceiveToken({ balance: '125', balanceFiat: '$125.00' }),
+ ]);
+ rerender(undefined);
+
+ // Assert
+ expect(result.current.destBalanceFiat).toBe('$125.00');
+ });
+
+ it('falls back to the snapshot balance when the selected receive token leaves the list', () => {
+ // Arrange
+ (useReceiveTokens as jest.Mock).mockReturnValue([
+ createReceiveToken({ balanceFiat: '$100.00' }),
+ ]);
+ const { result, rerender } = renderSellMode();
+
+ // Act — the list no longer contains the selected token.
+ (useReceiveTokens as jest.Mock).mockReturnValue([]);
+ rerender(undefined);
+
+ // Assert — degrade to the snapshot's fiat rather than blanking out.
+ expect(result.current.destBalanceFiat).toBe('$100.00');
+ });
+ });
+ });
+
describe('receive token auto-selection', () => {
const NATIVE_ADDRESS = '0x0000000000000000000000000000000000000000';
const USDC_DEST = '0xDEST'; // matches the default-mock position token
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyQuotes.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyQuotes.test.ts
index 1339dccbfa2c..e919402cf9cc 100644
--- a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyQuotes.test.ts
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/useQuickBuyQuotes.test.ts
@@ -1,4 +1,5 @@
import { act, renderHook, waitFor } from '@testing-library/react-native';
+import { FeatureId } from '@metamask/bridge-controller';
import { useSelector } from 'react-redux';
import Engine from '../../../../../../core/Engine';
import { MetaMetricsEvents } from '../../../../../../core/Analytics';
@@ -168,6 +169,18 @@ const setupSelectors = () => {
);
};
+type QuickBuyQuotesParams = Parameters[0];
+
+function quotesParams(params: QuickBuyQuotesParams): QuickBuyQuotesParams {
+ return {
+ ...params,
+ analyticsContext: {
+ source: 'leaderboard',
+ ...params.analyticsContext,
+ },
+ };
+}
+
describe('useQuickBuyQuotes', () => {
beforeEach(() => {
jest.useFakeTimers();
@@ -185,11 +198,13 @@ describe('useQuickBuyQuotes', () => {
it('returns idle state when any required input is missing', () => {
const { result } = renderHook(() =>
- useQuickBuyQuotes({
- sourceToken: undefined,
- destToken: createDestToken(),
- sourceTokenAmount: '0.001',
- }),
+ useQuickBuyQuotes(
+ quotesParams({
+ sourceToken: undefined,
+ destToken: createDestToken(),
+ sourceTokenAmount: '0.001',
+ }),
+ ),
);
expect(result.current.activeQuote).toBeUndefined();
@@ -202,11 +217,13 @@ describe('useQuickBuyQuotes', () => {
fetchQuotesMock.mockResolvedValue([createFetchedQuote()]);
renderHook(() =>
- useQuickBuyQuotes({
- sourceToken: createSourceToken(),
- destToken: createDestToken(),
- sourceTokenAmount: '0.001',
- }),
+ useQuickBuyQuotes(
+ quotesParams({
+ sourceToken: createSourceToken(),
+ destToken: createDestToken(),
+ sourceTokenAmount: '0.001',
+ }),
+ ),
);
expect(fetchQuotesMock).not.toHaveBeenCalled();
@@ -223,12 +240,14 @@ describe('useQuickBuyQuotes', () => {
const { rerender } = renderHook(
({ token }: { token: number }) =>
- useQuickBuyQuotes({
- sourceToken: createSourceToken(),
- destToken: createDestToken(),
- sourceTokenAmount: '0.001',
- immediateFetchToken: token,
- }),
+ useQuickBuyQuotes(
+ quotesParams({
+ sourceToken: createSourceToken(),
+ destToken: createDestToken(),
+ sourceTokenAmount: '0.001',
+ immediateFetchToken: token,
+ }),
+ ),
{ initialProps: { token: 0 } },
);
@@ -244,12 +263,14 @@ describe('useQuickBuyQuotes', () => {
const { rerender } = renderHook(
({ amount }: { amount: string }) =>
- useQuickBuyQuotes({
- sourceToken: createSourceToken(),
- destToken: createDestToken(),
- sourceTokenAmount: amount,
- immediateFetchToken: 0,
- }),
+ useQuickBuyQuotes(
+ quotesParams({
+ sourceToken: createSourceToken(),
+ destToken: createDestToken(),
+ sourceTokenAmount: amount,
+ immediateFetchToken: 0,
+ }),
+ ),
{ initialProps: { amount: '0.001' } },
);
@@ -289,12 +310,14 @@ describe('useQuickBuyQuotes', () => {
const { result, rerender } = renderHook(
({ token, amount }: { token: number; amount: string }) =>
- useQuickBuyQuotes({
- sourceToken: createSourceToken(),
- destToken: createDestToken(),
- sourceTokenAmount: amount,
- immediateFetchToken: token,
- }),
+ useQuickBuyQuotes(
+ quotesParams({
+ sourceToken: createSourceToken(),
+ destToken: createDestToken(),
+ sourceTokenAmount: amount,
+ immediateFetchToken: token,
+ }),
+ ),
{ initialProps: { token: 0, amount: '0.001' } },
);
@@ -306,7 +329,7 @@ describe('useQuickBuyQuotes', () => {
);
expect(fetchQuotesMock).toHaveBeenCalledTimes(2);
- const firstRequestSignal = fetchQuotesMock.mock.calls[0][1];
+ const firstRequestSignal = fetchQuotesMock.mock.calls[0][2];
expect(firstRequestSignal.aborted).toBe(true);
await act(async () => {
@@ -321,11 +344,13 @@ describe('useQuickBuyQuotes', () => {
fetchQuotesMock.mockResolvedValue([createFetchedQuote()]);
renderHook(() =>
- useQuickBuyQuotes({
- sourceToken: createSourceToken(),
- destToken: createDestToken(),
- sourceTokenAmount: '0.001',
- }),
+ useQuickBuyQuotes(
+ quotesParams({
+ sourceToken: createSourceToken(),
+ destToken: createDestToken(),
+ sourceTokenAmount: '0.001',
+ }),
+ ),
);
act(() => {
@@ -342,17 +367,47 @@ describe('useQuickBuyQuotes', () => {
gasIncluded: false,
gasIncluded7702: false,
});
+ expect(fetchQuotesMock.mock.calls[0][1]).toBe(
+ FeatureId.QUICK_BUY_FOLLOW_TRADING,
+ );
+ });
+
+ it('passes QUICK_BUY_TOKEN_DETAILS FeatureId when source is asset_details', async () => {
+ fetchQuotesMock.mockResolvedValue([createFetchedQuote()]);
+
+ renderHook(() =>
+ useQuickBuyQuotes(
+ quotesParams({
+ sourceToken: createSourceToken(),
+ destToken: createDestToken(),
+ sourceTokenAmount: '0.001',
+ analyticsContext: { source: 'asset_details' },
+ }),
+ ),
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(QUICK_BUY_QUOTE_DEBOUNCE_MS);
+ });
+
+ await waitFor(() => expect(fetchQuotesMock).toHaveBeenCalled());
+
+ expect(fetchQuotesMock.mock.calls[0][1]).toBe(
+ FeatureId.QUICK_BUY_TOKEN_DETAILS,
+ );
});
it('flags isNoQuotesAvailable when fetchQuotes returns an empty array', async () => {
fetchQuotesMock.mockResolvedValue([]);
const { result } = renderHook(() =>
- useQuickBuyQuotes({
- sourceToken: createSourceToken(),
- destToken: createDestToken(),
- sourceTokenAmount: '0.001',
- }),
+ useQuickBuyQuotes(
+ quotesParams({
+ sourceToken: createSourceToken(),
+ destToken: createDestToken(),
+ sourceTokenAmount: '0.001',
+ }),
+ ),
);
act(() => {
@@ -367,11 +422,13 @@ describe('useQuickBuyQuotes', () => {
fetchQuotesMock.mockRejectedValue(new Error('boom'));
const { result } = renderHook(() =>
- useQuickBuyQuotes({
- sourceToken: createSourceToken(),
- destToken: createDestToken(),
- sourceTokenAmount: '0.001',
- }),
+ useQuickBuyQuotes(
+ quotesParams({
+ sourceToken: createSourceToken(),
+ destToken: createDestToken(),
+ sourceTokenAmount: '0.001',
+ }),
+ ),
);
act(() => {
@@ -387,11 +444,13 @@ describe('useQuickBuyQuotes', () => {
fetchQuotesMock.mockRejectedValue(fetchError);
renderHook(() =>
- useQuickBuyQuotes({
- sourceToken: createSourceToken(),
- destToken: createDestToken(),
- sourceTokenAmount: '0.001',
- }),
+ useQuickBuyQuotes(
+ quotesParams({
+ sourceToken: createSourceToken(),
+ destToken: createDestToken(),
+ sourceTokenAmount: '0.001',
+ }),
+ ),
);
act(() => {
@@ -417,11 +476,13 @@ describe('useQuickBuyQuotes', () => {
it('skips fetching when the atomic source amount normalizes to zero', () => {
renderHook(() =>
- useQuickBuyQuotes({
- sourceToken: createSourceToken({ decimals: 18 }),
- destToken: createDestToken(),
- sourceTokenAmount: '0',
- }),
+ useQuickBuyQuotes(
+ quotesParams({
+ sourceToken: createSourceToken({ decimals: 18 }),
+ destToken: createDestToken(),
+ sourceTokenAmount: '0',
+ }),
+ ),
);
act(() => {
@@ -433,13 +494,15 @@ describe('useQuickBuyQuotes', () => {
it('skips fetching when sourceToken.decimals is undefined', () => {
renderHook(() =>
- useQuickBuyQuotes({
- sourceToken: createSourceToken({
- decimals: undefined as unknown as number,
+ useQuickBuyQuotes(
+ quotesParams({
+ sourceToken: createSourceToken({
+ decimals: undefined as unknown as number,
+ }),
+ destToken: createDestToken(),
+ sourceTokenAmount: '0.001',
}),
- destToken: createDestToken(),
- sourceTokenAmount: '0.001',
- }),
+ ),
);
act(() => {
@@ -454,16 +517,18 @@ describe('useQuickBuyQuotes', () => {
fetchQuotesMock.mockResolvedValue([fetched]);
renderHook(() =>
- useQuickBuyQuotes({
- sourceToken: createSourceToken(),
- destToken: createDestToken(),
- sourceTokenAmount: '0.001',
- analyticsContext: {
- traderAddress: '0xTRADER',
- caip19: 'eip155:8453/erc20:0xDEST',
- amountUsd: 50,
- },
- }),
+ useQuickBuyQuotes(
+ quotesParams({
+ sourceToken: createSourceToken(),
+ destToken: createDestToken(),
+ sourceTokenAmount: '0.001',
+ analyticsContext: {
+ traderAddress: '0xTRADER',
+ caip19: 'eip155:8453/erc20:0xDEST',
+ amountUsd: 50,
+ },
+ }),
+ ),
);
act(() => {
@@ -497,15 +562,17 @@ describe('useQuickBuyQuotes', () => {
fetchQuotesMock.mockRejectedValue(new Error('network error'));
renderHook(() =>
- useQuickBuyQuotes({
- sourceToken: createSourceToken(),
- destToken: createDestToken(),
- sourceTokenAmount: '0.001',
- analyticsContext: {
- traderAddress: '0xTRADER',
- caip19: 'eip155:8453/erc20:0xDEST',
- },
- }),
+ useQuickBuyQuotes(
+ quotesParams({
+ sourceToken: createSourceToken(),
+ destToken: createDestToken(),
+ sourceTokenAmount: '0.001',
+ analyticsContext: {
+ traderAddress: '0xTRADER',
+ caip19: 'eip155:8453/erc20:0xDEST',
+ },
+ }),
+ ),
);
act(() => {
@@ -524,16 +591,18 @@ describe('useQuickBuyQuotes', () => {
fetchQuotesMock.mockResolvedValue([createFetchedQuote()]);
renderHook(() =>
- useQuickBuyQuotes({
- sourceToken: createSourceToken(),
- destToken: createDestToken(),
- sourceTokenAmount: '0.001',
- analyticsContext: {
- traderAddress: '0xTRADER',
- caip19: 'eip155:8453/erc20:0xDEST',
- // amountUsd intentionally absent
- },
- }),
+ useQuickBuyQuotes(
+ quotesParams({
+ sourceToken: createSourceToken(),
+ destToken: createDestToken(),
+ sourceTokenAmount: '0.001',
+ analyticsContext: {
+ traderAddress: '0xTRADER',
+ caip19: 'eip155:8453/erc20:0xDEST',
+ // amountUsd intentionally absent
+ },
+ }),
+ ),
);
act(() => {
@@ -555,11 +624,13 @@ describe('useQuickBuyQuotes', () => {
.mockRejectedValue(new Error('network error'));
const { result } = renderHook(() =>
- useQuickBuyQuotes({
- sourceToken: createSourceToken(),
- destToken: createDestToken(),
- sourceTokenAmount: '0.001',
- }),
+ useQuickBuyQuotes(
+ quotesParams({
+ sourceToken: createSourceToken(),
+ destToken: createDestToken(),
+ sourceTokenAmount: '0.001',
+ }),
+ ),
);
await act(async () => {
@@ -601,11 +672,13 @@ describe('useQuickBuyQuotes', () => {
);
const { result } = renderHook(() =>
- useQuickBuyQuotes({
- sourceToken: createSourceToken(),
- destToken: createDestToken(),
- sourceTokenAmount: '0.001',
- }),
+ useQuickBuyQuotes(
+ quotesParams({
+ sourceToken: createSourceToken(),
+ destToken: createDestToken(),
+ sourceTokenAmount: '0.001',
+ }),
+ ),
);
act(() => {
@@ -624,11 +697,13 @@ describe('useQuickBuyQuotes', () => {
fetchQuotesMock.mockResolvedValue([createFetchedQuote()]);
const { result, rerender } = renderHook(() =>
- useQuickBuyQuotes({
- sourceToken: createSourceToken(),
- destToken: createDestToken(),
- sourceTokenAmount: '0.001',
- }),
+ useQuickBuyQuotes(
+ quotesParams({
+ sourceToken: createSourceToken(),
+ destToken: createDestToken(),
+ sourceTokenAmount: '0.001',
+ }),
+ ),
);
await act(async () => {
@@ -653,11 +728,13 @@ describe('useQuickBuyQuotes', () => {
fetchQuotesMock.mockResolvedValue([createFetchedQuote()]);
const { result, rerender } = renderHook(() =>
- useQuickBuyQuotes({
- sourceToken: createSourceToken(),
- destToken: createDestToken(),
- sourceTokenAmount: '0.001',
- }),
+ useQuickBuyQuotes(
+ quotesParams({
+ sourceToken: createSourceToken(),
+ destToken: createDestToken(),
+ sourceTokenAmount: '0.001',
+ }),
+ ),
);
await act(async () => {
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getQuickBuyFeatureId.test.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getQuickBuyFeatureId.test.ts
new file mode 100644
index 000000000000..086e58eeb48b
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getQuickBuyFeatureId.test.ts
@@ -0,0 +1,30 @@
+import { FeatureId } from '@metamask/bridge-controller';
+
+import { getQuickBuyFeatureId } from './getQuickBuyFeatureId';
+
+describe('getQuickBuyFeatureId', () => {
+ it('maps token-details surfaces to QUICK_BUY_TOKEN_DETAILS', () => {
+ expect(getQuickBuyFeatureId('asset_details')).toBe(
+ FeatureId.QUICK_BUY_TOKEN_DETAILS,
+ );
+ expect(getQuickBuyFeatureId('market_insights')).toBe(
+ FeatureId.QUICK_BUY_TOKEN_DETAILS,
+ );
+ });
+
+ it('maps follow-trading surfaces to QUICK_BUY_FOLLOW_TRADING', () => {
+ expect(getQuickBuyFeatureId('leaderboard')).toBe(
+ FeatureId.QUICK_BUY_FOLLOW_TRADING,
+ );
+ expect(getQuickBuyFeatureId('profile_position')).toBe(
+ FeatureId.QUICK_BUY_FOLLOW_TRADING,
+ );
+ expect(getQuickBuyFeatureId('notification')).toBe(
+ FeatureId.QUICK_BUY_FOLLOW_TRADING,
+ );
+ });
+
+ it('defaults to UNKNOWN when source is missing', () => {
+ expect(getQuickBuyFeatureId()).toBe(FeatureId.UNKNOWN);
+ });
+});
diff --git a/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getQuickBuyFeatureId.ts b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getQuickBuyFeatureId.ts
new file mode 100644
index 000000000000..685f8c1701f1
--- /dev/null
+++ b/app/components/Views/SocialLeaderboard/TraderPositionView/components/QuickBuy/utils/getQuickBuyFeatureId.ts
@@ -0,0 +1,17 @@
+import { FeatureId } from '@metamask/bridge-controller';
+
+import type { QuickBuySheetSource } from '../../../../analytics';
+
+export function getQuickBuyFeatureId(source?: QuickBuySheetSource): FeatureId {
+ switch (source) {
+ case 'asset_details':
+ case 'market_insights':
+ return FeatureId.QUICK_BUY_TOKEN_DETAILS;
+ case 'leaderboard':
+ case 'profile_position':
+ case 'notification':
+ return FeatureId.QUICK_BUY_FOLLOW_TRADING;
+ default:
+ return FeatureId.UNKNOWN;
+ }
+}
diff --git a/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx b/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx
index 17405b974014..112bc5518cb6 100644
--- a/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx
+++ b/app/components/Views/confirmations/components/rows/bridge-fee-row/bridge-fee-row.test.tsx
@@ -214,6 +214,18 @@ describe('BridgeFeeRow', () => {
expect(queryByTestId('transaction-fee')).toBeNull();
});
+ it('renders paid by MetaMask label for Money Account Deposit with all-zero fees and quotes', () => {
+ useTransactionTotalsMock.mockReturnValue(zeroFeesTotals);
+ useIsPaidByMetaMaskMock.mockReturnValue(true);
+
+ const { getByText, queryByTestId } = render({
+ type: TransactionType.moneyAccountDeposit,
+ });
+
+ expect(getByText('Paid by MetaMask')).toBeOnTheScreen();
+ expect(queryByTestId('transaction-fee')).toBeNull();
+ });
+
it('hides the tooltip icon when paid by MetaMask is shown', () => {
useTransactionTotalsMock.mockReturnValue(zeroFeesTotals);
useIsPaidByMetaMaskMock.mockReturnValue(true);
diff --git a/app/components/Views/confirmations/constants/send.ts b/app/components/Views/confirmations/constants/send.ts
index 5e82798ad902..b573041a836f 100644
--- a/app/components/Views/confirmations/constants/send.ts
+++ b/app/components/Views/confirmations/constants/send.ts
@@ -2,7 +2,6 @@ export const InitSendLocation = {
AssetOverview: 'asset_overview',
HomePage: 'home_page',
CollectibleContractOverview: 'collectible_contract_overview',
- CollectibleModal: 'collectible_modal',
CollectibleView: 'collectible_view',
NftDetails: 'nft_details',
WalletActions: 'wallet_actions',
diff --git a/app/components/Views/confirmations/hooks/pay/useIsPaidByMetaMask.test.ts b/app/components/Views/confirmations/hooks/pay/useIsPaidByMetaMask.test.ts
index f40eebc20667..0a37b67f38a9 100644
--- a/app/components/Views/confirmations/hooks/pay/useIsPaidByMetaMask.test.ts
+++ b/app/components/Views/confirmations/hooks/pay/useIsPaidByMetaMask.test.ts
@@ -3,6 +3,7 @@ import {
TransactionType,
} from '@metamask/transaction-controller';
import {
+ TransactionFiatPayment,
TransactionPayQuote,
TransactionPayTotals,
} from '@metamask/transaction-pay-controller';
@@ -12,6 +13,7 @@ import { Json } from '@metamask/utils';
import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider';
import { useIsPaidByMetaMask } from './useIsPaidByMetaMask';
import {
+ useTransactionPayFiatPayment,
useTransactionPayQuotes,
useTransactionPayTotals,
} from './useTransactionPayData';
@@ -54,12 +56,16 @@ function runHook({ type }: { type?: TransactionType } = {}) {
}
describe('useIsPaidByMetaMask', () => {
+ const useTransactionPayFiatPaymentMock = jest.mocked(
+ useTransactionPayFiatPayment,
+ );
const useTransactionPayQuotesMock = jest.mocked(useTransactionPayQuotes);
const useTransactionPayTotalsMock = jest.mocked(useTransactionPayTotals);
beforeEach(() => {
jest.resetAllMocks();
+ useTransactionPayFiatPaymentMock.mockReturnValue(undefined);
useTransactionPayQuotesMock.mockReturnValue([
{} as TransactionPayQuote,
]);
@@ -88,6 +94,16 @@ describe('useIsPaidByMetaMask', () => {
expect(result.current).toBe(false);
});
+ it('returns false when a fiat payment method is selected', () => {
+ useTransactionPayFiatPaymentMock.mockReturnValue({
+ selectedPaymentMethodId: 'apple-pay',
+ } as TransactionFiatPayment);
+
+ const { result } = runHook({ type: TransactionType.moneyAccountDeposit });
+
+ expect(result.current).toBe(false);
+ });
+
it('returns true when all four fee components are zero and the type is musdConversion', () => {
const { result } = runHook({ type: TransactionType.musdConversion });
diff --git a/app/components/Views/confirmations/hooks/pay/useIsPaidByMetaMask.ts b/app/components/Views/confirmations/hooks/pay/useIsPaidByMetaMask.ts
index 99dfa1428850..53b6ba77e249 100644
--- a/app/components/Views/confirmations/hooks/pay/useIsPaidByMetaMask.ts
+++ b/app/components/Views/confirmations/hooks/pay/useIsPaidByMetaMask.ts
@@ -3,13 +3,18 @@ import { BigNumber } from 'bignumber.js';
import { hasTransactionType } from '../../utils/transaction';
import { useTransactionMetadataOrThrow } from '../transactions/useTransactionMetadataRequest';
import {
+ useTransactionPayFiatPayment,
useTransactionPayQuotes,
useTransactionPayTotals,
} from './useTransactionPayData';
-const SUPPORTED_TYPES = [TransactionType.musdConversion];
+const SUPPORTED_TYPES = [
+ TransactionType.musdConversion,
+ TransactionType.moneyAccountDeposit,
+];
export function useIsPaidByMetaMask(): boolean {
+ const { selectedPaymentMethodId } = useTransactionPayFiatPayment() || {};
const totals = useTransactionPayTotals();
const quotes = useTransactionPayQuotes();
const transactionMetadata = useTransactionMetadataOrThrow();
@@ -17,7 +22,8 @@ export function useIsPaidByMetaMask(): boolean {
if (
!quotes?.length ||
!totals?.fees ||
- !hasTransactionType(transactionMetadata, SUPPORTED_TYPES)
+ !hasTransactionType(transactionMetadata, SUPPORTED_TYPES) ||
+ selectedPaymentMethodId
) {
return false;
}
diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts
index 2d136f52fb8d..a9ce8ea1e237 100644
--- a/app/constants/navigation/Routes.ts
+++ b/app/constants/navigation/Routes.ts
@@ -402,6 +402,7 @@ const Routes = {
POSITIONS: 'PredictPositions',
ACTIVITY_DETAIL: 'PredictActivityDetail',
WORLD_CUP: 'PredictWorldCup',
+ FEED: 'PredictFeed',
MODALS: {
ROOT: 'PredictModals',
BUY_PREVIEW: 'PredictBuyPreview',
diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts
index 816722a2ce3b..a4e370769c3e 100644
--- a/app/core/Analytics/MetaMetrics.events.ts
+++ b/app/core/Analytics/MetaMetrics.events.ts
@@ -631,6 +631,9 @@ enum EVENT_NAME {
CARD_DELEGATION_PROCESS_COMPLETED = 'Card Delegation Process Completed',
CARD_DELEGATION_PROCESS_FAILED = 'Card Delegation Process Failed',
CARD_DELEGATION_PROCESS_USER_CANCELED = 'Card Delegation Process User Canceled',
+ CARD_MONEY_ACCOUNT_LINKING_STARTED = 'Card Money Account Linking Started',
+ CARD_MONEY_ACCOUNT_LINKING_COMPLETED = 'Card Money Account Linking Completed',
+ CARD_MONEY_ACCOUNT_LINKING_FAILED = 'Card Money Account Linking Failed',
CARD_PUSH_PROVISIONING_STARTED = 'Card Push Provisioning Started',
CARD_PUSH_PROVISIONING_COMPLETED = 'Card Push Provisioning Completed',
CARD_PUSH_PROVISIONING_FAILED = 'Card Push Provisioning Failed',
@@ -1790,6 +1793,15 @@ const events = {
CARD_DELEGATION_PROCESS_USER_CANCELED: generateOpt(
EVENT_NAME.CARD_DELEGATION_PROCESS_USER_CANCELED,
),
+ CARD_MONEY_ACCOUNT_LINKING_STARTED: generateOpt(
+ EVENT_NAME.CARD_MONEY_ACCOUNT_LINKING_STARTED,
+ ),
+ CARD_MONEY_ACCOUNT_LINKING_COMPLETED: generateOpt(
+ EVENT_NAME.CARD_MONEY_ACCOUNT_LINKING_COMPLETED,
+ ),
+ CARD_MONEY_ACCOUNT_LINKING_FAILED: generateOpt(
+ EVENT_NAME.CARD_MONEY_ACCOUNT_LINKING_FAILED,
+ ),
CARD_PUSH_PROVISIONING_STARTED: generateOpt(
EVENT_NAME.CARD_PUSH_PROVISIONING_STARTED,
),
diff --git a/app/core/DeeplinkManager/handlers/legacy/__tests__/handlePredictUrl.test.ts b/app/core/DeeplinkManager/handlers/legacy/__tests__/handlePredictUrl.test.ts
index dea6a1b82282..d302b2cb5649 100644
--- a/app/core/DeeplinkManager/handlers/legacy/__tests__/handlePredictUrl.test.ts
+++ b/app/core/DeeplinkManager/handlers/legacy/__tests__/handlePredictUrl.test.ts
@@ -3,7 +3,10 @@ import NavigationService from '../../../../NavigationService';
import Routes from '../../../../../constants/navigation/Routes';
import DevLogger from '../../../../SDKConnect/utils/DevLogger';
import { DEFAULT_PREDICT_WORLD_CUP_FLAG } from '../../../../../components/UI/Predict/constants/flags';
-import { selectPredictWorldCupConfig } from '../../../../../components/UI/Predict/selectors/featureFlags';
+import {
+ selectPredictHomeRedesignEnabledFlag,
+ selectPredictWorldCupConfig,
+} from '../../../../../components/UI/Predict/selectors/featureFlags';
// Mock dependencies
jest.mock('../../../../NavigationService');
@@ -20,6 +23,7 @@ jest.mock(
'../../../../../components/UI/Predict/selectors/featureFlags',
() => ({
selectPredictWorldCupConfig: jest.fn(),
+ selectPredictHomeRedesignEnabledFlag: jest.fn(),
}),
);
@@ -40,6 +44,10 @@ describe('handlePredictUrl', () => {
jest
.mocked(selectPredictWorldCupConfig)
.mockReturnValue(DEFAULT_PREDICT_WORLD_CUP_FLAG);
+ // Generic feed routing is gated by the home redesign flag; default it on so
+ // generic-feed tests exercise the FEED path. Flag-off behavior is covered
+ // by a dedicated test below.
+ jest.mocked(selectPredictHomeRedesignEnabledFlag).mockReturnValue(true);
});
describe('with market parameter', () => {
@@ -376,6 +384,144 @@ describe('handlePredictUrl', () => {
});
});
+ describe('with generic feed parameter', () => {
+ it('navigates to the generic feed for a known feed id', async () => {
+ await handlePredictUrl({ predictPath: '?feed=sports' });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.FEED,
+ params: {
+ feedId: 'sports',
+ entryPoint: 'deeplink',
+ },
+ });
+ });
+
+ it('passes tab as initialTabId and filter as initialFilterId', async () => {
+ await handlePredictUrl({
+ predictPath: '?feed=sports&tab=all&filter=live',
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.FEED,
+ params: {
+ feedId: 'sports',
+ initialTabId: 'all',
+ initialFilterId: 'live',
+ entryPoint: 'deeplink',
+ },
+ });
+ });
+
+ it('parses filter separately from tab', async () => {
+ await handlePredictUrl({
+ predictPath: '?feed=popular-today&filter=elections',
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.FEED,
+ params: {
+ feedId: 'popular-today',
+ initialFilterId: 'elections',
+ entryPoint: 'deeplink',
+ },
+ });
+ });
+
+ it('preserves the search query for a generic feed', async () => {
+ await handlePredictUrl({ predictPath: '?feed=trending&q=bitcoin' });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.FEED,
+ params: {
+ feedId: 'trending',
+ query: 'bitcoin',
+ entryPoint: 'deeplink',
+ },
+ });
+ });
+
+ it('preserves utm_source attribution for generic feed links', async () => {
+ await handlePredictUrl({
+ predictPath: '?feed=crypto&utm_source=twitter',
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.FEED,
+ params: {
+ feedId: 'crypto',
+ entryPoint: 'deeplink_twitter',
+ },
+ });
+ });
+
+ it('normalizes an uppercase feed id to lowercase', async () => {
+ await handlePredictUrl({ predictPath: '?feed=POLITICS' });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.FEED,
+ params: {
+ feedId: 'politics',
+ entryPoint: 'deeplink',
+ },
+ });
+ });
+
+ it('falls back to the market list for an unknown feed id', async () => {
+ await handlePredictUrl({ predictPath: '?feed=unknown-feed' });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_LIST,
+ params: {
+ entryPoint: 'deeplink',
+ },
+ });
+ });
+
+ it('falls back to the market list when home redesign flag is disabled', async () => {
+ jest.mocked(selectPredictHomeRedesignEnabledFlag).mockReturnValue(false);
+
+ await handlePredictUrl({ predictPath: '?feed=sports&tab=all' });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_LIST,
+ params: {
+ entryPoint: 'deeplink',
+ },
+ });
+ });
+
+ it('prioritizes market parameter over a generic feed', async () => {
+ await handlePredictUrl({ predictPath: '?feed=sports&market=123' });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_DETAILS,
+ params: {
+ marketId: '123',
+ entryPoint: 'deeplink',
+ },
+ });
+ });
+
+ it('routes feed=world-cup to the World Cup flow, not the generic feed', async () => {
+ jest.mocked(selectPredictWorldCupConfig).mockReturnValue({
+ ...DEFAULT_PREDICT_WORLD_CUP_FLAG,
+ enabled: true,
+ showWorldCupScreen: true,
+ });
+
+ await handlePredictUrl({ predictPath: '?feed=world-cup&tab=live' });
+
+ expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.WORLD_CUP,
+ params: {
+ entryPoint: 'deeplink',
+ initialTab: 'live',
+ },
+ });
+ });
+ });
+
describe('with query parameter', () => {
it('navigates to market list with query parameter', async () => {
await handlePredictUrl({ predictPath: '?query=bitcoin' });
@@ -547,6 +693,7 @@ describe('handlePredictUrl', () => {
tab: undefined,
worldCupTab: undefined,
feed: undefined,
+ filter: undefined,
query: undefined,
},
);
@@ -840,6 +987,7 @@ describe('handlePredictUrl', () => {
tab: undefined,
worldCupTab: undefined,
feed: undefined,
+ filter: undefined,
query: undefined,
},
);
diff --git a/app/core/DeeplinkManager/handlers/legacy/handlePredictUrl.ts b/app/core/DeeplinkManager/handlers/legacy/handlePredictUrl.ts
index b38281f59f41..18a5323d0e43 100644
--- a/app/core/DeeplinkManager/handlers/legacy/handlePredictUrl.ts
+++ b/app/core/DeeplinkManager/handlers/legacy/handlePredictUrl.ts
@@ -12,8 +12,15 @@ import {
type PredictWorldCupTabKey,
} from '../../../../components/UI/Predict/constants/worldCupTabs';
import { DEFAULT_PREDICT_WORLD_CUP_FLAG } from '../../../../components/UI/Predict/constants/flags';
-import { selectPredictWorldCupConfig } from '../../../../components/UI/Predict/selectors/featureFlags';
+import {
+ selectPredictHomeRedesignEnabledFlag,
+ selectPredictWorldCupConfig,
+} from '../../../../components/UI/Predict/selectors/featureFlags';
import type { PredictWorldCupConfig } from '../../../../components/UI/Predict/types/flags';
+import {
+ isPredictFeedId,
+ type PredictFeedId,
+} from '../../../../components/UI/Predict/constants/feedConfig';
interface HandlePredictUrlParams {
predictPath: string;
@@ -27,8 +34,12 @@ interface PredictNavigationParams {
market?: string; // Market ID
utmSource?: string; // UTM source for analytics tracking
tab?: PredictTabKey; // Feed tab (when no market param)
- worldCupTab?: PredictWorldCupTabKey; // World Cup feed initial tab
+ // TODO: `worldCupTab` holds the raw (unvalidated) tab value and is also reused
+ // by the generic feed. Remove/rename to a neutral field once the World Cup
+ // feature is sunset.
+ worldCupTab?: PredictWorldCupTabKey; // World Cup feed initial tab (raw tab value)
feed?: string; // Dedicated feed key
+ filter?: string; // Generic feed filter chip id (parsed separately from tab)
query?: string; // Search query (when no market param)
}
@@ -49,6 +60,7 @@ const parsePredictNavigationParams = (
const tabParam = urlParams.get('tab')?.toLowerCase();
const tab = isPredictTabKey(tabParam) ? tabParam : undefined;
const feed = urlParams.get('feed')?.toLowerCase();
+ const filter = urlParams.get('filter')?.toLowerCase();
const query = urlParams.get('query') || urlParams.get('q') || undefined;
return {
@@ -57,6 +69,7 @@ const parsePredictNavigationParams = (
tab,
worldCupTab: tabParam,
feed: feed || undefined,
+ filter: filter || undefined,
query,
};
};
@@ -73,6 +86,18 @@ const getPredictWorldCupConfig = (): PredictWorldCupConfig => {
}
};
+const getPredictHomeRedesignEnabled = (): boolean => {
+ try {
+ return selectPredictHomeRedesignEnabledFlag(ReduxService.store.getState());
+ } catch (error) {
+ DevLogger.log(
+ '[handlePredictUrl] Unable to read home redesign flag, defaulting to disabled:',
+ error,
+ );
+ return false;
+ }
+};
+
const getMarketListParams = ({
entryPoint,
tab,
@@ -128,6 +153,37 @@ const handleWorldCupNavigation = ({
handleMarketListNavigation({ entryPoint });
};
+/**
+ * Handle navigation to a generic, config-driven Predict feed (PredictFeedView).
+ * @param params Resolved generic feed params
+ */
+const handleGenericFeedNavigation = ({
+ feedId,
+ initialTabId,
+ initialFilterId,
+ query,
+ entryPoint,
+}: {
+ feedId: PredictFeedId;
+ initialTabId?: string;
+ initialFilterId?: string;
+ query?: string;
+ entryPoint: string;
+}) => {
+ DevLogger.log('[handlePredictUrl] Navigating to generic feed:', feedId);
+
+ NavigationService.navigation.navigate(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.FEED,
+ params: {
+ feedId,
+ ...(initialTabId && { initialTabId }),
+ ...(initialFilterId && { initialFilterId }),
+ ...(query && { query }),
+ entryPoint,
+ },
+ });
+};
+
/**
* Handle market-specific navigation
* @param marketId The market ID to navigate to
@@ -175,6 +231,10 @@ const handleMarketNavigation = (marketId: string, entryPoint: string) => {
* - https://link.metamask.io/predict?query=bitcoin
* - https://link.metamask.io/predict?feed=world-cup
* - https://link.metamask.io/predict?feed=world-cup&tab=live
+ * - https://link.metamask.io/predict?feed=sports
+ * - https://link.metamask.io/predict?feed=sports&tab=all&filter=live
+ * - https://link.metamask.io/predict?feed=popular-today&filter=elections
+ * - https://link.metamask.io/predict?feed=trending&q=bitcoin
*
* Origin/EntryPoint handling:
* - Base entryPoint is origin if provided, otherwise 'deeplink'
@@ -185,6 +245,8 @@ const handleMarketNavigation = (marketId: string, entryPoint: string) => {
* - No market param: Navigate to market list
* - market=X or marketId=X: Navigate directly to market details for market X
* - feed=world-cup: Navigate to the dedicated World Cup feed when enabled
+ * - feed= (sports/politics/crypto/live/trending/popular-today): Navigate to the generic PredictFeedView when the predictHomeRedesign flag is enabled (tab -> initialTabId, filter -> initialFilterId)
+ * - Unknown feed (or flag disabled): Fall back to the Predict market list
* - Optional tab param when no market: Open feed on a specific tab
* - query=X or q=X: Open feed with search overlay showing results for X
*/
@@ -227,6 +289,19 @@ export const handlePredictUrl = async ({
requestedTab: navParams.worldCupTab,
entryPoint,
});
+ } else if (
+ isPredictFeedId(navParams.feed) &&
+ getPredictHomeRedesignEnabled()
+ ) {
+ handleGenericFeedNavigation({
+ feedId: navParams.feed,
+ // worldCupTab holds the raw (unvalidated) tab value; the generic feed's
+ // sub-tab ids (e.g. basketball/all/live) are resolved by the view.
+ initialTabId: navParams.worldCupTab,
+ initialFilterId: navParams.filter,
+ query: navParams.query,
+ entryPoint,
+ });
} else {
DevLogger.log('[handlePredictUrl] No market parameter, showing list');
handleMarketListNavigation({
diff --git a/app/core/redux/slices/card/index.test.ts b/app/core/redux/slices/card/index.test.ts
index 67133c73bb77..a637234ae669 100644
--- a/app/core/redux/slices/card/index.test.ts
+++ b/app/core/redux/slices/card/index.test.ts
@@ -15,6 +15,7 @@ import cardReducer, {
setPendingMoneyAccountCardLink,
selectPendingMoneyAccountCardLink,
} from '.';
+import { CardEntryPoint } from '../../../../components/UI/Card/util/metrics';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const CARD_STATE_MOCK: CardSliceState = {
@@ -25,7 +26,7 @@ const CARD_STATE_MOCK: CardSliceState = {
contactVerificationId: null,
consentSetId: null,
},
- pendingMoneyAccountCardLink: false,
+ pendingMoneyAccountCardLink: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -37,7 +38,7 @@ const EMPTY_CARD_STATE_MOCK: CardSliceState = {
contactVerificationId: null,
consentSetId: null,
},
- pendingMoneyAccountCardLink: false,
+ pendingMoneyAccountCardLink: null,
};
describe('Card Selectors', () => {
@@ -58,18 +59,22 @@ describe('Card Selectors', () => {
});
describe('selectPendingMoneyAccountCardLink', () => {
- it('returns false by default from initial state', () => {
+ it('returns null by default from initial state', () => {
const mockRootState = { card: initialState } as unknown as RootState;
- expect(selectPendingMoneyAccountCardLink(mockRootState)).toBe(false);
+ expect(selectPendingMoneyAccountCardLink(mockRootState)).toBe(null);
});
- it('returns true when pendingMoneyAccountCardLink is true', () => {
- const stateWithFlag: CardSliceState = {
+ it('returns the pending Money Account Card link entrypoint', () => {
+ const stateWithEntryPoint: CardSliceState = {
...initialState,
- pendingMoneyAccountCardLink: true,
+ pendingMoneyAccountCardLink: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
};
- const mockRootState = { card: stateWithFlag } as unknown as RootState;
- expect(selectPendingMoneyAccountCardLink(mockRootState)).toBe(true);
+ const mockRootState = {
+ card: stateWithEntryPoint,
+ } as unknown as RootState;
+ expect(selectPendingMoneyAccountCardLink(mockRootState)).toBe(
+ CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
+ );
});
});
@@ -155,7 +160,7 @@ describe('Card Reducer', () => {
contactVerificationId: null,
consentSetId: null,
},
- pendingMoneyAccountCardLink: true,
+ pendingMoneyAccountCardLink: CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
};
const state = cardReducer(currentState, resetCardState());
@@ -164,24 +169,30 @@ describe('Card Reducer', () => {
});
describe('setPendingMoneyAccountCardLink', () => {
- it('sets pendingMoneyAccountCardLink to true', () => {
+ it('stores the entrypoint for post-auth sheet resume', () => {
const state = cardReducer(
initialState,
- setPendingMoneyAccountCardLink(true),
+ setPendingMoneyAccountCardLink(
+ CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
+ ),
+ );
+
+ expect(state.pendingMoneyAccountCardLink).toBe(
+ CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
);
- expect(state.pendingMoneyAccountCardLink).toBe(true);
});
- it('sets pendingMoneyAccountCardLink back to false', () => {
+ it('clears pending state when set to null', () => {
const current: CardSliceState = {
...initialState,
- pendingMoneyAccountCardLink: true,
+ pendingMoneyAccountCardLink:
+ CardEntryPoint.MONEY_HOME_ONBOARDING_CARD,
};
const state = cardReducer(
current,
- setPendingMoneyAccountCardLink(false),
+ setPendingMoneyAccountCardLink(null),
);
- expect(state.pendingMoneyAccountCardLink).toBe(false);
+ expect(state.pendingMoneyAccountCardLink).toBeNull();
});
});
diff --git a/app/core/redux/slices/card/index.ts b/app/core/redux/slices/card/index.ts
index de6798a8cff4..2dd836a655cf 100644
--- a/app/core/redux/slices/card/index.ts
+++ b/app/core/redux/slices/card/index.ts
@@ -1,6 +1,7 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { createSelector } from 'reselect';
import { RootState } from '../../../../reducers';
+import { CardEntryPoint } from '../../../../components/UI/Card/util/metrics';
export interface OnboardingState {
onboardingId: string | null;
@@ -12,7 +13,7 @@ export interface CardSliceState {
hasViewedCardButton: boolean;
onboarding: OnboardingState;
isDaimoDemo: boolean;
- pendingMoneyAccountCardLink: boolean;
+ pendingMoneyAccountCardLink: CardEntryPoint | null;
}
export const initialState: CardSliceState = {
@@ -23,7 +24,7 @@ export const initialState: CardSliceState = {
consentSetId: null,
},
isDaimoDemo: false,
- pendingMoneyAccountCardLink: false,
+ pendingMoneyAccountCardLink: null,
};
const name = 'card';
@@ -55,7 +56,10 @@ const slice = createSlice({
consentSetId: null,
};
},
- setPendingMoneyAccountCardLink: (state, action: PayloadAction) => {
+ setPendingMoneyAccountCardLink: (
+ state,
+ action: PayloadAction,
+ ) => {
state.pendingMoneyAccountCardLink = action.payload;
},
},
diff --git a/app/store/persistConfig/index.ts b/app/store/persistConfig/index.ts
index b17b83b52089..ad816519c683 100644
--- a/app/store/persistConfig/index.ts
+++ b/app/store/persistConfig/index.ts
@@ -3,6 +3,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import FilesystemStorage from 'redux-persist-filesystem-storage';
import autoMergeLevel2 from 'redux-persist/lib/stateReconciler/autoMergeLevel2';
import { RootState } from '../../reducers';
+import type { CardSliceState } from '../../core/redux/slices/card';
import { version, migrations } from '../migrations';
import Logger from '../../util/Logger';
import Device from '../../util/device';
@@ -182,6 +183,24 @@ const persistOnboardingTransform = createTransform(
{ whitelist: ['onboarding'] },
);
+type PersistedCardState = Omit;
+
+const persistCardTransform = createTransform<
+ CardSliceState,
+ PersistedCardState
+>(
+ (inboundState) => {
+ const { pendingMoneyAccountCardLink: _omitSession, ...state } =
+ inboundState;
+ return state;
+ },
+ (outboundState) => ({
+ ...outboundState,
+ pendingMoneyAccountCardLink: null,
+ }),
+ { whitelist: ['card'] },
+);
+
const persistConfig = {
key: 'root',
version,
@@ -195,7 +214,11 @@ const persistConfig = {
'securityAlerts',
],
storage: MigratedStorage,
- transforms: [persistUserTransform, persistOnboardingTransform],
+ transforms: [
+ persistUserTransform,
+ persistOnboardingTransform,
+ persistCardTransform,
+ ],
stateReconciler: autoMergeLevel2, // see "Merge Process" section for details.
migrate: createMigrate(migrations, {
debug: false,
diff --git a/app/store/persistConfig/persistConfig.test.ts b/app/store/persistConfig/persistConfig.test.ts
index 8e140bbb85d3..a35b4914083b 100644
--- a/app/store/persistConfig/persistConfig.test.ts
+++ b/app/store/persistConfig/persistConfig.test.ts
@@ -368,7 +368,7 @@ describe('persistConfig', () => {
describe('transforms', () => {
it('have correct number of transforms', () => {
- expect(persistConfig.transforms).toHaveLength(2);
+ expect(persistConfig.transforms).toHaveLength(3);
});
it('have user transform configured', () => {
@@ -386,6 +386,14 @@ describe('persistConfig', () => {
> & { whitelist?: string[] };
expect(onboardingTransform.whitelist).toEqual(['onboarding']);
});
+
+ it('has card transform configured', () => {
+ const cardTransform = persistConfig.transforms[2] as Transform<
+ unknown,
+ unknown
+ > & { whitelist?: string[] };
+ expect(cardTransform.whitelist).toEqual(['card']);
+ });
});
describe('createPersistController', () => {
diff --git a/babel.config.tests.js b/babel.config.tests.js
index e74a1f70f797..0e1ef5d5ed45 100644
--- a/babel.config.tests.js
+++ b/babel.config.tests.js
@@ -71,6 +71,8 @@ const newOverrides = [
'app/util/networks/customNetworks.tsx',
'tests/framework/playwrightLogger.ts',
'tests/framework/services/providers/emulator/reinstallLocalBuildFromPath.ts',
+ 'tests/framework/services/appium/ScreenRecording.ts',
+ 'tests/framework/services/appium/AppiumServer.ts',
'.yarn/plugins/plugin-usage-tracking.cjs',
'.yarn/plugins/plugin-usage-tracking.test.ts',
],
diff --git a/docs/readme/deeplinking.md b/docs/readme/deeplinking.md
index d68a8d90bc50..7a31ca203c12 100644
--- a/docs/readme/deeplinking.md
+++ b/docs/readme/deeplinking.md
@@ -886,28 +886,28 @@ describe('Dynamic signature verification', () => {
The `deposit` / `metamask://deposit` deeplink is **deprecated** and no longer opens the cash deposit flow; use buy/universal on-ramp entry points instead.
-| Action | Purpose | Handler Function | Notes |
-| ---------------------- | --------------------------- | ------------------------ | ----------------------------------------------- |
-| `swap` | Token swap/bridge (CAIP-19) | `handleSwapUrl` | Params: `from`, `to`, `amount` (CAIP-19) |
-| `buy` / `buy-crypto` | Buy crypto | `handleRampUrl` | |
-| `sell` / `sell-crypto` | Sell crypto | `handleRampUrl` | |
-| `send` | Send transaction | Recursive `parse()` call | |
-| `home` | Navigate home | `navigateToHomeUrl` | Params: `previewToken`, `openNetworkSelector` |
-| `asset` | Asset overview | `handleAssetUrl` | Params: `assetId` (CAIP-19) |
-| `dapp` | Open dApp browser | `handleBrowserUrl` | |
-| `create-account` | Create new account | `handleCreateAccountUrl` | |
-| `perps` | Perpetuals trading | `handlePerpsUrl` | Params: `screen` (tabs/markets/asset), `symbol` |
-| `perps-markets` | Perps markets list | `handlePerpsUrl` | |
-| `perps-asset` | Perps specific asset | ⚠️ NOT IMPLEMENTED | Action defined but missing handler case |
-| `predict` | Prediction markets | `handlePredictUrl` | Params: `market` or `marketId`, `utm_source` |
-| `rewards` | Rewards program | `handleRewardsUrl` | Params: `referral` (referral code) |
-| `trending` | Explore / Trending | `handleTrendingUrl` | Params: `screen=stocks` (geo-block falls back) |
-| `wc` | WalletConnect | Recursive `parse()` call | |
-| `onboarding` | Fast onboarding | `handleFastOnboarding` | |
-| `enable-card-button` | Enable card feature | `handleEnableCardButton` | |
-| `bind` | Android SDK binding | `handleMetaMaskDeeplink` | |
-| `connect` | SDK connection | `handleMetaMaskDeeplink` | |
-| `mmsdk` | MetaMask SDK message | `handleMetaMaskDeeplink` | |
+| Action | Purpose | Handler Function | Notes |
+| ---------------------- | --------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------- |
+| `swap` | Token swap/bridge (CAIP-19) | `handleSwapUrl` | Params: `from`, `to`, `amount` (CAIP-19) |
+| `buy` / `buy-crypto` | Buy crypto | `handleRampUrl` | |
+| `sell` / `sell-crypto` | Sell crypto | `handleRampUrl` | |
+| `send` | Send transaction | Recursive `parse()` call | |
+| `home` | Navigate home | `navigateToHomeUrl` | Params: `previewToken`, `openNetworkSelector` |
+| `asset` | Asset overview | `handleAssetUrl` | Params: `assetId` (CAIP-19) |
+| `dapp` | Open dApp browser | `handleBrowserUrl` | |
+| `create-account` | Create new account | `handleCreateAccountUrl` | |
+| `perps` | Perpetuals trading | `handlePerpsUrl` | Params: `screen` (tabs/markets/asset), `symbol` |
+| `perps-markets` | Perps markets list | `handlePerpsUrl` | |
+| `perps-asset` | Perps specific asset | ⚠️ NOT IMPLEMENTED | Action defined but missing handler case |
+| `predict` | Prediction markets | `handlePredictUrl` | Params: `market`/`marketId`, `feed` (generic ids + `world-cup`), `tab`, `filter`, `q`/`query`, `utm_source` |
+| `rewards` | Rewards program | `handleRewardsUrl` | Params: `referral` (referral code) |
+| `trending` | Explore / Trending | `handleTrendingUrl` | Params: `screen=stocks` (geo-block falls back) |
+| `wc` | WalletConnect | Recursive `parse()` call | |
+| `onboarding` | Fast onboarding | `handleFastOnboarding` | |
+| `enable-card-button` | Enable card feature | `handleEnableCardButton` | |
+| `bind` | Android SDK binding | `handleMetaMaskDeeplink` | |
+| `connect` | SDK connection | `handleMetaMaskDeeplink` | |
+| `mmsdk` | MetaMask SDK message | `handleMetaMaskDeeplink` | |
> ⚠️ **Bug**: The `perps-asset` action is defined in `SUPPORTED_ACTIONS` and whitelisted, but **there is no case handler** for it in the switch statement. It will pass validation but silently do nothing.
diff --git a/package.json b/package.json
index faa171efa5ea..00a348a96079 100644
--- a/package.json
+++ b/package.json
@@ -132,6 +132,8 @@
"run-system-tests:android-onboarding-emu": "yarn playwright test --project system-android-onboarding-emu --config tests/playwright.system-emulator.config.ts",
"run-system-tests:ios-login-sim": "yarn playwright test --project system-ios-login-sim --config tests/playwright.system-emulator.config.ts",
"run-system-tests:ios-onboarding-sim": "yarn playwright test --project system-ios-onboarding-sim --config tests/playwright.system-emulator.config.ts",
+ "appium-smoke:android": "yarn playwright test --config tests/playwright.smoke-appium.config.ts --project android-smoke",
+ "appium-smoke:ios": "yarn playwright test --config tests/playwright.smoke-appium.config.ts --project ios-smoke",
"capture-visual-baselines:android": "CAPTURE_BASELINES=true AI_VISUAL_TESTING_ENABLED=true yarn playwright test --project system-android-login-emu --config tests/playwright.system-emulator.config.ts",
"capture-visual-baselines:ios": "CAPTURE_BASELINES=true AI_VISUAL_TESTING_ENABLED=true yarn playwright test --project system-ios-login-sim --config tests/playwright.system-emulator.config.ts",
"test:depcheck": "yarn depcheck",
@@ -244,8 +246,8 @@
"@metamask/authenticated-user-storage": "^2.0.0",
"@metamask/base-controller": "^9.0.1",
"@metamask/bitcoin-wallet-snap": "^1.12.0",
- "@metamask/bridge-controller": "^74.0.0",
- "@metamask/bridge-status-controller": "^72.0.2",
+ "@metamask/bridge-controller": "^75.1.0",
+ "@metamask/bridge-status-controller": "^72.1.0",
"@metamask/chain-agnostic-permission": "^1.5.0",
"@metamask/chomp-api-service": "^3.1.0",
"@metamask/client-controller": "^1.0.1",
@@ -344,7 +346,7 @@
"@metamask/storage-service": "^1.0.0",
"@metamask/superstruct": "^3.2.1",
"@metamask/swappable-obj-proxy": "^2.1.0",
- "@metamask/transaction-controller": "^67.0.0",
+ "@metamask/transaction-controller": "^67.1.0",
"@metamask/transaction-pay-controller": "^23.5.0",
"@metamask/tron-wallet-snap": "^1.25.6",
"@metamask/utils": "^11.11.0",
diff --git a/scripts/e2e/appium-smoke-tags.mjs b/scripts/e2e/appium-smoke-tags.mjs
new file mode 100644
index 000000000000..113b7be02e47
--- /dev/null
+++ b/scripts/e2e/appium-smoke-tags.mjs
@@ -0,0 +1,37 @@
+#!/usr/bin/env node
+/**
+ * Smoke tags executed by Appium CI.
+ * Android: run-appium-smoke-tests-android.yml (ci.yml).
+ * iOS: run-appium-smoke-tests-ios.yml (run-appium-smoke-tests-ios-scheduled.yml).
+ * Append a tag here and add a matching job to each orchestrator.
+ */
+export const APPIUM_SMOKE_TAGS = ['SmokeAccounts'];
+
+/**
+ * @param {string | string[]} selectedTags JSON array string or parsed tags
+ * @returns {boolean}
+ */
+export function shouldRunAppiumSmoke(selectedTags) {
+ const tags =
+ typeof selectedTags === 'string' ? JSON.parse(selectedTags) : selectedTags;
+
+ if (!Array.isArray(tags)) {
+ return false;
+ }
+
+ if (tags.includes('ALL')) {
+ return true;
+ }
+
+ return APPIUM_SMOKE_TAGS.some((tag) => tags.includes(tag));
+}
+
+const isMain =
+ process.argv[1] &&
+ (process.argv[1].endsWith('appium-smoke-tags.mjs') ||
+ process.argv[1].endsWith('appium-smoke-tags'));
+
+if (isMain) {
+ const selectedTagsJson = process.argv[2] ?? '["ALL"]';
+ process.stdout.write(shouldRunAppiumSmoke(selectedTagsJson) ? 'true' : 'false');
+}
diff --git a/scripts/e2e/ensure-ffmpeg-ci.sh b/scripts/e2e/ensure-ffmpeg-ci.sh
new file mode 100755
index 000000000000..a5912aabd09d
--- /dev/null
+++ b/scripts/e2e/ensure-ffmpeg-ci.sh
@@ -0,0 +1,76 @@
+#!/usr/bin/env bash
+# Install ffmpeg for Appium XCUITest screen recording in CI.
+# Uses a cached Homebrew bottle dir + Cellar snapshot under ~/.cache/mms-ffmpeg
+# so subsequent runs skip downloading and compiling ffmpeg dependencies.
+set -euo pipefail
+
+CACHE_ROOT="${HOME}/.cache/mms-ffmpeg"
+BREW_CACHE_DIR="${CACHE_ROOT}/brew"
+CELLAR_CACHE_DIR="${CACHE_ROOT}/cellar"
+
+export HOMEBREW_NO_AUTO_UPDATE=1
+export HOMEBREW_NO_INSTALL_CLEANUP=1
+export HOMEBREW_CACHE="${BREW_CACHE_DIR}"
+
+mkdir -p "${BREW_CACHE_DIR}" "${CELLAR_CACHE_DIR}"
+
+if ! command -v brew >/dev/null 2>&1; then
+ echo "Homebrew is required to install ffmpeg" >&2
+ exit 1
+fi
+
+restore_cached_cellars() {
+ local formula cellar_path
+ shopt -s nullglob
+ for cellar_path in "${CELLAR_CACHE_DIR}"/*; do
+ formula="$(basename "${cellar_path}")"
+ [[ "${formula}" == "ffmpeg" ]] && continue
+ mkdir -p "$(brew --cellar "${formula}")"
+ rsync -a "${cellar_path}/" "$(brew --cellar "${formula}")/"
+ brew link "${formula}" >/dev/null 2>&1 || true
+ done
+ if [[ -d "${CELLAR_CACHE_DIR}/ffmpeg" ]]; then
+ mkdir -p "$(brew --cellar ffmpeg)"
+ rsync -a "${CELLAR_CACHE_DIR}/ffmpeg/" "$(brew --cellar ffmpeg)/"
+ brew link ffmpeg >/dev/null 2>&1 || true
+ fi
+ shopt -u nullglob
+}
+
+cache_installed_cellars() {
+ local formula
+ for formula in ffmpeg $(brew deps --formula ffmpeg); do
+ if [[ ! -d "$(brew --cellar "${formula}")" ]]; then
+ continue
+ fi
+ mkdir -p "${CELLAR_CACHE_DIR}/${formula}"
+ rsync -a "$(brew --cellar "${formula}")/" "${CELLAR_CACHE_DIR}/${formula}/"
+ done
+}
+
+if command -v ffmpeg >/dev/null 2>&1; then
+ echo "ffmpeg already on PATH: $(command -v ffmpeg)"
+ ffmpeg -version | head -1
+ exit 0
+fi
+
+restore_cached_cellars
+
+if command -v ffmpeg >/dev/null 2>&1; then
+ echo "ffmpeg restored from Cellar cache: $(command -v ffmpeg)"
+ ffmpeg -version | head -1
+ exit 0
+fi
+
+echo "Installing ffmpeg via Homebrew (bottles cached under ${BREW_CACHE_DIR})..."
+brew install ffmpeg
+
+if ! command -v ffmpeg >/dev/null 2>&1; then
+ echo "ffmpeg install finished but binary is not on PATH" >&2
+ exit 1
+fi
+
+cache_installed_cellars
+
+echo "ffmpeg installed: $(command -v ffmpeg)"
+ffmpeg -version | head -1
diff --git a/scripts/e2e/ios-simulator-lib.mjs b/scripts/e2e/ios-simulator-lib.mjs
new file mode 100644
index 000000000000..e48b335a56f9
--- /dev/null
+++ b/scripts/e2e/ios-simulator-lib.mjs
@@ -0,0 +1,149 @@
+#!/usr/bin/env node
+/* eslint-disable import-x/no-nodejs-modules */
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+
+const execFileAsync = promisify(execFile);
+
+/**
+ * @param {string} deviceName
+ * @returns {Promise}
+ */
+export async function getIosSimulatorUdid(deviceName) {
+ const { stdout } = await execFileAsync('xcrun', [
+ 'simctl',
+ 'list',
+ 'devices',
+ 'available',
+ '-j',
+ ]);
+ const list = JSON.parse(stdout);
+
+ let firstMatch;
+
+ for (const devices of Object.values(list.devices)) {
+ for (const device of devices) {
+ if (device.name !== deviceName) {
+ continue;
+ }
+ if (device.state === 'Booted') {
+ return device.udid;
+ }
+ firstMatch ??= device.udid;
+ }
+ }
+
+ if (firstMatch) {
+ return firstMatch;
+ }
+
+ throw new Error(
+ `iOS simulator "${deviceName}" not found. Run \`xcrun simctl list devices available\`.`,
+ );
+}
+
+/**
+ * @param {string} udid
+ * @returns {Promise}
+ */
+async function isIosSimulatorBooted(udid) {
+ try {
+ const { stdout } = await execFileAsync('xcrun', [
+ 'simctl',
+ 'list',
+ 'devices',
+ 'available',
+ '-j',
+ ]);
+ const list = JSON.parse(stdout);
+ for (const devices of Object.values(list.devices)) {
+ const sim = devices.find((d) => d.udid === udid);
+ if (sim) {
+ return sim.state === 'Booted';
+ }
+ }
+ } catch {
+ return false;
+ }
+ return false;
+}
+
+/**
+ * @param {string} deviceName
+ * @returns {Promise} UDID of the booted simulator
+ */
+export async function bootIosSimulator(deviceName) {
+ const udid = await getIosSimulatorUdid(deviceName);
+
+ if (await isIosSimulatorBooted(udid)) {
+ console.log(
+ `iOS simulator "${deviceName}" (${udid}) is already booted — skipping boot.`,
+ );
+ return udid;
+ }
+
+ console.log(`Booting iOS simulator: ${deviceName} (${udid})`);
+
+ await execFileAsync('xcrun', ['simctl', 'boot', udid]).catch(
+ (err) => {
+ if (err.code !== 149) {
+ throw err;
+ }
+ },
+ );
+
+ await execFileAsync('xcrun', ['simctl', 'bootstatus', udid, '-b']);
+ console.log(`iOS simulator "${deviceName}" is booted and ready.`);
+ return udid;
+}
+
+/**
+ * Blocks until simctl reports the simulator fully booted (SpringBoard ready).
+ * Call before Appium session creation so XCUITest does not race sim boot.
+ * @param {string} udid
+ */
+export async function ensureIosSimulatorBooted(udid) {
+ await execFileAsync('xcrun', ['simctl', 'bootstatus', udid, '-b']);
+}
+
+/**
+ * @param {string} udid
+ * @param {string} bundleId
+ * @returns {Promise}
+ */
+export async function isIosAppInstalled(udid, bundleId) {
+ try {
+ await execFileAsync('xcrun', ['simctl', 'get_app_container', udid, bundleId]);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * @param {string} udid
+ * @param {string} bundleId
+ */
+export async function assertIosAppInstalled(udid, bundleId) {
+ if (!(await isIosAppInstalled(udid, bundleId))) {
+ throw new Error(
+ `App "${bundleId}" is not installed on simulator ${udid} after simctl install.`,
+ );
+ }
+ console.log(`Verified ${bundleId} is installed on simulator ${udid}.`);
+}
+
+/**
+ * @param {{ udid: string; bundleId: string; appPath: string }} options
+ */
+export async function installIosApp({ udid, bundleId, appPath }) {
+ try {
+ await execFileAsync('xcrun', ['simctl', 'uninstall', udid, bundleId]);
+ } catch {
+ // App may not be installed yet.
+ }
+
+ console.log(`simctl install: ${appPath} → simulator ${udid}`);
+ await execFileAsync('xcrun', ['simctl', 'install', udid, appPath]);
+ await assertIosAppInstalled(udid, bundleId);
+}
diff --git a/scripts/e2e/prebuild-wda.mjs b/scripts/e2e/prebuild-wda.mjs
new file mode 100644
index 000000000000..ded9c59b6a0b
--- /dev/null
+++ b/scripts/e2e/prebuild-wda.mjs
@@ -0,0 +1,9 @@
+#!/usr/bin/env node
+/* eslint-disable import-x/no-nodejs-modules */
+/**
+ * Prebuild WebDriverAgent for Appium XCUITest so session creation skips xcodebuild.
+ * Output is written to ~/appium-wda (must match appium:derivedDataPath in EmulatorConfigBuilder).
+ */
+import { ensureWdaPrebuilt } from './wda-lib.mjs';
+
+await ensureWdaPrebuilt();
diff --git a/scripts/e2e/prepare-ios-appium-runner.mjs b/scripts/e2e/prepare-ios-appium-runner.mjs
new file mode 100644
index 000000000000..d1f9c92c32d8
--- /dev/null
+++ b/scripts/e2e/prepare-ios-appium-runner.mjs
@@ -0,0 +1,118 @@
+#!/usr/bin/env node
+/* eslint-disable import-x/no-nodejs-modules */
+/**
+ * Prepares the iOS Appium runner before Playwright tests:
+ * 1. Boot simulator (parallel with WDA prebuild unless SKIP_WDA_PREBUILD=true)
+ * 2. Prebuild WDA into ~/appium-wda on cache miss
+ * 3. simctl install WebDriverAgentRunner + MetaMask.app (sequential — same UDID)
+ * 4. Warm WDA via a throwaway Appium session; leaves Appium running for tests
+ *
+ * Sets GITHUB_OUTPUT: ios-simulator-udid, ios-wda-preinstalled, ios-wda-bundle-id.
+ * WDA simctl install failures fall back to the xcodebuild path in tests.
+ */
+import { spawnSync } from 'node:child_process';
+import { appendFileSync, existsSync } from 'node:fs';
+import { bootIosSimulator, installIosApp } from './ios-simulator-lib.mjs';
+import {
+ ensureWdaPrebuilt,
+ findWdaArtifacts,
+ getDerivedDataPath,
+ hasUsableWdaArtifacts,
+ installWdaOnSimulator,
+ toWdaBundleIdBase,
+} from './wda-lib.mjs';
+import { warmUpIosAppiumWda } from './warm-up-ios-appium-wda.mjs';
+
+const simulatorName = process.env.IOS_SIMULATOR_NAME ?? 'iPhone 16 Pro';
+const appPath = process.env.IOS_APP_PATH;
+const bundleId = process.env.IOS_BUNDLE_ID ?? 'io.metamask.MetaMask';
+const skipWdaPrebuild = process.env.SKIP_WDA_PREBUILD === 'true';
+
+spawnSync(
+ 'defaults',
+ ['write', 'com.apple.iphonesimulator', 'SlowAnimations', '-bool', 'false'],
+ { stdio: 'inherit' },
+);
+
+console.log('Preparing iOS Appium runner (sim boot ∥ WDA prebuild)…');
+
+const [udid] = await Promise.all([
+ bootIosSimulator(simulatorName),
+ skipWdaPrebuild
+ ? Promise.resolve().then(() =>
+ console.log('SKIP_WDA_PREBUILD=true — skipping WDA prebuild'),
+ )
+ : ensureWdaPrebuilt(),
+]);
+
+if (appPath && !existsSync(appPath)) {
+ console.error(`IOS_APP_PATH does not exist: ${appPath}`);
+ process.exit(1);
+}
+
+let iosWdaPreinstalled = 'false';
+let iosWdaBundleIdBase = '';
+
+// simctl install must be sequential on the same UDID — parallel WDA + app installs race.
+if (hasUsableWdaArtifacts()) {
+ const { wdaApp } = findWdaArtifacts(getDerivedDataPath());
+ if (wdaApp) {
+ try {
+ const installedBundleId = await installWdaOnSimulator({ udid, wdaApp });
+ iosWdaPreinstalled = 'true';
+ iosWdaBundleIdBase = toWdaBundleIdBase(installedBundleId);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.warn(
+ `WDA simctl install failed — tests will use xcodebuild path: ${message}`,
+ );
+ }
+ }
+} else {
+ console.log(
+ 'WDA artifacts not found — skipping sim WDA install (tests will use xcodebuild).',
+ );
+}
+
+if (appPath) {
+ await installIosApp({ udid, bundleId, appPath });
+}
+
+if (iosWdaPreinstalled === 'true' && iosWdaBundleIdBase) {
+ try {
+ await warmUpIosAppiumWda({
+ udid,
+ wdaBundleIdBase: iosWdaBundleIdBase,
+ simulatorName,
+ });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.warn(
+ `WDA warm-up failed — prepare continues; first Playwright session will launch WDA: ${message}`,
+ );
+ }
+}
+
+console.log(`IOS_SIMULATOR_UDID=${udid}`);
+if (iosWdaPreinstalled === 'true') {
+ console.log(`IOS_WDA_PREINSTALLED=true`);
+ console.log(`IOS_WDA_BUNDLE_ID=${iosWdaBundleIdBase}`);
+}
+
+if (process.env.GITHUB_OUTPUT) {
+ appendFileSync(
+ process.env.GITHUB_OUTPUT,
+ `ios-simulator-udid=${udid}\nios-wda-preinstalled=${iosWdaPreinstalled}\n`,
+ );
+ if (iosWdaBundleIdBase) {
+ appendFileSync(
+ process.env.GITHUB_OUTPUT,
+ `ios-wda-bundle-id=${iosWdaBundleIdBase}\n`,
+ );
+ }
+}
+
+console.log('iOS Appium runner ready.');
+
+// Detached Appium + WebdriverIO can leave open handles; exit so GHA does not hang.
+process.exit(0);
diff --git a/scripts/e2e/resolve-xcuitest-driver-version.mjs b/scripts/e2e/resolve-xcuitest-driver-version.mjs
new file mode 100644
index 000000000000..9e42dcb69d68
--- /dev/null
+++ b/scripts/e2e/resolve-xcuitest-driver-version.mjs
@@ -0,0 +1,28 @@
+#!/usr/bin/env node
+/* eslint-disable import-x/no-nodejs-modules */
+/**
+ * Prints the appium-xcuitest-driver version for WDA cache keys.
+ * Source of truth: root package.json devDependencies (no yarn install required).
+ */
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '../..');
+const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
+
+const raw =
+ pkg.devDependencies?.['appium-xcuitest-driver'] ??
+ pkg.dependencies?.['appium-xcuitest-driver'];
+
+if (!raw) {
+ console.error(
+ 'appium-xcuitest-driver not found in package.json dependencies',
+ );
+ process.exit(1);
+}
+
+// Normalize semver range prefixes (^, ~, >=) — we pin exact versions today but
+// this keeps the cache key stable if a range is ever used.
+const version = String(raw).replace(/^[\^~>=<]+/, '');
+process.stdout.write(version);
diff --git a/scripts/e2e/warm-up-ios-appium-wda.mjs b/scripts/e2e/warm-up-ios-appium-wda.mjs
new file mode 100644
index 000000000000..46a1b692dcdc
--- /dev/null
+++ b/scripts/e2e/warm-up-ios-appium-wda.mjs
@@ -0,0 +1,135 @@
+#!/usr/bin/env node
+/* eslint-disable import-x/no-nodejs-modules */
+/**
+ * Launches WDA on a booted simulator before Playwright tests.
+ * Starts a detached Appium server (reused when SKIP_APPIUM_STOP=true) and opens a
+ * WDA-only WebDriverIO session (no app bundleId). useNewWDA:false keeps WDA alive
+ * after deleteSession so the test step attaches instead of cold-starting.
+ */
+import { spawn } from 'node:child_process';
+import { setTimeout as sleep } from 'node:timers/promises';
+import { ensureIosSimulatorBooted } from './ios-simulator-lib.mjs';
+
+const APPIUM_PORT = Number(process.env.APPIUM_PORT ?? 4723);
+const APPIUM_HOST = process.env.APPIUM_HOST ?? '127.0.0.1';
+const APPIUM_STARTUP_TIMEOUT_MS = 60_000;
+const WARMUP_SESSION_TIMEOUT_MS = 5 * 60 * 1000;
+const WARMUP_MAX_ATTEMPTS = 2;
+const WARMUP_RETRY_DELAY_MS = 5_000;
+
+async function isAppiumRunning() {
+ try {
+ const response = await fetch(`http://${APPIUM_HOST}:${APPIUM_PORT}/status`);
+ return response.ok;
+ } catch {
+ return false;
+ }
+}
+
+async function waitForAppiumReady(timeoutMs) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (await isAppiumRunning()) {
+ return;
+ }
+ await sleep(250);
+ }
+ throw new Error(`Appium did not start within ${timeoutMs}ms`);
+}
+
+async function startAppiumServer() {
+ if (await isAppiumRunning()) {
+ console.log(`Appium already running at http://${APPIUM_HOST}:${APPIUM_PORT} — reusing.`);
+ return;
+ }
+
+ console.log(`Starting Appium on http://${APPIUM_HOST}:${APPIUM_PORT} for WDA warm-up…`);
+
+ // stdio: 'ignore' — piped stdio would keep Node's event loop alive after unref().
+ const proc = spawn(
+ 'yarn',
+ [
+ 'appium',
+ '--allow-insecure=chromedriver_autodownload',
+ '--port',
+ String(APPIUM_PORT),
+ '--address',
+ APPIUM_HOST,
+ ],
+ { stdio: 'ignore', detached: true },
+ );
+
+ proc.on('error', (error) => {
+ console.error('Failed to spawn Appium:', error);
+ });
+
+ proc.unref();
+ await waitForAppiumReady(APPIUM_STARTUP_TIMEOUT_MS);
+}
+
+/**
+ * @param {{ udid: string; wdaBundleIdBase: string; simulatorName: string }} options
+ */
+async function createWarmUpSession({ udid, wdaBundleIdBase, simulatorName }) {
+ const { remote } = await import('webdriverio');
+ console.log('Creating warm-up Appium session (preinstalled WDA, no app launch)…');
+
+ const driver = await remote({
+ hostname: APPIUM_HOST,
+ port: APPIUM_PORT,
+ connectionRetryTimeout: WARMUP_SESSION_TIMEOUT_MS,
+ connectionRetryCount: 0,
+ capabilities: {
+ platformName: 'iOS',
+ 'appium:automationName': 'XCUITest',
+ 'appium:udid': udid,
+ 'appium:deviceName': simulatorName,
+ 'appium:usePreinstalledWDA': true,
+ 'appium:updatedWDABundleId': wdaBundleIdBase,
+ 'appium:useNewWDA': false,
+ 'appium:derivedDataPath': `${process.env.HOME}/appium-wda`,
+ 'appium:wdaLaunchTimeout': 120_000,
+ 'appium:wdaConnectionTimeout': 30_000,
+ 'appium:simulatorStartupTimeout': 180_000,
+ 'appium:noReset': true,
+ 'appium:skipLogCapture': true,
+ },
+ });
+
+ await driver.deleteSession();
+ if (typeof driver.close === 'function') {
+ await driver.close().catch(() => undefined);
+ }
+}
+
+/**
+ * @param {{ udid: string; wdaBundleIdBase: string; simulatorName: string }} options
+ * @returns {Promise} true when warm-up succeeded
+ */
+export async function warmUpIosAppiumWda({ udid, wdaBundleIdBase, simulatorName }) {
+ await startAppiumServer();
+
+ let lastError;
+
+ for (let attempt = 1; attempt <= WARMUP_MAX_ATTEMPTS; attempt += 1) {
+ try {
+ console.log(
+ `WDA warm-up attempt ${attempt}/${WARMUP_MAX_ATTEMPTS} (simulator ${udid})…`,
+ );
+ await ensureIosSimulatorBooted(udid);
+ await createWarmUpSession({ udid, wdaBundleIdBase, simulatorName });
+ console.log('WDA warm-up complete — Appium left running for Playwright.');
+ await sleep(2000);
+ return true;
+ } catch (error) {
+ lastError = error;
+ const message = error instanceof Error ? error.message : String(error);
+ console.warn(`WDA warm-up attempt ${attempt} failed: ${message}`);
+ if (attempt < WARMUP_MAX_ATTEMPTS) {
+ await sleep(WARMUP_RETRY_DELAY_MS);
+ }
+ }
+ }
+
+ throw lastError;
+}
diff --git a/scripts/e2e/wda-lib.mjs b/scripts/e2e/wda-lib.mjs
new file mode 100644
index 000000000000..b5f409dcf574
--- /dev/null
+++ b/scripts/e2e/wda-lib.mjs
@@ -0,0 +1,241 @@
+#!/usr/bin/env node
+/* eslint-disable import-x/no-nodejs-modules */
+import { execFile, spawnSync } from 'node:child_process';
+import { existsSync, readdirSync, statSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { promisify } from 'node:util';
+import { fileURLToPath } from 'node:url';
+
+const execFileAsync = promisify(execFile);
+
+export const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../..');
+
+export function getDerivedDataPath() {
+ return process.env.WDA_DERIVED_DATA_PATH ?? join(homedir(), 'appium-wda');
+}
+
+export function getSimulatorName() {
+ return process.env.IOS_SIMULATOR_NAME ?? 'iPhone 16 Pro';
+}
+
+/**
+ * @param {string} dir
+ * @param {(entryPath: string, name: string, isDirectory: boolean) => boolean | 'stop'} predicate
+ * @param {number} [maxDepth]
+ * @returns {string | undefined}
+ */
+export function findInTree(dir, predicate, maxDepth = 20) {
+ if (!existsSync(dir) || maxDepth < 0) {
+ return undefined;
+ }
+
+ for (const name of readdirSync(dir)) {
+ if (name === '.git' || name === '.yarn') {
+ continue;
+ }
+
+ const entryPath = join(dir, name);
+ let stat;
+ try {
+ stat = statSync(entryPath);
+ } catch {
+ continue;
+ }
+
+ const isDirectory = stat.isDirectory();
+ const result = predicate(entryPath, name, isDirectory);
+ if (result === true) {
+ return entryPath;
+ }
+ if (result === 'stop') {
+ return undefined;
+ }
+
+ if (isDirectory) {
+ const nested = findInTree(entryPath, predicate, maxDepth - 1);
+ if (nested) {
+ return nested;
+ }
+ }
+ }
+
+ return undefined;
+}
+
+export function findWdaProject() {
+ const nodeModules = join(repoRoot, 'node_modules');
+ if (!existsSync(nodeModules)) {
+ return undefined;
+ }
+
+ return findInTree(
+ nodeModules,
+ (entryPath, name, isDirectory) => {
+ if (
+ isDirectory &&
+ name === 'WebDriverAgent.xcodeproj' &&
+ entryPath.includes(
+ `${join('appium-webdriveragent', 'WebDriverAgent.xcodeproj')}`,
+ )
+ ) {
+ return true;
+ }
+ return false;
+ },
+ 12,
+ );
+}
+
+/**
+ * @param {string} derivedDataPath
+ * @returns {{ wdaApp?: string; xctestrun?: string }}
+ */
+export function findWdaArtifacts(derivedDataPath) {
+ const productsDir = join(derivedDataPath, 'Build', 'Products');
+ if (!existsSync(productsDir)) {
+ return {};
+ }
+
+ let wdaApp;
+ let xctestrun;
+
+ findInTree(
+ productsDir,
+ (entryPath, name, isDirectory) => {
+ if (!wdaApp && isDirectory && name === 'WebDriverAgentRunner-Runner.app') {
+ wdaApp = entryPath;
+ }
+ if (!xctestrun && !isDirectory && name.endsWith('.xctestrun')) {
+ xctestrun = entryPath;
+ }
+ if (wdaApp && xctestrun) {
+ return 'stop';
+ }
+ return false;
+ },
+ 8,
+ );
+
+ return { wdaApp, xctestrun };
+}
+
+export function hasUsableWdaArtifacts(derivedDataPath = getDerivedDataPath()) {
+ const artifacts = findWdaArtifacts(derivedDataPath);
+ return Boolean(artifacts.wdaApp && artifacts.xctestrun);
+}
+
+/**
+ * @param {string} appPath Path to .app bundle
+ * @returns {string} CFBundleIdentifier from Info.plist
+ */
+export function readAppBundleId(appPath) {
+ const plistPath = join(appPath, 'Info.plist');
+ const result = spawnSync(
+ '/usr/libexec/PlistBuddy',
+ ['-c', 'Print CFBundleIdentifier', plistPath],
+ { encoding: 'utf8' },
+ );
+ if (result.status !== 0) {
+ throw new Error(
+ `Could not read CFBundleIdentifier from ${plistPath}: ${result.stderr?.trim() ?? 'unknown error'}`,
+ );
+ }
+ return result.stdout.trim();
+}
+
+/**
+ * Appium `updatedWDABundleId` expects the base id; the driver adds `.xctrunner`.
+ * @param {string} bundleId
+ */
+export function toWdaBundleIdBase(bundleId) {
+ return bundleId.replace(/\.xctrunner$/, '');
+}
+
+/**
+ * Installs prebuilt WebDriverAgentRunner onto a booted simulator.
+ * @param {{ udid: string; wdaApp: string }} options
+ * @returns {Promise} installed bundle id (may include `.xctrunner`)
+ */
+export async function installWdaOnSimulator({ udid, wdaApp }) {
+ if (!existsSync(wdaApp)) {
+ throw new Error(`WDA app not found: ${wdaApp}`);
+ }
+
+ console.log(`Installing WebDriverAgent on simulator ${udid}…`);
+ console.log(` app: ${wdaApp}`);
+ await execFileAsync('xcrun', ['simctl', 'install', udid, wdaApp]);
+
+ const bundleId = readAppBundleId(wdaApp);
+ console.log(`WebDriverAgent installed (bundleId=${bundleId}).`);
+ return bundleId;
+}
+
+function logArtifacts(label, derivedDataPath, { wdaApp, xctestrun }) {
+ console.log(`${label} ${derivedDataPath}`);
+ if (wdaApp) {
+ console.log(` app: ${wdaApp}`);
+ }
+ if (xctestrun) {
+ console.log(` xctestrun: ${xctestrun}`);
+ }
+}
+
+/**
+ * Ensures WDA is prebuilt. No-op when artifacts already exist.
+ * @returns {Promise<{ wdaApp: string; xctestrun: string }>}
+ */
+export async function ensureWdaPrebuilt() {
+ const derivedDataPath = getDerivedDataPath();
+ const simulatorName = getSimulatorName();
+ const productsDir = join(derivedDataPath, 'Build', 'Products');
+
+ let artifacts = findWdaArtifacts(derivedDataPath);
+ if (artifacts.wdaApp && artifacts.xctestrun) {
+ logArtifacts('WDA already prebuilt at', derivedDataPath, artifacts);
+ return { wdaApp: artifacts.wdaApp, xctestrun: artifacts.xctestrun };
+ }
+
+ const wdaProject = findWdaProject();
+ if (!wdaProject) {
+ throw new Error(
+ 'Could not find appium-webdriveragent/WebDriverAgent.xcodeproj under node_modules. Run yarn install first.',
+ );
+ }
+
+ console.log(`Prebuilding WebDriverAgent for simulator "${simulatorName}"…`);
+ console.log(` project: ${wdaProject}`);
+ console.log(` derivedDataPath: ${derivedDataPath}`);
+
+ const build = spawnSync(
+ 'xcodebuild',
+ [
+ 'build-for-testing',
+ '-project',
+ wdaProject,
+ '-scheme',
+ 'WebDriverAgentRunner',
+ '-derivedDataPath',
+ derivedDataPath,
+ '-destination',
+ `platform=iOS Simulator,name=${simulatorName}`,
+ 'CODE_SIGNING_ALLOWED=NO',
+ ],
+ { stdio: 'inherit' },
+ );
+
+ if (build.status !== 0) {
+ process.exit(build.status ?? 1);
+ }
+
+ artifacts = findWdaArtifacts(derivedDataPath);
+ if (!artifacts.wdaApp || !artifacts.xctestrun) {
+ console.error(
+ `WDA prebuild finished but expected artifacts were not found under ${productsDir}`,
+ );
+ process.exit(1);
+ }
+
+ logArtifacts('WDA prebuild complete', derivedDataPath, artifacts);
+ return { wdaApp: artifacts.wdaApp, xctestrun: artifacts.xctestrun };
+}
diff --git a/tests/flows/accounts.flow.ts b/tests/flows/accounts.flow.ts
index 204542f34c36..80412d833b54 100644
--- a/tests/flows/accounts.flow.ts
+++ b/tests/flows/accounts.flow.ts
@@ -10,9 +10,42 @@ import TabBarComponent from '../page-objects/wallet/TabBarComponent';
import SettingsView from '../page-objects/Settings/SettingsView';
import SecurityAndPrivacyView from '../page-objects/Settings/SecurityAndPrivacy/SecurityAndPrivacyView';
import AccountDetails from '../page-objects/MultichainAccounts/AccountDetails';
+import { PlatformDetector } from '../framework/PlatformLocator';
+import { FrameworkDetector } from '../framework/FrameworkDetector';
+import PlaywrightAssertions from '../framework/PlaywrightAssertions';
+import {
+ asPlaywrightElement,
+ EncapsulatedElementType,
+} from '../framework/EncapsulatedElement';
+import { AssertionOptions } from '../framework/types';
const PASSWORD = '123123123';
+async function expectElementVisible(
+ target: EncapsulatedElementType | DetoxElement,
+ options: AssertionOptions = {},
+): Promise {
+ if (FrameworkDetector.isAppium()) {
+ await PlaywrightAssertions.expectElementToBeVisible(
+ await asPlaywrightElement(target as EncapsulatedElementType),
+ options,
+ );
+ return;
+ }
+ await Assertions.expectElementToBeVisible(target as DetoxElement, options);
+}
+
+async function expectTextVisible(
+ text: string,
+ options: AssertionOptions = {},
+): Promise {
+ if (FrameworkDetector.isAppium()) {
+ await PlaywrightAssertions.expectTextDisplayed(text, options);
+ return;
+ }
+ await Assertions.expectTextDisplayed(text, options);
+}
+
export const goToImportSrp = async () => {
await WalletView.tapIdenticon();
await Assertions.expectElementToBeVisible(AccountListBottomSheet.accountList);
@@ -48,19 +81,17 @@ export const completeSrpQuiz = async (expectedSrp: string) => {
// Tap the blur overlay to reveal the SRP
await RevealSecretRecoveryPhrase.tapToReveal();
- await Assertions.expectElementToBeVisible(
- RevealSecretRecoveryPhrase.container,
- );
+ await expectElementVisible(RevealSecretRecoveryPhrase.container);
// SRP is now displayed in grid format - verify first word is displayed
const srpWords = expectedSrp.split(' ');
- await Assertions.expectTextDisplayed(srpWords[0]);
+ await expectTextVisible(srpWords[0]);
await RevealSecretRecoveryPhrase.scrollToCopyToClipboardButton();
await RevealSecretRecoveryPhrase.tapToRevealPrivateCredentialQRCode();
- if (device.getPlatform() === 'ios') {
+ if (PlatformDetector.isIOS()) {
// For some reason, the QR code is visible on Android but detox cannot find it
- await Assertions.expectElementToBeVisible(
+ await expectElementVisible(
RevealSecretRecoveryPhrase.revealCredentialQRCodeImage,
);
}
diff --git a/tests/flows/wallet.flow.ts b/tests/flows/wallet.flow.ts
index 8248070ffc4e..59beded38633 100644
--- a/tests/flows/wallet.flow.ts
+++ b/tests/flows/wallet.flow.ts
@@ -421,12 +421,9 @@ export const loginToApp = async (password?: string): Promise => {
await Assertions.expectElementToBeVisible(LoginView.container, {
description: 'Login View container should be visible',
});
- await Assertions.expectElementToBeVisible(
- asDetoxElement(LoginView.passwordInput),
- {
- description: 'Login View password input should be visible',
- },
- );
+ await Assertions.expectElementToBeVisible(LoginView.passwordInput, {
+ description: 'Login View password input should be visible',
+ });
await LoginView.enterPassword(PASSWORD);
@@ -502,6 +499,21 @@ export const loginToAppPlaywright = async (
): Promise => {
const { scenarioType = 'login' } = options;
+ await PlaywrightAssertions.expectElementToBeVisible(
+ asPlaywrightElement(LoginView.container),
+ {
+ description: 'Login view container',
+ timeout: 45_000,
+ },
+ );
+ await PlaywrightAssertions.expectElementToBeVisible(
+ asPlaywrightElement(LoginView.passwordInput),
+ {
+ description: 'Login password input',
+ timeout: 15_000,
+ },
+ );
+
const password = getPasswordForScenario(scenarioType);
// Type password and unlock
await LoginView.enterPassword(password ?? '');
diff --git a/tests/framework/GestureStrategy.ts b/tests/framework/GestureStrategy.ts
index 564b8a34c4db..10269a3528a9 100644
--- a/tests/framework/GestureStrategy.ts
+++ b/tests/framework/GestureStrategy.ts
@@ -22,7 +22,7 @@ export interface UnifiedGestureOptions {
speed?: 'fast' | 'slow';
/** Swipe percentage (0–1) — Detox only; Appium ignores this */
percentage?: number;
- /** Scroll direction — Detox only; used by scrollToElement */
+ /** Scroll direction — used by scrollToElement (Detox default and Appium scrollIntoView) */
direction?: 'up' | 'down' | 'left' | 'right';
/** Scroll amount in px — Detox only; used by scrollToElement */
scrollAmount?: number;
@@ -47,6 +47,9 @@ export interface UnifiedGestureOptions {
export type TapAtIndexElement = EncapsulatedElementType | PlaywrightElement[];
export type ScrollViewMatcher = Promise;
+/** Detox scroll container: matcher promise, or testID resolved inside UnifiedGestures. */
+export type ScrollContainer = ScrollViewMatcher | string;
+
/**
* Strategy interface for framework-agnostic gesture execution.
*
@@ -85,7 +88,7 @@ export interface GestureStrategy {
scrollToElement(
target: EncapsulatedElementType,
- scrollView: ScrollViewMatcher,
+ scrollView?: ScrollContainer,
opts?: UnifiedGestureOptions,
): Promise;
@@ -218,9 +221,21 @@ export class DetoxGestureStrategy implements GestureStrategy {
*/
async scrollToElement(
target: EncapsulatedElementType,
- scrollView: ScrollViewMatcher,
+ scrollView?: ScrollContainer,
opts?: UnifiedGestureOptions,
): Promise {
+ if (!scrollView) {
+ throw new Error(
+ 'DetoxGestureStrategy.scrollToElement requires a scroll container testID or matcher.',
+ );
+ }
+
+ if (typeof scrollView === 'string') {
+ throw new Error(
+ 'DetoxGestureStrategy.scrollToElement received a testID string — resolve it in UnifiedGestures first.',
+ );
+ }
+
const resolvedScrollView = await scrollView;
if (this.isLikelyDetoxElement(resolvedScrollView)) {
@@ -408,10 +423,13 @@ export class AppiumGestureStrategy implements GestureStrategy {
*/
async scrollToElement(
target: EncapsulatedElementType,
- _scrollView: ScrollViewMatcher,
+ _scrollView?: ScrollContainer,
+ opts?: UnifiedGestureOptions,
): Promise {
const el = await asPlaywrightElement(target);
- await PlaywrightGestures.scrollIntoView(el);
+ await PlaywrightGestures.scrollIntoView(el, {
+ scrollParams: { direction: opts?.direction ?? 'down' },
+ });
}
/**
diff --git a/tests/framework/Matchers.ts b/tests/framework/Matchers.ts
index 649b6f6f34d8..46f4089f907e 100644
--- a/tests/framework/Matchers.ts
+++ b/tests/framework/Matchers.ts
@@ -1,5 +1,6 @@
import { web, system } from 'detox';
import { type EncapsulatedElementType } from './EncapsulatedElement.ts';
+import { FrameworkDetector } from './FrameworkDetector.ts';
import { resolve } from './Selector.ts';
/**
@@ -183,6 +184,11 @@ export default class Matchers {
static async getIdentifier(
selectorString: string,
): Promise {
+ if (FrameworkDetector.isAppium()) {
+ throw new Error(
+ 'Matchers.getIdentifier is Detox-only. Use scrollContainer(testId) for cross-framework scroll.',
+ );
+ }
return by.id(selectorString);
}
diff --git a/tests/framework/PlaywrightGestures.ts b/tests/framework/PlaywrightGestures.ts
index 563bc064e707..5926ac34a4e9 100644
--- a/tests/framework/PlaywrightGestures.ts
+++ b/tests/framework/PlaywrightGestures.ts
@@ -11,7 +11,7 @@ import {
const logger = createPlaywrightLogger('PlaywrightGestures');
export interface ScrollOptions {
- scrollParams?: { direction?: 'up' | 'down' };
+ scrollParams?: { direction?: 'up' | 'down' | 'left' | 'right' };
from?: { x: number; y: number };
to?: { x: number; y: number };
percent?: number;
@@ -482,7 +482,7 @@ export default class PlaywrightGestures {
* Hide keyboard for both Android and iOS
* @param keyName - The key to press on iOS keyboard (default: 'Done'). Common values: 'Done', 'Return', 'Search', 'Go', 'Next'
*/
- static async hideKeyboard(keyName: string = 'Done'): Promise {
+ static async hideKeyboard(): Promise {
const drv = getDriver();
if (!drv) throw new Error('Driver is not available');
@@ -490,11 +490,13 @@ export default class PlaywrightGestures {
if (PlatformDetector.isAndroid()) {
await drv.hideKeyboard();
} else {
- // iOS - tapOutside dismisses the keyboard by tapping outside the focused
- // element, which works regardless of keyboard type (password, numeric, etc.)
+ // iOS — use 'tapOutside' to dismiss the keyboard without pressing a
+ // return key. 'pressKey: Done' would trigger onSubmitEditing on inputs
+ // that have returnKeyType='done', causing unintended form submissions
+ // before the test has a chance to interact with other elements.
try {
await drv.executeScript('mobile: hideKeyboard', [
- { strategy: 'pressKey', key: keyName },
+ { strategy: 'tapOutside' },
]);
} catch {
// Keyboard may already be hidden
diff --git a/tests/framework/PlaywrightUtilities.test.ts b/tests/framework/PlaywrightUtilities.test.ts
index 9a50800ad55e..6b579265901e 100644
--- a/tests/framework/PlaywrightUtilities.test.ts
+++ b/tests/framework/PlaywrightUtilities.test.ts
@@ -44,9 +44,10 @@ describe('PlaywrightUtilities.launchApp', () => {
},
});
- await jest.advanceTimersByTimeAsync(1000);
+ await jest.advanceTimersByTimeAsync(2500);
await launchPromise;
+ expect(terminateAppMock).toHaveBeenCalledWith('io.metamask');
expect(executeMock).toHaveBeenCalledWith(
'mobile: startActivity',
expect.objectContaining({
@@ -87,7 +88,7 @@ describe('PlaywrightUtilities.launchApp', () => {
},
});
- await jest.advanceTimersByTimeAsync(1000);
+ await jest.advanceTimersByTimeAsync(2500);
await launchPromise;
const startActivityCall = executeMock.mock.calls.find(
diff --git a/tests/framework/PlaywrightUtilities.ts b/tests/framework/PlaywrightUtilities.ts
index ade02b5ba48c..5d2360114065 100644
--- a/tests/framework/PlaywrightUtilities.ts
+++ b/tests/framework/PlaywrightUtilities.ts
@@ -26,7 +26,8 @@ const deviceMatrix: DeviceMatrix = require('../performance/device-matrix.json');
type AndroidIntentExtra = ['s', string, string];
-/** Appium `mobile: startActivity` options — not passed to the app via launch arguments. */
+/** Brief pause after force-stopping Android before startActivity (CI emulators). */
+const ANDROID_PRE_LAUNCH_SETTLE_MS = 1500;
const APPIUM_START_ACTIVITY_CONTROL_KEYS = new Set([
'stop',
'wait',
@@ -435,6 +436,12 @@ class PlaywrightUtilities {
const stop = launchArgs?.stop ?? true;
const wait = launchArgs?.wait ?? true;
+ // Mirror iOS: terminate before launch so `-W -S` startActivity is not stuck on a hung process.
+ await drv.terminateApp(pkg).catch(() => undefined);
+ await new Promise((resolve) =>
+ setTimeout(resolve, ANDROID_PRE_LAUNCH_SETTLE_MS),
+ );
+
logger.debug(`Launching Android app ${pkg}/${activity}`);
await drv.execute('mobile: startActivity', {
component: `${pkg}/${activity}`,
diff --git a/tests/framework/UnifiedGestures.ts b/tests/framework/UnifiedGestures.ts
index ad2a30f61c18..3e4e1b532864 100644
--- a/tests/framework/UnifiedGestures.ts
+++ b/tests/framework/UnifiedGestures.ts
@@ -4,10 +4,11 @@ import {
GestureStrategy,
UnifiedGestureOptions,
TapAtIndexElement,
- ScrollViewMatcher,
+ type ScrollContainer,
DetoxGestureStrategy,
AppiumGestureStrategy,
} from './GestureStrategy.ts';
+import Matchers from './Matchers.ts';
import { resolve, isSelector, type Selector } from './Selector.ts';
/**
@@ -48,6 +49,26 @@ export default class UnifiedGestures {
this._strategy = null;
}
+ /**
+ * Resolve scroll container for scrollToElement.
+ * - `string` testID → Detox matcher via Matchers.getIdentifier; omitted under Appium.
+ * - `ScrollViewMatcher` → passed through (Detox-only; do not build with getIdentifier in page objects under Appium).
+ */
+ private static resolveScrollContainer(
+ scrollView?: ScrollContainer,
+ ): ScrollContainer | undefined {
+ if (scrollView === undefined) {
+ return undefined;
+ }
+ if (typeof scrollView === 'string') {
+ if (FrameworkDetector.isAppium()) {
+ return undefined;
+ }
+ return Matchers.getIdentifier(scrollView);
+ }
+ return scrollView;
+ }
+
// ── Gesture Methods ─────────────────────────────────────────
static async tap(
@@ -105,12 +126,12 @@ export default class UnifiedGestures {
static async scrollToElement(
target: EncapsulatedElementType | Selector,
- scrollView: ScrollViewMatcher,
+ scrollView?: ScrollContainer,
opts?: UnifiedGestureOptions,
): Promise {
await this.strategy.scrollToElement(
isSelector(target) ? resolve(target) : target,
- scrollView,
+ this.resolveScrollContainer(scrollView),
opts,
);
}
diff --git a/tests/framework/fixtures/playwright/currentDeviceDetails.fixture.ts b/tests/framework/fixtures/playwright/currentDeviceDetails.fixture.ts
index a87d8919f348..4cce0b64040f 100644
--- a/tests/framework/fixtures/playwright/currentDeviceDetails.fixture.ts
+++ b/tests/framework/fixtures/playwright/currentDeviceDetails.fixture.ts
@@ -7,6 +7,7 @@ import {
type WebDriverConfig,
} from '../../types.ts';
import { applyResolvedAndroidAdbToDevice } from '../../services/providers/emulator/android/resolveAndroidAdbUdid.ts';
+import { getIosSimulatorUdid } from '../../services/appium/EmulatorHelpers.ts';
import { createPlaywrightLogger } from '../../playwrightLogger.ts';
import type { CurrentDeviceDetails } from './types.ts';
@@ -67,6 +68,17 @@ export const currentDeviceDetailsFixture = {
);
}
+ // For iOS local simulators, resolve the UDID of the currently-booted device.
+ // CI sets IOS_SIMULATOR_UDID (and project device.udid) from prepare-ios-appium-runner;
+ // prefer those over name lookup when multiple simulators share a display name.
+ let resolvedIosUdid: string | undefined;
+ if (platform === Platform.IOS && isLocalEmulator && deviceNameField) {
+ const preferredUdid =
+ emulatorDevice?.udid?.trim() || process.env.IOS_SIMULATOR_UDID?.trim();
+ resolvedIosUdid =
+ preferredUdid || (await getIosSimulatorUdid(deviceNameField));
+ }
+
const displayName = deviceNameField ?? deviceUdid ?? 'unknown';
const providerLabel = isBrowserstack
? 'browserstack'
@@ -74,7 +86,7 @@ export const currentDeviceDetailsFixture = {
const deviceDetails: CurrentDeviceDetails = {
platform: platform as 'android' | 'ios',
deviceName: displayName,
- udid: emulatorDevice?.udid,
+ udid: resolvedIosUdid ?? emulatorDevice?.udid,
packageName,
appId,
launchableActivity,
diff --git a/tests/framework/fixtures/playwright/driver.fixture.ts b/tests/framework/fixtures/playwright/driver.fixture.ts
index d9e8c63aa90e..36f7fec92d6a 100644
--- a/tests/framework/fixtures/playwright/driver.fixture.ts
+++ b/tests/framework/fixtures/playwright/driver.fixture.ts
@@ -3,6 +3,11 @@ import type { WebDriverConfig } from '../../types.ts';
import { DEFAULT_IMPLICIT_WAIT_MS } from '../../Constants.ts';
import { setDeviceInfo } from '../../DeviceInfoCache.ts';
import type { TestLevelFixtures } from './types.ts';
+import {
+ isVideoRecordingOnFailureEnabled,
+ startFailureRecording,
+ stopFailureRecordingAndAttach,
+} from '../../services/appium/ScreenRecording.ts';
import { createPlaywrightLogger } from '../../playwrightLogger.ts';
const logger = createPlaywrightLogger('driver');
@@ -14,7 +19,12 @@ export const driverFixture = {
testInfo: TestInfo,
) => {
let driver: WebdriverIO.Browser | undefined;
+ let recordingBackend: Awaited>;
const project = testInfo.project as FullProject;
+ const platform = project.use.platform;
+ const recordVideoOnFailure = isVideoRecordingOnFailureEnabled(
+ project.use.device?.provider,
+ );
try {
logger.info(
@@ -81,6 +91,10 @@ export const driverFixture = {
logger.error('Failed to sync pre-test details:', error);
}
+ if (recordVideoOnFailure) {
+ recordingBackend = await startFailureRecording(driver, platform);
+ }
+
await use(driver);
} finally {
const testStatus = testInfo.status;
@@ -90,6 +104,19 @@ export const driverFixture = {
`Tearing down WebDriver session for "${testInfo.title}" (status: ${testStatus ?? 'unknown'})`,
);
+ try {
+ if (driver) {
+ await stopFailureRecordingAndAttach(
+ driver,
+ testInfo,
+ recordingBackend,
+ platform,
+ );
+ }
+ } catch (error) {
+ console.error('Failed to stop/attach failure screen recording:', error);
+ }
+
try {
await deviceProvider.syncTestDetails?.({
name: testInfo.title,
diff --git a/tests/framework/fixtures/playwright/types.ts b/tests/framework/fixtures/playwright/types.ts
index 708572f78220..5a2acc33e4bd 100644
--- a/tests/framework/fixtures/playwright/types.ts
+++ b/tests/framework/fixtures/playwright/types.ts
@@ -5,7 +5,8 @@ export interface CurrentDeviceDetails {
platform: 'android' | 'ios';
deviceName: string;
/**
- * Android: adb serial (e.g. `emulator-5554`) after AVD name resolution. Omitted on iOS.
+ * Android: adb serial (e.g. `emulator-5554`) after AVD name resolution.
+ * iOS: simulator UDID resolved from the display name at fixture time (prefers the Booted one).
*/
udid?: string;
packageName?: string;
diff --git a/tests/framework/index.ts b/tests/framework/index.ts
index 511d11aa7345..420be330a5ce 100644
--- a/tests/framework/index.ts
+++ b/tests/framework/index.ts
@@ -79,4 +79,5 @@ export {
type UnifiedGestureOptions,
type TapAtIndexElement,
type ScrollViewMatcher,
+ type ScrollContainer,
} from './GestureStrategy.ts';
diff --git a/tests/framework/services/appium/AppiumServer.test.ts b/tests/framework/services/appium/AppiumServer.test.ts
new file mode 100644
index 000000000000..2dcee500fd3a
--- /dev/null
+++ b/tests/framework/services/appium/AppiumServer.test.ts
@@ -0,0 +1,111 @@
+import {
+ getAppiumHost,
+ getAppiumPort,
+ getAppiumServerUrl,
+ isAppiumServerRunning,
+ shouldSkipAppiumStop,
+} from './AppiumServer.ts';
+
+describe('AppiumServer', () => {
+ const hostKey = 'APPIUM_HOST';
+ const portKey = 'APPIUM_PORT';
+ const skipStopKey = 'SKIP_APPIUM_STOP';
+
+ let previousHost: string | undefined;
+ let previousPort: string | undefined;
+ let previousSkipStop: string | undefined;
+ let fetchMock: jest.SpiedFunction;
+
+ beforeEach(() => {
+ previousHost = process.env[hostKey];
+ previousPort = process.env[portKey];
+ previousSkipStop = process.env[skipStopKey];
+ delete process.env[hostKey];
+ delete process.env[portKey];
+ delete process.env[skipStopKey];
+ fetchMock = jest.spyOn(globalThis, 'fetch');
+ });
+
+ afterEach(() => {
+ fetchMock.mockRestore();
+ if (previousHost === undefined) {
+ delete process.env[hostKey];
+ } else {
+ process.env[hostKey] = previousHost;
+ }
+ if (previousPort === undefined) {
+ delete process.env[portKey];
+ } else {
+ process.env[portKey] = previousPort;
+ }
+ if (previousSkipStop === undefined) {
+ delete process.env[skipStopKey];
+ } else {
+ process.env[skipStopKey] = previousSkipStop;
+ }
+ });
+
+ describe('getAppiumHost', () => {
+ it('defaults to 127.0.0.1', () => {
+ expect(getAppiumHost()).toBe('127.0.0.1');
+ });
+
+ it('reads APPIUM_HOST', () => {
+ process.env[hostKey] = 'localhost';
+ expect(getAppiumHost()).toBe('localhost');
+ });
+ });
+
+ describe('getAppiumPort', () => {
+ it('defaults to 4723', () => {
+ expect(getAppiumPort()).toBe(4723);
+ });
+
+ it('reads APPIUM_PORT', () => {
+ process.env[portKey] = '4725';
+ expect(getAppiumPort()).toBe(4725);
+ });
+
+ it('throws for invalid APPIUM_PORT', () => {
+ process.env[portKey] = 'not-a-port';
+ expect(() => getAppiumPort()).toThrow(/Invalid APPIUM_PORT/);
+ });
+ });
+
+ describe('getAppiumServerUrl', () => {
+ it('builds url from host and port env vars', () => {
+ process.env[hostKey] = '127.0.0.1';
+ process.env[portKey] = '4724';
+ expect(getAppiumServerUrl()).toBe('http://127.0.0.1:4724');
+ });
+ });
+
+ describe('shouldSkipAppiumStop', () => {
+ it('returns false by default', () => {
+ expect(shouldSkipAppiumStop()).toBe(false);
+ });
+
+ it('returns true when SKIP_APPIUM_STOP is true', () => {
+ process.env[skipStopKey] = 'true';
+ expect(shouldSkipAppiumStop()).toBe(true);
+ });
+ });
+
+ describe('isAppiumServerRunning', () => {
+ it('returns true when /status responds ok', async () => {
+ fetchMock.mockResolvedValue({ ok: true } as Response);
+ await expect(isAppiumServerRunning()).resolves.toBe(true);
+ expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:4723/status');
+ });
+
+ it('returns false when /status is unreachable', async () => {
+ fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
+ await expect(isAppiumServerRunning()).resolves.toBe(false);
+ });
+
+ it('returns false when /status responds with non-ok', async () => {
+ fetchMock.mockResolvedValue({ ok: false } as Response);
+ await expect(isAppiumServerRunning()).resolves.toBe(false);
+ });
+ });
+});
diff --git a/tests/framework/services/appium/AppiumServer.ts b/tests/framework/services/appium/AppiumServer.ts
index 38f2a44e3d05..c6acebaf30a6 100644
--- a/tests/framework/services/appium/AppiumServer.ts
+++ b/tests/framework/services/appium/AppiumServer.ts
@@ -1,29 +1,90 @@
/* eslint-disable import-x/no-nodejs-modules */
-import { spawn, exec, type ChildProcess } from 'child_process';
+import { spawn, type ChildProcess } from 'child_process';
import { createLogger, LogLevel } from '../../logger';
const logger = createLogger({ name: 'AppiumServer', level: LogLevel.INFO });
+// Track the running Appium process so we can kill it by PID instead of pkill.
+let appiumServerProcess: ChildProcess | null = null;
+
// Track the current exit handler to prevent listener accumulation
let currentExitHandler: (() => void) | null = null;
// Default timeout for Appium server startup (in milliseconds)
const APPIUM_STARTUP_TIMEOUT_MS = 60_000;
+const DEFAULT_APPIUM_HOST = '127.0.0.1';
+const DEFAULT_APPIUM_PORT = 4723;
+
+/**
+ * Resolve Appium host from env (default: 127.0.0.1).
+ */
+export function getAppiumHost(): string {
+ return process.env.APPIUM_HOST ?? DEFAULT_APPIUM_HOST;
+}
+
+/**
+ * Resolve Appium port from env (default: 4723).
+ */
+export function getAppiumPort(): number {
+ const parsed = Number(process.env.APPIUM_PORT ?? DEFAULT_APPIUM_PORT);
+ if (!Number.isInteger(parsed) || parsed <= 0) {
+ throw new Error(
+ `Invalid APPIUM_PORT "${process.env.APPIUM_PORT}". Expected a positive integer.`,
+ );
+ }
+ return parsed;
+}
+
+/**
+ * Build the Appium server base URL for health checks and WebDriverIO.
+ */
+export function getAppiumServerUrl(): string {
+ return `http://${getAppiumHost()}:${getAppiumPort()}`;
+}
+
+/**
+ * Whether the test runner should leave Appium running after the job.
+ * Set explicitly via SKIP_APPIUM_STOP (e.g. Android CI keeps one server per job).
+ */
+export function shouldSkipAppiumStop(): boolean {
+ return process.env.SKIP_APPIUM_STOP === 'true';
+}
+
+/**
+ * Check whether Appium is already listening on the configured host/port.
+ */
+export async function isAppiumServerRunning(): Promise {
+ try {
+ const response = await fetch(`${getAppiumServerUrl()}/status`);
+ return response.ok;
+ } catch {
+ return false;
+ }
+}
+
/**
* Start the Appium server
* @param timeoutMs - Maximum time to wait for Appium to start (default: 60 seconds)
*/
export async function startAppiumServer(
timeoutMs: number = APPIUM_STARTUP_TIMEOUT_MS,
-): Promise {
+): Promise {
+ if (await isAppiumServerRunning()) {
+ logger.info(`Reusing existing Appium server at ${getAppiumServerUrl()}.`);
+ return null;
+ }
+
+ const host = getAppiumHost();
+ const port = getAppiumPort();
+
return new Promise((resolve, reject) => {
let isSettled = false;
let startupTimeout: NodeJS.Timeout | null = null;
const settlePromise = (
settler: typeof resolve | typeof reject,
- value: ChildProcess | Error,
+ value: ChildProcess | Error | null,
) => {
if (isSettled) return;
isSettled = true;
@@ -31,14 +92,24 @@ export async function startAppiumServer(
clearTimeout(startupTimeout);
startupTimeout = null;
}
- settler(value as ChildProcess & Error);
+ settler(value as ChildProcess & Error & null);
};
const appiumProcess = spawn(
'yarn',
- ['appium', '--allow-insecure=chromedriver_autodownload'],
+ [
+ 'appium',
+ '--allow-insecure=chromedriver_autodownload',
+ '--port',
+ String(port),
+ '--address',
+ host,
+ ],
{
stdio: 'pipe',
+ // detached: true puts appium in its own process group so we can
+ // kill the whole group (yarn + appium children) with -pid later.
+ detached: true,
},
);
@@ -69,17 +140,26 @@ export async function startAppiumServer(
logger.debug(output);
if (output.includes('Error: listen EADDRINUSE')) {
+ if (await isAppiumServerRunning()) {
+ logger.info(
+ `Appium port ${port} already in use — reusing existing server.`,
+ );
+ appiumProcess.kill();
+ settlePromise(resolve, null);
+ return;
+ }
logger.error(`Appium: ${data}`);
settlePromise(
reject,
new Error(
- 'Appium server is already running. Please stop the server before running tests.',
+ `Appium port ${port} is in use but /status is not reachable.`,
),
);
}
if (output.includes('Appium REST http interface listener started')) {
logger.debug('Appium server is up and running.');
+ appiumServerProcess = appiumProcess;
settlePromise(resolve, appiumProcess);
}
});
@@ -96,8 +176,19 @@ export async function startAppiumServer(
// Create and track the new exit handler
currentExitHandler = () => {
+ if (shouldSkipAppiumStop()) {
+ return;
+ }
logger.debug('Main process exiting. Killing Appium server...');
- appiumProcess.kill();
+ if (appiumProcess.pid !== undefined) {
+ try {
+ process.kill(-appiumProcess.pid, 'SIGTERM');
+ } catch {
+ appiumProcess.kill('SIGTERM');
+ }
+ } else {
+ appiumProcess.kill('SIGTERM');
+ }
};
process.on('exit', currentExitHandler);
@@ -116,35 +207,55 @@ export async function startAppiumServer(
}
/**
- * Stop the Appium server
- *
- * Note: pkill exit codes:
- * - 0: One or more processes matched and were signaled
- * - 1: No processes matched (not an error - server wasn't running)
- * - 2: Syntax error in command line
- * - 3: Fatal error
+ * Stop the Appium server.
+ * Kills the tracked process by PID to avoid accidentally matching and killing
+ * the parent test-runner process (which also has "appium" in its command line).
+ * Skips stop when SKIP_APPIUM_STOP is set or when this process did not spawn Appium.
*/
-export function stopAppiumServer(): Promise {
+export function stopAppiumServer(): Promise {
+ if (shouldSkipAppiumStop()) {
+ logger.debug('Skipping Appium server stop (SKIP_APPIUM_STOP).');
+ return Promise.resolve();
+ }
+
// Remove the exit handler since we're explicitly stopping the server
if (currentExitHandler) {
process.removeListener('exit', currentExitHandler);
currentExitHandler = null;
}
- return new Promise((resolve, reject) => {
- exec('pkill -f appium', (error, stdout) => {
- if (error) {
- // Exit code 1 means no processes matched - this is fine, server wasn't running
- if ('code' in error && error.code === 1) {
- logger.debug('No Appium server process found to stop.');
- return resolve(stdout);
- }
- // Actual error (syntax error, fatal error, or system error)
- logger.error(`Error stopping Appium server: ${error.message}`);
- return reject(error);
- }
+ return new Promise((resolve) => {
+ const proc = appiumServerProcess;
+ appiumServerProcess = null;
+
+ if (!proc || proc.exitCode !== null || proc.killed) {
+ logger.debug('No running Appium server process found to stop.');
+ return resolve();
+ }
+
+ // Safety timeout: resolve after 10s even if 'close' never fires.
+ const fallbackTimer = setTimeout(() => {
+ logger.warn('Appium server did not exit within 10s; continuing anyway.');
+ resolve();
+ }, 10_000);
+
+ proc.once('close', () => {
+ clearTimeout(fallbackTimer);
logger.debug('Appium server stopped successfully.');
- resolve(stdout);
+ resolve();
});
+
+ // Kill the entire process group (-pid) so yarn AND the appium child
+ // process are both terminated. Falls back to killing just the direct
+ // child if process-group kill is unavailable (e.g. pid is undefined).
+ if (proc.pid !== undefined) {
+ try {
+ process.kill(-proc.pid, 'SIGTERM');
+ } catch {
+ proc.kill('SIGTERM');
+ }
+ } else {
+ proc.kill('SIGTERM');
+ }
});
}
diff --git a/tests/framework/services/appium/EmulatorHelpers.test.ts b/tests/framework/services/appium/EmulatorHelpers.test.ts
new file mode 100644
index 000000000000..661d9ec14239
--- /dev/null
+++ b/tests/framework/services/appium/EmulatorHelpers.test.ts
@@ -0,0 +1,75 @@
+import {
+ ANDROID_E2E_PACKAGES_TO_DISABLE,
+ findAnrDialogRecoveryTapPoint,
+ findAnrDialogWaitTapPoint,
+ shouldWaitForOfflineEmulator,
+} from './EmulatorHelpers.ts';
+
+describe('EmulatorHelpers', () => {
+ describe('shouldWaitForOfflineEmulator', () => {
+ it('returns true only when resolved AVD matches the request', () => {
+ expect(
+ shouldWaitForOfflineEmulator('appium_smoke_avd', 'appium_smoke_avd'),
+ ).toBe(true);
+ });
+
+ it('returns false when AVD name is unknown', () => {
+ expect(shouldWaitForOfflineEmulator('appium_smoke_avd', undefined)).toBe(
+ false,
+ );
+ });
+
+ it('returns false when offline emulator belongs to a different AVD', () => {
+ expect(shouldWaitForOfflineEmulator('appium_smoke_avd', 'emulator')).toBe(
+ false,
+ );
+ });
+ });
+
+ describe('findAnrDialogWaitTapPoint', () => {
+ it('returns Wait button center when Pixel Launcher ANR is visible', () => {
+ const uiDump = `
+
+
+
+ `;
+
+ expect(findAnrDialogWaitTapPoint(uiDump)).toEqual({ x: 780, y: 650 });
+ });
+
+ it('returns undefined when no ANR dialog is present', () => {
+ const uiDump =
+ '';
+
+ expect(findAnrDialogWaitTapPoint(uiDump)).toBeUndefined();
+ });
+
+ it('falls back to Close app when Wait is absent', () => {
+ const uiDump = `
+
+
+ `;
+
+ expect(findAnrDialogWaitTapPoint(uiDump)).toEqual({ x: 300, y: 650 });
+ });
+ });
+
+ describe('findAnrDialogRecoveryTapPoint', () => {
+ it('prefers Close app for Pixel Launcher ANR', () => {
+ const uiDump = `
+
+
+
+ `;
+
+ expect(findAnrDialogRecoveryTapPoint(uiDump)).toEqual({ x: 300, y: 650 });
+ });
+
+ it('lists Play Store and GMS in packages to disable', () => {
+ expect(ANDROID_E2E_PACKAGES_TO_DISABLE).toContain('com.android.vending');
+ expect(ANDROID_E2E_PACKAGES_TO_DISABLE).toContain(
+ 'com.google.android.gms',
+ );
+ });
+ });
+});
diff --git a/tests/framework/services/appium/EmulatorHelpers.ts b/tests/framework/services/appium/EmulatorHelpers.ts
index 2ecda109ebb3..d7cc5956d5e6 100644
--- a/tests/framework/services/appium/EmulatorHelpers.ts
+++ b/tests/framework/services/appium/EmulatorHelpers.ts
@@ -1,11 +1,523 @@
/* eslint-disable import-x/no-nodejs-modules */
-import { exec } from 'child_process';
+import { exec, spawn } from 'child_process';
import path from 'path';
import { Platform } from '../../types.ts';
import { createLogger } from '../../logger.ts';
const logger = createLogger({ name: 'EmulatorHelpers' });
+const ANDROID_BOOT_TIMEOUT_MS = 3 * 60 * 1000;
+const ANDROID_BOOT_POLL_INTERVAL_MS = 2000;
+const ANDROID_CI_INITIAL_SETTLE_MS = 15_000;
+const ANDROID_ANR_DISMISS_INTERVAL_MS = 3000;
+const ANDROID_ANR_CLEAR_STREAK_REQUIRED = 3;
+const ANDROID_ANR_STABILIZE_TIMEOUT_MS = 90_000;
+const ANDROID_EMULATOR_CI_CORES_DEFAULT = '8';
+const UI_AUTOMATOR_DUMP_PATH = '/sdcard/window_dump.xml';
+
+/** Play Store / GMS packages disabled after cold boot — not needed for Appium E2E. */
+export const ANDROID_E2E_PACKAGES_TO_DISABLE = [
+ 'com.android.vending',
+ 'com.google.android.gms',
+ 'com.google.android.gsf',
+ 'com.google.android.partnersetup',
+ 'com.google.android.setupwizard',
+ 'com.google.android.apps.restore',
+ 'com.google.android.apps.wellbeing',
+] as const;
+
+/** Launcher packages force-stopped so ANR dialogs do not cover the test app. */
+export const ANDROID_E2E_LAUNCHER_PACKAGES = [
+ 'com.google.android.apps.nexuslauncher',
+ 'com.android.launcher3',
+] as const;
+
+interface AdbDevice {
+ serial: string;
+ state: string;
+}
+
+function execAsync(cmd: string): Promise<{ stdout: string; stderr: string }> {
+ return new Promise((resolve, reject) => {
+ exec(cmd, (error, stdout, stderr) => {
+ if (error) {
+ reject(Object.assign(error, { stdout, stderr }));
+ } else {
+ resolve({ stdout, stderr });
+ }
+ });
+ });
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function parseAdbDevices(stdout: string): AdbDevice[] {
+ return stdout
+ .trim()
+ .split('\n')
+ .slice(1)
+ .map((line) => {
+ const trimmed = line.trim();
+ if (!trimmed) {
+ return null;
+ }
+ const tabIndex = trimmed.indexOf('\t');
+ if (tabIndex === -1) {
+ return null;
+ }
+ const serial = trimmed.slice(0, tabIndex);
+ const state = trimmed.slice(tabIndex + 1).trim();
+ if (!serial.startsWith('emulator-')) {
+ return null;
+ }
+ return { serial, state };
+ })
+ .filter((device): device is AdbDevice => device !== null);
+}
+
+async function listAdbDevices(): Promise {
+ const { stdout } = await execAsync('adb devices');
+ return parseAdbDevices(stdout);
+}
+
+async function getEmulatorAvdName(serial: string): Promise {
+ try {
+ const { stdout } = await execAsync(`adb -s ${serial} emu avd name`);
+ return stdout.trim().split('\n')[0]?.trim();
+ } catch {
+ return undefined;
+ }
+}
+
+async function findEmulatorSerialForAvd(
+ avdName: string,
+ states: string[],
+): Promise {
+ const devices = await listAdbDevices();
+ for (const device of devices) {
+ if (!states.includes(device.state)) {
+ continue;
+ }
+ const name = await getEmulatorAvdName(device.serial);
+ if (name === avdName) {
+ return device.serial;
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Only reuse an offline/authorizing emulator when its AVD name is known and matches.
+ * If `adb emu avd name` fails, do not attach — another host's emulator may be starting.
+ */
+export function shouldWaitForOfflineEmulator(
+ requestedAvdName: string,
+ resolvedAvdName: string | undefined,
+): boolean {
+ return resolvedAvdName === requestedAvdName;
+}
+
+interface TapPoint {
+ x: number;
+ y: number;
+}
+
+function parseUiNodeCenter(bounds: string): TapPoint | undefined {
+ const match = bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
+ if (!match) {
+ return undefined;
+ }
+ const left = Number.parseInt(match[1], 10);
+ const top = Number.parseInt(match[2], 10);
+ const right = Number.parseInt(match[3], 10);
+ const bottom = Number.parseInt(match[4], 10);
+ return {
+ x: Math.floor((left + right) / 2),
+ y: Math.floor((top + bottom) / 2),
+ };
+}
+
+/**
+ * Finds the tap target for an Android "app isn't responding" dialog.
+ * Prefers "Wait" over "Close app" so the launcher can recover.
+ */
+export function findAnrDialogWaitTapPoint(
+ uiDump: string,
+): TapPoint | undefined {
+ return findAnrDialogRecoveryTapPoint(uiDump, { preferCloseApp: false });
+}
+
+/**
+ * Finds an ANR recovery tap target. Prefers "Close app" for launcher ANRs
+ * (Pixel/Nexus Launcher) since we launch MetaMask directly and do not need HOME.
+ */
+export function findAnrDialogRecoveryTapPoint(
+ uiDump: string,
+ options?: { preferCloseApp?: boolean },
+): TapPoint | undefined {
+ if (!/isn.t responding/i.test(uiDump)) {
+ return undefined;
+ }
+
+ const preferCloseApp =
+ options?.preferCloseApp ??
+ /Pixel Launcher|Nexus Launcher|launcher/i.test(uiDump);
+
+ const waitNode = uiDump.match(
+ /text="Wait"[^>]*bounds="(\[[^\]]+\]\[[^\]]+\])"/,
+ );
+ const closeNode = uiDump.match(
+ /text="Close app"[^>]*bounds="(\[[^\]]+\]\[[^\]]+\])"/,
+ );
+
+ if (preferCloseApp) {
+ if (closeNode) {
+ return parseUiNodeCenter(closeNode[1]);
+ }
+ if (waitNode) {
+ return parseUiNodeCenter(waitNode[1]);
+ }
+ return undefined;
+ }
+
+ if (waitNode) {
+ return parseUiNodeCenter(waitNode[1]);
+ }
+ if (closeNode) {
+ return parseUiNodeCenter(closeNode[1]);
+ }
+
+ return undefined;
+}
+
+async function dumpUiHierarchy(serial: string): Promise {
+ await execAsync(
+ `adb -s ${serial} shell uiautomator dump ${UI_AUTOMATOR_DUMP_PATH}`,
+ ).catch(() => undefined);
+ const { stdout } = await execAsync(
+ `adb -s ${serial} shell cat ${UI_AUTOMATOR_DUMP_PATH}`,
+ ).catch(() => ({ stdout: '', stderr: '' }));
+ return stdout;
+}
+
+async function disableAndroidAnimations(serial: string): Promise {
+ const settings = [
+ 'settings put global window_animation_scale 0',
+ 'settings put global transition_animation_scale 0',
+ 'settings put global animator_duration_scale 0',
+ ];
+ for (const command of settings) {
+ await execAsync(`adb -s ${serial} shell ${command}`).catch(() => undefined);
+ }
+}
+
+async function runAdbShell(serial: string, command: string): Promise {
+ await execAsync(`adb -s ${serial} shell ${command}`).catch(() => undefined);
+}
+
+async function trimAndroidSystemForE2e(serial: string): Promise {
+ logger.info(
+ 'Trimming Android system for E2E (skip setup wizard, disable bloat packages)...',
+ );
+
+ const setupWizardSettings = [
+ 'settings put global device_provisioned 1',
+ 'settings put secure user_setup_complete 1',
+ 'settings put global setup_wizard_has_run 1',
+ ];
+ for (const command of setupWizardSettings) {
+ await runAdbShell(serial, command);
+ }
+
+ for (const packageName of ANDROID_E2E_PACKAGES_TO_DISABLE) {
+ await runAdbShell(serial, `pm disable-user --user 0 ${packageName}`);
+ }
+
+ for (const packageName of ANDROID_E2E_LAUNCHER_PACKAGES) {
+ await runAdbShell(serial, `am force-stop ${packageName}`);
+ }
+}
+
+async function dismissAndroidAnrDialogs(serial: string): Promise {
+ const uiDump = await dumpUiHierarchy(serial);
+ const tapPoint = findAnrDialogRecoveryTapPoint(uiDump);
+ if (!tapPoint) {
+ return false;
+ }
+
+ logger.warn(
+ `Android system ANR dialog detected on ${serial} — tapping recovery action.`,
+ );
+ await execAsync(
+ `adb -s ${serial} shell input tap ${tapPoint.x} ${tapPoint.y}`,
+ );
+ return true;
+}
+
+/**
+ * Poll until ANR dialogs are absent for several consecutive checks, dismissing
+ * any that appear. Falls through after timeout so boot does not hang forever.
+ */
+async function waitForAndroidSystemReady(serial: string): Promise {
+ const initialSettleMs = Number.parseInt(
+ process.env.ANDROID_EMULATOR_POST_BOOT_SETTLE_MS ??
+ String(ANDROID_CI_INITIAL_SETTLE_MS),
+ 10,
+ );
+ if (initialSettleMs > 0) {
+ logger.info(
+ `Waiting ${initialSettleMs / 1000}s before checking Android system readiness...`,
+ );
+ await sleep(initialSettleMs);
+ }
+
+ const deadline = Date.now() + ANDROID_ANR_STABILIZE_TIMEOUT_MS;
+ let clearStreak = 0;
+
+ while (Date.now() < deadline) {
+ const hadAnr = await dismissAndroidAnrDialogs(serial);
+ if (hadAnr) {
+ clearStreak = 0;
+ } else {
+ clearStreak += 1;
+ if (clearStreak >= ANDROID_ANR_CLEAR_STREAK_REQUIRED) {
+ logger.info(
+ `Android system stable (${clearStreak} consecutive ANR-free checks).`,
+ );
+ return;
+ }
+ }
+ await sleep(ANDROID_ANR_DISMISS_INTERVAL_MS);
+ }
+
+ logger.warn(
+ `Android system did not stabilize within ${ANDROID_ANR_STABILIZE_TIMEOUT_MS / 1000}s — continuing.`,
+ );
+}
+
+/**
+ * After cold `-wipe-data` boot, system apps can ANR while indexing.
+ * Trim bloat, wait for ANR-free window, then force-stop the launcher.
+ */
+async function stabilizeAndroidEmulatorAfterBoot(
+ serial: string,
+): Promise {
+ if (process.env.CI !== 'true') {
+ return;
+ }
+
+ logger.info('Stabilizing Android emulator after cold boot...');
+ await disableAndroidAnimations(serial);
+ await trimAndroidSystemForE2e(serial);
+ await execAsync(`adb -s ${serial} shell input keyevent 3`).catch(() => {
+ /* HOME — surface any remaining system dialogs */
+ });
+
+ await waitForAndroidSystemReady(serial);
+
+ for (const packageName of ANDROID_E2E_LAUNCHER_PACKAGES) {
+ await runAdbShell(serial, `am force-stop ${packageName}`);
+ }
+}
+
+async function waitForEmulatorBoot(serial: string): Promise {
+ await execAsync(`adb -s ${serial} wait-for-device`);
+
+ const deadline = Date.now() + ANDROID_BOOT_TIMEOUT_MS;
+ let booted = false;
+
+ while (Date.now() < deadline) {
+ const devices = await listAdbDevices();
+ const device = devices.find((entry) => entry.serial === serial);
+ if (device?.state === 'device') {
+ try {
+ const { stdout } = await execAsync(
+ `adb -s ${serial} shell getprop sys.boot_completed 2>/dev/null`,
+ );
+ if (stdout.trim() === '1') {
+ booted = true;
+ break;
+ }
+ } catch {
+ // Device not yet ready — keep polling
+ }
+ }
+ await sleep(ANDROID_BOOT_POLL_INTERVAL_MS);
+ }
+
+ if (!booted) {
+ throw new Error(
+ `Android emulator ${serial} did not complete booting within ${ANDROID_BOOT_TIMEOUT_MS / 1000}s.`,
+ );
+ }
+
+ await execAsync(`adb -s ${serial} shell input keyevent 82`).catch(() => {
+ /* screen may already be unlocked */
+ });
+
+ await stabilizeAndroidEmulatorAfterBoot(serial);
+}
+
+/**
+ * Check if any Android emulator is currently running and fully booted.
+ */
+export async function isAndroidEmulatorRunning(): Promise {
+ try {
+ const devices = await listAdbDevices();
+ return devices.some((device) => device.state === 'device');
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Start the Android emulator and wait for it to fully boot.
+ * If the requested AVD is already running and booted, this is a no-op.
+ * @returns adb serial for the booted emulator (e.g. emulator-5554)
+ */
+export async function startAndroidEmulator(avdName: string): Promise {
+ const bootedSerial = await findEmulatorSerialForAvd(avdName, ['device']);
+ if (bootedSerial) {
+ logger.info(
+ `Android emulator "${avdName}" (${bootedSerial}) is already running — skipping boot.`,
+ );
+ return bootedSerial;
+ }
+
+ const startingSerial = await findEmulatorSerialForAvd(avdName, [
+ 'offline',
+ 'authorizing',
+ ]);
+ if (startingSerial) {
+ logger.info(
+ `Android emulator "${avdName}" (${startingSerial}) is starting — waiting for boot.`,
+ );
+ await waitForEmulatorBoot(startingSerial);
+ return startingSerial;
+ }
+
+ const devices = await listAdbDevices();
+ const offlineEmulator = devices.find(
+ (device) => device.state === 'offline' || device.state === 'authorizing',
+ );
+ if (offlineEmulator) {
+ const offlineAvdName = await getEmulatorAvdName(offlineEmulator.serial);
+ if (shouldWaitForOfflineEmulator(avdName, offlineAvdName)) {
+ logger.info(
+ `Waiting for Android emulator ${offlineEmulator.serial} (${offlineAvdName}) to finish booting instead of spawning a duplicate.`,
+ );
+ await waitForEmulatorBoot(offlineEmulator.serial);
+ return offlineEmulator.serial;
+ }
+ if (offlineAvdName) {
+ logger.info(
+ `Ignoring offline emulator ${offlineEmulator.serial} (AVD "${offlineAvdName}") — requested "${avdName}".`,
+ );
+ } else {
+ logger.info(
+ `Ignoring offline emulator ${offlineEmulator.serial} (AVD unknown) — requested "${avdName}".`,
+ );
+ }
+ }
+
+ const androidHome = process.env.ANDROID_HOME;
+ if (!androidHome) {
+ throw new Error(
+ 'ANDROID_HOME is not set. Please set the ANDROID_HOME environment variable.',
+ );
+ }
+
+ const emulatorBin = path.join(androidHome, 'emulator', 'emulator');
+ const isCI = process.env.CI === 'true';
+
+ logger.info(`Starting Android emulator: ${avdName}`);
+
+ // Appium smoke CI uses AOSP (`default` image) — lighter cold boot than google_apis.
+ // RAM/CPU flags align with Detox CI where noted; cores overridable via env.
+ const args = ['-avd', avdName];
+ if (isCI) {
+ const cores =
+ process.env.ANDROID_EMULATOR_CI_CORES?.trim() ||
+ ANDROID_EMULATOR_CI_CORES_DEFAULT;
+ args.push(
+ '-skin',
+ '1080x2340',
+ '-memory',
+ '12288',
+ '-cores',
+ cores,
+ '-gpu',
+ 'swiftshader_indirect',
+ '-no-audio',
+ '-no-boot-anim',
+ '-partition-size',
+ '8192',
+ '-no-snapshot-save',
+ '-no-snapshot-load',
+ '-cache-size',
+ '2048',
+ '-accel',
+ 'on',
+ '-wipe-data',
+ '-read-only',
+ '-no-window',
+ );
+ } else {
+ args.push('-no-snapshot-load');
+ }
+
+ const emulatorProcess = spawn(emulatorBin, args, {
+ stdio: 'ignore',
+ detached: true,
+ });
+ emulatorProcess.unref();
+
+ logger.info('Waiting for Android emulator to appear in adb...');
+ const deadline = Date.now() + ANDROID_BOOT_TIMEOUT_MS;
+ let serial: string | undefined;
+
+ while (Date.now() < deadline) {
+ serial = await findEmulatorSerialForAvd(avdName, [
+ 'offline',
+ 'authorizing',
+ 'device',
+ ]);
+ if (serial) {
+ break;
+ }
+ await sleep(ANDROID_BOOT_POLL_INTERVAL_MS);
+ }
+
+ if (!serial) {
+ throw new Error(
+ `Android emulator for AVD "${avdName}" did not appear in adb within ${ANDROID_BOOT_TIMEOUT_MS / 1000}s.`,
+ );
+ }
+
+ await waitForEmulatorBoot(serial);
+ logger.info(`Android emulator "${avdName}" is booted and ready (${serial}).`);
+ return serial;
+}
+
+/**
+ * Stop the running Android emulator gracefully.
+ */
+export async function stopAndroidEmulator(serial?: string): Promise {
+ logger.info('Stopping Android emulator...');
+ try {
+ if (serial) {
+ await execAsync(`adb -s ${serial} emu kill`);
+ } else {
+ await execAsync('adb emu kill');
+ }
+ logger.info('Android emulator stopped.');
+ } catch (error) {
+ logger.warn(`Could not stop Android emulator: ${error}`);
+ }
+}
+
/**
* Check if emulator is installed for the given platform
* Not in use for now
@@ -46,40 +558,125 @@ export function isEmulatorInstalled(platform: Platform): Promise {
}
});
} else {
- // iOS simulators - to be implemented
resolve(true);
}
});
}
/**
- * Start Android emulator - to be implemented
+ * Per-process cache: deviceName → resolved UDID.
*/
-export function startAndroidEmulator(deviceName: string): void {
+const iosSimulatorUdidCache = new Map();
+
+export async function getIosSimulatorUdid(deviceName: string): Promise {
+ const cached = iosSimulatorUdidCache.get(deviceName);
+ if (cached && (await isIosSimulatorBooted(cached))) {
+ return cached;
+ }
+ if (cached) {
+ iosSimulatorUdidCache.delete(deviceName);
+ }
+
+ const { stdout } = await execAsync('xcrun simctl list devices available -j');
+ const list = JSON.parse(stdout) as {
+ devices: Record;
+ };
+
+ let firstMatch: string | undefined;
+
+ for (const devices of Object.values(list.devices)) {
+ for (const d of devices) {
+ if (d.name !== deviceName) continue;
+ if (d.state === 'Booted') {
+ iosSimulatorUdidCache.set(deviceName, d.udid);
+ return d.udid;
+ }
+ firstMatch ??= d.udid;
+ }
+ }
+
+ if (firstMatch) {
+ // Do not cache shutdown simulators — callers may boot a different UDID next.
+ return firstMatch;
+ }
+
throw new Error(
- `Starting Android emulator ${deviceName} - Not implemented yet`,
+ `iOS simulator "${deviceName}" not found in available devices. ` +
+ 'Run `xcrun simctl list devices available` to see available simulators.',
);
}
-/**
- * Stop Android emulator - to be implemented
- */
-export function stopAndroidEmulator(deviceName: string): void {
- throw new Error(
- `Stopping Android emulator ${deviceName} - Not implemented yet`,
+export async function isIosSimulatorBooted(udid: string): Promise {
+ try {
+ const { stdout } = await execAsync('xcrun simctl list devices -j');
+ const list = JSON.parse(stdout) as {
+ devices: Record;
+ };
+ for (const devices of Object.values(list.devices)) {
+ const sim = devices.find((d) => d.udid === udid);
+ if (sim) {
+ return sim.state === 'Booted';
+ }
+ }
+ } catch {
+ return false;
+ }
+ return false;
+}
+
+export async function bootIosSimulatorByUdid(udid: string): Promise {
+ if (await isIosSimulatorBooted(udid)) {
+ logger.info(`iOS simulator ${udid} is already booted — skipping boot.`);
+ return udid;
+ }
+
+ logger.info(`Booting iOS simulator: ${udid}`);
+
+ await execAsync(`xcrun simctl boot "${udid}"`).catch(
+ (err: { code?: number }) => {
+ if (err.code !== 149) {
+ throw err;
+ }
+ },
);
+
+ await execAsync(`xcrun simctl bootstatus "${udid}" -b`);
+
+ logger.info(`iOS simulator ${udid} is booted and ready.`);
+ return udid;
}
-/**
- * Start iOS simulator - to be implemented
- */
-export function startIosSimulator(deviceName: string): void {
- throw new Error(`Starting iOS simulator ${deviceName} - Not implemented yet`);
+export async function startIosSimulator(deviceName: string): Promise {
+ const udid = await getIosSimulatorUdid(deviceName);
+ const bootedUdid = await bootIosSimulatorByUdid(udid);
+ iosSimulatorUdidCache.set(deviceName, bootedUdid);
+ return bootedUdid;
}
/**
- * Stop iOS simulator - to be implemented
+ * Ensures an iOS simulator is booted before Appium session creation.
+ * Prefers `preferredUdid` or `IOS_SIMULATOR_UDID` (set by CI prepare step)
+ * so tests attach to the same sim that received the app install.
*/
-export function stopIosSimulator(deviceName: string): void {
- throw new Error(`Stopping iOS simulator ${deviceName} - Not implemented yet`);
+export async function ensureIosSimulatorReady(
+ deviceName: string,
+ preferredUdid?: string,
+): Promise {
+ const udid = preferredUdid?.trim() || process.env.IOS_SIMULATOR_UDID?.trim();
+ if (udid) {
+ const bootedUdid = await bootIosSimulatorByUdid(udid);
+ iosSimulatorUdidCache.set(deviceName, bootedUdid);
+ return bootedUdid;
+ }
+ return startIosSimulator(deviceName);
+}
+
+export async function stopIosSimulator(udid: string): Promise {
+ logger.info(`Shutting down iOS simulator: ${udid}`);
+ try {
+ await execAsync(`xcrun simctl shutdown "${udid}"`);
+ logger.info(`iOS simulator ${udid} shut down.`);
+ } catch (error) {
+ logger.warn(`Could not stop iOS simulator: ${error}`);
+ }
}
diff --git a/tests/framework/services/appium/ScreenRecording.test.ts b/tests/framework/services/appium/ScreenRecording.test.ts
new file mode 100644
index 000000000000..9f268a494849
--- /dev/null
+++ b/tests/framework/services/appium/ScreenRecording.test.ts
@@ -0,0 +1,156 @@
+import {
+ buildRecordingFileBaseName,
+ extractRecordingPayload,
+ isAndroidPlatform,
+ isLocalEmulatorProvider,
+ isVideoRecordingOnFailureEnabled,
+ sanitizeRecordingFileName,
+ shouldPersistRecordingAlways,
+} from './ScreenRecording.ts';
+import { ProviderName } from '../../types.ts';
+
+describe('ScreenRecording', () => {
+ const recordVideoKey = 'APPIUM_RECORD_VIDEO_ON_FAILURE';
+ const recordVideoAlwaysKey = 'APPIUM_RECORD_VIDEO_ALWAYS';
+ const ciKey = 'CI';
+ let previousRecordVideo: string | undefined;
+ let previousRecordVideoAlways: string | undefined;
+ let previousCi: string | undefined;
+
+ beforeEach(() => {
+ previousRecordVideo = process.env[recordVideoKey];
+ previousRecordVideoAlways = process.env[recordVideoAlwaysKey];
+ previousCi = process.env[ciKey];
+ delete process.env[recordVideoKey];
+ delete process.env[recordVideoAlwaysKey];
+ delete process.env[ciKey];
+ });
+
+ afterEach(() => {
+ if (previousRecordVideo === undefined) {
+ delete process.env[recordVideoKey];
+ } else {
+ process.env[recordVideoKey] = previousRecordVideo;
+ }
+ if (previousRecordVideoAlways === undefined) {
+ delete process.env[recordVideoAlwaysKey];
+ } else {
+ process.env[recordVideoAlwaysKey] = previousRecordVideoAlways;
+ }
+ if (previousCi === undefined) {
+ delete process.env[ciKey];
+ } else {
+ process.env[ciKey] = previousCi;
+ }
+ });
+
+ describe('isVideoRecordingOnFailureEnabled', () => {
+ it('returns false for BrowserStack', () => {
+ process.env[ciKey] = 'true';
+ expect(isVideoRecordingOnFailureEnabled(ProviderName.BROWSERSTACK)).toBe(
+ false,
+ );
+ });
+
+ it('returns true on CI for local emulator by default', () => {
+ process.env[ciKey] = 'true';
+ expect(isVideoRecordingOnFailureEnabled(ProviderName.EMULATOR)).toBe(
+ true,
+ );
+ });
+
+ it('returns false on CI when explicitly disabled', () => {
+ process.env[ciKey] = 'true';
+ process.env[recordVideoKey] = 'false';
+ expect(isVideoRecordingOnFailureEnabled(ProviderName.SIMULATOR)).toBe(
+ false,
+ );
+ });
+
+ it('returns false locally unless explicitly enabled', () => {
+ expect(isVideoRecordingOnFailureEnabled(ProviderName.EMULATOR)).toBe(
+ false,
+ );
+ process.env[recordVideoKey] = 'true';
+ expect(isVideoRecordingOnFailureEnabled(ProviderName.EMULATOR)).toBe(
+ true,
+ );
+ });
+ });
+
+ describe('isLocalEmulatorProvider', () => {
+ it('accepts emulator and simulator providers', () => {
+ expect(isLocalEmulatorProvider(ProviderName.EMULATOR)).toBe(true);
+ expect(isLocalEmulatorProvider(ProviderName.SIMULATOR)).toBe(true);
+ expect(isLocalEmulatorProvider(ProviderName.BROWSERSTACK)).toBe(false);
+ });
+ });
+
+ describe('sanitizeRecordingFileName', () => {
+ it('replaces unsafe characters', () => {
+ expect(sanitizeRecordingFileName('should login')).toBe('should_login');
+ });
+ });
+
+ describe('buildRecordingFileBaseName', () => {
+ it('includes describe block and test title in the file name', () => {
+ expect(
+ buildRecordingFileBaseName({
+ projectName: 'android-smoke',
+ titlePath: ['SmokeAccounts: Login to app', 'logs in successfully'],
+ }),
+ ).toBe('android-smoke-SmokeAccounts_Login_to_app__logs_in_successfully');
+ });
+
+ it('adds a retry suffix when the test is retried', () => {
+ expect(
+ buildRecordingFileBaseName({
+ projectName: 'ios-smoke',
+ titlePath: ['SmokeAccounts: Login to app', 'logs in successfully'],
+ retry: 1,
+ }),
+ ).toBe(
+ 'ios-smoke-SmokeAccounts_Login_to_app__logs_in_successfully-retry1',
+ );
+ });
+ });
+
+ describe('shouldPersistRecordingAlways', () => {
+ it('returns false by default', () => {
+ expect(shouldPersistRecordingAlways()).toBe(false);
+ });
+
+ it('returns true when APPIUM_RECORD_VIDEO_ALWAYS is true', () => {
+ process.env[recordVideoAlwaysKey] = 'true';
+ expect(shouldPersistRecordingAlways()).toBe(true);
+ });
+ });
+
+ describe('isAndroidPlatform', () => {
+ it('detects Android platform names case-insensitively', () => {
+ expect(isAndroidPlatform('Android')).toBe(true);
+ expect(isAndroidPlatform('android')).toBe(true);
+ expect(isAndroidPlatform('iOS')).toBe(false);
+ expect(isAndroidPlatform(undefined)).toBe(false);
+ });
+ });
+
+ describe('extractRecordingPayload', () => {
+ it('reads a raw base64 string', () => {
+ expect(extractRecordingPayload('abc123')).toBe('abc123');
+ });
+
+ it('ignores empty strings', () => {
+ expect(extractRecordingPayload('')).toBeUndefined();
+ });
+
+ it('reads payload from object wrappers', () => {
+ expect(extractRecordingPayload({ payload: 'video-data' })).toBe(
+ 'video-data',
+ );
+ expect(extractRecordingPayload({ media: 'video-data' })).toBe(
+ 'video-data',
+ );
+ });
+ });
+});
diff --git a/tests/framework/services/appium/ScreenRecording.ts b/tests/framework/services/appium/ScreenRecording.ts
new file mode 100644
index 000000000000..1947f8184b9c
--- /dev/null
+++ b/tests/framework/services/appium/ScreenRecording.ts
@@ -0,0 +1,403 @@
+/* eslint-disable import-x/no-nodejs-modules */
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import type { TestInfo } from '@playwright/test';
+import { Platform, type ProviderName } from '../../types.ts';
+import { createLogger } from '../../logger.ts';
+
+const logger = createLogger({ name: 'ScreenRecording' });
+
+const RECORD_VIDEO_ON_FAILURE_ENV_KEY = 'APPIUM_RECORD_VIDEO_ON_FAILURE';
+const RECORD_VIDEO_ALWAYS_ENV_KEY = 'APPIUM_RECORD_VIDEO_ALWAYS';
+const RECORD_VIDEO_TIME_LIMIT_ENV_KEY = 'APPIUM_RECORD_VIDEO_TIME_LIMIT_SEC';
+const CI_ENV_KEY = 'CI';
+
+/** Playwright Appium smoke tests are invoked from the repo root (`yarn appium-smoke:*`). */
+export const APPIUM_SMOKE_VIDEOS_DIR = join(
+ process.cwd(),
+ 'tests/test-reports/appium-smoke-videos',
+ process.env.APPIUM_SMOKE_SUITE_NAME?.trim() ?? '',
+);
+
+const DEFAULT_TIME_LIMIT_SEC = 600;
+
+/** Which Appium recording API was started for the active session. */
+export type ScreenRecordingBackend =
+ | 'ios'
+ | 'android-screenrecord'
+ | 'android-media-projection';
+
+/**
+ * Local emulator/simulator runs only — BrowserStack records server-side.
+ */
+export function isLocalEmulatorProvider(
+ provider: ProviderName | undefined,
+): boolean {
+ return provider === 'emulator' || provider === 'simulator';
+}
+
+/**
+ * - `APPIUM_RECORD_VIDEO_ON_FAILURE=false` — disabled
+ * - unset / `true` — enabled on CI; enabled locally only when explicitly `true`
+ */
+export function isVideoRecordingOnFailureEnabled(
+ provider: ProviderName | undefined,
+): boolean {
+ if (!isLocalEmulatorProvider(provider)) {
+ return false;
+ }
+
+ const raw =
+ process.env[RECORD_VIDEO_ON_FAILURE_ENV_KEY]?.trim().toLowerCase();
+ if (raw === 'false' || raw === '0' || raw === 'no') {
+ return false;
+ }
+ if (raw === 'true' || raw === '1' || raw === 'yes') {
+ return true;
+ }
+
+ return process.env[CI_ENV_KEY] === 'true';
+}
+
+export function sanitizeRecordingFileName(title: string): string {
+ return title
+ .replace(/[^\w.-]+/g, '_')
+ .replace(/_+/g, '_')
+ .replace(/^_|_$/g, '')
+ .slice(0, 120);
+}
+
+/** @internal exported for unit tests */
+export function buildRecordingFileBaseName(options: {
+ projectName: string;
+ titlePath: string[];
+ retry?: number;
+}): string {
+ const scenarioName = options.titlePath
+ .map((segment) => sanitizeRecordingFileName(segment))
+ .filter(Boolean)
+ .join('__');
+
+ const retrySuffix =
+ options.retry && options.retry > 0 ? `-retry${options.retry}` : '';
+
+ if (!scenarioName) {
+ return `${options.projectName}-unknown-test${retrySuffix}`;
+ }
+
+ return `${options.projectName}-${scenarioName}${retrySuffix}`;
+}
+
+function buildRecordingBaseName(testInfo: TestInfo): string {
+ return buildRecordingFileBaseName({
+ projectName: testInfo.project.name,
+ titlePath: testInfo.titlePath,
+ retry: testInfo.retry,
+ });
+}
+
+/** @internal exported for unit tests */
+export function extractRecordingPayload(result: unknown): string | undefined {
+ if (typeof result === 'string' && result.length > 0) {
+ return result;
+ }
+ if (result && typeof result === 'object') {
+ const record = result as Record;
+ for (const key of ['payload', 'value', 'video', 'media'] as const) {
+ const candidate = record[key];
+ if (typeof candidate === 'string' && candidate.length > 0) {
+ return candidate;
+ }
+ }
+ }
+ return undefined;
+}
+
+function recordingTimeLimitSec(): number {
+ const raw = process.env[RECORD_VIDEO_TIME_LIMIT_ENV_KEY];
+ if (!raw) {
+ return DEFAULT_TIME_LIMIT_SEC;
+ }
+ const parsed = Number.parseInt(raw, 10);
+ return Number.isFinite(parsed) && parsed > 0
+ ? parsed
+ : DEFAULT_TIME_LIMIT_SEC;
+}
+
+/** @internal exported for unit tests */
+export function isAndroidPlatform(platformName: string | undefined): boolean {
+ return platformName?.trim().toLowerCase() === 'android';
+}
+
+function resolveIsAndroid(
+ browser: WebdriverIO.Browser,
+ platform?: Platform,
+): boolean {
+ if (platform === Platform.ANDROID) {
+ return true;
+ }
+ if (platform === Platform.IOS) {
+ return false;
+ }
+ if (browser.isAndroid) {
+ return true;
+ }
+ if (browser.isIOS) {
+ return false;
+ }
+ return isAndroidPlatform(resolvePlatformName(browser));
+}
+
+function resolvePlatformName(browser: WebdriverIO.Browser): string | undefined {
+ const capabilities = browser.capabilities as Record;
+ const raw = capabilities.platformName ?? capabilities['appium:platformName'];
+ return typeof raw === 'string' ? raw : undefined;
+}
+
+function formatRecordingError(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+function logRecordingIssue(message: string): void {
+ logger.warn(message);
+ // Visible in CI step logs without logger level tuning.
+ console.warn(`[ScreenRecording] ${message}`);
+}
+
+type AppiumScreenRecorder = WebdriverIO.Browser & {
+ startRecordingScreen?: (
+ options?: Record,
+ ) => Promise;
+ stopRecordingScreen?: (options?: Record) => Promise;
+};
+
+async function startAndroidScreenRecord(
+ browser: WebdriverIO.Browser,
+): Promise {
+ const appiumDriver = browser as AppiumScreenRecorder;
+ if (typeof appiumDriver.startRecordingScreen === 'function') {
+ try {
+ await appiumDriver.startRecordingScreen({
+ timeLimit: String(recordingTimeLimitSec()),
+ });
+ logRecordingIssue('Android adb screenrecord started');
+ return 'android-screenrecord';
+ } catch (error) {
+ logRecordingIssue(
+ `adb screenrecord failed, trying media projection: ${formatRecordingError(error)}`,
+ );
+ }
+ }
+
+ try {
+ const started = await browser.execute(
+ 'mobile: startMediaProjectionRecording',
+ {
+ maxDurationSec: recordingTimeLimitSec(),
+ resolution: '1280x720',
+ },
+ );
+ logRecordingIssue(
+ started === false
+ ? 'Android media projection already running'
+ : 'Android media projection recording started',
+ );
+ return 'android-media-projection';
+ } catch (error) {
+ logRecordingIssue(
+ `Could not start Android recording: ${formatRecordingError(error)}`,
+ );
+ return undefined;
+ }
+}
+
+async function stopAndroidRecording(
+ browser: WebdriverIO.Browser,
+ backend: ScreenRecordingBackend,
+): Promise {
+ const appiumDriver = browser as AppiumScreenRecorder;
+ const attempts: ScreenRecordingBackend[] =
+ backend === 'android-screenrecord'
+ ? ['android-screenrecord', 'android-media-projection']
+ : ['android-media-projection', 'android-screenrecord'];
+
+ let lastError: unknown;
+ for (const attempt of attempts) {
+ try {
+ const result =
+ attempt === 'android-screenrecord' &&
+ typeof appiumDriver.stopRecordingScreen === 'function'
+ ? await appiumDriver.stopRecordingScreen()
+ : await browser.execute('mobile: stopMediaProjectionRecording');
+ const payload = extractRecordingPayload(result);
+ if (payload) {
+ return payload;
+ }
+ } catch (error) {
+ lastError = error;
+ }
+ }
+
+ if (lastError) {
+ throw lastError;
+ }
+ return undefined;
+}
+
+async function startIosRecording(
+ browser: WebdriverIO.Browser,
+): Promise {
+ const appiumDriver = browser as AppiumScreenRecorder;
+ if (typeof appiumDriver.startRecordingScreen !== 'function') {
+ logRecordingIssue(
+ 'startRecordingScreen is not available on this WebDriver session',
+ );
+ return undefined;
+ }
+
+ try {
+ await appiumDriver.startRecordingScreen({
+ timeLimit: String(recordingTimeLimitSec()),
+ videoQuality: 'medium',
+ });
+ logRecordingIssue('iOS screen recording started');
+ return 'ios';
+ } catch (error) {
+ logRecordingIssue(
+ `Could not start iOS recording: ${formatRecordingError(error)}`,
+ );
+ return undefined;
+ }
+}
+
+async function stopIosRecording(
+ browser: WebdriverIO.Browser,
+): Promise {
+ const appiumDriver = browser as AppiumScreenRecorder;
+ if (typeof appiumDriver.stopRecordingScreen !== 'function') {
+ throw new Error(
+ 'stopRecordingScreen is not available on this WebDriver session',
+ );
+ }
+
+ const result = await appiumDriver.stopRecordingScreen();
+ return extractRecordingPayload(result);
+}
+
+/**
+ * Starts Appium device screen recording for the active session.
+ * Android tries adb screenrecord first (reliable on emulators), then media projection.
+ */
+export async function startFailureRecording(
+ browser: WebdriverIO.Browser,
+ platform?: Platform,
+): Promise {
+ try {
+ if (resolveIsAndroid(browser, platform)) {
+ return await startAndroidScreenRecord(browser);
+ }
+
+ return await startIosRecording(browser);
+ } catch (error) {
+ logRecordingIssue(
+ `Could not start screen recording: ${formatRecordingError(error)}`,
+ );
+ return undefined;
+ }
+}
+
+/**
+ * Optional override: set APPIUM_RECORD_VIDEO_ALWAYS=true to persist every recording
+ * (useful for local debugging). CI defaults to failure-only via shouldPersistRecording.
+ */
+export function shouldPersistRecordingAlways(): boolean {
+ const raw = process.env[RECORD_VIDEO_ALWAYS_ENV_KEY]?.trim().toLowerCase();
+ return raw === 'true' || raw === '1' || raw === 'yes';
+}
+
+function shouldPersistRecording(testInfo: TestInfo): boolean {
+ if (shouldPersistRecordingAlways()) {
+ return true;
+ }
+
+ return (
+ testInfo.status === 'failed' ||
+ testInfo.status === 'timedOut' ||
+ testInfo.status === 'interrupted'
+ );
+}
+
+/**
+ * Stops recording. On failure, attaches MP4 to the Playwright report and writes
+ * a copy under {@link APPIUM_SMOKE_VIDEOS_DIR} for CI artifact upload.
+ * Always attempts stop when recording was started (even on pass) to free resources.
+ */
+export async function stopFailureRecordingAndAttach(
+ browser: WebdriverIO.Browser,
+ testInfo: TestInfo,
+ recordingBackend: ScreenRecordingBackend | undefined,
+ platform?: Platform,
+): Promise {
+ if (!recordingBackend) {
+ return;
+ }
+
+ let payload: string | undefined;
+ try {
+ if (
+ recordingBackend === 'android-screenrecord' ||
+ recordingBackend === 'android-media-projection' ||
+ resolveIsAndroid(browser, platform)
+ ) {
+ payload = await stopAndroidRecording(browser, recordingBackend);
+ } else {
+ payload = await stopIosRecording(browser);
+ }
+ } catch (error) {
+ logRecordingIssue(
+ `Could not stop screen recording: ${formatRecordingError(error)}`,
+ );
+ return;
+ }
+
+ if (!payload) {
+ logRecordingIssue(
+ `Screen recording stopped but returned no payload (status=${testInfo.status ?? 'unknown'})`,
+ );
+ return;
+ }
+
+ if (!shouldPersistRecording(testInfo)) {
+ logger.debug(
+ `Discarding screen recording (status=${testInfo.status ?? 'unknown'})`,
+ );
+ return;
+ }
+
+ const videoBuffer = Buffer.from(payload, 'base64');
+ const fileName = `${buildRecordingBaseName(testInfo)}.mp4`;
+ const filePath = join(APPIUM_SMOKE_VIDEOS_DIR, fileName);
+
+ try {
+ mkdirSync(APPIUM_SMOKE_VIDEOS_DIR, { recursive: true });
+ writeFileSync(filePath, videoBuffer);
+ logRecordingIssue(
+ `Saved recording (${videoBuffer.length} bytes): ${filePath}`,
+ );
+ } catch (error) {
+ logRecordingIssue(
+ `Could not write recording to disk: ${formatRecordingError(error)}`,
+ );
+ }
+
+ try {
+ await testInfo.attach(buildRecordingBaseName(testInfo), {
+ body: videoBuffer,
+ contentType: 'video/mp4',
+ });
+ } catch (error) {
+ logRecordingIssue(
+ `Could not attach recording to Playwright report: ${formatRecordingError(error)}`,
+ );
+ }
+}
diff --git a/tests/framework/services/appium/index.ts b/tests/framework/services/appium/index.ts
index 2194c09e6bd3..33ca9ce6912e 100644
--- a/tests/framework/services/appium/index.ts
+++ b/tests/framework/services/appium/index.ts
@@ -1 +1,15 @@
-export { startAppiumServer, stopAppiumServer } from './AppiumServer.ts';
+export {
+ getAppiumHost,
+ getAppiumPort,
+ getAppiumServerUrl,
+ isAppiumServerRunning,
+ shouldSkipAppiumStop,
+ startAppiumServer,
+ stopAppiumServer,
+} from './AppiumServer.ts';
+export {
+ APPIUM_SMOKE_VIDEOS_DIR,
+ isVideoRecordingOnFailureEnabled,
+ startFailureRecording,
+ stopFailureRecordingAndAttach,
+} from './ScreenRecording.ts';
diff --git a/tests/framework/services/device-commands/DeviceCommandHandler.test.ts b/tests/framework/services/device-commands/DeviceCommandHandler.test.ts
index 64b05ee8e7c4..83f278b54bfb 100644
--- a/tests/framework/services/device-commands/DeviceCommandHandler.test.ts
+++ b/tests/framework/services/device-commands/DeviceCommandHandler.test.ts
@@ -375,7 +375,7 @@ describe('DeviceCommandHandler', () => {
new DeviceCommandHandler({
currentDeviceDetails: iosDevice({ deviceName: '' }),
}).installApp({ buildPath: '/tmp/MetaMask.app' }),
- ).rejects.toThrow('currentDeviceDetails.deviceName');
+ ).rejects.toThrow('currentDeviceDetails.udid or deviceName');
await expect(
new DeviceCommandHandler({
diff --git a/tests/framework/services/device-commands/IOSDeviceCommandHandler.ts b/tests/framework/services/device-commands/IOSDeviceCommandHandler.ts
index c70744b50b5a..488458811b6f 100644
--- a/tests/framework/services/device-commands/IOSDeviceCommandHandler.ts
+++ b/tests/framework/services/device-commands/IOSDeviceCommandHandler.ts
@@ -189,13 +189,20 @@ export class IOSDeviceCommandHandler implements PlatformDeviceCommandHandler {
}
/**
- * Resolves the simulator name or UDID from current device details.
+ * Resolves the simulator identifier for simctl: prefers the UDID (set at
+ * fixture time by resolving the booted simulator) over the display name.
+ * Using a UDID avoids ambiguity when multiple simulators share the same name
+ * across iOS runtime versions.
*/
private resolveSimDevice(): string {
+ const udid = this.options.currentDeviceDetails.udid?.trim();
+ if (udid) {
+ return udid;
+ }
const deviceName = this.options.currentDeviceDetails.deviceName?.trim();
if (!deviceName) {
throw new Error(
- 'iOS device commands require currentDeviceDetails.deviceName (simctl device name or UDID).',
+ 'iOS device commands require currentDeviceDetails.udid or deviceName (simctl device name or UDID).',
);
}
return deviceName;
diff --git a/tests/framework/services/providers/emulator/EmulatorConfigBuilder.ts b/tests/framework/services/providers/emulator/EmulatorConfigBuilder.ts
index 5b9bd94a89a3..6fa6da32d871 100644
--- a/tests/framework/services/providers/emulator/EmulatorConfigBuilder.ts
+++ b/tests/framework/services/providers/emulator/EmulatorConfigBuilder.ts
@@ -1,5 +1,6 @@
import { Platform, type EmulatorConfig } from '../../../types.ts';
import type { ProjectConfig } from '../../common/types.ts';
+import { getAppiumHost, getAppiumPort } from '../../appium/AppiumServer.ts';
/**
* Builder for Emulator WebDriver configuration (local Android/iOS).
@@ -38,10 +39,41 @@ export class EmulatorConfigBuilder {
// - No `buildPath`: omit `appium:app` and target the existing install via
// bundleId / package+activity. Install presence is enforced in
// `EmulatorProvider.globalSetup()`.
+ // - iOS CI: globalSetup already simctl-installs from `buildPath`; set
+ // IOS_APPIUM_USE_BUNDLE_ID_ONLY=true to skip a redundant Appium-side install.
+ // - Android CI: globalSetup already adb-installs; set
+ // ANDROID_APPIUM_USE_PACKAGE_ONLY=true to avoid a second install at session start.
const hasLocalApp = Boolean(buildPath);
+ const skipIosAppiumAppInstall =
+ platformName === Platform.IOS &&
+ process.env.IOS_APPIUM_USE_BUNDLE_ID_ONLY === 'true';
+ const skipAndroidAppiumAppInstall =
+ platformName === Platform.ANDROID &&
+ process.env.ANDROID_APPIUM_USE_PACKAGE_ONLY === 'true';
+ const usePreinstalledWda =
+ platformName === Platform.IOS &&
+ process.env.IOS_WDA_PREINSTALLED === 'true';
+ const usePrebuiltWda =
+ platformName === Platform.IOS &&
+ process.env.USE_PREBUILT_WDA === 'true' &&
+ !usePreinstalledWda;
return {
- port: 4723,
+ hostname: getAppiumHost(),
+ port: getAppiumPort(),
+ // XCUITest driver must build and install WDA on first run (3-4 min on
+ // local, up to 10 min on CI). Raise the WebDriverIO HTTP timeout so the
+ // session-creation POST doesn't time out before Appium responds.
+ // connectionRetryCount: 0 — no retries on session creation; a timeout
+ // here is not a transient error and retrying just doubles the wait.
+ // Preinstalled WDA: prepare-ios-appium-runner already launched WDA on CI.
+ // Prebuilt/cold paths still need minutes for xcodebuild or first launch.
+ connectionRetryTimeout: usePreinstalledWda
+ ? 90 * 1000
+ : usePrebuiltWda
+ ? 5 * 60 * 1000
+ : 12 * 60 * 1000,
+ connectionRetryCount: 0,
capabilities: {
'appium:deviceName': emulatorDevice.name,
'appium:udid': emulatorDevice.udid,
@@ -52,6 +84,9 @@ export class EmulatorConfigBuilder {
? {
'appium:appPackage': this.project.use.app?.packageName,
'appium:appActivity': this.project.use.app?.launchableActivity,
+ // Release E2E launches with many intent extras; default 20s adbExecTimeout
+ // is too low on CI after a prior test (see appium-accounts-android-smoke).
+ 'appium:adbExecTimeout': 120_000,
}
: {
'appium:bundleId': this.project.use.app?.appId,
@@ -59,7 +94,11 @@ export class EmulatorConfigBuilder {
platformName,
'appium:newCommandTimeout': 300,
'appium:deviceOrientation': emulatorDevice.orientation,
- ...(hasLocalApp ? { 'appium:app': buildPath } : {}),
+ ...(hasLocalApp &&
+ !skipIosAppiumAppInstall &&
+ !skipAndroidAppiumAppInstall
+ ? { 'appium:app': buildPath }
+ : {}),
'appium:autoGrantPermissions': true,
'appium:autoAcceptAlerts': true,
'appium:fullReset': false,
@@ -69,7 +108,55 @@ export class EmulatorConfigBuilder {
'appium:animationCoolOffTimeout': 0, // Skip animation wait
'appium:reduceMotion': true, // Reduce iOS animations
'appium:waitForIdleTimeout': 0, // Don't wait for idle
- 'appium:wdaLaunchTimeout': 300_000,
+ ...(usePreinstalledWda
+ ? {
+ // WDA was simctl-installed in prepare-ios-appium-runner; launch only.
+ 'appium:usePreinstalledWDA': true,
+ 'appium:updatedWDABundleId':
+ process.env.IOS_WDA_BUNDLE_ID?.trim() ||
+ 'com.facebook.WebDriverAgentRunner',
+ 'appium:wdaLaunchTimeout': 60_000,
+ 'appium:wdaConnectionTimeout': 10_000,
+ 'appium:simulatorStartupTimeout': 120_000,
+ }
+ : usePrebuiltWda
+ ? {
+ // Prebuilt WDA on CI: xcodebuild test-without-building (~minutes).
+ 'appium:wdaLaunchTimeout': 60_000,
+ 'appium:wdaConnectionTimeout': 10_000,
+ // Sim is booted in getDriver(); this covers XCUITest attach on loaded CI hosts.
+ 'appium:simulatorStartupTimeout': 180_000,
+ }
+ : {
+ // Cold WDA build (local dev / cache miss): allow up to 10 min.
+ 'appium:wdaLaunchTimeout': 10 * 60_000,
+ 'appium:simulatorStartupTimeout': 10 * 60_000,
+ }),
+ // Pin WDA's DerivedData to a fixed path so CI can cache and restore it.
+ // When USE_PREBUILT_WDA=true (set by CI after prebuild/cache hit), xcuitest-driver
+ // skips xcodebuild entirely and installs+launches the cached WDA binary
+ // directly — cutting ~8 min off CI per run. Without it, xcodebuild runs
+ // even when DerivedData is present because actions/cache restores files
+ // with current timestamps, causing a full rebuild.
+ ...(platformName === Platform.IOS
+ ? {
+ 'appium:derivedDataPath': `${process.env.HOME ?? '~'}/appium-wda`,
+ 'appium:skipLogCapture': true,
+ ...(usePreinstalledWda
+ ? {
+ 'appium:useNewWDA': false,
+ 'appium:showXcodeLog': false,
+ }
+ : usePrebuiltWda
+ ? {
+ 'appium:usePrebuiltWDA': true,
+ // Reuse a WDA instance already listening on the sim (retries / multi-test).
+ 'appium:useNewWDA': false,
+ 'appium:showXcodeLog': false,
+ }
+ : {}),
+ }
+ : {}),
'appium:includeSafariInWebviews': true,
'appium:settings[actionAcknowledgmentTimeout]': 3000,
'appium:settings[ignoreUnimportantViews]': true,
diff --git a/tests/framework/services/providers/emulator/EmulatorProvider.ts b/tests/framework/services/providers/emulator/EmulatorProvider.ts
index 2a1d25e56829..bd894b2d85f8 100644
--- a/tests/framework/services/providers/emulator/EmulatorProvider.ts
+++ b/tests/framework/services/providers/emulator/EmulatorProvider.ts
@@ -10,7 +10,15 @@ import {
applyResolvedAndroidAdbToDevice,
resolveAndroidAdbUdidForDevice,
} from './android/resolveAndroidAdbUdid';
-import { reinstallFromBuildPathForProject } from './reinstallLocalBuildFromPath';
+import {
+ reinstallFromBuildPathForProject,
+ shouldSkipAppReinstallFromEnv,
+} from './reinstallLocalBuildFromPath';
+import {
+ startAndroidEmulator,
+ ensureIosSimulatorReady,
+ getIosSimulatorUdid,
+} from '../../appium/EmulatorHelpers';
/**
* Service provider for local emulator/simulator testing
@@ -52,36 +60,48 @@ export class EmulatorProvider extends BaseServiceProvider {
});
}
+ private async resolveIosSimulatorUdid(): Promise {
+ const emulatorDevice = this.project.use.device as EmulatorConfig;
+ const deviceName = emulatorDevice?.name;
+ const configuredUdid =
+ emulatorDevice?.udid?.trim() || process.env.IOS_SIMULATOR_UDID?.trim();
+ if (configuredUdid) {
+ return configuredUdid;
+ }
+ if (!deviceName) {
+ return undefined;
+ }
+ return getIosSimulatorUdid(deviceName).catch(() => deviceName);
+ }
+
/**
* Check if the iOS app is installed on the device
* @returns True if the app is installed, false otherwise
*/
private async isIOSAppInstalled(): Promise {
const bundleId = this.project.use.app?.appId;
- const deviceName = this.project.use.device?.name;
- if (!deviceName || !bundleId) {
+ const simId = await this.resolveIosSimulatorUdid();
+ if (!simId || !bundleId) {
this.logger.error(
- 'No device name or bundle id specified in project config',
+ 'No simulator UDID or bundle id specified in project config',
);
return false;
}
// eslint-disable-next-line @typescript-eslint/no-require-imports
- const { exec } = require('child_process');
+ const { execFile } = require('child_process');
return new Promise((resolve) => {
- // List installed apps on the given simulator and look for the bundle id
- exec(
- `xcrun simctl get_app_container "${deviceName}" "${bundleId}"`,
+ execFile(
+ 'xcrun',
+ ['simctl', 'get_app_container', simId, bundleId],
(error: Error | null, stdout: string) => {
if (error) {
- // If there's an error, assume app is not installed
this.logger.debug(
- `App with bundle id ${bundleId} NOT installed on simulator "${deviceName}". ${error.message}`,
+ `App with bundle id ${bundleId} NOT installed on simulator "${simId}". ${error.message}`,
);
resolve(false);
} else {
- // If stdout contains a path, the app is installed
this.logger.debug(
- `App with bundle id ${bundleId} IS installed on simulator "${deviceName}". App container: ${stdout.trim()}`,
+ `App with bundle id ${bundleId} IS installed on simulator "${simId}". App container: ${stdout.trim()}`,
);
resolve(true);
}
@@ -117,14 +137,60 @@ export class EmulatorProvider extends BaseServiceProvider {
}
/**
- * Global setup: validates local build artifact path, or that the app is
- * already installed when `buildPath` is unset. `SKIP_APP_REINSTALL` can skip
- * adb/simctl uninstall+install when `buildPath` is set. See
+ * Boot the configured device (emulator/simulator) if it is not already
+ * running. Controlled by the SKIP_DEVICE_BOOT env var:
+ *
+ * - SKIP_DEVICE_BOOT=true — skip booting (device must already be running)
+ * - SKIP_DEVICE_BOOT=false — boot if needed (default)
+ *
+ * Booting must happen before any app-install steps because those use
+ * adb / simctl which require a running device.
+ */
+ private async bootDevice(): Promise {
+ if (process.env.SKIP_DEVICE_BOOT === 'true') {
+ this.logger.info('SKIP_DEVICE_BOOT=true — skipping device boot.');
+ return;
+ }
+
+ if (this.project.use.platform === Platform.ANDROID) {
+ const avdName = (this.project.use.device as EmulatorConfig).name;
+ if (!avdName) {
+ throw new Error(
+ 'Android device boot requires `use.device.name` (AVD name) in the project config.',
+ );
+ }
+ const serial = await startAndroidEmulator(avdName);
+ (this.project.use.device as EmulatorConfig).udid = serial;
+ } else if (this.project.use.platform === Platform.IOS) {
+ const deviceName = this.project.use.device?.name;
+ if (!deviceName) {
+ throw new Error(
+ 'iOS device boot requires `use.device.name` (simulator name) in the project config.',
+ );
+ }
+ const udid = await ensureIosSimulatorReady(
+ deviceName,
+ (this.project.use.device as EmulatorConfig).udid,
+ );
+ // Persist the UDID onto the device config so Appium's XCUITest driver
+ // targets this exact simulator (not a fresh one with the same display name).
+ (this.project.use.device as EmulatorConfig).udid = udid;
+ }
+ }
+
+ /**
+ * Global setup: boots the device if needed, then validates the local build
+ * artifact path, or checks that the app is already installed when `buildPath`
+ * is unset. `SKIP_APP_REINSTALL` can skip adb/simctl uninstall+install when
+ * `buildPath` is set. See
* [PLAYWRIGHT_LOCAL_EMULATOR.md](../../../../docs/PLAYWRIGHT_LOCAL_EMULATOR.md).
*/
async globalSetup(): Promise {
await super.globalSetup?.();
+ // Boot the device first — adb/simctl calls below require a running device
+ await this.bootDevice();
+
if (this.project.use.app?.buildPath) {
this.logger.debug(
`Validating build path: ${this.project.use.app?.buildPath}`,
@@ -139,6 +205,20 @@ export class EmulatorProvider extends BaseServiceProvider {
buildPath,
this.logger,
);
+ if (
+ shouldSkipAppReinstallFromEnv() &&
+ this.project.use.platform === Platform.IOS
+ ) {
+ const isInstalled = await this.isIOSAppInstalled();
+ if (!isInstalled) {
+ throw new Error(
+ `SKIP_APP_REINSTALL is enabled but ${this.project.use.app?.appId} is not installed on the target simulator. The prepare step may have failed to install the .app.`,
+ );
+ }
+ this.logger.info(
+ 'Verified MetaMask is installed on the target iOS simulator.',
+ );
+ }
} else {
const isInstalled = await this.isAppInstalled();
if (!isInstalled) {
@@ -158,8 +238,9 @@ export class EmulatorProvider extends BaseServiceProvider {
async getDriver(): Promise {
this.logger.debug('Creating driver for local emulator');
+ const emulatorDevice = this.project.use.device as EmulatorConfig;
+
if (this.project.use.platform === Platform.ANDROID) {
- const emulatorDevice = this.project.use.device as EmulatorConfig;
if (!emulatorDevice.name && !emulatorDevice.udid) {
throw new Error(
'Android local emulator: set `use.device.name` (AVD name) or `use.device.udid` (e.g. emulator-5554).',
@@ -168,6 +249,22 @@ export class EmulatorProvider extends BaseServiceProvider {
await applyResolvedAndroidAdbToDevice(emulatorDevice, {
setAndroidSerialEnv: true,
});
+ } else if (this.project.use.platform === Platform.IOS) {
+ const deviceName = emulatorDevice.name;
+ if (!deviceName) {
+ throw new Error(
+ 'iOS local simulator: set `use.device.name` (simulator name) in the project config.',
+ );
+ }
+ // Boot (or re-boot) before Appium session creation. CI sets IOS_SIMULATOR_UDID
+ // from prepare-ios-appium-runner so we attach to the sim that received the .app.
+ emulatorDevice.udid = await ensureIosSimulatorReady(
+ deviceName,
+ emulatorDevice.udid,
+ );
+ this.logger.debug(
+ `iOS simulator ready for Appium (udid=${emulatorDevice.udid})`,
+ );
}
// Start Appium server
diff --git a/tests/framework/services/providers/emulator/reinstallLocalBuildFromPath.ts b/tests/framework/services/providers/emulator/reinstallLocalBuildFromPath.ts
index d774dd69c9fc..5084a45baf7f 100644
--- a/tests/framework/services/providers/emulator/reinstallLocalBuildFromPath.ts
+++ b/tests/framework/services/providers/emulator/reinstallLocalBuildFromPath.ts
@@ -6,6 +6,7 @@ import type { Logger } from '../../../logger.ts';
import { Platform, type EmulatorConfig } from '../../../types.ts';
import type { ProjectConfig } from '../../common/types.ts';
import { resolveAndroidAdbUdidForDevice } from './android/resolveAndroidAdbUdid';
+import { getIosSimulatorUdid } from '../../appium/EmulatorHelpers';
const execFileAsync = promisify(execFile);
@@ -83,13 +84,18 @@ export async function reinstallFromBuildPathForProject(
'iOS: set `use.device.name` to the target simulator to reinstall from `use.app.buildPath` in global setup.',
);
}
+ // Prefer CI/prepare UDID so simctl targets the sim that received the .app.
+ const configuredUdid =
+ (project.use.device as EmulatorConfig | undefined)?.udid?.trim() ||
+ process.env.IOS_SIMULATOR_UDID?.trim();
+ const simUdid = configuredUdid || (await getIosSimulatorUdid(simDevice));
logger.info(
'Reinstalling iOS app from build path (simctl uninstall + install)…',
);
await reinstallLocalIOSBuildArtifact({
buildPath,
bundleId,
- simDevice,
+ simDevice: simUdid,
logger,
});
}
@@ -172,7 +178,7 @@ export async function reinstallLocalIOSBuildArtifact({
);
}
- logger.info(`simctl install: ${absApp}`);
+ logger.info(`simctl install: ${absApp} → simulator ${simDevice}`);
const { stdout, stderr } = await execFileAsync(
'xcrun',
['simctl', 'install', simDevice, absApp],
@@ -182,4 +188,19 @@ export async function reinstallLocalIOSBuildArtifact({
if (out) {
logger.info(out);
}
+
+ try {
+ await execFileAsync(
+ 'xcrun',
+ ['simctl', 'get_app_container', simDevice, bundleId],
+ { timeout: 120_000, maxBuffer: 2 * 1024 * 1024 },
+ );
+ logger.info(`Verified ${bundleId} is installed on simulator ${simDevice}.`);
+ } catch (error) {
+ throw new Error(
+ `App "${bundleId}" is not installed on simulator ${simDevice} after simctl install: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ );
+ }
}
diff --git a/tests/jest.e2e.detox.config.js b/tests/jest.e2e.detox.config.js
index 732a4167750f..ed069dc1e0ef 100644
--- a/tests/jest.e2e.detox.config.js
+++ b/tests/jest.e2e.detox.config.js
@@ -12,6 +12,10 @@ require('dotenv').config({ path: '.js.env' });
module.exports = {
rootDir: '..',
testMatch: ['/tests/**/*.spec.{js,ts}'],
+ testPathIgnorePatterns: [
+ // Playwright + Appium smoke specs — run via tests/playwright.smoke-appium.config.ts
+ '/tests/smoke-appium/',
+ ],
testTimeout: 300000,
maxWorkers: 1,
clearMocks: true,
diff --git a/tests/page-objects/AccountMenu/AccountMenu.ts b/tests/page-objects/AccountMenu/AccountMenu.ts
index f58989d915eb..eb71c17d382d 100644
--- a/tests/page-objects/AccountMenu/AccountMenu.ts
+++ b/tests/page-objects/AccountMenu/AccountMenu.ts
@@ -2,6 +2,7 @@ import { AccountsMenuSelectorsIDs } from '../../../app/components/Views/Accounts
import Matchers from '../../../tests/framework/Matchers';
import Gestures from '../../../tests/framework/Gestures';
import { EncapsulatedElementType } from '../../framework';
+import UnifiedGestures from '../../framework/UnifiedGestures';
class AccountMenu {
get container(): EncapsulatedElementType {
@@ -53,8 +54,8 @@ class AccountMenu {
}
async tapSettings(): Promise {
- await Gestures.waitAndTap(this.settingsButton, {
- elemDescription: 'Settings button',
+ await UnifiedGestures.waitAndTap(this.settingsButton, {
+ description: 'Settings button',
});
}
diff --git a/tests/page-objects/Settings/SecurityAndPrivacy/RevealSecretRecoveryPhrase.ts b/tests/page-objects/Settings/SecurityAndPrivacy/RevealSecretRecoveryPhrase.ts
index 2617249c153a..2efed4c75583 100644
--- a/tests/page-objects/Settings/SecurityAndPrivacy/RevealSecretRecoveryPhrase.ts
+++ b/tests/page-objects/Settings/SecurityAndPrivacy/RevealSecretRecoveryPhrase.ts
@@ -5,7 +5,16 @@ import {
import Matchers from '../../../framework/Matchers';
import Gestures from '../../../framework/Gestures';
import Utilities from '../../../framework/Utilities';
-import { EncapsulatedElementType } from '../../../framework';
+import {
+ EncapsulatedElementType,
+ asPlaywrightElement,
+ asDetoxElement,
+} from '../../../framework';
+import { encapsulatedAction } from '../../../framework/encapsulatedAction';
+import PlaywrightAssertions from '../../../framework/PlaywrightAssertions';
+import PlaywrightGestures from '../../../framework/PlaywrightGestures';
+import UnifiedGestures from '../../../framework/UnifiedGestures';
+import { PlatformDetector } from '../../../framework/PlatformLocator';
class RevealSecretRecoveryPhrase {
get container(): EncapsulatedElementType {
@@ -37,6 +46,7 @@ class RevealSecretRecoveryPhrase {
RevealSeedViewSelectorsIDs.TAB_SCROLL_VIEW_TEXT,
);
}
+
get tabScrollViewQRCodeIdentifier(): Promise {
return Matchers.getIdentifier(
RevealSeedViewSelectorsIDs.TAB_SCROLL_VIEW_QR_CODE,
@@ -80,20 +90,49 @@ class RevealSecretRecoveryPhrase {
}
async enterPasswordToRevealSecretCredential(password: string): Promise {
- // Wait for password screen to be ready (e.g. after navigation or quiz on iOS/Android CI)
- await Utilities.waitForElementToBeVisible(
- this.passwordInputToRevealCredential,
- 15000,
- );
- await Gestures.typeText(this.passwordInputToRevealCredential, password, {
- hideKeyboard: true,
- elemDescription: 'Password input to reveal credential',
+ await encapsulatedAction({
+ detox: async () => {
+ await Utilities.waitForElementToBeVisible(
+ asDetoxElement(this.passwordInputToRevealCredential),
+ 15000,
+ );
+ await Gestures.typeText(
+ asDetoxElement(this.passwordInputToRevealCredential),
+ password,
+ {
+ hideKeyboard: true,
+ elemDescription: 'Password input to reveal credential',
+ },
+ );
+ },
+ appium: async () => {
+ await PlaywrightAssertions.expectElementToBeVisible(
+ await asPlaywrightElement(this.passwordInputToRevealCredential),
+ {
+ timeout: 15000,
+ description: 'Password input to reveal credential',
+ },
+ );
+ const textToType = PlatformDetector.isIOS()
+ ? `${password}\n`
+ : password;
+ await UnifiedGestures.typeText(
+ this.passwordInputToRevealCredential,
+ textToType,
+ {
+ description: 'Password input to reveal credential',
+ },
+ );
+ if (PlatformDetector.isAndroid()) {
+ await PlaywrightGestures.hideKeyboard();
+ }
+ },
});
}
async tapConfirmButton(): Promise {
- await Gestures.waitAndTap(this.confirmButton, {
- elemDescription: 'Confirm button to reveal credential',
+ await UnifiedGestures.waitAndTap(this.confirmButton, {
+ description: 'Confirm button to reveal credential',
});
}
@@ -103,10 +142,20 @@ class RevealSecretRecoveryPhrase {
*/
async isUnlocked(): Promise {
try {
- await Utilities.waitForElementToBeVisible(
- this.revealSecretRecoveryPhraseButton,
- 3000,
- );
+ await encapsulatedAction({
+ detox: async () => {
+ await Utilities.waitForElementToBeVisible(
+ asDetoxElement(this.revealSecretRecoveryPhraseButton),
+ 3000,
+ );
+ },
+ appium: async () => {
+ await PlaywrightAssertions.expectElementToBeVisible(
+ await asPlaywrightElement(this.revealSecretRecoveryPhraseButton),
+ { timeout: 3000 },
+ );
+ },
+ });
return true;
} catch {
return false;
@@ -114,51 +163,55 @@ class RevealSecretRecoveryPhrase {
}
async tapToReveal(): Promise {
- await Gestures.waitAndTap(this.revealSecretRecoveryPhraseButton, {
- elemDescription: 'Reveal secret recovery phrase button',
+ await UnifiedGestures.waitAndTap(this.revealSecretRecoveryPhraseButton, {
+ description: 'Reveal secret recovery phrase button',
});
}
async tapToCopyCredentialToClipboard() {
- await Gestures.tap(this.revealCredentialCopyToClipboardButton, {
- elemDescription: 'Reveal credential copy to clipboard button',
+ await UnifiedGestures.tap(this.revealCredentialCopyToClipboardButton, {
+ description: 'Reveal credential copy to clipboard button',
});
}
async tapToRevealPrivateCredentialQRCode(): Promise {
- await Gestures.tap(this.revealCredentialQRCodeTab, {
- elemDescription: 'Reveal credential QR code tab',
+ await UnifiedGestures.tap(this.revealCredentialQRCodeTab, {
+ description: 'Reveal credential QR code tab',
});
}
async scrollToDone(): Promise {
- await Gestures.scrollToElement(this.doneButton, this.scrollViewIdentifier, {
- elemDescription: 'Done button',
- });
+ await UnifiedGestures.scrollToElement(
+ this.doneButton,
+ RevealSeedViewSelectorsIDs.REVEAL_CREDENTIAL_SCROLL_ID,
+ {
+ description: 'Done button',
+ },
+ );
}
async tapDoneButton(): Promise {
- await Gestures.waitAndTap(this.doneButton, {
- elemDescription: 'Done button',
+ await UnifiedGestures.waitAndTap(this.doneButton, {
+ description: 'Done button',
});
}
async scrollToCopyToClipboardButton(): Promise {
- await Gestures.scrollToElement(
+ await UnifiedGestures.scrollToElement(
this.revealCredentialCopyToClipboardButton,
- this.tabScrollViewTextIdentifier,
+ RevealSeedViewSelectorsIDs.TAB_SCROLL_VIEW_TEXT,
{
- elemDescription: 'Copy to clipboard button',
+ description: 'Copy to clipboard button',
},
);
}
async scrollToQR(): Promise {
- await Gestures.scrollToElement(
+ await UnifiedGestures.scrollToElement(
this.revealCredentialQRCodeImage,
- this.tabScrollViewQRCodeIdentifier,
+ RevealSeedViewSelectorsIDs.TAB_SCROLL_VIEW_QR_CODE,
{
- elemDescription: 'QR code',
+ description: 'QR code',
},
);
}
diff --git a/tests/page-objects/Settings/SecurityAndPrivacy/SecurityAndPrivacyView.ts b/tests/page-objects/Settings/SecurityAndPrivacy/SecurityAndPrivacyView.ts
index c6a3053c657b..5205e7e7b446 100644
--- a/tests/page-objects/Settings/SecurityAndPrivacy/SecurityAndPrivacyView.ts
+++ b/tests/page-objects/Settings/SecurityAndPrivacy/SecurityAndPrivacyView.ts
@@ -5,6 +5,7 @@ import {
import Matchers from '../../../framework/Matchers';
import Gestures from '../../../framework/Gestures';
import { EncapsulatedElementType } from '../../../framework';
+import UnifiedGestures from '../../../framework/UnifiedGestures';
class SecurityAndPrivacy {
get changePasswordButton(): EncapsulatedElementType {
@@ -111,8 +112,8 @@ class SecurityAndPrivacy {
}
async tapRevealSecretRecoveryPhraseButton(): Promise {
- await Gestures.waitAndTap(this.revealSecretRecoveryPhraseButton, {
- elemDescription: 'Reveal secret recovery phrase button',
+ await UnifiedGestures.waitAndTap(this.revealSecretRecoveryPhraseButton, {
+ description: 'Reveal secret recovery phrase button',
});
}
diff --git a/tests/page-objects/Settings/SecurityAndPrivacy/SrpQuizModal.ts b/tests/page-objects/Settings/SecurityAndPrivacy/SrpQuizModal.ts
index 94eb70032b88..9fcdcca70e64 100644
--- a/tests/page-objects/Settings/SecurityAndPrivacy/SrpQuizModal.ts
+++ b/tests/page-objects/Settings/SecurityAndPrivacy/SrpQuizModal.ts
@@ -9,9 +9,9 @@ import {
import Matchers from '../../../framework/Matchers';
import Gestures from '../../../framework/Gestures';
import { EncapsulatedElementType } from '../../../framework';
+import UnifiedGestures from '../../../framework/UnifiedGestures';
class SrpQuizModal {
- // Getters for common elements
get getStartedContainer(): EncapsulatedElementType {
return Matchers.getElementByID(SrpQuizGetStartedSelectorsIDs.CONTAINER);
}
@@ -30,7 +30,6 @@ class SrpQuizModal {
return Matchers.getElementByID(SrpQuizGetStartedSelectorsIDs.BUTTON);
}
- // Mapping question number to selectors
getQuestionSelectors(questionNumber: number) {
switch (questionNumber) {
case 1:
@@ -48,7 +47,6 @@ class SrpQuizModal {
}
}
- // Getters for question elements
getQuestionContainer(questionNumber: number) {
const { ids } = this.getQuestionSelectors(questionNumber);
return Matchers.getElementByID(ids.CONTAINER);
@@ -84,7 +82,9 @@ class SrpQuizModal {
return Matchers.getElementByID(ids.WRONG_ANSWER_TRY_AGAIN_BUTTON);
}
- getQuestionRightAnswerButton(questionNumber: number) {
+ getQuestionRightAnswerButton(
+ questionNumber: number,
+ ): EncapsulatedElementType {
const { ids } = this.getQuestionSelectors(questionNumber);
return Matchers.getElementByID(ids.RIGHT_ANSWER);
}
@@ -99,12 +99,13 @@ class SrpQuizModal {
return Matchers.getElementByText(text.RIGHT_ANSWER_RESPONSE_DESCRIPTION);
}
- getQuestionRightContinueButton(questionNumber: number) {
+ getQuestionRightContinueButton(
+ questionNumber: number,
+ ): EncapsulatedElementType {
const { ids } = this.getQuestionSelectors(questionNumber);
return Matchers.getElementByID(ids.RIGHT_CONTINUE);
}
- // Methods for common actions
async tapQuizGetStartedScreenDismiss(): Promise {
await Gestures.waitAndTap(this.getStartedScreenDismiss, {
elemDescription: 'Srp Quiz - Get Started Screen Dismiss',
@@ -112,12 +113,11 @@ class SrpQuizModal {
}
async tapGetStartedButton(): Promise {
- await Gestures.waitAndTap(this.getStartedButton, {
- elemDescription: 'Srp Quiz - Get Started Button',
+ await UnifiedGestures.waitAndTap(this.getStartedButton, {
+ description: 'Srp Quiz - Get Started Button',
});
}
- // Methods for question actions
async tapQuestionDismiss(questionNumber: number): Promise {
await Gestures.waitAndTap(this.getQuestionDismiss(questionNumber), {
elemDescription: `Srp Quiz - Question ${questionNumber} Dismiss`,
@@ -142,19 +142,19 @@ class SrpQuizModal {
}
async tapQuestionRightAnswerButton(questionNumber: number): Promise {
- await Gestures.waitAndTap(
+ await UnifiedGestures.waitAndTap(
this.getQuestionRightAnswerButton(questionNumber),
{
- elemDescription: `Srp Quiz - Question ${questionNumber} Right Answer`,
+ description: `Srp Quiz - Question ${questionNumber} Right Answer`,
},
);
}
async tapQuestionContinueButton(questionNumber: number): Promise {
- await Gestures.waitAndTap(
+ await UnifiedGestures.waitAndTap(
this.getQuestionRightContinueButton(questionNumber),
{
- elemDescription: `Srp Quiz - Question ${questionNumber} Right Continue`,
+ description: `Srp Quiz - Question ${questionNumber} Right Continue`,
},
);
}
diff --git a/tests/page-objects/Settings/SettingsView.ts b/tests/page-objects/Settings/SettingsView.ts
index 3ff60132d34f..3d37923dbe11 100644
--- a/tests/page-objects/Settings/SettingsView.ts
+++ b/tests/page-objects/Settings/SettingsView.ts
@@ -6,6 +6,7 @@ import {
} from '../../../app/components/Views/Settings/SettingsView.testIds';
import { CommonSelectorsText } from '../../../app/util/Common.testIds';
import { EncapsulatedElementType } from '../../framework';
+import UnifiedGestures from '../../framework/UnifiedGestures';
class SettingsView {
get title(): EncapsulatedElementType {
@@ -118,8 +119,8 @@ class SettingsView {
}
async tapSecurityAndPrivacy(): Promise {
- await Gestures.waitAndTap(this.securityAndPrivacyButton, {
- elemDescription: 'Settings - Security and Privacy Button',
+ await UnifiedGestures.waitAndTap(this.securityAndPrivacyButton, {
+ description: 'Settings - Security and Privacy Button',
});
}
diff --git a/tests/page-objects/wallet/LoginView.ts b/tests/page-objects/wallet/LoginView.ts
index 7086ca7b5ea8..bd433596c7b6 100644
--- a/tests/page-objects/wallet/LoginView.ts
+++ b/tests/page-objects/wallet/LoginView.ts
@@ -9,7 +9,6 @@ import {
} from '../../framework/EncapsulatedElement';
import { encapsulatedAction } from '../../framework/encapsulatedAction';
import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
-import PlaywrightGestures from '../../framework/PlaywrightGestures';
import UnifiedGestures from '../../framework/UnifiedGestures';
import Utilities from '../../framework/Utilities';
@@ -83,7 +82,11 @@ class LoginView {
await UnifiedGestures.typeText(this.passwordInput, password, {
description: 'Password Input',
});
- await PlaywrightGestures.hideKeyboard();
+ // Do NOT call hideKeyboard here — the login button is above the
+ // keyboard (~184pt vs keyboard at ~574pt) and does not need dismissal.
+ // Both 'pressKey: Done' and 'tapOutside' strategies trigger navigation
+ // (either via onSubmitEditing or by tapping the login button itself)
+ // before tapLoginButton can find and tap the element.
},
});
}
diff --git a/tests/page-objects/wallet/TabBarComponent.ts b/tests/page-objects/wallet/TabBarComponent.ts
index 6d753630eef3..933c6a4dde58 100644
--- a/tests/page-objects/wallet/TabBarComponent.ts
+++ b/tests/page-objects/wallet/TabBarComponent.ts
@@ -1,13 +1,14 @@
import Matchers from '../../framework/Matchers';
-import Gestures from '../../framework/Gestures';
import UnifiedGestures from '../../framework/UnifiedGestures';
import { TabBarSelectorIDs } from '../../../app/components/Nav/Main/TabBar.testIds';
-import { Assertions, Utilities } from '../../framework';
import {
- encapsulated,
+ Assertions,
+ PlaywrightAssertions,
+ Utilities,
+ resolve,
EncapsulatedElementType,
asPlaywrightElement,
-} from '../../framework/EncapsulatedElement';
+} from '../../framework';
import { encapsulatedAction } from '../../framework/encapsulatedAction';
import PlaywrightMatchers from '../../framework/PlaywrightMatchers';
import PlaywrightGestures from '../../framework/PlaywrightGestures';
@@ -19,136 +20,84 @@ import TrendingView from '../Trending/TrendingView';
class TabBarComponent {
get tabBarExploreButton(): EncapsulatedElementType {
- return encapsulated({
- detox: () => Matchers.getElementByID(TabBarSelectorIDs.EXPLORE),
- appium: {
- android: () =>
- PlaywrightMatchers.getElementById(TabBarSelectorIDs.EXPLORE, {
- exact: true,
- }),
- ios: () =>
- PlaywrightMatchers.getElementByAccessibilityId(
- TabBarSelectorIDs.EXPLORE,
- ),
- },
- });
+ return Matchers.getElementByID(TabBarSelectorIDs.EXPLORE);
}
get tabBarBrowserButton(): EncapsulatedElementType {
- return encapsulated({
- detox: () => Matchers.getElementByID(TabBarSelectorIDs.BROWSER),
- appium: {
- android: () =>
- PlaywrightMatchers.getElementById(TabBarSelectorIDs.BROWSER, {
- exact: true,
- }),
- ios: () =>
- PlaywrightMatchers.getElementByAccessibilityId(
- TabBarSelectorIDs.BROWSER,
- ),
- },
- });
+ return Matchers.getElementByID(TabBarSelectorIDs.BROWSER);
}
get tabBarWalletButton(): EncapsulatedElementType {
- return encapsulated({
- detox: () => Matchers.getElementByID(TabBarSelectorIDs.WALLET),
- appium: {
- android: () =>
- PlaywrightMatchers.getElementById(TabBarSelectorIDs.WALLET, {
- exact: true,
- }),
- ios: () =>
- PlaywrightMatchers.getElementByAccessibilityId(
- TabBarSelectorIDs.WALLET,
- ),
- },
- });
+ return Matchers.getElementByID(TabBarSelectorIDs.WALLET);
}
get tabBarActionButton(): EncapsulatedElementType {
- return encapsulated({
- detox: () => Matchers.getElementByID(TabBarSelectorIDs.TRADE),
- appium: {
- android: () =>
- PlaywrightMatchers.getElementById(TabBarSelectorIDs.ACTIONS, {
- exact: true,
- }),
- ios: () =>
- PlaywrightMatchers.getElementByAccessibilityId(
- TabBarSelectorIDs.ACTIONS,
- ),
- },
+ return resolve({
+ detoxTestID: TabBarSelectorIDs.TRADE,
+ androidAppiumTestID: TabBarSelectorIDs.ACTIONS,
+ iosAppiumTestID: TabBarSelectorIDs.ACTIONS,
});
}
get tabBarTradeButton(): EncapsulatedElementType {
- return encapsulated({
- detox: () => Matchers.getElementByID(TabBarSelectorIDs.TRADE),
- appium: {
- android: () =>
- PlaywrightMatchers.getElementById(TabBarSelectorIDs.TRADE, {
- exact: true,
- }),
- ios: () =>
- PlaywrightMatchers.getElementByAccessibilityId(
- TabBarSelectorIDs.TRADE,
- ),
- },
- });
+ return Matchers.getElementByID(TabBarSelectorIDs.TRADE);
}
get tabBarSettingButton(): EncapsulatedElementType {
- return encapsulated({
- detox: () => Matchers.getElementByID(TabBarSelectorIDs.SETTING),
- appium: {
- android: () =>
- PlaywrightMatchers.getElementById(TabBarSelectorIDs.SETTING, {
- exact: true,
- }),
- ios: () =>
- PlaywrightMatchers.getElementByAccessibilityId(
- TabBarSelectorIDs.SETTING,
- ),
- },
- });
+ return Matchers.getElementByID(TabBarSelectorIDs.SETTING);
}
get tabBarActivityButton(): EncapsulatedElementType {
- return encapsulated({
- detox: () => Matchers.getElementByID(TabBarSelectorIDs.ACTIVITY),
- appium: {
- android: () =>
- PlaywrightMatchers.getElementById(TabBarSelectorIDs.ACTIVITY, {
- exact: true,
- }),
- ios: () =>
- PlaywrightMatchers.getElementByAccessibilityId(
- TabBarSelectorIDs.ACTIVITY,
- ),
- },
- });
+ return Matchers.getElementByID(TabBarSelectorIDs.ACTIVITY);
}
get tabBarRewardsButton(): EncapsulatedElementType {
- return encapsulated({
- detox: () => Matchers.getElementByID(TabBarSelectorIDs.REWARDS),
- appium: {
- android: () =>
- PlaywrightMatchers.getElementById(TabBarSelectorIDs.REWARDS, {
+ return Matchers.getElementByID(TabBarSelectorIDs.REWARDS);
+ }
+
+ get homeButton(): EncapsulatedElementType {
+ return resolve({
+ custom: {
+ detox: () => Matchers.getElementByText('Home'),
+ appium: () =>
+ PlaywrightMatchers.getElementById(TabBarSelectorIDs.WALLET, {
exact: true,
}),
- ios: () =>
- PlaywrightMatchers.getElementByAccessibilityId(
- TabBarSelectorIDs.REWARDS,
- ),
},
});
}
async tapHome(): Promise {
- const homeButton = Matchers.getElementByText('Home');
- await Gestures.waitAndTap(homeButton);
+ await Utilities.executeWithRetry(
+ async () => {
+ await encapsulatedAction({
+ detox: async () => {
+ await UnifiedGestures.waitAndTap(this.homeButton, {
+ timeout: 2000,
+ });
+ await Assertions.expectElementToBeVisible(WalletView.container, {
+ timeout: 500,
+ });
+ },
+ appium: async () => {
+ await PlaywrightGestures.waitAndTap(
+ await asPlaywrightElement(this.homeButton),
+ );
+ await PlaywrightAssertions.expectElementToBeVisible(
+ await asPlaywrightElement(WalletView.container),
+ {
+ timeout: 500,
+ },
+ );
+ },
+ });
+ },
+ {
+ maxRetries: 15,
+ timeout: 45000,
+ description: 'Tap Home Button with Validation',
+ },
+ );
}
async tapWallet(): Promise {
@@ -191,10 +140,27 @@ class TabBarComponent {
async tapAccountsMenu(): Promise {
await Utilities.executeWithRetry(
async () => {
- await UnifiedGestures.waitAndTap(this.tabBarWalletButton);
- await Assertions.expectElementToBeVisible(WalletView.container);
- await Gestures.waitAndTap(WalletView.hamburgerMenuButton);
- await Assertions.expectElementToBeVisible(AccountMenu.container);
+ await UnifiedGestures.waitAndTap(this.tabBarWalletButton, {
+ timeout: 2000,
+ });
+ await encapsulatedAction({
+ detox: async () => {
+ await Assertions.expectElementToBeVisible(WalletView.container);
+ await UnifiedGestures.waitAndTap(WalletView.hamburgerMenuButton);
+ await Assertions.expectElementToBeVisible(AccountMenu.container);
+ },
+ appium: async () => {
+ await PlaywrightAssertions.expectElementToBeVisible(
+ await asPlaywrightElement(WalletView.container),
+ { timeout: 500 },
+ );
+ await UnifiedGestures.waitAndTap(WalletView.hamburgerMenuButton);
+ await PlaywrightAssertions.expectElementToBeVisible(
+ await asPlaywrightElement(AccountMenu.container),
+ { timeout: 500 },
+ );
+ },
+ });
},
{
timeout: 45000,
@@ -206,7 +172,17 @@ class TabBarComponent {
async tapSettings(): Promise {
await this.tapAccountsMenu();
await AccountMenu.tapSettings();
- await Assertions.expectElementToBeVisible(SettingsView.title);
+ await encapsulatedAction({
+ detox: async () => {
+ await Assertions.expectElementToBeVisible(SettingsView.title);
+ },
+ appium: async () => {
+ await PlaywrightAssertions.expectElementToBeVisible(
+ await asPlaywrightElement(SettingsView.title),
+ { description: 'Settings view title' },
+ );
+ },
+ });
}
async tapExploreButton(): Promise {
await Utilities.executeWithRetry(
diff --git a/tests/playwright.smoke-appium.config.ts b/tests/playwright.smoke-appium.config.ts
new file mode 100644
index 000000000000..ee6f719bab12
--- /dev/null
+++ b/tests/playwright.smoke-appium.config.ts
@@ -0,0 +1,100 @@
+import dotenv from 'dotenv';
+dotenv.config({ path: '.e2e.env' });
+
+import { Platform, ProviderName } from './framework/types';
+import { defineConfig } from './framework/config';
+
+// Requires HAS_TEST_OVERRIDES=true baked in at Metro bundle time so the app
+// activates ReadOnlyNetworkStore and fetches fixture state from /state.json.
+// Build with: CONFIGURATION=Debug yarn build:android:main:e2e
+// (or add HAS_TEST_OVERRIDES=true + METAMASK_ENVIRONMENT=e2e to .js.env and
+// run CONFIGURATION=Debug yarn build:android:main:e2e)
+const DEFAULT_ANDROID_APK =
+ 'android/app/build/outputs/apk/prod/debug/app-prod-debug.apk';
+const DEFAULT_IOS_APP =
+ 'ios/build/Build/Products/Debug-iphonesimulator/MetaMask.app';
+
+/**
+ * Playwright runner config for Appium smoke tests.
+ *
+ * Runs Appium smoke specs from tests/smoke-appium. Tags live in describe titles
+ * via tags.js (same convention as Detox); --grep uses the tag id (e.g. SmokeAccounts).
+ *
+ * IMPORTANT: Requires a debug build with HAS_TEST_OVERRIDES=true so the app
+ * fetches fixture state from /state.json on launch. Build with:
+ * CONFIGURATION=Debug yarn build:android:main:e2e (Android)
+ * CONFIGURATION=Debug yarn build:ios:main:e2e (iOS)
+ *
+ * Environment variables (all optional — defaults shown):
+ * - ANDROID_APK_PATH — path to the APK (default: prod debug APK)
+ * - IOS_APP_PATH — path to the .app (default: Debug-iphonesimulator/MetaMask.app)
+ * - ANDROID_AVD_NAME — AVD name (default: 'Pixel_5_Pro_API_34')
+ * - IOS_SIMULATOR_NAME — simulator name (default: 'iPhone 16 Pro')
+ * - IOS_SIMULATOR_UDID — booted sim UDID (CI sets this from prepare-ios-appium-runner)
+ * - APPIUM_SMOKE_SUITE_NAME — CI suite id for per-job report/video paths
+ *
+ * Usage:
+ * yarn appium-smoke:android
+ * yarn appium-smoke:ios
+ */
+const suiteName = process.env.APPIUM_SMOKE_SUITE_NAME?.trim();
+const htmlReportDir = suiteName
+ ? `./test-reports/appium-smoke-report/${suiteName}`
+ : './test-reports/appium-smoke-report';
+const junitReportPath = suiteName
+ ? `./test-reports/appium-smoke-junit/${suiteName}.xml`
+ : './test-reports/appium-smoke-junit.xml';
+
+export default defineConfig({
+ testDir: './smoke-appium',
+ fullyParallel: false,
+ // Per-test timeout: cold WDA build on CI can take up to 10 min plus test time.
+ timeout: 15 * 60 * 1000,
+ retries: 1,
+ reporter: [
+ ['html', { open: 'never', outputFolder: htmlReportDir }],
+ ['junit', { outputFile: junitReportPath }],
+ ['list'],
+ // CI: step summary + JUnit (dorny/test-reporter). Skip the `github` reporter —
+ // it emits error annotations for failed retry attempts even when the test
+ // eventually passes, which makes passing jobs look failed in the UI.
+ ...(process.env.CI === 'true'
+ ? ([['./reporters/github-step-summary-reporter.mjs'] as const] as const)
+ : []),
+ ],
+
+ projects: [
+ {
+ name: 'android-smoke',
+ use: {
+ platform: Platform.ANDROID,
+ device: {
+ provider: ProviderName.EMULATOR,
+ name: process.env.ANDROID_AVD_NAME || 'Pixel_5_Pro_API_34',
+ },
+ app: {
+ packageName: 'io.metamask',
+ launchableActivity: 'io.metamask.MainActivity',
+ buildPath: process.env.ANDROID_APK_PATH || DEFAULT_ANDROID_APK,
+ },
+ },
+ },
+ {
+ name: 'ios-smoke',
+ use: {
+ platform: Platform.IOS,
+ device: {
+ provider: ProviderName.SIMULATOR,
+ name: process.env.IOS_SIMULATOR_NAME || 'iPhone 16 Pro',
+ ...(process.env.IOS_SIMULATOR_UDID?.trim()
+ ? { udid: process.env.IOS_SIMULATOR_UDID.trim() }
+ : {}),
+ },
+ app: {
+ appId: 'io.metamask.MetaMask',
+ buildPath: process.env.IOS_APP_PATH || DEFAULT_IOS_APP,
+ },
+ },
+ },
+ ],
+});
diff --git a/tests/reporters/github-step-summary-reporter.mjs b/tests/reporters/github-step-summary-reporter.mjs
new file mode 100644
index 000000000000..2aaf0c5eb9aa
--- /dev/null
+++ b/tests/reporters/github-step-summary-reporter.mjs
@@ -0,0 +1,139 @@
+#!/usr/bin/env node
+/* eslint-disable import-x/no-nodejs-modules */
+/**
+ * Appends a Playwright run summary to $GITHUB_STEP_SUMMARY (GitHub Actions job summary).
+ * Complements the built-in `github` reporter, which writes annotations to the job log only.
+ */
+import { appendFileSync, existsSync, readdirSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const testsRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
+
+/** @typedef {import('@playwright/test/reporter').FullConfig} FullConfig */
+/** @typedef {import('@playwright/test/reporter').FullResult} FullResult */
+/** @typedef {import('@playwright/test/reporter').Suite} Suite */
+/** @typedef {import('@playwright/test/reporter').TestCase} TestCase */
+
+export default class GitHubStepSummaryReporter {
+ /** @type {Suite | undefined} */
+ #rootSuite;
+
+ /** @param {FullConfig} _config @param {Suite} suite */
+ onBegin(_config, suite) {
+ this.#rootSuite = suite;
+ }
+
+ /** @param {FullResult} result */
+ onEnd(result) {
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY;
+ if (!summaryPath) {
+ return;
+ }
+
+ const stats = this.#rootSuite
+ ? this.#collectStats(this.#rootSuite)
+ : { passed: 0, failed: 0, flaky: 0, skipped: 0, total: 0 };
+
+ const title =
+ process.env.APPIUM_SMOKE_JOB_TITLE ?? 'Appium Smoke Tests';
+ const statusEmoji = result.status === 'passed' ? '✅' : '❌';
+ const suiteName = process.env.APPIUM_SMOKE_SUITE_NAME?.trim();
+ const reportDir = suiteName
+ ? join(testsRoot, 'test-reports/appium-smoke-report', suiteName)
+ : join(testsRoot, 'test-reports/appium-smoke-report');
+ const videosDir = suiteName
+ ? join(testsRoot, 'test-reports/appium-smoke-videos', suiteName)
+ : join(testsRoot, 'test-reports/appium-smoke-videos');
+ const videosArtifactName =
+ process.env.APPIUM_SMOKE_VIDEOS_ARTIFACT_NAME ??
+ 'appium-smoke-videos';
+ const artifactsUrl =
+ process.env.GITHUB_SERVER_URL &&
+ process.env.GITHUB_REPOSITORY &&
+ process.env.GITHUB_RUN_ID
+ ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}#artifacts`
+ : undefined;
+ const artifactName =
+ process.env.APPIUM_SMOKE_ARTIFACT_NAME ?? 'appium-smoke-report';
+
+ const lines = [
+ `## ${title}`,
+ '',
+ '| Result | Tests | Passed | Failed | Flaky | Skipped |',
+ '|--------|------:|-------:|-------:|------:|--------:|',
+ `| ${statusEmoji} ${result.status} | ${stats.total} | ${stats.passed} | ${stats.failed} | ${stats.flaky} | ${stats.skipped} |`,
+ '',
+ '### Playwright report',
+ '',
+ ];
+
+ if (existsSync(join(reportDir, 'index.html'))) {
+ lines.push(
+ `Download the **${artifactName}** artifact, then open \`index.html\` locally.`,
+ );
+ if (artifactsUrl) {
+ lines.push('', `[View run artifacts](${artifactsUrl})`);
+ }
+ } else {
+ lines.push('No HTML report was generated (run may have aborted early).');
+ }
+
+ lines.push(
+ '',
+ 'Failure details appear as inline annotations on this job (`github` reporter).',
+ );
+
+ const videoFiles = existsSync(videosDir)
+ ? readdirSync(videosDir).filter((name) => name.endsWith('.mp4'))
+ : [];
+ if (videoFiles.length > 0) {
+ lines.push('', '### Failure recordings', '');
+ lines.push(
+ `Download the **${videosArtifactName}** artifact for MP4 screen recordings (${videoFiles.length} file(s)).`,
+ );
+ if (artifactsUrl) {
+ lines.push('', `[View run artifacts](${artifactsUrl})`);
+ }
+ }
+
+ appendFileSync(summaryPath, `${lines.join('\n')}\n`);
+ }
+
+ /** @param {Suite} suite */
+ #collectStats(suite) {
+ /** @type {TestCase[]} */
+ const tests = suite.allTests();
+ let passed = 0;
+ let failed = 0;
+ let flaky = 0;
+ let skipped = 0;
+
+ for (const test of tests) {
+ switch (test.outcome()) {
+ case 'expected':
+ passed += 1;
+ break;
+ case 'unexpected':
+ failed += 1;
+ break;
+ case 'flaky':
+ flaky += 1;
+ break;
+ case 'skipped':
+ skipped += 1;
+ break;
+ default:
+ break;
+ }
+ }
+
+ return {
+ passed,
+ failed,
+ flaky,
+ skipped,
+ total: tests.length,
+ };
+ }
+}
diff --git a/tests/smoke-appium/accounts/reveal-secret-recovery-phrase.spec.ts b/tests/smoke-appium/accounts/reveal-secret-recovery-phrase.spec.ts
new file mode 100644
index 000000000000..9c4eaa57f179
--- /dev/null
+++ b/tests/smoke-appium/accounts/reveal-secret-recovery-phrase.spec.ts
@@ -0,0 +1,49 @@
+import { test as appiumTest } from '../../framework/fixtures/playwright/index.js';
+import { SmokeAccounts } from '../../tags.js';
+import { loginToAppPlaywright } from '../../flows/wallet.flow.js';
+import { completeSrpQuiz } from '../../flows/accounts.flow.js';
+import TabBarComponent from '../../page-objects/wallet/TabBarComponent.js';
+import SettingsView from '../../page-objects/Settings/SettingsView.js';
+import SecurityAndPrivacy from '../../page-objects/Settings/SecurityAndPrivacy/SecurityAndPrivacyView.js';
+import FixtureBuilder from '../../framework/fixtures/FixtureBuilder.js';
+import { withFixtures } from '../../framework/fixtures/FixtureHelper.js';
+import { defaultGanacheOptions } from '../../framework/Constants.js';
+import { asPlaywrightElement } from '../../framework/EncapsulatedElement.js';
+import PlaywrightAssertions from '../../framework/PlaywrightAssertions.js';
+
+appiumTest.describe(
+ SmokeAccounts('Secret Recovery Phrase Reveal from Settings'),
+ () => {
+ appiumTest(
+ 'navigate to reveal SRP screen and make the quiz',
+ async ({
+ driver: _driver, // required: sets globalThis.driver for Appium page objects
+ currentDeviceDetails,
+ }) => {
+ await withFixtures(
+ {
+ fixture: new FixtureBuilder().build(),
+ restartDevice: true,
+ currentDeviceDetails,
+ },
+ async () => {
+ await loginToAppPlaywright({
+ scenarioType: 'e2e',
+ });
+ await TabBarComponent.tapSettings();
+ await SettingsView.tapSecurityAndPrivacy();
+ await SecurityAndPrivacy.tapRevealSecretRecoveryPhraseButton();
+ await completeSrpQuiz(defaultGanacheOptions.mnemonic);
+
+ await PlaywrightAssertions.expectElementToBeVisible(
+ await asPlaywrightElement(
+ SecurityAndPrivacy.securityAndPrivacyHeading,
+ ),
+ { description: 'Security and privacy heading' },
+ );
+ },
+ );
+ },
+ );
+ },
+);
diff --git a/tests/tags.js b/tests/tags.js
index 3e4a4b72221a..d4720520badf 100644
--- a/tests/tags.js
+++ b/tests/tags.js
@@ -5,6 +5,7 @@
*
* Selection logic is defined in: tests/tools/e2e-ai-analyzer/modes/select-tags/prompt.ts
*/
+
const smokeTags = {
smokeAccounts: {
tag: 'SmokeAccounts:',
@@ -101,55 +102,70 @@ const otherTags = {
fixtureValidation: 'FixtureValidation:',
};
-// Smoke test tag functions
-const SmokeAccounts = (testName) =>
- `${smokeTags.smokeAccounts.tag} ${testName}`;
-const SmokeConfirmations = (testName) =>
- `${smokeTags.smokeConfirmations.tag} ${testName}`;
-const SmokeIdentity = (testName) =>
- `${smokeTags.smokeIdentity.tag} ${testName}`;
-const SmokeNetworkAbstractions = (testName) =>
- `${smokeTags.smokeNetworkAbstractions.tag} ${testName}`;
-const SmokeNetworkExpansion = (testName) =>
- `${smokeTags.smokeNetworkExpansion.tag} ${testName}`;
-const SmokeSwap = (testName) => `${smokeTags.smokeSwap.tag} ${testName}`;
-const SmokeStake = (testName) => `${smokeTags.smokeStake.tag} ${testName}`;
-const SmokeWalletPlatform = (testName) =>
- `${smokeTags.smokeWalletPlatform.tag} ${testName}`;
-const SmokeMoney = (testName) => `${smokeTags.smokeMoney.tag} ${testName}`;
-const SmokePerps = (testName) => `${smokeTags.smokePerps.tag} ${testName}`;
-const SmokeMultiChainAPI = (testName) =>
- `${smokeTags.smokeMultiChainAPI.tag} ${testName}`;
-const SmokePredictions = (testName) =>
- `${smokeTags.smokePredictions.tag} ${testName}`;
-const SmokeSeedlessOnboarding = (testName) =>
- `${smokeTags.smokeSeedlessOnboarding.tag} ${testName}`;
-const SmokeBrowser = (testName) => `${smokeTags.smokeBrowser.tag} ${testName}`;
-const SmokeSnaps = (testName) => `${smokeTags.smokeSnaps.tag} ${testName}`;
-// Other test tags functions.
-const RegressionAccounts = (testName) =>
- `${otherTags.regressionAccounts} ${testName}`;
-const RegressionConfirmations = (testName) =>
- `${otherTags.regressionConfirmations} ${testName}`;
-const RegressionIdentity = (testName) =>
- `${otherTags.regressionIdentity} ${testName}`;
-const RegressionNetworkAbstractions = (testName) =>
- `${otherTags.regressionNetworkAbstractions} ${testName}`;
-const RegressionWalletPlatform = (testName) =>
- `${otherTags.regressionWalletPlatform} ${testName}`;
-const RegressionNetworkExpansion = (testName) =>
- `${otherTags.regressionNetworkExpansion} ${testName}`;
-const RegressionAssets = (testName) =>
- `${otherTags.regressionAssets} ${testName}`;
-const RegressionWalletUX = (testName) =>
- `${otherTags.regressionWalletUX} ${testName}`;
-const RegressionTrade = (testName) =>
- `${otherTags.regressionTrade} ${testName}`;
-const RegressionSampleFeature = (testName) =>
- `${otherTags.regressionSampleFeature} ${testName}`;
-const SmokePerformance = (testName) => `${otherTags.performance} ${testName}`;
-const FixtureValidation = (testName) =>
- `${otherTags.fixtureValidation} ${testName}`;
+/** @param {string} tagPrefix Tag label including trailing colon, e.g. "SmokeAccounts:" */
+const tagDescribe = (tagPrefix) => (testName) => `${tagPrefix} ${testName}`;
+
+/** smokeAccounts → SmokeAccounts */
+const smokeExportName = (key) => `Smoke${key.slice('smoke'.length)}`;
+
+/** regressionAccounts → RegressionAccounts; performance → SmokePerformance (tag stays "Performance:") */
+const otherExportName = (key) => {
+ if (key === 'performance') {
+ return 'SmokePerformance';
+ }
+ return key.charAt(0).toUpperCase() + key.slice(1);
+};
+
+/** @param {Record} tags */
+const createSmokeDescribeFunctions = (tags) =>
+ Object.fromEntries(
+ Object.entries(tags).map(([key, { tag }]) => [
+ smokeExportName(key),
+ tagDescribe(tag),
+ ]),
+ );
+
+/** @param {Record} tags */
+const createOtherDescribeFunctions = (tags) =>
+ Object.fromEntries(
+ Object.entries(tags).map(([key, tag]) => [
+ otherExportName(key),
+ tagDescribe(tag),
+ ]),
+ );
+
+const {
+ SmokeAccounts,
+ SmokeConfirmations,
+ SmokeIdentity,
+ SmokeNetworkAbstractions,
+ SmokeNetworkExpansion,
+ SmokeSwap,
+ SmokeStake,
+ SmokeWalletPlatform,
+ SmokeMoney,
+ SmokePerps,
+ SmokeMultiChainAPI,
+ SmokePredictions,
+ SmokeSeedlessOnboarding,
+ SmokeBrowser,
+ SmokeSnaps,
+} = createSmokeDescribeFunctions(smokeTags);
+
+const {
+ RegressionAccounts,
+ RegressionConfirmations,
+ RegressionIdentity,
+ RegressionNetworkAbstractions,
+ RegressionWalletPlatform,
+ RegressionNetworkExpansion,
+ RegressionAssets,
+ RegressionWalletUX,
+ RegressionTrade,
+ RegressionSampleFeature,
+ SmokePerformance,
+ FixtureValidation,
+} = createOtherDescribeFunctions(otherTags);
export {
smokeTags,
diff --git a/yarn.lock b/yarn.lock
index 40a4abbc4a2b..da7f08b48392 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7880,7 +7880,7 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/assets-controller@npm:^8.3.1, @metamask/assets-controller@npm:^8.3.2, @metamask/assets-controller@npm:^8.3.3":
+"@metamask/assets-controller@npm:^8.3.1, @metamask/assets-controller@npm:^8.3.3":
version: 8.3.3
resolution: "@metamask/assets-controller@npm:8.3.3"
dependencies:
@@ -7917,7 +7917,7 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/assets-controllers@npm:^108.4.0, @metamask/assets-controllers@npm:^108.5.0, @metamask/assets-controllers@npm:^108.6.0":
+"@metamask/assets-controllers@npm:^108.4.0, @metamask/assets-controllers@npm:^108.6.0":
version: 108.6.0
resolution: "@metamask/assets-controllers@npm:108.6.0"
dependencies:
@@ -8082,39 +8082,6 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/bridge-controller@npm:^74.0.0":
- version: 74.0.0
- resolution: "@metamask/bridge-controller@npm:74.0.0"
- dependencies:
- "@ethersproject/address": "npm:^5.7.0"
- "@ethersproject/bignumber": "npm:^5.7.0"
- "@ethersproject/constants": "npm:^5.7.0"
- "@ethersproject/contracts": "npm:^5.7.0"
- "@ethersproject/providers": "npm:^5.7.0"
- "@metamask/accounts-controller": "npm:^39.0.0"
- "@metamask/assets-controller": "npm:^8.3.2"
- "@metamask/assets-controllers": "npm:^108.5.0"
- "@metamask/base-controller": "npm:^9.1.0"
- "@metamask/controller-utils": "npm:^12.1.0"
- "@metamask/gas-fee-controller": "npm:^26.2.2"
- "@metamask/keyring-api": "npm:^23.1.0"
- "@metamask/messenger": "npm:^1.2.0"
- "@metamask/metamask-eth-abis": "npm:^3.1.1"
- "@metamask/multichain-network-controller": "npm:^3.1.3"
- "@metamask/network-controller": "npm:^32.0.0"
- "@metamask/polling-controller": "npm:^16.0.6"
- "@metamask/profile-sync-controller": "npm:^28.1.1"
- "@metamask/remote-feature-flag-controller": "npm:^4.2.2"
- "@metamask/snaps-controllers": "npm:^19.0.0"
- "@metamask/transaction-controller": "npm:^67.0.0"
- "@metamask/utils": "npm:^11.9.0"
- bignumber.js: "npm:^9.1.2"
- reselect: "npm:^5.1.1"
- uuid: "npm:^8.3.2"
- checksum: 10/00f9f88567a0f43b1694bfd2becaef6036fc7fbdd0ad11590e0727d9e0163db241f354fd3dd9f22d731111be32b67a4e3c6e8f6d45b14dcaf1522110fc66f481
- languageName: node
- linkType: hard
-
"@metamask/bridge-controller@npm:^75.0.0, @metamask/bridge-controller@npm:^75.1.0":
version: 75.1.0
resolution: "@metamask/bridge-controller@npm:75.1.0"
@@ -8148,7 +8115,7 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/bridge-status-controller@npm:^72.0.2, @metamask/bridge-status-controller@npm:^72.1.0":
+"@metamask/bridge-status-controller@npm:^72.1.0":
version: 72.1.0
resolution: "@metamask/bridge-status-controller@npm:72.1.0"
dependencies:
@@ -35243,8 +35210,8 @@ __metadata:
"@metamask/auto-changelog": "npm:^5.3.0"
"@metamask/base-controller": "npm:^9.0.1"
"@metamask/bitcoin-wallet-snap": "npm:^1.12.0"
- "@metamask/bridge-controller": "npm:^74.0.0"
- "@metamask/bridge-status-controller": "npm:^72.0.2"
+ "@metamask/bridge-controller": "npm:^75.1.0"
+ "@metamask/bridge-status-controller": "npm:^72.1.0"
"@metamask/browser-passworder": "npm:^5.0.0"
"@metamask/browser-playground": "npm:0.3.0"
"@metamask/build-utils": "npm:^3.0.0"
@@ -35356,7 +35323,7 @@ __metadata:
"@metamask/test-dapp": "npm:9.5.0"
"@metamask/test-dapp-multichain": "npm:^0.17.1"
"@metamask/test-dapp-solana": "npm:^0.3.0"
- "@metamask/transaction-controller": "npm:^67.0.0"
+ "@metamask/transaction-controller": "npm:^67.1.0"
"@metamask/transaction-pay-controller": "npm:^23.5.0"
"@metamask/tron-wallet-snap": "npm:^1.25.6"
"@metamask/utils": "npm:^11.11.0"