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"