diff --git a/.env.template b/.env.template index 7997be1e7c..a3ae6dc6bf 100644 --- a/.env.template +++ b/.env.template @@ -21,4 +21,5 @@ ARBISCAN_API_KEY= OPSCAN_API_KEY= BASESCAN_API_KEY= STATIC_CONTENT_CARDS_ENABLED= -TEST_MODE_NETWORK= \ No newline at end of file +TEST_MODE_NETWORK= +WALLET_PHRASE= \ No newline at end of file diff --git a/.github/workflows/android-espresso-sandbox.yml b/.github/workflows/android-espresso-sandbox.yml new file mode 100644 index 0000000000..051be6d52b --- /dev/null +++ b/.github/workflows/android-espresso-sandbox.yml @@ -0,0 +1,283 @@ +name: Android Espresso E2E Sandbox + +on: + workflow_dispatch: + inputs: + environment: + description: "Select the environment (Sandbox or Automation)" + required: true + default: "Sandbox" + type: choice + options: + - Sandbox + - Main_Workspace + +concurrency: + group: e2e-android-espresso-${{ github.ref }} + cancel-in-progress: true + +jobs: + android-espresso-tests: + name: Android Espresso UI Tests + runs-on: ubuntu-latest + timeout-minutes: 120 + env: + ALLURE_RESULTS_DIR: ${{ github.workspace }}/allure-results-raw + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Force HTTPS for GitHub git dependencies + run: | + git config --global url."https://github.com/".insteadOf "git@github.com:" + git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" + git config --global url."https://github.com/".insteadOf "git+ssh://git@github.com/" + git config --global url."https://github.com/".insteadOf "git://github.com/" + + - name: Rewrite yarn.lock git+ssh URLs to HTTPS + run: | + if [ -f yarn.lock ]; then + sed -i 's#ssh://git@github.com/#https://github.com/#g' yarn.lock + sed -i 's#git+ssh://git@github.com/#https://github.com/#g' yarn.lock + sed -i 's#git@github.com:#https://github.com/#g' yarn.lock + fi + + - name: Free disk space + run: | + echo "=== Disk before cleanup ===" + df -h + + sudo rm -rf /usr/share/dotnet + sudo rm -rf /usr/local/lib/android/sdk/ndk/2[0-6]* + sudo rm -rf /opt/ghc + sudo rm -rf /usr/local/share/boost + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + sudo rm -rf /usr/local/share/powershell + sudo rm -rf /usr/share/swift + sudo rm -rf /opt/az + sudo rm -rf /usr/local/share/chromium + sudo rm -rf /usr/share/miniconda + + # Docker preloads several GB of images on GitHub-hosted runners + # that this workflow never uses. + docker image prune -af 2>/dev/null || true + docker system prune -af --volumes 2>/dev/null || true + + echo "=== Disk after cleanup ===" + df -h + + - name: Enable Corepack + run: corepack enable + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: '.node-version' + cache: 'yarn' + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Install dependencies + env: + GIT_TERMINAL_PROMPT: "0" + run: yarn install --frozen-lockfile --network-timeout 600000 + + - name: Check disk space after dependency install + run: | + echo "=== Disk after yarn install ===" + df -h + echo "=== Largest node_modules subdirs ===" + du -sh node_modules/* 2>/dev/null | sort -rh | head -15 + + - name: Create env file + env: + ENV_FILE: ${{ secrets.ENV_DEVELOPMENT }} + run: | + echo "$ENV_FILE" > .env.development + # E2E stability: redux-logger serializes the ENTIRE Redux state tree + # to logcat on every dispatched action. A recovery-phrase import fires + # hundreds of actions (per wallet, per chain, per token) and the + # serialization pins the JS thread long enough to blow past the + # Espresso wait budgets, so force dev logging off for the automated + # run regardless of what the secret carries. + sed -i '/^DISABLE_DEVELOPMENT_LOGGING=/d' .env.development + echo "DISABLE_DEVELOPMENT_LOGGING=true" >> .env.development + # E2E stability: index.js only calls LogBox.ignoreAllLogs() when + # IS_MAESTRO === 'true'. Without it, any console.error during bootstrap + # (e.g. "zenledgerInitialize: credentials not found" when the secret + # carries no ZenLedger creds) throws up the RN LogBox "Console Error" + # overlay, which covers the onboarding screen and makes every test + # fail on a missing skip-button. Force the flag on for the automated run. + sed -i '/^IS_MAESTRO=/d' .env.development + echo "IS_MAESTRO=true" >> .env.development + + - name: Set Wallet phrase + env: + WALLET_PHRASE: ${{ secrets.WALLET_PHRASE }} + run: yarn set:WalletPhrase + + - name: Create Sentry properties + run: | + echo "${{ secrets.SENTRY_PROPERTIES }}" > sentry.properties + echo "${{ secrets.SENTRY_PROPERTIES }}" > android/sentry.properties + + - name: Set native config values + run: yarn set:dev + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ hashFiles('android/**/*.gradle*', 'android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: gradle- + + - name: Enable KVM for emulator + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Start Metro bundler + run: | + npx react-native start & + echo $! > /tmp/metro.pid + npx wait-on http://localhost:8081/status --timeout 60000 + + - name: Build debug APK and test APK + run: | + cd android + ./gradlew assembleDebug assembleDebugAndroidTest \ + -PreactNativeArchitectures=x86_64 \ + --no-daemon + + - name: Check disk space before emulator start + run: | + echo "=== Disk after APK build, before emulator ===" + df -h + echo "=== Gradle cache size ===" + du -sh ~/.gradle/caches 2>/dev/null || true + echo "=== Android build outputs size ===" + du -sh android/app/build 2>/dev/null || true + + - name: Start Android emulator and run Espresso tests + uses: reactivecircus/android-emulator-runner@v2 + timeout-minutes: 75 + with: + api-level: 34 + arch: x86_64 + profile: pixel_6 + avd-name: e2e-emulator + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -memory 4096 + disable-animations: true + script: | + adb logcat -c + adb logcat > /home/runner/work/_temp/device-logcat.txt & + echo $! > /tmp/logcat.pid + adb reverse tcp:8081 tcp:8081 + bash scripts/run-tests.sh + kill $(cat /tmp/logcat.pid) || true + + - name: Stop Metro bundler + if: always() + run: kill $(cat /tmp/metro.pid) || true + + - name: Copy Allure results to workspace + if: always() + run: | + mkdir -p allure-results + cp -r "$ALLURE_RESULTS_DIR/." allure-results/ || true + find allure-results -type f || echo "nothing" + + - name: Check Allure results were produced + if: always() + run: | + if [ -z "$(find allure-results -name '*.json' 2>/dev/null)" ]; then + echo "::error::No Allure result JSON files found under allure-results/ — failing the job instead of uploading an empty archive to Testlio." + exit 1 + else + echo "Allure results found:" + find allure-results -name '*.json' + fi + + - name: Upload raw Espresso test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: espresso-android-results + path: android/app/build/outputs/androidTest-results/connected/ + retention-days: 7 + + - name: Install Allure CLI + if: always() + run: | + sudo apt-get update -q + sudo apt-get install -y -q default-jre-headless + curl -fLo allure.tgz https://github.com/allure-framework/allure2/releases/download/2.29.0/allure-2.29.0.tgz + tar -xzf allure.tgz + sudo mv allure-2.29.0 /opt/allure + sudo ln -sf /opt/allure/bin/allure /usr/local/bin/allure + + - name: Zip Allure Results + if: always() + run: | + rm -f allure-results.zip + zip -r allure-results.zip allure-results/ + echo "=== Zip contents ===" + unzip -l allure-results.zip | head -30 + + - name: Upload Allure Results Artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: allure-results + path: allure-results.zip + retention-days: 7 + + - name: Generate Allure HTML Report + if: always() + run: allure generate allure-results --clean -o allure-report + + - name: Upload Allure HTML Report + if: always() + uses: actions/upload-artifact@v4 + with: + name: allure-html-report + path: allure-report + retention-days: 7 + + - name: Install Testlio CLI + if: always() + run: npm install -g @testlio/cli + + - name: Upload Results to Testlio + if: always() + env: + TESTLIO_DEVICE_ID: ${{ secrets.TESTLIO_ANDROID_DEVICE_ID }} + RUN_API_TOKEN: ${{ inputs.environment == 'Sandbox' && secrets.TESTLIO_RUN_TOKEN || secrets.TESTLIO_RUN_TOKEN_BITPAY }} + run: | + if [ "${{ inputs.environment }}" = "Sandbox" ]; then + export TESTLIO_PROJECT_CONFIG="testlio-cli/project-config-sandbox.json" + else + export TESTLIO_PROJECT_CONFIG="testlio-cli/project-config.json" + fi + testlio create-run \ + --testConfig testlio-cli/test-config.json \ + --projectConfig "$TESTLIO_PROJECT_CONFIG" \ + --externalResults true \ + --resultProvider local \ + --automatedDeviceIds "$TESTLIO_DEVICE_ID" + + testlio parse-run-results \ + --projectConfig "$TESTLIO_PROJECT_CONFIG" \ + --path allure-results.zip \ + --automatedDeviceId "$TESTLIO_DEVICE_ID" + + testlio finalize-results \ + --projectConfig "$TESTLIO_PROJECT_CONFIG" diff --git a/.github/workflows/android-espresso.yml b/.github/workflows/android-espresso.yml new file mode 100644 index 0000000000..ff1a16c881 --- /dev/null +++ b/.github/workflows/android-espresso.yml @@ -0,0 +1,274 @@ +name: Android Espresso E2E + +on: + workflow_run: + workflows: ["iOS XCUITest"] + types: + - completed + +concurrency: + group: e2e-android-espresso-${{ github.ref }} + cancel-in-progress: true + +jobs: + android-espresso-tests: + name: Android Espresso UI Tests + runs-on: ubuntu-latest + timeout-minutes: 120 + env: + ALLURE_RESULTS_DIR: ${{ github.workspace }}/allure-results-raw + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + + - name: Force HTTPS for GitHub git dependencies + run: | + git config --global url."https://github.com/".insteadOf "git@github.com:" + git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" + git config --global url."https://github.com/".insteadOf "git+ssh://git@github.com/" + git config --global url."https://github.com/".insteadOf "git://github.com/" + + - name: Rewrite yarn.lock git+ssh URLs to HTTPS + run: | + if [ -f yarn.lock ]; then + sed -i 's#ssh://git@github.com/#https://github.com/#g' yarn.lock + sed -i 's#git+ssh://git@github.com/#https://github.com/#g' yarn.lock + sed -i 's#git@github.com:#https://github.com/#g' yarn.lock + fi + + - name: Free disk space + run: | + echo "=== Disk before cleanup ===" + df -h + + sudo rm -rf /usr/share/dotnet + sudo rm -rf /usr/local/lib/android/sdk/ndk/2[0-6]* + sudo rm -rf /opt/ghc + sudo rm -rf /usr/local/share/boost + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + sudo rm -rf /usr/local/share/powershell + sudo rm -rf /usr/share/swift + sudo rm -rf /opt/az + sudo rm -rf /usr/local/share/chromium + sudo rm -rf /usr/share/miniconda + + # Docker preloads several GB of images on GitHub-hosted runners + # that this workflow never uses. + docker image prune -af 2>/dev/null || true + docker system prune -af --volumes 2>/dev/null || true + + echo "=== Disk after cleanup ===" + df -h + + - name: Enable Corepack + run: corepack enable + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: '.node-version' + cache: 'yarn' + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Install dependencies + env: + GIT_TERMINAL_PROMPT: "0" + run: yarn install --frozen-lockfile --network-timeout 600000 + + - name: Check disk space after dependency install + run: | + echo "=== Disk after yarn install ===" + df -h + echo "=== Largest node_modules subdirs ===" + du -sh node_modules/* 2>/dev/null | sort -rh | head -15 + + - name: Create env file + env: + ENV_FILE: ${{ secrets.ENV_DEVELOPMENT }} + run: | + echo "$ENV_FILE" > .env.development + # E2E stability: redux-logger serializes the ENTIRE Redux state tree + # to logcat on every dispatched action. A recovery-phrase import fires + # hundreds of actions (per wallet, per chain, per token) and the + # serialization pins the JS thread long enough to blow past the + # Espresso wait budgets, so force dev logging off for the automated + # run regardless of what the secret carries. + sed -i '/^DISABLE_DEVELOPMENT_LOGGING=/d' .env.development + echo "DISABLE_DEVELOPMENT_LOGGING=true" >> .env.development + # E2E stability: index.js only calls LogBox.ignoreAllLogs() when + # IS_MAESTRO === 'true'. Without it, any console.error during bootstrap + # (e.g. "zenledgerInitialize: credentials not found" when the secret + # carries no ZenLedger creds) throws up the RN LogBox "Console Error" + # overlay, which covers the onboarding screen and makes every test + # fail on a missing skip-button. Force the flag on for the automated run. + sed -i '/^IS_MAESTRO=/d' .env.development + echo "IS_MAESTRO=true" >> .env.development + + - name: Set Wallet phrase + env: + WALLET_PHRASE: ${{ secrets.WALLET_PHRASE }} + run: yarn set:WalletPhrase + + - name: Create Sentry properties + run: | + echo "${{ secrets.SENTRY_PROPERTIES }}" > sentry.properties + echo "${{ secrets.SENTRY_PROPERTIES }}" > android/sentry.properties + + - name: Set native config values + run: yarn set:dev + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ hashFiles('android/**/*.gradle*', 'android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: gradle- + + - name: Enable KVM for emulator + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Start Metro bundler + run: | + npx react-native start & + echo $! > /tmp/metro.pid + npx wait-on http://localhost:8081/status --timeout 60000 + + - name: Build debug APK and test APK + run: | + cd android + ./gradlew assembleDebug assembleDebugAndroidTest \ + -PreactNativeArchitectures=x86_64 \ + --no-daemon + + - name: Check disk space before emulator start + run: | + echo "=== Disk after APK build, before emulator ===" + df -h + echo "=== Gradle cache size ===" + du -sh ~/.gradle/caches 2>/dev/null || true + echo "=== Android build outputs size ===" + du -sh android/app/build 2>/dev/null || true + + - name: Start Android emulator and run Espresso tests + uses: reactivecircus/android-emulator-runner@v2 + timeout-minutes: 75 + with: + api-level: 34 + arch: x86_64 + profile: pixel_6 + avd-name: e2e-emulator + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -memory 4096 + disable-animations: true + script: | + adb logcat -c + adb logcat > /home/runner/work/_temp/device-logcat.txt & + echo $! > /tmp/logcat.pid + adb reverse tcp:8081 tcp:8081 + bash scripts/run-tests.sh + kill $(cat /tmp/logcat.pid) || true + + - name: Stop Metro bundler + if: always() + run: kill $(cat /tmp/metro.pid) || true + + - name: Copy Allure results to workspace + if: always() + run: | + mkdir -p allure-results + cp -r "$ALLURE_RESULTS_DIR/." allure-results/ || true + find allure-results -type f || echo "nothing" + + - name: Check Allure results were produced + if: always() + run: | + if [ -z "$(find allure-results -name '*.json' 2>/dev/null)" ]; then + echo "::error::No Allure result JSON files found under allure-results/ — failing the job instead of uploading an empty archive to Testlio." + exit 1 + else + echo "Allure results found:" + find allure-results -name '*.json' + fi + + - name: Upload raw Espresso test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: espresso-android-results + path: android/app/build/outputs/androidTest-results/connected/ + retention-days: 7 + + - name: Install Allure CLI + if: always() + run: | + sudo apt-get update -q + sudo apt-get install -y -q default-jre-headless + curl -fLo allure.tgz https://github.com/allure-framework/allure2/releases/download/2.29.0/allure-2.29.0.tgz + tar -xzf allure.tgz + sudo mv allure-2.29.0 /opt/allure + sudo ln -sf /opt/allure/bin/allure /usr/local/bin/allure + + - name: Zip Allure Results + if: always() + run: | + rm -f allure-results.zip + zip -r allure-results.zip allure-results/ + echo "=== Zip contents ===" + unzip -l allure-results.zip | head -30 + + - name: Upload Allure Results Artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: allure-results + path: allure-results.zip + retention-days: 7 + + - name: Generate Allure HTML Report + if: always() + run: allure generate allure-results --clean -o allure-report + + - name: Upload Allure HTML Report + if: always() + uses: actions/upload-artifact@v4 + with: + name: allure-html-report + path: allure-report + retention-days: 7 + + - name: Install Testlio CLI + if: always() + run: npm install -g @testlio/cli + + - name: Upload Results to Testlio + if: always() + env: + RUN_API_TOKEN: ${{ secrets.TESTLIO_RUN_TOKEN_BITPAY }} + TESTLIO_DEVICE_ID: ${{ secrets.TESTLIO_ANDROID_DEVICE_ID }} + run: | + testlio create-run \ + --testConfig testlio-cli/test-config.json \ + --projectConfig testlio-cli/project-config.json \ + --externalResults true \ + --resultProvider local \ + --automatedDeviceIds "$TESTLIO_DEVICE_ID" + + testlio parse-run-results \ + --projectConfig testlio-cli/project-config.json \ + --path allure-results.zip \ + --automatedDeviceId "$TESTLIO_DEVICE_ID" + + testlio finalize-results \ + --projectConfig testlio-cli/project-config.json \ No newline at end of file diff --git a/.github/workflows/ios-xcuitest-sandbox.yml b/.github/workflows/ios-xcuitest-sandbox.yml new file mode 100644 index 0000000000..cbe750c50d --- /dev/null +++ b/.github/workflows/ios-xcuitest-sandbox.yml @@ -0,0 +1,279 @@ +name: iOS XCUITest Sandbox + +on: + workflow_dispatch: + inputs: + environment: + description: "Select the environment (Sandbox or Automation)" + required: true + default: "Sandbox" + type: choice + options: + - Sandbox + - Main_Workspace + +concurrency: + group: e2e-ios-xcui-${{ github.ref }} + cancel-in-progress: true + +jobs: + ios-ui-tests: + runs-on: macos-latest + timeout-minutes: 180 + + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Cache node_modules + id: cache-node-modules + uses: actions/cache@v4 + with: + path: node_modules + key: node-modules-${{ hashFiles('yarn.lock') }} + + - name: Configure git to use HTTPS instead of SSH + run: git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" + + - name: Install JS dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: yarn install --frozen-lockfile + + - name: Generate DKLS vendor files + run: node ./scripts/generate-dkls-vendor.js + + - name: Create local test environment file + run: | + cp .env.template .env.development + echo "DISABLE_DEVELOPMENT_LOGGING=true" >> .env.development + echo "IS_MAESTRO=true" >> .env.development + + - name: Set Wallet phrase + env: + WALLET_PHRASE: ${{ secrets.WALLET_PHRASE }} + run: yarn set:WalletPhrase + + - name: Create local Sentry properties + run: | + touch sentry.properties ios/sentry.properties + + - name: Set native config values + run: yarn set:dev + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '2.7.6' + bundler-cache: true + + - name: Select Xcode 26.2 + run: sudo xcode-select -switch /Applications/Xcode_26.2.app + + - name: Debug - installed runtimes + run: xcrun simctl list runtimes + + - name: Debug - Xcode and OS version + run: | + echo "=== Xcode version ===" + xcodebuild -version + echo "=== macOS version ===" + sw_vers + echo "=== Available Xcode installations ===" + ls /Applications/ | grep Xcode + + - name: List available simulators + run: xcrun simctl list devices available + + - name: Clean Xcode caches + run: | + rm -rf ios/build + rm -rf ~/Library/Developer/Xcode/DerivedData || true + + - name: Disable Sentry upload + run: echo "SENTRY_DISABLE_AUTO_UPLOAD=true" >> $GITHUB_ENV + + - name: Install Pods + run: cd ios && bundle exec pod install + + - name: Debug - Braze pod version + run: grep -A2 "Braze" ios/Podfile.lock + + - name: Generate React Native Bundle + run: | + npx react-native bundle \ + --entry-file index.js \ + --platform ios \ + --dev false \ + --bundle-output ios/main.jsbundle \ + --assets-dest ios + + - name: Boot Simulator + run: | + UDID=$(xcrun simctl list devices available | grep "iPhone 17 (" | grep -v "Pro\|Plus\|Max\|Air\|e (" | head -1 | grep -E -o '\([0-9A-F-]+\)' | tr -d '()') + if [ -z "$UDID" ]; then + echo "ERROR: Could not find iPhone 17 UDID" + exit 1 + fi + echo "Booting simulator UDID: $UDID" + xcrun simctl boot "$UDID" || true + xcrun simctl bootstatus "$UDID" -b + echo "SIMULATOR_UDID=$UDID" >> $GITHUB_ENV + + # Dynamically get the iOS version of the booted simulator + IOS_VERSION=$(xcrun simctl list devices available | grep "$UDID" | grep -E -o 'OS:[0-9]+\.[0-9]+' | cut -d: -f2) + if [ -z "$IOS_VERSION" ]; then + # fallback: get version from runtime list + IOS_VERSION=$(xcrun simctl list runtimes | grep "iOS" | head -1 | grep -E -o '[0-9]+\.[0-9]+' | head -1) + fi + echo "iOS version: $IOS_VERSION" + echo "IOS_VERSION=$IOS_VERSION" >> $GITHUB_ENV + + - name: Build for testing + env: + NODE_OPTIONS: "--max_old_space_size=8192" + SENTRY_DISABLE_AUTO_UPLOAD: true + run: | + export NODE_BINARY=$(which node) + export PATH="$(dirname "$NODE_BINARY"):$PATH" + set -o pipefail + xcodebuild \ + -workspace ios/BitPayApp.xcworkspace \ + -scheme BitPayApp \ + -configuration Release \ + -destination "platform=iOS Simulator,id=$SIMULATOR_UDID" \ + -derivedDataPath Build \ + -only-testing:BitPayAppUITests \ + VALIDATE_WORKSPACE=NO \ + VALIDATE_PRODUCT=NO \ + IPHONEOS_DEPLOYMENT_TARGET=${{ env.IOS_VERSION }} \ + NODE_BINARY="$NODE_BINARY" \ + build-for-testing 2>&1 | tee build-output.log + BUILD_EXIT=${PIPESTATUS[0]} + if [ $BUILD_EXIT -ne 0 ]; then + echo "=== BUILD ERRORS ===" + grep -E "error:" build-output.log | grep -v "warning\|note" | head -50 + fi + exit $BUILD_EXIT + + - name: Upload build log + if: failure() + uses: actions/upload-artifact@v4 + with: + name: build-log + path: build-output.log + retention-days: 7 + + - name: Run XCUITest in order + run: | + set -o pipefail + rm -rf TestResults.xcresult + xcodebuild \ + -workspace ios/BitPayApp.xcworkspace \ + -scheme BitPayApp \ + -configuration Release \ + -destination "platform=iOS Simulator,id=$SIMULATOR_UDID" \ + -derivedDataPath Build \ + -resultBundlePath TestResults.xcresult \ + -parallel-testing-enabled NO \ + -only-testing:BitPayAppUITests/Test591_OnboardingCreateWallet \ + -only-testing:BitPayAppUITests/Test592_ImportWalletRecoveryPhrase \ + -only-testing:BitPayAppUITests/Test593_BTCConfirmPaymentScreen \ + -only-testing:BitPayAppUITests/Test594_BasicSwapBTC \ + -only-testing:BitPayAppUITests/Test595_BasicSellBTC \ + -only-testing:BitPayAppUITests/Test596_BasicBuyBTC \ + test-without-building 2>&1 | tee test-output.log + TEST_EXIT=${PIPESTATUS[0]} + if [ $TEST_EXIT -ne 0 ]; then + echo "=== TEST FAILURES ===" + grep -E "failed:|error:" test-output.log | head -50 + fi + exit $TEST_EXIT + + - name: Upload XCUITest Results + if: always() + uses: actions/upload-artifact@v4 + with: + name: xcuitest-results + path: TestResults.xcresult + retention-days: 7 + + - name: Install Allure CLI + if: always() + run: brew install allure || true + + - name: Install allure-xcresult + if: always() + run: | + if ! command -v allure-xcresult &> /dev/null; then + curl -L -o allure-xcresult https://github.com/kvld/allure-xcresult/releases/download/v1.5.0/allure-xcresult + chmod +x allure-xcresult + sudo mv allure-xcresult /usr/local/bin/ + fi + + - name: Convert XCResult to Allure Results + if: always() + run: | + allure-xcresult \ + --input TestResults.xcresult \ + --output allure-results + + - name: Zip Allure Results + if: always() + run: | + rm -f allure-results.zip + zip -r allure-results.zip allure-results + + - name: Upload Allure Results Artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: allure-results + path: allure-results.zip + retention-days: 7 + + - name: Generate Allure HTML Report + if: always() + run: allure generate allure-results --clean -o allure-report + + - name: Upload Allure HTML Report + if: always() + uses: actions/upload-artifact@v4 + with: + name: allure-html-report + path: allure-report + retention-days: 7 + + - name: Install Testlio CLI + if: always() + run: npm install -g @testlio/cli + + - name: Upload Results to Testlio + if: always() + env: + RUN_API_TOKEN: ${{ inputs.environment == 'Sandbox' && secrets.TESTLIO_RUN_TOKEN || secrets.TESTLIO_RUN_TOKEN_BITPAY }} + TESTLIO_DEVICE_ID: ${{ secrets.TESTLIO_IOS_DEVICE_ID }} + run: | + if [ "${{ inputs.environment }}" = "Sandbox" ]; then + export TESTLIO_PROJECT_CONFIG="testlio-cli/project-config-sandbox.json" + else + export TESTLIO_PROJECT_CONFIG="testlio-cli/project-config.json" + fi + testlio create-run \ + --testConfig testlio-cli/test-config.json \ + --projectConfig "$TESTLIO_PROJECT_CONFIG" \ + --externalResults true \ + --resultProvider local \ + --automatedDeviceIds "$TESTLIO_DEVICE_ID" + + testlio parse-run-results \ + --projectConfig "$TESTLIO_PROJECT_CONFIG" \ + --path allure-results.zip \ + --automatedDeviceId "$TESTLIO_DEVICE_ID" + + testlio finalize-results \ + --projectConfig "$TESTLIO_PROJECT_CONFIG" \ No newline at end of file diff --git a/.github/workflows/ios-xcuitest.yml b/.github/workflows/ios-xcuitest.yml new file mode 100644 index 0000000000..fcb2b3253e --- /dev/null +++ b/.github/workflows/ios-xcuitest.yml @@ -0,0 +1,269 @@ +name: iOS XCUITest + +on: + schedule: + - cron: '0 08 * * *' # every day at 08:00 UTC + +concurrency: + group: e2e-ios-xcui-${{ github.ref }} + cancel-in-progress: true + +jobs: + ios-ui-tests: + if: ${{ github.event.schedule == '0 08 * * *' }} + runs-on: macos-latest + timeout-minutes: 180 + + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + with: + ref: develop + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Cache node_modules + id: cache-node-modules + uses: actions/cache@v4 + with: + path: node_modules + key: node-modules-${{ hashFiles('yarn.lock') }} + + - name: Configure git to use HTTPS instead of SSH + run: git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" + + - name: Install JS dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: yarn install --frozen-lockfile + + - name: Generate DKLS vendor files + run: node ./scripts/generate-dkls-vendor.js + + - name: Create local test environment file + run: | + cp .env.template .env.development + echo "DISABLE_DEVELOPMENT_LOGGING=true" >> .env.development + echo "IS_MAESTRO=true" >> .env.development + + - name: Set Wallet phrase + env: + WALLET_PHRASE: ${{ secrets.WALLET_PHRASE }} + run: yarn set:WalletPhrase + + - name: Create local Sentry properties + run: | + touch sentry.properties ios/sentry.properties + + - name: Set native config values + run: yarn set:dev + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '2.7.6' + bundler-cache: true + + - name: Select Xcode 26.2 + run: sudo xcode-select -switch /Applications/Xcode_26.2.app + + - name: Debug - installed runtimes + run: xcrun simctl list runtimes + + - name: Debug - Xcode and OS version + run: | + echo "=== Xcode version ===" + xcodebuild -version + echo "=== macOS version ===" + sw_vers + echo "=== Available Xcode installations ===" + ls /Applications/ | grep Xcode + + - name: List available simulators + run: xcrun simctl list devices available + + - name: Clean Xcode caches + run: | + rm -rf ios/build + rm -rf ~/Library/Developer/Xcode/DerivedData || true + + - name: Disable Sentry upload + run: echo "SENTRY_DISABLE_AUTO_UPLOAD=true" >> $GITHUB_ENV + + - name: Install Pods + run: cd ios && bundle exec pod install + + - name: Debug - Braze pod version + run: grep -A2 "Braze" ios/Podfile.lock + + - name: Generate React Native Bundle + run: | + npx react-native bundle \ + --entry-file index.js \ + --platform ios \ + --dev false \ + --bundle-output ios/main.jsbundle \ + --assets-dest ios + + - name: Boot Simulator + run: | + UDID=$(xcrun simctl list devices available | grep "iPhone 17 (" | grep -v "Pro\|Plus\|Max\|Air\|e (" | head -1 | grep -E -o '\([0-9A-F-]+\)' | tr -d '()') + if [ -z "$UDID" ]; then + echo "ERROR: Could not find iPhone 17 UDID" + exit 1 + fi + echo "Booting simulator UDID: $UDID" + xcrun simctl boot "$UDID" || true + xcrun simctl bootstatus "$UDID" -b + echo "SIMULATOR_UDID=$UDID" >> $GITHUB_ENV + + # Dynamically get the iOS version of the booted simulator + IOS_VERSION=$(xcrun simctl list devices available | grep "$UDID" | grep -E -o 'OS:[0-9]+\.[0-9]+' | cut -d: -f2) + if [ -z "$IOS_VERSION" ]; then + # fallback: get version from runtime list + IOS_VERSION=$(xcrun simctl list runtimes | grep "iOS" | head -1 | grep -E -o '[0-9]+\.[0-9]+' | head -1) + fi + echo "iOS version: $IOS_VERSION" + echo "IOS_VERSION=$IOS_VERSION" >> $GITHUB_ENV + + - name: Build for testing + env: + NODE_OPTIONS: "--max_old_space_size=8192" + SENTRY_DISABLE_AUTO_UPLOAD: true + run: | + export NODE_BINARY=$(which node) + export PATH="$(dirname "$NODE_BINARY"):$PATH" + set -o pipefail + xcodebuild \ + -workspace ios/BitPayApp.xcworkspace \ + -scheme BitPayApp \ + -configuration Release \ + -destination "platform=iOS Simulator,id=$SIMULATOR_UDID" \ + -derivedDataPath Build \ + -only-testing:BitPayAppUITests \ + VALIDATE_WORKSPACE=NO \ + VALIDATE_PRODUCT=NO \ + IPHONEOS_DEPLOYMENT_TARGET=${{ env.IOS_VERSION }} \ + NODE_BINARY="$NODE_BINARY" \ + build-for-testing 2>&1 | tee build-output.log + BUILD_EXIT=${PIPESTATUS[0]} + if [ $BUILD_EXIT -ne 0 ]; then + echo "=== BUILD ERRORS ===" + grep -E "error:" build-output.log | grep -v "warning\|note" | head -50 + fi + exit $BUILD_EXIT + + - name: Upload build log + if: failure() + uses: actions/upload-artifact@v4 + with: + name: build-log + path: build-output.log + retention-days: 7 + + - name: Run XCUITest in order + run: | + set -o pipefail + rm -rf TestResults.xcresult + xcodebuild \ + -workspace ios/BitPayApp.xcworkspace \ + -scheme BitPayApp \ + -configuration Release \ + -destination "platform=iOS Simulator,id=$SIMULATOR_UDID" \ + -derivedDataPath Build \ + -resultBundlePath TestResults.xcresult \ + -parallel-testing-enabled NO \ + -only-testing:BitPayAppUITests/Test591_OnboardingCreateWallet \ + -only-testing:BitPayAppUITests/Test592_ImportWalletRecoveryPhrase \ + -only-testing:BitPayAppUITests/Test593_BTCConfirmPaymentScreen \ + -only-testing:BitPayAppUITests/Test594_BasicSwapBTC \ + -only-testing:BitPayAppUITests/Test595_BasicSellBTC \ + -only-testing:BitPayAppUITests/Test596_BasicBuyBTC \ + test-without-building 2>&1 | tee test-output.log + TEST_EXIT=${PIPESTATUS[0]} + if [ $TEST_EXIT -ne 0 ]; then + echo "=== TEST FAILURES ===" + grep -E "failed:|error:" test-output.log | head -50 + fi + exit $TEST_EXIT + + - name: Upload XCUITest Results + if: always() + uses: actions/upload-artifact@v4 + with: + name: xcuitest-results + path: TestResults.xcresult + retention-days: 7 + + - name: Install Allure CLI + if: always() + run: brew install allure || true + + - name: Install allure-xcresult + if: always() + run: | + if ! command -v allure-xcresult &> /dev/null; then + curl -L -o allure-xcresult https://github.com/kvld/allure-xcresult/releases/download/v1.5.0/allure-xcresult + chmod +x allure-xcresult + sudo mv allure-xcresult /usr/local/bin/ + fi + + - name: Convert XCResult to Allure Results + if: always() + run: | + allure-xcresult \ + --input TestResults.xcresult \ + --output allure-results + + - name: Zip Allure Results + if: always() + run: | + rm -f allure-results.zip + zip -r allure-results.zip allure-results + + - name: Upload Allure Results Artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: allure-results + path: allure-results.zip + retention-days: 7 + + - name: Generate Allure HTML Report + if: always() + run: allure generate allure-results --clean -o allure-report + + - name: Upload Allure HTML Report + if: always() + uses: actions/upload-artifact@v4 + with: + name: allure-html-report + path: allure-report + retention-days: 7 + + - name: Install Testlio CLI + if: always() + run: npm install -g @testlio/cli + + - name: Upload Results to Testlio + if: always() + env: + RUN_API_TOKEN: ${{ secrets.TESTLIO_RUN_TOKEN_BITPAY }} + TESTLIO_DEVICE_ID: ${{ secrets.TESTLIO_IOS_DEVICE_ID }} + run: | + testlio create-run \ + --testConfig testlio-cli/test-config.json \ + --projectConfig testlio-cli/project-config.json \ + --externalResults true \ + --resultProvider local \ + --automatedDeviceIds "$TESTLIO_DEVICE_ID" + + testlio parse-run-results \ + --projectConfig testlio-cli/project-config.json \ + --path allure-results.zip \ + --automatedDeviceId "$TESTLIO_DEVICE_ID" + + testlio finalize-results \ + --projectConfig testlio-cli/project-config.json \ No newline at end of file diff --git a/README.md b/README.md index 7e44a57190..0bcf15c648 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,11 @@ +# Tests + # BitPay App v2 + Welcome to BitPay App v2! ## Get Started + Install dependencies with `yarn` >= 14.15.0 ## Translations @@ -17,55 +21,64 @@ and the review workflow. Run `yarn translation:check` before submitting changes. 3. Build and deploy to simulator `yarn ios` or device `yarn ios:device` ### Android + 1. `yarn start` to start dev server 2. Build and deploy to simulator or device `yarn android` #### Accessing your local server + To make requests to your local server, first take your local BitPay server cert and copy it into `android/app/src/main/res/raw` folder in either .pem or .der format. + - To convert .crt to .der, run: `openssl x509 -in your_cert_name.crt -out your_cert_name.der -outform DER` Then open `android/app/src/main/res/xml/network_security_config.xml` and add your local IP to the domain-config and your cert to the trust-anchors (without the file extension). -For example if your local IP is `123.0.0.7` and your cert is in `res/raw/lbitpay.der`: +For example if your local IP is `123.0.0.7` and your cert is in `res/raw/lbitpay.der`: - + ... 123.0.0.7 - - - - - + ### Redux Dev Tools + This project uses `react-redux` https://react-redux.js.org/ for state management. To take advantage of the tooling available, go to https://github.com/jhen0409/react-native-debugger and install the debugger. -1. To enable - make sure debugger is open - tap `D` in the hosting terminal window or shake the device +1. To enable - make sure debugger is open - tap `D` in the hosting terminal window or shake the device 2. A menu will popup - tap `Debug with Chrome`(IOS) or `Debug`(Android) 3. The logs should move from the terminal to the debugger ### Storybook + 1. In `src/contants/config.ts` change `APP_LOAD_STORY_BOOK=false` to `APP_LOAD_STORY_BOOK=true` 2. Run `yarn ` ex: `yarn ios`. Since we set `APP_LOAD_STORY_BOOK=true`, this runs Storybook instead of your actual app. ## Deeplinking -Test deeplinking via command line with these commands (note: ampersand must be escaped for multiple params): + +Test deeplinking via command line with these commands (note: ampersand must be escaped for multiple params): #### iOS + `npx uri-scheme open "bitpay://your/deeplink/path?params1=foo1\¶m2=foo2" --ios` #### Android + `npx uri-scheme open "bitpay://your/deeplink/path?param1=foo1\¶m2=foo2" --android` ### Modifying the intent prefix + If you want to associate the app with a different intent prefix eg. `myapp://`: 1. Open `src/constants/config.ts` 2. Modify `APP_DEEPLINK_PREFIX` to your desired prefix eg. `myapp://` (with colon and slashes) 3. Update the OS specific configs: - #### iOS + +#### iOS + 1. Open `ios/BitPayApp/info.plist` 2. Find `CFBundleURLSchemes` and modify the value to your desired prefix (without colon and slashes) eg. `myapp` - #### Android + +#### Android + 1. Open `android/app/src/main/AndroidManifest.xml` 2. Locate `` and modify the value to your desired prefix (without colon and slashes) eg. `myapp` diff --git a/android/app/build.gradle b/android/app/build.gradle index d238784d62..2252b5357e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -150,6 +150,7 @@ android { versionCode 91212433 versionName "14.46.0" missingDimensionStrategy 'react-native-camera', 'mlkit' + testInstrumentationRunner "io.qameta.allure.android.runners.AllureAndroidJUnitRunner" ndk { abiFilters "armeabi-v7a", "x86_64", "arm64-v8a" } @@ -196,6 +197,22 @@ android { } dependencies { + // Espresso libraries + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1' + androidTestImplementation 'androidx.test:runner:1.5.2' + androidTestImplementation 'androidx.test:rules:1.5.0' + androidTestImplementation 'androidx.test:core:1.5.0' + androidTestImplementation 'org.hamcrest:hamcrest:2.2' + androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.3.0' + + // Allure reporting + androidTestImplementation "io.qameta.allure:allure-kotlin-model:2.4.0" + androidTestImplementation "io.qameta.allure:allure-kotlin-commons:2.4.0" + androidTestImplementation "io.qameta.allure:allure-kotlin-junit4:2.4.0" + androidTestImplementation "io.qameta.allure:allure-kotlin-android:2.4.0" + // The version of react-native is set by the React Native Gradle Plugin implementation("com.facebook.react:react-android") implementation platform('com.google.firebase:firebase-bom:29.1.0') @@ -205,6 +222,10 @@ dependencies { implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0") implementation("androidx.core:core-splashscreen:1.0.0") + // Test Orchestrator + TestStorage + androidTestUtil "androidx.test:orchestrator:1.5.1" + androidTestImplementation "androidx.test.services:test-services:1.5.0" + if (hermesEnabled.toBoolean()) { implementation("com.facebook.react:hermes-android") } else { diff --git a/android/app/src/androidTest/AndroidManifest.xml b/android/app/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000000..343a7b66c4 --- /dev/null +++ b/android/app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/bitpay/wallet/TestConfig.kt b/android/app/src/androidTest/java/com/bitpay/wallet/TestConfig.kt new file mode 100644 index 0000000000..abb03af63b --- /dev/null +++ b/android/app/src/androidTest/java/com/bitpay/wallet/TestConfig.kt @@ -0,0 +1,5 @@ +package com.bitpay.wallet + +object TestConfig { + const val WALLET_PHRASE = "WALLET_PHRASE_REPLACE_ME" +} diff --git a/android/app/src/androidTest/java/com/bitpay/wallet/base/BaseTest.kt b/android/app/src/androidTest/java/com/bitpay/wallet/base/BaseTest.kt new file mode 100644 index 0000000000..65ead01ce7 --- /dev/null +++ b/android/app/src/androidTest/java/com/bitpay/wallet/base/BaseTest.kt @@ -0,0 +1,195 @@ +package com.bitpay.wallet.base + +import android.content.Intent +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import com.bitpay.wallet.pages.OnboardingPage +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import com.bitpay.wallet.utils.allureScreenshot +import com.bitpay.wallet.utils.WaitUtils +import org.junit.Rule +import androidx.test.uiautomator.Until +import androidx.test.uiautomator.By +import com.bitpay.wallet.utils.RetryRule +import org.junit.After +import org.junit.rules.TestName + +open class BaseTest { + + companion object { + var skipRelaunch: Boolean = false + var skipOnboardingHandling: Boolean = false + } + + /** + * Number of retry attempts for this test class. Defaults to 3. + * + * IMPORTANT: Retries here happen IN-PROCESS — the same app instance is + * reused across attempts, launchApp() only clears the Activity stack + * (FLAG_ACTIVITY_CLEAR_TASK), not persisted app data (prefs, DB, wallet + * state). A true "pm clear"-style reset can't safely run from inside + * the same process hosting the instrumentation (it would kill the test + * harness itself), so retries are only safe for read-only/idempotent + * tests (navigation checks, text assertions). + * + * For state-mutating flows (e.g. wallet creation, onboarding), override + * this to 1 in the test class so a genuine failure surfaces immediately + * instead of retrying against an app that's now in a different state + * than attempt 1 expected: + * + * override val retryCount = 1 + */ + protected open val retryCount: Int = 3 + + // Outer rule (order 0): captures a screenshot only once all retries + // are exhausted and the test has truly failed. + @get:Rule(order = 0) + val screenshotOnFailureRule = object : TestWatcher() { + override fun failed(e: Throwable?, description: Description?) { + allureScreenshot("Failure - ${description?.methodName}") + } + } + + // Inner rule (order 1): re-runs @Before -> test -> @After up to + // retryCount times before letting a failure propagate outward. + @get:Rule(order = 1) + val retryRule by lazy { RetryRule(retryCount) } + + val testName = TestName() + + private val onboardingPage = OnboardingPage() + + @Before + fun setup() { + if (!skipRelaunch) launchApp() + dismissNearbyDevicesPrompt(allow = true) + dismissLogboxIfPresent() + dismissDebuggerNotificationIfPresent() + if (!skipOnboardingHandling) handleOnboardingIfPresent() + } + + @After + fun stopRecording() { + // val video = ScreenRecorder.stop() + // allureVideo("Screen Recording - ${testName.methodName}", video) + } + + private fun launchApp() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = instrumentation.targetContext + + val device: UiDevice? = try { + UiDevice.getInstance(instrumentation) + } catch (e: IllegalStateException) { + android.util.Log.w("BaseTest", "UiAutomation already registered: ${e.message}") + null + } + + device?.let { if (!it.isScreenOn) it.wakeUp() } + + val intent = context.packageManager.getLaunchIntentForPackage("com.bitpay.wallet") + intent!!.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + context.startActivity(intent) + + // CI emulators are typically slower than local hardware to load and + // render the JS bundle. Give RN's startup a generous window before any + // test attempts its first onView() lookup, rather than racing ahead. + device?.wait(Until.hasObject(By.pkg("com.bitpay.wallet").depth(0)), 20000) + Thread.sleep(2000) + } + + /** + * Dismisses the React Native Logbox error overlay if it is present. + * The overlay (shown only in DEV builds) blocks all UI interaction, so it + * must be cleared before any test can proceed. Loops up to 5 times to + * handle multiple stacked errors (e.g. "Log 2 of 2"). + */ + private fun dismissLogboxIfPresent() { + val logboxIndicator = withText("Console Error") + val dismissButton = withText("Dismiss") + + repeat(5) { + try { + WaitUtils.waitForView(logboxIndicator, timeoutMs = 3000) + android.util.Log.w("BaseTest", "Logbox overlay detected — dismissing") + onView(dismissButton).perform(click()) + Thread.sleep(500) + } catch (_: Throwable) { + return + } + } + } + + /** + * Dismisses React Native's "Open debugger to view warnings." notification if + * it is showing. In dev builds a console warning surfaces this pill pinned to + * the bottom of the screen, where it overlays — and swallows taps meant for — + * bottom-aligned buttons such as "Continue without an account". RN only shows + * it once per session, so a single dismissal holds. No-op if it isn't present. + */ + private fun dismissDebuggerNotificationIfPresent() { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val selector = By.descContains("Open debugger to view warnings") + val pill = device.wait(Until.findObject(selector), 5000) ?: return + + android.util.Log.w("BaseTest", "Debugger-warnings notification present — dismissing") + + val bounds = pill.visibleBounds + // The dismiss "X" is the right-most clickable child; fall back to a tap at + // the notification's trailing edge if it can't be resolved. + val dismiss = pill.findObjects(By.clickable(true)) + .filter { it.visibleBounds != bounds } + .maxByOrNull { it.visibleBounds.centerX() } + dismiss?.click() ?: device.click(bounds.right - 40, bounds.centerY()) + device.waitForIdle() + + if (device.hasObject(selector)) { + device.click(bounds.right - 40, bounds.centerY()) + device.waitForIdle() + } + Thread.sleep(300) + } + + /** + * If the onboarding "Continue without an account" screen is currently + * displayed, walk through the standard onboarding flow once so every + * test starts from a consistent post-onboarding state. + * If that screen isn't present (e.g. app is already past onboarding, + * or skipRelaunch left it on a different screen), this is a no-op. + */ + private fun handleOnboardingIfPresent() { + if (!onboardingPage.isContinueWithoutAccountDisplayed()) { + return + } + + onboardingPage.waitForPageToLoad() + dismissDebuggerNotificationIfPresent() + onboardingPage.clickContinueWithoutAccount() + onboardingPage.clickSkip() // Skip turn on notifications + + assertTrue( + "Protect Your Wallet was not displayed", + onboardingPage.verifyProtectYourWalletIsDisplayed() + ) + onboardingPage.clickSkip() + } + + private fun dismissNearbyDevicesPrompt(allow: Boolean) { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val buttonId = if (allow) "permission_allow_button" else "permission_deny_button" + val button = listOf( + "com.android.permissioncontroller", + "com.google.android.permissioncontroller", + ).firstNotNullOfOrNull { pkg -> + device.wait(Until.findObject(By.res(pkg, buttonId)), 2000) + } ?: return + button.click() + device.waitForIdle() + } +} \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/bitpay/wallet/pages/AddYourCryptoOptionPage.kt b/android/app/src/androidTest/java/com/bitpay/wallet/pages/AddYourCryptoOptionPage.kt new file mode 100644 index 0000000000..3c0e0d1f9f --- /dev/null +++ b/android/app/src/androidTest/java/com/bitpay/wallet/pages/AddYourCryptoOptionPage.kt @@ -0,0 +1,27 @@ +package com.bitpay.wallet.pages + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import com.bitpay.wallet.utils.WaitUtils +import com.bitpay.wallet.utils.WaitUtils.withTestId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.withContentDescription +import org.hamcrest.Matchers.allOf + +class AddYourCryptoOptionPage { + + // ---- Locators ---- + private val importKeyButton = withTestId("creation-options-import-button") + + + // ---- Actions ---- + + + fun clickImportKey() { + WaitUtils.waitForView(importKeyButton) + onView(importKeyButton).perform(click()) + } + +} \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/bitpay/wallet/pages/ConfirmPaymentPage.kt b/android/app/src/androidTest/java/com/bitpay/wallet/pages/ConfirmPaymentPage.kt new file mode 100644 index 0000000000..236d07b380 --- /dev/null +++ b/android/app/src/androidTest/java/com/bitpay/wallet/pages/ConfirmPaymentPage.kt @@ -0,0 +1,43 @@ +package com.bitpay.wallet.pages + +import androidx.test.espresso.Espresso.onView +import com.bitpay.wallet.utils.WaitUtils +import com.bitpay.wallet.utils.WaitUtils.withTestId +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.pressImeActionButton +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText + +class ConfirmPaymentPage { + + // ---- Locators ---- + private val confirmPaymentText = withText("Confirm Payment") + + private val summaryText = withText("SUMMARY") + + + // ---- Actions ---- + + fun verifyConfirmPaymentTitleDisplayed(): Boolean { + return try { + WaitUtils.waitForView(confirmPaymentText) + onView(confirmPaymentText).check(matches(isDisplayed())) + true + } catch (e: Throwable) { + false + } + } + + fun verifySummaryTextDisplayed(): Boolean { + return try { + WaitUtils.waitForView(summaryText) + onView(summaryText).check(matches(isDisplayed())) + true + } catch (e: Throwable) { + false + } + } + +} \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/bitpay/wallet/pages/HomePage.kt b/android/app/src/androidTest/java/com/bitpay/wallet/pages/HomePage.kt new file mode 100644 index 0000000000..01a15db9ef --- /dev/null +++ b/android/app/src/androidTest/java/com/bitpay/wallet/pages/HomePage.kt @@ -0,0 +1,95 @@ +package com.bitpay.wallet.pages + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import com.bitpay.wallet.utils.WaitUtils +import com.bitpay.wallet.utils.WaitUtils.withTestId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.withContentDescription +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import java.util.regex.Pattern + +class HomePage { + + // ---- Locators ---- + private val portfolioBalanceText = withTestId("portfolio-balance-info-button") + private val myCryptoAddButton = withTestId("my-crypto-add-button") + private val buyButton = withTestId("buy-button") + private val sellButton = withTestId("sell-button") + private val swapButton = withTestId("swap-button") + private val receiveButton = withTestId("receive-button") + private val sendButton = withTestId("send-button") + + + // ---- Actions ---- + + fun waitForPageToLoad() { + WaitUtils.waitForView( + portfolioBalanceText, + timeoutMs = 120000 + ) + } + + fun verifyPortfolioBalanceTextDisplayed(): Boolean { + return try { + WaitUtils.waitForView(portfolioBalanceText) + onView(portfolioBalanceText).check(matches(isDisplayed())) + true + } catch (e: Throwable) { + false + } + } + + fun clickAddYourCrypto() { + WaitUtils.waitForView(myCryptoAddButton) + onView(myCryptoAddButton).perform(click()) + } + + fun clickBuy() { + WaitUtils.waitForView(buyButton) + onView(buyButton).perform(click()) + } + + fun clickSell() { + WaitUtils.waitForView(sellButton) + onView(sellButton).perform(click()) + } + + fun clickSwap() { + WaitUtils.waitForView(swapButton) + onView(swapButton).perform(click()) + } + + fun clickReceive() { + WaitUtils.waitForView(receiveButton) + onView(receiveButton).perform(click()) + } + + fun clickSend() { + // WaitUtils.waitForView(sendButton) + WaitUtils.waitForViewEnabled(sendButton) + onView(sendButton).perform(click()) + } + + + fun waitForFundedBalance(timeoutMs: Long = 180000, intervalMs: Long = 1000) { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val amount = Pattern.compile("^\\$[\\d,]+(\\.\\d+)?$") + val end = System.currentTimeMillis() + timeoutMs + + while (System.currentTimeMillis() < end) { + // The portfolio balance is the top-most dollar amount on Home. + val balance = device.findObjects(By.text(amount)) + .minByOrNull { it.visibleBounds.top } + ?.text + if (balance != null && balance.any { it in '1'..'9' }) { + return + } + Thread.sleep(intervalMs) + } + } +} diff --git a/android/app/src/androidTest/java/com/bitpay/wallet/pages/ImportWalletPage.kt b/android/app/src/androidTest/java/com/bitpay/wallet/pages/ImportWalletPage.kt new file mode 100644 index 0000000000..e651c6495f --- /dev/null +++ b/android/app/src/androidTest/java/com/bitpay/wallet/pages/ImportWalletPage.kt @@ -0,0 +1,132 @@ +package com.bitpay.wallet.pages + +import androidx.test.espresso.Espresso.onView +import com.bitpay.wallet.utils.WaitUtils +import com.bitpay.wallet.utils.WaitUtils.withTestId +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.pressImeActionButton +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until + +class ImportWalletPage { + + // ---- Locators ---- + private val recoveryPhraseInputField = withTestId("import-text-input") + private val importWalletButton = withTestId("import-wallet-button") + + // The import pins the app's main thread for minutes; Espresso onView() polling + // blocks on idle-sync and starves it further, so the wait below uses + // UiAutomator (no idle-sync) and matches by text / content-description. + private val ongoingProcessDesc = By.desc("ongoing-process-message") + private val importInProgressText = By.textStartsWith("Loading ") + private val importWalletButtonDesc = By.desc("Import wallet") + + // Any one of these means the import has moved off the Import screen. + private val firstTermCheckboxDesc = By.desc("first-term-checkbox") + private val termsHeadingText = By.text("I understand that:") + private val agreeAndContinueText = By.text("Agree and Continue") + private val backupPromptText = By.text("Would you like to backup your key?") + private val myKeyText = By.text("My Key") + + private val importAdvanceBudgetMs = 600_000L + private val resubmitCooldownMs = 30_000L + + private val device: UiDevice + get() = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + + // ---- Actions ---- + + fun pressKeyboardTick() { + onView(recoveryPhraseInputField).perform(pressImeActionButton()) + } + + fun enterRecoveryPhrase(recoveryPhrase: String) { + WaitUtils.waitForView(recoveryPhraseInputField) + onView(recoveryPhraseInputField).perform( + ViewActions.click(), + ViewActions.typeText(recoveryPhrase), + ViewActions.closeSoftKeyboard() + ) + } + + fun clickImportWallet() { + WaitUtils.waitForView(importWalletButton) + submitImport() + + if (waitForImportFlowAdvance(importAdvanceBudgetMs)) { + return + } + + throw RuntimeException( + "Import did not progress to backup/terms/home screen within " + + "${importAdvanceBudgetMs / 1000}s (still stuck on import/loading)." + ) + } + + // First tap while the app is still idle — Espresso is fine here. + private fun submitImport(): Boolean { + return try { + onView(importWalletButton).perform(click()) + true + } catch (_: Throwable) { + try { + onView(importWalletButton).perform(WaitUtils.forceClick) + true + } catch (_: Throwable) { + false + } + } + } + + private fun resubmitViaUiAutomator(): Boolean { + val btn = device.findObject(importWalletButtonDesc) + ?: device.findObject(By.text("Import Wallet")) + ?: return false + return try { + btn.click() + true + } catch (_: Throwable) { + false + } + } + + private fun waitForImportFlowAdvance(timeoutMs: Long): Boolean { + val end = System.currentTimeMillis() + timeoutMs + var lastResubmitAt = System.currentTimeMillis() + + while (System.currentTimeMillis() < end) { + if (device.hasObject(firstTermCheckboxDesc) || + device.hasObject(termsHeadingText) || + device.hasObject(agreeAndContinueText) || + device.hasObject(backupPromptText) || + device.hasObject(myKeyText) + ) { + return true + } + + if (device.hasObject(ongoingProcessDesc) || + device.hasObject(importInProgressText) + ) { + Thread.sleep(1_500) + continue + } + + // Back on the Import screen and idle — the first tap may have been + // dropped. Re-submit, at most once per cooldown. + if (device.hasObject(importWalletButtonDesc) && + System.currentTimeMillis() - lastResubmitAt > resubmitCooldownMs + ) { + resubmitViaUiAutomator() + lastResubmitAt = System.currentTimeMillis() + } + + Thread.sleep(1_000) + } + + return device.wait(Until.hasObject(firstTermCheckboxDesc), 5_000) != null + } +} diff --git a/android/app/src/androidTest/java/com/bitpay/wallet/pages/KeyboardPage.kt b/android/app/src/androidTest/java/com/bitpay/wallet/pages/KeyboardPage.kt new file mode 100644 index 0000000000..4f8fb51e34 --- /dev/null +++ b/android/app/src/androidTest/java/com/bitpay/wallet/pages/KeyboardPage.kt @@ -0,0 +1,122 @@ +package com.bitpay.wallet.pages + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.action.ViewActions.click +import com.bitpay.wallet.utils.WaitUtils +import com.bitpay.wallet.utils.WaitUtils.withTestId +import androidx.test.espresso.action.CoordinatesProvider +import androidx.test.espresso.action.GeneralClickAction +import androidx.test.espresso.action.Press +import androidx.test.espresso.action.Tap +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.hasSibling +import androidx.test.espresso.matcher.ViewMatchers.withParent +import androidx.test.espresso.matcher.ViewMatchers.withText +import android.view.View +import org.hamcrest.Matcher +import org.hamcrest.Matchers.allOf + +class KeyboardPage { + + // ---- Locators ---- + + private val continueButton = withText("Continue") + + + // ---- Actions ---- + fun enterAmount(amount: String = "0") { + for (char in amount) { + // Key labels are nested under per-key containers; sibling checks must + // be applied at the parent level, not directly between text views. + val key: Matcher = when (char) { + '0' -> allOf( + withText("0"), + withParent(hasSibling(hasDescendant(withText(".")))) + ) + else -> withText(char.toString()) + } + WaitUtils.waitForView(key) + Thread.sleep(200) + onView(key).perform(click()) + } + } + + fun clickContinue() { + WaitUtils.waitForViewEnabled(continueButton) + onView(continueButton).perform(click()) + } + + + /** + * Backspace key does not expose a unique accessibility identifier. + * Calculate its position dynamically using the relative spacing + * between the keypad keys "6" and "9". + */ + fun clickBackspace(count: Int = 1) { + // The digit labels are not direct siblings; constrain via each label's + // parent being adjacent to the expected neighbor key container. + val sixKey = allOf( + withText("6"), + withParent(hasSibling(hasDescendant(withText("5")))) + ) + val nineKey = allOf( + withText("9"), + withParent(hasSibling(hasDescendant(withText("8")))) + ) + + WaitUtils.waitForView(sixKey) + WaitUtils.waitForView(nineKey) + + var sixView: View? = null + var nineView: View? = null + + onView(sixKey).perform(object : androidx.test.espresso.ViewAction { + override fun getConstraints() = org.hamcrest.Matchers.any(View::class.java) + override fun getDescription() = "capture six key view" + override fun perform(uiController: androidx.test.espresso.UiController, view: View) { + sixView = view + } + }) + + onView(nineKey).perform(object : androidx.test.espresso.ViewAction { + override fun getConstraints() = org.hamcrest.Matchers.any(View::class.java) + override fun getDescription() = "capture nine key view" + override fun perform(uiController: androidx.test.espresso.UiController, view: View) { + nineView = view + } + }) + + val sixLocation = IntArray(2) + val nineLocation = IntArray(2) + sixView!!.getLocationOnScreen(sixLocation) + nineView!!.getLocationOnScreen(nineLocation) + + val sixMidY = sixLocation[1] + (sixView!!.height / 2) + val nineMidY = nineLocation[1] + (nineView!!.height / 2) + val nineMidX = nineLocation[0] + (nineView!!.width / 2) + + val rowHeight = Math.abs(nineMidY - sixMidY) + + val backspaceX = nineMidX.toFloat() + val backspaceY = (nineMidY + rowHeight).toFloat() + + val backspaceCoordinatesProvider = CoordinatesProvider { _ -> + floatArrayOf(backspaceX, backspaceY) + } + + repeat(count) { + onView(nineKey).perform( + GeneralClickAction( + Tap.SINGLE, + backspaceCoordinatesProvider, + Press.FINGER, + android.view.InputDevice.SOURCE_UNKNOWN, + android.view.MotionEvent.BUTTON_PRIMARY + ) + ) + } + } + + +} \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/bitpay/wallet/pages/MyKeyPage.kt b/android/app/src/androidTest/java/com/bitpay/wallet/pages/MyKeyPage.kt new file mode 100644 index 0000000000..f50418dfb8 --- /dev/null +++ b/android/app/src/androidTest/java/com/bitpay/wallet/pages/MyKeyPage.kt @@ -0,0 +1,50 @@ +package com.bitpay.wallet.pages + +import androidx.test.espresso.Espresso.onView +import com.bitpay.wallet.utils.WaitUtils +import com.bitpay.wallet.utils.WaitUtils.withTestId +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.pressImeActionButton +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText + +class MyKeyPage { + + // ---- Locators ---- + private val myKeyText = withText("My Key") + private val bitcoinText = withText("Bitcoin") + + + // ---- Actions ---- + + fun waitForPageToLoad() { + WaitUtils.waitForView( + myKeyText, + timeoutMs = 900000 + ) + } + + fun verifyMyKeyDisplayed(): Boolean { + return try { + WaitUtils.waitForView(myKeyText, 900000) + onView(myKeyText).check(matches(isDisplayed())) + true + } catch (e: Throwable) { + false + } + } + + fun verifyBitcoinTextDisplayed(): Boolean { + return try { + WaitUtils.waitForView(bitcoinText) + onView(bitcoinText).check(matches(isDisplayed())) + true + } catch (e: Throwable) { + false + } + } + + +} \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/bitpay/wallet/pages/OnboardingPage.kt b/android/app/src/androidTest/java/com/bitpay/wallet/pages/OnboardingPage.kt new file mode 100644 index 0000000000..a36ecff2c9 --- /dev/null +++ b/android/app/src/androidTest/java/com/bitpay/wallet/pages/OnboardingPage.kt @@ -0,0 +1,403 @@ +package com.bitpay.wallet.pages + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import com.bitpay.wallet.utils.WaitUtils +import com.bitpay.wallet.utils.WaitUtils.withTestId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.withContentDescription +import androidx.test.espresso.matcher.ViewMatchers.isClickable +import android.view.View +import org.hamcrest.Matchers.allOf +import org.hamcrest.Matchers.anyOf +import org.hamcrest.Matchers.containsString +import org.hamcrest.Matchers.startsWith +import org.hamcrest.Matcher +import org.junit.Assert.assertTrue +import com.bitpay.wallet.utils.WaitUtils.withIndex + +class OnboardingPage { + + // ---- Locators ---- + private val getStartedButton = withTestId("get-started-button") + private val continueWithoutAccountButton = withTestId("continue-without-an-account-button") + private val skipButton = withTestId("skip-button") + private val createKeyButton = withTestId("create-a-key-button") + private val alreadyHaveKeyButton = withTestId("i-already-have-a-key-button") + private val protectYourWalletText = withText("Protect your wallet") + private val backupKeyPromptText = withText("Would you like to backup your key?") + private val bottomSheetLaterButton = withTestId("bottom-notification-secondary-action-button") + private val bottomSheetBackupYourKeyButton = + withTestId("bottom-notification-primary-action-button") + private val backupRecoveryPhraseElement = withTestId("go-to-backup-button") + private val importTitleText = withText("Import") + private val importWalletButton = withTestId("import-wallet-button") + + private val ongoingProcessMessage = withTestId("ongoing-process-message") + private val loadingTokensText = withText( + anyOf( + startsWith("Loading "), + startsWith("Adding "), + startsWith("Searching "), + startsWith("Checking "), + startsWith("Deriving "), + startsWith("Found "), + startsWith("No wallets"), + startsWith("No more wallets"), + startsWith("Getting wallet"), + containsString("Almost there"), + ) + ) + private val portfolioBalanceText = withTestId("portfolio-balance-info-button") + private val myKeyText = withText("My Key") + + private fun iUnderstandCheckBox1Matchers(): List> { + return listOf( + allOf(withTestId("first-term-checkbox"), isClickable()), + withIndex(withTestId("first-term-checkbox"), 1), + withIndex(withTestId("first-term-checkbox"), 0) + ) + } + + private fun iUnderstandCheckBox2Matchers(): List> { + return listOf( + allOf(withTestId("second-term-checkbox"), isClickable()), + withIndex(withTestId("second-term-checkbox"), 1), + withIndex(withTestId("second-term-checkbox"), 0) + ) + } + + private fun iUnderstandCheckBox3Matchers(): List> { + return listOf( + allOf(withTestId("third-term-checkbox"), isClickable()), + withIndex(withTestId("third-term-checkbox"), 1), + withIndex(withTestId("third-term-checkbox"), 0) + ) + } + +// private val iUnderstandCheckBox2 = withTestId("second-term-checkbox") +// private val iUnderstandCheckBox3 = withTestId("third-term-checkbox") + + // private val iUnderstandCheckBox2 = withTestId("second-term-checkbox") +// private val iUnderstandCheckBox3 = withTestId("third-term-checkbox") + private val agreeAndContinueButton = withTestId("agree-and-continue-button") + + + // ---- Actions ---- + + fun waitForPageToLoad() { + WaitUtils.waitForView( + continueWithoutAccountButton, + timeoutMs = 120000 + ) + } + + fun clickGetStarted() { + WaitUtils.waitForView(getStartedButton) + onView(getStartedButton).perform(click()) + } + + fun isContinueWithoutAccountDisplayed(): Boolean { + return try { + WaitUtils.waitForView(continueWithoutAccountButton, timeoutMs = 120000) + true + } catch (e: Throwable) { + android.util.Log.e("OnboardingPage", "isContinueWithoutAccountDisplayed failed", e) + false + } + } + + fun clickContinueWithoutAccount() { + WaitUtils.waitForView(continueWithoutAccountButton) + onView(continueWithoutAccountButton).perform(click()) + } + + fun clickSkip() { + WaitUtils.waitForViewEffectivelyVisible(skipButton) + try { + onView(skipButton).perform(click()) + } catch (e: Throwable) { + onView(skipButton).perform(WaitUtils.forceClick) + } + } + + fun clickCreateKey() { + WaitUtils.waitForView(createKeyButton) + onView(createKeyButton).perform(click()) + } + + fun clickAlreadyHaveKey() { + if (isMatcherVisible(alreadyHaveKeyButton, timeoutMs = 3000)) { + WaitUtils.waitForView(alreadyHaveKeyButton) + onView(alreadyHaveKeyButton).perform(click()) + return + } + + // If onboarding is already completed or import screen is already open, + // this action is a no-op to keep reruns deterministic. + if (isPostOnboardingStateVisible(timeoutMs = 2500) || + isMatcherVisible(importTitleText, timeoutMs = 1500) + ) { + return + } + + WaitUtils.waitForView(alreadyHaveKeyButton) + onView(alreadyHaveKeyButton).perform(click()) + } + + fun verifyProtectYourWalletIsDisplayed(): Boolean { + return try { + WaitUtils.waitForView(protectYourWalletText) + onView(protectYourWalletText).check(matches(isDisplayed())) + true + } catch (e: Throwable) { + false + } + } + + fun verifyBackupKeyPromptIsDisplayed(): Boolean { + return try { + WaitUtils.waitForView(backupKeyPromptText, timeoutMs = 120000) + onView(backupKeyPromptText).check(matches(isDisplayed())) + true + } catch (e: Throwable) { + false + } + } + + fun clickBottomSheetBackUpYourKey() { + WaitUtils.waitForView(bottomSheetBackupYourKeyButton) + onView(bottomSheetBackupYourKeyButton).perform(click()) + } + + fun clickBottomSheetLater() { + WaitUtils.waitForViewEffectivelyVisible(bottomSheetLaterButton) + onView(bottomSheetLaterButton).perform(WaitUtils.forceClick) + } + + fun verifyBackupRecoveryPhraseDisplayed(): Boolean { + return try { + WaitUtils.waitForView(backupRecoveryPhraseElement) + onView(backupRecoveryPhraseElement).check(matches(isDisplayed())) + true + } catch (e: Throwable) { + false + } + } + fun clickBackupRecoveryPhrase() { + WaitUtils.waitForView(backupRecoveryPhraseElement) + onView(backupRecoveryPhraseElement).perform(click()) + } + + fun verifyIUnderstandCheckbox1Displayed(): Boolean { + prepareTermsScreen() + if (skipTermsIfAlreadyOnboarded()) { + return false + } + return isAnyMatcherDisplayed(iUnderstandCheckBox1Matchers(), timeoutMs = 720000) + } + + fun clickIUnderstandCheckbox1() { + if (skipTermsIfAlreadyOnboarded()) { + return + } + clickTermsCheckbox(iUnderstandCheckBox1Matchers(), "first-term-checkbox") + } + + fun clickIUnderstandCheckbox2() { + if (skipTermsIfAlreadyOnboarded()) { + return + } + clickTermsCheckbox(iUnderstandCheckBox2Matchers(), "second-term-checkbox") + } + + fun clickIUnderstandCheckbox3() { + if (skipTermsIfAlreadyOnboarded()) { + return + } + clickTermsCheckbox(iUnderstandCheckBox3Matchers(), "third-term-checkbox") + } + + fun clickAgreeAndContinue() { + if (skipTermsIfAlreadyOnboarded()) { + return + } + + // The third checkbox can occasionally miss taps; retry checkbox taps + // until CTA is enabled instead of moving on to a wrong screen state. + for (attempt in 1..3) { + if (isAgreeAndContinueEnabled()) { + onView(agreeAndContinueButton).perform(click()) + return + } + clickIUnderstandCheckbox1() + clickIUnderstandCheckbox2() + clickIUnderstandCheckbox3() + } + + assertTrue( + "Agree and Continue button did not enable after selecting all terms", + isAgreeAndContinueEnabled() + ) + onView(agreeAndContinueButton).perform(click()) + } + + private fun clickTermsCheckbox(matchers: List>, name: String) { + prepareTermsScreen() + var lastError: Throwable? = null + + for (matcher in matchers) { + try { + WaitUtils.waitForViewEffectivelyVisible(matcher, timeoutMs = 15000) + try { + onView(matcher).perform(click()) + } catch (e: Throwable) { + onView(matcher).perform(WaitUtils.forceClick) + } + Thread.sleep(350) + return + } catch (e: Throwable) { + lastError = e + } + } + + throw lastError ?: RuntimeException("Failed to click $name") + } + + private fun prepareTermsScreen(timeoutMs: Long = 720000) { + val end = System.currentTimeMillis() + timeoutMs + var lastImportRetryAt = 0L + + while (System.currentTimeMillis() < end) { + if (skipTermsIfAlreadyOnboarded()) { + return + } + + if (isAnyMatcherDisplayed(iUnderstandCheckBox1Matchers(), timeoutMs = 1200)) { + return + } + + var dismissedSomething = false + + if (isMatcherVisible(ongoingProcessMessage, timeoutMs = 600) || + isMatcherVisible(loadingTokensText, timeoutMs = 600) + ) { + Thread.sleep(1500) + continue + } + + // Import flow may land on backup prompt before terms. + if (isMatcherVisible(backupKeyPromptText, timeoutMs = 800) || + isMatcherVisible(backupRecoveryPhraseElement, timeoutMs = 800) + ) { + dismissedSomething = dismissedSomething or clickSkipIfVisible() + } + + // After skipping backup, a bottom sheet can block terms. + if (isMatcherVisible(bottomSheetLaterButton, timeoutMs = 800)) { + dismissedSomething = dismissedSomething or clickBottomSheetLaterIfVisible() + } + + // Imported-wallet path can remain on Import with "Loading ... tokens..." + // for a long time. Keep waiting and occasionally re-trigger import. + if (isMatcherVisible(importTitleText, timeoutMs = 600)) { + if (isMatcherVisible(loadingTokensText, timeoutMs = 600)) { + Thread.sleep(1200) + continue + } + + if (System.currentTimeMillis() - lastImportRetryAt > 12000) { + dismissedSomething = dismissedSomething or clickImportWalletIfVisible() + lastImportRetryAt = System.currentTimeMillis() + } + } + + if (!dismissedSomething) { + Thread.sleep(300) + } + } + } + + private fun skipTermsIfAlreadyOnboarded(): Boolean = + isPostOnboardingStateVisible(timeoutMs = 1500) + + private fun isPostOnboardingStateVisible(timeoutMs: Long): Boolean { + return isMatcherVisible(portfolioBalanceText, timeoutMs) || + isMatcherVisible(myKeyText, timeoutMs) + } + + private fun clickSkipIfVisible(): Boolean { + return try { + WaitUtils.waitForViewEffectivelyVisible(skipButton, timeoutMs = 2000, intervalMs = 200) + try { + onView(skipButton).perform(click()) + } catch (e: Throwable) { + onView(skipButton).perform(WaitUtils.forceClick) + } + Thread.sleep(300) + true + } catch (_: Throwable) { + false + } + } + + private fun clickBottomSheetLaterIfVisible(): Boolean { + return try { + WaitUtils.waitForViewEffectivelyVisible(bottomSheetLaterButton, timeoutMs = 2000, intervalMs = 200) + onView(bottomSheetLaterButton).perform(WaitUtils.forceClick) + Thread.sleep(300) + true + } catch (_: Throwable) { + false + } + } + + private fun clickImportWalletIfVisible(): Boolean { + return try { + WaitUtils.waitForViewEffectivelyVisible(importWalletButton, timeoutMs = 2000, intervalMs = 200) + try { + onView(importWalletButton).perform(click()) + } catch (e: Throwable) { + onView(importWalletButton).perform(WaitUtils.forceClick) + } + Thread.sleep(500) + true + } catch (_: Throwable) { + false + } + } + + private fun isMatcherVisible(matcher: Matcher, timeoutMs: Long): Boolean { + return try { + WaitUtils.waitForViewEffectivelyVisible(matcher, timeoutMs = timeoutMs, intervalMs = 200) + true + } catch (_: Throwable) { + false + } + } + + private fun isAnyMatcherDisplayed(matchers: List>, timeoutMs: Long): Boolean { + val slice = (timeoutMs / matchers.size).coerceAtLeast(3000) + for (matcher in matchers) { + try { + WaitUtils.waitForViewEffectivelyVisible(matcher, timeoutMs = slice) + return true + } catch (_: Throwable) { + // Try next matcher. + } + } + return false + } + + private fun isAgreeAndContinueEnabled(): Boolean { + return try { + WaitUtils.waitForViewEnabled(agreeAndContinueButton, timeoutMs = 3000, intervalMs = 250) + true + } catch (e: Throwable) { + false + } + } + +} \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/bitpay/wallet/pages/SelectCurrencyPage.kt b/android/app/src/androidTest/java/com/bitpay/wallet/pages/SelectCurrencyPage.kt new file mode 100644 index 0000000000..e628fa62ab --- /dev/null +++ b/android/app/src/androidTest/java/com/bitpay/wallet/pages/SelectCurrencyPage.kt @@ -0,0 +1,278 @@ +package com.bitpay.wallet.pages + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.action.ViewActions.click +import com.bitpay.wallet.utils.WaitUtils +import com.bitpay.wallet.utils.WaitUtils.withTestId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.withSubstring +import androidx.test.espresso.matcher.ViewMatchers.withContentDescription +import androidx.test.espresso.matcher.ViewMatchers.hasSibling +import com.bitpay.wallet.utils.WaitUtils.withIndex +import org.hamcrest.CoreMatchers.containsString +import org.hamcrest.Matchers.allOf + +class SelectCurrencyPage { + + // ---- Locators ---- + private val selectCurrencyText = withText("Select a Currency") + private val bitcoinText = withSubstring("Bitcoin") + private val btcBitcoinText = allOf( + withSubstring("Bitcoin"), + hasSibling(withText(containsString("BTC"))) + ) + private val ethereumText = withSubstring("Ethereum") + private val sendToText = withText("Send To") + private val sendToAddressInput = withTestId("send-to-address-input") + private val swapText = withText("Swap") + private val swapFrom = withTestId("swap-crypto-from-wallet-selector") + private val swapTo = withTestId("swap-crypto-to-wallet-selector") + private val swapToText = withText("Swap To") + private val selectCryptoText = withText("Select Crypto") + private val selectKeyToDepositToText = withText("Select Key to Deposit to") + private val cryptoToSwapText = withText("Crypto to Swap") + private val selectAccountToDepositToText = withText("Select Account to Deposit to") + private val myKeyWallet = withIndex(withTestId("wallet-card-My Key"), 1) + private val evmAccount = withText("EVM Account") + private val enterSwapAmountButton =withTestId("swap-crypto-enter-amount-button") + private val minButton = withText("MIN") + private val swapChangellyTermsCheckbox =withTestId("swap-crypto-changelly-terms-checkbox") + private val slideToSwipeButton = withContentDescription("Slide to swap") + private val buyText = withText("Buy") + private val sellText = withText("Sell") + private val buyContinueButton = withTestId("button") + private val chooseCrypto = withText("Choose Crypto") + + + // ---- Actions ---- + fun verifySelectCurrencyDisplayed(): Boolean { + return try { + WaitUtils.waitForView(selectCurrencyText) + onView(selectCurrencyText).check(matches(isDisplayed())) + true + } catch (e: Throwable) { + false + } + } + + fun verifyBitcoinTextDisplayed(): Boolean { + return try { + WaitUtils.waitForView(bitcoinText, timeoutMs = 300000) + onView(bitcoinText).check(matches(isDisplayed())) + true + } catch (e: Throwable) { + false + } + } + + fun clickBitcoin() { + WaitUtils.waitForView(bitcoinText, timeoutMs = 30000) + onView(bitcoinText).perform(click()) + } + + fun clickBTCBitcoin() { + WaitUtils.waitForView(btcBitcoinText, timeoutMs = 30000) + onView(btcBitcoinText).perform(click()) + } + + fun clickEthereum() { + WaitUtils.waitForView(ethereumText) + onView(ethereumText).perform(click()) + } + + fun verifySendToDisplayed(): Boolean { + return try { + WaitUtils.waitForView(sendToText) + onView(sendToText).check(matches(isDisplayed())) + true + } catch (e: Throwable) { + false + } + } + + fun enterSendToAddress(sendToAddress: String) { + WaitUtils.waitForView(sendToAddressInput) + onView(sendToAddressInput).perform( + ViewActions.click(), + ViewActions.typeText(sendToAddress), + ViewActions.closeSoftKeyboard() + ) + } + + fun verifySwapTitleDisplayed(): Boolean { + return try { + WaitUtils.waitForViewEffectivelyVisible(swapFrom, timeoutMs = 60 * 1000) + onView(swapFrom).check(matches(isDisplayed())) + true + } catch (e: Throwable) { + false + } + } + + fun verifyBuyTitleDisplayed(): Boolean { + return try { + WaitUtils.waitForView(buyText) + onView(buyText).check(matches(isDisplayed())) + true + } catch (e: Throwable) { + false + } + } + + fun verifySellTitleDisplayed(): Boolean { + return try { + WaitUtils.waitForView(sellText) + onView(sellText).check(matches(isDisplayed())) + true + } catch (e: Throwable) { + false + } + } + + fun verifySelectCryptoTitleDisplayed(): Boolean { + return try { + WaitUtils.waitForView(selectCryptoText, timeoutMs = 30 * 1000) + onView(selectCryptoText).check(matches(isDisplayed())) + true + } catch (e: Throwable) { + false + } + } + + fun verifyCryptoToSwapTitleDisplayed(): Boolean { + return try { + WaitUtils.waitForView(cryptoToSwapText, timeoutMs = 30 * 1000) + onView(cryptoToSwapText).check(matches(isDisplayed())) + true + } catch (e: Throwable) { + false + } + } + + fun verifyCryptoTopSwapDisplayed(): Boolean { + return try { + WaitUtils.waitForView(cryptoToSwapText, timeoutMs = 30 * 1000) + onView(cryptoToSwapText).check(matches(isDisplayed())) + true + } catch (e: Throwable) { + false + } + } + + fun verifySwapFromOptionDisplayed(): Boolean { + return try { + WaitUtils.waitForViewEffectivelyVisible(swapFrom, timeoutMs = 60 * 1000) + onView(swapFrom).check(matches(isDisplayed())) + true + } catch (e: Throwable) { + false + } + } + + fun clickSwapFromOption() { + WaitUtils.waitForViewEffectivelyVisible(swapFrom, timeoutMs = 60 * 1000) + try { + onView(swapFrom).perform(click()) + } catch (e: Throwable) { + onView(swapFrom).perform(WaitUtils.forceClick) + } + } + + fun verifySwapToTitleDisplayed(): Boolean { + return try { + WaitUtils.waitForView(swapToText, timeoutMs = 60 * 1000) + onView(swapToText).check(matches(isDisplayed())) + true + } catch (swapToMissing: Throwable) { + // Some builds render the same picker with a generic title. + try { + WaitUtils.waitForView(selectCryptoText, timeoutMs = 20 * 1000) + onView(selectCryptoText).check(matches(isDisplayed())) + true + } catch (selectCryptoMissing: Throwable) { + false + } + } + } + + fun clickSwapToOption() { + WaitUtils.waitForViewEffectivelyVisible(swapTo, timeoutMs = 30 * 1000) + try { + onView(swapTo).perform(click()) + } catch (e: Throwable) { + onView(swapTo).perform(WaitUtils.forceClick) + } + } + + fun verifySelectKeyToDepositToDisplayed(): Boolean { + return try { + WaitUtils.waitForView(selectKeyToDepositToText) + onView(selectKeyToDepositToText).check(matches(isDisplayed())) + true + } catch (e: Throwable) { + false + } + } + + fun verifySelectAccountToDepositToDisplayed(): Boolean { + return try { + WaitUtils.waitForView(selectAccountToDepositToText) + onView(selectAccountToDepositToText).check(matches(isDisplayed())) + true + } catch (e: Throwable) { + false + } + } + + fun clickEnterSwapAmount() { + WaitUtils.waitForView(enterSwapAmountButton) + onView(enterSwapAmountButton).perform(click()) + } + + fun clickMinSwapAmount() { + WaitUtils.waitForView(minButton) + onView(minButton).perform(click()) + } + + fun clickSecondMyKeyWallet() { + WaitUtils.waitForView(myKeyWallet) + onView(myKeyWallet).perform(click()) + } + + fun clickEVMAccount() { + WaitUtils.waitForView(evmAccount) + onView(evmAccount).perform(click()) + } + + fun clickChangellyTermsCheckbox() { + WaitUtils.waitForView(swapChangellyTermsCheckbox, timeoutMs = 30*1000) + onView(swapChangellyTermsCheckbox).perform(click()) + } + + fun verifySlideToSwapButtonEnabled(): Boolean { + return try { + WaitUtils.waitForViewEnabled(slideToSwipeButton) + true + } catch (e: Throwable) { + false + } + } + + fun verifyBuyContinueButtonEnabled(): Boolean { + return try { + WaitUtils.waitForViewEnabled(buyContinueButton, timeoutMs = 30 * 1000) + true + } catch (e: Throwable) { + false + } + } + + fun clickChooseCrypto() { + WaitUtils.waitForView(chooseCrypto) + onView(chooseCrypto).perform(click()) + } + +} \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/bitpay/wallet/tests/Test591OnboardingCreateWallet.kt b/android/app/src/androidTest/java/com/bitpay/wallet/tests/Test591OnboardingCreateWallet.kt new file mode 100644 index 0000000000..bb5cf4a131 --- /dev/null +++ b/android/app/src/androidTest/java/com/bitpay/wallet/tests/Test591OnboardingCreateWallet.kt @@ -0,0 +1,70 @@ +package com.bitpay.wallet.tests + +import com.bitpay.wallet.base.BaseTest +import com.bitpay.wallet.pages.HomePage +import com.bitpay.wallet.pages.OnboardingPage +import com.bitpay.wallet.utils.allureStep +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bitpay.wallet.utils.allureScreenshot +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + + +@RunWith(AndroidJUnit4::class) +class Test591OnboardingCreateWallet : BaseTest() { + private val onboardingPage = OnboardingPage() + private val homePage = HomePage() + + @Test + fun testOnboardingCreateWallet() { + allureStep("Click Create Key on onboarding screen") { + onboardingPage.clickCreateKey() + allureScreenshot("After Create Key clicked") + } + + allureStep("Verify backup key prompt is displayed") { + assertTrue( + "Backup key prompt was not displayed", + onboardingPage.verifyBackupKeyPromptIsDisplayed() + ) + assertTrue( + "Backup your recovery phrase not displayed", + onboardingPage.verifyBackupRecoveryPhraseDisplayed() + ) + + allureScreenshot("Backup key page displayed") + } + + allureStep("Skip backup prompt") { + onboardingPage.clickSkip() // Skip "Would you like to backup" + } + + allureStep("Dismiss bottom sheet (Later)") { + onboardingPage.clickBottomSheetLater() + } + + allureStep("Check all three 'I understand' checkboxes") { + onboardingPage.clickIUnderstandCheckbox1() + onboardingPage.clickIUnderstandCheckbox2() + onboardingPage.clickIUnderstandCheckbox3() + } + + allureStep("Click Agree and Continue") { + onboardingPage.clickAgreeAndContinue() + } + + allureStep("Wait for Home page to load") { + homePage.waitForPageToLoad() + } + + allureStep("Verify portfolio balance is displayed on Home page") { + assertTrue( + "Home Page - Portfolio balance text not displayed", + homePage.verifyPortfolioBalanceTextDisplayed() + ) + allureScreenshot("Final - Portfolio balance verified") + } + + } +} diff --git a/android/app/src/androidTest/java/com/bitpay/wallet/tests/Test592ImportWalletRecoveryPhrase.kt b/android/app/src/androidTest/java/com/bitpay/wallet/tests/Test592ImportWalletRecoveryPhrase.kt new file mode 100644 index 0000000000..c992d583b5 --- /dev/null +++ b/android/app/src/androidTest/java/com/bitpay/wallet/tests/Test592ImportWalletRecoveryPhrase.kt @@ -0,0 +1,104 @@ +package com.bitpay.wallet.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bitpay.wallet.base.BaseTest +import com.bitpay.wallet.pages.AddYourCryptoOptionPage +import com.bitpay.wallet.pages.HomePage +import com.bitpay.wallet.pages.ImportWalletPage +import com.bitpay.wallet.pages.MyKeyPage +import com.bitpay.wallet.pages.OnboardingPage +import com.bitpay.wallet.utils.allureScreenshot +import com.bitpay.wallet.utils.allureStep +import org.junit.Assert.assertTrue +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith +import com.bitpay.wallet.TestConfig + +@RunWith(AndroidJUnit4::class) +class Test592ImportWalletRecoveryPhrase : BaseTest() { + + private val onboardingPage = OnboardingPage() + private val homePage = HomePage() + private val addYourCryptoOptionPage = AddYourCryptoOptionPage() + private val importWalletPage = ImportWalletPage() + private val myKeyPage = MyKeyPage() + private val walletPhrase = TestConfig.WALLET_PHRASE + + + @Test + fun testImportWalletRecoveryPhrase() { + + allureStep("Click Create Key on onboarding screen") { + onboardingPage.clickCreateKey() + } + + allureStep("Verify backup key prompt is displayed") { + assertTrue( + "Backup key prompt was not displayed", + onboardingPage.verifyBackupKeyPromptIsDisplayed() + ) + allureScreenshot("Backup key prompt displayed") + } + + allureStep("Skip backup prompt") { + onboardingPage.clickSkip() // Skip "Would you like to backup" + } + + allureStep("Dismiss bottom sheet (Later)") { + onboardingPage.clickBottomSheetLater() + } + + allureStep("Check all three 'I understand' checkboxes") { + onboardingPage.clickIUnderstandCheckbox1() + onboardingPage.clickIUnderstandCheckbox2() + onboardingPage.clickIUnderstandCheckbox3() + } + + allureStep("Click Agree and Continue") { + onboardingPage.clickAgreeAndContinue() + } + + allureStep("Wait for Home page to load") { + homePage.waitForPageToLoad() + allureScreenshot("Home page loaded") + } + + allureStep("Verify portfolio balance is displayed on Home page") { + assertTrue( + "Home Page - Portfolio balance text not displayed", + homePage.verifyPortfolioBalanceTextDisplayed() + ) + } + + allureStep("Click Add Your Crypto on Home page") { + homePage.clickAddYourCrypto() + } + + allureStep("Click Import Key option") { + addYourCryptoOptionPage.clickImportKey() + } + + allureStep("Enter recovery phrase and import wallet") { + importWalletPage.enterRecoveryPhrase(walletPhrase) + allureScreenshot("Recovery phrase entered") + importWalletPage.clickImportWallet() + } + + allureStep("Verify wallet import succeeded - My Key page displayed") { + assertTrue( + "Wallet Import Failed - My Key not displayed", + myKeyPage.verifyMyKeyDisplayed() + ) + allureScreenshot("My Key page displayed after import") + } + + allureStep("Verify Bitcoin is displayed in My Key page") { + assertTrue( + "Bitcoin not displayed in My Key page", + myKeyPage.verifyBitcoinTextDisplayed() + ) + allureScreenshot("Final - Bitcoin shown in My Key page") + } + } +} \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/bitpay/wallet/tests/Test593SendBTC.kt b/android/app/src/androidTest/java/com/bitpay/wallet/tests/Test593SendBTC.kt new file mode 100644 index 0000000000..14e6ac7df0 --- /dev/null +++ b/android/app/src/androidTest/java/com/bitpay/wallet/tests/Test593SendBTC.kt @@ -0,0 +1,128 @@ +package com.bitpay.wallet.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bitpay.wallet.base.BaseTest +import com.bitpay.wallet.pages.AddYourCryptoOptionPage +import com.bitpay.wallet.pages.ConfirmPaymentPage +import com.bitpay.wallet.pages.HomePage +import com.bitpay.wallet.pages.ImportWalletPage +import com.bitpay.wallet.pages.KeyboardPage +import com.bitpay.wallet.pages.MyKeyPage +import com.bitpay.wallet.pages.OnboardingPage +import com.bitpay.wallet.pages.SelectCurrencyPage +import com.bitpay.wallet.utils.allureScreenshot +import com.bitpay.wallet.utils.allureStep +import org.junit.Assert.assertTrue +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith +import com.bitpay.wallet.TestConfig + +@RunWith(AndroidJUnit4::class) +class Test593SendBTC : BaseTest() { + + private val onboardingPage = OnboardingPage() + private val homePage = HomePage() + private val addYourCryptoOptionPage = AddYourCryptoOptionPage() + private val importWalletPage = ImportWalletPage() + private val myKeyPage = MyKeyPage() + private val selectCurrencyPage = SelectCurrencyPage() + private val keyboardPage = KeyboardPage() + private val confirmPaymentPage = ConfirmPaymentPage() + private val walletPhrase = TestConfig.WALLET_PHRASE + + @Test + fun testSendBTC() { + allureStep("Click 'Already have a key' on onboarding screen") { + onboardingPage.clickAlreadyHaveKey() + } + + allureStep("Enter recovery phrase and import wallet") { + importWalletPage.enterRecoveryPhrase(walletPhrase) + allureScreenshot("Recovery phrase entered") + importWalletPage.clickImportWallet() + } + + allureStep("Verify 'I understand' checkbox 1 is displayed") { + assertTrue( + "Important screen - checkbox 1 not displayed", + onboardingPage.verifyIUnderstandCheckbox1Displayed() + ) + } + + allureStep("Check all three 'I understand' checkboxes") { + onboardingPage.clickIUnderstandCheckbox1() + onboardingPage.clickIUnderstandCheckbox2() + onboardingPage.clickIUnderstandCheckbox3() + } + + allureStep("Click Agree and Continue") { + onboardingPage.clickAgreeAndContinue() + } + + allureStep("Wait for Home page to load") { + homePage.waitForPageToLoad() + homePage.waitForFundedBalance() + allureScreenshot("Home page loaded") + } + + allureStep("Verify portfolio balance is displayed on Home page") { + assertTrue( + "Home Page - Portfolio balance text not displayed", + homePage.verifyPortfolioBalanceTextDisplayed() + ) + } + + allureStep("Click Send on Home page") { + homePage.clickSend() + } + + allureStep("Verify Select Currency page is displayed") { + assertTrue( + "Select Currency page not displayed", + selectCurrencyPage.verifySelectCurrencyDisplayed() + ) + assertTrue( + "Select Currency page not displayed", + selectCurrencyPage.verifySelectCurrencyDisplayed() + ) + allureScreenshot("Select Currency page displayed") + } + + allureStep("Click Bitcoin currency option") { + selectCurrencyPage.clickBitcoin() + } + + allureStep("Verify Send To page is displayed") { + assertTrue( + "Send To page not displayed", + selectCurrencyPage.verifySendToDisplayed() + ) + } + + allureStep("Enter Send To address") { + selectCurrencyPage.enterSendToAddress("bc1q0effzahtsn685tyjppgukpvfhv37hrlm4g67ws") + } + + allureStep("Enter amount and click Continue") { + keyboardPage.enterAmount("0.00005") + keyboardPage.clickContinue() + } + + allureStep("Verify Confirm Payment page is displayed") { + assertTrue( + "Confirm Payment page not displayed", + confirmPaymentPage.verifyConfirmPaymentTitleDisplayed(), + ) + allureScreenshot("Confirm Payment page displayed") + } + + allureStep("Verify Summary text is displayed on Confirm Payment page") { + assertTrue( + "Confirm Payment - Summary page not displayed", + confirmPaymentPage.verifySummaryTextDisplayed() + ) + allureScreenshot("Final - Summary displayed before sending") + } + } +} \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/bitpay/wallet/tests/Test594SwapBTC.kt b/android/app/src/androidTest/java/com/bitpay/wallet/tests/Test594SwapBTC.kt new file mode 100644 index 0000000000..35a896c693 --- /dev/null +++ b/android/app/src/androidTest/java/com/bitpay/wallet/tests/Test594SwapBTC.kt @@ -0,0 +1,152 @@ +package com.bitpay.wallet.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bitpay.wallet.base.BaseTest +import com.bitpay.wallet.pages.AddYourCryptoOptionPage +import com.bitpay.wallet.pages.HomePage +import com.bitpay.wallet.pages.ImportWalletPage +import com.bitpay.wallet.pages.MyKeyPage +import com.bitpay.wallet.pages.OnboardingPage +import com.bitpay.wallet.pages.SelectCurrencyPage +import com.bitpay.wallet.utils.allureScreenshot +import com.bitpay.wallet.utils.allureStep +import org.junit.Assert.assertTrue +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith +import com.bitpay.wallet.TestConfig + +@RunWith(AndroidJUnit4::class) +class Test594SwapBTC : BaseTest() { + + private val onboardingPage = OnboardingPage() + private val homePage = HomePage() + private val addYourCryptoOptionPage = AddYourCryptoOptionPage() + private val importWalletPage = ImportWalletPage() + private val myKeyPage = MyKeyPage() + private val selectCurrencyPage = SelectCurrencyPage() + private val walletPhrase = TestConfig.WALLET_PHRASE + + @Test + fun testBTCSwap() { + allureStep("Click 'Already have a key' on onboarding screen") { + onboardingPage.clickAlreadyHaveKey() + } + + allureStep("Enter recovery phrase and import wallet") { + importWalletPage.enterRecoveryPhrase(walletPhrase) + allureScreenshot("Recovery phrase entered") + importWalletPage.clickImportWallet() + } + + allureStep("Verify 'I understand' checkbox 1 is displayed") { + assertTrue( + "Important screen - checkbox 1 not displayed", + onboardingPage.verifyIUnderstandCheckbox1Displayed() + ) + } + + allureStep("Check all three 'I understand' checkboxes") { + onboardingPage.clickIUnderstandCheckbox1() + onboardingPage.clickIUnderstandCheckbox2() + onboardingPage.clickIUnderstandCheckbox3() + } + + allureStep("Click Agree and Continue") { + onboardingPage.clickAgreeAndContinue() + } + + allureStep("Wait for Home page to load") { + homePage.waitForPageToLoad() + homePage.waitForFundedBalance() + allureScreenshot("Home page loaded") + } + + allureStep("Verify portfolio balance is displayed on Home page") { + assertTrue( + "Home Page - Portfolio balance text not displayed", + homePage.verifyPortfolioBalanceTextDisplayed() + ) + } + + allureStep("Click Swap on Home page") { + homePage.clickSwap() + } + + allureStep("Verify Swap page is displayed") { + assertTrue( + "Swap page not displayed", + selectCurrencyPage.verifySwapTitleDisplayed() + ) + assertTrue( + "Swap page - Swap From option not displayed", + selectCurrencyPage.verifySwapFromOptionDisplayed() + ) + assertTrue( + "Swap page - Bitcoin text not displayed", + selectCurrencyPage.verifyBitcoinTextDisplayed() + ) + allureScreenshot("Swap page displayed") + } + + allureStep("Select Bitcoin as Swap From currency") { + selectCurrencyPage.clickSwapFromOption() + allureScreenshot("Swap from Option") + + assertTrue( + "Swap page - Crypto To Swap text not displayed", + selectCurrencyPage.verifyCryptoToSwapTitleDisplayed() + ) + assertTrue( + "Swap page - Bitcoin text not displayed", + selectCurrencyPage.verifyBitcoinTextDisplayed() + ) + + allureScreenshot("Swap from Option - Before Bitcoin click") + selectCurrencyPage.clickBTCBitcoin() + assertTrue( + "Swap page - Bitcoin text not displayed", + selectCurrencyPage.verifyBitcoinTextDisplayed() + ) + allureScreenshot("Swap from Option - After Bitcoin click") + } + allureStep("Select Ethereum as Swap To currency") { + selectCurrencyPage.clickSwapToOption() + assertTrue( + "Swap To - Page not displayed", + selectCurrencyPage.verifySwapToTitleDisplayed() + ) + allureScreenshot("Swap To click") + selectCurrencyPage.clickEthereum() + } + + allureStep("Verify Select Account To Deposit To option is displayed") { + assertTrue( + "Select Account To Deposit To option not displayed", + selectCurrencyPage.verifySelectAccountToDepositToDisplayed() + ) + } + + allureStep("Select second My Key wallet and EVM account") { + selectCurrencyPage.clickEVMAccount() + } + + allureStep("Enter minimum swap amount") { + selectCurrencyPage.clickEnterSwapAmount() + selectCurrencyPage.clickMinSwapAmount() + allureScreenshot("Minimum swap amount entered") + } + + allureStep("Accept Changelly terms checkbox") { + selectCurrencyPage.clickChangellyTermsCheckbox() + } + + allureStep("Verify Slide to Swap button is enabled") { + assertTrue( + "Slide to Swap button not enabled", + selectCurrencyPage.verifySlideToSwapButtonEnabled() + ) + allureScreenshot("Final - Slide to Swap button enabled") + } + } +} \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/bitpay/wallet/tests/Test595SellBTC.kt b/android/app/src/androidTest/java/com/bitpay/wallet/tests/Test595SellBTC.kt new file mode 100644 index 0000000000..91f754aca0 --- /dev/null +++ b/android/app/src/androidTest/java/com/bitpay/wallet/tests/Test595SellBTC.kt @@ -0,0 +1,104 @@ +package com.bitpay.wallet.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bitpay.wallet.base.BaseTest +import com.bitpay.wallet.pages.AddYourCryptoOptionPage +import com.bitpay.wallet.pages.HomePage +import com.bitpay.wallet.pages.ImportWalletPage +import com.bitpay.wallet.pages.KeyboardPage +import com.bitpay.wallet.pages.MyKeyPage +import com.bitpay.wallet.pages.OnboardingPage +import com.bitpay.wallet.pages.SelectCurrencyPage +import com.bitpay.wallet.utils.allureScreenshot +import com.bitpay.wallet.utils.allureStep +import org.junit.Assert.assertTrue +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith +import com.bitpay.wallet.TestConfig + +@RunWith(AndroidJUnit4::class) +class Test595SellBTC : BaseTest() { + + private val onboardingPage = OnboardingPage() + private val homePage = HomePage() + private val addYourCryptoOptionPage = AddYourCryptoOptionPage() + private val importWalletPage = ImportWalletPage() + private val myKeyPage = MyKeyPage() + private val selectCurrencyPage = SelectCurrencyPage() + private val keyboardPage = KeyboardPage() + private val walletPhrase = TestConfig.WALLET_PHRASE + + @Test + fun testBTCSell() { + allureStep("Click 'Already have a key' on onboarding screen") { + onboardingPage.clickAlreadyHaveKey() + } + + allureStep("Enter recovery phrase and import wallet") { + importWalletPage.enterRecoveryPhrase(walletPhrase) + allureScreenshot("Recovery phrase entered") + importWalletPage.clickImportWallet() + } + + allureStep("Verify 'I understand' checkbox 1 is displayed") { + assertTrue( + "Important screen - checkbox 1 not displayed", + onboardingPage.verifyIUnderstandCheckbox1Displayed() + ) + } + + allureStep("Check all three 'I understand' checkboxes") { + onboardingPage.clickIUnderstandCheckbox1() + onboardingPage.clickIUnderstandCheckbox2() + onboardingPage.clickIUnderstandCheckbox3() + } + + allureStep("Click Agree and Continue") { + onboardingPage.clickAgreeAndContinue() + } + + allureStep("Wait for Home page to load") { + homePage.waitForPageToLoad() + homePage.waitForFundedBalance() + allureScreenshot("Home page loaded") + } + + allureStep("Verify portfolio balance is displayed on Home page") { + assertTrue( + "Home Page - Portfolio balance text not displayed", + homePage.verifyPortfolioBalanceTextDisplayed() + ) + } + + allureStep("Click Sell on Home page") { + homePage.clickSell() + } + + allureStep("Verify Sell page is displayed") { + assertTrue( + "Sell page not displayed", + selectCurrencyPage.verifySellTitleDisplayed() + ) + allureScreenshot("Sell page displayed") + } + + allureStep("Enter sell amount") { + keyboardPage.enterAmount("0.0007") + } + + allureStep("Choose Bitcoin as the crypto to sell") { + selectCurrencyPage.clickChooseCrypto() + selectCurrencyPage.clickBitcoin() + allureScreenshot("Bitcoin selected for sell") + } + + allureStep("Verify Continue button is enabled") { + assertTrue( + "Buy - Continue button not enabled", + selectCurrencyPage.verifyBuyContinueButtonEnabled() + ) + allureScreenshot("Final - Continue button enabled") + } + } +} \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/bitpay/wallet/tests/Test596BuyBTC.kt b/android/app/src/androidTest/java/com/bitpay/wallet/tests/Test596BuyBTC.kt new file mode 100644 index 0000000000..1ce7dec4c5 --- /dev/null +++ b/android/app/src/androidTest/java/com/bitpay/wallet/tests/Test596BuyBTC.kt @@ -0,0 +1,119 @@ +package com.bitpay.wallet.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bitpay.wallet.base.BaseTest +import com.bitpay.wallet.pages.AddYourCryptoOptionPage +import com.bitpay.wallet.pages.HomePage +import com.bitpay.wallet.pages.ImportWalletPage +import com.bitpay.wallet.pages.KeyboardPage +import com.bitpay.wallet.pages.MyKeyPage +import com.bitpay.wallet.pages.OnboardingPage +import com.bitpay.wallet.pages.SelectCurrencyPage +import com.bitpay.wallet.utils.allureScreenshot +import com.bitpay.wallet.utils.allureStep +import org.junit.Assert.assertTrue +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith +import com.bitpay.wallet.TestConfig + +@RunWith(AndroidJUnit4::class) +class Test596BuyBTC : BaseTest() { + + private val onboardingPage = OnboardingPage() + private val homePage = HomePage() + private val addYourCryptoOptionPage = AddYourCryptoOptionPage() + private val importWalletPage = ImportWalletPage() + private val myKeyPage = MyKeyPage() + private val selectCurrencyPage = SelectCurrencyPage() + private val keyboardPage = KeyboardPage() + private val walletPhrase = TestConfig.WALLET_PHRASE + + @Test + fun testBTCBuy() { + allureStep("Click 'Already have a key' on onboarding screen") { + onboardingPage.clickAlreadyHaveKey() + } + + allureStep("Enter recovery phrase and import wallet") { + importWalletPage.enterRecoveryPhrase(walletPhrase) + allureScreenshot("Recovery phrase entered") + importWalletPage.clickImportWallet() + } + + allureStep("Verify 'I understand' checkbox 1 is displayed") { + assertTrue( + "Important screen - checkbox 1 not displayed", + onboardingPage.verifyIUnderstandCheckbox1Displayed() + ) + } + + allureStep("Check all three 'I understand' checkboxes") { + onboardingPage.clickIUnderstandCheckbox1() + onboardingPage.clickIUnderstandCheckbox2() + onboardingPage.clickIUnderstandCheckbox3() + } + + allureStep("Click Agree and Continue") { + onboardingPage.clickAgreeAndContinue() + } + + allureStep("Wait for Home page to load") { + homePage.waitForPageToLoad() + allureScreenshot("Home page loaded") + } + + allureStep("Verify portfolio balance is displayed on Home page") { + assertTrue( + "Home Page - Portfolio balance text not displayed", + homePage.verifyPortfolioBalanceTextDisplayed() + ) + } + + allureStep("Click Buy on Home page") { + homePage.clickBuy() + } + + allureStep("Verify Buy page is displayed") { + assertTrue( + "Buy page not displayed", + selectCurrencyPage.verifyBuyTitleDisplayed() + ) + allureScreenshot("Buy page displayed") + } + + allureStep("Clear default amount and enter buy amount") { + keyboardPage.clickBackspace(3) + keyboardPage.enterAmount("30") + } + + allureStep("Select Bitcoin as the crypto to buy") { + selectCurrencyPage.clickBitcoin() + assertTrue( + "Select Crypto page not displayed", + selectCurrencyPage.verifySelectCryptoTitleDisplayed() + ) + selectCurrencyPage.clickBitcoin() // Select Crypto page + allureScreenshot("Bitcoin selected for buy") + } + + allureStep("Verify Select Key To Deposit To option is displayed") { + assertTrue( + "Select Key To Deposit To option not displayed", + selectCurrencyPage.verifySelectKeyToDepositToDisplayed() + ) + } + + allureStep("Select My Key wallet") { + selectCurrencyPage.clickSecondMyKeyWallet() + } + + allureStep("Verify Continue button is enabled") { + assertTrue( + "Buy - Continue button not enabled", + selectCurrencyPage.verifyBuyContinueButtonEnabled() + ) + allureScreenshot("Final - Continue button enabled") + } + } +} \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/bitpay/wallet/utils/AllureUtils.kt b/android/app/src/androidTest/java/com/bitpay/wallet/utils/AllureUtils.kt new file mode 100644 index 0000000000..b2589d6321 --- /dev/null +++ b/android/app/src/androidTest/java/com/bitpay/wallet/utils/AllureUtils.kt @@ -0,0 +1,30 @@ +package com.bitpay.wallet.utils + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import io.qameta.allure.kotlin.Allure +import java.io.File + +fun allureScreenshot(name: String = "Screenshot") { + if (System.getenv("GITHUB_ACTIONS") == "true") { + return + } + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val context = InstrumentationRegistry.getInstrumentation().targetContext + val file = File(context.cacheDir, "screenshot_${System.currentTimeMillis()}.png") + + val saved = device.takeScreenshot(file) + if (saved && file.exists()) { + Allure.attachment(name, file.inputStream()) + } +} + +inline fun allureStep(name: String, crossinline block: () -> Unit) { + Allure.step(name) { block() } +} + +fun allureVideo(name: String, file: File?) { + if (file == null || !file.exists()) return + Allure.attachment(name, file.inputStream(), "video/mp4") + file.delete() +} \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/bitpay/wallet/utils/RetryRule.kt b/android/app/src/androidTest/java/com/bitpay/wallet/utils/RetryRule.kt new file mode 100644 index 0000000000..28595af4fc --- /dev/null +++ b/android/app/src/androidTest/java/com/bitpay/wallet/utils/RetryRule.kt @@ -0,0 +1,29 @@ +package com.bitpay.wallet.utils + +import android.util.Log +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class RetryRule(private val retryCount: Int = 3) : TestRule { + override fun apply(base: Statement, description: Description): Statement = + object : Statement() { + override fun evaluate() { + var lastError: Throwable? = null + repeat(retryCount) { attempt -> + try { + base.evaluate() + return + } catch (t: Throwable) { + lastError = t + Log.e( + "RetryRule", + "${description.displayName}: attempt ${attempt + 1} failed", + t + ) + } + } + throw lastError!! + } + } +} \ No newline at end of file diff --git a/android/app/src/androidTest/java/com/bitpay/wallet/utils/WaitUtils.kt b/android/app/src/androidTest/java/com/bitpay/wallet/utils/WaitUtils.kt new file mode 100644 index 0000000000..f1882c0d44 --- /dev/null +++ b/android/app/src/androidTest/java/com/bitpay/wallet/utils/WaitUtils.kt @@ -0,0 +1,138 @@ +package com.bitpay.wallet.utils + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isEnabled +import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility +import org.hamcrest.Matcher +import android.view.View +import androidx.test.espresso.matcher.BoundedMatcher +import androidx.test.espresso.matcher.ViewMatchers +import org.hamcrest.Description +import org.hamcrest.TypeSafeMatcher + + +object WaitUtils { + + fun waitForView( + matcher: Matcher, + timeoutMs: Long = 30000, + intervalMs: Long = 500 + ) { + val endTime = System.currentTimeMillis() + timeoutMs + var lastError: Throwable? = null + + while (System.currentTimeMillis() < endTime) { + try { + onView(matcher).check(matches(isDisplayed())) + return // found it, exit + } catch (e: Throwable) { + // catch EVERYTHING - NoMatchingViewException, AssertionError, + // RootViewWithoutFocusException, anything else Espresso throws + lastError = e + Thread.sleep(intervalMs) + } + } + + // timed out - throw the last real error so the failure message is useful + throw lastError ?: RuntimeException("View not found within ${timeoutMs}ms: $matcher") + } + + fun waitForViewEnabled( + matcher: Matcher, + timeoutMs: Long = 120000, + intervalMs: Long = 500 + ) { + val endTime = System.currentTimeMillis() + timeoutMs + var lastError: Throwable? = null + + while (System.currentTimeMillis() < endTime) { + try { + onView(matcher).check(matches(isDisplayed())).check(matches(isEnabled())) + return + } catch (e: Throwable) { + lastError = e + Thread.sleep(intervalMs) + } + } + + throw lastError ?: RuntimeException("View not enabled within ${timeoutMs}ms: $matcher") + } + + /** + * React Native testIDs are surfaced to native Espresso as View.getTag(), + * NOT as an Android resource id. withResourceName()/withId() will never + * match them - this matcher checks the tag directly instead. + */ + fun withTestId(testId: String): BoundedMatcher { + return object : BoundedMatcher(View::class.java) { + override fun describeTo(description: Description) { + description.appendText("with RN testID: $testId") + } + + override fun matchesSafely(view: View): Boolean { + return testId == view.tag + } + } + } + + /** + * Waits for a view to have effective visibility VISIBLE without requiring + * getGlobalVisibleRect() to be non-empty. Use this when a view is in the + * hierarchy and marked VISIBLE but is clipped (e.g. animating bottom sheets). + */ + fun waitForViewEffectivelyVisible( + matcher: Matcher, + timeoutMs: Long = 30000, + intervalMs: Long = 500 + ) { + val endTime = System.currentTimeMillis() + timeoutMs + var lastError: Throwable? = null + while (System.currentTimeMillis() < endTime) { + try { + onView(matcher).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))) + return + } catch (e: Throwable) { + lastError = e + Thread.sleep(intervalMs) + } + } + throw lastError ?: RuntimeException("View not visible within ${timeoutMs}ms: $matcher") + } + + /** + * Clicks a view without the standard isDisplayed() precondition check. + * Use for elements that are VISIBLE but whose getGlobalVisibleRect() is empty + * (e.g. a bottom sheet button that is present but not yet in the visible rect). + */ + val forceClick: ViewAction = object : ViewAction { + override fun getConstraints(): Matcher = isEnabled() + override fun getDescription() = "force click ignoring visibility" + override fun perform(uiController: UiController, view: View) { + view.performClick() + uiController.loopMainThreadUntilIdle() + } + } + + fun withIndex(matcher: Matcher, index: Int): Matcher { + return object : TypeSafeMatcher() { + var currentIndex = 0 + + override fun describeTo(description: Description) { + description.appendText("with index: $index ") + matcher.describeTo(description) + } + + override fun matchesSafely(item: T): Boolean { + val matched = matcher.matches(item) + if (matched && currentIndex++ == index) { + return true + } + return false + } + } + } +} diff --git a/android/app/src/androidTest/resources/allure.properties b/android/app/src/androidTest/resources/allure.properties new file mode 100644 index 0000000000..890384fac0 --- /dev/null +++ b/android/app/src/androidTest/resources/allure.properties @@ -0,0 +1 @@ +allure.results.useTestStorage=true \ No newline at end of file diff --git a/android/app/src/main/java/com/bitpay/wallet/MainActivity.kt b/android/app/src/main/java/com/bitpay/wallet/MainActivity.kt index 8ad8bd6c2f..6506a3782e 100644 --- a/android/app/src/main/java/com/bitpay/wallet/MainActivity.kt +++ b/android/app/src/main/java/com/bitpay/wallet/MainActivity.kt @@ -1,7 +1,10 @@ package com.bitpay.wallet import android.content.Intent +import android.graphics.Color +import android.os.Build import android.os.Bundle +import android.view.View import android.view.WindowManager import com.braze.reactbridge.BrazeReactUtils import com.braze.ui.inappmessage.BrazeInAppMessageManager @@ -44,11 +47,41 @@ class MainActivity : ReactActivity() { (application as MainApplication).addActivityToStack(this.javaClass) window.apply { - setFlags( - WindowManager.LayoutParams.FLAG_SECURE, - WindowManager.LayoutParams.FLAG_SECURE - ) + if (!BuildConfig.DEBUG) { + // Disable screenshots/screen recording only in production. + // Allow automation tools (Espresso/Appium/Layout Inspector) + // to capture screenshots in debug builds. + setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE + ) + } + + if (Build.VERSION.SDK_INT in 19..20) { + setWindowFlag(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS, true) + } + + if (Build.VERSION.SDK_INT >= 19) { + decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + } + + // make fully Android Transparent Status bar + if (Build.VERSION.SDK_INT >= 21) { + setWindowFlag(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS, false) + statusBarColor = Color.TRANSPARENT + } + } + } + + private fun setWindowFlag(bits: Int, on: Boolean) { + val win = window + val winParams = win.attributes + if (on) { + winParams.flags = winParams.flags or bits + } else { + winParams.flags = winParams.flags and bits.inv() } + win.attributes = winParams } override fun onNewIntent(intent: Intent) { diff --git a/android/build.gradle b/android/build.gradle index d197f00d2c..343a3b0e2a 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -73,4 +73,45 @@ subprojects { subproject -> } else { subproject.afterEvaluate { injectPrefixMap() } } + + gradle.afterProject { proj -> + if (proj != rootProject && proj.hasProperty('android')) { + proj.android { + if (defaultConfig.minSdkVersion == null || defaultConfig.minSdkVersion.apiLevel < 24) { + defaultConfig { + minSdkVersion 24 + } + } + + if (compileSdkVersion == null || (compileSdkVersion as String).replace("android-", "").toInteger() < 34) { + compileSdkVersion 36 + } + + if (proj.name != 'app') { + packagingOptions { + pickFirst 'lib/*/libfbjni.so' + pickFirst 'lib/*/libc++_shared.so' + pickFirst 'lib/*/libjsi.so' + pickFirst 'lib/*/libreactnative.so' + pickFirst 'lib/*/libhermes.so' + + resources { + pickFirsts += 'META-INF/**/coroutines.pro' + excludes += [ + 'META-INF/MANIFEST.MF', + 'META-INF/LICENSE', + 'META-INF/LICENSE.txt', + 'META-INF/LICENSE.md', + 'META-INF/LICENSE-notice.md', + 'META-INF/NOTICE', + 'META-INF/NOTICE.txt', + 'META-INF/DEPENDENCIES', + 'META-INF/*.kotlin_module' + ] + } + } + } + } + } +} } diff --git a/ios/BitPayApp.xcodeproj/project.pbxproj b/ios/BitPayApp.xcodeproj/project.pbxproj index 75a3600f96..5716b19b77 100644 --- a/ios/BitPayApp.xcodeproj/project.pbxproj +++ b/ios/BitPayApp.xcodeproj/project.pbxproj @@ -22,10 +22,31 @@ 5BCF1C352740B8B2006D872E /* BootSplash.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 5BCF1C342740B8B2006D872E /* BootSplash.storyboard */; }; 5BEA4BF02838402700AB2121 /* RCTPaymentPass.mm in Sources */ = {isa = PBXBuildFile; fileRef = 5BEA4BEF2838402700AB2121 /* RCTPaymentPass.mm */; }; 6D615C4929725FD70063DB8E /* RCTTimer.mm in Sources */ = {isa = PBXBuildFile; fileRef = 6D615C4729725FD70063DB8E /* RCTTimer.mm */; }; - CA5471E01A2B3C4D5E6F7001 /* RCTCrashTest.mm in Sources */ = {isa = PBXBuildFile; fileRef = CA5471E01A2B3C4D5E6F7002 /* RCTCrashTest.mm */; }; 6D7C95E72E9007E200F0D481 /* RCTPushPermissionManager.mm in Sources */ = {isa = PBXBuildFile; fileRef = 6D7C95E62E9007E200F0D481 /* RCTPushPermissionManager.mm */; }; 71BABDA4524A43E98A190C87 /* Archivo-Light.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 6D649751C2644FDCA18A5547 /* Archivo-Light.ttf */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; + A484AB14306E7F5A00FE2D63 /* TestConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A484AB13306E7F5A00FE2D63 /* TestConfig.swift */; }; + CD1957ED2FB37AD40072A9AE /* Test595_BasicSellBTC_.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1957E92FB37AD40072A9AE /* Test595_BasicSellBTC_.swift */; }; + CD1957EE2FB37AD40072A9AE /* Test592_ImportWalletRecoveryPhrase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1957E62FB37AD40072A9AE /* Test592_ImportWalletRecoveryPhrase.swift */; }; + CD1957EF2FB37AD40072A9AE /* MykeyPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1957DB2FB37AD40072A9AE /* MykeyPage.swift */; }; + CD1957F02FB37AD40072A9AE /* BuyPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1957D62FB37AD40072A9AE /* BuyPage.swift */; }; + CD1957F12FB37AD40072A9AE /* SellPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1957E12FB37AD40072A9AE /* SellPage.swift */; }; + CD1957F22FB37AD40072A9AE /* Test591_OnboardingCreateWallet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1957E52FB37AD40072A9AE /* Test591_OnboardingCreateWallet.swift */; }; + CD1957F32FB37AD40072A9AE /* Test596_BasicBuyBTC_.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1957EA2FB37AD40072A9AE /* Test596_BasicBuyBTC_.swift */; }; + CD1957F62FB37AD40072A9AE /* ConfirmPaymentPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1957D72FB37AD40072A9AE /* ConfirmPaymentPage.swift */; }; + CD1957F72FB37AD40072A9AE /* AllureXCTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1957D32FB37AD40072A9AE /* AllureXCTestSupport.swift */; }; + CD1957F82FB37AD40072A9AE /* EnterAmountPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1957D82FB37AD40072A9AE /* EnterAmountPage.swift */; }; + CD1957F92FB37AD40072A9AE /* Test594_BasicSwapBTC.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1957E82FB37AD40072A9AE /* Test594_BasicSwapBTC.swift */; }; + CD1957FA2FB37AD40072A9AE /* AddCryptoOptionPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1957D52FB37AD40072A9AE /* AddCryptoOptionPage.swift */; }; + CD1957FB2FB37AD40072A9AE /* OnboardingPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1957DD2FB37AD40072A9AE /* OnboardingPage.swift */; }; + CD1957FC2FB37AD40072A9AE /* ImportRecoveryPhrasePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1957DA2FB37AD40072A9AE /* ImportRecoveryPhrasePage.swift */; }; + CD1957FD2FB37AD40072A9AE /* PortfolioBalancePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1957DE2FB37AD40072A9AE /* PortfolioBalancePage.swift */; }; + CD1957FE2FB37AD40072A9AE /* SwapPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1957E22FB37AD40072A9AE /* SwapPage.swift */; }; + CD1957FF2FB37AD40072A9AE /* Test593_BTCConfirmPaymentScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1957E72FB37AD40072A9AE /* Test593_BTCConfirmPaymentScreen.swift */; }; + CD1958002FB37AD40072A9AE /* SelectKeyToDepositPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1957E02FB37AD40072A9AE /* SelectKeyToDepositPage.swift */; }; + CD1958012FB37AD40072A9AE /* SelectCurrencyPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1957DF2FB37AD40072A9AE /* SelectCurrencyPage.swift */; }; + CD1958022FB37AD40072A9AE /* HomePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD1957D92FB37AD40072A9AE /* HomePage.swift */; }; + CD9683A82FB70CCC00192CFF /* AppFlows.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9683A72FB70CC700192CFF /* AppFlows.swift */; }; D34892F44CAB44409EC335A9 /* Archivo-Black.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F4435EB64D2448EC885B3036 /* Archivo-Black.ttf */; }; D9739196BA804EA29143DA96 /* Archivo-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 715670CB346B45A7A9EC2044 /* Archivo-Bold.ttf */; }; E6807D9059A643129784A5C3 /* Archivo-Thin.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 4DCF32F2D2364E70AD2F1E68 /* Archivo-Thin.ttf */; }; @@ -34,6 +55,16 @@ F64C0BF216CE4402A51A5ACC /* Archivo-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = BCDAB70057C84109A9201936 /* Archivo-Regular.ttf */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + CDF0850D2FACD8A500CA4C1B /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 13B07F861A680F5B00A75B9A; + remoteInfo = BitPayApp; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ B17FA8700A11CE0000000002 /* BitPayFabricModalPresenter.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = BitPayFabricModalPresenter.mm; sourceTree = ""; }; B17FA8700A11CE0000000003 /* BitPayFabricModalPresenter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BitPayFabricModalPresenter.h; sourceTree = ""; }; @@ -56,17 +87,38 @@ 5BCF1C342740B8B2006D872E /* BootSplash.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = BootSplash.storyboard; path = BitPayApp/BootSplash.storyboard; sourceTree = ""; }; 5BEA4BEE2838400700AB2121 /* RCTPaymentPass.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RCTPaymentPass.h; sourceTree = ""; }; 5BEA4BEF2838402700AB2121 /* RCTPaymentPass.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCTPaymentPass.mm; sourceTree = ""; }; - 5D5D40A7E7B5265D652187F9 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = BitPayApp/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 5D5D40A7E7B5265D652187F9 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = BitPayApp/PrivacyInfo.xcprivacy; sourceTree = ""; }; 6D615C4729725FD70063DB8E /* RCTTimer.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTTimer.mm; sourceTree = ""; }; 6D615C4829725FD70063DB8E /* RCTTimer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTTimer.h; sourceTree = ""; }; - CA5471E01A2B3C4D5E6F7002 /* RCTCrashTest.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTCrashTest.mm; sourceTree = ""; }; - CA5471E01A2B3C4D5E6F7003 /* RCTCrashTest.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTCrashTest.h; sourceTree = ""; }; 6D649751C2644FDCA18A5547 /* Archivo-Light.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Archivo-Light.ttf"; path = "../assets/fonts/Archivo-Light.ttf"; sourceTree = ""; }; 6D7C95E62E9007E200F0D481 /* RCTPushPermissionManager.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = RCTPushPermissionManager.mm; sourceTree = ""; }; 715670CB346B45A7A9EC2044 /* Archivo-Bold.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Archivo-Bold.ttf"; path = "../assets/fonts/Archivo-Bold.ttf"; sourceTree = ""; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = BitPayApp/AppDelegate.swift; sourceTree = ""; }; + A484AB13306E7F5A00FE2D63 /* TestConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConfig.swift; sourceTree = ""; }; B1AA7DD4226DBC3478258808 /* libPods-BitPayApp.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-BitPayApp.a"; sourceTree = BUILT_PRODUCTS_DIR; }; BCDAB70057C84109A9201936 /* Archivo-Regular.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Archivo-Regular.ttf"; path = "../assets/fonts/Archivo-Regular.ttf"; sourceTree = ""; }; + CD1957D32FB37AD40072A9AE /* AllureXCTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllureXCTestSupport.swift; sourceTree = ""; }; + CD1957D52FB37AD40072A9AE /* AddCryptoOptionPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddCryptoOptionPage.swift; sourceTree = ""; }; + CD1957D62FB37AD40072A9AE /* BuyPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuyPage.swift; sourceTree = ""; }; + CD1957D72FB37AD40072A9AE /* ConfirmPaymentPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmPaymentPage.swift; sourceTree = ""; }; + CD1957D82FB37AD40072A9AE /* EnterAmountPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnterAmountPage.swift; sourceTree = ""; }; + CD1957D92FB37AD40072A9AE /* HomePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomePage.swift; sourceTree = ""; }; + CD1957DA2FB37AD40072A9AE /* ImportRecoveryPhrasePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportRecoveryPhrasePage.swift; sourceTree = ""; }; + CD1957DB2FB37AD40072A9AE /* MykeyPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MykeyPage.swift; sourceTree = ""; }; + CD1957DD2FB37AD40072A9AE /* OnboardingPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingPage.swift; sourceTree = ""; }; + CD1957DE2FB37AD40072A9AE /* PortfolioBalancePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortfolioBalancePage.swift; sourceTree = ""; }; + CD1957DF2FB37AD40072A9AE /* SelectCurrencyPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectCurrencyPage.swift; sourceTree = ""; }; + CD1957E02FB37AD40072A9AE /* SelectKeyToDepositPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectKeyToDepositPage.swift; sourceTree = ""; }; + CD1957E12FB37AD40072A9AE /* SellPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SellPage.swift; sourceTree = ""; }; + CD1957E22FB37AD40072A9AE /* SwapPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapPage.swift; sourceTree = ""; }; + CD1957E52FB37AD40072A9AE /* Test591_OnboardingCreateWallet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Test591_OnboardingCreateWallet.swift; sourceTree = ""; }; + CD1957E62FB37AD40072A9AE /* Test592_ImportWalletRecoveryPhrase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Test592_ImportWalletRecoveryPhrase.swift; sourceTree = ""; }; + CD1957E72FB37AD40072A9AE /* Test593_BTCConfirmPaymentScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Test593_BTCConfirmPaymentScreen.swift; sourceTree = ""; }; + CD1957E82FB37AD40072A9AE /* Test594_BasicSwapBTC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Test594_BasicSwapBTC.swift; sourceTree = ""; }; + CD1957E92FB37AD40072A9AE /* Test595_BasicSellBTC_.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Test595_BasicSellBTC_.swift; sourceTree = ""; }; + CD1957EA2FB37AD40072A9AE /* Test596_BasicBuyBTC_.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Test596_BasicBuyBTC_.swift; sourceTree = ""; }; + CD9683A72FB70CC700192CFF /* AppFlows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFlows.swift; sourceTree = ""; }; + CDF085072FACD8A500CA4C1B /* BitPayAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BitPayAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; E30DDDCF29E5E048D75CCF1A /* BundleHash.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BitPayApp/BundleHash.swift; sourceTree = ""; }; E639E0380D05420CD0D64119 /* Pods-BitPayApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BitPayApp.release.xcconfig"; path = "Target Support Files/Pods-BitPayApp/Pods-BitPayApp.release.xcconfig"; sourceTree = ""; }; EC0FDD692CC92E90009E1B35 /* InAppMessageModule.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = InAppMessageModule.mm; sourceTree = ""; }; @@ -87,6 +139,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CDF085042FACD8A500CA4C1B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -106,8 +165,6 @@ 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */, 6D615C4829725FD70063DB8E /* RCTTimer.h */, 6D615C4729725FD70063DB8E /* RCTTimer.mm */, - CA5471E01A2B3C4D5E6F7003 /* RCTCrashTest.h */, - CA5471E01A2B3C4D5E6F7002 /* RCTCrashTest.mm */, 5B31AAFC2786381C002765E1 /* RCTDosh.h */, 5B31AAFD2786387B002765E1 /* RCTDosh.mm */, 5B31AB5E2786515A002765E1 /* Dosh.swift */, @@ -168,10 +225,12 @@ children = ( 13B07FAE1A68108700A75B9A /* BitPayApp */, 832341AE1AAA6A7D00B99B32 /* Libraries */, + CDF085082FACD8A500CA4C1B /* BitPayAppUITests */, 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, 7950FCCDC90E6306EDD77BCD /* Pods */, 3A6F88D6BF7C4F7A8D93264A /* Resources */, + CDF085022FACD87900CA4C1B /* Recovered References */, ); indentWidth = 2; sourceTree = ""; @@ -182,10 +241,80 @@ isa = PBXGroup; children = ( 13B07F961A680F5B00A75B9A /* BitPayApp.app */, + CDF085072FACD8A500CA4C1B /* BitPayAppUITests.xctest */, ); name = Products; sourceTree = ""; }; + CD1957D42FB37AD40072A9AE /* AllureReport */ = { + isa = PBXGroup; + children = ( + CD1957D32FB37AD40072A9AE /* AllureXCTestSupport.swift */, + ); + path = AllureReport; + sourceTree = ""; + }; + CD1957E42FB37AD40072A9AE /* pages */ = { + isa = PBXGroup; + children = ( + CD1957D52FB37AD40072A9AE /* AddCryptoOptionPage.swift */, + CD1957D62FB37AD40072A9AE /* BuyPage.swift */, + CD1957D72FB37AD40072A9AE /* ConfirmPaymentPage.swift */, + CD1957D82FB37AD40072A9AE /* EnterAmountPage.swift */, + CD1957D92FB37AD40072A9AE /* HomePage.swift */, + CD1957DA2FB37AD40072A9AE /* ImportRecoveryPhrasePage.swift */, + CD1957DB2FB37AD40072A9AE /* MykeyPage.swift */, + CD1957DD2FB37AD40072A9AE /* OnboardingPage.swift */, + CD1957DE2FB37AD40072A9AE /* PortfolioBalancePage.swift */, + CD1957DF2FB37AD40072A9AE /* SelectCurrencyPage.swift */, + CD1957E02FB37AD40072A9AE /* SelectKeyToDepositPage.swift */, + CD1957E12FB37AD40072A9AE /* SellPage.swift */, + CD1957E22FB37AD40072A9AE /* SwapPage.swift */, + ); + path = pages; + sourceTree = ""; + }; + CD1957EC2FB37AD40072A9AE /* tests */ = { + isa = PBXGroup; + children = ( + CD1957E52FB37AD40072A9AE /* Test591_OnboardingCreateWallet.swift */, + CD1957E62FB37AD40072A9AE /* Test592_ImportWalletRecoveryPhrase.swift */, + CD1957E72FB37AD40072A9AE /* Test593_BTCConfirmPaymentScreen.swift */, + CD1957E82FB37AD40072A9AE /* Test594_BasicSwapBTC.swift */, + CD1957E92FB37AD40072A9AE /* Test595_BasicSellBTC_.swift */, + CD1957EA2FB37AD40072A9AE /* Test596_BasicBuyBTC_.swift */, + ); + path = tests; + sourceTree = ""; + }; + CD96839F2FB70CAF00192CFF /* flows */ = { + isa = PBXGroup; + children = ( + CD9683A72FB70CC700192CFF /* AppFlows.swift */, + ); + path = flows; + sourceTree = ""; + }; + CDF085022FACD87900CA4C1B /* Recovered References */ = { + isa = PBXGroup; + children = ( + 6D7C95E62E9007E200F0D481 /* RCTPushPermissionManager.mm */, + ); + name = "Recovered References"; + sourceTree = ""; + }; + CDF085082FACD8A500CA4C1B /* BitPayAppUITests */ = { + isa = PBXGroup; + children = ( + A484AB13306E7F5A00FE2D63 /* TestConfig.swift */, + CD1957D42FB37AD40072A9AE /* AllureReport */, + CD1957E42FB37AD40072A9AE /* pages */, + CD1957EC2FB37AD40072A9AE /* tests */, + CD96839F2FB70CAF00192CFF /* flows */, + ); + path = BitPayAppUITests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -212,17 +341,40 @@ productReference = 13B07F961A680F5B00A75B9A /* BitPayApp.app */; productType = "com.apple.product-type.application"; }; + CDF085062FACD8A500CA4C1B /* BitPayAppUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = CDF085112FACD8A500CA4C1B /* Build configuration list for PBXNativeTarget "BitPayAppUITests" */; + buildPhases = ( + CDF085032FACD8A500CA4C1B /* Sources */, + CDF085042FACD8A500CA4C1B /* Frameworks */, + CDF085052FACD8A500CA4C1B /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + CDF0850E2FACD8A500CA4C1B /* PBXTargetDependency */, + ); + name = BitPayAppUITests; + productName = BitPayAppUITests; + productReference = CDF085072FACD8A500CA4C1B /* BitPayAppUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 83CBB9F71A601CBA00E9B192 /* Project object */ = { isa = PBXProject; attributes = { + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 1210; TargetAttributes = { 13B07F861A680F5B00A75B9A = { LastSwiftMigration = 1240; }; + CDF085062FACD8A500CA4C1B = { + CreatedOnToolsVersion = 26.0.1; + TestTargetID = 13B07F861A680F5B00A75B9A; + }; }; }; buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "BitPayApp" */; @@ -239,6 +391,7 @@ projectRoot = ""; targets = ( 13B07F861A680F5B00A75B9A /* BitPayApp */, + CDF085062FACD8A500CA4C1B /* BitPayAppUITests */, ); }; /* End PBXProject section */ @@ -262,6 +415,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CDF085052FACD8A500CA4C1B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -277,7 +437,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -e\n\nif [ -z \"$NODE_BINARY\" ]; then\n export NODE_BINARY=$(command -v node || true)\nfi\nif [ -z \"$NODE_BINARY\" ] || [ ! -x \"$NODE_BINARY\" ]; then\n echo \"error: NODE_BINARY not set and 'node' not found on PATH.\"\n exit 1\nfi\necho \"Using NODE_BINARY=$NODE_BINARY\"\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nSENTRY_XCODE=\"../node_modules/@sentry/react-native/scripts/sentry-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $SENTRY_XCODE\"\n"; + shellScript = "export SENTRY_DISABLE_AUTO_UPLOAD=true\n\nset -e\n\nif [ -z \"$NODE_BINARY\" ]; then\n export NODE_BINARY=$(command -v node || true)\nfi\nif [ -z \"$NODE_BINARY\" ] || [ ! -x \"$NODE_BINARY\" ]; then\n echo \"error: NODE_BINARY not set and 'node' not found on PATH.\"\n exit 1\nfi\necho \"Using NODE_BINARY=$NODE_BINARY\"\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nSENTRY_XCODE=\"../node_modules/@sentry/react-native/scripts/sentry-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $SENTRY_XCODE\"\n"; }; 3C1070649A3322DC1391852F /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; @@ -383,22 +543,57 @@ 6D7C95E72E9007E200F0D481 /* RCTPushPermissionManager.mm in Sources */, 5BEA4BF02838402700AB2121 /* RCTPaymentPass.mm in Sources */, 6D615C4929725FD70063DB8E /* RCTTimer.mm in Sources */, - CA5471E01A2B3C4D5E6F7001 /* RCTCrashTest.mm in Sources */, ECCB92782860B25D00F9CF8B /* SilentPushEvent.mm in Sources */, 5B31AB5F2786515A002765E1 /* Dosh.swift in Sources */, 52C9714B9004BCE190EA469D /* BundleHash.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + CDF085032FACD8A500CA4C1B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CD1957ED2FB37AD40072A9AE /* Test595_BasicSellBTC_.swift in Sources */, + CD1957EE2FB37AD40072A9AE /* Test592_ImportWalletRecoveryPhrase.swift in Sources */, + CD1957EF2FB37AD40072A9AE /* MykeyPage.swift in Sources */, + CD1957F02FB37AD40072A9AE /* BuyPage.swift in Sources */, + CD1957F12FB37AD40072A9AE /* SellPage.swift in Sources */, + CD1957F22FB37AD40072A9AE /* Test591_OnboardingCreateWallet.swift in Sources */, + CD1957F32FB37AD40072A9AE /* Test596_BasicBuyBTC_.swift in Sources */, + CD9683A82FB70CCC00192CFF /* AppFlows.swift in Sources */, + CD1957F62FB37AD40072A9AE /* ConfirmPaymentPage.swift in Sources */, + CD1957F72FB37AD40072A9AE /* AllureXCTestSupport.swift in Sources */, + CD1957F82FB37AD40072A9AE /* EnterAmountPage.swift in Sources */, + CD1957F92FB37AD40072A9AE /* Test594_BasicSwapBTC.swift in Sources */, + CD1957FA2FB37AD40072A9AE /* AddCryptoOptionPage.swift in Sources */, + CD1957FB2FB37AD40072A9AE /* OnboardingPage.swift in Sources */, + CD1957FC2FB37AD40072A9AE /* ImportRecoveryPhrasePage.swift in Sources */, + CD1957FD2FB37AD40072A9AE /* PortfolioBalancePage.swift in Sources */, + CD1957FE2FB37AD40072A9AE /* SwapPage.swift in Sources */, + A484AB14306E7F5A00FE2D63 /* TestConfig.swift in Sources */, + CD1957FF2FB37AD40072A9AE /* Test593_BTCConfirmPaymentScreen.swift in Sources */, + CD1958002FB37AD40072A9AE /* SelectKeyToDepositPage.swift in Sources */, + CD1958012FB37AD40072A9AE /* SelectCurrencyPage.swift in Sources */, + CD1958022FB37AD40072A9AE /* HomePage.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + CDF0850E2FACD8A500CA4C1B /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 13B07F861A680F5B00A75B9A /* BitPayApp */; + targetProxy = CDF0850D2FACD8A500CA4C1B /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 4221E54904CA4996330CEB54 /* Pods-BitPayApp.debug.xcconfig */; buildSettings = { - ALLOWED_URL_PREFIXES = "ALLOWED-URL-PREFIXES-REPLACE-ME"; - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + ALLOWED_URL_PREFIXES = "https://bitpay.com/,https://test.bitpay.com/,https://staging.bitpay.com/,https://bws.bitpay.com/,https://www.coinbase.com/,https://api.coinbase.com/,https://api.1inch.dev/swap/v5.2/,https://api.coingecko.com/api/v3/simple/token_price/,https://checkout.simplexcc.com/,https://sandbox.test-simplexcc.com,https://api.moonpay.com/,https://api.moonpay.dev/,https://api.zenledger.io/bitpay/wallets/,https://rest.iad-05.braze.com/,https://sdk.iad-05.braze.com/,https://cloudflare-eth.com/,https://sepolia.infura.io/v3/,https://polygon-rpc.com/,https://polygon-amoy.core.chainstack.com/,https://static.methodfi.com/,https://api.zenledger.io/,https://api.blockcypher.com/v1/,https://api.fullstack.cash/v5/,https://deep-index.moralis.io/api/v2.2/,https://api.bitcore.io/api,https://api-eth.bitcore.io/api,https://api-matic.bitcore.io/api,https://api-arb.bitcore.io/api,https://api-base.bitcore.io/api,https://api-op.bitcore.io/api,https://api-xrp.bitcore.io/api,https://api-sol.bitcore.io/api,https://portal.polygon.technology/bridge,https://bridge.arbitrum.io/,https://bridge.base.org/,https://app.optimism.io/bridge/,https://mainnet.optimism.io/,https://arb1.arbitrum.io/rpc,https://mainnet.base.org/,https://goerli.optimism.io,https://goerli-rollup.arbitrum.io/rpc,https://goerli.base.org,https://verify.walletconnect.com/,https://verify.walletconnect.org/,https://api.etherscan.io/v2/api,https://solana-gateway.moralis.io/,https://tokens-data.1inch.io/images/501/"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = NO; CLANG_ENABLE_MODULES = YES; @@ -443,8 +638,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = E639E0380D05420CD0D64119 /* Pods-BitPayApp.release.xcconfig */; buildSettings = { - ALLOWED_URL_PREFIXES = "ALLOWED-URL-PREFIXES-REPLACE-ME"; - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + ALLOWED_URL_PREFIXES = "https://bitpay.com/,https://test.bitpay.com/,https://staging.bitpay.com/,https://bws.bitpay.com/,https://www.coinbase.com/,https://api.coinbase.com/,https://api.1inch.dev/swap/v5.2/,https://api.coingecko.com/api/v3/simple/token_price/,https://checkout.simplexcc.com/,https://sandbox.test-simplexcc.com,https://api.moonpay.com/,https://api.moonpay.dev/,https://api.zenledger.io/bitpay/wallets/,https://rest.iad-05.braze.com/,https://sdk.iad-05.braze.com/,https://cloudflare-eth.com/,https://sepolia.infura.io/v3/,https://polygon-rpc.com/,https://polygon-amoy.core.chainstack.com/,https://static.methodfi.com/,https://api.zenledger.io/,https://api.blockcypher.com/v1/,https://api.fullstack.cash/v5/,https://deep-index.moralis.io/api/v2.2/,https://api.bitcore.io/api,https://api-eth.bitcore.io/api,https://api-matic.bitcore.io/api,https://api-arb.bitcore.io/api,https://api-base.bitcore.io/api,https://api-op.bitcore.io/api,https://api-xrp.bitcore.io/api,https://api-sol.bitcore.io/api,https://portal.polygon.technology/bridge,https://bridge.arbitrum.io/,https://bridge.base.org/,https://app.optimism.io/bridge/,https://mainnet.optimism.io/,https://arb1.arbitrum.io/rpc,https://mainnet.base.org/,https://goerli.optimism.io,https://goerli-rollup.arbitrum.io/rpc,https://goerli.base.org,https://verify.walletconnect.com/,https://verify.walletconnect.org/,https://api.etherscan.io/v2/api,https://solana-gateway.moralis.io/,https://tokens-data.1inch.io/images/501/"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = NO; CLANG_ENABLE_MODULES = YES; @@ -673,6 +867,81 @@ }; name = Release; }; + CDF0850F2FACD8A500CA4C1B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.test.appium.BitPayAppUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = NO; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = BitPayApp; + }; + name = Debug; + }; + CDF085102FACD8A500CA4C1B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.test.appium.BitPayAppUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = NO; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = BitPayApp; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -694,6 +963,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + CDF085112FACD8A500CA4C1B /* Build configuration list for PBXNativeTarget "BitPayAppUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CDF0850F2FACD8A500CA4C1B /* Debug */, + CDF085102FACD8A500CA4C1B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; diff --git a/ios/BitPayApp.xcodeproj/xcshareddata/xcschemes/BitPayApp.xcscheme b/ios/BitPayApp.xcodeproj/xcshareddata/xcschemes/BitPayApp.xcscheme index 3874813c99..92d73acdf4 100644 --- a/ios/BitPayApp.xcodeproj/xcshareddata/xcschemes/BitPayApp.xcscheme +++ b/ios/BitPayApp.xcodeproj/xcshareddata/xcschemes/BitPayApp.xcscheme @@ -26,7 +26,9 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + systemAttachmentLifetime = "keepAlways" + userAttachmentLifetime = "keepAlways"> @@ -38,6 +40,17 @@ ReferencedContainer = "container:BitPayApp.xcodeproj"> + + + + :` convention. + static func addLabel(_ name: String, value: String) { + XCTContext.runActivity(named: "allure.label.\(name):\(value)") { _ in } + } + + /// Sets a custom test name visible in the report. + static func setDisplayName(_ name: String) { + XCTContext.runActivity(named: "allure.name:\(name)") { _ in } + } + + /// Adds a link to an issue tracker entry. + static func addIssue(_ title: String, url: String) { + XCTContext.runActivity(named: "allure.issue.\(title):\(url)") { _ in } + } + + /// Adds a generic hyperlink label. + static func addLink(_ title: String, url: String) { + XCTContext.runActivity(named: "allure.link.\(title):\(url)") { _ in } + } + + /// Adds a plain description to the test. + static func addDescription(_ description: String) { + XCTContext.runActivity(named: "allure.description:\(description)") { _ in } + } + + /// Wraps a code block in an activity that appears as a step in Allure. + @discardableResult + static func step(_ name: String, block: () throws -> T) rethrows -> T { + try XCTContext.runActivity(named: name) { _ in try block() } + } +} + +extension XCTestCase { + /// Attaches a full-screen capture to the current test. Lifetime is `.keepAlways` + /// so it remains available in the `.xcresult` bundle for Allure to render. + func allureAttachScreenshot(name: String = "Screenshot") { + let screenshot = XCUIScreen.main.screenshot() + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + /// Attaches a screenshot of the supplied application. + func allureAttachAppScreenshot(app: XCUIApplication, name: String = "App Screenshot") { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + /// Captures failure-time artifacts if the current test did not succeed. + func allureCaptureFailureArtifacts(app: XCUIApplication?, name: String = "Failure Screenshot") { + guard let app else { return } + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} + diff --git a/ios/BitPayAppUITests/TestConfig.swift b/ios/BitPayAppUITests/TestConfig.swift new file mode 100644 index 0000000000..c8aced0863 --- /dev/null +++ b/ios/BitPayAppUITests/TestConfig.swift @@ -0,0 +1,5 @@ +import Foundation + +enum TestConfig { + static let walletPhrase = "WALLET_PHRASE_REPLACE_ME" +} \ No newline at end of file diff --git a/ios/BitPayAppUITests/flows/AppFlows.swift b/ios/BitPayAppUITests/flows/AppFlows.swift new file mode 100644 index 0000000000..31fe2645c5 --- /dev/null +++ b/ios/BitPayAppUITests/flows/AppFlows.swift @@ -0,0 +1,89 @@ +// +// AppFlows.swift +// BitPayApp +// +// Created by vinoth vasu on 15/05/26. +// + +import XCTest + +final class AppFlows { + + static func completeOnboardingIfRequired(app: XCUIApplication) { + + let onboardingPage = OnboardingPage(app: app) + + if onboardingPage.isContinueWithoutAccountButtonDisplayed() { + + onboardingPage.tapContinuewithoutAnAccount() + + onboardingPage.skipOnboarding() + Thread.sleep(forTimeInterval: 2.0) + onboardingPage.skipOnboarding() + + onboardingPage.createWallet() + + guard onboardingPage.isBackupKeyLabelDisplayed() else { + XCTFail("Backup key page did not appear") + return + } + + onboardingPage.skipBackup() + Thread.sleep(forTimeInterval: 2.0) + onboardingPage.tapLater() + onboardingPage.acceptTerms() + onboardingPage.tapAgreeAndContinueButton() + + XCTAssertTrue( + onboardingPage.isYourPortfolioBalanceTextDisplayed(), + "Home screen did not load" + ) + } + } + + /// Recovers the app to the Home screen. + + static func goToHomeScreen(app: XCUIApplication, attempts: Int = 15) { + let onboardingPage = OnboardingPage(app: app) + + let portfolio = app.descendants(matching: .any).element( + matching: NSPredicate(format: "identifier == 'portfolio-balance-info-button'") + ).firstMatch + let homeTab = app.descendants(matching: .any).element( + matching: NSPredicate(format: "label BEGINSWITH 'Home, tab'") + ).firstMatch + let backControl = app.descendants(matching: .any).element( + matching: NSPredicate( + format: "identifier IN {'back-button','header-back-button','back-arrow','nav-back-button','close-button'} " + + "OR label IN {'Back','Close','Cancel','Go back'}" + ) + ).firstMatch + + for _ in 0.. Bool { + return selectAnOptionText.waitForExistence(timeout: timeout) + } + + func tapImportKey() { + importKey.tap() + } + +} diff --git a/ios/BitPayAppUITests/pages/BuyPage.swift b/ios/BitPayAppUITests/pages/BuyPage.swift new file mode 100644 index 0000000000..7b472dc00f --- /dev/null +++ b/ios/BitPayAppUITests/pages/BuyPage.swift @@ -0,0 +1,36 @@ +import XCTest + +class BuyPage { + + let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + // Detect the Buy screen by its amount keypad; "Buy" static text also exists on Home. + var buyTitle: XCUIElement { + app.staticTexts["5"].firstMatch + } + + var bitcoin: XCUIElement { + app.otherElements.matching( + NSPredicate(format: "label == 'Bitcoin'") + ).firstMatch + } + + + // MARK: - Validations + + func isBuyPageTitleDisplayed(timeout: TimeInterval = 30) -> Bool { + return buyTitle.waitForExistence(timeout: timeout) + } + + func tapBitcoin() { + bitcoin.tap() + } + + +} diff --git a/ios/BitPayAppUITests/pages/ConfirmPaymentPage.swift b/ios/BitPayAppUITests/pages/ConfirmPaymentPage.swift new file mode 100644 index 0000000000..fd82fc0aac --- /dev/null +++ b/ios/BitPayAppUITests/pages/ConfirmPaymentPage.swift @@ -0,0 +1,45 @@ + +// +// OnboardingPage.swift +// BitPayApp +// +// Created by vinoth vasu on 16/03/26. +// + +import XCTest + +class ConfirmPaymentPage { + + let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + var confirmPaymentText: XCUIElement { + app.descendants(matching: .any).element( + matching: NSPredicate(format: "label == 'Confirm Payment'") + ).firstMatch + } + + var summaryText: XCUIElement { + app.descendants(matching: .any).element( + matching: NSPredicate(format: "label == 'SUMMARY'") + ).firstMatch + } + + + // MARK: - Actions + + func isConfirmPaymentTitleDisplayed(timeout: TimeInterval = 180) -> Bool { + return confirmPaymentText.waitForExistence(timeout: timeout) + } + + func isSummaryTextDisplayed(timeout: TimeInterval = 5) -> Bool { + return summaryText.waitForExistence(timeout: timeout) + } + + +} diff --git a/ios/BitPayAppUITests/pages/EnterAmountPage.swift b/ios/BitPayAppUITests/pages/EnterAmountPage.swift new file mode 100644 index 0000000000..ad10ee8b1b --- /dev/null +++ b/ios/BitPayAppUITests/pages/EnterAmountPage.swift @@ -0,0 +1,89 @@ + +// +// OnboardingPage.swift +// BitPayApp +// +// Created by vinoth vasu on 16/03/26. +// + +import XCTest + +class EnterAmountPage { + + let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + var amountField: XCUIElement { + app.staticTexts.matching(identifier: "0").firstMatch + } + + var continueButton: XCUIElement { + app.descendants(matching: .any).element( + matching: NSPredicate(format: "label CONTAINS 'Continue'") + ).firstMatch + } + + // MARK: - Actions + func enterAmount(amount: String = "0") { + for char in amount { + let key = app.staticTexts[String(char)].firstMatch + XCTAssertTrue( + key.waitForExistence(timeout: 15), + "'\(char)' key not found on the amount keypad" + ) + key.tap() + } + } + + func tapContinue() { + continueButton.tap() + } + + /** + Backspace key does not expose a unique accessibility identifier. + Calculate its position dynamically using the relative spacing + between the keypad keys "6" and "9". + */ + func tapBackspace(count: Int = 1) { + + let sixKey = app.staticTexts["6"].firstMatch + let nineKey = app.staticTexts["9"].firstMatch + + XCTAssertTrue( + sixKey.waitForExistence(timeout: 15), + "6 key not found - amount keypad is not on screen" + ) + + XCTAssertTrue( + nineKey.waitForExistence(timeout: 15), + "9 key not found - amount keypad is not on screen" + ) + + let sixFrame = sixKey.frame + let nineFrame = nineKey.frame + + let rowHeight = abs(nineFrame.midY - sixFrame.midY) + + let backspaceX = nineFrame.midX + let backspaceY = nineFrame.midY + rowHeight + + let backspaceCoordinate = app.coordinate( + withNormalizedOffset: CGVector(dx: 0, dy: 0) + ).withOffset( + CGVector( + dx: backspaceX, + dy: backspaceY + ) + ) + + for _ in 0.. Bool { + return backupKeyLabel.waitForExistence(timeout: timeout) + } + + func tapLater() { + laterButton.tap() + } + + func acceptTerms() { + checkbox1.tap() + checkbox2.tap() + checkbox3.tap() + } + + func tapAgreeAndContinueButton() { + agreeAndContinueButton.tap() + } + + func isYourPortfolioBalanceTextDisplayed(timeout: TimeInterval = 25) -> Bool { + return yourPortfolioBalanceText.waitForExistence(timeout: timeout) + } + + func swipeOnboarding() { + onboardingScrollView.swipeRight() + } + + func tapGotIt() { + gotItButton.tap() + } + + func tapBuyOption() { + buyButton.tap() + } + + func tapBackUpKey() { + backUpKey.tap() + } + + func tapAddCryptoButton() { + addCryptoButton.tap() + } + +} diff --git a/ios/BitPayAppUITests/pages/ImportRecoveryPhrasePage.swift b/ios/BitPayAppUITests/pages/ImportRecoveryPhrasePage.swift new file mode 100644 index 0000000000..ebed0b9eac --- /dev/null +++ b/ios/BitPayAppUITests/pages/ImportRecoveryPhrasePage.swift @@ -0,0 +1,39 @@ +import XCTest + +class ImportRecoveryPhrasePage { + + let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + var inputRecoveryPhrase: XCUIElement { + app.descendants(matching: .any) + .matching(NSPredicate(format: "label == 'Enter recovery phrase'")) + .firstMatch + } + + var importWalletButton: XCUIElement { + app.descendants(matching: .any) + .matching(NSPredicate(format: "label == 'Import Wallet'")) + .firstMatch + } + + // MARK: - Actions + + func isImportScreenDisplayed(timeout: TimeInterval = 5) -> Bool { + return inputRecoveryPhrase.waitForExistence(timeout: timeout) + } + + func enterRecoveryPhrase(_ phrase: String) { + inputRecoveryPhrase.tap() + inputRecoveryPhrase.typeText(phrase) + } + + func tapImportWallet() { + importWalletButton.tap() + } +} diff --git a/ios/BitPayAppUITests/pages/MykeyPage.swift b/ios/BitPayAppUITests/pages/MykeyPage.swift new file mode 100644 index 0000000000..1fb5546839 --- /dev/null +++ b/ios/BitPayAppUITests/pages/MykeyPage.swift @@ -0,0 +1,45 @@ +import XCTest + +class MyKeyPage { + + let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + var myKeyTab: XCUIElement { + app.otherElements.matching( + NSPredicate(format: "label == 'My Key'") + ).firstMatch + } + + var myWalletsText: XCUIElement { + app.staticTexts.matching( + NSPredicate(format: "label == 'My Wallets'") + ).firstMatch + } + + var bitcoinWallet: XCUIElement { + app.descendants(matching: .any).matching( + NSPredicate(format: "label CONTAINS[c] %@", "Bitcoin") + ).firstMatch + } + + // MARK: - Validations + + func isMyKeyDisplayed(timeout: TimeInterval = 900) -> Bool { + return myKeyTab.waitForExistence(timeout: timeout) + } + + func isMyWalletsDisplayed(timeout: TimeInterval = 5) -> Bool { + return myWalletsText.waitForExistence(timeout: timeout) + } + + func isBitcoinBTCWalletDisplayed(timeout: TimeInterval = 300) -> Bool { + return bitcoinWallet.waitForExistence(timeout: timeout) + } + +} diff --git a/ios/BitPayAppUITests/pages/OnboardingPage.swift b/ios/BitPayAppUITests/pages/OnboardingPage.swift new file mode 100644 index 0000000000..0996ac8ebf --- /dev/null +++ b/ios/BitPayAppUITests/pages/OnboardingPage.swift @@ -0,0 +1,388 @@ +// +// OnboardingPage.swift +// BitPayApp +// +// Created by vinoth vasu on 10/03/26. +// + +import XCTest + +class OnboardingPage { + + let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + var continueWithoutAnAccountButtonById: XCUIElement { + app.descendants(matching: .any).element( + matching: NSPredicate(format: "identifier == 'continue-without-an-account-button'") + ).firstMatch + } + + var continueWithoutAnAccountButtonByLabel: XCUIElement { + app.descendants(matching: .any).element( + matching: NSPredicate(format: "label == 'Continue without an account'") + ).firstMatch + } + + var continueWithoutAnAccountButtonByA11yIdAsLabel: XCUIElement { + app.descendants(matching: .any).element( + matching: NSPredicate(format: "label == 'continue-without-an-account-button'") + ).firstMatch + } + + var skipButton: XCUIElement { + app.descendants(matching: .any).element( + matching: NSPredicate(format: "identifier == 'skip-button'") + ).firstMatch + } + + var skipBackupButton: XCUIElement { + app.descendants(matching: .any).element( + matching: NSPredicate(format: "identifier == 'skip-button'") + ).firstMatch + } + + var createKeyButton: XCUIElement { + app.descendants(matching: .any).element( + matching: NSPredicate(format: "identifier == 'create-a-key-button'") + ).firstMatch + } + + var alreadyHaveWalletKeyButton: XCUIElement { + app.descendants(matching: .any).element( + matching: NSPredicate(format: "identifier == 'i-already-have-a-key-button'") + ).firstMatch + } + + var backupKeyLabel: XCUIElement { + app.staticTexts["Would you like to backup your key?"] + } + + var laterButton: XCUIElement { + app.descendants(matching: .any).element( + matching: NSPredicate(format: "identifier == 'bottom-notification-secondary-action-button'") + ).firstMatch + } + + var gotItButton: XCUIElement { + app.descendants(matching: .any).element( + matching: NSPredicate(format: "identifier == 'bottom-notification-primary-action-button'") + ).firstMatch + } + + var checkbox1: XCUIElement { + app.descendants(matching: .any).matching(identifier: "first-term-checkbox").firstMatch + } + + var checkbox2: XCUIElement { + app.descendants(matching: .any).matching(identifier: "second-term-checkbox").firstMatch + } + + var checkbox3: XCUIElement { + app.descendants(matching: .any).matching(identifier: "third-term-checkbox").firstMatch + } + + var agreeAndContinueButton: XCUIElement { + app.descendants(matching: .any).element( + matching: NSPredicate(format: "identifier == 'agree-and-continue-button'") + ).firstMatch + } + + var yourPortfolioBalanceText: XCUIElement { + app.descendants(matching: .any).element( + matching: NSPredicate(format: "identifier == 'portfolio-balance-info-button'") + ).firstMatch + } + + var onboardingScrollView: XCUIElement { + app.scrollViews.firstMatch + } + + // MARK: - Actions + + func handleTrackingPermissionIfDisplayed(timeout: TimeInterval = 30) { + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + let askAppNotToTrackButton = springboard.buttons["Ask App Not to Track"] + + if askAppNotToTrackButton.waitForExistence(timeout: timeout) { + askAppNotToTrackButton.tap() + } + } + + + /// No-op in Release builds or when no overlay is present. Mirrors the Android + /// BaseTest `dismissLogboxIfPresent` / `dismissDebuggerNotificationIfPresent`. + func dismissReactNativeDevOverlays(attempts: Int = 5) { + let dismissLabel = NSPredicate( + format: "label CONTAINS[c] 'debugger' OR label CONTAINS[c] 'view warnings' OR label CONTAINS[c] 'view errors'" + ) + + for _ in 0.. Bool { + dismissReactNativeDevOverlays() + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if continueWithoutAnAccountButtonById.exists || + continueWithoutAnAccountButtonByLabel.exists || + continueWithoutAnAccountButtonByA11yIdAsLabel.exists { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + } + return false + } + + func tapContinuewithoutAnAccount() { + dismissReactNativeDevOverlays() + + if isPostContinueScreenVisible(timeout: 2) { + return + } + + XCTAssertTrue( + tapFirstHittableElement( + candidates: [ + continueWithoutAnAccountButtonById, + continueWithoutAnAccountButtonByLabel, + continueWithoutAnAccountButtonByA11yIdAsLabel, + ], + timeout: 25 + ), + "Continue without an account button not tappable" + ) + } + + func skipOnboarding() { + if skipButton.waitForExistence(timeout: 30) { + skipButton.tap() + return + } + + // Fallback: Pin screen body "Maybe Later" button (skip-security-button) + let skipSecurityButton = app.descendants(matching: .any).element( + matching: NSPredicate(format: "identifier == 'skip-security-button'") + ).firstMatch + if skipSecurityButton.waitForExistence(timeout: 10) { + skipSecurityButton.tap() + return + } + + let skipLabelFallback = app.descendants(matching: .any).element( + matching: NSPredicate(format: "label == 'Skip'") + ).firstMatch + XCTAssertTrue(skipLabelFallback.waitForExistence(timeout: 5), "Skip button not found") + skipLabelFallback.tap() + } + + func skipBackup() { + if skipBackupButton.waitForExistence(timeout: 20) { + skipBackupButton.tap() + return + } + + let skipBackupLabelFallback = app.descendants(matching: .any).element( + matching: NSPredicate(format: "label == 'Skip backup'") + ).firstMatch + XCTAssertTrue(skipBackupLabelFallback.waitForExistence(timeout: 5), "Skip backup button not found") + skipBackupLabelFallback.tap() + } + + func createWallet() { + createKeyButton.tap() + } + + func alreadyHaveWalletKey() { + alreadyHaveWalletKeyButton.tap() + } + + func isBackupKeyLabelDisplayed(timeout: TimeInterval = 120) -> Bool { + return backupKeyLabel.waitForExistence(timeout: timeout) + } + + func tapLater() { + if laterButton.waitForExistence(timeout: 20) { + laterButton.tap() + return + } + + let laterLabelFallback = app.descendants(matching: .any).element( + matching: NSPredicate(format: "label == 'LATER'") + ).firstMatch + XCTAssertTrue(laterLabelFallback.waitForExistence(timeout: 5), "Later button not found") + laterLabelFallback.tap() + } + + func acceptTerms() { + XCTAssertTrue(waitForCheckbox("first-term-checkbox", timeout: 30), "First term checkbox not found") + + tapCheckbox("first-term-checkbox") + tapCheckbox("second-term-checkbox") + tapCheckbox("third-term-checkbox") + + XCTAssertTrue( + waitForAgreeAndContinueEnabled(timeout: 10), + "Agree and Continue did not enable after accepting all terms" + ) + } + + func tapAgreeAndContinueButton() { + XCTAssertTrue(agreeAndContinueButton.waitForExistence(timeout: 20), "Agree and Continue button not found") + for _ in 1...8 { + if agreeAndContinueButton.isEnabled { + break + } + RunLoop.current.run(until: Date().addingTimeInterval(0.3)) + } + + if agreeAndContinueButton.isHittable { + agreeAndContinueButton.tap() + } else { + agreeAndContinueButton.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + } + } + + func isYourPortfolioBalanceTextDisplayed(timeout: TimeInterval = 120) -> Bool { + if yourPortfolioBalanceText.waitForExistence(timeout: timeout) { + return true + } + + let labelFallback = app.descendants(matching: .any).element( + matching: NSPredicate(format: "label == 'Portfolio balance info'") + ).firstMatch + return labelFallback.waitForExistence(timeout: 5) + } + + func swipeOnboarding() { + onboardingScrollView.swipeRight() + } + + func tapGotIt() { + if gotItButton.waitForExistence(timeout: 20) { + gotItButton.tap() + return + } + + let gotItLabelFallback = app.staticTexts["GOT IT"] + XCTAssertTrue(gotItLabelFallback.waitForExistence(timeout: 5), "Got it button not found") + gotItLabelFallback.tap() + } + + // MARK: - Private Helpers + + private func waitForCheckbox(_ identifier: String, timeout: TimeInterval) -> Bool { + let endTime = Date().addingTimeInterval(timeout) + while Date() < endTime { + let query = app.descendants(matching: .any).matching(identifier: identifier) + if query.firstMatch.exists { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + } + return false + } + + private func tapCheckbox(_ identifier: String) { + let query = app.descendants(matching: .any).matching(identifier: identifier) + XCTAssertTrue(query.firstMatch.waitForExistence(timeout: 15), "Checkbox not found: \(identifier)") + + + let box = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier == %@ AND label == %@", identifier, "Checkbox")) + .firstMatch + let target = box.waitForExistence(timeout: 5) ? box : query.firstMatch + + if target.isHittable { + target.tap() + } else { + // The box only covers the toggle, so its own centre is a safe fallback. + target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + } + } + + private func waitForAgreeAndContinueEnabled(timeout: TimeInterval) -> Bool { + guard agreeAndContinueButton.waitForExistence(timeout: timeout) else { + return false + } + + let endTime = Date().addingTimeInterval(timeout) + while Date() < endTime { + if agreeAndContinueButton.isEnabled { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + } + return false + } + + private func tapFirstHittableElement( + candidates: [XCUIElement], + timeout: TimeInterval + ) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + for element in candidates where element.exists { + if element.isHittable { + element.tap() + return true + } + } + RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + } + + for element in candidates where element.exists { + element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + return true + } + return false + } + + private func isPostContinueScreenVisible(timeout: TimeInterval) -> Bool { + let skipById = app.descendants(matching: .any).element( + matching: NSPredicate(format: "identifier == 'skip-button'") + ).firstMatch + let createKey = app.descendants(matching: .any).element( + matching: NSPredicate(format: "identifier == 'create-a-key-button'") + ).firstMatch + let alreadyHaveKey = app.descendants(matching: .any).element( + matching: NSPredicate(format: "identifier == 'i-already-have-a-key-button'") + ).firstMatch + let portfolio = app.descendants(matching: .any).element( + matching: NSPredicate(format: "identifier == 'portfolio-balance-info-button'") + ).firstMatch + + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if skipById.exists || createKey.exists || alreadyHaveKey.exists || portfolio.exists { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } + return false + } +} diff --git a/ios/BitPayAppUITests/pages/PortfolioBalancePage.swift b/ios/BitPayAppUITests/pages/PortfolioBalancePage.swift new file mode 100644 index 0000000000..1759cd99a2 --- /dev/null +++ b/ios/BitPayAppUITests/pages/PortfolioBalancePage.swift @@ -0,0 +1,183 @@ + +// +// OnboardingPage.swift +// BitPayApp +// +// Created by vinoth vasu on 16/03/26. +// + +import XCTest + +class PortfolioBalancePage { + + let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + var yourPortfolioBalanceText: XCUIElement { + app.otherElements["portfolio-balance-info-button"].firstMatch + } + + var addCryptoButton: XCUIElement { + app.descendants(matching: .any).element( + matching: NSPredicate(format: "label == 'Add crypto wallet'") + ).firstMatch + } + + var buyButton: XCUIElement { + app.descendants(matching: .any) + .matching(identifier: "buy-button") + .firstMatch + } + + var sellButton: XCUIElement { + app.descendants(matching: .any) + .matching(identifier: "sell-button") + .firstMatch + } + + var sendButton: XCUIElement { + app.descendants(matching: .any) + .matching(identifier: "send-button") + .firstMatch + } + + var swapButton: XCUIElement { + app.descendants(matching: .any) + .matching(identifier: "swap-button") + .firstMatch + } + + // MARK: - Actions + + func isYourPortfolioBalanceTextDisplayed(timeout: TimeInterval = 25) -> Bool { + return yourPortfolioBalanceText.waitForExistence(timeout: timeout) + } + +// func tapBuyButton() { +// buyButton.tap() +// } + + func tapBuyButton(timeout: TimeInterval = 900) { + XCTAssertTrue( + buyButton.waitForExistence(timeout: timeout), + "Buy button did not appear within \(timeout) seconds" + ) + + // Buy can briefly route to a key/backup-required sheet while WALLET.keys + // rehydrates after relaunch; retry (dismissing the sheet) until the keypad shows. + let keypadKey = app.staticTexts["5"].firstMatch + let maybeLater = app.descendants(matching: .any).element( + matching: NSPredicate(format: "identifier == 'bottom-notification-secondary-action-button'") + ).firstMatch + + for _ in 1...4 { + if keypadKey.exists { + return + } + + if buyButton.exists { + buyButton.tap() + } + if keypadKey.waitForExistence(timeout: 15) { + return + } + + if maybeLater.exists && maybeLater.isHittable { + maybeLater.tap() + } + RunLoop.current.run(until: Date().addingTimeInterval(1.5)) + } + + XCTAssertTrue(keypadKey.exists, "Buy screen did not open after tapping Buy") + } + +// func tapSellButton() { +// sellButton.tap() +// } + + func tapSellButton(timeout: TimeInterval = 900) { + XCTAssertTrue( + sellButton.waitForExistence(timeout: timeout), + "Sell button did not appear within \(timeout) seconds" + ) + sellButton.tap() + } + +// func tapAddCryptoButton() { +// addCryptoButton.tap() +// } + + func tapAddCryptoButton() { + +// let selectAnOptionPage = AddCryptoOptionPage(app: app) +// +// for attempt in 1...5 { +// + addCryptoButton.tap() + +// if selectAnOptionPage.isSelectAnOptionTitleDisplayed() { +// return +// } +// +// if attempt < 5 { +// sleep(60) +// } +// } +// +// XCTFail("Failed to open 'Select an Option' screen after 5 attempts") + } + + func tapSendButton(timeout: TimeInterval = 900) { + XCTAssertTrue( + sendButton.waitForExistence(timeout: timeout), + "Send button did not appear within \(timeout) seconds" + ) + sendButton.tap() + } + +// func tapSendButton(timeout: TimeInterval = 900) { +// +// let isDisplayed = sendButton.waitForExistence(timeout: timeout) +// +// if !isDisplayed { +// +// // Attach full app hierarchy/page source into xcresult +// let pageSourceAttachment = XCTAttachment( +// string: app.debugDescription +// ) +// +// pageSourceAttachment.name = "App Debug Description XML" +// pageSourceAttachment.lifetime = .keepAlways +// +// XCTContext.runActivity( +// named: "Attach App Debug Description" +// ) { activity in +// activity.add(pageSourceAttachment) +// } +// +// XCTFail( +// "Send button did not appear within \(timeout) seconds" +// ) +// } +// +// sendButton.tap() +// } + +// func tapSwapButton() { +// swapButton.tap() +// } + + func tapSwapButton(timeout: TimeInterval = 900) { + XCTAssertTrue( + swapButton.waitForExistence(timeout: timeout), + "Swap button did not appear within \(timeout) seconds" + ) + swapButton.tap() + } + +} diff --git a/ios/BitPayAppUITests/pages/SelectCurrencyPage.swift b/ios/BitPayAppUITests/pages/SelectCurrencyPage.swift new file mode 100644 index 0000000000..9ddbdf8d80 --- /dev/null +++ b/ios/BitPayAppUITests/pages/SelectCurrencyPage.swift @@ -0,0 +1,68 @@ + +// +// OnboardingPage.swift +// BitPayApp +// +// Created by vinoth vasu on 16/03/26. +// + +import XCTest + +class SelectCurrencyPage { + + let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + var selectCurrencyTitle: XCUIElement { + app.descendants(matching: .any).element( + matching: NSPredicate(format: "label == 'Select a Currency'") + ).firstMatch + } + + var bitcoinCurrency: XCUIElement { + app.descendants(matching: .any).element( + matching: NSPredicate(format: "label CONTAINS 'Bitcoin, BTC'") + ).firstMatch + } + + var sendToTitle: XCUIElement { + app.descendants(matching: .any).element( + matching: NSPredicate(format: "label == 'Send To'") + ).firstMatch + } + + var recipientAddress: XCUIElement { + app.descendants(matching: .any).element( + matching: NSPredicate(format: "label == 'Recipient address'") + ).firstMatch + } + + + // MARK: - Actions + + func isSelectCurrencyTitleDisplayed(timeout: TimeInterval = 180) -> Bool { + return selectCurrencyTitle.waitForExistence(timeout: timeout) + } + + func tapBitcoinCurrency() { + bitcoinCurrency.tap() + } + + func isSendToTitleDisplayed(timeout: TimeInterval = 180) -> Bool { + return sendToTitle.waitForExistence(timeout: timeout) + } + + func enterRecipientAddress(address: String) { + recipientAddress.tap() + for character in address { + recipientAddress.typeText(String(character)) + // usleep(100000) + } + } + +} diff --git a/ios/BitPayAppUITests/pages/SelectKeyToDepositPage.swift b/ios/BitPayAppUITests/pages/SelectKeyToDepositPage.swift new file mode 100644 index 0000000000..89ed5846f4 --- /dev/null +++ b/ios/BitPayAppUITests/pages/SelectKeyToDepositPage.swift @@ -0,0 +1,57 @@ +import XCTest + +class SelectKeyToDepositPage { + + let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + var selectKeyToDeposit: XCUIElement { + app.staticTexts.matching( + NSPredicate(format: "label CONTAINS 'Select Key to Deposit to'") + ).firstMatch + } + + var myKeyWallet: XCUIElement { + app.otherElements.matching( + NSPredicate(format: "label == 'My Key wallet'") + ).firstMatch + } + + var secondMyKeyWallet: XCUIElement { + app.descendants(matching: .any) + .matching(NSPredicate(format: "label CONTAINS 'My Key'")) + .element(boundBy: 1) + } + + var evmAccount: XCUIElement { + app.descendants(matching: .any).matching( + NSPredicate(format: "label == 'EVM Account'") + ).firstMatch + } + + + // MARK: - Validations + + func isSelectKeyToDepositToDisplayed(timeout: TimeInterval = 5) -> Bool { + return selectKeyToDeposit.waitForExistence(timeout: timeout) + } + + func tapMyKeyWallet() { + myKeyWallet.tap() + } + + func tapSecondMyKeyWallet() { + secondMyKeyWallet.tap() + } + + func tapEVMAccount() { + evmAccount.tap() + } + + +} diff --git a/ios/BitPayAppUITests/pages/SellPage.swift b/ios/BitPayAppUITests/pages/SellPage.swift new file mode 100644 index 0000000000..1fb3b1e0d6 --- /dev/null +++ b/ios/BitPayAppUITests/pages/SellPage.swift @@ -0,0 +1,35 @@ +import XCTest + +class SellPage { + + let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + var sellTitle: XCUIElement { + app.staticTexts["Sell"].firstMatch + } + + var chooseCrypto: XCUIElement { + app.otherElements.matching( + NSPredicate(format: "label == 'Choose Crypto'") + ).firstMatch + } + + + // MARK: - Validations + + func isSellPageTitleDisplayed(timeout: TimeInterval = 180) -> Bool { + return sellTitle.waitForExistence(timeout: timeout) + } + + func tapChooseCrypto() { + chooseCrypto.tap() + } + + +} diff --git a/ios/BitPayAppUITests/pages/SwapPage.swift b/ios/BitPayAppUITests/pages/SwapPage.swift new file mode 100644 index 0000000000..51bab27c4c --- /dev/null +++ b/ios/BitPayAppUITests/pages/SwapPage.swift @@ -0,0 +1,267 @@ +import XCTest + +class SwapPage { + + let app: XCUIApplication + + init(app: XCUIApplication) { + self.app = app + } + + // MARK: - Elements + + var swapTitle: XCUIElement { + app.staticTexts["Swap"].firstMatch + } + + var selectWalletFrom: XCUIElement { + app.otherElements.matching( + NSPredicate(format: "label == 'Select wallet to swap from'") + ).firstMatch + } + + var cryptoToSwapPage: XCUIElement { + app.otherElements.matching( + NSPredicate(format: "label == 'Crypto to Swap'") + ).firstMatch + } + + var bitcoin: XCUIElement { + app.otherElements.matching( + NSPredicate(format: "label CONTAINS 'Bitcoin, BTC'") + ).firstMatch + } + + var selectWalletTo: XCUIElement { + app.otherElements.matching( + NSPredicate(format: "identifier == 'swap-crypto-to-wallet-selector'") + ).firstMatch + } + + var swapTo: XCUIElement { + app.descendants(matching: .any).matching( + NSPredicate(format: "label == 'Swap To'") + ).firstMatch + } + + var selectCrypto: XCUIElement { + app.descendants(matching: .any).matching( + NSPredicate(format: "label == 'Select Crypto'") + ).firstMatch + } + + var searchField: XCUIElement { + app.searchFields["Search"].firstMatch + } + + var allNetworksFilter: XCUIElement { + app.descendants(matching: .any).matching( + NSPredicate(format: "label == 'All Networks'") + ).firstMatch + } + + var ethereum: XCUIElement { + app.otherElements.matching( + NSPredicate(format: "label CONTAINS[c] 'Ethereum'") + ).firstMatch + } + + var selectKeyToDeposit: XCUIElement { + app.otherElements.matching( + NSPredicate(format: "label == 'Select Key to Deposit to'") + ).firstMatch + } + + var myKeyWallet: XCUIElement { + app.otherElements.matching( + NSPredicate(format: "label == 'My Key wallet'") + ).firstMatch + } + + var evmAccount: XCUIElement { + app.otherElements.matching( + NSPredicate(format: "label == 'EVM Account'") + ).firstMatch + } + + var swapCrypoButton: XCUIElement { + app.descendants(matching: .any).matching( + NSPredicate( + format: "label == 'Swap crypto toggle fiat display button'" + ) + ).firstMatch + } + + var enterAmount: XCUIElement { + app.descendants(matching: .any).matching( + NSPredicate( + format: "label == 'Enter amount'" + ) + ).firstMatch + } + + var minAmount: XCUIElement { + app.descendants(matching: .any).matching( + NSPredicate( + format: "label == 'MIN'" + ) + ).firstMatch + } + + var changellyTermsCheckbox: XCUIElement { + app.descendants(matching: .any).matching( + NSPredicate( + format: "identifier == 'swap-crypto-changelly-terms-checkbox'" + ) + ).firstMatch + } + + var slideToSwapButton: XCUIElement { + app.descendants(matching: .any).matching( + NSPredicate( + format: "label == 'Swap crypto slide to swap button' OR label CONTAINS[c] 'slide to swap' OR identifier == 'swap-crypto-slide-to-swap-button' OR identifier CONTAINS[c] 'slide-to-swap'" + ) + ).firstMatch + } + + + // MARK: - Validations + + func isSwapPageTitleDisplayed(timeout: TimeInterval = 5) -> Bool { + return swapTitle.waitForExistence(timeout: timeout) + } + + func tapSelectWalletFrom() { + selectWalletFrom.tap() + } + + func isCryptoToSwapPageDisplayed(timeout: TimeInterval = 5) -> Bool { + return cryptoToSwapPage.waitForExistence(timeout: timeout) + } + + func isBitcoinOptionDisplayed(timeout: TimeInterval = 10) -> Bool { + return bitcoin.waitForExistence(timeout: timeout) + } + + func tapBitcoin() { + bitcoin.tap() + } + + func tapSelectWalletTo() { + if selectWalletTo.waitForExistence(timeout: 30) { + for _ in 1...8 { + if selectWalletTo.isHittable { + selectWalletTo.tap() + return + } + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + } + + selectWalletTo.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + return + } + + let selectWalletToByLabel = app.otherElements.matching( + NSPredicate(format: "label == 'Select wallet to swap to'") + ).firstMatch + XCTAssertTrue(selectWalletToByLabel.waitForExistence(timeout: 5), "Swap To selector not found") + selectWalletToByLabel.tap() + } + + func isSwapToPageDisplayed(timeout: TimeInterval = 5) -> Bool { + let endTime = Date().addingTimeInterval(timeout) + + while Date() < endTime { + if swapTo.exists || selectCrypto.exists || searchField.exists || allNetworksFilter.exists { + return true + } + RunLoop.current.run(until: Date().addingTimeInterval(0.3)) + } + + return false + } + + func tapEthereum() { + if ethereum.waitForExistence(timeout: 20) { + if ethereum.isHittable { + ethereum.tap() + } else { + ethereum.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + } + return + } + + if searchField.waitForExistence(timeout: 5), searchField.isHittable { + searchField.tap() + searchField.typeText("Ethereum") + if ethereum.waitForExistence(timeout: 12) { + if ethereum.isHittable { + ethereum.tap() + } else { + ethereum.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + } + return + } + } + + XCTAssertTrue(false, "Ethereum option not found on Swap To list") + } + + func isSelectKeyToDepositToDisplayed(timeout: TimeInterval = 5) -> Bool { + return selectKeyToDeposit.waitForExistence(timeout: timeout) + } + + func tapMyKeyWallet() { + myKeyWallet.tap() + } + + func tapSwapCrypoButton() { + swapCrypoButton.tap() + } + + func tapEnterAmount() { + enterAmount.tap() + } + + func isChangellyTermsCheckboxDisplayed(timeout: TimeInterval = 5) -> Bool { + return changellyTermsCheckbox.waitForExistence(timeout: timeout) + } + + func tapChangellyTermsCheckbox() { + XCTAssertTrue( + changellyTermsCheckbox.waitForExistence(timeout: 20), + "Changelly Terms checkbox not found" + ) + + for _ in 1...4 { + if changellyTermsCheckbox.isHittable { + changellyTermsCheckbox.tap() + return + } + app.swipeUp() + RunLoop.current.run(until: Date().addingTimeInterval(0.4)) + } + + changellyTermsCheckbox.coordinate(withNormalizedOffset: CGVector(dx: 0.06, dy: 0.5)).tap() + } + + func tapMinAmount() { + minAmount.tap() + } + + func tapCenterOfScreen() { + let center = app.coordinate( + withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5) + ) + center.tap() + } + + func isSlideToSwapButtonDisplayed(timeout: TimeInterval = 30) -> Bool { + if slideToSwapButton.waitForExistence(timeout: timeout) { + return true + } + app.swipeUp() + return slideToSwapButton.waitForExistence(timeout: 10) + } + +} diff --git a/ios/BitPayAppUITests/tests/Test591_OnboardingCreateWallet.swift b/ios/BitPayAppUITests/tests/Test591_OnboardingCreateWallet.swift new file mode 100644 index 0000000000..ccb1f61567 --- /dev/null +++ b/ios/BitPayAppUITests/tests/Test591_OnboardingCreateWallet.swift @@ -0,0 +1,96 @@ +import XCTest + +final class Test591_OnboardingCreateWallet: XCTestCase { + + var app: XCUIApplication! + + override func setUp() { + continueAfterFailure = false + + app = XCUIApplication() + app.launch() + + let onboardingPage = OnboardingPage(app: app) + onboardingPage.handleTrackingPermissionIfDisplayed() + } + + @MainActor + func test591_OnboardingCreateWallet() throws { + + AllureXCTestSupport.setDisplayName( + "Onboarding Create Wallet (Continue Without Account)" + ) + AllureXCTestSupport.addLabel( + "testlioManualTestID", + value: "003e6686-1128-4e66-b020-aa03f3b6ec67" + ) + AllureXCTestSupport.addDescription( + "Onboarding Create Wallet without creating a new" + ) + + let onboardingPage = OnboardingPage(app: app) + + AllureXCTestSupport.step( + "(4) On the onboarding screen “Seamlessly buy & swap”, tap Continue without an account." + ) { + onboardingPage.tapContinuewithoutAnAccount() + } + + AllureXCTestSupport.step( + "(5) On the “Turn on notifications” screen, tap Skip." + ) { + onboardingPage.skipOnboarding() + } + + AllureXCTestSupport.step( + "(6) On the “Protect your wallet” screen, tap Skip" + ) { + onboardingPage.skipOnboarding() //To skip protect wallet + } + + AllureXCTestSupport.step( + "(7) On the “Create a key or import an existing key” screen, tap Create a Key." + ) { + onboardingPage.createWallet() + } + + AllureXCTestSupport.step( + "(8) Wait until wallet creation finishes and the “Would you like to backup your key?” screen is displayed." + ) { + XCTAssertTrue( + onboardingPage.isBackupKeyLabelDisplayed(), + "Backup key page did not appear after tapping 'Create a Key'" + ) + } + + AllureXCTestSupport.step("(9) Tap Skip on the backup prompt screen.") { + onboardingPage.skipBackup() //To skip backup key + } + + AllureXCTestSupport.step( + "(10) On the “Are you sure?” confirmation modal, tap Later. " + ) { + onboardingPage.tapLater() + } + + AllureXCTestSupport.step( + "(11, 12 & 13) On the “Important” screen, select all the three agreements checkbox. " + ) { + onboardingPage.acceptTerms() + } + + AllureXCTestSupport.step("(14) Tap Agree and Continue. ") { + onboardingPage.tapAgreeAndContinueButton() + } + + AllureXCTestSupport.step( + "(15) Wait until the Home screen finishes loading. " + ) { + XCTAssertTrue( + onboardingPage.isYourPortfolioBalanceTextDisplayed(), + "Home Page - Your Portfolio Balance text is not displayed" + ) + } + } + +} diff --git a/ios/BitPayAppUITests/tests/Test592_ImportWalletRecoveryPhrase.swift b/ios/BitPayAppUITests/tests/Test592_ImportWalletRecoveryPhrase.swift new file mode 100644 index 0000000000..bd0d4718b4 --- /dev/null +++ b/ios/BitPayAppUITests/tests/Test592_ImportWalletRecoveryPhrase.swift @@ -0,0 +1,85 @@ +import XCTest + +final class Test592_ImportWalletRecoveryPhrase: XCTestCase { + + var app: XCUIApplication! + + override func setUp() { + continueAfterFailure = false + + app = XCUIApplication() + app.launch() + + let onboardingPage = OnboardingPage(app: app) + onboardingPage.handleTrackingPermissionIfDisplayed() + + AppFlows.completeOnboardingIfRequired(app: app) + } + + @MainActor + func test592_ImportWalletRecoveryPhrase() throws { + + AllureXCTestSupport.setDisplayName( + "Import Wallet via Recovery Phrase (BTC)" + ) + AllureXCTestSupport.addLabel( + "testlioManualTestID", + value: "1ca663da-1909-451f-9908-25ba9cdc0396" + ) + AllureXCTestSupport.addDescription( + "Import Wallet via Recovery Phrase (BTC)" + ) + + let portfolioBalancePage = PortfolioBalancePage(app: app) + let selectAnOptionPage = AddCryptoOptionPage(app: app) + let importRecoveryPhasePage = ImportRecoveryPhrasePage(app: app) + let myKeyPage = MyKeyPage(app: app) + let walletPhrase = TestConfig.walletPhrase + + AllureXCTestSupport.step( + "(1 to 3) On the Home screen, tap the Plus (+) control near the “Your Crypto” section to open wallet/key options." + ) { + portfolioBalancePage.tapAddCryptoButton() + } + + AllureXCTestSupport.step( + "(4) Wait until the “Select an Option” screen is displayed." + ) { + XCTAssertTrue( + selectAnOptionPage.isSelectAnOptionTitleDisplayed(), + "Select an option page is not displayed" + ) + } + + AllureXCTestSupport.step("(5) Tap Import Key") { + selectAnOptionPage.tapImportKey() + } + + AllureXCTestSupport.step( + "(6 to 7) On the “Import” screen, tap the Recovery Phrase input field & Enter a valid Recovery Phrase. " + ) { + importRecoveryPhasePage.enterRecoveryPhrase(walletPhrase) + } + + AllureXCTestSupport.step("(8) Tap Import Wallet") { + importRecoveryPhasePage.tapImportWallet() + } + + AllureXCTestSupport.step( + "(9) Wait until the import finishes and the My Key screen is displayed (use an extended wait to account for slower imports). " + ) { + XCTAssertTrue(myKeyPage.isMyKeyDisplayed(), "Is My Key not displayed") + XCTAssertTrue( + myKeyPage.isMyWalletsDisplayed(), + "My Wallets not displayed" + ) + + XCTAssertTrue( + myKeyPage.isBitcoinBTCWalletDisplayed(), + "Bitcoin BTC Wallet not displayed" + ) + } + + } + +} diff --git a/ios/BitPayAppUITests/tests/Test593_BTCConfirmPaymentScreen.swift b/ios/BitPayAppUITests/tests/Test593_BTCConfirmPaymentScreen.swift new file mode 100644 index 0000000000..5ff3bad540 --- /dev/null +++ b/ios/BitPayAppUITests/tests/Test593_BTCConfirmPaymentScreen.swift @@ -0,0 +1,85 @@ +import XCTest + +final class Test593_BTCConfirmPaymentScreen: XCTestCase { + + var app: XCUIApplication! + + override func setUp() { + continueAfterFailure = false + + app = XCUIApplication() + app.launch() + + let onboardingPage = OnboardingPage(app: app) + onboardingPage.handleTrackingPermissionIfDisplayed() + + AppFlows.completeOnboardingIfRequired(app: app) + + AppFlows.goToHomeScreen(app: app) + } + + @MainActor + func test593_BTCConfirmPaymentScreen() throws { + + AllureXCTestSupport.setDisplayName( + "Basic Send (BTC) - Confirm Payment Screen" + ) + AllureXCTestSupport.addLabel( + "testlioManualTestID", + value: "be4a044a-2c3a-4cad-aa5c-9398f415b483" + ) + AllureXCTestSupport.addDescription( + "Basic Send (BTC) - Confirm Payment Screen" + ) + + let portfolioBalancePage = PortfolioBalancePage(app: app) + let selectCurrencyPage = SelectCurrencyPage(app: app) + let enterAmountPage = EnterAmountPage(app: app) + let confirmPaymentPage = ConfirmPaymentPage(app: app) + + AllureXCTestSupport.step("(1 to 4) On the Home screen, tap Send in the top menu.") { + portfolioBalancePage.tapSendButton() + } + + AllureXCTestSupport.step("(5) Wait until the Select a Currency screen is displayed. ") { + XCTAssertTrue( + selectCurrencyPage.isSelectCurrencyTitleDisplayed(), + "Select Currency page not displayed" + ) + } + + AllureXCTestSupport.step("(6) Tap Bitcoin.") { + selectCurrencyPage.tapBitcoinCurrency() + } + + AllureXCTestSupport.step("(7) Wait until the Send To screen is displayed.") { + XCTAssertTrue( + selectCurrencyPage.isSendToTitleDisplayed(), + "Send To page not displayed" + ) + } + + AllureXCTestSupport.step("(8 & 9) Tap the Search contact or enter address field & Paste the receiver address.") { + selectCurrencyPage.enterRecipientAddress(address: "bc1q0effzahtsn685tyjppgukpvfhv37hrlm4g67ws") + } + + AllureXCTestSupport.step("(10 & 11) Wait until the Amount screen is displayed & Enter 0.00005 BTC.") { + enterAmountPage.enterAmount(amount: "0.00005") + } + + AllureXCTestSupport.step("(12) Tap Continue and Verify") { + enterAmountPage.tapContinue() + + XCTAssertTrue( + confirmPaymentPage.isConfirmPaymentTitleDisplayed(), + "Confirm payment page not displayed" + ) + + XCTAssertTrue( + confirmPaymentPage.isSummaryTextDisplayed(), + "Confirm payment - Summary text not displayed" + ) + + } + } +} diff --git a/ios/BitPayAppUITests/tests/Test594_BasicSwapBTC.swift b/ios/BitPayAppUITests/tests/Test594_BasicSwapBTC.swift new file mode 100644 index 0000000000..2dd04e4630 --- /dev/null +++ b/ios/BitPayAppUITests/tests/Test594_BasicSwapBTC.swift @@ -0,0 +1,101 @@ +import XCTest + +final class Test594_BasicSwapBTC: XCTestCase { + + var app: XCUIApplication! + + override func setUp() { + continueAfterFailure = false + + app = XCUIApplication() + app.launch() + + let onboardingPage = OnboardingPage(app: app) + onboardingPage.handleTrackingPermissionIfDisplayed() + + AppFlows.completeOnboardingIfRequired(app: app) + + AppFlows.goToHomeScreen(app: app) + } + + @MainActor + func test594_BasicSwapBTC() throws { + + AllureXCTestSupport.setDisplayName( + "Basic Swap BTC to ETH" + ) + AllureXCTestSupport.addLabel( + "testlioManualTestID", + value: "947f685f-e106-4708-922f-43419f9f8f50" + ) + AllureXCTestSupport.addDescription( + "Basic Swap BTC to ETH (View Offers)" + ) + + let portfolioBalancePage = PortfolioBalancePage(app: app) + let swapPage = SwapPage(app: app) + let selectKeyToDepositPage = SelectKeyToDepositPage(app: app) + let enterAmountPage = EnterAmountPage(app: app) + let selectCurrencyPage = SelectCurrencyPage(app: app) + + AllureXCTestSupport.step("(1 to 4) From the Home screen, tap Swap in the top menu. ") { + portfolioBalancePage.tapSwapButton() + } + + AllureXCTestSupport.step("(5) Wait until the Swap Crypto screen is displayed.") { + XCTAssertTrue(swapPage.isSwapPageTitleDisplayed(), "Swap page not displayed") + } + + AllureXCTestSupport.step("(6) In the Swap From section, tap Select Wallet") { + swapPage.tapSelectWalletFrom() + } + + AllureXCTestSupport.step("(7) On the Crypto to Swap screen, tap Bitcoin (BTC)") { + XCTAssertTrue(swapPage.isBitcoinOptionDisplayed(), "Bitcoin option not displayed") + swapPage.tapBitcoin() + } + + AllureXCTestSupport.step("(8) Back on the Swap Crypto screen, in the Swap To section, tap Select Crypto.") { + swapPage.tapSelectWalletTo() + } + + AllureXCTestSupport.step("(9) On the Swap To list screen, tap Ethereum (ETH).") { + XCTAssertTrue( + swapPage.isSwapToPageDisplayed(timeout: 15), + "Swap To list not displayed" + ) + swapPage.tapEthereum() + } + + AllureXCTestSupport.step("(10) On the Select Key to Deposit to screen, tap My Key (choose the entry that has funds available if multiple are shown).") { + XCTAssertTrue(selectKeyToDepositPage.isSelectKeyToDepositToDisplayed(), "Select Key to Deposit to dialog not displayed") + selectKeyToDepositPage.tapSecondMyKeyWallet() + } + + AllureXCTestSupport.step("(11) On the Swap Crypto screen, tap Enter Amount.") { + selectKeyToDepositPage.tapEVMAccount() + // swapPage.tapSwapCrypoButton() this tap is not needed + } + + AllureXCTestSupport.step("(12) On the Swap Amount bottom sheet, enter 0.0007 BTC.") { + swapPage.tapEnterAmount() + swapPage.tapMinAmount() + // enterAmountPage.enterAmount(amount: "0.0007") + } + + AllureXCTestSupport.step("(13) Tap Changelly checkbox.") { + // swapPage.tapCenterOfScreen() + XCTAssertTrue(swapPage.isChangellyTermsCheckboxDisplayed(), "Changelly Terms and Conditions checkbox not displayed") + swapPage.tapChangellyTermsCheckbox() + } + + AllureXCTestSupport.step( + "(14) Verify Slide to Swap button is displayed." + ) { + XCTAssertTrue( + swapPage.isSlideToSwapButtonDisplayed(), + "Slide to Swap button not displayed" + ) + } + } +} diff --git a/ios/BitPayAppUITests/tests/Test595_BasicSellBTC_.swift b/ios/BitPayAppUITests/tests/Test595_BasicSellBTC_.swift new file mode 100644 index 0000000000..0a1028a553 --- /dev/null +++ b/ios/BitPayAppUITests/tests/Test595_BasicSellBTC_.swift @@ -0,0 +1,66 @@ +import XCTest + +final class Test595_BasicSellBTC: XCTestCase { + + var app: XCUIApplication! + + override func setUp() { + continueAfterFailure = false + + app = XCUIApplication() + app.launch() + + let onboardingPage = OnboardingPage(app: app) + onboardingPage.handleTrackingPermissionIfDisplayed() + + AppFlows.completeOnboardingIfRequired(app: app) + + AppFlows.goToHomeScreen(app: app) + } + + @MainActor + func test595_BasicSellBTC() throws { + + AllureXCTestSupport.setDisplayName( + "Basic Sell BTC - Select Bitcoin" + ) + AllureXCTestSupport.addLabel( + "testlioManualTestID", + value: "dfc4301a-fad9-4d7f-a13a-91ca1f1d54a7" + ) + AllureXCTestSupport.addDescription( + "Basic Sell BTC (Select Bitcoin and reach the sell amount flow)" + ) + + let portfolioBalancePage = PortfolioBalancePage(app: app) + let sellPage = SellPage(app: app) + let enterAmountPage = EnterAmountPage(app: app) + let selectCurrencyPage = SelectCurrencyPage(app: app) + + AllureXCTestSupport.step("(1 to 4) From the Home screen, tap Sell.") { + portfolioBalancePage.tapSellButton() + } + + AllureXCTestSupport.step("(5) Wait until the Sell screen is displayed.") { + XCTAssertTrue(sellPage.isSellPageTitleDisplayed(), "Sell page not displayed") + } + + AllureXCTestSupport.step("(6) Enter $30 as the sell amount.") { + enterAmountPage.enterAmount(amount: "30") + } + + AllureXCTestSupport.step("(7) Tap Choose Crypto.") { + sellPage.tapChooseCrypto() + } + + AllureXCTestSupport.step("(8) On the Select Crypto screen, tap Bitcoin (BTC). ") { + selectCurrencyPage.tapBitcoinCurrency() + //tap swap currency + } + + AllureXCTestSupport.step("(9) Tap Continue with.") { + enterAmountPage.tapContinue() + } + + } +} diff --git a/ios/BitPayAppUITests/tests/Test596_BasicBuyBTC_.swift b/ios/BitPayAppUITests/tests/Test596_BasicBuyBTC_.swift new file mode 100644 index 0000000000..abe226d880 --- /dev/null +++ b/ios/BitPayAppUITests/tests/Test596_BasicBuyBTC_.swift @@ -0,0 +1,68 @@ +import XCTest + +final class Test596_BasicBuyBTC: XCTestCase { + + var app: XCUIApplication! + + override func setUp() { + continueAfterFailure = false + + app = XCUIApplication() + app.launch() + + let onboardingPage = OnboardingPage(app: app) + onboardingPage.handleTrackingPermissionIfDisplayed() + + AppFlows.completeOnboardingIfRequired(app: app) + + AppFlows.goToHomeScreen(app: app) + } + + @MainActor + func test596_BasicBuyBTC() throws { + + AllureXCTestSupport.setDisplayName( + "Basic Buy BTC - Select tap My Key with funds" + ) + AllureXCTestSupport.addLabel( + "testlioManualTestID", + value: "3c9c0271-76ba-4da3-88ad-8a5d6ea60211" + ) + AllureXCTestSupport.addDescription( + "Basic Buy BTC (Select tap My Key with funds)" + ) + + let portfolioBalancePage = PortfolioBalancePage(app: app) + let buyPage = BuyPage(app: app) + let selectKeyToDepositPage = SelectKeyToDepositPage(app: app) + let enterAmountPage = EnterAmountPage(app: app) + let selectCurrencyPage = SelectCurrencyPage(app: app) + + AllureXCTestSupport.step("(1 to 4) From the Home screen, tap Buy.") { + portfolioBalancePage.tapBuyButton() + } + + AllureXCTestSupport.step("(5) Wait until the Buy screen is displayed. ") { + XCTAssertTrue(buyPage.isBuyPageTitleDisplayed(), "Buy page not displayed") + } + + AllureXCTestSupport.step("(6) Enter $30 as the buy amount.") { + enterAmountPage.tapBackspace(count: 3) + enterAmountPage.enterAmount(amount: "30") + } + + AllureXCTestSupport.step("(7) Tap Choose Crypto.") { + buyPage.tapBitcoin() + } + + AllureXCTestSupport.step("(8) On the Select Crypto screen, tap Bitcoin (BTC). ") { + selectCurrencyPage.tapBitcoinCurrency() + } + + AllureXCTestSupport.step("(9) On the Select Key to Deposit to screen, tap My Key with funds available.") { + XCTAssertTrue(selectKeyToDepositPage.isSelectKeyToDepositToDisplayed(), "Select Key to Deposit to dialog not displayed") + selectKeyToDepositPage.tapMyKeyWallet() + } + + } +} diff --git a/package.json b/package.json index 9fd36f50d8..2a83e983f8 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,10 @@ "reset-allowed-url-prefixes": "node ./scripts/allowed-url-prefixes.js reset", "reset-braze": "node ./scripts/braze-config-reset.js", "reset-google-maps": "node ./scripts/google-maps-reset.js", - "translation:extract": "npx i18next-cli extract", + "translation:extract": "yarn run i18next 'src/**/*.{js,jsx,ts,tsx}'", + "translation:upload": "crowdin upload sources --identity crowdin-credentials.yml", + "translation:download": "crowdin download --identity crowdin-credentials.yml", + "set:WalletPhrase": "export NODE_ENV=development && node ./scripts/wallet-phrase.js", "translation:check": "node --test scripts/check-translations.test.js && node scripts/check-translations.js" }, "dependencies": { diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh new file mode 100644 index 0000000000..b5abc2040e --- /dev/null +++ b/scripts/run-tests.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +OVERALL_EXIT=0 +ALLURE_RESULTS_DIR="${ALLURE_RESULTS_DIR:-$PWD/.tmp/allure-results}" +mkdir -p "$ALLURE_RESULTS_DIR" + +for TEST_CLASS in \ + "Test591OnboardingCreateWallet" \ + "Test592ImportWalletRecoveryPhrase" \ + "Test593SendBTC" \ + "Test594SwapBTC" \ + "Test595SellBTC" \ + "Test596BuyBTC" +do + echo "=== Clearing state before $TEST_CLASS ===" + adb shell pm clear com.bitpay.wallet || true + sleep 2 + + echo "=== Running $TEST_CLASS ===" + cd android && ./gradlew connectedDebugAndroidTest \ + -PreactNativeArchitectures=x86_64 \ + -Pandroid.testInstrumentationRunnerArguments.class="com.bitpay.wallet.tests.$TEST_CLASS" \ + --no-daemon + EXIT=$? + cd .. + + [ $EXIT -ne 0 ] && OVERALL_EXIT=$EXIT + + # Pull allure results immediately after each test + # so a timeout or crash on a later test doesn't lose earlier results + echo "=== Pulling allure results after $TEST_CLASS ===" + adb pull /sdcard/googletest/test_outputfiles/allure-results/. "$ALLURE_RESULTS_DIR/" || true +done + +exit $OVERALL_EXIT \ No newline at end of file diff --git a/scripts/wallet-phrase.js b/scripts/wallet-phrase.js new file mode 100644 index 0000000000..84a6fb503b --- /dev/null +++ b/scripts/wallet-phrase.js @@ -0,0 +1,37 @@ +const fs = require('fs'); +const dotenv = require('dotenv'); + +(() => { + if (!['development', 'production'].includes(process.env.NODE_ENV)) { + console.log('Please set NODE_ENV ["development" | "production"]'); + return; + } + + const envPath = `${__dirname}/../.env.${process.env.NODE_ENV}`; + const result = dotenv.config({path: envPath}); + + if (result.error) { + throw result.error; + } + + if (process.env.WALLET_PHRASE) { + const files = [ + `${__dirname}/../android/app/src/androidTest/java/com/bitpay/wallet/TestConfig.kt`, + `${__dirname}/../ios/BitPayAppUITests/TestConfig.swift`, + ]; + + files.forEach(file => { + if (!fs.existsSync(file)) { + console.log(`Skipped (not found): ${file}`); + return; + } + const content = fs.readFileSync(file, 'utf-8'); + fs.writeFileSync( + file, + content.replace('WALLET_PHRASE_REPLACE_ME', process.env.WALLET_PHRASE), + ); + }); + } + + console.log(`${process.env.NODE_ENV.toUpperCase()} Wallet phrase successfully updated.`); +})(); \ No newline at end of file diff --git a/src/navigation/wallet/screens/send/SendTo.tsx b/src/navigation/wallet/screens/send/SendTo.tsx index f5ba97ec09..c3abdb1c8b 100644 --- a/src/navigation/wallet/screens/send/SendTo.tsx +++ b/src/navigation/wallet/screens/send/SendTo.tsx @@ -155,8 +155,8 @@ const isEmailAddress = (text: string) => { if (!text.includes('@')) { return false; } - - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text); + const reg = /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w\w+)+$/; + return reg.test(text); }; export const BuildKeyAccountRow = ( diff --git a/testlio-cli/project-config-sandbox.json b/testlio-cli/project-config-sandbox.json new file mode 100644 index 0000000000..1ea5da9269 --- /dev/null +++ b/testlio-cli/project-config-sandbox.json @@ -0,0 +1,10 @@ +{ + "baseURI": "https://api.testlio.com/", + "platformURI": "https://app.testlio.com/tmt/project/", + "projectId": "3307", + "automatedRunCollectionGuid": "2df42fa4-225b-430e-882d-e5fc3ec31de7", + "testRunCollectionGuid": "a77e695c-5061-4451-98fe-4f93b8a26d9b", + "resultCollectionGuid": "3688212d-e132-40a3-a4bd-aae8b664a2d8", + "workspaceName": "bitpay---sandbox" +} + diff --git a/testlio-cli/project-config.json b/testlio-cli/project-config.json new file mode 100644 index 0000000000..9336b93825 --- /dev/null +++ b/testlio-cli/project-config.json @@ -0,0 +1,10 @@ +{ + "baseURI": "https://api.testlio.com/", + "platformURI": "https://app.testlio.com/tmt/project/", + "projectId": "2975", + "automatedRunCollectionGuid": "8d2e31a1-017d-4e11-9674-e63b4bf223f4", + "testRunCollectionGuid": "8eace78b-2c39-4cdb-935d-7e51bd4d6065", + "resultCollectionGuid": "a04d061a-88ad-4749-853e-90e227d5eb2a", + "workspaceName": "bitpay" +} + diff --git a/testlio-cli/test-config.json b/testlio-cli/test-config.json new file mode 100644 index 0000000000..2986f99020 --- /dev/null +++ b/testlio-cli/test-config.json @@ -0,0 +1,4 @@ +{ + "automatedTestNamePrefix": "Automation Run: Smoke Test" +} +